001///////////////////////////////////////////////////////////////////////////////////////////////
002// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
003// Copyright (C) 2001-2023 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, onCommentMatcher.start(), SuppressionType.ON, this);
265        }
266        if (offCommentMatcher.find()) {
267            suppression = new Suppression(offCommentMatcher.group(0),
268                lineNo + 1, offCommentMatcher.start(), 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 text.*/
314        private final String text;
315        /** Suppression line.*/
316        private final int lineNo;
317        /** Suppression column number.*/
318        private final int columnNo;
319        /** Suppression type. */
320        private final SuppressionType suppressionType;
321
322        /**
323         * Creates new suppression instance.
324         *
325         * @param text suppression text.
326         * @param lineNo suppression line number.
327         * @param columnNo suppression column number.
328         * @param suppressionType suppression type.
329         * @param filter the {@link SuppressWithPlainTextCommentFilter} with the context.
330         * @throws IllegalArgumentException if there is an error in the filter regex syntax.
331         */
332        private Suppression(
333            String text,
334            int lineNo,
335            int columnNo,
336            SuppressionType suppressionType,
337            SuppressWithPlainTextCommentFilter filter
338        ) {
339            this.text = text;
340            this.lineNo = lineNo;
341            this.columnNo = columnNo;
342            this.suppressionType = suppressionType;
343
344            final Pattern commentFormat;
345            if (this.suppressionType == SuppressionType.ON) {
346                commentFormat = filter.onCommentFormat;
347            }
348            else {
349                commentFormat = filter.offCommentFormat;
350            }
351
352            // Expand regexp for check and message
353            // Does not intern Patterns with Utils.getPattern()
354            String format = "";
355            try {
356                format = CommonUtil.fillTemplateWithStringsByRegexp(
357                        filter.checkFormat, text, commentFormat);
358                eventSourceRegexp = Pattern.compile(format);
359                if (filter.messageFormat == null) {
360                    eventMessageRegexp = null;
361                }
362                else {
363                    format = CommonUtil.fillTemplateWithStringsByRegexp(
364                            filter.messageFormat, text, commentFormat);
365                    eventMessageRegexp = Pattern.compile(format);
366                }
367                if (filter.idFormat == null) {
368                    eventIdRegexp = null;
369                }
370                else {
371                    format = CommonUtil.fillTemplateWithStringsByRegexp(
372                            filter.idFormat, text, commentFormat);
373                    eventIdRegexp = Pattern.compile(format);
374                }
375            }
376            catch (final PatternSyntaxException ex) {
377                throw new IllegalArgumentException(
378                    "unable to parse expanded comment " + format, ex);
379            }
380        }
381
382        /**
383         * Indicates whether some other object is "equal to" this one.
384         *
385         * @noinspection EqualsCalledOnEnumConstant
386         * @noinspectionreason EqualsCalledOnEnumConstant - enumeration is needed to keep
387         *      code consistent
388         */
389        @Override
390        public boolean equals(Object other) {
391            if (this == other) {
392                return true;
393            }
394            if (other == null || getClass() != other.getClass()) {
395                return false;
396            }
397            final Suppression suppression = (Suppression) other;
398            return Objects.equals(lineNo, suppression.lineNo)
399                    && Objects.equals(columnNo, suppression.columnNo)
400                    && Objects.equals(suppressionType, suppression.suppressionType)
401                    && Objects.equals(text, suppression.text)
402                    && Objects.equals(eventSourceRegexp, suppression.eventSourceRegexp)
403                    && Objects.equals(eventMessageRegexp, suppression.eventMessageRegexp)
404                    && Objects.equals(eventIdRegexp, suppression.eventIdRegexp);
405        }
406
407        @Override
408        public int hashCode() {
409            return Objects.hash(
410                text, lineNo, columnNo, suppressionType, eventSourceRegexp, eventMessageRegexp,
411                eventIdRegexp);
412        }
413
414        /**
415         * Checks whether the suppression matches the given {@link AuditEvent}.
416         *
417         * @param event {@link AuditEvent} instance.
418         * @return true if the suppression matches {@link AuditEvent}.
419         */
420        private boolean isMatch(AuditEvent event) {
421            return isInScopeOfSuppression(event)
422                    && isCheckMatch(event)
423                    && isIdMatch(event)
424                    && isMessageMatch(event);
425        }
426
427        /**
428         * Checks whether {@link AuditEvent} is in the scope of the suppression.
429         *
430         * @param event {@link AuditEvent} instance.
431         * @return true if {@link AuditEvent} is in the scope of the suppression.
432         */
433        private boolean isInScopeOfSuppression(AuditEvent event) {
434            return lineNo <= event.getLine();
435        }
436
437        /**
438         * Checks whether {@link AuditEvent} source name matches the check format.
439         *
440         * @param event {@link AuditEvent} instance.
441         * @return true if the {@link AuditEvent} source name matches the check format.
442         */
443        private boolean isCheckMatch(AuditEvent event) {
444            final Matcher checkMatcher = eventSourceRegexp.matcher(event.getSourceName());
445            return checkMatcher.find();
446        }
447
448        /**
449         * Checks whether the {@link AuditEvent} module ID matches the ID format.
450         *
451         * @param event {@link AuditEvent} instance.
452         * @return true if the {@link AuditEvent} module ID matches the ID format.
453         */
454        private boolean isIdMatch(AuditEvent event) {
455            boolean match = true;
456            if (eventIdRegexp != null) {
457                if (event.getModuleId() == null) {
458                    match = false;
459                }
460                else {
461                    final Matcher idMatcher = eventIdRegexp.matcher(event.getModuleId());
462                    match = idMatcher.find();
463                }
464            }
465            return match;
466        }
467
468        /**
469         * Checks whether the {@link AuditEvent} message matches the message format.
470         *
471         * @param event {@link AuditEvent} instance.
472         * @return true if the {@link AuditEvent} message matches the message format.
473         */
474        private boolean isMessageMatch(AuditEvent event) {
475            boolean match = true;
476            if (eventMessageRegexp != null) {
477                final Matcher messageMatcher = eventMessageRegexp.matcher(event.getMessage());
478                match = messageMatcher.find();
479            }
480            return match;
481        }
482    }
483
484}