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