SuppressWithNearbyTextFilter.java

///////////////////////////////////////////////////////////////////////////////////////////////
// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
// Copyright (C) 2001-2024 the original author or authors.
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
///////////////////////////////////////////////////////////////////////////////////////////////

package com.puppycrawl.tools.checkstyle.filters;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;

import com.puppycrawl.tools.checkstyle.AbstractAutomaticBean;
import com.puppycrawl.tools.checkstyle.PropertyType;
import com.puppycrawl.tools.checkstyle.XdocsPropertyType;
import com.puppycrawl.tools.checkstyle.api.AuditEvent;
import com.puppycrawl.tools.checkstyle.api.FileText;
import com.puppycrawl.tools.checkstyle.api.Filter;
import com.puppycrawl.tools.checkstyle.utils.CommonUtil;

/**
 * <p>
 * Filter {@code SuppressWithNearbyTextFilter} uses plain text to suppress
 * nearby audit events. The filter can suppress all checks which have Checker as a parent module.
 * </p>
 * <p>
 * Setting {@code .*} value to {@code nearbyTextPattern} property will see <b>any</b>
 * text as a suppression and will likely suppress all audit events in the file. It is
 * best to set this to a key phrase not commonly used in the file to help denote it
 * out of the rest of the file as a suppression. See the default value as an example.
 * </p>
 * <ul>
 * <li>
 * Property {@code checkPattern} - Specify check name pattern to suppress.
 * Property can also be a RegExp group index at {@code nearbyTextPattern} in
 * format of {@code $x} and be picked from line that matches {@code nearbyTextPattern}.
 * Type is {@code java.util.regex.Pattern}.
 * Default value is {@code ".*"}.
 * </li>
 * <li>
 * Property {@code idPattern} - Specify check ID pattern to suppress.
 * Type is {@code java.util.regex.Pattern}.
 * Default value is {@code null}.
 * </li>
 * <li>
 * Property {@code lineRange} - Specify negative/zero/positive value that
 * defines the number of lines preceding/at/following the suppressing nearby text.
 * Property can also be a RegExp group index at {@code nearbyTextPattern} in
 * format of {@code $x} and be picked from line that matches {@code nearbyTextPattern}.
 * Type is {@code java.lang.String}.
 * Default value is {@code "0"}.
 * </li>
 * <li>
 * Property {@code messagePattern} - Specify check violation message pattern to suppress.
 * Type is {@code java.util.regex.Pattern}.
 * Default value is {@code null}.
 * </li>
 * <li>
 * Property {@code nearbyTextPattern} - Specify nearby text
 * pattern to trigger filter to begin suppression.
 * Type is {@code java.util.regex.Pattern}.
 * Default value is {@code "SUPPRESS CHECKSTYLE (\w+)"}.
 * </li>
 * </ul>
 * <p>
 * Parent is {@code com.puppycrawl.tools.checkstyle.Checker}
 * </p>
 *
 * @since 10.10.0
 */
public class SuppressWithNearbyTextFilter extends AbstractAutomaticBean implements Filter {

    /** Default nearby text pattern to turn check reporting off. */
    private static final String DEFAULT_NEARBY_TEXT_PATTERN = "SUPPRESS CHECKSTYLE (\\w+)";

    /** Default regex for checks that should be suppressed. */
    private static final String DEFAULT_CHECK_PATTERN = ".*";

    /** Default number of lines that should be suppressed. */
    private static final String DEFAULT_LINE_RANGE = "0";

    /** Suppressions encountered in current file. */
    private final List<Suppression> suppressions = new ArrayList<>();

    /** Specify nearby text pattern to trigger filter to begin suppression. */
    @XdocsPropertyType(PropertyType.PATTERN)
    private Pattern nearbyTextPattern = Pattern.compile(DEFAULT_NEARBY_TEXT_PATTERN);

    /**
     * Specify check name pattern to suppress. Property can also be a RegExp group index
     * at {@code nearbyTextPattern} in format of {@code $x} and be picked from line that
     * matches {@code nearbyTextPattern}.
     */
    @XdocsPropertyType(PropertyType.PATTERN)
    private String checkPattern = DEFAULT_CHECK_PATTERN;

    /** Specify check violation message pattern to suppress. */
    @XdocsPropertyType(PropertyType.PATTERN)
    private String messagePattern;

    /** Specify check ID pattern to suppress. */
    @XdocsPropertyType(PropertyType.PATTERN)
    private String idPattern;

    /**
     * Specify negative/zero/positive value that defines the number of lines
     * preceding/at/following the suppressing nearby text. Property can also be a RegExp group
     * index at {@code nearbyTextPattern} in format of {@code $x} and be picked
     * from line that matches {@code nearbyTextPattern}.
     */
    private String lineRange = DEFAULT_LINE_RANGE;

    /** The absolute path to the currently processed file. */
    private String cachedFileAbsolutePath = "";

    /**
     * Setter to specify nearby text pattern to trigger filter to begin suppression.
     *
     * @param pattern a {@code Pattern} value.
     * @since 10.10.0
     */
    public final void setNearbyTextPattern(Pattern pattern) {
        nearbyTextPattern = pattern;
    }

    /**
     * Setter to specify check name pattern to suppress. Property can also
     * be a RegExp group index at {@code nearbyTextPattern} in
     * format of {@code $x} and be picked from line that matches {@code nearbyTextPattern}.
     *
     * @param pattern a {@code String} value.
     * @since 10.10.0
     */
    public final void setCheckPattern(String pattern) {
        checkPattern = pattern;
    }

    /**
     * Setter to specify check violation message pattern to suppress.
     *
     * @param pattern a {@code String} value.
     * @since 10.10.0
     */
    public void setMessagePattern(String pattern) {
        messagePattern = pattern;
    }

    /**
     * Setter to specify check ID pattern to suppress.
     *
     * @param pattern a {@code String} value.
     * @since 10.10.0
     */
    public void setIdPattern(String pattern) {
        idPattern = pattern;
    }

    /**
     * Setter to specify negative/zero/positive value that defines the number
     * of lines preceding/at/following the suppressing nearby text. Property can also
     * be a RegExp group index at {@code nearbyTextPattern} in
     * format of {@code $x} and be picked from line that matches {@code nearbyTextPattern}.
     *
     * @param format a {@code String} value.
     * @since 10.10.0
     */
    public final void setLineRange(String format) {
        lineRange = format;
    }

    @Override
    public boolean accept(AuditEvent event) {
        boolean accepted = true;

        if (event.getViolation() != null) {
            final String eventFileTextAbsolutePath = event.getFileName();

            if (!cachedFileAbsolutePath.equals(eventFileTextAbsolutePath)) {
                final FileText currentFileText = getFileText(eventFileTextAbsolutePath);

                if (currentFileText != null) {
                    cachedFileAbsolutePath = currentFileText.getFile().getAbsolutePath();
                    collectSuppressions(currentFileText);
                }
            }

            final Optional<Suppression> nearestSuppression =
                    getNearestSuppression(suppressions, event);
            accepted = nearestSuppression.isEmpty();
        }
        return accepted;
    }

    @Override
    protected void finishLocalSetup() {
        // No code by default
    }

    /**
     * Returns {@link FileText} instance created based on the given file name.
     *
     * @param fileName the name of the file.
     * @return {@link FileText} instance.
     * @throws IllegalStateException if the file could not be read.
     */
    private static FileText getFileText(String fileName) {
        final File file = new File(fileName);
        FileText result = null;

        // some violations can be on a directory, instead of a file
        if (!file.isDirectory()) {
            try {
                result = new FileText(file, StandardCharsets.UTF_8.name());
            }
            catch (IOException ex) {
                throw new IllegalStateException("Cannot read source file: " + fileName, ex);
            }
        }

        return result;
    }

    /**
     * Collets all {@link Suppression} instances retrieved from the given {@link FileText}.
     *
     * @param fileText {@link FileText} instance.
     */
    private void collectSuppressions(FileText fileText) {
        suppressions.clear();

        for (int lineNo = 0; lineNo < fileText.size(); lineNo++) {
            final Suppression suppression = getSuppression(fileText, lineNo);
            if (suppression != null) {
                suppressions.add(suppression);
            }
        }
    }

    /**
     * Tries to extract the suppression from the given line.
     *
     * @param fileText {@link FileText} instance.
     * @param lineNo line number.
     * @return {@link Suppression} instance.
     */
    private Suppression getSuppression(FileText fileText, int lineNo) {
        final String line = fileText.get(lineNo);
        final Matcher nearbyTextMatcher = nearbyTextPattern.matcher(line);

        Suppression suppression = null;
        if (nearbyTextMatcher.find()) {
            final String text = nearbyTextMatcher.group(0);
            suppression = new Suppression(text, lineNo + 1, this);
        }

        return suppression;
    }

    /**
     * Finds the nearest {@link Suppression} instance which can suppress
     * the given {@link AuditEvent}. The nearest suppression is the suppression which scope
     * is before the line and column of the event.
     *
     * @param suppressions collection of {@link Suppression} instances.
     * @param event {@link AuditEvent} instance.
     * @return {@link Suppression} instance.
     */
    private static Optional<Suppression> getNearestSuppression(Collection<Suppression> suppressions,
                                                               AuditEvent event) {
        return suppressions
                .stream()
                .filter(suppression -> suppression.isMatch(event))
                .findFirst();
    }

    /** The class which represents the suppression. */
    private static final class Suppression {

        /** The first line where warnings may be suppressed. */
        private final int firstLine;

        /** The last line where warnings may be suppressed. */
        private final int lastLine;

        /** The regexp which is used to match the event source.*/
        private final Pattern eventSourceRegexp;

        /** The regexp which is used to match the event message.*/
        private Pattern eventMessageRegexp;

        /** The regexp which is used to match the event ID.*/
        private Pattern eventIdRegexp;

        /**
         * Constructs new {@code Suppression} instance.
         *
         * @param text suppression text.
         * @param lineNo suppression line number.
         * @param filter the {@code SuppressWithNearbyTextFilter} with the context.
         * @throws IllegalArgumentException if there is an error in the filter regex syntax.
         */
        private Suppression(
                String text,
                int lineNo,
                SuppressWithNearbyTextFilter filter
        ) {
            final Pattern nearbyTextPattern = filter.nearbyTextPattern;
            final String lineRange = filter.lineRange;
            String format = "";
            try {
                format = CommonUtil.fillTemplateWithStringsByRegexp(
                        filter.checkPattern, text, nearbyTextPattern);
                eventSourceRegexp = Pattern.compile(format);
                if (filter.messagePattern != null) {
                    format = CommonUtil.fillTemplateWithStringsByRegexp(
                            filter.messagePattern, text, nearbyTextPattern);
                    eventMessageRegexp = Pattern.compile(format);
                }
                if (filter.idPattern != null) {
                    format = CommonUtil.fillTemplateWithStringsByRegexp(
                            filter.idPattern, text, nearbyTextPattern);
                    eventIdRegexp = Pattern.compile(format);
                }
                format = CommonUtil.fillTemplateWithStringsByRegexp(lineRange,
                                                                    text, nearbyTextPattern);

                final int range = parseRange(format, lineRange, text);

                firstLine = Math.min(lineNo, lineNo + range);
                lastLine = Math.max(lineNo, lineNo + range);
            }
            catch (final PatternSyntaxException ex) {
                throw new IllegalArgumentException(
                    "unable to parse expanded comment " + format, ex);
            }
        }

        /**
         * Gets range from suppress filter range format param.
         *
         * @param format range format to parse
         * @param lineRange raw line range
         * @param text text of the suppression
         * @return parsed range
         * @throws IllegalArgumentException when unable to parse int in format
         */
        private static int parseRange(String format, String lineRange, String text) {
            try {
                return Integer.parseInt(format);
            }
            catch (final NumberFormatException ex) {
                throw new IllegalArgumentException("unable to parse line range from '" + text
                        + "' using " + lineRange, ex);
            }
        }

        /**
         * Determines whether the source of an audit event
         * matches the text of this suppression.
         *
         * @param event the {@code AuditEvent} to check.
         * @return true if the source of event matches the text of this suppression.
         */
        private boolean isMatch(AuditEvent event) {
            return isInScopeOfSuppression(event)
                    && isCheckMatch(event)
                    && isIdMatch(event)
                    && isMessageMatch(event);
        }

        /**
         * Checks whether the {@link AuditEvent} is in the scope of the suppression.
         *
         * @param event {@link AuditEvent} instance.
         * @return true if the {@link AuditEvent} is in the scope of the suppression.
         */
        private boolean isInScopeOfSuppression(AuditEvent event) {
            final int eventLine = event.getLine();
            return eventLine >= firstLine && eventLine <= lastLine;
        }

        /**
         * Checks whether {@link AuditEvent} source name matches the check pattern.
         *
         * @param event {@link AuditEvent} instance.
         * @return true if the {@link AuditEvent} source name matches the check pattern.
         */
        private boolean isCheckMatch(AuditEvent event) {
            final Matcher checkMatcher = eventSourceRegexp.matcher(event.getSourceName());
            return checkMatcher.find();
        }

        /**
         * Checks whether the {@link AuditEvent} module ID matches the ID pattern.
         *
         * @param event {@link AuditEvent} instance.
         * @return true if the {@link AuditEvent} module ID matches the ID pattern.
         */
        private boolean isIdMatch(AuditEvent event) {
            boolean match = true;
            if (eventIdRegexp != null) {
                if (event.getModuleId() == null) {
                    match = false;
                }
                else {
                    final Matcher idMatcher = eventIdRegexp.matcher(event.getModuleId());
                    match = idMatcher.find();
                }
            }
            return match;
        }

        /**
         * Checks whether the {@link AuditEvent} message matches the message pattern.
         *
         * @param event {@link AuditEvent} instance.
         * @return true if the {@link AuditEvent} message matches the message pattern.
         */
        private boolean isMessageMatch(AuditEvent event) {
            boolean match = true;
            if (eventMessageRegexp != null) {
                final Matcher messageMatcher = eventMessageRegexp.matcher(event.getMessage());
                match = messageMatcher.find();
            }
            return match;
        }
    }
}