From a5a34a06c7abd8f5cdf3a915f65f6b63d9670411 Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Wed, 1 Apr 2026 15:36:26 +0100 Subject: [PATCH 1/6] new javadoc parsing --- .../JavaDocDescriptionRenderer.java | 566 ++++++++++++++++++ .../JavaDocDescriptionsProcessor.java | 5 +- .../JavaDocDescriptionRendererTest.java | 320 ++++++++++ .../description/ProcessDescriptionsTest.java | 104 ++++ 4 files changed, 993 insertions(+), 2 deletions(-) create mode 100644 allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java create mode 100644 allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java 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..577a86e9 --- /dev/null +++ b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java @@ -0,0 +1,566 @@ +/* + * 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:

+ *
    + *
  1. Take only the main description, stopping at the first block tag such as {@code @param} + * or {@code @throws}.
  2. + *
  3. Render the remaining content with a small parser that recognizes a limited set of inline + * JavaDoc tags and structural HTML tags.
  4. + *
  5. Escape or drop everything else so the output remains plain text or safe markdown.
  6. + *
+ * + *

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 = "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(); + boolean blockTagStarted = false; + int inlineTagDepth = 0; + + for (String line : lines) { + if (inlineTagDepth == 0 && startsBlockTag(line)) { + blockTagStarted = true; + } + if (blockTagStarted) { + continue; + } + 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) { + 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) { + final int closingStart = findClosingTag(fragment, end + 1, CODE_TAG); + if (closingStart > end) { + appendCode(rendered, fragment.substring(end + 1, closingStart)); + return closingStart + (CLOSING_TAG_PREFIX + CODE_TAG + HTML_TAG_END).length(); + } + return end + 1; + } + return end + 1; + } + + return end + 1; + } + + private void appendLink(final StringBuilder rendered, final String payload) { + if (payload.isEmpty()) { + return; + } + + final int separator = findWhitespace(payload); + if (separator < 0) { + rendered.append(escapeText(shortenReference(payload))); + return; + } + + final String label = payload.substring(separator + 1).trim(); + if (label.isEmpty()) { + rendered.append(escapeText(shortenReference(payload.substring(0, separator)))); + 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 annotations, final Round final Set methods = ElementFilter.methodsIn(elements); methods.forEach(method -> { final String rawDocs = elementUtils.getDocComment(method); - if (rawDocs == null) { return; } - final String docs = rawDocs.trim(); + final String docs = renderer.render(rawDocs); if (docs.isEmpty()) { return; } diff --git a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java new file mode 100644 index 00000000..6076d935 --- /dev/null +++ b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java @@ -0,0 +1,320 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class JavaDocDescriptionRendererTest { + + private final JavaDocDescriptionRenderer renderer = new JavaDocDescriptionRenderer(); + + @Test + void shouldRenderPlainTextAndTrimBlankLines() { + final String rendered = renderer.render( + "\r\n" + + " First line \r\n" + + "\r\n" + + " Second line\t\r\n" + + "\r\n" + ); + + assertThat(rendered) + .isEqualTo("First line\n\nSecond line"); + } + + @Test + void shouldReturnEmptyStringWhenBodyContainsOnlyBlockTags() { + final String rendered = renderer.render( + "@param value description\n" + + "@throws Exception description" + ); + + assertThat(rendered) + .isEmpty(); + } + + @Test + void shouldIgnoreBlockTagsAndEverythingAfterThem() { + final String rendered = renderer.render( + "Summary paragraph.\n" + + "\n" + + "@param value Description of the value.\n" + + "Continuation that should also be ignored." + ); + + assertThat(rendered) + .isEqualTo("Summary paragraph."); + } + + @Test + void shouldIgnoreStandardBlockTagsAfterMainDescription() { + final List blockTags = List.of( + "author", + "deprecated", + "exception", + "hidden", + "param", + "provides", + "return", + "see", + "serial", + "serialData", + "serialField", + "since", + "spec", + "throws", + "uses", + "version" + ); + + for (String blockTag : blockTags) { + assertThat(renderer.render("Summary paragraph.\n@" + blockTag + " metadata")) + .as(blockTag) + .isEqualTo("Summary paragraph."); + } + } + + @Test + void shouldNotTreatAtSignsInsideTextAsBlockTags() { + final String rendered = renderer.render( + "Email support@example.com\n" + + "Use @smoke in prose." + ); + + assertThat(rendered) + .isEqualTo("Email support@example.com\nUse @smoke in prose."); + } + + @Test + void shouldDecodeEscapedAtEntityBeforeBlockTags() { + final String rendered = renderer.render( + "@version stays in prose.\n" + + "@version 2.4.0" + ); + + assertThat(rendered) + .isEqualTo("@version stays in prose."); + } + + @Test + void shouldPreserveUnicodeCharactersInDescriptions() { + final String rendered = renderer.render("Release notes: cafe, café, Привет, 東京, λ."); + + assertThat(rendered) + .isEqualTo("Release notes: cafe, café, Привет, 東京, λ."); + } + + @Test + void shouldDecodeSupportedNamedAndNumericEntities() { + final String rendered = renderer.render( + "Use <tag>, &, {x}, @, λ, and λ." + ); + + assertThat(rendered) + .isEqualTo("Use <tag>, &, {x}, @, λ, and λ."); + } + + @Test + void shouldRenderSupportedInlineTags() { + final String rendered = renderer.render( + "Use {@code a < b}, {@literal }, " + + "{@link java.lang.String}, " + + "{@linkplain java.lang.String#valueOf(Object)}, " + + "{@link java.util.List list docs}." + ); + + assertThat(rendered) + .isEqualTo("Use `a < b`, <safe>, String, valueOf(Object), list docs."); + } + + @Test + void shouldSupportBalancedBracesInsideInlineTags() { + final String rendered = renderer.render( + "Payload {@code {\"outer\": {\"inner\": true}}}." + ); + + assertThat(rendered) + .isEqualTo("Payload `{\"outer\": {\"inner\": true}}`."); + } + + @Test + void shouldNotTreatAtLinesInsideBalancedInlineTagsAsBlockTags() { + final String rendered = renderer.render( + "Summary {@literal first line\n" + + "@notATag\n" + + "last line}\n" + + "@param ignored" + ); + + assertThat(rendered) + .isEqualTo("Summary first line\n@notATag\nlast line"); + } + + @Test + void shouldRenderNestedInlineTagsInsideLinkLabels() { + final String rendered = renderer.render( + "See {@linkplain java.util.List docs with {@code List}}." + ); + + assertThat(rendered) + .isEqualTo("See docs with `List`."); + } + + @Test + void shouldSafelyDegradeUnsupportedStandardInlineTags() { + final String rendered = renderer.render( + "Fallbacks: {@docRoot}, {@inheritDoc}, {@index release}, " + + "{@summary quick summary}, {@systemProperty user.home}, " + + "{@value java.lang.Integer#MAX_VALUE}." + ); + + assertThat(rendered) + .isEqualTo( + "Fallbacks: docRoot, inheritDoc, index release, summary quick summary, " + + "systemProperty user.home, value java.lang.Integer#MAX_VALUE." + ); + } + + @Test + void shouldSafelyDegradeSnippetTags() { + final String rendered = renderer.render( + "Snippet {@snippet :\n" + + "int answer = 42;\n" + + "@highlight substring=\"answer\"\n" + + "}." + ); + + assertThat(rendered) + .isEqualTo("Snippet snippet :\nint answer = 42;\n@highlight substring=\"answer\"."); + } + + @Test + void shouldEscapeUnknownInlineTags() { + final String rendered = renderer.render("Unsupported {@unknown } clause."); + + assertThat(rendered) + .isEqualTo("Unsupported unknown <tag> clause."); + } + + @Test + void shouldPreserveMalformedInlineTagsAsText() { + final String rendered = renderer.render("Broken {@code tag"); + + assertThat(rendered) + .isEqualTo("Broken {@code tag"); + } + + @Test + void shouldRenderSupportedHtmlStructure() { + final String rendered = renderer.render( + "First

Second
Third

  • one
  • two
  1. three
" + ); + + assertThat(rendered) + .isEqualTo("First\n\nSecond\nThird\n\n- one\n- two\n\n- three"); + } + + @Test + void shouldIgnoreUnclosedHtmlTagsSafely() { + final String rendered = renderer.render("Broken bold text"); + + assertThat(rendered) + .isEqualTo("Broken bold text"); + } + + @Test + void shouldIgnoreUnmatchedCodeHtmlTagsSafely() { + final String rendered = renderer.render("Broken 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 &notAnEntity; literal."); + } + + @Test + void shouldDropUnknownHtmlTagsButKeepTheirTextContentEscaped() { + final String rendered = renderer.render( + "prefix
safe & sound
" + ); + + assertThat(rendered) + .isEqualTo("prefix alert(\"x\") safe & sound"); + } + + @Test + void shouldRenderComplexModernJavadocExampleSafely() { + final String rendered = renderer.render( + "Fetches release metadata for the current build.\n" + + "\n" + + "

Use {@link java.net.URI URIs} for endpoint configuration.

\n" + + "
    \n" + + "
  • Supports café, Привет, 東京, and λ.
  • \n" + + "
  • See the Javadoc specification " + + "and {@linkplain java.lang.String#formatted(Object...) formatted examples}.
  • \n" + + "
\n" + + "Example: 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.

", + "*
    ", + "*
  • first item
  • ", + "*
  • second item
  • ", + "*
", + "* ", + "*", + "* @throws Exception", + "* Thrown when the test unexpectedly fails.", + "*/", + "@Description", + "public void sampleTest() throws Exception {", + "}", + "}" + ); + + 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( + "This is my test description with `sample` and <safe>.\n\n" + + "Use String for values.\n\n" + + "- first item\n" + + "- second item\n\n" + + "alert(\"xss\")" + ); + } + + @Test + void shouldCaptureComplexModernJavadocDescriptionSafely() { + 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 {", + "", + "/**", + "* Fetches release metadata for the current build.", + "*", + "*

Use {@link java.net.URI URIs} for endpoint configuration.

", + "*
    ", + "*
  • Supports café, Привет, 東京, and λ.
  • ", + "*
  • See the Javadoc specification", + "* and {@linkplain java.lang.String#formatted(Object...) formatted examples}.
  • ", + "*
", + "* Example: 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." + ); + } } From cd85cb3b78018673e2af6e4af9c5ad46fdb34b28 Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Wed, 1 Apr 2026 15:36:42 +0100 Subject: [PATCH 2/6] allure-java-commons --- .../java/io/qameta/allure/Description.java | 3 +-- .../io/qameta/allure/util/ResultsUtils.java | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) 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 getJavadocDescription(final ClassLoader classLoad parameterTypes); return readResource(classLoader, ALLURE_DESCRIPTIONS_FOLDER + signatureHash) - .map(desc -> separateLines() ? desc.replace("\n", "
") : desc); + .map(desc -> separateLines() ? toMarkdownLineBreaks(desc) : desc); } public static Optional firstNonEmpty(final String... items) { @@ -390,7 +390,7 @@ public static void processDescription(final ClassLoader classLoader, final Description annotation = method.getAnnotation(Description.class); if ("".equals(annotation.value())) { getJavadocDescription(classLoader, method) - .ifPresent(setDescriptionHtml); + .ifPresent(setDescription); } else { final String description = annotation.value(); setDescription.accept(description); @@ -398,6 +398,20 @@ public static void processDescription(final ClassLoader classLoader, } } + private static String toMarkdownLineBreaks(final String description) { + final String[] lines = description.split("\n", -1); + final StringBuilder markdown = new StringBuilder(); + for (int index = 0; index < lines.length; index++) { + if (index > 0) { + final String previousLine = lines[index - 1]; + final String currentLine = lines[index]; + markdown.append(previousLine.isEmpty() || currentLine.isEmpty() ? "\n" : " \n"); + } + markdown.append(lines[index]); + } + return markdown.toString(); + } + private static Optional readResource(final ClassLoader classLoader, final String resourceName) { try (InputStream is = classLoader.getResourceAsStream(resourceName)) { if (Objects.isNull(is)) { From d162fb1d7f7d9c143823674d59f22b11b0744177 Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Wed, 1 Apr 2026 15:36:52 +0100 Subject: [PATCH 3/6] allure-junit-platform --- .../allure/junitplatform/AllureJunitPlatformTest.java | 6 ++---- .../junitplatform/features/DescriptionJavadocTest.java | 4 +++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java index ebb98067..29de4f12 100644 --- a/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java +++ b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java @@ -65,7 +65,6 @@ import io.qameta.allure.test.AllureFeatures; import io.qameta.allure.test.AllureResults; import io.qameta.allure.test.RunUtils; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.platform.engine.discovery.DiscoverySelectors; @@ -589,15 +588,14 @@ void shouldSetOwner() { } @AllureFeatures.Descriptions - @Disabled("Fails when run using IDEA") @Test void shouldSetJavadocDescription() { final AllureResults results = runClasses(DescriptionJavadocTest.class); final List testResults = results.getTestResults(); assertThat(testResults) - .extracting(TestResult::getDescriptionHtml) - .contains(" Test javadoc description.\n"); + .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) + .containsExactly(tuple("Test javadoc description.", null)); } @AllureFeatures.Attachments diff --git a/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/features/DescriptionJavadocTest.java b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/features/DescriptionJavadocTest.java index c34d8dc8..d0247b9d 100644 --- a/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/features/DescriptionJavadocTest.java +++ b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/features/DescriptionJavadocTest.java @@ -25,9 +25,11 @@ public class DescriptionJavadocTest { /** * Test javadoc description. + * + * @throws Exception if block tags are not filtered out. */ @Description(useJavaDoc = true) @Test - void testWithJavadocDescription() { + void testWithJavadocDescription() throws Exception { } } From 88d62b335aa3e33f252a84bdd7728f33ff56fd94 Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Wed, 1 Apr 2026 15:37:22 +0100 Subject: [PATCH 4/6] allure-junit4 --- .../io/qameta/allure/junit4/samples/DescriptionsJavadoc.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/allure-junit4/src/test/java/io/qameta/allure/junit4/samples/DescriptionsJavadoc.java b/allure-junit4/src/test/java/io/qameta/allure/junit4/samples/DescriptionsJavadoc.java index 555632fb..0fbb5f9c 100644 --- a/allure-junit4/src/test/java/io/qameta/allure/junit4/samples/DescriptionsJavadoc.java +++ b/allure-junit4/src/test/java/io/qameta/allure/junit4/samples/DescriptionsJavadoc.java @@ -25,9 +25,11 @@ public class DescriptionsJavadoc { /** * Description from javadoc. + * + * @throws Exception if block tags are not filtered out. */ @Description @Test - public void simpleTest() { + public void simpleTest() throws Exception { } } From 5c4ad68725354eface7458fc9af668a41310d118 Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Wed, 1 Apr 2026 15:37:35 +0100 Subject: [PATCH 5/6] allure-testng --- .../allure/testng/AllureTestNgTest.java | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/allure-testng/src/test/java/io/qameta/allure/testng/AllureTestNgTest.java b/allure-testng/src/test/java/io/qameta/allure/testng/AllureTestNgTest.java index 0148ad0d..bc1f54bf 100644 --- a/allure-testng/src/test/java/io/qameta/allure/testng/AllureTestNgTest.java +++ b/allure-testng/src/test/java/io/qameta/allure/testng/AllureTestNgTest.java @@ -201,16 +201,16 @@ public void descriptionsWithLineSeparationTest() { System.setProperty(ALLURE_SEPARATE_LINES_SYSPROP, "true"); } try { - final String testDescription = "Sample test description
- next line
- another line"; + final String testDescription = "Sample test description \n- next line \n- another line"; final AllureResults results = runTestNgSuites("suites/descriptions-test.xml"); List testResult = results.getTestResults(); assertThat(testResult).as("Test case result has not been written") .hasSize(2) .filteredOn(result -> result.getName().equals("testSeparated")) - .extracting(result -> result.getDescriptionHtml().trim()) + .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) .as("Javadoc description of test case has not been processed correctly") - .contains(testDescription); + .contains(tuple(testDescription, null)); } finally { System.setProperty(ALLURE_SEPARATE_LINES_SYSPROP, String.valueOf(initialSeparateLines)); } @@ -226,10 +226,9 @@ public void descriptionsTest() { assertThat(testResult).as("Test case result has not been written") .hasSize(2) .filteredOn(result -> result.getName().equals("test")) - .extracting(TestResult::getDescriptionHtml) - .map(String::trim) + .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) .as("Javadoc description of test case has not been processed") - .contains(testDescription); + .contains(tuple(testDescription, null)); } @AllureFeatures.Descriptions @@ -246,9 +245,15 @@ public void descriptionsBefores(final XmlSuite.ParallelMode mode, final int thre assertThat(testContainers).as("Test containers has not been written") .isNotEmpty() .filteredOn(container -> !container.getBefores().isEmpty()) - .extracting(container -> container.getBefores().get(0).getDescriptionHtml().trim()) + .extracting( + container -> container.getBefores().get(0).getDescription(), + container -> container.getBefores().get(0).getDescriptionHtml() + ) .as("Javadoc description of befores have not been processed") - .containsOnly(beforeClassDescription, beforeMethodDescription); + .containsOnly( + tuple(beforeClassDescription, null), + tuple(beforeMethodDescription, null) + ); } @AllureFeatures.Descriptions @@ -1382,24 +1387,27 @@ private static void assertBeforeFixtures(String containerName, List containers, String methodReference, String expectedDescriptionHtml) { + private static void checkBeforeJavadocDescriptions(List containers, String methodReference, String expectedDescription) { assertThat(containers).as("Test containers has not been written") .isNotEmpty() .filteredOn(container -> !container.getBefores().isEmpty()) .filteredOn(container -> container.getName().equals(methodReference)) - .extracting(container -> container.getBefores().get(0).getDescriptionHtml().trim()) + .extracting( + container -> container.getBefores().get(0).getDescription(), + container -> container.getBefores().get(0).getDescriptionHtml() + ) .as("Javadoc description of befores have been processed incorrectly") - .containsOnly(expectedDescriptionHtml); + .containsOnly(tuple(expectedDescription, null)); } @Step("Check that javadoc description of tests refer to correct test methods") - private static void checkTestJavadocDescriptions(List results, String methodReference, String expectedDescriptionHtml) { + private static void checkTestJavadocDescriptions(List results, String methodReference, String expectedDescription) { assertThat(results).as("Test results has not been written") .isNotEmpty() .filteredOn(result -> result.getFullName().equals(methodReference)) - .extracting(result -> result.getDescriptionHtml().trim()) + .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) .as("Javadoc description of befores have been processed incorrectly") - .containsOnly(expectedDescriptionHtml); + .containsOnly(tuple(expectedDescription, null)); } private final TestPlanV1_0.TestCase onlyId2 = new TestPlanV1_0.TestCase().setId("2"); From 8e41b434d00691326ab9af2aca642567f5f90c01 Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Wed, 1 Apr 2026 17:03:06 +0100 Subject: [PATCH 6/6] review fixes --- .../JavaDocDescriptionRenderer.java | 34 ++++++++----------- .../JavaDocDescriptionRendererTest.java | 8 +++++ 2 files changed, 23 insertions(+), 19 deletions(-) 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 index 577a86e9..6546dbb4 100644 --- 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 @@ -86,15 +86,11 @@ String render(final String rawDocComment) { private String extractDescriptionBody(final String rawDocComment) { final String[] lines = normalize(rawDocComment).split("\n", -1); final StringBuilder body = new StringBuilder(); - boolean blockTagStarted = false; int inlineTagDepth = 0; for (String line : lines) { if (inlineTagDepth == 0 && startsBlockTag(line)) { - blockTagStarted = true; - } - if (blockTagStarted) { - continue; + return trimBlankLines(body.toString()); } if (body.length() > 0) { body.append('\n'); @@ -191,6 +187,10 @@ private int renderInlineTag(final String fragment, final int start, final String "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; @@ -233,15 +233,15 @@ private int renderHtmlTag(final String fragment, final int start, final StringBu return end + 1; } if (CODE_TAG.equals(name)) { - if (!closing) { - final int closingStart = findClosingTag(fragment, end + 1, CODE_TAG); - if (closingStart > end) { - appendCode(rendered, fragment.substring(end + 1, closingStart)); - return closingStart + (CLOSING_TAG_PREFIX + CODE_TAG + HTML_TAG_END).length(); - } + if (closing) { return end + 1; } - 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; @@ -253,14 +253,10 @@ private void appendLink(final StringBuilder rendered, final String payload) { } final int separator = findWhitespace(payload); - if (separator < 0) { - rendered.append(escapeText(shortenReference(payload))); - return; - } - - final String label = payload.substring(separator + 1).trim(); + final String label = separator < 0 ? "" : payload.substring(separator + 1).trim(); if (label.isEmpty()) { - rendered.append(escapeText(shortenReference(payload.substring(0, separator)))); + final String reference = separator < 0 ? payload : payload.substring(0, separator); + rendered.append(escapeText(shortenReference(reference))); return; } diff --git a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java index 6076d935..180d2b03 100644 --- a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java +++ b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java @@ -239,6 +239,14 @@ void shouldIgnoreUnclosedHtmlTagsSafely() { .isEqualTo("Broken bold text"); } + @Test + void shouldPreserveAngleBracketComparisonsAsText() { + final String rendered = renderer.render("Math says a < b > c."); + + assertThat(rendered) + .isEqualTo("Math says a < b > c."); + } + @Test void shouldIgnoreUnmatchedCodeHtmlTagsSafely() { final String rendered = renderer.render("Broken value < limit and stray tag");