mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-18 09:34:34 +07:00
GitOrigin-RevId: dca054f83dadfb60c9aa1b0d950423fc55e3a7a3
762 lines
29 KiB
Java
762 lines
29 KiB
Java
// 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.ide.nls.NlsMessages;
|
|
import com.intellij.java.JavaBundle;
|
|
import com.intellij.openapi.progress.ProcessCanceledException;
|
|
import com.intellij.openapi.util.NlsContexts;
|
|
import com.intellij.openapi.util.TextRange;
|
|
import com.intellij.openapi.util.text.StringUtil;
|
|
import com.intellij.psi.*;
|
|
import com.intellij.psi.impl.FakePsiElement;
|
|
import com.intellij.psi.javadoc.*;
|
|
import com.intellij.psi.tree.IElementType;
|
|
import com.intellij.psi.util.CachedValueProvider;
|
|
import com.intellij.psi.util.CachedValuesManager;
|
|
import com.intellij.util.containers.ContainerUtil;
|
|
import com.intellij.util.containers.Stack;
|
|
import one.util.streamex.StreamEx;
|
|
import org.jetbrains.annotations.Contract;
|
|
import org.jetbrains.annotations.NotNull;
|
|
import org.jetbrains.annotations.Nullable;
|
|
import org.jetbrains.annotations.TestOnly;
|
|
|
|
import java.util.*;
|
|
import java.util.function.Function;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
import java.util.regex.PatternSyntaxException;
|
|
import java.util.stream.Stream;
|
|
|
|
public class SnippetMarkup {
|
|
private final @NotNull List<@NotNull MarkupNode> myNodes;
|
|
private final @NotNull Map<@NotNull String, @NotNull MarkupNode> myRegionStarts;
|
|
private final @NotNull PsiElement myContext;
|
|
private final @NotNull BitSet myTextOffsets = new BitSet();
|
|
|
|
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 static final String ALLOWED_TAGS = "start|end|highlight|replace|link";
|
|
private static final Pattern MARKUP_SPEC = Pattern.compile("(?://|#|rem|REM|')\\s*(@(?:" + ALLOWED_TAGS + ")(?:\\s.+)?)$");
|
|
|
|
private static final Pattern MARKUP_TAG = Pattern.compile("@(" + ALLOWED_TAGS + ")\\s*");
|
|
|
|
private static final Pattern ATTRIBUTE = Pattern.compile("(\\w+)\\s*(=\\s*('([^']*)'|\"([^\"]*)\"|(\\S*)))?\\s*");
|
|
|
|
private SnippetMarkup(@NotNull List<@NotNull MarkupNode> nodes, @NotNull PsiElement context) {
|
|
myNodes = nodes;
|
|
myRegionStarts = StreamEx.of(nodes)
|
|
.mapToEntry(n -> n instanceof StartRegion start ? start.region() :
|
|
n instanceof LocationMarkupNode node ? node.region() :
|
|
null, Function.identity())
|
|
.removeKeys(k -> k == null || k.isEmpty())
|
|
.distinctKeys()
|
|
.toImmutableMap();
|
|
myContext = context;
|
|
StreamEx.of(nodes)
|
|
.select(PlainText.class)
|
|
.forEach(text -> myTextOffsets.set(text.range().getStartOffset(), text.range().getEndOffset()));
|
|
}
|
|
|
|
public @NotNull PsiElement getContext() {
|
|
return myContext;
|
|
}
|
|
|
|
/**
|
|
* @param region region
|
|
* @return starting node of the region
|
|
*/
|
|
public @Nullable MarkupNode getRegionStart(@NotNull String region) {
|
|
return myRegionStarts.get(region);
|
|
}
|
|
|
|
/**
|
|
* @param region region to process; null for the whole snippet
|
|
* @return common indent of all the lines in the region
|
|
*/
|
|
public int getCommonIndent(@Nullable String region) {
|
|
var visitor = new SnippetVisitor() {
|
|
int maxIndent = Integer.MAX_VALUE;
|
|
|
|
@Override
|
|
public void visitPlainText(@NotNull PlainText plainText, @NotNull List<@NotNull LocationMarkupNode> activeNodes) {
|
|
if (maxIndent == 0) return;
|
|
String content = plainText.content();
|
|
if (content.isBlank()) return;
|
|
int curIndent = 0;
|
|
while (curIndent < content.length() && content.charAt(curIndent) != '\n' && Character.isWhitespace(content.charAt(curIndent))) {
|
|
curIndent++;
|
|
}
|
|
maxIndent = Math.min(maxIndent, curIndent);
|
|
}
|
|
};
|
|
visitSnippet(region, visitor);
|
|
return visitor.maxIndent == Integer.MAX_VALUE ? 0 : visitor.maxIndent;
|
|
}
|
|
|
|
/**
|
|
* @param region region to test; null for the whole snippet
|
|
* @return true if any of {@code @replacement}, {@code @highlight}, or {@code @link} tags exist within the region
|
|
*/
|
|
public boolean hasMarkup(@Nullable String region) {
|
|
var visitor = new SnippetVisitor() {
|
|
boolean hasMarkup = false;
|
|
|
|
@Override
|
|
public void visitPlainText(@NotNull PlainText plainText, @NotNull List<@NotNull LocationMarkupNode> activeNodes) {
|
|
hasMarkup |= !activeNodes.isEmpty();
|
|
}
|
|
};
|
|
visitSnippet(region, visitor);
|
|
return visitor.hasMarkup;
|
|
}
|
|
|
|
public @Nullable TextRange getRegionRange(@Nullable String region) {
|
|
var visitor = new SnippetVisitor() {
|
|
TextRange myRange = null;
|
|
|
|
@Override
|
|
public void visitPlainText(@NotNull PlainText plainText, @NotNull List<@NotNull LocationMarkupNode> activeNodes) {
|
|
myRange = myRange == null ? plainText.range() : myRange.union(plainText.range());
|
|
}
|
|
};
|
|
visitSnippet(region, visitor);
|
|
return visitor.myRange;
|
|
}
|
|
|
|
/**
|
|
* @param range range to test
|
|
* @return true if the range completely or partially contains plain text chunk(s) to display
|
|
*/
|
|
public boolean isTextPart(@NotNull TextRange range) {
|
|
int nextSetBit = myTextOffsets.nextSetBit(range.getStartOffset());
|
|
return nextSetBit != -1 && nextSetBit < range.getEndOffset();
|
|
}
|
|
|
|
/**
|
|
* @param region region to extract; null for the whole snippet
|
|
* @return text of a given region, ignoring markup
|
|
*/
|
|
public String getTextWithoutMarkup(@Nullable String region) {
|
|
StringBuilder result = new StringBuilder();
|
|
visitSnippet(region, new SnippetVisitor() {
|
|
@Override
|
|
public void visitPlainText(@NotNull PlainText plainText, @NotNull List<@NotNull LocationMarkupNode> activeNodes) {
|
|
result.append(plainText.content());
|
|
}
|
|
});
|
|
return result.toString();
|
|
}
|
|
|
|
public Set<String> getRegions() {
|
|
return myRegionStarts.keySet();
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
@Contract(pure = true)
|
|
@NotNull TextRange range();
|
|
}
|
|
|
|
/**
|
|
* An interface that specifies to which part of text content the {@link LocationMarkupNode} is applied
|
|
*/
|
|
public sealed interface Selector {
|
|
/**
|
|
* @param string string to find the ranges
|
|
* @return non-overlapping sorted ranges inside the string matched by this selector
|
|
*/
|
|
@NotNull List<TextRange> ranges(String string);
|
|
}
|
|
|
|
/**
|
|
* Substring-selector; applicable to a specific substring
|
|
* @param substring substring to apply to
|
|
*/
|
|
public record Substring(@NotNull String substring) implements Selector {
|
|
@Override
|
|
public @NotNull List<TextRange> ranges(String string) {
|
|
int pos = 0;
|
|
List<TextRange> ranges = new ArrayList<>();
|
|
while (true) {
|
|
int nextPos = string.indexOf(substring, pos);
|
|
if (nextPos == -1) break;
|
|
ranges.add(TextRange.from(nextPos, substring.length()));
|
|
pos = nextPos + substring.length() + 1;
|
|
}
|
|
return ranges;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Regex-selector; applicable to a specific regex
|
|
* @param pattern pattern to apply to
|
|
*/
|
|
public record Regex(@NotNull Pattern pattern) implements Selector {
|
|
@Override
|
|
public @NotNull List<TextRange> ranges(String string) {
|
|
Matcher matcher = pattern.matcher(string);
|
|
List<TextRange> ranges = new ArrayList<>();
|
|
while (matcher.find()) {
|
|
ranges.add(TextRange.create(matcher.start(), matcher.end()));
|
|
}
|
|
return ranges;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Selector which is applicable to the whole line
|
|
*/
|
|
public record WholeLine() implements Selector {
|
|
@Override
|
|
public @NotNull List<TextRange> ranges(String string) {
|
|
return List.of(TextRange.create(0, string.endsWith("\n") ? string.length() - 1 : string.length()));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A markup tag applicable to a particular part of original text
|
|
*/
|
|
public sealed interface LocationMarkupNode extends MarkupNode {
|
|
/**
|
|
* @return selector to which this node is applicable
|
|
*/
|
|
@Contract(pure = true)
|
|
@NotNull Selector selector();
|
|
|
|
/**
|
|
* @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.
|
|
*/
|
|
@Contract(pure = true)
|
|
@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 {
|
|
}
|
|
|
|
private record Attribute(@NotNull TextRange range, @NotNull String key, @NotNull 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, @NotNull Selector selector, @Nullable String region,
|
|
@NotNull HighlightType type) implements LocationMarkupNode {
|
|
}
|
|
|
|
/**
|
|
* Represents a {@code @replace} tag
|
|
*
|
|
* @param replacement replacement text
|
|
*/
|
|
public record Replace(@NotNull TextRange range, @NotNull Selector selector, @Nullable String region,
|
|
@NotNull String replacement) implements LocationMarkupNode {
|
|
public Replace withReplacement(@NotNull String replacement) {
|
|
return replacement.equals(this.replacement) ? this : new Replace(range, selector, region, replacement);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Represents a {@code @link} tag
|
|
*
|
|
* @param target link target
|
|
* @param linkType link type
|
|
*/
|
|
public record Link(@NotNull TextRange range, @NotNull Selector selector, @Nullable String region, @NotNull String target,
|
|
@NotNull LinkType linkType) implements LocationMarkupNode {
|
|
}
|
|
|
|
@TestOnly
|
|
public static @NotNull SnippetMarkup parse(@NotNull String text) {
|
|
return parse(preparse(text), new FakePsiElement() {
|
|
@Override
|
|
public PsiElement getParent() {
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param element {@link PsiFile} or {@link PsiSnippetDocTagBody}
|
|
* @return markup for a given element
|
|
*/
|
|
public static @NotNull SnippetMarkup fromElement(@NotNull PsiElement element) {
|
|
if (!(element instanceof PsiFile) && !(element instanceof PsiSnippetDocTagBody)) {
|
|
throw new IllegalArgumentException();
|
|
}
|
|
return CachedValuesManager.getCachedValue(element, () -> {
|
|
SnippetMarkup markup = element instanceof PsiSnippetDocTagBody body ? parse(preparse(body), body) :
|
|
parse(preparse(element.getText()), element);
|
|
return new CachedValueProvider.Result<>(markup, element);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param snippet snippet to create a markup for
|
|
* @return a markup from snippet. For hybrid snippet, markup is created from the external snippet.
|
|
* Returns null if there's no snippet body and no external snippet resolved
|
|
*/
|
|
public static @Nullable SnippetMarkup fromSnippet(@NotNull PsiSnippetDocTagValue snippet) {
|
|
SnippetMarkup markup = fromExternalSnippet(snippet);
|
|
if (markup != null) {
|
|
return markup;
|
|
}
|
|
PsiSnippetDocTagBody body = snippet.getBody();
|
|
return body != null ? fromElement(body) : null;
|
|
}
|
|
|
|
/**
|
|
* @param snippet snippet to create a markup for
|
|
* @return markup from external snippet (ignoring body), null if there's no external snippet resolved.
|
|
*/
|
|
public static @Nullable SnippetMarkup fromExternalSnippet(@NotNull PsiSnippetDocTagValue snippet) {
|
|
PsiSnippetAttributeList list = snippet.getAttributeList();
|
|
PsiSnippetAttribute refAttribute = list.getAttribute(PsiSnippetAttribute.CLASS_ATTRIBUTE);
|
|
if (refAttribute == null) {
|
|
refAttribute = list.getAttribute(PsiSnippetAttribute.FILE_ATTRIBUTE);
|
|
}
|
|
if (refAttribute == null) return null;
|
|
PsiSnippetAttributeValue attrValue = refAttribute.getValue();
|
|
if (attrValue == null) return null;
|
|
PsiReference ref = attrValue.getReference();
|
|
if (ref == null || !(ref.resolve() instanceof PsiFile file)) return null;
|
|
return fromElement(file);
|
|
}
|
|
|
|
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 + 1), text.substring(pos, nextPos + 1)));
|
|
pos = nextPos + 1;
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
private static @NotNull List<@NotNull PlainText> preparse(@NotNull PsiSnippetDocTagBody body) {
|
|
List<PlainText> output = new ArrayList<>();
|
|
PsiElement[] children = body.getChildren();
|
|
boolean first = true;
|
|
for (PsiElement element : children) {
|
|
if (element instanceof PsiDocToken token) {
|
|
IElementType tokenType = token.getTokenType();
|
|
if (tokenType.equals(JavaDocTokenType.DOC_COMMENT_LEADING_ASTERISKS)) continue;
|
|
if (tokenType.equals(JavaDocTokenType.DOC_COMMENT_DATA)) {
|
|
output.add(new PlainText(element.getTextRangeInParent(), element.getText()));
|
|
}
|
|
}
|
|
else if (element instanceof PsiWhiteSpace) {
|
|
String text = element.getText();
|
|
if (text.contains("\n")) {
|
|
int idx = output.size() - 1;
|
|
if (idx >= 0 && !output.get(idx).content().endsWith("\n")) {
|
|
output.set(idx, new PlainText(output.get(idx).range(), output.get(idx).content() + "\n"));
|
|
}
|
|
else if (first) {
|
|
first = false;
|
|
}
|
|
else {
|
|
output.add(new PlainText(element.getTextRange(), "\n"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
private static @NotNull SnippetMarkup parse(@NotNull List<@NotNull PlainText> preparsed, @NotNull PsiElement context) {
|
|
return new SnippetMarkup(preparsed.stream().flatMap(SnippetMarkup::parseLine).toList(), context);
|
|
}
|
|
|
|
private static Stream<MarkupNode> parseLine(@NotNull PlainText text) {
|
|
String content = text.content();
|
|
Matcher matcher = MARKUP_SPEC.matcher(content);
|
|
if (!matcher.find()) return Stream.of(text);
|
|
int offset = text.range().getStartOffset();
|
|
String markupSpec = matcher.group(1).stripTrailing();
|
|
boolean hasColon = markupSpec.endsWith(":");
|
|
if (hasColon) {
|
|
markupSpec = markupSpec.substring(0, markupSpec.length() - 1);
|
|
}
|
|
int start = matcher.start(0);
|
|
PlainText prev = new PlainText(TextRange.create(offset, offset + start), text.content.substring(0, start) + "\n");
|
|
start = matcher.start(1);
|
|
List<MarkupNode> markupNodes = new ArrayList<>();
|
|
while (!markupSpec.isEmpty()) {
|
|
Matcher tagMatcher = MARKUP_TAG.matcher(markupSpec);
|
|
if (!tagMatcher.find()) {
|
|
markupNodes.add(new ErrorMarkup(TextRange.create(0, markupSpec.length()).shiftRight(start + offset),
|
|
JavaBundle.message("javadoc.snippet.error.markup.tag.expected")));
|
|
break;
|
|
}
|
|
if (tagMatcher.start() != 0) {
|
|
markupNodes.add(new ErrorMarkup(TextRange.create(0, tagMatcher.start()).shiftRight(start + offset),
|
|
JavaBundle.message("javadoc.snippet.error.markup.tag.expected")));
|
|
break;
|
|
}
|
|
String tagName = tagMatcher.group(1);
|
|
|
|
Set<String> allowedAttrs = ALLOWED_ATTRS.get(tagName);
|
|
Map<String, Attribute> attrs = new HashMap<>();
|
|
int attrPos = tagMatcher.end();
|
|
int attrEnd = attrPos;
|
|
while (attrPos < markupSpec.length()) {
|
|
Matcher attrMatcher = ATTRIBUTE.matcher(markupSpec);
|
|
boolean found = attrMatcher.find(attrPos);
|
|
if (!found || attrMatcher.start() != attrPos) break;
|
|
String key = attrMatcher.group(1);
|
|
attrEnd = attrMatcher.end(2);
|
|
if (attrEnd == -1) {
|
|
attrEnd = attrMatcher.end(1);
|
|
}
|
|
TextRange attrRange = TextRange.create(attrMatcher.start(), attrEnd).shiftRight(start + offset);
|
|
if (!allowedAttrs.contains(key)) {
|
|
markupNodes.add(new ErrorMarkup(attrRange, JavaBundle.message("javadoc.snippet.error.unsupported.attribute", tagName, key)));
|
|
}
|
|
else if (attrs.containsKey(key)) {
|
|
markupNodes.add(new ErrorMarkup(attrRange, JavaBundle.message("javadoc.snippet.error.duplicate.attribute", tagName, key)));
|
|
}
|
|
else {
|
|
String value = attrMatcher.group(4);
|
|
if (value == null) value = attrMatcher.group(5);
|
|
if (value == null) value = attrMatcher.group(6);
|
|
if (value == null) value = "";
|
|
attrs.put(key, new Attribute(attrRange, key, value));
|
|
}
|
|
attrPos = attrMatcher.end();
|
|
}
|
|
while (attrEnd > 0 && Character.isWhitespace(markupSpec.charAt(attrEnd - 1))) {
|
|
attrEnd--;
|
|
}
|
|
TextRange range = TextRange.create(0, attrEnd).shiftRight(start + offset);
|
|
validateAndAddTag(markupNodes, tagName, attrs, range);
|
|
start += attrPos;
|
|
markupSpec = markupSpec.substring(attrPos);
|
|
}
|
|
if (prev.content().isBlank() && !prev.content().isEmpty()) {
|
|
prev = new PlainText(TextRange.from(text.range().getEndOffset(), 0), "");
|
|
}
|
|
if (hasColon || (!prev.content().isEmpty() && ContainerUtil.and(markupNodes, mn -> mn instanceof EndRegion))) {
|
|
markupNodes.add(0, prev);
|
|
}
|
|
else {
|
|
markupNodes.add(prev);
|
|
}
|
|
return markupNodes.stream();
|
|
}
|
|
|
|
private static void validateAndAddTag(@NotNull List<MarkupNode> markupNodes,
|
|
@NotNull String tagName,
|
|
@NotNull Map<String, Attribute> attrs,
|
|
@NotNull TextRange range) {
|
|
String region = getRegion(attrs);
|
|
switch (tagName) {
|
|
case "start" -> {
|
|
region = getRequiredString(markupNodes, attrs, tagName, range, "region");
|
|
if (region != null) {
|
|
markupNodes.add(new StartRegion(range, region));
|
|
}
|
|
}
|
|
case "end" -> markupNodes.add(new EndRegion(range, region));
|
|
case "highlight" -> {
|
|
Attribute typeAttr = attrs.get("type");
|
|
HighlightType type = getEnumValue(markupNodes, "highlight", typeAttr, HighlightType.class, HighlightType.HIGHLIGHTED);
|
|
Selector selector = getSelector(markupNodes, attrs, tagName);
|
|
if (selector != null) {
|
|
markupNodes.add(new Highlight(range, selector, region, type));
|
|
}
|
|
else if (region != null) {
|
|
markupNodes.add(new StartRegion(range, region));
|
|
}
|
|
}
|
|
case "replace" -> {
|
|
String replacement = getRequiredString(markupNodes, attrs, tagName, range, "replacement");
|
|
Selector selector = getSelector(markupNodes, attrs, tagName);
|
|
if (replacement != null && selector != null) {
|
|
markupNodes.add(new Replace(range, selector, region, replacement));
|
|
}
|
|
else if (region != null) {
|
|
markupNodes.add(new StartRegion(range, region));
|
|
}
|
|
}
|
|
case "link" -> {
|
|
String target = getRequiredString(markupNodes, attrs, tagName, range, "target");
|
|
Attribute typeAttr = attrs.get("type");
|
|
LinkType type = getEnumValue(markupNodes, "link", typeAttr, LinkType.class, LinkType.LINK);
|
|
Selector selector = getSelector(markupNodes, attrs, tagName);
|
|
if (target != null && selector != null) {
|
|
markupNodes.add(new Link(range, selector, region, target, type));
|
|
}
|
|
else if (region != null) {
|
|
markupNodes.add(new StartRegion(range, region));
|
|
}
|
|
}
|
|
default -> throw new AssertionError("Unexpected tag: " + tagName);
|
|
}
|
|
}
|
|
|
|
private static <T extends Enum<T>> @NotNull T getEnumValue(@NotNull List<MarkupNode> markupNodes, @NotNull String tagName,
|
|
@Nullable Attribute attribute, @NotNull Class<T> enumClass, @NotNull T defaultValue) {
|
|
if (attribute == null) return defaultValue;
|
|
T value = ContainerUtil.find(enumClass.getEnumConstants(), val -> val.name().toLowerCase(Locale.ROOT).equals(attribute.value()));
|
|
if (value == null) {
|
|
markupNodes.add(
|
|
new ErrorMarkup(attribute.range(), JavaBundle.message(
|
|
"javadoc.snippet.error.unknown.enum.value",
|
|
tagName, attribute.key(), attribute.value(),
|
|
StreamEx.of(enumClass.getEnumConstants()).map(val -> "'" + val.name().toLowerCase(Locale.ROOT) + "'")
|
|
.collect(NlsMessages.joiningAnd()))));
|
|
return defaultValue;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private static @Nullable String getRegion(@NotNull Map<String, Attribute> attrs) {
|
|
Attribute attr = attrs.get("region");
|
|
return attr == null ? null : attr.value();
|
|
}
|
|
|
|
private static @Nullable String getRequiredString(@NotNull List<MarkupNode> markupNodes, @NotNull Map<String, Attribute> attrs,
|
|
@NotNull String tagName, @NotNull TextRange range, @NotNull String attrName) {
|
|
Attribute attr = attrs.get(attrName);
|
|
if (attr == null) {
|
|
markupNodes.add(new ErrorMarkup(range, JavaBundle.message("javadoc.snippet.error.missing.required.attribute", tagName, attrName)));
|
|
return null;
|
|
}
|
|
return attr.value();
|
|
}
|
|
|
|
private static @Nullable Selector getSelector(@NotNull List<MarkupNode> markupNodes, @NotNull Map<String, Attribute> attrs,
|
|
@NotNull String tagName) {
|
|
Attribute substring = attrs.get("substring");
|
|
Attribute regex = attrs.get("regex");
|
|
if (substring == null && regex == null) return new WholeLine();
|
|
if (substring != null && regex != null) {
|
|
markupNodes.add(new ErrorMarkup(regex.range(), JavaBundle.message("javadoc.snippet.error.both.substring.and.regex", tagName)));
|
|
return null;
|
|
}
|
|
if (regex == null) return new Substring(substring.value());
|
|
try {
|
|
return new Regex(Pattern.compile(regex.value()));
|
|
}
|
|
catch (PatternSyntaxException e) {
|
|
markupNodes.add(new ErrorMarkup(regex.range(),
|
|
JavaBundle.message("javadoc.snippet.error.malformed.regular.expression", tagName, e.getMessage().replace("\r\n", "\n"))));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public interface SnippetVisitor {
|
|
/**
|
|
* Called for every plain text chunk
|
|
*
|
|
* @param plainText text chunk
|
|
* @param activeNodes active markup nodes to apply to this chunk
|
|
*/
|
|
default void visitPlainText(@NotNull PlainText plainText, @NotNull List<@NotNull LocationMarkupNode> activeNodes) { }
|
|
|
|
/**
|
|
* Called for every markup error visited
|
|
* @param errorMarkup error information
|
|
*/
|
|
default void visitError(@NotNull ErrorMarkup errorMarkup) {}
|
|
}
|
|
|
|
/**
|
|
* Visit elements of the snippet within given region
|
|
*
|
|
* @param region region to visit
|
|
* @param visitor visitor to use
|
|
*/
|
|
public void visitSnippet(@Nullable String region, @NotNull SnippetVisitor visitor) {
|
|
visitSnippet(region, false, visitor);
|
|
}
|
|
|
|
/**
|
|
* Visit elements of the snippet within given region
|
|
*
|
|
* @param region region to visit
|
|
* @param preprocess if true, {@code @replace} tags will be processed automatically and not passed to the visitor;
|
|
* also common indent will be stripped automatically from {@link PlainText} nodes. This option
|
|
* is useful if you want to actually render markup. In this case, you only have to process
|
|
* {@link Highlight} and {@link Link} tags.
|
|
* @param visitor visitor to use
|
|
*/
|
|
public void visitSnippet(@Nullable String region, boolean preprocess, @NotNull SnippetVisitor visitor) {
|
|
Stack<String> regions = new Stack<>();
|
|
Map<String, List<LocationMarkupNode>> active = new LinkedHashMap<>();
|
|
int commonIndent = preprocess ? getCommonIndent(region) : 0;
|
|
for (MarkupNode node : myNodes) {
|
|
if (node instanceof StartRegion start) {
|
|
regions.push(start.region());
|
|
}
|
|
else if (node instanceof EndRegion end) {
|
|
if (end.region() == null) {
|
|
if (!regions.isEmpty()) {
|
|
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)) {
|
|
plainText = stripIndent(plainText, commonIndent);
|
|
List<LocationMarkupNode> flatActive = StreamEx.ofValues(active).toFlatList(Function.identity());
|
|
if (preprocess) {
|
|
processReplacements(visitor, plainText, flatActive);
|
|
}
|
|
else {
|
|
visitor.visitPlainText(plainText, flatActive);
|
|
}
|
|
}
|
|
active.values().forEach(
|
|
list -> list.replaceAll(
|
|
n -> n instanceof Replace repl && repl.selector() instanceof WholeLine ? repl.withReplacement("") : n));
|
|
active.remove(null);
|
|
}
|
|
else if (node instanceof ErrorMarkup errorMarkup) {
|
|
if (region == null || regions.contains(region)) {
|
|
visitor.visitError(errorMarkup);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static PlainText stripIndent(PlainText plainText, int commonIndent) {
|
|
if (commonIndent <= 0) return plainText;
|
|
String content = plainText.content();
|
|
int curIndent = 0;
|
|
while (curIndent < commonIndent &&
|
|
curIndent < content.length() &&
|
|
content.charAt(curIndent) != '\n' &&
|
|
Character.isWhitespace(content.charAt(curIndent))) {
|
|
curIndent++;
|
|
}
|
|
if (curIndent == 0) return plainText;
|
|
content = content.substring(curIndent);
|
|
return new PlainText(TextRange.create(plainText.range().getStartOffset() + curIndent, plainText.range().getEndOffset()), content);
|
|
}
|
|
|
|
private static void processReplacements(@NotNull SnippetVisitor visitor, @NotNull PlainText plainText, @NotNull List<LocationMarkupNode> flatActive) {
|
|
Map<Boolean, List<LocationMarkupNode>> map = StreamEx.of(flatActive).partitioningBy(n -> n instanceof Replace);
|
|
flatActive = map.get(false);
|
|
String content = plainText.content();
|
|
for (LocationMarkupNode markupNode : map.get(true)) {
|
|
Replace replace = (Replace)markupNode;
|
|
Selector selector = replace.selector();
|
|
String replacement = replace.replacement();
|
|
if (selector instanceof Regex(Pattern pattern)) {
|
|
boolean addLineBreak = false;
|
|
if (content.endsWith("\n")) {
|
|
content = content.substring(0, content.length() - 1);
|
|
addLineBreak = true;
|
|
}
|
|
try {
|
|
content = pattern.matcher(StringUtil.newBombedCharSequence(content, 1000)).replaceAll(replacement);
|
|
}
|
|
catch (StackOverflowError | ProcessCanceledException e) {
|
|
ErrorMarkup replacementError = new ErrorMarkup(
|
|
replace.range(), JavaBundle.message("javadoc.snippet.error.regex.too.complex", "replace", pattern.pattern()));
|
|
visitor.visitError(replacementError);
|
|
}
|
|
catch (IllegalArgumentException | IndexOutOfBoundsException e) {
|
|
ErrorMarkup replacementError = new ErrorMarkup(
|
|
replace.range(), JavaBundle.message("javadoc.snippet.error.malformed.replacement", "replace", replacement, e.getMessage()));
|
|
visitor.visitError(replacementError);
|
|
}
|
|
if (addLineBreak) {
|
|
content += "\n";
|
|
}
|
|
}
|
|
else if (selector instanceof Substring(String substring)) {
|
|
content = content.replace(substring, replacement);
|
|
}
|
|
else {
|
|
content = replacement;
|
|
}
|
|
}
|
|
visitor.visitPlainText(new PlainText(plainText.range(), content), flatActive);
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return StringUtil.join(myNodes, "\n");
|
|
}
|
|
}
|