From 032e3fdff621abf2a0a9c8a7279caf000c7d03eb Mon Sep 17 00:00:00 2001 From: jwortmann Date: Tue, 15 Aug 2023 21:42:52 +0200 Subject: [PATCH] Add support for folding range request (#2304) --- Default.sublime-commands | 5 + Default.sublime-keymap | 9 ++ LSP.sublime-settings | 8 ++ Main.sublime-menu | 17 +++ boot.py | 2 + docs/src/keyboard_shortcuts.md | 2 + plugin/core/protocol.py | 4 + plugin/core/sessions.py | 11 ++ plugin/core/types.py | 2 + plugin/documents.py | 24 ++++ plugin/folding_range.py | 207 +++++++++++++++++++++++++++++++++ stubs/sublime.pyi | 4 +- sublime-package.json | 24 ++++ 13 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 plugin/folding_range.py diff --git a/Default.sublime-commands b/Default.sublime-commands index 86765bffe..b1b0acab4 100644 --- a/Default.sublime-commands +++ b/Default.sublime-commands @@ -162,4 +162,9 @@ "caption": "LSP: Toggle Inlay Hints", "command": "lsp_toggle_inlay_hints", }, + // { + // "caption": "LSP: Fold All Comment Blocks", + // "command": "lsp_fold_all", + // "args": {"kind": "comment"} + // }, ] diff --git a/Default.sublime-keymap b/Default.sublime-keymap index 56cfd9156..2b9760d23 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -213,6 +213,15 @@ // "command": "lsp_expand_selection", // "context": [{"key": "lsp.session_with_capability", "operand": "selectionRangeProvider"}] // }, + // Fold around caret position - an optional "strict" argument can be used to configure whether + // to fold only when the caret is contained within the folded region (true), or even when it is + // anywhere on the starting line (false). + // { + // "keys": ["UNBOUND"], + // "command": "lsp_fold", + // "args": {"strict": true}, + // "context": [{"key": "lsp.session_with_capability", "operand": "foldingRangeProvider"}] + // }, //==== Internal key-bindings ==== { "keys": [""], diff --git a/LSP.sublime-settings b/LSP.sublime-settings index 45b2c8280..b5692d58d 100644 --- a/LSP.sublime-settings +++ b/LSP.sublime-settings @@ -212,6 +212,14 @@ // about how to configure your color scheme for semantic highlighting. "semantic_highlighting": false, + // Determines ranges which initially should be folded when a document is opened, + // provided that the language server has support for this. + "initially_folded": [ + // "comment", + // "imports", + // "region", + ], + // --- Debugging ---------------------------------------------------------------------- // Show verbose debug messages in the sublime console. diff --git a/Main.sublime-menu b/Main.sublime-menu index 7ff4e44a5..0b39db9c5 100644 --- a/Main.sublime-menu +++ b/Main.sublime-menu @@ -2,10 +2,27 @@ { "id": "edit", "children": [ + { + "id": "fold", + "children": [ + { + "id": "lsp", + "caption": "-" + }, + { + "command": "lsp_fold", + "args": {"prefetch": true} + } + ] + }, { "id": "lsp", "caption": "-" }, + { + "command": "lsp_fold", + "args": {"prefetch": true, "hidden": true} + }, { "command": "lsp_source_action", "args": {"id": -1} diff --git a/boot.py b/boot.py index dc4fd6680..686215f2f 100644 --- a/boot.py +++ b/boot.py @@ -42,6 +42,8 @@ from .plugin.documents import TextChangeListener from .plugin.edit import LspApplyDocumentEditCommand from .plugin.execute_command import LspExecuteCommand +from .plugin.folding_range import LspFoldAllCommand +from .plugin.folding_range import LspFoldCommand from .plugin.formatting import LspFormatCommand from .plugin.formatting import LspFormatDocumentCommand from .plugin.formatting import LspFormatDocumentRangeCommand diff --git a/docs/src/keyboard_shortcuts.md b/docs/src/keyboard_shortcuts.md index aa6920452..81c39bfd7 100644 --- a/docs/src/keyboard_shortcuts.md +++ b/docs/src/keyboard_shortcuts.md @@ -10,6 +10,8 @@ Refer to the [Customization section](customization.md#keyboard-shortcuts-key-bin | Auto Complete | ctrl space (also on macOS) | `auto_complete` | Expand Selection | unbound | `lsp_expand_selection` | Find References | shift f12 | `lsp_symbol_references` (supports optional args: `{"include_declaration": true/false}`) +| Fold | unbound | `lsp_fold` (supports optional args: `{"strict": true/false}` - to configure whether to fold only when the caret is contained within the folded region (`true`), or even when it is anywhere on the starting line (`false`)) +| Fold All | unbound | `lsp_fold_all` (supports optional args: `{"kind": "comment" | "imports" | "region"}`) | Follow Link | unbound | `lsp_open_link` | Format File | unbound | `lsp_format_document` | Format Selection | unbound | `lsp_format_document_range` diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index 883cbdf01..fbc476621 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -6136,6 +6136,10 @@ def prepareRename(cls, params: PrepareRenameParams, view: sublime.View, progress def selectionRange(cls, params: SelectionRangeParams) -> 'Request': return Request('textDocument/selectionRange', params) + @classmethod + def foldingRange(cls, params: FoldingRangeParams, view: sublime.View) -> 'Request': + return Request('textDocument/foldingRange', params, view) + @classmethod def workspaceSymbol(cls, params: WorkspaceSymbolParams) -> 'Request': return Request("workspace/symbol", params, None, progress=True) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 02d387e28..7c814aab9 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -37,6 +37,7 @@ from .protocol import ExecuteCommandParams from .protocol import FailureHandlingKind from .protocol import FileEvent +from .protocol import FoldingRangeKind from .protocol import GeneralClientCapabilities from .protocol import InitializeError from .protocol import InitializeParams @@ -400,6 +401,16 @@ def get_initialize_params(variables: Dict[str, str], workspace_folders: List[Wor "selectionRange": { "dynamicRegistration": True }, + "foldingRange": { + "dynamicRegistration": True, + "foldingRangeKind": { + "valueSet": [ + FoldingRangeKind.Comment, + FoldingRangeKind.Imports, + FoldingRangeKind.Region + ] + } + }, "codeLens": { "dynamicRegistration": True }, diff --git a/plugin/core/types.py b/plugin/core/types.py index b6ce5ea0b..79c86f3b0 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -198,6 +198,7 @@ class Settings: hover_highlight_style = cast(str, None) inhibit_snippet_completions = cast(bool, None) inhibit_word_completions = cast(bool, None) + initially_folded = cast(List[str], None) link_highlight_style = cast(str, None) completion_insert_mode = cast(str, None) log_debug = cast(bool, None) @@ -240,6 +241,7 @@ def r(name: str, default: Union[bool, int, str, list, dict]) -> None: r("disabled_capabilities", []) r("document_highlight_style", "underline") r("hover_highlight_style", "") + r("initially_folded", []) r("link_highlight_style", "underline") r("log_debug", False) r("log_max_size", 8 * 1024) diff --git a/plugin/documents.py b/plugin/documents.py index b3018b73a..f313e0d48 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -10,6 +10,8 @@ from .core.protocol import DocumentHighlightKind from .core.protocol import DocumentHighlightParams from .core.protocol import DocumentUri +from .core.protocol import FoldingRange +from .core.protocol import FoldingRangeParams from .core.protocol import Request from .core.protocol import SignatureHelp from .core.protocol import SignatureHelpContext @@ -41,9 +43,11 @@ from .core.views import MarkdownLangMap from .core.views import range_to_region from .core.views import show_lsp_popup +from .core.views import text_document_identifier from .core.views import text_document_position_params from .core.views import update_lsp_popup from .core.windows import WindowManager +from .folding_range import folding_range_to_range from .hover import code_actions_content from .session_buffer import SessionBuffer from .session_view import SessionView @@ -329,6 +333,14 @@ def on_load_async(self) -> None: if not self._registered and is_regular_view(self.view): self._register_async() return + initially_folded_kinds = userprefs().initially_folded + if initially_folded_kinds: + session = self.session_async('foldingRangeProvider') + if session: + params = {'textDocument': text_document_identifier(self.view)} # type: FoldingRangeParams + session.send_request_async( + Request.foldingRange(params, self.view), + partial(self._on_initial_folding_ranges, initially_folded_kinds)) self.on_activated_async() def on_activated_async(self) -> None: @@ -773,6 +785,18 @@ def render_highlights_on_main_thread() -> None: sublime.set_timeout(render_highlights_on_main_thread) + # --- textDocument/foldingRange ------------------------------------------------------------------------------------ + + def _on_initial_folding_ranges(self, kinds: List[str], response: Optional[List[FoldingRange]]) -> None: + if not response: + return + regions = [ + range_to_region(folding_range_to_range(folding_range), self.view) + for kind in kinds + for folding_range in response if kind == folding_range.get('kind') + ] + self.view.fold(regions) + # --- Public utility methods --------------------------------------------------------------------------------------- def session_async(self, capability: str, point: Optional[int] = None) -> Optional[Session]: diff --git a/plugin/folding_range.py b/plugin/folding_range.py new file mode 100644 index 000000000..5ab5b172a --- /dev/null +++ b/plugin/folding_range.py @@ -0,0 +1,207 @@ +from .core.protocol import FoldingRange +from .core.protocol import FoldingRangeKind +from .core.protocol import FoldingRangeParams +from .core.protocol import Range +from .core.protocol import Request +from .core.protocol import UINT_MAX +from .core.registry import LspTextCommand +from .core.typing import List, Optional +from .core.views import range_to_region +from .core.views import text_document_identifier +from functools import partial +import sublime + + +def folding_range_to_range(folding_range: FoldingRange) -> Range: + return { + 'start': { + 'line': folding_range['startLine'], + 'character': folding_range.get('startCharacter', UINT_MAX) + }, + 'end': { + 'line': folding_range['endLine'], + 'character': folding_range.get('endCharacter', UINT_MAX) + } + } + + +def sorted_folding_ranges(folding_ranges: List[FoldingRange]) -> List[FoldingRange]: + # Sort by reversed position and from innermost to outermost (if nested) + return sorted( + folding_ranges, + key=lambda r: ( + r['startLine'], + r.get('startCharacter', UINT_MAX), + -r['endLine'], + -r.get('endCharacter', UINT_MAX) + ), + reverse=True + ) + + +class LspFoldCommand(LspTextCommand): + """A command to fold at the current caret position or at a given point. + + Optional command arguments: + + - `prefetch`: Should usually be `false`, except for the built-in menu items under the "Edit" main menu, which + pre-run a request and cache the response to dynamically show or hide the item. + - `hidden`: Can be used for a hidden menu item with the purpose to run a request and store the response. + - `strict`: Allows to configure the folding behavior; `true` means to fold only when the caret is contained + within the folded region (like ST built-in `fold` command), and `false` will fold a region even if + the caret is anywhere else on the starting line. + - `point`: Can be used instead of the caret position, measured as character offset in the document. + """ + + capability = 'foldingRangeProvider' + folding_ranges = [] # type: List[FoldingRange] + change_count = -1 + folding_region = None # type: Optional[sublime.Region] + + def is_visible( + self, + prefetch: bool = False, + hidden: bool = False, + strict: bool = True, + event: Optional[dict] = None, + point: Optional[int] = None + ) -> bool: + if not prefetch: + return True + # There should be a single empty selection in the view, otherwise this functionality would be misleading + selection = self.view.sel() + if len(selection) != 1 or not selection[0].empty(): + return False + if hidden: # This is our dummy menu item, with the purpose to run the request when the "Edit" menu gets opened + view_change_count = self.view.change_count() + # If the stored change_count matches the view's actual change count, the request has already been run for + # this document state (i.e. "Edit" menu was opened before) and the results are still valid - no need to send + # another request. + if self.change_count == view_change_count: + return False + self.change_count = -1 + session = self.best_session(self.capability) + if session: + params = {'textDocument': text_document_identifier(self.view)} # type: FoldingRangeParams + session.send_request_async( + Request.foldingRange(params, self.view), + partial(self._handle_response_async, view_change_count) + ) + return False + return self.folding_region is not None # Already set or unset by self.description + + def _handle_response_async(self, change_count: int, response: Optional[List[FoldingRange]]) -> None: + self.change_count = change_count + self.folding_ranges = response or [] + + def description( + self, + prefetch: bool = False, + hidden: bool = False, + strict: bool = True, + event: Optional[dict] = None, + point: Optional[int] = None + ) -> str: + if not prefetch: + return "LSP: Fold" + # Implementation detail of Sublime Text: TextCommand.description is called *before* TextCommand.is_visible + self.folding_region = None + if self.change_count != self.view.change_count(): # Ensure that the response has already arrived + return "LSP " # is_visible will return False + if point is not None: + pt = point + else: + selection = self.view.sel() + if len(selection) != 1 or not selection[0].empty(): + return "LSP " # is_visible will return False + pt = selection[0].b + for folding_range in sorted_folding_ranges(self.folding_ranges): + region = range_to_region(folding_range_to_range(folding_range), self.view) + if (strict and region.contains(pt) or + not strict and sublime.Region(self.view.line(region.a).a, region.b).contains(pt)) and \ + not self.view.is_folded(region): + # Store the relevant folding region, so that we don't need to do the same computation again in + # self.is_visible and self.run + self.folding_region = region + kind = folding_range.get('kind') + if kind == FoldingRangeKind.Imports: + return "LSP: Fold Imports" + elif kind: + return "LSP: Fold this {}".format(kind.title()) + else: + return "LSP: Fold" + return "LSP " # is_visible will return False + + def run( + self, + edit: sublime.Edit, + prefetch: bool = False, + hidden: bool = False, + strict: bool = True, + event: Optional[dict] = None, + point: Optional[int] = None + ) -> None: + if prefetch: + if self.folding_region is not None: + self.view.fold(self.folding_region) + else: + if point is not None: + pt = point + else: + selection = self.view.sel() + if len(selection) != 1 or not selection[0].empty(): + self.view.run_command('fold') + return + pt = selection[0].b + session = self.best_session(self.capability) + if session: + params = {'textDocument': text_document_identifier(self.view)} # type: FoldingRangeParams + session.send_request_async( + Request.foldingRange(params, self.view), + partial(self._handle_response_manual_async, pt, strict) + ) + + def _handle_response_manual_async(self, point: int, strict: bool, response: Optional[List[FoldingRange]]) -> None: + if not response: + window = self.view.window() + if window: + window.status_message("Code Folding not available") + return + for folding_range in sorted_folding_ranges(response): + region = range_to_region(folding_range_to_range(folding_range), self.view) + if (strict and region.contains(point) or + not strict and sublime.Region(self.view.line(region.a).a, region.b).contains(point)) and \ + not self.view.is_folded(region): + self.view.fold(region) + return + else: + window = self.view.window() + if window: + window.status_message("Code Folding not available") + + +class LspFoldAllCommand(LspTextCommand): + + capability = 'foldingRangeProvider' + + def run(self, edit: sublime.Edit, kind: Optional[str] = None, event: Optional[dict] = None) -> None: + session = self.best_session(self.capability) + if session: + params = {'textDocument': text_document_identifier(self.view)} # type: FoldingRangeParams + session.send_request_async( + Request.foldingRange(params, self.view), partial(self._handle_response_async, kind)) + + def _handle_response_async(self, kind: Optional[str], response: Optional[List[FoldingRange]]) -> None: + if not response: + return + regions = [ + range_to_region(folding_range_to_range(folding_range), self.view) + for folding_range in response if not kind or kind == folding_range.get('kind') + ] + if not regions: + return + # Don't fold regions which contain the caret or selection + selections = self.view.sel() + regions = [region for region in regions if not any(region.intersects(selection) for selection in selections)] + if regions: + self.view.fold(regions) diff --git a/stubs/sublime.pyi b/stubs/sublime.pyi index 115126652..10ad0097d 100644 --- a/stubs/sublime.pyi +++ b/stubs/sublime.pyi @@ -919,7 +919,9 @@ class View: def em_width(self) -> float: ... - # def is_folded(self, sr) -> bool: ... + def is_folded(self, sr: Region) -> bool: + ... + # def folded_regions(self): ... def fold(self, x: Union[Region, List[Region]]) -> bool: ... diff --git a/sublime-package.json b/sublime-package.json index 00cd93edb..db3c9faab 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -726,6 +726,30 @@ "default": false, "markdownDescription": "Show inlay hints in the editor. Inlay hints are short annotations within the code, which show variable types or parameter names.\nThis is the default value used for new windows but can be overriden per-window using the `LSP: Toggle Inlay Hints` command from the Command Palette, Main Menu or a custom keybinding." }, + "initially_folded": { + "type": "array", + "items": { + "anyOf": [ + { + "enum": [ + "comment", + "imports", + "region" + ], + "markdownEnumDescriptions": [ + "Comment blocks", + "Imports or includes", + "Regions (e.g. `#region`)" + ] + }, + { + "type": "string" + } + ] + }, + "uniqueItems": true, + "markdownDescription": "Determines ranges which initially should be folded when a document is opened, provided that the language server has support for this." + } }, "additionalProperties": false }