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