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   * Properties {@code offCommentFormat} and {@code onCommentFormat} must have equal
74   * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/regex/Matcher.html#groupCount()">
75   * paren counts</a>.
76   * </p>
77   *
78   * <p>
79   * SuppressionWithPlainTextCommentFilter can suppress Checks that have Treewalker or
80   * Checker as parent module.
81   * </p>
82   * <ul>
83   * <li>
84   * Property {@code checkFormat} - Specify check pattern to suppress.
85   * Type is {@code java.util.regex.Pattern}.
86   * Default value is {@code ".*"}.
87   * </li>
88   * <li>
89   * Property {@code idFormat} - Specify check ID pattern to suppress.
90   * Type is {@code java.util.regex.Pattern}.
91   * Default value is {@code null}.
92   * </li>
93   * <li>
94   * Property {@code messageFormat} - Specify message pattern to suppress.
95   * Type is {@code java.util.regex.Pattern}.
96   * Default value is {@code null}.
97   * </li>
98   * <li>
99   * Property {@code offCommentFormat} - Specify comment pattern to trigger filter
100  * to begin suppression.
101  * Type is {@code java.util.regex.Pattern}.
102  * Default value is {@code "// CHECKSTYLE:OFF"}.
103  * </li>
104  * <li>
105  * Property {@code onCommentFormat} - Specify comment pattern to trigger filter
106  * to end suppression.
107  * Type is {@code java.util.regex.Pattern}.
108  * Default value is {@code "// CHECKSTYLE:ON"}.
109  * </li>
110  * </ul>
111  *
112  * <p>
113  * Parent is {@code com.puppycrawl.tools.checkstyle.Checker}
114  * </p>
115  *
116  * @since 8.6
117  */
118 public class SuppressWithPlainTextCommentFilter extends AbstractAutomaticBean implements Filter {
119 
120     /** Comment format which turns checkstyle reporting off. */
121     private static final String DEFAULT_OFF_FORMAT = "// CHECKSTYLE:OFF";
122 
123     /** Comment format which turns checkstyle reporting on. */
124     private static final String DEFAULT_ON_FORMAT = "// CHECKSTYLE:ON";
125 
126     /** Default check format to suppress. By default, the filter suppress all checks. */
127     private static final String DEFAULT_CHECK_FORMAT = ".*";
128 
129     /** List of suppressions from the file. By default, Its null. */
130     private final Collection<Suppression> currentFileSuppressionCache = new ArrayList<>();
131 
132     /** File name that was suppressed. By default, Its empty. */
133     private String currentFileName = "";
134 
135     /** Specify comment pattern to trigger filter to begin suppression. */
136     private Pattern offCommentFormat = CommonUtil.createPattern(DEFAULT_OFF_FORMAT);
137 
138     /** Specify comment pattern to trigger filter to end suppression. */
139     private Pattern onCommentFormat = CommonUtil.createPattern(DEFAULT_ON_FORMAT);
140 
141     /** Specify check pattern to suppress. */
142     @XdocsPropertyType(PropertyType.PATTERN)
143     private String checkFormat = DEFAULT_CHECK_FORMAT;
144 
145     /** Specify message pattern to suppress. */
146     @XdocsPropertyType(PropertyType.PATTERN)
147     private String messageFormat;
148 
149     /** Specify check ID pattern to suppress. */
150     @XdocsPropertyType(PropertyType.PATTERN)
151     private String idFormat;
152 
153     /**
154      * Setter to specify comment pattern to trigger filter to begin suppression.
155      *
156      * @param pattern off comment format pattern.
157      * @since 8.6
158      */
159     public final void setOffCommentFormat(Pattern pattern) {
160         offCommentFormat = pattern;
161     }
162 
163     /**
164      * Setter to specify comment pattern to trigger filter to end suppression.
165      *
166      * @param pattern  on comment format pattern.
167      * @since 8.6
168      */
169     public final void setOnCommentFormat(Pattern pattern) {
170         onCommentFormat = pattern;
171     }
172 
173     /**
174      * Setter to specify check pattern to suppress.
175      *
176      * @param format pattern for check format.
177      * @since 8.6
178      */
179     public final void setCheckFormat(String format) {
180         checkFormat = format;
181     }
182 
183     /**
184      * Setter to specify message pattern to suppress.
185      *
186      * @param format pattern for message format.
187      * @since 8.6
188      */
189     public final void setMessageFormat(String format) {
190         messageFormat = format;
191     }
192 
193     /**
194      * Setter to specify check ID pattern to suppress.
195      *
196      * @param format pattern for check ID format
197      * @since 8.24
198      */
199     public final void setIdFormat(String format) {
200         idFormat = format;
201     }
202 
203     @Override
204     public boolean accept(AuditEvent event) {
205         boolean accepted = true;
206         if (event.getViolation() != null) {
207             final String eventFileName = event.getFileName();
208 
209             if (!currentFileName.equals(eventFileName)) {
210                 currentFileName = eventFileName;
211                 final FileText fileText = getFileText(eventFileName);
212                 currentFileSuppressionCache.clear();
213                 if (fileText != null) {
214                     cacheSuppressions(fileText);
215                 }
216             }
217 
218             accepted = getNearestSuppression(currentFileSuppressionCache, event) == null;
219         }
220         return accepted;
221     }
222 
223     @Override
224     protected void finishLocalSetup() {
225         // No code by default
226     }
227 
228     /**
229      * Caches {@link FileText} instance created based on the given file name.
230      *
231      * @param fileName the name of the file.
232      * @return {@link FileText} instance.
233      * @throws IllegalStateException if the file could not be read.
234      */
235     private static FileText getFileText(String fileName) {
236         final File file = new File(fileName);
237         FileText result = null;
238 
239         // some violations can be on a directory, instead of a file
240         if (!file.isDirectory()) {
241             try {
242                 result = new FileText(file, StandardCharsets.UTF_8.name());
243             }
244             catch (IOException exc) {
245                 throw new IllegalStateException("Cannot read source file: " + fileName, exc);
246             }
247         }
248 
249         return result;
250     }
251 
252     /**
253      * Collects the list of {@link Suppression} instances retrieved from the given {@link FileText}.
254      *
255      * @param fileText {@link FileText} instance.
256      */
257     private void cacheSuppressions(FileText fileText) {
258         for (int lineNo = 0; lineNo < fileText.size(); lineNo++) {
259             final Optional<Suppression> suppression = getSuppression(fileText, lineNo);
260             suppression.ifPresent(currentFileSuppressionCache::add);
261         }
262     }
263 
264     /**
265      * Tries to extract the suppression from the given line.
266      *
267      * @param fileText {@link FileText} instance.
268      * @param lineNo line number.
269      * @return {@link Optional} of {@link Suppression}.
270      */
271     private Optional<Suppression> getSuppression(FileText fileText, int lineNo) {
272         final String line = fileText.get(lineNo);
273         final Matcher onCommentMatcher = onCommentFormat.matcher(line);
274         final Matcher offCommentMatcher = offCommentFormat.matcher(line);
275 
276         Suppression suppression = null;
277         if (onCommentMatcher.find()) {
278             suppression = new Suppression(onCommentMatcher.group(0),
279                 lineNo + 1, SuppressionType.ON, this);
280         }
281         if (offCommentMatcher.find()) {
282             suppression = new Suppression(offCommentMatcher.group(0),
283                 lineNo + 1, SuppressionType.OFF, this);
284         }
285 
286         return Optional.ofNullable(suppression);
287     }
288 
289     /**
290      * Finds the nearest {@link Suppression} instance which can suppress
291      * the given {@link AuditEvent}. The nearest suppression is the suppression which scope
292      * is before the line and column of the event.
293      *
294      * @param suppressions collection of {@link Suppression} instances.
295      * @param event {@link AuditEvent} instance.
296      * @return {@link Suppression} instance.
297      */
298     private static Suppression getNearestSuppression(Collection<Suppression> suppressions,
299                                                      AuditEvent event) {
300         return suppressions
301             .stream()
302             .filter(suppression -> suppression.isMatch(event))
303             .reduce((first, second) -> second)
304             .filter(suppression -> suppression.suppressionType != SuppressionType.ON)
305             .orElse(null);
306     }
307 
308     /** Enum which represents the type of the suppression. */
309     private enum SuppressionType {
310 
311         /** On suppression type. */
312         ON,
313         /** Off suppression type. */
314         OFF,
315 
316     }
317 
318     /** The class which represents the suppression. */
319     private static final class Suppression {
320 
321         /** The regexp which is used to match the event source.*/
322         private final Pattern eventSourceRegexp;
323         /** The regexp which is used to match the event message.*/
324         private final Pattern eventMessageRegexp;
325         /** The regexp which is used to match the event ID.*/
326         private final Pattern eventIdRegexp;
327 
328         /** Suppression line.*/
329         private final int lineNo;
330 
331         /** Suppression type. */
332         private final SuppressionType suppressionType;
333 
334         /**
335          * Creates new suppression instance.
336          *
337          * @param text suppression text.
338          * @param lineNo suppression line number.
339          * @param suppressionType suppression type.
340          * @param filter the {@link SuppressWithPlainTextCommentFilter} with the context.
341          * @throws IllegalArgumentException if there is an error in the filter regex syntax.
342          */
343         private Suppression(
344             String text,
345             int lineNo,
346             SuppressionType suppressionType,
347             SuppressWithPlainTextCommentFilter filter
348         ) {
349             this.lineNo = lineNo;
350             this.suppressionType = suppressionType;
351 
352             final Pattern commentFormat;
353             if (this.suppressionType == SuppressionType.ON) {
354                 commentFormat = filter.onCommentFormat;
355             }
356             else {
357                 commentFormat = filter.offCommentFormat;
358             }
359 
360             // Expand regexp for check and message
361             // Does not intern Patterns with Utils.getPattern()
362             String format = "";
363             try {
364                 format = CommonUtil.fillTemplateWithStringsByRegexp(
365                         filter.checkFormat, text, commentFormat);
366                 eventSourceRegexp = Pattern.compile(format);
367                 if (filter.messageFormat == null) {
368                     eventMessageRegexp = null;
369                 }
370                 else {
371                     format = CommonUtil.fillTemplateWithStringsByRegexp(
372                             filter.messageFormat, text, commentFormat);
373                     eventMessageRegexp = Pattern.compile(format);
374                 }
375                 if (filter.idFormat == null) {
376                     eventIdRegexp = null;
377                 }
378                 else {
379                     format = CommonUtil.fillTemplateWithStringsByRegexp(
380                             filter.idFormat, text, commentFormat);
381                     eventIdRegexp = Pattern.compile(format);
382                 }
383             }
384             catch (final PatternSyntaxException exc) {
385                 throw new IllegalArgumentException(
386                     "unable to parse expanded comment " + format, exc);
387             }
388         }
389 
390         /**
391          * Indicates whether some other object is "equal to" this one.
392          *
393          * @noinspection EqualsCalledOnEnumConstant
394          * @noinspectionreason EqualsCalledOnEnumConstant - enumeration is needed to keep
395          *      code consistent
396          */
397         @Override
398         public boolean equals(Object other) {
399             if (this == other) {
400                 return true;
401             }
402             if (other == null || getClass() != other.getClass()) {
403                 return false;
404             }
405             final Suppression suppression = (Suppression) other;
406             return Objects.equals(lineNo, suppression.lineNo)
407                     && Objects.equals(suppressionType, suppression.suppressionType)
408                     && Objects.equals(eventSourceRegexp, suppression.eventSourceRegexp)
409                     && Objects.equals(eventMessageRegexp, suppression.eventMessageRegexp)
410                     && Objects.equals(eventIdRegexp, suppression.eventIdRegexp);
411         }
412 
413         @Override
414         public int hashCode() {
415             return Objects.hash(
416                 lineNo, suppressionType, eventSourceRegexp, eventMessageRegexp,
417                 eventIdRegexp);
418         }
419 
420         /**
421          * Checks whether the suppression matches the given {@link AuditEvent}.
422          *
423          * @param event {@link AuditEvent} instance.
424          * @return true if the suppression matches {@link AuditEvent}.
425          */
426         private boolean isMatch(AuditEvent event) {
427             return isInScopeOfSuppression(event)
428                     && isCheckMatch(event)
429                     && isIdMatch(event)
430                     && isMessageMatch(event);
431         }
432 
433         /**
434          * Checks whether {@link AuditEvent} is in the scope of the suppression.
435          *
436          * @param event {@link AuditEvent} instance.
437          * @return true if {@link AuditEvent} is in the scope of the suppression.
438          */
439         private boolean isInScopeOfSuppression(AuditEvent event) {
440             return lineNo <= event.getLine();
441         }
442 
443         /**
444          * Checks whether {@link AuditEvent} source name matches the check format.
445          *
446          * @param event {@link AuditEvent} instance.
447          * @return true if the {@link AuditEvent} source name matches the check format.
448          */
449         private boolean isCheckMatch(AuditEvent event) {
450             final Matcher checkMatcher = eventSourceRegexp.matcher(event.getSourceName());
451             return checkMatcher.find();
452         }
453 
454         /**
455          * Checks whether the {@link AuditEvent} module ID matches the ID format.
456          *
457          * @param event {@link AuditEvent} instance.
458          * @return true if the {@link AuditEvent} module ID matches the ID format.
459          */
460         private boolean isIdMatch(AuditEvent event) {
461             boolean match = true;
462             if (eventIdRegexp != null) {
463                 if (event.getModuleId() == null) {
464                     match = false;
465                 }
466                 else {
467                     final Matcher idMatcher = eventIdRegexp.matcher(event.getModuleId());
468                     match = idMatcher.find();
469                 }
470             }
471             return match;
472         }
473 
474         /**
475          * Checks whether the {@link AuditEvent} message matches the message format.
476          *
477          * @param event {@link AuditEvent} instance.
478          * @return true if the {@link AuditEvent} message matches the message format.
479          */
480         private boolean isMessageMatch(AuditEvent event) {
481             boolean match = true;
482             if (eventMessageRegexp != null) {
483                 final Matcher messageMatcher = eventMessageRegexp.matcher(event.getMessage());
484                 match = messageMatcher.find();
485             }
486             return match;
487         }
488     }
489 
490 }