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 * Notes:
074 * Properties {@code offCommentFormat} and {@code onCommentFormat} must have equal
075 * <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/regex/Matcher.html#groupCount()">
076 * paren counts</a>.
077 * </p>
078 *
079 * <p>
080 * SuppressionWithPlainTextCommentFilter can suppress Checks that have Treewalker or
081 * Checker as parent module.
082 * </p>
083 * <ul>
084 * <li>
085 * Property {@code checkFormat} - Specify check pattern to suppress.
086 * Type is {@code java.util.regex.Pattern}.
087 * Default value is {@code ".*"}.
088 * </li>
089 * <li>
090 * Property {@code idFormat} - Specify check ID pattern to suppress.
091 * Type is {@code java.util.regex.Pattern}.
092 * Default value is {@code null}.
093 * </li>
094 * <li>
095 * Property {@code messageFormat} - Specify message pattern to suppress.
096 * Type is {@code java.util.regex.Pattern}.
097 * Default value is {@code null}.
098 * </li>
099 * <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 */
119public 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}