View Javadoc
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.internal;
21  
22  import static com.google.common.collect.ImmutableList.toImmutableList;
23  import static com.google.common.truth.Truth.assertWithMessage;
24  import static java.lang.Integer.parseInt;
25  
26  import java.beans.PropertyDescriptor;
27  import java.io.File;
28  import java.io.IOException;
29  import java.io.StringReader;
30  import java.lang.reflect.Array;
31  import java.lang.reflect.Field;
32  import java.lang.reflect.ParameterizedType;
33  import java.net.URI;
34  import java.net.URLEncoder;
35  import java.nio.charset.StandardCharsets;
36  import java.nio.file.Files;
37  import java.nio.file.Path;
38  import java.util.ArrayList;
39  import java.util.Arrays;
40  import java.util.BitSet;
41  import java.util.Collection;
42  import java.util.Collections;
43  import java.util.HashMap;
44  import java.util.HashSet;
45  import java.util.Iterator;
46  import java.util.List;
47  import java.util.Locale;
48  import java.util.Map;
49  import java.util.NoSuchElementException;
50  import java.util.Optional;
51  import java.util.Properties;
52  import java.util.Set;
53  import java.util.TreeSet;
54  import java.util.regex.Matcher;
55  import java.util.regex.Pattern;
56  import java.util.stream.Collectors;
57  import java.util.stream.IntStream;
58  import java.util.stream.Stream;
59  
60  import javax.xml.parsers.DocumentBuilder;
61  import javax.xml.parsers.DocumentBuilderFactory;
62  
63  import org.apache.commons.beanutils.PropertyUtils;
64  import org.junit.jupiter.api.Test;
65  import org.w3c.dom.Document;
66  import org.w3c.dom.Element;
67  import org.w3c.dom.Node;
68  import org.w3c.dom.NodeList;
69  import org.xml.sax.InputSource;
70  
71  import com.puppycrawl.tools.checkstyle.Checker;
72  import com.puppycrawl.tools.checkstyle.ConfigurationLoader;
73  import com.puppycrawl.tools.checkstyle.ConfigurationLoader.IgnoredModulesOptions;
74  import com.puppycrawl.tools.checkstyle.ModuleFactory;
75  import com.puppycrawl.tools.checkstyle.PropertiesExpander;
76  import com.puppycrawl.tools.checkstyle.XdocsPropertyType;
77  import com.puppycrawl.tools.checkstyle.api.AbstractCheck;
78  import com.puppycrawl.tools.checkstyle.api.AbstractFileSetCheck;
79  import com.puppycrawl.tools.checkstyle.api.CheckstyleException;
80  import com.puppycrawl.tools.checkstyle.api.Configuration;
81  import com.puppycrawl.tools.checkstyle.checks.javadoc.AbstractJavadocCheck;
82  import com.puppycrawl.tools.checkstyle.checks.naming.AccessModifierOption;
83  import com.puppycrawl.tools.checkstyle.internal.annotation.PreserveOrder;
84  import com.puppycrawl.tools.checkstyle.internal.utils.CheckUtil;
85  import com.puppycrawl.tools.checkstyle.internal.utils.TestUtil;
86  import com.puppycrawl.tools.checkstyle.internal.utils.XdocUtil;
87  import com.puppycrawl.tools.checkstyle.internal.utils.XmlUtil;
88  import com.puppycrawl.tools.checkstyle.utils.TokenUtil;
89  
90  /**
91   * Validates xdocs pages generated during the Maven {@code process-classes} phase.
92   */
93  public class XdocsPagesTest {
94  
95      private static final Path SITE_PATH = Path.of("src/site/site.xml");
96      private static final Path CHECKSTYLE_JS_PATH = Path.of(
97          "src/site/resources/js/checkstyle.js");
98  
99      private static final Path AVAILABLE_CHECKS_PATH = Path.of("src/site/xdoc/checks.xml");
100     private static final Path AVAILABLE_FILE_FILTERS_PATH = Path.of(
101         "src/site/xdoc/filefilters/index.xml");
102     private static final Path AVAILABLE_FILTERS_PATH = Path.of("src/site/xdoc/filters/index.xml");
103 
104     private static final Pattern VERSION = Pattern.compile("\\d+\\.\\d+(\\.\\d+)?");
105 
106     private static final Pattern DESCRIPTION_VERSION = Pattern
107             .compile("^Since Checkstyle \\d+\\.\\d+(\\.\\d+)?");
108 
109     private static final Pattern END_OF_SENTENCE = Pattern.compile("(.*?\\.)\\s", Pattern.DOTALL);
110 
111     /** Matches the numeric id, e.g. "Example3" or "UseCase1", from a "-config" paragraph id. */
112     private static final Pattern EXAMPLE_ID_PATTERN =
113             Pattern.compile("^((?:Example|UseCase)\\d+)-config$");
114 
115     /** Strips inline HTML tags left in scraped paragraph text except {@code <code>} tags. */
116     private static final Pattern TAG_PATTERN = Pattern.compile("</?(?!code\\b)[a-zA-Z][^>]*>");
117 
118     private static final List<String> XML_FILESET_LIST = List.of(
119             "TreeWalker",
120             "name=\"Checker\"",
121             "name=\"Header\"",
122             "name=\"LineLength\"",
123             "name=\"Translation\"",
124             "name=\"SeverityMatchFilter\"",
125             "name=\"SuppressWithNearbyTextFilter\"",
126             "name=\"SuppressWithPlainTextCommentFilter\"",
127             "name=\"SuppressionFilter\"",
128             "name=\"SuppressionSingleFilter\"",
129             "name=\"SuppressWarningsFilter\"",
130             "name=\"BeforeExecutionExclusionFileFilter\"",
131             "name=\"RegexpHeader\"",
132             "name=\"MultiFileRegexpHeader\"",
133             "name=\"RegexpOnFilename\"",
134             "name=\"RegexpSingleline\"",
135             "name=\"RegexpMultiline\"",
136             "name=\"JavadocPackage\"",
137             "name=\"LineEnding\"",
138             "name=\"NewlineAtEndOfFile\"",
139             "name=\"OrderedProperties\"",
140             "name=\"UniqueProperties\"",
141             "name=\"FileLength\"",
142             "name=\"FileTabCharacter\""
143     );
144 
145     private static final Set<String> CHECK_PROPERTIES = getProperties(AbstractCheck.class);
146     private static final Set<String> JAVADOC_CHECK_PROPERTIES =
147             getProperties(AbstractJavadocCheck.class);
148     private static final Set<String> FILESET_PROPERTIES = getProperties(AbstractFileSetCheck.class);
149 
150     private static final Set<String> UNDOCUMENTED_PROPERTIES = Set.of(
151             "Checker.classLoader",
152             "Checker.classloader",
153             "Checker.moduleClassLoader",
154             "Checker.moduleFactory",
155             "TreeWalker.classLoader",
156             "TreeWalker.moduleFactory",
157             "TreeWalker.cacheFile",
158             "TreeWalker.upChild",
159             "SuppressWithNearbyCommentFilter.fileContents",
160             "SuppressionCommentFilter.fileContents"
161     );
162 
163     private static final Set<String> PROPERTIES_ALLOWED_GET_TYPES_FROM_METHOD = Set.of(
164             // static field (all upper case)
165             "SuppressWarningsHolder.aliasList",
166             // loads string into memory similar to file
167             "Header.header",
168             "RegexpHeader.header",
169             // property is an int, but we cut off excess to accommodate old versions
170             "RedundantModifier.jdkVersion",
171             // until https://github.com/checkstyle/checkstyle/issues/13376
172             "CustomImportOrder.customImportOrderRules"
173     );
174 
175     private static final Set<String> SUN_MODULES = Collections.unmodifiableSet(
176         CheckUtil.getConfigSunStyleModules());
177     // ignore the not yet properly covered modules while testing newly added ones
178     // add proper sections to the coverage report and integration tests
179     // and then remove this list eventually
180     private static final Set<String> IGNORED_SUN_MODULES = Set.of(
181             "ArrayTypeStyle",
182             "AvoidNestedBlocks",
183             "AvoidStarImport",
184             "ConstantName",
185             "DesignForExtension",
186             "EmptyBlock",
187             "EmptyForIteratorPad",
188             "EmptyStatement",
189             "EqualsHashCode",
190             "FileLength",
191             "FileTabCharacter",
192             "FinalClass",
193             "FinalParameters",
194             "GenericWhitespace",
195             "HiddenField",
196             "HideUtilityClassConstructor",
197             "IllegalImport",
198             "IllegalInstantiation",
199             "InnerAssignment",
200             "InterfaceIsType",
201             "JavadocMethod",
202             "JavadocPackage",
203             "JavadocType",
204             "JavadocVariable",
205             "LeftCurly",
206             "LineLength",
207             "LocalFinalVariableName",
208             "LocalVariableName",
209             "MagicNumber",
210             "MemberName",
211             "MethodLength",
212             "MethodName",
213             "MethodParamPad",
214             "MissingJavadocMethod",
215             "MissingSwitchDefault",
216             "ModifierOrder",
217             "NeedBraces",
218             "NewlineAtEndOfFile",
219             "NoWhitespaceAfter",
220             "NoWhitespaceBefore",
221             "OperatorWrap",
222             "PackageName",
223             "ParameterName",
224             "ParameterNumber",
225             "ParenPad",
226             "RedundantImport",
227             "RedundantModifier",
228             "RegexpSingleline",
229             "RightCurly",
230             "SimplifyBooleanExpression",
231             "SimplifyBooleanReturn",
232             "StaticVariableName",
233             "TodoComment",
234             "Translation",
235             "TypecastParenPad",
236             "TypeName",
237             "UnusedImports",
238             "UpperEll",
239             "VisibilityModifier",
240             "WhitespaceAfter",
241             "WhitespaceAround"
242     );
243 
244     private static final Set<String> GOOGLE_MODULES = Collections.unmodifiableSet(
245         CheckUtil.getConfigGoogleStyleModules());
246 
247     // Requirement is not yet public.
248     private static final Set<String> IGNORED_GOOGLE_MODULES = Set.of(
249             "RegexpSingleline"
250     );
251 
252     private static final Set<String> OPENJDK_MODULES = Collections.unmodifiableSet(
253         CheckUtil.getConfigOpenJdkStyleModules());
254 
255     private static final Set<String> DOC_COMMENTS_MODULES = Collections.unmodifiableSet(
256         CheckUtil.getConfigDocCommentsStyleModules());
257 
258     /**
259      * Example pairs that are intentionally placed in the same separated group, as they
260      * demonstrate the same configuration applied to files of different types.
261      * Each entry has the form {@code templateFileName:previousExamplePrefix:currentExamplePrefix}
262      * and marks that pair as allowed to appear without a separator between them.
263      */
264     private static final Set<String> ALLOWED_EXAMPLES_WITHOUT_SEPARATOR = Set.of(
265         "newlineatendoffile.xml.template:Example4:Example6"
266     );
267 
268     private static final Set<String> NON_MODULE_XDOC = Set.of(
269         "config-system-properties.xml",
270         "sponsoring.xml",
271         "consulting.xml",
272         "index.xml",
273         "extending.xml",
274         "contributing.xml",
275         "running.xml",
276         "checks.xml",
277         "property-types.xml",
278         "google-style.xml",
279         "openjdk-style.xml",
280         "sun-style.xml",
281         "doc-comments-style.xml",
282         "style-configs.xml",
283         "writing-filters.xml",
284         "writing-filefilters.xml",
285         "eclipse.xml",
286         "netbeans.xml",
287         "idea.xml",
288         "beginning-development.xml",
289         "writing-checks.xml",
290         "config.xml",
291         "report-issue.xml",
292         "result-reports.xml",
293         "xpath.xml",
294         "google_style.xml",
295         "openjdk_style.xml",
296         "sun_style.xml",
297         "property_types.xml",
298         "releasenotes.xml",
299         "report_issue.xml",
300         "result_reports.xml",
301         "style_configs.xml",
302         "writingchecks.xml",
303         "writingfilefilters.xml",
304         "writingfilters.xml",
305         "writingjavadocchecks.xml",
306         "writinglisteners.xml",
307         "anttask.xml",
308         "beginning_development.xml",
309         "doc_comments_style.xml"
310     );
311 
312     private static final String NAMES_MUST_BE_IN_ALPHABETICAL_ORDER_SITE_PATH =
313             " names must be in alphabetical order at " + SITE_PATH;
314 
315     @Test
316     public void testAllChecksPresentOnAvailableChecksPage() throws Exception {
317         final String availableChecks = Files.readString(AVAILABLE_CHECKS_PATH);
318 
319         CheckUtil.getSimpleNames(CheckUtil.getCheckstyleChecks())
320             .forEach(checkName -> {
321                 if (!isPresent(availableChecks, checkName)) {
322                     assertWithMessage(
323                             "%s is not correctly listed on Available Checks page - add it to %s",
324                             checkName, AVAILABLE_CHECKS_PATH).fail();
325                 }
326             });
327     }
328 
329     private static boolean isPresent(String availableChecks, String checkName) {
330         final String linkPattern = String.format(Locale.ROOT,
331                 "(?s).*<a href=\"[^\"]+#%1$s\">([\\r\\n\\s])*%1$s([\\r\\n\\s])*</a>.*",
332                 checkName);
333         return availableChecks.matches(linkPattern);
334     }
335 
336     @Test
337     public void testAllConfigsHaveLinkInSite() throws Exception {
338         final String siteContent = Files.readString(SITE_PATH);
339 
340         for (Path path : XdocUtil.getXdocsConfigFilePaths(XdocUtil.getXdocsFilePaths())) {
341             final String expectedFile = path.toString()
342                     .replace(".xml", ".html")
343                     .replaceAll("\\\\", "/")
344                     .replaceAll("src[\\\\/]site[\\\\/]xdoc[\\\\/]", "");
345             final boolean isConfigHtmlFile = Pattern.matches("config_[a-z]+.html", expectedFile);
346             final boolean isChecksIndexHtmlFile = "checks/index.html".equals(expectedFile);
347             final boolean isOldReleaseNotes = path.toString().contains("release-notes-");
348             final boolean isInnerPage = "report-issue.html".equals(expectedFile);
349             final boolean isRedirectStub = Set.of(
350                     "google_style.html",
351                     "openjdk_style.html",
352                     "sun_style.html",
353                     "property_types.html",
354                     "releasenotes.html",
355                     "report_issue.html",
356                     "result_reports.html",
357                     "style_configs.html",
358                     "writingchecks.html",
359                     "writingfilefilters.html",
360                     "writingfilters.html",
361                     "writingjavadocchecks.html",
362                     "writinglisteners.html",
363                     "anttask.html",
364                     "beginning_development.html",
365                     "doc_comments_style.html"
366             ).contains(expectedFile);
367 
368             if (!isConfigHtmlFile && !isChecksIndexHtmlFile
369                 && !isOldReleaseNotes && !isInnerPage && !isRedirectStub) {
370                 final String expectedLink = String.format(Locale.ROOT, "href=\"%s\"", expectedFile);
371                 assertWithMessage("Expected to find link to '%s' in %s", expectedLink, SITE_PATH)
372                         .that(siteContent)
373                         .contains(expectedLink);
374             }
375         }
376     }
377 
378     @Test
379     public void testAllModulesPageInSyncWithModuleSummaries() throws Exception {
380         validateModulesSyncWithTheirSummaries(AVAILABLE_CHECKS_PATH,
381             (Path path) -> {
382                 final String fileName = path.getFileName().toString();
383                 return isNonModulePage(fileName) || !path.toString().contains("checks");
384             });
385 
386         validateModulesSyncWithTheirSummaries(AVAILABLE_FILTERS_PATH,
387             (Path path) -> {
388                 final String fileName = path.getFileName().toString();
389                 return isNonModulePage(fileName)
390                     || path.toString().contains("checks")
391                     || path.toString().contains("filefilters");
392             });
393 
394         validateModulesSyncWithTheirSummaries(AVAILABLE_FILE_FILTERS_PATH,
395             (Path path) -> {
396                 final String fileName = path.getFileName().toString();
397                 return isNonModulePage(fileName) || !path.toString().contains("filefilters");
398             });
399     }
400 
401     private static void validateModulesSyncWithTheirSummaries(Path availablePagePath,
402                                                               PredicateProcess skipPredicate)
403             throws Exception {
404         for (Path path : XdocUtil.getXdocsConfigFilePaths(XdocUtil.getXdocsFilePaths())) {
405             if (skipPredicate.hasFit(path)) {
406                 continue;
407             }
408 
409             final String fileName = path.getFileName().toString();
410             final Map<String, String> summaries = readSummaries(availablePagePath);
411             final NodeList subsectionSources = getTagSourcesNode(path, "subsection");
412 
413             for (int position = 0; position < subsectionSources.getLength(); position++) {
414                 final Node subsection = subsectionSources.item(position);
415                 final String subsectionName = XmlUtil.getNameAttributeOfNode(subsection);
416                 if (!"Description".equals(subsectionName)) {
417                     continue;
418                 }
419 
420                 final String moduleName = XmlUtil.getNameAttributeOfNode(
421                     subsection.getParentNode());
422                 final Matcher matcher = END_OF_SENTENCE.matcher(subsection.getTextContent());
423                 assertWithMessage(
424                     "The first sentence of the \"Description\" subsection for "
425                         + "the module %s in the file \"%s\" should end with a period",
426                     moduleName, fileName)
427                     .that(matcher.find())
428                     .isTrue();
429 
430                 final String firstSentence = XmlUtil.sanitizeXml(matcher.group(1));
431 
432                 assertWithMessage(
433                     "The summary for module %s in the file \"%s\" "
434                         + "should match the first sentence of "
435                         + "the \"Description\" subsection for this module in the file \"%s\"",
436                     moduleName, availablePagePath, fileName)
437                     .that(summaries.get(moduleName))
438                     .isEqualTo(firstSentence);
439             }
440         }
441     }
442 
443     @Test
444     public void testCategoryIndexPageTableInSyncWithAllChecksPageTable() throws Exception {
445         final Map<String, String> summaries = readSummaries(AVAILABLE_CHECKS_PATH);
446         for (Path path : XdocUtil.getXdocsConfigFilePaths(XdocUtil.getXdocsFilePaths())) {
447             final String fileName = path.getFileName().toString();
448             if (!"index.xml".equals(fileName)
449                     // Filters are excluded because they are not included in the main checks.xml
450                     // file and have their own separate validation in
451                     // testAllFiltersIndexPageTable()
452                     || path.getParent().toString().contains("filters")) {
453                 continue;
454             }
455 
456             final NodeList sources = getTagSourcesNode(path, "tr");
457 
458             for (int position = 0; position < sources.getLength(); position++) {
459                 final Node tableRow = sources.item(position);
460                 final Iterator<Node> cells = XmlUtil
461                         .findChildElementsByTag(tableRow, "td").iterator();
462                 final String checkName = XmlUtil.sanitizeXml(cells.next().getTextContent());
463                 final String description = XmlUtil.sanitizeXml(cells.next().getTextContent());
464                 assertWithMessage(
465                     "The summary for check %s in the file \"%s\" "
466                         + "should match the summary for this check in the file \"%s\"",
467                     checkName, path, AVAILABLE_CHECKS_PATH)
468                     .that(description)
469                     .isEqualTo(summaries.get(checkName));
470             }
471         }
472     }
473 
474     @Test
475     public void testAllFiltersIndexPageTable() throws Exception {
476         validateFilterTypeIndexPage(AVAILABLE_FILTERS_PATH);
477         validateFilterTypeIndexPage(AVAILABLE_FILE_FILTERS_PATH);
478     }
479 
480     private static void validateFilterTypeIndexPage(Path availablePath)
481             throws Exception {
482         final NodeList tableRowSources = getTagSourcesNode(availablePath, "tr");
483 
484         for (int position = 0; position < tableRowSources.getLength(); position++) {
485             final Node tableRow = tableRowSources.item(position);
486             final Iterator<Node> tdCells = XmlUtil
487                 .findChildElementsByTag(tableRow, "td").iterator();
488 
489             assertWithMessage("Filter name cell at row %s in %s should exist", position + 1,
490                 availablePath)
491                 .that(tdCells.hasNext())
492                 .isTrue();
493             final Node nameCell = tdCells.next();
494             final String filterName = XmlUtil.sanitizeXml(nameCell.getTextContent().trim());
495 
496             assertWithMessage("Description cell for %s in index.xml should exist", filterName)
497                 .that(tdCells.hasNext())
498                 .isTrue();
499 
500             assertWithMessage("Filter name at row %s in %s should not be empty", position + 1,
501                 availablePath)
502                 .that(filterName)
503                 .isNotEmpty();
504 
505             final Node descriptionCell = tdCells.next();
506             final String description = XmlUtil.sanitizeXml(
507                 descriptionCell.getTextContent().trim());
508 
509             assertWithMessage("Filter description for %s in %s should not be empty", filterName,
510                 availablePath)
511                 .that(description)
512                 .isNotEmpty();
513 
514             assertWithMessage("Filter description for %s in %s should end with a period",
515                 filterName, availablePath)
516                 .that(description.charAt(description.length() - 1))
517                 .isEqualTo('.');
518         }
519     }
520 
521     private static NodeList getTagSourcesNode(Path availablePath, String tagName)
522             throws Exception {
523         final String input = Files.readString(availablePath);
524         final Document document = XmlUtil.getRawXml(
525             availablePath.toString(), input, input);
526 
527         return document.getElementsByTagName(tagName);
528     }
529 
530     @Test
531     public void testAlphabetOrderInNames() throws Exception {
532         final NodeList nodes = getTagSourcesNode(SITE_PATH, "item");
533 
534         for (int nodeIndex = 0; nodeIndex < nodes.getLength(); nodeIndex++) {
535             final Node current = nodes.item(nodeIndex);
536 
537             if ("Checks".equals(XmlUtil.getNameAttributeOfNode(current))) {
538                 final List<String> groupNames = getNames(current);
539                 final List<String> groupNamesSorted = groupNames.stream()
540                         .sorted()
541                         .toList();
542 
543                 assertWithMessage("Group%s", NAMES_MUST_BE_IN_ALPHABETICAL_ORDER_SITE_PATH)
544                         .that(groupNames)
545                         .containsExactlyElementsIn(groupNamesSorted)
546                         .inOrder();
547 
548                 Node groupNode = current.getFirstChild();
549                 int index = 0;
550                 final int totalGroups = XmlUtil.getChildrenElements(current).size();
551                 while (index < totalGroups) {
552                     if ("item".equals(groupNode.getNodeName())) {
553                         final List<String> checkNames = getNames(groupNode);
554                         final List<String> checkNamesSorted = checkNames.stream()
555                                 .sorted()
556                                 .toList();
557                         assertWithMessage("Check%s", NAMES_MUST_BE_IN_ALPHABETICAL_ORDER_SITE_PATH)
558                                 .that(checkNames)
559                                 .containsExactlyElementsIn(checkNamesSorted)
560                                 .inOrder();
561                         index++;
562                     }
563                     groupNode = groupNode.getNextSibling();
564                 }
565             }
566             if ("Filters".equals(XmlUtil.getNameAttributeOfNode(current))) {
567                 final List<String> filterNames = getNames(current);
568                 final List<String> filterNamesSorted = filterNames.stream()
569                         .sorted()
570                         .toList();
571                 assertWithMessage("Filter%s", NAMES_MUST_BE_IN_ALPHABETICAL_ORDER_SITE_PATH)
572                         .that(filterNames)
573                         .containsExactlyElementsIn(filterNamesSorted)
574                         .inOrder();
575             }
576             if ("File Filters".equals(XmlUtil.getNameAttributeOfNode(current))) {
577                 final List<String> fileFilterNames = getNames(current);
578                 final List<String> fileFilterNamesSorted = fileFilterNames.stream()
579                         .sorted()
580                         .toList();
581                 assertWithMessage("File Filter%s", NAMES_MUST_BE_IN_ALPHABETICAL_ORDER_SITE_PATH)
582                         .that(fileFilterNames)
583                         .containsExactlyElementsIn(fileFilterNamesSorted)
584                         .inOrder();
585             }
586         }
587     }
588 
589     @Test
590     public void testAlphabetOrderAtIndexPages() throws Exception {
591         final Path allChecks = Path.of("src/site/xdoc/checks.xml");
592         validateOrder(allChecks, "Check");
593 
594         final String[] groupNames = {"annotation", "blocks", "design",
595             "coding", "header", "imports", "javadoc", "metrics",
596             "misc", "modifier", "naming", "regexp", "sizes", "whitespace"};
597         for (String name : groupNames) {
598             final Path checks = Path.of("src/site/xdoc/checks/" + name + "/index.xml");
599             validateOrder(checks, "Check");
600         }
601         validateOrder(AVAILABLE_FILTERS_PATH, "Filter");
602 
603         final Path fileFilters = Path.of("src/site/xdoc/filefilters/index.xml");
604         validateOrder(fileFilters, "File Filter");
605     }
606 
607     public static void validateOrder(Path path, String name) throws Exception {
608         final NodeList nodes = getTagSourcesNode(path, "div");
609 
610         for (int nodeIndex = 0; nodeIndex < nodes.getLength(); nodeIndex++) {
611             final Node current = nodes.item(nodeIndex);
612             final List<String> names = getNamesFromIndexPage(current);
613             final List<String> namesSorted = names.stream()
614                     .sorted()
615                     .toList();
616 
617             assertWithMessage("%s%s%s", name, NAMES_MUST_BE_IN_ALPHABETICAL_ORDER_SITE_PATH, path)
618                     .that(names)
619                     .containsExactlyElementsIn(namesSorted)
620                     .inOrder();
621         }
622     }
623 
624     private static List<String> getNamesFromIndexPage(Node node) {
625         final List<String> result = new ArrayList<>();
626         final Set<Node> children = XmlUtil.findChildElementsByTag(node, "a");
627 
628         Node current = node.getFirstChild();
629         Node treeNode = current;
630         boolean getFirstChild = false;
631         int index = 0;
632         while (current != null && index < children.size()) {
633             if ("tr".equals(current.getNodeName())) {
634                 treeNode = current.getNextSibling();
635             }
636             if ("a".equals(current.getNodeName())) {
637                 final String name = current.getFirstChild().getTextContent()
638                     .replace(" ", "").replace("\n", "");
639                 result.add(name);
640                 current = treeNode;
641                 getFirstChild = false;
642                 index++;
643             }
644             else if (getFirstChild) {
645                 current = current.getFirstChild();
646                 getFirstChild = false;
647             }
648             else {
649                 current = current.getNextSibling();
650                 getFirstChild = true;
651             }
652         }
653         return result;
654     }
655 
656     private static List<String> getNames(Node node) {
657         final Set<Node> children = XmlUtil.getChildrenElements(node);
658         final List<String> result = new ArrayList<>();
659         Node current = node.getFirstChild();
660         int index = 0;
661         while (index < children.size()) {
662             if ("item".equals(current.getNodeName())) {
663                 final String name = XmlUtil.getNameAttributeOfNode(current);
664                 result.add(name);
665                 index++;
666             }
667             current = current.getNextSibling();
668         }
669         return result;
670     }
671 
672     private static Map<String, String> readSummaries(Path availablePath) throws Exception {
673         final NodeList rows = getTagSourcesNode(availablePath, "tr");
674         final Map<String, String> result = new HashMap<>();
675 
676         for (int position = 0; position < rows.getLength(); position++) {
677             final Node row = rows.item(position);
678             final Iterator<Node> cells = XmlUtil.findChildElementsByTag(row, "td").iterator();
679             final String name = XmlUtil.sanitizeXml(cells.next().getTextContent());
680             final String summary = XmlUtil.sanitizeXml(cells.next().getTextContent());
681 
682             result.put(name, summary);
683         }
684 
685         return result;
686     }
687 
688     @Test
689     public void testAllSubSections() throws Exception {
690         for (Path path : XdocUtil.getXdocsFilePaths()) {
691             final String fileName = path.getFileName().toString();
692             final NodeList subSections = getTagSourcesNode(path, "subsection");
693 
694             for (int position = 0; position < subSections.getLength(); position++) {
695                 final Node subSection = subSections.item(position);
696                 final Node name = subSection.getAttributes().getNamedItem("name");
697                 assertWithMessage("All sub-sections in '%s' must have a name", fileName)
698                     .that(name)
699                     .isNotNull();
700                 final Node id = subSection.getAttributes().getNamedItem("id");
701                 assertWithMessage("All sub-sections in '%s' must have an id", fileName)
702                     .that(id)
703                     .isNotNull();
704                 // Checks and filters have their own xdocs files, so the section name
705                 // is the same as the section id by default.
706                 String sectionName = XmlUtil.getNameAttributeOfNode(subSection.getParentNode());
707                 final String nameString = name.getNodeValue();
708                 final String subsectionId = id.getNodeValue();
709                 final String expectedId;
710                 if ("google-style.xml".equals(fileName)) {
711                     sectionName = "Google";
712                     expectedId = (sectionName + "_" + nameString).replace(' ', '_');
713                 }
714                 else if ("sun-style.xml".equals(fileName)) {
715                     sectionName = "Sun";
716                     expectedId = (sectionName + "_" + nameString).replace(' ', '_');
717                 }
718                 else if ("openjdk-style.xml".equals(fileName)) {
719                     sectionName = "OpenJDK";
720                     expectedId = (sectionName + "_" + nameString).replace(' ', '_');
721                 }
722                 else if ("doc-comments-style.xml".equals(fileName)) {
723                     sectionName = "Documentation Comments";
724                     expectedId = (sectionName + "_" + nameString).replace(' ', '_');
725                 }
726                 else if (sectionName.isEmpty()) {
727                     expectedId = nameString.replace(' ', '_');
728                 }
729                 else {
730                     expectedId = (sectionName + "_" + nameString).replace(' ', '_');
731                 }
732                 assertWithMessage("%s sub-section %s for section %s must match", fileName,
733                     nameString, sectionName)
734                     .that(subsectionId)
735                     .isEqualTo(expectedId);
736             }
737         }
738     }
739 
740     @Test
741     public void testAllXmlExamples() throws Exception {
742         for (Path path : XdocUtil.getXdocsFilePaths()) {
743             final String fileName = path.getFileName().toString();
744             final NodeList sources = getTagSourcesNode(path, "source");
745 
746             for (int position = 0; position < sources.getLength(); position++) {
747                 final String unserializedSource = sources.item(position).getTextContent()
748                         .replace("...", "").trim();
749 
750                 if (unserializedSource.length() > 1 && (unserializedSource.charAt(0) != '<'
751                         || unserializedSource.charAt(unserializedSource.length() - 1) != '>'
752                         // no dtd testing yet
753                         || unserializedSource.contains("<!"))) {
754                     continue;
755                 }
756 
757                 final String code = buildXml(unserializedSource);
758                 // validate only
759                 XmlUtil.getRawXml(fileName, code, unserializedSource);
760 
761                 // can't test ant structure, or old and outdated checks
762                 assertWithMessage("Xml is invalid, old or has outdated structure")
763                         .that(fileName.startsWith("ant-task")
764                                 || fileName.startsWith("release-notes")
765                                 || fileName.startsWith("writing-javadoc-checks")
766                                 || isValidCheckstyleXml(fileName, code, unserializedSource))
767                         .isTrue();
768             }
769         }
770     }
771 
772     private static String buildXml(String unserializedSource) throws IOException {
773         // not all examples come with the full xml structure
774         String code = unserializedSource
775             // don't corrupt our own cachefile
776             .replace("target/cachefile", "target/cachefile-test");
777 
778         if (!hasFileSetClass(code)) {
779             code = "<module name=\"TreeWalker\">\n" + code + "\n</module>";
780         }
781         if (!code.contains("name=\"Checker\"")) {
782             code = "<module name=\"Checker\">\n" + code + "\n</module>";
783         }
784         if (!code.startsWith("<?xml")) {
785             final String dtdPath = new File(
786                     "src/main/resources/com/puppycrawl/tools/checkstyle/configuration_1_3.dtd")
787                     .getCanonicalPath();
788 
789             code = "<?xml version=\"1.0\"?>\n<!DOCTYPE module PUBLIC "
790                     + "\"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN\" \"" + dtdPath
791                     + "\">\n" + code;
792         }
793         return code;
794     }
795 
796     private static boolean hasFileSetClass(String xml) {
797         boolean found = false;
798 
799         for (String find : XML_FILESET_LIST) {
800             if (xml.contains(find)) {
801                 found = true;
802                 break;
803             }
804         }
805 
806         return found;
807     }
808 
809     private static boolean isValidCheckstyleXml(String fileName, String code,
810                                                 String unserializedSource)
811             throws IOException, CheckstyleException {
812         // can't process non-existent examples, or out of context snippets
813         if (!code.contains("com.mycompany") && !code.contains("checkstyle-packages")
814                 && !code.contains("MethodLimit") && !code.contains("<suppress ")
815                 && !code.contains("<suppress-xpath ")
816                 && !code.contains("<import-control ")
817                 && !unserializedSource.startsWith("<property ")
818                 && !unserializedSource.startsWith("<taskdef ")) {
819             // validate checkstyle structure and contents
820             try {
821                 final Properties properties = new Properties();
822 
823                 properties.setProperty("checkstyle.header.file",
824                         new File("config/java.header").getCanonicalPath());
825                 properties.setProperty("config.folder",
826                         new File("config").getCanonicalPath());
827 
828                 final PropertiesExpander expander = new PropertiesExpander(properties);
829                 final Configuration config = ConfigurationLoader.loadConfiguration(new InputSource(
830                         new StringReader(code)), expander, IgnoredModulesOptions.EXECUTE);
831                 final Checker checker = new Checker();
832 
833                 try {
834                     final ClassLoader moduleClassLoader = Checker.class.getClassLoader();
835                     checker.setModuleClassLoader(moduleClassLoader);
836                     checker.configure(config);
837                 }
838                 finally {
839                     checker.destroy();
840                 }
841             }
842             catch (CheckstyleException exc) {
843                 throw new CheckstyleException(fileName + " has invalid Checkstyle xml: "
844                         + unserializedSource, exc);
845             }
846         }
847         return true;
848     }
849 
850     @Test
851     public void testAllCheckSections() throws Exception {
852         final ModuleFactory moduleFactory = TestUtil.getPackageObjectFactory();
853 
854         for (Path path : XdocUtil.getXdocsConfigFilePaths(XdocUtil.getXdocsFilePaths())) {
855             final String fileName = path.getFileName().toString();
856 
857             if (isNonModulePage(fileName)) {
858                 continue;
859             }
860 
861             final NodeList sources = getTagSourcesNode(path, "section");
862             String lastSectionName = null;
863 
864             for (int position = 0; position < sources.getLength(); position++) {
865                 final Node section = sources.item(position);
866                 final String sectionName = XmlUtil.getNameAttributeOfNode(section);
867 
868                 if ("Content".equals(sectionName) || "Overview".equals(sectionName)) {
869                     assertWithMessage("%s section '%s' should be first", fileName, sectionName)
870                         .that(lastSectionName)
871                         .isNull();
872                     continue;
873                 }
874 
875                 assertWithMessage(
876                         "%s section '%s' shouldn't end with 'Check'", fileName, sectionName)
877                                 .that(sectionName.endsWith("Check"))
878                                 .isFalse();
879                 if (lastSectionName != null) {
880                     assertWithMessage("%s section '%s' is out of order compared to '%s'", fileName,
881                         sectionName, lastSectionName)
882                                     .that(sectionName.toLowerCase(Locale.ENGLISH).compareTo(
883                                             lastSectionName.toLowerCase(Locale.ENGLISH)) >= 0)
884                                     .isTrue();
885                 }
886 
887                 validateCheckSection(moduleFactory, fileName, sectionName, section);
888 
889                 lastSectionName = sectionName;
890             }
891         }
892     }
893 
894     public static boolean isNonModulePage(String fileName) {
895         return NON_MODULE_XDOC.contains(fileName)
896             || fileName.startsWith("release-notes")
897             || Pattern.matches("config_[a-z]+.xml", fileName);
898     }
899 
900     @Test
901     public void testAllCheckSectionsEx() throws Exception {
902         final ModuleFactory moduleFactory = TestUtil.getPackageObjectFactory();
903 
904         final Path path = Path.of(XdocUtil.DIRECTORY_PATH + "/config.xml");
905         final String fileName = path.getFileName().toString();
906 
907         final NodeList sources = getTagSourcesNode(path, "section");
908 
909         for (int position = 0; position < sources.getLength(); position++) {
910             final Node section = sources.item(position);
911             final String sectionName = XmlUtil.getNameAttributeOfNode(section);
912 
913             if (!"Checker".equals(sectionName) && !"TreeWalker".equals(sectionName)) {
914                 continue;
915             }
916 
917             validateCheckSection(moduleFactory, fileName, sectionName, section);
918         }
919     }
920 
921     private static void validateCheckSection(ModuleFactory moduleFactory, String fileName,
922             String sectionName, Node section) throws Exception {
923         final Object instance;
924 
925         try {
926             instance = moduleFactory.createModule(sectionName);
927         }
928         catch (CheckstyleException exc) {
929             throw new CheckstyleException(fileName + " couldn't find class: " + sectionName, exc);
930         }
931 
932         int subSectionPos = 0;
933         for (Node subSection : XmlUtil.getChildrenElements(section)) {
934             if (subSectionPos == 0 && "p".equals(subSection.getNodeName())) {
935                 validateSinceDescriptionSection(fileName, sectionName, subSection);
936                 continue;
937             }
938             if ("div".equals(subSection.getNodeName())) {
939                 continue;
940             }
941 
942             final String subSectionName = XmlUtil.getNameAttributeOfNode(subSection);
943 
944             // can be in different orders, and completely optional
945             if ("Notes".equals(subSectionName)
946                     || "Rule Description".equals(subSectionName)
947                     || "Metadata".equals(subSectionName)) {
948                 continue;
949             }
950 
951             // optional sections that can be skipped if they have nothing to report
952             if (subSectionPos == 1 && !"Properties".equals(subSectionName)) {
953                 validatePropertySection(fileName, sectionName, null, instance);
954                 subSectionPos++;
955             }
956             if (subSectionPos == 3 && !"Use Cases".equals(subSectionName)) {
957                 subSectionPos++;
958             }
959             if (subSectionPos == 5 && !"Violation Messages".equals(subSectionName)) {
960                 validateViolationSection(fileName, sectionName, null, instance);
961                 subSectionPos++;
962             }
963 
964             assertWithMessage("%s section '%s' should be in order", fileName, sectionName)
965                 .that(subSectionName)
966                 .isEqualTo(getSubSectionName(subSectionPos));
967 
968             switch (subSectionPos) {
969                 case 0 -> validateDescriptionSection(fileName, sectionName, subSection);
970                 case 1 -> validatePropertySection(fileName, sectionName, subSection, instance);
971                 case 4 -> validateUsageExample(fileName, sectionName, subSection);
972                 case 5 -> validateViolationSection(fileName, sectionName, subSection, instance);
973                 case 6 -> validateFullyQualifiedNameSection(
974                         fileName, sectionName, subSection, instance);
975                 case 7 -> validateParentSection(fileName, sectionName, subSection);
976                 default -> {
977                     // no code by design
978                 }
979             }
980 
981             subSectionPos++;
982         }
983 
984         if ("Checker".equals(sectionName)) {
985             assertWithMessage("%s section '%s' should contain up to 'Package' sub-section",
986                 fileName, sectionName)
987                     .that(subSectionPos)
988                     .isGreaterThan(6);
989         }
990         else {
991             assertWithMessage("%s section '%s' should contain up to 'Parent' sub-section", fileName,
992                 sectionName)
993                     .that(subSectionPos)
994                     .isGreaterThan(7);
995         }
996     }
997 
998     private static void validateSinceDescriptionSection(String fileName, String sectionName,
999             Node subSection) {
1000         assertWithMessage(
1001             "%s section '%s' should have a valid version at the start of the description like:\n%s",
1002                     fileName, sectionName, DESCRIPTION_VERSION.pattern())
1003                 .that(DESCRIPTION_VERSION.matcher(subSection.getTextContent().trim()).find())
1004                 .isTrue();
1005     }
1006 
1007     private static Object getSubSectionName(int subSectionPos) {
1008         return switch (subSectionPos) {
1009             case 0 -> "Description";
1010             case 1 -> "Properties";
1011             case 2 -> "Examples";
1012             case 3 -> "Use Cases";
1013             case 4 -> "Example of Usage";
1014             case 5 -> "Violation Messages";
1015             case 6 -> "Fully Qualified Name";
1016             case 7 -> "Parent Module";
1017             default -> null;
1018         };
1019     }
1020 
1021     private static void validateDescriptionSection(String fileName, String sectionName,
1022             Node subSection) {
1023         if ("config-filters.xml".equals(fileName) && "SuppressionXpathFilter".equals(sectionName)) {
1024             validateListOfSuppressionXpathFilterIncompatibleChecks(subSection);
1025         }
1026     }
1027 
1028     private static void validateListOfSuppressionXpathFilterIncompatibleChecks(Node subSection) {
1029         assertWithMessage(
1030             "Incompatible check list should match XpathRegressionTest.INCOMPATIBLE_CHECK_NAMES")
1031             .that(getListById(subSection, "SuppressionXpathFilter_IncompatibleChecks"))
1032             .isEqualTo(XpathRegressionTest.INCOMPATIBLE_CHECK_NAMES);
1033         final Set<String> suppressionXpathFilterJavadocChecks = getListById(subSection,
1034                 "SuppressionXpathFilter_JavadocChecks");
1035         assertWithMessage(
1036             "Javadoc check list should match XpathRegressionTest.INCOMPATIBLE_JAVADOC_CHECK_NAMES")
1037             .that(suppressionXpathFilterJavadocChecks)
1038             .isEqualTo(XpathRegressionTest.INCOMPATIBLE_JAVADOC_CHECK_NAMES);
1039     }
1040 
1041     private static void validatePropertySection(String fileName, String sectionName,
1042             Node subSection, Object instance) throws Exception {
1043         final Set<String> properties = getProperties(instance.getClass());
1044         final Class<?> clss = instance.getClass();
1045 
1046         fixCapturedProperties(sectionName, instance, clss, properties);
1047 
1048         if (subSection != null) {
1049             assertWithMessage("%s section '%s' should have no properties to show", fileName,
1050                 sectionName)
1051                 .that(properties)
1052                 .isNotEmpty();
1053 
1054             final Set<Node> nodes = XmlUtil.getChildrenElements(subSection);
1055             assertWithMessage("%s section '%s' subsection 'Properties' should have one child node",
1056                 fileName, sectionName)
1057                 .that(nodes)
1058                 .hasSize(1);
1059 
1060             final Node div = nodes.iterator().next();
1061             assertWithMessage("%s section '%s' subsection 'Properties' has unexpected child node",
1062                 fileName, sectionName)
1063                 .that(div.getNodeName())
1064                 .isEqualTo("div");
1065             final String wrapperMessage = String.format(Locale.ROOT,
1066                 "%s section '%s' subsection 'Properties'"
1067                     + " wrapping div for table needs the class 'wrapper'",
1068                 fileName, sectionName);
1069             assertWithMessage(wrapperMessage)
1070                     .that(div.hasAttributes())
1071                     .isTrue();
1072             assertWithMessage(wrapperMessage)
1073                 .that(div.getAttributes().getNamedItem("class").getNodeValue())
1074                 .isNotNull();
1075             assertWithMessage(wrapperMessage)
1076                     .that(div.getAttributes().getNamedItem("class").getNodeValue())
1077                     .contains("wrapper");
1078 
1079             final Node table = XmlUtil.getFirstChildElement(div);
1080             assertWithMessage("%s section '%s' subsection 'Properties' has unexpected child node",
1081                 fileName, sectionName)
1082                 .that(table.getNodeName())
1083                 .isEqualTo("table");
1084 
1085             validatePropertySectionPropertiesOrder(fileName, sectionName, table, properties);
1086 
1087             validatePropertySectionProperties(fileName, sectionName, table, instance,
1088                     properties);
1089         }
1090 
1091         assertWithMessage(
1092                 "%s section '%s' should show properties: %s", fileName, sectionName, properties)
1093             .that(properties)
1094             .isEmpty();
1095     }
1096 
1097     private static void validatePropertySectionPropertiesOrder(String fileName, String sectionName,
1098                                                                Node table, Set<String> properties) {
1099         final Set<Node> rows = XmlUtil.getChildrenElements(table);
1100         final List<String> orderedPropertyNames = new ArrayList<>(properties);
1101         final List<String> tablePropertyNames = new ArrayList<>();
1102 
1103         // javadocTokens and tokens should be last
1104         if (orderedPropertyNames.contains("javadocTokens")) {
1105             orderedPropertyNames.remove("javadocTokens");
1106             orderedPropertyNames.add("javadocTokens");
1107         }
1108         if (orderedPropertyNames.contains("tokens")) {
1109             orderedPropertyNames.remove("tokens");
1110             orderedPropertyNames.add("tokens");
1111         }
1112 
1113         rows
1114             .stream()
1115             // First row is header row
1116             .skip(1)
1117             .forEach(row -> {
1118                 final List<Node> columns = new ArrayList<>(XmlUtil.getChildrenElements(row));
1119                 assertWithMessage("%s section '%s' should have the requested columns", fileName,
1120                     sectionName)
1121                     .that(columns)
1122                     .hasSize(5);
1123 
1124                 final String propertyName = columns.getFirst().getTextContent();
1125                 tablePropertyNames.add(propertyName);
1126             });
1127 
1128         assertWithMessage("%s section '%s' should have properties in the requested order", fileName,
1129             sectionName)
1130             .that(tablePropertyNames)
1131             .isEqualTo(orderedPropertyNames);
1132     }
1133 
1134     private static void fixCapturedProperties(String sectionName, Object instance, Class<?> clss,
1135             Set<String> properties) {
1136         // remove global properties that don't need documentation
1137         if (hasParentModule(sectionName)) {
1138             if (AbstractJavadocCheck.class.isAssignableFrom(clss)) {
1139                 properties.removeAll(JAVADOC_CHECK_PROPERTIES);
1140 
1141                 // override
1142                 properties.add("violateExecutionOnNonTightHtml");
1143             }
1144             else if (AbstractCheck.class.isAssignableFrom(clss)) {
1145                 properties.removeAll(CHECK_PROPERTIES);
1146             }
1147         }
1148         if (AbstractFileSetCheck.class.isAssignableFrom(clss)) {
1149             properties.removeAll(FILESET_PROPERTIES);
1150 
1151             // override
1152             properties.add("fileExtensions");
1153         }
1154 
1155         // remove undocumented properties
1156         new HashSet<>(properties).stream()
1157             .filter(prop -> UNDOCUMENTED_PROPERTIES.contains(clss.getSimpleName() + "." + prop))
1158             .forEach(properties::remove);
1159 
1160         if (AbstractCheck.class.isAssignableFrom(clss)) {
1161             final AbstractCheck check = (AbstractCheck) instance;
1162 
1163             final int[] acceptableTokens = check.getAcceptableTokens();
1164             Arrays.sort(acceptableTokens);
1165             final int[] defaultTokens = check.getDefaultTokens();
1166             Arrays.sort(defaultTokens);
1167             final int[] requiredTokens = check.getRequiredTokens();
1168             Arrays.sort(requiredTokens);
1169 
1170             if (!Arrays.equals(acceptableTokens, defaultTokens)
1171                     || !Arrays.equals(acceptableTokens, requiredTokens)) {
1172                 properties.add("tokens");
1173             }
1174         }
1175 
1176         if (AbstractJavadocCheck.class.isAssignableFrom(clss)) {
1177             final AbstractJavadocCheck check = (AbstractJavadocCheck) instance;
1178 
1179             final int[] acceptableJavadocTokens = check.getAcceptableJavadocTokens();
1180             Arrays.sort(acceptableJavadocTokens);
1181             final int[] defaultJavadocTokens = check.getDefaultJavadocTokens();
1182             Arrays.sort(defaultJavadocTokens);
1183             final int[] requiredJavadocTokens = check.getRequiredJavadocTokens();
1184             Arrays.sort(requiredJavadocTokens);
1185 
1186             if (!Arrays.equals(acceptableJavadocTokens, defaultJavadocTokens)
1187                     || !Arrays.equals(acceptableJavadocTokens, requiredJavadocTokens)) {
1188                 properties.add("javadocTokens");
1189             }
1190         }
1191     }
1192 
1193     private static void validatePropertySectionProperties(String fileName, String sectionName,
1194             Node table, Object instance, Set<String> properties) throws Exception {
1195         boolean skip = true;
1196         boolean didJavadocTokens = false;
1197         boolean didTokens = false;
1198 
1199         for (Node row : XmlUtil.getChildrenElements(table)) {
1200             final List<Node> columns = new ArrayList<>(XmlUtil.getChildrenElements(row));
1201 
1202             assertWithMessage("%s section '%s' should have the requested columns", fileName,
1203                 sectionName)
1204                 .that(columns)
1205                 .hasSize(5);
1206 
1207             if (skip) {
1208                 assertWithMessage("%s section '%s' should have the specific title", fileName,
1209                     sectionName)
1210                     .that(columns.getFirst().getTextContent())
1211                     .isEqualTo("name");
1212                 assertWithMessage("%s section '%s' should have the specific title", fileName,
1213                     sectionName)
1214                     .that(columns.get(1).getTextContent())
1215                     .isEqualTo("description");
1216                 assertWithMessage("%s section '%s' should have the specific title", fileName,
1217                     sectionName)
1218                     .that(columns.get(2).getTextContent())
1219                     .isEqualTo("type");
1220                 assertWithMessage("%s section '%s' should have the specific title", fileName,
1221                     sectionName)
1222                     .that(columns.get(3).getTextContent())
1223                     .isEqualTo("default value");
1224                 assertWithMessage("%s section '%s' should have the specific title", fileName,
1225                     sectionName)
1226                     .that(columns.get(4).getTextContent())
1227                     .isEqualTo("since");
1228 
1229                 skip = false;
1230                 continue;
1231             }
1232 
1233             assertWithMessage("%s section '%s' should have token properties last", fileName,
1234                 sectionName)
1235                     .that(didTokens)
1236                     .isFalse();
1237 
1238             final String propertyName = columns.getFirst().getTextContent();
1239             assertWithMessage("%s section '%s' should not contain the property: %s", fileName,
1240                 sectionName, propertyName)
1241                     .that(properties.remove(propertyName))
1242                     .isTrue();
1243 
1244             if ("tokens".equals(propertyName)) {
1245                 final AbstractCheck check = (AbstractCheck) instance;
1246                 validatePropertySectionPropertyTokens(fileName, sectionName, check, columns);
1247                 didTokens = true;
1248             }
1249             else if ("javadocTokens".equals(propertyName)) {
1250                 final AbstractJavadocCheck check = (AbstractJavadocCheck) instance;
1251                 validatePropertySectionPropertyJavadocTokens(fileName, sectionName, check, columns);
1252                 didJavadocTokens = true;
1253             }
1254             else {
1255                 assertWithMessage(
1256                     "%s section '%s' should have javadoc token properties"
1257                         + " next to last, before tokens",
1258                     fileName, sectionName)
1259                     .that(didJavadocTokens)
1260                     .isFalse();
1261 
1262                 validatePropertySectionPropertyEx(fileName, sectionName, instance, columns,
1263                         propertyName);
1264             }
1265 
1266             assertWithMessage("%s section '%s' should have a version for %s",
1267                             fileName, sectionName, propertyName)
1268                     .that(columns.get(4).getTextContent().trim())
1269                     .isNotEmpty();
1270             assertWithMessage("%s section '%s' should have a valid version for %s",
1271                             fileName, sectionName, propertyName)
1272                     .that(columns.get(4).getTextContent().trim())
1273                     .matches(VERSION);
1274         }
1275     }
1276 
1277     private static void validatePropertySectionPropertyEx(String fileName, String sectionName,
1278             Object instance, List<Node> columns, String propertyName) throws Exception {
1279         assertWithMessage("%s section '%s' should have a description for %s",
1280                         fileName, sectionName, propertyName)
1281                 .that(columns.get(1).getTextContent().trim())
1282                 .isNotEmpty();
1283         assertWithMessage("%s section '%s' should have a description for %s"
1284                         + " that starts with uppercase character",
1285                         fileName, sectionName, propertyName)
1286                 .that(Character.isUpperCase(columns.get(1).getTextContent().trim().charAt(0)))
1287                 .isTrue();
1288 
1289         final String actualTypeName = columns.get(2).getTextContent().replace("\n", "")
1290                 .replace("\r", "").replaceAll(" +", " ").trim();
1291 
1292         assertWithMessage(
1293                 "%s section '%s' should have a type for %s", fileName, sectionName, propertyName)
1294                         .that(actualTypeName)
1295                         .isNotEmpty();
1296 
1297         final Field field = getField(instance.getClass(), propertyName);
1298         final Class<?> fieldClass = getFieldClass(fileName, sectionName, instance, field,
1299                 propertyName);
1300 
1301         final String expectedTypeName = Optional.ofNullable(field)
1302                 .map(nonNullField -> nonNullField.getAnnotation(XdocsPropertyType.class))
1303                 .map(propertyType -> propertyType.value().getDescription())
1304                 .map(XdocsPagesTest::simplifyTypeName)
1305                 .orElseGet(fieldClass::getSimpleName);
1306         final String expectedValue = getModulePropertyExpectedValue(sectionName, propertyName,
1307                 field, fieldClass, instance);
1308 
1309         assertWithMessage("%s section '%s' should have the type for %s", fileName, sectionName,
1310             propertyName)
1311             .that(actualTypeName)
1312             .isEqualTo(expectedTypeName);
1313 
1314         if (expectedValue != null) {
1315             final String actualValue = columns.get(3).getTextContent().trim()
1316                     .replaceAll("\\s+", " ")
1317                     .replaceAll("\\s,", ",");
1318 
1319             assertWithMessage("%s section '%s' should have the value for %s", fileName, sectionName,
1320                 propertyName)
1321                 .that(actualValue)
1322                 .isEqualTo(expectedValue);
1323         }
1324     }
1325 
1326     private static String simplifyTypeName(String fullTypeName) {
1327         final int separatorIndex = Math.max(fullTypeName.lastIndexOf('$'),
1328                 fullTypeName.lastIndexOf('.'));
1329         return fullTypeName.substring(separatorIndex + 1);
1330     }
1331 
1332     private static void validatePropertySectionPropertyTokens(String fileName, String sectionName,
1333             AbstractCheck check, List<Node> columns) {
1334         assertWithMessage("%s section '%s' should have the basic token description", fileName,
1335             sectionName)
1336             .that(columns.get(1).getTextContent())
1337             .isEqualTo("tokens to check");
1338 
1339         final String acceptableTokenText = columns.get(2).getTextContent().trim();
1340         String expectedAcceptableTokenText = "subset of tokens "
1341                 + CheckUtil.getTokenText(check.getAcceptableTokens(),
1342                 check.getRequiredTokens());
1343         if (isAllTokensAcceptable(check)) {
1344             expectedAcceptableTokenText = "set of any supported tokens";
1345         }
1346         assertWithMessage("%s section '%s' should have all the acceptable tokens", fileName,
1347             sectionName)
1348             .that(acceptableTokenText
1349                         .replaceAll("\\s+", " ")
1350                         .replaceAll("\\s,", ",")
1351                         .replaceAll("\\s\\.", "."))
1352             .isEqualTo(expectedAcceptableTokenText);
1353         assertWithMessage(
1354             "%s's acceptable token section: %s should have ',' & '.' "
1355                 + "at beginning of the next corresponding lines.",
1356             fileName, sectionName)
1357                         .that(isInvalidTokenPunctuation(acceptableTokenText))
1358                         .isFalse();
1359 
1360         final String defaultTokenText = columns.get(3).getTextContent().trim();
1361         final String expectedDefaultTokenText = CheckUtil.getTokenText(check.getDefaultTokens(),
1362                 check.getRequiredTokens());
1363         if (expectedDefaultTokenText.isEmpty()) {
1364             assertWithMessage("Empty tokens should have 'empty' string in xdoc")
1365                 .that(defaultTokenText)
1366                 .isEqualTo("empty");
1367         }
1368         else {
1369             assertWithMessage("%s section '%s' should have all the default tokens", fileName,
1370                 sectionName)
1371                 .that(defaultTokenText
1372                             .replaceAll("\\s+", " ")
1373                             .replaceAll("\\s,", ",")
1374                             .replaceAll("\\s\\.", "."))
1375                 .isEqualTo(expectedDefaultTokenText);
1376             assertWithMessage(
1377                 "%s's default token section: %s should have ',' or '.' "
1378                     + "at beginning of the next corresponding lines.",
1379                 fileName, sectionName)
1380                             .that(isInvalidTokenPunctuation(defaultTokenText))
1381                             .isFalse();
1382         }
1383 
1384     }
1385 
1386     private static boolean isAllTokensAcceptable(AbstractCheck check) {
1387         return Arrays.equals(check.getAcceptableTokens(), TokenUtil.getAllTokenIds());
1388     }
1389 
1390     private static void validatePropertySectionPropertyJavadocTokens(String fileName,
1391             String sectionName, AbstractJavadocCheck check, List<Node> columns) {
1392         assertWithMessage("%s section '%s' should have the basic token javadoc description",
1393             fileName, sectionName)
1394             .that(columns.get(1).getTextContent())
1395             .isEqualTo("javadoc tokens to check");
1396 
1397         final String acceptableTokenText = columns.get(2).getTextContent().trim();
1398         assertWithMessage("%s section '%s' should have all the acceptable javadoc tokens", fileName,
1399             sectionName)
1400             .that(acceptableTokenText
1401                         .replaceAll("\\s+", " ")
1402                         .replaceAll("\\s,", ",")
1403                         .replaceAll("\\s\\.", "."))
1404             .isEqualTo("subset of javadoc tokens "
1405                         + CheckUtil.getJavadocTokenText(check.getAcceptableJavadocTokens(),
1406                 check.getRequiredJavadocTokens()));
1407         assertWithMessage(
1408             "%s's acceptable javadoc token section: %s should have ',' & '.' "
1409                 + "at beginning of the next corresponding lines.",
1410             fileName, sectionName)
1411                         .that(isInvalidTokenPunctuation(acceptableTokenText))
1412                         .isFalse();
1413 
1414         final String defaultTokenText = columns.get(3).getTextContent().trim();
1415         assertWithMessage("%s section '%s' should have all the default javadoc tokens", fileName,
1416             sectionName)
1417             .that(defaultTokenText
1418                         .replaceAll("\\s+", " ")
1419                         .replaceAll("\\s,", ",")
1420                         .replaceAll("\\s\\.", "."))
1421             .isEqualTo(CheckUtil.getJavadocTokenText(check.getDefaultJavadocTokens(),
1422                 check.getRequiredJavadocTokens()));
1423         assertWithMessage(
1424             "%s's default javadoc token section: %s should have ',' & '.' "
1425                 + "at beginning of the next corresponding lines.",
1426             fileName, sectionName)
1427                         .that(isInvalidTokenPunctuation(defaultTokenText))
1428                         .isFalse();
1429     }
1430 
1431     private static boolean isInvalidTokenPunctuation(String tokenText) {
1432         return Pattern.compile("\\w,").matcher(tokenText).find()
1433                 || Pattern.compile("\\w\\.").matcher(tokenText).find();
1434     }
1435 
1436     /**
1437      * Gets the name of the bean property's default value for the class.
1438      *
1439      * @param sectionName The name of the section/module being worked on
1440      * @param propertyName The property name to work with
1441      * @param field The bean property's field
1442      * @param fieldClass The bean property's type
1443      * @param instance The class instance to work with
1444      * @return String form of property's default value
1445      * @noinspection IfStatementWithTooManyBranches
1446      * @noinspectionreason IfStatementWithTooManyBranches - complex nature of getting properties
1447      *      from XML files requires giant if/else statement
1448      */
1449     private static String getModulePropertyExpectedValue(String sectionName, String propertyName,
1450             Field field, Class<?> fieldClass, Object instance) throws Exception {
1451         String result = null;
1452 
1453         if (field != null) {
1454             final Object value = field.get(instance);
1455 
1456             if ("Checker".equals(sectionName) && "localeCountry".equals(propertyName)) {
1457                 result = "default locale country for the Java Virtual Machine";
1458             }
1459             else if ("Checker".equals(sectionName) && "localeLanguage".equals(propertyName)) {
1460                 result = "default locale language for the Java Virtual Machine";
1461             }
1462             else if ("Checker".equals(sectionName) && "charset".equals(propertyName)) {
1463                 result = "UTF-8";
1464             }
1465             else if ("charset".equals(propertyName)) {
1466                 result = "the charset property of the parent"
1467                     + " <a href=\"https://checkstyle.org/config.html#Checker\">Checker</a> module";
1468             }
1469             else if ("PropertyCacheFile".equals(fieldClass.getSimpleName())) {
1470                 result = "null (no cache file)";
1471             }
1472             else if (fieldClass == boolean.class) {
1473                 result = value.toString();
1474             }
1475             else if (fieldClass == int.class) {
1476                 result = value.toString();
1477             }
1478             else if (fieldClass == int[].class) {
1479                 result = getIntArrayPropertyValue(value);
1480             }
1481             else if (fieldClass == double[].class) {
1482                 result = Arrays.toString((double[]) value).replace("[", "").replace("]", "")
1483                         .replace(".0", "");
1484                 if (result.isEmpty()) {
1485                     result = "{}";
1486                 }
1487             }
1488             else if (fieldClass == String[].class) {
1489                 final boolean preserveOrder = hasPreserveOrderAnnotation(field);
1490                 result = getStringArrayPropertyValue(propertyName, value, preserveOrder);
1491             }
1492             else if (fieldClass == URI.class || fieldClass == String.class) {
1493                 if (value != null) {
1494                     result = value.toString();
1495                 }
1496             }
1497             else if (fieldClass == Pattern.class) {
1498                 if (value != null) {
1499                     result = value.toString().replace("\n", "\\n").replace("\t", "\\t")
1500                             .replace("\r", "\\r").replace("\f", "\\f");
1501                 }
1502             }
1503             else if (fieldClass == Pattern[].class) {
1504                 result = getPatternArrayPropertyValue(value);
1505             }
1506             else if (fieldClass.isEnum()) {
1507                 if (value != null) {
1508                     result = value.toString().toLowerCase(Locale.ENGLISH);
1509                 }
1510             }
1511             else if (fieldClass == AccessModifierOption[].class) {
1512                 result = Arrays.toString((Object[]) value).replace("[", "").replace("]", "");
1513             }
1514             else {
1515                 assertWithMessage("Unknown property type: %s", fieldClass.getSimpleName()).fail();
1516             }
1517 
1518             if (result == null) {
1519                 result = "null";
1520             }
1521         }
1522 
1523         return result;
1524     }
1525 
1526     private static boolean hasPreserveOrderAnnotation(Field field) {
1527         return field != null && field.isAnnotationPresent(PreserveOrder.class);
1528     }
1529 
1530     /**
1531      * Gets the name of the bean property's default value for the Pattern array class.
1532      *
1533      * @param fieldValue The bean property's value
1534      * @return String form of property's default value
1535      */
1536     private static String getPatternArrayPropertyValue(Object fieldValue) {
1537         Object value = fieldValue;
1538         String result;
1539         if (value instanceof Collection<?> collection) {
1540             final Pattern[] newArray = new Pattern[collection.size()];
1541             final Iterator<?> iterator = collection.iterator();
1542             int index = 0;
1543 
1544             while (iterator.hasNext()) {
1545                 final Object next = iterator.next();
1546                 newArray[index] = (Pattern) next;
1547                 index++;
1548             }
1549 
1550             value = newArray;
1551         }
1552 
1553         if (value != null && Array.getLength(value) > 0) {
1554             final String[] newArray = new String[Array.getLength(value)];
1555 
1556             for (int i = 0; i < newArray.length; i++) {
1557                 newArray[i] = ((Pattern) Array.get(value, i)).pattern();
1558             }
1559 
1560             result = Arrays.toString(newArray).replace("[", "").replace("]", "");
1561         }
1562         else {
1563             result = "";
1564         }
1565 
1566         if (result.isEmpty()) {
1567             result = "{}";
1568         }
1569         return result;
1570     }
1571 
1572     /**
1573      * Gets the name of the bean property's default value for the string array class.
1574      *
1575      * @param propertyName The bean property's name
1576      * @param value The bean property's value
1577      * @param preserveOrder whether to preserve the original order
1578      * @return String form of property's default value
1579      */
1580     private static String getStringArrayPropertyValue(String propertyName, Object value,
1581             boolean preserveOrder) {
1582         String result;
1583         if (value == null) {
1584             result = "";
1585         }
1586         else {
1587             final Stream<?> valuesStream;
1588             if (value instanceof Collection<?> collection) {
1589                 valuesStream = collection.stream();
1590             }
1591             else {
1592                 final Object[] array = (Object[]) value;
1593                 valuesStream = Arrays.stream(array);
1594             }
1595 
1596             Stream<String> stringStream = valuesStream.map(String.class::cast);
1597 
1598             if (!preserveOrder) {
1599                 stringStream = stringStream.sorted();
1600             }
1601 
1602             result = stringStream.collect(Collectors.joining(", "));
1603 
1604         }
1605 
1606         if (result.isEmpty()) {
1607             if ("fileExtensions".equals(propertyName)) {
1608                 result = "all files";
1609             }
1610             else {
1611                 result = "{}";
1612             }
1613         }
1614         return result;
1615     }
1616 
1617     /**
1618      * Returns the name of the bean property's default value for the int array class.
1619      *
1620      * @param value The bean property's value.
1621      * @return String form of property's default value.
1622      */
1623     private static String getIntArrayPropertyValue(Object value) {
1624         final IntStream stream = switch (value) {
1625             case null -> throw new IllegalArgumentException("value is null");
1626             case Collection<?> collection -> collection.stream()
1627                     .mapToInt(number -> (int) number);
1628             case BitSet set -> set.stream();
1629             default -> Arrays.stream((int[]) value);
1630         };
1631         String result = stream
1632                 .mapToObj(TokenUtil::getTokenName)
1633                 .sorted()
1634                 .collect(Collectors.joining(", "));
1635         if (result.isEmpty()) {
1636             result = "{}";
1637         }
1638         return result;
1639     }
1640 
1641     /**
1642      * Returns the bean property's field.
1643      *
1644      * @param fieldClass The bean property's type
1645      * @param propertyName The bean property's name
1646      * @return the bean property's field
1647      */
1648     private static Field getField(Class<?> fieldClass, String propertyName) {
1649         Field result = null;
1650         Class<?> currentClass = fieldClass;
1651 
1652         while (!Object.class.equals(currentClass)) {
1653             try {
1654                 result = currentClass.getDeclaredField(propertyName);
1655                 result.trySetAccessible();
1656                 break;
1657             }
1658             catch (NoSuchFieldException ignored) {
1659                 currentClass = currentClass.getSuperclass();
1660             }
1661         }
1662 
1663         return result;
1664     }
1665 
1666     private static Class<?> getFieldClass(String fileName, String sectionName, Object instance,
1667             Field field, String propertyName) throws Exception {
1668         Class<?> result = null;
1669 
1670         if (PROPERTIES_ALLOWED_GET_TYPES_FROM_METHOD.contains(sectionName + "." + propertyName)) {
1671             final PropertyDescriptor descriptor = PropertyUtils.getPropertyDescriptor(instance,
1672                     propertyName);
1673             result = descriptor.getPropertyType();
1674         }
1675         if (field != null && result == null) {
1676             result = field.getType();
1677         }
1678         if (result == null) {
1679             assertWithMessage(
1680                     "%s section '%s' could not find field %s", fileName, sectionName, propertyName)
1681                     .fail();
1682         }
1683         if (field != null && (result == List.class || result == Set.class)) {
1684             final ParameterizedType type = (ParameterizedType) field.getGenericType();
1685             final Class<?> parameterClass = (Class<?>) type.getActualTypeArguments()[0];
1686 
1687             if (parameterClass == Integer.class) {
1688                 result = int[].class;
1689             }
1690             else if (parameterClass == String.class) {
1691                 result = String[].class;
1692             }
1693             else if (parameterClass == Pattern.class) {
1694                 result = Pattern[].class;
1695             }
1696             else {
1697                 assertWithMessage("Unknown parameterized type: %s", parameterClass.getSimpleName())
1698                         .fail();
1699             }
1700         }
1701         else if (result == BitSet.class) {
1702             result = int[].class;
1703         }
1704 
1705         return result;
1706     }
1707 
1708     private static Set<String> getListById(Node subSection, String id) {
1709         Set<String> result = null;
1710         final Node node = XmlUtil.findChildElementById(subSection, id);
1711         if (node != null) {
1712             result = XmlUtil.getChildrenElements(node)
1713                     .stream()
1714                     .map(Node::getTextContent)
1715                     .collect(Collectors.toUnmodifiableSet());
1716         }
1717         return result;
1718     }
1719 
1720     private static void validateViolationSection(String fileName, String sectionName,
1721                                                  Node subSection,
1722                                                  Object instance) throws Exception {
1723         final Class<?> clss = instance.getClass();
1724         final Set<Field> fields = CheckUtil.getCheckMessagesWithDeepScan(clss);
1725         final Set<String> list = new TreeSet<>();
1726 
1727         for (Field field : fields) {
1728             // below is required for package/private classes
1729             field.trySetAccessible();
1730 
1731             list.add(field.get(null).toString());
1732         }
1733 
1734         final StringBuilder expectedText = new StringBuilder(120);
1735 
1736         for (String s : list) {
1737             expectedText.append(s)
1738                     .append('\n');
1739         }
1740 
1741         if (!expectedText.isEmpty()) {
1742             expectedText.append(
1743                 """
1744                 All messages can be customized if the default message doesn't suit you.
1745                 Please see the documentation to learn how to.
1746                 """);
1747         }
1748 
1749         if (subSection == null) {
1750             assertWithMessage("%s section '%s' should have the expected error keys", fileName,
1751                 sectionName)
1752                 .that(expectedText.toString())
1753                 .isEqualTo("");
1754         }
1755         else {
1756             final String subsectionTextContent = subSection.getTextContent()
1757                     .replaceAll("\n\\s+", "\n")
1758                     .replaceAll("\\s+", " ")
1759                     .trim();
1760             assertWithMessage("%s section '%s' should have the expected error keys", fileName,
1761                 sectionName)
1762                 .that(subsectionTextContent)
1763                 .isEqualTo(expectedText.toString().replace("\n", " ").trim());
1764 
1765             for (Node node : XmlUtil.findChildElementsByTag(subSection, "a")) {
1766                 final String url = node.getAttributes().getNamedItem("href").getTextContent();
1767                 final String linkText = node.getTextContent().trim();
1768                 final String expectedUrl;
1769 
1770                 if ("see the documentation".equals(linkText)) {
1771                     expectedUrl = "../../config.html#Custom_messages";
1772                 }
1773                 else {
1774                     final String query = "path:src/main/resources/"
1775                             + clss.getPackage().getName().replace('.', '/')
1776                             + " path:**/messages*.properties repo:checkstyle/checkstyle \""
1777                             + linkText + "\"";
1778                     expectedUrl = "https://github.com/search?q="
1779                             + URLEncoder.encode(query, StandardCharsets.UTF_8);
1780                 }
1781 
1782                 assertWithMessage("%s section '%s' should have matching url for '%s'", fileName,
1783                     sectionName, linkText)
1784                     .that(url)
1785                     .isEqualTo(expectedUrl);
1786             }
1787         }
1788     }
1789 
1790     private static void validateUsageExample(String fileName, String sectionName, Node subSection) {
1791         final String text = subSection.getTextContent()
1792             .replace("Checkstyle Style", "")
1793             .replace("Google Style", "")
1794             .replace("Sun Style", "")
1795             .replace("OpenJDK Style", "")
1796             .replace("Documentation Comments Style", "")
1797             .replace("Checkstyle's Import Control Config", "")
1798             .trim();
1799 
1800         assertWithMessage("%s section '%s' has unknown text in 'Example of Usage': %s", fileName,
1801             sectionName, text)
1802             .that(text)
1803             .isEmpty();
1804 
1805         boolean hasCheckstyle = false;
1806         boolean hasGoogle = false;
1807         boolean hasSun = false;
1808         boolean hasOpenjdk = false;
1809         boolean hasDocComments = false;
1810 
1811         for (Node node : XmlUtil.findChildElementsByTag(subSection, "a")) {
1812             final String url = node.getAttributes().getNamedItem("href").getTextContent();
1813             final String linkText = node.getTextContent().trim();
1814             String expectedUrl = null;
1815 
1816             if ("Checkstyle Style".equals(linkText)) {
1817                 hasCheckstyle = true;
1818                 expectedUrl = "https://github.com/search?q="
1819                         + "path%3Aconfig%20path%3A**%2Fcheckstyle-checks.xml+"
1820                         + "repo%3Acheckstyle%2Fcheckstyle+" + sectionName;
1821             }
1822             else if ("Google Style".equals(linkText)) {
1823                 hasGoogle = true;
1824                 expectedUrl = getExpectedStyleGuideUrl("google_checks.xml")
1825                         + sectionName;
1826 
1827                 assertWithMessage(
1828                     "%s section '%s' should be in google_checks.xml"
1829                         + " or not reference 'Google Style'",
1830                     fileName, sectionName)
1831                         .that(GOOGLE_MODULES)
1832                         .contains(sectionName);
1833             }
1834             else if ("Sun Style".equals(linkText)) {
1835                 hasSun = true;
1836                 expectedUrl = getExpectedStyleGuideUrl("sun_checks.xml")
1837                         + sectionName;
1838 
1839                 assertWithMessage(
1840                     "%s section '%s' should be in sun_checks.xml or not reference 'Sun Style'",
1841                     fileName, sectionName)
1842                     .that(SUN_MODULES)
1843                     .contains(sectionName);
1844             }
1845             else if ("OpenJDK Style".equals(linkText)) {
1846                 hasOpenjdk = true;
1847                 expectedUrl = getExpectedStyleGuideUrl("openjdk_checks.xml")
1848                         + sectionName;
1849                 assertWithMessage(
1850                     "%s section '%s' should be in openjdk_checks.xml "
1851                            + "or not reference 'OpenJDK Style'",
1852                     fileName, sectionName)
1853                     .that(OPENJDK_MODULES)
1854                     .contains(sectionName);
1855             }
1856             else if ("Documentation Comments Style".equals(linkText)) {
1857                 hasDocComments = true;
1858                 expectedUrl = getExpectedStyleGuideUrl("doc_comments_checks.xml")
1859                         + sectionName;
1860                 assertWithMessage(
1861                     "%s section '%s' should be in doc_comments_checks.xml "
1862                            + "or not reference 'Documentation Comments Style'",
1863                     fileName, sectionName)
1864                     .that(DOC_COMMENTS_MODULES)
1865                     .contains(sectionName);
1866             }
1867             else if ("Checkstyle's Import Control Config".equals(linkText)) {
1868                 expectedUrl = "https://github.com/checkstyle/checkstyle/blob/master/config/"
1869                     + "import-control.xml";
1870             }
1871 
1872             assertWithMessage("%s section '%s' should have matching url", fileName, sectionName)
1873                 .that(url)
1874                 .isEqualTo(expectedUrl);
1875         }
1876 
1877         assertWithMessage("%s section '%s' should have a checkstyle section", fileName, sectionName)
1878                 .that(hasCheckstyle)
1879                 .isTrue();
1880         assertWithMessage("%s section '%s' should have a google section since it is in it's config",
1881             fileName, sectionName)
1882                 .that(hasGoogle
1883                     || !GOOGLE_MODULES.contains(sectionName)
1884                     || IGNORED_GOOGLE_MODULES.contains(sectionName))
1885                 .isTrue();
1886         assertWithMessage("%s section '%s' should have a sun section since it is in it's config",
1887             fileName, sectionName)
1888                 .that(hasSun || !SUN_MODULES.contains(sectionName))
1889                 .isTrue();
1890         assertWithMessage("%s section '%s' should have an openjdk section since "
1891                         + "it is in its config",
1892             fileName, sectionName)
1893                 .that(hasOpenjdk || !OPENJDK_MODULES.contains(sectionName))
1894                 .isTrue();
1895         assertWithMessage("%s section '%s' should have a documentation comments section since "
1896                         + "it is in its config",
1897             fileName, sectionName)
1898                 .that(hasDocComments || !DOC_COMMENTS_MODULES.contains(sectionName))
1899                 .isTrue();
1900     }
1901 
1902     private static void validateFullyQualifiedNameSection(String fileName, String sectionName,
1903                                                           Node subSection, Object instance) {
1904         final String fullyQualifiedName = subSection.getTextContent()
1905                 .replaceAll("\\s+", "");
1906 
1907         assertWithMessage("%s section '%s' should have matching fully qualified name",
1908                 fileName, sectionName)
1909                 .that(fullyQualifiedName)
1910                 .contains(instance.getClass().getName());
1911     }
1912 
1913     private static void validateParentSection(String fileName, String sectionName,
1914             Node subSection) {
1915         final String expected;
1916 
1917         if (!"TreeWalker".equals(sectionName) && hasParentModule(sectionName)) {
1918             expected = "TreeWalker";
1919         }
1920         else {
1921             expected = "Checker";
1922         }
1923 
1924         assertWithMessage("%s section '%s' should have matching parent", fileName, sectionName)
1925             .that(subSection.getTextContent().trim())
1926             .isEqualTo(expected);
1927     }
1928 
1929     private static boolean hasParentModule(String sectionName) {
1930         final String search = "\"" + sectionName + "\"";
1931         boolean result = true;
1932 
1933         for (String find : XML_FILESET_LIST) {
1934             if (find.contains(search)) {
1935                 result = false;
1936                 break;
1937             }
1938         }
1939 
1940         return result;
1941     }
1942 
1943     private static Set<String> getProperties(Class<?> clss) {
1944         final Set<String> result = new TreeSet<>();
1945         final PropertyDescriptor[] map = PropertyUtils.getPropertyDescriptors(clss);
1946 
1947         for (PropertyDescriptor p : map) {
1948             if (p.getWriteMethod() != null) {
1949                 result.add(p.getName());
1950             }
1951         }
1952 
1953         return result;
1954     }
1955 
1956     private static boolean shouldSkipStyleFile(String fileName, String styleName) {
1957         return "doc_comments".equals(styleName) || "openjdk".equals(styleName)
1958                 || "google_style.xml".equals(fileName) || "openjdk_style.xml".equals(fileName)
1959                 || "sun_style.xml".equals(fileName) || "doc_comments_style.xml".equals(fileName);
1960     }
1961 
1962     @Test
1963     public void testAllStyleRules() throws Exception {
1964         for (Path path : XdocUtil.getXdocsStyleFilePaths(XdocUtil.getXdocsFilePaths())) {
1965             final String fileName = path.getFileName().toString();
1966             final String styleName = fileName.substring(0, fileName.lastIndexOf('_'));
1967             if (shouldSkipStyleFile(fileName, styleName)) {
1968                 continue;
1969             }
1970             final NodeList sources = getTagSourcesNode(path, "tr");
1971 
1972             final Set<String> styleChecks = switch (styleName) {
1973                 case "google" -> {
1974                     final Set<String> checks = new HashSet<>(GOOGLE_MODULES);
1975                     checks.removeAll(IGNORED_GOOGLE_MODULES);
1976                     yield checks;
1977                 }
1978                 case "sun" -> {
1979                     final Set<String> checks = new HashSet<>(SUN_MODULES);
1980                     checks.removeAll(IGNORED_SUN_MODULES);
1981                     yield checks;
1982                 }
1983                 case null -> {
1984                     assertWithMessage("Style name is unexpectedly null")
1985                             .fail();
1986                     yield null;
1987                 }
1988                 default -> {
1989                     assertWithMessage("Missing modules list for style file '%s'", fileName)
1990                             .fail();
1991                     yield null;
1992                 }
1993             };
1994 
1995             String lastRuleName = null;
1996             String[] lastRuleNumberParts = null;
1997 
1998             for (int position = 0; position < sources.getLength(); position++) {
1999                 final Node row = sources.item(position);
2000                 final List<Node> columns = new ArrayList<>(
2001                         XmlUtil.findChildElementsByTag(row, "td"));
2002 
2003                 if (columns.isEmpty()) {
2004                     continue;
2005                 }
2006 
2007                 final String ruleName = columns.get(1).getTextContent().trim();
2008                 lastRuleNumberParts = validateRuleNameOrder(
2009                         fileName, lastRuleName, lastRuleNumberParts, ruleName);
2010 
2011                 if (!"--".equals(ruleName)) {
2012                     validateStyleAnchors(XmlUtil.findChildElementsByTag(columns.getFirst(), "a"),
2013                             fileName, ruleName);
2014                 }
2015 
2016                 validateStyleModules(XmlUtil.findChildElementsByTag(columns.get(2), "a"),
2017                         XmlUtil.findChildElementsByTag(columns.get(3), "a"), styleChecks, styleName,
2018                         ruleName);
2019 
2020                 lastRuleName = ruleName;
2021             }
2022 
2023             removeCommonUndocumentedModules(styleChecks);
2024             assertWithMessage(
2025                     "%s requires the following check(s) to appear: %s", fileName, styleChecks)
2026                 .that(styleChecks)
2027                 .isEmpty();
2028         }
2029     }
2030 
2031     private static String[] validateRuleNameOrder(String fileName, String lastRuleName,
2032                                                   String[] lastRuleNumberParts, String ruleName) {
2033         final String[] ruleNumberParts = ruleName.split(" ", 2)[0].split("\\.");
2034 
2035         if (lastRuleName != null) {
2036             final int ruleNumberPartsAmount = ruleNumberParts.length;
2037             final int lastRuleNumberPartsAmount = lastRuleNumberParts.length;
2038             final String outOfOrderReason = fileName + " rule '" + ruleName
2039                     + "' is out of order compared to '" + lastRuleName + "'";
2040             boolean lastRuleNumberPartWasEqual = false;
2041             int partIndex;
2042             for (partIndex = 0; partIndex < ruleNumberPartsAmount; partIndex++) {
2043                 if (lastRuleNumberPartsAmount <= partIndex) {
2044                     // equal up to here and last rule has fewer parts,
2045                     // thus order is correct, stop comparing
2046                     break;
2047                 }
2048 
2049                 final String ruleNumberPart = ruleNumberParts[partIndex];
2050                 final String lastRuleNumberPart = lastRuleNumberParts[partIndex];
2051                 final boolean ruleNumberPartsAreNumeric = IntStream.concat(
2052                         ruleNumberPart.chars(),
2053                         lastRuleNumberPart.chars()
2054                 ).allMatch(Character::isDigit);
2055 
2056                 if (ruleNumberPartsAreNumeric) {
2057                     final int numericRuleNumberPart = parseInt(ruleNumberPart);
2058                     final int numericLastRuleNumberPart = parseInt(lastRuleNumberPart);
2059                     assertWithMessage(outOfOrderReason)
2060                         .that(numericRuleNumberPart)
2061                         .isAtLeast(numericLastRuleNumberPart);
2062                 }
2063                 else {
2064                     assertWithMessage(outOfOrderReason)
2065                         .that(ruleNumberPart.compareToIgnoreCase(lastRuleNumberPart))
2066                         .isAtLeast(0);
2067                 }
2068                 lastRuleNumberPartWasEqual = ruleNumberPart.equalsIgnoreCase(lastRuleNumberPart);
2069                 if (!lastRuleNumberPartWasEqual) {
2070                     // number part is not equal but properly ordered,
2071                     // thus order is correct, stop comparing
2072                     break;
2073                 }
2074             }
2075             if (ruleNumberPartsAmount == partIndex && lastRuleNumberPartWasEqual) {
2076                 if (lastRuleNumberPartsAmount == partIndex) {
2077                     assertWithMessage("%s rule '%s' and rule '%s' have the same rule number",
2078                         fileName, ruleName, lastRuleName).fail();
2079                 }
2080                 else {
2081                     assertWithMessage(outOfOrderReason).fail();
2082                 }
2083             }
2084         }
2085 
2086         return ruleNumberParts;
2087     }
2088 
2089     private static void validateStyleAnchors(Set<Node> anchors, String fileName, String ruleName) {
2090         assertWithMessage("%s rule '%s' must have two row anchors", fileName, ruleName)
2091             .that(anchors)
2092             .hasSize(2);
2093 
2094         final int space = ruleName.indexOf(' ');
2095         assertWithMessage(
2096             "%s rule '%s' must have have a space between the rule's number and the rule's name",
2097             fileName, ruleName)
2098             .that(space)
2099             .isNotEqualTo(-1);
2100 
2101         final String ruleNumber = ruleName.substring(0, space);
2102 
2103         int position = 1;
2104 
2105         for (Node anchor : anchors) {
2106             final String actualUrl;
2107             final String expectedUrl;
2108 
2109             if (position == 1) {
2110                 actualUrl = XmlUtil.getNameAttributeOfNode(anchor);
2111                 expectedUrl = "a" + ruleNumber;
2112             }
2113             else {
2114                 actualUrl = anchor.getAttributes().getNamedItem("href").getTextContent();
2115                 expectedUrl = "#" + ruleNumber;
2116             }
2117 
2118             assertWithMessage("%s rule '%s' anchor %s should have matching name/url", fileName,
2119                 ruleName, position)
2120                 .that(actualUrl)
2121                 .isEqualTo(expectedUrl);
2122 
2123             position++;
2124         }
2125     }
2126 
2127     private static void validateStyleModules(Set<Node> checks, Set<Node> configs,
2128             Set<String> styleChecks, String styleName, String ruleName) {
2129         final Iterator<Node> itrChecks = checks.iterator();
2130         final Iterator<Node> itrConfigs = configs.iterator();
2131         final boolean isGoogleDocumentation = "google".equals(styleName);
2132         final boolean isSunDocumentation = "sun".equals(styleName);
2133 
2134         if (isGoogleDocumentation || isSunDocumentation) {
2135             validateChapterWiseTesting(itrChecks, itrConfigs, styleChecks, styleName, ruleName);
2136         }
2137         else {
2138             validateModuleWiseTesting(itrChecks, itrConfigs, styleChecks, styleName, ruleName);
2139         }
2140 
2141         assertWithMessage("%s_style.xml rule '%s' has too many configs", styleName, ruleName)
2142                 .that(itrConfigs.hasNext())
2143                 .isFalse();
2144     }
2145 
2146     private static void validateModuleWiseTesting(Iterator<Node> itrChecks,
2147           Iterator<Node> itrConfigs, Set<String> styleChecks, String styleName, String ruleName) {
2148         while (itrChecks.hasNext()) {
2149             final Node module = itrChecks.next();
2150             final String moduleName = module.getTextContent().trim();
2151             final String href = module.getAttributes().getNamedItem("href").getTextContent();
2152             final boolean moduleIsCheck = href.startsWith("checks/");
2153 
2154             if (!moduleIsCheck) {
2155                 continue;
2156             }
2157 
2158             assertWithMessage("%s_style.xml rule '%s' module '%s' shouldn't end with 'Check'",
2159                 styleName, ruleName, moduleName)
2160                     .that(moduleName.endsWith("Check"))
2161                     .isFalse();
2162 
2163             styleChecks.remove(moduleName);
2164 
2165             for (String configName : new String[] {"config", "test"}) {
2166                 Node config = null;
2167 
2168                 try {
2169                     config = itrConfigs.next();
2170                 }
2171                 catch (NoSuchElementException ignore) {
2172                     assertWithMessage(
2173                         "%s_style.xml rule '%s' module '%s' is missing the config link: %s",
2174                         styleName, ruleName, moduleName, configName).fail();
2175                 }
2176 
2177                 assertWithMessage(
2178                     "%s_style.xml rule '%s' module '%s' has mismatched config/test links",
2179                     styleName, ruleName, moduleName)
2180                     .that(config.getTextContent().trim())
2181                     .isEqualTo(configName);
2182 
2183                 final String configUrl = config.getAttributes().getNamedItem("href")
2184                         .getTextContent();
2185 
2186                 if ("config".equals(configName)) {
2187                     final String expectedUrl = getExpectedStyleGuideUrl(styleName + "_checks.xml")
2188                             + moduleName;
2189 
2190                     assertWithMessage(
2191                         "%s_style.xml rule '%s' module '%s' should have matching %s url", styleName,
2192                         ruleName, moduleName, configName)
2193                         .that(configUrl)
2194                         .isEqualTo(expectedUrl);
2195                 }
2196                 else if ("test".equals(configName)) {
2197                     assertWithMessage(
2198                         "%s_style.xml rule '%s' module '%s' should have matching %s url", styleName,
2199                         ruleName, moduleName, configName)
2200                             .that(configUrl)
2201                             .startsWith("https://github.com/checkstyle/checkstyle/"
2202                                     + "blob/master/src/it/java/com/" + styleName
2203                                     + "/checkstyle/test/");
2204                     assertWithMessage(
2205                         "%s_style.xml rule '%s' module '%s' should have matching %s url", styleName,
2206                         ruleName, moduleName, configName)
2207                             .that(configUrl)
2208                             .endsWith("/" + moduleName + "Test.java");
2209 
2210                     assertWithMessage(
2211                         "%s_style.xml rule '%s' module '%s' should have a test that exists",
2212                         styleName, ruleName, moduleName)
2213                             .that(new File(configUrl.substring(53).replace('/',
2214                                             File.separatorChar)).exists())
2215                             .isTrue();
2216                 }
2217             }
2218         }
2219     }
2220 
2221     private static void validateChapterWiseTesting(Iterator<Node> itrChecks,
2222           Iterator<Node> itrSample, Set<String> styleChecks, String styleName, String ruleName) {
2223         boolean hasChecks = false;
2224         final Set<String> usedModules = new HashSet<>();
2225 
2226         while (itrChecks.hasNext()) {
2227             final Node module = itrChecks.next();
2228             final String moduleName = module.getTextContent().trim();
2229             final String href = module.getAttributes().getNamedItem("href").getTextContent();
2230             final boolean moduleIsCheck = href.startsWith("checks/");
2231 
2232             final String partialConfigUrl = "https://github.com/search?q="
2233                     + "path%3Asrc%2Fmain%2Fresources%20path%3A**%2F" + styleName;
2234 
2235             if (!moduleIsCheck) {
2236                 if (href.startsWith(partialConfigUrl)) {
2237                     assertWithMessage(
2238                         "%s_style.xml rule '%s' module '%s' has too many config links",
2239                         styleName, ruleName, moduleName).fail();
2240                 }
2241                 continue;
2242             }
2243 
2244             hasChecks = true;
2245 
2246             final Node idAttr = module.getAttributes().getNamedItem("id");
2247             String moduleId = "";
2248             if (idAttr != null) {
2249                 moduleId = idAttr.getTextContent();
2250             }
2251             final String moduleKey;
2252             if (moduleId.isEmpty()) {
2253                 moduleKey = moduleName;
2254             }
2255             else {
2256                 moduleKey = moduleName + "#" + moduleId;
2257             }
2258 
2259             assertWithMessage(
2260                 "Module ids should be unique. Duplicate id '%s' was found for "
2261                     + "module '%s' in rule '%s' of style guide '%s_style.xml'",
2262                 moduleId, moduleName, ruleName, styleName)
2263                 .that(usedModules)
2264                 .doesNotContain(moduleKey);
2265 
2266             usedModules.add(moduleKey);
2267 
2268             assertWithMessage("%s_style.xml rule '%s' module '%s' shouldn't end with 'Check'",
2269                 styleName, ruleName, moduleName)
2270                 .that(moduleName.endsWith("Check"))
2271                 .isFalse();
2272 
2273             styleChecks.remove(moduleName);
2274 
2275             if (itrChecks.hasNext()) {
2276                 final Node config = itrChecks.next();
2277 
2278                 final String configUrl = config.getAttributes()
2279                                        .getNamedItem("href").getTextContent();
2280 
2281                 final String expectedUrl =
2282                     partialConfigUrl + "_checks.xml+repo%3Acheckstyle%2Fcheckstyle+" + moduleName;
2283 
2284                 if (moduleId.isEmpty()) {
2285                     assertWithMessage(
2286                             "%s_style.xml rule '%s' module '%s' should have matching config url",
2287                             styleName, ruleName, moduleName)
2288                             .that(configUrl)
2289                             .isEqualTo(expectedUrl);
2290                 }
2291                 else {
2292                     final String expectedUrlWithId = expectedUrl + "+" + moduleId;
2293                     assertWithMessage(
2294                             "%s_style.xml rule '%s' module '%s' should have matching config url",
2295                             styleName, ruleName, moduleName)
2296                             .that(configUrl)
2297                             .isEqualTo(expectedUrlWithId);
2298                 }
2299             }
2300             else {
2301                 assertWithMessage("%s_style.xml rule '%s' module '%s' is missing the config link",
2302                     styleName, ruleName, moduleName).fail();
2303             }
2304         }
2305 
2306         if (itrSample.hasNext()) {
2307             assertWithMessage("%s_style.xml rule '%s' should have checks if it has sample links",
2308                 styleName, ruleName)
2309                     .that(hasChecks)
2310                     .isTrue();
2311 
2312             final Node sample = itrSample.next();
2313             final String inputFolderUrl = sample.getAttributes().getNamedItem("href")
2314                     .getTextContent();
2315             final String extractedChapterNumber = getExtractedChapterNumber(ruleName);
2316             final String extractedSectionNumber = getExtractedSectionNumber(ruleName);
2317 
2318             assertWithMessage("%s_style.xml rule '%s' rule '' should have matching sample url",
2319                 styleName, ruleName)
2320                     .that(inputFolderUrl)
2321                     .startsWith("https://github.com/checkstyle/checkstyle/"
2322                         + "tree/master/src/it/resources/com/" + styleName
2323                         + "/checkstyle/test/");
2324 
2325             assertWithMessage("%s_style.xml rule '%s' should have matching sample url",
2326                 styleName, ruleName)
2327                 .that(inputFolderUrl)
2328                 .containsMatch(
2329                     "/chapter" + extractedChapterNumber
2330                           + "\\D[^/]+/rule" + extractedSectionNumber + "\\D");
2331 
2332             assertWithMessage(
2333                 "%s_style.xml rule '%s' should have a inputs test folder that exists", styleName,
2334                 ruleName)
2335                     .that(new File(inputFolderUrl.substring(53).replace('/',
2336                             File.separatorChar)).exists())
2337                     .isTrue();
2338 
2339             assertWithMessage("%s_style.xml rule '%s' has too many samples link", styleName,
2340                 ruleName)
2341                     .that(itrSample.hasNext())
2342                     .isFalse();
2343         }
2344         else {
2345             assertWithMessage("%s_style.xml rule '%s' is missing sample link", styleName, ruleName)
2346                 .that(hasChecks)
2347                 .isFalse();
2348         }
2349     }
2350 
2351     private static String getExpectedStyleGuideUrl(String styleGuideName) {
2352         return "https://github.com/search?q=path%3Asrc%2Fmain%2Fresources%20path%3A**%2F"
2353             + styleGuideName
2354             + "+repo%3Acheckstyle%2Fcheckstyle+";
2355     }
2356 
2357     private static String getExtractedChapterNumber(String ruleName) {
2358         final Pattern pattern = Pattern.compile("^\\d+");
2359         final Matcher matcher = pattern.matcher(ruleName);
2360         matcher.find();
2361         return matcher.group();
2362     }
2363 
2364     private static String getExtractedSectionNumber(String ruleName) {
2365         final Pattern pattern = Pattern.compile("^\\d+(\\.\\d+)*");
2366         final Matcher matcher = pattern.matcher(ruleName);
2367         matcher.find();
2368         return matcher.group().replaceAll("\\.", "");
2369     }
2370 
2371     @Test
2372     public void testDocCommentsStyleRules() throws Exception {
2373         final Path path = Path.of("src/site/xdoc/doc-comments-style.xml");
2374         final NodeList sources = getTagSourcesNode(path, "tr");
2375         final Set<String> styleChecks = new HashSet<>(DOC_COMMENTS_MODULES);
2376 
2377         for (int position = 0; position < sources.getLength(); position++) {
2378             final Node row = sources.item(position);
2379             final List<Node> columns = new ArrayList<>(
2380                     XmlUtil.findChildElementsByTag(row, "td"));
2381 
2382             if (columns.isEmpty()) {
2383                 continue;
2384             }
2385 
2386             final String ruleName = columns.get(1).getTextContent().trim();
2387 
2388             validateDocCommentsStyleModules(XmlUtil.findChildElementsByTag(columns.get(2), "a"),
2389                     XmlUtil.findChildElementsByTag(columns.get(3), "a"), styleChecks, ruleName);
2390         }
2391 
2392         removeCommonUndocumentedModules(styleChecks);
2393         assertWithMessage(
2394                 "doc-comments-style.xml requires the following check(s) to appear: %s",
2395                 styleChecks)
2396             .that(styleChecks)
2397             .isEmpty();
2398     }
2399 
2400     private static void validateDocCommentsStyleModules(Set<Node> checks, Set<Node> samples,
2401             Set<String> styleChecks, String ruleName) {
2402         final Iterator<Node> itrChecks = checks.iterator();
2403         boolean hasChecks = false;
2404         final Set<String> usedModules = new HashSet<>();
2405 
2406         while (itrChecks.hasNext()) {
2407             final Node module = itrChecks.next();
2408             final String moduleName = module.getTextContent().trim();
2409             final String href = module.getAttributes().getNamedItem("href").getTextContent();
2410             final boolean moduleIsCheck = href.startsWith("checks/");
2411 
2412             final String partialConfigUrl = getExpectedStyleGuideUrl("doc_comments_checks.xml");
2413 
2414             if (!moduleIsCheck) {
2415                 if (href.startsWith(partialConfigUrl)) {
2416                     assertWithMessage(
2417                         "doc-comments-style.xml rule '%s' module '%s' has too many config links",
2418                         ruleName, moduleName).fail();
2419                 }
2420                 continue;
2421             }
2422 
2423             hasChecks = true;
2424 
2425             assertWithMessage(
2426                 "The module '%s' in the rule '%s' of the style guide 'doc-comments-style.xml'"
2427                     + " should not appear more than once in the section.",
2428                 moduleName, ruleName)
2429                 .that(usedModules)
2430                 .doesNotContain(moduleName);
2431 
2432             usedModules.add(moduleName);
2433 
2434             assertWithMessage("doc-comments-style.xml rule '%s' module '%s' shouldn't end"
2435                     + " with 'Check'", ruleName, moduleName)
2436                 .that(moduleName.endsWith("Check"))
2437                 .isFalse();
2438 
2439             styleChecks.remove(moduleName);
2440 
2441             if (itrChecks.hasNext()) {
2442                 final Node config = itrChecks.next();
2443 
2444                 final String configUrl = config.getAttributes()
2445                                        .getNamedItem("href").getTextContent();
2446 
2447                 final String expectedUrl = partialConfigUrl + moduleName;
2448 
2449                 assertWithMessage(
2450                     "doc-comments-style.xml rule '%s' module '%s' should have matching config url",
2451                     ruleName, moduleName)
2452                     .that(configUrl)
2453                     .isEqualTo(expectedUrl);
2454             }
2455             else {
2456                 assertWithMessage("doc-comments-style.xml rule '%s' module '%s' is missing the"
2457                         + " config link", ruleName, moduleName).fail();
2458             }
2459         }
2460 
2461         validateDocCommentsStyleSamples(samples.iterator(), hasChecks, ruleName);
2462     }
2463 
2464     private static void validateDocCommentsStyleSamples(Iterator<Node> itrSample,
2465             boolean hasChecks, String ruleName) {
2466         if (itrSample.hasNext()) {
2467             assertWithMessage("doc-comments-style.xml rule '%s' should have checks if it has"
2468                     + " sample links", ruleName)
2469                     .that(hasChecks)
2470                     .isTrue();
2471 
2472             final Node sample = itrSample.next();
2473             final String inputFolderUrl = sample.getAttributes().getNamedItem("href")
2474                     .getTextContent();
2475 
2476             assertWithMessage("doc-comments-style.xml rule '%s' should have matching sample url",
2477                 ruleName)
2478                     .that(inputFolderUrl)
2479                     .startsWith("https://github.com/checkstyle/checkstyle/"
2480                         + "tree/master/src/it/resources/com/doccomments/checkstyle/test/");
2481 
2482             assertWithMessage(
2483                 "doc-comments-style.xml rule '%s' should have a inputs test folder that exists",
2484                 ruleName)
2485                     .that(new File(inputFolderUrl.substring(53).replace('/',
2486                             File.separatorChar)).exists())
2487                     .isTrue();
2488 
2489             assertWithMessage("doc-comments-style.xml rule '%s' has too many samples link",
2490                 ruleName)
2491                     .that(itrSample.hasNext())
2492                     .isFalse();
2493         }
2494         else {
2495             assertWithMessage("doc-comments-style.xml rule '%s' is missing sample link", ruleName)
2496                 .that(hasChecks)
2497                 .isFalse();
2498         }
2499     }
2500 
2501     @Test
2502     public void testOpenJdkStyleRules() throws Exception {
2503         final Path path = Path.of("src/site/xdoc/openjdk-style.xml");
2504         final NodeList source = getTagSourcesNode(path, "tr");
2505         final Set<String> styleChecks = new HashSet<>(OPENJDK_MODULES);
2506 
2507         for (int position = 0; position < source.getLength(); position++) {
2508             final Node row = source.item(position);
2509             final List<Node> columns = new ArrayList<>(
2510                     XmlUtil.findChildElementsByTag(row, "td"));
2511 
2512             if (columns.isEmpty()) {
2513                 continue;
2514             }
2515             final String ruleName = columns.get(1).getTextContent().trim();
2516 
2517             if (!"--".equals(ruleName)) {
2518                 validateStyleAnchorsForOpenjdk(
2519                     XmlUtil.findChildElementsByTag(columns.getFirst(), "a"),
2520                     "openjdk_checks.xml", columns.get(1));
2521             }
2522 
2523             validateOpenJdkStyleModules(XmlUtil.findChildElementsByTag(columns.get(2), "a"),
2524                     XmlUtil.findChildElementsByTag(columns.get(3), "a"), styleChecks, ruleName);
2525         }
2526 
2527         removeCommonUndocumentedModules(styleChecks);
2528         assertWithMessage(
2529             "openjdk-style.xml requires the following check(s) to appear: %s", styleChecks)
2530             .that(styleChecks)
2531             .isEmpty();
2532     }
2533 
2534     private static void validateOpenJdkStyleModules(Set<Node> checks, Set<Node> samples,
2535             Set<String> styleChecks, String ruleName) {
2536         final Iterator<Node> itrChecks = checks.iterator();
2537         boolean hasChecks = false;
2538         final Set<String> usedModules = new HashSet<>();
2539 
2540         while (itrChecks.hasNext()) {
2541             final Node module = itrChecks.next();
2542             final String moduleName = module.getTextContent().trim();
2543             final String href = module.getAttributes().getNamedItem("href").getTextContent();
2544             final boolean moduleIsCheck = href.startsWith("checks/");
2545 
2546             final String partialConfigUrl = getExpectedStyleGuideUrl("openjdk_checks.xml");
2547 
2548             if (!moduleIsCheck) {
2549                 if (href.startsWith(partialConfigUrl)) {
2550                     assertWithMessage(
2551                         "openjdk-style.xml rule '%s' module '%s' has too many config links",
2552                         ruleName, moduleName).fail();
2553                 }
2554                 continue;
2555             }
2556 
2557             hasChecks = true;
2558 
2559             assertWithMessage(
2560                 "The module '%s' in the rule '%s' of the style guide 'openjdk-style.xml'"
2561                     + " should not appear more than once in the section.",
2562                 moduleName, ruleName)
2563                 .that(usedModules)
2564                 .doesNotContain(moduleName);
2565 
2566             usedModules.add(moduleName);
2567 
2568             assertWithMessage("openjdk-style.xml rule '%s' module '%s' shouldn't end"
2569                     + " with 'Check'", ruleName, moduleName)
2570                 .that(moduleName.endsWith("Check"))
2571                 .isFalse();
2572 
2573             styleChecks.remove(moduleName);
2574 
2575             if (itrChecks.hasNext()) {
2576                 final Node config = itrChecks.next();
2577 
2578                 final String configUrl = config.getAttributes()
2579                                        .getNamedItem("href").getTextContent();
2580 
2581                 final String expectedUrl = partialConfigUrl + moduleName;
2582 
2583                 assertWithMessage(
2584                     "openjdk-style.xml rule '%s' module '%s' should have matching config url",
2585                     ruleName, moduleName)
2586                     .that(configUrl)
2587                     .isEqualTo(expectedUrl);
2588             }
2589             else {
2590                 assertWithMessage("openjdk-style.xml rule '%s' module '%s' is missing the"
2591                         + " config link", ruleName, moduleName).fail();
2592             }
2593         }
2594 
2595         validateOpenJdkStyleSamples(samples.iterator(), hasChecks, ruleName);
2596     }
2597 
2598     private static void validateOpenJdkStyleSamples(Iterator<Node> itrSample,
2599             boolean hasChecks, String ruleName) {
2600         if (itrSample.hasNext()) {
2601             assertWithMessage("openjdk-style.xml rule '%s' should have checks if it has"
2602                     + " sample links", ruleName)
2603                     .that(hasChecks)
2604                     .isTrue();
2605 
2606             final Node sample = itrSample.next();
2607             final String inputFolderUrl = sample.getAttributes().getNamedItem("href")
2608                     .getTextContent();
2609 
2610             assertWithMessage("openjdk-style.xml rule '%s' should have matching sample url",
2611                 ruleName)
2612                     .that(inputFolderUrl)
2613                     .startsWith("https://github.com/checkstyle/checkstyle/"
2614                         + "tree/master/src/it/resources/com/openjdk/checkstyle/test/");
2615 
2616             assertWithMessage(
2617                 "openjdk-style.xml rule '%s' should have a inputs test folder that exists",
2618                 ruleName)
2619                     .that(new File(inputFolderUrl.substring(53).replace('/',
2620                             File.separatorChar)).exists())
2621                     .isTrue();
2622 
2623             assertWithMessage("openjdk-style.xml rule '%s' has too many samples link",
2624                 ruleName)
2625                     .that(itrSample.hasNext())
2626                     .isFalse();
2627         }
2628         else {
2629             assertWithMessage("openjdk-style.xml rule '%s' is missing sample link", ruleName)
2630                 .that(hasChecks)
2631                 .isFalse();
2632         }
2633     }
2634 
2635     private static void validateStyleAnchorsForOpenjdk(Set<Node> anchors,
2636             String fileName, Node ruleColumn) {
2637 
2638         final String ruleName = ruleColumn.getTextContent().trim();
2639         assertWithMessage("%s rule '%s' must have two row anchors", fileName, ruleName)
2640             .that(anchors)
2641             .hasSize(2);
2642 
2643         final Node ruleAnchor = XmlUtil.findChildElementsByTag(ruleColumn, "a")
2644                                     .iterator().next();
2645         final String ruleHref = ruleAnchor.getAttributes()
2646                                     .getNamedItem("href").getTextContent();
2647 
2648         final String anchorUrl = ruleHref.substring(ruleHref.indexOf('#') + 1);
2649 
2650         int position = 1;
2651 
2652         for (Node anchor : anchors) {
2653             final String actualUrl;
2654             final String expectedUrl;
2655 
2656             if (position == 1) {
2657                 actualUrl = XmlUtil.getNameAttributeOfNode(anchor);
2658                 expectedUrl = anchorUrl;
2659             }
2660             else {
2661                 actualUrl = anchor.getAttributes().getNamedItem("href").getTextContent();
2662                 expectedUrl = "#" + anchorUrl;
2663             }
2664 
2665             assertWithMessage("%s rule '%s' anchor %s should have matching name/url", fileName,
2666                 ruleName, position)
2667                 .that(actualUrl)
2668                 .isEqualTo(expectedUrl);
2669 
2670             position++;
2671         }
2672     }
2673 
2674     /**
2675      * Removes modules that are present in style configs but are not documented
2676      * in the style guide tables.
2677      *
2678      * @param styleChecks modules still expected to be documented.
2679      */
2680     private static void removeCommonUndocumentedModules(Set<String> styleChecks) {
2681         styleChecks.remove("BeforeExecutionExclusionFileFilter");
2682         styleChecks.remove("SuppressionFilter");
2683         styleChecks.remove("SuppressionXpathFilter");
2684         styleChecks.remove("SuppressionXpathSingleFilter");
2685         styleChecks.remove("TreeWalker");
2686         styleChecks.remove("Checker");
2687         styleChecks.remove("SuppressWithNearbyCommentFilter");
2688         styleChecks.remove("SuppressionCommentFilter");
2689         styleChecks.remove("SuppressWarningsFilter");
2690         styleChecks.remove("SuppressWarningsHolder");
2691         styleChecks.remove("SuppressWithNearbyTextFilter");
2692         styleChecks.remove("SuppressWithPlainTextCommentFilter");
2693     }
2694 
2695     @Test
2696     public void testAllExampleMacrosHaveParagraphWithIdBeforeThem() throws Exception {
2697         for (Path path : XdocUtil.getXdocsTemplatesFilePaths()) {
2698             final String fileName = path.getFileName().toString();
2699             final NodeList sources = getTagSourcesNode(path, "macro");
2700 
2701             for (int position = 0; position < sources.getLength(); position++) {
2702                 final Node macro = sources.item(position);
2703                 final String macroName = macro.getAttributes()
2704                         .getNamedItem("name").getTextContent();
2705 
2706                 if (!"example".equals(macroName)) {
2707                     continue;
2708                 }
2709 
2710                 final Node precedingParagraph = getPrecedingParagraph(macro);
2711                 assertWithMessage("%s: paragraph before example macro should have an id attribute",
2712                     fileName)
2713                         .that(precedingParagraph.hasAttributes())
2714                         .isTrue();
2715 
2716                 final Node idAttribute = precedingParagraph.getAttributes().getNamedItem("id");
2717                 assertWithMessage("%s: paragraph before example macro should have an id attribute",
2718                     fileName)
2719                         .that(idAttribute)
2720                         .isNotNull();
2721 
2722                 validatePrecedingParagraphId(macro, fileName, idAttribute);
2723             }
2724         }
2725     }
2726 
2727     private static void validatePrecedingParagraphId(
2728             Node macro, String fileName, Node idAttribute) {
2729         String exampleName = "";
2730         String exampleType = "";
2731         final NodeList params = macro.getChildNodes();
2732         for (int paramPosition = 0; paramPosition < params.getLength(); paramPosition++) {
2733             final Node item = params.item(paramPosition);
2734 
2735             if (!"param".equals(item.getNodeName())) {
2736                 continue;
2737             }
2738 
2739             final String paramName = item.getAttributes()
2740                     .getNamedItem("name").getTextContent();
2741             final String paramValue = item.getAttributes()
2742                     .getNamedItem("value").getTextContent();
2743             if ("path".equals(paramName)) {
2744                 exampleName = paramValue.substring(paramValue.lastIndexOf('/') + 1,
2745                         paramValue.lastIndexOf('.'));
2746             }
2747             else if ("type".equals(paramName)) {
2748                 exampleType = paramValue;
2749             }
2750         }
2751 
2752         final String id = idAttribute.getTextContent();
2753         final String expectedId = String.format(Locale.ROOT, "%s-%s", exampleName,
2754                 exampleType);
2755         if (expectedId.startsWith("package-info")) {
2756             assertWithMessage(
2757                 "%s: paragraph before example macro should have the expected id value", fileName)
2758                 .that(id)
2759                 .endsWith(expectedId);
2760         }
2761         else {
2762             assertWithMessage(
2763                 "%s: paragraph before example macro should have the expected id value", fileName)
2764                 .that(id)
2765                 .isEqualTo(expectedId);
2766         }
2767     }
2768 
2769     private static Node getPrecedingParagraph(Node macro) {
2770         Node precedingNode = macro.getPreviousSibling();
2771         while (!"p".equals(precedingNode.getNodeName())) {
2772             precedingNode = precedingNode.getPreviousSibling();
2773         }
2774         return precedingNode;
2775     }
2776 
2777     @Test
2778     public void validateExampleSectionSeparation() throws Exception {
2779         final List<Path> templates = collectAllXmlTemplatesUnderSrcSite();
2780 
2781         for (final Path template : templates) {
2782             final Document doc = parseXmlToDomDocument(template);
2783             final NodeList subsectionList = doc.getElementsByTagName("subsection");
2784 
2785             for (int index = 0; index < subsectionList.getLength(); index++) {
2786                 final Element subsection = (Element) subsectionList.item(index);
2787                 final String subSectionName = subsection.getAttribute("name");
2788 
2789                 if (!"Examples".equals(subSectionName) && !"Use Cases".equals(subSectionName)) {
2790                     continue;
2791                 }
2792 
2793                 final NodeList children = subsection.getChildNodes();
2794                 String lastExampleIdPrefix = null;
2795                 boolean separatorSeen = false;
2796 
2797                 for (int childIndex = 0; childIndex < children.getLength(); childIndex++) {
2798                     final Node child = children.item(childIndex);
2799                     if (child.getNodeType() != Node.ELEMENT_NODE) {
2800                         continue;
2801                     }
2802 
2803                     final Element element = (Element) child;
2804                     if ("hr".equals(element.getTagName())
2805                             && "example-separator".equals(element.getAttribute("class"))) {
2806                         separatorSeen = true;
2807                         continue;
2808                     }
2809 
2810                     final String currentId = element.getAttribute("id");
2811                     if (currentId != null && (currentId.startsWith("Example")
2812                         || currentId.startsWith("UseCase"))) {
2813                         final String currentExPrefix = getExamplePrefix(currentId);
2814                         if (lastExampleIdPrefix != null
2815                                 && !lastExampleIdPrefix.equals(currentExPrefix)) {
2816                             final boolean isSeparated = separatorSeen
2817                                     || isSeparatorSuppressed(template, lastExampleIdPrefix,
2818                                             currentExPrefix);
2819                             assertWithMessage(
2820                                 "Missing <hr class=\"example-separator\"/> "
2821                                     + "between %s and %s in file: %s",
2822                                     lastExampleIdPrefix, currentExPrefix, template)
2823                                     .that(isSeparated)
2824                                     .isTrue();
2825                             separatorSeen = false;
2826                         }
2827                         lastExampleIdPrefix = currentExPrefix;
2828                     }
2829                 }
2830             }
2831         }
2832     }
2833 
2834     /**
2835      * Checks whether the given pair of consecutive examples is explicitly allowed to be
2836      * grouped together without a separator between them.
2837      *
2838      * @param template template file the examples belong to
2839      * @param previousExamplePrefix prefix of the preceding example
2840      * @param currentExamplePrefix prefix of the following example
2841      * @return true if the missing separator is intentional
2842      */
2843     private static boolean isSeparatorSuppressed(Path template, String previousExamplePrefix,
2844                                                  String currentExamplePrefix) {
2845         final String key = template.getFileName() + ":" + previousExamplePrefix
2846                 + ":" + currentExamplePrefix;
2847         return ALLOWED_EXAMPLES_WITHOUT_SEPARATOR.contains(key);
2848     }
2849 
2850     private static List<Path> collectAllXmlTemplatesUnderSrcSite() throws IOException {
2851         final Path root = Path.of("src/site/xdoc");
2852         try (Stream<Path> walk = Files.walk(root)) {
2853             return walk
2854                     .filter(path -> path.getFileName().toString().endsWith(".xml.template"))
2855                     .collect(toImmutableList());
2856         }
2857     }
2858 
2859     private static Document parseXmlToDomDocument(Path template) throws Exception {
2860         final DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance();
2861         dbFactory.setNamespaceAware(true);
2862         final DocumentBuilder dBuilder = dbFactory.newDocumentBuilder();
2863         final Document doc = dBuilder.parse(template.toFile());
2864         doc.getDocumentElement().normalize();
2865         return doc;
2866     }
2867 
2868     private static String getExamplePrefix(String id) {
2869         final int dash = id.indexOf('-');
2870         final String result;
2871         if (dash == -1) {
2872             result = id;
2873         }
2874         else {
2875             result = id.substring(0, dash);
2876         }
2877         return result;
2878     }
2879 
2880     @Test
2881     public void testAllOldReleaseNotesHaveRedirectInCheckstyleJs() throws Exception {
2882         final String checkstyleJsContent = Files.readString(CHECKSTYLE_JS_PATH);
2883         for (Path path : XdocUtil.getXdocsFilePaths()) {
2884             if (!path.toString().contains("release-notes-old-")) {
2885                 continue;
2886             }
2887             final String fileNameWithoutExtension =
2888                     path.getFileName().toString().replace(".xml", "");
2889             final String expectedRedirect = String.format(Locale.ROOT,
2890                     "window.location.replace(`./%s.html", fileNameWithoutExtension);
2891             assertWithMessage(String.format(
2892                         Locale.ROOT,
2893                         "Missing redirect for %s: expected '%s...' in %s",
2894                         fileNameWithoutExtension,
2895                         expectedRedirect,
2896                         CHECKSTYLE_JS_PATH))
2897                     .that(checkstyleJsContent)
2898                     .contains(expectedRedirect);
2899         }
2900     }
2901 
2902     @Test
2903     public void testAllXdocsModulesTemplatesHaveSinceMacroAtTheBeginning() throws Exception {
2904         for (Path path : XdocUtil.getXdocsTemplatesFilePaths()) {
2905             final String fileName = path.getFileName().toString();
2906 
2907             if (isNonModulePage(fileName.replace(".template", ""))) {
2908                 continue;
2909             }
2910 
2911             final NodeList sources = getTagSourcesNode(path, "section");
2912             final Node section = sources.item(0);
2913             final String sectionName = section.getNodeName();
2914             final Node firstChild = XmlUtil.getFirstChildElement(section);
2915             assertWithMessage(
2916                 "%s first child of section %s should be a <macro> tag", fileName, sectionName)
2917                 .that(firstChild.getNodeName())
2918                 .isEqualTo("macro");
2919             assertWithMessage(
2920                 "%s first child of section %s should be a <macro> tag with name 'since'", fileName,
2921                 sectionName)
2922                 .that(firstChild.getAttributes().getNamedItem("name").getTextContent())
2923                 .isEqualTo("since");
2924         }
2925     }
2926 
2927     @Test
2928     public void testUseCasesSectionExistsWhenUseCaseIdsPresent() throws Exception {
2929         final List<Path> templates = collectAllXmlTemplatesUnderSrcSite();
2930         final List<Path> violations = new ArrayList<>();
2931 
2932         for (final Path template : templates) {
2933             final Document doc = parseXmlToDomDocument(template);
2934 
2935             if (hasAnyUseCaseId(doc) && !hasUseCasesSubsection(doc)) {
2936                 violations.add(template);
2937             }
2938         }
2939 
2940         final String message;
2941         if (violations.isEmpty()) {
2942             message = "";
2943         }
2944         else {
2945             final StringBuilder builder = new StringBuilder(256);
2946             builder.append("Found ")
2947                 .append(violations.size())
2948                 .append(" template(s) with 'UseCase' ids but no "
2949                     + "<subsection name=\"Use Cases\" .../> to hold them:\n");
2950             for (Path violation : violations) {
2951                 builder.append("  ").append(violation).append('\n');
2952             }
2953             message = builder.toString();
2954         }
2955 
2956         assertWithMessage(message)
2957             .that(violations)
2958             .isEmpty();
2959     }
2960 
2961     @Test
2962     public void testAllExampleAndUseCaseParagraphsHaveDescriptiveText() throws Exception {
2963         final List<Path> templates = collectAllXmlTemplatesUnderSrcSite();
2964 
2965         assertWithMessage("Expected to find at least one xdoc template under src/site")
2966                 .that(templates)
2967                 .isNotEmpty();
2968 
2969         final List<String> failures = new ArrayList<>();
2970 
2971         for (final Path template : templates) {
2972             final String content = Files.readString(template);
2973             final String fileName = template.getFileName().toString();
2974 
2975             failures.addAll(validateTocExtractableDescriptions(fileName, content));
2976         }
2977 
2978         assertWithMessage("TOC-extractable description problems found:\n%s",
2979                 String.join("\n", failures))
2980                 .that(failures)
2981                 .isEmpty();
2982     }
2983 
2984     /**
2985      * Validates that every Example/UseCase id found in the template has a
2986      * matching descriptive paragraph immediately before its example macro,
2987      * with non-empty text content once tags are stripped -- the same
2988      * extraction TocMacro performs to build nested TOC entries.
2989      *
2990      * @param fileName the template's file name, for failure messages.
2991      * @param content the full template source text.
2992      * @return the list of failure messages.
2993      * @throws Exception if the content cannot be parsed as XML.
2994      */
2995     private static List<String> validateTocExtractableDescriptions(String fileName,
2996             String content) throws Exception {
2997         final Document doc = parseXml(content);
2998         final Set<String> matchedIds = new HashSet<>();
2999         final List<String> failures = new ArrayList<>();
3000         final NodeList paragraphs = doc.getElementsByTagName("p");
3001 
3002         for (int index = 0; index < paragraphs.getLength(); index++) {
3003             final Element paragraph = (Element) paragraphs.item(index);
3004             final Matcher idMatcher = EXAMPLE_ID_PATTERN.matcher(paragraph.getAttribute("id"));
3005 
3006             if (!idMatcher.matches()) {
3007                 continue;
3008             }
3009 
3010             final Element nextElement = nextSiblingElement(paragraph);
3011             if (nextElement == null
3012                     || !"macro".equals(nextElement.getTagName())
3013                     || !"example".equals(nextElement.getAttribute("name"))
3014                     || !hasPathParam(nextElement)) {
3015                 continue;
3016             }
3017 
3018             final String exampleId = idMatcher.group(1);
3019             final String strippedText = TAG_PATTERN.matcher(paragraph.getTextContent())
3020                     .replaceAll("")
3021                     .replaceAll("\\s+", " ")
3022                     .trim();
3023 
3024             if ("Notes:".equals(strippedText)) {
3025                 matchedIds.add(exampleId);
3026                 continue;
3027             }
3028 
3029             if (strippedText.isEmpty()) {
3030                 failures.add(String.format(Locale.ROOT,
3031                         "%s: description paragraph for '%s-config' must have non-empty text "
3032                                 + "so TocMacro can extract a TOC title from it",
3033                         fileName, exampleId));
3034             }
3035 
3036             matchedIds.add(exampleId);
3037         }
3038 
3039         final Set<String> unmatchedIds = new TreeSet<>(findAllExampleAndUseCaseIds(content));
3040         unmatchedIds.removeAll(matchedIds);
3041 
3042         if (!unmatchedIds.isEmpty()) {
3043             failures.add(String.format(Locale.ROOT,
3044                     "%s: the following Example/UseCase ids have a config paragraph that "
3045                             + "TocMacro's extraction pattern cannot match (paragraph must "
3046                             + "immediately precede a <macro name=\"example\"> with a 'path' "
3047                             + "param): %s",
3048                     fileName, unmatchedIds));
3049         }
3050         return failures;
3051     }
3052 
3053     /**
3054      * Finds every {@code ExampleN}/{@code UseCaseN} id declared via a
3055      * {@code -config} paragraph anywhere in the template, regardless of
3056      * whether it matches the extraction pattern -- used to detect ids that
3057      * exist but silently fail extraction.
3058      *
3059      * @param content the full template source text.
3060      * @return the set of "ExampleN"/"UseCaseN" prefixes found.
3061      * @throws Exception if the content cannot be parsed as XML.
3062      */
3063     private static Set<String> findAllExampleAndUseCaseIds(String content) throws Exception {
3064         final Document doc = parseXml(content);
3065         final Set<String> result = new TreeSet<>();
3066         final NodeList paragraphs = doc.getElementsByTagName("p");
3067 
3068         for (int index = 0; index < paragraphs.getLength(); index++) {
3069             final Element paragraph = (Element) paragraphs.item(index);
3070             final Matcher idMatcher = EXAMPLE_ID_PATTERN.matcher(paragraph.getAttribute("id"));
3071             if (idMatcher.matches()) {
3072                 result.add(idMatcher.group(1));
3073             }
3074         }
3075 
3076         return result;
3077     }
3078 
3079     /**
3080      * Parses the given xdoc source text into a DOM {@link Document}.
3081      *
3082      * @param content the full template source text.
3083      * @return the parsed document.
3084      * @throws Exception if parsing fails.
3085      */
3086     private static Document parseXml(String content) throws Exception {
3087         final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
3088         factory.setNamespaceAware(false);
3089         final DocumentBuilder builder = factory.newDocumentBuilder();
3090         return builder.parse(new InputSource(new StringReader(content)));
3091     }
3092 
3093     /**
3094      * Finds the next sibling that is itself an {@link Element}, skipping over
3095      * text/whitespace nodes and {@code <ul>} elements (which are allowed between
3096      * a config paragraph and its example macro).
3097      *
3098      * @param node the node to start from.
3099      * @return the next sibling element, or {@code null} if none exists.
3100      */
3101     private static Element nextSiblingElement(Node node) {
3102         Node sibling = node.getNextSibling();
3103         Element result = null;
3104         while (sibling != null) {
3105             if (sibling.getNodeType() == Node.ELEMENT_NODE) {
3106                 final Element element = (Element) sibling;
3107                 // Skip <ul> elements as they're allowed between paragraph and macro
3108                 if (!"ul".equals(element.getTagName())) {
3109                     result = element;
3110                     break;
3111                 }
3112             }
3113             sibling = sibling.getNextSibling();
3114         }
3115         return result;
3116     }
3117 
3118     /**
3119      * Checks whether the given {@code <macro name="example">} element has a
3120      * child {@code <param name="path">}.
3121      *
3122      * @param macroElement the macro element to inspect.
3123      * @return {@code true} if a path param child is present.
3124      */
3125     private static boolean hasPathParam(Element macroElement) {
3126         final NodeList params = macroElement.getElementsByTagName("param");
3127         boolean result = false;
3128         for (int index = 0; index < params.getLength(); index++) {
3129             final Element param = (Element) params.item(index);
3130             if ("path".equals(param.getAttribute("name"))) {
3131                 result = true;
3132                 break;
3133             }
3134         }
3135         return result;
3136     }
3137 
3138     private static boolean hasAnyUseCaseId(Document doc) {
3139         final NodeList allParagraphElements = doc.getElementsByTagName("p");
3140         boolean found = false;
3141 
3142         for (int index = 0; !found && index < allParagraphElements.getLength(); index++) {
3143             final Element element = (Element) allParagraphElements.item(index);
3144             final String id = element.getAttribute("id");
3145             if (id != null && id.startsWith("UseCase")) {
3146                 found = true;
3147             }
3148         }
3149 
3150         return found;
3151     }
3152 
3153     private static boolean hasUseCasesSubsection(Document doc) {
3154         final NodeList subsections = doc.getElementsByTagName("subsection");
3155         boolean found = false;
3156 
3157         for (int index = 0; !found && index < subsections.getLength(); index++) {
3158             final Element subsection = (Element) subsections.item(index);
3159             if ("Use Cases".equals(subsection.getAttribute("name"))) {
3160                 found = true;
3161             }
3162         }
3163 
3164         return found;
3165     }
3166 
3167     @FunctionalInterface
3168     private interface PredicateProcess {
3169         boolean hasFit(Path path);
3170     }
3171 
3172 }