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