From 245f00e42d11abd9276d39e57334308a6c195dab Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 8 Jun 2022 17:10:45 +0200 Subject: [PATCH 01/14] Add support for documentLink request --- LSP.sublime-settings | 10 ++++++++ plugin/core/protocol.py | 15 ++++++++++++ plugin/core/sessions.py | 7 ++++++ plugin/core/types.py | 2 ++ plugin/core/views.py | 2 ++ plugin/hover.py | 18 +++++++++++++++ plugin/session_buffer.py | 50 ++++++++++++++++++++++++++++++++++++++++ plugin/session_view.py | 2 ++ sublime-package.json | 9 ++++++++ 9 files changed, 115 insertions(+) diff --git a/LSP.sublime-settings b/LSP.sublime-settings index cef118333..d5442d089 100644 --- a/LSP.sublime-settings +++ b/LSP.sublime-settings @@ -105,6 +105,16 @@ // Valid values are "dot", "circle", "bookmark", "sign" or "" "diagnostics_gutter_marker": "dot", + // Highlight style of links to internal or external resources, like another text document + // or a web site. Link navigation is implemented via the popup on mouse hover. + // Valid values are: + // "underline" + // "none" - disables special highlighting, but still displays links in the hover popup + // "off" - disables special highlighting and the hover popup for links + // Note that depending on support in the syntax and color scheme, some internet URLs may + // still be underlined via regular syntax highlighting. + "link_highlight_style": "underline", + // Enable semantic highlighting in addition to standard syntax highlighting (experimental!). // Note: Must be supported by the language server and also requires a special rule in the // color scheme to work. If you use none of the built-in color schemes from Sublime Text, diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index ffc4f85c1..4f2b648d3 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -295,6 +295,13 @@ class SemanticTokenModifiers: 'items': List[CompletionItem], }, total=True) +DocumentLink = TypedDict('DocumentLink', { + 'range': RangeLsp, + 'target': DocumentUri, + 'tooltip': str, + 'data': Any +}, total=False) + MarkedString = Union[str, Dict[str, str]] MarkupContent = Dict[str, str] @@ -384,6 +391,10 @@ def documentSymbols(cls, params: Mapping[str, Any], view: sublime.View) -> 'Requ def documentHighlight(cls, params: Mapping[str, Any], view: sublime.View) -> 'Request': return Request("textDocument/documentHighlight", params, view) + @classmethod + def documentLink(cls, params: Mapping[str, Any], view: sublime.View) -> 'Request': + return Request("textDocument/documentLink", params, view) + @classmethod def semanticTokensFull(cls, params: Mapping[str, Any], view: sublime.View) -> 'Request': return Request("textDocument/semanticTokens/full", params, view) @@ -400,6 +411,10 @@ def semanticTokensRange(cls, params: Mapping[str, Any], view: sublime.View) -> ' def resolveCompletionItem(cls, params: CompletionItem, view: sublime.View) -> 'Request': return Request("completionItem/resolve", params, view) + @classmethod + def resolveDocumentLink(cls, params: DocumentLink, view: sublime.View) -> 'Request': + return Request("documentLink/resolve", params, view) + @classmethod def shutdown(cls) -> 'Request': return Request("shutdown") diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 9b3348134..0ae621035 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -280,6 +280,10 @@ def get_initialize_params(variables: Dict[str, str], workspace_folders: List[Wor "valueSet": symbol_tag_value_set } }, + "documentLink": { + "dynamicRegistration": True, + "tooltipSupport": True + }, "formatting": { "dynamicRegistration": True # exceptional }, @@ -523,6 +527,9 @@ def unregister_capability_async( def on_diagnostics_async(self, raw_diagnostics: List[Diagnostic], version: Optional[int]) -> None: ... + def get_document_links(self) -> List[Any]: + ... + def do_semantic_tokens_async(self, view: sublime.View) -> None: ... diff --git a/plugin/core/types.py b/plugin/core/types.py index 8f2b3c5cf..85d352167 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -193,6 +193,7 @@ class Settings: document_highlight_style = None # type: str inhibit_snippet_completions = None # type: bool inhibit_word_completions = None # type: bool + link_highlight_style = None # type: str log_debug = None # type: bool log_max_size = None # type: int log_server = None # type: List[str] @@ -230,6 +231,7 @@ def r(name: str, default: Union[bool, int, str, list, dict]) -> None: r("diagnostics_panel_include_severity_level", 4) r("disabled_capabilities", []) r("document_highlight_style", "underline") + r("link_highlight_style", "underline") r("log_debug", False) r("log_max_size", 8 * 1024) r("lsp_code_actions_on_save", {}) diff --git a/plugin/core/views.py b/plugin/core/views.py index 89fdafcc8..c078fbef1 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -35,6 +35,8 @@ MarkdownLangMap = Dict[str, Tuple[Tuple[str, ...], Tuple[str, ...]]] +DOCUMENT_LINK_FLAGS = sublime.HIDE_ON_MINIMAP | sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE | sublime.DRAW_SOLID_UNDERLINE # noqa: E501 + _baseflags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE | sublime.DRAW_EMPTY_AS_OVERWRITE DIAGNOSTIC_SEVERITY = [ diff --git a/plugin/hover.py b/plugin/hover.py index 2329202a8..77cba2b2b 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -215,6 +215,23 @@ def diagnostics_content(self) -> str: formatted.append("") return "".join(formatted) + def document_link_content(self, listener: AbstractViewListener, point: int) -> str: + if userprefs().link_highlight_style in ("underline", "none"): + for sv in listener.session_views_async(): + if sv.view == self.view and sv.has_capability_async("documentLinkProvider"): + sb = sv.session_buffer + for link in sb.get_document_links(): + if link.contains(point): + title = link.tooltip or "Follow link" + if link.target is not None: + return ''.format(link.target, title) + else: + # TODO send documentLink/resolve request + # TODO maybe figure out how "Promise" works and if it could help here + return "" + break + return "" + def hover_content(self) -> str: contents = [] for hover, language_map in self._hover_responses: @@ -231,6 +248,7 @@ def _show_hover(self, listener: AbstractViewListener, point: int, only_diagnosti contents = self.diagnostics_content() + hover_content + code_actions_content(self._actions_by_config) if contents and not only_diagnostics and hover_content: contents += self.symbol_actions_content(listener, point) + contents += self.document_link_content(listener, point) _test_contents.clear() _test_contents.append(contents) # for testing only diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 0192cd317..e8b4bbffc 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -1,5 +1,6 @@ from .core.protocol import Diagnostic from .core.protocol import DiagnosticSeverity +from .core.protocol import DocumentLink from .core.protocol import DocumentUri from .core.protocol import Range from .core.protocol import Request @@ -20,6 +21,7 @@ from .core.views import did_open from .core.views import did_save from .core.views import document_color_params +from .core.views import DOCUMENT_LINK_FLAGS from .core.views import lsp_color_to_phantom from .core.views import MissingUriError from .core.views import range_to_region @@ -58,6 +60,21 @@ def __init__(self, severity: int) -> None: self.icon = userprefs().diagnostics_gutter_marker +class DocumentLinkData: + + __slots__ = ('region', 'target', 'tooltip', 'data') + + def __init__( + self, region: sublime.Region, target: Optional[DocumentUri], tooltip: Optional[str], data: Any) -> None: + self.region = region + self.target = target + self.tooltip = tooltip + self.data = data + + def contains(self, point: int) -> bool: + return self.region.contains(point) + + class SemanticTokensData: __slots__ = ( @@ -104,6 +121,7 @@ def __init__(self, session_view: SessionViewProtocol, buffer_id: int, uri: Docum self.should_show_diagnostics_panel = False self.diagnostics_debouncer = Debouncer() self.color_phantoms = sublime.PhantomSet(view, "lsp_color") + self.document_links = [] # type: List[DocumentLinkData] self.semantic_tokens = SemanticTokensData() self._semantic_region_keys = {} # type: Dict[str, int] self._last_semantic_region_key = 0 @@ -140,6 +158,8 @@ def _check_did_open(self, view: sublime.View) -> None: self.opened = True self._do_color_boxes_async(view, view.change_count()) self.do_semantic_tokens_async(view) + if userprefs().link_highlight_style in ("underline", "none"): + self._do_document_link_async(view, view.change_count()) self.session.notify_plugin_on_session_buffer_change(self) def _check_did_close(self) -> None: @@ -281,6 +301,8 @@ def purge_changes_async(self, view: sublime.View) -> None: self.pending_changes = None self._do_color_boxes_async(view, version) self.do_semantic_tokens_async(view) + if userprefs().link_highlight_style in ("underline", "none"): + self._do_document_link_async(view, version) self.session.notify_plugin_on_session_buffer_change(self) def on_pre_save_async(self, view: sublime.View) -> None: @@ -333,6 +355,34 @@ def _on_color_boxes_async(self, view: sublime.View, response: Any) -> None: color_infos = response if response else [] self.color_phantoms.update([lsp_color_to_phantom(view, color_info) for color_info in color_infos]) + # --- textDocument/documentLink ------------------------------------------------------------------------------------ + + def _do_document_link_async(self, view: sublime.View, version: int) -> None: + if self.session.has_capability("documentLinkProvider"): + self.session.send_request_async( + Request.documentLink({'textDocument': text_document_identifier(view)}, view), + self._if_view_unchanged(self._on_document_link_async, version) + ) + + def _on_document_link_async(self, view: sublime.View, response: Optional[List[DocumentLink]]) -> None: + self.document_links = \ + [DocumentLinkData( + range_to_region(Range.from_lsp(link["range"]), view), + link.get("target"), + link.get("tooltip"), + link.get("data")) for link in response] if response else [] + if self.document_links and userprefs().link_highlight_style == "underline": + view.add_regions( + "lsp_document_link", + [link.region for link in self.document_links], + scope="markup.underline.link.lsp", + flags=DOCUMENT_LINK_FLAGS) + else: + view.erase_regions("lsp_document_link") + + def get_document_links(self) -> List[DocumentLinkData]: + return self.document_links + # --- textDocument/publishDiagnostics ------------------------------------------------------------------------------ def on_diagnostics_async(self, raw_diagnostics: List[Diagnostic], version: Optional[int]) -> None: diff --git a/plugin/session_view.py b/plugin/session_view.py index 9cb7907f3..48f5c5b36 100644 --- a/plugin/session_view.py +++ b/plugin/session_view.py @@ -79,6 +79,7 @@ def on_before_remove(self) -> None: for severity in reversed(range(1, len(DIAGNOSTIC_SEVERITY) + 1)): self.view.erase_regions(self.diagnostics_key(severity, False)) self.view.erase_regions(self.diagnostics_key(severity, True)) + self.view.erase_regions("lsp_document_link") self.session_buffer.remove_session_view(self) @property @@ -124,6 +125,7 @@ def _initialize_region_keys(self) -> None: for mode in line_modes: for tag in range(1, 3): self.view.add_regions("lsp{}d{}{}_tags_{}".format(self.session.config.name, mode, severity, tag), r) + self.view.add_regions("lsp_document_link", r) for severity in range(1, 5): for mode in line_modes: self.view.add_regions("lsp{}d{}{}".format(self.session.config.name, mode, severity), r) diff --git a/sublime-package.json b/sublime-package.json index c1eedc347..6446d6689 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -368,6 +368,15 @@ "default": "dot", "markdownDescription": "Gutter marker for code diagnostics." }, + "link_highlight_style": { + "enum": [ + "underline", + "none", + "off" + ], + "default": "underline", + "markdownDescription": "Highlight style of links to internal or external resources, like another text document or a web site." + }, "semantic_highlighting": { "type": "boolean", "default": false, From b1852a895870e53de774faf68ae17117caf84ae3 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 9 Jun 2022 16:36:50 +0200 Subject: [PATCH 02/14] Collect links from all sessions --- plugin/hover.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/plugin/hover.py b/plugin/hover.py index 77cba2b2b..e7ab4160b 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -216,21 +216,21 @@ def diagnostics_content(self) -> str: return "".join(formatted) def document_link_content(self, listener: AbstractViewListener, point: int) -> str: - if userprefs().link_highlight_style in ("underline", "none"): - for sv in listener.session_views_async(): - if sv.view == self.view and sv.has_capability_async("documentLinkProvider"): - sb = sv.session_buffer - for link in sb.get_document_links(): - if link.contains(point): - title = link.tooltip or "Follow link" - if link.target is not None: - return ''.format(link.target, title) - else: - # TODO send documentLink/resolve request - # TODO maybe figure out how "Promise" works and if it could help here - return "" - break - return "" + if userprefs().link_highlight_style not in ("underline", "none"): + return "" + contents = [] + for sv in listener.session_views_async(): + if sv.has_capability_async("documentLinkProvider"): + for link in sv.session_buffer.get_document_links(): + if link.contains(point): + title = link.tooltip or "Follow link" + if link.target is not None: + contents.append('{}'.format(link.target, title)) + else: + # TODO send documentLink/resolve request + # TODO maybe figure out how "Promise" works and if it could help here + pass + return '' if contents else '' def hover_content(self) -> str: contents = [] From 5bf2530572f7cb236591bd509351465670f5665c Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 9 Jun 2022 16:51:41 +0200 Subject: [PATCH 03/14] Store links as DocumentLink in preparation of documentLink/resolve request --- plugin/core/sessions.py | 3 ++- plugin/hover.py | 19 ++++++++++--------- plugin/session_buffer.py | 32 +++++++------------------------- 3 files changed, 19 insertions(+), 35 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 0ae621035..30f084aa4 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -24,6 +24,7 @@ from .protocol import DiagnosticSeverity from .protocol import DiagnosticTag from .protocol import DidChangeWatchedFilesRegistrationOptions +from .protocol import DocumentLink from .protocol import DocumentUri from .protocol import Error from .protocol import ErrorCode @@ -527,7 +528,7 @@ def unregister_capability_async( def on_diagnostics_async(self, raw_diagnostics: List[Diagnostic], version: Optional[int]) -> None: ... - def get_document_links(self) -> List[Any]: + def get_document_link_at_point(self, view: sublime.View, point: int) -> Optional[DocumentLink]: ... def do_semantic_tokens_async(self, view: sublime.View) -> None: diff --git a/plugin/hover.py b/plugin/hover.py index e7ab4160b..396cba340 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -221,15 +221,16 @@ def document_link_content(self, listener: AbstractViewListener, point: int) -> s contents = [] for sv in listener.session_views_async(): if sv.has_capability_async("documentLinkProvider"): - for link in sv.session_buffer.get_document_links(): - if link.contains(point): - title = link.tooltip or "Follow link" - if link.target is not None: - contents.append('{}'.format(link.target, title)) - else: - # TODO send documentLink/resolve request - # TODO maybe figure out how "Promise" works and if it could help here - pass + link = sv.session_buffer.get_document_link_at_point(sv.view, point) + if link is not None: + title = link.get("tooltip") or "Follow link" + target = link.get("target") + if target is not None: + contents.append('{}'.format(target, title)) + else: + # TODO send documentLink/resolve request + # TODO maybe figure out how "Promise" works and if it could help here + pass return '' if contents else '' def hover_content(self) -> str: diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index e8b4bbffc..4eb9630e6 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -60,21 +60,6 @@ def __init__(self, severity: int) -> None: self.icon = userprefs().diagnostics_gutter_marker -class DocumentLinkData: - - __slots__ = ('region', 'target', 'tooltip', 'data') - - def __init__( - self, region: sublime.Region, target: Optional[DocumentUri], tooltip: Optional[str], data: Any) -> None: - self.region = region - self.target = target - self.tooltip = tooltip - self.data = data - - def contains(self, point: int) -> bool: - return self.region.contains(point) - - class SemanticTokensData: __slots__ = ( @@ -121,7 +106,7 @@ def __init__(self, session_view: SessionViewProtocol, buffer_id: int, uri: Docum self.should_show_diagnostics_panel = False self.diagnostics_debouncer = Debouncer() self.color_phantoms = sublime.PhantomSet(view, "lsp_color") - self.document_links = [] # type: List[DocumentLinkData] + self.document_links = [] # type: List[DocumentLink] self.semantic_tokens = SemanticTokensData() self._semantic_region_keys = {} # type: Dict[str, int] self._last_semantic_region_key = 0 @@ -365,23 +350,20 @@ def _do_document_link_async(self, view: sublime.View, version: int) -> None: ) def _on_document_link_async(self, view: sublime.View, response: Optional[List[DocumentLink]]) -> None: - self.document_links = \ - [DocumentLinkData( - range_to_region(Range.from_lsp(link["range"]), view), - link.get("target"), - link.get("tooltip"), - link.get("data")) for link in response] if response else [] + self.document_links = response or [] if self.document_links and userprefs().link_highlight_style == "underline": view.add_regions( "lsp_document_link", - [link.region for link in self.document_links], + [range_to_region(Range.from_lsp(link["range"]), view) for link in self.document_links], scope="markup.underline.link.lsp", flags=DOCUMENT_LINK_FLAGS) else: view.erase_regions("lsp_document_link") - def get_document_links(self) -> List[DocumentLinkData]: - return self.document_links + def get_document_link_at_point(self, view: sublime.View, point: int) -> Optional[DocumentLink]: + for link in self.document_links: + if range_to_region(Range.from_lsp(link["range"]), view).contains(point): + return link # --- textDocument/publishDiagnostics ------------------------------------------------------------------------------ From 463f53515aa4c554f8516e476277548b317609f6 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 9 Jun 2022 18:10:28 +0200 Subject: [PATCH 04/14] Rename setting value --- LSP.sublime-settings | 8 ++++---- sublime-package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/LSP.sublime-settings b/LSP.sublime-settings index d5442d089..9cecd3af9 100644 --- a/LSP.sublime-settings +++ b/LSP.sublime-settings @@ -109,10 +109,10 @@ // or a web site. Link navigation is implemented via the popup on mouse hover. // Valid values are: // "underline" - // "none" - disables special highlighting, but still displays links in the hover popup - // "off" - disables special highlighting and the hover popup for links - // Note that depending on support in the syntax and color scheme, some internet URLs may - // still be underlined via regular syntax highlighting. + // "none" - disables special highlighting, but still allows to navigate links in the hover popup + // "disabled" - disables highlighting and the hover popup for links + // Note that depending on the syntax and color scheme, some internet URLs may still be + // underlined via regular syntax highlighting. "link_highlight_style": "underline", // Enable semantic highlighting in addition to standard syntax highlighting (experimental!). diff --git a/sublime-package.json b/sublime-package.json index 6446d6689..73927b458 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -372,7 +372,7 @@ "enum": [ "underline", "none", - "off" + "disabled" ], "default": "underline", "markdownDescription": "Highlight style of links to internal or external resources, like another text document or a web site." From cf084cf67d60e7b55fa2261a6e45f1cea03d8535 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 9 Jun 2022 18:15:55 +0200 Subject: [PATCH 05/14] Add explicit return statement for mypy --- plugin/session_buffer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 4eb9630e6..0f4cdfa27 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -364,6 +364,8 @@ def get_document_link_at_point(self, view: sublime.View, point: int) -> Optional for link in self.document_links: if range_to_region(Range.from_lsp(link["range"]), view).contains(point): return link + else: + return None # --- textDocument/publishDiagnostics ------------------------------------------------------------------------------ From 82f810aa7a98c7558b94af6020dde2c7882d6166 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 11 Jun 2022 07:38:57 +0200 Subject: [PATCH 06/14] Add command, example keybinding and menu entry --- Default.sublime-keymap | 14 +++++++++ Main.sublime-menu | 4 +++ boot.py | 1 + plugin/document_link.py | 63 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 plugin/document_link.py diff --git a/Default.sublime-keymap b/Default.sublime-keymap index 7acd553a3..f8fb878b0 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -353,6 +353,20 @@ // } // ] // }, + // Follow Link + // { + // "command": "lsp_open_link", + // "keys": [ + // "UNBOUND" + // ], + // "context": [ + // { + // "key": "lsp.session_with_capability", + // "operator": "equal", + // "operand": "documentLinkProvider" + // } + // ] + // }, // Expand Selection (a replacement for ST's "Expand Selection") // { // "command": "lsp_expand_selection", diff --git a/Main.sublime-menu b/Main.sublime-menu index b87ae9da8..cddbe32cb 100644 --- a/Main.sublime-menu +++ b/Main.sublime-menu @@ -72,6 +72,10 @@ { "caption": "LSP: Find References", "command": "lsp_symbol_references" + }, + { + "caption": "LSP: Follow Link", + "command": "lsp_open_link" } ] }, diff --git a/boot.py b/boot.py index de855f092..f2057e1b1 100644 --- a/boot.py +++ b/boot.py @@ -35,6 +35,7 @@ from .plugin.core.typing import Any, Optional, List, Type, Dict from .plugin.core.views import get_uri_and_position_from_location from .plugin.core.views import LspRunTextCommandHelperCommand +from .plugin.document_link import LspOpenLinkCommand from .plugin.documents import DocumentSyncListener from .plugin.documents import TextChangeListener from .plugin.edit import LspApplyDocumentEditCommand diff --git a/plugin/document_link.py b/plugin/document_link.py new file mode 100644 index 000000000..f482b114c --- /dev/null +++ b/plugin/document_link.py @@ -0,0 +1,63 @@ +from .core.registry import get_position +from .core.registry import LspTextCommand +from .core.typing import Optional +from urllib.parse import unquote, urlparse +import re +import sublime +import webbrowser + + +class LspOpenLinkCommand(LspTextCommand): + capability = 'documentLinkProvider' + + def is_enabled(self, event: Optional[dict] = None) -> bool: + point = get_position(self.view, event) + if not point: + return False + session = self.best_session(self.capability, point) + if not session: + return False + sv = session.session_view_for_view_async(self.view) + if not sv: + return False + link = sv.session_buffer.get_document_link_at_point(self.view, point) + return link is not None + + def run(self, edit: sublime.Edit, event: Optional[dict] = None) -> None: + + def open_target(target: str) -> None: + if target.startswith("file:"): + window = self.view.window() + if window: + decoded = unquote(target) # decode percent-encoded characters + parsed = urlparse(decoded) + filepath = parsed.path + if sublime.platform() == "windows": + filepath = re.sub(r"^/([a-zA-Z]:)", r"\1", filepath) # remove slash preceding drive letter + fn = "{}:{}".format(filepath, parsed.fragment) if parsed.fragment else filepath + window.open_file(fn, flags=sublime.ENCODED_POSITION) + else: + if not (target.lower().startswith("http://") or target.lower().startswith("https://")): + target = "http://" + target + if not webbrowser.open(target): + sublime.status_message("failed to open: " + target) + + point = get_position(self.view, event) + if not point: + return + session = self.best_session(self.capability, point) + if not session: + return + sv = session.session_view_for_view_async(self.view) + if not sv: + return + link = sv.session_buffer.get_document_link_at_point(self.view, point) + if not link: + return + target = link.get("target") + + if target is not None: + open_target(target) + else: + # TODO send documentLink/resolve request + sublime.status_message("Links with unresolved target are currently not supported") From 27add922c91ceda0cf7369dbcf0a64c90ee3a2c5 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 11 Jun 2022 08:58:05 +0200 Subject: [PATCH 07/14] Fix complaints from mypy --- plugin/document_link.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plugin/document_link.py b/plugin/document_link.py index f482b114c..5688cb0b9 100644 --- a/plugin/document_link.py +++ b/plugin/document_link.py @@ -10,17 +10,19 @@ class LspOpenLinkCommand(LspTextCommand): capability = 'documentLinkProvider' - def is_enabled(self, event: Optional[dict] = None) -> bool: - point = get_position(self.view, event) - if not point: + def is_enabled(self, event: Optional[dict] = None, point: Optional[int] = None) -> bool: + if not super().is_enabled(event, point): return False - session = self.best_session(self.capability, point) + position = get_position(self.view, event) + if not position: + return False + session = self.best_session(self.capability, position) if not session: return False sv = session.session_view_for_view_async(self.view) if not sv: return False - link = sv.session_buffer.get_document_link_at_point(self.view, point) + link = sv.session_buffer.get_document_link_at_point(self.view, position) return link is not None def run(self, edit: sublime.Edit, event: Optional[dict] = None) -> None: From f16882f1f7afc10faf1ff0ca26142fe3fc52c18f Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 11 Jun 2022 19:54:28 +0200 Subject: [PATCH 08/14] Add support for documentLink/resolve --- plugin/core/sessions.py | 3 +++ plugin/document_link.py | 48 ++++++++++++++++++++++------------------ plugin/hover.py | 24 ++++++++++++++------ plugin/session_buffer.py | 9 ++++++++ 4 files changed, 56 insertions(+), 28 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 30f084aa4..75216bbf8 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -531,6 +531,9 @@ def on_diagnostics_async(self, raw_diagnostics: List[Diagnostic], version: Optio def get_document_link_at_point(self, view: sublime.View, point: int) -> Optional[DocumentLink]: ... + def update_document_link(self, link: DocumentLink) -> bool: + ... + def do_semantic_tokens_async(self, view: sublime.View) -> None: ... diff --git a/plugin/document_link.py b/plugin/document_link.py index 5688cb0b9..f49d3df92 100644 --- a/plugin/document_link.py +++ b/plugin/document_link.py @@ -1,3 +1,5 @@ +from .core.logging import debug +from .core.protocol import DocumentLink, Request from .core.registry import get_position from .core.registry import LspTextCommand from .core.typing import Optional @@ -10,6 +12,23 @@ class LspOpenLinkCommand(LspTextCommand): capability = 'documentLinkProvider' + def open_target(self, target: str) -> None: + if target.startswith("file:"): + window = self.view.window() + if window: + decoded = unquote(target) # decode percent-encoded characters + parsed = urlparse(decoded) + filepath = parsed.path + if sublime.platform() == "windows": + filepath = re.sub(r"^/([a-zA-Z]:)", r"\1", filepath) # remove slash preceding drive letter + fn = "{}:{}".format(filepath, parsed.fragment) if parsed.fragment else filepath + window.open_file(fn, flags=sublime.ENCODED_POSITION) + else: + if not (target.lower().startswith("http://") or target.lower().startswith("https://")): + target = "http://" + target + if not webbrowser.open(target): + sublime.status_message("failed to open: " + target) + def is_enabled(self, event: Optional[dict] = None, point: Optional[int] = None) -> bool: if not super().is_enabled(event, point): return False @@ -26,24 +45,6 @@ def is_enabled(self, event: Optional[dict] = None, point: Optional[int] = None) return link is not None def run(self, edit: sublime.Edit, event: Optional[dict] = None) -> None: - - def open_target(target: str) -> None: - if target.startswith("file:"): - window = self.view.window() - if window: - decoded = unquote(target) # decode percent-encoded characters - parsed = urlparse(decoded) - filepath = parsed.path - if sublime.platform() == "windows": - filepath = re.sub(r"^/([a-zA-Z]:)", r"\1", filepath) # remove slash preceding drive letter - fn = "{}:{}".format(filepath, parsed.fragment) if parsed.fragment else filepath - window.open_file(fn, flags=sublime.ENCODED_POSITION) - else: - if not (target.lower().startswith("http://") or target.lower().startswith("https://")): - target = "http://" + target - if not webbrowser.open(target): - sublime.status_message("failed to open: " + target) - point = get_position(self.view, event) if not point: return @@ -59,7 +60,12 @@ def open_target(target: str) -> None: target = link.get("target") if target is not None: - open_target(target) + self.open_target(target) else: - # TODO send documentLink/resolve request - sublime.status_message("Links with unresolved target are currently not supported") + if not session.has_capability("documentLinkProvider.resolveProvider"): + debug("DocumentLink.target is missing, but the server doesn't support documentLink/resolve") + return + session.send_request_async(Request.resolveDocumentLink(link, self.view), self._on_resolved_async) + + def _on_resolved_async(self, response: DocumentLink) -> None: + self.open_target(response["target"]) diff --git a/plugin/hover.py b/plugin/hover.py index 396cba340..01cc120f1 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -3,6 +3,7 @@ from .core.logging import debug from .core.promise import Promise from .core.protocol import Diagnostic +from .core.protocol import DocumentLink from .core.protocol import Error from .core.protocol import ExperimentalTextDocumentRangeParams from .core.protocol import Hover @@ -223,16 +224,25 @@ def document_link_content(self, listener: AbstractViewListener, point: int) -> s if sv.has_capability_async("documentLinkProvider"): link = sv.session_buffer.get_document_link_at_point(sv.view, point) if link is not None: - title = link.get("tooltip") or "Follow link" target = link.get("target") - if target is not None: - contents.append('{}'.format(target, title)) - else: - # TODO send documentLink/resolve request - # TODO maybe figure out how "Promise" works and if it could help here - pass + if not target: + if not sv.has_capability_async("documentLinkProvider.resolveProvider"): + continue + sv.session.send_request_task(Request.resolveDocumentLink(link, sv.view)).then( + lambda result: self._on_resolved_link(sv.session_buffer, result)) + link = sv.session_buffer.get_document_link_at_point(sv.view, point) # resolved link ??? + if not link: + continue + target = link.get("target") + if not target: + continue + title = link.get("tooltip") or "Follow link" + contents.append('{}'.format(target, title)) return '' if contents else '' + def _on_resolved_link(self, session_buffer: SessionBufferProtocol, link: DocumentLink) -> None: + session_buffer.update_document_link(link) + def hover_content(self) -> str: contents = [] for hover, language_map in self._hover_responses: diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 0f4cdfa27..dd0faefaa 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -367,6 +367,15 @@ def get_document_link_at_point(self, view: sublime.View, point: int) -> Optional else: return None + def update_document_link(self, new_link: DocumentLink) -> bool: + new_link_range = Range.from_lsp(new_link["range"]) + for link in self.document_links: + if Range.from_lsp(link["range"]) == new_link_range: + self.document_links.remove(link) + self.document_links.append(new_link) + return True + return False + # --- textDocument/publishDiagnostics ------------------------------------------------------------------------------ def on_diagnostics_async(self, raw_diagnostics: List[Diagnostic], version: Optional[int]) -> None: From 0b0c1721992ba2919749a8ffaed296c5fac72331 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 12 Jun 2022 19:11:20 +0200 Subject: [PATCH 09/14] Improve presentation if there is more content in the popup --- plugin/hover.py | 31 ++++++++++++++++++++----------- popups.css | 3 +++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/plugin/hover.py b/plugin/hover.py index 01cc120f1..d07430b90 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -197,11 +197,8 @@ def provider_exists(self, listener: AbstractViewListener, link: LinkKind) -> boo return bool(listener.session_async('{}Provider'.format(link.lsp_name))) def symbol_actions_content(self, listener: AbstractViewListener, point: int) -> str: - if userprefs().show_symbol_action_links: - actions = [lk.link(point, self.view) for lk in link_kinds if self.provider_exists(listener, lk)] - if actions: - return '
' + " | ".join(actions) + "
" - return "" + actions = [lk.link(point, self.view) for lk in link_kinds if self.provider_exists(listener, lk)] + return " | ".join(actions) if actions else "" def diagnostics_content(self) -> str: formatted = [] @@ -216,10 +213,11 @@ def diagnostics_content(self) -> str: formatted.append("") return "".join(formatted) - def document_link_content(self, listener: AbstractViewListener, point: int) -> str: + def document_link_content(self, listener: AbstractViewListener, point: int) -> Tuple[str, bool]: if userprefs().link_highlight_style not in ("underline", "none"): - return "" + return "", False contents = [] + link_has_standard_tooltip = True for sv in listener.session_views_async(): if sv.has_capability_async("documentLinkProvider"): link = sv.session_buffer.get_document_link_at_point(sv.view, point) @@ -237,8 +235,12 @@ def document_link_content(self, listener: AbstractViewListener, point: int) -> s if not target: continue title = link.get("tooltip") or "Follow link" + if title != "Follow link": + link_has_standard_tooltip = False contents.append('{}'.format(target, title)) - return '' if contents else '' + if len(contents) > 1: + link_has_standard_tooltip = False + return '
'.join(contents) if contents else '', link_has_standard_tooltip def _on_resolved_link(self, session_buffer: SessionBufferProtocol, link: DocumentLink) -> None: session_buffer.update_document_link(link) @@ -257,9 +259,16 @@ def show_hover(self, listener: AbstractViewListener, point: int, only_diagnostic def _show_hover(self, listener: AbstractViewListener, point: int, only_diagnostics: bool) -> None: hover_content = self.hover_content() contents = self.diagnostics_content() + hover_content + code_actions_content(self._actions_by_config) - if contents and not only_diagnostics and hover_content: - contents += self.symbol_actions_content(listener, point) - contents += self.document_link_content(listener, point) + link_content, link_has_standard_tooltip = self.document_link_content(listener, point) + if userprefs().show_symbol_action_links and contents and not only_diagnostics and hover_content: + symbol_actions_content = self.symbol_actions_content(listener, point) + if link_content and link_has_standard_tooltip: + symbol_actions_content += ' | ' + link_content + elif link_content: + contents += '' + contents += '
' + symbol_actions_content + '
' + elif link_content: + contents += '
{}
'.format('link with-padding' if contents else 'link', link_content) _test_contents.clear() _test_contents.append(contents) # for testing only diff --git a/popups.css b/popups.css index 530190bd1..dd562ff79 100644 --- a/popups.css +++ b/popups.css @@ -60,6 +60,9 @@ .actions a.icon { text-decoration: none; } +.link.with-padding { + padding: 0.5rem; +} pre.related_info { border-top: 1px solid color(var(--background) alpha(0.25)); margin-top: 0.7rem; From 6da0ab70bf324ba0dd602a4d0e824021bc9eaf5e Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 12 Jun 2022 19:17:58 +0200 Subject: [PATCH 10/14] Fix unintended space --- plugin/hover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/hover.py b/plugin/hover.py index d07430b90..fed31e7c1 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -261,7 +261,7 @@ def _show_hover(self, listener: AbstractViewListener, point: int, only_diagnosti contents = self.diagnostics_content() + hover_content + code_actions_content(self._actions_by_config) link_content, link_has_standard_tooltip = self.document_link_content(listener, point) if userprefs().show_symbol_action_links and contents and not only_diagnostics and hover_content: - symbol_actions_content = self.symbol_actions_content(listener, point) + symbol_actions_content = self.symbol_actions_content(listener, point) if link_content and link_has_standard_tooltip: symbol_actions_content += ' | ' + link_content elif link_content: From b8115495eaee49193323b0d80d3c79fc6a549c04 Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Mon, 13 Jun 2022 22:01:53 +0200 Subject: [PATCH 11/14] correct resolving of document links --- plugin/core/sessions.py | 2 +- plugin/hover.py | 78 +++++++++++++++++++++++----------------- plugin/session_buffer.py | 4 +-- 3 files changed, 47 insertions(+), 37 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 75216bbf8..e1c0786c6 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -531,7 +531,7 @@ def on_diagnostics_async(self, raw_diagnostics: List[Diagnostic], version: Optio def get_document_link_at_point(self, view: sublime.View, point: int) -> Optional[DocumentLink]: ... - def update_document_link(self, link: DocumentLink) -> bool: + def update_document_link(self, link: DocumentLink) -> None: ... def do_semantic_tokens_async(self, view: sublime.View) -> None: diff --git a/plugin/hover.py b/plugin/hover.py index fed31e7c1..89fb31799 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -119,6 +119,7 @@ def run( wm = windows.lookup(window) self._base_dir = wm.get_project_path(self.view.file_name() or "") self._hover_responses = [] # type: List[Tuple[Hover, Optional[MarkdownLangMap]]] + self._document_link_content = ('', False) self._actions_by_config = {} # type: Dict[str, List[CodeActionOrCommand]] self._diagnostics_by_config = [] # type: Sequence[Tuple[SessionBufferProtocol, Sequence[Diagnostic]]] # TODO: For code actions it makes more sense to use the whole selection under mouse (if available) @@ -130,6 +131,8 @@ def run_async() -> None: return if not only_diagnostics: self.request_symbol_hover_async(listener, hover_point) + if userprefs().link_highlight_style in ("underline", "none"): + self.request_document_link_async(listener, hover_point) self._diagnostics_by_config, covering = listener.diagnostics_touching_point_async( hover_point, userprefs().show_diagnostics_severity_level) if self._diagnostics_by_config: @@ -184,6 +187,47 @@ def _on_all_settled( self._hover_responses = hovers self.show_hover(listener, point, only_diagnostics=False) + def request_document_link_async(self, listener: AbstractViewListener, point: int) -> None: + link_promises = [] # type: List[Promise[DocumentLink]] + for sv in listener.session_views_async(): + if not sv.has_capability_async("documentLinkProvider"): + continue + link = sv.session_buffer.get_document_link_at_point(sv.view, point) + if link is None: + continue + target = link.get("target") + if target: + resolved_link = link + link_promises.append(Promise(lambda resolve: resolve(resolved_link))) + elif sv.has_capability_async("documentLinkProvider.resolveProvider"): + link_promises.append(sv.session.send_request_task(Request.resolveDocumentLink(link, sv.view)).then( + lambda link: self._on_resolved_link(sv.session_buffer, link))) + if link_promises: + continuation = functools.partial(self._on_all_document_links_resolved, listener, point) + Promise.all(link_promises).then(continuation) + + def _on_resolved_link(self, session_buffer: SessionBufferProtocol, link: DocumentLink) -> DocumentLink: + session_buffer.update_document_link(link) + return link + + def _on_all_document_links_resolved( + self, listener: AbstractViewListener, point: int, links: List[DocumentLink] + ) -> None: + contents = [] + link_has_standard_tooltip = True + for link in links: + target = link.get("target") + if not target: + continue + title = link.get("tooltip") or "Follow link" + if title != "Follow link": + link_has_standard_tooltip = False + contents.append('{}'.format(target, title)) + if len(contents) > 1: + link_has_standard_tooltip = False + self._document_link_content = ('
'.join(contents) if contents else '', link_has_standard_tooltip) + self.show_hover(listener, point, only_diagnostics=False) + def handle_code_actions( self, listener: AbstractViewListener, @@ -213,38 +257,6 @@ def diagnostics_content(self) -> str: formatted.append("") return "".join(formatted) - def document_link_content(self, listener: AbstractViewListener, point: int) -> Tuple[str, bool]: - if userprefs().link_highlight_style not in ("underline", "none"): - return "", False - contents = [] - link_has_standard_tooltip = True - for sv in listener.session_views_async(): - if sv.has_capability_async("documentLinkProvider"): - link = sv.session_buffer.get_document_link_at_point(sv.view, point) - if link is not None: - target = link.get("target") - if not target: - if not sv.has_capability_async("documentLinkProvider.resolveProvider"): - continue - sv.session.send_request_task(Request.resolveDocumentLink(link, sv.view)).then( - lambda result: self._on_resolved_link(sv.session_buffer, result)) - link = sv.session_buffer.get_document_link_at_point(sv.view, point) # resolved link ??? - if not link: - continue - target = link.get("target") - if not target: - continue - title = link.get("tooltip") or "Follow link" - if title != "Follow link": - link_has_standard_tooltip = False - contents.append('{}'.format(target, title)) - if len(contents) > 1: - link_has_standard_tooltip = False - return '
'.join(contents) if contents else '', link_has_standard_tooltip - - def _on_resolved_link(self, session_buffer: SessionBufferProtocol, link: DocumentLink) -> None: - session_buffer.update_document_link(link) - def hover_content(self) -> str: contents = [] for hover, language_map in self._hover_responses: @@ -259,7 +271,7 @@ def show_hover(self, listener: AbstractViewListener, point: int, only_diagnostic def _show_hover(self, listener: AbstractViewListener, point: int, only_diagnostics: bool) -> None: hover_content = self.hover_content() contents = self.diagnostics_content() + hover_content + code_actions_content(self._actions_by_config) - link_content, link_has_standard_tooltip = self.document_link_content(listener, point) + link_content, link_has_standard_tooltip = self._document_link_content if userprefs().show_symbol_action_links and contents and not only_diagnostics and hover_content: symbol_actions_content = self.symbol_actions_content(listener, point) if link_content and link_has_standard_tooltip: diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index dd0faefaa..ec9f3d8ff 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -367,14 +367,12 @@ def get_document_link_at_point(self, view: sublime.View, point: int) -> Optional else: return None - def update_document_link(self, new_link: DocumentLink) -> bool: + def update_document_link(self, new_link: DocumentLink) -> None: new_link_range = Range.from_lsp(new_link["range"]) for link in self.document_links: if Range.from_lsp(link["range"]) == new_link_range: self.document_links.remove(link) self.document_links.append(new_link) - return True - return False # --- textDocument/publishDiagnostics ------------------------------------------------------------------------------ From 69e59547019936c0cf21b3943553ed515cbf5d64 Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Mon, 13 Jun 2022 22:05:43 +0200 Subject: [PATCH 12/14] cleanup --- plugin/hover.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugin/hover.py b/plugin/hover.py index 89fb31799..be981979d 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -197,8 +197,7 @@ def request_document_link_async(self, listener: AbstractViewListener, point: int continue target = link.get("target") if target: - resolved_link = link - link_promises.append(Promise(lambda resolve: resolve(resolved_link))) + link_promises.append(Promise.resolve(link)) elif sv.has_capability_async("documentLinkProvider.resolveProvider"): link_promises.append(sv.session.send_request_task(Request.resolveDocumentLink(link, sv.view)).then( lambda link: self._on_resolved_link(sv.session_buffer, link))) From b4c7ab419d0c78908a13700e7233f36b14d901aa Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Tue, 14 Jun 2022 01:48:01 +0200 Subject: [PATCH 13/14] Break loop after link updated --- plugin/session_buffer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index ec9f3d8ff..023d274c6 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -373,6 +373,7 @@ def update_document_link(self, new_link: DocumentLink) -> None: if Range.from_lsp(link["range"]) == new_link_range: self.document_links.remove(link) self.document_links.append(new_link) + break # --- textDocument/publishDiagnostics ------------------------------------------------------------------------------ From 6c7da88984bc19a7825f3d8f845ddec7133844e7 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Tue, 14 Jun 2022 01:50:59 +0200 Subject: [PATCH 14/14] Reorder methods --- plugin/document_link.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/plugin/document_link.py b/plugin/document_link.py index f49d3df92..2c4377329 100644 --- a/plugin/document_link.py +++ b/plugin/document_link.py @@ -12,23 +12,6 @@ class LspOpenLinkCommand(LspTextCommand): capability = 'documentLinkProvider' - def open_target(self, target: str) -> None: - if target.startswith("file:"): - window = self.view.window() - if window: - decoded = unquote(target) # decode percent-encoded characters - parsed = urlparse(decoded) - filepath = parsed.path - if sublime.platform() == "windows": - filepath = re.sub(r"^/([a-zA-Z]:)", r"\1", filepath) # remove slash preceding drive letter - fn = "{}:{}".format(filepath, parsed.fragment) if parsed.fragment else filepath - window.open_file(fn, flags=sublime.ENCODED_POSITION) - else: - if not (target.lower().startswith("http://") or target.lower().startswith("https://")): - target = "http://" + target - if not webbrowser.open(target): - sublime.status_message("failed to open: " + target) - def is_enabled(self, event: Optional[dict] = None, point: Optional[int] = None) -> bool: if not super().is_enabled(event, point): return False @@ -69,3 +52,20 @@ def run(self, edit: sublime.Edit, event: Optional[dict] = None) -> None: def _on_resolved_async(self, response: DocumentLink) -> None: self.open_target(response["target"]) + + def open_target(self, target: str) -> None: + if target.startswith("file:"): + window = self.view.window() + if window: + decoded = unquote(target) # decode percent-encoded characters + parsed = urlparse(decoded) + filepath = parsed.path + if sublime.platform() == "windows": + filepath = re.sub(r"^/([a-zA-Z]:)", r"\1", filepath) # remove slash preceding drive letter + fn = "{}:{}".format(filepath, parsed.fragment) if parsed.fragment else filepath + window.open_file(fn, flags=sublime.ENCODED_POSITION) + else: + if not (target.lower().startswith("http://") or target.lower().startswith("https://")): + target = "http://" + target + if not webbrowser.open(target): + sublime.status_message("failed to open: " + target)