001///////////////////////////////////////////////////////////////////////////////////////////////
002// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
003// Copyright (C) 2001-2026 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.Files;
024import java.nio.file.Path;
025import java.util.LinkedHashMap;
026import java.util.Map;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.Set;
030import java.util.regex.Matcher;
031import java.util.regex.Pattern;
032
033import org.apache.maven.doxia.macro.AbstractMacro;
034import org.apache.maven.doxia.macro.Macro;
035import org.apache.maven.doxia.macro.MacroExecutionException;
036import org.apache.maven.doxia.macro.MacroRequest;
037import org.apache.maven.doxia.sink.Sink;
038import org.codehaus.plexus.component.annotations.Component;
039
040/**
041 * A macro that generates an "In this article" table of contents for an xdoc
042 * page. Every canonical section key in {@link #SECTION_NAMES} is attempted
043 * for every page; the anchor id used in the link is read directly from a
044 * matching {@code <subsection>} tag's real {@code id} attribute, so links
045 * can never drift from what the page actually renders, and any section not
046 * present in a given file's source is silently skipped -- pages do not need
047 * to declare which sections they have. Examples and Use Cases subsections
048 * additionally get nested entries, titled either by the first non-default
049 * {@code property=value} found in the example's referenced source file, or
050 * by a shortened form of the example's descriptive paragraph when no
051 * distinguishing property can be found.
052 */
053@Component(role = Macro.class, hint = "sitetoc")
054public class TocMacro extends AbstractMacro {
055
056    /** Section key/name: Description. */
057    private static final String SECTION_DESCRIPTION = "Description";
058
059    /** Section key/name: Properties. */
060    private static final String SECTION_PROPERTIES = "Properties";
061
062    /** Section key/name: Examples. */
063    private static final String SECTION_EXAMPLES = "Examples";
064
065    /** Section key: UseCases. */
066    private static final String SECTION_USE_CASES_KEY = "UseCases";
067
068    /** Closing quote and angle bracket used when terminating an HTML attribute. */
069    private static final String QUOTE_CLOSE_TAG = "\">";
070
071    /** Fallback title when no descriptive paragraph is available. */
072    private static final String DEFAULT_TITLE = "Default configuration";
073
074    /** A single double quote character, used to open HTML attribute values. */
075    private static final String QUOTE = "\"";
076
077    /** Base directory that example {@code path} params are resolved against. */
078    private static final String EXAMPLES_BASE_DIR = "src/xdocs-examples/";
079
080    /** Matches an Example/UseCase paragraph followed immediately by its example macro. */
081    private static final Pattern ITEM_WITH_PATH_PATTERN = Pattern.compile(
082            "<p\\s+id=\"((?:Example|UseCase)\\d+)-(config|raw)\"[^>]*>\\s*(.*?)\\s*</p>\\s*"
083                    + "<macro\\s+name=\"example\">\\s*"
084                    + "<param\\s+name=\"path\"\\s+value=\"([^\"]+)\"\\s*/>",
085            Pattern.DOTALL);
086
087    /** Matches a property assignment inside an example's embedded config comment. */
088    private static final Pattern PROPERTY_PATTERN = Pattern.compile(
089            "<property\\s+name=\"([^\"]+)\"\\s+value=\"([^\"]*)\"\\s*/>");
090
091    /** Strips the common "To configure the check to produce a violation on/when" lead-in. */
092    private static final Pattern LEAD_IN_PATTERN = Pattern.compile(
093            "^\\s*To configure(?: the check)?"
094                    + "(?:\\s+to\\s+(?:produce\\s+a\\s+violation)?)?"
095                    + "(?:\\s+on)?"
096                    + "(?:\\s+when)?\\s*",
097            Pattern.CASE_INSENSITIVE);
098
099    /** Strips inline HTML tags left in scraped paragraph text except {@code <code>} tags. */
100    private static final Pattern TAG_PATTERN = Pattern.compile(
101            "</?(?!code\\b)[a-zA-Z][^>]*>");
102
103    /** Collapses any run of whitespace (including newlines) into a single space. */
104    private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
105
106    /** Matches a subsection tag and captures its name/id attributes, in either order. */
107    private static final Pattern SUBSECTION_TAG_PATTERN = Pattern.compile(
108            "<subsection\\s+(?:name=\"([^\"]*)\"\\s+id=\"([^\"]*)\""
109                    + "|id=\"([^\"]*)\"\\s+name=\"([^\"]*)\")");
110
111    /** Property name to always skip when deriving a property=value title. */
112    private static final String TOKENS_PROPERTY = "tokens";
113
114    /** Regex group index of the {@code id} attribute when it appears second (name, id order). */
115    private static final int GROUP_ID_NAME_FIRST = 2;
116
117    /** Regex group index of the {@code id} attribute when it appears first (id, name order). */
118    private static final int GROUP_ID_ID_FIRST = 3;
119
120    /** Regex group index of the {@code name} attribute when it appears second (id, name order). */
121    private static final int GROUP_NAME_ID_FIRST = 4;
122
123    /**
124     * Regex group index of the {@code path} param's value in
125     * {@link #ITEM_WITH_PATH_PATTERN}. Coincidentally the same numeric value
126     * as {@link #GROUP_ID_ID_FIRST}, but kept as a distinct constant since the
127     * two patterns and group meanings are otherwise unrelated.
128     */
129    private static final int GROUP_EXAMPLE_PATH = 4;
130
131    /** Closing anchor/list-item tag pair used to end a TOC entry. */
132    private static final String LI_CLOSE = "</a></li>\n";
133
134    /**
135     * Maps every canonical section key, in the fixed order TOC entries
136     * should appear, to the subsection's {@code name} attribute value. Every
137     * key is attempted for every page; any section not present in a given
138     * file's source is silently skipped by {@link #writeSectionEntry}.
139     */
140    private static final Map<String, String> SECTION_NAMES = new LinkedHashMap<>();
141
142    static {
143        SECTION_NAMES.put(SECTION_DESCRIPTION, SECTION_DESCRIPTION);
144        SECTION_NAMES.put(SECTION_PROPERTIES, SECTION_PROPERTIES);
145        SECTION_NAMES.put(SECTION_EXAMPLES, SECTION_EXAMPLES);
146        SECTION_NAMES.put(SECTION_USE_CASES_KEY, "Use Cases");
147        SECTION_NAMES.put("ExampleOfUsage", "Example of Usage");
148        SECTION_NAMES.put("ViolationMessages", "Violation Messages");
149        SECTION_NAMES.put("FullyQualifiedName", "Fully Qualified Name");
150        SECTION_NAMES.put("ParentModule", "Parent Module");
151    }
152
153    /** Section keys that get nested sub-entries. */
154    private static final Set<String> NESTED_SECTIONS =
155            Set.of(SECTION_EXAMPLES, SECTION_USE_CASES_KEY);
156
157    /**
158     * Creates a new {@code TocMacro} instance.
159     */
160    public TocMacro() {
161        // no code by default
162    }
163
164    @Override
165    public void execute(Sink sink, MacroRequest request) {
166        final String modulePath = (String) request.getParameter("modulePath");
167        final String sourceContent = request.getSourceContent();
168
169        final Map<String, PropertyDetails> propertyDetails = loadPropertyDetails(modulePath);
170        final Path repoRoot = resolveRepoRoot(modulePath);
171        final TocContext context = new TocContext(sourceContent, propertyDetails, repoRoot);
172
173        sink.rawText("<div class=\"toc-panel\">\n");
174        sink.rawText("  <input type=\"checkbox\" id=\"toc-toggle\" "
175                + "class=\"toc-toggle-checkbox\" checked=\"checked\"/>\n");
176        sink.rawText("  <label for=\"toc-toggle\" class=\"toc-toggle-arrow\" "
177                + "title=\"Collapse\"/>\n");
178        sink.rawText("  <div class=\"toc-content\">\n");
179        sink.rawText("    <p class=\"toc-heading\">On This Page</p>\n");
180        sink.rawText("    <ul class=\"toc-list\">\n");
181
182        for (String section : SECTION_NAMES.keySet()) {
183            writeSectionEntry(sink, section, context);
184        }
185
186        sink.rawText("    </ul>\n");
187        sink.rawText("  </div>\n");
188        sink.rawText("</div>");
189    }
190
191    /**
192     * Writes a single top-level {@code <li>}, with nested Example/UseCase
193     * entries when the section is Examples or Use Cases. The anchor id is
194     * read directly from the matching {@code <subsection>} tag's actual
195     * {@code id} attribute in the source, rather than assumed from a naming
196     * convention, so the link can never drift from what the page really
197     * renders. If no matching {@code <subsection>} exists in the source,
198     * nothing is written for this section -- pages are not required to have
199     * every canonical section.
200     *
201     * @param sink sink to write to.
202     * @param section the canonical section key.
203     * @param context shared per-page context for resolving anchors and titles.
204     */
205    private static void writeSectionEntry(Sink sink, String section, TocContext context) {
206        final String sectionName = SECTION_NAMES.get(section);
207
208        if (sectionName != null) {
209            final Optional<String> anchorId =
210                    findSubsectionAnchor(context.sourceContent(), sectionName);
211
212            if (anchorId.isPresent()) {
213                final String anchor = anchorId.get();
214
215                if (NESTED_SECTIONS.contains(section)) {
216                    final String body = extractSectionBody(context.sourceContent(), anchor);
217                    sink.rawText("          <li>\n");
218                    final String toggleId = "toc-sub-" + section;
219                    sink.rawText("            <input type=\"checkbox\" id=\"" + toggleId
220                            + "\" class=\"toc-sub-toggle-checkbox\" checked=\"checked\"/>\n");
221                    sink.rawText("            <label for=\""
222                            + toggleId + "\" class=\"toc-sub-toggle\">"
223                            + "<a href=\"#" + anchor + QUOTE_CLOSE_TAG + sectionName + "</a>"
224                            + "<span class=\"toc-sub-arrow\"/></label>\n");
225                    writeNestedItems(sink, body, context);
226                    sink.rawText("          </li>\n");
227                }
228                else {
229                    sink.rawText("          <li><a href=\"#" + anchor + QUOTE_CLOSE_TAG
230                            + sectionName + LI_CLOSE);
231                }
232            }
233        }
234    }
235
236    /**
237     * Finds the actual {@code id} attribute value of the {@code <subsection>}
238     * tag whose {@code name} attribute matches the given section name.
239     *
240     * @param sourceContent the full template source text.
241     * @param sectionName the subsection's {@code name} attribute value to match.
242     * @return the subsection's real id, or empty if no matching tag is found.
243     */
244    private static Optional<String> findSubsectionAnchor(String sourceContent,
245                                                         String sectionName) {
246        final Matcher matcher = SUBSECTION_TAG_PATTERN.matcher(sourceContent);
247        Optional<String> result = Optional.empty();
248
249        while (matcher.find() && result.isEmpty()) {
250            final String nameNameFirst = matcher.group(1);
251            final String name;
252            if (nameNameFirst != null) {
253                name = nameNameFirst;
254            }
255            else {
256                name = matcher.group(GROUP_NAME_ID_FIRST);
257            }
258            final String idNameFirst = matcher.group(GROUP_ID_NAME_FIRST);
259            final String id;
260            if (idNameFirst != null) {
261                id = idNameFirst;
262            }
263            else {
264                id = matcher.group(GROUP_ID_ID_FIRST);
265            }
266            if (sectionName.equals(name)) {
267                result = Optional.of(id);
268            }
269        }
270        return result;
271    }
272
273    /**
274     * Extracts the text of one subsection from the full source, bounded by
275     * that subsection's opening tag and the next subsection's opening tag.
276     *
277     * @param sourceContent the full template source text.
278     * @param anchorId the subsection's id attribute value.
279     * @return the subsection's raw inner text, or an empty string if not found.
280     */
281    private static String extractSectionBody(String sourceContent, String anchorId) {
282        String body = "";
283        final int start = sourceContent.indexOf("id=\"" + anchorId + QUOTE);
284        if (start >= 0) {
285            final int nextSubsection = sourceContent.indexOf("<subsection", start + 1);
286            final int end;
287            if (nextSubsection >= 0) {
288                end = nextSubsection;
289            }
290            else {
291                end = sourceContent.length();
292            }
293            body = sourceContent.substring(start, end);
294        }
295        return body;
296    }
297
298    /**
299     * Writes nested {@code <li>} entries for each Example/UseCase found
300     * within a subsection's body text, titled by property=value when
301     * possible, falling back to a shortened descriptive sentence. Titles
302     * are shown in full and allowed to wrap across lines, rather than being
303     * truncated with an ellipsis, so the whole label is always readable.
304     *
305     * @param sink sink to write to.
306     * @param sectionBody the raw text of the subsection.
307     * @param context shared per-page context for resolving titles.
308     */
309    private static void writeNestedItems(Sink sink, String sectionBody, TocContext context) {
310        final Matcher itemMatcher = ITEM_WITH_PATH_PATTERN.matcher(sectionBody);
311        boolean hasItems = false;
312
313        while (itemMatcher.find()) {
314            if (!hasItems) {
315                sink.rawText("            <ul class=\"toc-sublist\">\n");
316                hasItems = true;
317            }
318            final String anchorId = itemMatcher.group(1);
319            final String suffix = itemMatcher.group(2);
320            final String rawParagraph = itemMatcher.group(3);
321            final String fallbackTitle;
322            if (rawParagraph == null) {
323                fallbackTitle = DEFAULT_TITLE;
324            }
325            else {
326                fallbackTitle = toSentenceTitle(rawParagraph);
327            }
328            final String examplePath = itemMatcher.group(GROUP_EXAMPLE_PATH);
329
330            final String title;
331            if (examplePath == null) {
332                title = fallbackTitle;
333            }
334            else {
335                title = derivePropertyTitle(examplePath, context).orElse(fallbackTitle);
336            }
337
338            sink.rawText("              <li><a href=\"#" + anchorId
339                    + "-" + suffix + "\" class=\"toc-sublink\">" + title + LI_CLOSE);
340        }
341
342        if (hasItems) {
343            sink.rawText("            </ul>\n");
344        }
345    }
346
347    /**
348     * Loads documented property defaults for the current module, if a
349     * {@code modulePath} param was supplied. Returns an empty map otherwise,
350     * or if lookup fails for any reason -- property-based titling is a
351     * nice-to-have, not something that should break page generation.
352     *
353     * @param modulePath path to the module's Java source, or {@code null}.
354     * @return a map of property name to its documented details.
355     */
356    private static Map<String, PropertyDetails> loadPropertyDetails(String modulePath) {
357        Map<String, PropertyDetails> result = Map.of();
358        if (modulePath != null) {
359            try {
360                final Path modulePathObj = Path.of(modulePath.replace('\\', '/'));
361                final Path fileName = modulePathObj.getFileName();
362                if (fileName != null) {
363                    final String moduleName = fileName.toString().replace("Check.java", "");
364                    final Object instance = SiteUtil.getModuleInstance(moduleName);
365                    result = SiteUtil.buildPropertyDetails(
366                            SiteUtil.getPropertiesForDocumentation(instance.getClass(), instance),
367                            moduleName, modulePathObj, instance);
368                }
369            }
370            catch (MacroExecutionException ignored) {
371                result = Map.of();
372            }
373        }
374        return result;
375    }
376
377    /**
378     * Derives the repository root from the module's source path so example
379     * resource paths (relative to {@link #EXAMPLES_BASE_DIR}) can be resolved
380     * to an absolute file location.
381     *
382     * @param modulePath path to the module's Java source, or {@code null}.
383     * @return the repository root, or the current working directory if
384     *         {@code modulePath} is absent or doesn't contain the expected marker.
385     */
386    private static Path resolveRepoRoot(String modulePath) {
387        Path result = Path.of("");
388        if (modulePath != null) {
389            final String normalized = modulePath.replace('\\', '/');
390            final int marker = normalized.indexOf("src/main/java");
391            if (marker > 0) {
392                result = Path.of(normalized.substring(0, marker));
393            }
394        }
395        return result;
396    }
397
398    /**
399     * Reads an example's referenced source file and finds the first property
400     * whose value differs from its documented default, skipping {@code tokens}.
401     *
402     * @param examplePath the {@code path} param value from the example macro.
403     * @param context shared per-page context providing property defaults and repo root.
404     * @return an "name=value" title, or empty if the file can't be read or
405     *         every property in it matches its documented default.
406     */
407    private static Optional<String> derivePropertyTitle(String examplePath, TocContext context) {
408        Optional<String> result = Optional.empty();
409        final Map<String, PropertyDetails> propertyDetails = context.propertyDetails();
410        if (!propertyDetails.isEmpty()) {
411            try {
412                final Path fullPath = context.repoRoot()
413                        .resolve(EXAMPLES_BASE_DIR + examplePath.replace('\\', '/'));
414                final String content = Files.readString(fullPath);
415                final Matcher propMatcher = PROPERTY_PATTERN.matcher(content);
416
417                while (propMatcher.find() && result.isEmpty()) {
418                    result = extractPropertyTitle(propMatcher, propertyDetails);
419                }
420            }
421            catch (IOException ignored) {
422                result = Optional.empty();
423            }
424        }
425        return result;
426    }
427
428    /**
429     * Extracts a property title from a matcher group if the property differs
430     * from its default value.
431     *
432     * @param propMatcher the property matcher positioned at a match.
433     * @param propertyDetails the documented property defaults.
434     * @return an "name=value" title, or empty if the property matches default.
435     */
436    private static Optional<String> extractPropertyTitle(
437            Matcher propMatcher, Map<String, PropertyDetails> propertyDetails) {
438        final String name = propMatcher.group(1);
439        final String value = propMatcher.group(2);
440        Optional<String> result = Optional.empty();
441
442        if (name != null && value != null && !TOKENS_PROPERTY.equals(name)) {
443            final PropertyDetails details = propertyDetails.get(name);
444            final boolean isDefault = details == null
445                    || Objects.equals(details.getDefaultValue(), value);
446            if (!isDefault) {
447                result = Optional.of(name + "=" + value);
448            }
449        }
450        return result;
451    }
452
453    /**
454     * Converts a raw "ExampleN-config"/"UseCaseN-config" paragraph into a
455     * short sentence-based title by removing inline markup, normalizing
456     * whitespace, stripping the common lead-in clause, and trimming the
457     * trailing colon. Used as a fallback when no distinguishing property
458     * can be found.
459     *
460     * @param rawParagraph the paragraph's raw inner text.
461     * @return a short, human-readable title.
462     */
463    private static String toSentenceTitle(String rawParagraph) {
464        String text = TAG_PATTERN.matcher(rawParagraph).replaceAll("");
465        text = normalizeWhitespace(text);
466        text = LEAD_IN_PATTERN.matcher(text).replaceFirst("");
467        text = text.strip();
468        if (text.endsWith(":")) {
469            text = text.substring(0, text.length() - 1);
470        }
471        if (text.isEmpty()) {
472            text = DEFAULT_TITLE;
473        }
474        else {
475            text = Character.toUpperCase(text.charAt(0)) + text.substring(1);
476        }
477        return text;
478    }
479
480    /**
481     * Collapses internal newlines/indentation from raw xdoc paragraph text
482     * into single spaces, so tooltips and labels render on one line.
483     *
484     * @param text the raw text, possibly containing multi-line whitespace.
485     * @return the text with all whitespace runs collapsed to a single space.
486     */
487    private static String normalizeWhitespace(String text) {
488        return WHITESPACE_PATTERN.matcher(text).replaceAll(" ").strip();
489    }
490
491    /**
492     * Bundles the per-page context needed while writing TOC entries, so
493     * helper methods don't need long parameter lists.
494     *
495     * @param sourceContent the full template source text.
496     * @param propertyDetails documented property defaults for this module.
497     * @param repoRoot repository root, for resolving example file paths.
498     */
499    private record TocContext(String sourceContent,
500                              Map<String, PropertyDetails> propertyDetails, Path repoRoot) {
501    }
502
503}