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