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 knows nothing about AST, it treats only plain text
046 * comments and extracts the information required for suppression from the plain
047 * text comments. Currently, the filter supports only single-line comments.
048 * </div>
049 *
050 * <p>
051 * Please, be aware of the fact that, it is not recommended to use the filter
052 * for Java code anymore.
053 * </p>
054 *
055 * <p>
056 * Rationale: Sometimes there are legitimate reasons for violating a check.
057 * When this is a matter of the code in question and not personal preference,
058 * the best place to override the policy is in the code itself. Semi-structured
059 * comments can be associated with the check. This is sometimes superior to
060 * a separate suppressions file, which must be kept up-to-date as the source
061 * file is edited.
062 * </p>
063 *
064 * <p>
065 * Note that the suppression comment should be put before the violation.
066 * You can use more than one suppression comment each on separate line.
067 * </p>
068 *
069 * <p>
070 * Notes:
071 * Properties {@code offCommentFormat} and {@code onCommentFormat} must have equal
072 * <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/regex/Matcher.html#groupCount()">
073 * paren counts</a>.
074 * </p>
075 *
076 * <p>
077 * SuppressWithPlainTextCommentFilter can suppress Checks that have Treewalker or
078 * Checker as parent module.
079 * </p>
080 *
081 * @since 8.6
082 */
083public class SuppressWithPlainTextCommentFilter extends AbstractAutomaticBean implements Filter {
084
085    /** Comment format which turns checkstyle reporting off. */
086    private static final String DEFAULT_OFF_FORMAT = "// CHECKSTYLE:OFF";
087
088    /** Comment format which turns checkstyle reporting on. */
089    private static final String DEFAULT_ON_FORMAT = "// CHECKSTYLE:ON";
090
091    /** Default check format to suppress. By default, the filter suppress all checks. */
092    private static final String DEFAULT_CHECK_FORMAT = ".*";
093
094    /** List of suppressions from the file. By default, Its null. */
095    private final Collection<Suppression> currentFileSuppressionCache = new ArrayList<>();
096
097    /** File name that was suppressed. By default, Its empty. */
098    private String currentFileName = "";
099
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 {@link 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 {@link 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 {@link Suppression} instances.
268     * @param event {@link AuditEvent} instance.
269     * @return {@link 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 {@link AuditEvent} instance.
397         * @return true if the suppression matches {@link 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 {@link AuditEvent} instance.
410         * @return true if {@link 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 {@link AuditEvent} instance.
420         * @return true if the {@link 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 {@link AuditEvent} instance.
431         * @return true if the {@link 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 {@link AuditEvent} instance.
451         * @return true if the {@link 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}