Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for textDocument/documentLink request #1974

Merged
merged 14 commits into from
Jun 16, 2022
Merged
14 changes: 14 additions & 0 deletions Default.sublime-keymap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions LSP.sublime-settings
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions Main.sublime-menu
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@
{
"caption": "LSP: Find References",
"command": "lsp_symbol_references"
},
{
"caption": "LSP: Follow Link",
"command": "lsp_open_link"
}
]
},
Expand Down
1 change: 1 addition & 0 deletions boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions plugin/core/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ class SemanticTokenModifiers:
'items': List[CompletionItem],
}, total=True)

DocumentLink = TypedDict('DocumentLink', {
'range': RangeLsp,
'target': DocumentUri,
'tooltip': str,
'data': Any
}, total=False)
rchl marked this conversation as resolved.
Show resolved Hide resolved

MarkedString = Union[str, Dict[str, str]]

MarkupContent = Dict[str, str]
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
11 changes: 11 additions & 0 deletions plugin/core/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
},
Expand Down Expand Up @@ -523,6 +528,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) -> bool:
...

def do_semantic_tokens_async(self, view: sublime.View) -> None:
...

Expand Down
2 changes: 2 additions & 0 deletions plugin/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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", {})
Expand Down
2 changes: 2 additions & 0 deletions plugin/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
71 changes: 71 additions & 0 deletions plugin/document_link.py
Original file line number Diff line number Diff line change
@@ -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 open_target(self, target: str) -> None:
jwortmann marked this conversation as resolved.
Show resolved Hide resolved
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
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"])
29 changes: 29 additions & 0 deletions plugin/hover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -215,6 +216,33 @@ def diagnostics_content(self) -> str:
formatted.append("</div>")
return "".join(formatted)

def document_link_content(self, listener: AbstractViewListener, point: int) -> str:
if userprefs().link_highlight_style not in ("underline", "none"):
return ""
contents = []
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 ???
jwortmann marked this conversation as resolved.
Show resolved Hide resolved
if not link:
continue
target = link.get("target")
if not target:
continue
title = link.get("tooltip") or "Follow link"
contents.append('<a href="{}">{}</a>'.format(target, title))
return '<div class="link">' + '<br>'.join(contents) + '</div>' 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:
Expand All @@ -231,6 +259,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
Expand Down
43 changes: 43 additions & 0 deletions plugin/session_buffer.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -333,6 +340,42 @@ 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) -> 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:
Expand Down
2 changes: 2 additions & 0 deletions plugin/session_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions sublime-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down