001///////////////////////////////////////////////////////////////////////////////////////////////
002// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
003// Copyright (C) 2001-2026 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.IOException;
023import java.nio.charset.StandardCharsets;
024import java.nio.file.Files;
025import java.nio.file.Path;
026import java.util.ArrayList;
027import java.util.Collection;
028import java.util.Objects;
029import java.util.Optional;
030import java.util.regex.Matcher;
031import java.util.regex.Pattern;
032import java.util.regex.PatternSyntaxException;
033
034import com.puppycrawl.tools.checkstyle.AbstractAutomaticBean;
035import com.puppycrawl.tools.checkstyle.PropertyType;
036import com.puppycrawl.tools.checkstyle.XdocsPropertyType;
037import com.puppycrawl.tools.checkstyle.api.AuditEvent;
038import com.puppycrawl.tools.checkstyle.api.FileText;
039import com.puppycrawl.tools.checkstyle.api.Filter;
040import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
041
042/**
043 * <div>
044 * Filter {@code SuppressWithPlainTextCommentFilter} uses plain text to suppress
045 * audit events. The filter can be used only to suppress audit events received
046 * from the checks which implement FileSetCheck interface. In other words, the
047 * checks which have Checker as a parent module. The filter knows nothing about
048 * AST, it treats only plain text comments and extracts the information required
049 * for suppression from the plain text comments. Currently, the filter supports
050 * only single-line comments.
051 * </div>
052 *
053 * <p>
054 * Please, be aware of the fact that, it is not recommended to use the filter
055 * for Java code anymore, however you still are able to use it to suppress audit
056 * events received from the checks which implement FileSetCheck interface.
057 * </p>
058 *
059 * <p>
060 * Rationale: Sometimes there are legitimate reasons for violating a check.
061 * When this is a matter of the code in question and not personal preference,
062 * the best place to override the policy is in the code itself. Semi-structured
063 * comments can be associated with the check. This is sometimes superior to
064 * a separate suppressions file, which must be kept up-to-date as the source
065 * file is edited.
066 * </p>
067 *
068 * <p>
069 * Note that the suppression comment should be put before the violation.
070 * You can use more than one suppression comment each on separate line.
071 * </p>
072 *
073 * <p>
074 * Notes:
075 * Properties {@code offCommentFormat} and {@code onCommentFormat} must have equal
076 * <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/regex/Matcher.html#groupCount()">
077 * paren counts</a>.
078 * </p>
079 *
080 * <p>
081 * SuppressionWithPlainTextCommentFilter can suppress Checks that have Treewalker or
082 * Checker as parent module.
083 * </p>
084 *
085 * @since 8.6
086 */
087public class SuppressWithPlainTextCommentFilter extends AbstractAutomaticBean implements Filter {
088
089    /** Comment format which turns checkstyle reporting off. */
090    private static final String DEFAULT_OFF_FORMAT = "// CHECKSTYLE:OFF";
091
092    /** Comment format which turns checkstyle reporting on. */
093    private static final String DEFAULT_ON_FORMAT = "// CHECKSTYLE:ON";
094
095    /** Default check format to suppress. By default, the filter suppress all checks. */
096    private static final String DEFAULT_CHECK_FORMAT = ".*";
097
098    /** List of suppressions from the file. By default, Its null. */
099    private final Collection<Suppression> currentFileSuppressionCache = new ArrayList<>();
100
101    /** File name that was suppressed. By default, Its empty. */
102    private String currentFileName = "";
103
104    /** Specify comment pattern to trigger filter to begin suppression. */
105    private Pattern offCommentFormat = CommonUtil.createPattern(DEFAULT_OFF_FORMAT);
106
107    /** Specify comment pattern to trigger filter to end suppression. */
108    private Pattern onCommentFormat = CommonUtil.createPattern(DEFAULT_ON_FORMAT);
109
110    /** Specify check pattern to suppress. */
111    @XdocsPropertyType(PropertyType.PATTERN)
112    private String checkFormat = DEFAULT_CHECK_FORMAT;
113
114    /** Specify message pattern to suppress. */
115    @XdocsPropertyType(PropertyType.PATTERN)
116    private String messageFormat;
117
118    /** Specify check ID pattern to suppress. */
119    @XdocsPropertyType(PropertyType.PATTERN)
120    private String idFormat;
121
122    /**
123     * Setter to specify comment pattern to trigger filter to begin suppression.
124     *
125     * @param pattern off comment format pattern.
126     * @since 8.6
127     */
128    public final void setOffCommentFormat(Pattern pattern) {
129        offCommentFormat = pattern;
130    }
131
132    /**
133     * Setter to specify comment pattern to trigger filter to end suppression.
134     *
135     * @param pattern  on comment format pattern.
136     * @since 8.6
137     */
138    public final void setOnCommentFormat(Pattern pattern) {
139        onCommentFormat = pattern;
140    }
141
142    /**
143     * Setter to specify check pattern to suppress.
144     *
145     * @param format pattern for check format.
146     * @since 8.6
147     */
148    public final void setCheckFormat(String format) {
149        checkFormat = format;
150    }
151
152    /**
153     * Setter to specify message pattern to suppress.
154     *
155     * @param format pattern for message format.
156     * @since 8.6
157     */
158    public final void setMessageFormat(String format) {
159        messageFormat = format;
160    }
161
162    /**
163     * Setter to specify check ID pattern to suppress.
164     *
165     * @param format pattern for check ID format
166     * @since 8.24
167     */
168    public final void setIdFormat(String format) {
169        idFormat = format;
170    }
171
172    @Override
173    public boolean accept(AuditEvent event) {
174        boolean accepted = true;
175        if (event.getViolation() != null) {
176            final String eventFileName = event.getFileName();
177
178            if (!currentFileName.equals(eventFileName)) {
179                currentFileName = eventFileName;
180                final FileText fileText = getFileText(eventFileName);
181                currentFileSuppressionCache.clear();
182                if (fileText != null) {
183                    cacheSuppressions(fileText);
184                }
185            }
186
187            accepted = getNearestSuppression(currentFileSuppressionCache, event) == null;
188        }
189        return accepted;
190    }
191
192    @Override
193    protected void finishLocalSetup() {
194        // No code by default
195    }
196
197    /**
198     * Caches {@link FileText} instance created based on the given file name.
199     *
200     * @param fileName the name of the file.
201     * @return {@link FileText} instance.
202     * @throws IllegalStateException if the file could not be read.
203     */
204    private static FileText getFileText(String fileName) {
205        final Path path = Path.of(fileName);
206        FileText result = null;
207
208        // some violations can be on a directory, instead of a file
209        if (!Files.isDirectory(path)) {
210            try {
211                result = new FileText(path.toFile(), StandardCharsets.UTF_8.name());
212            }
213            catch (IOException exc) {
214                throw new IllegalStateException("Cannot read source file: " + fileName, exc);
215            }
216        }
217
218        return result;
219    }
220
221    /**
222     * Collects the list of {@link Suppression} instances retrieved from the given {@link FileText}.
223     *
224     * @param fileText {@link FileText} instance.
225     */
226    private void cacheSuppressions(FileText fileText) {
227        for (int lineNo = 0; lineNo < fileText.size(); lineNo++) {
228            final Optional<Suppression> suppression = getSuppression(fileText, lineNo);
229            suppression.ifPresent(currentFileSuppressionCache::add);
230        }
231    }
232
233    /**
234     * Tries to extract the suppression from the given line.
235     *
236     * @param fileText {@link FileText} instance.
237     * @param lineNo line number.
238     * @return {@link Optional} of {@link Suppression}.
239     */
240    private Optional<Suppression> getSuppression(FileText fileText, int lineNo) {
241        final String line = fileText.get(lineNo);
242        final Matcher onCommentMatcher = onCommentFormat.matcher(line);
243        final Matcher offCommentMatcher = offCommentFormat.matcher(line);
244
245        Suppression suppression = null;
246        if (onCommentMatcher.find()) {
247            suppression = new Suppression(onCommentMatcher.group(0),
248                lineNo + 1, SuppressionType.ON, this);
249        }
250        if (offCommentMatcher.find()) {
251            suppression = new Suppression(offCommentMatcher.group(0),
252                lineNo + 1, SuppressionType.OFF, this);
253        }
254
255        return Optional.ofNullable(suppression);
256    }
257
258    /**
259     * Finds the nearest {@link Suppression} instance which can suppress
260     * the given {@link AuditEvent}. The nearest suppression is the suppression which scope
261     * is before the line and column of the event.
262     *
263     * @param suppressions collection of {@link Suppression} instances.
264     * @param event {@link AuditEvent} instance.
265     * @return {@link Suppression} instance.
266     */
267    private static Suppression getNearestSuppression(Collection<Suppression> suppressions,
268                                                     AuditEvent event) {
269        return suppressions
270            .stream()
271            .filter(suppression -> suppression.isMatch(event))
272            .reduce((first, second) -> second)
273            .filter(suppression -> suppression.suppressionType != SuppressionType.ON)
274            .orElse(null);
275    }
276
277    /** Enum which represents the type of the suppression. */
278    private enum SuppressionType {
279
280        /** On suppression type. */
281        ON,
282        /** Off suppression type. */
283        OFF,
284
285    }
286
287    /** The class which represents the suppression. */
288    private static final class Suppression {
289
290        /** The regexp which is used to match the event source.*/
291        private final Pattern eventSourceRegexp;
292        /** The regexp which is used to match the event message.*/
293        private final Pattern eventMessageRegexp;
294        /** The regexp which is used to match the event ID.*/
295        private final Pattern eventIdRegexp;
296
297        /** Suppression line.*/
298        private final int lineNo;
299
300        /** Suppression type. */
301        private final SuppressionType suppressionType;
302
303        /**
304         * Creates new suppression instance.
305         *
306         * @param text suppression text.
307         * @param lineNo suppression line number.
308         * @param suppressionType suppression type.
309         * @param filter the {@link SuppressWithPlainTextCommentFilter} with the context.
310         * @throws IllegalArgumentException if there is an error in the filter regex syntax.
311         */
312        private Suppression(
313            String text,
314            int lineNo,
315            SuppressionType suppressionType,
316            SuppressWithPlainTextCommentFilter filter
317        ) {
318            this.lineNo = lineNo;
319            this.suppressionType = suppressionType;
320
321            final Pattern commentFormat;
322            if (this.suppressionType == SuppressionType.ON) {
323                commentFormat = filter.onCommentFormat;
324            }
325            else {
326                commentFormat = filter.offCommentFormat;
327            }
328
329            // Expand regexp for check and message
330            // Does not intern Patterns with Utils.getPattern()
331            String format = "";
332            try {
333                format = CommonUtil.fillTemplateWithStringsByRegexp(
334                        filter.checkFormat, text, commentFormat);
335                eventSourceRegexp = Pattern.compile(format);
336                if (filter.messageFormat == null) {
337                    eventMessageRegexp = null;
338                }
339                else {
340                    format = CommonUtil.fillTemplateWithStringsByRegexp(
341                            filter.messageFormat, text, commentFormat);
342                    eventMessageRegexp = Pattern.compile(format);
343                }
344                if (filter.idFormat == null) {
345                    eventIdRegexp = null;
346                }
347                else {
348                    format = CommonUtil.fillTemplateWithStringsByRegexp(
349                            filter.idFormat, text, commentFormat);
350                    eventIdRegexp = Pattern.compile(format);
351                }
352            }
353            catch (final PatternSyntaxException exc) {
354                throw new IllegalArgumentException(
355                    "unable to parse expanded comment " + format, exc);
356            }
357        }
358
359        /**
360         * Indicates whether some other object is "equal to" this one.
361         *
362         * @noinspection EqualsCalledOnEnumConstant
363         * @noinspectionreason EqualsCalledOnEnumConstant - enumeration is needed to keep
364         *      code consistent
365         */
366        @Override
367        public boolean equals(Object other) {
368            if (this == other) {
369                return true;
370            }
371            if (other == null || getClass() != other.getClass()) {
372                return false;
373            }
374            final Suppression suppression = (Suppression) other;
375            return Objects.equals(lineNo, suppression.lineNo)
376                    && Objects.equals(suppressionType, suppression.suppressionType)
377                    && Objects.equals(eventSourceRegexp, suppression.eventSourceRegexp)
378                    && Objects.equals(eventMessageRegexp, suppression.eventMessageRegexp)
379                    && Objects.equals(eventIdRegexp, suppression.eventIdRegexp);
380        }
381
382        @Override
383        public int hashCode() {
384            return Objects.hash(
385                lineNo, suppressionType, eventSourceRegexp, eventMessageRegexp,
386                eventIdRegexp);
387        }
388
389        /**
390         * Checks whether the suppression matches the given {@link AuditEvent}.
391         *
392         * @param event {@link AuditEvent} instance.
393         * @return true if the suppression matches {@link AuditEvent}.
394         */
395        private boolean isMatch(AuditEvent event) {
396            return isInScopeOfSuppression(event)
397                    && isCheckMatch(event)
398                    && isIdMatch(event)
399                    && isMessageMatch(event);
400        }
401
402        /**
403         * Checks whether {@link AuditEvent} is in the scope of the suppression.
404         *
405         * @param event {@link AuditEvent} instance.
406         * @return true if {@link AuditEvent} is in the scope of the suppression.
407         */
408        private boolean isInScopeOfSuppression(AuditEvent event) {
409            return lineNo <= event.getLine();
410        }
411
412        /**
413         * Checks whether {@link AuditEvent} source name matches the check format.
414         *
415         * @param event {@link AuditEvent} instance.
416         * @return true if the {@link AuditEvent} source name matches the check format.
417         */
418        private boolean isCheckMatch(AuditEvent event) {
419            final Matcher checkMatcher = eventSourceRegexp.matcher(event.getSourceName());
420            return checkMatcher.find();
421        }
422
423        /**
424         * Checks whether the {@link AuditEvent} module ID matches the ID format.
425         *
426         * @param event {@link AuditEvent} instance.
427         * @return true if the {@link AuditEvent} module ID matches the ID format.
428         */
429        private boolean isIdMatch(AuditEvent event) {
430            boolean match = true;
431            if (eventIdRegexp != null) {
432                if (event.getModuleId() == null) {
433                    match = false;
434                }
435                else {
436                    final Matcher idMatcher = eventIdRegexp.matcher(event.getModuleId());
437                    match = idMatcher.find();
438                }
439            }
440            return match;
441        }
442
443        /**
444         * Checks whether the {@link AuditEvent} message matches the message format.
445         *
446         * @param event {@link AuditEvent} instance.
447         * @return true if the {@link AuditEvent} message matches the message format.
448         */
449        private boolean isMessageMatch(AuditEvent event) {
450            boolean match = true;
451            if (eventMessageRegexp != null) {
452                final Matcher messageMatcher = eventMessageRegexp.matcher(event.getMessage());
453                match = messageMatcher.find();
454            }
455            return match;
456        }
457    }
458
459}