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.checks.header;
021
022import java.io.BufferedInputStream;
023import java.io.File;
024import java.io.IOException;
025import java.io.InputStreamReader;
026import java.io.LineNumberReader;
027import java.net.URI;
028import java.nio.charset.StandardCharsets;
029import java.util.ArrayList;
030import java.util.List;
031import java.util.Set;
032import java.util.regex.Pattern;
033import java.util.regex.PatternSyntaxException;
034import java.util.stream.Collectors;
035
036import com.puppycrawl.tools.checkstyle.FileStatefulCheck;
037import com.puppycrawl.tools.checkstyle.PropertyType;
038import com.puppycrawl.tools.checkstyle.XdocsPropertyType;
039import com.puppycrawl.tools.checkstyle.api.AbstractFileSetCheck;
040import com.puppycrawl.tools.checkstyle.api.CheckstyleException;
041import com.puppycrawl.tools.checkstyle.api.ExternalResourceHolder;
042import com.puppycrawl.tools.checkstyle.api.FileText;
043import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
044
045/**
046 * <div>
047 * Checks the header of a source file against multiple header files that contain a
048 * <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/regex/Pattern.html">
049 * pattern</a> for each line of the source header.
050 * </div>
051 *
052 * @since 10.24.0
053 */
054@FileStatefulCheck
055public class MultiFileRegexpHeaderCheck
056        extends AbstractFileSetCheck
057        implements ExternalResourceHolder {
058
059    /**
060     * Constant indicating that no header line mismatch was found.
061     */
062    public static final int MISMATCH_CODE = -1;
063
064    /**
065     * A key is pointing to the warning message text in "messages.properties" file.
066     */
067    public static final String MSG_HEADER_MISSING = "multi.file.regexp.header.missing";
068
069    /**
070     * A key is pointing to the warning message text in "messages.properties" file.
071     */
072    public static final String MSG_HEADER_MISMATCH = "multi.file.regexp.header.mismatch";
073
074    /**
075     * Regex pattern for a blank line.
076     */
077    private static final String EMPTY_LINE_PATTERN = "^$";
078
079    /**
080     * Separator for multiple header file paths in the configuration and messages.
081     */
082    private static final String HEADER_FILE_SEPARATOR = ", ";
083
084    /**
085     * Compiled regex pattern for a blank line.
086     */
087    private static final Pattern BLANK_LINE = Pattern.compile(EMPTY_LINE_PATTERN);
088
089    /**
090     * List of metadata objects for each configured header file,
091     * containing patterns and line contents.
092     */
093    private final List<HeaderFileMetadata> headerFilesMetadata = new ArrayList<>();
094
095    /**
096     * Specify a comma-separated list of files containing the required headers.
097     * If a file's header matches none, the violation references
098     * the first file in this list. Users can order files to set
099     * a preferred header for such reporting.
100     */
101    @XdocsPropertyType(PropertyType.STRING)
102    private String headerFiles;
103
104    /**
105     * Creates a new {@code MultiFileRegexpHeaderCheck} instance.
106     */
107    public MultiFileRegexpHeaderCheck() {
108        // no code by default
109    }
110
111    /**
112     * Setter to specify a comma-separated list of files containing the required headers.
113     * If a file's header matches none, the violation references
114     * the first file in this list. Users can order files to set
115     * a preferred header for such reporting.
116     *
117     * @param headerFiles comma-separated list of header files
118     * @throws IllegalArgumentException if headerFiles is null or empty
119     * @since 10.24.0
120     */
121    public void setHeaderFiles(String... headerFiles) {
122        final String[] files;
123        if (headerFiles == null) {
124            files = CommonUtil.EMPTY_STRING_ARRAY;
125        }
126        else {
127            files = headerFiles.clone();
128            this.headerFiles = String.join(HEADER_FILE_SEPARATOR, headerFiles);
129        }
130
131        headerFilesMetadata.clear();
132
133        for (final String headerFile : files) {
134            headerFilesMetadata.add(HeaderFileMetadata.createFromFile(headerFile));
135        }
136    }
137
138    @Override
139    public Set<String> getExternalResourceLocations() {
140        return headerFilesMetadata.stream()
141                .map(HeaderFileMetadata::headerFileUri)
142                .map(URI::toASCIIString)
143                .collect(Collectors.toUnmodifiableSet());
144    }
145
146    @Override
147    protected void processFiltered(File file, FileText fileText) {
148        if (!headerFilesMetadata.isEmpty()) {
149            final List<MatchResult> matchResult = headerFilesMetadata.stream()
150                    .map(headerFile -> matchHeader(fileText, headerFile))
151                    .toList();
152
153            if (matchResult.stream().noneMatch(MatchResult::isMatching)) {
154                final MatchResult mismatch = matchResult.getFirst();
155                final String allConfiguredHeaderPaths = headerFiles;
156                log(mismatch.lineNumber(), mismatch.messageKey(),
157                        mismatch.messageArg(), allConfiguredHeaderPaths);
158            }
159        }
160    }
161
162    /**
163     * Analyzes if the file text matches the header file patterns and generates a detailed result.
164     *
165     * @param fileText the text of the file being checked
166     * @param headerFile the header file metadata to check against
167     * @return a MatchResult containing the result of the analysis
168     */
169    private static MatchResult matchHeader(FileText fileText, HeaderFileMetadata headerFile) {
170        final int fileSize = fileText.size();
171        final List<Pattern> headerPatterns = headerFile.headerPatterns();
172        final int headerPatternSize = headerPatterns.size();
173
174        int mismatchLine = MISMATCH_CODE;
175        int index;
176        for (index = 0; index < headerPatternSize && index < fileSize; index++) {
177            if (!headerPatterns.get(index).matcher(fileText.get(index)).find()) {
178                mismatchLine = index;
179                break;
180            }
181        }
182        if (index < headerPatternSize) {
183            mismatchLine = index;
184        }
185
186        final MatchResult matchResult;
187        if (mismatchLine == MISMATCH_CODE) {
188            matchResult = MatchResult.matching();
189        }
190        else {
191            matchResult = createMismatchResult(headerFile, fileText, mismatchLine);
192        }
193        return matchResult;
194    }
195
196    /**
197     * Creates a MatchResult for a mismatch case.
198     *
199     * @param headerFile the header file metadata
200     * @param fileText the text of the file being checked
201     * @param mismatchLine the line number of the mismatch (0-based)
202     * @return a MatchResult representing the mismatch
203     */
204    private static MatchResult createMismatchResult(HeaderFileMetadata headerFile,
205                                                    FileText fileText, int mismatchLine) {
206        final String messageKey;
207        final int lineToLog;
208        final String messageArg;
209
210        if (headerFile.headerPatterns().size() > fileText.size()) {
211            messageKey = MSG_HEADER_MISSING;
212            lineToLog = 1;
213            messageArg = headerFile.headerFilePath();
214        }
215        else {
216            messageKey = MSG_HEADER_MISMATCH;
217            lineToLog = mismatchLine + 1;
218            final String lineContent = headerFile.lineContents().get(mismatchLine);
219            if (lineContent.isEmpty()) {
220                messageArg = EMPTY_LINE_PATTERN;
221            }
222            else {
223                messageArg = lineContent;
224            }
225        }
226        return MatchResult.mismatch(lineToLog, messageKey, messageArg);
227    }
228
229    /**
230     * Reads all lines from the specified header file URI.
231     *
232     * @param headerFile path to the header file (for error messages)
233     * @param uri URI of the header file
234     * @return list of lines read from the header file
235     * @throws IllegalArgumentException if the file cannot be read or is empty
236     */
237    public static List<String> getLines(String headerFile, URI uri) {
238        final List<String> readerLines = new ArrayList<>();
239        try (LineNumberReader lineReader = new LineNumberReader(
240                new InputStreamReader(
241                        new BufferedInputStream(uri.toURL().openStream()),
242                        StandardCharsets.UTF_8)
243        )) {
244            String line;
245            do {
246                line = lineReader.readLine();
247                if (line != null) {
248                    readerLines.add(line);
249                }
250            } while (line != null);
251        }
252        catch (final IOException exc) {
253            throw new IllegalArgumentException("unable to load header file " + headerFile, exc);
254        }
255
256        if (readerLines.isEmpty()) {
257            throw new IllegalArgumentException("Header file is empty: " + headerFile);
258        }
259        return readerLines;
260    }
261
262    /**
263     * Metadata holder for a header file, storing its URI, compiled patterns, and line contents.
264     *
265     * @param headerFileUri URI of the header file
266     * @param headerFilePath original path string of the header file
267     * @param headerPatterns compiled regex patterns for header lines
268     * @param lineContents raw lines from the header file
269     */
270    private record HeaderFileMetadata(
271            URI headerFileUri,
272            String headerFilePath,
273            List<Pattern> headerPatterns,
274            List<String> lineContents) {
275
276        /**
277         * Creates a HeaderFileMetadata instance by reading and processing
278         * the specified header file.
279         *
280         * @param headerPath path to the header file
281         * @return HeaderFileMetadata instance
282         * @throws IllegalArgumentException if the header file is invalid or cannot be read
283         */
284        /* package */ static HeaderFileMetadata createFromFile(String headerPath) {
285            if (CommonUtil.isBlank(headerPath)) {
286                throw new IllegalArgumentException("Header file is not set");
287            }
288            try {
289                final URI uri = CommonUtil.getUriByFilename(headerPath);
290                final List<String> readerLines = getLines(headerPath, uri);
291                final List<Pattern> patterns = readerLines.stream()
292                        .map(HeaderFileMetadata::createPatternFromLine)
293                        .toList();
294                return new HeaderFileMetadata(uri, headerPath, patterns, readerLines);
295            }
296            catch (CheckstyleException exc) {
297                throw new IllegalArgumentException(
298                        "Error reading or corrupted header file: " + headerPath, exc);
299            }
300        }
301
302        /**
303         * Creates a Pattern object from a line of text.
304         *
305         * @param line the line to create a pattern from
306         * @return the compiled Pattern
307         */
308        private static Pattern createPatternFromLine(String line) {
309            final Pattern result;
310            if (line.isEmpty()) {
311                result = BLANK_LINE;
312            }
313            else {
314                result = Pattern.compile(validateRegex(line));
315            }
316            return result;
317        }
318
319        /**
320         * Returns an unmodifiable list of compiled header patterns.
321         *
322         * @return header patterns
323         */
324        @Override
325        public List<Pattern> headerPatterns() {
326            return List.copyOf(headerPatterns);
327        }
328
329        /**
330         * Returns an unmodifiable list of raw header line contents.
331         *
332         * @return header lines
333         */
334        @Override
335        public List<String> lineContents() {
336            return List.copyOf(lineContents);
337        }
338
339        /**
340         * Ensures that the given input string is a valid regular expression.
341         *
342         * <p>This method validates that the input is a correctly formatted regex string
343         * and will throw a PatternSyntaxException if it's invalid.
344         *
345         * @param input the string to be treated as a regex pattern
346         * @return the validated regex pattern string
347         * @throws IllegalArgumentException if the pattern is not a valid regex
348         */
349        private static String validateRegex(String input) {
350            try {
351                Pattern.compile(input);
352                return input;
353            }
354            catch (final PatternSyntaxException exc) {
355                throw new IllegalArgumentException("Invalid regex pattern: " + input, exc);
356            }
357        }
358    }
359
360    /**
361     * Represents the result of a header match check, containing information about any mismatch.
362     *
363     * @param isMatching whether the header matched
364     * @param lineNumber line number of mismatch (1-based)
365     * @param messageKey message key for violation
366     * @param messageArg message argument
367     */
368    private record MatchResult(
369            boolean isMatching,
370            int lineNumber,
371            String messageKey,
372            String messageArg) {
373
374        /**
375         * Creates a matching result.
376         *
377         * @return a matching result
378         */
379        /* package */ static MatchResult matching() {
380            return new MatchResult(true, 0, null, null);
381        }
382
383        /**
384         * Creates a mismatch result.
385         *
386         * @param lineNumber the line number where mismatch occurred (1-based)
387         * @param messageKey the message key for the violation
388         * @param messageArg the argument for the message
389         * @return a mismatch result
390         */
391        /* package */ static MatchResult mismatch(int lineNumber, String messageKey,
392                                           String messageArg) {
393            return new MatchResult(false, lineNumber, messageKey, messageArg);
394        }
395    }
396
397}