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