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 &lt;xdocsDir&gt; &lt;outputFilePath&gt;
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"} -&gt; label "Example1", type "config"</li>
244     *   <li>{@code id="Example1-code"}   -&gt; 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     *   &lt;p id="Example1-config"&gt;To configure the check...&lt;/p&gt;
623     *   &lt;macro name="example"&gt;&lt;param name="type" value="config"/&gt;&lt;/macro&gt;
624     *   &lt;p id="Example1-code"&gt;Example:&lt;/p&gt;
625     *   &lt;macro name="example"&gt;&lt;param name="type" value="code"/&gt;&lt;/macro&gt;
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}