001///////////////////////////////////////////////////////////////////////////////////////////////
002// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
003// Copyright (C) 2001-2025 the original author or authors.
004//
005// This library is free software; you can redistribute it and/or
006// modify it under the terms of the GNU Lesser General Public
007// License as published by the Free Software Foundation; either
008// version 2.1 of the License, or (at your option) any later version.
009//
010// This library is distributed in the hope that it will be useful,
011// but WITHOUT ANY WARRANTY; without even the implied warranty of
012// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
013// Lesser General Public License for more details.
014//
015// You should have received a copy of the GNU Lesser General Public
016// License along with this library; if not, write to the Free Software
017// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
018///////////////////////////////////////////////////////////////////////////////////////////////
019
020package com.puppycrawl.tools.checkstyle.site;
021
022import java.io.IOException;
023import java.nio.file.FileVisitResult;
024import java.nio.file.Files;
025import java.nio.file.Path;
026import java.nio.file.SimpleFileVisitor;
027import java.nio.file.attribute.BasicFileAttributes;
028import java.util.ArrayList;
029import java.util.List;
030import java.util.Locale;
031import java.util.Map;
032import java.util.TreeMap;
033import java.util.regex.Pattern;
034
035import javax.annotation.Nullable;
036
037import org.apache.maven.doxia.macro.AbstractMacro;
038import org.apache.maven.doxia.macro.Macro;
039import org.apache.maven.doxia.macro.MacroExecutionException;
040import org.apache.maven.doxia.macro.MacroRequest;
041import org.apache.maven.doxia.sink.Sink;
042import org.codehaus.plexus.component.annotations.Component;
043
044import com.puppycrawl.tools.checkstyle.api.DetailNode;
045import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
046
047/**
048 * Macro to generate table rows for all Checkstyle modules.
049 * Includes every Check.java file that has a Javadoc.
050 * Uses href path structure based on src/site/xdoc/checks.
051 */
052@Component(role = Macro.class, hint = "allCheckSummaries")
053public class AllCheckSummaries extends AbstractMacro {
054
055    /**
056     * Matches HTML anchor tags and captures their inner text.
057     * Used to strip <a> elements while keeping their display text.
058     */
059    private static final Pattern LINK_PATTERN = Pattern.compile("<a[^>]*>([^<]*)</a>");
060
061    /**
062     * Matches common HTML tags such as paragraph, div, span, strong, and em.
063     * Used to remove formatting tags from the Javadoc HTML content.
064     */
065    private static final Pattern TAG_PATTERN =
066            Pattern.compile("(?i)</?(?:p|div|span|strong|em)[^>]*>");
067
068    /**
069     * Matches one or more whitespace characters.
070     * Used to normalize spacing in sanitized text.
071     */
072    private static final Pattern SPACE_PATTERN = Pattern.compile("\\s+");
073
074    /**
075     * Matches '&amp;' characters that are not part of a valid HTML entity.
076     */
077    private static final Pattern AMP_PATTERN = Pattern.compile("&(?![a-zA-Z#0-9]+;)");
078
079    /** Path component for source directory. */
080    private static final String SRC = "src";
081
082    /** Path component for checks directory. */
083    private static final String CHECKS = "checks";
084
085    /** Root path for Java check files. */
086    private static final Path JAVA_CHECKS_ROOT = Path.of(
087            SRC, "main", "java", "com", "puppycrawl", "tools", "checkstyle", CHECKS);
088
089    /** Root path for site check XML files. */
090    private static final Path SITE_CHECKS_ROOT = Path.of(SRC, "site", "xdoc", CHECKS);
091
092    /** Maximum line width considering indentation. */
093    private static final int MAX_LINE_WIDTH = 86;
094
095    /** XML file extension. */
096    private static final String XML_EXTENSION = ".xml";
097
098    /** HTML file extension. */
099    private static final String HTML_EXTENSION = ".html";
100
101    /** TD opening tag. */
102    private static final String TD_TAG = "<td>";
103
104    /** TD closing tag. */
105    private static final String TD_CLOSE_TAG = "</td>";
106
107    @Override
108    public void execute(Sink sink, MacroRequest request) throws MacroExecutionException {
109        final Map<String, String> xmlHrefMap = buildXmlHtmlMap();
110        final Map<String, CheckInfo> infos = new TreeMap<>();
111
112        processCheckFiles(infos, xmlHrefMap);
113
114        final StringBuilder normalRows = new StringBuilder(4096);
115        final StringBuilder holderRows = new StringBuilder(512);
116
117        buildTableRows(infos, normalRows, holderRows);
118
119        sink.rawText(normalRows.toString());
120
121        if (!holderRows.isEmpty()) {
122            appendHolderSection(sink, holderRows);
123        }
124    }
125
126    /**
127     * Scans Java sources and populates info map with modules having Javadoc.
128     *
129     * @param infos map of collected module info
130     * @param xmlHrefMap map of XML-to-HTML hrefs
131     * @throws MacroExecutionException if file walk fails
132     */
133    private static void processCheckFiles(Map<String, CheckInfo> infos,
134                                          Map<String, String> xmlHrefMap)
135            throws MacroExecutionException {
136        try {
137            final List<Path> checkFiles = new ArrayList<>();
138            Files.walkFileTree(JAVA_CHECKS_ROOT, new SimpleFileVisitor<>() {
139                @Override
140                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
141                    if (isCheckOrHolderFile(file)) {
142                        checkFiles.add(file);
143                    }
144                    return FileVisitResult.CONTINUE;
145                }
146            });
147
148            checkFiles.forEach(path -> processCheckFile(path, infos, xmlHrefMap));
149        }
150        catch (IOException | IllegalStateException exception) {
151            throw new MacroExecutionException("Failed to discover checks", exception);
152        }
153    }
154
155    /**
156     * Checks if a path is a Check or Holder Java file.
157     *
158     * @param path the path to check
159     * @return true if the path is a Check or Holder file, false otherwise
160     */
161    private static boolean isCheckOrHolderFile(Path path) {
162        final boolean result;
163        if (Files.isRegularFile(path)) {
164            final Path fileName = path.getFileName();
165            if (fileName == null) {
166                result = false;
167            }
168            else {
169                final String name = fileName.toString();
170                result = name.endsWith("Check.java") || name.endsWith("Holder.java");
171            }
172        }
173        else {
174            result = false;
175        }
176        return result;
177    }
178
179    /**
180     * Processes a single check class file and extracts metadata.
181     *
182     * @param path the check class file
183     * @param infos map of results
184     * @param xmlHrefMap map of XML hrefs
185     * @throws IllegalArgumentException if macro execution fails
186     */
187    private static void processCheckFile(Path path, Map<String, CheckInfo> infos,
188                                         Map<String, String> xmlHrefMap) {
189        try {
190            final String moduleName = CommonUtil.getFileNameWithoutExtension(path.toString());
191            final boolean isHolder = moduleName.endsWith("Holder");
192            final String simpleName;
193            if (isHolder) {
194                simpleName = moduleName;
195            }
196            else {
197                simpleName = moduleName.substring(0, moduleName.length() - "Check".length());
198            }
199            final DetailNode javadoc = SiteUtil.getModuleJavadoc(moduleName, path);
200            if (javadoc != null) {
201                final String description = getDescriptionIfPresent(javadoc);
202                if (description != null) {
203                    final String summary = createSummary(description);
204                    final String category = extractCategory(path);
205                    final String href = resolveHref(xmlHrefMap, category, simpleName);
206                    addCheckInfo(infos, simpleName, href, summary, isHolder);
207                }
208            }
209
210        }
211        catch (MacroExecutionException exceptionThrown) {
212            throw new IllegalArgumentException(exceptionThrown);
213        }
214    }
215
216    /**
217     * Returns the module description if present and non-empty.
218     *
219     * @param javadoc the parsed Javadoc node
220     * @return the description text, or {@code null} if not present
221     */
222    @Nullable
223    private static String getDescriptionIfPresent(DetailNode javadoc) {
224        String result = null;
225        final String desc = getModuleDescriptionSafe(javadoc);
226        if (desc != null && !desc.isEmpty()) {
227            result = desc;
228        }
229        return result;
230    }
231
232    /**
233     * Produces a concise, sanitized summary from the full Javadoc description.
234     *
235     * @param description full Javadoc text
236     * @return sanitized first sentence of the description
237     */
238    private static String createSummary(String description) {
239        return sanitizeAndFirstSentence(description);
240    }
241
242    /**
243     * Extracts category name from the given Java source path.
244     *
245     * @param path source path of the class
246     * @return category name string
247     */
248    private static String extractCategory(Path path) {
249        return extractCategoryFromJavaPath(path);
250    }
251
252    /**
253     * Adds a new {@link CheckInfo} record to the provided map.
254     *
255     * @param infos map to update
256     * @param simpleName simple class name
257     * @param href documentation href
258     * @param summary short summary of the check
259     * @param isHolder true if the check is a holder module
260     */
261    private static void addCheckInfo(Map<String, CheckInfo> infos,
262                                     String simpleName,
263                                     String href,
264                                     String summary,
265                                     boolean isHolder) {
266        infos.put(simpleName, new CheckInfo(simpleName, href, summary, isHolder));
267    }
268
269    /**
270     * Retrieves Javadoc description node safely.
271     *
272     * @param javadoc DetailNode root
273     * @return module description or null
274     */
275    @Nullable
276    private static String getModuleDescriptionSafe(DetailNode javadoc) {
277        String result = null;
278        if (javadoc != null) {
279            try {
280                if (ModuleJavadocParsingUtil
281                        .getModuleSinceVersionTagStartNode(javadoc) != null) {
282                    result = ModuleJavadocParsingUtil.getModuleDescription(javadoc);
283                }
284            }
285            catch (IllegalStateException exception) {
286                result = null;
287            }
288        }
289        return result;
290    }
291
292    /**
293     * Builds HTML rows for both normal and holder check modules.
294     *
295     * @param infos map of collected module info
296     * @param normalRows builder for normal check rows
297     * @param holderRows builder for holder check rows
298     */
299    private static void buildTableRows(Map<String, CheckInfo> infos,
300                                       StringBuilder normalRows,
301                                       StringBuilder holderRows) {
302        appendRows(infos, normalRows, holderRows);
303        finalizeRows(normalRows, holderRows);
304    }
305
306    /**
307     * Iterates over collected check info entries and appends corresponding rows.
308     *
309     * @param infos map of check info entries
310     * @param normalRows builder for normal check rows
311     * @param holderRows builder for holder check rows
312     */
313    private static void appendRows(Map<String, CheckInfo> infos,
314                                   StringBuilder normalRows,
315                                   StringBuilder holderRows) {
316        for (CheckInfo info : infos.values()) {
317            final String row = buildTableRow(info);
318            if (info.isHolder) {
319                holderRows.append(row);
320            }
321            else {
322                normalRows.append(row);
323            }
324        }
325    }
326
327    /**
328     * Removes leading newlines from the generated table row builders.
329     *
330     * @param normalRows builder for normal check rows
331     * @param holderRows builder for holder check rows
332     */
333    private static void finalizeRows(StringBuilder normalRows, StringBuilder holderRows) {
334        removeLeadingNewline(normalRows);
335        removeLeadingNewline(holderRows);
336    }
337
338    /**
339     * Builds a single table row for a check module.
340     *
341     * @param info check module information
342     * @return the HTML table row as a string
343     */
344    private static String buildTableRow(CheckInfo info) {
345        final String ind10 = ModuleJavadocParsingUtil.INDENT_LEVEL_10;
346        final String ind12 = ModuleJavadocParsingUtil.INDENT_LEVEL_12;
347        final String ind14 = ModuleJavadocParsingUtil.INDENT_LEVEL_14;
348        final String ind16 = ModuleJavadocParsingUtil.INDENT_LEVEL_16;
349
350        return ind10 + "<tr>"
351                + ind12 + TD_TAG
352                + ind14
353                + "<a href=\""
354                + info.link
355                + "\">"
356                + ind16 + info.simpleName
357                + ind14 + "</a>"
358                + ind12 + TD_CLOSE_TAG
359                + ind12 + TD_TAG
360                + ind14 + wrapSummary(info.summary)
361                + ind12 + TD_CLOSE_TAG
362                + ind10 + "</tr>";
363    }
364
365    /**
366     * Removes leading newline characters from a StringBuilder.
367     *
368     * @param builder the StringBuilder to process
369     */
370    private static void removeLeadingNewline(StringBuilder builder) {
371        while (!builder.isEmpty() && Character.isWhitespace(builder.charAt(0))) {
372            builder.delete(0, 1);
373        }
374    }
375
376    /**
377     * Appends the Holder Checks HTML section.
378     *
379     * @param sink the output sink
380     * @param holderRows the holder rows content
381     */
382    private static void appendHolderSection(Sink sink, StringBuilder holderRows) {
383        final String holderSection = buildHolderSectionHtml(holderRows);
384        sink.rawText(holderSection);
385    }
386
387    /**
388     * Builds the HTML for the Holder Checks section.
389     *
390     * @param holderRows the holder rows content
391     * @return the complete HTML section as a string
392     */
393    private static String buildHolderSectionHtml(StringBuilder holderRows) {
394        return ModuleJavadocParsingUtil.INDENT_LEVEL_8
395                + "</table>"
396                + ModuleJavadocParsingUtil.INDENT_LEVEL_6
397                + "</div>"
398                + ModuleJavadocParsingUtil.INDENT_LEVEL_4
399                + "</section>"
400                + ModuleJavadocParsingUtil.INDENT_LEVEL_4
401                + "<section name=\"Holder Checks\">"
402                + ModuleJavadocParsingUtil.INDENT_LEVEL_6
403                + "<p>"
404                + ModuleJavadocParsingUtil.INDENT_LEVEL_8
405                + "These checks aren't normal checks and are usually"
406                + ModuleJavadocParsingUtil.INDENT_LEVEL_8
407                + "associated with a specialized filter to gather"
408                + ModuleJavadocParsingUtil.INDENT_LEVEL_8
409                + "information the filter can't get on its own."
410                + ModuleJavadocParsingUtil.INDENT_LEVEL_6
411                + "</p>"
412                + ModuleJavadocParsingUtil.INDENT_LEVEL_6
413                + "<div class=\"wrapper\">"
414                + ModuleJavadocParsingUtil.INDENT_LEVEL_8
415                + "<table>"
416                + ModuleJavadocParsingUtil.INDENT_LEVEL_10
417                + holderRows;
418    }
419
420    /**
421     * Builds map of XML file names to HTML documentation paths.
422     *
423     * @return map of lowercase check names to hrefs
424     */
425    private static Map<String, String> buildXmlHtmlMap() {
426        final Map<String, String> map = new TreeMap<>();
427        if (Files.exists(SITE_CHECKS_ROOT)) {
428            try {
429                final List<Path> xmlFiles = new ArrayList<>();
430                Files.walkFileTree(SITE_CHECKS_ROOT, new SimpleFileVisitor<>() {
431                    @Override
432                    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
433                        if (isValidXmlFile(file)) {
434                            xmlFiles.add(file);
435                        }
436                        return FileVisitResult.CONTINUE;
437                    }
438                });
439
440                xmlFiles.forEach(path -> addXmlHtmlMapping(path, map));
441            }
442            catch (IOException ignored) {
443                // ignore
444            }
445        }
446        return map;
447    }
448
449    /**
450     * Checks if a path is a valid XML file for processing.
451     *
452     * @param path the path to check
453     * @return true if the path is a valid XML file, false otherwise
454     */
455    private static boolean isValidXmlFile(Path path) {
456        final boolean result;
457        if (Files.isRegularFile(path)
458                && path.toString().endsWith(XML_EXTENSION)) {
459            final Path fileName = path.getFileName();
460            result = fileName != null
461                    && !("index" + XML_EXTENSION)
462                    .equalsIgnoreCase(fileName.toString());
463        }
464        else {
465            result = false;
466        }
467        return result;
468    }
469
470    /**
471     * Adds XML-to-HTML mapping entry to map.
472     *
473     * @param path the XML file path
474     * @param map the mapping to update
475     */
476    private static void addXmlHtmlMapping(Path path, Map<String, String> map) {
477        final Path fileName = path.getFileName();
478        if (fileName != null) {
479            final String fileNameString = fileName.toString();
480            final int extensionLength = 4;
481            final String base = fileNameString.substring(0,
482                            fileNameString.length() - extensionLength)
483                    .toLowerCase(Locale.ROOT);
484            final Path relativePath = SITE_CHECKS_ROOT.relativize(path);
485            final String relativePathString = relativePath.toString();
486            final String rel = relativePathString
487                    .replace('\\', '/')
488                    .replace(XML_EXTENSION, HTML_EXTENSION);
489            map.put(base, CHECKS + "/" + rel);
490        }
491    }
492
493    /**
494     * Resolves the href for a given check module.
495     *
496     * @param xmlMap map of XML file names to HTML paths
497     * @param category the category of the check
498     * @param simpleName simple name of the check
499     * @return the resolved href for the check
500     */
501    private static String resolveHref(Map<String, String> xmlMap, String category,
502                                      String simpleName) {
503        final String lower = simpleName.toLowerCase(Locale.ROOT);
504        final String href = xmlMap.get(lower);
505        final String result;
506        if (href != null) {
507            result = href + "#" + simpleName;
508        }
509        else {
510            result = String.format(Locale.ROOT, "%s/%s/%s.html#%s",
511                    CHECKS, category, lower, simpleName);
512        }
513        return result;
514    }
515
516    /**
517     * Extracts category path from a Java file path.
518     *
519     * @param javaPath the Java source file path
520     * @return the category path extracted from the Java path
521     */
522    private static String extractCategoryFromJavaPath(Path javaPath) {
523        final Path rel = JAVA_CHECKS_ROOT.relativize(javaPath);
524        final Path parent = rel.getParent();
525        final String result;
526        if (parent == null) {
527            result = "";
528        }
529        else {
530            result = parent.toString().replace('\\', '/');
531        }
532        return result;
533    }
534
535    /**
536     * Sanitizes HTML and extracts first sentence.
537     *
538     * @param html the HTML string to process
539     * @return the sanitized first sentence
540     */
541    private static String sanitizeAndFirstSentence(String html) {
542        final String result;
543        if (html == null || html.isEmpty()) {
544            result = "";
545        }
546        else {
547            String cleaned = LINK_PATTERN.matcher(html).replaceAll("$1");
548            cleaned = TAG_PATTERN.matcher(cleaned).replaceAll("");
549            cleaned = SPACE_PATTERN.matcher(cleaned).replaceAll(" ").trim();
550            cleaned = AMP_PATTERN.matcher(cleaned).replaceAll("&amp;");
551            result = extractFirstSentence(cleaned);
552        }
553        return result;
554    }
555
556    /**
557     * Extracts first sentence from plain text.
558     *
559     * @param text the text to process
560     * @return the first sentence extracted from the text
561     */
562    private static String extractFirstSentence(String text) {
563        String result = "";
564        if (text != null && !text.isEmpty()) {
565            int end = -1;
566            for (int index = 0; index < text.length(); index++) {
567                if (text.charAt(index) == '.'
568                        && (index == text.length() - 1
569                        || Character.isWhitespace(text.charAt(index + 1))
570                        || text.charAt(index + 1) == '<')) {
571                    end = index;
572                    break;
573                }
574            }
575            if (end == -1) {
576                result = text.trim();
577            }
578            else {
579                result = text.substring(0, end + 1).trim();
580            }
581        }
582        return result;
583    }
584
585    /**
586     * Wraps long summaries to avoid exceeding line width.
587     *
588     * @param text the text to wrap
589     * @return the wrapped text
590     */
591    private static String wrapSummary(String text) {
592        final String result;
593        if (text == null || text.isEmpty()) {
594            result = "";
595        }
596        else if (text.length() <= MAX_LINE_WIDTH) {
597            result = text;
598        }
599        else {
600            result = performWrapping(text);
601        }
602        return result;
603    }
604
605    /**
606     * Performs wrapping of summary text.
607     *
608     * @param text the text to wrap
609     * @return the wrapped text
610     */
611    private static String performWrapping(String text) {
612        final int textLength = text.length();
613        final StringBuilder result = new StringBuilder(textLength + 100);
614        int pos = 0;
615        final String indent = ModuleJavadocParsingUtil.INDENT_LEVEL_14;
616        boolean firstLine = true;
617
618        while (pos < textLength) {
619            final int end = Math.min(pos + MAX_LINE_WIDTH, textLength);
620            if (end >= textLength) {
621                if (!firstLine) {
622                    result.append(indent);
623                }
624                result.append(text.substring(pos));
625                break;
626            }
627            int breakPos = text.lastIndexOf(' ', end);
628            if (breakPos <= pos) {
629                breakPos = end;
630            }
631            if (!firstLine) {
632                result.append(indent);
633            }
634            result.append(text, pos, breakPos);
635            pos = breakPos + 1;
636            firstLine = false;
637        }
638        return result.toString();
639    }
640
641    /**
642     * Data holder for each Check module entry.
643     */
644    private static final class CheckInfo {
645        /** Simple name of the check. */
646        private final String simpleName;
647        /** Documentation link. */
648        private final String link;
649        /** Short summary text. */
650        private final String summary;
651        /** Whether the module is a holder type. */
652        private final boolean isHolder;
653
654        /**
655         * Constructs an info record.
656         *
657         * @param simpleName check simple name
658         * @param link documentation link
659         * @param summary module summary
660         * @param isHolder whether holder
661         * @noinspection unused
662         * @noinspectionreason moduleName parameter is required for consistent API
663         *      but not used in this implementation
664         */
665        private CheckInfo(String simpleName, String link,
666                          String summary, boolean isHolder) {
667            this.simpleName = simpleName;
668            this.link = link;
669            this.summary = summary;
670            this.isHolder = isHolder;
671        }
672    }
673}