View Javadoc
1   ///////////////////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3   // Copyright (C) 2001-2026 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       * Separator for multiple header file paths in the configuration and messages.
81       */
82      private static final String HEADER_FILE_SEPARATOR = ", ";
83  
84      /**
85       * Compiled regex pattern for a blank line.
86       **/
87      private static final Pattern BLANK_LINE = Pattern.compile(EMPTY_LINE_PATTERN);
88  
89      /**
90       * List of metadata objects for each configured header file,
91       * containing patterns and line contents.
92       */
93      private final List<HeaderFileMetadata> headerFilesMetadata = new ArrayList<>();
94  
95      /**
96       * Specify a comma-separated list of files containing the required headers.
97       * If a file's header matches none, the violation references
98       * the first file in this list. Users can order files to set
99       * 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 }