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