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.LinkedHashMap; 026import java.util.Map; 027import java.util.Objects; 028import java.util.Optional; 029import java.util.Set; 030import java.util.regex.Matcher; 031import java.util.regex.Pattern; 032 033import org.apache.maven.doxia.macro.AbstractMacro; 034import org.apache.maven.doxia.macro.Macro; 035import org.apache.maven.doxia.macro.MacroExecutionException; 036import org.apache.maven.doxia.macro.MacroRequest; 037import org.apache.maven.doxia.sink.Sink; 038import org.codehaus.plexus.component.annotations.Component; 039 040/** 041 * A macro that generates an "In this article" table of contents for an xdoc 042 * page. Every canonical section key in {@link #SECTION_NAMES} is attempted 043 * for every page; the anchor id used in the link is read directly from a 044 * matching {@code <subsection>} tag's real {@code id} attribute, so links 045 * can never drift from what the page actually renders, and any section not 046 * present in a given file's source is silently skipped -- pages do not need 047 * to declare which sections they have. Examples and Use Cases subsections 048 * additionally get nested entries, titled either by the first non-default 049 * {@code property=value} found in the example's referenced source file, or 050 * by a shortened form of the example's descriptive paragraph when no 051 * distinguishing property can be found. 052 */ 053@Component(role = Macro.class, hint = "sitetoc") 054public class TocMacro extends AbstractMacro { 055 056 /** Section key/name: Description. */ 057 private static final String SECTION_DESCRIPTION = "Description"; 058 059 /** Section key/name: Properties. */ 060 private static final String SECTION_PROPERTIES = "Properties"; 061 062 /** Section key/name: Examples. */ 063 private static final String SECTION_EXAMPLES = "Examples"; 064 065 /** Section key: UseCases. */ 066 private static final String SECTION_USE_CASES_KEY = "UseCases"; 067 068 /** Closing quote and angle bracket used when terminating an HTML attribute. */ 069 private static final String QUOTE_CLOSE_TAG = "\">"; 070 071 /** Fallback title when no descriptive paragraph is available. */ 072 private static final String DEFAULT_TITLE = "Default configuration"; 073 074 /** A single double quote character, used to open HTML attribute values. */ 075 private static final String QUOTE = "\""; 076 077 /** Base directory that example {@code path} params are resolved against. */ 078 private static final String EXAMPLES_BASE_DIR = "src/xdocs-examples/"; 079 080 /** Matches an Example/UseCase paragraph followed immediately by its example macro. */ 081 private static final Pattern ITEM_WITH_PATH_PATTERN = Pattern.compile( 082 "<p\\s+id=\"((?:Example|UseCase)\\d+)-(config|raw)\"[^>]*>\\s*(.*?)\\s*</p>\\s*" 083 + "<macro\\s+name=\"example\">\\s*" 084 + "<param\\s+name=\"path\"\\s+value=\"([^\"]+)\"\\s*/>", 085 Pattern.DOTALL); 086 087 /** Matches a property assignment inside an example's embedded config comment. */ 088 private static final Pattern PROPERTY_PATTERN = Pattern.compile( 089 "<property\\s+name=\"([^\"]+)\"\\s+value=\"([^\"]*)\"\\s*/>"); 090 091 /** Strips the common "To configure the check to produce a violation on/when" lead-in. */ 092 private static final Pattern LEAD_IN_PATTERN = Pattern.compile( 093 "^\\s*To configure(?: the check)?" 094 + "(?:\\s+to\\s+(?:produce\\s+a\\s+violation)?)?" 095 + "(?:\\s+on)?" 096 + "(?:\\s+when)?\\s*", 097 Pattern.CASE_INSENSITIVE); 098 099 /** Strips inline HTML tags left in scraped paragraph text except {@code <code>} tags. */ 100 private static final Pattern TAG_PATTERN = Pattern.compile( 101 "</?(?!code\\b)[a-zA-Z][^>]*>"); 102 103 /** Collapses any run of whitespace (including newlines) into a single space. */ 104 private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); 105 106 /** Matches a subsection tag and captures its name/id attributes, in either order. */ 107 private static final Pattern SUBSECTION_TAG_PATTERN = Pattern.compile( 108 "<subsection\\s+(?:name=\"([^\"]*)\"\\s+id=\"([^\"]*)\"" 109 + "|id=\"([^\"]*)\"\\s+name=\"([^\"]*)\")"); 110 111 /** Property name to always skip when deriving a property=value title. */ 112 private static final String TOKENS_PROPERTY = "tokens"; 113 114 /** Regex group index of the {@code id} attribute when it appears second (name, id order). */ 115 private static final int GROUP_ID_NAME_FIRST = 2; 116 117 /** Regex group index of the {@code id} attribute when it appears first (id, name order). */ 118 private static final int GROUP_ID_ID_FIRST = 3; 119 120 /** Regex group index of the {@code name} attribute when it appears second (id, name order). */ 121 private static final int GROUP_NAME_ID_FIRST = 4; 122 123 /** 124 * Regex group index of the {@code path} param's value in 125 * {@link #ITEM_WITH_PATH_PATTERN}. Coincidentally the same numeric value 126 * as {@link #GROUP_ID_ID_FIRST}, but kept as a distinct constant since the 127 * two patterns and group meanings are otherwise unrelated. 128 */ 129 private static final int GROUP_EXAMPLE_PATH = 4; 130 131 /** Closing anchor/list-item tag pair used to end a TOC entry. */ 132 private static final String LI_CLOSE = "</a></li>\n"; 133 134 /** 135 * Maps every canonical section key, in the fixed order TOC entries 136 * should appear, to the subsection's {@code name} attribute value. Every 137 * key is attempted for every page; any section not present in a given 138 * file's source is silently skipped by {@link #writeSectionEntry}. 139 */ 140 private static final Map<String, String> SECTION_NAMES = new LinkedHashMap<>(); 141 142 static { 143 SECTION_NAMES.put(SECTION_DESCRIPTION, SECTION_DESCRIPTION); 144 SECTION_NAMES.put(SECTION_PROPERTIES, SECTION_PROPERTIES); 145 SECTION_NAMES.put(SECTION_EXAMPLES, SECTION_EXAMPLES); 146 SECTION_NAMES.put(SECTION_USE_CASES_KEY, "Use Cases"); 147 SECTION_NAMES.put("ExampleOfUsage", "Example of Usage"); 148 SECTION_NAMES.put("ViolationMessages", "Violation Messages"); 149 SECTION_NAMES.put("FullyQualifiedName", "Fully Qualified Name"); 150 SECTION_NAMES.put("ParentModule", "Parent Module"); 151 } 152 153 /** Section keys that get nested sub-entries. */ 154 private static final Set<String> NESTED_SECTIONS = 155 Set.of(SECTION_EXAMPLES, SECTION_USE_CASES_KEY); 156 157 /** 158 * Creates a new {@code TocMacro} instance. 159 */ 160 public TocMacro() { 161 // no code by default 162 } 163 164 @Override 165 public void execute(Sink sink, MacroRequest request) { 166 final String modulePath = (String) request.getParameter("modulePath"); 167 final String sourceContent = request.getSourceContent(); 168 169 final Map<String, PropertyDetails> propertyDetails = loadPropertyDetails(modulePath); 170 final Path repoRoot = resolveRepoRoot(modulePath); 171 final TocContext context = new TocContext(sourceContent, propertyDetails, repoRoot); 172 173 sink.rawText("<div class=\"toc-panel\">\n"); 174 sink.rawText(" <input type=\"checkbox\" id=\"toc-toggle\" " 175 + "class=\"toc-toggle-checkbox\" checked=\"checked\"/>\n"); 176 sink.rawText(" <label for=\"toc-toggle\" class=\"toc-toggle-arrow\" " 177 + "title=\"Collapse\"/>\n"); 178 sink.rawText(" <div class=\"toc-content\">\n"); 179 sink.rawText(" <p class=\"toc-heading\">On This Page</p>\n"); 180 sink.rawText(" <ul class=\"toc-list\">\n"); 181 182 for (String section : SECTION_NAMES.keySet()) { 183 writeSectionEntry(sink, section, context); 184 } 185 186 sink.rawText(" </ul>\n"); 187 sink.rawText(" </div>\n"); 188 sink.rawText("</div>"); 189 } 190 191 /** 192 * Writes a single top-level {@code <li>}, with nested Example/UseCase 193 * entries when the section is Examples or Use Cases. The anchor id is 194 * read directly from the matching {@code <subsection>} tag's actual 195 * {@code id} attribute in the source, rather than assumed from a naming 196 * convention, so the link can never drift from what the page really 197 * renders. If no matching {@code <subsection>} exists in the source, 198 * nothing is written for this section -- pages are not required to have 199 * every canonical section. 200 * 201 * @param sink sink to write to. 202 * @param section the canonical section key. 203 * @param context shared per-page context for resolving anchors and titles. 204 */ 205 private static void writeSectionEntry(Sink sink, String section, TocContext context) { 206 final String sectionName = SECTION_NAMES.get(section); 207 208 if (sectionName != null) { 209 final Optional<String> anchorId = 210 findSubsectionAnchor(context.sourceContent(), sectionName); 211 212 if (anchorId.isPresent()) { 213 final String anchor = anchorId.get(); 214 215 if (NESTED_SECTIONS.contains(section)) { 216 final String body = extractSectionBody(context.sourceContent(), anchor); 217 sink.rawText(" <li>\n"); 218 final String toggleId = "toc-sub-" + section; 219 sink.rawText(" <input type=\"checkbox\" id=\"" + toggleId 220 + "\" class=\"toc-sub-toggle-checkbox\" checked=\"checked\"/>\n"); 221 sink.rawText(" <label for=\"" 222 + toggleId + "\" class=\"toc-sub-toggle\">" 223 + "<a href=\"#" + anchor + QUOTE_CLOSE_TAG + sectionName + "</a>" 224 + "<span class=\"toc-sub-arrow\"/></label>\n"); 225 writeNestedItems(sink, body, context); 226 sink.rawText(" </li>\n"); 227 } 228 else { 229 sink.rawText(" <li><a href=\"#" + anchor + QUOTE_CLOSE_TAG 230 + sectionName + LI_CLOSE); 231 } 232 } 233 } 234 } 235 236 /** 237 * Finds the actual {@code id} attribute value of the {@code <subsection>} 238 * tag whose {@code name} attribute matches the given section name. 239 * 240 * @param sourceContent the full template source text. 241 * @param sectionName the subsection's {@code name} attribute value to match. 242 * @return the subsection's real id, or empty if no matching tag is found. 243 */ 244 private static Optional<String> findSubsectionAnchor(String sourceContent, 245 String sectionName) { 246 final Matcher matcher = SUBSECTION_TAG_PATTERN.matcher(sourceContent); 247 Optional<String> result = Optional.empty(); 248 249 while (matcher.find() && result.isEmpty()) { 250 final String nameNameFirst = matcher.group(1); 251 final String name; 252 if (nameNameFirst != null) { 253 name = nameNameFirst; 254 } 255 else { 256 name = matcher.group(GROUP_NAME_ID_FIRST); 257 } 258 final String idNameFirst = matcher.group(GROUP_ID_NAME_FIRST); 259 final String id; 260 if (idNameFirst != null) { 261 id = idNameFirst; 262 } 263 else { 264 id = matcher.group(GROUP_ID_ID_FIRST); 265 } 266 if (sectionName.equals(name)) { 267 result = Optional.of(id); 268 } 269 } 270 return result; 271 } 272 273 /** 274 * Extracts the text of one subsection from the full source, bounded by 275 * that subsection's opening tag and the next subsection's opening tag. 276 * 277 * @param sourceContent the full template source text. 278 * @param anchorId the subsection's id attribute value. 279 * @return the subsection's raw inner text, or an empty string if not found. 280 */ 281 private static String extractSectionBody(String sourceContent, String anchorId) { 282 String body = ""; 283 final int start = sourceContent.indexOf("id=\"" + anchorId + QUOTE); 284 if (start >= 0) { 285 final int nextSubsection = sourceContent.indexOf("<subsection", start + 1); 286 final int end; 287 if (nextSubsection >= 0) { 288 end = nextSubsection; 289 } 290 else { 291 end = sourceContent.length(); 292 } 293 body = sourceContent.substring(start, end); 294 } 295 return body; 296 } 297 298 /** 299 * Writes nested {@code <li>} entries for each Example/UseCase found 300 * within a subsection's body text, titled by property=value when 301 * possible, falling back to a shortened descriptive sentence. Titles 302 * are shown in full and allowed to wrap across lines, rather than being 303 * truncated with an ellipsis, so the whole label is always readable. 304 * 305 * @param sink sink to write to. 306 * @param sectionBody the raw text of the subsection. 307 * @param context shared per-page context for resolving titles. 308 */ 309 private static void writeNestedItems(Sink sink, String sectionBody, TocContext context) { 310 final Matcher itemMatcher = ITEM_WITH_PATH_PATTERN.matcher(sectionBody); 311 boolean hasItems = false; 312 313 while (itemMatcher.find()) { 314 if (!hasItems) { 315 sink.rawText(" <ul class=\"toc-sublist\">\n"); 316 hasItems = true; 317 } 318 final String anchorId = itemMatcher.group(1); 319 final String suffix = itemMatcher.group(2); 320 final String rawParagraph = itemMatcher.group(3); 321 final String fallbackTitle; 322 if (rawParagraph == null) { 323 fallbackTitle = DEFAULT_TITLE; 324 } 325 else { 326 fallbackTitle = toSentenceTitle(rawParagraph); 327 } 328 final String examplePath = itemMatcher.group(GROUP_EXAMPLE_PATH); 329 330 final String title; 331 if (examplePath == null) { 332 title = fallbackTitle; 333 } 334 else { 335 title = derivePropertyTitle(examplePath, context).orElse(fallbackTitle); 336 } 337 338 sink.rawText(" <li><a href=\"#" + anchorId 339 + "-" + suffix + "\" class=\"toc-sublink\">" + title + LI_CLOSE); 340 } 341 342 if (hasItems) { 343 sink.rawText(" </ul>\n"); 344 } 345 } 346 347 /** 348 * Loads documented property defaults for the current module, if a 349 * {@code modulePath} param was supplied. Returns an empty map otherwise, 350 * or if lookup fails for any reason -- property-based titling is a 351 * nice-to-have, not something that should break page generation. 352 * 353 * @param modulePath path to the module's Java source, or {@code null}. 354 * @return a map of property name to its documented details. 355 */ 356 private static Map<String, PropertyDetails> loadPropertyDetails(String modulePath) { 357 Map<String, PropertyDetails> result = Map.of(); 358 if (modulePath != null) { 359 try { 360 final Path modulePathObj = Path.of(modulePath.replace('\\', '/')); 361 final Path fileName = modulePathObj.getFileName(); 362 if (fileName != null) { 363 final String moduleName = fileName.toString().replace("Check.java", ""); 364 final Object instance = SiteUtil.getModuleInstance(moduleName); 365 result = SiteUtil.buildPropertyDetails( 366 SiteUtil.getPropertiesForDocumentation(instance.getClass(), instance), 367 moduleName, modulePathObj, instance); 368 } 369 } 370 catch (MacroExecutionException ignored) { 371 result = Map.of(); 372 } 373 } 374 return result; 375 } 376 377 /** 378 * Derives the repository root from the module's source path so example 379 * resource paths (relative to {@link #EXAMPLES_BASE_DIR}) can be resolved 380 * to an absolute file location. 381 * 382 * @param modulePath path to the module's Java source, or {@code null}. 383 * @return the repository root, or the current working directory if 384 * {@code modulePath} is absent or doesn't contain the expected marker. 385 */ 386 private static Path resolveRepoRoot(String modulePath) { 387 Path result = Path.of(""); 388 if (modulePath != null) { 389 final String normalized = modulePath.replace('\\', '/'); 390 final int marker = normalized.indexOf("src/main/java"); 391 if (marker > 0) { 392 result = Path.of(normalized.substring(0, marker)); 393 } 394 } 395 return result; 396 } 397 398 /** 399 * Reads an example's referenced source file and finds the first property 400 * whose value differs from its documented default, skipping {@code tokens}. 401 * 402 * @param examplePath the {@code path} param value from the example macro. 403 * @param context shared per-page context providing property defaults and repo root. 404 * @return an "name=value" title, or empty if the file can't be read or 405 * every property in it matches its documented default. 406 */ 407 private static Optional<String> derivePropertyTitle(String examplePath, TocContext context) { 408 Optional<String> result = Optional.empty(); 409 final Map<String, PropertyDetails> propertyDetails = context.propertyDetails(); 410 if (!propertyDetails.isEmpty()) { 411 try { 412 final Path fullPath = context.repoRoot() 413 .resolve(EXAMPLES_BASE_DIR + examplePath.replace('\\', '/')); 414 final String content = Files.readString(fullPath); 415 final Matcher propMatcher = PROPERTY_PATTERN.matcher(content); 416 417 while (propMatcher.find() && result.isEmpty()) { 418 result = extractPropertyTitle(propMatcher, propertyDetails); 419 } 420 } 421 catch (IOException ignored) { 422 result = Optional.empty(); 423 } 424 } 425 return result; 426 } 427 428 /** 429 * Extracts a property title from a matcher group if the property differs 430 * from its default value. 431 * 432 * @param propMatcher the property matcher positioned at a match. 433 * @param propertyDetails the documented property defaults. 434 * @return an "name=value" title, or empty if the property matches default. 435 */ 436 private static Optional<String> extractPropertyTitle( 437 Matcher propMatcher, Map<String, PropertyDetails> propertyDetails) { 438 final String name = propMatcher.group(1); 439 final String value = propMatcher.group(2); 440 Optional<String> result = Optional.empty(); 441 442 if (name != null && value != null && !TOKENS_PROPERTY.equals(name)) { 443 final PropertyDetails details = propertyDetails.get(name); 444 final boolean isDefault = details == null 445 || Objects.equals(details.getDefaultValue(), value); 446 if (!isDefault) { 447 result = Optional.of(name + "=" + value); 448 } 449 } 450 return result; 451 } 452 453 /** 454 * Converts a raw "ExampleN-config"/"UseCaseN-config" paragraph into a 455 * short sentence-based title by removing inline markup, normalizing 456 * whitespace, stripping the common lead-in clause, and trimming the 457 * trailing colon. Used as a fallback when no distinguishing property 458 * can be found. 459 * 460 * @param rawParagraph the paragraph's raw inner text. 461 * @return a short, human-readable title. 462 */ 463 private static String toSentenceTitle(String rawParagraph) { 464 String text = TAG_PATTERN.matcher(rawParagraph).replaceAll(""); 465 text = normalizeWhitespace(text); 466 text = LEAD_IN_PATTERN.matcher(text).replaceFirst(""); 467 text = text.strip(); 468 if (text.endsWith(":")) { 469 text = text.substring(0, text.length() - 1); 470 } 471 if (text.isEmpty()) { 472 text = DEFAULT_TITLE; 473 } 474 else { 475 text = Character.toUpperCase(text.charAt(0)) + text.substring(1); 476 } 477 return text; 478 } 479 480 /** 481 * Collapses internal newlines/indentation from raw xdoc paragraph text 482 * into single spaces, so tooltips and labels render on one line. 483 * 484 * @param text the raw text, possibly containing multi-line whitespace. 485 * @return the text with all whitespace runs collapsed to a single space. 486 */ 487 private static String normalizeWhitespace(String text) { 488 return WHITESPACE_PATTERN.matcher(text).replaceAll(" ").strip(); 489 } 490 491 /** 492 * Bundles the per-page context needed while writing TOC entries, so 493 * helper methods don't need long parameter lists. 494 * 495 * @param sourceContent the full template source text. 496 * @param propertyDetails documented property defaults for this module. 497 * @param repoRoot repository root, for resolving example file paths. 498 */ 499 private record TocContext(String sourceContent, 500 Map<String, PropertyDetails> propertyDetails, Path repoRoot) { 501 } 502 503}