[javadoc] Snippet markup parsing and visiting (IDEA-314507)

GitOrigin-RevId: 61cc6fd201222573e430261d8138bd7b85a74b10
This commit is contained in:
Tagir Valeev
2023-03-10 09:49:35 +00:00
committed by intellij-monorepo-bot
parent 702d6eedda
commit 2e3c106b5d
3 changed files with 593 additions and 0 deletions
@@ -0,0 +1,357 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInsight.javadoc;
import com.intellij.java.JavaBundle;
import com.intellij.openapi.util.NlsContexts;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.psi.PsiElement;
import com.intellij.psi.javadoc.PsiSnippetDocTagBody;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.containers.Stack;
import one.util.streamex.StreamEx;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
public class SnippetMarkup {
private final @NotNull List<@NotNull MarkupNode> myNodes;
// \\S+ = language-dependent comment start token, like "//" or "#"
private static final Pattern MARKUP_TAG = Pattern.compile("\\S+\\s*@(start|end|highlight|replace|link)\\s*(\\S.+?)?\\s*(:?)\\s*$");
private static final Pattern ATTRIBUTE = Pattern.compile("(\\w+)\\s*(=\\s*(['\"]?)(\\S+)\\3)?");
private static final Map<String, Set<String>> ALLOWED_ATTRS =
Map.of(
"start", Set.of("region"),
"end", Set.of("region"),
"highlight", Set.of("substring", "regex", "region", "type"),
"replace", Set.of("substring", "regex", "region", "replacement"),
"link", Set.of("substring", "regex", "region", "target", "type")
);
private SnippetMarkup(@NotNull List<@NotNull MarkupNode> nodes) {
myNodes = nodes;
}
/**
* Type of the highlighting
* @see Highlight
*/
public enum HighlightType {
BOLD, ITALIC, HIGHLIGHTED
}
/**
* Type of the link
* @see Link
*/
public enum LinkType {
LINK, LINKPLAIN
}
/**
* Element of snippet text
*/
public sealed interface MarkupNode {
/**
* @return range in the parent PSI element that corresponds to this element
*/
@NotNull TextRange range();
}
/**
* A markup tag applicable to a particular part of original text
*/
public sealed interface LocationMarkupNode extends MarkupNode {
/**
* @return substring text to which this node is applicable
*/
@Nullable String substring();
/**
* @return regular expression, so this node is applicable to the matching substrings
*/
@Nullable String regex();
/**
* @return if present, defines a region name to which this tag is applicable. If null, then the tag is applicable to the next
* {@link PlainText} node.
*/
@Nullable String region();
}
/**
* A node that represents a chunk of plain text, ending with a linebreak
*
* @param content text content (excluding the final linebreak)
*/
public record PlainText(@NotNull TextRange range, @NotNull String content) implements MarkupNode {
public PlainText {
if (range.getLength() != content.length()) {
throw new IllegalArgumentException();
}
}
/**
* @param start start offset of substring
* @param end end offset of substring
* @return PlainText object that corresponds to the substring of this object
*/
@NotNull PlainText substring(@SuppressWarnings("SameParameterValue") int start, int end) {
int offset = range().getStartOffset();
return new PlainText(TextRange.create(offset + start, offset + end), content.substring(start, end));
}
}
private record Attribute(@NotNull TextRange range, @NotNull String key, @Nullable String value) implements MarkupNode {
}
/**
* Represents a {@code @start} tag
*
* @param region name of the region
*/
public record StartRegion(@NotNull TextRange range, @NotNull String region) implements MarkupNode {
}
/**
* Represents an {@code @end} tag
* @param region name of the region to close. If null then the most recently open region is closed by this tag
*/
public record EndRegion(@NotNull TextRange range, @Nullable String region) implements MarkupNode {
}
/**
* Represents a malformed markup chunk
*
* @param message localized error message
*/
public record ErrorMarkup(@NotNull TextRange range, @NlsContexts.ParsingError @NotNull String message) implements MarkupNode {
}
/**
* Represents a {@code @highlight} tag
*
* @param type highlighting type
*/
public record Highlight(@NotNull TextRange range, @Nullable String substring, @Nullable String regex, @Nullable String region,
@NotNull HighlightType type) implements LocationMarkupNode {
}
/**
* Represents a {@code @replace} tag
*
* @param replacement replacement text
*/
public record Replace(@NotNull TextRange range, @Nullable String substring, @Nullable String regex, @Nullable String region,
@NotNull String replacement) implements LocationMarkupNode {
}
/**
* Represents a {@code @link} tag
*
* @param target link target
* @param linkType link type
*/
public record Link(@NotNull TextRange range, @Nullable String substring, @Nullable String regex, @Nullable String region, @NotNull String target,
@NotNull LinkType linkType) implements LocationMarkupNode {
}
public static @NotNull SnippetMarkup parse(@NotNull String text) {
return parse(preparse(text));
}
public static @NotNull SnippetMarkup parse(@NotNull PsiSnippetDocTagBody body) {
return parse(preparse(body));
}
private static @NotNull List<@NotNull PlainText> preparse(@NotNull String text) {
List<PlainText> output = new ArrayList<>();
int pos = 0;
while (true) {
int nextPos = text.indexOf('\n', pos);
if (nextPos == -1) {
output.add(new PlainText(TextRange.create(pos, text.length()), text.substring(pos)));
break;
}
else {
output.add(new PlainText(TextRange.create(pos, nextPos), text.substring(pos, nextPos)));
pos = nextPos + 1;
}
}
return output;
}
private static @NotNull List<@NotNull PlainText> preparse(@NotNull PsiSnippetDocTagBody body) {
List<PlainText> output = new ArrayList<>();
for (PsiElement element : body.getContent()) {
output.add(new PlainText(element.getTextRangeInParent(), element.getText()));
}
return output;
}
private static @NotNull SnippetMarkup parse(@NotNull List<@NotNull PlainText> preparsed) {
return new SnippetMarkup(preparsed.stream().flatMap(SnippetMarkup::parseLine).toList());
}
private static Stream<MarkupNode> parseLine(@NotNull PlainText text) {
String content = text.content();
Matcher matcher = MARKUP_TAG.matcher(content);
if (!matcher.find()) return Stream.of(text);
int start = matcher.start();
PlainText prev = text.substring(0, start);
String tagName = matcher.group(1);
List<MarkupNode> attrs = parseAttributes(text.range().getStartOffset() + matcher.start(2), matcher.group(2),
ALLOWED_ATTRS.get(tagName));
List<ErrorMarkup> errors = new ArrayList<>(ContainerUtil.filterIsInstance(attrs, ErrorMarkup.class));
Map<String, String> attrValues = StreamEx.of(attrs).select(Attribute.class).toMap(Attribute::key, Attribute::value);
TextRange range = TextRange.create(matcher.start(1), matcher.end()).shiftRight(text.range().getStartOffset());
boolean hasColon = !matcher.group(3).isEmpty();
MarkupNode node = switch (tagName) {
case "start" -> {
String region = attrValues.get("region");
if (region == null) {
yield new ErrorMarkup(range, JavaBundle.message("javadoc.snippet.error.missing.required.attribute", "@start", "region"));
}
yield new StartRegion(range, region);
}
case "end" -> new EndRegion(range, attrValues.get("region"));
case "highlight" -> {
Attribute typeAttr =
(Attribute)ContainerUtil.find(attrs, n -> n instanceof Attribute attr && attr.key().equals("type"));
HighlightType type = null;
if (typeAttr != null) {
type = ContainerUtil.find(HighlightType.values(),
ht -> ht.name().toLowerCase(Locale.ROOT).equals(typeAttr.value()));
if (type == null) {
errors.add(new ErrorMarkup(typeAttr.range(), JavaBundle.message("javadoc.snippet.error.unknown.highlight.type", typeAttr.value())));
}
}
yield new Highlight(range, attrValues.get("substring"), attrValues.get("regex"), attrValues.get("region"),
Objects.requireNonNullElse(type, HighlightType.HIGHLIGHTED));
}
case "replace" -> {
String replacement = attrValues.get("replacement");
if (replacement == null) {
errors.add(new ErrorMarkup(range, JavaBundle.message("javadoc.snippet.error.missing.required.attribute", "@replace", "replacement")));
replacement = "";
}
yield new Replace(range, attrValues.get("substring"), attrValues.get("regex"), attrValues.get("region"), replacement);
}
case "link" -> {
String target = attrValues.get("target");
if (target == null) {
errors.add(new ErrorMarkup(range, JavaBundle.message("javadoc.snippet.error.missing.required.attribute", "@link", "target")));
target = "";
}
Attribute typeAttr =
(Attribute)ContainerUtil.find(attrs, n -> n instanceof Attribute attr && attr.key().equals("type"));
LinkType type = null;
if (typeAttr != null) {
type = ContainerUtil.find(LinkType.values(),
ht -> ht.name().toLowerCase(Locale.ROOT).equals(typeAttr.value()));
if (type == null) {
errors.add(new ErrorMarkup(typeAttr.range(), JavaBundle.message("javadoc.snippet.error.unknown.link.type", typeAttr.value())));
}
}
yield new Link(range, attrValues.get("substring"), attrValues.get("regex"), attrValues.get("region"),
target, Objects.requireNonNullElse(type, LinkType.LINK));
}
default -> throw new AssertionError("Unexpected tag: " + tagName);
};
return prev.content().isBlank() ? StreamEx.of(node).append(errors) :
hasColon ? StreamEx.of(prev, node).append(errors) :
StreamEx.of(node).append(errors).append(prev);
}
private static List<MarkupNode> parseAttributes(int offset, @Nullable String attrs, Set<String> allowedAttrs) {
if (attrs == null) {
return List.of();
}
Matcher matcher = ATTRIBUTE.matcher(attrs);
int pos = 0;
List<MarkupNode> result = new ArrayList<>();
Set<String> used = new HashSet<>();
while (true) {
boolean found = matcher.find(pos);
if (!found) {
if (pos < attrs.length()) {
result.add(new ErrorMarkup(TextRange.create(offset + pos, offset + attrs.length()),
JavaBundle.message("javadoc.snippet.error.malformed.attribute")));
}
break;
}
if (!attrs.substring(pos, matcher.start()).isBlank()) {
result.add(new ErrorMarkup(TextRange.create(offset + pos, offset + matcher.start()),
JavaBundle.message("javadoc.snippet.error.malformed.attribute")));
break;
}
String key = matcher.group(1);
TextRange range = TextRange.create(offset + matcher.start(), offset + matcher.end());
if (!allowedAttrs.contains(key)) {
result.add(new ErrorMarkup(range, JavaBundle.message("javadoc.snippet.error.unsupported.attribute", key)));
}
else if (!used.add(key)) {
result.add(new ErrorMarkup(range, JavaBundle.message("javadoc.snippet.error.duplicate.attribute", key)));
}
else {
result.add(new Attribute(range, key, Objects.requireNonNullElse(matcher.group(4), "")));
}
pos = matcher.end();
}
return result;
}
public interface SnippetVisitor {
void visitPlainText(@NotNull PlainText plainText, @NotNull List<@NotNull LocationMarkupNode> activeNodes);
void visitError(@NotNull ErrorMarkup errorMarkup);
}
public void visitSnippet(@Nullable String region, SnippetVisitor visitor) {
Stack<String> regions = new Stack<>();
Map<String, List<LocationMarkupNode>> active = new LinkedHashMap<>();
for (int i = 0; i < myNodes.size(); i++) {
MarkupNode node = myNodes.get(i);
if (node instanceof StartRegion start) {
regions.push(start.region());
} else if (node instanceof EndRegion end) {
if (end.region() == null) {
active.remove(regions.pop());
} else {
regions.remove(end.region());
active.remove(end.region());
}
} else if (node instanceof LocationMarkupNode loc) {
String regionName = loc.region();
if (regionName != null) {
regions.push(regionName);
}
active.computeIfAbsent(regionName, k -> new ArrayList<>()).add(loc);
}
else if (node instanceof PlainText plainText) {
if (region == null || regions.contains(region)) {
visitor.visitPlainText(plainText, StreamEx.ofValues(active).toFlatList(Function.identity()));
}
active.remove(null);
}
else if (node instanceof ErrorMarkup errorMarkup) {
if (region == null || regions.contains(region)) {
visitor.visitError(errorMarkup);
}
}
}
}
@Override
public String toString() {
return StringUtil.join(myNodes, "\n");
}
}
@@ -0,0 +1,230 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.java.codeInsight.javadoc;
import com.intellij.codeInsight.javadoc.SnippetMarkup;
import com.intellij.codeInsight.javadoc.SnippetMarkup.ErrorMarkup;
import com.intellij.codeInsight.javadoc.SnippetMarkup.LocationMarkupNode;
import com.intellij.codeInsight.javadoc.SnippetMarkup.PlainText;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
public class SnippetMarkupTest {
@Test
public void plainText() {
testParsing(
"""
Hello!!!
World!!!""", """
PlainText[range=(0,8), content=Hello!!!]
PlainText[range=(9,17), content=World!!!]""");
}
@Test
public void someMarkup() {
testParsing(
"""
public class X {
// @start region=test
void main() {
System.out.println("Hello"); // @replace substring=Hello replacement=...
// @highlight substring=Hello :
System.out.println("Hello");
// @highlight region:
System.out.println("Hello1");
System.out.println("Hello2"); // @link regex=H\\w+ target=Hello type=linkplain
// @end
}
// @end
}
""", """
PlainText[range=(0,16), content=public class X {]
StartRegion[range=(23,40), region=test]
PlainText[range=(41,56), content= void main() {]
Replace[range=(94,133), substring=Hello, regex=null, region=null, replacement=...]
PlainText[range=(57,90), content= System.out.println("Hello"); ]
Highlight[range=(142,169), substring=Hello, regex=null, region=null, type=HIGHLIGHTED]
PlainText[range=(170,202), content= System.out.println("Hello");]
Highlight[range=(211,228), substring=null, regex=null, region=, type=HIGHLIGHTED]
PlainText[range=(229,262), content= System.out.println("Hello1");]
Link[range=(301,344), substring=null, regex=H\\w+, region=null, target=Hello, linkType=LINKPLAIN]
PlainText[range=(263,297), content= System.out.println("Hello2"); ]
EndRegion[range=(353,356), region=null]
PlainText[range=(357,360), content= }]
EndRegion[range=(367,370), region=null]
PlainText[range=(371,372), content=}]
PlainText[range=(373,373), content=]""");
}
@Test
public void errors() {
testParsing(
"""
public class X {
// @start
void main() {
System.out.println("Hello"); // @replace substring="Hello" substring2='GoodBye'
// @highlight type=underline
System.out.println("Hello"); // @link type=pfff
}
// @end test
}
""", """
PlainText[range=(0,16), content=public class X {]
ErrorMarkup[range=(23,28), message=@start: missing 'region' attribute]
PlainText[range=(29,44), content= void main() {]
Replace[range=(82,128), substring=Hello, regex=null, region=null, replacement=]
ErrorMarkup[range=(108,128), message=Unsupported attribute: 'substring2']
ErrorMarkup[range=(82,128), message=@replace: missing 'replacement' attribute]
PlainText[range=(45,78), content= System.out.println("Hello"); ]
Highlight[range=(137,161), substring=null, regex=null, region=null, type=HIGHLIGHTED]
ErrorMarkup[range=(147,161), message=Unknown type 'underline'; only 'bold', 'italic', and 'highlighted' are supported]
Link[range=(199,213), substring=null, regex=null, region=null, target=, linkType=LINK]
ErrorMarkup[range=(199,213), message=@link: missing 'target' attribute]
ErrorMarkup[range=(204,213), message=Unknown type 'pfff'; only 'link' and 'linkplain' are supported]
PlainText[range=(162,195), content= System.out.println("Hello"); ]
PlainText[range=(214,217), content= }]
EndRegion[range=(224,232), region=null]
ErrorMarkup[range=(228,232), message=Unsupported attribute: 'test']
PlainText[range=(233,234), content=}]
PlainText[range=(235,235), content=]""");
}
@Test
public void visitorSimple() {
String text = """
public class Hello {
// @start region=main
public static void main(String[] args) {
code: // @replace regex='code:' replacement="..."
// @highlight substring="Hello"
System.out.println("Hello");
// @highlight region substring="idx"
for(int idx=0; idx<10; idx++) {
System.out.println(idx);
}
// @end
}
// @end
}
""";
testParsing(text, """
PlainText[range=(0,20), content=public class Hello {]
StartRegion[range=(27,44), region=main]
PlainText[range=(45,87), content= public static void main(String[] args) {]
Replace[range=(102,141), substring=null, regex=code:, region=null, replacement=...]
PlainText[range=(88,98), content= code: ]
Highlight[range=(150,177), substring=Hello, regex=null, region=null, type=HIGHLIGHTED]
PlainText[range=(178,210), content= System.out.println("Hello");]
Highlight[range=(219,251), substring=idx, regex=null, region=, type=HIGHLIGHTED]
PlainText[range=(252,287), content= for(int idx=0; idx<10; idx++) {]
PlainText[range=(288,318), content= System.out.println(idx);]
PlainText[range=(319,324), content= }]
EndRegion[range=(333,336), region=null]
PlainText[range=(337,340), content= }]
EndRegion[range=(347,350), region=null]
PlainText[range=(351,352), content=}]
PlainText[range=(353,353), content=]""");
testVisitor(text, null, """
public class Hello {
public static void main(String[] args) {
code: // [Replace[range=(102,141), substring=null, regex=code:, region=null, replacement=...]]
System.out.println("Hello"); // [Highlight[range=(150,177), substring=Hello, regex=null, region=null, type=HIGHLIGHTED]]
for(int idx=0; idx<10; idx++) { // [Highlight[range=(219,251), substring=idx, regex=null, region=, type=HIGHLIGHTED]]
System.out.println(idx); // [Highlight[range=(219,251), substring=idx, regex=null, region=, type=HIGHLIGHTED]]
} // [Highlight[range=(219,251), substring=idx, regex=null, region=, type=HIGHLIGHTED]]
}
}
""");
testVisitor(text, "main", """
public static void main(String[] args) {
code: // [Replace[range=(102,141), substring=null, regex=code:, region=null, replacement=...]]
System.out.println("Hello"); // [Highlight[range=(150,177), substring=Hello, regex=null, region=null, type=HIGHLIGHTED]]
for(int idx=0; idx<10; idx++) { // [Highlight[range=(219,251), substring=idx, regex=null, region=, type=HIGHLIGHTED]]
System.out.println(idx); // [Highlight[range=(219,251), substring=idx, regex=null, region=, type=HIGHLIGHTED]]
} // [Highlight[range=(219,251), substring=idx, regex=null, region=, type=HIGHLIGHTED]]
}
""");
}
@Test
public void visitorNested() {
String text = """
// @highlight region="r1" substring="xxx" type=bold
r1 start xxx
// @highlight region="r2" substring="yyy" type=italic
r2 start yyy
// @highlight substring="zzz" type=highlighted:
one line with zzz highlighting
r1+r2 xxx
// @end region="r1"
r2 continues yyy
// @end region="r2"
""";
testParsing(text, """
Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD]
PlainText[range=(52,64), content=r1 start xxx]
Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC]
PlainText[range=(119,131), content=r2 start yyy]
Highlight[range=(136,179), substring=zzz, regex=null, region=null, type=HIGHLIGHTED]
PlainText[range=(180,210), content=one line with zzz highlighting]
PlainText[range=(211,220), content=r1+r2 xxx]
EndRegion[range=(225,240), region=r1]
PlainText[range=(241,257), content=r2 continues yyy]
EndRegion[range=(262,277), region=r2]
PlainText[range=(278,278), content=]""");
testVisitor(text, null, """
r1 start xxx // [Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD]]
r2 start yyy // [Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD], Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC]]
one line with zzz highlighting // [Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD], Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC], Highlight[range=(136,179), substring=zzz, regex=null, region=null, type=HIGHLIGHTED]]
r1+r2 xxx // [Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD], Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC]]
r2 continues yyy // [Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC]]
""");
testVisitor(text, "r1", """
r1 start xxx // [Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD]]
r2 start yyy // [Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD], Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC]]
one line with zzz highlighting // [Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD], Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC], Highlight[range=(136,179), substring=zzz, regex=null, region=null, type=HIGHLIGHTED]]
r1+r2 xxx // [Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD], Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC]]
""");
testVisitor(text, "r2", """
r2 start yyy // [Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD], Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC]]
one line with zzz highlighting // [Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD], Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC], Highlight[range=(136,179), substring=zzz, regex=null, region=null, type=HIGHLIGHTED]]
r1+r2 xxx // [Highlight[range=(4,51), substring=xxx, regex=null, region=r1, type=BOLD], Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC]]
r2 continues yyy // [Highlight[range=(69,118), substring=yyy, regex=null, region=r2, type=ITALIC]]
""");
}
private static void testParsing(@NotNull String input, @NotNull String expected) {
assertEquals(expected, SnippetMarkup.parse(input).toString());
}
private static void testVisitor(@NotNull String input, @Nullable String region, @NotNull String expected) {
var visitor = new SnippetMarkup.SnippetVisitor() {
StringBuilder sb = new StringBuilder();
@Override
public void visitPlainText(@NotNull PlainText plainText,
@NotNull List<@NotNull LocationMarkupNode> activeNodes) {
sb.append(plainText.content());
if (!activeNodes.isEmpty()) {
sb.append(" // ").append(activeNodes);
}
sb.append("\n");
}
@Override
public void visitError(@NotNull ErrorMarkup errorMarkup) {
sb.append("Error: ").append(errorMarkup.message()).append("\n");
}
};
SnippetMarkup.parse(input).visitSnippet(region, visitor);
assertEquals(expected, visitor.sb.toString());
}
}
@@ -1819,3 +1819,9 @@ fix.move.to.source.root=Move to source root
warning.java.file.outside.source.root=Java file is located outside of the module source root, so it won't be compiled
inspection.message.snippet.file.not.found=Snippet file is not found
javadoc.snippet.not.found=Snippet not found:
javadoc.snippet.error.unknown.highlight.type=Unknown type ''{0}''; only ''bold'', ''italic'', and ''highlighted'' are supported
javadoc.snippet.error.unknown.link.type=Unknown type ''{0}''; only ''link'' and ''linkplain'' are supported
javadoc.snippet.error.malformed.attribute=Malformed attribute
javadoc.snippet.error.unsupported.attribute=Unsupported attribute: ''{0}''
javadoc.snippet.error.duplicate.attribute=Duplicate attribute: ''{0}''
javadoc.snippet.error.missing.required.attribute={0}: missing ''{1}'' attribute