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