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 '