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