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