Skip to content

Commit d87f24c

Browse files
jwortmannrchl
andauthored
Add support for textDocument/documentLink request (#1974)
Co-authored-by: Rafal Chlodnicki <[email protected]>
1 parent ff5bc9f commit d87f24c

14 files changed

+242
-7
lines changed

Default.sublime-keymap

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,20 @@
353353
// }
354354
// ]
355355
// },
356+
// Follow Link
357+
// {
358+
// "command": "lsp_open_link",
359+
// "keys": [
360+
// "UNBOUND"
361+
// ],
362+
// "context": [
363+
// {
364+
// "key": "lsp.session_with_capability",
365+
// "operator": "equal",
366+
// "operand": "documentLinkProvider"
367+
// }
368+
// ]
369+
// },
356370
// Expand Selection (a replacement for ST's "Expand Selection")
357371
// {
358372
// "command": "lsp_expand_selection",

LSP.sublime-settings

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@
105105
// Valid values are "dot", "circle", "bookmark", "sign" or ""
106106
"diagnostics_gutter_marker": "dot",
107107

108+
// Highlight style of links to internal or external resources, like another text document
109+
// or a web site. Link navigation is implemented via the popup on mouse hover.
110+
// Valid values are:
111+
// "underline"
112+
// "none" - disables special highlighting, but still allows to navigate links in the hover popup
113+
// "disabled" - disables highlighting and the hover popup for links
114+
// Note that depending on the syntax and color scheme, some internet URLs may still be
115+
// underlined via regular syntax highlighting.
116+
"link_highlight_style": "underline",
117+
108118
// Enable semantic highlighting in addition to standard syntax highlighting (experimental!).
109119
// Note: Must be supported by the language server and also requires a special rule in the
110120
// color scheme to work. If you use none of the built-in color schemes from Sublime Text,

Main.sublime-menu

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@
7272
{
7373
"caption": "LSP: Find References",
7474
"command": "lsp_symbol_references"
75+
},
76+
{
77+
"caption": "LSP: Follow Link",
78+
"command": "lsp_open_link"
7579
}
7680
]
7781
},

boot.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from .plugin.core.typing import Any, Optional, List, Type, Dict
3636
from .plugin.core.views import get_uri_and_position_from_location
3737
from .plugin.core.views import LspRunTextCommandHelperCommand
38+
from .plugin.document_link import LspOpenLinkCommand
3839
from .plugin.documents import DocumentSyncListener
3940
from .plugin.documents import TextChangeListener
4041
from .plugin.edit import LspApplyDocumentEditCommand

plugin/core/protocol.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,13 @@ class SemanticTokenModifiers:
301301
'items': List[CompletionItem],
302302
}, total=True)
303303

304+
DocumentLink = TypedDict('DocumentLink', {
305+
'range': RangeLsp,
306+
'target': DocumentUri,
307+
'tooltip': str,
308+
'data': Any
309+
}, total=False)
310+
304311
MarkedString = Union[str, Dict[str, str]]
305312

306313
MarkupContent = Dict[str, str]
@@ -390,6 +397,10 @@ def documentSymbols(cls, params: Mapping[str, Any], view: sublime.View) -> 'Requ
390397
def documentHighlight(cls, params: Mapping[str, Any], view: sublime.View) -> 'Request':
391398
return Request("textDocument/documentHighlight", params, view)
392399

400+
@classmethod
401+
def documentLink(cls, params: Mapping[str, Any], view: sublime.View) -> 'Request':
402+
return Request("textDocument/documentLink", params, view)
403+
393404
@classmethod
394405
def semanticTokensFull(cls, params: Mapping[str, Any], view: sublime.View) -> 'Request':
395406
return Request("textDocument/semanticTokens/full", params, view)
@@ -406,6 +417,10 @@ def semanticTokensRange(cls, params: Mapping[str, Any], view: sublime.View) -> '
406417
def resolveCompletionItem(cls, params: CompletionItem, view: sublime.View) -> 'Request':
407418
return Request("completionItem/resolve", params, view)
408419

420+
@classmethod
421+
def resolveDocumentLink(cls, params: DocumentLink, view: sublime.View) -> 'Request':
422+
return Request("documentLink/resolve", params, view)
423+
409424
@classmethod
410425
def shutdown(cls) -> 'Request':
411426
return Request("shutdown")

plugin/core/sessions.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .protocol import DiagnosticSeverity
2525
from .protocol import DiagnosticTag
2626
from .protocol import DidChangeWatchedFilesRegistrationOptions
27+
from .protocol import DocumentLink
2728
from .protocol import DocumentUri
2829
from .protocol import Error
2930
from .protocol import ErrorCode
@@ -280,6 +281,10 @@ def get_initialize_params(variables: Dict[str, str], workspace_folders: List[Wor
280281
"valueSet": symbol_tag_value_set
281282
}
282283
},
284+
"documentLink": {
285+
"dynamicRegistration": True,
286+
"tooltipSupport": True
287+
},
283288
"formatting": {
284289
"dynamicRegistration": True # exceptional
285290
},
@@ -524,6 +529,12 @@ def unregister_capability_async(
524529
def on_diagnostics_async(self, raw_diagnostics: List[Diagnostic], version: Optional[int]) -> None:
525530
...
526531

532+
def get_document_link_at_point(self, view: sublime.View, point: int) -> Optional[DocumentLink]:
533+
...
534+
535+
def update_document_link(self, link: DocumentLink) -> None:
536+
...
537+
527538
def do_semantic_tokens_async(self, view: sublime.View) -> None:
528539
...
529540

plugin/core/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ class Settings:
193193
document_highlight_style = None # type: str
194194
inhibit_snippet_completions = None # type: bool
195195
inhibit_word_completions = None # type: bool
196+
link_highlight_style = None # type: str
196197
log_debug = None # type: bool
197198
log_max_size = None # type: int
198199
log_server = None # type: List[str]
@@ -230,6 +231,7 @@ def r(name: str, default: Union[bool, int, str, list, dict]) -> None:
230231
r("diagnostics_panel_include_severity_level", 4)
231232
r("disabled_capabilities", [])
232233
r("document_highlight_style", "underline")
234+
r("link_highlight_style", "underline")
233235
r("log_debug", False)
234236
r("log_max_size", 8 * 1024)
235237
r("lsp_code_actions_on_save", {})

plugin/core/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535

3636
MarkdownLangMap = Dict[str, Tuple[Tuple[str, ...], Tuple[str, ...]]]
3737

38+
DOCUMENT_LINK_FLAGS = sublime.HIDE_ON_MINIMAP | sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE | sublime.DRAW_SOLID_UNDERLINE # noqa: E501
39+
3840
_baseflags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE | sublime.DRAW_EMPTY_AS_OVERWRITE
3941

4042
DIAGNOSTIC_SEVERITY = [

plugin/document_link.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from .core.logging import debug
2+
from .core.protocol import DocumentLink, Request
3+
from .core.registry import get_position
4+
from .core.registry import LspTextCommand
5+
from .core.typing import Optional
6+
from urllib.parse import unquote, urlparse
7+
import re
8+
import sublime
9+
import webbrowser
10+
11+
12+
class LspOpenLinkCommand(LspTextCommand):
13+
capability = 'documentLinkProvider'
14+
15+
def is_enabled(self, event: Optional[dict] = None, point: Optional[int] = None) -> bool:
16+
if not super().is_enabled(event, point):
17+
return False
18+
position = get_position(self.view, event)
19+
if not position:
20+
return False
21+
session = self.best_session(self.capability, position)
22+
if not session:
23+
return False
24+
sv = session.session_view_for_view_async(self.view)
25+
if not sv:
26+
return False
27+
link = sv.session_buffer.get_document_link_at_point(self.view, position)
28+
return link is not None
29+
30+
def run(self, edit: sublime.Edit, event: Optional[dict] = None) -> None:
31+
point = get_position(self.view, event)
32+
if not point:
33+
return
34+
session = self.best_session(self.capability, point)
35+
if not session:
36+
return
37+
sv = session.session_view_for_view_async(self.view)
38+
if not sv:
39+
return
40+
link = sv.session_buffer.get_document_link_at_point(self.view, point)
41+
if not link:
42+
return
43+
target = link.get("target")
44+
45+
if target is not None:
46+
self.open_target(target)
47+
else:
48+
if not session.has_capability("documentLinkProvider.resolveProvider"):
49+
debug("DocumentLink.target is missing, but the server doesn't support documentLink/resolve")
50+
return
51+
session.send_request_async(Request.resolveDocumentLink(link, self.view), self._on_resolved_async)
52+
53+
def _on_resolved_async(self, response: DocumentLink) -> None:
54+
self.open_target(response["target"])
55+
56+
def open_target(self, target: str) -> None:
57+
if target.startswith("file:"):
58+
window = self.view.window()
59+
if window:
60+
decoded = unquote(target) # decode percent-encoded characters
61+
parsed = urlparse(decoded)
62+
filepath = parsed.path
63+
if sublime.platform() == "windows":
64+
filepath = re.sub(r"^/([a-zA-Z]:)", r"\1", filepath) # remove slash preceding drive letter
65+
fn = "{}:{}".format(filepath, parsed.fragment) if parsed.fragment else filepath
66+
window.open_file(fn, flags=sublime.ENCODED_POSITION)
67+
else:
68+
if not (target.lower().startswith("http://") or target.lower().startswith("https://")):
69+
target = "http://" + target
70+
if not webbrowser.open(target):
71+
sublime.status_message("failed to open: " + target)

plugin/hover.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .core.logging import debug
44
from .core.promise import Promise
55
from .core.protocol import Diagnostic
6+
from .core.protocol import DocumentLink
67
from .core.protocol import Error
78
from .core.protocol import ExperimentalTextDocumentRangeParams
89
from .core.protocol import Hover
@@ -118,6 +119,7 @@ def run(
118119
wm = windows.lookup(window)
119120
self._base_dir = wm.get_project_path(self.view.file_name() or "")
120121
self._hover_responses = [] # type: List[Tuple[Hover, Optional[MarkdownLangMap]]]
122+
self._document_link_content = ('', False)
121123
self._actions_by_config = {} # type: Dict[str, List[CodeActionOrCommand]]
122124
self._diagnostics_by_config = [] # type: Sequence[Tuple[SessionBufferProtocol, Sequence[Diagnostic]]]
123125
# 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:
129131
return
130132
if not only_diagnostics:
131133
self.request_symbol_hover_async(listener, hover_point)
134+
if userprefs().link_highlight_style in ("underline", "none"):
135+
self.request_document_link_async(listener, hover_point)
132136
self._diagnostics_by_config, covering = listener.diagnostics_touching_point_async(
133137
hover_point, userprefs().show_diagnostics_severity_level)
134138
if self._diagnostics_by_config:
@@ -183,6 +187,46 @@ def _on_all_settled(
183187
self._hover_responses = hovers
184188
self.show_hover(listener, point, only_diagnostics=False)
185189

190+
def request_document_link_async(self, listener: AbstractViewListener, point: int) -> None:
191+
link_promises = [] # type: List[Promise[DocumentLink]]
192+
for sv in listener.session_views_async():
193+
if not sv.has_capability_async("documentLinkProvider"):
194+
continue
195+
link = sv.session_buffer.get_document_link_at_point(sv.view, point)
196+
if link is None:
197+
continue
198+
target = link.get("target")
199+
if target:
200+
link_promises.append(Promise.resolve(link))
201+
elif sv.has_capability_async("documentLinkProvider.resolveProvider"):
202+
link_promises.append(sv.session.send_request_task(Request.resolveDocumentLink(link, sv.view)).then(
203+
lambda link: self._on_resolved_link(sv.session_buffer, link)))
204+
if link_promises:
205+
continuation = functools.partial(self._on_all_document_links_resolved, listener, point)
206+
Promise.all(link_promises).then(continuation)
207+
208+
def _on_resolved_link(self, session_buffer: SessionBufferProtocol, link: DocumentLink) -> DocumentLink:
209+
session_buffer.update_document_link(link)
210+
return link
211+
212+
def _on_all_document_links_resolved(
213+
self, listener: AbstractViewListener, point: int, links: List[DocumentLink]
214+
) -> None:
215+
contents = []
216+
link_has_standard_tooltip = True
217+
for link in links:
218+
target = link.get("target")
219+
if not target:
220+
continue
221+
title = link.get("tooltip") or "Follow link"
222+
if title != "Follow link":
223+
link_has_standard_tooltip = False
224+
contents.append('<a href="{}">{}</a>'.format(target, title))
225+
if len(contents) > 1:
226+
link_has_standard_tooltip = False
227+
self._document_link_content = ('<br>'.join(contents) if contents else '', link_has_standard_tooltip)
228+
self.show_hover(listener, point, only_diagnostics=False)
229+
186230
def handle_code_actions(
187231
self,
188232
listener: AbstractViewListener,
@@ -196,11 +240,8 @@ def provider_exists(self, listener: AbstractViewListener, link: LinkKind) -> boo
196240
return bool(listener.session_async('{}Provider'.format(link.lsp_name)))
197241

198242
def symbol_actions_content(self, listener: AbstractViewListener, point: int) -> str:
199-
if userprefs().show_symbol_action_links:
200-
actions = [lk.link(point, self.view) for lk in link_kinds if self.provider_exists(listener, lk)]
201-
if actions:
202-
return '<div class="actions">' + " | ".join(actions) + "</div>"
203-
return ""
243+
actions = [lk.link(point, self.view) for lk in link_kinds if self.provider_exists(listener, lk)]
244+
return " | ".join(actions) if actions else ""
204245

205246
def diagnostics_content(self) -> str:
206247
formatted = []
@@ -229,8 +270,16 @@ def show_hover(self, listener: AbstractViewListener, point: int, only_diagnostic
229270
def _show_hover(self, listener: AbstractViewListener, point: int, only_diagnostics: bool) -> None:
230271
hover_content = self.hover_content()
231272
contents = self.diagnostics_content() + hover_content + code_actions_content(self._actions_by_config)
232-
if contents and not only_diagnostics and hover_content:
233-
contents += self.symbol_actions_content(listener, point)
273+
link_content, link_has_standard_tooltip = self._document_link_content
274+
if userprefs().show_symbol_action_links and contents and not only_diagnostics and hover_content:
275+
symbol_actions_content = self.symbol_actions_content(listener, point)
276+
if link_content and link_has_standard_tooltip:
277+
symbol_actions_content += ' | ' + link_content
278+
elif link_content:
279+
contents += '<div class="link with-padding">' + link_content + '</div>'
280+
contents += '<div class="actions">' + symbol_actions_content + '</div>'
281+
elif link_content:
282+
contents += '<div class="{}">{}</div>'.format('link with-padding' if contents else 'link', link_content)
234283

235284
_test_contents.clear()
236285
_test_contents.append(contents) # for testing only

0 commit comments

Comments
 (0)