/******************************************************************************* * Copyright (c) 2020 Red Hat, Inc. * Distributed under license by Red Hat, Inc. All rights reserved. * This program is made available under the terms of the * Eclipse Public License v2.0 which accompanies this distribution, * and is available at http://www.eclipse.org/legal/epl-v20.html * * Contributors: * Red Hat, Inc. - initial API and implementation ******************************************************************************/ package com.redhat.devtools.intellij.quarkus.lsp4ij.operations.hover; import com.intellij.lang.documentation.DocumentationProvider; import com.intellij.lang.documentation.DocumentationProviderEx; import com.intellij.openapi.editor.Document; import com.intellij.openapi.editor.Editor; import com.intellij.psi.PsiDocumentManager; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; import com.redhat.devtools.intellij.quarkus.lsp4ij.LSPIJUtils; import com.redhat.devtools.intellij.quarkus.lsp4ij.LanguageServiceAccessor; import com.vladsch.flexmark.html.HtmlRenderer; import com.vladsch.flexmark.parser.Parser; import org.eclipse.lsp4j.Hover; import org.eclipse.lsp4j.MarkedString; import org.eclipse.lsp4j.MarkupContent; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.awt.Color; import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Predicate; import java.util.stream.Collectors; public class LSPTextHover extends DocumentationProviderEx { private static final Logger LOGGER = LoggerFactory.getLogger(LSPTextHover.class); private static final String HEAD = "<head>"; //$NON-NLS-1$ private static final Parser PARSER = Parser.builder().build(); private static final HtmlRenderer RENDERER = HtmlRenderer.builder().build(); private PsiElement lastElement; private int lastOffset = -1; private CompletableFuture<List<Hover>> request; public LSPTextHover() { LOGGER.info("LSPTextHover"); } public static String styleHtml(Editor editor, String html) { if (html == null || html.isEmpty()) { return html; } Color background = editor.getColorsScheme().getDefaultBackground(); Color foreground = editor.getColorsScheme().getDefaultForeground(); // put CSS styling to match Eclipse style String style = "<html><head><style TYPE='text/css'>html { " + //$NON-NLS-1$ (background != null ? "background-color: " + toHTMLrgb(background) + "; " : "") + //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ (foreground != null ? "color: " + toHTMLrgb(foreground) + "; " : "") + //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ " }</style></head><body>"; //$NON-NLS-1$ /*int headIndex = html.indexOf(HEAD); StringBuilder builder = new StringBuilder(html.length() + style.length()); builder.append(html.substring(0, headIndex + HEAD.length())); builder.append(style); builder.append(html.substring(headIndex + HEAD.length())); return builder.toString();*/ StringBuilder builder = new StringBuilder(style); builder.append(html).append("</body></html>"); return builder.toString(); } private static String toHTMLrgb(Color rgb) { StringBuilder builder = new StringBuilder(7); builder.append('#'); appendAsHexString(builder, rgb.getRed()); appendAsHexString(builder, rgb.getGreen()); appendAsHexString(builder, rgb.getBlue()); return builder.toString(); } private static void appendAsHexString(StringBuilder buffer, int intValue) { String hexValue= Integer.toHexString(intValue); if (hexValue.length() == 1) { buffer.append('0'); } buffer.append(hexValue); } @Nullable @Override public String getQuickNavigateInfo(PsiElement element, PsiElement originalElement) { return generateDoc(element, originalElement); } @Nullable @Override public List<String> getUrlFor(PsiElement element, PsiElement originalElement) { return null; } @Nullable @Override public String generateDoc(PsiElement element, @Nullable PsiElement originalElement) { Editor editor = LSPIJUtils.editorForFile(element.getContainingFile().getVirtualFile()); if (editor != null) { if (this.request == null || !element.equals(this.lastElement) || lastOffset != editor.getCaretModel().getCurrentCaret().getOffset()) { initiateHoverRequest(element, editor.getCaretModel().getCurrentCaret().getOffset()); } try { String result = request.get(500, TimeUnit.MILLISECONDS).stream() .filter(Objects::nonNull) .map(LSPTextHover::getHoverString) .filter(Objects::nonNull) .collect(Collectors.joining("\n\n")) //$NON-NLS-1$ .trim(); if (!result.isEmpty()) { return styleHtml(editor, RENDERER.render(PARSER.parse(result))); } } catch (ExecutionException | TimeoutException e) { LOGGER.warn(e.getLocalizedMessage(), e); } catch (InterruptedException e) { LOGGER.warn(e.getLocalizedMessage(), e); Thread.currentThread().interrupt(); } } return null; } protected static @Nullable String getHoverString(Hover hover) { Either<List<Either<String, MarkedString>>, MarkupContent> hoverContent = hover.getContents(); if (hoverContent.isLeft()) { List<Either<String, MarkedString>> contents = hoverContent.getLeft(); if (contents == null || contents.isEmpty()) { return null; } return contents.stream().map(content -> { if (content.isLeft()) { return content.getLeft(); } else if (content.isRight()) { MarkedString markedString = content.getRight(); // TODO this won't work fully until markup parser will support syntax // highlighting but will help display // strings with language tags, e.g. without it things after <?php tag aren't // displayed if (markedString.getLanguage() != null && !markedString.getLanguage().isEmpty()) { return String.format("```%s%n%s%n```", markedString.getLanguage(), markedString.getValue()); //$NON-NLS-1$ } else { return markedString.getValue(); } } else { return ""; //$NON-NLS-1$ } }).filter(((Predicate<String>) String::isEmpty).negate()).collect(Collectors.joining("\n\n")); //$NON-NLS-1$ ) } else { return hoverContent.getRight().getValue(); } } /** * Initialize hover requests with hover (if available) and codelens (if * available). * * @param element * the PSI element. * @param offset * the hovered offset. */ private void initiateHoverRequest(PsiElement element, int offset) { PsiDocumentManager manager = PsiDocumentManager.getInstance(element.getProject()); final Document document = manager.getDocument(element.getContainingFile()); this.lastElement = element; this.lastOffset = offset; this.request = LanguageServiceAccessor.getInstance(element.getProject()) .getLanguageServers(document, capabilities -> Boolean.TRUE.equals(capabilities.getHoverProvider())) .thenApplyAsync(languageServers -> // Async is very important here, otherwise the LS Client thread is in // deadlock and doesn't read bytes from LS languageServers.stream() .map(languageServer -> { try { return languageServer.getTextDocumentService() .hover(LSPIJUtils.toHoverParams(offset, document)).get(); } catch (ExecutionException e) { LOGGER.warn(e.getLocalizedMessage(), e); return null; } catch (InterruptedException e) { LOGGER.warn(e.getLocalizedMessage(), e); Thread.currentThread().interrupt(); return null; } }).filter(Objects::nonNull).collect(Collectors.toList())); } @Nullable @Override public PsiElement getDocumentationElementForLookupItem(PsiManager psiManager, Object object, PsiElement element) { return null; } @Nullable @Override public PsiElement getDocumentationElementForLink(PsiManager psiManager, String link, PsiElement context) { return null; } @Nullable @Override public PsiElement getCustomDocumentationElement(@NotNull Editor editor, @NotNull PsiFile file, @Nullable PsiElement contextElement) { return null; } }