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