From 865b8477729048068c6e1fe498fb90e147cecb7e 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 --- .../redhat/devtools/lsp4ij/LSPIJUtils.java | 94 ++++++++++++++- ...LSPDocumentLinkGotoDeclarationHandler.java | 23 +--- .../LSPDocumentationLinkHandler.java | 29 +++++ .../documentation/LSPDocumentationTarget.java | 3 + .../documentation/MarkdownConverter.java | 80 ++++++++---- .../markdown/LSPLinkResolver.java | 114 ++++++++++++++++++ .../SyntaxColorationCodeBlockRenderer.java | 20 ++- .../TextMateHighlighterHelper.java | 3 +- .../lsp4ij/hint/LSPNavigationLinkHandler.java | 113 +++++++++++------ src/main/resources/META-INF/plugin.xml | 3 + .../documentation/MarkdownConverterTest.java | 15 ++- .../MarkdownConverterWithPsiFileTest.java | 88 ++++++++++++++ 12 files changed, 494 insertions(+), 91 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/LSPIJUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java index b9319dded..7c08fd68d 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; @@ -63,6 +64,10 @@ public class LSPIJUtils { private static final String JRT_SCHEME = JRT_PROTOCOL + ":"; + private 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 +126,98 @@ 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 fileUri with the given position in an editor. + * + * @param fileUri the file Uri. + * @param position the position. + * @param focusEditor true if editor will take the focus and false otherwise. + * @param project the project. + * @return true if the file was 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. + 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 openInEditor(file, position, focusEditor, project); } + /** + * 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. * @@ -412,7 +505,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/features/documentLink/LSPDocumentLinkGotoDeclarationHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentLink/LSPDocumentLinkGotoDeclarationHandler.java index 1e8be0bb3..f5e8a2f41 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 @@ -20,15 +20,12 @@ 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 +33,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; @@ -100,22 +96,9 @@ 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")); - } - } + ApplicationManager.getApplication() + .invokeLater(() -> { + 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. 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..c6435dcf7 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationLinkHandler.java @@ -0,0 +1,29 @@ +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.NotNull; +import org.jetbrains.annotations.Nullable; + +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() + .invokeLater(() -> { + 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..07920e1f0 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,19 +12,21 @@ 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.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.NotNull; import org.jetbrains.annotations.Nullable; +import java.nio.file.Path; import java.util.Arrays; /** @@ -32,8 +34,14 @@ */ 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 BASE_DIR_CONTEXT = new DataKey<>("LSP_BASE_DIR", (Path)null); + private final Project project; private final Parser htmlParser; private final HtmlRenderer htmlRenderer; private final MutableDataSet options; @@ -62,16 +70,18 @@ private MarkdownConverter(Project project) { 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 +101,38 @@ 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) { + htmlRenderer = file.getUserData(HTML_RENDERER_KEY); + if (htmlRenderer == null) { + htmlRenderer = getHtmlRenderer(file); + } + } + return htmlRenderer.render(htmlParser.parse(markdown)); + } + + private synchronized HtmlRenderer getHtmlRenderer(PsiFile file) { + var htmlRenderer = file.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(BASE_DIR_CONTEXT, baseDir); + + htmlRenderer = createHtmlRenderer(fileOptions); + 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 +140,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(BASE_DIR_CONTEXT, baseDir); + fileOptions.set(LANGUAGE_CONTEXT, language); + fileOptions.set(FILE_NAME_CONTEXT, fileName); + htmlRenderer = createHtmlRenderer(options); } 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..71e298a85 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/LSPLinkResolver.java @@ -0,0 +1,114 @@ +/******************************************************************************* + * 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.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 baseDir; + + private enum FileUrlKind { + RELATIVE, + ABSOLUTE, + NONE; + } + + public LSPLinkResolver(LinkResolverBasicContext context) { + this.baseDir = MarkdownConverter.BASE_DIR_CONTEXT.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 (baseDir != null && fileUrlKind == FileUrlKind.RELATIVE) { + String position = ""; + int hashIndex= url.indexOf("#"); + if (hashIndex != -1) { + position = url.substring(hashIndex, url.length()); + url = url.substring(0, hashIndex); + } + try { + File resolvedFile = baseDir.resolve(url).toFile(); + String resolvedUri = LSPIJUtils.toUri(resolvedFile).toASCIIString() + position; + return link.withStatus(LinkStatus.VALID) + .withUrl(resolvedUri); + } + catch(Exception e) { + + } + } + } + return link; + } + + + private static FileUrlKind getFileUrlKind(String url) { + int index = url.indexOf("://"); + if (index == -1) { + return FileUrlKind.RELATIVE; + } + if (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..3e4079177 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/hint/LSPNavigationLinkHandler.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/hint/LSPNavigationLinkHandler.java @@ -15,13 +15,14 @@ import com.intellij.codeInsight.highlighting.TooltipLinkHandler; import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.LSPIJUtils; import org.eclipse.lsp4j.Location; 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; /** * Handles tooltip links in format {@code #lsp-navigation/file_path:startLine;startChar;endLine;endChar}. @@ -35,27 +36,41 @@ */ public class LSPNavigationLinkHandler extends TooltipLinkHandler { - private static final Logger LOGGER = LoggerFactory.getLogger(LSPNavigationLinkHandler.class);//$NON-NLS-1$ + public static final String PREFIX_OR_SUFFIX = "#lsp-navigation/"; - private static final String PREFIX = "#lsp-navigation/"; - public static final String POS_SEPARATOR = ";"; + public static final String HASH_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; - } + public boolean handleLink(@NotNull String fileUrl, + @NotNull Editor editor) { + return handleLink(fileUrl, null, editor.getProject()); + } - 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()); + /** + * Open in an editor the given fileUrl and tries to create the file if it doesn't exist. + * + *

+ * the fileUrl can have following syntax: + *

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

+ * @param fileUrl the file Url to open. + * @param file the psi file which trigger the link. If not null it is used to resolve absolute path. + * @param project the project. + * @return true if file Url can be opened and false otherwise. + */ + public static boolean handleLink(@NotNull String fileUrl, + @Nullable PsiFile file, + @NotNull Project project) { + int pos = fileUrl.lastIndexOf(HASH_SEPARATOR); + boolean hasPosition = pos > 0 && pos != fileUrl.length() - 1; + String uri = hasPosition ? fileUrl.substring(0, pos) : fileUrl; + Position start = hasPosition ? toPosition(fileUrl.substring(pos + 1)) : null; + return LSPIJUtils.openInEditor(uri, start, project); } /** @@ -69,41 +84,63 @@ public boolean handleLink(@NotNull String refSuffix, @NotNull Editor editor) { * @return the LSP navigation url from the given location. */ public static String toNavigationUrl(@NotNull Location location) { - StringBuilder url = new StringBuilder(PREFIX); + return toNavigationUrl(location, true); + } + + public static String toNavigationUrl(@NotNull Location location, boolean prefix) { + StringBuilder url = new StringBuilder(PREFIX_OR_SUFFIX); + if (prefix) { + url.append(PREFIX_OR_SUFFIX); + } url.append(location.getUri()); - url.append(":"); - if (location.getRange() != null) { - toString(location.getRange(), url); + appendStartPositionIfNeeded(location.getRange(), url); + if (!prefix) { + url.append(PREFIX_OR_SUFFIX); } return url.toString(); } /** * Serialize LSP range used in the LSP location Url. + * * @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 void appendStartPositionIfNeeded(@Nullable Range range, StringBuilder result) { + Position start = range != null ? range.getStart() : null; + if (start == null) { + return; + } + result.append(HASH_SEPARATOR); + result.append("L"); + result.append(start.getLine()); + result.append(":"); + result.append(start.getCharacter()); } - private static Range toRange(String rangeString) { - if (rangeString.isEmpty()) { + private static Position toPosition(String positionString) { + if (positionString == null || positionString.isEmpty()) { return null; } - 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); + 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); + int character = toInt(1, positions); + return new Position(line, character); } private static int toInt(int index, String[] positions) { - return Integer.valueOf(positions[index]); + if (index < positions.length) { + try { + return Integer.valueOf(positions[index]); + } catch (Exception e) {} + } + return 0; } } 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"/> + """; - 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,17 @@ public void testXmlHighlightIndentedBlockquoteWithLanguageConversion() { """; - assertEquals(html, toHtml(markdown, XMLLanguage.INSTANCE, null)); + assertEquals(html, toHtml(markdown, null, XMLLanguage.INSTANCE, null)); } 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..849e0c271 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverterWithPsiFileTest.java @@ -0,0 +1,88 @@ +/******************************************************************************* + * 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.redhat.devtools.lsp4ij.fixtures.LSPCodeInsightFixtureTestCase; + +/** + * Test Markdown conversion to HTML by using PsiFile. + */ +public class MarkdownConverterWithPsiFileTest extends LSPCodeInsightFixtureTestCase { + + 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); + } + + private void assertMarkdownConverter(String fileName, String markdown, String html) { + myFixture.configureByText(fileName, ""); + var file = myFixture.getFile(); + String actual = MarkdownConverter.getInstance(file.getProject()).toHtml(markdown, file); + assertEquals(html, actual); + } + +} \ No newline at end of file