From 5926089d17c4092bd22f8ef323a4eacadf02dfb0 Mon Sep 17 00:00:00 2001 From: jwortmann Date: Mon, 12 Sep 2022 23:42:03 +0200 Subject: [PATCH] Parse position or selection from link fragment (#2049) --- plugin/core/open.py | 55 +++++++++++++++++++++++++++++++++++++++++ plugin/core/url.py | 1 + plugin/document_link.py | 18 +++----------- plugin/hover.py | 20 +++------------ 4 files changed, 64 insertions(+), 30 deletions(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index 1c3a179d1..3cfd23dff 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -4,15 +4,62 @@ from .protocol import DocumentUri from .protocol import Range from .protocol import RangeLsp +from .protocol import UINT_MAX from .typing import Dict, Tuple, Optional +from .typing import cast from .url import parse_uri from .views import range_to_region +from urllib.parse import unquote, urlparse import os +import re import sublime import subprocess +import webbrowser opening_files = {} # type: Dict[str, Tuple[Promise[Optional[sublime.View]], ResolveFunc[Optional[sublime.View]]]] +FRAGMENT_PATTERN = re.compile(r'^L?(\d+)(?:,(\d+))?(?:-L?(\d+)(?:,(\d+))?)?') + + +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[RangeLsp]: + match = FRAGMENT_PATTERN.match(fragment) + if match: + selection = {'start': {'line': 0, 'character': 0}, 'end': {'line': 0, 'character': 0}} # type: RangeLsp + # 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) + if selection: + return open_promise.then(lambda view: _select_and_center(view, cast(RangeLsp, selection))) + return open_promise + + +def _select_and_center(view: Optional[sublime.View], r: RangeLsp) -> Optional[sublime.View]: + if view: + return center_selection(view, r) + return None def _return_existing_view(flags: int, existing_view_group: int, active_group: int, specified_group: int) -> bool: @@ -78,6 +125,14 @@ def center_selection(v: sublime.View, r: RangeLsp) -> sublime.View: return v +def open_in_browser(uri: str) -> None: + # NOTE: Remove this check when on py3.8. + if not uri.lower().startswith(("http://", "https://")): + uri = "https://" + uri + if not webbrowser.open(uri): + sublime.status_message("failed to open: " + uri) + + def open_externally(uri: str, take_focus: bool) -> bool: """ A blocking function that invokes the OS's "open with default extension" diff --git a/plugin/core/url.py b/plugin/core/url.py index ba1c8287c..19f40b990 100644 --- a/plugin/core/url.py +++ b/plugin/core/url.py @@ -52,6 +52,7 @@ def parse_uri(uri: str) -> Tuple[str, str]: if os.name == 'nt': netloc = url2pathname(parsed.netloc) path = path.lstrip("\\") + path = re.sub(r"^/([a-zA-Z]:)", r"\1", path) # remove slash preceding drive letter path = re.sub(r"^([a-z]):", _uppercase_driveletter, path) if netloc: # Convert to UNC path diff --git a/plugin/document_link.py b/plugin/document_link.py index 2c4377329..e2a665250 100644 --- a/plugin/document_link.py +++ b/plugin/document_link.py @@ -1,12 +1,11 @@ from .core.logging import debug +from .core.open import open_file_uri +from .core.open import open_in_browser 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): @@ -57,15 +56,6 @@ 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) + open_file_uri(window, target) 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) + open_in_browser(target) diff --git a/plugin/hover.py b/plugin/hover.py index b4411ca89..65403b26a 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -1,6 +1,7 @@ from .code_actions import actions_manager from .code_actions import CodeActionOrCommand -from .core.logging import debug +from .core.open import open_file_uri +from .core.open import open_in_browser from .core.promise import Promise from .core.protocol import Diagnostic from .core.protocol import DocumentLink @@ -37,12 +38,9 @@ from .core.views import unpack_href_location from .core.views import update_lsp_popup from .session_view import HOVER_HIGHLIGHT_KEY -from urllib.parse import unquote, urlparse import functools import html -import re import sublime -import webbrowser SUBLIME_WORD_MASK = 515 @@ -330,13 +328,7 @@ def _on_navigate(self, href: str, point: int) -> None: elif href.startswith("file:"): window = self.view.window() if window: - decoded = unquote(href) # 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) + open_file_uri(window, href) elif href.startswith('code-actions:'): _, config_name = href.split(":") actions = self._actions_by_config[config_name] @@ -360,11 +352,7 @@ def _on_navigate(self, href: str, point: int) -> None: r = {"start": position, "end": position} # type: RangeLsp sublime.set_timeout_async(functools.partial(session.open_uri_async, uri, r)) else: - # NOTE: Remove this check when on py3.8. - if not (href.lower().startswith("http://") or href.lower().startswith("https://")): - href = "http://" + href - if not webbrowser.open(href): - debug("failed to open:", href) + open_in_browser(href) def handle_code_action_select(self, config_name: str, index: int) -> None: if index > -1: