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

Allow attaching to URI schemes other than the 'file' scheme #1758

Merged
merged 23 commits into from
Jun 29, 2021
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 21 additions & 24 deletions plugin/code_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,31 +122,28 @@ def _request_async(

collector = CodeActionsCollector(actions_handler)
with collector:
file_name = view.file_name()
if file_name:
listener = windows.listener_for_view(view)
if listener:
for session in listener.sessions_async('codeActionProvider'):
if on_save_actions:
supported_kinds = session.get_capability('codeActionProvider.codeActionKinds')
matching_kinds = get_matching_kinds(on_save_actions, supported_kinds or [])
if matching_kinds:
params = text_document_code_action_params(
view, file_name, region, [], matching_kinds)
request = Request.codeAction(params, view)
session.send_request_async(
request, *filtering_collector(session.config.name, matching_kinds, collector))
else:
diagnostics = [] # type: Sequence[Diagnostic]
for sb, diags in session_buffer_diagnostics:
if sb.session == session:
diagnostics = diags
break
if only_with_diagnostics and not diagnostics:
continue
params = text_document_code_action_params(view, file_name, region, diagnostics)
listener = windows.listener_for_view(view)
if listener:
for session in listener.sessions_async('codeActionProvider'):
if on_save_actions:
supported_kinds = session.get_capability('codeActionProvider.codeActionKinds')
matching_kinds = get_matching_kinds(on_save_actions, supported_kinds or [])
if matching_kinds:
params = text_document_code_action_params(view, region, [], matching_kinds)
request = Request.codeAction(params, view)
session.send_request_async(request, collector.create_collector(session.config.name))
session.send_request_async(
request, *filtering_collector(session.config.name, matching_kinds, collector))
else:
diagnostics = [] # type: Sequence[Diagnostic]
for sb, diags in session_buffer_diagnostics:
if sb.session == session:
diagnostics = diags
break
if only_with_diagnostics and not diagnostics:
continue
params = text_document_code_action_params(view, region, diagnostics)
request = Request.codeAction(params, view)
session.send_request_async(request, collector.create_collector(session.config.name))
if use_cache:
self._response_cache = (location_cache_key, collector)

Expand Down
17 changes: 11 additions & 6 deletions plugin/core/configurations.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from .logging import debug
from .types import ClientConfig
from .typing import Any, Generator, List, Set, Dict
from .typing import Generator, List, Set, Dict
from .workspace import enable_in_project, disable_in_project
import sublime
import urllib.parse


class ConfigManager(object):
Expand Down Expand Up @@ -36,18 +37,22 @@ def get_configs(self) -> List[ClientConfig]:

def match_view(self, view: sublime.View, include_disabled: bool = False) -> Generator[ClientConfig, None, None]:
"""
Yields configurations matching with the language's document_selector
Yields configurations where:

- the configuration's "selector" matches with the view's base scope, and
- the view's URI scheme is an element of the configuration's "schemes".
"""
try:
uri = view.settings().get("lsp_uri")
if not isinstance(uri, str):
return
scheme = urllib.parse.urlparse(uri).scheme
for config in self.all.values():
if config.match_view(view) and (config.enabled or include_disabled):
if config.match_view(view, scheme) and (config.enabled or include_disabled):
yield config
except (IndexError, RuntimeError):
pass

def is_supported(self, view: Any) -> bool:
return any(self.match_view(view))

def update(self) -> None:
project_settings = (self._window.project_data() or {}).get("settings", {}).get("LSP", {})
self.all.clear()
Expand Down
49 changes: 32 additions & 17 deletions plugin/core/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
from .types import method_to_capability
from .types import SettingsRegistration
from .typing import Callable, cast, Dict, Any, Optional, List, Tuple, Generator, Type, Protocol, Mapping, Union
from .url import uri_to_filename
from .version import __version__
from .views import COMPLETION_KINDS
from .views import extract_variables
Expand Down Expand Up @@ -316,6 +315,12 @@ class SessionViewProtocol(Protocol):
listener = None # type: Any
session_buffer = None # type: Any

def get_uri(self) -> Optional[str]:
...

def get_language_id(self) -> Optional[str]:
...

def on_capability_added_async(self, registration_id: str, capability_path: str, options: Dict[str, Any]) -> None:
...

Expand Down Expand Up @@ -348,8 +353,12 @@ class SessionBufferProtocol(Protocol):

session = None # type: Session
session_views = None # type: WeakSet[SessionViewProtocol]
file_name = None # type: str
language_id = None # type: str

def get_uri(self) -> Optional[str]:
...

def get_language_id(self) -> Optional[str]:
...

def register_capability_async(
self,
Expand Down Expand Up @@ -914,23 +923,25 @@ def session_buffers_async(self) -> Generator[SessionBufferProtocol, None, None]:
"""
yield from self._session_buffers

def get_session_buffer_for_uri_async(self, uri: str) -> Optional[SessionBufferProtocol]:
file_name = uri_to_filename(uri)
def get_session_buffer_for_uri_async(self, uri: DocumentUri) -> Optional[SessionBufferProtocol]:
for sb in self.session_buffers_async():
try:
if sb.file_name == file_name or os.path.samefile(file_name, sb.file_name):
return sb
except FileNotFoundError:
pass
if sb.get_uri() == uri:
return sb
return None

# --- capability observers -----------------------------------------------------------------------------------------

def can_handle(self, view: sublime.View, capability: Optional[str], inside_workspace: bool) -> bool:
file_name = view.file_name() or ''
if (self.config.match_view(view)
and self.state == ClientStates.READY
and self.handles_path(file_name, inside_workspace)):
def can_handle(self, view: sublime.View, scheme: str, capability: Optional[str], inside_workspace: bool) -> bool:
if not self.state == ClientStates.READY:
return False
if scheme == "file":
file_name = view.file_name()
if not file_name:
# We're closing down
return False
elif not self.handles_path(file_name, inside_workspace):
return False
if self.config.match_view(view, scheme):
# If there's no capability requirement then this session can handle the view
if capability is None:
return True
Expand Down Expand Up @@ -974,10 +985,11 @@ def handles_path(self, file_path: Optional[str], inside_workspace: bool) -> bool
if self._supports_workspace_folders():
# A workspace-aware language server handles any path, both inside and outside the workspaces.
return True
# buffer views or URI views
if not file_path:
return True
# If we end up here then the language server is workspace-unaware. This means there can be more than one
# language server with the same config name. So we have to actually do the subpath checks.
if not file_path:
return False
if not self._workspace_folders or not inside_workspace:
return True
for folder in self._workspace_folders:
Expand Down Expand Up @@ -1115,6 +1127,9 @@ def open_uri_async(

def open_scratch_buffer(title: str, content: str, syntax: str) -> None:
v = self.window.new_file(syntax=syntax, flags=flags)
# Note: the __init__ of ViewEventListeners is invoked in the next UI frame, so we can fill in the
# settings object here at our leisure.
v.settings().set("lsp_uri", uri)
v.set_scratch(True)
v.set_name(title)
v.run_command("append", {"characters": content})
Expand Down
34 changes: 24 additions & 10 deletions plugin/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import socket
import sublime
import time
import urllib.parse


TCP_CONNECT_TIMEOUT = 5 # seconds
FEATURES_TIMEOUT = 300 # milliseconds
Expand Down Expand Up @@ -279,8 +281,9 @@ def __call__(self, view: sublime.View) -> bool:
if not syntax or basescope2languageid(syntax.scope) != self.language:
return False
if self.scheme:
# Can be "file" or "untitled"?
pass
uri = view.settings().get("lsp_uri")
if isinstance(uri, str) and urllib.parse.urlparse(uri).scheme != self.scheme:
return False
if self.pattern:
if not globmatch(view.file_name() or "", self.pattern, flags=GLOBSTAR | BRACE):
return False
Expand Down Expand Up @@ -532,6 +535,7 @@ def __init__(self,
name: str,
selector: str,
priority_selector: Optional[str] = None,
schemes: Optional[List[str]] = None,
command: Optional[List[str]] = None,
binary_args: Optional[List[str]] = None, # DEPRECATED
tcp_port: Optional[int] = None,
Expand All @@ -546,6 +550,10 @@ def __init__(self,
self.name = name
self.selector = selector
self.priority_selector = priority_selector if priority_selector else self.selector
if isinstance(schemes, list):
self.schemes = schemes # type: List[str]
else:
self.schemes = ["file"]
if isinstance(command, list):
self.command = command
else:
Expand Down Expand Up @@ -578,6 +586,7 @@ def from_sublime_settings(cls, name: str, s: sublime.Settings, file: str) -> "Cl
name=name,
selector=_read_selector(s),
priority_selector=_read_priority_selector(s),
schemes=s.get("schemes"),
command=read_list_setting(s, "command", []),
tcp_port=s.get("tcp_port"),
auto_complete_selector=s.get("auto_complete_selector"),
Expand All @@ -598,10 +607,14 @@ def from_dict(cls, name: str, d: Dict[str, Any]) -> "ClientConfig":
disabled_capabilities = DottedDict(disabled_capabilities)
else:
disabled_capabilities = DottedDict()
schemes = d.get("schemes")
if not isinstance(schemes, list):
schemes = ["file"]
return ClientConfig(
name=name,
selector=_read_selector(d),
priority_selector=_read_priority_selector(d),
schemes=schemes,
command=d.get("command", []),
tcp_port=d.get("tcp_port"),
auto_complete_selector=d.get("auto_complete_selector"),
Expand All @@ -626,6 +639,7 @@ def from_config(cls, src_config: "ClientConfig", override: Dict[str, Any]) -> "C
name=src_config.name,
selector=_read_selector(override) or src_config.selector,
priority_selector=_read_priority_selector(override) or src_config.priority_selector,
schemes=override.get("schemes", src_config.schemes),
command=override.get("command", src_config.command),
tcp_port=override.get("tcp_port", src_config.tcp_port),
auto_complete_selector=override.get("auto_complete_selector", src_config.auto_complete_selector),
Expand Down Expand Up @@ -677,15 +691,15 @@ def set_view_status(self, view: sublime.View, message: str) -> None:
def erase_view_status(self, view: sublime.View) -> None:
view.erase_status(self.status_key)

def match_view(self, view: sublime.View) -> bool:
def match_view(self, view: sublime.View, scheme: str) -> bool:
syntax = view.syntax()
if syntax:
# Every part of a x.y.z scope seems to contribute 8.
# An empty selector result in a score of 1.
# A non-matching non-empty selector results in a score of 0.
# We want to match at least one part of an x.y.z, and we don't want to match on empty selectors.
return sublime.score_selector(syntax.scope, self.selector) >= 8
return False
if not syntax:
return False
# Every part of a x.y.z scope seems to contribute 8.
# An empty selector result in a score of 1.
# A non-matching non-empty selector results in a score of 0.
# We want to match at least one part of an x.y.z, and we don't want to match on empty selectors.
return scheme in self.schemes and sublime.score_selector(syntax.scope, self.selector) >= 8

def map_client_path_to_server_uri(self, path: str) -> str:
if self.path_maps:
Expand Down
48 changes: 46 additions & 2 deletions plugin/core/url.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,63 @@
from .typing import Any
from urllib.parse import quote
from urllib.parse import urljoin
from urllib.parse import urlparse
from urllib.request import pathname2url
from urllib.request import url2pathname
import os
import re

import sublime

def filename_to_uri(path: str) -> str:
return urljoin('file:', pathname2url(path))

def filename_to_uri(file_name: str) -> str:
"""
Convert a file name obtained from view.file_name() into an URI
"""
prefix = sublime.installed_packages_path()
if file_name.startswith(prefix):
return _to_resource_uri(file_name, prefix)
prefix = sublime.packages_path()
if file_name.startswith(prefix) and not os.path.exists(file_name):
return _to_resource_uri(file_name, prefix)
path = pathname2url(file_name)
re.sub(r"^([A-Z]):/", _lowercase_driveletter, path)
return urljoin("file:", path)


def view_to_uri(view: sublime.View) -> str:
file_name = view.file_name()
if not file_name:
return "buffer://sublime/{}".format(view.buffer_id())
return filename_to_uri(file_name)


def uri_to_filename(uri: str) -> str:
"""
DEPRECATED: An URI associated to a view does not necessarily have a "file:" scheme.
Use urllib.parse.urlparse to determine the scheme and go from there.
Use urllib.parse.unquote to unquote the path.
"""
parsed = urlparse(uri)
assert parsed.scheme == "file"
if os.name == 'nt':
# url2pathname does not understand %3A (VS Code's encoding forced on all servers :/)
return url2pathname(parsed.path).strip('\\')
else:
return url2pathname(parsed.path)


Comment on lines 35 to +49
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have an alternative helper function for getting filename from URI? I'd need to use something in eslint.

Having to play with urllib doesn't sound like fun and also I wonder if such helper needs to apply some path transformations to stay consistent.

I imagine such helper could return a tuple with scheme and file path (if file scheme at least).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also do that in another PR?

def _to_resource_uri(path: str, prefix: str) -> str:
"""
Terrible hacks from ST core leak into packages as well.

See: https://github.com/sublimehq/sublime_text/issues/3742
"""
return "res://Packages{}".format(quote(path[len(prefix):]))


def _lowercase_driveletter(match: Any) -> str:
"""
For compatibility with certain other language clients.
"""
return "{}:/".format(match.group(1).lower())
Loading