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