feat: add live-cycling LSP completion

This commit is contained in:
2026-10-10 18:32:39 +02:00
parent 9d3b25e9d3
commit b6059d5ed9
11 changed files with 1916 additions and 55 deletions
+2 -1
View File
@@ -22,6 +22,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
| Move in Normal | `h`/`j`/`k`/`l` or arrows; `w`/`b`/`e` for words | | Move in Normal | `h`/`j`/`k`/`l` or arrows; `w`/`b`/`e` for words |
| First/last line in Normal or Visual | `gt` / `gb`, at first nonblank; Visual extends the selection | | First/last line in Normal or Visual | `gt` / `gb`, at first nonblank; Visual extends the selection |
| Enter/leave Insert | `i` / Esc | | Enter/leave Insert | `i` / Esc |
| Manual LSP completion in Insert | Ctrl+n starts/cycles next; Ctrl+j/k cycles next/previous, replacing text live |
| Save in Normal | Space then `w`, or `:w` then Enter | | Save in Normal | Space then `w`, or `:w` then Enter |
| Undo/redo in Normal | `u` / `U` (Shift+u) | | Undo/redo in Normal | `u` / `U` (Shift+u) |
| Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous | | Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous |
@@ -44,7 +45,7 @@ Ruby configuration and plugins run with your account's filesystem/process permis
- One visible buffer at a time; no splits/tabs or graphical frontend. - One visible buffer at a time; no splits/tabs or graphical frontend.
- Modal editing, not full Vim compatibility: characterwise and linewise Visual selections, but no block selection, named registers, or Vim operators. Search is literal, case-sensitive, and single-line. - Modal editing, not full Vim compatibility: characterwise and linewise Visual selections, but no block selection, named registers, or Vim operators. Search is literal, case-sensitive, and single-line.
- Native opt-in LSP supports diagnostics, go-to-definition, plain-text hover, and automatic semantic highlighting—not completion, rename, formatting, or code actions. - Native opt-in LSP supports diagnostics, go-to-definition, plain-text hover, manual primary-caret completion, and automatic semantic highlighting—not rename, formatting, or code actions. Completion has no automatic popup, snippets, or acceptance step; other carets are preserved and translated through the chosen edits.
- System clipboard access supports macOS and Linux. Separate clipboard mode keeps internal editing available on unsupported/headless environments. - System clipboard access supports macOS and Linux. Separate clipboard mode keeps internal editing available on unsupported/headless environments.
Performance and subprocess limits are documented in [development](docs/development.md#performance-and-process-limits). Performance and subprocess limits are documented in [development](docs/development.md#performance-and-process-limits).
+7 -4
View File
@@ -13,9 +13,9 @@ One crate; no workspace or terminal-coordinate types in the editing backend:
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets. - `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency. - `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
- `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency. - `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency.
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers, and versioned asynchronous update contracts. Semantic token updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types. - `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers and completion edit/item DTOs, and versioned asynchronous update contracts. Semantic token and completion updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types.
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs. - `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation, and retires failed plugin ownership. - `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership.
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy. - `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. - The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
@@ -36,6 +36,7 @@ Frontends inspect `file_picker()` for query, presentation, view, explorer-local
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds. Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling. Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the document workspace without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates. Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the document workspace without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates.
Frontends borrow `completion()` for the read-only item list and selected index: zero is Original text, and other indices select `items[selected - 1]`. The editor owns the immutable original document/carets, atomic candidate transactions, cycling/dismissal, pending Insert undo, and final-only repeat recording. It changes only the primary completion target and translates secondary carets through all edits. The TUI only places a windowed list near the current caret inside the document text workspace, sanitizes/clips labels and detail by grapheme, and preserves gutter, explorer, and hardware cursor ownership; wheel input over the list is consumed without dispatching edits.
## Terminal lifecycle and layout ## Terminal lifecycle and layout
@@ -65,10 +66,12 @@ macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`,
The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses. The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses.
Language configurations can own an ordered server stack. The first matching language wins extension overlap; all its matching servers receive document events, while the first handles hover/definition. The Python preset uses ty then Ruff. LSP merges each worker's latest diagnostic snapshot in assignment order before emitting the unchanged core-owned update. Empty publications/retirement remove only their source; revision/path/root changes and close discard all contributions. Worker snapshots retain identity so queued pre-rename diagnostics cannot attach to a new path with the same text revision. Combined diagnostic message bytes remain bounded to 4 MiB; an overflowing publication retires its worker instead of discarding healthy sibling annotations. Workers are stored in config/root order, avoiding per-poll key cloning/sorting. Shutdown flushes the exit notification and waits for peer termination within the existing bounded grace period before process cleanup. Language configurations can own an ordered server stack. The first matching language wins extension overlap; all its matching servers receive document events, while the first handles hover/definition/completion. The Python preset uses ty then Ruff. LSP merges each worker's latest diagnostic snapshot in assignment order before emitting the unchanged core-owned update. Empty publications/retirement remove only their source; revision/path/root changes and close discard all contributions. Worker snapshots retain identity so queued pre-rename diagnostics cannot attach to a new path with the same text revision. Combined diagnostic message bytes remain bounded to 4 MiB; an overflowing publication retires its worker instead of discarding healthy sibling annotations. Workers are stored in config/root order, avoiding per-poll key cloning/sorting. Shutdown flushes the exit notification and waits for peer termination within the existing bounded grace period before process cleanup.
Semantic tokens belong only to the first assigned server. Initialization advertises standard token types/modifiers, relative encoding, full/range requests, and server refresh support; deltas, overlapping/multiline tokens, and dynamic registration are not advertised. Each buffer has at most one request in flight; changes/refreshes coalesce into the latest snapshot. Full requests are preferred, with whole-document range fallback. A linear UTF-16 decoder validates raw coordinates and legend indices before outward grapheme rounding; overlong ends truncate at EOL, unknown types and invisible spans fall back to syntax. Both wire frames and decoded spans are bounded to 4 MiB. Snapshot identity rejects stale/renamed/closed replies before decoding. Invalid semantic replies clear the overlay and report an error without retiring diagnostics/navigation; primary retirement clears its overlays, and sibling retirement leaves them intact. Semantic tokens belong only to the first assigned server. Initialization advertises standard token types/modifiers, relative encoding, full/range requests, and server refresh support; deltas, overlapping/multiline tokens, and dynamic registration are not advertised. Each buffer has at most one request in flight; changes/refreshes coalesce into the latest snapshot. Full requests are preferred, with whole-document range fallback. A linear UTF-16 decoder validates raw coordinates and legend indices before outward grapheme rounding; overlong ends truncate at EOL, unknown types and invisible spans fall back to syntax. Both wire frames and decoded spans are bounded to 4 MiB. Snapshot identity rejects stale/renamed/closed replies before decoding. Invalid semantic replies clear the overlay and report an error without retiring diagnostics/navigation; primary retirement clears its overlays, and sibling retirement leaves them intact.
Manual completion dispatches the Insert-only `core.lsp.completion` action directly to the session, without the Ex execution path's mode/history transitions. The native worker sends the primary caret as UTF-16 and normalizes results into `CompletionItem { label, detail, primary_edit, edits }`; each `CompletionEdit` uses original-document, end-exclusive grapheme coordinates. Primary and additional edits form one atomic candidate. Session freshness includes the latest request, buffer/revision, primary caret, Insert source mode, and absence of modal overlays; any intervening input invalidates a pending reply. Decoding/validation errors remain completion-local and preserve healthy diagnostics/semantic tokens. Snippets, server commands, indentation adjustment, resolve requests, and automatic triggers are not supported; both frames and aggregate decoded completion text remain bounded.
## Ruby action catalog ## Ruby action catalog
Ruby builds `config.core` from the Rust action catalog before loading configuration or plugin scripts. Dotted identifiers create nested namespaces automatically; each namespace and Action value is frozen. Action names are not duplicated in Ruby, so Rust catalog additions/renames immediately change the Ruby interface without code generation or handwritten exports. Ruby builds `config.core` from the Rust action catalog before loading configuration or plugin scripts. Dotted identifiers create nested namespaces automatically; each namespace and Action value is frozen. Action names are not duplicated in Ruby, so Rust catalog additions/renames immediately change the Ruby interface without code generation or handwritten exports.
+13 -5
View File
@@ -69,6 +69,7 @@ The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visua
| Normal | `follow_link` | | Normal | `follow_link` |
| Normal | `jump_back`, `jump_forward` | | Normal | `jump_back`, `jump_forward` |
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | | Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
| Insert | `lsp.completion` |
| Normal | `search`, `paste_before`, `paste_after` | | Normal | `search`, `paste_before`, `paste_after` |
| Normal | `paste_system_before`, `paste_system_after` | | Normal | `paste_system_before`, `paste_system_after` |
| Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` | | Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` |
@@ -127,7 +128,7 @@ config.bind "ctrl+h", nil, mode: config.modes.tree
Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired. Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired.
Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside lists, existing editing bindings are unchanged: Ctrl+k still clones above in Normal/Visual, and explicit Ctrl+j defaults insert a newline in Insert or submit Command/Search prompts. Enter and Ctrl+j can now be rebound independently. Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside completion and selection lists, Ctrl+k still clones above in Normal/Visual, and Ctrl+j defaults to newline in Insert or submit in Command/Search. While completion is open, Ctrl+j/k cycles candidates; Enter always closes completion and inserts a newline. Enter and Ctrl+j can be rebound independently outside the completion cycle.
## Leader key ## Leader key
@@ -158,6 +159,7 @@ Vamp.configure do |config|
config.bind "leader d", config.core.lsp.definition config.bind "leader d", config.core.lsp.definition
config.bind "leader D", config.core.lsp.diagnostics config.bind "leader D", config.core.lsp.diagnostics
config.bind "K", config.core.lsp.hover config.bind "K", config.core.lsp.hover
config.bind "ctrl+n", config.core.lsp.completion, mode: config.modes.insert
end end
``` ```
@@ -169,7 +171,7 @@ Install Solargraph yourself with `gem install solargraph` in the project's Ruby
**Python (Astral):** install [ty](https://docs.astral.sh/ty/editors/) and [Ruff](https://docs.astral.sh/ruff/editors/setup/) yourself, for example with `uv tool install ty` and `uv tool install ruff`. The Python preset sends the same documents to both servers, uses ty for hover/definition, and merges type errors with Ruff lint diagnostics. Clearing or losing one server preserves the other's diagnostics. Formatting and code actions remain unsupported by Vamp. **Python (Astral):** install [ty](https://docs.astral.sh/ty/editors/) and [Ruff](https://docs.astral.sh/ruff/editors/setup/) yourself, for example with `uv tool install ty` and `uv tool install ruff`. The Python preset sends the same documents to both servers, uses ty for hover/definition, and merges type errors with Ruff lint diagnostics. Clearing or losing one server preserves the other's diagnostics. Formatting and code actions remain unsupported by Vamp.
Use `servers` to customize the stack independently; order selects the hover/definition server: Use `servers` to customize the stack independently; order selects the hover/definition/completion server:
```ruby ```ruby
config.lsp["python"] = { config.lsp["python"] = {
@@ -188,13 +190,13 @@ config.lsp["python"] = {
**uv remains optional project tooling.** Run project setup/synchronization yourself; [ty discovers an existing `.venv`](https://docs.astral.sh/ty/reference/configuration/#python) without a uv launcher. Vamp neither runs `uv sync` nor changes dependencies/lockfiles. The default ty preset sets `experimental.useUv: "off"` so inherited `TY_UV` cannot enable ty's [experimental environment-management integration](https://docs.astral.sh/ty/reference/editor-settings/). Ruff's initialization options default to `{}`. When replacing ty's initialization options, retain that flag if you want the same policy. **uv remains optional project tooling.** Run project setup/synchronization yourself; [ty discovers an existing `.venv`](https://docs.astral.sh/ty/reference/configuration/#python) without a uv launcher. Vamp neither runs `uv sync` nor changes dependencies/lockfiles. The default ty preset sets `experimental.useUv: "off"` so inherited `TY_UV` cannot enable ty's [experimental environment-management integration](https://docs.astral.sh/ty/reference/editor-settings/). Ruff's initialization options default to `{}`. When replacing ty's initialization options, retain that flag if you want the same policy.
Servers start lazily only when a matching buffer opens. Extension matching is case-sensitive; if enabled languages overlap, the first language in sorted key order wins. All matching servers in that language run, and the first handles hover/definition, semantic highlighting, and the diagnostic-status command without fallback to sibling servers. Each server/project root gets one worker, with at most 16 configured servers and 16 server/project keys; Python uses two slots. Start vamp from your project root: its startup working directory is the workspace; automatic root discovery is not implemented. Servers start lazily only when a matching buffer opens. Extension matching is case-sensitive; if enabled languages overlap, the first language in sorted key order wins. All matching servers in that language run, and the first handles hover/definition/completion, semantic highlighting, and the diagnostic-status command without fallback to sibling servers. Each server/project root gets one worker, with at most 16 configured servers and 16 server/project keys; Python uses two slots. Start vamp from your project root: its startup working directory is the workspace; automatic root discovery is not implemented.
See [native LSP architecture](architecture.md#native-lsp) for synchronization and worker behavior. See [native LSP architecture](architecture.md#native-lsp) for synchronization and worker behavior.
`:lsp.definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed. `:lsp.definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed.
For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Action objects: `definition`, `diagnostics`, and `hover`. Each routes to the same native implementation as its Ex command; no LSP keys are bound by default. These actions are also available in the command palette. The namespace is distinct from `config.lsp[language_id]` server configuration. For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Action objects `definition`, `diagnostics`, and `hover`, plus the **Insert-only** `completion` Action. Normal actions route to their corresponding Ex commands and have no default keys. Completion defaults to Insert Ctrl+n; override or disable it using an Insert binding. It requests directly without leaving Insert or committing pending typing; there is no Normal `:lsp.completion` command. These actions are also available in the command palette according to mode eligibility. The namespace is distinct from `config.lsp[language_id]` server configuration.
Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`). The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments. Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`). The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments.
@@ -204,8 +206,14 @@ Diagnostics appear as severity-colored gutter numbers and text underlines; the p
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. The popup grows up to 100 columns and 24 rows, clamped to the available document workspace and content height. Multiline content and Markdown markers remain visible, with the same Markdown syntax colors as document buffers; named fenced code blocks use their language's bundled highlighter. Highlighting is cached across scrolling and redraws. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized. Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. The popup grows up to 100 columns and 24 rows, clamped to the available document workspace and content height. Multiline content and Markdown markers remain visible, with the same Markdown syntax colors as document buffers; named fenced code blocks use their language's bundled highlighter. Highlighting is cached across scrolling and redraws. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
Completion is manual and uses only the primary insertion caret. Ctrl+n requests it; the first candidate replaces text immediately. While the list is open Ctrl+n/Ctrl+j selects next and Ctrl+k previous, including an Original text row that restores the original document and carets. Other keys close it and act normally, with no accept key: Enter inserts a newline, Tab inserts a tab, and Escape returns to Normal keeping the displayed candidate. Secondary carets are translated, not completed independently. Cycling belongs to the pending Insert undo group; repeat records only the final chosen edits. See [editing behavior](editing.md#manual-lsp-completion).
The first assigned server must advertise a completion provider. Vamp handles item arrays and CompletionList results, plain text, TextEdit, InsertReplaceEdit (using its replacement range), supported edit-range/format/mode defaults, and atomic additionalTextEdits in the same document. Insert-only fallback text replaces the identifier prefix before the caret; `filterText` and stable `sortText`/label ordering are honored. All edit coordinates are validated at UTF-16 and grapheme boundaries against the immutable request document; malformed or overlapping edits report a completion error without retiring a healthy server. Aggregate completion text is bounded to 4 MiB. Pending replies cannot reopen completion after intervening input, source changes, superseding requests, or modal overlays.
There are no automatic triggers, snippets, commit characters, server commands, indentation adjustment, or completion-item resolve requests. Items requiring snippet expansion, commands, or indentation adjustment are omitted rather than partly applied. Server-specific availability depends on the installed server's advertised capabilities; configuring a preset does not guarantee completion support.
Semantic highlighting is **enabled automatically** when the first server advertises [LSP semantic tokens](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens). No additional configuration or binding is needed. Full-document requests are preferred; range-only servers receive a whole-document range request. Token colors override Tree-sitter where recognized; unsupported servers, unknown custom token types, missing tokens, and failed replies retain syntax highlighting. Text/path changes clear old semantic colors immediately, and stale replies cannot restore them. Same-text refreshes keep the current overlay until the replacement arrives. Semantic highlighting is **enabled automatically** when the first server advertises [LSP semantic tokens](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens). No additional configuration or binding is needed. Full-document requests are preferred; range-only servers receive a whole-document range request. Token colors override Tree-sitter where recognized; unsupported servers, unknown custom token types, missing tokens, and failed replies retain syntax highlighting. Text/path changes clear old semantic colors immediately, and stale replies cannot restore them. Same-text refreshes keep the current overlay until the replacement arrives.
rust-analyzer and ty support semantic tokens. Solargraph 0.61.0 and Ruff do not; Ruby therefore retains Tree-sitter colors with the Solargraph preset, while Python uses ty's tokens and Ruff's diagnostics. Declaration/definition modifiers use bold, deprecated symbols use strikethrough, and readonly variables/parameters/properties use italics without changing their role's color. Diagnostics, search, selections, and carets retain their existing styling precedence. rust-analyzer and ty support semantic tokens. Solargraph 0.61.0 and Ruff do not; Ruby therefore retains Tree-sitter colors with the Solargraph preset, while Python uses ty's tokens and Ruff's diagnostics. Declaration/definition modifiers use bold, deprecated symbols use strikethrough, and readonly variables/parameters/properties use italics without changing their role's color. Diagnostics, search, selections, and carets retain their existing styling precedence.
Native LSP currently provides synchronization, diagnostics, go-to-definition, hover, and semantic highlighting—not completion, rename, formatting, code actions, or workspace edits. Semantic token deltas, dynamic registration, and partial-result streaming are not implemented. Native LSP currently provides synchronization, diagnostics, go-to-definition, hover, manual completion, and semantic highlighting—not rename, formatting, code actions, or general workspace edits. Semantic token deltas, dynamic registration, and partial-result streaming are not implemented.
+10 -1
View File
@@ -29,6 +29,7 @@ Default controls and editing behavior. For custom bindings, see [configuration](
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes; `v`/`V` switches shape, the same selector or Esc cancels | | Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes; `v`/`V` switches shape, the same selector or Esc cancels |
| Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences | | Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences |
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret; Esc returns to Normal, retaining carets | | Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret; Esc returns to Normal, retaining carets |
| Insert | Ctrl+n requests completion at the primary caret or cycles next; Ctrl+j/k cycles next/previous while completion is open |
| Command | Enter executes; Backspace edits; Esc cancels | | Command | Enter executes; Backspace edits; Esc cancels |
| Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels | | Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels |
@@ -36,6 +37,14 @@ Default controls and editing behavior. For custom bindings, see [configuration](
`gt`/`gb` move every caret to the first nonblank grapheme on the first/last row, including the empty final row after a trailing newline. Duplicate carets and overlapping Visual selections normalize as usual. Both Visual shapes retain their anchors and extend to the destination. These motions do not edit text or add undo history; count prefixes are consumed rather than interpreted as line numbers. `g` waits for its second key, and Escape cancels only the pending prefix. In Insert and command/search prompts, `gt`/`gb` remain literal text. `gt`/`gb` move every caret to the first nonblank grapheme on the first/last row, including the empty final row after a trailing newline. Duplicate carets and overlapping Visual selections normalize as usual. Both Visual shapes retain their anchors and extend to the destination. These motions do not edit text or add undo history; count prefixes are consumed rather than interpreted as line numbers. `g` waits for its second key, and Escape cancels only the pending prefix. In Insert and command/search prompts, `gt`/`gb` remain literal text.
## Manual LSP completion
With an enabled server that provides completion, Ctrl+n in Insert requests candidates at the **primary caret only**. The first candidate is inserted immediately and a small list appears near the caret; Ctrl+n or Ctrl+j cycles forward, Ctrl+k backward, with wraparound. There is no acceptance step. The **Original text** row restores the document and carets from before the result was shown, including any additional edits. Completion never broadcasts a candidate to secondary carets: they are preserved and translated through each chosen edit.
Every candidate is applied from that same immutable original document, not on top of the previous candidate. A candidate's primary replacement and additional text edits are one atomic change. Every other key closes the list and performs its ordinary action: typing continues at the carets, Enter inserts a newline, Tab inserts a literal tab, and Escape returns to Normal retaining the displayed candidate. There is no automatic popup or snippet expansion. Empty, unsupported, or invalid results leave the document unchanged and report a message.
Cycling creates no separate undo entries. Only the final chosen edits join the existing Insert undo group; choosing Original text adds no completion edit. Undo restores that group, and dot repeats the final resolved completion edits rather than requests or intermediate candidates. The list stays inside the document text workspace, does not cover the gutter or explorer, and never takes the hardware cursor. Wheel input over it is consumed without choosing a candidate.
## Word motions ## Word motions
Word motions separate runs of Unicode letters/numbers/underscore from runs of punctuation or emoji. Classification uses each grapheme's first character; combining sequences and ZWJ emoji stay intact. WORD motions treat any non-whitespace run as one WORD. Word motions separate runs of Unicode letters/numbers/underscore from runs of punctuation or emoji. Classification uses each grapheme's first character; combining sequences and ZWJ emoji stay intact. WORD motions treat any non-whitespace run as one WORD.
@@ -114,7 +123,7 @@ Paste is backend input: it is ignored in Normal, Command, Search, and Visual mod
Mouse-wheel up/down scrolls the document view three rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates and resizing preserve a wheel-scrolled view, clamped to the document. Mouse-wheel up/down scrolls the document view three rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates and resizing preserve a wheel-scrolled view, clamped to the document.
Wheel input over a hover popup or floating/docked file explorer scrolls that panel instead. Explorer scrolling preserves the selected file, filter, and keyboard focus; keyboard input resumes selection-following. An offscreen selected entry has no browsing cursor, while the filter input keeps its own cursor. Command palettes and startup notices consume wheel input without scrolling the background. Mouse clicks and dragging are not editing actions. Wheel input over a hover popup or floating/docked file explorer scrolls that panel instead. Explorer scrolling preserves the selected file, filter, and keyboard focus; keyboard input resumes selection-following. An offscreen selected entry has no browsing cursor, while the filter input keeps its own cursor. Completion lists, command palettes, and startup notices consume wheel input without scrolling the background or editing text. Mouse clicks and dragging are not editing actions.
## Buffers ## Buffers
+723 -23
View File
@@ -1,6 +1,6 @@
use crate::clipboard::{ClipboardMode, SystemClipboard}; use crate::clipboard::{ClipboardMode, SystemClipboard};
use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
use crate::plugin::{Diagnostic, SemanticToken, TextPosition}; use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, TextPosition};
use serde::Serialize; use serde::Serialize;
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
@@ -83,6 +83,26 @@ pub struct Hover {
caret: Cursor, caret: Cursor,
} }
/// A live preview; zero selects the immutable original document.
pub struct Completion {
pub items: Vec<CompletionItem>,
pub selected: usize,
}
struct CompletionState {
popup: Completion,
original: Snapshot,
}
#[cfg_attr(test, derive(Debug))]
struct RelativeCompletionEdit {
start: isize,
end: isize,
text: String,
// Extra edits are replayed only against the same surrounding logical lines.
context: Option<Vec<String>>,
}
impl Hover { impl Hover {
pub fn text(&self) -> &str { pub fn text(&self) -> &str {
&self.text &self.text
@@ -193,7 +213,7 @@ struct Snapshot {
} }
struct Edit<'a> { struct Edit<'a> {
target: usize, target: Option<usize>,
start: usize, start: usize,
end: usize, end: usize,
text: Cow<'a, str>, text: Cow<'a, str>,
@@ -239,6 +259,10 @@ enum InsertStep {
Move(Action, usize), Move(Action, usize),
Delete(Vec<bool>), Delete(Vec<bool>),
Backspace(Vec<bool>), Backspace(Vec<bool>),
Completion {
primary_edit: usize,
edits: Vec<RelativeCompletionEdit>,
},
} }
#[cfg_attr(test, derive(Debug))] #[cfg_attr(test, derive(Debug))]
@@ -302,6 +326,7 @@ enum Action {
LspDefinition, LspDefinition,
LspHover, LspHover,
LspDiagnostics, LspDiagnostics,
LspCompletion,
} }
// Names and mode eligibility are shared by configured bindings and the palette. // Names and mode eligibility are shared by configured bindings and the palette.
@@ -503,6 +528,11 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
&[Mode::Normal], &[Mode::Normal],
), ),
("core.lsp.hover", Action::LspHover, &[Mode::Normal]), ("core.lsp.hover", Action::LspHover, &[Mode::Normal]),
(
"core.lsp.completion",
Action::LspCompletion,
&[Mode::Insert],
),
( (
"core.lsp.diagnostics", "core.lsp.diagnostics",
Action::LspDiagnostics, Action::LspDiagnostics,
@@ -739,6 +769,10 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
.or_default() .or_default()
.insert(key, Binding::Action(action)); .insert(key, Binding::Action(action));
} }
bindings
.entry(Mode::Insert)
.or_default()
.insert(Input::Ctrl('n'), Binding::Action(LspCompletion));
for mode in [Mode::Insert, Mode::Command, Mode::Search] { for mode in [Mode::Insert, Mode::Command, Mode::Search] {
for (key, action) in [ for (key, action) in [
(Input::Escape, Normal), (Input::Escape, Normal),
@@ -938,6 +972,7 @@ pub struct Editor {
g_pending: bool, g_pending: bool,
palette: Option<Palette>, palette: Option<Palette>,
hover: Option<Hover>, hover: Option<Hover>,
completion: Option<CompletionState>,
startup_diagnostic: Option<StartupDiagnostic>, startup_diagnostic: Option<StartupDiagnostic>,
project_root: PathBuf, project_root: PathBuf,
file_picker: Option<FilePicker>, file_picker: Option<FilePicker>,
@@ -1002,6 +1037,7 @@ impl Editor {
g_pending: false, g_pending: false,
palette: None, palette: None,
hover: None, hover: None,
completion: None,
startup_diagnostic: None, startup_diagnostic: None,
project_root: std::env::current_dir()?, project_root: std::env::current_dir()?,
file_picker: None, file_picker: None,
@@ -1342,6 +1378,7 @@ impl Editor {
/// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure. /// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure.
pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> { pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> {
self.finish_completion();
let source = self.jump_location(); let source = self.jump_location();
self.cancel_leader(); self.cancel_leader();
self.clear_hover(); self.clear_hover();
@@ -1471,6 +1508,7 @@ impl Editor {
} }
pub(crate) fn show_startup_diagnostic(&mut self, text: String) { pub(crate) fn show_startup_diagnostic(&mut self, text: String) {
self.finish_completion();
let lines = split_lines(&text); let lines = split_lines(&text);
let max_column = lines let max_column = lines
.iter() .iter()
@@ -1485,6 +1523,129 @@ impl Editor {
}); });
} }
/// Borrowed live-completion state; original document bookkeeping stays private.
pub fn completion(&self) -> Option<&Completion> {
self.completion.as_ref().map(|state| &state.popup)
}
/// Validates the entire response before changing text, history, or popup state.
pub fn validate_completion(&self, items: &[CompletionItem]) -> Result<(), String> {
validate_completion_items(&self.active.lines, items)
}
pub fn show_completion(&mut self, mut items: Vec<CompletionItem>) -> Result<(), String> {
if self.mode() != Mode::Insert
|| self.palette.is_some()
|| self.startup_diagnostic.is_some()
|| self
.file_picker
.as_ref()
.is_some_and(|picker| picker.focused)
{
return Err("Completion requires Insert editor focus".into());
}
self.validate_completion(&items)?;
self.finish_completion();
if items.is_empty() {
self.set_message("No completions");
return Ok(());
}
for item in &mut items {
for edit in &mut item.edits {
if let Cow::Owned(normalized) = normalize_newlines(&edit.text) {
edit.text = normalized;
}
}
}
let original = self.snapshot();
self.preview_completion(&original, &items[0]);
self.completion = Some(CompletionState {
popup: Completion { items, selected: 1 },
original,
});
self.message.clear();
Ok(())
}
fn preview_completion(&mut self, original: &Snapshot, item: &CompletionItem) {
let offsets = line_offsets(&original.lines);
let edits = completion_edits(&original.lines, &offsets, original.primary, item);
self.apply_edits_from(edits, Some(original));
}
fn cycle_completion(&mut self, next: bool) {
let mut state = self.completion.take().expect("completion is active");
let count = state.popup.items.len() + 1;
state.popup.selected = if next {
(state.popup.selected + 1) % count
} else {
(state.popup.selected + count - 1) % count
};
if state.popup.selected == 0 {
if self.active.lines != state.original.lines {
self.active.text_changed();
self.active.search_match = None;
}
self.active.lines.clone_from(&state.original.lines);
self.active.targets.clone_from(&state.original.targets);
self.active.primary = state.original.primary;
} else {
self.preview_completion(
&state.original,
&state.popup.items[state.popup.selected - 1],
);
}
self.completion = Some(state);
}
/// Retains the displayed candidate and records only its resolved native edits.
fn finish_completion(&mut self) {
let Some(state) = self.completion.take() else {
return;
};
if state.popup.selected == 0 || state.original.lines == self.active.lines {
return;
}
let item = state
.popup
.items
.into_iter()
.nth(state.popup.selected - 1)
.expect("selected completion exists");
let caret = state.original.targets[state.original.primary].active;
let origin = grapheme_offset(&state.original.lines, caret) as isize;
let edits = item
.edits
.into_iter()
.enumerate()
.map(|(index, edit)| RelativeCompletionEdit {
start: grapheme_offset(
&state.original.lines,
Cursor {
row: edit.start.row,
column: edit.start.column,
},
) as isize
- origin,
end: grapheme_offset(
&state.original.lines,
Cursor {
row: edit.end.row,
column: edit.end.column,
},
) as isize
- origin,
text: edit.text,
context: (index != item.primary_edit)
.then(|| state.original.lines[edit.start.row..=edit.end.row].to_vec()),
})
.collect();
self.push_insert_step(InsertStep::Completion {
primary_edit: item.primary_edit,
edits,
});
}
/// Borrowed popup state, visible only while its source remains current. /// Borrowed popup state, visible only while its source remains current.
pub fn hover(&self) -> Option<&Hover> { pub fn hover(&self) -> Option<&Hover> {
self.hover.as_ref().filter(|hover| { self.hover.as_ref().filter(|hover| {
@@ -2298,6 +2459,25 @@ impl Editor {
Input::Ctrl(ch) => control_key(ch), Input::Ctrl(ch) => control_key(ch),
input => input, input => input,
}; };
if self.completion.is_some() {
let request = matches!(
self.bindings
.get(&self.active.mode)
.and_then(|keys| keys.get(&input)),
Some(Binding::Action(Action::LspCompletion))
);
if request || input == Input::Ctrl('j') {
self.cycle_completion(true);
return None;
}
match input {
Input::Ctrl('k') => {
self.cycle_completion(false);
return None;
}
_ => self.finish_completion(),
}
}
if std::mem::take(&mut self.g_pending) { if std::mem::take(&mut self.g_pending) {
if matches!(self.active.mode, Mode::Normal | Mode::Visual) if matches!(self.active.mode, Mode::Normal | Mode::Visual)
&& self.startup_diagnostic.is_none() && self.startup_diagnostic.is_none()
@@ -2480,6 +2660,7 @@ impl Editor {
} }
fn act(&mut self, action: Action) -> Option<Invocation> { fn act(&mut self, action: Action) -> Option<Invocation> {
self.finish_completion();
if !matches!(action, Action::JumpBack | Action::JumpForward) { if !matches!(action, Action::JumpBack | Action::JumpForward) {
self.clear_hover(); self.clear_hover();
} }
@@ -2507,6 +2688,15 @@ impl Editor {
Action::LspDefinition => return self.execute("lsp.definition"), Action::LspDefinition => return self.execute("lsp.definition"),
Action::LspHover => return self.execute("lsp.hover"), Action::LspHover => return self.execute("lsp.hover"),
Action::LspDiagnostics => return self.execute("lsp.diagnostics"), Action::LspDiagnostics => return self.execute("lsp.diagnostics"),
Action::LspCompletion => {
if self.active.mode == Mode::Insert {
return Some(Invocation {
plugin: "core".into(),
command: "lsp.completion".into(),
args: String::new(),
});
}
}
Action::Insert Action::Insert
| Action::InsertFirstNonblank | Action::InsertFirstNonblank
| Action::AppendLineEnd | Action::AppendLineEnd
@@ -2803,6 +2993,7 @@ impl Editor {
} }
fn park_source(&mut self) { fn park_source(&mut self) {
self.finish_completion();
self.clear_hover(); self.clear_hover();
if self.active.mode == Mode::Insert { if self.active.mode == Mode::Insert {
self.finish_insert(); self.finish_insert();
@@ -2990,6 +3181,7 @@ impl Editor {
} }
pub fn execute(&mut self, command: &str) -> Option<Invocation> { pub fn execute(&mut self, command: &str) -> Option<Invocation> {
self.finish_completion();
self.clear_hover(); self.clear_hover();
self.count = None; self.count = None;
self.clear_leader(); self.clear_leader();
@@ -3109,6 +3301,7 @@ impl Editor {
if inserting { if inserting {
self.finish_insert(); self.finish_insert();
} }
self.finish_completion();
let before = effects let before = effects
.iter() .iter()
.any(|effect| { .any(|effect| {
@@ -3815,7 +4008,7 @@ impl Editor {
start -= 1; start -= 1;
} }
Edit { Edit {
target, target: Some(target),
start, start,
end, end,
text: Cow::Borrowed(if changing && last < last_row { text: Cow::Borrowed(if changing && last < last_row {
@@ -3851,7 +4044,7 @@ impl Editor {
.iter() .iter()
.enumerate() .enumerate()
.map(|(target, range)| Edit { .map(|(target, range)| Edit {
target, target: Some(target),
start: cursor_offset(&self.active.lines, &offsets, range.start), start: cursor_offset(&self.active.lines, &offsets, range.start),
end: cursor_offset(&self.active.lines, &offsets, range.end), end: cursor_offset(&self.active.lines, &offsets, range.end),
text: Cow::Borrowed(""), text: Cow::Borrowed(""),
@@ -3981,7 +4174,7 @@ impl Editor {
}; };
let first = fragment.split('\n').next().unwrap(); let first = fragment.split('\n').next().unwrap();
Edit { Edit {
target, target: Some(target),
start, start,
end: start, end: start,
caret: prefix + grapheme_byte(first, first_nonblank(first)), caret: prefix + grapheme_byte(first, first_nonblank(first)),
@@ -4011,7 +4204,7 @@ impl Editor {
let start = cursor_offset(&self.active.lines, &offsets, cursor); let start = cursor_offset(&self.active.lines, &offsets, cursor);
let text = &fragments[if fragments.len() == 1 { 0 } else { target }]; let text = &fragments[if fragments.len() == 1 { 0 } else { target }];
Edit { Edit {
target, target: Some(target),
start, start,
end: start, end: start,
text: Cow::Borrowed(text), text: Cow::Borrowed(text),
@@ -4041,6 +4234,7 @@ impl Editor {
} }
fn finish_insert(&mut self) { fn finish_insert(&mut self) {
self.finish_completion();
let recipe = self.active.pending_edit.take(); let recipe = self.active.pending_edit.take();
if let Some(before) = self.active.insert_start.take() { if let Some(before) = self.active.insert_start.take() {
if let Some(mut recipe) = recipe { if let Some(mut recipe) = recipe {
@@ -4297,6 +4491,40 @@ impl Editor {
self.backspace(mask); self.backspace(mask);
} }
} }
InsertStep::Completion {
primary_edit,
edits,
} => {
let origin = grapheme_offset(&self.active.lines, self.cursor());
let resolve = |distance: isize| {
origin
.checked_add_signed(distance)
.and_then(|index| grapheme_cursor(&self.active.lines, index))
.ok_or("Repeat completion range is outside the document")
};
let offsets = line_offsets(&self.active.lines);
let mut batch = Vec::with_capacity(edits.len());
for (index, edit) in edits.iter().enumerate() {
let start = resolve(edit.start)?;
let end = resolve(edit.end)?;
if edit.context.as_ref().is_some_and(|context| {
*context != self.active.lines[start.row..=end.row]
}) {
return Err("Repeat completion additional-edit context changed");
}
// Translating every boundary by the same origin preserves
// the validated ordering and nonoverlap of the original batch.
batch.push(Edit {
target: (index == *primary_edit).then_some(self.active.primary),
start: cursor_offset(&self.active.lines, &offsets, start),
end: cursor_offset(&self.active.lines, &offsets, end),
text: Cow::Borrowed(&edit.text),
caret: edit.text.len(),
round_up: true,
});
}
self.apply_edits(batch);
}
} }
} }
if self.active.mode == Mode::Insert { if self.active.mode == Mode::Insert {
@@ -4548,7 +4776,11 @@ impl Editor {
/// Every edit in a batch refers to this one pre-edit buffer. Duplicate row /// Every edit in a batch refers to this one pre-edit buffer. Duplicate row
/// operations share an edit; caret destinations are remapped only afterwards. /// operations share an edit; caret destinations are remapped only afterwards.
// ponytail: rebuilding text costs O(buffer + carets²); use per-line edits for large buffers. // ponytail: rebuilding text costs O(buffer + carets²); use per-line edits for large buffers.
fn apply_edits(&mut self, mut edits: Vec<Edit<'_>>) { fn apply_edits(&mut self, edits: Vec<Edit<'_>>) {
self.apply_edits_from(edits, None);
}
fn apply_edits_from(&mut self, mut edits: Vec<Edit<'_>>, original: Option<&Snapshot>) {
if edits.is_empty() { if edits.is_empty() {
return; return;
} }
@@ -4565,8 +4797,10 @@ impl Editor {
} }
unique.push(index); unique.push(index);
} }
let old = self.active.lines.join("\n"); let lines = original.map_or(&self.active.lines, |before| &before.lines);
let offsets = line_offsets(&self.active.lines); let targets = original.map_or(&self.active.targets, |before| &before.targets);
let old = lines.join("\n");
let offsets = line_offsets(lines);
let removed: usize = unique let removed: usize = unique
.iter() .iter()
.map(|index| edits[*index].end - edits[*index].start) .map(|index| edits[*index].end - edits[*index].start)
@@ -4598,27 +4832,31 @@ impl Editor {
} }
position - removed + added position - removed + added
}; };
let mut destinations: Vec<_> = self let mut destinations: Vec<_> = targets
.active
.targets
.iter() .iter()
.map(|target| { .map(|target| {
( (
remap( remap(cursor_offset(lines, &offsets, target.active), true),
cursor_offset(&self.active.lines, &offsets, target.active),
true,
),
true, true,
) )
}) })
.collect(); .collect();
for edit in &edits { for edit in &edits {
destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up); if let Some(target) = edit.target {
destinations[target] = (remap(edit.start, false) + edit.caret, edit.round_up);
}
} }
if text != old { if !text
.split('\n')
.eq(self.active.lines.iter().map(String::as_str))
{
self.active.text_changed(); self.active.text_changed();
self.active.search_match = None; self.active.search_match = None;
} }
if let Some(original) = original {
self.active.targets.clone_from(&original.targets);
self.active.primary = original.primary;
}
self.active.lines = text.split('\n').map(str::to_owned).collect(); self.active.lines = text.split('\n').map(str::to_owned).collect();
let offsets = line_offsets(&self.active.lines); let offsets = line_offsets(&self.active.lines);
for (target, (byte, round_up)) in self.active.targets.iter_mut().zip(destinations) { for (target, (byte, round_up)) in self.active.targets.iter_mut().zip(destinations) {
@@ -4642,7 +4880,7 @@ impl Editor {
.map(|(target, item)| { .map(|(target, item)| {
let start = cursor_offset(&self.active.lines, &offsets, item.active); let start = cursor_offset(&self.active.lines, &offsets, item.active);
Edit { Edit {
target, target: Some(target),
start, start,
end: start, end: start,
text: Cow::Borrowed(text), text: Cow::Borrowed(text),
@@ -4669,7 +4907,7 @@ impl Editor {
0 0
}; };
Edit { Edit {
target, target: Some(target),
start, start,
end: start, end: start,
text: Cow::Borrowed("\n"), text: Cow::Borrowed("\n"),
@@ -4704,7 +4942,7 @@ impl Editor {
return None; return None;
}; };
Some(Edit { Some(Edit {
target, target: Some(target),
start, start,
end, end,
text: Cow::Borrowed(""), text: Cow::Borrowed(""),
@@ -4743,7 +4981,7 @@ impl Editor {
}; };
let start = offsets[row] + line.len(); let start = offsets[row] + line.len();
Some(Edit { Some(Edit {
target, target: Some(target),
start, start,
end: start + 1 + next.len() - trimmed.len(), end: start + 1 + next.len() - trimmed.len(),
text: Cow::Borrowed(separator), text: Cow::Borrowed(separator),
@@ -4774,7 +5012,7 @@ impl Editor {
cursor_offset(&self.active.lines, &offsets, self.previous_boundary(cursor)); cursor_offset(&self.active.lines, &offsets, self.previous_boundary(cursor));
let end = cursor_offset(&self.active.lines, &offsets, cursor); let end = cursor_offset(&self.active.lines, &offsets, cursor);
Some(Edit { Some(Edit {
target, target: Some(target),
start, start,
end, end,
text: Cow::Borrowed(""), text: Cow::Borrowed(""),
@@ -4913,6 +5151,120 @@ fn valid_command(command: &str) -> bool {
}) })
} }
fn grapheme_offset(lines: &[String], cursor: Cursor) -> usize {
lines[..cursor.row]
.iter()
.map(|line| grapheme_count(line) + 1)
.sum::<usize>()
+ cursor.column
}
fn grapheme_cursor(lines: &[String], mut index: usize) -> Option<Cursor> {
for (row, line) in lines.iter().enumerate() {
let columns = grapheme_count(line);
if index <= columns {
return Some(Cursor { row, column: index });
}
index -= columns + 1;
}
None
}
fn validate_completion_items(lines: &[String], items: &[CompletionItem]) -> Result<(), String> {
let mut total = 0usize;
let offsets = line_offsets(lines);
for item in items {
if item.primary_edit >= item.edits.len() {
return Err("Completion has no primary edit".into());
}
for text in std::iter::once(item.label.as_str())
.chain(item.detail.as_deref())
.chain(item.edits.iter().map(|edit| edit.text.as_str()))
{
total = total
.checked_add(text.len())
.ok_or("Completions exceed 4 MiB")?;
if total > MAX_EDIT_BYTES {
return Err("Completions exceed 4 MiB".into());
}
validate_text(text)?;
}
let mut ranges = Vec::with_capacity(item.edits.len());
for edit in &item.edits {
for position in [edit.start, edit.end] {
if lines
.get(position.row)
.is_none_or(|line| position.column > grapheme_count(line))
{
return Err("Completion range is outside the document".into());
}
}
let start = cursor_offset(
lines,
&offsets,
Cursor {
row: edit.start.row,
column: edit.start.column,
},
);
let end = cursor_offset(
lines,
&offsets,
Cursor {
row: edit.end.row,
column: edit.end.column,
},
);
if start > end {
return Err("Completion range is reversed".into());
}
ranges.push((start, end));
}
ranges.sort_unstable();
if ranges
.windows(2)
.any(|pair| pair[0].1 > pair[1].0 || pair[0].0 == pair[1].0)
{
return Err("Completion edits overlap".into());
}
}
Ok(())
}
fn completion_edits<'a>(
lines: &[String],
offsets: &[usize],
primary: usize,
item: &'a CompletionItem,
) -> Vec<Edit<'a>> {
item.edits
.iter()
.enumerate()
.map(|(index, edit)| Edit {
target: (index == item.primary_edit).then_some(primary),
start: cursor_offset(
lines,
offsets,
Cursor {
row: edit.start.row,
column: edit.start.column,
},
),
end: cursor_offset(
lines,
offsets,
Cursor {
row: edit.end.row,
column: edit.end.column,
},
),
text: Cow::Borrowed(&edit.text),
caret: edit.text.len(),
round_up: true,
})
.collect()
}
pub(crate) fn validate_hover_text(text: &str) -> Result<(), String> { pub(crate) fn validate_hover_text(text: &str) -> Result<(), String> {
if text.len() > MAX_EDIT_BYTES { if text.len() > MAX_EDIT_BYTES {
Err("Hover exceeds 4 MiB".into()) Err("Hover exceeds 4 MiB".into())
@@ -5084,6 +5436,7 @@ fn atomic_save(path: &Path, lines: &[String]) -> io::Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::plugin::CompletionEdit;
struct Directory(PathBuf); struct Directory(PathBuf);
impl Directory { impl Directory {
@@ -12331,4 +12684,351 @@ mod tests {
assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(fs::read_to_string(source).unwrap(), "source"); assert_eq!(fs::read_to_string(source).unwrap(), "source");
} }
fn completion_item(start: (usize, usize), end: (usize, usize), text: &str) -> CompletionItem {
CompletionItem {
label: text.into(),
detail: None,
primary_edit: 0,
edits: vec![CompletionEdit {
start: TextPosition {
row: start.0,
column: start.1,
},
end: TextPosition {
row: end.0,
column: end.1,
},
text: text.into(),
}],
}
}
#[test]
fn completion_cycles_from_original_spans_and_restores_pending_typing() {
let mut editor = editor();
type_text(&mut editor, "iabcd");
let revision = editor.text_revision();
let steps = editor.active.pending_edit.as_ref().unwrap().steps.len();
editor
.show_completion(vec![
completion_item((0, 2), (0, 4), "X"),
completion_item((0, 0), (0, 1), "Q\nZ"),
])
.unwrap();
assert_eq!(editor.context().text, "abX");
assert_eq!(editor.completion().unwrap().selected, 1);
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.context().text, "Q\nZbcd");
assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 });
editor.handle(Input::Ctrl('j'));
assert_eq!(editor.context().text, "abcd");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
assert_eq!(editor.completion().unwrap().selected, 0);
assert_eq!(
editor.active.pending_edit.as_ref().unwrap().steps.len(),
steps
);
assert!(editor.text_revision() > revision);
editor.handle(Input::Ctrl('k'));
assert_eq!(editor.context().text, "Q\nZbcd");
editor.handle(Input::Char('!'));
assert!(editor.completion().is_none());
assert_eq!(editor.context().text, "Q\nZ!bcd");
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
}
#[test]
fn completion_same_span_noop_preview_and_original_preserve_redo() {
let mut editor = editor();
type_text(&mut editor, "iold");
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.active.redo.len(), 1);
editor.handle(Input::Char('i'));
editor
.show_completion(vec![
completion_item((0, 0), (0, 0), "one"),
completion_item((0, 0), (0, 0), "one"),
])
.unwrap();
let revision = editor.text_revision();
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.context().text, "one");
assert_eq!(editor.text_revision(), revision);
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Escape);
assert_eq!(editor.active.redo.len(), 1);
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "old");
}
#[test]
fn completion_noncycle_keys_keep_standard_insert_semantics() {
for (key, expected, mode) in [
(Input::Char('!'), "completed!", Mode::Insert),
(Input::Enter, "completed\n", Mode::Insert),
(Input::Char('\t'), "completed\t", Mode::Insert),
(Input::Escape, "completed", Mode::Normal),
(Input::Left, "completed", Mode::Insert),
(Input::Ctrl('z'), "completed", Mode::Insert),
] {
let mut editor = editor();
type_text(&mut editor, "iprefix");
editor
.show_completion(vec![completion_item((0, 0), (0, 6), "completed")])
.unwrap();
editor.handle(key);
assert!(editor.completion().is_none());
assert_eq!(editor.context().text, expected);
assert_eq!(editor.mode(), mode);
if mode == Mode::Insert {
editor.handle(Input::Escape);
}
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
}
}
#[test]
fn completion_request_and_cycle_use_effective_insert_binding() {
let mut editor = editor();
type_text(&mut editor, "iab");
let steps = editor.active.pending_edit.as_ref().unwrap().steps.len();
assert_eq!(
editor.handle(Input::Ctrl('n')),
Some(Invocation {
plugin: "core".into(),
command: "lsp.completion".into(),
args: String::new(),
})
);
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(
editor.active.pending_edit.as_ref().unwrap().steps.len(),
steps
);
assert!(editor.active.undo.is_empty());
editor
.show_completion(vec![
completion_item((0, 0), (0, 2), "first"),
completion_item((0, 0), (0, 2), "second"),
])
.unwrap();
editor.bind("insert", "Ctrl+n", None).unwrap();
editor
.bind("insert", "Ctrl+x", Some("core.lsp.completion".into()))
.unwrap();
editor.handle(Input::Ctrl('x'));
assert_eq!(editor.context().text, "second");
editor.handle(Input::Ctrl('n'));
assert!(editor.completion().is_none());
assert_eq!(editor.context().text, "second");
}
#[test]
fn completion_dot_rebases_only_final_choice_and_refuses_out_of_bounds_atomically() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("left ab".into())])
.unwrap();
editor.handle(Input::Char('A'));
editor
.show_completion(vec![
completion_item((0, 5), (0, 7), "wrong"),
completion_item((0, 5), (0, 7), "final"),
])
.unwrap();
editor.handle(Input::Ctrl('n'));
editor.handle(Input::Escape);
editor
.apply(vec![Effect::Replace("different zz".into())])
.unwrap();
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, "different final");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "different zz");
editor.apply(vec![Effect::Replace("x".into())]).unwrap();
let revision = editor.text_revision();
let cursor = editor.cursor();
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, "x");
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.text_revision(), revision);
assert!(editor.message().contains("outside"));
editor
.apply(vec![Effect::Replace("new yy".into())])
.unwrap();
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, "new final");
}
#[test]
fn completion_additional_edits_translate_unicode_secondary_carets() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("e\u{301}x\n👩‍🚀z".into())])
.unwrap();
editor.handle(Input::Ctrl('e'));
let undo_carets = editor.carets();
editor.handle(Input::Char('A'));
let original = editor.carets();
let mut item = completion_item((1, 0), (1, 2), "界\n好");
item.edits.push(CompletionEdit {
start: TextPosition { row: 0, column: 0 },
end: TextPosition { row: 0, column: 1 },
text: "prefix".into(),
});
editor.show_completion(vec![item]).unwrap();
assert_eq!(editor.context().text, "prefixx\n界\n好");
assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 });
assert_eq!(
editor.carets(),
[Cursor { row: 0, column: 7 }, Cursor { row: 2, column: 1 },]
);
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.context().text, "e\u{301}x\n👩‍🚀z");
assert_eq!(editor.carets(), original);
editor.handle(Input::Ctrl('k'));
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "e\u{301}x\n👩‍🚀z");
assert_eq!(editor.carets(), undo_carets);
}
#[test]
fn completion_validation_rejects_entire_set_before_any_mutation() {
let mut editor = editor();
type_text(&mut editor, "iabc");
let good = completion_item((0, 0), (0, 3), "valid");
let mut bad_primary = good.clone();
bad_primary.primary_edit = 1;
let mut overlap = good.clone();
overlap.edits.push(overlap.edits[0].clone());
for invalid in [
bad_primary,
overlap,
completion_item((1, 0), (1, 0), "bad"),
completion_item((0, 4), (0, 4), "bad"),
completion_item((0, 2), (0, 1), "bad"),
completion_item((0, 0), (0, 3), "\0"),
completion_item((0, 0), (0, 3), &"x".repeat(MAX_EDIT_BYTES + 1)),
] {
let revision = editor.text_revision();
let steps = editor.active.pending_edit.as_ref().unwrap().steps.len();
assert!(editor.show_completion(vec![good.clone(), invalid]).is_err());
assert_eq!(editor.context().text, "abc");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 });
assert_eq!(editor.text_revision(), revision);
assert_eq!(
editor.active.pending_edit.as_ref().unwrap().steps.len(),
steps
);
assert!(editor.completion().is_none());
assert!(editor.active.undo.is_empty());
}
let revision = editor.text_revision();
editor.show_completion(Vec::new()).unwrap();
assert_eq!(editor.context().text, "abc");
assert_eq!(editor.text_revision(), revision);
assert_eq!(editor.mode(), Mode::Insert);
assert!(editor.completion().is_none());
assert_eq!(editor.message(), "No completions");
}
#[test]
fn completion_overlay_command_buffer_and_paste_boundaries_commit_displayed_choice() {
for boundary in ["palette", "startup", "command", "paste", "rejected_paste"] {
let mut editor = editor();
type_text(&mut editor, "iab");
editor
.show_completion(vec![completion_item((0, 0), (0, 2), "chosen")])
.unwrap();
match boundary {
"palette" => {
editor.handle(Input::Ctrl('p'));
}
"startup" => editor.show_startup_diagnostic("notice".into()),
"command" => {
editor.execute("buffers");
}
"paste" => {
editor.handle(Input::Paste("!".into()));
}
_ => {
editor.handle(Input::Paste("\0".into()));
}
}
assert!(editor.completion().is_none());
assert_eq!(
editor.context().text,
if boundary == "paste" {
"chosen!"
} else {
"chosen"
}
);
if boundary == "palette" || boundary == "startup" {
editor.handle(Input::Escape);
}
if editor.mode() == Mode::Insert {
editor.handle(Input::Escape);
}
if boundary == "paste" {
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "chosen");
}
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
}
let directory = Directory::new();
let other = directory.path("other");
fs::write(&other, "other").unwrap();
let mut editor = editor();
type_text(&mut editor, "iab");
editor
.show_completion(vec![completion_item((0, 0), (0, 2), "chosen")])
.unwrap();
editor.execute(&format!("edit {}", other.display()));
assert!(editor.completion().is_none());
editor.execute("buffer 1");
assert_eq!(editor.context().text, "chosen");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
}
#[test]
fn completion_dot_additional_edits_require_compatible_context_and_replay_atomically() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("use\nab".into())])
.unwrap();
type_text(&mut editor, "jA");
let mut item = completion_item((1, 0), (1, 2), "done");
item.edits.push(CompletionEdit {
start: TextPosition { row: 0, column: 0 },
end: TextPosition { row: 0, column: 3 },
text: "import".into(),
});
editor.show_completion(vec![item]).unwrap();
editor.handle(Input::Escape);
editor
.apply(vec![Effect::Replace("use\nzz".into())])
.unwrap();
type_text(&mut editor, "j.");
assert_eq!(editor.context().text, "import\ndone");
editor
.apply(vec![Effect::Replace("bad\nzz".into())])
.unwrap();
type_text(&mut editor, "j");
let before = editor.context().text;
let carets = editor.carets();
let revision = editor.text_revision();
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, before);
assert_eq!(editor.carets(), carets);
assert_eq!(editor.text_revision(), revision);
assert!(editor.message().contains("context changed"));
}
} }
+423 -4
View File
@@ -18,9 +18,9 @@ use unicode_segmentation::UnicodeSegmentation;
use crate::{ use crate::{
editor::Context, editor::Context,
plugin::{ plugin::{
Diagnostic, DiagnosticSeverity, DocumentEvent, DocumentEventKind, DocumentSnapshot, CompletionEdit, CompletionItem, Diagnostic, DiagnosticSeverity, DocumentEvent,
PluginUpdate, SemanticToken, SemanticTokenKind, SemanticTokenModifier, TextPosition, DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind,
UpdateEffect, SemanticTokenModifier, TextPosition, UpdateEffect,
}, },
}; };
@@ -94,6 +94,7 @@ enum Input {
enum PositionKind { enum PositionKind {
Definition, Definition,
Hover, Hover,
Completion,
} }
impl PositionKind { impl PositionKind {
@@ -101,6 +102,7 @@ impl PositionKind {
match self { match self {
Self::Definition => "definition", Self::Definition => "definition",
Self::Hover => "hover", Self::Hover => "hover",
Self::Completion => "completion",
} }
} }
} }
@@ -265,6 +267,10 @@ impl Lsp {
self.position(context, request_id, PositionKind::Hover) self.position(context, request_id, PositionKind::Hover)
} }
pub fn completion(&mut self, context: &Context, request_id: u64) -> Result<(), String> {
self.position(context, request_id, PositionKind::Completion)
}
fn position( fn position(
&mut self, &mut self,
context: &Context, context: &Context,
@@ -600,6 +606,7 @@ enum Request {
Initialize, Initialize,
Definition(Source), Definition(Source),
Hover(Source), Hover(Source),
Completion(Source, Arc<DocumentSnapshot>),
SemanticTokens(Arc<DocumentSnapshot>), SemanticTokens(Arc<DocumentSnapshot>),
Shutdown, Shutdown,
} }
@@ -620,6 +627,7 @@ struct Runtime {
ready: bool, ready: bool,
definition: bool, definition: bool,
hover: bool, hover: bool,
completion: bool,
semantic: Option<SemanticOptions>, semantic: Option<SemanticOptions>,
sync: SyncOptions, sync: SyncOptions,
} }
@@ -649,6 +657,7 @@ impl Runtime {
ready: false, ready: false,
definition: false, definition: false,
hover: false, hover: false,
completion: false,
semantic: None, semantic: None,
sync: SyncOptions::default(), sync: SyncOptions::default(),
}; };
@@ -670,6 +679,19 @@ impl Runtime {
"definition": {"dynamicRegistration": false, "linkSupport": true}, "definition": {"dynamicRegistration": false, "linkSupport": true},
"hover": {"dynamicRegistration": false, "hover": {"dynamicRegistration": false,
"contentFormat": ["plaintext", "markdown"]}, "contentFormat": ["plaintext", "markdown"]},
"completion": {
"dynamicRegistration": false, "contextSupport": true,
"insertTextMode": 1,
"completionItem": {
"snippetSupport": false, "commitCharactersSupport": false,
"insertReplaceSupport": true,
"insertTextModeSupport": {"valueSet": [1]},
"resolveSupport": {"properties": []}
},
"completionList": {
"itemDefaults": ["editRange", "insertTextFormat", "insertTextMode"]
}
},
"publishDiagnostics": {"relatedInformation": false, "versionSupport": true}, "publishDiagnostics": {"relatedInformation": false, "versionSupport": true},
"semanticTokens": { "semanticTokens": {
"dynamicRegistration": false, "dynamicRegistration": false,
@@ -942,17 +964,26 @@ impl Runtime {
"textDocument/hover", "textDocument/hover",
"server does not support hover", "server does not support hover",
), ),
PositionKind::Completion => (
self.completion,
"textDocument/completion",
"server does not support completion",
),
}; };
if !supported { if !supported {
return self.emit(message(unsupported, Some(source.request_id))); return self.emit(message(unsupported, Some(source.request_id)));
} }
let position = outbound_position(&doc.snapshot.text, source.row, source.column)?; let position = outbound_position(&doc.snapshot.text, source.row, source.column)?;
let params = json!({ let mut params = json!({
"textDocument": {"uri": doc.uri}, "position": position "textDocument": {"uri": doc.uri}, "position": position
}); });
if matches!(kind, PositionKind::Completion) {
params["context"] = json!({"triggerKind": 1});
}
let request = match kind { let request = match kind {
PositionKind::Definition => Request::Definition(source), PositionKind::Definition => Request::Definition(source),
PositionKind::Hover => Request::Hover(source), PositionKind::Hover => Request::Hover(source),
PositionKind::Completion => Request::Completion(source, Arc::clone(&doc.snapshot)),
}; };
self.request(method, Some(params), request) self.request(method, Some(params), request)
} }
@@ -986,6 +1017,35 @@ impl Runtime {
Ok(()) Ok(())
} }
fn completion_result(
&self,
source: Source,
snapshot: Arc<DocumentSnapshot>,
result: Result<&Value, String>,
) -> Result<(), String> {
if !self
.documents
.get(&source.buffer_id)
.is_some_and(|doc| doc.primary && Arc::ptr_eq(&doc.snapshot, &snapshot))
{
return Ok(());
}
match result.and_then(|value| decode_completion(&snapshot.text, value, &source)) {
Ok(items) => self.emit(PluginUpdate {
request_id: Some(source.request_id),
effects: vec![UpdateEffect::Completion {
buffer_id: source.buffer_id,
revision: source.revision,
items,
}],
}),
Err(reason) => self.emit(message(
&format!("completion failed: {reason}"),
Some(source.request_id),
)),
}
}
fn semantic_result( fn semantic_result(
&mut self, &mut self,
snapshot: Arc<DocumentSnapshot>, snapshot: Arc<DocumentSnapshot>,
@@ -1074,6 +1134,11 @@ impl Runtime {
Request::Hover(source) => { Request::Hover(source) => {
self.hover_result(source, Err("unexpected LSP response".into())) self.hover_result(source, Err("unexpected LSP response".into()))
} }
Request::Completion(source, snapshot) => self.completion_result(
source,
snapshot,
Err("unexpected LSP response".into()),
),
Request::SemanticTokens(snapshot) => { Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Err("unexpected LSP response".into())) self.semantic_result(snapshot, Err("unexpected LSP response".into()))
} }
@@ -1089,6 +1154,11 @@ impl Runtime {
Request::Hover(source) => { Request::Hover(source) => {
self.hover_result(source, Err("invalid LSP error response".into())) self.hover_result(source, Err("invalid LSP error response".into()))
} }
Request::Completion(source, snapshot) => self.completion_result(
source,
snapshot,
Err("invalid LSP error response".into()),
),
Request::SemanticTokens(snapshot) => { Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Err("invalid LSP error response".into())) self.semantic_result(snapshot, Err("invalid LSP error response".into()))
} }
@@ -1102,6 +1172,9 @@ impl Runtime {
Some(source.request_id), Some(source.request_id),
)), )),
Request::Hover(source) => self.hover_result(source, Err(text.to_owned())), Request::Hover(source) => self.hover_result(source, Err(text.to_owned())),
Request::Completion(source, snapshot) => {
self.completion_result(source, snapshot, Err(text.to_owned()))
}
Request::SemanticTokens(snapshot) => { Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Err(text.to_owned())) self.semantic_result(snapshot, Err(text.to_owned()))
} }
@@ -1112,6 +1185,9 @@ impl Runtime {
Request::Initialize => self.initialized(&frame["result"]), Request::Initialize => self.initialized(&frame["result"]),
Request::Definition(source) => self.definition_result(source, &frame["result"]), Request::Definition(source) => self.definition_result(source, &frame["result"]),
Request::Hover(source) => self.hover_result(source, Ok(&frame["result"])), Request::Hover(source) => self.hover_result(source, Ok(&frame["result"])),
Request::Completion(source, snapshot) => {
self.completion_result(source, snapshot, Ok(&frame["result"]))
}
Request::SemanticTokens(snapshot) => { Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Ok(&frame["result"])) self.semantic_result(snapshot, Ok(&frame["result"]))
} }
@@ -1138,6 +1214,9 @@ impl Runtime {
self.hover = capabilities self.hover = capabilities
.get("hoverProvider") .get("hoverProvider")
.is_some_and(|value| value == true || value.is_object()); .is_some_and(|value| value == true || value.is_object());
self.completion = capabilities
.get("completionProvider")
.is_some_and(Value::is_object);
match semantic_options(capabilities.get("semanticTokensProvider")) { match semantic_options(capabilities.get("semanticTokensProvider")) {
Ok(options) => self.semantic = options, Ok(options) => self.semantic = options,
Err(reason) => { Err(reason) => {
@@ -2364,6 +2443,319 @@ fn normalized(text: &str) -> std::borrow::Cow<'_, str> {
} }
} }
fn completion_string<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, String> {
value
.get(field)
.map(|value| {
value
.as_str()
.ok_or_else(|| format!("invalid completion {field}"))
})
.transpose()
}
fn completion_text(text: &str, multiline: bool) -> Result<(), String> {
if text
.chars()
.any(|ch| ch.is_control() && !(multiline && matches!(ch, '\n' | '\r' | '\t')))
{
return Err("unsupported control character in completion".into());
}
Ok(())
}
fn completion_position(lines: &[&str], value: &Value) -> Result<TextPosition, String> {
let (row, units) = coordinates(value)?;
let line = lines
.get(row)
.ok_or("completion line is outside the document")?;
let mut offset = 0;
let mut column = 0;
for cluster in line.graphemes(true) {
if units == offset {
return Ok(TextPosition { row, column });
}
offset += cluster.encode_utf16().count();
if units < offset {
return Err("completion position splits a grapheme".into());
}
column += 1;
}
if units != offset {
return Err("completion position is outside the line".into());
}
Ok(TextPosition { row, column })
}
fn completion_range(lines: &[&str], value: &Value) -> Result<(TextPosition, TextPosition), String> {
let start = completion_position(lines, &value["start"])?;
let end = completion_position(lines, &value["end"])?;
if (start.row, start.column) > (end.row, end.column) {
return Err("reversed completion range".into());
}
Ok((start, end))
}
fn completion_primary_range(
lines: &[&str],
value: &Value,
source: &Source,
) -> Result<(TextPosition, TextPosition), String> {
let (start, end) = completion_range(lines, value)?;
if start.row != source.row
|| end.row != source.row
|| start.column > source.column
|| end.column < source.column
{
return Err(
"completion primary range must be single-line and contain the request position".into(),
);
}
Ok((start, end))
}
fn completion_replacement(
lines: &[&str],
value: &Value,
source: &Source,
) -> Result<(TextPosition, TextPosition), String> {
if value.get("insert").is_some() || value.get("replace").is_some() {
if value.get("range").is_some() || value.get("start").is_some() {
return Err("ambiguous completion edit range".into());
}
let (start, end) = completion_primary_range(lines, &value["replace"], source)?;
let (insert_start, insert_end) = completion_primary_range(lines, &value["insert"], source)?;
if insert_start != start || insert_end.column > end.column {
return Err("completion insert range is not a prefix of replace range".into());
}
Ok((start, end))
} else {
completion_primary_range(lines, value.get("range").unwrap_or(value), source)
}
}
fn decode_completion(
text: &str,
result: &Value,
source: &Source,
) -> Result<Vec<CompletionItem>, String> {
if result.is_null() {
return Ok(Vec::new());
}
let (items, defaults) = if let Some(items) = result.as_array() {
(items, None)
} else {
if !result["isIncomplete"].is_boolean() {
return Err("invalid completion list isIncomplete".into());
}
let defaults = result.get("itemDefaults");
if defaults.is_some_and(|value| !value.is_object()) {
return Err("invalid completion itemDefaults".into());
}
if let Some(defaults) = defaults {
for key in defaults.as_object().unwrap().keys() {
if !matches!(
key.as_str(),
"editRange" | "insertTextFormat" | "insertTextMode"
) {
return Err("unsupported completion item default".into());
}
}
}
(
result["items"]
.as_array()
.ok_or("invalid completion items")?,
defaults,
)
};
let text = normalized(text);
let lines: Vec<_> = text.split('\n').collect();
let line = lines
.get(source.row)
.ok_or("completion request line is outside the document")?;
let mut prefix_start = 0;
let mut prefix_byte = 0;
let mut caret_byte = line.len();
let mut columns = 0;
for (column, (byte, cluster)) in line.grapheme_indices(true).enumerate() {
if column == source.column {
caret_byte = byte;
break;
}
if !cluster
.chars()
.next()
.is_some_and(|ch| ch.is_alphanumeric() || ch == '_')
{
prefix_start = column + 1;
prefix_byte = byte + cluster.len();
}
columns = column + 1;
}
if source.column > columns {
return Err("completion request column is outside the line".into());
}
let prefix = &line[prefix_byte..caret_byte];
// The prefix is the only guessed range; explicit server edits remain authoritative.
if let Some(defaults) = defaults {
if let Some(range) = defaults.get("editRange") {
completion_replacement(&lines, range, source)?;
}
for name in ["insertTextFormat", "insertTextMode"] {
if defaults
.get(name)
.is_some_and(|value| !matches!(value.as_u64(), Some(1 | 2)))
{
return Err(format!("invalid completion default {name}"));
}
}
}
let mut decoded = Vec::with_capacity(items.len());
let mut bytes = 0usize;
for item in items {
if !item.is_object() {
return Err("invalid completion item".into());
}
let label = completion_string(item, "label")?.ok_or("missing completion label")?;
completion_text(label, false)?;
let detail = completion_string(item, "detail")?;
let sort = completion_string(item, "sortText")?.unwrap_or(label);
let filter = completion_string(item, "filterText")?;
let insert = completion_string(item, "insertText")?;
let edit_text = completion_string(item, "textEditText")?;
if let Some(detail) = detail {
completion_text(detail, true)?;
}
completion_text(sort, false)?;
if let Some(filter) = filter {
completion_text(filter, false)?;
}
let property = |name: &str| {
item.get(name)
.or_else(|| defaults.and_then(|value| value.get(name)))
};
let format = property("insertTextFormat")
.map(|value| {
value
.as_u64()
.filter(|value| matches!(value, 1 | 2))
.ok_or("invalid completion insertTextFormat")
})
.transpose()?
.unwrap_or(1);
let mode = property("insertTextMode")
.map(|value| {
value
.as_u64()
.filter(|value| matches!(value, 1 | 2))
.ok_or("invalid completion insertTextMode")
})
.transpose()?
.unwrap_or(1);
// No snippets, indentation adjustment, server commands or lazy edit resolution.
if format == 2 || mode == 2 || item.get("command").is_some() {
continue;
}
let explicit = item.get("textEdit");
let default_range = defaults.and_then(|value| value.get("editRange"));
let (start, end, new_text) = if let Some(edit) = explicit {
if !edit.is_object() {
return Err("invalid completion textEdit".into());
}
let (start, end) = completion_replacement(&lines, edit, source)?;
let new_text =
completion_string(edit, "newText")?.ok_or("missing completion newText")?;
(start, end, new_text)
} else if let Some(range) = default_range {
let (start, end) = completion_replacement(&lines, range, source)?;
(start, end, edit_text.unwrap_or(label))
} else {
(
TextPosition {
row: source.row,
column: prefix_start,
},
TextPosition {
row: source.row,
column: source.column,
},
insert.unwrap_or(label),
)
};
let filter_prefix = if explicit.is_some() || default_range.is_some() {
let byte = line
.grapheme_indices(true)
.nth(start.column)
.map_or(line.len(), |(byte, _)| byte);
&line[byte..caret_byte]
} else {
prefix
};
completion_text(new_text, true)?;
let mut edits = vec![CompletionEdit {
start,
end,
text: normalized(new_text).into_owned(),
}];
if let Some(additional) = item.get("additionalTextEdits") {
for edit in additional
.as_array()
.ok_or("invalid completion additionalTextEdits")?
{
let (start, end) = completion_range(&lines, &edit["range"])?;
let new_text =
completion_string(edit, "newText")?.ok_or("missing additional edit newText")?;
completion_text(new_text, true)?;
edits.push(CompletionEdit {
start,
end,
text: normalized(new_text).into_owned(),
});
}
}
edits.sort_unstable_by_key(|edit| {
(
(edit.start.row, edit.start.column),
(edit.end.row, edit.end.column),
)
});
if edits.windows(2).any(|pair| {
(pair[0].end.row, pair[0].end.column) > (pair[1].start.row, pair[1].start.column)
|| pair[0].start == pair[1].start
}) {
return Err("overlapping completion edits".into());
}
let size = label.len()
+ detail.map_or(0, str::len)
+ edits.iter().map(|edit| edit.text.len()).sum::<usize>();
bytes = bytes
.checked_add(size)
.filter(|bytes| *bytes <= MAX_BODY)
.ok_or("completion text exceeds 4 MiB")?;
if (filter.is_some() || (explicit.is_none() && default_range.is_none()))
&& !filter.unwrap_or(label).starts_with(filter_prefix)
{
continue;
}
let primary_edit = edits
.iter()
.position(|edit| edit.start == start && edit.end == end)
.unwrap();
decoded.push((
sort,
CompletionItem {
label: label.to_owned(),
detail: detail.map(str::to_owned),
primary_edit,
edits,
},
));
}
decoded.sort_by(|a, b| a.0.cmp(b.0));
Ok(decoded.into_iter().map(|(_, item)| item).collect())
}
fn coordinates(value: &Value) -> Result<(usize, usize), String> { fn coordinates(value: &Value) -> Result<(usize, usize), String> {
let line = value["line"] let line = value["line"]
.as_u64() .as_u64()
@@ -2673,4 +3065,31 @@ mod tests {
assert!(hover_text(&value, source).is_err()); assert!(hover_text(&value, source).is_err());
} }
} }
#[test]
fn completion_text_limit_counts_the_atomic_batch_not_sort_metadata() {
let source = Source {
buffer_id: 1,
revision: 0,
row: 0,
column: 0,
request_id: 1,
};
let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]);
assert_eq!(
decode_completion("", &at_limit, &source).unwrap()[0].edits[0]
.text
.len(),
MAX_BODY - 1
);
let over_limit = json!([
{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)},
{"label": "y"}
]);
assert!(
decode_completion("", &over_limit, &source)
.unwrap_err()
.contains("4 MiB")
);
}
} }
+3 -1
View File
@@ -30,7 +30,9 @@ Visual: motions extend selections, y copy, d/x/Delete cut, c change
I/A insert at each selected line's first nonblank/end I/A insert at each selected line's first nonblank/end
Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all
v/V switches selection shape; same selector or Esc cancels retaining carets v/V switches selection shape; same selector or Esc cancels retaining carets
Insert: Esc returns to Normal retaining carets Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+j/k next/previous
Candidates replace text live; Original text restores it; other keys close and act normally
Enter inserts newline, Tab inserts tab, Esc returns to Normal retaining candidate/carets
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps) Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS] Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N] Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
+24
View File
@@ -105,6 +105,24 @@ pub struct SemanticToken {
pub modifiers: u16, pub modifiers: u16,
} }
/// A completion replacement in zero-based, end-exclusive grapheme coordinates.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CompletionEdit {
pub start: TextPosition,
pub end: TextPosition,
pub text: String,
}
/// Protocol-independent completion text and its atomic document edits.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CompletionItem {
pub label: String,
pub detail: Option<String>,
/// Index of the primary replacement; other edits never determine caret placement.
pub primary_edit: usize,
pub edits: Vec<CompletionEdit>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum UpdateEffect { pub enum UpdateEffect {
@@ -132,6 +150,12 @@ pub enum UpdateEffect {
revision: u64, revision: u64,
text: String, text: String,
}, },
#[serde(skip_deserializing)]
Completion {
buffer_id: usize,
revision: u64,
items: Vec<CompletionItem>,
},
Message { Message {
text: String, text: String,
}, },
+75 -11
View File
@@ -41,7 +41,9 @@ pub struct EditorSession {
lsp: Lsp, lsp: Lsp,
lsp_request_id: u64, lsp_request_id: u64,
lsp_source_cursor: Option<Cursor>, lsp_source_cursor: Option<Cursor>,
lsp_source_mode: Mode,
pending_hover: bool, pending_hover: bool,
pending_completion: bool,
hover_owner: Option<String>, hover_owner: Option<String>,
} }
@@ -129,7 +131,9 @@ impl EditorSession {
lsp: Lsp::new(config.lsp), lsp: Lsp::new(config.lsp),
lsp_request_id: 0, lsp_request_id: 0,
lsp_source_cursor: None, lsp_source_cursor: None,
lsp_source_mode: Mode::Normal,
pending_hover: false, pending_hover: false,
pending_completion: false,
hover_owner: None, hover_owner: None,
}; };
session.synchronize_documents(); session.synchronize_documents();
@@ -141,7 +145,7 @@ impl EditorSession {
} }
pub fn handle(&mut self, input: Input) { pub fn handle(&mut self, input: Input) {
self.cancel_pending_hover(); self.cancel_pending_popup_request();
let invocation = self.editor.handle(input); let invocation = self.editor.handle(input);
self.synchronize_documents(); self.synchronize_documents();
if let Some(invocation) = invocation { if let Some(invocation) = invocation {
@@ -151,7 +155,7 @@ impl EditorSession {
} }
pub fn execute(&mut self, command: &str) { pub fn execute(&mut self, command: &str) {
self.cancel_pending_hover(); self.cancel_pending_popup_request();
let invocation = self.editor.execute(command); let invocation = self.editor.execute(command);
self.synchronize_documents(); self.synchronize_documents();
if let Some(invocation) = invocation { if let Some(invocation) = invocation {
@@ -163,7 +167,7 @@ impl EditorSession {
fn dispatch(&mut self, invocation: Invocation) { fn dispatch(&mut self, invocation: Invocation) {
if invocation.plugin == "core" { if invocation.plugin == "core" {
let result = match invocation.command.as_str() { let result = match invocation.command.as_str() {
"lsp.definition" | "lsp.hover" => { "lsp.definition" | "lsp.hover" | "lsp.completion" => {
if let Some(id) = self.lsp_request_id.checked_add(1) { if let Some(id) = self.lsp_request_id.checked_add(1) {
self.lsp_request_id = id; self.lsp_request_id = id;
let context = self.editor.context(); let context = self.editor.context();
@@ -173,11 +177,13 @@ impl EditorSession {
}; };
self.requests.entry("core".into()).or_default().latest = Some((id, source)); self.requests.entry("core".into()).or_default().latest = Some((id, source));
self.lsp_source_cursor = Some(self.editor.cursor()); self.lsp_source_cursor = Some(self.editor.cursor());
self.lsp_source_mode = self.editor.mode();
self.pending_hover = invocation.command == "lsp.hover"; self.pending_hover = invocation.command == "lsp.hover";
if self.pending_hover { self.pending_completion = invocation.command == "lsp.completion";
self.lsp.hover(&context, id) match invocation.command.as_str() {
} else { "lsp.hover" => self.lsp.hover(&context, id),
self.lsp.definition(&context, id) "lsp.completion" => self.lsp.completion(&context, id),
_ => self.lsp.definition(&context, id),
} }
} else { } else {
Err("LSP request IDs exhausted".into()) Err("LSP request IDs exhausted".into())
@@ -187,6 +193,7 @@ impl EditorSession {
_ => unreachable!("session-dispatched core command"), _ => unreachable!("session-dispatched core command"),
}; };
if let Err(error) = result { if let Err(error) = result {
self.cancel_pending_popup_request();
self.editor.set_message(format!("LSP: {error}")); self.editor.set_message(format!("LSP: {error}"));
} }
return; return;
@@ -216,12 +223,13 @@ impl EditorSession {
} }
} }
fn cancel_pending_hover(&mut self) { fn cancel_pending_popup_request(&mut self) {
if self.pending_hover { if self.pending_hover || self.pending_completion {
if let Some(requests) = self.requests.get_mut("core") { if let Some(requests) = self.requests.get_mut("core") {
requests.latest = None; requests.latest = None;
} }
self.pending_hover = false; self.pending_hover = false;
self.pending_completion = false;
} }
} }
@@ -243,12 +251,13 @@ impl EditorSession {
source.buffer_id != self.editor.buffer_id() source.buffer_id != self.editor.buffer_id()
|| source.revision != self.editor.text_revision() || source.revision != self.editor.text_revision()
|| self.lsp_source_cursor != Some(self.editor.cursor()) || self.lsp_source_cursor != Some(self.editor.cursor())
|| self.editor.mode() != Mode::Normal || self.editor.mode() != self.lsp_source_mode
}) })
{ {
requests.latest = None; requests.latest = None;
self.lsp_source_cursor = None; self.lsp_source_cursor = None;
self.pending_hover = false; self.pending_hover = false;
self.pending_completion = false;
} }
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) { if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
self.documents.clear(); self.documents.clear();
@@ -363,7 +372,7 @@ impl EditorSession {
&& self.source_is_current(source) && self.source_is_current(source)
&& (owner != "core" && (owner != "core"
|| (self.lsp_source_cursor == Some(self.editor.cursor()) || (self.lsp_source_cursor == Some(self.editor.cursor())
&& self.editor.mode() == Mode::Normal)) && self.editor.mode() == self.lsp_source_mode))
&& self && self
.requests .requests
.get(owner) .get(owner)
@@ -390,6 +399,18 @@ impl EditorSession {
.is_none_or(|picker| !picker.focused) .is_none_or(|picker| !picker.focused)
} }
fn completion_is_current(&self, request_id: Option<u64>, source: InvocationSource) -> bool {
self.pending_completion
&& self.request_is_current("core", request_id, source)
&& self.editor.mode() == Mode::Insert
&& self.editor.palette().is_none()
&& self
.editor
.file_picker()
.is_none_or(|picker| !picker.focused)
&& self.editor.startup_diagnostic().is_none()
}
fn apply_update(&mut self, owner: &str, update: PluginUpdate) -> Result<bool, String> { fn apply_update(&mut self, owner: &str, update: PluginUpdate) -> Result<bool, String> {
if owner == "core" if owner == "core"
&& let Some(id) = update.request_id && let Some(id) = update.request_id
@@ -402,6 +423,30 @@ impl EditorSession {
{ {
return Ok(false); return Ok(false);
} }
for effect in &update.effects {
if let UpdateEffect::Completion {
buffer_id,
revision,
items,
} = effect
{
if owner != "core" {
return Err("Completion is a native LSP update only".into());
}
let source = InvocationSource {
buffer_id: *buffer_id,
revision: *revision,
};
if self.completion_is_current(update.request_id, source)
&& let Err(error) = self.editor.validate_completion(items)
{
self.pending_completion = false;
self.editor
.set_message(format!("LSP completion failed: {error}"));
return Ok(true);
}
}
}
// Validate the whole applicable annotation batch before changing any buffer. // Validate the whole applicable annotation batch before changing any buffer.
let validation = (|| -> Result<(), String> { let validation = (|| -> Result<(), String> {
for effect in &update.effects { for effect in &update.effects {
@@ -460,6 +505,7 @@ impl EditorSession {
crate::editor::validate_hover_text(text)?; crate::editor::validate_hover_text(text)?;
} }
} }
UpdateEffect::Completion { .. } => {}
UpdateEffect::Message { .. } => {} UpdateEffect::Message { .. } => {}
} }
} }
@@ -531,6 +577,24 @@ impl EditorSession {
changed = true; changed = true;
} }
} }
UpdateEffect::Completion {
buffer_id,
revision,
items,
} => {
let source = InvocationSource {
buffer_id,
revision,
};
if self.completion_is_current(update.request_id, source) {
self.pending_completion = false;
if let Err(error) = self.editor.show_completion(items) {
self.editor
.set_message(format!("LSP completion failed: {error}"));
}
changed = true;
}
}
UpdateEffect::Message { text } => { UpdateEffect::Message { text } => {
changed |= self.editor.message() != text; changed |= self.editor.message() != text;
self.editor.set_message(text); self.editor.set_message(text);
+163 -4
View File
@@ -23,7 +23,7 @@ use crossterm::{
use ratatui::{ use ratatui::{
Frame, Terminal, Frame, Terminal,
backend::CrosstermBackend, backend::CrosstermBackend,
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Position, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::Line, text::Line,
widgets::{Block, Borders, Clear, Paragraph}, widgets::{Block, Borders, Clear, Paragraph},
@@ -32,7 +32,9 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::{ use crate::{
editor::{Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic}, editor::{
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
},
files::{DockSide, FilePicker, FilePresentation, FileView}, files::{DockSide, FilePicker, FilePresentation, FileView},
plugin::{ plugin::{
Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier, Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier,
@@ -446,6 +448,7 @@ fn draw(
let height = usize::from(viewport.height); let height = usize::from(viewport.height);
let width = usize::from(viewport.width); let width = usize::from(viewport.width);
let cursor = editor.cursor(); let cursor = editor.cursor();
let mut document_caret = None;
let carets = editor.carets(); let carets = editor.carets();
let selections = editor.selections(); let selections = editor.selections();
if document.manual.as_ref().is_some_and(|manual| { if document.manual.as_ref().is_some_and(|manual| {
@@ -820,13 +823,20 @@ fn draw(
&& column >= *left && column >= *left
&& column < left.saturating_add(width) && column < left.saturating_add(width)
{ {
frame.set_cursor_position(( let position = Position::new(
viewport.x + (column - *left) as u16, viewport.x + (column - *left) as u16,
viewport.y + (cursor.row - *top) as u16, viewport.y + (cursor.row - *top) as u16,
)); );
frame.set_cursor_position(position);
document_caret = Some(position);
} }
} }
} }
if let Some(completion) = editor.completion()
&& let Some(caret) = document_caret
{
panels.completion = draw_completion(frame, completion, viewport, caret);
}
if let Some(picker) = editor.file_picker() { if let Some(picker) = editor.file_picker() {
if picker.presentation == FilePresentation::Floating { if picker.presentation == FilePresentation::Floating {
let panel = floating_panel(area); let panel = floating_panel(area);
@@ -916,6 +926,7 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
#[derive(Default)] #[derive(Default)]
struct WheelPanels { struct WheelPanels {
completion: Option<Rect>,
hover: Option<(Rect, usize)>, hover: Option<(Rect, usize)>,
hover_syntax: SyntaxCache, hover_syntax: SyntaxCache,
files: Option<(Rect, usize, usize)>, files: Option<(Rect, usize, usize)>,
@@ -952,6 +963,7 @@ impl WheelPanels {
self.manual_files = None; self.manual_files = None;
} }
self.hover = None; self.hover = None;
self.completion = None;
self.files = None; self.files = None;
} }
@@ -992,6 +1004,13 @@ impl WheelPanels {
return true; return true;
} }
let position = ratatui::layout::Position::new(mouse.column, mouse.row); let position = ratatui::layout::Position::new(mouse.column, mouse.row);
if editor.completion().is_some()
&& self
.completion
.is_some_and(|panel| panel.contains(position))
{
return true;
}
if let Some(hover) = editor.hover() if let Some(hover) = editor.hover()
&& let Some((panel, height)) = self.hover && let Some((panel, height)) = self.hover
&& panel.contains(position) && panel.contains(position)
@@ -1042,6 +1061,69 @@ impl WheelPanels {
} }
} }
fn draw_completion(
frame: &mut Frame,
completion: &Completion,
area: Rect,
caret: Position,
) -> Option<Rect> {
if area.width == 0 || area.height == 0 || !area.contains(caret) {
return None;
}
let count = completion.items.len().saturating_add(1);
let selected = completion.selected.min(count - 1);
let desired_height = count.min(8) as u16;
let below = area.bottom() - caret.y - 1;
let above = caret.y - area.y;
let downward = below >= desired_height || below >= above;
let height = desired_height.min(if downward { below } else { above }.max(1));
let first = selected.saturating_sub(usize::from(height) - 1);
let lines: Vec<Line> = (first..count)
.take(usize::from(height))
.map(|index| {
let label = if index == 0 {
"Original text".to_owned()
} else {
let item = &completion.items[index - 1];
let mut label = display_text(&item.label);
if let Some(detail) = &item.detail {
label.push_str(" · ");
label.push_str(&display_text(detail));
}
label
};
let style = if index == selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default().fg(Color::Gray).bg(Color::Black)
};
Line::styled(
visible_line(&label, 0, usize::from(area.width.min(60))),
style,
)
})
.collect();
let width = lines.iter().map(Line::width).max().unwrap_or(1).max(1) as u16;
let panel = Rect::new(
caret.x.min(area.right() - width),
if below == 0 && above == 0 {
caret.y
} else if downward {
caret.y + 1
} else {
caret.y - height
},
width,
height,
);
frame.render_widget(Clear, panel);
frame.render_widget(
Paragraph::new(lines).style(Style::default().fg(Color::Gray).bg(Color::Black)),
panel,
);
Some(panel)
}
fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect, panels: &mut WheelPanels) { fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect, panels: &mut WheelPanels) {
if area.width == 0 || area.height == 0 { if area.width == 0 || area.height == 0 {
return; return;
@@ -1548,6 +1630,83 @@ mod tests {
.join("\n") .join("\n")
} }
#[test]
fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() {
let mut completion = Completion {
items: (0..20)
.map(|index| crate::plugin::CompletionItem {
label: if index == 19 {
"界e\u{301}👩‍🚀\u{1b}".into()
} else {
format!("item-{index:02}")
},
detail: Some("\tTAIL".into()),
primary_edit: 0,
edits: vec![],
})
.collect(),
selected: 20,
};
for (width, height) in [(14, 6), (9, 1), (5, 1)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
let workspace = Rect::new(2, 0, width - 4, height);
let caret = Position::new(2, 0);
let mut panel = None;
completion.selected = 20;
terminal
.draw(|frame| {
let rows: Vec<Line> = (0..height)
.map(|_| Line::from(format!("GG{}EE", ".".repeat(usize::from(width - 4)))))
.collect();
frame.render_widget(Paragraph::new(rows), frame.area());
frame.set_cursor_position(caret);
panel = draw_completion(frame, &completion, workspace, caret);
})
.unwrap();
let mut panel = panel.unwrap();
let selected_y = panel.bottom() - 1;
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(panel.x, selected_y)].bg, Color::Cyan);
assert!(!screen_text(&terminal).contains("item-00"));
for y in 0..height {
for x in [0, 1] {
assert_eq!(buffer[(x, y)].symbol(), "G");
}
for x in [width - 2, width - 1] {
assert_eq!(buffer[(x, y)].symbol(), "E");
}
}
if workspace.width >= 5 {
assert_eq!(buffer[(panel.x, selected_y)].symbol(), "界");
assert_eq!(buffer[(panel.x + 2, selected_y)].symbol(), "e\u{301}");
assert_eq!(buffer[(panel.x + 3, selected_y)].symbol(), "👩‍🚀");
} else {
assert_eq!(buffer[(panel.x, selected_y)].symbol(), " ");
}
if workspace.width >= 6 {
assert_eq!(buffer[(panel.x + 5, selected_y)].symbol(), "�");
}
assert_eq!(terminal.get_cursor_position().unwrap(), caret);
completion.selected = 0;
terminal
.draw(|frame| {
frame.set_cursor_position(caret);
panel = draw_completion(frame, &completion, workspace, caret).unwrap();
})
.unwrap();
assert_eq!(
terminal.backend().buffer()[(panel.x, panel.y)].symbol(),
"O"
);
assert_eq!(
terminal.backend().buffer()[(panel.x, panel.y)].bg,
Color::Cyan
);
assert_eq!(terminal.get_cursor_position().unwrap(), caret);
}
}
#[test] #[test]
fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() { fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() {
let mut editor = Editor::open(None, vec![]).unwrap(); let mut editor = Editor::open(None, vec![]).unwrap();
+473 -1
View File
@@ -68,12 +68,13 @@ initialize_id = nil
initialized_sent = false initialized_sent = false
held = [] held = []
semantic_held = [] semantic_held = []
completion_held = []
definition_count = 0 definition_count = 0
source_uri = uri(source) source_uri = uri(source)
documents = {} documents = {}
control_id = nil control_id = nil
loop do loop do
if initialize_id && !initialized_sent && (!['gated', 'hover-gated', 'controlled-gated'].include?(mode) || File.exist?(release)) if initialize_id && !initialized_sent && (!['gated', 'hover-gated', 'completion-gated', 'controlled-gated'].include?(mode) || File.exist?(release))
sync = case mode sync = case mode
when 'numeric' then 1 when 'numeric' then 1
when 'none' then {'openClose' => false, 'change' => 0, 'save' => false} when 'none' then {'openClose' => false, 'change' => 0, 'save' => false}
@@ -83,6 +84,7 @@ loop do
capabilities = {'positionEncoding' => 'utf-16', capabilities = {'positionEncoding' => 'utf-16',
'textDocumentSync' => sync, 'definitionProvider' => ['unsupported', 'controlled-unsupported'].include?(mode) ? false : {'workDoneProgress' => false}, 'textDocumentSync' => sync, 'definitionProvider' => ['unsupported', 'controlled-unsupported'].include?(mode) ? false : {'workDoneProgress' => false},
'hoverProvider' => ['hover-unsupported', 'controlled-unsupported'].include?(mode) ? false : {'workDoneProgress' => false}} 'hoverProvider' => ['hover-unsupported', 'controlled-unsupported'].include?(mode) ? false : {'workDoneProgress' => false}}
capabilities['completionProvider'] = {'resolveProvider' => true} unless ['completion-unsupported', 'controlled-unsupported'].include?(mode)
if mode.include?('semantic') if mode.include?('semantic')
capabilities['semanticTokensProvider'] = { capabilities['semanticTokensProvider'] = {
'legend' => {'tokenTypes' => ['custom', 'variable', 'function', 'comment', 'type'], 'legend' => {'tokenTypes' => ['custom', 'variable', 'function', 'comment', 'type'],
@@ -115,6 +117,10 @@ loop do
request = semantic_held.delete_at(command.fetch('index', 0)) request = semantic_held.delete_at(command.fetch('index', 0))
raise 'no held semantic request' unless request raise 'no held semantic request' unless request
emit({'id' => request.fetch('id')}.merge(command.fetch('response'))) emit({'id' => request.fetch('id')}.merge(command.fetch('response')))
when 'completion-result'
request = completion_held.delete_at(command.fetch('index', 0))
raise 'no held completion request' unless request
emit({'id' => request.fetch('id')}.merge(command.fetch('response')))
when 'refresh' when 'refresh'
emit({'id' => "refresh-#{control_id}", 'method' => 'workspace/semanticTokens/refresh'}) emit({'id' => "refresh-#{control_id}", 'method' => 'workspace/semanticTokens/refresh'})
when 'die' when 'die'
@@ -223,6 +229,21 @@ loop do
hover = contents.nil? ? nil : {'contents' => contents, 'range' => range(mode == 'hover-invalid-range' ? 999 : 0, 2, 4)} hover = contents.nil? ? nil : {'contents' => contents, 'range' => range(mode == 'hover-invalid-range' ? 999 : 0, 2, 4)}
result(frame['id'], hover) result(frame['id'], hover)
end end
when 'textDocument/completion'
if mode.include?('completion-controlled')
completion_held << frame
elsif mode == 'completion-empty'
result(frame['id'], nil)
else
result(frame['id'], [
{'label' => 'alps', 'detail' => 'second candidate', 'sortText' => '2'},
{'label' => 'alpha', 'detail' => 'first candidate', 'sortText' => '1'},
{'label' => 'unrelated', 'sortText' => '0'},
{'label' => 'alSnippet', 'insertTextFormat' => 2, 'insertText' => 'al${1:snippet}'},
{'label' => 'alCommand', 'command' => {'title' => 'unsupported', 'command' => 'fix'}},
{'label' => 'alIndented', 'insertTextMode' => 2}
])
end
when 'textDocument/semanticTokens/full', 'textDocument/semanticTokens/range' when 'textDocument/semanticTokens/full', 'textDocument/semanticTokens/range'
if mode.start_with?('controlled') if mode.start_with?('controlled')
semantic_held << frame semantic_held << frame
@@ -2238,3 +2259,454 @@ fn lsp_semantic_primary_retirement_clears_active_and_parked_without_sibling_take
); );
} }
} }
fn completion_reply(session: &mut EditorSession, fixture: &Fixture, id: u64, response: Value) {
fixture.control(
0,
id,
json!({"action": "completion-result", "response": response}),
);
until(session, fixture, |_| fixture.acknowledged(0, id));
// The acknowledgement is sent after the worker queued preceding result updates.
session.poll();
}
#[test]
fn lsp_completion_live_cycle_original_and_ordinary_keys_use_one_request() {
for (input, expected, mode) in [
(Input::Char('!'), "alpha! tail\n", Mode::Insert),
(Input::Enter, "alpha\n tail\n", Mode::Insert),
(Input::Char('\t'), "alpha\t tail\n", Mode::Insert),
(Input::Escape, "alpha tail\n", Mode::Normal),
] {
let fixture = Fixture::new();
fs::write(&fixture.source, "al tail\n").unwrap();
let mut session = fixture.open("completion");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
assert!(session.editor().completion().is_none());
assert_eq!(fixture.method_count("textDocument/completion"), 0);
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |session| {
session.editor().completion().is_some()
});
let popup = session.editor().completion().unwrap();
assert_eq!(
popup
.items
.iter()
.map(|item| item.label.as_str())
.collect::<Vec<_>>(),
["alpha", "alps"]
);
assert_eq!(popup.items[0].detail.as_deref(), Some("first candidate"));
assert_eq!(popup.selected, 1);
assert_eq!(session.editor().context().text, "alpha tail\n");
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 5 });
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "alps tail\n");
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().completion().unwrap().selected, 0);
assert_eq!(session.editor().context().text, "al tail\n");
session.handle(Input::Ctrl('k'));
assert_eq!(session.editor().context().text, "alps tail\n");
session.handle(Input::Ctrl('k'));
assert_eq!(session.editor().context().text, "alpha tail\n");
session.handle(input);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, expected);
assert_eq!(session.editor().mode(), mode);
assert_eq!(fixture.method_count("textDocument/completion"), 1);
assert_eq!(fixture.method_count("completionItem/resolve"), 0);
if mode == Mode::Insert {
session.handle(Input::Escape);
}
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "al tail\n");
}
}
#[test]
fn lsp_completion_groups_typed_prefix_and_only_repeats_the_final_candidate() {
let fixture = Fixture::new();
fs::write(&fixture.source, " tail\n").unwrap();
let mut session = fixture.open("completion");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Char('a'));
session.handle(Input::Char('l'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |session| {
session.editor().completion().is_some()
});
session.handle(Input::Ctrl('n'));
session.handle(Input::Ctrl('j'));
session.handle(Input::Ctrl('k'));
session.handle(Input::Escape);
assert_eq!(session.editor().context().text, "alps tail\n");
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, " tail\n");
session.handle(Input::Char('.'));
assert_eq!(session.editor().context().text, "alps tail\n");
assert_eq!(fixture.method_count("textDocument/completion"), 1);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, " tail\n");
}
#[test]
fn lsp_completion_primary_only_translates_and_preserves_secondary_carets() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al tail\nal tail\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Ctrl('e'));
assert_eq!(session.editor().carets().len(), 2);
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
let original_carets = session.editor().carets();
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 2 });
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "alpha",
"textEdit": {"range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 2}}, "newText": "alpha"},
"additionalTextEdits": [{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, "newText": "use X;\n"}]
}]}),
);
assert_eq!(
session.editor().context().text,
"use X;\nal tail\nalpha tail\n"
);
assert_eq!(
session.editor().carets(),
[Cursor { row: 1, column: 2 }, Cursor { row: 2, column: 5 }]
);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "al tail\nal tail\n");
assert_eq!(session.editor().carets(), original_carets);
session.handle(Input::Ctrl('k'));
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "al tail\nal tail\n");
let request = fixture
.frames()
.into_iter()
.find(|frame| frame["method"] == "textDocument/completion")
.unwrap();
assert_eq!(
request["params"]["position"],
json!({"line": 1, "character": 2})
);
}
#[test]
fn lsp_completion_utf16_text_edit_insert_replace_and_defaults_are_atomic() {
let fixture = Fixture::new();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
for _ in 0..3 {
session.handle(Input::Right);
}
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
let range = |start, end| json!({"start": {"line": 0, "character": start}, "end": {"line": 0, "character": end}});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": {
"isIncomplete": true,
"itemDefaults": {"editRange": {"insert": range(1, 5), "replace": range(1, 6)}, "insertTextFormat": 1, "insertTextMode": 1},
"items": [
{"label": "first", "sortText": "1", "textEdit": {"range": range(1, 5), "newText": "λ"}},
{"label": "second", "sortText": "2", "textEdit": {"insert": range(1, 5), "replace": range(1, 6), "newText": "omega"}, "additionalTextEdits": [
{"range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 0}}, "newText": "tail"}
]},
{"label": "default", "sortText": "3", "textEditText": "δ"},
{"label": "filtered", "sortText": "0", "filterText": "does-not-match", "textEditText": "bad"}
]
}}),
);
assert_eq!(session.editor().context().text, "AλZ\n");
let popup = session.editor().completion().unwrap();
assert_eq!(popup.items.len(), 3);
assert_eq!(
popup.items[0].edits[0].start,
TextPosition { row: 0, column: 1 }
);
assert_eq!(
popup.items[0].edits[0].end,
TextPosition { row: 0, column: 3 }
);
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "Aomega\ntail");
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 6 });
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "Aδ\n");
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
session.handle(Input::Escape);
assert!(!session.editor().dirty());
let frames = fixture.frames();
let request = frames
.iter()
.find(|frame| frame["method"] == "textDocument/completion")
.unwrap();
assert_eq!(
request["params"]["position"],
json!({"line": 0, "character": 5})
);
assert_eq!(request["params"]["context"], json!({"triggerKind": 1}));
let capabilities = &frames
.iter()
.find(|frame| frame["method"] == "initialize")
.unwrap()["params"]["capabilities"]["textDocument"]["completion"];
assert_eq!(capabilities["completionItem"]["snippetSupport"], false);
assert_eq!(capabilities["completionItem"]["insertReplaceSupport"], true);
assert_eq!(
capabilities["completionItem"]["resolveSupport"]["properties"],
json!([])
);
}
#[test]
fn lsp_completion_waits_for_initialize_and_stale_payloads_are_not_decoded() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al\n").unwrap();
let mut session = fixture.open("completion-gated");
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("initialize") == 1
});
assert_eq!(fixture.method_count("textDocument/completion"), 0);
fixture.release();
until(&mut session, &fixture, |session| {
session.editor().completion().is_some()
});
assert_eq!(session.editor().context().text, "alpha\n");
drop(session);
for input in [Input::Escape, Input::Right, Input::Char('!')] {
let fixture = Fixture::new();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
session.handle(input);
let text = session.editor().context().text;
completion_reply(
&mut session,
&fixture,
1,
json!({"result": {"invalid": "stale"}}),
);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, text);
session.execute("lsp.hover");
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
}
}
#[test]
fn lsp_completion_feature_errors_keep_healthy_services() {
let fixture = Fixture::new();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
let range = |start, end| json!({"start": {"line": 0, "character": start}, "end": {"line": 0, "character": end}});
let invalid = vec![
json!({"result": {"items": []}}),
json!({"result": [{"detail": "missing label"}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 99), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 2), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 4), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(1, 5), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": {"start": {"line": 0, "character": 0}, "end": {"line": 1, "character": 0}}, "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 1), "newText": "\u{1b}"}}]}),
json!({"result": [{"label": "x", "textEdit": {"insert": range(0, 3), "replace": range(0, 1), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 1), "newText": "x"}, "additionalTextEdits": [{"range": range(0, 1), "newText": "bad"}]}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 0), "newText": "x"}, "additionalTextEdits": [{"range": range(0, 0), "newText": "bad"}]}]}),
json!({"result": [{"label": "x", "additionalTextEdits": {}}]}),
json!({"error": {"code": -32603, "message": "completion unavailable"}}),
json!({"error": {"message": "malformed error"}}),
];
for (index, response) in invalid.into_iter().enumerate() {
session.handle(Input::Char('i'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == index + 1
});
completion_reply(&mut session, &fixture, index as u64 + 1, response);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
assert_eq!(session.editor().diagnostics().count(), 2);
session.handle(Input::Escape);
}
session.execute("lsp.hover");
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
session.handle(Input::Escape);
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().context().text, "!A😀e\u{301}Z\n");
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
}
#[test]
fn lsp_completion_fallback_filter_text_insert_text_and_empty_shapes() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al suffix\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [
{"label": "display name", "filterText": "alpha", "insertText": "actual", "sortText": "1"},
{"label": "alLabel", "sortText": "2"},
{"label": "does not match", "filterText": "no", "insertText": "must not insert"}
]}),
);
assert_eq!(session.editor().context().text, "actual suffix\n");
assert_eq!(
session.editor().completion().unwrap().items[0].label,
"display name"
);
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "alLabel suffix\n");
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "al suffix\n");
session.handle(Input::Escape);
for (index, result) in [
Value::Null,
json!([]),
json!({"isIncomplete": false, "items": []}),
json!([{"label": "alSnippet", "insertTextFormat": 2, "insertText": "${1:al}"}]),
]
.into_iter()
.enumerate()
{
session.handle(Input::Char('i'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == index + 2
});
completion_reply(
&mut session,
&fixture,
index as u64 + 2,
json!({"result": result}),
);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, "al suffix\n");
session.handle(Input::Escape);
}
}
#[test]
fn lsp_completion_superseded_and_preinitialize_changed_sources_never_replace_text() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 2
});
fixture.control(0, 1, json!({"action": "completion-result", "index": 1, "response": {"result": [{"label": "alLatest"}]}}));
until(&mut session, &fixture, |_| fixture.acknowledged(0, 1));
assert_eq!(session.editor().context().text, "alLatest\n");
completion_reply(
&mut session,
&fixture,
2,
json!({"result": {"malformed": "superseded"}}),
);
assert_eq!(session.editor().context().text, "alLatest\n");
assert!(session.editor().completion().is_some());
drop(session);
let fixture = Fixture::new();
fs::write(&fixture.source, "al\n").unwrap();
let mut session = fixture.open("completion-gated");
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("initialize") == 1
});
session.handle(Input::Char('!'));
fixture.release();
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didOpen") == 1
});
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, "al!\n");
assert_eq!(fixture.method_count("textDocument/completion"), 0);
}
#[test]
fn lsp_completion_routes_only_to_first_configured_server_without_fallback() {
let fixture = Fixture::new();
let mut session = fixture.open_stack(["controlled-unsupported", "controlled"], false);
until(&mut session, &fixture, |_| {
fixture.worker_count(0, "textDocument/didOpen") == 1
&& fixture.worker_count(1, "textDocument/didOpen") == 1
});
session.handle(Input::Char('i'));
let previous_message = session.editor().message().to_owned();
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |session| {
session.editor().message() != previous_message
});
assert!(session.editor().completion().is_none());
assert_eq!(fixture.worker_count(0, "textDocument/completion"), 0);
assert_eq!(fixture.worker_count(1, "textDocument/completion"), 0);
session.handle(Input::Char('!'));
assert_eq!(session.editor().context().text, "!A😀e\u{301}Z\n");
}