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 057 implements ExternalResourceHolder { 058 059 /** 060 * Constant indicating that no header line mismatch was found. 061 */ 062 public static final int MISMATCH_CODE = -1; 063 064 /** 065 * A key is pointing to the warning message text in "messages.properties" 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" 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 * Separator for multiple header file paths in the configuration and messages. 081 */ 082 private static final String HEADER_FILE_SEPARATOR = ", "; 083 084 /** 085 * Compiled regex pattern for a blank line. 086 */ 087 private static final Pattern BLANK_LINE = Pattern.compile(EMPTY_LINE_PATTERN); 088 089 /** 090 * List of metadata objects for each configured header file, 091 * containing patterns and line contents. 092 */ 093 private final List<HeaderFileMetadata> headerFilesMetadata = new ArrayList<>(); 094 095 /** 096 * Specify a comma-separated list of files containing the required headers. 097 * If a file's header matches none, the violation references 098 * the first file in this list. Users can order files to set 099 * a preferred header for such reporting. 100 */ 101 @XdocsPropertyType(PropertyType.STRING) 102 private String headerFiles; 103 104 /** 105 * Creates a new {@code MultiFileRegexpHeaderCheck} instance. 106 */ 107 public MultiFileRegexpHeaderCheck() { 108 // no code by default 109 } 110 111 /** 112 * Setter to specify a comma-separated list of files containing the required headers. 113 * If a file's header matches none, the violation references 114 * the first file in this list. Users can order files to set 115 * a preferred header for such reporting. 116 * 117 * @param headerFiles comma-separated list of header files 118 * @throws IllegalArgumentException if headerFiles is null or empty 119 * @since 10.24.0 120 */ 121 public void setHeaderFiles(String... headerFiles) { 122 final String[] files; 123 if (headerFiles == null) { 124 files = CommonUtil.EMPTY_STRING_ARRAY; 125 } 126 else { 127 files = headerFiles.clone(); 128 this.headerFiles = String.join(HEADER_FILE_SEPARATOR, headerFiles); 129 } 130 131 headerFilesMetadata.clear(); 132 133 for (final String headerFile : files) { 134 headerFilesMetadata.add(HeaderFileMetadata.createFromFile(headerFile)); 135 } 136 } 137 138 @Override 139 public Set<String> getExternalResourceLocations() { 140 return headerFilesMetadata.stream() 141 .map(HeaderFileMetadata::headerFileUri) 142 .map(URI::toASCIIString) 143 .collect(Collectors.toUnmodifiableSet()); 144 } 145 146 @Override 147 protected void processFiltered(File file, FileText fileText) { 148 if (!headerFilesMetadata.isEmpty()) { 149 final List<MatchResult> matchResult = headerFilesMetadata.stream() 150 .map(headerFile -> matchHeader(fileText, headerFile)) 151 .toList(); 152 153 if (matchResult.stream().noneMatch(MatchResult::isMatching)) { 154 final MatchResult mismatch = matchResult.getFirst(); 155 final String allConfiguredHeaderPaths = headerFiles; 156 log(mismatch.lineNumber(), mismatch.messageKey(), 157 mismatch.messageArg(), allConfiguredHeaderPaths); 158 } 159 } 160 } 161 162 /** 163 * Analyzes if the file text matches the header file patterns and generates a detailed result. 164 * 165 * @param fileText the text of the file being checked 166 * @param headerFile the header file metadata to check against 167 * @return a MatchResult containing the result of the analysis 168 */ 169 private static MatchResult matchHeader(FileText fileText, HeaderFileMetadata headerFile) { 170 final int fileSize = fileText.size(); 171 final List<Pattern> headerPatterns = headerFile.headerPatterns(); 172 final int headerPatternSize = headerPatterns.size(); 173 174 int mismatchLine = MISMATCH_CODE; 175 int index; 176 for (index = 0; index < headerPatternSize && index < fileSize; index++) { 177 if (!headerPatterns.get(index).matcher(fileText.get(index)).find()) { 178 mismatchLine = index; 179 break; 180 } 181 } 182 if (index < headerPatternSize) { 183 mismatchLine = index; 184 } 185 186 final MatchResult matchResult; 187 if (mismatchLine == MISMATCH_CODE) { 188 matchResult = MatchResult.matching(); 189 } 190 else { 191 matchResult = createMismatchResult(headerFile, fileText, mismatchLine); 192 } 193 return matchResult; 194 } 195 196 /** 197 * Creates a MatchResult for a mismatch case. 198 * 199 * @param headerFile the header file metadata 200 * @param fileText the text of the file being checked 201 * @param mismatchLine the line number of the mismatch (0-based) 202 * @return a MatchResult representing the mismatch 203 */ 204 private static MatchResult createMismatchResult(HeaderFileMetadata headerFile, 205 FileText fileText, int mismatchLine) { 206 final String messageKey; 207 final int lineToLog; 208 final String messageArg; 209 210 if (headerFile.headerPatterns().size() > fileText.size()) { 211 messageKey = MSG_HEADER_MISSING; 212 lineToLog = 1; 213 messageArg = headerFile.headerFilePath(); 214 } 215 else { 216 messageKey = MSG_HEADER_MISMATCH; 217 lineToLog = mismatchLine + 1; 218 final String lineContent = headerFile.lineContents().get(mismatchLine); 219 if (lineContent.isEmpty()) { 220 messageArg = EMPTY_LINE_PATTERN; 221 } 222 else { 223 messageArg = lineContent; 224 } 225 } 226 return MatchResult.mismatch(lineToLog, messageKey, messageArg); 227 } 228 229 /** 230 * Reads all lines from the specified header file URI. 231 * 232 * @param headerFile path to the header file (for error messages) 233 * @param uri URI of the header file 234 * @return list of lines read from the header file 235 * @throws IllegalArgumentException if the file cannot be read or is empty 236 */ 237 public static List<String> getLines(String headerFile, URI uri) { 238 final List<String> readerLines = new ArrayList<>(); 239 try (LineNumberReader lineReader = new LineNumberReader( 240 new InputStreamReader( 241 new BufferedInputStream(uri.toURL().openStream()), 242 StandardCharsets.UTF_8) 243 )) { 244 String line; 245 do { 246 line = lineReader.readLine(); 247 if (line != null) { 248 readerLines.add(line); 249 } 250 } while (line != null); 251 } 252 catch (final IOException exc) { 253 throw new IllegalArgumentException("unable to load header file " + headerFile, exc); 254 } 255 256 if (readerLines.isEmpty()) { 257 throw new IllegalArgumentException("Header file is empty: " + headerFile); 258 } 259 return readerLines; 260 } 261 262 /** 263 * Metadata holder for a header file, storing its URI, compiled patterns, and line contents. 264 * 265 * @param headerFileUri URI of the header file 266 * @param headerFilePath original path string of the header file 267 * @param headerPatterns compiled regex patterns for header lines 268 * @param lineContents raw lines from the header file 269 */ 270 private record HeaderFileMetadata( 271 URI headerFileUri, 272 String headerFilePath, 273 List<Pattern> headerPatterns, 274 List<String> lineContents) { 275 276 /** 277 * Creates a HeaderFileMetadata instance by reading and processing 278 * the specified header file. 279 * 280 * @param headerPath path to the header file 281 * @return HeaderFileMetadata instance 282 * @throws IllegalArgumentException if the header file is invalid or cannot be read 283 */ 284 /* package */ static HeaderFileMetadata createFromFile(String headerPath) { 285 if (CommonUtil.isBlank(headerPath)) { 286 throw new IllegalArgumentException("Header file is not set"); 287 } 288 try { 289 final URI uri = CommonUtil.getUriByFilename(headerPath); 290 final List<String> readerLines = getLines(headerPath, uri); 291 final List<Pattern> patterns = readerLines.stream() 292 .map(HeaderFileMetadata::createPatternFromLine) 293 .toList(); 294 return new HeaderFileMetadata(uri, headerPath, patterns, readerLines); 295 } 296 catch (CheckstyleException exc) { 297 throw new IllegalArgumentException( 298 "Error reading or corrupted header file: " + headerPath, exc); 299 } 300 } 301 302 /** 303 * Creates a Pattern object from a line of text. 304 * 305 * @param line the line to create a pattern from 306 * @return the compiled Pattern 307 */ 308 private static Pattern createPatternFromLine(String line) { 309 final Pattern result; 310 if (line.isEmpty()) { 311 result = BLANK_LINE; 312 } 313 else { 314 result = Pattern.compile(validateRegex(line)); 315 } 316 return result; 317 } 318 319 /** 320 * Returns an unmodifiable list of compiled header patterns. 321 * 322 * @return header patterns 323 */ 324 @Override 325 public List<Pattern> headerPatterns() { 326 return List.copyOf(headerPatterns); 327 } 328 329 /** 330 * Returns an unmodifiable list of raw header line contents. 331 * 332 * @return header lines 333 */ 334 @Override 335 public List<String> lineContents() { 336 return List.copyOf(lineContents); 337 } 338 339 /** 340 * Ensures that the given input string is a valid regular expression. 341 * 342 * <p>This method validates that the input is a correctly formatted regex string 343 * and will throw a PatternSyntaxException if it's invalid. 344 * 345 * @param input the string to be treated as a regex pattern 346 * @return the validated regex pattern string 347 * @throws IllegalArgumentException if the pattern is not a valid regex 348 */ 349 private static String validateRegex(String input) { 350 try { 351 Pattern.compile(input); 352 return input; 353 } 354 catch (final PatternSyntaxException exc) { 355 throw new IllegalArgumentException("Invalid regex pattern: " + input, exc); 356 } 357 } 358 } 359 360 /** 361 * Represents the result of a header match check, containing information about any mismatch. 362 * 363 * @param isMatching whether the header matched 364 * @param lineNumber line number of mismatch (1-based) 365 * @param messageKey message key for violation 366 * @param messageArg message argument 367 */ 368 private record MatchResult( 369 boolean isMatching, 370 int lineNumber, 371 String messageKey, 372 String messageArg) { 373 374 /** 375 * Creates a matching result. 376 * 377 * @return a matching result 378 */ 379 /* package */ static MatchResult matching() { 380 return new MatchResult(true, 0, null, null); 381 } 382 383 /** 384 * Creates a mismatch result. 385 * 386 * @param lineNumber the line number where mismatch occurred (1-based) 387 * @param messageKey the message key for the violation 388 * @param messageArg the argument for the message 389 * @return a mismatch result 390 */ 391 /* package */ static MatchResult mismatch(int lineNumber, String messageKey, 392 String messageArg) { 393 return new MatchResult(false, lineNumber, messageKey, messageArg); 394 } 395 } 396 397}