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 }