From 65c14ceb50fc481049855a987568dfe2d6c2a550 Mon Sep 17 00:00:00 2001 From: azerr Date: Thu, 20 Jun 2024 15:26:06 +0200 Subject: [PATCH] feat: Support file link in hover Fixes #376 Signed-off-by: azerr --- .../devtools/lsp4ij/LSPFileSupport.java | 16 +- .../redhat/devtools/lsp4ij/LSPIJUtils.java | 149 +++++++++++++++++- .../devtools/lsp4ij/ServerMessageHandler.java | 2 +- ...LSPDocumentLinkGotoDeclarationHandler.java | 30 +--- .../LSPDocumentationLinkHandler.java | 45 ++++++ .../documentation/LSPDocumentationTarget.java | 3 + .../documentation/MarkdownConverter.java | 90 ++++++++--- .../markdown/LSPLinkResolver.java | 125 +++++++++++++++ .../SyntaxColorationCodeBlockRenderer.java | 20 ++- .../TextMateHighlighterHelper.java | 3 +- .../lsp4ij/hint/LSPNavigationLinkHandler.java | 73 +++------ src/main/resources/META-INF/plugin.xml | 3 + .../documentation/MarkdownConverterTest.java | 35 ++-- .../MarkdownConverterWithPsiFileTest.java | 137 ++++++++++++++++ 14 files changed, 609 insertions(+), 122 deletions(-) create mode 100644 src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationLinkHandler.java create mode 100644 src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/LSPLinkResolver.java rename src/main/java/com/redhat/devtools/lsp4ij/features/documentation/{ => markdown}/SyntaxColorationCodeBlockRenderer.java (87%) rename src/main/java/com/redhat/devtools/lsp4ij/features/documentation/{ => markdown}/TextMateHighlighterHelper.java (94%) create mode 100644 src/test/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverterWithPsiFileTest.java diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LSPFileSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/LSPFileSupport.java index 58c637911..7636f34a8 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LSPFileSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LSPFileSupport.java @@ -12,6 +12,7 @@ import com.intellij.openapi.Disposable; import com.intellij.openapi.util.Key; +import com.intellij.openapi.util.UserDataHolderBase; import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.features.codeAction.intention.LSPIntentionCodeActionSupport; import com.redhat.devtools.lsp4ij.features.codeLens.LSPCodeLensSupport; @@ -30,13 +31,15 @@ import com.redhat.devtools.lsp4ij.features.rename.LSPRenameSupport; import com.redhat.devtools.lsp4ij.features.signatureHelp.LSPSignatureHelpSupport; import com.redhat.devtools.lsp4ij.features.typeDefinition.LSPTypeDefinitionSupport; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; /** * LSP file support stored in the opened {@link PsiFile} with key "lsp.file.support" * which manages and caches LSP codeLens, inlayHint, color futures. */ -public class LSPFileSupport implements Disposable { +@ApiStatus.Internal +public class LSPFileSupport extends UserDataHolderBase implements Disposable { private static final Key LSP_FILE_SUPPORT_KEY = Key.create("lsp.file.support"); private final PsiFile file; @@ -118,6 +121,14 @@ public void dispose() { getReferenceSupport().cancel(); getDeclarationSupport().cancel(); getTypeDefinitionSupport().cancel(); + var map = getUserMap(); + var keys= map.getKeys(); + for(var key : keys) { + var value = map.get(key); + if (value instanceof Disposable) { + ((Disposable) value).dispose(); + } + } } /** @@ -306,4 +317,7 @@ public static boolean hasSupport(@NotNull PsiFile file) { return file.getUserData(LSP_FILE_SUPPORT_KEY) != null; } + public PsiFile getFile() { + return file; + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java index b9319dded..5a2009c41 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java @@ -28,6 +28,7 @@ import com.intellij.openapi.roots.ModuleRootManager; import com.intellij.openapi.roots.ProjectFileIndex; import com.intellij.openapi.roots.ProjectRootManager; +import com.intellij.openapi.ui.Messages; import com.intellij.openapi.util.TextRange; import com.intellij.openapi.vfs.*; import com.intellij.psi.PsiElement; @@ -48,6 +49,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Paths; import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; /** * Utilities class for LSP. @@ -63,6 +65,10 @@ public class LSPIJUtils { private static final String JRT_SCHEME = JRT_PROTOCOL + ":"; + public static final String HASH_SEPARATOR = "#"; + + private static final String ENCODED_HASH_SEPARATOR = "%23"; + private static final Comparator TEXT_EDITS_ASCENDING_COMPARATOR = (a, b) -> { int diff = a.getRange().getStart().getLine() - b.getRange().getStart().getLine(); if (diff == 0) { @@ -121,10 +127,121 @@ public static boolean openInEditor(@NotNull String fileUri, @Nullable Position position, boolean focusEditor, @NotNull Project project) { + return openInEditor(fileUri, position, focusEditor, false, project); + } + + /** + * Open the given fileUrl in an editor. + * + *

+ * the following syntax is supported for fileUrl: + *

    + *
  • file:///C:/Users/username/foo.txt
  • + *
  • C:/Users/username/foo.txt
  • + *
  • file:///C:/Users/username/foo.txt#L1:5
  • + *
+ *

+ * @param fileUri the file Uri to open. + * @param position the position. + * @param focusEditor true if editor will take the focus and false otherwise. + * @param createFileIfNeeded true if file must be created if doesn't exist and false otherwise. + * @param project the project. + * @return true if file Url can be opened and false otherwise. + */ + public static boolean openInEditor(@NotNull String fileUri, + @Nullable Position position, + boolean focusEditor, + boolean createFileIfNeeded, + @NotNull Project project) { + if (position == null) { + // Try to get position information from the fileUri + // ex : + // - file:///c:/Users/azerr/Downloads/simpleTest/simpleTest/yes.lua#L2 + // - file:///c:/Users/azerr/Downloads/simpleTest/simpleTest/yes.lua#L2:5 + // - file:///c%3A/Users/azerr/Downloads/simpleTest/simpleTest/yes.lua%23L2 + String findHash = HASH_SEPARATOR; + int hashIndex = fileUri.lastIndexOf(findHash); + if (hashIndex == -1) { + findHash = ENCODED_HASH_SEPARATOR; + hashIndex = fileUri.lastIndexOf(findHash); + } + boolean hasPosition = hashIndex > 0 && hashIndex != fileUri.length() - 1; + if (hasPosition) { + position = toPosition(fileUri.substring(hashIndex + findHash.length())); + fileUri = fileUri.substring(0, hashIndex); + } + } VirtualFile file = findResourceFor(fileUri); + if (file == null && createFileIfNeeded) { + // The file doesn't exit, + // open a dialog to confirm the creation of the file. + final String uri = fileUri; + if (ApplicationManager.getApplication().isDispatchThread()) { + return createFileAndOpenInEditor(uri, project); + } else { + AtomicBoolean result = new AtomicBoolean(false); + ApplicationManager.getApplication().invokeAndWait(() -> { + result.set(createFileAndOpenInEditor(uri, project)); + }); + return result.get(); + } + } return openInEditor(file, position, focusEditor, project); } + private static boolean createFileAndOpenInEditor(@NotNull String fileUri, @NotNull Project project) { + int result = Messages.showYesNoDialog(LanguageServerBundle.message("lsp.create.file.confirm.dialog.message", fileUri), + LanguageServerBundle.message("lsp.create.file.confirm.dialog.title"), Messages.getQuestionIcon()); + if (result == Messages.YES) { + try { + // Create file + VirtualFile newFile = LSPIJUtils.createFile(fileUri); + if (newFile != null) { + // Open it in an editor + return LSPIJUtils.openInEditor(newFile, null, project); + } + } catch (Exception e) { + Messages.showErrorDialog(LanguageServerBundle.message("lsp.create.file.error.dialog.message", fileUri, e.getMessage()), + LanguageServerBundle.message("lsp.create.file.error.dialog.title")); + } + } + return false; + } + + /** + * Convert position String 'L1:2' to an LSP {@link Position} and null otherwise. + * + * @param positionString the position string (ex: 'L1:2') + * + * @return position String 'L1:2' to an LSP {@link Position} and null otherwise. + */ + private static Position toPosition(String positionString) { + if (positionString == null || positionString.isEmpty()) { + return null; + } + if (positionString.charAt(0) != 'L') { + return null; + } + positionString = positionString.substring(1, positionString.length()); + String[] positions = positionString.split(":"); + if (positions.length == 0) { + return null; + } + int line = toInt(0, positions) - 1; // Line numbers should be 1-based + int character = toInt(1, positions); + return new Position(line, character); + } + + private static int toInt(int index, String[] positions) { + if (index < positions.length) { + try { + return Integer.valueOf(positions[index]); + } catch (Exception e) { + } + } + return 0; + } + /** * Open the given file with the given position in an editor. * @@ -153,19 +270,36 @@ public static boolean openInEditor(@Nullable VirtualFile file, boolean focusEditor, @NotNull Project project) { if (file != null) { - if (position == null) { - return FileEditorManager.getInstance(project).openFile(file, true).length > 0; + final Document document = position != null ? LSPIJUtils.getDocument(file) : null; + if (ApplicationManager.getApplication().isDispatchThread()) { + return doOpenInEditor(file, position, document, focusEditor, project); } else { - Document document = FileDocumentManager.getInstance().getDocument(file); - if (document != null) { - OpenFileDescriptor desc = new OpenFileDescriptor(project, file, LSPIJUtils.toOffset(position, document)); - return FileEditorManager.getInstance(project).openTextEditor(desc, focusEditor) != null; - } + AtomicBoolean result = new AtomicBoolean(false); + ApplicationManager.getApplication().invokeAndWait(() -> { + result.set(doOpenInEditor(file, position, document, focusEditor, project)); + }); + return result.get(); } } return false; } + private static boolean doOpenInEditor(@NotNull VirtualFile file, + @Nullable Position position, + @Nullable Document document, + boolean focusEditor, + @NotNull Project project) { + if (position == null) { + return FileEditorManager.getInstance(project).openFile(file, true).length > 0; + } else { + if (document != null) { + OpenFileDescriptor desc = new OpenFileDescriptor(project, file, LSPIJUtils.toOffset(position, document)); + return FileEditorManager.getInstance(project).openTextEditor(desc, focusEditor) != null; + } + return false; + } + } + /** * Returns the file language of the given file and null otherwise. * @@ -412,7 +546,6 @@ public static URI toUri(Module module) { * Return top-level directories which contain files related to the project. * * @param project the project. - * * @return top-level directories which contain files related to the project. */ @NotNull diff --git a/src/main/java/com/redhat/devtools/lsp4ij/ServerMessageHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/ServerMessageHandler.java index ff8617d09..3dc1398bf 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/ServerMessageHandler.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/ServerMessageHandler.java @@ -176,7 +176,7 @@ public static CompletableFuture showDocument(@NotNull ShowDo CompletableFuture future = new CompletableFuture<>(); ApplicationManager.getApplication() - .invokeLater(() -> { + .executeOnPooledThread(() -> { if (LSPIJUtils.openInEditor(uri, position, focusEditor, project)) { future.complete(SHOW_DOCUMENT_RESULT_WITH_SUCCESS); } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentLink/LSPDocumentLinkGotoDeclarationHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentLink/LSPDocumentLinkGotoDeclarationHandler.java index 1e8be0bb3..6385b747f 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentLink/LSPDocumentLinkGotoDeclarationHandler.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentLink/LSPDocumentLinkGotoDeclarationHandler.java @@ -14,21 +14,17 @@ package com.redhat.devtools.lsp4ij.features.documentLink; import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler; -import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.editor.Document; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.module.Module; import com.intellij.openapi.progress.ProcessCanceledException; import com.intellij.openapi.project.Project; -import com.intellij.openapi.ui.Messages; import com.intellij.openapi.util.TextRange; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; -import com.intellij.psi.PsiManager; import com.redhat.devtools.lsp4ij.LSPFileSupport; import com.redhat.devtools.lsp4ij.LSPIJUtils; -import com.redhat.devtools.lsp4ij.LanguageServerBundle; import com.redhat.devtools.lsp4ij.LanguageServersRegistry; import org.eclipse.lsp4j.DocumentLink; import org.eclipse.lsp4j.DocumentLinkParams; @@ -36,7 +32,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; import java.util.List; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; @@ -71,7 +66,8 @@ public class LSPDocumentLinkGotoDeclarationHandler implements GotoDeclarationHan CompletableFuture> documentLinkFuture = documentLinkSupport.getDocumentLinks(params); try { waitUntilDone(documentLinkFuture, psiFile); - } catch (ProcessCanceledException e) {//Since 2024.2 ProcessCanceledException extends CancellationException so we can't use multicatch to keep backward compatibility + } catch ( + ProcessCanceledException e) {//Since 2024.2 ProcessCanceledException extends CancellationException so we can't use multicatch to keep backward compatibility //TODO delete block when minimum required version is 2024.2 documentLinkSupport.cancel(); return null; @@ -100,28 +96,12 @@ public class LSPDocumentLinkGotoDeclarationHandler implements GotoDeclarationHan // which asks if user want to create the file. // At this step we cannot open a dialog directly, we need to open the dialog // with invoke later. - ApplicationManager.getApplication().invokeLater(() -> { - int result = Messages.showYesNoDialog(LanguageServerBundle.message("lsp.create.file.confirm.dialog.message", target), - LanguageServerBundle.message("lsp.create.file.confirm.dialog.title"), Messages.getQuestionIcon()); - if (result == Messages.YES) { - try { - // Create file - VirtualFile newFile = LSPIJUtils.createFile(target); - if (newFile != null) { - // Open it in an editor - LSPIJUtils.openInEditor(newFile, null, project); - } - } catch (IOException e) { - Messages.showErrorDialog(LanguageServerBundle.message("lsp.create.file.error.dialog.message", target, e.getMessage()), - LanguageServerBundle.message("lsp.create.file.error.dialog.title")); - } - } - }); + LSPIJUtils.openInEditor(target, null, true, true, project); // Return an empty result here. - // If user accepts to create the file, the open is done after the creation of teh file. + // If user accepts to create the file, the open is done after the creation of the file. return PsiElement.EMPTY_ARRAY; } - return new PsiElement[]{ LSPIJUtils.getPsiFile(targetFile, project)}; + return new PsiElement[]{LSPIJUtils.getPsiFile(targetFile, project)}; } } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationLinkHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationLinkHandler.java new file mode 100644 index 000000000..2d37432b1 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationLinkHandler.java @@ -0,0 +1,45 @@ +/******************************************************************************* + * Copyright (c) 2024 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.lsp4ij.features.documentation; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.platform.backend.documentation.DocumentationLinkHandler; +import com.intellij.platform.backend.documentation.DocumentationTarget; +import com.intellij.platform.backend.documentation.LinkResolveResult; +import com.redhat.devtools.lsp4ij.LSPIJUtils; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * LSP {@link DocumentationLinkHandler} to open file in an + * IJ Editor (and with a given position if declared) declared as link in HTML. + */ +@ApiStatus.Internal +public class LSPDocumentationLinkHandler implements DocumentationLinkHandler { + + @Override + public @Nullable LinkResolveResult resolveLink(@NotNull DocumentationTarget target, + @NotNull String url) { + if (target instanceof LSPDocumentationTarget lspTarget) { + if (url.startsWith("file://")) { + ApplicationManager.getApplication() + .executeOnPooledThread(() -> { + var file = lspTarget.getFile(); + LSPIJUtils.openInEditor(url, null, true, true, file.getProject()); + }); + return LinkResolveResult.resolvedTarget(target); + } + } + return null; + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationTarget.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationTarget.java index 2eb40b604..4d2692086 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationTarget.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationTarget.java @@ -60,4 +60,7 @@ public Pointer createPointer() { return Pointer.hardPointer(this); } + public PsiFile getFile() { + return file; + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverter.java index d2df185d6..3ff6a2b5f 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverter.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverter.java @@ -12,28 +12,39 @@ import com.intellij.lang.Language; import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Key; import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.LSPFileSupport; +import com.redhat.devtools.lsp4ij.features.documentation.markdown.LSPLinkResolver; +import com.redhat.devtools.lsp4ij.features.documentation.markdown.SyntaxColorationCodeBlockRenderer; import com.vladsch.flexmark.ext.autolink.AutolinkExtension; import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension; import com.vladsch.flexmark.ext.tables.TablesExtension; import com.vladsch.flexmark.html.HtmlRenderer; -import com.vladsch.flexmark.html.renderer.NodeRenderer; -import com.vladsch.flexmark.html.renderer.NodeRendererFactory; import com.vladsch.flexmark.parser.Parser; -import com.vladsch.flexmark.util.data.DataHolder; +import com.vladsch.flexmark.util.data.DataKey; import com.vladsch.flexmark.util.data.MutableDataSet; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.nio.file.Path; import java.util.Arrays; /** * Converts Markdown to HTML */ +@ApiStatus.Internal public class MarkdownConverter { - private final Project project; + private static final Key HTML_RENDERER_KEY = Key.create("lsp.html.renderer"); + + public static final DataKey PROJECT_CONTEXT = new DataKey<>("LSP_PROJECT", (Project) null); + public static final DataKey FILE_NAME_CONTEXT = new DataKey<>("LSP_FILE_NAME", ""); + public static final DataKey LANGUAGE_CONTEXT = new DataKey<>("LSP_LANGUAGE", (Language) null); + public static final DataKey FILE_BASE_DIR = new DataKey<>("LSP_BASE_DIR", (Path) null); + private final Project project; private final Parser htmlParser; private final HtmlRenderer htmlRenderer; private final MutableDataSet options; @@ -60,18 +71,20 @@ private MarkdownConverter(Project project) { .set(TablesExtension.DISCARD_EXTRA_COLUMNS, true) .set(TablesExtension.HEADER_SEPARATOR_COLUMN_MATCH, true); - options.set(HtmlRenderer.SOFT_BREAK, "
\n"); + options.set(HtmlRenderer.SOFT_BREAK, "\n"); options.set(HtmlRenderer.GENERATE_HEADER_ID, true); - htmlRenderer = HtmlRenderer.builder(options) - .nodeRendererFactory(new NodeRendererFactory() { - @Override - public NodeRenderer apply(DataHolder options) { - return new SyntaxColorationCodeBlockRenderer(project, null, null); - } - }).build(); + options.set(PROJECT_CONTEXT, project); + htmlRenderer = createHtmlRenderer(options); htmlParser = Parser.builder(options).build(); } + @NotNull + private static HtmlRenderer createHtmlRenderer(MutableDataSet options) { + return HtmlRenderer.builder(options) + .linkResolverFactory(new LSPLinkResolver.Factory()) + .nodeRendererFactory(new SyntaxColorationCodeBlockRenderer.Factory()) + .build(); + } /** * Convert the given markdown to Html. @@ -91,11 +104,43 @@ public NodeRenderer apply(DataHolder options) { * the syntax coloration to use if MarkDown content contains some code block or blockquote to highlight. * @return the given markdown to Html. */ - public @NotNull String toHtml(@NotNull String markdown, @Nullable PsiFile file) { - return toHtml(markdown, file != null ? file.getLanguage() : null, file != null ? file.getName() : null); + public @NotNull String toHtml(@NotNull String markdown, + @Nullable PsiFile file) { + var htmlRenderer = this.htmlRenderer; + if (file != null) { + // The HtmlRenderer is stored in LSPFileSupport instead of PsiFile + // to evict it when file is closed. + var fileSupport = LSPFileSupport.getSupport(file); + htmlRenderer = fileSupport.getUserData(HTML_RENDERER_KEY); + if (htmlRenderer == null) { + htmlRenderer = getHtmlRenderer(fileSupport); + } + } + return htmlRenderer.render(htmlParser.parse(markdown)); + } + + private synchronized HtmlRenderer getHtmlRenderer(@NotNull LSPFileSupport fileSupport) { + var file = fileSupport.getFile(); + var htmlRenderer = fileSupport.getUserData(HTML_RENDERER_KEY); + if (htmlRenderer != null) { + return htmlRenderer; + } + + MutableDataSet fileOptions = new MutableDataSet(options); + fileOptions.set(LANGUAGE_CONTEXT, file.getLanguage()); + fileOptions.set(FILE_NAME_CONTEXT, file.getName()); + Path baseDir = file.getVirtualFile().getParent().getFileSystem().getNioPath(file.getVirtualFile().getParent()); + fileOptions.set(FILE_BASE_DIR, baseDir); + + htmlRenderer = createHtmlRenderer(fileOptions); + fileSupport.putUserData(HTML_RENDERER_KEY, htmlRenderer); + file.putUserData(HTML_RENDERER_KEY, htmlRenderer); + return htmlRenderer; } /** + * This method is just used by Junit tests. + *

* Convert the given markdown to Html. * * @param markdown the MarkDown content to convert to Html. @@ -103,16 +148,17 @@ public NodeRenderer apply(DataHolder options) { * @param fileName the file name which must be used to retrieve TextMate (if non-null) for MarkDown code block which defines the language or indented blockquote. * @return the given markdown to Html. */ - public @NotNull String toHtml(@NotNull String markdown, @Nullable Language language, @Nullable String fileName) { + public @NotNull String toHtml(@NotNull String markdown, + @Nullable Path baseDir, + @Nullable Language language, + @Nullable String fileName) { var htmlRenderer = this.htmlRenderer; if (language != null || fileName != null) { - htmlRenderer = HtmlRenderer.builder(options) - .nodeRendererFactory(new NodeRendererFactory() { - @Override - public NodeRenderer apply(DataHolder options) { - return new SyntaxColorationCodeBlockRenderer(project, language, fileName); - } - }).build(); + MutableDataSet fileOptions = new MutableDataSet(options); + fileOptions.set(FILE_BASE_DIR, baseDir); + fileOptions.set(LANGUAGE_CONTEXT, language); + fileOptions.set(FILE_NAME_CONTEXT, fileName); + htmlRenderer = createHtmlRenderer(fileOptions); } return htmlRenderer.render(htmlParser.parse(markdown)); } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/LSPLinkResolver.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/LSPLinkResolver.java new file mode 100644 index 000000000..c10ff427e --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/LSPLinkResolver.java @@ -0,0 +1,125 @@ +/******************************************************************************* + * Copyright (c) 2024 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 https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentation.markdown; + +import com.redhat.devtools.lsp4ij.LSPIJUtils; +import com.redhat.devtools.lsp4ij.features.documentation.MarkdownConverter; +import com.vladsch.flexmark.ast.Image; +import com.vladsch.flexmark.ast.Link; +import com.vladsch.flexmark.ast.Reference; +import com.vladsch.flexmark.html.LinkResolver; +import com.vladsch.flexmark.html.LinkResolverFactory; +import com.vladsch.flexmark.html.renderer.LinkResolverBasicContext; +import com.vladsch.flexmark.html.renderer.LinkStatus; +import com.vladsch.flexmark.html.renderer.ResolvedLink; +import com.vladsch.flexmark.util.ast.Node; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Set; + +/** + * Custom link resolver used to resolve relative path by using the {@link com.intellij.psi.PsiFile} + * path which triggers the MarkDown converter for hover and completion documentation. + */ +public class LSPLinkResolver implements LinkResolver { + + private final Path fileBaseDir; + + private enum FileUrlKind { + RELATIVE, + ABSOLUTE, + NONE; + } + + public LSPLinkResolver(LinkResolverBasicContext context) { + this.fileBaseDir = MarkdownConverter.FILE_BASE_DIR.get(context.getOptions()); + } + + @Override + public @NotNull ResolvedLink resolveLink(@NotNull Node node, @NotNull LinkResolverBasicContext context, @NotNull ResolvedLink link) { + if (node instanceof Image || node instanceof Link || node instanceof Reference) { + String url = link.getUrl(); + FileUrlKind fileUrlKind = getFileUrlKind(url); + if (fileBaseDir != null && fileUrlKind != FileUrlKind.NONE) { + String position = ""; + int hashIndex= url.indexOf("#"); + if (hashIndex != -1) { + position = url.substring(hashIndex, url.length()); + url = url.substring(0, hashIndex); + } + try { + File resolvedFile = getResolvedFile(url, fileUrlKind); + String resolvedUri = LSPIJUtils.toUri(resolvedFile).toASCIIString() + position; + return link.withStatus(LinkStatus.VALID) + .withUrl(resolvedUri); + } + catch(Exception e) { + + } + } + } + return link; + } + + private @NotNull File getResolvedFile(String url, FileUrlKind fileUrlKind) { + if (fileUrlKind == FileUrlKind.RELATIVE) { + return fileBaseDir.resolve(url).toFile(); + } + return Paths.get(url).toFile(); + } + + + private static FileUrlKind getFileUrlKind(String url) { + if (url.isBlank()) { + return FileUrlKind.NONE; + } + int index = url.indexOf("://"); + if (index == -1) { + return FileUrlKind.RELATIVE; + } + if (url.charAt(0) == '/' || url.substring(0, index).equals("file")) { + return FileUrlKind.ABSOLUTE; + } + return FileUrlKind.NONE; + } + + + public static class Factory implements LinkResolverFactory { + + @Nullable + @Override + public Set> getAfterDependents() { + return null; + } + + @Nullable + @Override + public Set> getBeforeDependents() { + return null; + } + + @Override + public boolean affectsGlobalScope() { + return false; + } + + @NotNull + @Override + public LinkResolver apply(@NotNull LinkResolverBasicContext context) { + return new LSPLinkResolver(context); + } + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/SyntaxColorationCodeBlockRenderer.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/SyntaxColorationCodeBlockRenderer.java similarity index 87% rename from src/main/java/com/redhat/devtools/lsp4ij/features/documentation/SyntaxColorationCodeBlockRenderer.java rename to src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/SyntaxColorationCodeBlockRenderer.java index 1f7e23221..800f67be7 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/SyntaxColorationCodeBlockRenderer.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/SyntaxColorationCodeBlockRenderer.java @@ -8,7 +8,7 @@ * Contributors: * Red Hat, Inc. - initial API and implementation ******************************************************************************/ -package com.redhat.devtools.lsp4ij.features.documentation; +package com.redhat.devtools.lsp4ij.features.documentation.markdown; import com.intellij.lang.Language; import com.intellij.openapi.editor.highlighter.EditorHighlighter; @@ -17,6 +17,8 @@ import com.intellij.openapi.editor.richcopy.SyntaxInfoBuilder; import com.intellij.openapi.project.Project; import com.intellij.psi.TokenType; +import com.redhat.devtools.lsp4ij.features.documentation.LightQuickDocHighlightingHelper; +import com.redhat.devtools.lsp4ij.features.documentation.MarkdownConverter; import com.redhat.devtools.lsp4ij.internal.SimpleLanguageUtils; import com.redhat.devtools.lsp4ij.internal.StringUtils; import com.vladsch.flexmark.ast.FencedCodeBlock; @@ -24,8 +26,10 @@ import com.vladsch.flexmark.html.HtmlWriter; import com.vladsch.flexmark.html.renderer.NodeRenderer; import com.vladsch.flexmark.html.renderer.NodeRendererContext; +import com.vladsch.flexmark.html.renderer.NodeRendererFactory; import com.vladsch.flexmark.html.renderer.NodeRenderingHandler; import com.vladsch.flexmark.util.ast.ContentNode; +import com.vladsch.flexmark.util.data.DataHolder; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -54,10 +58,10 @@ public class SyntaxColorationCodeBlockRenderer implements NodeRenderer { private final String fileName; - public SyntaxColorationCodeBlockRenderer(Project project, Language fileLanguage, String fileName) { - this.project = project; - this.fileLanguage = fileLanguage; - this.fileName = fileName; + public SyntaxColorationCodeBlockRenderer(DataHolder options) { + this.project = MarkdownConverter.PROJECT_CONTEXT.get(options); + this.fileLanguage = MarkdownConverter.LANGUAGE_CONTEXT.get(options); + this.fileName = MarkdownConverter.FILE_NAME_CONTEXT.get(options); } @Override @@ -208,5 +212,11 @@ private static boolean hasTextMateSupport() { } } + public static class Factory implements NodeRendererFactory { + @Override + public NodeRenderer apply(DataHolder options) { + return new SyntaxColorationCodeBlockRenderer(options); + } + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/TextMateHighlighterHelper.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/TextMateHighlighterHelper.java similarity index 94% rename from src/main/java/com/redhat/devtools/lsp4ij/features/documentation/TextMateHighlighterHelper.java rename to src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/TextMateHighlighterHelper.java index 94b83f4c0..6aa9733c5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/TextMateHighlighterHelper.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/TextMateHighlighterHelper.java @@ -8,7 +8,7 @@ * Contributors: * Red Hat, Inc. - initial API and implementation ******************************************************************************/ -package com.redhat.devtools.lsp4ij.features.documentation; +package com.redhat.devtools.lsp4ij.features.documentation.markdown; import com.intellij.openapi.editor.colors.EditorColorsManager; import com.intellij.openapi.editor.colors.EditorColorsScheme; @@ -18,6 +18,7 @@ import com.intellij.openapi.fileTypes.SyntaxHighlighter; import com.intellij.openapi.util.registry.Registry; import com.redhat.devtools.lsp4ij.LanguageServersRegistry; +import com.redhat.devtools.lsp4ij.features.documentation.markdown.SyntaxColorationCodeBlockRenderer; import com.redhat.devtools.lsp4ij.internal.StringUtils; import com.vladsch.flexmark.html.HtmlWriter; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/hint/LSPNavigationLinkHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/hint/LSPNavigationLinkHandler.java index 51c1b801d..51921cfd5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/hint/LSPNavigationLinkHandler.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/hint/LSPNavigationLinkHandler.java @@ -20,49 +20,35 @@ import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.jetbrains.annotations.Nullable; + +import static com.redhat.devtools.lsp4ij.LSPIJUtils.HASH_SEPARATOR; /** * Handles tooltip links in format {@code #lsp-navigation/file_path:startLine;startChar;endLine;endChar}. * On a click opens specified file in an editor and positions caret to the given offset. * *

- * This handler looks like {@link com.intellij.codeInsight.hint.NavigationLinkHandler} but as LSP works with position (line, character) - * instead of offset, we provide this handler to avoid resolving offset from LSP position when - * IntelliJ annotation is created with the tooltip. + * This handler looks like {@link com.intellij.codeInsight.hint.NavigationLinkHandler} but as LSP works with position (line, character) + * instead of offset, we provide this handler to avoid resolving offset from LSP position when + * IntelliJ annotation is created with the tooltip. *

*/ -public class LSPNavigationLinkHandler extends TooltipLinkHandler { - - private static final Logger LOGGER = LoggerFactory.getLogger(LSPNavigationLinkHandler.class);//$NON-NLS-1$ +public class LSPNavigationLinkHandler extends TooltipLinkHandler { private static final String PREFIX = "#lsp-navigation/"; - public static final String POS_SEPARATOR = ";"; @Override - public boolean handleLink(@NotNull String refSuffix, @NotNull Editor editor) { - int pos = refSuffix.lastIndexOf(':'); - if (pos <= 0 || pos == refSuffix.length() - 1) { - LOGGER.info("Malformed suffix: " + refSuffix); - return true; - } - - String uri = refSuffix.substring(0, pos); - Range range = toRange(refSuffix.substring(pos + 1)); - Location location = new Location(); - location.setUri(uri); - if (range != null) { - location.setRange(range); - } - return LSPIJUtils.openInEditor(location, editor.getProject()); + public boolean handleLink(@NotNull String fileUrl, + @NotNull Editor editor) { + return LSPIJUtils.openInEditor(fileUrl, null, true, true, editor.getProject()); } /** * Returns the LSP navigation url from the given location. * *

- * {@code #lsp-navigation/file_path:startLine;startChar;endLine;endChar} + * {@code #lsp-navigation/file_path:startLine;startChar;endLine;endChar} *

* * @param location the LSP location. @@ -71,39 +57,26 @@ public boolean handleLink(@NotNull String refSuffix, @NotNull Editor editor) { public static String toNavigationUrl(@NotNull Location location) { StringBuilder url = new StringBuilder(PREFIX); url.append(location.getUri()); - url.append(":"); - if (location.getRange() != null) { - toString(location.getRange(), url); - } + appendStartPositionIfNeeded(location.getRange(), url); return url.toString(); } /** * Serialize LSP range used in the LSP location Url. - * @param range the LSP range. + * + * @param range the LSP range. * @param result */ - private static void toString(@NotNull Range range, StringBuilder result) { - result.append(range.getStart().getLine()); - result.append(POS_SEPARATOR); - result.append(range.getStart().getCharacter()); - result.append(POS_SEPARATOR); - result.append(range.getEnd().getLine()); - result.append(POS_SEPARATOR); - result.append(range.getEnd().getCharacter()); - } - - private static Range toRange(String rangeString) { - if (rangeString.isEmpty()) { - return null; + private static void appendStartPositionIfNeeded(@Nullable Range range, StringBuilder result) { + Position start = range != null ? range.getStart() : null; + if (start == null) { + return; } - String[] positions = rangeString.split(POS_SEPARATOR); - Position start = new Position(toInt(0, positions), toInt(1, positions)); - Position end = new Position(toInt(2, positions), toInt(3, positions)); - return new Range(start, end); + result.append(HASH_SEPARATOR); + result.append("L"); + result.append(start.getLine()); + result.append(":"); + result.append(start.getCharacter()); } - private static int toInt(int index, String[] positions) { - return Integer.valueOf(positions[index]); - } } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 41c2ddc77..97ae01b9c 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -216,6 +216,9 @@ id="LSPDocumentationTargetProvider" implementation="com.redhat.devtools.lsp4ij.features.documentation.LSPDocumentationTargetProvider" order="first"/> + The <project> element is the root of the descriptor.

\n", toHtml("The `` element is the root of the descriptor.")); assertEquals("

Hey Man

\n", toHtml("# Hey Man #")); assertEquals("

ONLY_THIS_TEXT

\n", toHtml("ONLY_THIS_TEXT")); - assertEquals("

This is
\nbold

\n", toHtml(""" + assertEquals("

This is\nbold

\n", toHtml(""" This is **bold** """)); @@ -92,9 +94,9 @@ public void testMultiLineConversion() { """; String html = """ -

multi
- line
- HTML
+

multi + line + HTML stuff

"""; assertEquals(html, toHtml(markdown)); @@ -218,7 +220,7 @@ public void testTypeScriptHighlightBlockquoteConversion() { """; - assertEquals(html, toHtml(markdown, null, "test.ts")); + assertEquals(html, toHtml(markdown, null, null, "test.ts")); } public void testTypeScriptHighlightIndentedBlockquoteWithFileNameConversion() { @@ -236,7 +238,7 @@ public void testTypeScriptHighlightIndentedBlockquoteWithFileNameConversion() { """; - assertEquals(html, toHtml(markdown, null, "test.ts")); + assertEquals(html, toHtml(markdown, null, null, "test.ts")); } public void testXmlHighlightIndentedBlockquoteWithLanguageConversion() { @@ -255,14 +257,29 @@ public void testXmlHighlightIndentedBlockquoteWithLanguageConversion() { """; - assertEquals(html, toHtml(markdown, XMLLanguage.INSTANCE, null)); + assertEquals(html, toHtml(markdown, null, XMLLanguage.INSTANCE, null)); + } + + public void testTypeScriptWithLinksInsideParagraph() { + String markdown = "\n```typescript\nfunction foo(): void\n```\nSome content...\n\u003e and some links:\n [bar.ts](bar.ts#L2:2)\n [lsp4ij](https://github.com/redhat-developer/lsp4ij)"; + String html = "
function foo(): void
\n" + + "

Some content...

\n" + + "
\n" + + "

and some links:\n" + + "bar.ts\n" + + "lsp4ij

\n" + + "
\n"; + assertEquals(html, toHtml(markdown, null, null, "test.ts")); } private String toHtml(String markdown) { return MarkdownConverter.getInstance(myFixture.getProject()).toHtml(markdown); } - private String toHtml(@NotNull String markdown, @Nullable Language language, @Nullable String fileName) { - return MarkdownConverter.getInstance(myFixture.getProject()).toHtml(markdown,language, fileName); + private String toHtml(@NotNull String markdown, + @Nullable Path baseDir, + @Nullable Language language, + @Nullable String fileName) { + return MarkdownConverter.getInstance(myFixture.getProject()).toHtml(markdown, baseDir, language, fileName); } } \ No newline at end of file diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverterWithPsiFileTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverterWithPsiFileTest.java new file mode 100644 index 000000000..d212c0373 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverterWithPsiFileTest.java @@ -0,0 +1,137 @@ +/******************************************************************************* + * Copyright (c) 2024 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 https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentation; + +import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.LSPIJUtils; +import com.redhat.devtools.lsp4ij.fixtures.LSPCodeInsightFixtureTestCase; +import org.jetbrains.annotations.NotNull; + +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Test Markdown conversion to HTML by using PsiFile. + */ +public class MarkdownConverterWithPsiFileTest extends LSPCodeInsightFixtureTestCase { + + private static final String USER_HOME = System.getProperty("user.home"); + + public void testHighlightCodeBlockConversion() { + // Here code block language is not set, the language is retrieved from the PsiFile. + String fileName = "test.txt"; + + String markdown = """ + Here's some XML code: + + ``` + + + Angelo + Fred + Tests + I wrote them! + + ``` + """; + + // As file is NOT an XML file, there are no syntax coloration + String html = """ +

Here's some XML code:

+
<?xml version="1.0" encoding="UTF-8"?>
+                <note>
+                  <to>Angelo</to>
+                  <from>Fred</from>
+                  <heading>Tests</heading>
+                  <body>I wrote them!</body>
+                </note>
+                
+ """; + + assertMarkdownConverter(fileName, markdown, html); + } + + public void testXmlHighlightCodeBlockConversion() { + // Here code block language is not set, the language is retrieved from the PsiFile. + String fileName = "test.xml"; + + String markdown = """ + Here's some XML code: + + ``` + + + Angelo + Fred + Tests + I wrote them! + + ``` + """; + + // As file is an XML file, the XML syntax coloration is used. + String html = """ +

Here's some XML code:

+
<?xml version="1.0" encoding="UTF-8"?>
<note>
<to>Angelo</to>
<from>Fred</from>
<heading>Tests</heading>
<body>I wrote them!</body>
</note>
+ """; + + assertMarkdownConverter(fileName, markdown, html); + } + + public void testAbsoluteFileLink() { + String markdown = "[foo](file://" + USER_HOME + "/lsp/foo.txt)"; + assertMarkdownConverter("bar.txt", markdown, "

foo

\n"); + } + + public void testAbsoluteFileLinkWithSlash() { + String markdown = "[foo](/lsp/foo.txt)"; + assertMarkdownConverter("bar.txt", markdown, "

foo

\n"); + } + + public void testRelativeFileLink() { + String markdown = "[foo](lsp/foo.txt)"; + assertMarkdownConverter("bar.txt", markdown, "

foo

\n"); + } + + private void assertMarkdownConverter(String fileName, String markdown, String html) { + myFixture.configureByText(fileName, ""); + var file = myFixture.getFile(); + + String systemBaseDir = getSystemBaseDir(); + html = html.replace("$SYSTEM_BASE_DIR$", systemBaseDir); + + String fileBaseDir = getFileBaseDir(file); + html = html.replace("$FILE_BASE_DIR$", fileBaseDir); + + String actual = MarkdownConverter.getInstance(file.getProject()).toHtml(markdown, file); + assertEquals("$SYSTEM_BASE_DIR$=" + systemBaseDir + ", $FILE_BASE_DIR$=" + fileBaseDir, html, actual); + } + + private String getSystemBaseDir() { + String systemBaseDir = LSPIJUtils.toUri(Paths.get("/").toFile()).toASCIIString(); + systemBaseDir = systemBaseDir.substring("file://".length()); + if (systemBaseDir.endsWith("/")) { + systemBaseDir = systemBaseDir.substring(0, systemBaseDir.length() - 1); + } + return systemBaseDir; + } + + private static @NotNull String getFileBaseDir(PsiFile file) { + Path baseDir = file.getVirtualFile().getParent().getFileSystem().getNioPath(file.getVirtualFile().getParent()); + String fileBaseDir = LSPIJUtils.toUri(baseDir.toFile()).toASCIIString(); + fileBaseDir = fileBaseDir.substring("file://".length()); + if (fileBaseDir.endsWith("/")) { + fileBaseDir = fileBaseDir.substring(0, fileBaseDir.length() - 1); + } + return fileBaseDir; + } + +} \ No newline at end of file