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