MultiFileRegexpHeaderCheck.java

///////////////////////////////////////////////////////////////////////////////////////////////
// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
// Copyright (C) 2001-2025 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.checks.header;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.stream.Collectors;

import com.puppycrawl.tools.checkstyle.FileStatefulCheck;
import com.puppycrawl.tools.checkstyle.PropertyType;
import com.puppycrawl.tools.checkstyle.XdocsPropertyType;
import com.puppycrawl.tools.checkstyle.api.AbstractFileSetCheck;
import com.puppycrawl.tools.checkstyle.api.CheckstyleException;
import com.puppycrawl.tools.checkstyle.api.ExternalResourceHolder;
import com.puppycrawl.tools.checkstyle.api.FileText;
import com.puppycrawl.tools.checkstyle.utils.CommonUtil;

/**
 * <div>
 * Checks the header of a source file against multiple header files that contain a
 * <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/regex/Pattern.html">
 * pattern</a> for each line of the source header.
 * </div>
 * <ul>
 * <li>
 * Property {@code fileExtensions} - Specify the file extensions of the files to process.
 * Type is {@code java.lang.String[]}.
 * Default value is {@code ""}.
 * </li>
 * <li>
 * Property {@code headerFiles} - Specify a comma-separated list of files containing
 * the required headers. If a file's header matches none, the violation references
 * the first file in this list. Users can order files to set
 * a preferred header for such reporting.
 * Type is {@code java.lang.String}.
 * Default value is {@code null}.
 * </li>
 * </ul>
 *
 * <p>
 * Parent is {@code com.puppycrawl.tools.checkstyle.Checker}
 * </p>
 *
 * <p>
 * Violation Message Keys:
 * </p>
 * <ul>
 * <li>
 * {@code multi.file.regexp.header.mismatch}
 * </li>
 * <li>
 * {@code multi.file.regexp.header.missing}
 * </li>
 * </ul>
 *
 * @since 10.24.0
 */
@FileStatefulCheck
public class MultiFileRegexpHeaderCheck
        extends AbstractFileSetCheck implements ExternalResourceHolder {
    /**
     * Constant indicating that no header line mismatch was found.
     */
    public static final int MISMATCH_CODE = -1;

    /**
     * A key is pointing to the warning message text in "messages.properties"
     * file.
     */
    public static final String MSG_HEADER_MISSING = "multi.file.regexp.header.missing";

    /**
     * A key is pointing to the warning message text in "messages.properties"
     * file.
     */
    public static final String MSG_HEADER_MISMATCH = "multi.file.regexp.header.mismatch";

    /**
     * Regex pattern for a blank line.
     **/
    private static final String EMPTY_LINE_PATTERN = "^$";

    /**
     * Compiled regex pattern for a blank line.
     **/
    private static final Pattern BLANK_LINE = Pattern.compile(EMPTY_LINE_PATTERN);

    /**
     * List of metadata objects for each configured header file,
     * containing patterns and line contents.
     */
    private final List<HeaderFileMetadata> headerFilesMetadata = new ArrayList<>();

    /**
     * Specify a comma-separated list of files containing the required headers.
     * If a file's header matches none, the violation references
     * the first file in this list. Users can order files to set
     * a preferred header for such reporting.
     */
    @XdocsPropertyType(PropertyType.STRING)
    private String headerFiles;

    /**
     * Setter to specify a comma-separated list of files containing the required headers.
     * If a file's header matches none, the violation references
     * the first file in this list. Users can order files to set
     * a preferred header for such reporting.
     *
     * @param headerFiles comma-separated list of header files
     * @throws IllegalArgumentException if headerFiles is null or empty
     * @since 10.24.0
     */
    public void setHeaderFiles(String... headerFiles) {
        final String[] files;
        if (headerFiles == null) {
            files = CommonUtil.EMPTY_STRING_ARRAY;
        }
        else {
            files = headerFiles.clone();
        }

        headerFilesMetadata.clear();

        for (final String headerFile : files) {
            headerFilesMetadata.add(HeaderFileMetadata.createFromFile(headerFile));
        }
    }

    /**
     * Returns a comma-separated string of all configured header file paths.
     *
     * @return A comma-separated string of all configured header file paths,
     *         or an empty string if no header files are configured or none have valid paths.
     */
    public String getConfiguredHeaderPaths() {
        return headerFilesMetadata.stream()
                .map(HeaderFileMetadata::getHeaderFilePath)
                .collect(Collectors.joining(", "));
    }

    @Override
    public Set<String> getExternalResourceLocations() {
        return headerFilesMetadata.stream()
                .map(HeaderFileMetadata::getHeaderFileUri)
                .map(URI::toASCIIString)
                .collect(Collectors.toUnmodifiableSet());
    }

    @Override
    protected void processFiltered(File file, FileText fileText) {
        if (!headerFilesMetadata.isEmpty()) {
            final List<MatchResult> matchResult = headerFilesMetadata.stream()
                    .map(headerFile -> matchHeader(fileText, headerFile))
                    .collect(Collectors.toUnmodifiableList());

            if (matchResult.stream().noneMatch(match -> match.isMatching)) {
                final MatchResult mismatch = matchResult.get(0);
                final String allConfiguredHeaderPaths = getConfiguredHeaderPaths();
                log(mismatch.lineNumber, mismatch.messageKey,
                        mismatch.messageArg, allConfiguredHeaderPaths);
            }
        }
    }

    /**
     * Analyzes if the file text matches the header file patterns and generates a detailed result.
     *
     * @param fileText the text of the file being checked
     * @param headerFile the header file metadata to check against
     * @return a MatchResult containing the result of the analysis
     */
    private static MatchResult matchHeader(FileText fileText, HeaderFileMetadata headerFile) {
        final int fileSize = fileText.size();
        final List<Pattern> headerPatterns = headerFile.getHeaderPatterns();
        final int headerPatternSize = headerPatterns.size();

        int mismatchLine = MISMATCH_CODE;
        int index;
        for (index = 0; index < headerPatternSize && index < fileSize; index++) {
            if (!headerPatterns.get(index).matcher(fileText.get(index)).find()) {
                mismatchLine = index;
                break;
            }
        }
        if (index < headerPatternSize) {
            mismatchLine = index;
        }

        final MatchResult matchResult;
        if (mismatchLine == MISMATCH_CODE) {
            matchResult = MatchResult.matching();
        }
        else {
            matchResult = createMismatchResult(headerFile, fileText, mismatchLine);
        }
        return matchResult;
    }

    /**
     * Creates a MatchResult for a mismatch case.
     *
     * @param headerFile the header file metadata
     * @param fileText the text of the file being checked
     * @param mismatchLine the line number of the mismatch (0-based)
     * @return a MatchResult representing the mismatch
     */
    private static MatchResult createMismatchResult(HeaderFileMetadata headerFile,
                                                    FileText fileText, int mismatchLine) {
        final String messageKey;
        final int lineToLog;
        final String messageArg;

        if (headerFile.getHeaderPatterns().size() > fileText.size()) {
            messageKey = MSG_HEADER_MISSING;
            lineToLog = 1;
            messageArg = headerFile.getHeaderFilePath();
        }
        else {
            messageKey = MSG_HEADER_MISMATCH;
            lineToLog = mismatchLine + 1;
            final String lineContent = headerFile.getLineContents().get(mismatchLine);
            if (lineContent.isEmpty()) {
                messageArg = EMPTY_LINE_PATTERN;
            }
            else {
                messageArg = lineContent;
            }
        }
        return MatchResult.mismatch(lineToLog, messageKey, messageArg);
    }

    /**
     * Reads all lines from the specified header file URI.
     *
     * @param headerFile path to the header file (for error messages)
     * @param uri URI of the header file
     * @return list of lines read from the header file
     * @throws IllegalArgumentException if the file cannot be read or is empty
     */
    public static List<String> getLines(String headerFile, URI uri) {
        final List<String> readerLines = new ArrayList<>();
        try (LineNumberReader lineReader = new LineNumberReader(
                new InputStreamReader(
                        new BufferedInputStream(uri.toURL().openStream()),
                        StandardCharsets.UTF_8)
        )) {
            String line;
            do {
                line = lineReader.readLine();
                if (line != null) {
                    readerLines.add(line);
                }
            } while (line != null);
        }
        catch (final IOException exc) {
            throw new IllegalArgumentException("unable to load header file " + headerFile, exc);
        }

        if (readerLines.isEmpty()) {
            throw new IllegalArgumentException("Header file is empty: " + headerFile);
        }
        return readerLines;
    }

    /**
     * Metadata holder for a header file, storing its URI, compiled patterns, and line contents.
     */
    private static final class HeaderFileMetadata {
        /** URI of the header file. */
        private final URI headerFileUri;
        /** Original path string of the header file. */
        private final String headerFilePath;
        /** Compiled regex patterns for each line of the header. */
        private final List<Pattern> headerPatterns;
        /** Raw line contents of the header file. */
        private final List<String> lineContents;

        /**
         * Initializes the metadata holder.
         *
         * @param headerFileUri URI of the header file
         * @param headerFilePath original path string of the header file
         * @param headerPatterns compiled regex patterns for header lines
         * @param lineContents raw lines from the header file
         */
        private HeaderFileMetadata(
                URI headerFileUri, String headerFilePath,
                List<Pattern> headerPatterns, List<String> lineContents
        ) {
            this.headerFileUri = headerFileUri;
            this.headerFilePath = headerFilePath;
            this.headerPatterns = headerPatterns;
            this.lineContents = lineContents;
        }

        /**
         * Creates a HeaderFileMetadata instance by reading and processing
         * the specified header file.
         *
         * @param headerPath path to the header file
         * @return HeaderFileMetadata instance
         * @throws IllegalArgumentException if the header file is invalid or cannot be read
         */
        public static HeaderFileMetadata createFromFile(String headerPath) {
            if (CommonUtil.isBlank(headerPath)) {
                throw new IllegalArgumentException("Header file is not set");
            }
            try {
                final URI uri = CommonUtil.getUriByFilename(headerPath);
                final List<String> readerLines = getLines(headerPath, uri);
                final List<Pattern> patterns = readerLines.stream()
                        .map(HeaderFileMetadata::createPatternFromLine)
                        .collect(Collectors.toUnmodifiableList());
                return new HeaderFileMetadata(uri, headerPath, patterns, readerLines);
            }
            catch (CheckstyleException exc) {
                throw new IllegalArgumentException(
                        "Error reading or corrupted header file: " + headerPath, exc);
            }
        }

        /**
         * Creates a Pattern object from a line of text.
         *
         * @param line the line to create a pattern from
         * @return the compiled Pattern
         */
        private static Pattern createPatternFromLine(String line) {
            final Pattern result;
            if (line.isEmpty()) {
                result = BLANK_LINE;
            }
            else {
                result = Pattern.compile(validateRegex(line));
            }
            return result;
        }

        /**
         * Returns the URI of the header file.
         *
         * @return header file URI
         */
        public URI getHeaderFileUri() {
            return headerFileUri;
        }

        /**
         * Returns the original path string of the header file.
         *
         * @return header file path string
         */
        public String getHeaderFilePath() {
            return headerFilePath;
        }

        /**
         * Returns an unmodifiable list of compiled header patterns.
         *
         * @return header patterns
         */
        public List<Pattern> getHeaderPatterns() {
            return List.copyOf(headerPatterns);
        }

        /**
         * Returns an unmodifiable list of raw header line contents.
         *
         * @return header lines
         */
        public List<String> getLineContents() {
            return List.copyOf(lineContents);
        }

        /**
         * Ensures that the given input string is a valid regular expression.
         *
         * <p>This method validates that the input is a correctly formatted regex string
         * and will throw a PatternSyntaxException if it's invalid.
         *
         * @param input the string to be treated as a regex pattern
         * @return the validated regex pattern string
         * @throws IllegalArgumentException if the pattern is not a valid regex
         */
        private static String validateRegex(String input) {
            try {
                Pattern.compile(input);
                return input;
            }
            catch (final PatternSyntaxException exc) {
                throw new IllegalArgumentException("Invalid regex pattern: " + input, exc);
            }
        }
    }

    /**
     * Represents the result of a header match check, containing information about any mismatch.
     */
    private static final class MatchResult {
        /** Whether the header matched the file. */
        private final boolean isMatching;
        /** Line number where the mismatch occurred (1-based). */
        private final int lineNumber;
        /** The message key for the violation. */
        private final String messageKey;
        /** The argument for the message. */
        private final String messageArg;

        /**
         * Private constructor.
         *
         * @param isMatching whether the header matched
         * @param lineNumber line number of mismatch (1-based)
         * @param messageKey message key for violation
         * @param messageArg message argument
         */
        private MatchResult(boolean isMatching, int lineNumber, String messageKey,
                            String messageArg) {
            this.isMatching = isMatching;
            this.lineNumber = lineNumber;
            this.messageKey = messageKey;
            this.messageArg = messageArg;
        }

        /**
         * Creates a matching result.
         *
         * @return a matching result
         */
        public static MatchResult matching() {
            return new MatchResult(true, 0, null, null);
        }

        /**
         * Creates a mismatch result.
         *
         * @param lineNumber the line number where mismatch occurred (1-based)
         * @param messageKey the message key for the violation
         * @param messageArg the argument for the message
         * @return a mismatch result
         */
        public static MatchResult mismatch(int lineNumber, String messageKey,
                                           String messageArg) {
            return new MatchResult(false, lineNumber, messageKey, messageArg);
        }
    }
}