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 }