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/17/docs/api/java.base/java/util/regex/Pattern.html"> 049 * pattern</a> for each line of the source header. 050 * </div> 051 * 052 * @since 10.24.0 053 */ 054@FileStatefulCheck 055public class MultiFileRegexpHeaderCheck 056 extends AbstractFileSetCheck implements ExternalResourceHolder { 057 /** 058 * Constant indicating that no header line mismatch was found. 059 */ 060 public static final int MISMATCH_CODE = -1; 061 062 /** 063 * A key is pointing to the warning message text in "messages.properties" 064 * file. 065 */ 066 public static final String MSG_HEADER_MISSING = "multi.file.regexp.header.missing"; 067 068 /** 069 * A key is pointing to the warning message text in "messages.properties" 070 * file. 071 */ 072 public static final String MSG_HEADER_MISMATCH = "multi.file.regexp.header.mismatch"; 073 074 /** 075 * Regex pattern for a blank line. 076 **/ 077 private static final String EMPTY_LINE_PATTERN = "^$"; 078 079 /** 080 * Compiled regex pattern for a blank line. 081 **/ 082 private static final Pattern BLANK_LINE = Pattern.compile(EMPTY_LINE_PATTERN); 083 084 /** 085 * List of metadata objects for each configured header file, 086 * containing patterns and line contents. 087 */ 088 private final List<HeaderFileMetadata> headerFilesMetadata = new ArrayList<>(); 089 090 /** 091 * Specify a comma-separated list of files containing the required headers. 092 * If a file's header matches none, the violation references 093 * the first file in this list. Users can order files to set 094 * a preferred header for such reporting. 095 */ 096 @XdocsPropertyType(PropertyType.STRING) 097 private String headerFiles; 098 099 /** 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}