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