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