001/////////////////////////////////////////////////////////////////////////////////////////////// 002// checkstyle: Checks Java source code and other text files for adherence to a set of rules. 003// Copyright (C) 2001-2026 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::headerFilePath) 134 .collect(Collectors.joining(", ")); 135 } 136 137 @Override 138 public Set<String> getExternalResourceLocations() { 139 return headerFilesMetadata.stream() 140 .map(HeaderFileMetadata::headerFileUri) 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(MatchResult::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.headerPatterns(); 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.headerPatterns().size() > fileText.size()) { 210 messageKey = MSG_HEADER_MISSING; 211 lineToLog = 1; 212 messageArg = headerFile.headerFilePath(); 213 } 214 else { 215 messageKey = MSG_HEADER_MISMATCH; 216 lineToLog = mismatchLine + 1; 217 final String lineContent = headerFile.lineContents().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 * @param headerFileUri URI of the header file 265 * @param headerFilePath original path string of the header file 266 * @param headerPatterns compiled regex patterns for header lines 267 * @param lineContents raw lines from the header file 268 */ 269 private record HeaderFileMetadata( 270 URI headerFileUri, 271 String headerFilePath, 272 List<Pattern> headerPatterns, 273 List<String> lineContents) { 274 275 /** 276 * Creates a HeaderFileMetadata instance by reading and processing 277 * the specified header file. 278 * 279 * @param headerPath path to the header file 280 * @return HeaderFileMetadata instance 281 * @throws IllegalArgumentException if the header file is invalid or cannot be read 282 */ 283 public static HeaderFileMetadata createFromFile(String headerPath) { 284 if (CommonUtil.isBlank(headerPath)) { 285 throw new IllegalArgumentException("Header file is not set"); 286 } 287 try { 288 final URI uri = CommonUtil.getUriByFilename(headerPath); 289 final List<String> readerLines = getLines(headerPath, uri); 290 final List<Pattern> patterns = readerLines.stream() 291 .map(HeaderFileMetadata::createPatternFromLine) 292 .toList(); 293 return new HeaderFileMetadata(uri, headerPath, patterns, readerLines); 294 } 295 catch (CheckstyleException exc) { 296 throw new IllegalArgumentException( 297 "Error reading or corrupted header file: " + headerPath, exc); 298 } 299 } 300 301 /** 302 * Creates a Pattern object from a line of text. 303 * 304 * @param line the line to create a pattern from 305 * @return the compiled Pattern 306 */ 307 private static Pattern createPatternFromLine(String line) { 308 final Pattern result; 309 if (line.isEmpty()) { 310 result = BLANK_LINE; 311 } 312 else { 313 result = Pattern.compile(validateRegex(line)); 314 } 315 return result; 316 } 317 318 /** 319 * Returns an unmodifiable list of compiled header patterns. 320 * 321 * @return header patterns 322 */ 323 @Override 324 public List<Pattern> headerPatterns() { 325 return List.copyOf(headerPatterns); 326 } 327 328 /** 329 * Returns an unmodifiable list of raw header line contents. 330 * 331 * @return header lines 332 */ 333 @Override 334 public List<String> lineContents() { 335 return List.copyOf(lineContents); 336 } 337 338 /** 339 * Ensures that the given input string is a valid regular expression. 340 * 341 * <p>This method validates that the input is a correctly formatted regex string 342 * and will throw a PatternSyntaxException if it's invalid. 343 * 344 * @param input the string to be treated as a regex pattern 345 * @return the validated regex pattern string 346 * @throws IllegalArgumentException if the pattern is not a valid regex 347 */ 348 private static String validateRegex(String input) { 349 try { 350 Pattern.compile(input); 351 return input; 352 } 353 catch (final PatternSyntaxException exc) { 354 throw new IllegalArgumentException("Invalid regex pattern: " + input, exc); 355 } 356 } 357 } 358 359 /** 360 * Represents the result of a header match check, containing information about any mismatch. 361 * 362 * @param isMatching whether the header matched 363 * @param lineNumber line number of mismatch (1-based) 364 * @param messageKey message key for violation 365 * @param messageArg message argument 366 */ 367 private record MatchResult( 368 boolean isMatching, 369 int lineNumber, 370 String messageKey, 371 String messageArg) { 372 373 /** 374 * Creates a matching result. 375 * 376 * @return a matching result 377 */ 378 public static MatchResult matching() { 379 return new MatchResult(true, 0, null, null); 380 } 381 382 /** 383 * Creates a mismatch result. 384 * 385 * @param lineNumber the line number where mismatch occurred (1-based) 386 * @param messageKey the message key for the violation 387 * @param messageArg the argument for the message 388 * @return a mismatch result 389 */ 390 public static MatchResult mismatch(int lineNumber, String messageKey, 391 String messageArg) { 392 return new MatchResult(false, lineNumber, messageKey, messageArg); 393 } 394 } 395}