001/////////////////////////////////////////////////////////////////////////////////////////////// 002// checkstyle: Checks Java source code and other text files for adherence to a set of rules. 003// Copyright (C) 2001-2025 the original author or authors. 004// 005// This library is free software; you can redistribute it and/or 006// modify it under the terms of the GNU Lesser General Public 007// License as published by the Free Software Foundation; either 008// version 2.1 of the License, or (at your option) any later version. 009// 010// This library is distributed in the hope that it will be useful, 011// but WITHOUT ANY WARRANTY; without even the implied warranty of 012// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 013// Lesser General Public License for more details. 014// 015// You should have received a copy of the GNU Lesser General Public 016// License along with this library; if not, write to the Free Software 017// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 018/////////////////////////////////////////////////////////////////////////////////////////////// 019 020package com.puppycrawl.tools.checkstyle.checks.header; 021 022import java.io.BufferedInputStream; 023import java.io.File; 024import java.io.IOException; 025import java.io.InputStreamReader; 026import java.io.LineNumberReader; 027import java.net.URI; 028import java.nio.charset.StandardCharsets; 029import java.util.ArrayList; 030import java.util.List; 031import java.util.Set; 032import java.util.regex.Pattern; 033import java.util.regex.PatternSyntaxException; 034import java.util.stream.Collectors; 035 036import com.puppycrawl.tools.checkstyle.FileStatefulCheck; 037import com.puppycrawl.tools.checkstyle.PropertyType; 038import com.puppycrawl.tools.checkstyle.XdocsPropertyType; 039import com.puppycrawl.tools.checkstyle.api.AbstractFileSetCheck; 040import com.puppycrawl.tools.checkstyle.api.CheckstyleException; 041import com.puppycrawl.tools.checkstyle.api.ExternalResourceHolder; 042import com.puppycrawl.tools.checkstyle.api.FileText; 043import com.puppycrawl.tools.checkstyle.utils.CommonUtil; 044 045/** 046 * <div> 047 * Checks the header of a source file against multiple header files that contain a 048 * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/regex/Pattern.html"> 049 * pattern</a> for each line of the source header. 050 * </div> 051 * <ul> 052 * <li> 053 * Property {@code fileExtensions} - Specify the file extensions of the files to process. 054 * Type is {@code java.lang.String[]}. 055 * Default value is {@code ""}. 056 * </li> 057 * <li> 058 * Property {@code headerFiles} - Specify a comma-separated list of files containing 059 * the required headers. If a file's header matches none, the violation references 060 * the first file in this list. Users can order files to set 061 * a preferred header for such reporting. 062 * Type is {@code java.lang.String}. 063 * Default value is {@code null}. 064 * </li> 065 * </ul> 066 * 067 * <p> 068 * Parent is {@code com.puppycrawl.tools.checkstyle.Checker} 069 * </p> 070 * 071 * <p> 072 * Violation Message Keys: 073 * </p> 074 * <ul> 075 * <li> 076 * {@code multi.file.regexp.header.mismatch} 077 * </li> 078 * <li> 079 * {@code multi.file.regexp.header.missing} 080 * </li> 081 * </ul> 082 * 083 * @since 10.24.0 084 */ 085@FileStatefulCheck 086public class MultiFileRegexpHeaderCheck 087 extends AbstractFileSetCheck implements ExternalResourceHolder { 088 /** 089 * Constant indicating that no header line mismatch was found. 090 */ 091 public static final int MISMATCH_CODE = -1; 092 093 /** 094 * A key is pointing to the warning message text in "messages.properties" 095 * file. 096 */ 097 public static final String MSG_HEADER_MISSING = "multi.file.regexp.header.missing"; 098 099 /** 100 * A key is pointing to the warning message text in "messages.properties" 101 * file. 102 */ 103 public static final String MSG_HEADER_MISMATCH = "multi.file.regexp.header.mismatch"; 104 105 /** 106 * Regex pattern for a blank line. 107 **/ 108 private static final String EMPTY_LINE_PATTERN = "^$"; 109 110 /** 111 * Compiled regex pattern for a blank line. 112 **/ 113 private static final Pattern BLANK_LINE = Pattern.compile(EMPTY_LINE_PATTERN); 114 115 /** 116 * List of metadata objects for each configured header file, 117 * containing patterns and line contents. 118 */ 119 private final List<HeaderFileMetadata> headerFilesMetadata = new ArrayList<>(); 120 121 /** 122 * Specify a comma-separated list of files containing the required headers. 123 * If a file's header matches none, the violation references 124 * the first file in this list. Users can order files to set 125 * a preferred header for such reporting. 126 */ 127 @XdocsPropertyType(PropertyType.STRING) 128 private String headerFiles; 129 130 /** 131 * Setter to specify a comma-separated list of files containing the required headers. 132 * If a file's header matches none, the violation references 133 * the first file in this list. Users can order files to set 134 * a preferred header for such reporting. 135 * 136 * @param headerFiles comma-separated list of header files 137 * @throws IllegalArgumentException if headerFiles is null or empty 138 * @since 10.24.0 139 */ 140 public void setHeaderFiles(String... headerFiles) { 141 final String[] files; 142 if (headerFiles == null) { 143 files = CommonUtil.EMPTY_STRING_ARRAY; 144 } 145 else { 146 files = headerFiles.clone(); 147 } 148 149 headerFilesMetadata.clear(); 150 151 for (final String headerFile : files) { 152 headerFilesMetadata.add(HeaderFileMetadata.createFromFile(headerFile)); 153 } 154 } 155 156 /** 157 * Returns a comma-separated string of all configured header file paths. 158 * 159 * @return A comma-separated string of all configured header file paths, 160 * or an empty string if no header files are configured or none have valid paths. 161 */ 162 public String getConfiguredHeaderPaths() { 163 return headerFilesMetadata.stream() 164 .map(HeaderFileMetadata::getHeaderFilePath) 165 .collect(Collectors.joining(", ")); 166 } 167 168 @Override 169 public Set<String> getExternalResourceLocations() { 170 return headerFilesMetadata.stream() 171 .map(HeaderFileMetadata::getHeaderFileUri) 172 .map(URI::toASCIIString) 173 .collect(Collectors.toUnmodifiableSet()); 174 } 175 176 @Override 177 protected void processFiltered(File file, FileText fileText) { 178 if (!headerFilesMetadata.isEmpty()) { 179 final List<MatchResult> matchResult = headerFilesMetadata.stream() 180 .map(headerFile -> matchHeader(fileText, headerFile)) 181 .collect(Collectors.toUnmodifiableList()); 182 183 if (matchResult.stream().noneMatch(match -> match.isMatching)) { 184 final MatchResult mismatch = matchResult.get(0); 185 final String allConfiguredHeaderPaths = getConfiguredHeaderPaths(); 186 log(mismatch.lineNumber, mismatch.messageKey, 187 mismatch.messageArg, allConfiguredHeaderPaths); 188 } 189 } 190 } 191 192 /** 193 * Analyzes if the file text matches the header file patterns and generates a detailed result. 194 * 195 * @param fileText the text of the file being checked 196 * @param headerFile the header file metadata to check against 197 * @return a MatchResult containing the result of the analysis 198 */ 199 private static MatchResult matchHeader(FileText fileText, HeaderFileMetadata headerFile) { 200 final int fileSize = fileText.size(); 201 final List<Pattern> headerPatterns = headerFile.getHeaderPatterns(); 202 final int headerPatternSize = headerPatterns.size(); 203 204 int mismatchLine = MISMATCH_CODE; 205 int index; 206 for (index = 0; index < headerPatternSize && index < fileSize; index++) { 207 if (!headerPatterns.get(index).matcher(fileText.get(index)).find()) { 208 mismatchLine = index; 209 break; 210 } 211 } 212 if (index < headerPatternSize) { 213 mismatchLine = index; 214 } 215 216 final MatchResult matchResult; 217 if (mismatchLine == MISMATCH_CODE) { 218 matchResult = MatchResult.matching(); 219 } 220 else { 221 matchResult = createMismatchResult(headerFile, fileText, mismatchLine); 222 } 223 return matchResult; 224 } 225 226 /** 227 * Creates a MatchResult for a mismatch case. 228 * 229 * @param headerFile the header file metadata 230 * @param fileText the text of the file being checked 231 * @param mismatchLine the line number of the mismatch (0-based) 232 * @return a MatchResult representing the mismatch 233 */ 234 private static MatchResult createMismatchResult(HeaderFileMetadata headerFile, 235 FileText fileText, int mismatchLine) { 236 final String messageKey; 237 final int lineToLog; 238 final String messageArg; 239 240 if (headerFile.getHeaderPatterns().size() > fileText.size()) { 241 messageKey = MSG_HEADER_MISSING; 242 lineToLog = 1; 243 messageArg = headerFile.getHeaderFilePath(); 244 } 245 else { 246 messageKey = MSG_HEADER_MISMATCH; 247 lineToLog = mismatchLine + 1; 248 final String lineContent = headerFile.getLineContents().get(mismatchLine); 249 if (lineContent.isEmpty()) { 250 messageArg = EMPTY_LINE_PATTERN; 251 } 252 else { 253 messageArg = lineContent; 254 } 255 } 256 return MatchResult.mismatch(lineToLog, messageKey, messageArg); 257 } 258 259 /** 260 * Reads all lines from the specified header file URI. 261 * 262 * @param headerFile path to the header file (for error messages) 263 * @param uri URI of the header file 264 * @return list of lines read from the header file 265 * @throws IllegalArgumentException if the file cannot be read or is empty 266 */ 267 public static List<String> getLines(String headerFile, URI uri) { 268 final List<String> readerLines = new ArrayList<>(); 269 try (LineNumberReader lineReader = new LineNumberReader( 270 new InputStreamReader( 271 new BufferedInputStream(uri.toURL().openStream()), 272 StandardCharsets.UTF_8) 273 )) { 274 String line; 275 do { 276 line = lineReader.readLine(); 277 if (line != null) { 278 readerLines.add(line); 279 } 280 } while (line != null); 281 } 282 catch (final IOException exc) { 283 throw new IllegalArgumentException("unable to load header file " + headerFile, exc); 284 } 285 286 if (readerLines.isEmpty()) { 287 throw new IllegalArgumentException("Header file is empty: " + headerFile); 288 } 289 return readerLines; 290 } 291 292 /** 293 * Metadata holder for a header file, storing its URI, compiled patterns, and line contents. 294 */ 295 private static final class HeaderFileMetadata { 296 /** URI of the header file. */ 297 private final URI headerFileUri; 298 /** Original path string of the header file. */ 299 private final String headerFilePath; 300 /** Compiled regex patterns for each line of the header. */ 301 private final List<Pattern> headerPatterns; 302 /** Raw line contents of the header file. */ 303 private final List<String> lineContents; 304 305 /** 306 * Initializes the metadata holder. 307 * 308 * @param headerFileUri URI of the header file 309 * @param headerFilePath original path string of the header file 310 * @param headerPatterns compiled regex patterns for header lines 311 * @param lineContents raw lines from the header file 312 */ 313 private HeaderFileMetadata( 314 URI headerFileUri, String headerFilePath, 315 List<Pattern> headerPatterns, List<String> lineContents 316 ) { 317 this.headerFileUri = headerFileUri; 318 this.headerFilePath = headerFilePath; 319 this.headerPatterns = headerPatterns; 320 this.lineContents = lineContents; 321 } 322 323 /** 324 * Creates a HeaderFileMetadata instance by reading and processing 325 * the specified header file. 326 * 327 * @param headerPath path to the header file 328 * @return HeaderFileMetadata instance 329 * @throws IllegalArgumentException if the header file is invalid or cannot be read 330 */ 331 public static HeaderFileMetadata createFromFile(String headerPath) { 332 if (CommonUtil.isBlank(headerPath)) { 333 throw new IllegalArgumentException("Header file is not set"); 334 } 335 try { 336 final URI uri = CommonUtil.getUriByFilename(headerPath); 337 final List<String> readerLines = getLines(headerPath, uri); 338 final List<Pattern> patterns = readerLines.stream() 339 .map(HeaderFileMetadata::createPatternFromLine) 340 .collect(Collectors.toUnmodifiableList()); 341 return new HeaderFileMetadata(uri, headerPath, patterns, readerLines); 342 } 343 catch (CheckstyleException exc) { 344 throw new IllegalArgumentException( 345 "Error reading or corrupted header file: " + headerPath, exc); 346 } 347 } 348 349 /** 350 * Creates a Pattern object from a line of text. 351 * 352 * @param line the line to create a pattern from 353 * @return the compiled Pattern 354 */ 355 private static Pattern createPatternFromLine(String line) { 356 final Pattern result; 357 if (line.isEmpty()) { 358 result = BLANK_LINE; 359 } 360 else { 361 result = Pattern.compile(validateRegex(line)); 362 } 363 return result; 364 } 365 366 /** 367 * Returns the URI of the header file. 368 * 369 * @return header file URI 370 */ 371 public URI getHeaderFileUri() { 372 return headerFileUri; 373 } 374 375 /** 376 * Returns the original path string of the header file. 377 * 378 * @return header file path string 379 */ 380 public String getHeaderFilePath() { 381 return headerFilePath; 382 } 383 384 /** 385 * Returns an unmodifiable list of compiled header patterns. 386 * 387 * @return header patterns 388 */ 389 public List<Pattern> getHeaderPatterns() { 390 return List.copyOf(headerPatterns); 391 } 392 393 /** 394 * Returns an unmodifiable list of raw header line contents. 395 * 396 * @return header lines 397 */ 398 public List<String> getLineContents() { 399 return List.copyOf(lineContents); 400 } 401 402 /** 403 * Ensures that the given input string is a valid regular expression. 404 * 405 * <p>This method validates that the input is a correctly formatted regex string 406 * and will throw a PatternSyntaxException if it's invalid. 407 * 408 * @param input the string to be treated as a regex pattern 409 * @return the validated regex pattern string 410 * @throws IllegalArgumentException if the pattern is not a valid regex 411 */ 412 private static String validateRegex(String input) { 413 try { 414 Pattern.compile(input); 415 return input; 416 } 417 catch (final PatternSyntaxException exc) { 418 throw new IllegalArgumentException("Invalid regex pattern: " + input, exc); 419 } 420 } 421 } 422 423 /** 424 * Represents the result of a header match check, containing information about any mismatch. 425 */ 426 private static final class MatchResult { 427 /** Whether the header matched the file. */ 428 private final boolean isMatching; 429 /** Line number where the mismatch occurred (1-based). */ 430 private final int lineNumber; 431 /** The message key for the violation. */ 432 private final String messageKey; 433 /** The argument for the message. */ 434 private final String messageArg; 435 436 /** 437 * Private constructor. 438 * 439 * @param isMatching whether the header matched 440 * @param lineNumber line number of mismatch (1-based) 441 * @param messageKey message key for violation 442 * @param messageArg message argument 443 */ 444 private MatchResult(boolean isMatching, int lineNumber, String messageKey, 445 String messageArg) { 446 this.isMatching = isMatching; 447 this.lineNumber = lineNumber; 448 this.messageKey = messageKey; 449 this.messageArg = messageArg; 450 } 451 452 /** 453 * Creates a matching result. 454 * 455 * @return a matching result 456 */ 457 public static MatchResult matching() { 458 return new MatchResult(true, 0, null, null); 459 } 460 461 /** 462 * Creates a mismatch result. 463 * 464 * @param lineNumber the line number where mismatch occurred (1-based) 465 * @param messageKey the message key for the violation 466 * @param messageArg the argument for the message 467 * @return a mismatch result 468 */ 469 public static MatchResult mismatch(int lineNumber, String messageKey, 470 String messageArg) { 471 return new MatchResult(false, lineNumber, messageKey, messageArg); 472 } 473 } 474}