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