From 9eed2d42aa073c98b27d8a65929f177290cb387a Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 11 Sep 2022 15:09:58 +0200 Subject: [PATCH 01/10] Parse selection from fragment of hover popup links --- plugin/core/open.py | 70 +++++++++++++++++++++++++++++++++++++++++ plugin/core/url.py | 2 +- plugin/document_link.py | 18 +++-------- plugin/hover.py | 20 +++--------- 4 files changed, 79 insertions(+), 31 deletions(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index 1c3a179d1..5cd7f2b62 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -2,17 +2,79 @@ from .promise import Promise from .promise import ResolveFunc from .protocol import DocumentUri +from .protocol import UINT_MAX from .protocol import Range from .protocol import RangeLsp from .typing import Dict, Tuple, Optional 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_int(s: Optional[str]) -> Optional[int]: + if s: + try: + # assume that line and column numbers in the fragment are 1-based + return max(1, int(s)) + except ValueError: + return None + return None + + def parse_fragment(fragment: str) -> RangeLsp: + match = FRAGMENT_PATTERN.match(fragment) + if match: + start_line, start_column, end_line, end_column = [parse_int(g) for g in match.groups()] + if start_line is not None: + if end_line is not None: + if start_column is not None and end_column is not None: + return { + "start": {"line": start_line - 1, "character": start_column - 1}, + "end": {"line": end_line - 1, "character": end_column - 1} + } + else: + return { + "start": {"line": start_line - 1, "character": 0}, + "end": {"line": end_line - 1, "character": UINT_MAX} + } + elif start_column is not None: + return { + "start": {"line": start_line - 1, "character": start_column - 1}, + "end": {"line": start_line - 1, "character": start_column - 1} + } + else: + return { + "start": {"line": start_line - 1, "character": 0}, + "end": {"line": start_line - 1, "character": 0} + } + return {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}} + + decoded_uri = unquote(uri) # decode percent-encoded characters + parsed = urlparse(decoded_uri) + if parsed.fragment: + r = parse_fragment(parsed.fragment) + + def handle_continuation(view: Optional[sublime.View]) -> Promise[Optional[sublime.View]]: + if view: + center_selection(view, r) + return Promise.resolve(view) + return Promise.resolve(None) + + return open_file(window, decoded_uri, flags, group).then(handle_continuation) + else: + return open_file(window, decoded_uri, flags, group) def _return_existing_view(flags: int, existing_view_group: int, active_group: int, specified_group: int) -> bool: @@ -78,6 +140,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://") or uri.lower().startswith("https://")): + uri = "http://" + 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..e58b37d47 100644 --- a/plugin/core/url.py +++ b/plugin/core/url.py @@ -51,7 +51,7 @@ def parse_uri(uri: str) -> Tuple[str, str]: path = url2pathname(parsed.path) if os.name == 'nt': netloc = url2pathname(parsed.netloc) - path = path.lstrip("\\") + path = re.sub(r"^[/\\]([a-zA-Z]:)", r"\1", path) # remove slash or backslash 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: From 965d9018a661434f738da84b2142f21c7e742aa8 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 11 Sep 2022 19:58:58 +0200 Subject: [PATCH 02/10] Revert windows backslash handling attempt --- plugin/core/url.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugin/core/url.py b/plugin/core/url.py index e58b37d47..19f40b990 100644 --- a/plugin/core/url.py +++ b/plugin/core/url.py @@ -51,7 +51,8 @@ def parse_uri(uri: str) -> Tuple[str, str]: path = url2pathname(parsed.path) if os.name == 'nt': netloc = url2pathname(parsed.netloc) - path = re.sub(r"^[/\\]([a-zA-Z]:)", r"\1", path) # remove slash or backslash preceding drive letter + 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 From c01ce20edc104f2c3c8bc06abadbaf5714ec2f63 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 11 Sep 2022 20:08:54 +0200 Subject: [PATCH 03/10] Simplify --- plugin/core/open.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index 5cd7f2b62..44cbf36da 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -24,19 +24,11 @@ def open_file_uri( window: sublime.Window, uri: DocumentUri, flags: int = 0, group: int = -1 ) -> Promise[Optional[sublime.View]]: - def parse_int(s: Optional[str]) -> Optional[int]: - if s: - try: - # assume that line and column numbers in the fragment are 1-based - return max(1, int(s)) - except ValueError: - return None - return None - def parse_fragment(fragment: str) -> RangeLsp: match = FRAGMENT_PATTERN.match(fragment) if match: - start_line, start_column, end_line, end_column = [parse_int(g) for g in match.groups()] + # assume that line and column numbers in the fragment are 1-based + start_line, start_column, end_line, end_column = [max(1, int(g)) if g else None for g in match.groups()] if start_line is not None: if end_line is not None: if start_column is not None and end_column is not None: From 21ecdc54e359ad4714b763e7c637a1858842f2fa Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 11 Sep 2022 22:57:03 +0200 Subject: [PATCH 04/10] Simplify code --- plugin/core/open.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index 44cbf36da..0e2facb0f 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -55,18 +55,16 @@ def parse_fragment(fragment: str) -> RangeLsp: decoded_uri = unquote(uri) # decode percent-encoded characters parsed = urlparse(decoded_uri) + open_promise = open_file(window, decoded_uri, flags, group) if parsed.fragment: - r = parse_fragment(parsed.fragment) + return open_promise.then(lambda view: _select_and_center(view, parse_fragment(parsed.fragment))) + return open_promise - def handle_continuation(view: Optional[sublime.View]) -> Promise[Optional[sublime.View]]: - if view: - center_selection(view, r) - return Promise.resolve(view) - return Promise.resolve(None) - return open_file(window, decoded_uri, flags, group).then(handle_continuation) - else: - return open_file(window, decoded_uri, flags, group) +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: From 85c7672bfe369cf30cb12c6c3a7880d07b7a25ee Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Mon, 12 Sep 2022 02:12:39 +0200 Subject: [PATCH 05/10] http -> https --- plugin/core/open.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index 0e2facb0f..1a3b21a1b 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -133,7 +133,7 @@ def center_selection(v: sublime.View, r: RangeLsp) -> sublime.View: def open_in_browser(uri: str) -> None: # NOTE: Remove this check when on py3.8. if not (uri.lower().startswith("http://") or uri.lower().startswith("https://")): - uri = "http://" + uri + uri = "https://" + uri if not webbrowser.open(uri): sublime.status_message("failed to open: " + uri) From 8c19cb33f405f835f39b92312a99ed3528f71cad Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Mon, 12 Sep 2022 15:25:05 +0200 Subject: [PATCH 06/10] startswith can take tuple --- plugin/core/open.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index 1a3b21a1b..560fb2b8d 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -132,7 +132,7 @@ def center_selection(v: sublime.View, r: RangeLsp) -> sublime.View: def open_in_browser(uri: str) -> None: # NOTE: Remove this check when on py3.8. - if not (uri.lower().startswith("http://") or uri.lower().startswith("https://")): + if not uri.lower().startswith(("http://", "https://")): uri = "https://" + uri if not webbrowser.open(uri): sublime.status_message("failed to open: " + uri) From 2a995a4418f0bc83775581c9c6feb550fe944537 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Mon, 12 Sep 2022 15:54:10 +0200 Subject: [PATCH 07/10] Refactor to reduce indentation --- plugin/core/open.py | 41 ++++++++++++++++------------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index 560fb2b8d..cac787890 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -2,9 +2,9 @@ from .promise import Promise from .promise import ResolveFunc from .protocol import DocumentUri -from .protocol import UINT_MAX from .protocol import Range from .protocol import RangeLsp +from .protocol import UINT_MAX from .typing import Dict, Tuple, Optional from .url import parse_uri from .views import range_to_region @@ -26,32 +26,23 @@ def open_file_uri( def parse_fragment(fragment: str) -> RangeLsp: match = FRAGMENT_PATTERN.match(fragment) + selection = {'start': {'line': 0, 'character': 0}, 'end': {'line': 0, 'character': 0}} # type: RangeLsp if match: - # assume that line and column numbers in the fragment are 1-based - start_line, start_column, end_line, end_column = [max(1, int(g)) if g else None for g in match.groups()] + # 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 is not None: - if end_line is not None: - if start_column is not None and end_column is not None: - return { - "start": {"line": start_line - 1, "character": start_column - 1}, - "end": {"line": end_line - 1, "character": end_column - 1} - } - else: - return { - "start": {"line": start_line - 1, "character": 0}, - "end": {"line": end_line - 1, "character": UINT_MAX} - } - elif start_column is not None: - return { - "start": {"line": start_line - 1, "character": start_column - 1}, - "end": {"line": start_line - 1, "character": start_column - 1} - } - else: - return { - "start": {"line": start_line - 1, "character": 0}, - "end": {"line": start_line - 1, "character": 0} - } - return {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}} + selection['start']['line'] = start_line + selection['end']['line'] = start_line + if start_column is not None: + selection['start']['character'] = start_column + selection['end']['character'] = start_column + if end_line is not None: + selection['end']['line'] = end_line + selection['end']['character'] = UINT_MAX + if end_column is not None: + selection['end']['character'] = end_column + return selection decoded_uri = unquote(uri) # decode percent-encoded characters parsed = urlparse(decoded_uri) From 1ae53a6bed3d6101f2dd48f499960013fdc770af Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Mon, 12 Sep 2022 16:09:58 +0200 Subject: [PATCH 08/10] Small tweak (0 is already the default value) --- plugin/core/open.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index cac787890..120b41f8c 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -31,16 +31,16 @@ def parse_fragment(fragment: str) -> 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 is not None: + if start_line: selection['start']['line'] = start_line selection['end']['line'] = start_line - if start_column is not None: + if start_column: selection['start']['character'] = start_column selection['end']['character'] = start_column - if end_line is not None: + if end_line: selection['end']['line'] = end_line selection['end']['character'] = UINT_MAX - if end_column is not None: + if end_column: selection['end']['character'] = end_column return selection From 92171d3b81e0ab7429b8bcecbd0cbf7304613d0b Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Mon, 12 Sep 2022 18:34:43 +0200 Subject: [PATCH 09/10] Fix ranges including newline character --- plugin/core/open.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index 120b41f8c..bb80f4361 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -40,7 +40,7 @@ def parse_fragment(fragment: str) -> RangeLsp: if end_line: selection['end']['line'] = end_line selection['end']['character'] = UINT_MAX - if end_column: + if end_column is not None: selection['end']['character'] = end_column return selection From 3e9362742615a3dcbfcba6c81f71e4dbb364d1ff Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Mon, 12 Sep 2022 23:15:23 +0200 Subject: [PATCH 10/10] Don't scroll to top if file is already open and fragment can't be parsed --- plugin/core/open.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index bb80f4361..3cfd23dff 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -6,6 +6,7 @@ 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 @@ -24,10 +25,10 @@ def open_file_uri( window: sublime.Window, uri: DocumentUri, flags: int = 0, group: int = -1 ) -> Promise[Optional[sublime.View]]: - def parse_fragment(fragment: str) -> RangeLsp: + def parse_fragment(fragment: str) -> Optional[RangeLsp]: match = FRAGMENT_PATTERN.match(fragment) - selection = {'start': {'line': 0, 'character': 0}, 'end': {'line': 0, 'character': 0}} # type: RangeLsp 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()] @@ -42,13 +43,16 @@ def parse_fragment(fragment: str) -> RangeLsp: selection['end']['character'] = UINT_MAX if end_column is not None: selection['end']['character'] = end_column - return selection + 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: - return open_promise.then(lambda view: _select_and_center(view, parse_fragment(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