diff --git a/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java new file mode 100644 index 00000000..6546dbb4 --- /dev/null +++ b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java @@ -0,0 +1,562 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.qameta.allure.description; + +import java.util.Locale; + +/** + * Renders raw JavaDoc comment text into a safe, markdown-friendly description for Allure. + * + *
This renderer intentionally implements a small, conservative subset of the JavaDoc comment + * specification instead of attempting to preserve the full doclet output. The goal is to keep + * JavaDoc-backed descriptions readable in reports while ensuring that untrusted comment content is + * never treated as executable HTML.
+ * + *The rendering algorithm is intentionally simple:
+ *Currently supported JavaDoc constructs include inline tags such as {@code {@code ...}}, + * {@code {@literal ...}}, {@code {@link ...}}, and {@code {@linkplain ...}}.
+ * + *The renderer also understands a small set of structural HTML tags: {@code p}, {@code br}, + * {@code ul}, {@code ol}, {@code li}, and {@code code}. Common entity references such as + * {@code <}, {@code >}, {@code &}, {@code @}, + * {@code {}, and numeric entities are decoded before the output is escaped again.
+ * + *Unsupported tags are degraded to escaped text instead of being interpreted. Unknown HTML tags + * are ignored as markup while their text content remains visible. This keeps the JavaDoc + * description path suitable for open source projects where comments may evolve over time and where + * security is more important than pixel-perfect parity with the standard doclet.
+ */ +final class JavaDocDescriptionRenderer { + + private static final String PARAGRAPH_BREAK = "\n\n"; + private static final String HTML_LT = "<"; + private static final String HTML_GT = ">"; + private static final String HTML_AMP = "&"; + private static final String INLINE_CODE_MARKER = "`"; + private static final String ESCAPED_INLINE_CODE_MARKER = "``"; + private static final String CODE_TAG = "code"; + private static final String HTML_TAG_END = ">"; + private static final String CLOSING_TAG_PREFIX = ""; + + /** + * Converts raw JavaDoc comment text into the description format stored by the annotation + * processor. + * + *The method extracts the JavaDoc main description, renders the supported inline and HTML + * constructs into plain text or markdown, normalizes whitespace, and escapes unsafe content. + * The returned value is intended for Allure's plain {@code description} field, not for + * {@code descriptionHtml}.
+ * + * @param rawDocComment the comment text returned by {@link javax.lang.model.util.Elements#getDocComment} + * @return a safe markdown/plain-text description, or an empty string if the comment has no main + * description + */ + String render(final String rawDocComment) { + final String descriptionBody = extractDescriptionBody(rawDocComment); + if (descriptionBody.isEmpty()) { + return ""; + } + + final StringBuilder rendered = new StringBuilder(); + renderFragment(descriptionBody, rendered); + return cleanup(rendered.toString()); + } + + private String extractDescriptionBody(final String rawDocComment) { + final String[] lines = normalize(rawDocComment).split("\n", -1); + final StringBuilder body = new StringBuilder(); + int inlineTagDepth = 0; + + for (String line : lines) { + if (inlineTagDepth == 0 && startsBlockTag(line)) { + return trimBlankLines(body.toString()); + } + if (body.length() > 0) { + body.append('\n'); + } + body.append(trimTrailingWhitespace(line)); + inlineTagDepth = updateInlineTagDepth(line, inlineTagDepth); + } + + return trimBlankLines(body.toString()); + } + + private boolean startsBlockTag(final String line) { + final String trimmed = line.trim(); + return trimmed.length() > 1 && trimmed.charAt(0) == '@' && Character.isJavaIdentifierStart(trimmed.charAt(1)); + } + + @SuppressWarnings("checkstyle:CyclomaticComplexity") + private void renderFragment(final String fragment, final StringBuilder rendered) { + int index = 0; + while (index < fragment.length()) { + final char current = fragment.charAt(index); + if (current == '{' && index + 1 < fragment.length() && fragment.charAt(index + 1) == '@') { + final int nextIndex = renderInlineTag(fragment, index, rendered); + if (nextIndex > index) { + index = nextIndex; + continue; + } + } + if (current == '<') { + final int nextIndex = renderHtmlTag(fragment, index, rendered); + if (nextIndex > index) { + index = nextIndex; + continue; + } + rendered.append(HTML_LT); + index++; + continue; + } + if (current == '&') { + final int nextIndex = renderEntityReference(fragment, index, rendered); + if (nextIndex > index) { + index = nextIndex; + continue; + } + rendered.append(HTML_AMP); + index++; + continue; + } + if (current == '>') { + rendered.append(HTML_GT); + index++; + continue; + } + rendered.append(current); + index++; + } + } + + @SuppressWarnings("checkstyle:ReturnCount") + private int renderInlineTag(final String fragment, final int start, final StringBuilder rendered) { + final int end = findInlineTagEnd(fragment, start); + if (end < 0) { + return start; + } + + final String content = fragment.substring(start + 2, end).trim(); + if (content.isEmpty()) { + return end + 1; + } + + final int separator = findWhitespace(content); + final String tag = separator < 0 ? content : content.substring(0, separator); + final String payload = separator < 0 ? "" : content.substring(separator + 1).trim(); + + if (CODE_TAG.equals(tag)) { + appendCode(rendered, payload); + return end + 1; + } + if ("literal".equals(tag)) { + rendered.append(escapeText(payload)); + return end + 1; + } + if ("link".equals(tag) || "linkplain".equals(tag)) { + appendLink(rendered, payload); + return end + 1; + } + + rendered.append(escapeText(content)); + return end + 1; + } + + @SuppressWarnings({ + "checkstyle:CyclomaticComplexity", + "checkstyle:NPathComplexity", + "checkstyle:ReturnCount"}) + private int renderHtmlTag(final String fragment, final int start, final StringBuilder rendered) { + if (start + 1 >= fragment.length() || Character.isWhitespace(fragment.charAt(start + 1))) { + return start; + } + + final int end = fragment.indexOf('>', start + 1); + if (end < 0) { + return start; + } + + final String rawTag = fragment.substring(start + 1, end).trim(); + if (rawTag.isEmpty()) { + return start; + } + + boolean closing = false; + String tag = rawTag; + if (tag.charAt(0) == '/') { + closing = true; + tag = tag.substring(1).trim(); + } + + if (tag.endsWith("/")) { + tag = tag.substring(0, tag.length() - 1).trim(); + } + + final int separator = findTagNameEnd(tag); + if (separator <= 0) { + return end + 1; + } + + final String name = tag.substring(0, separator).toLowerCase(Locale.ROOT); + if ("br".equals(name)) { + appendLineBreak(rendered); + return end + 1; + } + if ("p".equals(name) || "ul".equals(name) || "ol".equals(name)) { + appendParagraphBreak(rendered); + return end + 1; + } + if ("li".equals(name)) { + if (!closing) { + startListItem(rendered); + } + return end + 1; + } + if (CODE_TAG.equals(name)) { + if (closing) { + return end + 1; + } + final int closingStart = findClosingTag(fragment, end + 1, CODE_TAG); + if (closingStart <= end) { + return end + 1; + } + appendCode(rendered, fragment.substring(end + 1, closingStart)); + return closingStart + (CLOSING_TAG_PREFIX + CODE_TAG + HTML_TAG_END).length(); + } + + return end + 1; + } + + private void appendLink(final StringBuilder rendered, final String payload) { + if (payload.isEmpty()) { + return; + } + + final int separator = findWhitespace(payload); + final String label = separator < 0 ? "" : payload.substring(separator + 1).trim(); + if (label.isEmpty()) { + final String reference = separator < 0 ? payload : payload.substring(0, separator); + rendered.append(escapeText(shortenReference(reference))); + return; + } + + renderFragment(label, rendered); + } + + private String shortenReference(final String reference) { + final String trimmed = reference.trim(); + final int hashIndex = trimmed.lastIndexOf('#'); + if (hashIndex >= 0 && hashIndex + 1 < trimmed.length()) { + return trimmed.substring(hashIndex + 1); + } + + final int dotIndex = trimmed.lastIndexOf('.'); + if (dotIndex >= 0 && dotIndex + 1 < trimmed.length()) { + return trimmed.substring(dotIndex + 1); + } + + return trimmed; + } + + private void appendCode(final StringBuilder rendered, final String payload) { + final String escaped = escapeText(payload); + final String marker = escaped.contains(INLINE_CODE_MARKER) + ? ESCAPED_INLINE_CODE_MARKER + : INLINE_CODE_MARKER; + rendered.append(marker) + .append(escaped) + .append(marker); + } + + private void startListItem(final StringBuilder rendered) { + trimTrailingSpaces(rendered); + if (rendered.length() > 0 && rendered.charAt(rendered.length() - 1) != '\n') { + rendered.append('\n'); + } + rendered.append("- "); + } + + private void appendParagraphBreak(final StringBuilder rendered) { + trimTrailingSpaces(rendered); + if (rendered.length() == 0 || endsWith(rendered, PARAGRAPH_BREAK)) { + return; + } + if (rendered.charAt(rendered.length() - 1) == '\n') { + rendered.append('\n'); + return; + } + rendered.append(PARAGRAPH_BREAK); + } + + private void appendLineBreak(final StringBuilder rendered) { + trimTrailingSpaces(rendered); + if (rendered.length() == 0 || rendered.charAt(rendered.length() - 1) == '\n') { + return; + } + rendered.append('\n'); + } + + private String cleanup(final String rendered) { + final String[] lines = normalize(rendered).split("\n", -1); + final StringBuilder cleaned = new StringBuilder(); + boolean blankLinePending = false; + + for (String line : lines) { + final String trimmed = line.trim(); + if (trimmed.isEmpty()) { + if (cleaned.length() > 0) { + blankLinePending = true; + } + continue; + } + + if (cleaned.length() > 0) { + cleaned.append(blankLinePending ? PARAGRAPH_BREAK : "\n"); + } + cleaned.append(trimmed); + blankLinePending = false; + } + + return cleaned.toString(); + } + + private String trimBlankLines(final String value) { + final String[] lines = normalize(value).split("\n", -1); + int start = 0; + int end = lines.length; + + while (start < end && isBlank(lines[start])) { + start++; + } + while (end > start && isBlank(lines[end - 1])) { + end--; + } + + final StringBuilder result = new StringBuilder(); + for (int index = start; index < end; index++) { + if (result.length() > 0) { + result.append('\n'); + } + result.append(lines[index]); + } + return result.toString(); + } + + private int updateInlineTagDepth(final String line, final int initialDepth) { + int depth = initialDepth; + int index = 0; + while (index < line.length()) { + final char current = line.charAt(index); + if (depth == 0) { + if (current == '{' && index + 1 < line.length() && line.charAt(index + 1) == '@') { + depth = 1; + index += 2; + continue; + } + } else if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + } + index++; + } + return depth; + } + + private void trimTrailingSpaces(final StringBuilder builder) { + while (builder.length() > 0) { + final char current = builder.charAt(builder.length() - 1); + if (current != ' ' && current != '\t') { + break; + } + builder.deleteCharAt(builder.length() - 1); + } + } + + private boolean endsWith(final StringBuilder builder, final String suffix) { + return builder.length() >= suffix.length() + && builder.substring(builder.length() - suffix.length()).equals(suffix); + } + + private int findWhitespace(final String value) { + for (int index = 0; index < value.length(); index++) { + if (Character.isWhitespace(value.charAt(index))) { + return index; + } + } + return -1; + } + + private int findTagNameEnd(final String tag) { + for (int index = 0; index < tag.length(); index++) { + final char current = tag.charAt(index); + if (!(Character.isLetterOrDigit(current) || current == '-' || current == '_')) { + return index; + } + } + return tag.length(); + } + + private int findClosingTag(final String fragment, final int fromIndex, final String tagName) { + return fragment.toLowerCase(Locale.ROOT).indexOf(CLOSING_TAG_PREFIX + tagName + HTML_TAG_END, fromIndex); + } + + private int findInlineTagEnd(final String fragment, final int start) { + int depth = 1; + for (int index = start + 2; index < fragment.length(); index++) { + final char current = fragment.charAt(index); + if (current == '{') { + depth++; + continue; + } + if (current == '}') { + depth--; + if (depth == 0) { + return index; + } + } + } + return -1; + } + + private String trimTrailingWhitespace(final String line) { + int end = line.length(); + while (end > 0) { + final char current = line.charAt(end - 1); + if (current != ' ' && current != '\t') { + break; + } + end--; + } + return line.substring(0, end); + } + + private String normalize(final String value) { + return value.replace("\r\n", "\n").replace('\r', '\n'); + } + + private boolean isBlank(final String value) { + for (int index = 0; index < value.length(); index++) { + if (!Character.isWhitespace(value.charAt(index))) { + return false; + } + } + return true; + } + + private int renderEntityReference(final String fragment, final int start, final StringBuilder rendered) { + final int end = fragment.indexOf(';', start + 1); + if (end < 0) { + return start; + } + + final String decoded = decodeEntity(fragment.substring(start + 1, end)); + if (decoded == null) { + return start; + } + + rendered.append(escapeText(decoded)); + return end + 1; + } + + @SuppressWarnings({ + "checkstyle:CyclomaticComplexity", + "checkstyle:NPathComplexity", + "checkstyle:ReturnCount"}) + private String decodeEntity(final String entity) { + if (entity.isEmpty()) { + return null; + } + + if (entity.charAt(0) == '#') { + return decodeNumericEntity(entity); + } + + if ("amp".equals(entity)) { + return Character.toString('&'); + } + if ("lt".equals(entity)) { + return Character.toString('<'); + } + if ("gt".equals(entity)) { + return Character.toString('>'); + } + if ("quot".equals(entity)) { + return "\""; + } + if ("apos".equals(entity)) { + return "'"; + } + if ("nbsp".equals(entity)) { + return " "; + } + if ("lbrace".equals(entity)) { + return "{"; + } + if ("rbrace".equals(entity)) { + return "}"; + } + if ("commat".equals(entity)) { + return Character.toString('@'); + } + return null; + } + + private String decodeNumericEntity(final String entity) { + try { + final int codePoint; + if (entity.startsWith("#x") || entity.startsWith("#X")) { + codePoint = Integer.parseInt(entity.substring(2), 16); + } else { + codePoint = Integer.parseInt(entity.substring(1), 10); + } + return new String(Character.toChars(codePoint)); + } catch (IllegalArgumentException e) { + return null; + } + } + + private String escapeText(final String value) { + final StringBuilder escaped = new StringBuilder(); + int index = 0; + while (index < value.length()) { + final char current = value.charAt(index); + if (current == '&') { + final int nextIndex = renderEntityReference(value, index, escaped); + if (nextIndex > index) { + index = nextIndex; + continue; + } + escaped.append(HTML_AMP); + } else if (current == '<') { + escaped.append(HTML_LT); + } else if (current == '>') { + escaped.append(HTML_GT); + } else { + escaped.append(current); + } + index++; + } + return escaped.toString(); + } +} diff --git a/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java index f423c7bb..acc1d555 100644 --- a/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java +++ b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java @@ -54,6 +54,7 @@ public class JavaDocDescriptionsProcessor extends AbstractProcessor { private Filer filer; private Elements elementUtils; private Messager messager; + private JavaDocDescriptionRenderer renderer; @Override @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel") @@ -62,6 +63,7 @@ public synchronized void init(final ProcessingEnvironment env) { filer = env.getFiler(); elementUtils = env.getElementUtils(); messager = env.getMessager(); + renderer = new JavaDocDescriptionRenderer(); } @Override @@ -76,12 +78,11 @@ public boolean process(final Set extends TypeElement> annotations, final Round final SetSecond
Third
value < limit and stray tag");
+
+ assertThat(rendered)
+ .isEqualTo("Broken `value < limit and stray `tag");
+ }
+
+ @Test
+ void shouldIgnoreOpeningCodeTagWithoutClosingTag() {
+ final String rendered = renderer.render("Broken value < limit");
+
+ assertThat(rendered)
+ .isEqualTo("Broken value < limit");
+ }
+
+ @Test
+ void shouldIgnoreClosingCodeTagWithoutOpeningTag() {
+ final String rendered = renderer.render("Broken tag");
+
+ assertThat(rendered)
+ .isEqualTo("Broken tag");
+ }
+
+ @Test
+ void shouldRenderHtmlCodeTagAsCodeSpan() {
+ final String rendered = renderer.render("name < value & more");
+
+ assertThat(rendered)
+ .isEqualTo("`name < value & more`");
+ }
+
+ @Test
+ void shouldLeaveUnknownEntitiesEscaped() {
+ final String rendered = renderer.render("Keep ¬AnEntity; literal.");
+
+ assertThat(rendered)
+ .isEqualTo("Keep ¬AnEntity; literal.");
+ }
+
+ @Test
+ void shouldDropUnknownHtmlTagsButKeepTheirTextContentEscaped() {
+ final String rendered = renderer.render(
+ "prefix Use {@link java.net.URI URIs} for endpoint configuration.
\n" + + "client.fetch(\"v2\")\n"
+ + "@beta remains prose.\n"
+ + "@author Jane Doe\n"
+ + "@version 2.3.0\n"
+ + "@since 2.0"
+ );
+
+ assertThat(rendered)
+ .isEqualTo(
+ "Fetches release metadata for the current build.\n\n"
+ + "Use URIs for endpoint configuration.\n\n"
+ + "- Supports café, Привет, 東京, and λ.\n"
+ + "- See the Javadoc specification and formatted examples.\n\n"
+ + "Example: `client.fetch(\"v2\")`\n"
+ + "@beta remains prose."
+ );
+ }
+}
diff --git a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java
index bf29d9f1..29f80277 100644
--- a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java
+++ b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/ProcessDescriptionsTest.java
@@ -194,4 +194,108 @@ void captureDescriptionParametrizedTestWithPrimitivesParameterTest() {
.contentsAsUtf8String()
.isEqualTo("Captured javadoc description");
}
+
+ @Test
+ void shouldIgnoreBlockTagsAndRenderSafeMarkdown() {
+ final String expectedMethodSignatureHash = "4e7f896021ef2fce7c1deb7f5b9e38fb";
+
+ final JavaFileObject source = JavaFileObjects.forSourceLines(
+ "io.qameta.allure.description.test.DescriptionSample",
+ "package io.qameta.allure.description.test;",
+ "import io.qameta.allure.Description;",
+ "",
+ "public class DescriptionSample {",
+ "",
+ "/**",
+ "* This is my test description with {@code sample} and {@literal Use {@link java.lang.String String} for values.
", + "*Use {@link java.net.URI URIs} for endpoint configuration.
", + "*client.fetch(\"v2\")",
+ "* @beta remains prose.",
+ "*",
+ "* @author Jane Doe",
+ "* @version 2.3.0",
+ "* @since 2.0",
+ "* @see Javadoc spec",
+ "*/",
+ "@Description",
+ "public void sampleTest() {",
+ "}",
+ "}"
+ );
+
+ final Compiler compiler = javac().withProcessors(new JavaDocDescriptionsProcessor())
+ .withOptions("-Werror");
+ final Compilation compilation = compiler.compile(source);
+ assertThat(compilation)
+ .generatedFile(
+ StandardLocation.CLASS_OUTPUT,
+ "",
+ ALLURE_DESCRIPTIONS_FOLDER + expectedMethodSignatureHash
+ )
+ .contentsAsUtf8String()
+ .isEqualTo(
+ "Fetches release metadata for the current build.\n\n"
+ + "Use URIs for endpoint configuration.\n\n"
+ + "- Supports café, Привет, 東京, and λ.\n"
+ + "- See the Javadoc specification\n"
+ + "and formatted examples.\n\n"
+ + "Example: `client.fetch(\"v2\")`\n"
+ + "@beta remains prose."
+ );
+ }
}
diff --git a/allure-java-commons/src/main/java/io/qameta/allure/Description.java b/allure-java-commons/src/main/java/io/qameta/allure/Description.java
index b349a7c0..7c879ead 100644
--- a/allure-java-commons/src/main/java/io/qameta/allure/Description.java
+++ b/allure-java-commons/src/main/java/io/qameta/allure/Description.java
@@ -35,8 +35,7 @@
String value() default "";
/**
- * Use annotated method's javadoc to extract description that
- * supports html markdown.
+ * Use annotated method's javadoc to extract a safe markdown/plain-text description.
*
* @return boolean flag to enable description extraction from javadoc.
* @deprecated use {@link Description} without value specified instead.
diff --git a/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java b/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java
index 289df7df..d8d3b7f8 100644
--- a/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java
+++ b/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java
@@ -304,7 +304,7 @@ public static Optional