001///////////////////////////////////////////////////////////////////////////////////////////////
002// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
003// Copyright (C) 2001-2025 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.File;
023import java.io.IOException;
024import java.nio.charset.StandardCharsets;
025import java.util.ArrayList;
026import java.util.Collection;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.regex.Matcher;
030import java.util.regex.Pattern;
031import java.util.regex.PatternSyntaxException;
032
033import com.puppycrawl.tools.checkstyle.AbstractAutomaticBean;
034import com.puppycrawl.tools.checkstyle.PropertyType;
035import com.puppycrawl.tools.checkstyle.XdocsPropertyType;
036import com.puppycrawl.tools.checkstyle.api.AuditEvent;
037import com.puppycrawl.tools.checkstyle.api.FileText;
038import com.puppycrawl.tools.checkstyle.api.Filter;
039import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
040
041/**
042 * <div>
043 * Filter {@code SuppressWithPlainTextCommentFilter} uses plain text to suppress
044 * audit events. The filter can be used only to suppress audit events received
045 * from the checks which implement FileSetCheck interface. In other words, the
046 * checks which have Checker as a parent module. The filter knows nothing about
047 * AST, it treats only plain text comments and extracts the information required
048 * for suppression from the plain text comments. Currently, the filter supports
049 * only single-line comments.
050 * </div>
051 *
052 * <p>
053 * Please, be aware of the fact that, it is not recommended to use the filter
054 * for Java code anymore, however you still are able to use it to suppress audit
055 * events received from the checks which implement FileSetCheck interface.
056 * </p>
057 *
058 * <p>
059 * Rationale: Sometimes there are legitimate reasons for violating a check.
060 * When this is a matter of the code in question and not personal preference,
061 * the best place to override the policy is in the code itself. Semi-structured
062 * comments can be associated with the check. This is sometimes superior to
063 * a separate suppressions file, which must be kept up-to-date as the source
064 * file is edited.
065 * </p>
066 *
067 * <p>
068 * Note that the suppression comment should be put before the violation.
069 * You can use more than one suppression comment each on separate line.
070 * </p>
071 *
072 * <p>
073 * Properties {@code offCommentFormat} and {@code onCommentFormat} must have equal
074 * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/regex/Matcher.html#groupCount()">
075 * paren counts</a>.
076 * </p>
077 *
078 * <p>
079 * SuppressionWithPlainTextCommentFilter can suppress Checks that have Treewalker or
080 * Checker as parent module.
081 * </p>
082 * <ul>
083 * <li>
084 * Property {@code checkFormat} - Specify check pattern to suppress.
085 * Type is {@code java.util.regex.Pattern}.
086 * Default value is {@code ".*"}.
087 * </li>
088 * <li>
089 * Property {@code idFormat} - Specify check ID pattern to suppress.
090 * Type is {@code java.util.regex.Pattern}.
091 * Default value is {@code null}.
092 * </li>
093 * <li>
094 * Property {@code messageFormat} - Specify message pattern to suppress.
095 * Type is {@code java.util.regex.Pattern}.
096 * Default value is {@code null}.
097 * </li>
098 * <li>
099 * 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 */
118public 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}