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.File; 023import java.io.IOException; 024import java.io.PrintWriter; 025import java.nio.file.Files; 026import java.nio.file.Path; 027import java.util.ArrayList; 028import java.util.Arrays; 029import java.util.HashSet; 030import java.util.LinkedHashMap; 031import java.util.LinkedHashSet; 032import java.util.List; 033import java.util.Locale; 034import java.util.Map; 035import java.util.Set; 036import java.util.regex.Matcher; 037import java.util.regex.Pattern; 038import java.util.stream.Collectors; 039 040import javax.xml.parsers.DocumentBuilder; 041import javax.xml.parsers.DocumentBuilderFactory; 042import javax.xml.parsers.ParserConfigurationException; 043 044import org.w3c.dom.Document; 045import org.w3c.dom.Element; 046import org.w3c.dom.NodeList; 047import org.xml.sax.SAXException; 048 049/** 050 * Generates {@code search-index.json} from the Checkstyle XDoc source files. 051 * 052 * <p>This is a plain Java {@code main()} class - no Maven plugin API required. 053 * It is invoked by {@code exec-maven-plugin} during the {@code process-classes} 054 * phase so the index is ready when Maven Site copies static resources.</p> 055 * 056 * <p>Output is written as a JSON file. The search widget fetches this file 057 * using the fetch API and parses it to populate the search index.</p> 058 * 059 * <h2>Key design decisions</h2> 060 * <ul> 061 * <li><b>No duplicates.</b> Only plain {@code .xml} files are processed for 062 * check/filter/filefilter directories. The {@code .xml.template} and 063 * {@code .xml.vm} siblings are pre-render source files that would produce 064 * identical URLs and duplicate entries. A secondary URL-keyed dedup guard 065 * is also applied across the entire output list.</li> 066 * 067 * <li><b>Identifiable example titles.</b> Both {@code -config} and 068 * {@code -code} example paragraphs are indexed. Their titles use the 069 * pattern {@code "<CheckName>: Example1 [config]"} and 070 * {@code "<CheckName>: Example1 [code]"} so users can distinguish a 071 * configuration snippet from its matching Java code example in search 072 * results.</li> 073 * 074 * <li><b>Full general-page indexing.</b> Each meaningful {@code <section>} 075 * in general documentation pages (e.g. {@code config_system_properties}, 076 * {@code writingchecks}, {@code cmdline}) is indexed as its own entry 077 * with the full section text used for keyword extraction - not just the 078 * first sentence. This makes page-internal headings discoverable.</li> 079 * 080 * <li><b>Disambiguated generic titles.</b> Structural section names that are 081 * repeated across many pages (e.g. "Overview", "Debug", "Contributing") 082 * are prefixed with the page title, yielding e.g. 083 * "Eclipse IDE: Debug" instead of a bare "Debug" that collides with 084 * "IntelliJ IDE: Debug".</li> 085 * 086 * <li><b>Junk pages excluded.</b> Release notes, auto-generated style 087 * coverage reports and bare category aggregator stubs are skipped.</li> 088 * </ul> 089 * 090 * <p>Usage (called by exec-maven-plugin in pom.xml):</p> 091 * <pre> 092 * java SearchIndexGenerator <xdocsDir> <outputFilePath> 093 * java SearchIndexGenerator src/site/xdoc target/site/search-index.json 094 * </pre> 095 */ 096public final class SearchIndexGenerator { 097 098 /** String literal for checks directory. */ 099 private static final String CHECKS = "checks"; 100 101 /** String literal for comma. */ 102 private static final String COMMA_STR = ","; 103 104 /** String literal for space. */ 105 private static final String SPACE = " "; 106 107 /** Character literal for space. */ 108 private static final char SPACE_CHAR = ' '; 109 110 /** String literal for colon separator used in disambiguated titles. */ 111 private static final String TITLE_SEPARATOR = ": "; 112 113 /** String literal for ellipsis. */ 114 private static final String ELLIPSIS = "..."; 115 116 /** String literal for external general entities feature. */ 117 private static final String EXTERNAL_GENERAL_ENTITIES = 118 "http://xml.org/sax/features/external-general-entities"; 119 120 /** String literal for external parameter entities feature. */ 121 private static final String EXTERNAL_PARAMETER_ENTITIES = 122 "http://xml.org/sax/features/external-parameter-entities"; 123 124 /** String literal for General category. */ 125 private static final String GENERAL = "General"; 126 127 /** String literal for Example document type. */ 128 private static final String EXAMPLE_TYPE = "Example"; 129 130 /** String literal for Property document type. */ 131 private static final String PROPERTY_TYPE = "Property"; 132 133 /** String literal for subsection element. */ 134 private static final String SUBSECTION = "subsection"; 135 136 /** String literal for name attribute. */ 137 private static final String NAME_ATTR = "name"; 138 139 /** String literal for id attribute. */ 140 private static final String ID_ATTR = "id"; 141 142 /** String literal for index.xml. */ 143 private static final String INDEX_XML = "index.xml"; 144 145 /** Constant for the filters directory. */ 146 private static final String FILTERS_DIR = "filters"; 147 148 /** Constant for the filefilters directory. */ 149 private static final String FILEFILTERS_DIR = "filefilters"; 150 151 /** Constant for the index file name. */ 152 private static final String INDEX_HTML = "index.html"; 153 154 /** String literal for Content. */ 155 private static final String CONTENT = "Content"; 156 157 /** String literal for the Examples subsection name. */ 158 private static final String EXAMPLES_SUBSECTION = "examples"; 159 160 /** String literal for body element. */ 161 private static final String BODY = "body"; 162 163 /** String literal for section element. */ 164 private static final String SECTION = "section"; 165 166 /** String literal for title element. */ 167 private static final String TITLE = "title"; 168 169 /** String literal for description element. */ 170 private static final String DESCRIPTION = "description"; 171 172 /** String literal for anchor separator. */ 173 private static final String ANCHOR_SEPARATOR = "#"; 174 175 /** String literal for path separator in URLs. */ 176 private static final String PATH_SEPARATOR = "/"; 177 178 /** String literal for the Properties subsection name fragment. */ 179 private static final String PROPERTIES_FRAGMENT = "propert"; 180 181 /** Exception message prefix used when an XDoc file fails to parse. */ 182 private static final String PARSE_FAILURE_MSG = "Failed to parse XDoc file: "; 183 184 /** 185 * Suffix label appended to example titles for configuration snippets. 186 * Yields e.g. "AnnotationLocation: Example1 [config]". 187 */ 188 private static final String EXAMPLE_LABEL_CONFIG = " [config]"; 189 190 /** 191 * Suffix label appended to example titles for Java code examples. 192 * Yields e.g. "AnnotationLocation: Example1 [code]". 193 */ 194 private static final String EXAMPLE_LABEL_CODE = " [code]"; 195 196 /** Magic number for minimum word length. */ 197 private static final int MIN_WORD_LENGTH = 2; 198 199 /** Magic number for maximum keywords. */ 200 private static final int MAX_KEYWORDS = 15; 201 202 /** Magic number for maximum description length. */ 203 private static final int MAX_DESCRIPTION_LENGTH = 150; 204 205 /** Whitespace pattern. */ 206 private static final Pattern WHITESPACE = Pattern.compile("\\s+"); 207 208 /** Non-alphanumeric pattern. */ 209 private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^a-z0-9]+"); 210 211 /** 212 * Matches only plain {@code .xml} files (not {@code .xml.vm} or 213 * {@code .xml.template}). Used when scanning check/filter/filefilter 214 * directories to avoid processing pre-render source templates and 215 * producing duplicate index entries. 216 */ 217 private static final Pattern PLAIN_XML = Pattern.compile("\\.xml$"); 218 219 /** 220 * Matches {@code .xml}, {@code .xml.vm} and {@code .xml.template}. 221 * Used only for URL building (stripping the extension to produce a 222 * {@code .html} path) and for the general-pages scanner where we 223 * want to exclude templates by name rather than by extension. 224 */ 225 private static final Pattern DOC_EXTENSION = 226 Pattern.compile("\\.xml$|\\.xml\\.vm$|\\.xml\\.template$"); 227 228 /** 229 * Matches {@code config_<category>.xml} files that redirect to check category pages. 230 * Captures the category name (e.g. "metrics" from "config_metrics.xml") in group 1. 231 */ 232 private static final Pattern CONFIG_CATEGORY = 233 Pattern.compile("^config_(.+)\\.xml$"); 234 235 /** 236 * Matches an example paragraph {@code id} attribute that has a suffix of 237 * either {@code -config} or {@code -code}, capturing the base label 238 * (e.g. "Example1") in group 1 and the type ("config" or "code") in 239 * group 2. 240 * 241 * <p>Example ids found in XDoc source:</p> 242 * <ul> 243 * <li>{@code id="Example1-config"} -> label "Example1", type "config"</li> 244 * <li>{@code id="Example1-code"} -> label "Example1", type "code"</li> 245 * </ul> 246 */ 247 private static final Pattern EXAMPLE_PARAGRAPH_ID = 248 Pattern.compile("^(Example\\d+)-(config|code)$"); 249 250 /** 251 * Generic section/subsection names that are structurally repeated across 252 * many unrelated general pages (IDE setup guides, writing-* guides, etc). 253 * On their own they are meaningless in search results ("Debug" appears 254 * identically in eclipse.xml, idea.xml, and netbeans.xml) so when one of 255 * these is used as a section title it is always disambiguated with the 256 * source page's own title, e.g. "Eclipse IDE: Debug". 257 */ 258 private static final Set<String> GENERIC_SECTION_NAMES = new HashSet<>(Arrays.asList( 259 "overview", DESCRIPTION, EXAMPLES_SUBSECTION, "example", "debug", 260 "contributing", "limitations", "parameters", "installation" 261 )); 262 263 /** 264 * Display names for the check category subdirectories under 265 * {@code checks/}, keyed by lowercase directory name. Every directory 266 * that exists under {@code checks/} must have an entry here - 267 * {@link #processChecksDirectory} fails fast if one is missing, so a 268 * contributor adding a new category is forced to register its display 269 * name instead of getting a guessed-at label. 270 */ 271 private static final Map<String, String> CHECKS_CATEGORY_DISPLAY_NAMES = new LinkedHashMap<>(); 272 273 static { 274 CHECKS_CATEGORY_DISPLAY_NAMES.put("annotation", "Annotations"); 275 CHECKS_CATEGORY_DISPLAY_NAMES.put("blocks", "Block Checks"); 276 CHECKS_CATEGORY_DISPLAY_NAMES.put("coding", "Coding"); 277 CHECKS_CATEGORY_DISPLAY_NAMES.put("design", "Class Design"); 278 CHECKS_CATEGORY_DISPLAY_NAMES.put("header", "Headers"); 279 CHECKS_CATEGORY_DISPLAY_NAMES.put("imports", "Imports"); 280 CHECKS_CATEGORY_DISPLAY_NAMES.put("javadoc", "Javadoc Comments"); 281 CHECKS_CATEGORY_DISPLAY_NAMES.put("metrics", "Metrics"); 282 CHECKS_CATEGORY_DISPLAY_NAMES.put("misc", "Miscellaneous"); 283 CHECKS_CATEGORY_DISPLAY_NAMES.put("modifier", "Modifiers"); 284 CHECKS_CATEGORY_DISPLAY_NAMES.put("naming", "Naming Conventions"); 285 CHECKS_CATEGORY_DISPLAY_NAMES.put("regexp", "Regexp"); 286 CHECKS_CATEGORY_DISPLAY_NAMES.put("sizes", "Size Violations"); 287 CHECKS_CATEGORY_DISPLAY_NAMES.put("whitespace", "Whitespace"); 288 } 289 290 /** Stop words: too generic to be useful as search keywords. */ 291 private static final Set<String> STOP_WORDS = new HashSet<>(Arrays.asList( 292 "a", "an", "the", "and", "or", "of", "to", "in", "is", "it", 293 "that", "this", "for", "on", "with", "are", "be", "by", "at", 294 "as", "if", "its", "from", "which", "whether", "can", "will", 295 "has", "have", "not", "also", "only", "any", "all", "each", 296 "more", "than", "when", "then", "into", "such", "use", "used", 297 "check", CHECKS, "checkstyle" 298 )); 299 300 /** Accumulated search index entries. */ 301 private List<SearchIndexEntry> entries; 302 303 /** Deduplication guard for URLs. */ 304 private Set<String> seenUrls; 305 306 /** Prevent instantiation. */ 307 private SearchIndexGenerator() { 308 } 309 310 /** 311 * Main entry point called by exec-maven-plugin. 312 * 313 * @param args args[0] = path to src/xdocs, args[1] = path to target/site 314 * @throws IOException on file write failure 315 * @throws IllegalArgumentException if args are missing 316 * @throws IllegalStateException if xdocsDir is missing 317 * @noinspectionreason UseOfSystemOutOrSystemErr - main method of a CLI utility 318 */ 319 public static void main(String... args) throws IOException { 320 new SearchIndexGenerator().execute(args); 321 } 322 323 /** 324 * Internal execution method to avoid static context for the logger. 325 * 326 * @param args args[0] = path to src/xdocs, args[1] = output file path 327 * @throws IOException on file write failure 328 * @throws IllegalArgumentException if args are missing 329 * @throws IllegalStateException if xdocsDir is missing 330 */ 331 private void execute(String... args) throws IOException { 332 if (args.length < 2) { 333 throw new IllegalArgumentException( 334 "Usage: SearchIndexGenerator <xdocsDir> <outputFilePath>"); 335 } 336 337 final Path xdocsPath = Path.of(args[0]); 338 final Path outputFilePath = Path.of(args[1]); 339 final File xdocsDir = xdocsPath.toFile(); 340 341 if (!Files.exists(xdocsPath)) { 342 final String error = "[SearchIndex] ERROR: xdocsDir not found: " 343 + xdocsPath.toAbsolutePath(); 344 throw new IllegalStateException(error); 345 } 346 347 seenUrls = new LinkedHashSet<>(); 348 entries = new ArrayList<>(); 349 350 final Path checksPath = xdocsPath.resolve(CHECKS); 351 if (Files.exists(checksPath)) { 352 processChecksDirectory(checksPath.toFile(), xdocsDir); 353 } 354 355 final Path filtersPath = xdocsPath.resolve(FILTERS_DIR); 356 if (Files.exists(filtersPath)) { 357 processDirectory(filtersPath.toFile(), xdocsDir, 358 "Filters", "Filter"); 359 } 360 361 final Path fileFiltersPath = xdocsPath.resolve(FILEFILTERS_DIR); 362 if (Files.exists(fileFiltersPath)) { 363 processDirectory(fileFiltersPath.toFile(), xdocsDir, 364 "File Filters", "File Filter"); 365 } 366 367 processGeneralPages(xdocsDir); 368 writeJson(entries, outputFilePath); 369 370 } 371 372 /** 373 * Walks {@code src/xdocs/checks/} and processes each category subdirectory. 374 * 375 * <p>Every directory found here must have a corresponding entry in 376 * {@link #CHECKS_CATEGORY_DISPLAY_NAMES}; an unmapped directory likely 377 * means a new check category was added without registering its display 378 * name, so this fails fast rather than guessing a label from the 379 * directory name.</p> 380 * 381 * @param checksDir the checks root directory 382 * @param xdocsDir the xdocs root (used for URL building) 383 * @throws IllegalStateException if {@code checksDir} cannot be listed, or 384 * if one of its subdirectories has no entry in 385 * {@link #CHECKS_CATEGORY_DISPLAY_NAMES} 386 */ 387 private void processChecksDirectory(File checksDir, File xdocsDir) { 388 final File[] categoryDirs = checksDir.listFiles(File::isDirectory); 389 if (categoryDirs == null) { 390 throw new IllegalStateException( 391 "Unable to list check category directories under: " + checksDir); 392 } 393 394 Arrays.sort(categoryDirs); 395 for (File categoryDir : categoryDirs) { 396 final String dirName = categoryDir.getName().toLowerCase(Locale.ROOT); 397 final String category = CHECKS_CATEGORY_DISPLAY_NAMES.get(dirName); 398 if (category == null) { 399 throw new IllegalStateException( 400 "No display name registered for check category directory '" 401 + dirName + "' in CHECKS_CATEGORY_DISPLAY_NAMES. " 402 + "Please add one."); 403 } 404 processDirectory(categoryDir, xdocsDir, category, "Check"); 405 } 406 } 407 408 /** 409 * Processes all <b>plain</b> {@code .xml} files in a directory 410 * (non-recursive). {@code index.xml} files and any file whose name ends 411 * with {@code .xml.template} or {@code .xml.vm} are skipped. 412 * 413 * <p>Skipping templates is critical: every check page has a sibling 414 * {@code *.xml.template} file that resolves to the <em>same</em> HTML 415 * URL. Without this filter both files would be processed, producing two 416 * identical (or near-identical) main entries plus doubled example and 417 * property entries for every check.</p> 418 * 419 * <p>For each plain {@code .xml} file, the main check/filter entry, 420 * per-example entries (both config and code), and per-property entries 421 * are added.</p> 422 * 423 * @param dir directory to scan 424 * @param xdocsDir xdocs root (used for URL building) 425 * @param category category label for all entries in this directory 426 * @param type document type ("Check", "Filter", "File Filter") 427 */ 428 private void processDirectory(File dir, File xdocsDir, 429 String category, String type) { 430 final File[] xmlFiles = dir.listFiles(file -> { 431 return file.isFile() 432 && PLAIN_XML.matcher(file.getName()).find() 433 && !INDEX_XML.equals(file.getName()); 434 }); 435 436 if (xmlFiles != null) { 437 Arrays.sort(xmlFiles); 438 for (File xmlFile : xmlFiles) { 439 processXmlFile(xmlFile, xdocsDir, category, type); 440 } 441 } 442 } 443 444 /** 445 * Parses a single check/filter XDoc file and adds its main, example, and 446 * property entries to the index. 447 * 448 * <p>A parse failure here means the source XDoc itself is malformed, 449 * which is a real problem with the documentation rather than something 450 * safe to skip - so this fails the build instead of logging a warning 451 * and silently continuing.</p> 452 * 453 * @param xmlFile the XDoc source file to process 454 * @param xdocsDir xdocs root (used for URL building) 455 * @param category category label for entries from this file 456 * @param type document type ("Check", "Filter", "File Filter") 457 * @throws IllegalStateException if {@code xmlFile} cannot be parsed 458 */ 459 private void processXmlFile(File xmlFile, File xdocsDir, String category, String type) { 460 try { 461 final Document doc = parseXml(xmlFile); 462 final String baseUrl = buildUrl(xmlFile, xdocsDir); 463 464 addIfNew(buildMainEntry(doc, xmlFile, category, type, baseUrl)); 465 466 for (SearchIndexEntry entry : extractExampleEntries(doc, baseUrl, category)) { 467 addIfNew(entry); 468 } 469 for (SearchIndexEntry entry : extractPropertyEntries(doc, baseUrl, category)) { 470 addIfNew(entry); 471 } 472 } 473 catch (IOException | SAXException | ParserConfigurationException exception) { 474 throw new IllegalStateException(PARSE_FAILURE_MSG + xmlFile, exception); 475 } 476 } 477 478 /** 479 * Adds entries for the top-level general documentation pages. 480 * 481 * <p>Each remaining page is indexed per top-level {@code <section>}, 482 * using the section's full text content for keyword extraction so 483 * page-internal headings are fully discoverable. Generic structural 484 * section names (see {@link #GENERIC_SECTION_NAMES}) are disambiguated 485 * by prefixing the page's own title.</p> 486 * 487 * @param xdocsDir the xdocs root directory 488 */ 489 private void processGeneralPages(File xdocsDir) { 490 final File[] xmlFiles = xdocsDir.listFiles(file -> { 491 final String name = file.getName(); 492 return file.isFile() 493 && PLAIN_XML.matcher(name).find(); 494 }); 495 496 if (xmlFiles != null) { 497 Arrays.sort(xmlFiles); 498 for (File xmlFile : xmlFiles) { 499 processGeneralPage(xmlFile); 500 } 501 } 502 } 503 504 /** 505 * Parses a single general-documentation XDoc page and adds its 506 * per-section entries to the index. 507 * 508 * <p>A parse failure here means the source XDoc itself is malformed, so 509 * this fails the build instead of logging a warning and continuing.</p> 510 * 511 * @param xmlFile the XDoc source file to process 512 * @throws IllegalStateException if {@code xmlFile} cannot be parsed 513 */ 514 private void processGeneralPage(File xmlFile) { 515 try { 516 for (SearchIndexEntry entry : buildGeneralPageEntries(xmlFile)) { 517 addIfNew(entry); 518 } 519 } 520 catch (IOException | SAXException | ParserConfigurationException exception) { 521 throw new IllegalStateException(PARSE_FAILURE_MSG + xmlFile, exception); 522 } 523 } 524 525 /** 526 * Builds the main search entry representing an entire check/filter document. 527 * 528 * @param doc the parsed XDoc document 529 * @param xmlFile the source file 530 * @param category category label for this file's entry 531 * @param type document type ("Check", "Filter", etc.) 532 * @param baseUrl the page url without anchor 533 * @return an entry representing the document 534 */ 535 private static SearchIndexEntry buildMainEntry(Document doc, File xmlFile, 536 String category, String type, 537 String baseUrl) { 538 final Element body = requireBody(doc, xmlFile.toString()); 539 final NodeList sections = body.getElementsByTagName(SECTION); 540 541 final String title = extractTitle(doc, xmlFile, sections); 542 final String description = extractAggregateDescription(sections); 543 final String keywords = extractAggregateKeywords(title, sections); 544 545 return new SearchIndexEntry(title, baseUrl, category, type, description, keywords); 546 } 547 548 /** 549 * Builds one search entry per top-level {@code <section>} in a general 550 * documentation page, using each section's full text for keyword 551 * extraction so that page-internal content is fully discoverable. 552 * 553 * <p>Generic structural section names (see {@link #GENERIC_SECTION_NAMES}) 554 * are disambiguated as {@code "<page title>: <section name>"} to avoid 555 * collisions across pages (e.g. "Eclipse IDE: Debug" vs 556 * "IntelliJ IDE: Debug").</p> 557 * 558 * @param xmlFile the XDoc source file to parse 559 * @return list of entries, one per top-level section found 560 * @throws ParserConfigurationException on XML parser setup failure 561 * @throws SAXException on XML parse error 562 * @throws IOException on file read failure 563 */ 564 private static List<SearchIndexEntry> buildGeneralPageEntries(File xmlFile) 565 throws ParserConfigurationException, SAXException, IOException { 566 final List<SearchIndexEntry> results = new ArrayList<>(); 567 final Document doc = parseXml(xmlFile); 568 final Element body = requireBody(doc, xmlFile.toString()); 569 final NodeList sections = body.getElementsByTagName(SECTION); 570 final String pageUrl = resolvePageUrl(xmlFile, xmlFile.getParentFile()); 571 final String pageTitle = derivePageTitle(doc, xmlFile); 572 573 if (sections.getLength() == 0) { 574 final String fullText = WHITESPACE.matcher(body.getTextContent()) 575 .replaceAll(SPACE).trim(); 576 final String description = extractFirstSentenceOrTruncated(fullText); 577 final String keywords = extractKeywordsFromText( 578 pageTitle + SPACE + fullText); 579 results.add(new SearchIndexEntry( 580 pageTitle, pageUrl, GENERAL, GENERAL, description, keywords)); 581 } 582 else { 583 for (int index = 0; index < sections.getLength(); index++) { 584 final Element section = (Element) sections.item(index); 585 if (body.equals(section.getParentNode())) { 586 final String sectionName = section.getAttribute(NAME_ATTR).trim(); 587 if (!sectionName.isEmpty() && !CONTENT.equalsIgnoreCase(sectionName)) { 588 589 final String entryTitle = disambiguateTitle(sectionName, pageTitle); 590 final String anchor = doxiaAnchorFor(sectionName); 591 final String url = pageUrl + ANCHOR_SEPARATOR + anchor; 592 593 final String sectionText = WHITESPACE.matcher(section.getTextContent()) 594 .replaceAll(SPACE).trim(); 595 final String description = extractFirstSentenceOrTruncated(sectionText); 596 final String keywords = extractKeywordsFromText( 597 pageTitle + SPACE + sectionName + SPACE + sectionText); 598 599 results.add(new SearchIndexEntry( 600 entryTitle, url, GENERAL, GENERAL, description, keywords)); 601 } 602 } 603 } 604 } 605 606 return results; 607 } 608 609 /** 610 * Extracts per-example search entries from a check/filter document. 611 * 612 * <p>Both {@code -config} and {@code -code} example paragraphs are 613 * indexed so users can find both the configuration snippet and the 614 * corresponding Java code example independently in search results.</p> 615 * 616 * <p>Titles use the pattern {@code "<CheckName>: Example1 [config]"} and 617 * {@code "<CheckName>: Example1 [code]"} to make the type immediately 618 * visible in search result listings without needing to open the page.</p> 619 * 620 * <p>Confirmed XDoc template structure for the Examples subsection:</p> 621 * <pre> 622 * <p id="Example1-config">To configure the check...</p> 623 * <macro name="example"><param name="type" value="config"/></macro> 624 * <p id="Example1-code">Example:</p> 625 * <macro name="example"><param name="type" value="code"/></macro> 626 * </pre> 627 * 628 * @param doc the parsed XDoc document 629 * @param baseUrl the page url without anchor 630 * @param category category label 631 * @return list of per-example entries (both config and code); empty if 632 * none found 633 */ 634 private static List<SearchIndexEntry> extractExampleEntries(Document doc, 635 String baseUrl, 636 String category) { 637 final List<SearchIndexEntry> exampleEntries = new ArrayList<>(); 638 final Element body = requireBody(doc, baseUrl); 639 final NodeList sections = body.getElementsByTagName(SECTION); 640 641 for (int sectionIdx = 0; sectionIdx < sections.getLength(); sectionIdx++) { 642 final Element section = (Element) sections.item(sectionIdx); 643 final String checkName = section.getAttribute(NAME_ATTR).trim(); 644 final Element examplesSubsection = 645 findSubsectionByPrefix(section, EXAMPLES_SUBSECTION); 646 647 if (examplesSubsection == null) { 648 continue; 649 } 650 651 final NodeList paragraphs = examplesSubsection.getElementsByTagName("p"); 652 653 for (int paragraphIndex = 0; paragraphIndex < paragraphs.getLength(); 654 paragraphIndex++) { 655 final Element paragraph = (Element) paragraphs.item(paragraphIndex); 656 final SearchIndexEntry entry = buildExampleEntry( 657 paragraph, checkName, baseUrl, category); 658 if (entry != null) { 659 exampleEntries.add(entry); 660 } 661 } 662 } 663 664 return exampleEntries; 665 } 666 667 /** 668 * Builds a single example entry from a paragraph element. 669 * 670 * @param paragraph the paragraph element containing the example 671 * @param checkName the name of the check 672 * @param baseUrl the base URL for the page 673 * @param category the category label 674 * @return a SearchIndexEntry if the paragraph matches the example pattern, 675 * null otherwise 676 */ 677 private static SearchIndexEntry buildExampleEntry(Element paragraph, 678 String checkName, 679 String baseUrl, 680 String category) { 681 final String id = paragraph.getAttribute(ID_ATTR); 682 final Matcher matcher = EXAMPLE_PARAGRAPH_ID.matcher(id); 683 SearchIndexEntry result = null; 684 685 if (matcher.matches()) { 686 final String exampleLabel = matcher.group(1); 687 final String exampleType = matcher.group(2); 688 689 final String labelSuffix; 690 if ("config".equals(exampleType)) { 691 labelSuffix = EXAMPLE_LABEL_CONFIG; 692 } 693 else { 694 labelSuffix = EXAMPLE_LABEL_CODE; 695 } 696 697 final String introText = WHITESPACE 698 .matcher(paragraph.getTextContent()) 699 .replaceAll(SPACE).trim(); 700 701 final String title = checkName + TITLE_SEPARATOR 702 + exampleLabel + labelSuffix; 703 final String url = baseUrl + ANCHOR_SEPARATOR + id; 704 final String description = 705 truncate(introText, MAX_DESCRIPTION_LENGTH); 706 final String keywords = extractKeywordsFromText( 707 checkName + SPACE + exampleLabel 708 + SPACE + exampleType + SPACE + introText); 709 710 result = new SearchIndexEntry( 711 title, url, category, EXAMPLE_TYPE, 712 description, keywords); 713 } 714 715 return result; 716 } 717 718 /** 719 * Extracts per-property search entries from a check/filter document. 720 * 721 * <p>Each row of the Properties table is indexed under the title 722 * {@code "<CheckName>: <propertyName>"} and linked to the property's 723 * own anchor on the page.</p> 724 * 725 * @param doc the parsed XDoc document 726 * @param baseUrl the page url without anchor 727 * @param category category label 728 * @return list of per-property entries; empty if none found 729 */ 730 private static List<SearchIndexEntry> extractPropertyEntries(Document doc, 731 String baseUrl, 732 String category) { 733 final List<SearchIndexEntry> propertyEntries = new ArrayList<>(); 734 final Element body = requireBody(doc, baseUrl); 735 final NodeList sections = body.getElementsByTagName(SECTION); 736 737 for (int sectionIdx = 0; sectionIdx < sections.getLength(); sectionIdx++) { 738 final Element section = (Element) sections.item(sectionIdx); 739 final Element propertiesSubsection = 740 findSubsectionByPrefix(section, PROPERTIES_FRAGMENT); 741 742 if (propertiesSubsection != null) { 743 final String checkName = section.getAttribute(NAME_ATTR).trim(); 744 extractPropertiesFromRows(propertiesSubsection, checkName, baseUrl, 745 category, propertyEntries); 746 } 747 } 748 749 return propertyEntries; 750 } 751 752 /** 753 * Extracts property entries from table rows and adds them to the list. 754 * 755 * @param propertiesSubsection the properties subsection element 756 * @param checkName the check name 757 * @param baseUrl the page url without anchor 758 * @param category category label 759 * @param propertyEntries the list to add entries to 760 */ 761 private static void extractPropertiesFromRows(Element propertiesSubsection, 762 String checkName, 763 String baseUrl, 764 String category, 765 List<SearchIndexEntry> propertyEntries) { 766 final NodeList rows = propertiesSubsection.getElementsByTagName("tr"); 767 768 for (int rowIdx = 1; rowIdx < rows.getLength(); rowIdx++) { 769 final Element row = (Element) rows.item(rowIdx); 770 final NodeList cells = row.getElementsByTagName("td"); 771 if (cells.getLength() >= 2) { 772 processPropertyRow(cells, checkName, baseUrl, category, propertyEntries); 773 } 774 } 775 } 776 777 /** 778 * Processes a single property row and adds an entry if valid. 779 * 780 * @param cells the table cells 781 * @param checkName the check name 782 * @param baseUrl the page url without anchor 783 * @param category category label 784 * @param propertyEntries the list to add entries to 785 */ 786 private static void processPropertyRow(NodeList cells, 787 String checkName, 788 String baseUrl, 789 String category, 790 List<SearchIndexEntry> propertyEntries) { 791 final String propName = WHITESPACE 792 .matcher(cells.item(0).getTextContent()) 793 .replaceAll(SPACE).trim(); 794 795 if (!propName.isEmpty()) { 796 final String propDesc = WHITESPACE 797 .matcher(cells.item(1).getTextContent()) 798 .replaceAll(SPACE).trim(); 799 800 final String title = checkName + TITLE_SEPARATOR + propName; 801 final String url = baseUrl + ANCHOR_SEPARATOR + propName; 802 final String description = truncate(propDesc, MAX_DESCRIPTION_LENGTH); 803 final String keywords = extractKeywordsFromText( 804 checkName + SPACE + propName + SPACE + propDesc); 805 806 propertyEntries.add(new SearchIndexEntry( 807 title, url, category, PROPERTY_TYPE, 808 description, keywords)); 809 } 810 } 811 812 /** 813 * Adds an entry to the output list only if its URL has not been seen 814 * before. This is a secondary guard that catches any duplicates that 815 * slip through the primary filter (only processing plain {@code .xml} 816 * files), e.g. if a check has the same example paragraph id repeated 817 * across two sections. 818 * 819 * @param entry the entry to conditionally add 820 */ 821 private void addIfNew(SearchIndexEntry entry) { 822 if (seenUrls.add(entry.url())) { 823 entries.add(entry); 824 } 825 } 826 827 /** 828 * Finds a subsection within a section whose lowercased name contains the 829 * given fragment (e.g. "examples" or "propert" to match "Properties"). 830 * 831 * @param section the section to search 832 * @param fragment lowercase fragment to match against the subsection name 833 * @return the matching subsection element, or {@code null} if not found 834 */ 835 private static Element findSubsectionByPrefix(Element section, String fragment) { 836 final NodeList subsections = section.getElementsByTagName(SUBSECTION); 837 Element result = null; 838 for (int index = 0; index < subsections.getLength(); index++) { 839 final Element sub = (Element) subsections.item(index); 840 if (sub.getAttribute(NAME_ATTR).trim() 841 .toLowerCase(Locale.ROOT).contains(fragment)) { 842 result = sub; 843 break; 844 } 845 } 846 return result; 847 } 848 849 /** 850 * Parses the XML file into a Document with external entity resolution 851 * disabled for security. 852 * 853 * @param xmlFile the XDoc source file 854 * @return the parsed Document 855 * @throws ParserConfigurationException on XML parser setup failure 856 * @throws SAXException on XML parse error 857 * @throws IOException on file read failure 858 */ 859 private static Document parseXml(File xmlFile) 860 throws ParserConfigurationException, SAXException, IOException { 861 final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); 862 factory.setFeature(EXTERNAL_GENERAL_ENTITIES, false); 863 factory.setFeature(EXTERNAL_PARAMETER_ENTITIES, false); 864 865 final DocumentBuilder builder = factory.newDocumentBuilder(); 866 builder.setErrorHandler(null); 867 868 final Document doc = builder.parse(xmlFile); 869 doc.getDocumentElement().normalize(); 870 return doc; 871 } 872 873 /** 874 * Returns the document's {@code <body>} element, failing fast if it is 875 * absent. Every XDoc page processed by this generator is expected to 876 * have one; its absence indicates a malformed source file that should 877 * be fixed rather than silently skipped or producing an empty entry. 878 * 879 * @param doc the parsed document 880 * @param identifier file path or URL used to identify the source in the 881 * error message 882 * @return the body element 883 * @throws IllegalStateException if {@code doc} has no {@code <body>} element 884 */ 885 private static Element requireBody(Document doc, String identifier) { 886 final NodeList bodies = doc.getElementsByTagName(BODY); 887 if (bodies.getLength() == 0) { 888 throw new IllegalStateException( 889 "XDoc file is missing a <body> element: " + identifier); 890 } 891 final Element body = (Element) bodies.item(0); 892 if (body == null) { 893 throw new IllegalStateException( 894 "XDoc file has a null <body> element: " + identifier); 895 } 896 return body; 897 } 898 899 /** 900 * Extracts the document title from the {@code <title>} element, falling 901 * back to the first non-empty, non-"Content" section name, and finally 902 * to a capitalised version of the file name. 903 * 904 * @param doc the document 905 * @param xmlFile the source file 906 * @param sections the list of sections 907 * @return the title string, never empty 908 */ 909 private static String extractTitle(Document doc, File xmlFile, NodeList sections) { 910 final NodeList titles = doc.getElementsByTagName(TITLE); 911 String title = ""; 912 if (titles.getLength() > 0) { 913 title = titles.item(0).getTextContent().trim(); 914 } 915 916 if ((title.isEmpty() || CONTENT.equalsIgnoreCase(title)) 917 && sections.getLength() > 0) { 918 final String firstSection = 919 ((Element) sections.item(0)).getAttribute(NAME_ATTR).trim(); 920 if (!firstSection.isEmpty() && !CONTENT.equalsIgnoreCase(firstSection)) { 921 title = firstSection; 922 } 923 } 924 925 if (title.isEmpty() || CONTENT.equalsIgnoreCase(title)) { 926 final String name = 927 xmlFile.getName().replaceFirst(DOC_EXTENSION.pattern(), ""); 928 title = capitalise(name.replace('_', ' ')); 929 } 930 return title; 931 } 932 933 /** 934 * Aggregates description from sections, taking the first non-empty 935 * Description subsection found across all sections in the document. 936 * 937 * @param sections list of sections 938 * @return description string, possibly empty 939 */ 940 private static String extractAggregateDescription(NodeList sections) { 941 String description = ""; 942 for (int index = 0; index < sections.getLength(); index++) { 943 description = extractDescription((Element) sections.item(index)); 944 if (!description.isEmpty()) { 945 break; 946 } 947 } 948 return description; 949 } 950 951 /** 952 * Aggregates keywords from sections using all section text so that the 953 * main check entry is discoverable by any term in the document. 954 * 955 * @param title the document title 956 * @param sections list of sections 957 * @return keywords string 958 */ 959 private static String extractAggregateKeywords(String title, NodeList sections) { 960 final StringBuilder keywordSource = new StringBuilder(title); 961 for (int index = 0; index < sections.getLength(); index++) { 962 final Element section = (Element) sections.item(index); 963 keywordSource.append(SPACE_CHAR) 964 .append(section.getAttribute(NAME_ATTR)) 965 .append(SPACE_CHAR) 966 .append(section.getTextContent()); 967 } 968 return extractKeywordsFromText(keywordSource.toString()); 969 } 970 971 /** 972 * Extracts the first sentence of the Description subsection. 973 * Returns an empty string if no Description subsection is found. 974 * 975 * @param section the {@code <section>} element to search 976 * @return first sentence of the description, or empty string 977 */ 978 private static String extractDescription(Element section) { 979 final Element sub = findSubsectionByPrefix(section, DESCRIPTION); 980 String result = ""; 981 if (sub != null) { 982 final String text = WHITESPACE.matcher(sub.getTextContent()) 983 .replaceAll(SPACE).trim(); 984 result = extractFirstSentenceOrTruncated(text); 985 } 986 return result; 987 } 988 989 /** 990 * Derives a fallback page title from the document's {@code <title>} 991 * element or, failing that, from the filename. 992 * 993 * @param doc the parsed document 994 * @param xmlFile the source file 995 * @return a non-empty title string 996 */ 997 private static String derivePageTitle(Document doc, File xmlFile) { 998 final NodeList titles = doc.getElementsByTagName(TITLE); 999 String title = ""; 1000 if (titles.getLength() > 0) { 1001 title = titles.item(0).getTextContent().trim(); 1002 } 1003 if (title.isEmpty()) { 1004 final String name = 1005 xmlFile.getName().replaceFirst(DOC_EXTENSION.pattern(), ""); 1006 title = capitalise(name.replace('_', ' ')); 1007 } 1008 return title; 1009 } 1010 1011 /** 1012 * Disambiguates a section title when it is a generic, structurally 1013 * repeated header (see {@link #GENERIC_SECTION_NAMES}). 1014 * Non-generic section names are returned unchanged. 1015 * 1016 * @param sectionName the raw section name 1017 * @param pageTitle the owning page's own title 1018 * @return either {@code sectionName} unchanged, or 1019 * {@code "<pageTitle>: <sectionName>"} if generic 1020 */ 1021 private static String disambiguateTitle(String sectionName, String pageTitle) { 1022 final String result; 1023 if (GENERIC_SECTION_NAMES.contains(sectionName.toLowerCase(Locale.ROOT))) { 1024 result = pageTitle + TITLE_SEPARATOR + sectionName; 1025 } 1026 else { 1027 result = sectionName; 1028 } 1029 return result; 1030 } 1031 1032 /** 1033 * Converts a Doxia {@code <section name="...">} value into the anchor id 1034 * Doxia generates for it in the rendered HTML by replacing runs of 1035 * whitespace with single underscores. 1036 * 1037 * @param sectionName the raw {@code name} attribute value 1038 * @return the anchor id Doxia would render for this section name 1039 */ 1040 private static String doxiaAnchorFor(String sectionName) { 1041 return WHITESPACE.matcher(sectionName.trim()).replaceAll("_"); 1042 } 1043 1044 /** 1045 * Returns the first sentence of the given text (up to and including the 1046 * first period), or the text truncated to {@link #MAX_DESCRIPTION_LENGTH} 1047 * with an ellipsis if no period is found within range. 1048 * 1049 * @param text the source text, already whitespace-normalised 1050 * @return first sentence or truncated text 1051 */ 1052 private static String extractFirstSentenceOrTruncated(String text) { 1053 final String result; 1054 final int dot = text.indexOf('.'); 1055 if (dot > 0) { 1056 result = text.substring(0, dot + 1).trim(); 1057 } 1058 else { 1059 result = truncate(text, MAX_DESCRIPTION_LENGTH); 1060 } 1061 return result; 1062 } 1063 1064 /** 1065 * Truncates text to the given max length, appending an ellipsis if 1066 * truncation occurred. 1067 * 1068 * @param text the text to truncate 1069 * @param maxLength maximum length before truncation 1070 * @return original text if short enough, otherwise truncated with ellipsis 1071 */ 1072 private static String truncate(String text, int maxLength) { 1073 final String result; 1074 if (text.length() > maxLength) { 1075 result = text.substring(0, maxLength) + ELLIPSIS; 1076 } 1077 else { 1078 result = text; 1079 } 1080 return result; 1081 } 1082 1083 /** 1084 * Builds the root-relative URL for an XDoc file, without any anchor. 1085 * Always uses forward slashes regardless of OS. 1086 * 1087 * @param xmlFile the source XDoc file 1088 * @param xdocsDir the xdocs root directory 1089 * @return root-relative URL string with no anchor 1090 */ 1091 private static String buildUrl(File xmlFile, File xdocsDir) { 1092 return xdocsDir.toPath() 1093 .relativize(xmlFile.toPath()) 1094 .toString() 1095 .replace(File.separatorChar, '/') 1096 .replaceFirst(DOC_EXTENSION.pattern(), ".html"); 1097 } 1098 1099 /** 1100 * Resolves the correct URL for a general page file. For {@code config_<category>.xml} files 1101 * that redirect to check category pages, maps to {@code checks/<category>/index.html} instead 1102 * of the file path. 1103 * 1104 * @param xmlFile the source XDoc file 1105 * @param xdocsDir the xdocs root directory 1106 * @return the resolved URL 1107 */ 1108 private static String resolvePageUrl(File xmlFile, File xdocsDir) { 1109 String url = buildUrl(xmlFile, xdocsDir); 1110 final Matcher matcher = CONFIG_CATEGORY.matcher(xmlFile.getName()); 1111 if (matcher.find()) { 1112 final String category = matcher.group(1); 1113 if (CHECKS_CATEGORY_DISPLAY_NAMES.containsKey(category)) { 1114 url = CHECKS + PATH_SEPARATOR + category + PATH_SEPARATOR + INDEX_HTML; 1115 } 1116 else if (FILTERS_DIR.equals(category) || FILEFILTERS_DIR.equals(category)) { 1117 url = category + PATH_SEPARATOR + INDEX_HTML; 1118 } 1119 } 1120 return url; 1121 } 1122 1123 /** 1124 * Extracts keywords from free-form text by splitting on non-word 1125 * characters and filtering short and stop words. 1126 * 1127 * @param text input text 1128 * @return comma-separated keyword string (up to {@link #MAX_KEYWORDS} words) 1129 */ 1130 private static String extractKeywordsFromText(String text) { 1131 String result = ""; 1132 if (text != null && !text.isEmpty()) { 1133 result = NON_ALPHANUMERIC.splitAsStream(text.toLowerCase(Locale.ROOT)) 1134 .filter(word -> { 1135 return word.length() >= MIN_WORD_LENGTH 1136 && !STOP_WORDS.contains(word); 1137 }) 1138 .distinct() 1139 .limit(MAX_KEYWORDS) 1140 .collect(Collectors.joining(COMMA_STR)); 1141 } 1142 return result; 1143 } 1144 1145 /** 1146 * Writes all index entries to the output file. 1147 * 1148 * @param indexEntries the list of entries to serialise 1149 * @param outputFilePath the full path to the output file 1150 * @throws IOException on file write failure 1151 */ 1152 private static void writeJson(List<SearchIndexEntry> indexEntries, Path outputFilePath) 1153 throws IOException { 1154 1155 final Path outputPath = outputFilePath.getParent(); 1156 if (outputPath != null) { 1157 Files.createDirectories(outputPath); 1158 } 1159 1160 try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter( 1161 outputFilePath))) { 1162 writer.println("["); 1163 1164 final int size = indexEntries.size(); 1165 for (int index = 0; index < size; index++) { 1166 final String comma; 1167 if (index < size - 1) { 1168 comma = COMMA_STR; 1169 } 1170 else { 1171 comma = ""; 1172 } 1173 writer.println(" " + indexEntries.get(index).toJson() + comma); 1174 } 1175 writer.println("]"); 1176 } 1177 } 1178 1179 /** 1180 * Capitalises the first character of a string. 1181 * 1182 * @param input the string to capitalise 1183 * @return string with first character uppercased, or input unchanged if 1184 * empty 1185 */ 1186 private static String capitalise(String input) { 1187 String result = input; 1188 if (input != null && !input.isEmpty()) { 1189 result = Character.toUpperCase(input.charAt(0)) + input.substring(1); 1190 } 1191 return result; 1192 } 1193 1194}