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