001///////////////////////////////////////////////////////////////////////////////////////////////
002// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
003// Copyright (C) 2001-2026 the original author or authors.
004//
005// This library is free software; you can redistribute it and/or
006// modify it under the terms of the GNU Lesser General Public
007// License as published by the Free Software Foundation; either
008// version 2.1 of the License, or (at your option) any later version.
009//
010// This library is distributed in the hope that it will be useful,
011// but WITHOUT ANY WARRANTY; without even the implied warranty of
012// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
013// Lesser General Public License for more details.
014//
015// You should have received a copy of the GNU Lesser General Public
016// License along with this library; if not, write to the Free Software
017// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
018///////////////////////////////////////////////////////////////////////////////////////////////
019
020package com.puppycrawl.tools.checkstyle.filters;
021
022import java.io.IOException;
023import java.nio.charset.StandardCharsets;
024import java.nio.file.Files;
025import java.nio.file.Path;
026import java.util.ArrayList;
027import java.util.Collection;
028import java.util.Objects;
029import java.util.Optional;
030import java.util.regex.Matcher;
031import java.util.regex.Pattern;
032import java.util.regex.PatternSyntaxException;
033
034import com.puppycrawl.tools.checkstyle.AbstractAutomaticBean;
035import com.puppycrawl.tools.checkstyle.PropertyType;
036import com.puppycrawl.tools.checkstyle.XdocsPropertyType;
037import com.puppycrawl.tools.checkstyle.api.AuditEvent;
038import com.puppycrawl.tools.checkstyle.api.FileText;
039import com.puppycrawl.tools.checkstyle.api.Filter;
040import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
041
042/**
043 * <div>
044 * Filter {@code SuppressWithPlainTextCommentFilter} uses plain text to suppress
045 * audit events. The filter knows nothing about AST, it treats only plain text
046 * comments and extracts the information required for suppression from the plain
047 * text comments. Currently, the filter supports only single-line comments.
048 * </div>
049 *
050 * <p>
051 * Please, be aware of the fact that, it is not recommended to use the filter
052 * for Java code anymore.
053 * </p>
054 *
055 * <p>
056 * Rationale: Sometimes there are legitimate reasons for violating a check.
057 * When this is a matter of the code in question and not personal preference,
058 * the best place to override the policy is in the code itself. Semi-structured
059 * comments can be associated with the check. This is sometimes superior to
060 * a separate suppressions file, which must be kept up-to-date as the source
061 * file is edited.
062 * </p>
063 *
064 * <p>
065 * Note that the suppression comment should be put before the violation.
066 * You can use more than one suppression comment each on separate line.
067 * </p>
068 *
069 * <p>
070 * Notes:
071 * Properties {@code offCommentFormat} and {@code onCommentFormat} must have equal
072 * <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/regex/Matcher.html#groupCount()">
073 * paren counts</a>.
074 * </p>
075 *
076 * <p>
077 * SuppressWithPlainTextCommentFilter can suppress Checks that have Treewalker or
078 * Checker as parent module.
079 * </p>
080 *
081 * @since 8.6
082 */
083public class SuppressWithPlainTextCommentFilter extends AbstractAutomaticBean implements Filter {
084
085    /** Comment format which turns checkstyle reporting off. */
086    private static final String DEFAULT_OFF_FORMAT = "// CHECKSTYLE:OFF";
087
088    /** Comment format which turns checkstyle reporting on. */
089    private static final String DEFAULT_ON_FORMAT = "// CHECKSTYLE:ON";
090
091    /** Default check format to suppress. By default, the filter suppress all checks. */
092    private static final String DEFAULT_CHECK_FORMAT = ".*";
093
094    /** List of suppressions from the file. By default, Its null. */
095    private final Collection<Suppression> currentFileSuppressionCache = new ArrayList<>();
096
097    /** File name that was suppressed. By default, Its empty. */
098    private String currentFileName = "";
099
100    /** Specify comment pattern to trigger filter to begin suppression. */
101    private Pattern offCommentFormat = CommonUtil.createPattern(DEFAULT_OFF_FORMAT);
102
103    /** Specify comment pattern to trigger filter to end suppression. */
104    private Pattern onCommentFormat = CommonUtil.createPattern(DEFAULT_ON_FORMAT);
105
106    /** Specify check pattern to suppress. */
107    @XdocsPropertyType(PropertyType.PATTERN)
108    private String checkFormat = DEFAULT_CHECK_FORMAT;
109
110    /** Specify message pattern to suppress. */
111    @XdocsPropertyType(PropertyType.PATTERN)
112    private String messageFormat;
113
114    /** Specify check ID pattern to suppress. */
115    @XdocsPropertyType(PropertyType.PATTERN)
116    private String idFormat;
117
118    /**
119     * Setter to specify comment pattern to trigger filter to begin suppression.
120     *
121     * @param pattern off comment format pattern.
122     * @since 8.6
123     */
124    public final void setOffCommentFormat(Pattern pattern) {
125        offCommentFormat = pattern;
126    }
127
128    /**
129     * Setter to specify comment pattern to trigger filter to end suppression.
130     *
131     * @param pattern  on comment format pattern.
132     * @since 8.6
133     */
134    public final void setOnCommentFormat(Pattern pattern) {
135        onCommentFormat = pattern;
136    }
137
138    /**
139     * Setter to specify check pattern to suppress.
140     * The pattern is matched against the fully qualified class name of the Check.
141     *
142     * @param format pattern for check format.
143     * @since 8.6
144     */
145    public final void setCheckFormat(String format) {
146        checkFormat = format;
147    }
148
149    /**
150     * Setter to specify message pattern to suppress.
151     *
152     * @param format pattern for message format.
153     * @since 8.6
154     */
155    public final void setMessageFormat(String format) {
156        messageFormat = format;
157    }
158
159    /**
160     * Setter to specify check ID pattern to suppress.
161     *
162     * @param format pattern for check ID format
163     * @since 8.24
164     */
165    public final void setIdFormat(String format) {
166        idFormat = format;
167    }
168
169    @Override
170    public boolean accept(AuditEvent event) {
171        boolean accepted = true;
172        if (event.getViolation() != null) {
173            final String eventFileName = event.getFileName();
174
175            if (!currentFileName.equals(eventFileName)) {
176                currentFileName = eventFileName;
177                final FileText fileText = getFileText(eventFileName);
178                currentFileSuppressionCache.clear();
179                if (fileText != null) {
180                    cacheSuppressions(fileText);
181                }
182            }
183
184            accepted = getNearestSuppression(currentFileSuppressionCache, event) == null;
185        }
186        return accepted;
187    }
188
189    @Override
190    protected void finishLocalSetup() {
191        // No code by default
192    }
193
194    /**
195     * Caches {@link FileText} instance created based on the given file name.
196     *
197     * @param fileName the name of the file.
198     * @return {@link FileText} instance.
199     * @throws IllegalStateException if the file could not be read.
200     */
201    private static FileText getFileText(String fileName) {
202        final Path path = Path.of(fileName);
203        FileText result = null;
204
205        // some violations can be on a directory, instead of a file
206        if (!Files.isDirectory(path)) {
207            try {
208                result = new FileText(path.toFile(), StandardCharsets.UTF_8.name());
209            }
210            catch (IOException exc) {
211                throw new IllegalStateException("Cannot read source file: " + fileName, exc);
212            }
213        }
214
215        return result;
216    }
217
218    /**
219     * Collects the list of {@link Suppression} instances retrieved from the given {@link FileText}.
220     *
221     * @param fileText {@link FileText} instance.
222     */
223    private void cacheSuppressions(FileText fileText) {
224        for (int lineNo = 0; lineNo < fileText.size(); lineNo++) {
225            final Optional<Suppression> suppression = getSuppression(fileText, lineNo);
226            suppression.ifPresent(currentFileSuppressionCache::add);
227        }
228    }
229
230    /**
231     * Tries to extract the suppression from the given line.
232     *
233     * @param fileText {@link FileText} instance.
234     * @param lineNo line number.
235     * @return {@link Optional} of {@link Suppression}.
236     */
237    private Optional<Suppression> getSuppression(FileText fileText, int lineNo) {
238        final String line = fileText.get(lineNo);
239        final Matcher onCommentMatcher = onCommentFormat.matcher(line);
240        final Matcher offCommentMatcher = offCommentFormat.matcher(line);
241
242        Suppression suppression = null;
243        if (onCommentMatcher.find()) {
244            suppression = new Suppression(onCommentMatcher.group(0),
245                lineNo + 1, SuppressionType.ON, this);
246        }
247        if (offCommentMatcher.find()) {
248            suppression = new Suppression(offCommentMatcher.group(0),
249                lineNo + 1, SuppressionType.OFF, this);
250        }
251
252        return Optional.ofNullable(suppression);
253    }
254
255    /**
256     * Finds the nearest {@link Suppression} instance which can suppress
257     * the given {@link AuditEvent}. The nearest suppression is the suppression which scope
258     * is before the line and column of the event.
259     *
260     * @param suppressions collection of {@link Suppression} instances.
261     * @param event {@link AuditEvent} instance.
262     * @return {@link Suppression} instance.
263     */
264    private static Suppression getNearestSuppression(Collection<Suppression> suppressions,
265                                                     AuditEvent event) {
266        return suppressions
267            .stream()
268            .filter(suppression -> suppression.isMatch(event))
269            .reduce((first, second) -> second)
270            .filter(suppression -> suppression.suppressionType != SuppressionType.ON)
271            .orElse(null);
272    }
273
274    /** Enum which represents the type of the suppression. */
275    private enum SuppressionType {
276
277        /** On suppression type. */
278        ON,
279        /** Off suppression type. */
280        OFF,
281
282    }
283
284    /** The class which represents the suppression. */
285    private static final class Suppression {
286
287        /** The regexp which is used to match the event source.*/
288        private final Pattern eventSourceRegexp;
289        /** The regexp which is used to match the event message.*/
290        private final Pattern eventMessageRegexp;
291        /** The regexp which is used to match the event ID.*/
292        private final Pattern eventIdRegexp;
293
294        /** Suppression line.*/
295        private final int lineNo;
296
297        /** Suppression type. */
298        private final SuppressionType suppressionType;
299
300        /**
301         * Creates new suppression instance.
302         *
303         * @param text suppression text.
304         * @param lineNo suppression line number.
305         * @param suppressionType suppression type.
306         * @param filter the {@link SuppressWithPlainTextCommentFilter} with the context.
307         * @throws IllegalArgumentException if there is an error in the filter regex syntax.
308         */
309        private Suppression(
310            String text,
311            int lineNo,
312            SuppressionType suppressionType,
313            SuppressWithPlainTextCommentFilter filter
314        ) {
315            this.lineNo = lineNo;
316            this.suppressionType = suppressionType;
317
318            final Pattern commentFormat;
319            if (this.suppressionType == SuppressionType.ON) {
320                commentFormat = filter.onCommentFormat;
321            }
322            else {
323                commentFormat = filter.offCommentFormat;
324            }
325
326            // Expand regexp for check and message
327            // Does not intern Patterns with Utils.getPattern()
328            String format = "";
329            try {
330                format = CommonUtil.fillTemplateWithStringsByRegexp(
331                        filter.checkFormat, text, commentFormat);
332                eventSourceRegexp = Pattern.compile(format);
333                if (filter.messageFormat == null) {
334                    eventMessageRegexp = null;
335                }
336                else {
337                    format = CommonUtil.fillTemplateWithStringsByRegexp(
338                            filter.messageFormat, text, commentFormat);
339                    eventMessageRegexp = Pattern.compile(format);
340                }
341                if (filter.idFormat == null) {
342                    eventIdRegexp = null;
343                }
344                else {
345                    format = CommonUtil.fillTemplateWithStringsByRegexp(
346                            filter.idFormat, text, commentFormat);
347                    eventIdRegexp = Pattern.compile(format);
348                }
349            }
350            catch (final PatternSyntaxException exc) {
351                throw new IllegalArgumentException(
352                    "unable to parse expanded comment " + format, exc);
353            }
354        }
355
356        /**
357         * Indicates whether some other object is "equal to" this one.
358         *
359         * @noinspection EqualsCalledOnEnumConstant
360         * @noinspectionreason EqualsCalledOnEnumConstant - enumeration is needed to keep
361         *      code consistent
362         */
363        @Override
364        public boolean equals(Object other) {
365            if (this == other) {
366                return true;
367            }
368            if (other == null || getClass() != other.getClass()) {
369                return false;
370            }
371            final Suppression suppression = (Suppression) other;
372            return lineNo == suppression.lineNo
373                    && Objects.equals(suppressionType, suppression.suppressionType)
374                    && Objects.equals(eventSourceRegexp, suppression.eventSourceRegexp)
375                    && Objects.equals(eventMessageRegexp, suppression.eventMessageRegexp)
376                    && Objects.equals(eventIdRegexp, suppression.eventIdRegexp);
377        }
378
379        @Override
380        public int hashCode() {
381            return Objects.hash(
382                lineNo, suppressionType, eventSourceRegexp, eventMessageRegexp,
383                eventIdRegexp);
384        }
385
386        /**
387         * Checks whether the suppression matches the given {@link AuditEvent}.
388         *
389         * @param event {@link AuditEvent} instance.
390         * @return true if the suppression matches {@link AuditEvent}.
391         */
392        private boolean isMatch(AuditEvent event) {
393            return isInScopeOfSuppression(event)
394                    && isCheckMatch(event)
395                    && isIdMatch(event)
396                    && isMessageMatch(event);
397        }
398
399        /**
400         * Checks whether {@link AuditEvent} is in the scope of the suppression.
401         *
402         * @param event {@link AuditEvent} instance.
403         * @return true if {@link AuditEvent} is in the scope of the suppression.
404         */
405        private boolean isInScopeOfSuppression(AuditEvent event) {
406            return lineNo <= event.getLine();
407        }
408
409        /**
410         * Checks whether {@link AuditEvent} source name matches the check format.
411         *
412         * @param event {@link AuditEvent} instance.
413         * @return true if the {@link AuditEvent} source name matches the check format.
414         */
415        private boolean isCheckMatch(AuditEvent event) {
416            final Matcher checkMatcher = eventSourceRegexp.matcher(event.getSourceName());
417            return checkMatcher.find();
418        }
419
420        /**
421         * Checks whether the {@link AuditEvent} module ID matches the ID format.
422         *
423         * @param event {@link AuditEvent} instance.
424         * @return true if the {@link AuditEvent} module ID matches the ID format.
425         */
426        private boolean isIdMatch(AuditEvent event) {
427            boolean match = true;
428            if (eventIdRegexp != null) {
429                if (event.getModuleId() == null) {
430                    match = false;
431                }
432                else {
433                    final Matcher idMatcher = eventIdRegexp.matcher(event.getModuleId());
434                    match = idMatcher.find();
435                }
436            }
437            return match;
438        }
439
440        /**
441         * Checks whether the {@link AuditEvent} message matches the message format.
442         *
443         * @param event {@link AuditEvent} instance.
444         * @return true if the {@link AuditEvent} message matches the message format.
445         */
446        private boolean isMessageMatch(AuditEvent event) {
447            boolean match = true;
448            if (eventMessageRegexp != null) {
449                final Matcher messageMatcher = eventMessageRegexp.matcher(event.getMessage());
450                match = messageMatcher.find();
451            }
452            return match;
453        }
454    }
455
456}