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 /** 059 * Constant indicating that no header line mismatch was found. 060 */ 061 public static final int MISMATCH_CODE = -1; 062 063 /** 064 * A key is pointing to the warning message text in "messages.properties" 065 * file. 066 */ 067 public static final String MSG_HEADER_MISSING = "multi.file.regexp.header.missing"; 068 069 /** 070 * A key is pointing to the warning message text in "messages.properties" 071 * file. 072 */ 073 public static final String MSG_HEADER_MISMATCH = "multi.file.regexp.header.mismatch"; 074 075 /** 076 * Regex pattern for a blank line. 077 **/ 078 private static final String EMPTY_LINE_PATTERN = "^$"; 079 080 /** 081 * Separator for multiple header file paths in the configuration and messages. 082 */ 083 private static final String HEADER_FILE_SEPARATOR = ", "; 084 085 /** 086 * Compiled regex pattern for a blank line. 087 **/ 088 private static final Pattern BLANK_LINE = Pattern.compile(EMPTY_LINE_PATTERN); 089 090 /** 091 * List of metadata objects for each configured header file, 092 * containing patterns and line contents. 093 */ 094 private final List<HeaderFileMetadata> headerFilesMetadata = new ArrayList<>(); 095 096 /** 097 * Specify a comma-separated list of files containing the required headers. 098 * If a file's header matches none, the violation references 099 * 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}