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.List;
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 SuppressWithNearbyTextFilter} uses plain text to suppress
045 * nearby audit events. The filter can suppress all checks which have Checker as a parent module.
046 * </div>
047 *
048 * <p>
049 * Notes:
050 * Setting {@code .*} value to {@code nearbyTextPattern} property will see <b>any</b>
051 * text as a suppression and will likely suppress all audit events in the file. It is
052 * best to set this to a key phrase not commonly used in the file to help denote it
053 * out of the rest of the file as a suppression. See the default value as an example.
054 * </p>
055 *
056 * @since 10.10.0
057 */
058public class SuppressWithNearbyTextFilter extends AbstractAutomaticBean implements Filter {
059
060    /** Default nearby text pattern to turn check reporting off. */
061    private static final String DEFAULT_NEARBY_TEXT_PATTERN = "SUPPRESS CHECKSTYLE (\\w+)";
062
063    /** Default regex for checks that should be suppressed. */
064    private static final String DEFAULT_CHECK_PATTERN = ".*";
065
066    /** Default number of lines that should be suppressed. */
067    private static final String DEFAULT_LINE_RANGE = "0";
068
069    /** Suppressions encountered in current file. */
070    private final List<Suppression> suppressions = new ArrayList<>();
071
072    /** Specify nearby text pattern to trigger filter to begin suppression. */
073    @XdocsPropertyType(PropertyType.PATTERN)
074    private Pattern nearbyTextPattern = Pattern.compile(DEFAULT_NEARBY_TEXT_PATTERN);
075
076    /**
077     * Specify check name pattern to suppress. Property can also be a RegExp group index
078     * at {@code nearbyTextPattern} in format of {@code $x} and be picked from line that
079     * matches {@code nearbyTextPattern}.
080     */
081    @XdocsPropertyType(PropertyType.PATTERN)
082    private String checkPattern = DEFAULT_CHECK_PATTERN;
083
084    /** Specify check violation message pattern to suppress. */
085    @XdocsPropertyType(PropertyType.PATTERN)
086    private String messagePattern;
087
088    /** Specify check ID pattern to suppress. */
089    @XdocsPropertyType(PropertyType.PATTERN)
090    private String idPattern;
091
092    /**
093     * Specify negative/zero/positive value that defines the number of lines
094     * preceding/at/following the suppressing nearby text. Property can also be a RegExp group
095     * index at {@code nearbyTextPattern} in format of {@code $x} and be picked
096     * from line that matches {@code nearbyTextPattern}.
097     */
098    private String lineRange = DEFAULT_LINE_RANGE;
099
100    /** The absolute path to the currently processed file. */
101    private String cachedFileAbsolutePath = "";
102
103    /**
104     * Setter to specify nearby text pattern to trigger filter to begin suppression.
105     *
106     * @param pattern a {@code Pattern} value.
107     * @since 10.10.0
108     */
109    public final void setNearbyTextPattern(Pattern pattern) {
110        nearbyTextPattern = pattern;
111    }
112
113    /**
114     * Setter to specify check name pattern to suppress. Property can also
115     * be a RegExp group index at {@code nearbyTextPattern} in
116     * format of {@code $x} and be picked from line that matches {@code nearbyTextPattern}.
117     * The pattern is matched against the fully qualified class name of the Check.
118     *
119     * @param pattern a {@code String} value.
120     * @since 10.10.0
121     */
122    public final void setCheckPattern(String pattern) {
123        checkPattern = pattern;
124    }
125
126    /**
127     * Setter to specify check violation message pattern to suppress.
128     *
129     * @param pattern a {@code String} value.
130     * @since 10.10.0
131     */
132    public void setMessagePattern(String pattern) {
133        messagePattern = pattern;
134    }
135
136    /**
137     * Setter to specify check ID pattern to suppress.
138     *
139     * @param pattern a {@code String} value.
140     * @since 10.10.0
141     */
142    public void setIdPattern(String pattern) {
143        idPattern = pattern;
144    }
145
146    /**
147     * Setter to specify negative/zero/positive value that defines the number
148     * of lines preceding/at/following the suppressing nearby text. Property can also
149     * be a RegExp group index at {@code nearbyTextPattern} in
150     * format of {@code $x} and be picked from line that matches {@code nearbyTextPattern}.
151     *
152     * @param format a {@code String} value.
153     * @since 10.10.0
154     */
155    public final void setLineRange(String format) {
156        lineRange = format;
157    }
158
159    @Override
160    public boolean accept(AuditEvent event) {
161        boolean accepted = true;
162
163        if (event.getViolation() != null) {
164            final String eventFileTextAbsolutePath = event.getFileName();
165
166            if (!cachedFileAbsolutePath.equals(eventFileTextAbsolutePath)) {
167                final FileText currentFileText = getFileText(eventFileTextAbsolutePath);
168
169                if (currentFileText != null) {
170                    cachedFileAbsolutePath = currentFileText.getFile().getAbsolutePath();
171                    collectSuppressions(currentFileText);
172                }
173            }
174
175            final Optional<Suppression> nearestSuppression =
176                    getNearestSuppression(suppressions, event);
177            accepted = nearestSuppression.isEmpty();
178        }
179        return accepted;
180    }
181
182    @Override
183    protected void finishLocalSetup() {
184        // No code by default
185    }
186
187    /**
188     * Returns {@link FileText} instance created based on the given file name.
189     *
190     * @param fileName the name of the file.
191     * @return {@link FileText} instance.
192     * @throws IllegalStateException if the file could not be read.
193     */
194    private static FileText getFileText(String fileName) {
195        final Path path = Path.of(fileName);
196        FileText result = null;
197
198        // some violations can be on a directory, instead of a file
199        if (!Files.isDirectory(path)) {
200            try {
201                result = new FileText(path.toFile(), StandardCharsets.UTF_8.name());
202            }
203            catch (IOException exc) {
204                throw new IllegalStateException("Cannot read source file: " + fileName, exc);
205            }
206        }
207
208        return result;
209    }
210
211    /**
212     * Collets all {@link Suppression} instances retrieved from the given {@link FileText}.
213     *
214     * @param fileText {@link FileText} instance.
215     */
216    private void collectSuppressions(FileText fileText) {
217        suppressions.clear();
218
219        for (int lineNo = 0; lineNo < fileText.size(); lineNo++) {
220            final Suppression suppression = getSuppression(fileText, lineNo);
221            if (suppression != null) {
222                suppressions.add(suppression);
223            }
224        }
225    }
226
227    /**
228     * Tries to extract the suppression from the given line.
229     *
230     * @param fileText {@link FileText} instance.
231     * @param lineNo line number.
232     * @return {@link Suppression} instance.
233     */
234    private Suppression getSuppression(FileText fileText, int lineNo) {
235        final String line = fileText.get(lineNo);
236        final Matcher nearbyTextMatcher = nearbyTextPattern.matcher(line);
237
238        Suppression suppression = null;
239        if (nearbyTextMatcher.find()) {
240            final String text = nearbyTextMatcher.group(0);
241            suppression = new Suppression(text, lineNo + 1, this);
242        }
243
244        return suppression;
245    }
246
247    /**
248     * Finds the nearest {@link Suppression} instance which can suppress
249     * the given {@link AuditEvent}. The nearest suppression is the suppression which scope
250     * is before the line and column of the event.
251     *
252     * @param suppressions collection of {@link Suppression} instances.
253     * @param event {@link AuditEvent} instance.
254     * @return {@link Suppression} instance.
255     */
256    private static Optional<Suppression> getNearestSuppression(Collection<Suppression> suppressions,
257                                                               AuditEvent event) {
258        return suppressions
259                .stream()
260                .filter(suppression -> suppression.isMatch(event))
261                .findFirst();
262    }
263
264    /** The class which represents the suppression. */
265    private static final class Suppression {
266
267        /** The first line where warnings may be suppressed. */
268        private final int firstLine;
269
270        /** The last line where warnings may be suppressed. */
271        private final int lastLine;
272
273        /** The regexp which is used to match the event source.*/
274        private final Pattern eventSourceRegexp;
275
276        /** The regexp which is used to match the event message.*/
277        private Pattern eventMessageRegexp;
278
279        /** The regexp which is used to match the event ID.*/
280        private Pattern eventIdRegexp;
281
282        /**
283         * Constructs new {@code Suppression} instance.
284         *
285         * @param text suppression text.
286         * @param lineNo suppression line number.
287         * @param filter the {@code SuppressWithNearbyTextFilter} with the context.
288         * @throws IllegalArgumentException if there is an error in the filter regex syntax.
289         */
290        private Suppression(
291                String text,
292                int lineNo,
293                SuppressWithNearbyTextFilter filter
294        ) {
295            final Pattern nearbyTextPattern = filter.nearbyTextPattern;
296            final String lineRange = filter.lineRange;
297            String format = "";
298            try {
299                format = CommonUtil.fillTemplateWithStringsByRegexp(
300                        filter.checkPattern, text, nearbyTextPattern);
301                eventSourceRegexp = Pattern.compile(format);
302                if (filter.messagePattern != null) {
303                    format = CommonUtil.fillTemplateWithStringsByRegexp(
304                            filter.messagePattern, text, nearbyTextPattern);
305                    eventMessageRegexp = Pattern.compile(format);
306                }
307                if (filter.idPattern != null) {
308                    format = CommonUtil.fillTemplateWithStringsByRegexp(
309                            filter.idPattern, text, nearbyTextPattern);
310                    eventIdRegexp = Pattern.compile(format);
311                }
312                format = CommonUtil.fillTemplateWithStringsByRegexp(lineRange,
313                                                                    text, nearbyTextPattern);
314
315                final int range = parseRange(format, lineRange, text);
316
317                firstLine = Math.min(lineNo, lineNo + range);
318                lastLine = Math.max(lineNo, lineNo + range);
319            }
320            catch (final PatternSyntaxException exc) {
321                throw new IllegalArgumentException(
322                    "unable to parse expanded comment " + format, exc);
323            }
324        }
325
326        /**
327         * Gets range from suppress filter range format param.
328         *
329         * @param format range format to parse
330         * @param lineRange raw line range
331         * @param text text of the suppression
332         * @return parsed range
333         * @throws IllegalArgumentException when unable to parse int in format
334         */
335        private static int parseRange(String format, String lineRange, String text) {
336            try {
337                return Integer.parseInt(format);
338            }
339            catch (final NumberFormatException exc) {
340                throw new IllegalArgumentException("unable to parse line range from '" + text
341                        + "' using " + lineRange, exc);
342            }
343        }
344
345        /**
346         * Determines whether the source of an audit event
347         * matches the text of this suppression.
348         *
349         * @param event the {@code AuditEvent} to check.
350         * @return true if the source of event matches the text of this suppression.
351         */
352        private boolean isMatch(AuditEvent event) {
353            return isInScopeOfSuppression(event)
354                    && isCheckMatch(event)
355                    && isIdMatch(event)
356                    && isMessageMatch(event);
357        }
358
359        /**
360         * Checks whether the {@link AuditEvent} is in the scope of the suppression.
361         *
362         * @param event {@link AuditEvent} instance.
363         * @return true if the {@link AuditEvent} is in the scope of the suppression.
364         */
365        private boolean isInScopeOfSuppression(AuditEvent event) {
366            final int eventLine = event.getLine();
367            return eventLine >= firstLine && eventLine <= lastLine;
368        }
369
370        /**
371         * Checks whether {@link AuditEvent} source name matches the check pattern.
372         *
373         * @param event {@link AuditEvent} instance.
374         * @return true if the {@link AuditEvent} source name matches the check pattern.
375         */
376        private boolean isCheckMatch(AuditEvent event) {
377            final Matcher checkMatcher = eventSourceRegexp.matcher(event.getSourceName());
378            return checkMatcher.find();
379        }
380
381        /**
382         * Checks whether the {@link AuditEvent} module ID matches the ID pattern.
383         *
384         * @param event {@link AuditEvent} instance.
385         * @return true if the {@link AuditEvent} module ID matches the ID pattern.
386         */
387        private boolean isIdMatch(AuditEvent event) {
388            boolean match = true;
389            if (eventIdRegexp != null) {
390                if (event.getModuleId() == null) {
391                    match = false;
392                }
393                else {
394                    final Matcher idMatcher = eventIdRegexp.matcher(event.getModuleId());
395                    match = idMatcher.find();
396                }
397            }
398            return match;
399        }
400
401        /**
402         * Checks whether the {@link AuditEvent} message matches the message pattern.
403         *
404         * @param event {@link AuditEvent} instance.
405         * @return true if the {@link AuditEvent} message matches the message pattern.
406         */
407        private boolean isMessageMatch(AuditEvent event) {
408            boolean match = true;
409            if (eventMessageRegexp != null) {
410                final Matcher messageMatcher = eventMessageRegexp.matcher(event.getMessage());
411                match = messageMatcher.find();
412            }
413            return match;
414        }
415    }
416}