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.ArrayList;
026import java.util.Collection;
027import java.util.List;
028import java.util.Locale;
029import java.util.regex.Pattern;
030import java.util.stream.Collectors;
031
032import org.apache.maven.doxia.macro.AbstractMacro;
033import org.apache.maven.doxia.macro.Macro;
034import org.apache.maven.doxia.macro.MacroExecutionException;
035import org.apache.maven.doxia.macro.MacroRequest;
036import org.apache.maven.doxia.sink.Sink;
037import org.codehaus.plexus.component.annotations.Component;
038
039/**
040 * A macro that inserts a snippet of code or configuration from a file.
041 */
042@Component(role = Macro.class, hint = "example")
043public class ExampleMacro extends AbstractMacro {
044
045    /** Starting delimiter for config snippets. */
046    private static final String XML_CONFIG_START = "/*xml";
047
048    /** Ending delimiter for config snippets. */
049    private static final String XML_CONFIG_END = "*/";
050
051    /** Starting delimiter for code snippets. */
052    private static final String CODE_SNIPPET_START = "// xdoc section -- start";
053
054    /** Ending delimiter for code snippets. */
055    private static final String CODE_SNIPPET_END = "// xdoc section -- end";
056
057    /** The pattern of xml code blocks. */
058    private static final Pattern XML_PATTERN = Pattern.compile(
059            "^\\s*(<!DOCTYPE\\s+.*?>|<\\?xml\\s+.*?>|<module\\s+.*?>)\\s*",
060            Pattern.DOTALL
061    );
062
063    /** The path of the last file. */
064    private String lastPath = "";
065
066    /** The line contents of the last file. */
067    private List<String> lastLines = new ArrayList<>();
068
069    /**
070     * Creates a new {@code ExampleMacro} instance.
071     */
072    public ExampleMacro() {
073        // no code by default
074    }
075
076    @Override
077    public void execute(Sink sink, MacroRequest request) throws MacroExecutionException {
078        final String path = (String) request.getParameter("path");
079        final String type = (String) request.getParameter("type");
080
081        List<String> lines = lastLines;
082        if (!path.equals(lastPath)) {
083            lines = readFile("src/xdocs-examples/" + path);
084            lastPath = path;
085            lastLines = lines;
086        }
087
088        if ("config".equals(type)) {
089            final String config = getConfigSnippet(lines);
090
091            if (config.isBlank()) {
092                final String message = String.format(Locale.ROOT,
093                        "Empty config snippet from %s, check"
094                                + " for xml config snippet delimiters in input file.", path
095                );
096                throw new MacroExecutionException(message);
097            }
098
099            writeSnippet(sink, config);
100        }
101        else if ("code".equals(type)) {
102            String code = getCodeSnippet(lines);
103            // Replace tabs with spaces for FileTabCharacterCheck examples
104            if (path.contains("filetabcharacter")) {
105                code = code.replace("\t", "  ");
106            }
107
108            if (code.isBlank()) {
109                final String message = String.format(Locale.ROOT,
110                        "Empty code snippet from %s, check"
111                                + " for code snippet delimiters in input file.", path
112                );
113                throw new MacroExecutionException(message);
114            }
115
116            writeSnippet(sink, code);
117        }
118        else if ("raw".equals(type)) {
119            final String content = String.join(ModuleJavadocParsingUtil.NEWLINE, lines);
120            writeSnippet(sink, content);
121        }
122        else {
123            final String message = String.format(Locale.ROOT, "Unknown example type: %s", type);
124            throw new MacroExecutionException(message);
125        }
126    }
127
128    /**
129     * Read the file at the given path and returns its contents as a list of lines.
130     *
131     * @param path the path to the file to read.
132     * @return the contents of the file as a list of lines.
133     * @throws MacroExecutionException if the file could not be read.
134     */
135    private static List<String> readFile(String path) throws MacroExecutionException {
136        try {
137            final Path exampleFilePath = Path.of(path);
138            return Files.readAllLines(exampleFilePath);
139        }
140        catch (IOException ioException) {
141            final String message = String.format(Locale.ROOT, "Failed to read %s", path);
142            throw new MacroExecutionException(message, ioException);
143        }
144    }
145
146    /**
147     * Extract a configuration snippet from the given lines. Config delimiters use the whole
148     * line for themselves and have no indentation. We use equals() instead of contains()
149     * to be more strict because some examples contain those delimiters. If the delimiters
150     * are not found, returns the entire file content.
151     *
152     * @param lines the lines to extract the snippet from.
153     * @return the configuration snippet.
154     */
155    private static String getConfigSnippet(Collection<String> lines) {
156        final String snippet = lines.stream()
157                .dropWhile(line -> !XML_CONFIG_START.equals(line))
158                .skip(1)
159                .takeWhile(line -> !XML_CONFIG_END.equals(line))
160                .collect(Collectors.joining(ModuleJavadocParsingUtil.NEWLINE));
161
162        // If no snippet was found (markers not present), return the entire file content
163        final String result;
164        if (snippet.isBlank()) {
165            result = String.join(ModuleJavadocParsingUtil.NEWLINE, lines);
166        }
167        else {
168            result = snippet;
169        }
170
171        return result;
172    }
173
174    /**
175     * Extract a code snippet from the given lines. Code delimiters can be indented, so
176     * we use contains() instead of equals(). If the delimiters are not found, returns
177     * the file content excluding the XML config block (if present).
178     *
179     * @param lines the lines to extract the snippet from.
180     * @return the code snippet.
181     */
182    private static String getCodeSnippet(Collection<String> lines) {
183        final String snippet = lines.stream()
184                .dropWhile(line -> !line.contains(CODE_SNIPPET_START))
185                .skip(1)
186                .takeWhile(line -> !line.contains(CODE_SNIPPET_END))
187                .collect(Collectors.joining(ModuleJavadocParsingUtil.NEWLINE));
188
189        // If no snippet was found (markers not present), return the file content
190        // excluding the XML config block (if present)
191        final String result;
192        if (snippet.isBlank()) {
193            final List<String> linesList = new ArrayList<>(lines);
194            final int configEndIndex = linesList.indexOf(XML_CONFIG_END);
195            if (configEndIndex >= 0) {
196                // XML config block is present, return content after it
197                result = String.join(ModuleJavadocParsingUtil.NEWLINE,
198                        linesList.stream()
199                                .skip(configEndIndex + 1)
200                                .toList());
201            }
202            else {
203                // No XML config block, return entire file
204                result = String.join(ModuleJavadocParsingUtil.NEWLINE, linesList);
205            }
206        }
207        else {
208            result = snippet;
209        }
210
211        return result;
212    }
213
214    /**
215     * Writes the given snippet inside a formatted source block.
216     *
217     * @param sink the sink to write to.
218     * @param snippet the snippet to write.
219     */
220    private static void writeSnippet(Sink sink, String snippet) {
221        sink.rawText("<div class=\"wrapper\">");
222        final boolean isXml = isXml(snippet);
223
224        final String languageClass;
225        if (isXml) {
226            languageClass = "language-xml";
227        }
228        else {
229            languageClass = "language-java";
230        }
231        sink.rawText("<pre class=\"prettyprint\"><code class=\"" + languageClass + "\">"
232            + ModuleJavadocParsingUtil.NEWLINE);
233        sink.rawText(escapeHtml(snippet).trim() + ModuleJavadocParsingUtil.NEWLINE);
234        sink.rawText("</code></pre>");
235        sink.rawText("</div>");
236    }
237
238    /**
239     * Escapes HTML special characters in the snippet.
240     *
241     * @param snippet the snippet to escape.
242     * @return the escaped snippet.
243     */
244    private static String escapeHtml(String snippet) {
245        return snippet.replace("&", "&amp;")
246                .replace("<", "&lt;")
247                .replace(">", "&gt;");
248    }
249
250    /**
251     * Determines if the given snippet is likely an XML fragment.
252     *
253     * @param snippet the code snippet to analyze.
254     * @return {@code true} if the snippet appears to be XML, otherwise {@code false}.
255     */
256    private static boolean isXml(String snippet) {
257        return XML_PATTERN.matcher(snippet.trim()).matches();
258    }
259
260}