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.truth.Truth.assertWithMessage;
23  
24  import java.io.ByteArrayInputStream;
25  import java.io.File;
26  import java.io.IOException;
27  import java.nio.charset.StandardCharsets;
28  import java.nio.file.Files;
29  import java.nio.file.Path;
30  import java.util.ArrayList;
31  import java.util.Collections;
32  import java.util.Comparator;
33  import java.util.HashSet;
34  import java.util.List;
35  import java.util.Locale;
36  import java.util.Objects;
37  import java.util.Set;
38  import java.util.concurrent.ConcurrentHashMap;
39  import java.util.concurrent.ConcurrentMap;
40  import java.util.function.Predicate;
41  import java.util.regex.Matcher;
42  import java.util.regex.Pattern;
43  import java.util.stream.Stream;
44  
45  import javax.xml.parsers.DocumentBuilder;
46  import javax.xml.parsers.DocumentBuilderFactory;
47  import javax.xml.parsers.ParserConfigurationException;
48  
49  import org.junit.jupiter.api.Test;
50  import org.w3c.dom.Document;
51  import org.w3c.dom.Element;
52  import org.w3c.dom.Node;
53  import org.w3c.dom.NodeList;
54  import org.xml.sax.SAXException;
55  
56  import com.puppycrawl.tools.checkstyle.JavaParser;
57  import com.puppycrawl.tools.checkstyle.api.CheckstyleException;
58  import com.puppycrawl.tools.checkstyle.api.DetailAST;
59  import com.puppycrawl.tools.checkstyle.api.FileContents;
60  import com.puppycrawl.tools.checkstyle.api.FileText;
61  import com.puppycrawl.tools.checkstyle.api.TokenTypes;
62  import com.puppycrawl.tools.checkstyle.internal.utils.CheckUtil;
63  import com.puppycrawl.tools.checkstyle.internal.utils.XdocUtil;
64  import com.puppycrawl.tools.checkstyle.utils.TokenUtil;
65  
66  /**
67   * Ensures xdocs Java examples for a check differ only by comments, and that
68   * example count matches documented property count.
69   */
70  public class XdocsExamplesAstConsistencyTest {
71  
72      public static final String XDOC_START_MARKER = "// xdoc section - start";
73      public static final String XDOC_END_MARKER = "// xdoc section - end";
74  
75      public static final Path XDOCS_ROOT = Path.of(
76              "src/xdocs-examples/resources/com/puppycrawl/tools/checkstyle"
77      );
78  
79      private static final Path XDOCS_NONCOMPILABLE_ROOT = Path.of(
80              "src/xdocs-examples/resources-noncompilable/com/puppycrawl/tools/checkstyle"
81      );
82  
83      private static final Pattern BLOCK_COMMENT_PATTERN = Pattern.compile("(?s)/\\*.*?\\*/");
84  
85      /**
86       * Examples that cannot be parsed as valid Java.
87       */
88      private static final Set<String> UNPARSEABLE_EXAMPLES = Set.of(
89              "checks/regexp/regexponfilename/Example1",
90              "checks/translation/Example1",
91              "filters/suppressionxpathsinglefilter/Example7"
92      );
93  
94      /**
95       * Properties intentionally never demonstrated in an example.
96       */
97      private static final Set<String> IGNORED_PROPERTIES_FOR_COVERAGE = Set.of(
98          "violateExecutionOnNonTightHtml"
99      );
100 
101     /**
102      * Cache for module property counts.
103      */
104     private static final ConcurrentMap<String, Integer> PROPERTY_COUNT_CACHE =
105         new ConcurrentHashMap<>();
106 
107     /**
108      * Cache mapping a lower-cased xdocs directory name to the check's simple class name.
109      */
110     private static final ConcurrentMap<String, String> MODULE_SIMPLE_NAME_CACHE =
111         buildModuleSimpleNameIndex();
112 
113     /**
114      * Examples that have independent code structure and should not be compared.
115      * Format: "directory/ExampleN" where the example has unique code.
116      * Until: <a href="https://github.com/checkstyle/checkstyle/issues/19891">...</a>
117      */
118     private static final Set<String> SUPPRESSED_EXAMPLES = Set.of(
119             // Note: customImport/ImportOrder changes import group ORDER affecting AST structure
120             "checks/imports/customimportorder/Example10",
121             "checks/imports/customimportorder/Example11",
122             "checks/imports/customimportorder/Example12",
123             "checks/imports/customimportorder/Example13",
124             "checks/imports/customimportorder/Example14",
125             "checks/imports/customimportorder/Example15",
126             "checks/imports/customimportorder/Example2",
127             "checks/imports/customimportorder/Example3",
128             "checks/imports/customimportorder/Example4",
129             "checks/imports/customimportorder/Example5",
130             "checks/imports/customimportorder/Example6",
131             "checks/imports/customimportorder/Example7",
132             "checks/imports/customimportorder/Example8",
133             "checks/imports/customimportorder/Example9",
134             "checks/imports/importorder/Example10",
135             "checks/imports/importorder/Example11",
136             "checks/imports/importorder/Example12",
137             "checks/imports/importorder/Example2",
138             "checks/imports/importorder/Example3",
139             "checks/imports/importorder/Example4",
140             "checks/imports/importorder/Example5",
141             "checks/imports/importorder/Example6",
142             "checks/imports/importorder/Example7",
143             "checks/imports/importorder/Example8",
144             "checks/imports/importorder/Example9"
145     );
146 
147     /**
148      * Modules with no example demonstrating the default configuration. Most require
149      * at least one property to be meaningful; others are documentation gaps.
150      * Until: <a href="https://github.com/checkstyle/checkstyle/issues/21137">...</a>
151      */
152     private static final Set<String> EXAMPLE_DEFAULT_CONFIG_SUPPRESSED_MODULES = Set.of(
153             "checks/coding/returncount",
154             "checks/descendanttoken",
155             "checks/imports/importcontrol",
156             "filters/severitymatchfilter",
157             "filters/suppressionsinglefilter",
158             "filters/suppressionxpathfilter",
159             "filters/suppresswithplaintextcommentfilter"
160     );
161 
162     /**
163      * Modules whose example count does not exactly match property count + 1.
164      * These modules have extra examples beyond the expected count and need
165      * to be fixed in a follow-up issue.
166      * Until: <a href="https://github.com/checkstyle/checkstyle/issues/21229">...</a>
167      */
168     private static final Set<String> EXAMPLE_COUNT_SUPPRESSED_MODULES = Set.of(
169             "checks/javadoc/javadocpackage",
170             "checks/sizes/linelength",
171             "checks/whitespace/emptylineseparator",
172             "filters/suppresswarningsfilter"
173     );
174 
175     /**
176      * Tests that examples with same code structure maintain consistency.
177      *
178      * @throws IOException if an I/O error occurs
179      */
180     @Test
181     public void testExamplesDifferOnlyByComments() throws IOException {
182         final List<Violation> violations = new ArrayList<>();
183 
184         try (Stream<Path> pathStream = Files.walk(XDOCS_ROOT)) {
185             final List<Path> exampleDirs = pathStream
186                     .filter(Files::isDirectory)
187                     .filter(XdocsExamplesAstConsistencyTest::isModuleDirectory)
188                     .filter(XdocsExamplesAstConsistencyTest::containsMultipleExamples)
189                     .toList();
190 
191             for (Path dir : exampleDirs) {
192                 final List<Violation> dirViolations = checkExamplesInDirectory(dir);
193                 violations.addAll(dirViolations);
194             }
195         }
196 
197         final String message;
198 
199         if (violations.isEmpty()) {
200             message = "";
201         }
202         else {
203             final StringBuilder builder = new StringBuilder(1024);
204 
205             builder.append("Found ")
206                     .append(violations.size())
207                     .append(" example files with AST mismatches.\n\n");
208 
209             for (Violation violation : violations) {
210                 builder.append(violation)
211                         .append("\n\n");
212             }
213 
214             builder.append(
215                     """
216                     Note: a mismatch reason of "line numbers differ only" usually means \
217                     an example has an extra/missing blank line or shifted code relative to its \
218                     reference - fix the line alignment before considering suppression.
219                     """);
220 
221             for (Violation violation : violations) {
222                 final String pattern = violation.getSuppressionPattern();
223                 builder.append('"').append(pattern).append("\",\n");
224             }
225 
226             message = builder.toString();
227         }
228 
229         assertWithMessage(message)
230                 .that(violations)
231                 .isEmpty();
232     }
233 
234     /**
235      * Tests that no example uses block comments as {@code ok} or {@code violation} markers.
236      *
237      * @throws IOException if an I/O error occurs
238      */
239     @Test
240     public void testNoBlockCommentMarkers() throws IOException {
241         final List<String> violations = new ArrayList<>();
242 
243         try (Stream<Path> pathStream = Files.walk(XDOCS_ROOT)) {
244             pathStream
245                     .filter(path -> path.getFileName().toString().matches("Example\\d+\\.java"))
246                     .filter(path -> {
247                         final String relativePath = getRelativePath(path.getParent());
248                         final String fileName = path.getFileName().toString();
249                         return !isExampleIndependent(relativePath, fileName);
250                     })
251                     .sorted()
252                     .forEach(path -> {
253                         try {
254                             violations.addAll(checkForBlockCommentMarkers(path));
255                         }
256                         catch (IOException exception) {
257                             throw new IllegalStateException(
258                                     "Failed to read file: " + path, exception);
259                         }
260                     });
261         }
262 
263         final String message;
264         if (violations.isEmpty()) {
265             message = "";
266         }
267         else {
268             message = formatBlockCommentMarkerViolationsMessage(violations);
269         }
270 
271         assertWithMessage(message)
272                 .that(violations)
273                 .isEmpty();
274     }
275 
276     /**
277      * Tests that AST-consistent example count matches property count + 1.
278      *
279      * @throws IOException if an I/O error occurs
280      */
281     @Test
282     public void testExampleCountMatchesPropertyCount() throws IOException {
283         final List<String> violations = Collections.synchronizedList(new ArrayList<>());
284 
285         try (Stream<Path> pathStream = Files.walk(XDOCS_ROOT)) {
286             pathStream
287                 .filter(Files::isDirectory)
288                 .filter(XdocsExamplesAstConsistencyTest::isModuleDirectory)
289                 .parallel()
290                 .forEach(dir -> processDirectory(dir, violations));
291         }
292 
293         final String message = formatViolationsMessage(violations);
294 
295         assertWithMessage(message)
296             .that(violations)
297             .isEmpty();
298     }
299 
300     /**
301      * Tests that every documented property is configured by at least one example.
302      *
303      * @throws IOException if an I/O error occurs
304      */
305     @Test
306     public void testEveryPropertyHasAnExample() throws IOException {
307         final List<String> violations = Collections.synchronizedList(new ArrayList<>());
308 
309         try (Stream<Path> pathStream = Files.walk(XDOCS_ROOT)) {
310             pathStream
311                 .filter(Files::isDirectory)
312                 .filter(XdocsExamplesAstConsistencyTest::isModuleDirectory)
313                 .parallel()
314                 .forEach(dir -> processDirectoryForPropertyCoverage(dir, violations));
315         }
316 
317         final String message = formatPropertyCoverageViolationsMessage(violations);
318 
319         assertWithMessage(message)
320             .that(violations)
321             .isEmpty();
322     }
323 
324     @Test
325     public void testEveryModuleHasDefaultConfigExample() throws IOException {
326         final List<String> violations = Collections.synchronizedList(new ArrayList<>());
327 
328         try (Stream<Path> pathStream = Files.walk(XDOCS_ROOT)) {
329             pathStream
330                     .filter(Files::isDirectory)
331                     .filter(XdocsExamplesAstConsistencyTest::isModuleDirectory)
332                     .parallel()
333                     .forEach(dir -> processDirectoryForDefaultConfigCheck(dir, violations));
334         }
335 
336         final String message = formatDefaultConfigViolationsMessage(violations);
337 
338         assertWithMessage(message)
339                 .that(violations)
340                 .isEmpty();
341     }
342 
343     /**
344      * Processes directory to check for default config example.
345      *
346      * @param dir the directory to check
347      * @param violations a thread-safe list to collect any discovered violations
348      */
349     private static void processDirectoryForDefaultConfigCheck(Path dir, List<String> violations) {
350         try {
351             final List<Path> examples = new ArrayList<>(getExamplePropertyCoverageFiles(dir));
352             examples.addAll(getNonCompilableExamplePropertyCoverageFiles(dir));
353 
354             final String moduleName = toModuleClassSimpleName(dir.getFileName().toString());
355             final String relativePath = getRelativePath(dir);
356 
357             if (moduleName != null && !examples.isEmpty() && !isModuleWithNoProperties(examples)
358                     && !EXAMPLE_DEFAULT_CONFIG_SUPPRESSED_MODULES.contains(relativePath)) {
359                 final String xmlModuleName = stripCheckSuffix(moduleName);
360                 boolean hasDefaultConfig = false;
361 
362                 for (Path example : examples) {
363                     if (hasExampleDefaultConfig(example, xmlModuleName)) {
364                         hasDefaultConfig = true;
365                         break;
366                     }
367                 }
368 
369                 if (!hasDefaultConfig) {
370                     violations.add("Directory: " + relativePath
371                             + "\nNo example uses the default configuration "
372                             + "(module element with zero configured properties).");
373                 }
374             }
375         }
376         catch (IOException | ParserConfigurationException | SAXException exception) {
377             throw new IllegalStateException("Failed processing directory: " + dir, exception);
378         }
379     }
380 
381     /**
382      * Checks if example demonstrates module's default configuration.
383      *
384      * @param example the example file to check
385      * @param xmlModuleName the module's simple name as it appears in embedded XML
386      * @return true if the example's config block has a module element with no properties
387      * @throws IOException if an I/O error occurs
388      * @throws ParserConfigurationException if a document builder cannot be created
389      * @throws SAXException if the XML content is malformed
390      */
391     private static boolean hasExampleDefaultConfig(Path example, String xmlModuleName)
392             throws IOException, ParserConfigurationException, SAXException {
393         final String xmlBlock = extractXmlConfigBlock(example);
394         final Element moduleElement;
395         if (xmlBlock == null) {
396             moduleElement = null;
397         }
398         else {
399             moduleElement = parseConfigModuleElement(xmlBlock, xmlModuleName);
400         }
401         return moduleElement != null && collectPropertyNames(moduleElement).isEmpty();
402     }
403 
404     /**
405      * Formats default-config violations into a readable error message.
406      *
407      * @param violations the list of violation strings
408      * @return a formatted string detailing all found gaps
409      */
410     private static String formatDefaultConfigViolationsMessage(List<String> violations) {
411         final StringBuilder builder = new StringBuilder(1024);
412         if (!violations.isEmpty()) {
413             builder.append(String.format(Locale.ROOT,
414                     "Found %d modules with no example demonstrating the default"
415                             + "configuration.%n%n",
416                     violations.size()));
417 
418             violations.stream()
419                     .sorted()
420                     .forEach(violation -> builder.append(violation).append("\n\n"));
421         }
422         return builder.toString();
423     }
424 
425     /**
426      * Collects files from {@code dir} and its subdirectories.
427      *
428      * @param dir the directory to search
429      * @param fileFilter predicate selecting which regular files to collect
430      * @return the collected files, in no particular order
431      * @throws IOException if an I/O error occurs
432      */
433     private static List<Path> collectFilesWithinModule(Path dir,
434                    Predicate<Path> fileFilter) throws IOException {
435         final List<Path> result = new ArrayList<>();
436 
437         try (Stream<Path> pathStream = Files.list(dir)) {
438             for (Path entry : pathStream.toList()) {
439                 if (Files.isDirectory(entry)) {
440                     if (!isModuleDirectory(entry)) {
441                         result.addAll(collectFilesWithinModule(entry, fileFilter));
442                     }
443                 }
444                 else if (fileFilter.test(entry)) {
445                     result.add(entry);
446                 }
447             }
448         }
449 
450         return result;
451     }
452 
453     /**
454      * Checks if directory resolves to a checkstyle module.
455      *
456      * @param dir the directory to check
457      * @return true if the directory name resolves to a known module
458      */
459     public static boolean isModuleDirectory(Path dir) {
460         return toModuleClassSimpleName(dir.getFileName().toString()) != null;
461     }
462 
463     private static void processDirectoryForPropertyCoverage(Path dir, List<String> violations) {
464         try {
465             final List<Path> examples = new ArrayList<>(getExamplePropertyCoverageFiles(dir));
466             examples.addAll(getNonCompilableExamplePropertyCoverageFiles(dir));
467 
468             if (examples.size() > 1) {
469                 final String violation = checkPropertyCoverage(dir, examples);
470                 if (violation != null) {
471                     violations.add(violation);
472                 }
473             }
474         }
475         catch (IOException exception) {
476             throw new IllegalStateException("Failed processing directory: " + dir, exception);
477         }
478     }
479 
480     /**
481      * Gets Example* files with embedded XML config from non-compilable directory.
482      *
483      * @param dir the compilable xdocs directory
484      * @return list of example file paths from the non-compilable sibling directory
485      * @throws IOException if an I/O error occurs
486      */
487     public static List<Path> getNonCompilableExamplePropertyCoverageFiles(Path dir)
488             throws IOException {
489         final String relativePath = getRelativePath(dir);
490         final Path nonCompilableDir = XDOCS_NONCOMPILABLE_ROOT.resolve(relativePath);
491 
492         List<Path> examples = List.of();
493         if (Files.isDirectory(nonCompilableDir)) {
494             examples = getExamplePropertyCoverageFiles(nonCompilableDir);
495         }
496         return examples;
497     }
498 
499     /**
500      * Formats property-coverage violations into a readable error message.
501      *
502      * @param violations the list of violation strings
503      * @return a formatted string detailing all found gaps
504      */
505     private static String formatPropertyCoverageViolationsMessage(List<String> violations) {
506         final StringBuilder builder = new StringBuilder(1024);
507         if (!violations.isEmpty()) {
508             builder.append("Found ").append(violations.size())
509                 .append(" module(s) with a documented property not covered by any example.\n\n");
510 
511             violations.stream()
512                 .sorted()
513                 .forEach(violation -> builder.append(violation).append("\n\n"));
514 
515             builder.append("If intentional add to EXAMPLE_PROPERTY_COVERAGE_SUPPRESSED_MODULES.\n");
516         }
517         return builder.toString();
518     }
519 
520     /**
521      * Checks module directory: compares configured vs documented properties.
522      *
523      * @param dir the directory to check
524      * @param examples the list of pre-fetched example files
525      * @return a violation message, or null if every property is covered / not applicable
526      * @throws IOException if an I/O error occurs
527      */
528     private static String checkPropertyCoverage(Path dir, List<Path> examples)
529             throws IOException {
530         String result = null;
531 
532         if (!isModuleWithNoProperties(examples)) {
533 
534             final String moduleName = toModuleClassSimpleName(dir.getFileName().toString());
535 
536             if (moduleName != null) {
537                 final Set<String> documentedProperties = resolveDocumentedPropertyNames(dir);
538 
539                 if (!documentedProperties.isEmpty()) {
540                     final String xmlModuleName = stripCheckSuffix(moduleName);
541 
542                     final Set<String> configuredProperties = new HashSet<>();
543                     for (Path example : examples) {
544                         configuredProperties.addAll(
545                                 extractConfiguredPropertyNames(example,
546                                         xmlModuleName));
547                     }
548 
549                     final Set<String> uncoveredProperties = new HashSet<>(documentedProperties);
550                     uncoveredProperties.removeAll(configuredProperties);
551                     uncoveredProperties.removeAll(IGNORED_PROPERTIES_FOR_COVERAGE);
552 
553                     if (!uncoveredProperties.isEmpty()) {
554                         final String relativePath = getRelativePath(dir);
555                         result = "Directory: " + relativePath
556                                 + "\nDocumented properties: " + documentedProperties
557                                 + "\nProperties with no covering example: " + uncoveredProperties;
558                     }
559                 }
560             }
561         }
562 
563         return result;
564     }
565 
566     /**
567      * Resolves full set of documented property names for the module.
568      *
569      * @param dir the example directory
570      * @return the set of documented property names, or an empty set
571      */
572     private static Set<String> resolveDocumentedPropertyNames(Path dir) {
573         final String moduleName = toModuleClassSimpleName(dir.getFileName().toString());
574         Set<String> result = Set.of();
575 
576         if (moduleName != null) {
577             result = XdocUtil.getDocumentedProperties(moduleName);
578         }
579 
580         return result;
581     }
582 
583     /**
584      * Extracts property names configured for {@code moduleName} in example's XML.
585      *
586      * @param example the example file
587      * @param moduleName the module's simple name as it appears in the embedded XML
588      * @return the set of property names configured for that module in this example
589      * @throws IOException if reading the file fails
590      */
591     private static Set<String> extractConfiguredPropertyNames(Path example, String moduleName)
592             throws IOException {
593         Set<String> result = Set.of();
594         final String xmlBlock = extractXmlConfigBlock(example);
595 
596         if (xmlBlock != null) {
597             try {
598                 final Element moduleElement = parseConfigModuleElement(xmlBlock, moduleName);
599                 if (moduleElement != null) {
600                     result = collectPropertyNames(moduleElement);
601                 }
602             }
603             catch (ParserConfigurationException | SAXException exception) {
604                 throw new IllegalStateException(
605                     "Failed to parse example config XML: " + example, exception);
606             }
607         }
608 
609         return result;
610     }
611 
612     /**
613      * Parses XML config fragment and finds the {@code <module>} element.
614      *
615      * @param xmlBlock the raw XML content, rooted at {@code <module name="Checker">}
616      * @param moduleName the module simple name to find
617      * @return the matching module {@link Element}, or null if not found
618      * @throws ParserConfigurationException if a document builder cannot be created
619      * @throws SAXException if the XML content is malformed
620      */
621     public static Element parseConfigModuleElement(String xmlBlock, String moduleName)
622             throws ParserConfigurationException, SAXException {
623         final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
624         factory.setValidating(false);
625         factory.setNamespaceAware(false);
626         factory.setFeature(
627             "http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
628         factory.setFeature(
629             "http://xml.org/sax/features/external-general-entities", false);
630         factory.setFeature(
631             "http://xml.org/sax/features/external-parameter-entities", false);
632 
633         final DocumentBuilder builder = factory.newDocumentBuilder();
634         final Document document;
635         try {
636             document = builder.parse(
637                 new ByteArrayInputStream(xmlBlock.getBytes(StandardCharsets.UTF_8)));
638         }
639         catch (IOException exception) {
640             throw new IllegalStateException("Failed to parse in-memory XML block", exception);
641         }
642 
643         return findModuleElement(document.getDocumentElement(), moduleName);
644     }
645 
646     /**
647      * Recursively searches XML {@link Element} tree for {@code <module>} element.
648      *
649      * @param element the element to search from
650      * @param moduleName the module simple name to find
651      * @return the matching element, or null if not found
652      */
653     private static Element findModuleElement(Element element, String moduleName) {
654         Element result = null;
655 
656         if (moduleName.equals(element.getAttribute("name"))) {
657             result = element;
658         }
659         else {
660             final NodeList children = element.getChildNodes();
661             for (int index = 0; result == null && index < children.getLength(); index++) {
662                 final Node node = children.item(index);
663                 if (node instanceof Element childElement
664                     && "module".equals(node.getNodeName())) {
665                     result = findModuleElement(childElement, moduleName);
666                 }
667             }
668         }
669 
670         return result;
671     }
672 
673     /**
674      * Collects {@code name} attribute of every direct {@code <property>} child.
675      *
676      * @param moduleElement the module element to read properties from
677      * @return the set of configured property names
678      */
679     public static Set<String> collectPropertyNames(Element moduleElement) {
680         final Set<String> names = new HashSet<>();
681         final NodeList children = moduleElement.getChildNodes();
682 
683         for (int index = 0; index < children.getLength(); index++) {
684             final Node node = children.item(index);
685             if (node instanceof Element childElement
686                 && "property".equals(node.getNodeName())) {
687                 names.add(childElement.getAttribute("name"));
688             }
689         }
690 
691         return names;
692     }
693 
694     /**
695      * Extracts embedded XML configuration block from example file.
696      *
697      * @param file the example file to read
698      * @return the XML content between the markers, or null if no such block is present
699      * @throws IOException if an I/O error occurs
700      */
701     public static String extractXmlConfigBlock(Path file) throws IOException {
702         final String content = Files.readString(file);
703         String result = null;
704 
705         final int startMarker = content.indexOf("/*xml");
706         if (startMarker >= 0) {
707             final int contentStart = startMarker + "/*xml".length();
708             final int endMarker = content.indexOf("*/", contentStart);
709             if (endMarker >= 0) {
710                 result = content.substring(contentStart, endMarker).strip();
711             }
712         }
713 
714         return result;
715     }
716 
717     /**
718      * Processes a directory to identify example-count-vs-property-count violations.
719      */
720     private static void processDirectory(Path dir, List<String> violations) {
721         try {
722             final List<Path> examples = getExampleFiles(dir);
723             if (examples.size() > 1) {
724                 final String violation = checkExampleCount(dir, examples);
725                 if (violation != null) {
726                     violations.add(violation);
727                 }
728             }
729         }
730         catch (IOException exception) {
731             throw new IllegalStateException("Failed processing directory: " + dir, exception);
732         }
733     }
734 
735     /**
736      * Formats violations into a readable error message.
737      *
738      * @param violations the list of violation strings
739      * @return a formatted string detailing all found inconsistencies
740      */
741     private static String formatViolationsMessage(List<String> violations) {
742         final StringBuilder builder = new StringBuilder(1024);
743         if (!violations.isEmpty()) {
744             builder.append("Found ").append(violations.size())
745                 .append(" module(s) whose example count does not match property count + 1.\n\n");
746 
747             violations.stream()
748                 .sorted()
749                 .forEach(violation -> builder.append(violation).append("\n\n"));
750         }
751         return builder.toString();
752     }
753 
754     /**
755      * Checks module directory: compares documented property count vs AST-matching examples.
756      *
757      * @param dir the directory to check
758      * @param examples the list of pre-fetched example files
759      * @return a violation message, or null if consistent / not applicable
760      * @throws IOException if an I/O error occurs
761      */
762     private static String checkExampleCount(Path dir, List<Path> examples) throws IOException {
763         String result = null;
764 
765         if (!isModuleWithNoProperties(examples)) {
766             final String relativePath = getRelativePath(dir);
767             final String moduleName = toModuleClassSimpleName(dir.getFileName().toString());
768             final String xmlModuleName;
769             if (moduleName == null) {
770                 xmlModuleName = null;
771             }
772             else {
773                 xmlModuleName = stripCheckSuffix(moduleName);
774             }
775 
776             final List<Path> regularExamples = examples.stream()
777                     .filter(example -> {
778                         return !isExampleIndependent(
779                                 relativePath, example.getFileName().toString());
780                     })
781                     .filter(example -> {
782                         return xmlModuleName == null
783                                 || !hasConfiguresOnlyIgnoredProperties(example, xmlModuleName);
784                     })
785                     .toList();
786 
787             result = validateExampleCount(dir, relativePath, regularExamples);
788         }
789         return result;
790     }
791 
792     /**
793      * Validates example count against property count.
794      *
795      * @param dir the directory to check
796      * @param relativePath the relative path of the module
797      * @param regularExamples the list of regular example files
798      * @return a violation message, or null if consistent
799      * @throws IOException if an I/O error occurs
800      */
801     private static String validateExampleCount(Path dir, String relativePath,
802                                                 List<Path> regularExamples) throws IOException {
803         String result = null;
804         final int propertyCount = resolvePropertyCount(dir);
805 
806         if (propertyCount >= 0 && regularExamples.size() > 1
807                 && !EXAMPLE_COUNT_SUPPRESSED_MODULES.contains(relativePath)) {
808             final List<Path> parseableExamples = new ArrayList<>();
809             for (Path example : regularExamples) {
810                 if (isActuallyParseable(example)) {
811                     parseableExamples.add(example);
812                 }
813             }
814 
815             if (parseableExamples.isEmpty()) {
816                 result = checkPropertyCoverageFallback(dir, relativePath,
817                         regularExamples, propertyCount);
818             }
819             else {
820                 final int largestAstGroupSize =
821                         findLargestAstMatchingGroupSize(parseableExamples);
822                 final int expected = propertyCount + 1;
823 
824                 if (largestAstGroupSize != expected) {
825                     result = "Directory: " + relativePath
826                             + "\nProperties: " + propertyCount
827                             + "\nExpected AST-matching examples: " + expected
828                             + "\nActual largest AST-matching group: " + largestAstGroupSize
829                             + " (of " + parseableExamples.size() + " total example files)";
830                 }
831             }
832         }
833         return result;
834     }
835 
836     /**
837      * Checks if an example's config only contains ignored properties.
838      * Such examples are default-config and shouldn't count as demonstrating real properties.
839      *
840      * @param example the example file
841      * @param xmlModuleName the module's simple name as it appears in the embedded XML
842      * @return true if the example's only configured properties (if any) are ignored ones
843      */
844     private static boolean hasConfiguresOnlyIgnoredProperties(Path example, String xmlModuleName) {
845         final boolean result;
846         try {
847             final Set<String> configured = new HashSet<>(
848                     extractConfiguredPropertyNames(example, xmlModuleName));
849             result = !configured.isEmpty()
850                     && IGNORED_PROPERTIES_FOR_COVERAGE.containsAll(configured);
851         }
852         catch (IOException exception) {
853             throw new IllegalStateException("Failed to read example: " + example, exception);
854         }
855         return result;
856     }
857 
858     /**
859      * Checks whether an example's xdoc section actually parses as valid Java.
860      *
861      * @param example the example file path
862      * @return true if the xdoc section parses successfully as Java
863      * @throws IOException if an I/O error occurs
864      */
865     private static boolean isActuallyParseable(Path example) throws IOException {
866         final String xdocSection = extractXdocSection(example);
867         boolean result;
868         try {
869             result = parseContent(xdocSection) != null;
870         }
871         catch (CheckstyleException exception) {
872             result = false;
873         }
874         return result;
875     }
876 
877     /**
878      * Fallback validation for pseudo-path modules whose examples cannot be parsed.
879      *
880      * @param dir the directory being checked
881      * @param relativePath the directory's relative path, for messaging
882      * @param examples the regular (non-suppressed) examples in this directory
883      * @param propertyCount the documented property count, for context in the message
884      * @return a violation message, or null if every property is covered
885      * @throws IOException if an I/O error occurs
886      */
887     private static String checkPropertyCoverageFallback(Path dir, String relativePath,
888                     List<Path> examples, int propertyCount) throws IOException {
889         String result = null;
890 
891         final Set<String> documentedProperties = resolveDocumentedPropertyNames(dir);
892         if (!documentedProperties.isEmpty()) {
893             final String moduleName = toModuleClassSimpleName(dir.getFileName().toString());
894             final String xmlModuleName = stripCheckSuffix(moduleName);
895 
896             final Set<String> configuredProperties = new HashSet<>();
897             for (Path example : examples) {
898                 configuredProperties.addAll(
899                         extractConfiguredPropertyNames(example, xmlModuleName));
900             }
901 
902             final Set<String> uncoveredProperties = new HashSet<>(documentedProperties);
903             uncoveredProperties.removeAll(configuredProperties);
904             uncoveredProperties.removeAll(IGNORED_PROPERTIES_FOR_COVERAGE);
905 
906             if (uncoveredProperties.isEmpty()) {
907                 final int expected = propertyCount + 1;
908                 if (examples.size() < expected) {
909                     result = "Directory: " + relativePath
910                             + "\nProperties: " + propertyCount
911                             + "\nExpected examples (at least, including a baseline): " + expected
912                             + "\nActual example count: " + examples.size();
913                 }
914             }
915             else {
916                 result = "Directory: " + relativePath
917                         + "\nProperties: " + propertyCount
918                         + "\nProperties with no covering example (pseudo-path format): "
919                         + uncoveredProperties;
920             }
921         }
922 
923         return result;
924     }
925 
926     /**
927      * Groups examples by structural AST equality and returns largest group size.
928      * Examples are pre-split by constructor presence.
929      *
930      * @param examples candidate example files (already filtered for suppression)
931      * @return size of the largest AST-identical group, or 0 if none parse
932      * @throws IOException if reading a file fails
933      */
934     private static int findLargestAstMatchingGroupSize(List<Path> examples) throws IOException {
935         final List<Path> ctorExamples = new ArrayList<>();
936         final List<Path> nonCtorExamples = new ArrayList<>();
937 
938         for (Path example : examples) {
939             if (containsConstructorDefinition(example)) {
940                 ctorExamples.add(example);
941             }
942             else {
943                 nonCtorExamples.add(example);
944             }
945         }
946 
947         return Math.max(
948             largestGroupWithinSubset(nonCtorExamples),
949             largestGroupWithinSubset(ctorExamples)
950         );
951     }
952 
953     /**
954      * Finds size of largest group of structurally-identical ASTs within example subset.
955      *
956      * @param examples the subset of examples to group
957      * @return size of the largest AST-identical group, or 0 if none parse
958      * @throws IOException if reading a file fails
959      */
960     private static int largestGroupWithinSubset(List<Path> examples) throws IOException {
961         final List<StructuralAstNode> asts = new ArrayList<>();
962 
963         for (Path example : examples) {
964             try {
965                 final String xdocSection = extractXdocSection(example);
966                 final DetailAST detailAst = parseContent(xdocSection);
967                 if (detailAst != null) {
968                     asts.add(toStructuralAst(detailAst));
969                 }
970             }
971             catch (CheckstyleException exception) {
972                 // unparseable excluded from grouping, handled by UNPARSEABLE_EXAMPLES elsewhere
973             }
974         }
975 
976         int best = 0;
977         for (StructuralAstNode candidate : asts) {
978             int count = 0;
979             for (StructuralAstNode other : asts) {
980                 if (candidate.equals(other)) {
981                     count++;
982                 }
983             }
984             best = Math.max(best, count);
985         }
986         return best;
987     }
988 
989     /**
990      * Checks if examples in this directory define any module properties.
991      *
992      * @param examples the list of example files in the directory
993      * @return true if no example file contains a {@code <property} element in its XML config
994      * @throws IOException if an I/O error occurs reading an example file
995      */
996     public static boolean isModuleWithNoProperties(List<Path> examples) throws IOException {
997         boolean noProperties = true;
998 
999         for (Path example : examples) {
1000             final String content = Files.readString(example);
1001 
1002             if (content.contains("<property ")) {
1003                 noProperties = false;
1004                 break;
1005             }
1006         }
1007 
1008         return noProperties;
1009     }
1010 
1011     /**
1012      * Retrieves documented property count for a module using cache to optimize performance.
1013      *
1014      * @param dir the directory path associated with the module
1015      * @return the number of properties, or -1 if the module cannot be resolved
1016      */
1017     private static int resolvePropertyCount(Path dir) {
1018         final String moduleName = toModuleClassSimpleName(dir.getFileName().toString());
1019         int result = -1;
1020 
1021         if (moduleName != null) {
1022             result = PROPERTY_COUNT_CACHE.computeIfAbsent(moduleName,
1023                 XdocsExamplesAstConsistencyTest::loadPropertyCount);
1024         }
1025 
1026         return result;
1027     }
1028 
1029     /**
1030      * Helper method to load property count via reflection.
1031      *
1032      * @param moduleName the simple class name of the check
1033      * @return the property count, or -1 on failure
1034      */
1035     private static int loadPropertyCount(String moduleName) {
1036         final Set<String> properties = new HashSet<>(
1037                 XdocUtil.getDocumentedProperties(moduleName));
1038         properties.removeAll(IGNORED_PROPERTIES_FOR_COVERAGE);
1039         return properties.size();
1040     }
1041 
1042     /**
1043      * Converts a lower-cased directory name (e.g. {@code declarationorder}) into
1044      * the check's simple class name (e.g. {@code DeclarationOrderCheck}) as expected by
1045      * the generated documentation, using an index built once from all known Checkstyle
1046      * module classes.
1047      *
1048      * @param dirName the last path segment of the example directory
1049      * @return the resolved module simple name, or null if no matching module class was found
1050      */
1051     public static String toModuleClassSimpleName(String dirName) {
1052         return MODULE_SIMPLE_NAME_CACHE.get(dirName.toLowerCase(Locale.ROOT));
1053     }
1054 
1055     /**
1056      * Builds a one-time index mapping lower-cased simple class name stem
1057      * (i.e. class simple name with any trailing {@code Check} removed, lower-cased)
1058      * to the actual module simple class name.
1059      * Built once to avoid repeating an expensive classpath scan per directory.
1060      *
1061      * @return the populated index
1062      */
1063     private static ConcurrentMap<String, String> buildModuleSimpleNameIndex() {
1064         final ConcurrentMap<String, String> index = new ConcurrentHashMap<>();
1065 
1066         try {
1067             for (Class<?> moduleClass : CheckUtil.getCheckstyleModules()) {
1068                 final String simpleName = moduleClass.getSimpleName();
1069                 final String stem = stripCheckSuffix(simpleName);
1070                 index.putIfAbsent(stem.toLowerCase(Locale.ROOT), simpleName);
1071             }
1072         }
1073         catch (IOException exception) {
1074             throw new IllegalStateException("Failed to build module simple name index",
1075                 exception);
1076         }
1077 
1078         return index;
1079     }
1080 
1081     /**
1082      * Removes trailing {@code Check} suffix from class simple name, if present.
1083      *
1084      * @param simpleName the class simple name
1085      * @return the name with any trailing {@code Check} removed
1086      */
1087     public static String stripCheckSuffix(String simpleName) {
1088         String result = simpleName;
1089         if (simpleName.endsWith("Check")) {
1090             result = simpleName.substring(0, simpleName.length() - "Check".length());
1091         }
1092         return result;
1093     }
1094 
1095     /**
1096      * Formats violation message for block comment markers.
1097      *
1098      * @param violations the list of violations
1099      * @return formatted message
1100      */
1101     private static String formatBlockCommentMarkerViolationsMessage(List<String> violations) {
1102         final StringBuilder builder = new StringBuilder(1024);
1103         builder.append("Found ")
1104                 .append(violations.size())
1105                 .append(
1106                         """
1107                          example file(s) using block comments as ok/violation markers.
1108                         Convert them to single-line comments, e.g.:
1109                           BAD:  /* ok, allowMissingReturnTag is true */
1110                           GOOD: // ok, allowMissingReturnTag is true
1111 
1112                         """);
1113 
1114         for (String violation : violations) {
1115             builder.append(violation).append('\n');
1116         }
1117 
1118         return builder.toString();
1119     }
1120 
1121     /**
1122      * Checks example file for block comments used as ok/violation markers.
1123      *
1124      * @param file the example file to check
1125      * @return the list of violation messages
1126      * @throws IOException if an I/O error occurs
1127      */
1128     private static List<String> checkForBlockCommentMarkers(Path file)
1129             throws IOException {
1130         final List<String> fileViolations = new ArrayList<>();
1131         final String content = Files.readString(file);
1132         final Matcher matcher = BLOCK_COMMENT_PATTERN.matcher(content);
1133 
1134         while (matcher.find()) {
1135             final String block = matcher.group();
1136             final String inner = block
1137                     .replaceAll("^/\\*+", "")
1138                     .replaceAll("\\*/$", "")
1139                     .replace("*", "")
1140                     .strip();
1141 
1142             if (inner.startsWith("ok") || inner.startsWith("violation")) {
1143                 int lineNo = 1;
1144                 for (int index = 0; index < matcher.start(); index++) {
1145                     if (content.charAt(index) == '\n') {
1146                         lineNo++;
1147                     }
1148                 }
1149                 fileViolations.add(file + ":" + lineNo
1150                         + " - use single-line comment instead: // "
1151                         + inner);
1152             }
1153         }
1154         return fileViolations;
1155     }
1156 
1157     /**
1158      * Checks if directory contains multiple example files, including any
1159      * contained in its own non-module subdirectories.
1160      *
1161      * @param dir the directory to check
1162      * @return true if the directory (recursively, stopping at nested module
1163      *         boundaries) contains 2 or more Example*.java files
1164      */
1165     private static boolean containsMultipleExamples(Path dir) {
1166         try {
1167             return collectFilesWithinModule(dir,
1168                     path -> path.getFileName().toString().matches("Example\\d+\\.java"))
1169                     .size() > 1;
1170         }
1171         catch (IOException exception) {
1172             throw new IllegalStateException("Failed to list files in directory: " + dir,
1173                 exception);
1174         }
1175     }
1176 
1177     /**
1178      * Checks examples in a directory. Non-independent examples must match.
1179      *
1180      * @param dir the directory containing example files
1181      * @return list of violation messages for mismatches
1182      * @throws IOException if an I/O error occurs
1183      */
1184     private static List<Violation> checkExamplesInDirectory(Path dir) throws IOException {
1185         final List<Violation> violations = new ArrayList<>();
1186         final List<Path> examples = getExampleFiles(dir);
1187 
1188         if (!examples.isEmpty()) {
1189             violations.addAll(compareExamples(dir, examples));
1190         }
1191 
1192         return violations;
1193     }
1194 
1195     /**
1196      * Gets all Example*.java files from a directory, including those in its own
1197      * subdirectories. The walk stops at nested module directory boundaries via
1198      * {@link #collectFilesWithinModule}, so it never crosses into sibling modules.
1199      *
1200      * @param dir the module directory to search
1201      * @return list of example file paths
1202      * @throws IOException if an I/O error occurs
1203      */
1204     private static List<Path> getExampleFiles(Path dir) throws IOException {
1205         final List<Path> examples = collectFilesWithinModule(dir,
1206                 path -> path.getFileName().toString().matches("Example\\d+\\.java"));
1207         return examples.stream()
1208                 .sorted(Comparator.comparing(Path::toString))
1209                 .toList();
1210     }
1211 
1212     /**
1213      * Gets all Example* files from a directory (and its own subdirectories) that
1214      * contain an embedded {@code /*xml ... *}{@code /} config block, regardless
1215      * of file extension. Used only for property-coverage checking
1216      * ({@link #testEveryPropertyHasAnExample}), which inspects the embedded block
1217      * rather than parsing the file as Java. Files matching the {@code Example<N>}
1218      * pattern without a config block are excluded.
1219      *
1220      * @param dir the module directory to search
1221      * @return list of example file paths containing an XML config block
1222      * @throws IOException if an I/O error occurs
1223      */
1224     public static List<Path> getExamplePropertyCoverageFiles(Path dir) throws IOException {
1225         final List<Path> examples = collectFilesWithinModule(dir, path -> {
1226             return path.getFileName().toString().matches("Example\\d+(\\..+)?")
1227                     && hasXmlConfigBlock(path);
1228         });
1229         return examples.stream()
1230                 .sorted(Comparator.comparing(Path::toString))
1231                 .toList();
1232     }
1233 
1234     /**
1235      * Checks whether a file contains an embedded {@code /*xml ... *}{@code /}
1236      * configuration block.
1237      *
1238      * @param file the file to check
1239      * @return true if an XML config block is present
1240      */
1241     private static boolean hasXmlConfigBlock(Path file) {
1242         final boolean result;
1243         try {
1244             result = extractXmlConfigBlock(file) != null;
1245         }
1246         catch (IOException exception) {
1247             throw new IllegalStateException("Failed to read file: " + file, exception);
1248         }
1249         return result;
1250     }
1251 
1252     /**
1253      * Checks if a specific example is marked as unparseable.
1254      *
1255      * @param relativePath the relative directory path
1256      * @param exampleFileName the example filename (e.g., "Example1.java")
1257      * @return true if this example cannot be parsed
1258      */
1259     private static boolean isExampleUnparseable(String relativePath, String exampleFileName) {
1260         final String exampleName = exampleFileName.replace(".java", "");
1261         final String fullPath = relativePath + "/" + exampleName;
1262         return UNPARSEABLE_EXAMPLES.contains(fullPath);
1263     }
1264 
1265     /**
1266      * Checks if a specific example is marked as independent.
1267      *
1268      * @param relativePath the relative directory path
1269      * @param exampleFileName the example filename (e.g., "Example1.java")
1270      * @return true if this example is independent
1271      */
1272     private static boolean isExampleIndependent(String relativePath, String exampleFileName) {
1273         final String exampleName = exampleFileName.replace(".java", "");
1274         final String fullPath = relativePath + "/" + exampleName;
1275         return SUPPRESSED_EXAMPLES.contains(fullPath);
1276     }
1277 
1278     /**
1279      * Gets the relative path from the common base path.
1280      *
1281      * @param dir the directory path
1282      * @return the relative path string
1283      */
1284     private static String getRelativePath(Path dir) {
1285         return XDOCS_ROOT.relativize(dir).toString().replace('\\', '/');
1286     }
1287 
1288     /**
1289      * Compares examples: groups by AST, validates groups, reports mismatches.
1290      *
1291      * @param dir the directory containing the examples
1292      * @param examples the list of example files
1293      * @return list of violation messages for mismatches
1294      */
1295     private static List<Violation> compareExamples(Path dir, List<Path> examples)
1296             throws IOException {
1297         final List<Violation> violations = new ArrayList<>();
1298 
1299         if (!isModuleWithNoProperties(examples)) {
1300             final String relativePath = getRelativePath(dir);
1301 
1302             final List<Path> regularExamples = new ArrayList<>();
1303 
1304             for (Path example : examples) {
1305                 final String fileName = example.getFileName().toString();
1306                 if (!isExampleIndependent(relativePath, fileName)) {
1307                     regularExamples.add(example);
1308                 }
1309             }
1310 
1311             if (regularExamples.size() > 1) {
1312                 violations.addAll(validateExamplesByConstructorPresence(dir, regularExamples));
1313             }
1314         }
1315 
1316         return violations;
1317     }
1318 
1319     /**
1320      * Validates examples by comparing files with and without constructors separately.
1321      *
1322      * @param dir the directory containing examples
1323      * @param examples the list of examples that must be validated
1324      * @return list of violation messages for mismatches
1325      * @throws IOException if an I/O error occurs
1326      */
1327     private static List<Violation> validateExamplesByConstructorPresence(Path dir,
1328                                                                          List<Path> examples)
1329             throws IOException {
1330         final List<Violation> violations = new ArrayList<>();
1331         final List<Path> constructorExamples = new ArrayList<>();
1332         final List<Path> nonConstructorExamples = new ArrayList<>();
1333 
1334         for (Path example : examples) {
1335             if (containsConstructorDefinition(example)) {
1336                 constructorExamples.add(example);
1337             }
1338             else {
1339                 nonConstructorExamples.add(example);
1340             }
1341         }
1342 
1343         if (nonConstructorExamples.size() > 1) {
1344             violations.addAll(validateAllMatch(dir, nonConstructorExamples));
1345         }
1346         if (constructorExamples.size() > 1) {
1347             violations.addAll(validateAllMatch(dir, constructorExamples));
1348         }
1349 
1350         return violations;
1351     }
1352 
1353     /**
1354      * Checks whether an example contains at least one constructor definition.
1355      *
1356      * @param example the example file path
1357      * @return true if the parsed xdoc section contains a constructor definition
1358      * @throws IOException if an I/O error occurs
1359      */
1360     private static boolean containsConstructorDefinition(Path example) throws IOException {
1361         final String xdocSection = extractXdocSection(example);
1362         boolean result;
1363         try {
1364             final DetailAST ast = parseContent(xdocSection);
1365             result = ast != null && hasDescendantOfType(ast, TokenTypes.CTOR_DEF);
1366         }
1367         catch (CheckstyleException exception) {
1368             result = false;
1369         }
1370 
1371         return result;
1372     }
1373 
1374     /**
1375      * Checks whether an AST contains a descendant of the given token type.
1376      *
1377      * @param ast the AST root to inspect
1378      * @param tokenType the token type to find
1379      * @return true if a matching node is found
1380      */
1381     private static boolean hasDescendantOfType(DetailAST ast, int tokenType) {
1382         boolean result = false;
1383         if (ast.getType() == tokenType) {
1384             result = true;
1385         }
1386         else {
1387             for (DetailAST child = ast.getFirstChild(); child != null;
1388                  child = child.getNextSibling()) {
1389                 if (hasDescendantOfType(child, tokenType)) {
1390                     result = true;
1391                     break;
1392                 }
1393             }
1394         }
1395 
1396         return result;
1397     }
1398 
1399     /**
1400      * Validates that all examples in the list have identical AST structure,
1401      * including identical line numbers for each node within the xdoc section.
1402      *
1403      * @param dir the directory containing the examples
1404      * @param examples the list of examples that must all match
1405      * @return list of violation messages for mismatches
1406      * @throws IOException if an I/O error occurs
1407      */
1408     private static List<Violation> validateAllMatch(Path dir, List<Path> examples)
1409             throws IOException {
1410         final List<Violation> violations = new ArrayList<>();
1411         final Path reference = examples.getFirst();
1412         final String referenceXdocSection = extractXdocSection(reference);
1413 
1414         try {
1415             final DetailAST referenceDetailAst = parseContent(referenceXdocSection);
1416             if (referenceDetailAst != null) {
1417                 final StructuralAstNode referenceAst = toStructuralAst(referenceDetailAst);
1418                 final List<String> referenceComments =
1419                         extractComments(referenceXdocSection);
1420                 for (int index = 1; index < examples.size(); index++) {
1421                     final Path example = examples.get(index);
1422                     final Violation violation = compareSingleExample(
1423                             dir, example, reference, referenceAst, referenceComments
1424                     );
1425                     if (violation != null) {
1426                         violations.add(violation);
1427                     }
1428                 }
1429             }
1430         }
1431         catch (CheckstyleException exception) {
1432             // Skip examples that can't be parsed as Java
1433         }
1434 
1435         return violations;
1436     }
1437 
1438     /**
1439      * Extracts content between xdoc section markers from a file. The extracted
1440      * lines are re-joined and parsed fresh by {@link #parseContent}, so AST line
1441      * numbers are relative to the start of the section, independent of header
1442      * length above the marker.
1443      *
1444      * @param file the file to read
1445      * @return the content between markers, or entire file if no markers
1446      * @throws IOException if an I/O error occurs
1447      */
1448     private static String extractXdocSection(Path file) throws IOException {
1449         final List<String> lines = Files.readAllLines(file);
1450         int startIndex = -1;
1451         int endIndex = -1;
1452 
1453         for (int index = 0; index < lines.size(); index++) {
1454             final String line = lines.get(index);
1455             if (line.contains(XDOC_START_MARKER)) {
1456                 startIndex = index + 1;
1457             }
1458             else if (line.contains(XDOC_END_MARKER)) {
1459                 endIndex = index;
1460                 break;
1461             }
1462         }
1463 
1464         final String result;
1465         if (startIndex == -1 || endIndex == -1 || startIndex >= endIndex) {
1466             result = String.join("\n", lines);
1467         }
1468         else {
1469             result = String.join("\n", lines.subList(startIndex, endIndex));
1470         }
1471 
1472         return result;
1473     }
1474 
1475     /**
1476      * Parses Java content string into a DetailAST.
1477      *
1478      * @param content the Java code content to parse
1479      * @return the parsed AST, or null if parsing fails
1480      * @throws CheckstyleException if parsing fails
1481      */
1482     private static DetailAST parseContent(String content) throws CheckstyleException {
1483         final FileText text = new FileText(
1484                 new File("Example.java").getAbsoluteFile(),
1485                 content.lines().toList()
1486         );
1487         final FileContents contents = new FileContents(text);
1488         return JavaParser.parse(contents);
1489     }
1490 
1491     /**
1492      * Compares a single example file against the reference.
1493      *
1494      * @param dir          the directory containing the examples
1495      * @param example      the example file to compare
1496      * @param reference    the reference file
1497      * @param referenceAst the reference AST
1498      * @return violation message if mismatch found, null otherwise
1499      * @throws IOException if an I/O error occurs
1500      */
1501     private static Violation compareSingleExample(Path dir, Path example,
1502                                                   Path reference,
1503                                                   StructuralAstNode referenceAst,
1504                                                   List<String> referenceComments)
1505             throws IOException {
1506         final String exampleXdocSection = extractXdocSection(example);
1507         final String relativePath = getRelativePath(dir);
1508         final String exampleFileName = example.getFileName().toString();
1509 
1510         DetailAST exampleDetailAst = null;
1511         try {
1512             exampleDetailAst = parseContent(exampleXdocSection);
1513         }
1514         catch (CheckstyleException exception) {
1515             if (!isExampleUnparseable(relativePath, exampleFileName)) {
1516                 throw new IllegalStateException(
1517                         "Failed to parse example: " + example, exception);
1518             }
1519         }
1520 
1521         final Violation result;
1522         if (exampleDetailAst == null) {
1523             result = null;
1524         }
1525         else {
1526             final StructuralAstNode ast = toStructuralAst(exampleDetailAst);
1527 
1528             final List<String> exampleComments =
1529                     extractComments(exampleXdocSection);
1530 
1531             if (referenceAst.equals(ast) && referenceComments.equals(exampleComments)) {
1532                 result = null;
1533             }
1534             else if (referenceAst.equals(ast)) {
1535                 result = new Violation(relativePath, reference.getFileName().toString(),
1536                         example.getFileName().toString(), "Comments mismatch");
1537             }
1538             else if (referenceAst.equalsIgnoringLineNumbers(ast)) {
1539                 result = new Violation(relativePath, reference.getFileName().toString(),
1540                     example.getFileName().toString(),
1541                     "AST structure mismatch (line numbers differ only - "
1542                         + "check for added/removed blank lines or shifted code)");
1543             }
1544             else {
1545                 result = new Violation(relativePath, reference.getFileName().toString(),
1546                         example.getFileName().toString(), "AST structure mismatch");
1547             }
1548         }
1549 
1550         return result;
1551     }
1552 
1553     /**
1554      * Converts a DetailAST into a structural representation that excludes only
1555      * {@code ok}, {@code violation}, and {@code xdoc section} single-line comments.
1556      *
1557      * @param ast the AST to convert
1558      * @return structural representation of the AST, or null if the node is a
1559      *         skippable comment
1560      */
1561     private static StructuralAstNode toStructuralAst(DetailAST ast) {
1562         final boolean ignoreName = isTypeName(ast)
1563                 || isConstructorName(ast)
1564                 || isExtendsAnExampleClass(ast);
1565         final StructuralAstNode node = new StructuralAstNode(
1566                 ast.getType(), ast.getText(), ignoreName, ast.getLineNo(), ignoreName
1567         );
1568 
1569         for (DetailAST child = ast.getFirstChild();
1570              child != null;
1571              child = child.getNextSibling()) {
1572             final StructuralAstNode structuralChild = toStructuralAst(child);
1573             if (structuralChild != null) {
1574                 node.addChild(structuralChild);
1575             }
1576         }
1577         return node;
1578     }
1579 
1580     /**
1581      * Checks if an AST node is an identifier representing a type name.
1582      *
1583      * @param ast the AST node to check
1584      * @return true if the node is a type name identifier
1585      */
1586     private static boolean isTypeName(DetailAST ast) {
1587         final DetailAST parent = ast.getParent();
1588         return parent != null
1589                 && ast.getType() == TokenTypes.IDENT
1590                 && (parent.getType() == TokenTypes.CLASS_DEF
1591                     || parent.getType() == TokenTypes.INTERFACE_DEF
1592                     || parent.getType() == TokenTypes.ENUM_DEF
1593                     || parent.getType() == TokenTypes.RECORD_DEF
1594                     || parent.getType() == TokenTypes.ANNOTATION_DEF);
1595     }
1596 
1597     /**
1598      * Checks if an AST node is an identifier representing a constructor name.
1599      *
1600      * @param ast the AST node to check
1601      * @return true if the node is a constructor name identifier
1602      */
1603     private static boolean isConstructorName(DetailAST ast) {
1604         final DetailAST parent = ast.getParent();
1605         return parent != null
1606                 && ast.getType() == TokenTypes.IDENT
1607                 && parent.getType() == TokenTypes.CTOR_DEF;
1608     }
1609 
1610     /**
1611      * Checks if an AST node is an identifier in an extends clause of an example class.
1612      *
1613      * @param ast the AST node to check
1614      * @return true if the node is an identifier in an extends clause of an example class
1615      */
1616     private static boolean isExtendsAnExampleClass(DetailAST ast) {
1617         final DetailAST parent = ast.getParent();
1618         boolean result = false;
1619         if (parent != null
1620                 && ast.getType() == TokenTypes.IDENT
1621                 && parent.getType() == TokenTypes.EXTENDS_CLAUSE) {
1622             final DetailAST classDef = parent.getParent();
1623             if (classDef != null && classDef.getType() == TokenTypes.CLASS_DEF) {
1624                 final DetailAST className = classDef.findFirstToken(TokenTypes.IDENT);
1625                 result = className != null
1626                         && className.getText().matches("Example\\d+");
1627             }
1628         }
1629         return result;
1630     }
1631 
1632     /**
1633      * Checks whether a comment is a documentation marker that should be
1634      * excluded from structural comparison. Skipped prefixes include
1635      * {@code ok}, {@code violation} (including {@code filtered violation}),
1636      * {@code xdoc section}, count-style {@code N violation(s)}, and
1637      * single-quoted continuation lines.
1638      *
1639      * @param comment the stripped comment text (everything after {@code //})
1640      * @return true if the comment is a marker that should be ignored
1641      */
1642     private static boolean isIgnoredComment(String comment) {
1643         return comment.startsWith("ok")
1644                 || comment.startsWith("violation")
1645                 || comment.startsWith("filtered violation")
1646                 || comment.startsWith("xdoc section")
1647                 || comment.contains("// ok")
1648                 || comment.contains("// violation")
1649                 || comment.matches("\\d+\\s+violations?.*")
1650                 || comment.matches("'.*'");
1651     }
1652 
1653     /**
1654      * Extracts comments that participate in comparison. Ignored comments are
1655      * {@code ok}, {@code violation} (including count-style {@code N violations}),
1656      * xdoc section markers, and standalone continuation lines immediately
1657      * following a skipped marker. All other comments, including javadoc, are
1658      * included; inline marker comments within javadoc are excluded per
1659      * {@link #isIgnoredComment}.
1660      *
1661      * @param content example content
1662      * @return comments participating in comparison
1663      */
1664     private static List<String> extractComments(String content) {
1665         final List<String> comments = new ArrayList<>();
1666         comments.addAll(extractJavadocComments(content));
1667         comments.addAll(extractSingleLineComments(content));
1668         return comments;
1669     }
1670 
1671     /**
1672      * Extracts javadoc comments, stripping any trailing inline marker comment
1673      * (ok/violation/etc., per {@link #isIgnoredComment}) from each line rather
1674      * than dropping the whole line. Javadocs that become empty after stripping
1675      * are excluded entirely.
1676      *
1677      * @param content example content
1678      * @return filtered javadoc comments, excluding any that become empty
1679      */
1680     private static List<String> extractJavadocComments(String content) {
1681         final List<String> javadocComments = new ArrayList<>();
1682         final Matcher javadocMatcher = Pattern.compile("/\\*\\*[\\s\\S]*?\\*/").matcher(content);
1683 
1684         while (javadocMatcher.find()) {
1685             final String originalJavadoc = javadocMatcher.group().strip();
1686             final StringBuilder filteredJavadoc = new StringBuilder(256);
1687 
1688             for (String line : originalJavadoc.lines().toList()) {
1689                 filteredJavadoc.append(stripInlineMarkers(line)).append('\n');
1690             }
1691 
1692             final String filteredJavadocStr = filteredJavadoc.toString().strip();
1693             if (!filteredJavadocStr.isEmpty()) {
1694                 javadocComments.add(filteredJavadocStr);
1695             }
1696         }
1697 
1698         return javadocComments;
1699     }
1700 
1701     /**
1702      * Removes a trailing inline marker comment (ok/violation/filtered violation/
1703      * N violations/etc., per {@link #isIgnoredComment}) from a single line,
1704      * keeping the code (or whitespace) that precedes it. Lines whose trailing
1705      * {@code //} comment is not a recognized marker are returned unchanged.
1706      *
1707      * @param line the line to strip
1708      * @return the line with any trailing marker comment removed
1709      */
1710     private static String stripInlineMarkers(String line) {
1711         String result = line;
1712         final int commentIndex = findCommentStart(line);
1713 
1714         if (commentIndex >= 0) {
1715             final String comment = line.substring(commentIndex + 2).strip();
1716             if (isIgnoredComment(comment)) {
1717                 result = line.substring(0, commentIndex).stripTrailing();
1718             }
1719         }
1720 
1721         return result;
1722     }
1723 
1724     /**
1725      * Extracts single-line ({@code //}) comments that participate in comparison,
1726      * skipping ok/violation/xdoc-section markers and standalone continuation
1727      * lines that immediately follow such a marker.
1728      *
1729      * @param content example content
1730      * @return single-line comments participating in comparison
1731      */
1732     private static List<String> extractSingleLineComments(String content) {
1733         final List<String> comments = new ArrayList<>();
1734         boolean prevLineWasMarker = false;
1735 
1736         for (String line : content.lines().toList()) {
1737             final int commentIndex = findCommentStart(line);
1738 
1739             if (commentIndex < 0) {
1740                 prevLineWasMarker = false;
1741                 continue;
1742             }
1743 
1744             final String comment = line.substring(commentIndex + 2).strip();
1745             final boolean isCodeBefore = !line.substring(0, commentIndex).isBlank();
1746 
1747             if (isIgnoredComment(comment)) {
1748                 prevLineWasMarker = true;
1749             }
1750             else if (!prevLineWasMarker || isCodeBefore) {
1751                 comments.add(comment);
1752                 prevLineWasMarker = false;
1753             }
1754         }
1755 
1756         return comments;
1757     }
1758 
1759     /**
1760      * Finds the starting index of a single-line comment ({@code //}) in a given line,
1761      * ignoring comments within string literals or character literals.
1762      *
1763      * @param line the string line to search
1764      * @return the index of the first {@code //}, or -1 if not found or within a literal
1765      */
1766     private static int findCommentStart(String line) {
1767         int result = -1;
1768         boolean inString = false;
1769         boolean inChar = false;
1770         boolean escaped = false;
1771 
1772         for (int index = 0; index < line.length() - 1; index++) {
1773             final char current = line.charAt(index);
1774 
1775             if (escaped) {
1776                 escaped = false;
1777             }
1778             else if (current == '\\') {
1779                 escaped = true;
1780             }
1781             else if (!inChar && current == '"') {
1782                 inString = !inString;
1783             }
1784             else if (!inString && current == '\'') {
1785                 inChar = !inChar;
1786             }
1787             else if (isCommentAt(line, index, inString, inChar)) {
1788                 result = index;
1789                 break;
1790             }
1791         }
1792 
1793         return result;
1794     }
1795 
1796     /**
1797      * Checks if a single-line comment starts at the given index.
1798      *
1799      * @param line current line
1800      * @param index current index
1801      * @param inString whether currently inside a string literal
1802      * @param inChar whether currently inside a character literal
1803      * @return true if comment starts at index
1804      */
1805     private static boolean isCommentAt(String line, int index, boolean inString, boolean inChar) {
1806         return !inString && !inChar
1807                 && line.charAt(index) == '/'
1808                 && line.charAt(index + 1) == '/';
1809     }
1810 
1811     /**
1812      * Represents a structural AST node without skippable comments.
1813      * This allows for structural comparison between example files.
1814      * Includes literal text values and identifier names for semantic comparison,
1815      * and line numbers for positional consistency validation.
1816      *
1817      * <p>Line numbers are section-relative (line 1 = first line of the extracted
1818      * xdoc section) because {@link #parseContent} re-parses only the extracted
1819      * section string. Class and constructor name nodes have their line number
1820      * set to {@code null} so that intentional name differences do not cause
1821      * false positives.
1822      */
1823     private static final class StructuralAstNode {
1824         private final int type;
1825         private final String text;
1826         /** Section-relative line number; null when position is intentionally ignored. */
1827         private final Integer lineNo;
1828         private final List<StructuralAstNode> children = new ArrayList<>();
1829 
1830         /**
1831          * Constructs a structural AST node.
1832          *
1833          * @param type           the token type
1834          * @param text           the token text from the source
1835          * @param ignoreText     if true, the text field is not stored (class/ctor names)
1836          * @param lineNo         the line number of this node within the parsed section
1837          * @param ignorePosition if true, the line number is not stored (class/ctor names)
1838          */
1839         private StructuralAstNode(int type, String text, boolean ignoreText,
1840                                   int lineNo, boolean ignorePosition) {
1841             this.type = type;
1842             if (ignoreText) {
1843                 this.text = null;
1844             }
1845             else if (isLiteralToken(type)) {
1846                 this.text = text;
1847             }
1848             else {
1849                 this.text = null;
1850             }
1851             if (ignorePosition) {
1852                 this.lineNo = null;
1853             }
1854             else {
1855                 this.lineNo = lineNo;
1856             }
1857         }
1858 
1859         /**
1860          * Checks if a token type represents a value whose text should be compared.
1861          * This includes numeric, string, boolean, null literals, and identifiers.
1862          * Identifiers are included so that differences in variable names, parameter
1863          * names, annotation names, etc. are detected as mismatches.
1864          * Class and constructor name identifiers are excluded via the
1865          * {@code ignoreText} flag set in {@link #toStructuralAst}.
1866          *
1867          * @param tokenType the token type
1868          * @return true if the token text carries semantic value
1869          */
1870         private static boolean isLiteralToken(int tokenType) {
1871             return switch (tokenType) {
1872                 case TokenTypes.NUM_INT, TokenTypes.NUM_LONG, TokenTypes.NUM_FLOAT,
1873                      TokenTypes.NUM_DOUBLE, TokenTypes.STRING_LITERAL,
1874                      TokenTypes.CHAR_LITERAL, TokenTypes.LITERAL_TRUE,
1875                      TokenTypes.LITERAL_FALSE, TokenTypes.LITERAL_NULL,
1876                      TokenTypes.IDENT -> true;
1877                 default -> false;
1878             };
1879         }
1880 
1881         private void addChild(StructuralAstNode child) {
1882             children.add(child);
1883         }
1884 
1885         @Override
1886         public boolean equals(Object obj) {
1887             if (!(obj instanceof StructuralAstNode other)) {
1888                 return false;
1889             }
1890             final boolean typeMatch = type == other.type;
1891             final boolean textMatch = Objects.equals(text, other.text);
1892             final boolean lineNoMatch = Objects.equals(lineNo, other.lineNo);
1893             final boolean childrenMatch = children.equals(other.children);
1894             return typeMatch && textMatch && lineNoMatch && childrenMatch;
1895         }
1896 
1897         /**
1898          * Compares this node against another, ignoring line-number differences.
1899          * Used to distinguish a genuine structural mismatch from one caused purely
1900          * by a shift in line numbers (e.g. an added or removed blank line), so the
1901          * violation message can point reviewers toward the right kind of fix.
1902          *
1903          * @param other the node to compare against
1904          * @return true if the two nodes (and their children) are structurally
1905          *         identical except possibly for line numbers
1906          */
1907         private boolean equalsIgnoringLineNumbers(StructuralAstNode other) {
1908             final boolean typeMatch = type == other.type;
1909             final boolean textMatch = Objects.equals(text, other.text);
1910             boolean childrenMatch = children.size() == other.children.size();
1911             if (childrenMatch) {
1912                 for (int index = 0; index < children.size(); index++) {
1913                     if (!children.get(index)
1914                         .equalsIgnoringLineNumbers(other.children.get(index))) {
1915                         childrenMatch = false;
1916                         break;
1917                     }
1918                 }
1919             }
1920             return typeMatch && textMatch && childrenMatch;
1921         }
1922 
1923         @Override
1924         public int hashCode() {
1925             return Objects.hash(type, text, lineNo, children);
1926         }
1927 
1928         @Override
1929         public String toString() {
1930             final StringBuilder sb = new StringBuilder(128);
1931             sb.append("StructuralAstNode{type=");
1932             try {
1933                 sb.append(TokenUtil.getTokenName(type));
1934             }
1935             catch (IllegalArgumentException exception) {
1936                 sb.append(type);
1937             }
1938             if (text != null) {
1939                 sb.append(", text='").append(text).append('\'');
1940             }
1941             if (lineNo != null) {
1942                 sb.append(", line=").append(lineNo);
1943             }
1944             if (!children.isEmpty()) {
1945                 sb.append(", children=").append(children.size());
1946             }
1947             sb.append('}');
1948             return sb.toString();
1949         }
1950     }
1951 
1952     /**
1953      * Represents a violation found during consistency check.
1954      *
1955      * @param relativePath      relative directory path
1956      * @param referenceFileName reference file name
1957      * @param mismatchFileName  mismatch file name
1958      * @param reason            mismatch reason
1959      */
1960     private record Violation(String relativePath, String referenceFileName,
1961                              String mismatchFileName, String reason) {
1962         @Override
1963         public String toString() {
1964             return "Directory: " + relativePath + "\n"
1965                     + "Reference: " + referenceFileName + "\n"
1966                     + "Mismatch:  " + mismatchFileName + "\n"
1967                     + "Reason:    " + reason;
1968         }
1969 
1970         /**
1971          * Gets the pattern to use for suppression.
1972          *
1973          * @return the suppression pattern
1974          */
1975         /* package */ String getSuppressionPattern() {
1976             return relativePath + "/" + mismatchFileName.replace(".java", "");
1977         }
1978     }
1979 
1980 }