View Javadoc
1   ///////////////////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3   // Copyright (C) 2001-2025 the original author or authors.
4   //
5   // This library is free software; you can redistribute it and/or
6   // modify it under the terms of the GNU Lesser General Public
7   // License as published by the Free Software Foundation; either
8   // version 2.1 of the License, or (at your option) any later version.
9   //
10  // This library is distributed in the hope that it will be useful,
11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  // Lesser General Public License for more details.
14  //
15  // You should have received a copy of the GNU Lesser General Public
16  // License along with this library; if not, write to the Free Software
17  // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18  ///////////////////////////////////////////////////////////////////////////////////////////////
19  
20  package com.puppycrawl.tools.checkstyle.checks.header;
21  
22  import java.io.BufferedInputStream;
23  import java.io.File;
24  import java.io.IOException;
25  import java.io.InputStreamReader;
26  import java.io.LineNumberReader;
27  import java.net.URI;
28  import java.nio.charset.StandardCharsets;
29  import java.util.ArrayList;
30  import java.util.List;
31  import java.util.Set;
32  import java.util.regex.Pattern;
33  import java.util.regex.PatternSyntaxException;
34  import java.util.stream.Collectors;
35  
36  import com.puppycrawl.tools.checkstyle.FileStatefulCheck;
37  import com.puppycrawl.tools.checkstyle.PropertyType;
38  import com.puppycrawl.tools.checkstyle.XdocsPropertyType;
39  import com.puppycrawl.tools.checkstyle.api.AbstractFileSetCheck;
40  import com.puppycrawl.tools.checkstyle.api.CheckstyleException;
41  import com.puppycrawl.tools.checkstyle.api.ExternalResourceHolder;
42  import com.puppycrawl.tools.checkstyle.api.FileText;
43  import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
44  
45  /**
46   * <div>
47   * Checks the header of a source file against multiple header files that contain a
48   * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/regex/Pattern.html">
49   * pattern</a> for each line of the source header.
50   * </div>
51   * <ul>
52   * <li>
53   * Property {@code fileExtensions} - Specify the file extensions of the files to process.
54   * Type is {@code java.lang.String[]}.
55   * Default value is {@code ""}.
56   * </li>
57   * <li>
58   * Property {@code headerFiles} - Specify a comma-separated list of files containing
59   * the required headers. If a file's header matches none, the violation references
60   * the first file in this list. Users can order files to set
61   * a preferred header for such reporting.
62   * Type is {@code java.lang.String}.
63   * Default value is {@code null}.
64   * </li>
65   * </ul>
66   *
67   * <p>
68   * Parent is {@code com.puppycrawl.tools.checkstyle.Checker}
69   * </p>
70   *
71   * <p>
72   * Violation Message Keys:
73   * </p>
74   * <ul>
75   * <li>
76   * {@code multi.file.regexp.header.mismatch}
77   * </li>
78   * <li>
79   * {@code multi.file.regexp.header.missing}
80   * </li>
81   * </ul>
82   *
83   * @since 10.24.0
84   */
85  @FileStatefulCheck
86  public class MultiFileRegexpHeaderCheck
87          extends AbstractFileSetCheck implements ExternalResourceHolder {
88      /**
89       * Constant indicating that no header line mismatch was found.
90       */
91      public static final int MISMATCH_CODE = -1;
92  
93      /**
94       * A key is pointing to the warning message text in "messages.properties"
95       * file.
96       */
97      public static final String MSG_HEADER_MISSING = "multi.file.regexp.header.missing";
98  
99      /**
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 }