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