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 }); 523 524 if (xmlFiles != null) { 525 Arrays.sort(xmlFiles); 526 for (File xmlFile : xmlFiles) { 527 processGeneralPage(xmlFile); 528 } 529 } 530 } 531 532 /** 533 * Parses a single general-documentation XDoc page and adds its 534 * per-section entries to the index. 535 * 536 * <p>A parse failure here means the source XDoc itself is malformed, so 537 * this fails the build instead of logging a warning and continuing.</p> 538 * 539 * @param xmlFile the XDoc source file to process 540 * @throws IllegalStateException if {@code xmlFile} cannot be parsed 541 */ 542 private void processGeneralPage(File xmlFile) { 543 try { 544 for (SearchIndexEntry entry : buildGeneralPageEntries(xmlFile)) { 545 addIfNew(entry); 546 } 547 } 548 catch (IOException | SAXException | ParserConfigurationException exception) { 549 throw new IllegalStateException(PARSE_FAILURE_MSG + xmlFile, exception); 550 } 551 } 552 553 /** 554 * Builds the main search entry representing an entire check/filter document. 555 * 556 * @param doc the parsed XDoc document 557 * @param xmlFile the source file 558 * @param category category label for this file's entry 559 * @param type document type ("Check", "Filter", etc.) 560 * @param baseUrl the page url without anchor 561 * @return an entry representing the document 562 */ 563 private static SearchIndexEntry buildMainEntry(Document doc, File xmlFile, 564 String category, String type, 565 String baseUrl) { 566 final Element body = requireBody(doc, xmlFile.toString()); 567 final NodeList sections = body.getElementsByTagName(SECTION); 568 569 final String title = extractTitle(doc, xmlFile, sections); 570 final String description = extractAggregateDescription(sections); 571 final String keywords = extractAggregateKeywords(title, sections); 572 final String since = extractSince(body); 573 final int weight = getWeightForType(type); 574 575 return new SearchIndexEntry(title, baseUrl, category, type, 576 description, keywords, since, weight); 577 } 578 579 /** 580 * Builds one search entry per top-level {@code <section>} in a general 581 * documentation page, using each section's full text for keyword 582 * extraction so that page-internal content is fully discoverable. 583 * 584 * <p>Generic structural section names (see {@link #GENERIC_SECTION_NAMES}) 585 * are disambiguated as {@code "<page title>: <section name>"} to avoid 586 * collisions across pages (e.g. "Eclipse IDE: Debug" vs 587 * "IntelliJ IDE: Debug").</p> 588 * 589 * @param xmlFile the XDoc source file to parse 590 * @return list of entries, one per top-level section found 591 * @throws ParserConfigurationException on XML parser setup failure 592 * @throws SAXException on XML parse error 593 * @throws IOException on file read failure 594 */ 595 private static List<SearchIndexEntry> buildGeneralPageEntries(File xmlFile) 596 throws ParserConfigurationException, SAXException, IOException { 597 final List<SearchIndexEntry> results = new ArrayList<>(); 598 final Document doc = parseXml(xmlFile); 599 final Element body = requireBody(doc, xmlFile.toString()); 600 final NodeList sections = body.getElementsByTagName(SECTION); 601 final String pageUrl = resolvePageUrl(xmlFile, xmlFile.getParentFile()); 602 final String pageTitle = derivePageTitle(doc, xmlFile); 603 final int generalWeight = getWeightForType(GENERAL); 604 605 if (sections.getLength() == 0) { 606 final String fullText = WHITESPACE.matcher(body.getTextContent()) 607 .replaceAll(SPACE).trim(); 608 final String description = extractFirstSentenceOrTruncated(fullText); 609 final String keywords = extractKeywordsFromText( 610 pageTitle + SPACE + fullText); 611 results.add(new SearchIndexEntry( 612 pageTitle, pageUrl, GENERAL, GENERAL, description, keywords, 613 "", generalWeight)); 614 } 615 else { 616 for (int index = 0; index < sections.getLength(); index++) { 617 final Element section = (Element) sections.item(index); 618 if (body.equals(section.getParentNode())) { 619 final String sectionName = section.getAttribute(NAME_ATTR).trim(); 620 if (!sectionName.isEmpty() && !CONTENT.equalsIgnoreCase(sectionName)) { 621 622 final String entryTitle = disambiguateTitle(sectionName, pageTitle); 623 final String anchor = doxiaAnchorFor(sectionName); 624 final String url = pageUrl + ANCHOR_SEPARATOR + anchor; 625 626 final String sectionText = WHITESPACE.matcher(section.getTextContent()) 627 .replaceAll(SPACE).trim(); 628 final String description = extractFirstSentenceOrTruncated(sectionText); 629 final String keywords = extractKeywordsFromText( 630 pageTitle + SPACE + sectionName + SPACE + sectionText); 631 632 results.add(new SearchIndexEntry( 633 entryTitle, url, GENERAL, GENERAL, description, 634 keywords, "", generalWeight)); 635 } 636 } 637 } 638 } 639 640 return results; 641 } 642 643 /** 644 * Extracts per-example search entries from a check/filter document. 645 * 646 * <p>Both {@code -config} and {@code -code} example paragraphs are 647 * indexed so users can find both the configuration snippet and the 648 * corresponding Java code example independently in search results.</p> 649 * 650 * <p>Titles use the pattern {@code "<CheckName>: Example1 [config]"} and 651 * {@code "<CheckName>: Example1 [code]"} to make the type immediately 652 * visible in search result listings without needing to open the page.</p> 653 * 654 * <p>Confirmed XDoc template structure for the Examples subsection:</p> 655 * <pre> 656 * <p id="Example1-config">To configure the check...</p> 657 * <macro name="example"><param name="type" value="config"/></macro> 658 * <p id="Example1-code">Example:</p> 659 * <macro name="example"><param name="type" value="code"/></macro> 660 * </pre> 661 * 662 * @param doc the parsed XDoc document 663 * @param baseUrl the page url without anchor 664 * @param category category label 665 * @return list of per-example entries (both config and code); empty if 666 * none found 667 */ 668 private static List<SearchIndexEntry> extractExampleEntries(Document doc, 669 String baseUrl, 670 String category) { 671 final List<SearchIndexEntry> exampleEntries = new ArrayList<>(); 672 final Element body = requireBody(doc, baseUrl); 673 final NodeList sections = body.getElementsByTagName(SECTION); 674 675 for (int sectionIdx = 0; sectionIdx < sections.getLength(); sectionIdx++) { 676 final Element section = (Element) sections.item(sectionIdx); 677 final String checkName = section.getAttribute(NAME_ATTR).trim(); 678 final Element examplesSubsection = 679 findSubsectionByPrefix(section, EXAMPLES_SUBSECTION); 680 681 if (examplesSubsection == null) { 682 continue; 683 } 684 685 final NodeList paragraphs = 686 examplesSubsection.getElementsByTagName(P_TAG); 687 688 for (int paragraphIndex = 0; paragraphIndex < paragraphs.getLength(); 689 paragraphIndex++) { 690 final Element paragraph = (Element) paragraphs.item(paragraphIndex); 691 final SearchIndexEntry entry = buildExampleEntry( 692 paragraph, checkName, baseUrl, category); 693 if (entry != null) { 694 exampleEntries.add(entry); 695 } 696 } 697 } 698 699 return exampleEntries; 700 } 701 702 /** 703 * Builds a single example entry from a paragraph element. 704 * 705 * @param paragraph the paragraph element containing the example 706 * @param checkName the name of the check 707 * @param baseUrl the base URL for the page 708 * @param category the category label 709 * @return a SearchIndexEntry if the paragraph matches the example pattern, 710 * null otherwise 711 */ 712 private static SearchIndexEntry buildExampleEntry(Element paragraph, 713 String checkName, 714 String baseUrl, 715 String category) { 716 final String id = paragraph.getAttribute(ID_ATTR); 717 final Matcher matcher = EXAMPLE_PARAGRAPH_ID.matcher(id); 718 SearchIndexEntry result = null; 719 720 if (matcher.matches()) { 721 final String exampleLabel = matcher.group(1); 722 final String exampleType = matcher.group(2); 723 724 final String introText = WHITESPACE 725 .matcher(paragraph.getTextContent()) 726 .replaceAll(SPACE).trim(); 727 728 final String title = checkName + TITLE_SEPARATOR 729 + exampleLabel; 730 final String url = baseUrl + ANCHOR_SEPARATOR + id; 731 final String description = 732 truncate(introText, MAX_DESCRIPTION_LENGTH); 733 final String keywords = extractKeywordsFromText( 734 checkName + SPACE + exampleLabel 735 + SPACE + exampleType + SPACE + introText); 736 737 result = new SearchIndexEntry( 738 title, url, category, EXAMPLE_TYPE, 739 description, keywords, "", getWeightForType(EXAMPLE_TYPE)); 740 } 741 742 return result; 743 } 744 745 /** 746 * Extracts per-property search entries from a check/filter document. 747 * 748 * <p>Each row of the Properties table is indexed under the title 749 * {@code "<CheckName>: <propertyName>"} and linked to the property's 750 * own anchor on the page.</p> 751 * 752 * @param doc the parsed XDoc document 753 * @param baseUrl the page url without anchor 754 * @param category category label 755 * @return list of per-property entries; empty if none found 756 */ 757 private static List<SearchIndexEntry> extractPropertyEntries(Document doc, 758 String baseUrl, 759 String category) { 760 final List<SearchIndexEntry> propertyEntries = new ArrayList<>(); 761 final Element body = requireBody(doc, baseUrl); 762 final NodeList sections = body.getElementsByTagName(SECTION); 763 764 for (int sectionIdx = 0; sectionIdx < sections.getLength(); sectionIdx++) { 765 final Element section = (Element) sections.item(sectionIdx); 766 final Element propertiesSubsection = 767 findSubsectionByPrefix(section, PROPERTIES_FRAGMENT); 768 769 if (propertiesSubsection != null) { 770 final String checkName = section.getAttribute(NAME_ATTR).trim(); 771 extractPropertiesFromRows(propertiesSubsection, checkName, baseUrl, 772 category, propertyEntries); 773 } 774 } 775 776 return propertyEntries; 777 } 778 779 /** 780 * Extracts property entries from table rows and adds them to the list. 781 * 782 * @param propertiesSubsection the properties subsection element 783 * @param checkName the check name 784 * @param baseUrl the page url without anchor 785 * @param category category label 786 * @param propertyEntries the list to add entries to 787 */ 788 private static void extractPropertiesFromRows(Element propertiesSubsection, 789 String checkName, 790 String baseUrl, 791 String category, 792 List<SearchIndexEntry> propertyEntries) { 793 final NodeList rows = propertiesSubsection.getElementsByTagName("tr"); 794 795 for (int rowIdx = 1; rowIdx < rows.getLength(); rowIdx++) { 796 final Element row = (Element) rows.item(rowIdx); 797 final NodeList cells = row.getElementsByTagName("td"); 798 if (cells.getLength() >= 2) { 799 processPropertyRow(cells, checkName, baseUrl, category, propertyEntries); 800 } 801 } 802 } 803 804 /** 805 * Processes a single property row and adds an entry if valid. 806 * 807 * @param cells the table cells 808 * @param checkName the check name 809 * @param baseUrl the page url without anchor 810 * @param category category label 811 * @param propertyEntries the list to add entries to 812 */ 813 private static void processPropertyRow(NodeList cells, 814 String checkName, 815 String baseUrl, 816 String category, 817 List<SearchIndexEntry> propertyEntries) { 818 final String propName = WHITESPACE 819 .matcher(cells.item(0).getTextContent()) 820 .replaceAll(SPACE).trim(); 821 822 if (!propName.isEmpty()) { 823 final String propDesc = WHITESPACE 824 .matcher(cells.item(1).getTextContent()) 825 .replaceAll(SPACE).trim(); 826 827 final String title = checkName + TITLE_SEPARATOR + propName; 828 final String url = baseUrl + ANCHOR_SEPARATOR + propName; 829 final String description = truncate(propDesc, MAX_DESCRIPTION_LENGTH); 830 final String keywords = extractKeywordsFromText( 831 checkName + SPACE + propName + SPACE + propDesc); 832 String since = ""; 833 if (cells.getLength() >= EXPECTED_PROPERTY_COLUMNS) { 834 final Node sinceCell = cells.item(PROPERTY_SINCE_COLUMN_INDEX); 835 if (sinceCell != null) { 836 final String sinceText = sinceCell.getTextContent(); 837 if (sinceText != null) { 838 since = WHITESPACE.matcher(sinceText) 839 .replaceAll(SPACE).trim(); 840 } 841 } 842 } 843 final int weight = getWeightForType(PROPERTY_TYPE); 844 845 propertyEntries.add(new SearchIndexEntry( 846 title, url, category, PROPERTY_TYPE, 847 description, keywords, since, weight)); 848 } 849 } 850 851 /** 852 * Adds an entry to the output list only if its URL has not been seen 853 * before. This is a secondary guard that catches any duplicates that 854 * slip through the primary filter (only processing plain {@code .xml} 855 * files), e.g. if a check has the same example paragraph id repeated 856 * across two sections. 857 * 858 * @param entry the entry to conditionally add 859 */ 860 private void addIfNew(SearchIndexEntry entry) { 861 if (seenUrls.add(entry.url())) { 862 entries.add(entry); 863 } 864 } 865 866 /** 867 * Finds a subsection within a section whose lowercased name contains the 868 * given fragment (e.g. "examples" or "propert" to match "Properties"). 869 * 870 * @param section the section to search 871 * @param fragment lowercase fragment to match against the subsection name 872 * @return the matching subsection element, or {@code null} if not found 873 */ 874 private static Element findSubsectionByPrefix(Element section, String fragment) { 875 final NodeList subsections = section.getElementsByTagName(SUBSECTION); 876 Element result = null; 877 for (int index = 0; index < subsections.getLength(); index++) { 878 final Element sub = (Element) subsections.item(index); 879 if (sub.getAttribute(NAME_ATTR).trim() 880 .toLowerCase(Locale.ROOT).contains(fragment)) { 881 result = sub; 882 break; 883 } 884 } 885 return result; 886 } 887 888 /** 889 * Parses the XML file into a Document with external entity resolution 890 * disabled for security. 891 * 892 * @param xmlFile the XDoc source file 893 * @return the parsed Document 894 * @throws ParserConfigurationException on XML parser setup failure 895 * @throws SAXException on XML parse error 896 * @throws IOException on file read failure 897 */ 898 private static Document parseXml(File xmlFile) 899 throws ParserConfigurationException, SAXException, IOException { 900 final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); 901 factory.setFeature(EXTERNAL_GENERAL_ENTITIES, false); 902 factory.setFeature(EXTERNAL_PARAMETER_ENTITIES, false); 903 904 final DocumentBuilder builder = factory.newDocumentBuilder(); 905 builder.setErrorHandler(null); 906 907 final Document doc = builder.parse(xmlFile); 908 doc.getDocumentElement().normalize(); 909 return doc; 910 } 911 912 /** 913 * Returns the document's {@code <body>} element, failing fast if it is 914 * absent. Every XDoc page processed by this generator is expected to 915 * have one; its absence indicates a malformed source file that should 916 * be fixed rather than silently skipped or producing an empty entry. 917 * 918 * @param doc the parsed document 919 * @param identifier file path or URL used to identify the source in the 920 * error message 921 * @return the body element 922 * @throws IllegalStateException if {@code doc} has no {@code <body>} element 923 */ 924 private static Element requireBody(Document doc, String identifier) { 925 final NodeList bodies = doc.getElementsByTagName(BODY); 926 if (bodies.getLength() == 0) { 927 throw new IllegalStateException( 928 "XDoc file is missing a <body> element: " + identifier); 929 } 930 final Element body = (Element) bodies.item(0); 931 if (body == null) { 932 throw new IllegalStateException( 933 "XDoc file has a null <body> element: " + identifier); 934 } 935 return body; 936 } 937 938 /** 939 * Extracts the document title from the {@code <title>} element, falling 940 * back to the first non-empty, non-"Content" section name, and finally 941 * to a capitalised version of the file name. 942 * 943 * @param doc the document 944 * @param xmlFile the source file 945 * @param sections the list of sections 946 * @return the title string, never empty 947 */ 948 private static String extractTitle(Document doc, File xmlFile, NodeList sections) { 949 final NodeList titles = doc.getElementsByTagName(TITLE); 950 String title = ""; 951 if (titles.getLength() > 0) { 952 title = titles.item(0).getTextContent().trim(); 953 } 954 955 if ((title.isEmpty() || CONTENT.equalsIgnoreCase(title)) 956 && sections.getLength() > 0) { 957 final String firstSection = 958 ((Element) sections.item(0)).getAttribute(NAME_ATTR).trim(); 959 if (!firstSection.isEmpty() && !CONTENT.equalsIgnoreCase(firstSection)) { 960 title = firstSection; 961 } 962 } 963 964 if (title.isEmpty() || CONTENT.equalsIgnoreCase(title)) { 965 final String name = 966 xmlFile.getName().replaceFirst(DOC_EXTENSION.pattern(), ""); 967 title = capitalise(name.replace('_', ' ')); 968 } 969 return title; 970 } 971 972 /** 973 * Aggregates description from sections, taking the first non-empty 974 * Description subsection found across all sections in the document. 975 * 976 * @param sections list of sections 977 * @return description string, possibly empty 978 */ 979 private static String extractAggregateDescription(NodeList sections) { 980 String description = ""; 981 for (int index = 0; index < sections.getLength(); index++) { 982 description = extractDescription((Element) sections.item(index)); 983 if (!description.isEmpty()) { 984 break; 985 } 986 } 987 return description; 988 } 989 990 /** 991 * Aggregates keywords from sections using all section text so that the 992 * main check entry is discoverable by any term in the document. 993 * 994 * @param title the document title 995 * @param sections list of sections 996 * @return keywords string 997 */ 998 private static String extractAggregateKeywords(String title, NodeList sections) { 999 final StringBuilder keywordSource = new StringBuilder(title); 1000 for (int index = 0; index < sections.getLength(); index++) { 1001 final Element section = (Element) sections.item(index); 1002 keywordSource.append(SPACE_CHAR) 1003 .append(section.getAttribute(NAME_ATTR)) 1004 .append(SPACE_CHAR) 1005 .append(section.getTextContent()); 1006 } 1007 return extractKeywordsFromText(keywordSource.toString()); 1008 } 1009 1010 /** 1011 * Extracts the first sentence of the Description subsection. 1012 * Returns an empty string if no Description subsection is found. 1013 * 1014 * @param section the {@code <section>} element to search 1015 * @return first sentence of the description, or empty string 1016 */ 1017 private static String extractDescription(Element section) { 1018 final Element sub = findSubsectionByPrefix(section, DESCRIPTION); 1019 String result = ""; 1020 if (sub != null) { 1021 final String text = WHITESPACE.matcher(sub.getTextContent()) 1022 .replaceAll(SPACE).trim(); 1023 result = extractFirstSentenceOrTruncated(text); 1024 } 1025 return result; 1026 } 1027 1028 /** 1029 * Derives a fallback page title from the document's {@code <title>} 1030 * element or, failing that, from the filename. 1031 * 1032 * @param doc the parsed document 1033 * @param xmlFile the source file 1034 * @return a non-empty title string 1035 */ 1036 private static String derivePageTitle(Document doc, File xmlFile) { 1037 final NodeList titles = doc.getElementsByTagName(TITLE); 1038 String title = ""; 1039 if (titles.getLength() > 0) { 1040 title = titles.item(0).getTextContent().trim(); 1041 } 1042 if (title.isEmpty()) { 1043 final String name = 1044 xmlFile.getName().replaceFirst(DOC_EXTENSION.pattern(), ""); 1045 title = capitalise(name.replace('_', ' ')); 1046 } 1047 return title; 1048 } 1049 1050 /** 1051 * Disambiguates a section title when it is a generic, structurally 1052 * repeated header (see {@link #GENERIC_SECTION_NAMES}). 1053 * Non-generic section names are returned unchanged. 1054 * 1055 * @param sectionName the raw section name 1056 * @param pageTitle the owning page's own title 1057 * @return either {@code sectionName} unchanged, or 1058 * {@code "<pageTitle>: <sectionName>"} if generic 1059 */ 1060 private static String disambiguateTitle(String sectionName, String pageTitle) { 1061 final String result; 1062 if (GENERIC_SECTION_NAMES.contains(sectionName.toLowerCase(Locale.ROOT))) { 1063 result = pageTitle + TITLE_SEPARATOR + sectionName; 1064 } 1065 else { 1066 result = sectionName; 1067 } 1068 return result; 1069 } 1070 1071 /** 1072 * Converts a Doxia {@code <section name="...">} value into the anchor id 1073 * Doxia generates for it in the rendered HTML by replacing runs of 1074 * whitespace with single underscores. 1075 * 1076 * @param sectionName the raw {@code name} attribute value 1077 * @return the anchor id Doxia would render for this section name 1078 */ 1079 private static String doxiaAnchorFor(String sectionName) { 1080 return WHITESPACE.matcher(sectionName.trim()).replaceAll("_"); 1081 } 1082 1083 /** 1084 * Returns the first sentence of the given text (up to and including the 1085 * first period), or the text truncated to {@link #MAX_DESCRIPTION_LENGTH} 1086 * with an ellipsis if no period is found within range. 1087 * 1088 * @param text the source text, already whitespace-normalised 1089 * @return first sentence or truncated text 1090 */ 1091 private static String extractFirstSentenceOrTruncated(String text) { 1092 final String result; 1093 final int dot = text.indexOf('.'); 1094 if (dot > 0) { 1095 result = text.substring(0, dot + 1).trim(); 1096 } 1097 else { 1098 result = truncate(text, MAX_DESCRIPTION_LENGTH); 1099 } 1100 return result; 1101 } 1102 1103 /** 1104 * Truncates text to the given max length, appending an ellipsis if 1105 * truncation occurred. 1106 * 1107 * @param text the text to truncate 1108 * @param maxLength maximum length before truncation 1109 * @return original text if short enough, otherwise truncated with ellipsis 1110 */ 1111 private static String truncate(String text, int maxLength) { 1112 final String result; 1113 if (text.length() > maxLength) { 1114 result = text.substring(0, maxLength) + ELLIPSIS; 1115 } 1116 else { 1117 result = text; 1118 } 1119 return result; 1120 } 1121 1122 /** 1123 * Builds the root-relative URL for an XDoc file, without any anchor. 1124 * Always uses forward slashes regardless of OS. 1125 * 1126 * @param xmlFile the source XDoc file 1127 * @param xdocsDir the xdocs root directory 1128 * @return root-relative URL string with no anchor 1129 */ 1130 private static String buildUrl(File xmlFile, File xdocsDir) { 1131 return xdocsDir.toPath() 1132 .relativize(xmlFile.toPath()) 1133 .toString() 1134 .replace(File.separatorChar, '/') 1135 .replaceFirst(DOC_EXTENSION.pattern(), ".html"); 1136 } 1137 1138 /** 1139 * Resolves the correct URL for a general page file. For {@code config_<category>.xml} files 1140 * that redirect to check category pages, maps to {@code checks/<category>/index.html} instead 1141 * of the file path. 1142 * 1143 * @param xmlFile the source XDoc file 1144 * @param xdocsDir the xdocs root directory 1145 * @return the resolved URL 1146 */ 1147 private static String resolvePageUrl(File xmlFile, File xdocsDir) { 1148 String url = buildUrl(xmlFile, xdocsDir); 1149 final Matcher matcher = CONFIG_CATEGORY.matcher(xmlFile.getName()); 1150 if (matcher.find()) { 1151 final String category = matcher.group(1); 1152 if (CHECKS_CATEGORY_DISPLAY_NAMES.containsKey(category)) { 1153 url = CHECKS + PATH_SEPARATOR + category + PATH_SEPARATOR + INDEX_HTML; 1154 } 1155 else if (FILTERS_DIR.equals(category) || FILEFILTERS_DIR.equals(category)) { 1156 url = category + PATH_SEPARATOR + INDEX_HTML; 1157 } 1158 } 1159 return url; 1160 } 1161 1162 /** 1163 * Extracts keywords from free-form text by splitting on non-word 1164 * characters and filtering short and stop words. 1165 * 1166 * @param text input text 1167 * @return comma-separated keyword string (up to {@link #MAX_KEYWORDS} words) 1168 */ 1169 private static String extractKeywordsFromText(String text) { 1170 String result = ""; 1171 if (text != null && !text.isEmpty()) { 1172 result = NON_ALPHANUMERIC.splitAsStream(text.toLowerCase(Locale.ROOT)) 1173 .filter(word -> { 1174 return word.length() >= MIN_WORD_LENGTH 1175 && !STOP_WORDS.contains(word); 1176 }) 1177 .distinct() 1178 .limit(MAX_KEYWORDS) 1179 .collect(Collectors.joining(COMMA_STR)); 1180 } 1181 return result; 1182 } 1183 1184 /** 1185 * Extracts the "since" version from the document body, if present. 1186 * 1187 * @param body the body element to search 1188 * @return the version string, or empty string if not found 1189 */ 1190 private static String extractSince(final Element body) { 1191 String since = ""; 1192 final NodeList paragraphs = body.getElementsByTagName(P_TAG); 1193 for (int index = 0; index < paragraphs.getLength(); index++) { 1194 final Node node = paragraphs.item(index); 1195 if (node != null) { 1196 final String textContent = node.getTextContent(); 1197 if (textContent != null) { 1198 final String text = textContent.trim(); 1199 if (text.startsWith(SINCE_CHECKSTYLE)) { 1200 since = text.substring(SINCE_CHECKSTYLE.length()) 1201 .trim(); 1202 break; 1203 } 1204 } 1205 } 1206 } 1207 return since; 1208 } 1209 1210 /** 1211 * Returns a ranking weight based on the document type. 1212 * 1213 * @param type the document type 1214 * @return an integer weight 1215 */ 1216 private static int getWeightForType(final String type) { 1217 final int weight; 1218 if (CHECK_TYPE.equals(type)) { 1219 weight = WEIGHT_CHECK; 1220 } 1221 else if (FILTER_TYPE.equals(type) || FILE_FILTER_TYPE.equals(type)) { 1222 weight = WEIGHT_FILTER; 1223 } 1224 else if (GENERAL.equals(type)) { 1225 weight = WEIGHT_GENERAL; 1226 } 1227 else if (PROPERTY_TYPE.equals(type)) { 1228 weight = WEIGHT_PROPERTY; 1229 } 1230 else if (EXAMPLE_TYPE.equals(type)) { 1231 weight = WEIGHT_EXAMPLE; 1232 } 1233 else { 1234 weight = WEIGHT_DEFAULT; 1235 } 1236 return weight; 1237 } 1238 1239 /** 1240 * Writes all index entries to the output file. 1241 * 1242 * @param indexEntries the list of entries to serialise 1243 * @param outputFilePath the full path to the output file 1244 * @throws IOException on file write failure 1245 */ 1246 private static void writeJson(List<SearchIndexEntry> indexEntries, Path outputFilePath) 1247 throws IOException { 1248 1249 final Path outputPath = outputFilePath.getParent(); 1250 if (outputPath != null) { 1251 Files.createDirectories(outputPath); 1252 } 1253 1254 try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter( 1255 outputFilePath))) { 1256 writer.println("["); 1257 1258 final int size = indexEntries.size(); 1259 for (int index = 0; index < size; index++) { 1260 final String comma; 1261 if (index < size - 1) { 1262 comma = COMMA_STR; 1263 } 1264 else { 1265 comma = ""; 1266 } 1267 writer.println(" " + indexEntries.get(index).toJson() + comma); 1268 } 1269 writer.println("]"); 1270 } 1271 } 1272 1273 /** 1274 * Capitalises the first character of a string. 1275 * 1276 * @param input the string to capitalise 1277 * @return string with first character uppercased, or input unchanged if 1278 * empty 1279 */ 1280 private static String capitalise(String input) { 1281 String result = input; 1282 if (input != null && !input.isEmpty()) { 1283 result = Character.toUpperCase(input.charAt(0)) + input.substring(1); 1284 } 1285 return result; 1286 } 1287 1288}