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.site;
21  
22  import java.io.IOException;
23  import java.nio.file.FileVisitResult;
24  import java.nio.file.Files;
25  import java.nio.file.Path;
26  import java.nio.file.SimpleFileVisitor;
27  import java.nio.file.attribute.BasicFileAttributes;
28  import java.util.ArrayList;
29  import java.util.List;
30  import java.util.Locale;
31  import java.util.Map;
32  import java.util.TreeMap;
33  import java.util.regex.Matcher;
34  import java.util.regex.Pattern;
35  
36  import javax.annotation.Nullable;
37  
38  import org.apache.maven.doxia.macro.AbstractMacro;
39  import org.apache.maven.doxia.macro.Macro;
40  import org.apache.maven.doxia.macro.MacroExecutionException;
41  import org.apache.maven.doxia.macro.MacroRequest;
42  import org.apache.maven.doxia.sink.Sink;
43  import org.codehaus.plexus.component.annotations.Component;
44  
45  import com.puppycrawl.tools.checkstyle.api.DetailNode;
46  import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
47  
48  /**
49   * Macro to generate table rows for all Checkstyle modules.
50   * Includes every Check.java file that has a Javadoc.
51   * Uses href path structure based on src/site/xdoc/checks.
52   * Usage:
53   * <pre>
54   * &lt;macro name="allCheckSummaries"/&gt;
55   * </pre>
56   *
57   * <p>Supports optional "package" parameter to filter checks by package.
58   * When package parameter is provided, only checks from that package are included.
59   * Usage:
60   * <pre>
61   * &lt;macro name="allCheckSummaries"&gt;
62   *   &lt;param name="package" value="annotation"/&gt;
63   * &lt;/macro&gt;
64   * </pre>
65   */
66  @Component(role = Macro.class, hint = "allCheckSummaries")
67  public class AllCheckSummaries extends AbstractMacro {
68  
69      /** Initial capacity for StringBuilder in wrapSummary method. */
70      public static final int CAPACITY = 3000;
71  
72      /**
73       * Matches common HTML tags such as paragraph, div, span, strong, and em.
74       * Used to remove formatting tags from the Javadoc HTML content.
75       * Note: anchor tags are preserved.
76       */
77      private static final Pattern TAG_PATTERN =
78              Pattern.compile("(?i)</?(?:p|div|span|strong|em)[^>]*>");
79  
80      /** Whitespace regex pattern string. */
81      private static final String WHITESPACE_REGEX = "\\s+";
82  
83      /**
84       * Matches one or more whitespace characters.
85       * Used to normalize spacing in sanitized text.
86       */
87      private static final Pattern SPACE_PATTERN = Pattern.compile(WHITESPACE_REGEX);
88  
89      /**
90       * Matches '&amp;' characters that are not part of a valid HTML entity.
91       */
92      private static final Pattern AMP_PATTERN = Pattern.compile("&(?![a-zA-Z#0-9]+;)");
93  
94      /**
95       * Pattern to match href attributes in anchor tags.
96       * Captures the URL within the href attribute, including any newlines.
97       * DOTALL flag allows . to match newlines, making the pattern work across line breaks.
98       */
99      private static final Pattern HREF_PATTERN =
100             Pattern.compile("href\\s*=\\s*['\"]([^'\"]*)['\"]",
101                     Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
102 
103     /** Path component for source directory. */
104     private static final String SRC = "src";
105 
106     /** Path component for checks directory. */
107     private static final String CHECKS = "checks";
108 
109     /** Root path for Java check files. */
110     private static final Path JAVA_CHECKS_ROOT = Path.of(
111             SRC, "main", "java", "com", "puppycrawl", "tools", "checkstyle", CHECKS);
112 
113     /** Root path for site check XML files. */
114     private static final Path SITE_CHECKS_ROOT = Path.of(SRC, "site", "xdoc", CHECKS);
115 
116     /** XML file extension. */
117     private static final String XML_EXTENSION = ".xml";
118 
119     /** HTML file extension. */
120     private static final String HTML_EXTENSION = ".html";
121 
122     /** TD opening tag. */
123     private static final String TD_TAG = "<td>";
124 
125     /** TD closing tag. */
126     private static final String TD_CLOSE_TAG = "</td>";
127 
128     /** Package name for miscellaneous checks. */
129     private static final String MISC_PACKAGE = "misc";
130 
131     /** Package name for annotation checks. */
132     private static final String ANNOTATION_PACKAGE = "annotation";
133 
134     /** HTML table closing tag. */
135     private static final String TABLE_CLOSE_TAG = "</table>";
136 
137     /** HTML div closing tag. */
138     private static final String DIV_CLOSE_TAG = "</div>";
139 
140     /** HTML section closing tag. */
141     private static final String SECTION_CLOSE_TAG = "</section>";
142 
143     /** HTML div wrapper opening tag. */
144     private static final String DIV_WRAPPER_TAG = "<div class=\"wrapper\">";
145 
146     /** HTML table opening tag. */
147     private static final String TABLE_OPEN_TAG = "<table>";
148 
149     /** HTML anchor separator. */
150     private static final String ANCHOR_SEPARATOR = "#";
151 
152     /** Regex replacement for first capture group. */
153     private static final String FIRST_CAPTURE_GROUP = "$1";
154 
155     /** Maximum line width for complete line including indentation. */
156     private static final int MAX_LINE_WIDTH_TOTAL = 100;
157 
158     /** Indentation width for INDENT_LEVEL_14 (14 spaces). */
159     private static final int INDENT_WIDTH = 14;
160 
161     /** Maximum content width excluding indentation. */
162     private static final int MAX_CONTENT_WIDTH = MAX_LINE_WIDTH_TOTAL - INDENT_WIDTH;
163 
164     /** Closing anchor tag. */
165     private static final String CLOSING_ANCHOR_TAG = "</a>";
166 
167     /** Pattern to match trailing spaces before closing code tags. */
168     private static final Pattern CODE_SPACE_PATTERN = Pattern.compile(WHITESPACE_REGEX
169             + "(" + CLOSING_ANCHOR_TAG.substring(0, 2) + "code>)");
170 
171     @Override
172     public void execute(Sink sink, MacroRequest request) throws MacroExecutionException {
173         final String packageFilter = (String) request.getParameter("package");
174 
175         final Map<String, String> xmlHrefMap = buildXmlHtmlMap();
176         final Map<String, CheckInfo> infos = new TreeMap<>();
177 
178         processCheckFiles(infos, xmlHrefMap, packageFilter);
179 
180         final StringBuilder normalRows = new StringBuilder(4096);
181         final StringBuilder holderRows = new StringBuilder(512);
182 
183         buildTableRows(infos, normalRows, holderRows);
184 
185         sink.rawText(normalRows.toString());
186         if (packageFilter == null && !holderRows.isEmpty()) {
187             appendHolderSection(sink, holderRows);
188         }
189         else if (packageFilter != null && !holderRows.isEmpty()) {
190             appendFilteredHolderSection(sink, holderRows, packageFilter);
191         }
192     }
193 
194     /**
195      * Scans Java sources and populates info map with modules having Javadoc.
196      *
197      * @param infos map of collected module info
198      * @param xmlHrefMap map of XML-to-HTML hrefs
199      * @param packageFilter optional package to filter by, null for all
200      * @throws MacroExecutionException if file walk fails
201      */
202     private static void processCheckFiles(Map<String, CheckInfo> infos,
203                                           Map<String, String> xmlHrefMap,
204                                           String packageFilter)
205             throws MacroExecutionException {
206         try {
207             final List<Path> checkFiles = new ArrayList<>();
208             Files.walkFileTree(JAVA_CHECKS_ROOT, new SimpleFileVisitor<>() {
209                 @Override
210                 public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
211                     if (isCheckOrHolderFile(file)) {
212                         checkFiles.add(file);
213                     }
214                     return FileVisitResult.CONTINUE;
215                 }
216             });
217 
218             checkFiles.forEach(path -> processCheckFile(path, infos, xmlHrefMap, packageFilter));
219         }
220         catch (IOException | IllegalStateException exception) {
221             throw new MacroExecutionException("Failed to discover checks", exception);
222         }
223     }
224 
225     /**
226      * Checks if a path is a Check or Holder Java file.
227      *
228      * @param path the path to check
229      * @return true if the path is a Check or Holder file, false otherwise
230      */
231     private static boolean isCheckOrHolderFile(Path path) {
232         final Path fileName = path.getFileName();
233         return fileName != null
234                 && (fileName.toString().endsWith("Check.java")
235                 || fileName.toString().endsWith("Holder.java"))
236                 && Files.isRegularFile(path);
237     }
238 
239     /**
240      * Checks if a module is a holder type.
241      *
242      * @param moduleName the module name
243      * @return true if the module is a holder, false otherwise
244      */
245     private static boolean isHolder(String moduleName) {
246         return moduleName.endsWith("Holder");
247     }
248 
249     /**
250      * Processes a single check class file and extracts metadata.
251      *
252      * @param path the check class file
253      * @param infos map of results
254      * @param xmlHrefMap map of XML hrefs
255      * @param packageFilter optional package to filter by, null for all
256      * @throws IllegalArgumentException if macro execution fails
257      */
258     private static void processCheckFile(Path path, Map<String, CheckInfo> infos,
259                                          Map<String, String> xmlHrefMap,
260                                          String packageFilter) {
261         try {
262             final String moduleName = CommonUtil.getFileNameWithoutExtension(path.toString());
263             final DetailNode javadoc = SiteUtil.getModuleJavadoc(moduleName, path);
264             if (javadoc != null) {
265                 String description = getDescriptionIfPresent(javadoc);
266                 if (description != null) {
267                     description = sanitizeAnchorUrls(description);
268 
269                     final String[] moduleInfo = determineModuleInfo(path, moduleName);
270                     final String packageName = moduleInfo[1];
271                     if (packageFilter == null || packageFilter.equals(packageName)) {
272                         final String simpleName = moduleInfo[0];
273                         final String summary = sanitizeAndFirstSentence(description);
274                         final String href = resolveHref(xmlHrefMap, packageName, simpleName,
275                                 packageFilter);
276                         infos.put(simpleName, new CheckInfo(simpleName, href, summary));
277                     }
278                 }
279             }
280 
281         }
282         catch (MacroExecutionException exceptionThrown) {
283             throw new IllegalArgumentException(exceptionThrown);
284         }
285     }
286 
287     /**
288      * Determines the simple name and package name for a check module.
289      *
290      * @param path the check class file
291      * @param moduleName the full module name
292      * @return array with [simpleName, packageName]
293      */
294     private static String[] determineModuleInfo(Path path, String moduleName) {
295         String packageName = extractCategoryFromJavaPath(path);
296 
297         if ("indentation".equals(packageName)) {
298             packageName = MISC_PACKAGE;
299         }
300         if (isHolder(moduleName)) {
301             packageName = ANNOTATION_PACKAGE;
302         }
303         final String simpleName;
304         if (isHolder(moduleName)) {
305             simpleName = moduleName;
306         }
307         else {
308             simpleName = moduleName.substring(0, moduleName.length() - "Check".length());
309         }
310 
311         return new String[] {simpleName, packageName};
312     }
313 
314     /**
315      * Returns the module description if present and non-empty.
316      *
317      * @param javadoc the parsed Javadoc node
318      * @return the description text, or {@code null} if not present
319      */
320     @Nullable
321     private static String getDescriptionIfPresent(DetailNode javadoc) {
322         String result = null;
323         if (javadoc != null) {
324             try {
325                 if (ModuleJavadocParsingUtil
326                         .getModuleSinceVersionTagStartNode(javadoc) != null) {
327                     final String desc = ModuleJavadocParsingUtil.getModuleDescription(javadoc);
328                     if (!desc.isEmpty()) {
329                         result = desc;
330                     }
331                 }
332             }
333             catch (IllegalStateException exception) {
334                 result = null;
335             }
336         }
337         return result;
338     }
339 
340     /**
341      * Builds HTML rows for both normal and holder check modules.
342      *
343      * @param infos map of collected module info
344      * @param normalRows builder for normal check rows
345      * @param holderRows builder for holder check rows
346      */
347     private static void buildTableRows(Map<String, CheckInfo> infos,
348                                        StringBuilder normalRows,
349                                        StringBuilder holderRows) {
350         for (CheckInfo info : infos.values()) {
351             final String row = buildTableRow(info);
352             if (isHolder(info.simpleName())) {
353                 holderRows.append(row);
354             }
355             else {
356                 normalRows.append(row);
357             }
358         }
359         removeLeadingNewline(normalRows);
360         removeLeadingNewline(holderRows);
361     }
362 
363     /**
364      * Builds a single table row for a check module.
365      *
366      * @param info check module information
367      * @return the HTML table row as a string
368      */
369     private static String buildTableRow(CheckInfo info) {
370         final String ind10 = ModuleJavadocParsingUtil.INDENT_LEVEL_10;
371         final String ind12 = ModuleJavadocParsingUtil.INDENT_LEVEL_12;
372         final String ind14 = ModuleJavadocParsingUtil.INDENT_LEVEL_14;
373         final String ind16 = ModuleJavadocParsingUtil.INDENT_LEVEL_16;
374 
375         final String cleanSummary = sanitizeAnchorUrls(info.summary());
376 
377         return ind10 + "<tr>"
378                 + ind12 + TD_TAG
379                 + ind14
380                 + "<a href=\""
381                 + info.link()
382                 + "\">"
383                 + ind16 + info.simpleName()
384                 + ind14 + CLOSING_ANCHOR_TAG
385                 + ind12 + TD_CLOSE_TAG
386                 + ind12 + TD_TAG
387                 + ind14 + wrapSummary(cleanSummary)
388                 + ind12 + TD_CLOSE_TAG
389                 + ind10 + "</tr>";
390     }
391 
392     /**
393      * Removes leading newline characters from a StringBuilder.
394      *
395      * @param builder the StringBuilder to process
396      */
397     private static void removeLeadingNewline(StringBuilder builder) {
398         while (!builder.isEmpty() && Character.isWhitespace(builder.charAt(0))) {
399             builder.delete(0, 1);
400         }
401     }
402 
403     /**
404      * Appends the Holder Checks HTML section.
405      *
406      * @param sink the output sink
407      * @param holderRows the holder rows content
408      */
409     private static void appendHolderSection(Sink sink, StringBuilder holderRows) {
410         final String holderSection = ModuleJavadocParsingUtil.INDENT_LEVEL_8
411                 + TABLE_CLOSE_TAG
412                 + ModuleJavadocParsingUtil.INDENT_LEVEL_6
413                 + DIV_CLOSE_TAG
414                 + ModuleJavadocParsingUtil.INDENT_LEVEL_4
415                 + SECTION_CLOSE_TAG
416                 + ModuleJavadocParsingUtil.INDENT_LEVEL_4
417                 + "<section name=\"Holder Checks\">"
418                 + ModuleJavadocParsingUtil.INDENT_LEVEL_6
419                 + "<p>"
420                 + ModuleJavadocParsingUtil.INDENT_LEVEL_8
421                 + "These checks aren't normal checks and are usually"
422                 + ModuleJavadocParsingUtil.INDENT_LEVEL_8
423                 + "associated with a specialized filter to gather"
424                 + ModuleJavadocParsingUtil.INDENT_LEVEL_8
425                 + "information the filter can't get on its own."
426                 + ModuleJavadocParsingUtil.INDENT_LEVEL_6
427                 + "</p>"
428                 + ModuleJavadocParsingUtil.INDENT_LEVEL_6
429                 + DIV_WRAPPER_TAG
430                 + ModuleJavadocParsingUtil.INDENT_LEVEL_8
431                 + TABLE_OPEN_TAG
432                 + ModuleJavadocParsingUtil.INDENT_LEVEL_10
433                 + holderRows;
434         sink.rawText(holderSection);
435     }
436 
437     /**
438      * Appends the filtered Holder Checks section for package views.
439      *
440      * @param sink the output sink
441      * @param holderRows the holder rows content
442      * @param packageName the package name
443      */
444     private static void appendFilteredHolderSection(Sink sink, StringBuilder holderRows,
445                                                     String packageName) {
446         final String packageTitle = getPackageDisplayName(packageName);
447         final String holderSection = ModuleJavadocParsingUtil.INDENT_LEVEL_8
448                 + TABLE_CLOSE_TAG
449                 + ModuleJavadocParsingUtil.INDENT_LEVEL_6
450                 + DIV_CLOSE_TAG
451                 + ModuleJavadocParsingUtil.INDENT_LEVEL_4
452                 + SECTION_CLOSE_TAG
453                 + ModuleJavadocParsingUtil.INDENT_LEVEL_4
454                 + "<section name=\"" + packageTitle + " Holder Checks\">"
455                 + ModuleJavadocParsingUtil.INDENT_LEVEL_6
456                 + DIV_WRAPPER_TAG
457                 + ModuleJavadocParsingUtil.INDENT_LEVEL_8
458                 + TABLE_OPEN_TAG
459                 + ModuleJavadocParsingUtil.INDENT_LEVEL_10
460                 + holderRows;
461         sink.rawText(holderSection);
462     }
463 
464     /**
465      * Get display name for package (capitalize first letter).
466      *
467      * @param packageName the package name
468      * @return the capitalized package name
469      */
470     private static String getPackageDisplayName(String packageName) {
471         final String result;
472         if (packageName == null || packageName.isEmpty()) {
473             result = packageName;
474         }
475         else {
476             result = packageName.substring(0, 1).toUpperCase(Locale.ENGLISH)
477                     + packageName.substring(1);
478         }
479         return result;
480     }
481 
482     /**
483      * Builds map of XML file names to HTML documentation paths.
484      *
485      * @return map of lowercase check names to hrefs
486      */
487     private static Map<String, String> buildXmlHtmlMap() {
488         final Map<String, String> map = new TreeMap<>();
489         if (Files.exists(SITE_CHECKS_ROOT)) {
490             try {
491                 final List<Path> xmlFiles = new ArrayList<>();
492                 Files.walkFileTree(SITE_CHECKS_ROOT, new SimpleFileVisitor<>() {
493                     @Override
494                     public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
495                         if (isValidXmlFile(file)) {
496                             xmlFiles.add(file);
497                         }
498                         return FileVisitResult.CONTINUE;
499                     }
500                 });
501 
502                 xmlFiles.forEach(path -> addXmlHtmlMapping(path, map));
503             }
504             catch (IOException ignored) {
505                 // ignore
506             }
507         }
508         return map;
509     }
510 
511     /**
512      * Checks if a path is a valid XML file for processing.
513      *
514      * @param path the path to check
515      * @return true if the path is a valid XML file, false otherwise
516      */
517     private static boolean isValidXmlFile(Path path) {
518         final Path fileName = path.getFileName();
519         return fileName != null
520                 && !("index" + XML_EXTENSION).equalsIgnoreCase(fileName.toString())
521                 && path.toString().endsWith(XML_EXTENSION)
522                 && Files.isRegularFile(path);
523     }
524 
525     /**
526      * Adds XML-to-HTML mapping entry to map.
527      *
528      * @param path the XML file path
529      * @param map the mapping to update
530      */
531     private static void addXmlHtmlMapping(Path path, Map<String, String> map) {
532         final Path fileName = path.getFileName();
533         if (fileName != null) {
534             final String fileNameString = fileName.toString();
535             final int extensionLength = 4;
536             final String base = fileNameString.substring(0,
537                             fileNameString.length() - extensionLength)
538                     .toLowerCase(Locale.ROOT);
539             final Path relativePath = SITE_CHECKS_ROOT.relativize(path);
540             final String relativePathString = relativePath.toString();
541             final String rel = relativePathString
542                     .replace('\\', '/')
543                     .replace(XML_EXTENSION, HTML_EXTENSION);
544             map.put(base, CHECKS + "/" + rel);
545         }
546     }
547 
548     /**
549      * Resolves the href for a given check module.
550      * When packageFilter is null, returns full path: checks/category/filename.html#CheckName
551      * When packageFilter is set, returns relative path: filename.html#CheckName
552      *
553      * @param xmlMap map of XML file names to HTML paths
554      * @param category the category of the check
555      * @param simpleName simple name of the check
556      * @param packageFilter optional package filter, null for all checks
557      * @return the resolved href for the check
558      */
559     private static String resolveHref(Map<String, String> xmlMap, String category,
560                                       String simpleName, @Nullable String packageFilter) {
561         final String lower = simpleName.toLowerCase(Locale.ROOT);
562         final String href = xmlMap.get(lower);
563         final String result;
564 
565         if (href != null) {
566             if (packageFilter == null) {
567                 result = href + ANCHOR_SEPARATOR + simpleName;
568             }
569             else {
570                 final int lastSlash = href.lastIndexOf('/');
571                 final String filename;
572                 if (lastSlash >= 0) {
573                     filename = href.substring(lastSlash + 1);
574                 }
575                 else {
576                     filename = href;
577                 }
578                 result = filename + ANCHOR_SEPARATOR + simpleName;
579             }
580         }
581         else {
582             if (packageFilter == null) {
583                 result = String.format(Locale.ROOT, "%s/%s/%s.html%s%s",
584                         CHECKS, category, lower, ANCHOR_SEPARATOR, simpleName);
585             }
586             else {
587                 result = String.format(Locale.ROOT, "%s.html%s%s",
588                         lower, ANCHOR_SEPARATOR, simpleName);
589             }
590         }
591         return result;
592     }
593 
594     /**
595      * Extracts category path from a Java file path.
596      *
597      * @param javaPath the Java source file path
598      * @return the category path extracted from the Java path
599      */
600     private static String extractCategoryFromJavaPath(Path javaPath) {
601         final Path rel = JAVA_CHECKS_ROOT.relativize(javaPath);
602         final Path parent = rel.getParent();
603         final String result;
604         if (parent == null) {
605             result = MISC_PACKAGE;
606         }
607         else {
608             result = parent.toString().replace('\\', '/');
609         }
610         return result;
611     }
612 
613     /**
614      * Sanitizes URLs within anchor tags by removing whitespace from href attributes.
615      *
616      * @param html the HTML string containing anchor tags
617      * @return the HTML with sanitized URLs
618      */
619     private static String sanitizeAnchorUrls(String html) {
620         final String result;
621         if (html == null || html.isEmpty()) {
622             result = html;
623         }
624         else {
625             final Matcher matcher = HREF_PATTERN.matcher(html);
626             final StringBuilder buffer = new StringBuilder(html.length());
627 
628             while (matcher.find()) {
629                 final String originalUrl = matcher.group(1);
630                 final String cleanedUrl = SPACE_PATTERN.matcher(originalUrl).replaceAll("");
631                 final String replacement = "href=\""
632                         + Matcher.quoteReplacement(cleanedUrl) + "\"";
633                 matcher.appendReplacement(buffer, replacement);
634             }
635             matcher.appendTail(buffer);
636 
637             result = buffer.toString();
638         }
639         return result;
640     }
641 
642     /**
643      * Sanitizes HTML and extracts first sentence.
644      * Preserves anchor tags while removing other HTML formatting.
645      * Also cleans whitespace from URLs in href attributes.
646      *
647      * @param html the HTML string to process
648      * @return the sanitized first sentence
649      */
650     private static String sanitizeAndFirstSentence(String html) {
651         final String result;
652         if (html == null || html.isEmpty()) {
653             result = "";
654         }
655         else {
656             String cleaned = sanitizeAnchorUrls(html);
657             cleaned = TAG_PATTERN.matcher(cleaned).replaceAll("");
658             cleaned = SPACE_PATTERN.matcher(cleaned).replaceAll(" ").trim();
659             cleaned = AMP_PATTERN.matcher(cleaned).replaceAll("&amp;");
660             cleaned = CODE_SPACE_PATTERN.matcher(cleaned).replaceAll(FIRST_CAPTURE_GROUP);
661             result = extractFirstSentence(cleaned);
662         }
663         return result;
664     }
665 
666     /**
667      * Extracts first sentence from plain text.
668      *
669      * @param text the text to process
670      * @return the first sentence extracted from the text
671      */
672     private static String extractFirstSentence(String text) {
673         String result = "";
674         if (text != null && !text.isEmpty()) {
675             int end = -1;
676             for (int index = 0; index < text.length(); index++) {
677                 if (text.charAt(index) == '.'
678                         && (index == text.length() - 1
679                         || Character.isWhitespace(text.charAt(index + 1))
680                         || text.charAt(index + 1) == '<')) {
681                     end = index;
682                     break;
683                 }
684             }
685             if (end == -1) {
686                 result = text.trim();
687             }
688             else {
689                 result = text.substring(0, end + 1).trim();
690             }
691         }
692         return result;
693     }
694 
695     /**
696      * Wraps long summaries to avoid exceeding line width.
697      * Preserves URLs in anchor tags by breaking after the opening tag's closing bracket.
698      *
699      * @param text the text to wrap
700      * @return the wrapped text
701      */
702     private static String wrapSummary(String text) {
703         String wrapped = "";
704 
705         if (text != null && !text.isEmpty()) {
706             final String sanitized = sanitizeAnchorUrls(text);
707 
708             final String indent = ModuleJavadocParsingUtil.INDENT_LEVEL_14;
709             final String clean = sanitized.trim();
710 
711             final StringBuilder result = new StringBuilder(CAPACITY);
712             int cleanIndex = 0;
713             final int cleanLen = clean.length();
714 
715             while (cleanIndex < cleanLen) {
716                 final int remainingChars = cleanLen - cleanIndex;
717 
718                 if (remainingChars <= MAX_CONTENT_WIDTH) {
719                     result.append(indent)
720                             .append(clean.substring(cleanIndex))
721                             .append('\n');
722                     break;
723                 }
724 
725                 final int idealBreak = cleanIndex + MAX_CONTENT_WIDTH;
726                 final int actualBreak = calculateBreakPoint(clean, cleanIndex, idealBreak);
727 
728                 result.append(indent)
729                         .append(clean, cleanIndex, actualBreak);
730 
731                 cleanIndex = actualBreak;
732                 while (cleanIndex < cleanLen && clean.charAt(cleanIndex) == ' ') {
733                     cleanIndex++;
734                 }
735             }
736 
737             wrapped = result.toString().trim();
738         }
739 
740         return wrapped;
741     }
742 
743     /**
744      * Calculates the appropriate break point for text wrapping.
745      * Handles anchor tags specially to avoid breaking URLs.
746      *
747      * @param clean the cleaned text to process
748      * @param cleanIndex current position in text
749      * @param idealBreak ideal break position
750      * @return the actual break position
751      */
752     private static int calculateBreakPoint(String clean, int cleanIndex, int idealBreak) {
753         final int anchorStart = clean.indexOf("<a ", cleanIndex);
754         final int anchorOpenEnd;
755         if (anchorStart == -1) {
756             anchorOpenEnd = -1;
757         }
758         else {
759             anchorOpenEnd = clean.indexOf('>', anchorStart);
760         }
761 
762         final int actualBreak;
763         if (shouldBreakAfterAnchorOpen(anchorStart, anchorOpenEnd, idealBreak)) {
764             actualBreak = anchorOpenEnd + 1;
765         }
766         else if (shouldBreakAfterAnchorContent(anchorStart, anchorOpenEnd,
767                 idealBreak, clean)) {
768             actualBreak = anchorOpenEnd + 1;
769         }
770         else {
771             actualBreak = findSafeBreakPoint(clean, cleanIndex, idealBreak);
772         }
773 
774         return actualBreak;
775     }
776 
777     /**
778      * Determines if break should occur after anchor opening tag.
779      *
780      * @param anchorStart start position of anchor tag
781      * @param anchorOpenEnd end position of anchor opening tag
782      * @param idealBreak ideal break position
783      * @return true if should break after anchor opening
784      */
785     private static boolean shouldBreakAfterAnchorOpen(int anchorStart, int anchorOpenEnd,
786                                                       int idealBreak) {
787         return anchorStart != -1 && anchorStart < idealBreak
788                 && anchorOpenEnd != -1 && anchorOpenEnd >= idealBreak;
789     }
790 
791     /**
792      * Determines if break should occur after anchor content.
793      *
794      * @param anchorStart start position of anchor tag
795      * @param anchorOpenEnd end position of anchor opening tag
796      * @param idealBreak ideal break position
797      * @param clean the text being processed
798      * @return true if should break after anchor content
799      */
800     private static boolean shouldBreakAfterAnchorContent(int anchorStart, int anchorOpenEnd,
801                                                          int idealBreak, String clean) {
802         final boolean result;
803         if (anchorStart != -1 && anchorStart < idealBreak
804                 && anchorOpenEnd != -1 && anchorOpenEnd < idealBreak) {
805             final int anchorCloseStart = clean.indexOf(CLOSING_ANCHOR_TAG, anchorOpenEnd);
806             result = anchorCloseStart != -1 && anchorCloseStart >= idealBreak;
807         }
808         else {
809             result = false;
810         }
811         return result;
812     }
813 
814     /**
815      * Finds a safe break point at a space character.
816      *
817      * @param text the text to search
818      * @param start the start index
819      * @param idealBreak the ideal break position
820      * @return the actual break position
821      */
822     private static int findSafeBreakPoint(String text, int start, int idealBreak) {
823         final int actualBreak;
824         final int lastSpace = text.lastIndexOf(' ', idealBreak);
825 
826         if (lastSpace > start && lastSpace >= start + MAX_CONTENT_WIDTH / 2) {
827             actualBreak = lastSpace;
828         }
829         else {
830             actualBreak = idealBreak;
831         }
832 
833         return actualBreak;
834     }
835 
836     /**
837      * Data holder for each Check module entry.
838      *
839      * @param simpleName check simple name
840      * @param link documentation link
841      * @param summary module summary
842      */
843     private record CheckInfo(String simpleName, String link, String summary) {
844     }
845 }