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