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 }