/* * Copyright 2000-2017 JetBrains s.r.o. * * 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 com.intellij.codeInsight.navigation; import com.intellij.codeInsight.documentation.DocumentationManagerProtocol; import com.intellij.lang.documentation.DocumentationProvider; import com.intellij.openapi.util.TextRange; import com.intellij.psi.PsiElement; import com.intellij.util.containers.ContainerUtilRt; import gnu.trove.TIntHashSet; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Provides utility methods for building documentation preview. * <p/> * Thread-safe. * * @author Denis Zhdanov * @since 7/10/12 8:06 AM */ public class DocPreviewUtil { private static final TIntHashSet ALLOWED_LINK_SEPARATORS = new TIntHashSet(); static { ALLOWED_LINK_SEPARATORS.add(','); ALLOWED_LINK_SEPARATORS.add(' '); ALLOWED_LINK_SEPARATORS.add('.'); ALLOWED_LINK_SEPARATORS.add(';'); ALLOWED_LINK_SEPARATORS.add('&'); ALLOWED_LINK_SEPARATORS.add('\t'); ALLOWED_LINK_SEPARATORS.add('\n'); ALLOWED_LINK_SEPARATORS.add('['); ALLOWED_LINK_SEPARATORS.add(']'); ALLOWED_LINK_SEPARATORS.add('('); ALLOWED_LINK_SEPARATORS.add(')'); ALLOWED_LINK_SEPARATORS.add('<'); ALLOWED_LINK_SEPARATORS.add('>'); } /** * We shorten links text from fully qualified name to short names (e.g. from {@code 'java.lang.String'} to {@code 'String'}). * There is a possible situation then that we have two replacements where one key is a simple name and another one is a fully qualified * one. We want to apply {@code 'from fully qualified name'} replacement first then. */ private static final Comparator<String> REPLACEMENTS_COMPARATOR = new Comparator<String>() { @Override public int compare(@NotNull String o1, @NotNull String o2) { String shortName1 = extractShortName(o1); String shortName2 = extractShortName(o2); if (!shortName1.equals(shortName2)) { return shortName1.compareTo(shortName2); } if (o1.endsWith(o2)) { return -1; } else if (o2.endsWith(o1)) { return 1; } else { return o1.compareTo(o2); } } private String extractShortName(@NotNull String s) { int i = s.lastIndexOf('.'); return i > 0 && i < s.length() - 1 ? s.substring(i + 1) : s; } }; private DocPreviewUtil() { } /** * Allows to build a documentation preview from the given arguments. Basically, takes given 'header' text and tries to modify * it by using hyperlink information encapsulated at the given 'full text'. * * @param header target documentation header. Is expected to be a result of the * {@link DocumentationProvider#getQuickNavigateInfo(PsiElement, PsiElement)} call * @param qName there is a possible case that not all documentation text will be included to the preview * (according to the given 'desired rows and columns per-row' arguments). A link that points to the * element with the given qualified name is added to the preview's end if the qName is provided then * @param fullText full documentation text (if available) */ @NotNull public static String buildPreview(@NotNull final String header, @Nullable final String qName, @Nullable final String fullText) { if (fullText == null) { return header; } // Build links info. Map<String/*qName*/, String/*address*/> links = ContainerUtilRt.newHashMap(); process(fullText, new LinksCollector(links)); // Add derived names. Map<String, String> toAdd = ContainerUtilRt.newHashMap(); for (Map.Entry<String, String> entry : links.entrySet()) { String shortName = parseShortName(entry.getKey()); if (shortName != null) { toAdd.put(shortName, entry.getValue()); } String longName = parseLongName(entry.getKey(), entry.getValue()); if (longName != null) { toAdd.put(longName, entry.getValue()); } } links.putAll(toAdd); if (qName != null) { links.put(qName, DocumentationManagerProtocol.PSI_ELEMENT_PROTOCOL + qName); } // Apply links info to the header template. List<TextRange> modifiedRanges = ContainerUtilRt.newArrayList(); List<String> sortedReplacements = ContainerUtilRt.newArrayList(links.keySet()); Collections.sort(sortedReplacements, REPLACEMENTS_COMPARATOR); StringBuilder buffer = new StringBuilder(header); replace(buffer, "\n", "<br/>", modifiedRanges); for (String replaceFrom : sortedReplacements) { replace(buffer, replaceFrom, String.format("<a href=\"%s\">%s</a>", links.get(replaceFrom), replaceFrom), modifiedRanges); } return buffer.toString(); } /** * Tries to build a short name form the given name assuming that it is a full name. * <p/> * Example: return {@code 'String'} for a given {@code 'java.lang.String'}. * * @param name name to process * @return short name derived from the given full name if possible; {@code null} otherwise */ @Nullable private static String parseShortName(@NotNull String name) { int i = name.lastIndexOf('.'); return i > 0 && i < name.length() - 1 ? name.substring(i + 1) : null; } /** * Tries to build a long name from the given short name and a link. * <p/> * Example: return {@code 'java.lang.String'} for a given pair (name {@code 'String'}; address: {@code 'psi_element://java.lang.String'}. * * @param shortName short name to process * @param address address to process * @return long name derived from the given arguments (if any); {@code null} otherwise */ @Nullable private static String parseLongName(@NotNull String shortName, @NotNull String address) { String pureAddress = address; int i = pureAddress.lastIndexOf("//"); if (i > 0 && i < pureAddress.length() - 2) { pureAddress = pureAddress.substring(i + 2); } return (pureAddress.equals(shortName) || !pureAddress.endsWith(shortName)) ? null : pureAddress; } private static void replace(@NotNull StringBuilder text, @NotNull String replaceFrom, @NotNull String replaceTo, @NotNull List<TextRange> readOnlyChanges) { for (int i = text.indexOf(replaceFrom); i >= 0 && i < text.length() - 1; i = text.indexOf(replaceFrom, i + 1)) { int end = i + replaceFrom.length(); if (intersects(readOnlyChanges, i, end)) { continue; } if (!"\n".equals(replaceFrom)) { if (end < text.length() && !ALLOWED_LINK_SEPARATORS.contains(text.charAt(end))) { // Consider a situation when we have, say, replacement from text 'PsiType' and encounter a 'PsiTypeParameter' in the text. // We don't want to perform the replacement then. continue; } if (i > 0 && !ALLOWED_LINK_SEPARATORS.contains(text.charAt(i - 1))) { // Similar situation but targets head match: from = 'TextRange', text = 'getTextRange()'. continue; } } text.replace(i, end, replaceTo); int diff = replaceTo.length() - replaceFrom.length(); for (int j = 0; j < readOnlyChanges.size(); j++) { TextRange range = readOnlyChanges.get(j); if (range.getStartOffset() >= end) { readOnlyChanges.set(j, range.shiftRight(diff)); } } readOnlyChanges.add(new TextRange(i, i + replaceTo.length())); } } private static boolean intersects(@NotNull List<TextRange> ranges, int start, int end) { for (TextRange range : ranges) { if (range.intersectsStrict(start, end)) { return true; } } return false; } private enum State {TEXT, INSIDE_OPEN_TAG, INSIDE_CLOSE_TAG} @SuppressWarnings("AssignmentToForLoopParameter") private static int process(@NotNull String text, @NotNull Callback callback) { State state = State.TEXT; int dataStartOffset = 0; int tagNameStartOffset = 0; String tagName = null; int i = 0; for (; i < text.length(); i++) { char c = text.charAt(i); switch (state) { case TEXT: if (c == '<') { if (i > dataStartOffset) { if (!callback.onText(text.substring(dataStartOffset, i).replace(" ", " "))) { return dataStartOffset; } } dataStartOffset = i; if (i < text.length() - 1 && text.charAt(i + 1) == '/') { state = State.INSIDE_CLOSE_TAG; tagNameStartOffset = ++i + 1; } else { state = State.INSIDE_OPEN_TAG; tagNameStartOffset = i + 1; } } break; case INSIDE_OPEN_TAG: if (c == ' ') { tagName = text.substring(tagNameStartOffset, i); } else if (c == '/') { if (i < text.length() - 1 && text.charAt(i + 1) == '>') { if (tagName == null) { tagName = text.substring(tagNameStartOffset, i); } if (!callback.onStandaloneTag(tagName, text.substring(dataStartOffset, i + 2))) { return dataStartOffset; } tagName = null; state = State.TEXT; dataStartOffset = ++i + 1; break; } } else if (c == '>') { if (tagName == null) { tagName = text.substring(tagNameStartOffset, i); } if (!callback.onOpenTag(tagName, text.substring(dataStartOffset, i + 1))) { return dataStartOffset; } tagName = null; state = State.TEXT; dataStartOffset = i + 1; } break; case INSIDE_CLOSE_TAG: if (c == '>') { if (tagName == null) { tagName = text.substring(tagNameStartOffset, i); } if (!callback.onCloseTag(tagName, text.substring(dataStartOffset, i + 1))) { return dataStartOffset; } tagName = null; state = State.TEXT; dataStartOffset = i + 1; } } } if (dataStartOffset < text.length()) { callback.onText(text.substring(dataStartOffset, text.length()).replace(" ", " ")); } return i; } private interface Callback { boolean onOpenTag(@NotNull String name, @NotNull String text); boolean onCloseTag(@NotNull String name, @NotNull String text); boolean onStandaloneTag(@NotNull String name, @NotNull String text); boolean onText(@NotNull String text); } private static class LinksCollector implements Callback { private static final Pattern HREF_PATTERN = Pattern.compile("href=[\"']([^\"']+)"); @NotNull private final Map<String, String> myLinks; private String myHref; LinksCollector(@NotNull Map<String, String> links) { myLinks = links; } @Override public boolean onOpenTag(@NotNull String name, @NotNull String text) { if (!"a".equals(name)) { return true; } Matcher matcher = HREF_PATTERN.matcher(text); if (matcher.find()) { myHref = matcher.group(1); } return true; } @Override public boolean onCloseTag(@NotNull String name, @NotNull String text) { if ("a".equals(name)) { myHref = null; } return true; } @Override public boolean onStandaloneTag(@NotNull String name, @NotNull String text) { return true; } @Override public boolean onText(@NotNull String text) { if (myHref != null) { myLinks.put(text, myHref); myHref = null; } return true; } } }