View Javadoc
1   ///////////////////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3   // Copyright (C) 2001-2025 the original author or authors.
4   //
5   // This library is free software; you can redistribute it and/or
6   // modify it under the terms of the GNU Lesser General Public
7   // License as published by the Free Software Foundation; either
8   // version 2.1 of the License, or (at your option) any later version.
9   //
10  // This library is distributed in the hope that it will be useful,
11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  // Lesser General Public License for more details.
14  //
15  // You should have received a copy of the GNU Lesser General Public
16  // License along with this library; if not, write to the Free Software
17  // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18  ///////////////////////////////////////////////////////////////////////////////////////////////
19  
20  package com.puppycrawl.tools.checkstyle.filters;
21  
22  import java.io.File;
23  import java.io.IOException;
24  import java.nio.charset.StandardCharsets;
25  import java.util.ArrayList;
26  import java.util.Collection;
27  import java.util.Objects;
28  import java.util.Optional;
29  import java.util.regex.Matcher;
30  import java.util.regex.Pattern;
31  import java.util.regex.PatternSyntaxException;
32  
33  import com.puppycrawl.tools.checkstyle.AbstractAutomaticBean;
34  import com.puppycrawl.tools.checkstyle.PropertyType;
35  import com.puppycrawl.tools.checkstyle.XdocsPropertyType;
36  import com.puppycrawl.tools.checkstyle.api.AuditEvent;
37  import com.puppycrawl.tools.checkstyle.api.FileText;
38  import com.puppycrawl.tools.checkstyle.api.Filter;
39  import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
40  
41  /**
42   * <div>
43   * Filter {@code SuppressWithPlainTextCommentFilter} uses plain text to suppress
44   * audit events. The filter can be used only to suppress audit events received
45   * from the checks which implement FileSetCheck interface. In other words, the
46   * checks which have Checker as a parent module. The filter knows nothing about
47   * AST, it treats only plain text comments and extracts the information required
48   * for suppression from the plain text comments. Currently, the filter supports
49   * only single-line comments.
50   * </div>
51   *
52   * <p>
53   * Please, be aware of the fact that, it is not recommended to use the filter
54   * for Java code anymore, however you still are able to use it to suppress audit
55   * events received from the checks which implement FileSetCheck interface.
56   * </p>
57   *
58   * <p>
59   * Rationale: Sometimes there are legitimate reasons for violating a check.
60   * When this is a matter of the code in question and not personal preference,
61   * the best place to override the policy is in the code itself. Semi-structured
62   * comments can be associated with the check. This is sometimes superior to
63   * a separate suppressions file, which must be kept up-to-date as the source
64   * file is edited.
65   * </p>
66   *
67   * <p>
68   * Note that the suppression comment should be put before the violation.
69   * You can use more than one suppression comment each on separate line.
70   * </p>
71   *
72   * <p>
73   * Notes:
74   * Properties {@code offCommentFormat} and {@code onCommentFormat} must have equal
75   * <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/regex/Matcher.html#groupCount()">
76   * paren counts</a>.
77   * </p>
78   *
79   * <p>
80   * SuppressionWithPlainTextCommentFilter can suppress Checks that have Treewalker or
81   * Checker as parent module.
82   * </p>
83   * <ul>
84   * <li>
85   * Property {@code checkFormat} - Specify check pattern to suppress.
86   * Type is {@code java.util.regex.Pattern}.
87   * Default value is {@code ".*"}.
88   * </li>
89   * <li>
90   * Property {@code idFormat} - Specify check ID pattern to suppress.
91   * Type is {@code java.util.regex.Pattern}.
92   * Default value is {@code null}.
93   * </li>
94   * <li>
95   * Property {@code messageFormat} - Specify message pattern to suppress.
96   * Type is {@code java.util.regex.Pattern}.
97   * Default value is {@code null}.
98   * </li>
99   * <li>
100  * Property {@code offCommentFormat} - Specify comment pattern to trigger filter
101  * to begin suppression.
102  * Type is {@code java.util.regex.Pattern}.
103  * Default value is {@code "// CHECKSTYLE:OFF"}.
104  * </li>
105  * <li>
106  * Property {@code onCommentFormat} - Specify comment pattern to trigger filter
107  * to end suppression.
108  * Type is {@code java.util.regex.Pattern}.
109  * Default value is {@code "// CHECKSTYLE:ON"}.
110  * </li>
111  * </ul>
112  *
113  * <p>
114  * Parent is {@code com.puppycrawl.tools.checkstyle.Checker}
115  * </p>
116  *
117  * @since 8.6
118  */
119 public class SuppressWithPlainTextCommentFilter extends AbstractAutomaticBean implements Filter {
120 
121     /** Comment format which turns checkstyle reporting off. */
122     private static final String DEFAULT_OFF_FORMAT = "// CHECKSTYLE:OFF";
123 
124     /** Comment format which turns checkstyle reporting on. */
125     private static final String DEFAULT_ON_FORMAT = "// CHECKSTYLE:ON";
126 
127     /** Default check format to suppress. By default, the filter suppress all checks. */
128     private static final String DEFAULT_CHECK_FORMAT = ".*";
129 
130     /** List of suppressions from the file. By default, Its null. */
131     private final Collection<Suppression> currentFileSuppressionCache = new ArrayList<>();
132 
133     /** File name that was suppressed. By default, Its empty. */
134     private String currentFileName = "";
135 
136     /** Specify comment pattern to trigger filter to begin suppression. */
137     private Pattern offCommentFormat = CommonUtil.createPattern(DEFAULT_OFF_FORMAT);
138 
139     /** Specify comment pattern to trigger filter to end suppression. */
140     private Pattern onCommentFormat = CommonUtil.createPattern(DEFAULT_ON_FORMAT);
141 
142     /** Specify check pattern to suppress. */
143     @XdocsPropertyType(PropertyType.PATTERN)
144     private String checkFormat = DEFAULT_CHECK_FORMAT;
145 
146     /** Specify message pattern to suppress. */
147     @XdocsPropertyType(PropertyType.PATTERN)
148     private String messageFormat;
149 
150     /** Specify check ID pattern to suppress. */
151     @XdocsPropertyType(PropertyType.PATTERN)
152     private String idFormat;
153 
154     /**
155      * Setter to specify comment pattern to trigger filter to begin suppression.
156      *
157      * @param pattern off comment format pattern.
158      * @since 8.6
159      */
160     public final void setOffCommentFormat(Pattern pattern) {
161         offCommentFormat = pattern;
162     }
163 
164     /**
165      * Setter to specify comment pattern to trigger filter to end suppression.
166      *
167      * @param pattern  on comment format pattern.
168      * @since 8.6
169      */
170     public final void setOnCommentFormat(Pattern pattern) {
171         onCommentFormat = pattern;
172     }
173 
174     /**
175      * Setter to specify check pattern to suppress.
176      *
177      * @param format pattern for check format.
178      * @since 8.6
179      */
180     public final void setCheckFormat(String format) {
181         checkFormat = format;
182     }
183 
184     /**
185      * Setter to specify message pattern to suppress.
186      *
187      * @param format pattern for message format.
188      * @since 8.6
189      */
190     public final void setMessageFormat(String format) {
191         messageFormat = format;
192     }
193 
194     /**
195      * Setter to specify check ID pattern to suppress.
196      *
197      * @param format pattern for check ID format
198      * @since 8.24
199      */
200     public final void setIdFormat(String format) {
201         idFormat = format;
202     }
203 
204     @Override
205     public boolean accept(AuditEvent event) {
206         boolean accepted = true;
207         if (event.getViolation() != null) {
208             final String eventFileName = event.getFileName();
209 
210             if (!currentFileName.equals(eventFileName)) {
211                 currentFileName = eventFileName;
212                 final FileText fileText = getFileText(eventFileName);
213                 currentFileSuppressionCache.clear();
214                 if (fileText != null) {
215                     cacheSuppressions(fileText);
216                 }
217             }
218 
219             accepted = getNearestSuppression(currentFileSuppressionCache, event) == null;
220         }
221         return accepted;
222     }
223 
224     @Override
225     protected void finishLocalSetup() {
226         // No code by default
227     }
228 
229     /**
230      * Caches {@link FileText} instance created based on the given file name.
231      *
232      * @param fileName the name of the file.
233      * @return {@link FileText} instance.
234      * @throws IllegalStateException if the file could not be read.
235      */
236     private static FileText getFileText(String fileName) {
237         final File file = new File(fileName);
238         FileText result = null;
239 
240         // some violations can be on a directory, instead of a file
241         if (!file.isDirectory()) {
242             try {
243                 result = new FileText(file, StandardCharsets.UTF_8.name());
244             }
245             catch (IOException exc) {
246                 throw new IllegalStateException("Cannot read source file: " + fileName, exc);
247             }
248         }
249 
250         return result;
251     }
252 
253     /**
254      * Collects the list of {@link Suppression} instances retrieved from the given {@link FileText}.
255      *
256      * @param fileText {@link FileText} instance.
257      */
258     private void cacheSuppressions(FileText fileText) {
259         for (int lineNo = 0; lineNo < fileText.size(); lineNo++) {
260             final Optional<Suppression> suppression = getSuppression(fileText, lineNo);
261             suppression.ifPresent(currentFileSuppressionCache::add);
262         }
263     }
264 
265     /**
266      * Tries to extract the suppression from the given line.
267      *
268      * @param fileText {@link FileText} instance.
269      * @param lineNo line number.
270      * @return {@link Optional} of {@link Suppression}.
271      */
272     private Optional<Suppression> getSuppression(FileText fileText, int lineNo) {
273         final String line = fileText.get(lineNo);
274         final Matcher onCommentMatcher = onCommentFormat.matcher(line);
275         final Matcher offCommentMatcher = offCommentFormat.matcher(line);
276 
277         Suppression suppression = null;
278         if (onCommentMatcher.find()) {
279             suppression = new Suppression(onCommentMatcher.group(0),
280                 lineNo + 1, SuppressionType.ON, this);
281         }
282         if (offCommentMatcher.find()) {
283             suppression = new Suppression(offCommentMatcher.group(0),
284                 lineNo + 1, SuppressionType.OFF, this);
285         }
286 
287         return Optional.ofNullable(suppression);
288     }
289 
290     /**
291      * Finds the nearest {@link Suppression} instance which can suppress
292      * the given {@link AuditEvent}. The nearest suppression is the suppression which scope
293      * is before the line and column of the event.
294      *
295      * @param suppressions collection of {@link Suppression} instances.
296      * @param event {@link AuditEvent} instance.
297      * @return {@link Suppression} instance.
298      */
299     private static Suppression getNearestSuppression(Collection<Suppression> suppressions,
300                                                      AuditEvent event) {
301         return suppressions
302             .stream()
303             .filter(suppression -> suppression.isMatch(event))
304             .reduce((first, second) -> second)
305             .filter(suppression -> suppression.suppressionType != SuppressionType.ON)
306             .orElse(null);
307     }
308 
309     /** Enum which represents the type of the suppression. */
310     private enum SuppressionType {
311 
312         /** On suppression type. */
313         ON,
314         /** Off suppression type. */
315         OFF,
316 
317     }
318 
319     /** The class which represents the suppression. */
320     private static final class Suppression {
321 
322         /** The regexp which is used to match the event source.*/
323         private final Pattern eventSourceRegexp;
324         /** The regexp which is used to match the event message.*/
325         private final Pattern eventMessageRegexp;
326         /** The regexp which is used to match the event ID.*/
327         private final Pattern eventIdRegexp;
328 
329         /** Suppression line.*/
330         private final int lineNo;
331 
332         /** Suppression type. */
333         private final SuppressionType suppressionType;
334 
335         /**
336          * Creates new suppression instance.
337          *
338          * @param text suppression text.
339          * @param lineNo suppression line number.
340          * @param suppressionType suppression type.
341          * @param filter the {@link SuppressWithPlainTextCommentFilter} with the context.
342          * @throws IllegalArgumentException if there is an error in the filter regex syntax.
343          */
344         private Suppression(
345             String text,
346             int lineNo,
347             SuppressionType suppressionType,
348             SuppressWithPlainTextCommentFilter filter
349         ) {
350             this.lineNo = lineNo;
351             this.suppressionType = suppressionType;
352 
353             final Pattern commentFormat;
354             if (this.suppressionType == SuppressionType.ON) {
355                 commentFormat = filter.onCommentFormat;
356             }
357             else {
358                 commentFormat = filter.offCommentFormat;
359             }
360 
361             // Expand regexp for check and message
362             // Does not intern Patterns with Utils.getPattern()
363             String format = "";
364             try {
365                 format = CommonUtil.fillTemplateWithStringsByRegexp(
366                         filter.checkFormat, text, commentFormat);
367                 eventSourceRegexp = Pattern.compile(format);
368                 if (filter.messageFormat == null) {
369                     eventMessageRegexp = null;
370                 }
371                 else {
372                     format = CommonUtil.fillTemplateWithStringsByRegexp(
373                             filter.messageFormat, text, commentFormat);
374                     eventMessageRegexp = Pattern.compile(format);
375                 }
376                 if (filter.idFormat == null) {
377                     eventIdRegexp = null;
378                 }
379                 else {
380                     format = CommonUtil.fillTemplateWithStringsByRegexp(
381                             filter.idFormat, text, commentFormat);
382                     eventIdRegexp = Pattern.compile(format);
383                 }
384             }
385             catch (final PatternSyntaxException exc) {
386                 throw new IllegalArgumentException(
387                     "unable to parse expanded comment " + format, exc);
388             }
389         }
390 
391         /**
392          * Indicates whether some other object is "equal to" this one.
393          *
394          * @noinspection EqualsCalledOnEnumConstant
395          * @noinspectionreason EqualsCalledOnEnumConstant - enumeration is needed to keep
396          *      code consistent
397          */
398         @Override
399         public boolean equals(Object other) {
400             if (this == other) {
401                 return true;
402             }
403             if (other == null || getClass() != other.getClass()) {
404                 return false;
405             }
406             final Suppression suppression = (Suppression) other;
407             return Objects.equals(lineNo, suppression.lineNo)
408                     && Objects.equals(suppressionType, suppression.suppressionType)
409                     && Objects.equals(eventSourceRegexp, suppression.eventSourceRegexp)
410                     && Objects.equals(eventMessageRegexp, suppression.eventMessageRegexp)
411                     && Objects.equals(eventIdRegexp, suppression.eventIdRegexp);
412         }
413 
414         @Override
415         public int hashCode() {
416             return Objects.hash(
417                 lineNo, suppressionType, eventSourceRegexp, eventMessageRegexp,
418                 eventIdRegexp);
419         }
420 
421         /**
422          * Checks whether the suppression matches the given {@link AuditEvent}.
423          *
424          * @param event {@link AuditEvent} instance.
425          * @return true if the suppression matches {@link AuditEvent}.
426          */
427         private boolean isMatch(AuditEvent event) {
428             return isInScopeOfSuppression(event)
429                     && isCheckMatch(event)
430                     && isIdMatch(event)
431                     && isMessageMatch(event);
432         }
433 
434         /**
435          * Checks whether {@link AuditEvent} is in the scope of the suppression.
436          *
437          * @param event {@link AuditEvent} instance.
438          * @return true if {@link AuditEvent} is in the scope of the suppression.
439          */
440         private boolean isInScopeOfSuppression(AuditEvent event) {
441             return lineNo <= event.getLine();
442         }
443 
444         /**
445          * Checks whether {@link AuditEvent} source name matches the check format.
446          *
447          * @param event {@link AuditEvent} instance.
448          * @return true if the {@link AuditEvent} source name matches the check format.
449          */
450         private boolean isCheckMatch(AuditEvent event) {
451             final Matcher checkMatcher = eventSourceRegexp.matcher(event.getSourceName());
452             return checkMatcher.find();
453         }
454 
455         /**
456          * Checks whether the {@link AuditEvent} module ID matches the ID format.
457          *
458          * @param event {@link AuditEvent} instance.
459          * @return true if the {@link AuditEvent} module ID matches the ID format.
460          */
461         private boolean isIdMatch(AuditEvent event) {
462             boolean match = true;
463             if (eventIdRegexp != null) {
464                 if (event.getModuleId() == null) {
465                     match = false;
466                 }
467                 else {
468                     final Matcher idMatcher = eventIdRegexp.matcher(event.getModuleId());
469                     match = idMatcher.find();
470                 }
471             }
472             return match;
473         }
474 
475         /**
476          * Checks whether the {@link AuditEvent} message matches the message format.
477          *
478          * @param event {@link AuditEvent} instance.
479          * @return true if the {@link AuditEvent} message matches the message format.
480          */
481         private boolean isMessageMatch(AuditEvent event) {
482             boolean match = true;
483             if (eventMessageRegexp != null) {
484                 final Matcher messageMatcher = eventMessageRegexp.matcher(event.getMessage());
485                 match = messageMatcher.find();
486             }
487             return match;
488         }
489     }
490 
491 }