diff --git a/boot.py b/boot.py index dc4b8632e..e86454de7 100644 --- a/boot.py +++ b/boot.py @@ -42,6 +42,7 @@ from .plugin.documents import DocumentSyncListener from .plugin.documents import TextChangeListener from .plugin.edit import LspApplyDocumentEditCommand +from .plugin.edit import LspApplyWorkspaceEditCommand from .plugin.execute_command import LspExecuteCommand from .plugin.folding_range import LspFoldAllCommand from .plugin.folding_range import LspFoldCommand @@ -68,6 +69,7 @@ from .plugin.panels import LspUpdateLogPanelCommand from .plugin.panels import LspUpdatePanelCommand from .plugin.references import LspSymbolReferencesCommand +from .plugin.rename import LspHideRenameButtonsCommand from .plugin.rename import LspSymbolRenameCommand from .plugin.save_command import LspSaveAllCommand from .plugin.save_command import LspSaveCommand diff --git a/docs/src/language_servers.md b/docs/src/language_servers.md index f5cfbc212..41162b1ac 100644 --- a/docs/src/language_servers.md +++ b/docs/src/language_servers.md @@ -717,6 +717,24 @@ Follow installation instructions on [LSP-tailwindcss](https://github.com/sublime Follow installation instructions on [LSP-terraform](https://github.com/sublimelsp/LSP-terraform). +## Toit + +1. Install the [Toit](https://packagecontrol.io/packages/Toit) package from Package Control for syntax highlighting. +2. Install the [Jaguar Language Server](https://github.com/toitlang/jaguar). +3. Open `Preferences > Package Settings > LSP > Settings` and add the `"jag"` client configuration to the `"clients"`: + + ```jsonc + { + "clients": { + "jag": { + "enabled": true, + "command": ["jag" "lsp"], + "selector": "source.toit" + } + } + } + ``` + ## TypeScript See [Javascript/TypeScript](#javascripttypescript). diff --git a/plugin/core/panels.py b/plugin/core/panels.py index eb8c15ac7..58a5190a1 100644 --- a/plugin/core/panels.py +++ b/plugin/core/panels.py @@ -1,6 +1,6 @@ from .types import PANEL_FILE_REGEX from .types import PANEL_LINE_REGEX -from .typing import Optional +from .typing import Iterable, Optional import sublime @@ -38,6 +38,7 @@ class PanelName: class PanelManager: def __init__(self, window: sublime.Window) -> None: self._window = window + self._rename_panel_buttons = None # type: Optional[sublime.PhantomSet] def destroy_output_panels(self) -> None: for field in filter(lambda a: not a.startswith('__'), PanelName.__dict__.keys()): @@ -46,6 +47,7 @@ def destroy_output_panels(self) -> None: if panel and panel.is_valid(): panel.settings().set("syntax", "Packages/Text/Plain text.tmLanguage") self._window.destroy_output_panel(panel_name) + self._rename_panel_buttons = None def toggle_output_panel(self, panel_type: str) -> None: panel_name = "output.{}".format(panel_type) @@ -91,6 +93,8 @@ def _create_panel(self, name: str, result_file_regex: str, result_line_regex: st panel = self.create_output_panel(name) if not panel: return None + if name == PanelName.Rename: + self._rename_panel_buttons = sublime.PhantomSet(panel, "lsp_rename_buttons") settings = panel.settings() if result_file_regex: settings.set("result_file_regex", result_file_regex) @@ -121,3 +125,7 @@ def show_diagnostics_panel_async(self) -> None: def hide_diagnostics_panel_async(self) -> None: if self.is_panel_open(PanelName.Diagnostics): self.toggle_output_panel(PanelName.Diagnostics) + + def update_rename_panel_buttons(self, phantoms: Iterable[sublime.Phantom]) -> None: + if self._rename_panel_buttons: + self._rename_panel_buttons.update(phantoms) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 6cc789e07..827ba788c 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -937,6 +937,15 @@ def on_post_start(cls, window: sublime.Window, initiating_view: sublime.View, """ pass + @classmethod + def should_ignore(cls, view: sublime.View) -> bool: + """ + Exclude a view from being handled by the language server, even if it matches the URI scheme(s) and selector from + the configuration. This can be used to, for example, ignore certain file patterns which are listed in a + configuration file (e.g. .gitignore). + """ + return False + @classmethod def markdown_language_id_to_st_syntax_map(cls) -> Optional[MarkdownLangMap]: """ @@ -1383,6 +1392,9 @@ def compare_by_string(sb: Optional[SessionBufferProtocol]) -> bool: 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 self._plugin and self._plugin.should_ignore(view): + debug(view, "ignored by plugin", self._plugin.__class__.__name__) + return False if scheme == "file": file_name = view.file_name() if not file_name: @@ -1759,8 +1771,7 @@ def _apply_code_action_async( arguments = command.get("arguments") if arguments is not None: execute_command['arguments'] = arguments - return promise.then( - lambda _: self.execute_command(execute_command, progress=False, view=view)) + return promise.then(lambda _: self.execute_command(execute_command, progress=False, view=view)) return promise def apply_workspace_edit_async(self, edit: WorkspaceEdit) -> Promise[None]: @@ -1771,12 +1782,16 @@ def apply_workspace_edit_async(self, edit: WorkspaceEdit) -> Promise[None]: return self.apply_parsed_workspace_edits(parse_workspace_edit(edit)) def apply_parsed_workspace_edits(self, changes: WorkspaceChanges) -> Promise[None]: + active_sheet = self.window.active_sheet() + selected_sheets = self.window.selected_sheets() promises = [] # type: List[Promise[None]] for uri, (edits, view_version) in changes.items(): promises.append( self.open_uri_async(uri).then(functools.partial(self._apply_text_edits, edits, view_version, uri)) ) - return Promise.all(promises).then(lambda _: None) + return Promise.all(promises) \ + .then(lambda _: self._set_selected_sheets(selected_sheets)) \ + .then(lambda _: self._set_focused_sheet(active_sheet)) def _apply_text_edits( self, edits: List[TextEdit], view_version: Optional[int], uri: str, view: Optional[sublime.View] @@ -1786,6 +1801,14 @@ def _apply_text_edits( return apply_text_edits(view, edits, required_view_version=view_version) + def _set_selected_sheets(self, sheets: List[sublime.Sheet]) -> None: + if len(sheets) > 1 and len(self.window.selected_sheets()) != len(sheets): + self.window.select_sheets(sheets) + + def _set_focused_sheet(self, sheet: Optional[sublime.Sheet]) -> None: + if sheet and sheet != self.window.active_sheet(): + self.window.focus_sheet(sheet) + def decode_semantic_token( self, token_type_encoded: int, token_modifiers_encoded: int) -> Tuple[str, List[str], Optional[str]]: types_legend = tuple(cast(List[str], self.get_capability('semanticTokensProvider.legend.tokenTypes'))) @@ -1914,8 +1937,8 @@ def m_workspace_configuration(self, params: Dict[str, Any], request_id: Any) -> def m_workspace_applyEdit(self, params: Any, request_id: Any) -> None: """handles the workspace/applyEdit request""" - self.apply_workspace_edit_async(params.get('edit', {})).then( - lambda _: self.send_response(Response(request_id, {"applied": True}))) + self.apply_workspace_edit_async(params.get('edit', {})) \ + .then(lambda _: self.send_response(Response(request_id, {"applied": True}))) def m_workspace_codeLens_refresh(self, _: Any, request_id: Any) -> None: """handles the workspace/codeLens/refresh request""" diff --git a/plugin/core/types.py b/plugin/core/types.py index 8ae09bfb1..06710a094 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -855,7 +855,7 @@ def map_client_path_to_server_uri(self, path: str) -> str: def map_server_uri_to_client_path(self, uri: str) -> str: scheme, path = parse_uri(uri) - if scheme != "file": + if scheme not in ("file", "res"): raise ValueError("{}: {} URI scheme is unsupported".format(uri, scheme)) if self.path_maps: for path_map in self.path_maps: diff --git a/plugin/core/url.py b/plugin/core/url.py index 024379105..1f6699f91 100644 --- a/plugin/core/url.py +++ b/plugin/core/url.py @@ -80,7 +80,7 @@ def _to_resource_uri(path: str, prefix: str) -> str: See: https://github.com/sublimehq/sublime_text/issues/3742 """ - return "res://Packages{}".format(pathname2url(path[len(prefix):])) + return "res:/Packages{}".format(pathname2url(path[len(prefix):])) def _uppercase_driveletter(match: Any) -> str: diff --git a/plugin/core/views.py b/plugin/core/views.py index 060de4264..327e1bb26 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -86,7 +86,7 @@ def __str__(self) -> str: return "invalid URI scheme: {}".format(self.uri) -def get_line(window: sublime.Window, file_name: str, row: int) -> str: +def get_line(window: sublime.Window, file_name: str, row: int, strip: bool = True) -> str: ''' Get the line from the buffer if the view is open, else get line from linecache. row - is 0 based. If you want to get the first line, you should pass 0. @@ -95,11 +95,12 @@ def get_line(window: sublime.Window, file_name: str, row: int) -> str: if view: # get from buffer point = view.text_point(row, 0) - return view.substr(view.line(point)).strip() + line = view.substr(view.line(point)) else: # get from linecache # linecache row is not 0 based, so we increment it by 1 to get the correct line. - return linecache.getline(file_name, row + 1).strip() + line = linecache.getline(file_name, row + 1) + return line.strip() if strip else line def get_storage_path() -> str: diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 1c4cb43f9..80978f601 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -232,7 +232,11 @@ def _needed_config(self, view: sublime.View) -> Optional[ClientConfig]: handled = True break if not handled: - return config + plugin = get_plugin(config.name) + if plugin and plugin.should_ignore(view): + debug(view, "ignored by plugin", plugin.__name__) + else: + return config return None def start_async(self, config: ClientConfig, initiating_view: sublime.View) -> None: diff --git a/plugin/edit.py b/plugin/edit.py index f0f5dd15a..b22dcdac1 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -1,5 +1,8 @@ from .core.edit import parse_range +from .core.logging import debug from .core.protocol import TextEdit +from .core.protocol import WorkspaceEdit +from .core.registry import LspWindowCommand from .core.typing import List, Optional, Any, Generator, Iterable, Tuple from contextlib import contextmanager import operator @@ -24,6 +27,16 @@ def temporary_setting(settings: sublime.Settings, key: str, val: Any) -> Generat settings.set(key, prev_val) +class LspApplyWorkspaceEditCommand(LspWindowCommand): + + def run(self, session_name: str, edit: WorkspaceEdit) -> None: + session = self.session_by_name(session_name) + if not session: + debug('Could not find session', session_name, 'required to apply WorkspaceEdit') + return + sublime.set_timeout_async(lambda: session.apply_workspace_edit_async(edit)) + + class LspApplyDocumentEditCommand(sublime_plugin.TextCommand): re_placeholder = re.compile(r'\$(0|\{0:([^}]*)\})') diff --git a/plugin/hover.py b/plugin/hover.py index d91975028..cde9540c6 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -195,8 +195,10 @@ def request_document_link_async(self, listener: AbstractViewListener, point: int if target: link_promises.append(Promise.resolve(link)) elif sv.has_capability_async("documentLinkProvider.resolveProvider"): - link_promises.append(sv.session.send_request_task(Request.resolveDocumentLink(link, sv.view)).then( - lambda link: self._on_resolved_link(sv.session_buffer, link))) + link_promises.append( + sv.session.send_request_task(Request.resolveDocumentLink(link, sv.view)) + .then(lambda link: self._on_resolved_link(sv.session_buffer, link)) + ) if link_promises: Promise.all(link_promises).then(partial(self._on_all_document_links_resolved, listener, point)) diff --git a/plugin/locationpicker.py b/plugin/locationpicker.py index 2802d5118..8fb390cf3 100644 --- a/plugin/locationpicker.py +++ b/plugin/locationpicker.py @@ -47,7 +47,7 @@ def open_basic_file( if uri.startswith("file:"): filename = session.config.map_server_uri_to_client_path(uri) else: - prefix = 'res://Packages' # Note: keep in sync with core/url.py#_to_resource_uri + prefix = 'res:/Packages' # Note: keep in sync with core/url.py#_to_resource_uri assert uri.startswith(prefix) filename = sublime.packages_path() + url2pathname(uri[len(prefix):]) # Window.open_file can only focus and scroll to a location in a resource file if it is already opened diff --git a/plugin/rename.py b/plugin/rename.py index 07f89d7ba..3acdbd626 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -24,10 +24,66 @@ import sublime_plugin +BUTTONS_TEMPLATE = """ + + + Apply  + Discard +""" + +DISCARD_COMMAND_URL = sublime.command_url('chain', { + 'commands': [ + ['hide_panel', {}], + ['lsp_hide_rename_buttons', {}] + ] +}) + + def is_range_response(result: PrepareRenameResult) -> TypeGuard[Range]: return 'start' in result +def utf16_to_code_points(s: str, col: int) -> int: + """Convert a position from UTF-16 code units to Unicode code points, usable for string slicing.""" + utf16_len = 0 + idx = 0 + for idx, c in enumerate(s): + if utf16_len >= col: + if utf16_len > col: # If col is in the middle of a character (emoji), don't advance to the next code point + idx -= 1 + break + utf16_len += 1 if ord(c) < 65536 else 2 + else: + idx += 1 # get_line function trims the trailing '\n' + return idx + + # The flow of this command is fairly complicated so it deserves some documentation. # # When "LSP: Rename" is triggered from the Command Palette, the flow can go one of two ways: @@ -134,17 +190,17 @@ def _on_rename_result_async(self, session: Session, response: Optional[Workspace if not response: return session.window.status_message('Nothing to rename') changes = parse_workspace_edit(response) - count = len(changes.keys()) - if count == 1: + file_count = len(changes.keys()) + if file_count == 1: session.apply_parsed_workspace_edits(changes) return total_changes = sum(map(len, changes.values())) - message = "Replace {} occurrences across {} files?".format(total_changes, count) - choice = sublime.yes_no_cancel_dialog(message, "Replace", "Dry Run") + message = "Replace {} occurrences across {} files?".format(total_changes, file_count) + choice = sublime.yes_no_cancel_dialog(message, "Replace", "Preview", title="Rename") if choice == sublime.DIALOG_YES: session.apply_parsed_workspace_edits(changes) elif choice == sublime.DIALOG_NO: - self._render_rename_panel(changes, total_changes, count) + self._render_rename_panel(response, changes, total_changes, file_count, session.config.name) def _on_prepare_result(self, pos: int, response: Optional[PrepareRenameResult]) -> None: if response is None: @@ -172,39 +228,91 @@ def _get_relative_path(self, file_path: str) -> str: base_dir = wm.get_project_path(file_path) return os.path.relpath(file_path, base_dir) if base_dir else file_path - def _render_rename_panel(self, changes_per_uri: WorkspaceChanges, total_changes: int, file_count: int) -> None: + def _render_rename_panel( + self, + workspace_edit: WorkspaceEdit, + changes_per_uri: WorkspaceChanges, + total_changes: int, + file_count: int, + session_name: str + ) -> None: wm = windows.lookup(self.view.window()) if not wm: return - panel = wm.panel_manager and wm.panel_manager.ensure_rename_panel() + pm = wm.panel_manager + if not pm: + return + panel = pm.ensure_rename_panel() if not panel: return to_render = [] # type: List[str] + reference_document = [] # type: List[str] + header_lines = "{} changes across {} files.\n".format(total_changes, file_count) + to_render.append(header_lines) + reference_document.append(header_lines) + ROWCOL_PREFIX = " {:>4}:{:<4} {}" for uri, (changes, _) in changes_per_uri.items(): scheme, file = parse_uri(uri) - if scheme == "file": - to_render.append('{}:'.format(self._get_relative_path(file))) - else: - to_render.append('{}:'.format(uri)) + filename_line = '{}:'.format(self._get_relative_path(file) if scheme == 'file' else uri) + to_render.append(filename_line) + reference_document.append(filename_line) for edit in changes: - start = parse_range(edit['range']['start']) - if scheme == "file": - line_content = get_line(wm.window, file, start[0]) + start_row, start_col_utf16 = parse_range(edit['range']['start']) + line_content = get_line(wm.window, file, start_row, strip=False) if scheme == 'file' else \ + '' + start_col = utf16_to_code_points(line_content, start_col_utf16) + original_line = ROWCOL_PREFIX.format(start_row + 1, start_col + 1, line_content.strip() + "\n") + reference_document.append(original_line) + if scheme == "file" and line_content: + end_row, end_col_utf16 = parse_range(edit['range']['end']) + new_text_rows = edit['newText'].split('\n') + new_line_content = line_content[:start_col] + new_text_rows[0] + if start_row == end_row and len(new_text_rows) == 1: + end_col = start_col if end_col_utf16 <= start_col_utf16 else \ + utf16_to_code_points(line_content, end_col_utf16) + if end_col < len(line_content): + new_line_content += line_content[end_col:] + to_render.append( + ROWCOL_PREFIX.format(start_row + 1, start_col + 1, new_line_content.strip() + "\n")) else: - line_content = '' - to_render.append(" {:>4}:{:<4} {}".format(start[0] + 1, start[1] + 1, line_content)) - to_render.append("") # this adds a spacing between filenames + to_render.append(original_line) characters = "\n".join(to_render) base_dir = wm.get_project_path(self.view.file_name() or "") panel.settings().set("result_base_dir", base_dir) panel.run_command("lsp_clear_panel") wm.window.run_command("show_panel", {"panel": "output.rename"}) - fmt = "{} changes across {} files.\n\n{}" panel.run_command('append', { - 'characters': fmt.format(total_changes, file_count, characters), + 'characters': characters, 'force': True, 'scroll_to_end': False }) + panel.set_reference_document("\n".join(reference_document)) + selection = panel.sel() + selection.add(sublime.Region(0, panel.size())) + panel.run_command('toggle_inline_diff') + selection.clear() + BUTTONS_HTML = BUTTONS_TEMPLATE.format( + apply=sublime.command_url('chain', { + 'commands': [ + [ + 'lsp_apply_workspace_edit', + {'session_name': session_name, 'edit': workspace_edit} + ], + [ + 'hide_panel', + {} + ], + [ + 'lsp_hide_rename_buttons', + {} + ] + ] + }), + discard=DISCARD_COMMAND_URL + ) + pm.update_rename_panel_buttons([ + sublime.Phantom(sublime.Region(len(to_render[0]) - 1), BUTTONS_HTML, sublime.LAYOUT_BLOCK) + ]) class RenameSymbolInputHandler(sublime_plugin.TextInputHandler): @@ -226,3 +334,13 @@ def initial_text(self) -> str: def validate(self, name: str) -> bool: return len(name) > 0 + + +class LspHideRenameButtonsCommand(sublime_plugin.WindowCommand): + + def run(self) -> None: + wm = windows.lookup(self.window) + if not wm: + return + if wm.panel_manager: + wm.panel_manager.update_rename_panel_buttons([]) diff --git a/tests/setup.py b/tests/setup.py index b72f5c134..059b4b7a6 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -210,8 +210,8 @@ def await_promise(cls, promise: Union[YieldPromise, Promise]) -> Generator: def await_run_code_action(self, code_action: Dict[str, Any]) -> Generator: promise = YieldPromise() sublime.set_timeout_async( - lambda: self.session.run_code_action_async(code_action, progress=False, view=self.view).then( - promise.fulfill)) + lambda: self.session.run_code_action_async(code_action, progress=False, view=self.view) + .then(promise.fulfill)) yield from self.await_promise(promise) def set_response(self, method: str, response: Any) -> None: diff --git a/tests/test_rename_panel.py b/tests/test_rename_panel.py new file mode 100644 index 000000000..a9fd78a7a --- /dev/null +++ b/tests/test_rename_panel.py @@ -0,0 +1,50 @@ +from LSP.plugin.rename import utf16_to_code_points +import unittest + + +class LspRenamePanelTests(unittest.TestCase): + + def test_utf16_ascii(self): + s = 'abc' + self.assertEqual(utf16_to_code_points(s, 0), 0) + self.assertEqual(utf16_to_code_points(s, 1), 1) + self.assertEqual(utf16_to_code_points(s, 2), 2) + self.assertEqual(utf16_to_code_points(s, 3), 3) # EOL after last character should count as its own code point + self.assertEqual(utf16_to_code_points(s, 1337), 3) # clamp to EOL + + def test_utf16_deseret_letter(self): + # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocuments + s = 'a𐐀b' + self.assertEqual(len(s), 3) + self.assertEqual(utf16_to_code_points(s, 0), 0) + self.assertEqual(utf16_to_code_points(s, 1), 1) + self.assertEqual(utf16_to_code_points(s, 2), 1) # 𐐀 needs 2 UTF-16 code units, so this is still at code point 1 + self.assertEqual(utf16_to_code_points(s, 3), 2) + self.assertEqual(utf16_to_code_points(s, 4), 3) + self.assertEqual(utf16_to_code_points(s, 1337), 3) + + def test_utf16_emoji(self): + s = 'a😀x' + self.assertEqual(len(s), 3) + self.assertEqual(utf16_to_code_points(s, 0), 0) + self.assertEqual(utf16_to_code_points(s, 1), 1) + self.assertEqual(utf16_to_code_points(s, 2), 1) + self.assertEqual(utf16_to_code_points(s, 3), 2) + self.assertEqual(utf16_to_code_points(s, 4), 3) + self.assertEqual(utf16_to_code_points(s, 1337), 3) + + def test_utf16_emoji_zwj_sequence(self): + # https://unicode.org/emoji/charts/emoji-zwj-sequences.html + s = 'a😵‍💫x' + self.assertEqual(len(s), 5) + self.assertEqual(s, 'a\U0001f635\u200d\U0001f4abx') + # 😵‍💫 consists of 5 UTF-16 code units and Python treats it as 3 characters + self.assertEqual(utf16_to_code_points(s, 0), 0) # a + self.assertEqual(utf16_to_code_points(s, 1), 1) # \U0001f635 + self.assertEqual(utf16_to_code_points(s, 2), 1) # \U0001f635 + self.assertEqual(utf16_to_code_points(s, 3), 2) # \u200d (zero width joiner) + self.assertEqual(utf16_to_code_points(s, 4), 3) # \U0001f4ab + self.assertEqual(utf16_to_code_points(s, 5), 3) # \U0001f4ab + self.assertEqual(utf16_to_code_points(s, 6), 4) # x + self.assertEqual(utf16_to_code_points(s, 7), 5) # after x + self.assertEqual(utf16_to_code_points(s, 1337), 5) diff --git a/tests/test_url.py b/tests/test_url.py index 9d782418a..31a06556f 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -62,7 +62,7 @@ class MultiplatformTests(unittest.TestCase): def test_resource_path(self): uri = filename_to_uri(os.path.join(sublime.installed_packages_path(), "Package Control", "dir", "file.py")) - self.assertEqual(uri, "res://Packages/Package%20Control/dir/file.py") + self.assertEqual(uri, "res:/Packages/Package%20Control/dir/file.py") def test_buffer_uri(self): view = sublime.active_window().active_view()