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