From 32c3973dc78a2462ecf196d95ded6fe5a4afdd2a Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sun, 15 Oct 2023 22:57:08 +0200 Subject: [PATCH 1/5] Handle custom URI schemes in hover text links Before this change, custom URI schemes would be delegated to the browser. With this change, an attempt is made to look for a session that can handle the URI. Moreover, if there is a fragment present in the URI, then it's assumed that that fragment encodes the (row, col) to jump to. This logic is somewhat dubious, but it's the only reasonable way (as far as I can see) to handle this. Anoher approach could be to introduce a new on_open_uri2_async callback for plugins where they can also provide a (row, col) to jump to. But, then we would also require an opt-in switch for plugins where they would advertise that they can handle the "version 2" of on_open_uri. --- plugin/core/views.py | 29 +++++++++++++++++++++++++++++ plugin/hover.py | 16 ++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/plugin/core/views.py b/plugin/core/views.py index c2a20cfae..8f458453c 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -1,3 +1,4 @@ +from urllib.parse import urlparse from .css import css as lsp_css from .protocol import CodeAction from .protocol import CodeActionKind @@ -997,6 +998,34 @@ def is_location_href(href: str) -> bool: return href.startswith("location:") +def starts_with_custom_uri_scheme(href: str) -> bool: + return urlparse(href).scheme.lower() not in ("", "http", "https") + + +def row_col_from_uri_fragment(href: str) -> Tuple[Optional[int], Optional[int]]: + fragment = urlparse(href).fragment + if not fragment: + return (None, None) + rowcol = fragment.split(":") + row = None # type: Optional[int] + col = None # type: Optional[int] + if len(rowcol) >= 1: + try: + row = int(rowcol[0]) + # rowcols in URI fragments are 1-based (dubious) + row -= 1 + except Exception: + pass + if len(rowcol) >= 2: + try: + col = int(rowcol[1]) + # rowcols in URI fragments are 1-based (dubious) + col -= 1 + except Exception: + pass + return (row, col) + + def _format_diagnostic_related_info( config: ClientConfig, info: DiagnosticRelatedInformation, diff --git a/plugin/hover.py b/plugin/hover.py index e0b1257f2..7b659dee3 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -30,7 +30,9 @@ from .core.views import MarkdownLangMap from .core.views import minihtml from .core.views import range_to_region +from .core.views import row_col_from_uri_fragment from .core.views import show_lsp_popup +from .core.views import starts_with_custom_uri_scheme from .core.views import text_document_position_params from .core.views import unpack_href_location from .core.views import update_lsp_popup @@ -353,6 +355,8 @@ def on_select(targets: List[str], idx: int) -> None: position = {"line": row, "character": col_utf16} # type: Position r = {"start": position, "end": position} # type: Range sublime.set_timeout_async(partial(session.open_uri_async, uri, r)) + elif starts_with_custom_uri_scheme(href): + sublime.set_timeout_async(partial(self.try_open_custom_uri_async, href)) else: open_in_browser(href) @@ -366,3 +370,15 @@ def run_async() -> None: session.run_code_action_async(actions[index], progress=True, view=self.view) sublime.set_timeout_async(run_async) + + def try_open_custom_uri_async(self, href: str) -> None: + row, col_utf16 = row_col_from_uri_fragment(href) + if isinstance(row, int): + position = {"line": row, "character": col_utf16 or 0} # type: Position + r = {"start": position, "end": position} # type: Optional[Range] + else: + r = None + for session in self.sessions(): + promise = session.open_uri_async(href, r) + if not promise.resolved or isinstance(promise.value, sublime.View): + return From 8e8f2db28c26f378112e6a54da37a6086ed32597 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 18 Oct 2023 22:00:46 +0200 Subject: [PATCH 2/5] Reuse parse_fragment from open.py and add try_open_uri_async method --- plugin/core/open.py | 45 +++++++++++++++++++++-------------------- plugin/core/sessions.py | 22 +++++++++++++++----- plugin/core/views.py | 24 ---------------------- plugin/hover.py | 13 ++++-------- 4 files changed, 44 insertions(+), 60 deletions(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index 094b48ee1..84fc03465 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -21,36 +21,37 @@ FRAGMENT_PATTERN = re.compile(r'^L?(\d+)(?:,(\d+))?(?:-L?(\d+)(?:,(\d+))?)?') +def lsp_range_from_uri_fragment(fragment: str) -> Optional[Range]: + match = FRAGMENT_PATTERN.match(fragment) + if match: + selection = {'start': {'line': 0, 'character': 0}, 'end': {'line': 0, 'character': 0}} # type: Range + # Line and column numbers in the fragment are assumed to be 1-based and need to be converted to 0-based + # numbers for the LSP Position structure. + start_line, start_column, end_line, end_column = [max(0, int(g) - 1) if g else None for g in match.groups()] + if start_line: + selection['start']['line'] = start_line + selection['end']['line'] = start_line + if start_column: + selection['start']['character'] = start_column + selection['end']['character'] = start_column + if end_line: + selection['end']['line'] = end_line + selection['end']['character'] = UINT_MAX + if end_column is not None: + selection['end']['character'] = end_column + return selection + return None + + def open_file_uri( window: sublime.Window, uri: DocumentUri, flags: int = 0, group: int = -1 ) -> Promise[Optional[sublime.View]]: - def parse_fragment(fragment: str) -> Optional[Range]: - match = FRAGMENT_PATTERN.match(fragment) - if match: - selection = {'start': {'line': 0, 'character': 0}, 'end': {'line': 0, 'character': 0}} # type: Range - # Line and column numbers in the fragment are assumed to be 1-based and need to be converted to 0-based - # numbers for the LSP Position structure. - start_line, start_column, end_line, end_column = [max(0, int(g) - 1) if g else None for g in match.groups()] - if start_line: - selection['start']['line'] = start_line - selection['end']['line'] = start_line - if start_column: - selection['start']['character'] = start_column - selection['end']['character'] = start_column - if end_line: - selection['end']['line'] = end_line - selection['end']['character'] = UINT_MAX - if end_column is not None: - selection['end']['character'] = end_column - return selection - return None - decoded_uri = unquote(uri) # decode percent-encoded characters parsed = urlparse(decoded_uri) open_promise = open_file(window, decoded_uri, flags, group) if parsed.fragment: - selection = parse_fragment(parsed.fragment) + selection = lsp_range_from_uri_fragment(parsed.fragment) if selection: return open_promise.then(lambda view: _select_and_center(view, cast(Range, selection))) return open_promise diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index f0080bc36..d6e7ea214 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1622,13 +1622,13 @@ def run_code_action_async( return self._maybe_resolve_code_action(code_action) \ .then(lambda code_action: self._apply_code_action_async(code_action, view)) - def open_uri_async( + def try_open_uri_async( self, uri: DocumentUri, r: Optional[Range] = None, flags: int = 0, group: int = -1 - ) -> Promise[Optional[sublime.View]]: + ) -> Optional[Promise[Optional[sublime.View]]]: if uri.startswith("file:"): return self._open_file_uri_async(uri, r, flags, group) # Try to find a pre-existing session-buffer @@ -1642,7 +1642,19 @@ def open_uri_async( # There is no pre-existing session-buffer, so we have to go through AbstractPlugin.on_open_uri_async. if self._plugin: return self._open_uri_with_plugin_async(self._plugin, uri, r, flags, group) - return Promise.resolve(None) + return None + + def open_uri_async( + self, + uri: DocumentUri, + r: Optional[Range] = None, + flags: int = 0, + group: int = -1 + ) -> Promise[Optional[sublime.View]]: + promise = self.try_open_uri_async(uri, r, flags, group) + if promise is None: + raise RuntimeError("unexpected URI scheme") + return promise def _open_file_uri_async( self, @@ -1668,7 +1680,7 @@ def _open_uri_with_plugin_async( r: Optional[Range], flags: int, group: int, - ) -> Promise[Optional[sublime.View]]: + ) -> Optional[Promise[Optional[sublime.View]]]: # I cannot type-hint an unpacked tuple pair = Promise.packaged_task() # type: PackagedTask[Tuple[str, str, str]] # It'd be nice to have automatic tuple unpacking continuations @@ -1693,7 +1705,7 @@ def open_scratch_buffer(title: str, content: str, syntax: str) -> None: pair[0].then(lambda tup: sublime.set_timeout(lambda: open_scratch_buffer(*tup))) return result[0] - return Promise.resolve(None) + return None def open_location_async(self, location: Union[Location, LocationLink], flags: int = 0, group: int = -1) -> Promise[Optional[sublime.View]]: diff --git a/plugin/core/views.py b/plugin/core/views.py index db7ed4e66..f1b6050fe 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -989,30 +989,6 @@ def starts_with_custom_uri_scheme(href: str) -> bool: return urlparse(href).scheme.lower() not in ("", "http", "https") -def row_col_from_uri_fragment(href: str) -> Tuple[Optional[int], Optional[int]]: - fragment = urlparse(href).fragment - if not fragment: - return (None, None) - rowcol = fragment.split(":") - row = None # type: Optional[int] - col = None # type: Optional[int] - if len(rowcol) >= 1: - try: - row = int(rowcol[0]) - # rowcols in URI fragments are 1-based (dubious) - row -= 1 - except Exception: - pass - if len(rowcol) >= 2: - try: - col = int(rowcol[1]) - # rowcols in URI fragments are 1-based (dubious) - col -= 1 - except Exception: - pass - return (row, col) - - def _format_diagnostic_related_info( config: ClientConfig, info: DiagnosticRelatedInformation, diff --git a/plugin/hover.py b/plugin/hover.py index cc5761c59..046e00953 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -1,6 +1,8 @@ +from urllib.parse import urlparse from .code_actions import actions_manager from .code_actions import CodeActionOrCommand from .code_actions import CodeActionsByConfigName +from .core.open import lsp_range_from_uri_fragment from .core.open import open_file_uri from .core.open import open_in_browser from .core.promise import Promise @@ -30,7 +32,6 @@ from .core.views import MarkdownLangMap from .core.views import minihtml from .core.views import range_to_region -from .core.views import row_col_from_uri_fragment from .core.views import show_lsp_popup from .core.views import starts_with_custom_uri_scheme from .core.views import text_document_position_params @@ -381,13 +382,7 @@ def run_async() -> None: sublime.set_timeout_async(run_async) def try_open_custom_uri_async(self, href: str) -> None: - row, col_utf16 = row_col_from_uri_fragment(href) - if isinstance(row, int): - position = {"line": row, "character": col_utf16 or 0} # type: Position - r = {"start": position, "end": position} # type: Optional[Range] - else: - r = None + r = lsp_range_from_uri_fragment(urlparse(href).fragment) for session in self.sessions(): - promise = session.open_uri_async(href, r) - if not promise.resolved or isinstance(promise.value, sublime.View): + if session.try_open_uri_async(href, r) is not None: return From e70914950065209d8a2e0981a577fc234dbc4b7a Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 19 Oct 2023 23:18:01 +0200 Subject: [PATCH 3/5] check the scheme inline --- plugin/core/views.py | 5 ----- plugin/hover.py | 3 +-- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/plugin/core/views.py b/plugin/core/views.py index f1b6050fe..a55d1a239 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -1,4 +1,3 @@ -from urllib.parse import urlparse from .css import css as lsp_css from .protocol import CodeAction from .protocol import CodeActionKind @@ -985,10 +984,6 @@ def is_location_href(href: str) -> bool: return href.startswith("location:") -def starts_with_custom_uri_scheme(href: str) -> bool: - return urlparse(href).scheme.lower() not in ("", "http", "https") - - def _format_diagnostic_related_info( config: ClientConfig, info: DiagnosticRelatedInformation, diff --git a/plugin/hover.py b/plugin/hover.py index 046e00953..da7af9718 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -33,7 +33,6 @@ from .core.views import minihtml from .core.views import range_to_region from .core.views import show_lsp_popup -from .core.views import starts_with_custom_uri_scheme from .core.views import text_document_position_params from .core.views import unpack_href_location from .core.views import update_lsp_popup @@ -365,7 +364,7 @@ def on_select(targets: List[str], idx: int) -> None: position = {"line": row, "character": col_utf16} # type: Position r = {"start": position, "end": position} # type: Range sublime.set_timeout_async(partial(session.open_uri_async, uri, r)) - elif starts_with_custom_uri_scheme(href): + elif urlparse(href).scheme.lower() not in ("", "http", "https"): sublime.set_timeout_async(partial(self.try_open_custom_uri_async, href)) else: open_in_browser(href) From afebe3b696a8dbd802589dd3ac8b247808bd9f9b Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 19 Oct 2023 23:21:17 +0200 Subject: [PATCH 4/5] sort --- plugin/hover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/hover.py b/plugin/hover.py index da7af9718..ee24e34ab 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -1,4 +1,3 @@ -from urllib.parse import urlparse from .code_actions import actions_manager from .code_actions import CodeActionOrCommand from .code_actions import CodeActionsByConfigName @@ -38,6 +37,7 @@ from .core.views import update_lsp_popup from .session_view import HOVER_HIGHLIGHT_KEY from functools import partial +from urllib.parse import urlparse import html import mdpopups import sublime From 784dd78abbcc816cdd3937082437a47a369771b8 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 19 Oct 2023 23:31:39 +0200 Subject: [PATCH 5/5] don't change behavior --- plugin/core/sessions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index d6e7ea214..ae918a325 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1652,9 +1652,7 @@ def open_uri_async( group: int = -1 ) -> Promise[Optional[sublime.View]]: promise = self.try_open_uri_async(uri, r, flags, group) - if promise is None: - raise RuntimeError("unexpected URI scheme") - return promise + return Promise.resolve(None) if promise is None else promise def _open_file_uri_async( self,