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