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