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/17/docs/api/java.base/java/util/regex/Pattern.html">
49   * pattern</a> for each line of the source header.
50   * </div>
51   *
52   * @since 10.24.0
53   */
54  @FileStatefulCheck
55  public class MultiFileRegexpHeaderCheck
56          extends AbstractFileSetCheck implements ExternalResourceHolder {
57      /**
58       * Constant indicating that no header line mismatch was found.
59       */
60      public static final int MISMATCH_CODE = -1;
61  
62      /**
63       * A key is pointing to the warning message text in "messages.properties"
64       * file.
65       */
66      public static final String MSG_HEADER_MISSING = "multi.file.regexp.header.missing";
67  
68      /**
69       * A key is pointing to the warning message text in "messages.properties"
70       * file.
71       */
72      public static final String MSG_HEADER_MISMATCH = "multi.file.regexp.header.mismatch";
73  
74      /**
75       * Regex pattern for a blank line.
76       **/
77      private static final String EMPTY_LINE_PATTERN = "^$";
78  
79      /**
80       * Compiled regex pattern for a blank line.
81       **/
82      private static final Pattern BLANK_LINE = Pattern.compile(EMPTY_LINE_PATTERN);
83  
84      /**
85       * List of metadata objects for each configured header file,
86       * containing patterns and line contents.
87       */
88      private final List<HeaderFileMetadata> headerFilesMetadata = new ArrayList<>();
89  
90      /**
91       * Specify a comma-separated list of files containing the required headers.
92       * If a file's header matches none, the violation references
93       * the first file in this list. Users can order files to set
94       * a preferred header for such reporting.
95       */
96      @XdocsPropertyType(PropertyType.STRING)
97      private String headerFiles;
98  
99      /**
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 }