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.stream.Collectors;
030
031import org.apache.maven.doxia.macro.AbstractMacro;
032import org.apache.maven.doxia.macro.Macro;
033import org.apache.maven.doxia.macro.MacroExecutionException;
034import org.apache.maven.doxia.macro.MacroRequest;
035import org.apache.maven.doxia.sink.Sink;
036import org.codehaus.plexus.component.annotations.Component;
037
038import com.puppycrawl.tools.checkstyle.utils.InlineConfigUtils;
039
040/**
041 * A macro that inserts a snippet of code or configuration from a file.
042 */
043@Component(role = Macro.class, hint = "example")
044public class ExampleMacro extends AbstractMacro {
045
046    /** Starting delimiter for code snippets. */
047    private static final String CODE_SNIPPET_START = "xdoc section - start";
048
049    /** Ending delimiter for code snippets. */
050    private static final String CODE_SNIPPET_END = "xdoc section - end";
051
052    /** File extension for .properties files. */
053    private static final String PROPERTIES_EXTENSION = ".properties";
054
055    /** The {@code prettyprint} language class for XML content. */
056    private static final String LANGUAGE_XML = "language-xml";
057
058    /** The path of the last file. */
059    private String lastPath = "";
060
061    /** The line contents of the last file. */
062    private List<String> lastLines = new ArrayList<>();
063
064    /**
065     * Creates a new {@code ExampleMacro} instance.
066     */
067    public ExampleMacro() {
068        // no code by default
069    }
070
071    @Override
072    public void execute(Sink sink, MacroRequest request) throws MacroExecutionException {
073        final String path = (String) request.getParameter("path");
074        final String type = (String) request.getParameter("type");
075
076        List<String> lines = lastLines;
077        if (!path.equals(lastPath)) {
078            lines = readFile("src/xdocs-examples/" + path);
079            lastPath = path;
080            lastLines = lines;
081        }
082
083        if ("config".equals(type)) {
084            writeConfigSnippet(sink, lines, path);
085        }
086        else if ("code".equals(type)) {
087            writeCodeSnippet(sink, lines, path);
088        }
089        else if ("raw".equals(type)) {
090            final String content = String.join(ModuleJavadocParsingUtil.NEWLINE, lines);
091            writeSnippet(sink, content, getExtension(path), false);
092        }
093        else {
094            final String message = String.format(Locale.ROOT, "Unknown example type: %s", type);
095            throw new MacroExecutionException(message);
096        }
097    }
098
099    /**
100     * Extracts the config snippet from the given lines and writes it to the sink.
101     *
102     * @param sink the sink to write to.
103     * @param lines the lines of the source file.
104     * @param path the file path, used for extension detection and error messages.
105     * @throws MacroExecutionException if the config block is invalid or empty.
106     */
107    private static void writeConfigSnippet(Sink sink, List<String> lines, String path)
108            throws MacroExecutionException {
109        final String extension = getExtension(path);
110        final String config;
111        try {
112            config = getConfigSnippet(lines, extension);
113        }
114        catch (IllegalArgumentException illegalArgumentException) {
115            final String message = String.format(Locale.ROOT,
116                    "%s (in %s)", illegalArgumentException.getMessage(), path
117            );
118            throw new MacroExecutionException(message, illegalArgumentException);
119        }
120
121        if (config.isBlank()) {
122            final String message = String.format(Locale.ROOT,
123                    "Empty config snippet from %s, check"
124                            + " for xml config snippet delimiters in input file.", path
125            );
126            throw new MacroExecutionException(message);
127        }
128
129        writeSnippet(sink, config, extension, true);
130    }
131
132    /**
133     * Extracts the code snippet from the given lines and writes it to the sink.
134     *
135     * @param sink the sink to write to.
136     * @param lines the lines of the source file.
137     * @param path the file path, used for tab-replacement detection and error messages.
138     * @throws MacroExecutionException if the code snippet is empty.
139     */
140    private static void writeCodeSnippet(Sink sink, List<String> lines, String path)
141            throws MacroExecutionException {
142        String code = getCodeSnippet(lines);
143        // Replace tabs with spaces for FileTabCharacterCheck examples
144        if (path.contains("filetabcharacter")) {
145            code = code.replace("\t", "  ");
146        }
147
148        if (code.isBlank()) {
149            final String message = String.format(Locale.ROOT,
150                    "Empty code snippet from %s, check"
151                            + " for code snippet delimiters in input file.", path
152            );
153            throw new MacroExecutionException(message);
154        }
155
156        writeSnippet(sink, code, getExtension(path), false);
157    }
158
159    /**
160     * Read the file at the given path and returns its contents as a list of lines.
161     *
162     * @param path the path to the file to read.
163     * @return the contents of the file as a list of lines.
164     * @throws MacroExecutionException if the file could not be read.
165     */
166    private static List<String> readFile(String path) throws MacroExecutionException {
167        try {
168            final Path exampleFilePath = Path.of(path);
169            return Files.readAllLines(exampleFilePath);
170        }
171        catch (IOException ioException) {
172            final String message = String.format(Locale.ROOT, "Failed to read %s", path);
173            throw new MacroExecutionException(message, ioException);
174        }
175    }
176
177    /**
178     * Extracts the file extension (including the leading dot) from a file path, or an
179     * empty string if the path has no extension.
180     *
181     * @param path the file path.
182     * @return the file extension, e.g. {@code ".xml"}, or {@code ""} if none.
183     */
184    private static String getExtension(String path) {
185        final int dotIndex = path.lastIndexOf('.');
186        String result = "";
187        if (dotIndex >= 0) {
188            result = path.substring(dotIndex);
189        }
190        return result;
191    }
192
193    /**
194     * Extracts the configuration snippet from the given lines.
195     *
196     * @param lines the lines to extract the config from.
197     * @param extension the file extension (e.g. {@code ".xml"}), used to decide which
198     *     delimiter conventions are valid.
199     * @return the configuration snippet.
200     * @throws IllegalArgumentException if the config block is invalid.
201     */
202    private static String getConfigSnippet(Collection<String> lines, String extension) {
203        final List<String> linesList = new ArrayList<>(lines);
204        final InlineConfigUtils.MatchedDelimiter matched =
205                InlineConfigUtils.matchDelimiter(linesList, extension);
206
207        if (matched == null) {
208            final String message = String.format(Locale.ROOT,
209                    "No valid config block found. Expected the first line to be %s.",
210                    InlineConfigUtils.describeExpectedDelimiters(extension)
211            );
212            throw new IllegalArgumentException(message);
213        }
214
215        final int endIndex = InlineConfigUtils.getConfigEndIndex(linesList, matched);
216        if (endIndex <= 0) {
217            final String message = String.format(Locale.ROOT,
218                    "Config start delimiter found but no matching end delimiter \"%s\".",
219                    matched.end()
220            );
221            throw new IllegalArgumentException(message);
222        }
223
224        final List<String> configLines = linesList.subList(1, endIndex);
225        final String result;
226        if (PROPERTIES_EXTENSION.equals(extension)) {
227            result = String.join(ModuleJavadocParsingUtil.NEWLINE,
228                    InlineConfigUtils.stripPropertiesCommentPrefix(configLines));
229        }
230        else {
231            result = String.join(ModuleJavadocParsingUtil.NEWLINE, configLines);
232        }
233
234        return result;
235    }
236
237    /**
238     * Extract a code snippet from the given lines. Code delimiters can be indented, so
239     * we use contains() instead of equals(). If the delimiters are not found, returns
240     * the file content excluding the XML config block (if present).
241     *
242     * @param lines the lines to extract the snippet from.
243     * @return the code snippet.
244     */
245    private static String getCodeSnippet(Collection<String> lines) {
246        final String snippet = lines.stream()
247                .dropWhile(line -> !hasCodeSnippetStart(line))
248                .skip(1)
249                .takeWhile(line -> !hasCodeSnippetEnd(line))
250                .collect(Collectors.joining(ModuleJavadocParsingUtil.NEWLINE));
251
252        // If no snippet was found (markers not present), return the file content
253        // excluding the XML config block (if present)
254        final String result;
255        if (snippet.isBlank()) {
256            final List<String> linesList = new ArrayList<>(lines);
257            final int configEndIndex = linesList.indexOf(InlineConfigUtils.JAVA_CONFIG_END);
258            if (configEndIndex >= 0) {
259                // XML config block is present, return content after it
260                result = String.join(ModuleJavadocParsingUtil.NEWLINE,
261                        linesList.stream()
262                                .skip(configEndIndex + 1)
263                                .toList());
264            }
265            else {
266                // No XML config block, return entire file
267                result = String.join(ModuleJavadocParsingUtil.NEWLINE, linesList);
268            }
269        }
270        else {
271            result = snippet;
272        }
273
274        return result;
275    }
276
277    /**
278     * Checks if the line contains a code snippet start delimiter.
279     *
280     * @param line the line to check.
281     * @return true if the line contains a start delimiter.
282     */
283    private static boolean hasCodeSnippetStart(String line) {
284        return line.contains(CODE_SNIPPET_START);
285    }
286
287    /**
288     * Checks if the line contains a code snippet end delimiter.
289     *
290     * @param line the line to check.
291     * @return true if the line contains an end delimiter.
292     */
293    private static boolean hasCodeSnippetEnd(String line) {
294        return line.contains(CODE_SNIPPET_END);
295    }
296
297    /**
298     * Writes the given snippet inside a formatted source block, choosing syntax
299     * highlighting based on the source file's extension. Config snippets (the content
300     * inside a Java-comment-style config block) are always XML regardless of the
301     * target file's extension, except for {@code .properties} files, whose bare
302     * {@code #}-comment config block is not highlighted at all.
303     *
304     * @param sink the sink to write to.
305     * @param snippet the snippet to write.
306     * @param extension the source file extension, e.g. {@code ".xml"}, {@code ".sql"}.
307     * @param isConfig true if the snippet is a config block rather than a code block.
308     */
309    private static void writeSnippet(Sink sink, String snippet, String extension,
310                                     boolean isConfig) {
311        sink.rawText("<div class=\"wrapper\">");
312
313        final String languageClass = determineLanguageClass(extension, isConfig);
314        final String codeOpenTag;
315        if (languageClass == null) {
316            codeOpenTag = "<pre class=\"prettyprint\"><code>";
317        }
318        else {
319            codeOpenTag = "<pre class=\"prettyprint\"><code class=\"" + languageClass + "\">";
320        }
321
322        sink.rawText(codeOpenTag + ModuleJavadocParsingUtil.NEWLINE);
323        sink.rawText(escapeHtml(snippet).trim() + ModuleJavadocParsingUtil.NEWLINE);
324        sink.rawText("</code></pre>");
325        sink.rawText("</div>");
326    }
327
328    /**
329     * Determines the {@code prettyprint} language class to use for a snippet, based on
330     * the source file's extension.
331     *
332     * @param extension the source file extension, e.g. {@code ".xml"}, {@code ".sql"}.
333     * @param isConfig true if the snippet is a config block (always XML syntax, except
334     *     for {@code .properties} files.
335     * @return the language class to apply, or {@code null} if the snippet should not be
336     *     syntax-highlighted.
337     */
338    private static String determineLanguageClass(String extension, boolean isConfig) {
339        final String result;
340        if (PROPERTIES_EXTENSION.equals(extension)) {
341            result = null;
342        }
343        else if (isConfig || ".xml".equals(extension)) {
344            // Config blocks are always <module> XML, regardless of target file type.
345            result = LANGUAGE_XML;
346        }
347        else if (".sql".equals(extension)) {
348            result = "language-sql";
349        }
350        else {
351            result = "language-java";
352        }
353        return result;
354    }
355
356    /**
357     * Escapes HTML special characters in the snippet.
358     *
359     * @param snippet the snippet to escape.
360     * @return the escaped snippet.
361     */
362    private static String escapeHtml(String snippet) {
363        return snippet.replace("&", "&amp;")
364                .replace("<", "&lt;")
365                .replace(">", "&gt;");
366    }
367
368}