diff --git a/plugin/code_actions.py b/plugin/code_actions.py index c105a5391..157e948a7 100644 --- a/plugin/code_actions.py +++ b/plugin/code_actions.py @@ -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) diff --git a/plugin/core/configurations.py b/plugin/core/configurations.py index 53a68ee95..a1d076f3f 100644 --- a/plugin/core/configurations.py +++ b/plugin/core/configurations.py @@ -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): @@ -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() diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 646f070fe..0d9271939 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -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 @@ -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: ... @@ -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, @@ -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 @@ -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: @@ -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}) diff --git a/plugin/core/types.py b/plugin/core/types.py index 4cb819d70..a2467a60e 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -13,6 +13,8 @@ import socket import sublime import time +import urllib.parse + TCP_CONNECT_TIMEOUT = 5 # seconds FEATURES_TIMEOUT = 300 # milliseconds @@ -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 @@ -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, @@ -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: @@ -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"), @@ -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"), @@ -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), @@ -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: diff --git a/plugin/core/url.py b/plugin/core/url.py index d263d4147..cb8b6cf02 100644 --- a/plugin/core/url.py +++ b/plugin/core/url.py @@ -1,15 +1,43 @@ +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': @@ -17,3 +45,19 @@ def uri_to_filename(uri: str) -> str: return url2pathname(parsed.path).strip('\\') else: return url2pathname(parsed.path) + + +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()) diff --git a/plugin/core/views.py b/plugin/core/views.py index 4e1dc42ca..1de05e96b 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -16,7 +16,6 @@ from .settings import userprefs from .types import ClientConfig from .typing import Callable, Optional, Dict, Any, Iterable, List, Union, Tuple, Sequence, cast -from .url import filename_to_uri from .workspace import is_subpath_of from urllib.parse import urlparse import html @@ -198,25 +197,25 @@ def location_to_encoded_filename(location: Union[Location, LocationLink]) -> str return to_encoded_filename(*get_uri_and_position_from_location(location)) -class MissingFilenameError(Exception): +class MissingUriError(Exception): def __init__(self, view_id: int) -> None: - super().__init__("View {} has no filename".format(view_id)) + super().__init__("View {} has no URI".format(view_id)) self.view_id = view_id -def uri_from_view(view: sublime.View) -> str: - file_name = view.file_name() - if file_name: - return filename_to_uri(file_name) - raise MissingFilenameError(view.id()) +def uri_from_view(view: sublime.View) -> DocumentUri: + uri = view.settings().get("lsp_uri") + if isinstance(uri, DocumentUri): + return uri + raise MissingUriError(view.id()) -def text_document_identifier(view_or_file_name: Union[str, sublime.View]) -> Dict[str, Any]: - if isinstance(view_or_file_name, str): - uri = filename_to_uri(view_or_file_name) +def text_document_identifier(view_or_uri: Union[DocumentUri, sublime.View]) -> Dict[str, Any]: + if isinstance(view_or_uri, DocumentUri): + uri = view_or_uri else: - uri = uri_from_view(view_or_file_name) + uri = uri_from_view(view_or_uri) return {"uri": uri} @@ -285,22 +284,22 @@ def did_change_text_document_params(view: sublime.View, version: int, return result -def will_save_text_document_params(view_or_file_name: Union[str, sublime.View], reason: int) -> Dict[str, Any]: - return {"textDocument": text_document_identifier(view_or_file_name), "reason": reason} +def will_save_text_document_params(view_or_uri: Union[DocumentUri, sublime.View], reason: int) -> Dict[str, Any]: + return {"textDocument": text_document_identifier(view_or_uri), "reason": reason} def did_save_text_document_params( - view: sublime.View, include_text: bool, file_name: Optional[str] = None + view: sublime.View, include_text: bool, uri: Optional[DocumentUri] = None ) -> Dict[str, Any]: - identifier = text_document_identifier(file_name if file_name is not None else view) + identifier = text_document_identifier(uri if uri is not None else view) result = {"textDocument": identifier} # type: Dict[str, Any] if include_text: result["text"] = entire_content(view) return result -def did_close_text_document_params(file_name: str) -> Dict[str, Any]: - return {"textDocument": text_document_identifier(file_name)} +def did_close_text_document_params(uri: DocumentUri) -> Dict[str, Any]: + return {"textDocument": text_document_identifier(uri)} def did_open(view: sublime.View, language_id: str) -> Notification: @@ -312,20 +311,20 @@ def did_change(view: sublime.View, version: int, return Notification.didChange(did_change_text_document_params(view, version, changes)) -def will_save(file_name: str, reason: int) -> Notification: - return Notification.willSave(will_save_text_document_params(file_name, reason)) +def will_save(uri: DocumentUri, reason: int) -> Notification: + return Notification.willSave(will_save_text_document_params(uri, reason)) def will_save_wait_until(view: sublime.View, reason: int) -> Request: return Request.willSaveWaitUntil(will_save_text_document_params(view, reason), view) -def did_save(view: sublime.View, include_text: bool, file_name: Optional[str] = None) -> Notification: - return Notification.didSave(did_save_text_document_params(view, include_text, file_name)) +def did_save(view: sublime.View, include_text: bool, uri: Optional[DocumentUri] = None) -> Notification: + return Notification.didSave(did_save_text_document_params(view, include_text, uri)) -def did_close(file_name: str) -> Notification: - return Notification.didClose(did_close_text_document_params(file_name)) +def did_close(uri: DocumentUri) -> Notification: + return Notification.didClose(did_close_text_document_params(uri)) def formatting_options(settings: sublime.Settings) -> Dict[str, Any]: @@ -370,15 +369,12 @@ def selection_range_params(view: sublime.View) -> Dict[str, Any]: def text_document_code_action_params( view: sublime.View, - file_name: str, region: sublime.Region, diagnostics: Sequence[Diagnostic], on_save_actions: Optional[Sequence[str]] = None ) -> Dict[str, Any]: params = { - "textDocument": { - "uri": filename_to_uri(file_name) - }, + "textDocument": text_document_identifier(view), "range": region_to_range(view, region).to_lsp(), "context": { "diagnostics": diagnostics diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 60ca1335a..76a77020c 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -34,6 +34,7 @@ import os import sublime import threading +import urllib.parse _NO_DIAGNOSTICS_PLACEHOLDER = " No diagnostics. Well done!" @@ -247,10 +248,15 @@ def _dequeue_listener_async(self) -> None: def _publish_sessions_to_listener_async(self, listener: AbstractViewListener) -> None: inside_workspace = self._workspace.contains(listener.view) + scheme = urllib.parse.urlparse(listener.get_uri()).scheme for session in self._sessions: - if session.can_handle(listener.view, None, inside_workspace): + if session.can_handle(listener.view, scheme, capability=None, inside_workspace=inside_workspace): # debug("registering session", session.config.name, "to listener", listener) - listener.on_session_initialized_async(session) + try: + listener.on_session_initialized_async(session) + except Exception as ex: + message = "failed to register session {} to listener {}".format(session.config.name, listener) + exception_log(message, ex) def window(self) -> sublime.Window: return self._window @@ -258,8 +264,12 @@ def window(self) -> sublime.Window: def sessions(self, view: sublime.View, capability: Optional[str] = None) -> Generator[Session, None, None]: inside_workspace = self._workspace.contains(view) sessions = list(self._sessions) + uri = view.settings().get("lsp_uri") + if not isinstance(uri, str): + return + scheme = urllib.parse.urlparse(uri).scheme for session in sessions: - if session.can_handle(view, capability, inside_workspace): + if session.can_handle(view, scheme, capability, inside_workspace): yield session def get_session(self, config_name: str, file_path: str) -> Optional[Session]: @@ -278,7 +288,7 @@ def _find_session(self, config_name: str, file_path: str) -> Optional[Session]: def _needed_config(self, view: sublime.View) -> Optional[ClientConfig]: configs = self._configs.match_view(view) handled = False - file_name = view.file_name() or '' + file_name = view.file_name() inside = self._workspace.contains(view) for config in configs: handled = False diff --git a/plugin/documents.py b/plugin/documents.py index f3d6ce261..414ff6d35 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -19,7 +19,9 @@ from .core.types import basescope2languageid from .core.types import debounced from .core.types import FEATURES_TIMEOUT +from .core.types import SettingsRegistration from .core.typing import Any, Callable, Optional, Dict, Generator, Iterable, List, Tuple, Union +from .core.url import view_to_uri from .core.views import DIAGNOSTIC_SEVERITY from .core.views import document_color_params from .core.views import first_selection_region @@ -41,6 +43,7 @@ import sublime import sublime_plugin import textwrap +import weakref import webbrowser @@ -67,9 +70,8 @@ def is_regular_view(v: sublime.View) -> bool: - # Not from the quick panel (CTRL+P), must have a filename on-disk, and not a special view like a console, - # output panel or find-in-files panels. - return not v.sheet().is_transient() and bool(v.file_name()) and v.element() is None + # Not from the quick panel (CTRL+P), and not a special view like a console, output panel or find-in-files panels. + return not v.sheet().is_transient() and v.element() is None def previous_non_whitespace_char(view: sublime.View, pt: int) -> str: @@ -134,25 +136,46 @@ class DocumentSyncListener(sublime_plugin.ViewEventListener, AbstractViewListene highlights_debounce_time = FEATURES_TIMEOUT code_lenses_debounce_time = FEATURES_TIMEOUT + _uri = None # type: str + @classmethod def applies_to_primary_view_only(cls) -> bool: return False def __init__(self, view: sublime.View) -> None: super().__init__(view) + weakself = weakref.ref(self) + + def on_change() -> None: + nonlocal weakself + this = weakself() + if this is not None: + this._on_settings_object_changed() + + self._current_syntax = None + foreign_uri = view.settings().get("lsp_uri") + if isinstance(foreign_uri, str): + self._uri = foreign_uri + else: + self.set_uri(view_to_uri(view)) + self._registration = SettingsRegistration(view.settings(), on_change=on_change) self._setup() def __del__(self) -> None: self._cleanup() def _setup(self) -> None: + syntax = self.view.syntax() + if syntax: + self._language_id = basescope2languageid(syntax.scope) # type: str + else: + debug("view", self.view.id(), "has no syntax") + self._language_id = "" self._manager = None # type: Optional[WindowManager] self._session_views = {} # type: Dict[str, SessionView] self._stored_region = sublime.Region(-1, -1) self._color_phantoms = sublime.PhantomSet(self.view, "lsp_color") self._sighelp = None # type: Optional[SigHelp] - self._uri = "" - self._language_id = "" self._registered = False def _cleanup(self) -> None: @@ -166,6 +189,13 @@ def _cleanup(self) -> None: self._clear_highlight_regions() self._clear_session_views_async() + def _reset(self) -> None: + # Have to do this on the main thread, since __init__ and __del__ are invoked on the main thread too + self._cleanup() + self._setup() + # But this has to run on the async thread again + sublime.set_timeout_async(self.on_activated_async) + # --- Implements AbstractViewListener ------------------------------------------------------------------------------ def on_post_move_window_async(self) -> None: @@ -177,21 +207,13 @@ def on_post_move_window_async(self) -> None: if new_window.id() == old_window.id(): return self._manager.unregister_listener_async(self) - - def reset() -> None: - # Have to do this on the main thread, since __init__ and __del__ are invoked on the main thread too - self._cleanup() - self._setup() - # But this has to run on the async thread again - sublime.set_timeout_async(self.on_activated_async) - - sublime.set_timeout(reset) + sublime.set_timeout(self._reset) def on_session_initialized_async(self, session: Session) -> None: assert not self.view.is_loading() added = False if session.config.name not in self._session_views: - self._session_views[session.config.name] = SessionView(self, session) + self._session_views[session.config.name] = SessionView(self, session, self._uri) buf = self.view.buffer() if buf: text_change_listener = TextChangeListener.ids_to_listeners.get(buf.buffer_id) @@ -314,6 +336,10 @@ def on_text_changed_async(self, change_count: int, changes: Iterable[sublime.Tex def get_uri(self) -> str: return self._uri + def set_uri(self, new_uri: str) -> None: + self._uri = new_uri + self.view.settings().set("lsp_uri", self._uri) + def get_language_id(self) -> str: return self._language_id @@ -343,9 +369,12 @@ def on_selection_modified_async(self) -> None: self._resolve_visible_code_lenses_async() def on_post_save_async(self) -> None: + # Re-determine the URI; this time it's guaranteed to be a file because ST can only save files to a real + # filesystem. + self.set_uri(view_to_uri(self.view)) if self.view.is_primary(): for sv in self.session_views_async(): - sv.on_post_save_async() + sv.on_post_save_async(self._uri) def on_close(self) -> None: if self._registered and self._manager: @@ -684,7 +713,7 @@ def purge_changes_async(self) -> None: def trigger_on_pre_save_async(self) -> None: for sv in self.session_views_async(): - sv.on_pre_save_async(self.view.file_name() or "") + sv.on_pre_save_async() def sum_total_errors_and_warnings_async(self) -> Tuple[int, int]: errors = 0 @@ -710,11 +739,6 @@ def _when_selection_remains_stable_async(self, f: Callable[[], None], r: sublime debounced(f, after_ms, lambda: self._stored_region == r, async_thread=True) def _register_async(self) -> None: - syntax = self.view.syntax() - if not syntax: - debug("view", self.view.id(), "has no syntax") - return - self._language_id = basescope2languageid(syntax.scope) buf = self.view.buffer() if not buf: debug("not tracking bufferless view", self.view.id()) @@ -771,5 +795,11 @@ def clear_async() -> None: sublime.set_timeout_async(clear_async) + def _on_settings_object_changed(self) -> None: + new_syntax = self.view.settings().get("syntax") + if new_syntax != self._current_syntax: + self._current_syntax = new_syntax + self._reset() + def __repr__(self) -> str: return "ViewListener({})".format(self.view.id()) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 7e7c30b45..afaf1d002 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -1,8 +1,9 @@ from .core.protocol import Diagnostic from .core.protocol import DiagnosticSeverity +from .core.protocol import DocumentUri +from .core.protocol import Range from .core.protocol import TextDocumentSyncKindFull from .core.protocol import TextDocumentSyncKindNone -from .core.protocol import Range from .core.sessions import SessionViewProtocol from .core.settings import userprefs from .core.types import Capabilities @@ -17,7 +18,7 @@ from .core.views import did_open from .core.views import did_save from .core.views import format_diagnostic_for_panel -from .core.views import MissingFilenameError +from .core.views import MissingUriError from .core.views import range_to_region from .core.views import will_save from weakref import WeakSet @@ -56,24 +57,20 @@ class SessionBuffer: """ Holds state per session per buffer. - It stores the filename, handles document synchronization for the buffer, and stores/receives diagnostics for the + It stores the URI, handles document synchronization for the buffer, and stores/receives diagnostics for the buffer. The diagnostics are then published further to the views attached to this buffer. It also maintains the dynamically registered capabilities applicable to this particular buffer. """ - def __init__(self, session_view: SessionViewProtocol, buffer_id: int, language_id: str) -> None: + def __init__(self, session_view: SessionViewProtocol, buffer_id: int, uri: DocumentUri) -> None: view = session_view.view - file_name = view.file_name() - if not file_name: - raise ValueError("missing filename") self.opened = False # Every SessionBuffer has its own personal capabilities due to "dynamic registration". self.capabilities = Capabilities() self.session = session_view.session self.session_views = WeakSet() # type: WeakSet[SessionViewProtocol] self.session_views.add(session_view) - self.file_name = file_name - self.language_id = language_id + self.last_known_uri = uri self.id = buffer_id self.pending_changes = None # type: Optional[PendingChanges] self.diagnostics = [] # type: List[Tuple[Diagnostic, sublime.Region]] @@ -102,14 +99,35 @@ def __del__(self) -> None: def _check_did_open(self, view: sublime.View) -> None: if not self.opened and self.should_notify_did_open(): - self.session.send_notification(did_open(view, self.language_id)) + language_id = self.get_language_id() + if not language_id: + # we're closing + return + self.session.send_notification(did_open(view, language_id)) self.opened = True def _check_did_close(self) -> None: if self.opened and self.should_notify_did_close(): - self.session.send_notification(did_close(self.file_name)) + self.session.send_notification(did_close(uri=self.last_known_uri)) self.opened = False + def get_uri(self) -> Optional[str]: + for sv in self.session_views: + return sv.get_uri() + return None + + def get_language_id(self) -> Optional[str]: + for sv in self.session_views: + return sv.get_language_id() + return None + + @property + def language_id(self) -> str: + """ + Deprecated: use get_language_id + """ + return self.get_language_id() or "" + def add_session_view(self, sv: SessionViewProtocol) -> None: self.session_views.add(sv) @@ -210,28 +228,26 @@ def purge_changes_async(self, view: sublime.View) -> None: try: notification = did_change(view, version, changes) self.session.send_notification(notification) - except MissingFilenameError: + except MissingUriError: pass # we're closing self.pending_changes = None - def on_pre_save_async(self, view: sublime.View, old_file_name: str) -> None: + def on_pre_save_async(self, view: sublime.View) -> None: if self.should_notify_will_save(): self.purge_changes_async(view) # TextDocumentSaveReason.Manual - self.session.send_notification(will_save(old_file_name, 1)) + self.session.send_notification(will_save(self.last_known_uri, 1)) - def on_post_save_async(self, view: sublime.View) -> None: - file_name = view.file_name() - if file_name and file_name != self.file_name: + def on_post_save_async(self, view: sublime.View, new_uri: DocumentUri) -> None: + if new_uri != self.last_known_uri: self._check_did_close() - self.file_name = file_name + self.last_known_uri = new_uri self._check_did_open(view) else: send_did_save, include_text = self.should_notify_did_save() if send_did_save: self.purge_changes_async(view) - # mypy: expected sublime.View, got ViewLike - self.session.send_notification(did_save(view, include_text, self.file_name)) + self.session.send_notification(did_save(view, include_text, self.last_known_uri)) if self.should_show_diagnostics_panel: mgr = self.session.manager() if mgr: @@ -354,4 +370,4 @@ def _present_diagnostics_async( mgr.update_diagnostics_panel_async() def __str__(self) -> str: - return '{}:{}:{}'.format(self.session.config.name, self.id, self.file_name) + return '{}:{}:{}'.format(self.session.config.name, self.id, self.get_uri()) diff --git a/plugin/session_view.py b/plugin/session_view.py index 310464e8a..ce2c0f3aa 100644 --- a/plugin/session_view.py +++ b/plugin/session_view.py @@ -3,6 +3,7 @@ from .core.promise import Promise from .core.protocol import CodeLens from .core.protocol import DiagnosticTag +from .core.protocol import DocumentUri from .core.protocol import Notification from .core.protocol import Request from .core.sessions import Session @@ -15,8 +16,8 @@ from .session_buffer import SessionBuffer from weakref import ref from weakref import WeakValueDictionary -import sublime import functools +import sublime DIAGNOSTIC_TAG_VALUES = [v for (k, v) in DiagnosticTag.__dict__.items() if not k.startswith('_')] @@ -35,7 +36,7 @@ class SessionView: _session_buffers = WeakValueDictionary() # type: WeakValueDictionary[Tuple[int, int], SessionBuffer] - def __init__(self, listener: AbstractViewListener, session: Session) -> None: + def __init__(self, listener: AbstractViewListener, session: Session, uri: DocumentUri) -> None: self.view = listener.view self.session = session self.active_requests = {} # type: Dict[int, Request] @@ -47,7 +48,7 @@ def __init__(self, listener: AbstractViewListener, session: Session) -> None: key = (id(session), buffer_id) session_buffer = self._session_buffers.get(key) if session_buffer is None: - session_buffer = SessionBuffer(self, buffer_id, listener.get_language_id()) + session_buffer = SessionBuffer(self, buffer_id, uri) self._session_buffers[key] = session_buffer else: session_buffer.add_session_view(self) @@ -151,6 +152,14 @@ def _decrement_hover_count(self) -> None: settings.erase(self.HOVER_PROVIDER_COUNT_KEY) settings.set(self.SHOW_DEFINITIONS_KEY, True) + def get_uri(self) -> Optional[str]: + listener = self.listener() + return listener.get_uri() if listener else None + + def get_language_id(self) -> Optional[str]: + listener = self.listener() + return listener.get_language_id() if listener else None + def get_capability_async(self, capability_path: str) -> Optional[Any]: return self.session_buffer.get_capability(capability_path) @@ -258,11 +267,11 @@ def on_reload_async(self) -> None: def purge_changes_async(self) -> None: self.session_buffer.purge_changes_async(self.view) - def on_pre_save_async(self, old_file_name: str) -> None: - self.session_buffer.on_pre_save_async(self.view, old_file_name) + def on_pre_save_async(self) -> None: + self.session_buffer.on_pre_save_async(self.view) - def on_post_save_async(self) -> None: - self.session_buffer.on_post_save_async(self.view) + def on_post_save_async(self, new_uri: DocumentUri) -> None: + self.session_buffer.on_post_save_async(self.view, new_uri) def _start_progress_reporter_async(self, request_id: int, title: str) -> ViewProgressReporter: progress = ViewProgressReporter( diff --git a/tests/test_configurations.py b/tests/test_configurations.py index 871952644..89d1d96e2 100644 --- a/tests/test_configurations.py +++ b/tests/test_configurations.py @@ -12,12 +12,12 @@ class GlobalConfigManagerTests(unittest.TestCase): def test_empty_configs(self): manager = ConfigManager({}) window_mgr = manager.for_window(sublime.active_window()) - self.assertEqual(list(window_mgr.all.values()), []) + self.assertNotIn(TEST_CONFIG.name, window_mgr.all) def test_global_config(self): manager = ConfigManager({TEST_CONFIG.name: TEST_CONFIG}) window_mgr = manager.for_window(sublime.active_window()) - self.assertEqual(list(window_mgr.all.values()), [TEST_CONFIG]) + self.assertIn(TEST_CONFIG.name, window_mgr.all) def test_override_config(self): manager = ConfigManager({TEST_CONFIG.name: TEST_CONFIG}) @@ -32,12 +32,16 @@ class WindowConfigManagerTests(unittest.TestCase): def test_no_configs(self): view = sublime.active_window().active_view() + self.assertIsNotNone(view) + assert view manager = WindowConfigManager(sublime.active_window(), {}) - self.assertFalse(manager.is_supported(view)) + self.assertEqual(list(manager.match_view(view)), []) def test_with_single_config(self): window = sublime.active_window() view = window.active_view() + self.assertIsNotNone(view) + assert view manager = WindowConfigManager(window, {TEST_CONFIG.name: TEST_CONFIG}) view.syntax = MagicMock(return_value=sublime.Syntax( path="Packages/Text/Plain text.tmLanguage", @@ -45,7 +49,7 @@ def test_with_single_config(self): scope="text.plain", hidden=False )) - self.assertTrue(manager.is_supported(view)) + view.settings().set("lsp_uri", "file:///foo/bar.txt") self.assertEqual(list(manager.match_view(view)), [TEST_CONFIG]) def test_applies_project_settings(self): @@ -68,6 +72,7 @@ def test_applies_project_settings(self): scope="text.plain", hidden=False )) + view.settings().set("lsp_uri", "file:///foo/bar.txt") configs = list(manager.match_view(view)) self.assertEqual(len(configs), 1) config = configs[0] diff --git a/tests/test_views.py b/tests/test_views.py index ec143abd9..d0d611832 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -2,6 +2,7 @@ from LSP.plugin.core.protocol import Diagnostic from LSP.plugin.core.protocol import Point from LSP.plugin.core.protocol import Range +from LSP.plugin.core.types import Any from LSP.plugin.core.url import filename_to_uri from LSP.plugin.core.views import did_change from LSP.plugin.core.views import did_open @@ -11,11 +12,12 @@ from LSP.plugin.core.views import FORMAT_STRING, FORMAT_MARKED_STRING, FORMAT_MARKUP_CONTENT, minihtml from LSP.plugin.core.views import lsp_color_to_html from LSP.plugin.core.views import lsp_color_to_phantom -from LSP.plugin.core.views import MissingFilenameError +from LSP.plugin.core.views import MissingUriError from LSP.plugin.core.views import point_to_offset from LSP.plugin.core.views import range_to_region from LSP.plugin.core.views import selection_range_params from LSP.plugin.core.views import text2html +from LSP.plugin.core.views import text_document_code_action_params from LSP.plugin.core.views import text_document_formatting from LSP.plugin.core.views import text_document_position_params from LSP.plugin.core.views import text_document_range_formatting @@ -43,12 +45,25 @@ def tearDown(self) -> None: self.view.close() return super().tearDown() - def test_missing_filename(self) -> None: - self.view.file_name = MagicMock(return_value=None) - with self.assertRaises(MissingFilenameError): + def test_missing_uri(self) -> None: + self.view.settings().erase("lsp_uri") + with self.assertRaises(MissingUriError): uri_from_view(self.view) + def test_nonmissing_uri(self) -> None: + + class MockSettings: + + def get(value: str, default: Any) -> Any: + return "file:///hello/there.txt" + + mock_settings = MockSettings() + self.view.settings = MagicMock(return_value=mock_settings) + uri = uri_from_view(self.view) + self.assertEqual(uri, "file:///hello/there.txt") + def test_did_open(self) -> None: + self.view.settings().set("lsp_uri", filename_to_uri(self.mock_file_name)) self.assertEqual(did_open(self.view, "python").params, { "textDocument": { "uri": filename_to_uri(self.mock_file_name), @@ -60,6 +75,7 @@ def test_did_open(self) -> None: def test_did_change_full(self) -> None: version = self.view.change_count() + self.view.settings().set("lsp_uri", filename_to_uri(self.mock_file_name)) self.assertEqual(did_change(self.view, version).params, { "textDocument": { "uri": filename_to_uri(self.mock_file_name), @@ -69,18 +85,21 @@ def test_did_change_full(self) -> None: }) def test_will_save(self) -> None: - self.assertEqual(will_save(self.view.file_name() or '', 42).params, { + self.view.settings().set("lsp_uri", filename_to_uri(self.mock_file_name)) + self.assertEqual(will_save(filename_to_uri(self.mock_file_name), 42).params, { "textDocument": {"uri": filename_to_uri(self.mock_file_name)}, "reason": 42 }) def test_will_save_wait_until(self) -> None: + self.view.settings().set("lsp_uri", filename_to_uri(self.mock_file_name)) self.assertEqual(will_save_wait_until(self.view, 1337).params, { "textDocument": {"uri": filename_to_uri(self.mock_file_name)}, "reason": 1337 }) def test_did_save(self) -> None: + self.view.settings().set("lsp_uri", filename_to_uri(self.mock_file_name)) self.assertEqual(did_save(self.view, include_text=False).params, { "textDocument": {"uri": filename_to_uri(self.mock_file_name)} }) @@ -90,6 +109,7 @@ def test_did_save(self) -> None: }) def test_text_document_position_params(self) -> None: + self.view.settings().set("lsp_uri", filename_to_uri(self.mock_file_name)) self.assertEqual(text_document_position_params(self.view, 2), { "textDocument": {"uri": filename_to_uri(self.mock_file_name)}, "position": {"line": 0, "character": 2} @@ -98,7 +118,10 @@ def test_text_document_position_params(self) -> None: def test_text_document_formatting(self) -> None: self.view.settings = MagicMock(return_value={ "translate_tabs_to_spaces": False, - "tab_size": 1234, "ensure_newline_at_eof_on_save": True}) + "tab_size": 1234, + "ensure_newline_at_eof_on_save": True, + "lsp_uri": filename_to_uri(self.mock_file_name) + }) self.assertEqual(text_document_formatting(self.view).params, { "textDocument": {"uri": filename_to_uri(self.mock_file_name)}, "options": { @@ -111,7 +134,10 @@ def test_text_document_formatting(self) -> None: }) def test_text_document_range_formatting(self) -> None: - self.view.settings = MagicMock(return_value={"tab_size": 4321}) + self.view.settings = MagicMock(return_value={ + "tab_size": 4321, + "lsp_uri": filename_to_uri(self.mock_file_name) + }) self.assertEqual(text_document_range_formatting(self.view, sublime.Region(0, 2)).params, { "textDocument": {"uri": filename_to_uri(self.mock_file_name)}, "options": { @@ -140,8 +166,8 @@ def test_point_to_offset_utf16(self) -> None: self.assertEqual(point_to_offset(Point(1, foobarbaz_length + 2), self.view) - offset, 1) def test_selection_range_params(self) -> None: - self.view.sel().clear() - self.view.sel().add_all([sublime.Region(0, 5), sublime.Region(6, 11)]) + self.view.run_command("lsp_selection_set", {"regions": [(0, 5), (6, 11)]}) + self.view.settings().set("lsp_uri", filename_to_uri(self.mock_file_name)) self.assertEqual(len(self.view.sel()), 2) self.assertEqual(self.view.substr(self.view.sel()[0]), "hello") self.assertEqual(self.view.substr(self.view.sel()[1]), "world") @@ -308,9 +334,35 @@ def test_lsp_color_to_phantom(self) -> None: self.assertEqual(phantom.region, range_to_region(Range.from_lsp(response[0]["range"]), self.view)) def test_document_color_params(self) -> None: + self.view.settings().set("lsp_uri", filename_to_uri(self.mock_file_name)) self.assertEqual( document_color_params(self.view), - {"textDocument": {"uri": filename_to_uri(self.view.file_name() or '')}}) + {"textDocument": {"uri": filename_to_uri(self.mock_file_name)}}) + + def test_text_document_code_action_params(self) -> None: + self.view.settings().set("lsp_uri", filename_to_uri(self.mock_file_name)) + diagnostic = { + "message": "oops", + "severity": 1, + "range": { + "start": { + "character": 0, + "line": 0 + }, + "end": { + "character": 1, + "line": 0 + } + } + } # type: Diagnostic + self.view.run_command("append", {"characters": "a b c\n"}) + params = text_document_code_action_params( + view=self.view, + region=sublime.Region(0, 1), + diagnostics=[diagnostic], + on_save_actions=["refactor"] + ) + self.assertEqual(params["textDocument"], {"uri": filename_to_uri(self.mock_file_name)}) def test_format_diagnostic_for_html(self) -> None: diagnostic1 = {