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        });
523
524        if (xmlFiles != null) {
525            Arrays.sort(xmlFiles);
526            for (File xmlFile : xmlFiles) {
527                processGeneralPage(xmlFile);
528            }
529        }
530    }
531
532    /**
533     * Parses a single general-documentation XDoc page and adds its
534     * per-section entries to the index.
535     *
536     * <p>A parse failure here means the source XDoc itself is malformed, so
537     * this fails the build instead of logging a warning and continuing.</p>
538     *
539     * @param xmlFile the XDoc source file to process
540     * @throws IllegalStateException if {@code xmlFile} cannot be parsed
541     */
542    private void processGeneralPage(File xmlFile) {
543        try {
544            for (SearchIndexEntry entry : buildGeneralPageEntries(xmlFile)) {
545                addIfNew(entry);
546            }
547        }
548        catch (IOException | SAXException | ParserConfigurationException exception) {
549            throw new IllegalStateException(PARSE_FAILURE_MSG + xmlFile, exception);
550        }
551    }
552
553    /**
554     * Builds the main search entry representing an entire check/filter document.
555     *
556     * @param doc      the parsed XDoc document
557     * @param xmlFile  the source file
558     * @param category category label for this file's entry
559     * @param type     document type ("Check", "Filter", etc.)
560     * @param baseUrl  the page url without anchor
561     * @return an entry representing the document
562     */
563    private static SearchIndexEntry buildMainEntry(Document doc, File xmlFile,
564                                                   String category, String type,
565                                                   String baseUrl) {
566        final Element body = requireBody(doc, xmlFile.toString());
567        final NodeList sections = body.getElementsByTagName(SECTION);
568
569        final String title = extractTitle(doc, xmlFile, sections);
570        final String description = extractAggregateDescription(sections);
571        final String keywords = extractAggregateKeywords(title, sections);
572        final String since = extractSince(body);
573        final int weight = getWeightForType(type);
574
575        return new SearchIndexEntry(title, baseUrl, category, type,
576                description, keywords, since, weight);
577    }
578
579    /**
580     * Builds one search entry per top-level {@code <section>} in a general
581     * documentation page, using each section's full text for keyword
582     * extraction so that page-internal content is fully discoverable.
583     *
584     * <p>Generic structural section names (see {@link #GENERIC_SECTION_NAMES})
585     * are disambiguated as {@code "<page title>: <section name>"} to avoid
586     * collisions across pages (e.g. "Eclipse IDE: Debug" vs
587     * "IntelliJ IDE: Debug").</p>
588     *
589     * @param xmlFile the XDoc source file to parse
590     * @return list of entries, one per top-level section found
591     * @throws ParserConfigurationException on XML parser setup failure
592     * @throws SAXException on XML parse error
593     * @throws IOException on file read failure
594     */
595    private static List<SearchIndexEntry> buildGeneralPageEntries(File xmlFile)
596            throws ParserConfigurationException, SAXException, IOException {
597        final List<SearchIndexEntry> results = new ArrayList<>();
598        final Document doc = parseXml(xmlFile);
599        final Element body = requireBody(doc, xmlFile.toString());
600        final NodeList sections = body.getElementsByTagName(SECTION);
601        final String pageUrl = resolvePageUrl(xmlFile, xmlFile.getParentFile());
602        final String pageTitle = derivePageTitle(doc, xmlFile);
603        final int generalWeight = getWeightForType(GENERAL);
604
605        if (sections.getLength() == 0) {
606            final String fullText = WHITESPACE.matcher(body.getTextContent())
607                    .replaceAll(SPACE).trim();
608            final String description = extractFirstSentenceOrTruncated(fullText);
609            final String keywords = extractKeywordsFromText(
610                    pageTitle + SPACE + fullText);
611            results.add(new SearchIndexEntry(
612                    pageTitle, pageUrl, GENERAL, GENERAL, description, keywords,
613                    "", generalWeight));
614        }
615        else {
616            for (int index = 0; index < sections.getLength(); index++) {
617                final Element section = (Element) sections.item(index);
618                if (body.equals(section.getParentNode())) {
619                    final String sectionName = section.getAttribute(NAME_ATTR).trim();
620                    if (!sectionName.isEmpty() && !CONTENT.equalsIgnoreCase(sectionName)) {
621
622                        final String entryTitle = disambiguateTitle(sectionName, pageTitle);
623                        final String anchor = doxiaAnchorFor(sectionName);
624                        final String url = pageUrl + ANCHOR_SEPARATOR + anchor;
625
626                        final String sectionText = WHITESPACE.matcher(section.getTextContent())
627                                .replaceAll(SPACE).trim();
628                        final String description = extractFirstSentenceOrTruncated(sectionText);
629                        final String keywords = extractKeywordsFromText(
630                                pageTitle + SPACE + sectionName + SPACE + sectionText);
631
632                        results.add(new SearchIndexEntry(
633                                entryTitle, url, GENERAL, GENERAL, description,
634                                keywords, "", generalWeight));
635                    }
636                }
637            }
638        }
639
640        return results;
641    }
642
643    /**
644     * Extracts per-example search entries from a check/filter document.
645     *
646     * <p>Both {@code -config} and {@code -code} example paragraphs are
647     * indexed so users can find both the configuration snippet and the
648     * corresponding Java code example independently in search results.</p>
649     *
650     * <p>Titles use the pattern {@code "<CheckName>: Example1 [config]"} and
651     * {@code "<CheckName>: Example1 [code]"} to make the type immediately
652     * visible in search result listings without needing to open the page.</p>
653     *
654     * <p>Confirmed XDoc template structure for the Examples subsection:</p>
655     * <pre>
656     *   &lt;p id="Example1-config"&gt;To configure the check...&lt;/p&gt;
657     *   &lt;macro name="example"&gt;&lt;param name="type" value="config"/&gt;&lt;/macro&gt;
658     *   &lt;p id="Example1-code"&gt;Example:&lt;/p&gt;
659     *   &lt;macro name="example"&gt;&lt;param name="type" value="code"/&gt;&lt;/macro&gt;
660     * </pre>
661     *
662     * @param doc      the parsed XDoc document
663     * @param baseUrl  the page url without anchor
664     * @param category category label
665     * @return list of per-example entries (both config and code); empty if
666     *         none found
667     */
668    private static List<SearchIndexEntry> extractExampleEntries(Document doc,
669                                                                String baseUrl,
670                                                                String category) {
671        final List<SearchIndexEntry> exampleEntries = new ArrayList<>();
672        final Element body = requireBody(doc, baseUrl);
673        final NodeList sections = body.getElementsByTagName(SECTION);
674
675        for (int sectionIdx = 0; sectionIdx < sections.getLength(); sectionIdx++) {
676            final Element section = (Element) sections.item(sectionIdx);
677            final String checkName = section.getAttribute(NAME_ATTR).trim();
678            final Element examplesSubsection =
679                    findSubsectionByPrefix(section, EXAMPLES_SUBSECTION);
680
681            if (examplesSubsection == null) {
682                continue;
683            }
684
685            final NodeList paragraphs =
686                    examplesSubsection.getElementsByTagName(P_TAG);
687
688            for (int paragraphIndex = 0; paragraphIndex < paragraphs.getLength();
689                 paragraphIndex++) {
690                final Element paragraph = (Element) paragraphs.item(paragraphIndex);
691                final SearchIndexEntry entry = buildExampleEntry(
692                        paragraph, checkName, baseUrl, category);
693                if (entry != null) {
694                    exampleEntries.add(entry);
695                }
696            }
697        }
698
699        return exampleEntries;
700    }
701
702    /**
703     * Builds a single example entry from a paragraph element.
704     *
705     * @param paragraph the paragraph element containing the example
706     * @param checkName the name of the check
707     * @param baseUrl the base URL for the page
708     * @param category the category label
709     * @return a SearchIndexEntry if the paragraph matches the example pattern,
710     *         null otherwise
711     */
712    private static SearchIndexEntry buildExampleEntry(Element paragraph,
713                                                       String checkName,
714                                                       String baseUrl,
715                                                       String category) {
716        final String id = paragraph.getAttribute(ID_ATTR);
717        final Matcher matcher = EXAMPLE_PARAGRAPH_ID.matcher(id);
718        SearchIndexEntry result = null;
719
720        if (matcher.matches()) {
721            final String exampleLabel = matcher.group(1);
722            final String exampleType = matcher.group(2);
723
724            final String introText = WHITESPACE
725                    .matcher(paragraph.getTextContent())
726                    .replaceAll(SPACE).trim();
727
728            final String title = checkName + TITLE_SEPARATOR
729                    + exampleLabel;
730            final String url = baseUrl + ANCHOR_SEPARATOR + id;
731            final String description =
732                    truncate(introText, MAX_DESCRIPTION_LENGTH);
733            final String keywords = extractKeywordsFromText(
734                    checkName + SPACE + exampleLabel
735                            + SPACE + exampleType + SPACE + introText);
736
737            result = new SearchIndexEntry(
738                    title, url, category, EXAMPLE_TYPE,
739                    description, keywords, "", getWeightForType(EXAMPLE_TYPE));
740        }
741
742        return result;
743    }
744
745    /**
746     * Extracts per-property search entries from a check/filter document.
747     *
748     * <p>Each row of the Properties table is indexed under the title
749     * {@code "<CheckName>: <propertyName>"} and linked to the property's
750     * own anchor on the page.</p>
751     *
752     * @param doc      the parsed XDoc document
753     * @param baseUrl  the page url without anchor
754     * @param category category label
755     * @return list of per-property entries; empty if none found
756     */
757    private static List<SearchIndexEntry> extractPropertyEntries(Document doc,
758                                                                 String baseUrl,
759                                                                 String category) {
760        final List<SearchIndexEntry> propertyEntries = new ArrayList<>();
761        final Element body = requireBody(doc, baseUrl);
762        final NodeList sections = body.getElementsByTagName(SECTION);
763
764        for (int sectionIdx = 0; sectionIdx < sections.getLength(); sectionIdx++) {
765            final Element section = (Element) sections.item(sectionIdx);
766            final Element propertiesSubsection =
767                    findSubsectionByPrefix(section, PROPERTIES_FRAGMENT);
768
769            if (propertiesSubsection != null) {
770                final String checkName = section.getAttribute(NAME_ATTR).trim();
771                extractPropertiesFromRows(propertiesSubsection, checkName, baseUrl,
772                        category, propertyEntries);
773            }
774        }
775
776        return propertyEntries;
777    }
778
779    /**
780     * Extracts property entries from table rows and adds them to the list.
781     *
782     * @param propertiesSubsection the properties subsection element
783     * @param checkName the check name
784     * @param baseUrl the page url without anchor
785     * @param category category label
786     * @param propertyEntries the list to add entries to
787     */
788    private static void extractPropertiesFromRows(Element propertiesSubsection,
789                                                  String checkName,
790                                                  String baseUrl,
791                                                  String category,
792                                                  List<SearchIndexEntry> propertyEntries) {
793        final NodeList rows = propertiesSubsection.getElementsByTagName("tr");
794
795        for (int rowIdx = 1; rowIdx < rows.getLength(); rowIdx++) {
796            final Element row = (Element) rows.item(rowIdx);
797            final NodeList cells = row.getElementsByTagName("td");
798            if (cells.getLength() >= 2) {
799                processPropertyRow(cells, checkName, baseUrl, category, propertyEntries);
800            }
801        }
802    }
803
804    /**
805     * Processes a single property row and adds an entry if valid.
806     *
807     * @param cells the table cells
808     * @param checkName the check name
809     * @param baseUrl the page url without anchor
810     * @param category category label
811     * @param propertyEntries the list to add entries to
812     */
813    private static void processPropertyRow(NodeList cells,
814                                           String checkName,
815                                           String baseUrl,
816                                           String category,
817                                           List<SearchIndexEntry> propertyEntries) {
818        final String propName = WHITESPACE
819                .matcher(cells.item(0).getTextContent())
820                .replaceAll(SPACE).trim();
821
822        if (!propName.isEmpty()) {
823            final String propDesc = WHITESPACE
824                    .matcher(cells.item(1).getTextContent())
825                    .replaceAll(SPACE).trim();
826
827            final String title = checkName + TITLE_SEPARATOR + propName;
828            final String url = baseUrl + ANCHOR_SEPARATOR + propName;
829            final String description = truncate(propDesc, MAX_DESCRIPTION_LENGTH);
830            final String keywords = extractKeywordsFromText(
831                    checkName + SPACE + propName + SPACE + propDesc);
832            String since = "";
833            if (cells.getLength() >= EXPECTED_PROPERTY_COLUMNS) {
834                final Node sinceCell = cells.item(PROPERTY_SINCE_COLUMN_INDEX);
835                if (sinceCell != null) {
836                    final String sinceText = sinceCell.getTextContent();
837                    if (sinceText != null) {
838                        since = WHITESPACE.matcher(sinceText)
839                                .replaceAll(SPACE).trim();
840                    }
841                }
842            }
843            final int weight = getWeightForType(PROPERTY_TYPE);
844
845            propertyEntries.add(new SearchIndexEntry(
846                    title, url, category, PROPERTY_TYPE,
847                    description, keywords, since, weight));
848        }
849    }
850
851    /**
852     * Adds an entry to the output list only if its URL has not been seen
853     * before. This is a secondary guard that catches any duplicates that
854     * slip through the primary filter (only processing plain {@code .xml}
855     * files), e.g. if a check has the same example paragraph id repeated
856     * across two sections.
857     *
858     * @param entry the entry to conditionally add
859     */
860    private void addIfNew(SearchIndexEntry entry) {
861        if (seenUrls.add(entry.url())) {
862            entries.add(entry);
863        }
864    }
865
866    /**
867     * Finds a subsection within a section whose lowercased name contains the
868     * given fragment (e.g. "examples" or "propert" to match "Properties").
869     *
870     * @param section  the section to search
871     * @param fragment lowercase fragment to match against the subsection name
872     * @return the matching subsection element, or {@code null} if not found
873     */
874    private static Element findSubsectionByPrefix(Element section, String fragment) {
875        final NodeList subsections = section.getElementsByTagName(SUBSECTION);
876        Element result = null;
877        for (int index = 0; index < subsections.getLength(); index++) {
878            final Element sub = (Element) subsections.item(index);
879            if (sub.getAttribute(NAME_ATTR).trim()
880                    .toLowerCase(Locale.ROOT).contains(fragment)) {
881                result = sub;
882                break;
883            }
884        }
885        return result;
886    }
887
888    /**
889     * Parses the XML file into a Document with external entity resolution
890     * disabled for security.
891     *
892     * @param xmlFile the XDoc source file
893     * @return the parsed Document
894     * @throws ParserConfigurationException on XML parser setup failure
895     * @throws SAXException on XML parse error
896     * @throws IOException on file read failure
897     */
898    private static Document parseXml(File xmlFile)
899            throws ParserConfigurationException, SAXException, IOException {
900        final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
901        factory.setFeature(EXTERNAL_GENERAL_ENTITIES, false);
902        factory.setFeature(EXTERNAL_PARAMETER_ENTITIES, false);
903
904        final DocumentBuilder builder = factory.newDocumentBuilder();
905        builder.setErrorHandler(null);
906
907        final Document doc = builder.parse(xmlFile);
908        doc.getDocumentElement().normalize();
909        return doc;
910    }
911
912    /**
913     * Returns the document's {@code <body>} element, failing fast if it is
914     * absent. Every XDoc page processed by this generator is expected to
915     * have one; its absence indicates a malformed source file that should
916     * be fixed rather than silently skipped or producing an empty entry.
917     *
918     * @param doc        the parsed document
919     * @param identifier file path or URL used to identify the source in the
920     *                   error message
921     * @return the body element
922     * @throws IllegalStateException if {@code doc} has no {@code <body>} element
923     */
924    private static Element requireBody(Document doc, String identifier) {
925        final NodeList bodies = doc.getElementsByTagName(BODY);
926        if (bodies.getLength() == 0) {
927            throw new IllegalStateException(
928                    "XDoc file is missing a <body> element: " + identifier);
929        }
930        final Element body = (Element) bodies.item(0);
931        if (body == null) {
932            throw new IllegalStateException(
933                    "XDoc file has a null <body> element: " + identifier);
934        }
935        return body;
936    }
937
938    /**
939     * Extracts the document title from the {@code <title>} element, falling
940     * back to the first non-empty, non-"Content" section name, and finally
941     * to a capitalised version of the file name.
942     *
943     * @param doc      the document
944     * @param xmlFile  the source file
945     * @param sections the list of sections
946     * @return the title string, never empty
947     */
948    private static String extractTitle(Document doc, File xmlFile, NodeList sections) {
949        final NodeList titles = doc.getElementsByTagName(TITLE);
950        String title = "";
951        if (titles.getLength() > 0) {
952            title = titles.item(0).getTextContent().trim();
953        }
954
955        if ((title.isEmpty() || CONTENT.equalsIgnoreCase(title))
956                && sections.getLength() > 0) {
957            final String firstSection =
958                    ((Element) sections.item(0)).getAttribute(NAME_ATTR).trim();
959            if (!firstSection.isEmpty() && !CONTENT.equalsIgnoreCase(firstSection)) {
960                title = firstSection;
961            }
962        }
963
964        if (title.isEmpty() || CONTENT.equalsIgnoreCase(title)) {
965            final String name =
966                    xmlFile.getName().replaceFirst(DOC_EXTENSION.pattern(), "");
967            title = capitalise(name.replace('_', ' '));
968        }
969        return title;
970    }
971
972    /**
973     * Aggregates description from sections, taking the first non-empty
974     * Description subsection found across all sections in the document.
975     *
976     * @param sections list of sections
977     * @return description string, possibly empty
978     */
979    private static String extractAggregateDescription(NodeList sections) {
980        String description = "";
981        for (int index = 0; index < sections.getLength(); index++) {
982            description = extractDescription((Element) sections.item(index));
983            if (!description.isEmpty()) {
984                break;
985            }
986        }
987        return description;
988    }
989
990    /**
991     * Aggregates keywords from sections using all section text so that the
992     * main check entry is discoverable by any term in the document.
993     *
994     * @param title    the document title
995     * @param sections list of sections
996     * @return keywords string
997     */
998    private static String extractAggregateKeywords(String title, NodeList sections) {
999        final StringBuilder keywordSource = new StringBuilder(title);
1000        for (int index = 0; index < sections.getLength(); index++) {
1001            final Element section = (Element) sections.item(index);
1002            keywordSource.append(SPACE_CHAR)
1003                .append(section.getAttribute(NAME_ATTR))
1004                .append(SPACE_CHAR)
1005                .append(section.getTextContent());
1006        }
1007        return extractKeywordsFromText(keywordSource.toString());
1008    }
1009
1010    /**
1011     * Extracts the first sentence of the Description subsection.
1012     * Returns an empty string if no Description subsection is found.
1013     *
1014     * @param section the {@code <section>} element to search
1015     * @return first sentence of the description, or empty string
1016     */
1017    private static String extractDescription(Element section) {
1018        final Element sub = findSubsectionByPrefix(section, DESCRIPTION);
1019        String result = "";
1020        if (sub != null) {
1021            final String text = WHITESPACE.matcher(sub.getTextContent())
1022                    .replaceAll(SPACE).trim();
1023            result = extractFirstSentenceOrTruncated(text);
1024        }
1025        return result;
1026    }
1027
1028    /**
1029     * Derives a fallback page title from the document's {@code <title>}
1030     * element or, failing that, from the filename.
1031     *
1032     * @param doc     the parsed document
1033     * @param xmlFile the source file
1034     * @return a non-empty title string
1035     */
1036    private static String derivePageTitle(Document doc, File xmlFile) {
1037        final NodeList titles = doc.getElementsByTagName(TITLE);
1038        String title = "";
1039        if (titles.getLength() > 0) {
1040            title = titles.item(0).getTextContent().trim();
1041        }
1042        if (title.isEmpty()) {
1043            final String name =
1044                    xmlFile.getName().replaceFirst(DOC_EXTENSION.pattern(), "");
1045            title = capitalise(name.replace('_', ' '));
1046        }
1047        return title;
1048    }
1049
1050    /**
1051     * Disambiguates a section title when it is a generic, structurally
1052     * repeated header (see {@link #GENERIC_SECTION_NAMES}).
1053     * Non-generic section names are returned unchanged.
1054     *
1055     * @param sectionName the raw section name
1056     * @param pageTitle   the owning page's own title
1057     * @return either {@code sectionName} unchanged, or
1058     *         {@code "<pageTitle>: <sectionName>"} if generic
1059     */
1060    private static String disambiguateTitle(String sectionName, String pageTitle) {
1061        final String result;
1062        if (GENERIC_SECTION_NAMES.contains(sectionName.toLowerCase(Locale.ROOT))) {
1063            result = pageTitle + TITLE_SEPARATOR + sectionName;
1064        }
1065        else {
1066            result = sectionName;
1067        }
1068        return result;
1069    }
1070
1071    /**
1072     * Converts a Doxia {@code <section name="...">} value into the anchor id
1073     * Doxia generates for it in the rendered HTML by replacing runs of
1074     * whitespace with single underscores.
1075     *
1076     * @param sectionName the raw {@code name} attribute value
1077     * @return the anchor id Doxia would render for this section name
1078     */
1079    private static String doxiaAnchorFor(String sectionName) {
1080        return WHITESPACE.matcher(sectionName.trim()).replaceAll("_");
1081    }
1082
1083    /**
1084     * Returns the first sentence of the given text (up to and including the
1085     * first period), or the text truncated to {@link #MAX_DESCRIPTION_LENGTH}
1086     * with an ellipsis if no period is found within range.
1087     *
1088     * @param text the source text, already whitespace-normalised
1089     * @return first sentence or truncated text
1090     */
1091    private static String extractFirstSentenceOrTruncated(String text) {
1092        final String result;
1093        final int dot = text.indexOf('.');
1094        if (dot > 0) {
1095            result = text.substring(0, dot + 1).trim();
1096        }
1097        else {
1098            result = truncate(text, MAX_DESCRIPTION_LENGTH);
1099        }
1100        return result;
1101    }
1102
1103    /**
1104     * Truncates text to the given max length, appending an ellipsis if
1105     * truncation occurred.
1106     *
1107     * @param text      the text to truncate
1108     * @param maxLength maximum length before truncation
1109     * @return original text if short enough, otherwise truncated with ellipsis
1110     */
1111    private static String truncate(String text, int maxLength) {
1112        final String result;
1113        if (text.length() > maxLength) {
1114            result = text.substring(0, maxLength) + ELLIPSIS;
1115        }
1116        else {
1117            result = text;
1118        }
1119        return result;
1120    }
1121
1122    /**
1123     * Builds the root-relative URL for an XDoc file, without any anchor.
1124     * Always uses forward slashes regardless of OS.
1125     *
1126     * @param xmlFile  the source XDoc file
1127     * @param xdocsDir the xdocs root directory
1128     * @return root-relative URL string with no anchor
1129     */
1130    private static String buildUrl(File xmlFile, File xdocsDir) {
1131        return xdocsDir.toPath()
1132                .relativize(xmlFile.toPath())
1133                .toString()
1134                .replace(File.separatorChar, '/')
1135                .replaceFirst(DOC_EXTENSION.pattern(), ".html");
1136    }
1137
1138    /**
1139     * Resolves the correct URL for a general page file. For {@code config_<category>.xml} files
1140     * that redirect to check category pages, maps to {@code checks/<category>/index.html} instead
1141     * of the file path.
1142     *
1143     * @param xmlFile  the source XDoc file
1144     * @param xdocsDir the xdocs root directory
1145     * @return the resolved URL
1146     */
1147    private static String resolvePageUrl(File xmlFile, File xdocsDir) {
1148        String url = buildUrl(xmlFile, xdocsDir);
1149        final Matcher matcher = CONFIG_CATEGORY.matcher(xmlFile.getName());
1150        if (matcher.find()) {
1151            final String category = matcher.group(1);
1152            if (CHECKS_CATEGORY_DISPLAY_NAMES.containsKey(category)) {
1153                url = CHECKS + PATH_SEPARATOR + category + PATH_SEPARATOR + INDEX_HTML;
1154            }
1155            else if (FILTERS_DIR.equals(category) || FILEFILTERS_DIR.equals(category)) {
1156                url = category + PATH_SEPARATOR + INDEX_HTML;
1157            }
1158        }
1159        return url;
1160    }
1161
1162    /**
1163     * Extracts keywords from free-form text by splitting on non-word
1164     * characters and filtering short and stop words.
1165     *
1166     * @param text input text
1167     * @return comma-separated keyword string (up to {@link #MAX_KEYWORDS} words)
1168     */
1169    private static String extractKeywordsFromText(String text) {
1170        String result = "";
1171        if (text != null && !text.isEmpty()) {
1172            result = NON_ALPHANUMERIC.splitAsStream(text.toLowerCase(Locale.ROOT))
1173                    .filter(word -> {
1174                        return word.length() >= MIN_WORD_LENGTH
1175                                && !STOP_WORDS.contains(word);
1176                    })
1177                    .distinct()
1178                    .limit(MAX_KEYWORDS)
1179                    .collect(Collectors.joining(COMMA_STR));
1180        }
1181        return result;
1182    }
1183
1184    /**
1185     * Extracts the "since" version from the document body, if present.
1186     *
1187     * @param body the body element to search
1188     * @return the version string, or empty string if not found
1189     */
1190    private static String extractSince(final Element body) {
1191        String since = "";
1192        final NodeList paragraphs = body.getElementsByTagName(P_TAG);
1193        for (int index = 0; index < paragraphs.getLength(); index++) {
1194            final Node node = paragraphs.item(index);
1195            if (node != null) {
1196                final String textContent = node.getTextContent();
1197                if (textContent != null) {
1198                    final String text = textContent.trim();
1199                    if (text.startsWith(SINCE_CHECKSTYLE)) {
1200                        since = text.substring(SINCE_CHECKSTYLE.length())
1201                                .trim();
1202                        break;
1203                    }
1204                }
1205            }
1206        }
1207        return since;
1208    }
1209
1210    /**
1211     * Returns a ranking weight based on the document type.
1212     *
1213     * @param type the document type
1214     * @return an integer weight
1215     */
1216    private static int getWeightForType(final String type) {
1217        final int weight;
1218        if (CHECK_TYPE.equals(type)) {
1219            weight = WEIGHT_CHECK;
1220        }
1221        else if (FILTER_TYPE.equals(type) || FILE_FILTER_TYPE.equals(type)) {
1222            weight = WEIGHT_FILTER;
1223        }
1224        else if (GENERAL.equals(type)) {
1225            weight = WEIGHT_GENERAL;
1226        }
1227        else if (PROPERTY_TYPE.equals(type)) {
1228            weight = WEIGHT_PROPERTY;
1229        }
1230        else if (EXAMPLE_TYPE.equals(type)) {
1231            weight = WEIGHT_EXAMPLE;
1232        }
1233        else {
1234            weight = WEIGHT_DEFAULT;
1235        }
1236        return weight;
1237    }
1238
1239    /**
1240     * Writes all index entries to the output file.
1241     *
1242     * @param indexEntries the list of entries to serialise
1243     * @param outputFilePath the full path to the output file
1244     * @throws IOException on file write failure
1245     */
1246    private static void writeJson(List<SearchIndexEntry> indexEntries, Path outputFilePath)
1247            throws IOException {
1248
1249        final Path outputPath = outputFilePath.getParent();
1250        if (outputPath != null) {
1251            Files.createDirectories(outputPath);
1252        }
1253
1254        try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter(
1255                outputFilePath))) {
1256            writer.println("[");
1257
1258            final int size = indexEntries.size();
1259            for (int index = 0; index < size; index++) {
1260                final String comma;
1261                if (index < size - 1) {
1262                    comma = COMMA_STR;
1263                }
1264                else {
1265                    comma = "";
1266                }
1267                writer.println("  " + indexEntries.get(index).toJson() + comma);
1268            }
1269            writer.println("]");
1270        }
1271    }
1272
1273    /**
1274     * Capitalises the first character of a string.
1275     *
1276     * @param input the string to capitalise
1277     * @return string with first character uppercased, or input unchanged if
1278     *         empty
1279     */
1280    private static String capitalise(String input) {
1281        String result = input;
1282        if (input != null && !input.isEmpty()) {
1283            result = Character.toUpperCase(input.charAt(0)) + input.substring(1);
1284        }
1285        return result;
1286    }
1287
1288}