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/LSP.sublime-settings b/LSP.sublime-settings index cef118333..9cecd3af9 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 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!). // 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/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/core/protocol.py b/plugin/core/protocol.py index 7c2ef323a..5addf68d1 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -301,6 +301,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] @@ -390,6 +397,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) @@ -406,6 +417,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 de8227a12..f790e499f 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 @@ -280,6 +281,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 }, @@ -524,6 +529,12 @@ def unregister_capability_async( def on_diagnostics_async(self, raw_diagnostics: List[Diagnostic], version: Optional[int]) -> None: ... + def get_document_link_at_point(self, view: sublime.View, point: int) -> Optional[DocumentLink]: + ... + + def update_document_link(self, link: DocumentLink) -> None: + ... + 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/document_link.py b/plugin/document_link.py new file mode 100644 index 000000000..2c4377329 --- /dev/null +++ b/plugin/document_link.py @@ -0,0 +1,71 @@ +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 +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, point: Optional[int] = None) -> bool: + if not super().is_enabled(event, point): + return False + 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, position) + return link is not None + + def run(self, edit: sublime.Edit, event: Optional[dict] = None) -> None: + 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: + self.open_target(target) + else: + 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"]) + + 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) diff --git a/plugin/hover.py b/plugin/hover.py index 2329202a8..be981979d 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 @@ -118,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) @@ -129,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: @@ -183,6 +187,46 @@ 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: + 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))) + 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, @@ -196,11 +240,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 = [] @@ -229,8 +270,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) + 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: + 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/plugin/session_buffer.py b/plugin/session_buffer.py index 0192cd317..023d274c6 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 @@ -104,6 +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[DocumentLink] self.semantic_tokens = SemanticTokensData() self._semantic_region_keys = {} # type: Dict[str, int] self._last_semantic_region_key = 0 @@ -140,6 +143,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 +286,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 +340,41 @@ 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 = response or [] + if self.document_links and userprefs().link_highlight_style == "underline": + view.add_regions( + "lsp_document_link", + [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_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 + else: + return None + + 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) + break + # --- 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/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; diff --git a/sublime-package.json b/sublime-package.json index c1eedc347..73927b458 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", + "disabled" + ], + "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,