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.site; 021 022import java.io.IOException; 023import java.nio.file.Files; 024import java.nio.file.Path; 025import java.util.ArrayList; 026import java.util.Collection; 027import java.util.List; 028import java.util.Locale; 029import java.util.stream.Collectors; 030 031import org.apache.maven.doxia.macro.AbstractMacro; 032import org.apache.maven.doxia.macro.Macro; 033import org.apache.maven.doxia.macro.MacroExecutionException; 034import org.apache.maven.doxia.macro.MacroRequest; 035import org.apache.maven.doxia.sink.Sink; 036import org.codehaus.plexus.component.annotations.Component; 037 038import com.puppycrawl.tools.checkstyle.utils.InlineConfigUtils; 039 040/** 041 * A macro that inserts a snippet of code or configuration from a file. 042 */ 043@Component(role = Macro.class, hint = "example") 044public class ExampleMacro extends AbstractMacro { 045 046 /** Starting delimiter for code snippets. */ 047 private static final String CODE_SNIPPET_START = "xdoc section - start"; 048 049 /** Ending delimiter for code snippets. */ 050 private static final String CODE_SNIPPET_END = "xdoc section - end"; 051 052 /** File extension for .properties files. */ 053 private static final String PROPERTIES_EXTENSION = ".properties"; 054 055 /** The {@code prettyprint} language class for XML content. */ 056 private static final String LANGUAGE_XML = "language-xml"; 057 058 /** The path of the last file. */ 059 private String lastPath = ""; 060 061 /** The line contents of the last file. */ 062 private List<String> lastLines = new ArrayList<>(); 063 064 /** 065 * Creates a new {@code ExampleMacro} instance. 066 */ 067 public ExampleMacro() { 068 // no code by default 069 } 070 071 @Override 072 public void execute(Sink sink, MacroRequest request) throws MacroExecutionException { 073 final String path = (String) request.getParameter("path"); 074 final String type = (String) request.getParameter("type"); 075 076 List<String> lines = lastLines; 077 if (!path.equals(lastPath)) { 078 lines = readFile("src/xdocs-examples/" + path); 079 lastPath = path; 080 lastLines = lines; 081 } 082 083 if ("config".equals(type)) { 084 writeConfigSnippet(sink, lines, path); 085 } 086 else if ("code".equals(type)) { 087 writeCodeSnippet(sink, lines, path); 088 } 089 else if ("raw".equals(type)) { 090 final String content = String.join(ModuleJavadocParsingUtil.NEWLINE, lines); 091 writeSnippet(sink, content, getExtension(path), false); 092 } 093 else { 094 final String message = String.format(Locale.ROOT, "Unknown example type: %s", type); 095 throw new MacroExecutionException(message); 096 } 097 } 098 099 /** 100 * Extracts the config snippet from the given lines and writes it to the sink. 101 * 102 * @param sink the sink to write to. 103 * @param lines the lines of the source file. 104 * @param path the file path, used for extension detection and error messages. 105 * @throws MacroExecutionException if the config block is invalid or empty. 106 */ 107 private static void writeConfigSnippet(Sink sink, List<String> lines, String path) 108 throws MacroExecutionException { 109 final String extension = getExtension(path); 110 final String config; 111 try { 112 config = getConfigSnippet(lines, extension); 113 } 114 catch (IllegalArgumentException illegalArgumentException) { 115 final String message = String.format(Locale.ROOT, 116 "%s (in %s)", illegalArgumentException.getMessage(), path 117 ); 118 throw new MacroExecutionException(message, illegalArgumentException); 119 } 120 121 if (config.isBlank()) { 122 final String message = String.format(Locale.ROOT, 123 "Empty config snippet from %s, check" 124 + " for xml config snippet delimiters in input file.", path 125 ); 126 throw new MacroExecutionException(message); 127 } 128 129 writeSnippet(sink, config, extension, true); 130 } 131 132 /** 133 * Extracts the code snippet from the given lines and writes it to the sink. 134 * 135 * @param sink the sink to write to. 136 * @param lines the lines of the source file. 137 * @param path the file path, used for tab-replacement detection and error messages. 138 * @throws MacroExecutionException if the code snippet is empty. 139 */ 140 private static void writeCodeSnippet(Sink sink, List<String> lines, String path) 141 throws MacroExecutionException { 142 String code = getCodeSnippet(lines); 143 // Replace tabs with spaces for FileTabCharacterCheck examples 144 if (path.contains("filetabcharacter")) { 145 code = code.replace("\t", " "); 146 } 147 148 if (code.isBlank()) { 149 final String message = String.format(Locale.ROOT, 150 "Empty code snippet from %s, check" 151 + " for code snippet delimiters in input file.", path 152 ); 153 throw new MacroExecutionException(message); 154 } 155 156 writeSnippet(sink, code, getExtension(path), false); 157 } 158 159 /** 160 * Read the file at the given path and returns its contents as a list of lines. 161 * 162 * @param path the path to the file to read. 163 * @return the contents of the file as a list of lines. 164 * @throws MacroExecutionException if the file could not be read. 165 */ 166 private static List<String> readFile(String path) throws MacroExecutionException { 167 try { 168 final Path exampleFilePath = Path.of(path); 169 return Files.readAllLines(exampleFilePath); 170 } 171 catch (IOException ioException) { 172 final String message = String.format(Locale.ROOT, "Failed to read %s", path); 173 throw new MacroExecutionException(message, ioException); 174 } 175 } 176 177 /** 178 * Extracts the file extension (including the leading dot) from a file path, or an 179 * empty string if the path has no extension. 180 * 181 * @param path the file path. 182 * @return the file extension, e.g. {@code ".xml"}, or {@code ""} if none. 183 */ 184 private static String getExtension(String path) { 185 final int dotIndex = path.lastIndexOf('.'); 186 String result = ""; 187 if (dotIndex >= 0) { 188 result = path.substring(dotIndex); 189 } 190 return result; 191 } 192 193 /** 194 * Extracts the configuration snippet from the given lines. 195 * 196 * @param lines the lines to extract the config from. 197 * @param extension the file extension (e.g. {@code ".xml"}), used to decide which 198 * delimiter conventions are valid. 199 * @return the configuration snippet. 200 * @throws IllegalArgumentException if the config block is invalid. 201 */ 202 private static String getConfigSnippet(Collection<String> lines, String extension) { 203 final List<String> linesList = new ArrayList<>(lines); 204 final InlineConfigUtils.MatchedDelimiter matched = 205 InlineConfigUtils.matchDelimiter(linesList, extension); 206 207 if (matched == null) { 208 final String message = String.format(Locale.ROOT, 209 "No valid config block found. Expected the first line to be %s.", 210 InlineConfigUtils.describeExpectedDelimiters(extension) 211 ); 212 throw new IllegalArgumentException(message); 213 } 214 215 final int endIndex = InlineConfigUtils.getConfigEndIndex(linesList, matched); 216 if (endIndex <= 0) { 217 final String message = String.format(Locale.ROOT, 218 "Config start delimiter found but no matching end delimiter \"%s\".", 219 matched.end() 220 ); 221 throw new IllegalArgumentException(message); 222 } 223 224 final List<String> configLines = linesList.subList(1, endIndex); 225 final String result; 226 if (PROPERTIES_EXTENSION.equals(extension)) { 227 result = String.join(ModuleJavadocParsingUtil.NEWLINE, 228 InlineConfigUtils.stripPropertiesCommentPrefix(configLines)); 229 } 230 else { 231 result = String.join(ModuleJavadocParsingUtil.NEWLINE, configLines); 232 } 233 234 return result; 235 } 236 237 /** 238 * Extract a code snippet from the given lines. Code delimiters can be indented, so 239 * we use contains() instead of equals(). If the delimiters are not found, returns 240 * the file content excluding the XML config block (if present). 241 * 242 * @param lines the lines to extract the snippet from. 243 * @return the code snippet. 244 */ 245 private static String getCodeSnippet(Collection<String> lines) { 246 final String snippet = lines.stream() 247 .dropWhile(line -> !hasCodeSnippetStart(line)) 248 .skip(1) 249 .takeWhile(line -> !hasCodeSnippetEnd(line)) 250 .collect(Collectors.joining(ModuleJavadocParsingUtil.NEWLINE)); 251 252 // If no snippet was found (markers not present), return the file content 253 // excluding the XML config block (if present) 254 final String result; 255 if (snippet.isBlank()) { 256 final List<String> linesList = new ArrayList<>(lines); 257 final int configEndIndex = linesList.indexOf(InlineConfigUtils.JAVA_CONFIG_END); 258 if (configEndIndex >= 0) { 259 // XML config block is present, return content after it 260 result = String.join(ModuleJavadocParsingUtil.NEWLINE, 261 linesList.stream() 262 .skip(configEndIndex + 1) 263 .toList()); 264 } 265 else { 266 // No XML config block, return entire file 267 result = String.join(ModuleJavadocParsingUtil.NEWLINE, linesList); 268 } 269 } 270 else { 271 result = snippet; 272 } 273 274 return result; 275 } 276 277 /** 278 * Checks if the line contains a code snippet start delimiter. 279 * 280 * @param line the line to check. 281 * @return true if the line contains a start delimiter. 282 */ 283 private static boolean hasCodeSnippetStart(String line) { 284 return line.contains(CODE_SNIPPET_START); 285 } 286 287 /** 288 * Checks if the line contains a code snippet end delimiter. 289 * 290 * @param line the line to check. 291 * @return true if the line contains an end delimiter. 292 */ 293 private static boolean hasCodeSnippetEnd(String line) { 294 return line.contains(CODE_SNIPPET_END); 295 } 296 297 /** 298 * Writes the given snippet inside a formatted source block, choosing syntax 299 * highlighting based on the source file's extension. Config snippets (the content 300 * inside a Java-comment-style config block) are always XML regardless of the 301 * target file's extension, except for {@code .properties} files, whose bare 302 * {@code #}-comment config block is not highlighted at all. 303 * 304 * @param sink the sink to write to. 305 * @param snippet the snippet to write. 306 * @param extension the source file extension, e.g. {@code ".xml"}, {@code ".sql"}. 307 * @param isConfig true if the snippet is a config block rather than a code block. 308 */ 309 private static void writeSnippet(Sink sink, String snippet, String extension, 310 boolean isConfig) { 311 sink.rawText("<div class=\"wrapper\">"); 312 313 final String languageClass = determineLanguageClass(extension, isConfig); 314 final String codeOpenTag; 315 if (languageClass == null) { 316 codeOpenTag = "<pre class=\"prettyprint\"><code>"; 317 } 318 else { 319 codeOpenTag = "<pre class=\"prettyprint\"><code class=\"" + languageClass + "\">"; 320 } 321 322 sink.rawText(codeOpenTag + ModuleJavadocParsingUtil.NEWLINE); 323 sink.rawText(escapeHtml(snippet).trim() + ModuleJavadocParsingUtil.NEWLINE); 324 sink.rawText("</code></pre>"); 325 sink.rawText("</div>"); 326 } 327 328 /** 329 * Determines the {@code prettyprint} language class to use for a snippet, based on 330 * the source file's extension. 331 * 332 * @param extension the source file extension, e.g. {@code ".xml"}, {@code ".sql"}. 333 * @param isConfig true if the snippet is a config block (always XML syntax, except 334 * for {@code .properties} files. 335 * @return the language class to apply, or {@code null} if the snippet should not be 336 * syntax-highlighted. 337 */ 338 private static String determineLanguageClass(String extension, boolean isConfig) { 339 final String result; 340 if (PROPERTIES_EXTENSION.equals(extension)) { 341 result = null; 342 } 343 else if (isConfig || ".xml".equals(extension)) { 344 // Config blocks are always <module> XML, regardless of target file type. 345 result = LANGUAGE_XML; 346 } 347 else if (".sql".equals(extension)) { 348 result = "language-sql"; 349 } 350 else { 351 result = "language-java"; 352 } 353 return result; 354 } 355 356 /** 357 * Escapes HTML special characters in the snippet. 358 * 359 * @param snippet the snippet to escape. 360 * @return the escaped snippet. 361 */ 362 private static String escapeHtml(String snippet) { 363 return snippet.replace("&", "&") 364 .replace("<", "<") 365 .replace(">", ">"); 366 } 367 368}