Compare commits
7 Commits
dc0bdd60d0
...
3f05287d79
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f05287d79 | |||
| 4e9378816e | |||
| 232be6a032 | |||
| b6059d5ed9 | |||
| 9d3b25e9d3 | |||
| 287865735f | |||
| 27b94744f0 |
+1
-1
@@ -26,7 +26,7 @@ A named frontend ability required by a feature for compatibility, not a permissi
|
||||
## Configuration interfaces
|
||||
|
||||
- Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings.
|
||||
- `src/editor.rs`'s `CORE_ACTIONS` is authoritative for core action names and eligibility. Ruby action methods and nested namespaces are generated from its startup catalog; add or rename actions there rather than maintaining a second Ruby list.
|
||||
- `src/editor.rs`'s `CORE_ACTIONS` is authoritative for core action names, eligibility, and Ex aliases. Ruby action methods and nested namespaces, protected core command registration, and palette entries are generated from this catalog; add or rename actions there rather than maintaining separate lists.
|
||||
- Use strings for open-ended identifiers: language IDs, paths, and external plugin command names. Interactive Ex command names remain textual.
|
||||
- Ruby action/mode objects provide validation during configuration evaluation and discoverable members, not compile-time enum safety.
|
||||
|
||||
|
||||
@@ -20,12 +20,16 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
|
||||
| Task | Default keys |
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
| Enter/leave Insert | `i` / Esc |
|
||||
| Manual LSP completion in Insert | Ctrl+n starts/cycles next; Ctrl+j/k cycles next/previous, replacing text live |
|
||||
| Edit completion snippet arguments | Type to replace the highlighted argument; Tab / Shift+Tab moves next/previous |
|
||||
| Save in Normal | Space then `w`, or `:w` then Enter |
|
||||
| Undo/redo in Normal | `u` / `U` (Shift+u) |
|
||||
| Delete/change whole lines in Normal | `dd` / `cc`; leading counts work (`3dd`, `2cc`) |
|
||||
| Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous |
|
||||
| Select/copy/cut | `v` in Normal, move to extend, then `y`/`d` in Visual |
|
||||
| Paste in Normal | `p`/`P` after/before the caret; system clipboard by default |
|
||||
| Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual |
|
||||
| Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default |
|
||||
| Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels |
|
||||
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
|
||||
|
||||
@@ -42,8 +46,8 @@ Ruby configuration and plugins run with your account's filesystem/process permis
|
||||
## Current limits
|
||||
|
||||
- One visible buffer at a time; no splits/tabs or graphical frontend.
|
||||
- Modal editing, not full Vim compatibility: characterwise Visual selections, but no line/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.
|
||||
- Modal editing, not full Vim compatibility: characterwise and linewise Visual selections and counted `dd`/`cc`, but no block selection, named registers, or general operator–motion grammar. Search is literal, case-sensitive, and single-line.
|
||||
- Native opt-in LSP supports diagnostics, go-to-definition, plain-text hover, manual primary-caret completion with snippet arguments, and automatic semantic highlighting—not rename, formatting, or code actions. Completion has no automatic popup or acceptance step; other carets are preserved and translated through the chosen edits. Snippet regex transforms and unresolved variables without defaults are omitted.
|
||||
- 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).
|
||||
|
||||
+16
-8
@@ -13,9 +13,10 @@ 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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
|
||||
- `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy.
|
||||
- `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.
|
||||
- `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.
|
||||
@@ -23,19 +24,22 @@ One crate; no workspace or terminal-coordinate types in the editing backend:
|
||||
## Frontend interface
|
||||
|
||||
Frontends drive `EditorSession::handle` or `execute`, periodically call `poll()` even without input, and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. `poll()` reports whether background activity requires redrawing. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
|
||||
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module.
|
||||
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. `visual_linewise()` is true only for the whole-row shape in `Mode::Visual`; it changes neither binding modes nor Ruby mode names. Linewise ranges expand to complete rows. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. `linewise_rows()` exposes inclusive selected row ranges only in linewise Visual, including an empty final row without changing `selections()` cardinality. The TUI highlights the copied text on those rows and one cell for each copied newline, never unused viewport space or the gutter, and labels their mode VISUAL LINE. Selection normalization, clipboard shape and contents, edit planning, and history remain inside the editor module.
|
||||
The TUI also observes the editor's logical caret-reveal generation. Core motion/navigation dispatch advances it even when the resulting position is unchanged, so a detached viewport can resume following without duplicating key-binding policy or changing text revisions and LSP freshness.
|
||||
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
|
||||
Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy.
|
||||
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
|
||||
Frontends inspect `count()` for the optional pending numeric prefix; parsing, counted motion dispatch, and counted Normal line actions remain editor-owned.
|
||||
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
|
||||
Frontends inspect `g_pending()` for the built-in Normal `gf` prefix. Markdown following preflights links and headings before buffer/caret mutation; an unloaded target is validated/prepared once, while loaded targets use their current unsaved text. File identity and activation stay editor-owned.
|
||||
Frontends inspect `g_pending()` for the built-in Normal/Visual `gt`/`gb` prefix and Normal-only `gf`. Document-edge motions share multi-target motion dispatch and viewport reveal, retaining Visual anchors and selection shape. Markdown following preflights links and headings before buffer/caret mutation; an unloaded target is validated/prepared once, while loaded targets use their current unsaved text. File identity and activation stay editor-owned.
|
||||
Frontends inspect `line_operator_pending()` for the optional Normal `d`/`c` prefix. Dedicated `dd`/`cc` actions project counted caret rows through existing linewise selection normalization, clipboard publication, and atomic range editing, without exposing a temporary Visual mode. Clipboard failures restore the original targets before returning. Repeat recipes retain the requested row count rather than the originally clamped range; change recipes share the existing Insert undo group. There is no general operator–motion parser.
|
||||
Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and grapheme cursors plus a current index, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal uses unrecorded buffer activation and position placement, skips closed/clamped-duplicate locations, and truncates forward entries only on a distinct new jump. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply.
|
||||
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
||||
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||
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 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.
|
||||
Frontends borrow `snippet_selections()` for the active argument's end-exclusive grapheme ranges, including mirrors and empty tabstops. The editor caches those ranges after previews, argument edits, and navigation; rendering adds no snippet allocation. Template edits stay primary-only, translate other carets, and fold resolved replacement text and caret placement into the pending completion repeat step. Per-occurrence ancestry distinguishes nested arguments from adjacent empty stops, allowing enclosing replacements to retire only overwritten descendants.
|
||||
|
||||
## Terminal lifecycle and layout
|
||||
|
||||
@@ -65,10 +69,14 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
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, snippet }`; each `CompletionEdit` uses original-document, end-exclusive grapheme coordinates. Snippet primary text is expanded and newline-normalized before byte-relative metadata is assigned; additional edits remain plain text. 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. 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 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.
|
||||
`src/editor.rs`'s `CORE_ACTIONS` catalog owns core action names, eligible modes, optional Ex aliases, and prompt policies. It generates the Rust Action enum and protected core command registration. Typed keys and textual Ex aliases share Action-backed execution; the palette has one row per core action and searches its alias without adding duplicate command rows.
|
||||
|
||||
Rust supplies name/Ex-alias descriptors before loading configuration or plugin scripts. Ruby generates `config.core` methods and a frozen built-in alias lookup from those descriptors, rejecting built-in string binding targets without a second command list. Dotted identifiers create nested namespaces automatically; each namespace and Action value is frozen.
|
||||
|
||||
+33
-12
@@ -21,6 +21,7 @@ Vamp.configure do |config|
|
||||
config.plugin "plugins/greeting.rb"
|
||||
config.bind "g", "greet"
|
||||
config.bind "leader g", "greet"
|
||||
config.bind "leader q", config.core.quit
|
||||
config.bind "ctrl+r", config.core.redo
|
||||
config.bind "shift+j", config.core.join_lines
|
||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||
@@ -41,7 +42,7 @@ Plugin paths resolve relative to the config file, not the shell's working direct
|
||||
|
||||
## Actions and modes
|
||||
|
||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, built-in Ex aliases (such as `"q"` or `"w"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands such as `:q` and `:w path` remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||
|
||||
Registered command names may contain dot-separated segments, each starting with an ASCII letter followed by ASCII letters/digits/underscores/hyphens, plus one optional trailing `!`. Names cannot contain whitespace, empty segments, or leading/trailing dots. This grammar applies to core and plugin commands alike.
|
||||
|
||||
@@ -50,14 +51,16 @@ Registered command names may contain dot-separated segments, each starting with
|
||||
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
|
||||
|
||||
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
|
||||
- Modifiers: `ctrl+a` through `ctrl+z`, and `shift+a` through `shift+z`. `shift+j` and `J` address the same key.
|
||||
- Modifiers: `ctrl+a` through `ctrl+z`, `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key.
|
||||
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
|
||||
|
||||
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside leader sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. Only a single modifier on an ASCII letter is supported. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside leader sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. A single modifier on an ASCII letter is supported, plus Shift+Tab. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||
|
||||
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab remains literal text. Override or disable these keys with the ordinary per-mode binding interface; both jump actions also appear in the command palette.
|
||||
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab inserts literal text outside snippets; inside one it moves to the next argument. Override or disable these keys with the ordinary per-mode binding interface; jump actions also appear in the command palette.
|
||||
|
||||
The built-in Normal `gf` sequence follows Markdown file links. It is not a configurable general sequence prefix: bind `config.core.follow_link` to a single key or leader sequence to add another route. A configured or disabled Normal `g`, or choosing `g` as the leader, overrides the built-in prefix.
|
||||
The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. This is not a configurable general sequence prefix: bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to a single key or leader sequence to add another route. A configured or disabled `g` overrides the built-in prefix in that binding mode; choosing `g` as the leader overrides it in both modes.
|
||||
|
||||
Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `config.core.delete_line` or `config.core.change_line` to a single key or leader sequence for another route. A configured/disabled `d` or `c`, or choosing that key as the leader, overrides its built-in doubled sequence. These are dedicated line actions, not general operator–motion grammar.
|
||||
|
||||
## Action reference
|
||||
|
||||
@@ -66,14 +69,19 @@ The built-in Normal `gf` sequence follows Markdown file links. It is not a confi
|
||||
| Normal | `insert`, `append`, `open_below`, `open_above` |
|
||||
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||
| Normal | `delete_line`, `change_line` |
|
||||
| Normal | `save`, `quit`, `force_quit`, `save_and_quit`, `plugins` |
|
||||
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
||||
| Normal | `follow_link` |
|
||||
| Normal | `jump_back`, `jump_forward` |
|
||||
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
||||
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
|
||||
| Normal | `search`, `paste_before`, `paste_after` |
|
||||
| Normal | `paste_system_before`, `paste_system_after` |
|
||||
| Normal, Visual | `select`, `search_next`, `search_previous` |
|
||||
| Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` |
|
||||
| Normal, Visual | `repeat_last_edit` |
|
||||
| Normal, Visual | `file_picker`, `file_explorer` |
|
||||
| Normal, Visual | `document_start`, `document_end` |
|
||||
| Normal, Insert, Visual | `line_start`, `line_end` |
|
||||
| Normal, Insert, Visual, Tree | `left`, `right` |
|
||||
| Normal, Insert, Visual, Selection, Tree | `down`, `up` |
|
||||
@@ -86,6 +94,10 @@ The built-in Normal `gf` sequence follows Markdown file links. It is not a confi
|
||||
| Insert | `newline` |
|
||||
| Command, Search | `submit` |
|
||||
|
||||
`core.select` (`v`) and `core.select_linewise` (`V`) select characterwise and whole-row Visual shapes. Switching selectors retains endpoints; pressing the active shape's selector again cancels. Both use `config.modes.visual`; there is no separate linewise binding mode. `core.select_lines` (Visual Ctrl+l) still splits touched rows into Normal carets.
|
||||
|
||||
`core.save` saves the current path; `core.save_and_quit` saves the active buffer and quits only if no buffer remains dirty. `core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit` and `core.buffer_force_delete` keys act immediately, without confirmation.
|
||||
|
||||
## Binding overrides and failures
|
||||
|
||||
Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode:
|
||||
@@ -124,11 +136,11 @@ 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+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
|
||||
|
||||
Space starts a leader sequence in Normal/Visual when it has no direct binding in that mode. Leader bindings use `leader` followed by whitespace-separated single-key names, for example `config.bind "leader b n", "bnext"`. Nested prefixes wait for the next key without a timeout. The status bar shows the actual pending sequence before the file path; the palette advertises effective sequences alongside ordinary bindings.
|
||||
Space starts a leader sequence in Normal/Visual when it has no direct binding in that mode. Leader bindings use `leader` followed by whitespace-separated single-key names, for example `config.bind "leader b n", config.core.buffer_next`. Nested prefixes wait for the next key without a timeout. The status bar shows the actual pending sequence before the file path; the palette advertises effective sequences alongside ordinary bindings.
|
||||
|
||||
Set `config.leader = ","` to change the trigger for every default and configured leader route. Suffix keys stay unchanged: the default palette binding becomes comma then Space, save becomes comma then `w`, and buffer-next becomes comma then `b` then `n`. Choose an unused single key in the existing notation; Escape, ASCII digits, malformed keys, and a new trigger already directly bound in Normal/Visual are rejected without replacing the current choice. A direct binding on the current trigger—including `nil`—takes precedence per mode and hides unreachable leader routes from the palette.
|
||||
|
||||
@@ -155,6 +167,7 @@ Vamp.configure do |config|
|
||||
config.bind "leader d", config.core.lsp.definition
|
||||
config.bind "leader D", config.core.lsp.diagnostics
|
||||
config.bind "K", config.core.lsp.hover
|
||||
config.bind "ctrl+n", config.core.lsp.completion, mode: config.modes.insert
|
||||
end
|
||||
```
|
||||
|
||||
@@ -166,7 +179,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.
|
||||
|
||||
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
|
||||
config.lsp["python"] = {
|
||||
@@ -185,13 +198,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.
|
||||
|
||||
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.
|
||||
|
||||
`: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.
|
||||
|
||||
@@ -201,8 +214,16 @@ 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.
|
||||
|
||||
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 the list with no accept key. Snippets expand the whole template and highlight the first argument: typing replaces it and its mirrors, Tab/Shift+Tab moves next/previous, and the final `$0` ends navigation. Outside a snippet Tab inserts a tab; Escape returns to Normal keeping the displayed text. Secondary carets are translated, not completed independently. Cycling and argument edits belong to the pending Insert undo group; repeat records resolved text and caret placement. Rebind the Insert-only `config.core.snippet.next` / `config.core.snippet.previous` actions normally. 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.
|
||||
|
||||
Supported [LSP snippet syntax](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#snippet_syntax) includes numbered tabstops, nested placeholders/defaults, mirrors, escapes, choices (first option), variable defaults, and `TM_SELECTED_TEXT`, `TM_CURRENT_LINE`, `TM_CURRENT_WORD`, `TM_LINE_INDEX`, `TM_LINE_NUMBER`, `TM_FILEPATH`, `TM_FILENAME`, `TM_FILENAME_BASE`, and `TM_DIRECTORY`. Insert has no selected text, so `TM_SELECTED_TEXT` is empty unless a default is supplied. Unsupported regex transforms, unknown variables without defaults, repeated/nonempty final `$0`, or final stops nested inside arguments omit that candidate. Malformed templates report a completion-local error. Each template and expansion is bounded to 256 KiB, with 4096 parsed nodes, 1024 placeholder occurrences, 32 nesting levels, and a bounded rendering traversal.
|
||||
|
||||
There are no automatic triggers, commit characters, server commands, indentation adjustment, or completion-item resolve requests. Items requiring 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.
|
||||
|
||||
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.
|
||||
|
||||
+48
-19
@@ -17,22 +17,42 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
||||
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
|
||||
| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
|
||||
| Normal | `gf` follows a Markdown file link or heading fragment at the primary caret |
|
||||
| Normal, Visual | `gt` goes to the first line; `gb` goes to the last line, at first nonblank; Visual extends every selection |
|
||||
| Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations |
|
||||
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
|
||||
| Normal | `J` (Shift+j) joins each targeted line with the next line |
|
||||
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
||||
| Normal | `dd` deletes whole lines; `cc` replaces them and enters Insert; leading counts work (`3dd`, `2cc`) |
|
||||
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
|
||||
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
||||
| Normal | `v` starts Visual selections; `p`/`P` paste the configured clipboard after/before each caret (system by default); Esc keeps only the primary caret |
|
||||
| Normal | `v` starts characterwise Visual; `V` starts linewise Visual; `p`/`P` paste the configured clipboard after/before each caret or row for linewise fragments (system by default); Esc keeps only the primary caret |
|
||||
| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below |
|
||||
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes selections; `v`/Esc cancels highlighting |
|
||||
| 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 |
|
||||
| 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 outside snippets; 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 |
|
||||
| Insert, active snippet | Type to replace the highlighted argument and its mirrors; Tab / Shift+Tab moves next/previous |
|
||||
| Command | Enter executes; Backspace edits; Esc cancels |
|
||||
| Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels |
|
||||
|
||||
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
|
||||
|
||||
`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. Other keys close the list without accepting it: plain-text completion retains ordinary typing, newline, and literal Tab behavior; snippet completion keeps its argument navigation described below. Escape returns to Normal retaining the displayed text. There is no automatic popup. 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.
|
||||
|
||||
Snippet candidates expand the full template immediately, including parentheses and argument defaults. The first numbered argument is highlighted; typing replaces it, then continues within that argument. Repeated occurrences of the same argument update together, without broadcasting to secondary carets. Backspace/Delete stay within the active argument; Enter can insert a newline there. **Tab** moves forward and **Shift+Tab** backward in ascending tabstop order. Replacing an enclosing argument removes its nested arguments, not adjacent independent ones. Reaching the final `$0` position ends navigation, so subsequent Tab inserts a literal tab. Escape, ordinary cursor motion, paste, commands, and focus/buffer changes also end navigation without removing the expanded text.
|
||||
|
||||
For example, `call(${1:argument}, ${2:count})$0` previews `call(argument, count)`. Type `value`, Tab, `3`, Tab to produce `call(value, 3)` with the caret after the closing parenthesis. Argument edits remain in the same Insert undo group, and dot repeats the resolved text and final caret rather than re-expanding placeholders.
|
||||
|
||||
Numbered tabstops, nested defaults, mirrors, escapes, variable defaults, and context variables are supported. Choices insert their first option, which remains editable. Regex transforms, unknown variables without defaults, repeated/nonempty final `$0`, and final stops nested inside arguments are omitted rather than inserted partly or literally. See [protocol limits](configuration.md#native-lsp).
|
||||
|
||||
## 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.
|
||||
@@ -47,21 +67,30 @@ Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, and `n`/`N`,
|
||||
|
||||
Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop.
|
||||
|
||||
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader prefixes retain the count until their leaf action resolves. Other commands consume the prefix and execute once, including line-start/end, edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and Vim operator grammar such as `3dw` is not supported.
|
||||
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader and `dd`/`cc` prefixes retain the count until their action resolves. Line actions use that count; other non-motion commands consume it and execute once, including line-start/end, other edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and general Vim operator grammar such as `3dw` is not supported.
|
||||
|
||||
## Normal line actions
|
||||
|
||||
`dd` cuts the current line and its newline into the configured linewise clipboard, staying in Normal. `cc` cuts the same whole-row contents, leaves one empty replacement row, and enters Insert at column 0; indentation is not retained. Leading counts select rows downward from each caret, clamped at EOF: `3dd` cuts up to three rows and `2cc` changes up to two rows. Overlapping ranges merge before editing or clipboard publication; adjacent ranges remain separate targets. Deleting at EOF removes the preceding separator when needed, and the buffer always retains at least one row.
|
||||
|
||||
The first `d` or `c` waits for its matching second key and appears in the status bar. Escape cancels only that pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Only leading counts are supported, not forms such as `d2d` or `d{motion}`. Explicit bindings, disabled first keys, and a leader using that first key take precedence.
|
||||
|
||||
System clipboard publication must succeed before any deletion. Failure preserves text, carets, mode, undo/redo, and repeat intent. `dd` is one undo action; `cc` and subsequent replacement typing form one Insert undo group. Dot repeats the original requested row count at the current carets—even if the original action was clamped at EOF—and replays `cc` replacement text. Rebind the Normal-only `config.core.delete_line` / `config.core.change_line` actions to a single key or leader sequence without relying on the built-in doubled keys.
|
||||
|
||||
|
||||
## Selections and multi-caret editing
|
||||
|
||||
Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. Visual selection is characterwise and inclusive of the endpoints; its read-only ranges are end-exclusive. `$`/End can select the newline after a non-final row; empty-line newlines are selectable too. Selected newlines appear as a blue blank cell after the line. Blue highlighting overrides yellow search highlighting; secondary carets are magenta, including on empty lines and at line ends. Only the primary owns the hardware cursor and viewport. The status line counts all targets, including offscreen targets.
|
||||
Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. `v` selects characterwise, inclusive of endpoints; `V` selects every complete row touched by the endpoints, including blank and empty final rows. Switching between `v` and `V` retains endpoints; pressing the current shape's selector again cancels. Read-only ranges are end-exclusive. Characterwise `$`/End can select the newline after a non-final row; selected newlines appear as a blue blank cell after the line. Linewise highlighting covers the copied text, including trailing whitespace, plus one blank cell for each copied newline; unused viewport space and the gutter remain unhighlighted. The status reads VISUAL LINE. Blue highlighting overrides yellow search highlighting; secondary carets are magenta, including on empty lines and at line ends. Only the primary owns the hardware cursor and viewport. The status line counts all targets, including offscreen targets.
|
||||
|
||||
Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred terminal-cell columns (tabs use four-cell stops); Visual cloning preserves both endpoints. Duplicate carets and truly overlapping selections merge; adjacent ranges remain distinct. Visual Ctrl+l creates one Normal caret at the first nonblank of each touched row. Visual `I`/`A` similarly create one Insert caret per touched row at first nonblank/end. Occurrence actions use the primary selection as a literal, potentially multiline, grapheme-aligned query: Ctrl+n adds the next non-overlapping occurrence and wraps; Ctrl+s moves only the primary to that occurrence; Ctrl+a selects all occurrences.
|
||||
|
||||
Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. `p`/`P` paste after/before each current grapheme, including multiline text. Each caret lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard.
|
||||
Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. Characterwise `p`/`P` paste after/before each current grapheme, including multiline text. Linewise clipboard fragments paste complete rows below/above each target row, including at end of file. Internal fragments retain their shape; system clipboard shape is retained only while its bytes match the editor's last publication, and unrelated external clipboard text is characterwise. Characterwise paste lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline; linewise paste lands at the first nonblank of the first inserted row. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard.
|
||||
|
||||
Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only empty final-row selections enters Insert without overwriting either clipboard.
|
||||
Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only characterwise empty final-row selections enters Insert without overwriting either clipboard; linewise empty final rows are genuine rows and publish a newline.
|
||||
|
||||
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal-mode operator grammar such as `cw` is not supported; `c` types literal text in Insert mode.
|
||||
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal supports counted `dd` and `cc`, but not general operator grammar such as `cw`, `dw`, or `yy`; blockwise Visual is also unsupported. `c` and `d` type literal text in Insert mode.
|
||||
|
||||
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only empty final-row selections does not overwrite either register. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
|
||||
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only characterwise empty final-row selections does not overwrite either register. Linewise copy/cut retain whole-row shape, including a newline fragment for an empty final row. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
|
||||
|
||||
## Clipboard policy
|
||||
|
||||
@@ -75,7 +104,7 @@ config.bind "ctrl+s", config.core.copy_system, mode: config.modes.visual
|
||||
config.bind "ctrl+g", config.core.paste_system_before
|
||||
```
|
||||
|
||||
For other applications, multi-selection exports join fragments with LF. Vamp retains their original boundaries privately while the OS text exactly matches its export, so its own copies still paste pairwise. Different external text invalidates that metadata and becomes one literal fragment—even when it contains newlines—broadcasting to all targets. Identical external replacement text is inherently indistinguishable from an unchanged export. No empty-system fallback to stale internal text occurs.
|
||||
For other applications, characterwise multi-selection exports join fragments with LF; linewise exports concatenate whole-row fragments without extra separators because each fragment already ends in LF. Vamp retains their original boundaries and shape privately while the OS text exactly matches its export, so its own copies still paste pairwise. Different external text invalidates that metadata and becomes one characterwise literal fragment—even when it contains newlines—broadcasting to all targets. Identical external replacement text is inherently indistinguishable from an unchanged export. No empty-system fallback to stale internal text occurs.
|
||||
|
||||
System paste validates UTF-8, unsupported controls, newlines, and a 4 MiB limit before editing; CRLF/CR normalize to LF. System exports also have a 4 MiB limit. Provider failures report an error without deleting/changing buffer text, caret/selection state, history, or repeat intent. No silent internal fallback occurs in System mode; choose Separate if clipboard access is unavailable. A native provider that fails after publishing may already have changed OS clipboard formats; vamp cannot roll that external side effect back.
|
||||
|
||||
@@ -83,11 +112,11 @@ System clipboard access supports macOS through `osascript`, Linux Wayland throug
|
||||
|
||||
## Repeat last edit
|
||||
|
||||
`.` repeats the session's last successful native edit in Normal/Visual. Insertion placements (`i`/`I`/`a`/`A`/`o`/`O`), Insert typing and edit-context motions/newline/backspace/delete, Visual change/cut, Normal deletion, clipboard/literal paste, and line joining are recorded as resolved edit operations—not keystrokes, command invocations, or old byte offsets. Remapping keys afterward does not change the recorded operation. Insert `.` and popup query `.` remain literal. A count before dot is consumed and repeats once, consistent with other non-motion commands.
|
||||
`.` repeats the session's last successful native edit in Normal/Visual. Insertion placements (`i`/`I`/`a`/`A`/`o`/`O`), Insert typing and edit-context motions/newline/backspace/delete, Visual change/cut, counted Normal `dd`/`cc`, Normal character deletion, clipboard/literal paste, and line joining are recorded as resolved edit operations—not keystrokes, command invocations, or old byte offsets. Remapping keys afterward does not change the recorded operation. Insert `.` and popup query `.` remain literal. A count before dot is consumed and repeats once; a stored `dd`/`cc` recipe retains its own requested row count.
|
||||
|
||||
In Normal, Visual change/cut targeting replays its resolved motions and counts at each current caret: `vecitem` followed later by `.` changes a fresh whole word, even if its length differs. If targeting was rebuilt by cloning, occurrence gathering, search, or overlap merging, repeat uses relative whole-grapheme spans instead. Equal spans broadcast; unequal spans require matching target counts. In Visual, the current explicit ranges replace the stored targeting. Target-building commands themselves do not recreate old cursors.
|
||||
In Normal, characterwise Visual change/cut targeting replays its resolved motions and counts at each current caret: `vecitem` followed later by `.` changes a fresh whole word, even if its length differs. If characterwise targeting was rebuilt by cloning, occurrence gathering, search, or overlap merging, repeat uses relative whole-grapheme spans instead. Visual linewise targeting uses signed relative row spans, preserving whole-row shape rather than replaying motions. Equal spans broadcast; unequal spans require matching target counts. In Visual, current explicit ranges and selection shape replace stored Visual targeting. Native `dd`/`cc` recipes instead always select their requested rows from each current active caret. Target-building commands themselves do not recreate old cursors.
|
||||
|
||||
Clipboard paste captures the fragments actually pasted, so a later yank or external clipboard change does not change repeat's payload; dot paste never rereads the OS. One fragment broadcasts; equal fragment/target counts pair; mismatches refuse atomically. Other per-target edit data, such as partially effective Insert deletion, obeys the same conservative distribution policy. Dot cut/change follows the current clipboard policy and stages publication until replay validation finishes. A refusal or provider error preserves editor text, caret/primary state, register caches, history, and recipe; already-published OS data cannot be transactionally restored.
|
||||
Clipboard paste captures the fragments and their characterwise/linewise shape actually pasted, so a later yank or external clipboard change does not change repeat's payload; dot paste never rereads the OS. One fragment broadcasts; equal fragment/target counts pair; mismatches refuse atomically. Other per-target edit data, such as partially effective Insert deletion, obeys the same conservative distribution policy. Dot cut/change follows the current clipboard policy and stages publication until replay validation finishes. A refusal or provider error preserves editor text, caret/primary state, register caches, history, and recipe; already-published OS data cannot be transactionally restored.
|
||||
|
||||
Each repeat returns to Normal and creates one undo entry restoring the full caret set and primary. Original typing/paste/typing boundaries remain separate; dot replays the selected committed group as one transaction. Typed text retains scalar insertion boundaries, preserving combining/ZWJ interactions with surrounding graphemes; bracketed paste remains one bulk literal operation. Trailing Insert movement is not repeated.
|
||||
|
||||
@@ -111,7 +140,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.
|
||||
|
||||
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
|
||||
|
||||
@@ -166,11 +195,11 @@ The project root is the working directory captured at startup. Scanning uses the
|
||||
|
||||
## Command palette
|
||||
|
||||
Ctrl+p opens a searchable overlay without leaving the current mode. It lists core actions and registered Ex/plugin commands with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
||||
Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
||||
|
||||
Search is case-insensitive: every whitespace-separated query token must occur in the command name, human-readable label, or owner. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open.
|
||||
Search is case-insensitive: every whitespace-separated query token must occur in the action/command name, built-in Ex alias, human-readable label, or owner. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open.
|
||||
|
||||
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. Accepting an Ex/plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch. Choosing `q!` therefore does not immediately discard changes.
|
||||
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. `core.edit` and `core.buffer` instead open prefilled Ex prompts for their required arguments. The destructive `core.force_quit` (`q!`) and `core.buffer_force_delete` (`bdelete!`) actions also open Ex prompts, requiring another Enter before discarding changes. Accepting a plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch.
|
||||
|
||||
Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; list navigation uses the shared configurable `selection` context. Other popup controls keep their defaults unless explicitly intercepted by a selection binding. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals.
|
||||
|
||||
@@ -180,7 +209,7 @@ Space begins a leader sequence in Normal/Visual by default. Press the suffix key
|
||||
|
||||
## Jump history
|
||||
|
||||
Normal Ctrl+o moves back through previous locations; Ctrl+i moves forward. Traditional terminals encode Ctrl+i as Tab, so Normal Tab performs the same action while Insert Tab still inserts text. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available history.
|
||||
Normal Ctrl+o moves back through previous locations; Ctrl+i moves forward. Traditional terminals encode Ctrl+i as Tab, so Normal Tab performs the same action while Insert Tab inserts text outside snippets. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available history.
|
||||
|
||||
The session keeps up to 100 primary-caret locations across buffers. Successful Markdown links, accepted LSP definitions, file-picker opens, `:edit`, `:buffer`, `:bnext`/`:bprev`, and search `/`/`n`/`N` jumps record locations. Ordinary cursor/word motion, edits, failed navigation, and jumps that do not change location do not add entries. Traversal itself does not record another jump.
|
||||
|
||||
@@ -198,7 +227,7 @@ Local paths resolve relative to the source file, not the working directory; perc
|
||||
|
||||
External URL schemes/network paths are rejected; this action never launches a browser or shell. Numeric HTML entities and common named entities (`amp`, `lt`, `gt`, `quot`, `apos`, `nbsp` and their supported aliases) are decoded; other recognized named entities report an unsupported-entity error rather than guessing. Unicode slugs/reference matching use standard-library case conversion, not a complete Unicode case-fold/category table.
|
||||
|
||||
`g` waits for `f`, shows in status, and Escape cancels. Other input cancels the prefix and follows ordinary input handling. A configured Normal `g` binding overrides this built-in prefix. Custom bindings use the generated action:
|
||||
`g` waits for `f` in Normal, or `t`/`b` in Normal and Visual, shows in status, and Escape cancels. Other input cancels the prefix and follows ordinary input handling. A configured `g` binding overrides this built-in prefix in its mode; choosing `g` as leader also takes precedence. Custom bindings use the generated action:
|
||||
|
||||
```ruby
|
||||
config.bind "leader f", config.core.follow_link
|
||||
|
||||
@@ -23,6 +23,7 @@ Vamp.configure do |config|
|
||||
# config.bind "K", config.core.lsp.hover
|
||||
config.bind "g", "greet"
|
||||
config.bind "leader g", "greet"
|
||||
config.bind "leader q", config.core.quit
|
||||
config.bind "ctrl+r", config.core.redo
|
||||
config.bind "leader r", config.core.repeat_last_edit
|
||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||
|
||||
+3548
-256
File diff suppressed because it is too large
Load Diff
@@ -7,5 +7,6 @@ mod markdown;
|
||||
pub mod plugin;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
mod snippet;
|
||||
pub mod syntax;
|
||||
pub mod tui;
|
||||
|
||||
+486
-4
@@ -18,10 +18,11 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
use crate::{
|
||||
editor::Context,
|
||||
plugin::{
|
||||
Diagnostic, DiagnosticSeverity, DocumentEvent, DocumentEventKind, DocumentSnapshot,
|
||||
PluginUpdate, SemanticToken, SemanticTokenKind, SemanticTokenModifier, TextPosition,
|
||||
UpdateEffect,
|
||||
CompletionEdit, CompletionItem, Diagnostic, DiagnosticSeverity, DocumentEvent,
|
||||
DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind,
|
||||
SemanticTokenModifier, TextPosition, UpdateEffect,
|
||||
},
|
||||
snippet,
|
||||
};
|
||||
|
||||
const MAX_BODY: usize = 4 * 1024 * 1024;
|
||||
@@ -94,6 +95,7 @@ enum Input {
|
||||
enum PositionKind {
|
||||
Definition,
|
||||
Hover,
|
||||
Completion,
|
||||
}
|
||||
|
||||
impl PositionKind {
|
||||
@@ -101,6 +103,7 @@ impl PositionKind {
|
||||
match self {
|
||||
Self::Definition => "definition",
|
||||
Self::Hover => "hover",
|
||||
Self::Completion => "completion",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,6 +268,10 @@ impl Lsp {
|
||||
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(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
@@ -600,6 +607,7 @@ enum Request {
|
||||
Initialize,
|
||||
Definition(Source),
|
||||
Hover(Source),
|
||||
Completion(Source, Arc<DocumentSnapshot>),
|
||||
SemanticTokens(Arc<DocumentSnapshot>),
|
||||
Shutdown,
|
||||
}
|
||||
@@ -620,6 +628,7 @@ struct Runtime {
|
||||
ready: bool,
|
||||
definition: bool,
|
||||
hover: bool,
|
||||
completion: bool,
|
||||
semantic: Option<SemanticOptions>,
|
||||
sync: SyncOptions,
|
||||
}
|
||||
@@ -649,6 +658,7 @@ impl Runtime {
|
||||
ready: false,
|
||||
definition: false,
|
||||
hover: false,
|
||||
completion: false,
|
||||
semantic: None,
|
||||
sync: SyncOptions::default(),
|
||||
};
|
||||
@@ -670,6 +680,19 @@ impl Runtime {
|
||||
"definition": {"dynamicRegistration": false, "linkSupport": true},
|
||||
"hover": {"dynamicRegistration": false,
|
||||
"contentFormat": ["plaintext", "markdown"]},
|
||||
"completion": {
|
||||
"dynamicRegistration": false, "contextSupport": true,
|
||||
"insertTextMode": 1,
|
||||
"completionItem": {
|
||||
"snippetSupport": true, "commitCharactersSupport": false,
|
||||
"insertReplaceSupport": true,
|
||||
"insertTextModeSupport": {"valueSet": [1]},
|
||||
"resolveSupport": {"properties": []}
|
||||
},
|
||||
"completionList": {
|
||||
"itemDefaults": ["editRange", "insertTextFormat", "insertTextMode"]
|
||||
}
|
||||
},
|
||||
"publishDiagnostics": {"relatedInformation": false, "versionSupport": true},
|
||||
"semanticTokens": {
|
||||
"dynamicRegistration": false,
|
||||
@@ -942,17 +965,26 @@ impl Runtime {
|
||||
"textDocument/hover",
|
||||
"server does not support hover",
|
||||
),
|
||||
PositionKind::Completion => (
|
||||
self.completion,
|
||||
"textDocument/completion",
|
||||
"server does not support completion",
|
||||
),
|
||||
};
|
||||
if !supported {
|
||||
return self.emit(message(unsupported, Some(source.request_id)));
|
||||
}
|
||||
let position = outbound_position(&doc.snapshot.text, source.row, source.column)?;
|
||||
let params = json!({
|
||||
let mut params = json!({
|
||||
"textDocument": {"uri": doc.uri}, "position": position
|
||||
});
|
||||
if matches!(kind, PositionKind::Completion) {
|
||||
params["context"] = json!({"triggerKind": 1});
|
||||
}
|
||||
let request = match kind {
|
||||
PositionKind::Definition => Request::Definition(source),
|
||||
PositionKind::Hover => Request::Hover(source),
|
||||
PositionKind::Completion => Request::Completion(source, Arc::clone(&doc.snapshot)),
|
||||
};
|
||||
self.request(method, Some(params), request)
|
||||
}
|
||||
@@ -986,6 +1018,37 @@ impl Runtime {
|
||||
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, snapshot.path.as_deref())
|
||||
}) {
|
||||
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(
|
||||
&mut self,
|
||||
snapshot: Arc<DocumentSnapshot>,
|
||||
@@ -1074,6 +1137,11 @@ impl Runtime {
|
||||
Request::Hover(source) => {
|
||||
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) => {
|
||||
self.semantic_result(snapshot, Err("unexpected LSP response".into()))
|
||||
}
|
||||
@@ -1089,6 +1157,11 @@ impl Runtime {
|
||||
Request::Hover(source) => {
|
||||
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) => {
|
||||
self.semantic_result(snapshot, Err("invalid LSP error response".into()))
|
||||
}
|
||||
@@ -1102,6 +1175,9 @@ impl Runtime {
|
||||
Some(source.request_id),
|
||||
)),
|
||||
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) => {
|
||||
self.semantic_result(snapshot, Err(text.to_owned()))
|
||||
}
|
||||
@@ -1112,6 +1188,9 @@ impl Runtime {
|
||||
Request::Initialize => self.initialized(&frame["result"]),
|
||||
Request::Definition(source) => self.definition_result(source, &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) => {
|
||||
self.semantic_result(snapshot, Ok(&frame["result"]))
|
||||
}
|
||||
@@ -1138,6 +1217,9 @@ impl Runtime {
|
||||
self.hover = capabilities
|
||||
.get("hoverProvider")
|
||||
.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")) {
|
||||
Ok(options) => self.semantic = options,
|
||||
Err(reason) => {
|
||||
@@ -2364,6 +2446,379 @@ 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,
|
||||
path: Option<&str>,
|
||||
) -> 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 word_end = caret_byte
|
||||
+ line[caret_byte..]
|
||||
.graphemes(true)
|
||||
.take_while(|cluster| {
|
||||
cluster
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|ch| ch.is_alphanumeric() || ch == '_')
|
||||
})
|
||||
.map(str::len)
|
||||
.sum::<usize>();
|
||||
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 indentation adjustment, server commands or lazy edit resolution.
|
||||
if 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.or(insert).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 (new_text, snippet) = if format == 2 {
|
||||
match snippet::expand(new_text, |name| {
|
||||
completion_variable(name, line, &line[prefix_byte..word_end], source.row, path)
|
||||
}) {
|
||||
Ok((text, snippet)) => (text, Some(snippet)),
|
||||
Err(snippet::Error::Unsupported) => continue,
|
||||
Err(snippet::Error::Invalid) => return Err("invalid completion snippet".into()),
|
||||
Err(snippet::Error::Limit) => {
|
||||
return Err("completion snippet exceeds expansion limits".into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(normalized(new_text).into_owned(), None)
|
||||
};
|
||||
completion_text(&new_text, true)?;
|
||||
let mut edits = vec![CompletionEdit {
|
||||
start,
|
||||
end,
|
||||
text: new_text,
|
||||
}];
|
||||
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,
|
||||
snippet,
|
||||
},
|
||||
));
|
||||
}
|
||||
decoded.sort_by(|a, b| a.0.cmp(b.0));
|
||||
Ok(decoded.into_iter().map(|(_, item)| item).collect())
|
||||
}
|
||||
|
||||
fn completion_variable(
|
||||
name: &str,
|
||||
line: &str,
|
||||
word: &str,
|
||||
row: usize,
|
||||
path: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let value = match name {
|
||||
"TM_SELECTED_TEXT" => "",
|
||||
"TM_CURRENT_LINE" => line,
|
||||
"TM_CURRENT_WORD" => word,
|
||||
"TM_LINE_INDEX" => return Some(row.to_string()),
|
||||
"TM_LINE_NUMBER" => return Some((row + 1).to_string()),
|
||||
"TM_FILEPATH" => path.unwrap_or(""),
|
||||
"TM_FILENAME" => path
|
||||
.and_then(|path| Path::new(path).file_name())
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(""),
|
||||
"TM_FILENAME_BASE" => path
|
||||
.and_then(|path| Path::new(path).file_stem())
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(""),
|
||||
"TM_DIRECTORY" => path
|
||||
.and_then(|path| Path::new(path).parent())
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(""),
|
||||
_ => return None,
|
||||
};
|
||||
// Context values have the same per-expansion bound as template text.
|
||||
(value.len() <= 256 * 1024).then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn coordinates(value: &Value) -> Result<(usize, usize), String> {
|
||||
let line = value["line"]
|
||||
.as_u64()
|
||||
@@ -2673,4 +3128,31 @@ mod tests {
|
||||
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, None).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, None)
|
||||
.unwrap_err()
|
||||
.contains("4 MiB")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-4
@@ -18,18 +18,23 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
h/j/k/l move, 0/$ line ends, x delete, J join lines
|
||||
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
||||
gf follows Markdown file links/reference links and heading fragments (no LSP)
|
||||
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported; Insert Tab unchanged)
|
||||
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
|
||||
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported)
|
||||
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
|
||||
u undo, U redo, : command, / search, n/N next/previous match
|
||||
dd deletes lines; cc changes lines and enters Insert; counts: 3dd, 2cc; Esc cancels prefix
|
||||
Normal/Visual: . repeats last native edit at current targets (one undo group)
|
||||
v select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
|
||||
v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
|
||||
y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
|
||||
Esc keeps only the primary caret
|
||||
Visual: motions extend selections, y copy, d/x/Delete cut, c change
|
||||
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
|
||||
v/Esc returns to Normal retaining carets
|
||||
Insert: Esc returns to Normal retaining carets
|
||||
v/V switches selection shape; same selector or Esc cancels 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
|
||||
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
|
||||
Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal
|
||||
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
||||
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
|
||||
|
||||
@@ -105,6 +105,41 @@ pub struct SemanticToken {
|
||||
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,
|
||||
}
|
||||
|
||||
/// Expanded snippet tabstops, in byte offsets relative to the primary edit text.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CompletionSnippet {
|
||||
/// Positive tabstops in ascending order, followed by the final zero tabstop.
|
||||
pub tabstops: Vec<SnippetTabstop>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SnippetTabstop {
|
||||
pub index: u32,
|
||||
/// Repeated occurrences of the same tabstop are edited together.
|
||||
pub ranges: Vec<std::ops::Range<usize>>,
|
||||
/// Enclosing numbered stops per occurrence, outermost first; parallel to ranges.
|
||||
pub ancestors: Vec<Vec<u32>>,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
pub snippet: Option<CompletionSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UpdateEffect {
|
||||
@@ -132,6 +167,12 @@ pub enum UpdateEffect {
|
||||
revision: u64,
|
||||
text: String,
|
||||
},
|
||||
#[serde(skip_deserializing)]
|
||||
Completion {
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
items: Vec<CompletionItem>,
|
||||
},
|
||||
Message {
|
||||
text: String,
|
||||
},
|
||||
|
||||
+109
-4
@@ -869,6 +869,113 @@ end
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_core_actions_execute_through_ruby_configuration() {
|
||||
use crate::{
|
||||
editor::{Input, Mode},
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
let fixture = Fixture::new();
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
r#"Vamp.configure do |config|
|
||||
config.clipboard_mode = "separate"
|
||||
config.bind "Q", config.core.quit
|
||||
config.bind "F", config.core.force_quit
|
||||
config.bind "W", config.core.save
|
||||
config.bind "X", config.core.save_and_quit
|
||||
config.bind "E", config.core.edit
|
||||
config.bind "B", config.core.buffer
|
||||
config.bind "P", config.core.plugins
|
||||
config.bind "L", config.core.buffers
|
||||
config.bind "N", config.core.buffer_next
|
||||
config.bind "V", config.core.buffer_previous
|
||||
config.bind "D", config.core.buffer_delete
|
||||
config.bind "Z", config.core.buffer_force_delete
|
||||
end"#,
|
||||
);
|
||||
for (key, dirty, quit, saved) in [
|
||||
('Q', false, true, false),
|
||||
('Q', true, false, false),
|
||||
('F', true, true, false),
|
||||
('W', true, false, true),
|
||||
('X', true, true, true),
|
||||
] {
|
||||
let buffer = fixture.file("buffer.txt", "base");
|
||||
let mut session =
|
||||
EditorSession::open(Some(buffer.clone()), vec!["text".into()], Some(&config))
|
||||
.unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
if dirty {
|
||||
for input in [Input::Char('i'), Input::Char('!'), Input::Escape] {
|
||||
session.handle(input);
|
||||
}
|
||||
}
|
||||
session.handle(Input::Char(key));
|
||||
assert_eq!(session.editor().should_quit(), quit, "{key}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&buffer).unwrap(),
|
||||
if saved { "!base" } else { "base" },
|
||||
"{key}"
|
||||
);
|
||||
assert_eq!(session.editor().dirty(), dirty && !saved, "{key}");
|
||||
}
|
||||
|
||||
let buffer = fixture.file("buffer.txt", "base");
|
||||
let second = fixture.file("second.txt", "second");
|
||||
let mut session =
|
||||
EditorSession::open(Some(buffer), vec!["text".into()], Some(&config)).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session.handle(Input::Char('E'));
|
||||
assert_eq!(session.editor().mode(), Mode::Command);
|
||||
assert_eq!(session.editor().command_line(), "edit ");
|
||||
for ch in second.to_string_lossy().chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(session.editor().lines(), &["second"]);
|
||||
session.handle(Input::Char('B'));
|
||||
assert_eq!(session.editor().mode(), Mode::Command);
|
||||
assert_eq!(session.editor().command_line(), "buffer ");
|
||||
session.handle(Input::Char('1'));
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(session.editor().buffer_id(), 1);
|
||||
assert_eq!(session.editor().lines(), &["base"]);
|
||||
session.handle(Input::Char('P'));
|
||||
assert!(session.editor().message().contains("text"));
|
||||
session.handle(Input::Char('L'));
|
||||
assert!(session.editor().message().contains("buffer.txt"));
|
||||
assert!(session.editor().message().contains("second.txt"));
|
||||
session.handle(Input::Char('N'));
|
||||
assert_eq!(session.editor().buffer_id(), 2);
|
||||
session.handle(Input::Char('V'));
|
||||
assert_eq!(session.editor().buffer_id(), 1);
|
||||
session.handle(Input::Char('D'));
|
||||
assert!(!session.editor().has_buffer(1));
|
||||
assert_eq!(session.editor().buffer_id(), 2);
|
||||
for input in [Input::Char('i'), Input::Char('!'), Input::Escape] {
|
||||
session.handle(input);
|
||||
}
|
||||
session.handle(Input::Char('D'));
|
||||
assert!(session.editor().has_buffer(2));
|
||||
session.handle(Input::Char('Z'));
|
||||
assert!(!session.editor().has_buffer(2));
|
||||
assert_eq!(std::fs::read_to_string(second).unwrap(), "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_builtin_aliases_are_not_external_binding_commands() {
|
||||
let fixture = Fixture::new();
|
||||
for name in ["q", "core.quit"] {
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.bind 'K', '{name}' }}"),
|
||||
);
|
||||
assert!(load_config(&config).is_err(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -895,10 +1002,8 @@ end
|
||||
.contains("must be a String")
|
||||
);
|
||||
for target in [
|
||||
"config.core.lsp_hover",
|
||||
"config.core.lsp_definition",
|
||||
"config.core.lsp_diagnostics",
|
||||
"config.core.lsp.hvoer",
|
||||
"7",
|
||||
"{}",
|
||||
"config.core.lsp",
|
||||
"config.core.lsp.hover.tap { |action| action.name = 'core.delete' }",
|
||||
"config.core.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus",
|
||||
|
||||
+3
-1
@@ -124,7 +124,9 @@ module Vamp
|
||||
# @return [String]
|
||||
def external_command(command)
|
||||
name = Vamp.string!(command, "binding command")
|
||||
raise TypeError, "use config.core for built-in actions" if name.start_with?("core.")
|
||||
if name.start_with?("core.") || Vamp.builtin_command?(name)
|
||||
raise TypeError, "use config.core for built-in actions"
|
||||
end
|
||||
|
||||
name
|
||||
end
|
||||
|
||||
+18
-4
@@ -58,16 +58,30 @@ module Vamp
|
||||
# @type [Module<Vamp::Core>]
|
||||
Core = Module.new
|
||||
|
||||
# @param names [Array<String>] dotted core action names
|
||||
# @param entries [Array<Hash{String => String, nil}>] action name and optional Ex alias descriptors
|
||||
# @return [Array<Module>] frozen action namespaces
|
||||
def self.install_actions(names)
|
||||
raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array)
|
||||
def self.install_actions(entries)
|
||||
raise TypeError, "core action catalog must be an Array" unless entries.is_a?(Array)
|
||||
|
||||
namespaces = [Core]
|
||||
names.each { |name| install_action(name, namespaces) }
|
||||
aliases = {}
|
||||
entries.each do |entry|
|
||||
raise TypeError, "core action descriptor must be a Hash" unless entry.is_a?(Hash)
|
||||
|
||||
install_action(entry.fetch("name"), namespaces)
|
||||
ex = entry.fetch("ex")
|
||||
aliases[string!(ex, "core Ex alias").freeze] = true unless ex.nil?
|
||||
end
|
||||
@builtin_commands = aliases.freeze
|
||||
namespaces.each(&:freeze)
|
||||
end
|
||||
|
||||
# @param name [String] binding command name
|
||||
# @return [Boolean] whether the catalog owns this textual Ex alias
|
||||
def self.builtin_command?(name)
|
||||
@builtin_commands.key?(name)
|
||||
end
|
||||
|
||||
# Frozen binding mode values, distinct from a buffer's editing state.
|
||||
module Modes
|
||||
# @!method self.normal
|
||||
|
||||
+108
-11
@@ -41,7 +41,9 @@ pub struct EditorSession {
|
||||
lsp: Lsp,
|
||||
lsp_request_id: u64,
|
||||
lsp_source_cursor: Option<Cursor>,
|
||||
lsp_source_mode: Mode,
|
||||
pending_hover: bool,
|
||||
pending_completion: bool,
|
||||
hover_owner: Option<String>,
|
||||
}
|
||||
|
||||
@@ -129,7 +131,9 @@ impl EditorSession {
|
||||
lsp: Lsp::new(config.lsp),
|
||||
lsp_request_id: 0,
|
||||
lsp_source_cursor: None,
|
||||
lsp_source_mode: Mode::Normal,
|
||||
pending_hover: false,
|
||||
pending_completion: false,
|
||||
hover_owner: None,
|
||||
};
|
||||
session.synchronize_documents();
|
||||
@@ -141,7 +145,7 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) {
|
||||
self.cancel_pending_hover();
|
||||
self.cancel_pending_popup_request();
|
||||
let invocation = self.editor.handle(input);
|
||||
self.synchronize_documents();
|
||||
if let Some(invocation) = invocation {
|
||||
@@ -151,7 +155,7 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
pub fn execute(&mut self, command: &str) {
|
||||
self.cancel_pending_hover();
|
||||
self.cancel_pending_popup_request();
|
||||
let invocation = self.editor.execute(command);
|
||||
self.synchronize_documents();
|
||||
if let Some(invocation) = invocation {
|
||||
@@ -163,7 +167,7 @@ impl EditorSession {
|
||||
fn dispatch(&mut self, invocation: Invocation) {
|
||||
if invocation.plugin == "core" {
|
||||
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) {
|
||||
self.lsp_request_id = id;
|
||||
let context = self.editor.context();
|
||||
@@ -173,11 +177,13 @@ impl EditorSession {
|
||||
};
|
||||
self.requests.entry("core".into()).or_default().latest = Some((id, source));
|
||||
self.lsp_source_cursor = Some(self.editor.cursor());
|
||||
self.lsp_source_mode = self.editor.mode();
|
||||
self.pending_hover = invocation.command == "lsp.hover";
|
||||
if self.pending_hover {
|
||||
self.lsp.hover(&context, id)
|
||||
} else {
|
||||
self.lsp.definition(&context, id)
|
||||
self.pending_completion = invocation.command == "lsp.completion";
|
||||
match invocation.command.as_str() {
|
||||
"lsp.hover" => self.lsp.hover(&context, id),
|
||||
"lsp.completion" => self.lsp.completion(&context, id),
|
||||
_ => self.lsp.definition(&context, id),
|
||||
}
|
||||
} else {
|
||||
Err("LSP request IDs exhausted".into())
|
||||
@@ -187,6 +193,7 @@ impl EditorSession {
|
||||
_ => unreachable!("session-dispatched core command"),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
self.cancel_pending_popup_request();
|
||||
self.editor.set_message(format!("LSP: {error}"));
|
||||
}
|
||||
return;
|
||||
@@ -216,12 +223,13 @@ impl EditorSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_pending_hover(&mut self) {
|
||||
if self.pending_hover {
|
||||
fn cancel_pending_popup_request(&mut self) {
|
||||
if self.pending_hover || self.pending_completion {
|
||||
if let Some(requests) = self.requests.get_mut("core") {
|
||||
requests.latest = None;
|
||||
}
|
||||
self.pending_hover = false;
|
||||
self.pending_completion = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,12 +251,13 @@ impl EditorSession {
|
||||
source.buffer_id != self.editor.buffer_id()
|
||||
|| source.revision != self.editor.text_revision()
|
||||
|| self.lsp_source_cursor != Some(self.editor.cursor())
|
||||
|| self.editor.mode() != Mode::Normal
|
||||
|| self.editor.mode() != self.lsp_source_mode
|
||||
})
|
||||
{
|
||||
requests.latest = None;
|
||||
self.lsp_source_cursor = None;
|
||||
self.pending_hover = false;
|
||||
self.pending_completion = false;
|
||||
}
|
||||
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
|
||||
self.documents.clear();
|
||||
@@ -363,7 +372,7 @@ impl EditorSession {
|
||||
&& self.source_is_current(source)
|
||||
&& (owner != "core"
|
||||
|| (self.lsp_source_cursor == Some(self.editor.cursor())
|
||||
&& self.editor.mode() == Mode::Normal))
|
||||
&& self.editor.mode() == self.lsp_source_mode))
|
||||
&& self
|
||||
.requests
|
||||
.get(owner)
|
||||
@@ -390,6 +399,18 @@ impl EditorSession {
|
||||
.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> {
|
||||
if owner == "core"
|
||||
&& let Some(id) = update.request_id
|
||||
@@ -402,6 +423,30 @@ impl EditorSession {
|
||||
{
|
||||
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.
|
||||
let validation = (|| -> Result<(), String> {
|
||||
for effect in &update.effects {
|
||||
@@ -460,6 +505,7 @@ impl EditorSession {
|
||||
crate::editor::validate_hover_text(text)?;
|
||||
}
|
||||
}
|
||||
UpdateEffect::Completion { .. } => {}
|
||||
UpdateEffect::Message { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -531,6 +577,24 @@ impl EditorSession {
|
||||
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 } => {
|
||||
changed |= self.editor.message() != text;
|
||||
self.editor.set_message(text);
|
||||
@@ -1527,6 +1591,39 @@ end
|
||||
assert_eq!(session.editor().lines(), &["base"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_linewise_change_and_paste_preserve_rows_and_one_replacement_history() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", " alpha\n界beta\ntail");
|
||||
let mut session = fixture.open(
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.clipboard_mode = "separate"
|
||||
config.bind "V", nil
|
||||
config.bind "s", config.core.select_linewise
|
||||
config.bind "s", config.core.select_linewise, mode: config.modes.visual
|
||||
config.bind "r", config.core.change, mode: config.modes.visual
|
||||
end
|
||||
"#,
|
||||
);
|
||||
for key in "llsjrnew".chars() {
|
||||
session.handle(Input::Char(key));
|
||||
}
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().lines(), &["new", "tail"]);
|
||||
session.handle(Input::Char('p'));
|
||||
assert_eq!(
|
||||
session.editor().lines(),
|
||||
&["new", " alpha", "界beta", "tail"]
|
||||
);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &["new", "tail"]);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &[" alpha", "界beta", "tail"]);
|
||||
session.handle(Input::Char('U'));
|
||||
assert_eq!(session.editor().lines(), &["new", "tail"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_selection_actions_edit_together_and_invoke_plugins_once() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
use std::{collections::BTreeMap, ops::Range};
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::plugin::{CompletionSnippet, SnippetTabstop};
|
||||
|
||||
const MAX_BYTES: usize = 256 * 1024;
|
||||
const MAX_NODES: usize = 4096;
|
||||
const MAX_STOPS: usize = 1024;
|
||||
const MAX_DEPTH: usize = 32;
|
||||
const MAX_WORK: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) enum Error {
|
||||
Invalid,
|
||||
Unsupported,
|
||||
Limit,
|
||||
}
|
||||
|
||||
enum Node<'a> {
|
||||
Text(&'a str),
|
||||
Value(String),
|
||||
Stop(u32, Option<Vec<usize>>),
|
||||
Default(Vec<usize>),
|
||||
}
|
||||
|
||||
struct Parser<'a, F> {
|
||||
text: &'a str,
|
||||
cursor: usize,
|
||||
nodes: Vec<Node<'a>>,
|
||||
value_bytes: usize,
|
||||
variable: F,
|
||||
}
|
||||
|
||||
/// Parse normalized snippet text before assigning any byte offsets. Unsupported
|
||||
/// constructs omit the entire candidate, never leaking template syntax into text.
|
||||
pub(crate) fn expand(
|
||||
text: &str,
|
||||
variable: impl Fn(&str) -> Option<String>,
|
||||
) -> Result<(String, CompletionSnippet), Error> {
|
||||
if text.len() > MAX_BYTES {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
let normalized = if text.contains('\r') {
|
||||
std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(text)
|
||||
};
|
||||
let mut parser = Parser {
|
||||
text: &normalized,
|
||||
cursor: 0,
|
||||
nodes: Vec::new(),
|
||||
value_bytes: 0,
|
||||
variable,
|
||||
};
|
||||
let roots = parser.sequence(false, 0)?;
|
||||
let mut definitions = BTreeMap::new();
|
||||
let mut pending: Vec<_> = roots.iter().rev().copied().collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
match &parser.nodes[id] {
|
||||
Node::Stop(index, Some(children)) => {
|
||||
definitions.entry(*index).or_insert(id);
|
||||
pending.extend(children.iter().rev().copied());
|
||||
}
|
||||
Node::Default(children) => pending.extend(children.iter().rev().copied()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut rendered = Render {
|
||||
nodes: &parser.nodes,
|
||||
definitions: &definitions,
|
||||
text: String::new(),
|
||||
stops: BTreeMap::new(),
|
||||
ancestors: Vec::new(),
|
||||
occurrences: 0,
|
||||
work: 0,
|
||||
};
|
||||
rendered.sequence(&roots, 0)?;
|
||||
if !rendered.stops.contains_key(&0) {
|
||||
if rendered.occurrences == MAX_STOPS {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
rendered.stops.insert(
|
||||
0,
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: std::iter::once(rendered.text.len()..rendered.text.len()).collect(),
|
||||
ancestors: vec![Vec::new()],
|
||||
},
|
||||
);
|
||||
}
|
||||
let final_stop = rendered.stops.get(&0).unwrap();
|
||||
if final_stop.ranges.len() != 1
|
||||
|| !final_stop.ranges[0].is_empty()
|
||||
|| !final_stop.ancestors[0].is_empty()
|
||||
|| rendered.stops.values().any(|stop| {
|
||||
stop.ancestors
|
||||
.iter()
|
||||
.any(|ancestors| ancestors.contains(&0))
|
||||
})
|
||||
{
|
||||
return Err(Error::Unsupported);
|
||||
}
|
||||
let boundaries: Vec<_> = rendered
|
||||
.text
|
||||
.grapheme_indices(true)
|
||||
.map(|(offset, _)| offset)
|
||||
.chain(std::iter::once(rendered.text.len()))
|
||||
.collect();
|
||||
for stop in rendered.stops.values() {
|
||||
let mut previous: Option<&Range<usize>> = None;
|
||||
for range in &stop.ranges {
|
||||
if boundaries.binary_search(&range.start).is_err()
|
||||
|| boundaries.binary_search(&range.end).is_err()
|
||||
|| previous.is_some_and(|old| {
|
||||
old.end > range.start
|
||||
|| (old.start == range.start && !(old.is_empty() && range.is_empty()))
|
||||
})
|
||||
{
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
previous = Some(range);
|
||||
}
|
||||
}
|
||||
let final_stop = rendered.stops.remove(&0).unwrap();
|
||||
let mut tabstops: Vec<_> = rendered.stops.into_values().collect();
|
||||
tabstops.push(final_stop);
|
||||
Ok((rendered.text, CompletionSnippet { tabstops }))
|
||||
}
|
||||
|
||||
impl<'a, F: Fn(&str) -> Option<String>> Parser<'a, F> {
|
||||
fn node(&mut self, mut node: Node<'a>) -> Result<usize, Error> {
|
||||
if self.nodes.len() == MAX_NODES {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
if let Node::Value(value) = &mut node {
|
||||
if value.len() > MAX_BYTES {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
if value.contains('\r') {
|
||||
*value = value.replace("\r\n", "\n").replace('\r', "\n");
|
||||
}
|
||||
self.value_bytes = self
|
||||
.value_bytes
|
||||
.checked_add(value.len())
|
||||
.filter(|size| *size <= MAX_BYTES)
|
||||
.ok_or(Error::Limit)?;
|
||||
}
|
||||
let id = self.nodes.len();
|
||||
self.nodes.push(node);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn byte(&self) -> Option<u8> {
|
||||
self.text.as_bytes().get(self.cursor).copied()
|
||||
}
|
||||
|
||||
fn sequence(&mut self, nested: bool, depth: usize) -> Result<Vec<usize>, Error> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
let mut nodes = Vec::new();
|
||||
while let Some(byte) = self.byte() {
|
||||
if nested && byte == b'}' {
|
||||
self.cursor += 1;
|
||||
return Ok(nodes);
|
||||
}
|
||||
if byte == b'$' {
|
||||
nodes.push(self.construct(depth)?);
|
||||
} else if byte == b'\\' {
|
||||
self.cursor += 1;
|
||||
let start = self.cursor;
|
||||
if !matches!(self.byte(), Some(b'$' | b'}' | b'\\')) {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.cursor += 1;
|
||||
nodes.push(self.node(Node::Text(&self.text[start..self.cursor]))?);
|
||||
} else {
|
||||
let start = self.cursor;
|
||||
while let Some(next) = self.byte() {
|
||||
if next == b'$' || next == b'\\' || (nested && next == b'}') {
|
||||
break;
|
||||
}
|
||||
self.cursor += 1;
|
||||
}
|
||||
nodes.push(self.node(Node::Text(&self.text[start..self.cursor]))?);
|
||||
}
|
||||
}
|
||||
if nested {
|
||||
Err(Error::Invalid)
|
||||
} else {
|
||||
Ok(nodes)
|
||||
}
|
||||
}
|
||||
|
||||
fn construct(&mut self, depth: usize) -> Result<usize, Error> {
|
||||
self.cursor += 1;
|
||||
let braced = self.byte() == Some(b'{');
|
||||
self.cursor += usize::from(braced);
|
||||
let start = self.cursor;
|
||||
let numbered = self.byte().is_some_and(|byte| byte.is_ascii_digit());
|
||||
if numbered {
|
||||
while self.byte().is_some_and(|byte| byte.is_ascii_digit()) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
} else {
|
||||
if !self
|
||||
.byte()
|
||||
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
|
||||
{
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.cursor += 1;
|
||||
while self
|
||||
.byte()
|
||||
.is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
let name = &self.text[start..self.cursor];
|
||||
let mut children = None;
|
||||
if braced {
|
||||
match self.byte() {
|
||||
Some(b'}') => self.cursor += 1,
|
||||
Some(b':') => {
|
||||
self.cursor += 1;
|
||||
children = Some(self.sequence(true, depth + 1)?);
|
||||
}
|
||||
Some(b'|') if numbered => {
|
||||
self.cursor += 1;
|
||||
children = Some(self.choice()?);
|
||||
}
|
||||
Some(b'/') => return Err(Error::Unsupported),
|
||||
_ => return Err(Error::Invalid),
|
||||
}
|
||||
}
|
||||
if numbered {
|
||||
let index = name.parse::<u32>().map_err(|_| Error::Limit)?;
|
||||
self.node(Node::Stop(index, children))
|
||||
} else {
|
||||
match (self.variable)(name) {
|
||||
Some(value) if !value.is_empty() => self.node(Node::Value(value)),
|
||||
value => match children {
|
||||
Some(children) => self.node(Node::Default(children)),
|
||||
None if value.is_some() => self.node(Node::Text("")),
|
||||
None => Err(Error::Unsupported),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn choice(&mut self) -> Result<Vec<usize>, Error> {
|
||||
let mut first = String::new();
|
||||
let mut keep = true;
|
||||
loop {
|
||||
match self.byte().ok_or(Error::Invalid)? {
|
||||
b'|' => {
|
||||
self.cursor += 1;
|
||||
if self.byte() != Some(b'}') {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.cursor += 1;
|
||||
return Ok(vec![self.node(Node::Value(first))?]);
|
||||
}
|
||||
b',' => {
|
||||
keep = false;
|
||||
self.cursor += 1;
|
||||
}
|
||||
b'\\' => {
|
||||
self.cursor += 1;
|
||||
let byte = self.byte().ok_or(Error::Invalid)?;
|
||||
if !matches!(byte, b'$' | b'}' | b'\\' | b',' | b'|') {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
if keep {
|
||||
first.push(byte as char);
|
||||
}
|
||||
self.cursor += 1;
|
||||
}
|
||||
_ => {
|
||||
let ch = self.text[self.cursor..].chars().next().unwrap();
|
||||
if keep {
|
||||
first.push(ch);
|
||||
}
|
||||
self.cursor += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Render<'a> {
|
||||
nodes: &'a [Node<'a>],
|
||||
definitions: &'a BTreeMap<u32, usize>,
|
||||
text: String,
|
||||
stops: BTreeMap<u32, SnippetTabstop>,
|
||||
ancestors: Vec<u32>,
|
||||
occurrences: usize,
|
||||
work: usize,
|
||||
}
|
||||
|
||||
impl Render<'_> {
|
||||
fn sequence(&mut self, nodes: &[usize], depth: usize) -> Result<(), Error> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
let arena = self.nodes;
|
||||
for &id in nodes {
|
||||
self.work += 1;
|
||||
if self.work > MAX_WORK {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
match &arena[id] {
|
||||
Node::Text(text) => self.append(text)?,
|
||||
Node::Value(text) => self.append(text)?,
|
||||
Node::Default(children) => self.sequence(children, depth + 1)?,
|
||||
Node::Stop(index, _) => {
|
||||
if self.occurrences == MAX_STOPS {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
self.occurrences += 1;
|
||||
let start = self.text.len();
|
||||
let ancestors = self.ancestors.clone();
|
||||
if let Some(&definition) = self.definitions.get(index) {
|
||||
if self.ancestors.contains(index) {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.ancestors.push(*index);
|
||||
if let Node::Stop(_, Some(children)) = &arena[definition] {
|
||||
self.sequence(children, depth + 1)?;
|
||||
}
|
||||
self.ancestors.pop();
|
||||
}
|
||||
let stop = self.stops.entry(*index).or_insert_with(|| SnippetTabstop {
|
||||
index: *index,
|
||||
ranges: Vec::new(),
|
||||
ancestors: Vec::new(),
|
||||
});
|
||||
stop.ranges.push(start..self.text.len());
|
||||
stop.ancestors.push(ancestors);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append(&mut self, text: &str) -> Result<(), Error> {
|
||||
if self
|
||||
.text
|
||||
.len()
|
||||
.checked_add(text.len())
|
||||
.is_none_or(|size| size > MAX_BYTES)
|
||||
{
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
self.text.push_str(text);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// These fixtures intentionally contain ranges, not lists of indices.
|
||||
#[allow(clippy::single_range_in_vec_init)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grammar_expands_nested_mirrors_choices_variables_and_normalized_offsets() {
|
||||
let (text, snippet) = expand(
|
||||
"call($1, ${1:λ${2:x}}, ${3|a\\,b,c|}, ${UNKNOWN:${TM_LINE_NUMBER}})\r\n$0\\$",
|
||||
|name| (name == "TM_LINE_NUMBER").then(|| "4".to_owned()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(text, "call(λx, λx, a,b, 4)\n$");
|
||||
assert_eq!(
|
||||
snippet.tabstops,
|
||||
vec![
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![5..8, 10..13],
|
||||
ancestors: vec![vec![], vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 2,
|
||||
ranges: vec![7..8, 12..13],
|
||||
ancestors: vec![vec![1], vec![1]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 3,
|
||||
ranges: vec![15..18],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: vec![23..23],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
]
|
||||
);
|
||||
let (text, snippet) = expand("call($1, ${2})", |_| None).unwrap();
|
||||
assert_eq!(text, "call(, )");
|
||||
assert_eq!(
|
||||
snippet.tabstops,
|
||||
vec![
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![5..5],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 2,
|
||||
ranges: vec![7..7],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: vec![8..8],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
expand("${KNOWN:${1:hidden}}$1", |name| {
|
||||
(name == "KNOWN").then(|| "shown".to_owned())
|
||||
})
|
||||
.unwrap()
|
||||
.0,
|
||||
"shown"
|
||||
);
|
||||
let (text, snippet) = expand("call(${CTX})$1", |name| {
|
||||
(name == "CTX").then(|| "a\r\nb\rc".to_owned())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(text, "call(a\nb\nc)");
|
||||
assert_eq!(snippet.tabstops[0].ranges, [11..11]);
|
||||
assert_eq!(
|
||||
expand("${1|a\\|b,c|}\\}\\\\", |_| None).unwrap().0,
|
||||
"a|b}\\"
|
||||
);
|
||||
for input in ["${1", "${1:$1}", "${1:a}${1:b}$1", "${1:e}\u{301}"] {
|
||||
if input == "${1:a}${1:b}$1" {
|
||||
assert_eq!(expand(input, |_| None).unwrap().0, "aaa");
|
||||
} else {
|
||||
assert_eq!(expand(input, |_| None), Err(Error::Invalid));
|
||||
}
|
||||
}
|
||||
let (text, snippet) = expand("${1}$1", |_| None).unwrap();
|
||||
assert!(text.is_empty());
|
||||
assert_eq!(
|
||||
snippet.tabstops[0],
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![0..0, 0..0],
|
||||
ancestors: vec![vec![], vec![]],
|
||||
}
|
||||
);
|
||||
for input in [
|
||||
"$UNKNOWN",
|
||||
"${TM_FILENAME/(.*)/$1/}",
|
||||
"${1/a/b/}",
|
||||
"${1:a$0b}",
|
||||
"$0$0",
|
||||
"${0:arg}",
|
||||
"${0:$1}",
|
||||
] {
|
||||
assert_eq!(expand(input, |_| None), Err(Error::Unsupported));
|
||||
}
|
||||
assert_eq!(
|
||||
expand(&"$1".repeat(MAX_STOPS + 1), |_| None),
|
||||
Err(Error::Limit)
|
||||
);
|
||||
assert_eq!(
|
||||
expand(
|
||||
&format!(
|
||||
"{}x{}",
|
||||
"${1:".repeat(MAX_DEPTH + 1),
|
||||
"}".repeat(MAX_DEPTH + 1)
|
||||
),
|
||||
|_| None
|
||||
),
|
||||
Err(Error::Limit)
|
||||
);
|
||||
}
|
||||
}
|
||||
+354
-6
@@ -23,7 +23,7 @@ use crossterm::{
|
||||
use ratatui::{
|
||||
Frame, Terminal,
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
layout::{Constraint, Layout, Position, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
@@ -32,7 +32,9 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
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},
|
||||
plugin::{
|
||||
Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier,
|
||||
@@ -338,6 +340,7 @@ fn translate(key: KeyEvent) -> Option<Input> {
|
||||
KeyCode::Home => Input::Home,
|
||||
KeyCode::End => Input::End,
|
||||
KeyCode::Tab => Input::Char('\t'),
|
||||
KeyCode::BackTab => Input::BackTab,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -446,6 +449,7 @@ fn draw(
|
||||
let height = usize::from(viewport.height);
|
||||
let width = usize::from(viewport.width);
|
||||
let cursor = editor.cursor();
|
||||
let mut document_caret = None;
|
||||
let carets = editor.carets();
|
||||
let selections = editor.selections();
|
||||
if document.manual.as_ref().is_some_and(|manual| {
|
||||
@@ -665,7 +669,26 @@ fn draw(
|
||||
buffer[(viewport.x + (column - *left) as u16, y)].set_style(style);
|
||||
}
|
||||
}
|
||||
for selection in &selections {
|
||||
let selection_style = Style::default().fg(Color::White).bg(Color::Blue);
|
||||
for rows in editor.linewise_rows() {
|
||||
let first = (*rows.start()).max(*top);
|
||||
let last = (*rows.end()).min(top.saturating_add(height - 1));
|
||||
for row in first..=last {
|
||||
let end = display_column(&editor.lines()[row], usize::MAX)
|
||||
.saturating_add(1)
|
||||
.min(left.saturating_add(width));
|
||||
let y = viewport.y + (row - *top) as u16;
|
||||
for column in *left..end {
|
||||
frame.buffer_mut()[(viewport.x + (column - *left) as u16, y)]
|
||||
.set_style(selection_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
for selection in selections
|
||||
.iter()
|
||||
.filter(|_| !editor.visual_linewise())
|
||||
.chain(editor.snippet_selections())
|
||||
{
|
||||
let first = selection.start.row.max(*top);
|
||||
let last = selection.end.row.min(top.saturating_add(height - 1));
|
||||
let style = Style::default().fg(Color::White).bg(Color::Blue);
|
||||
@@ -712,13 +735,20 @@ fn draw(
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { '�' } else { c })
|
||||
.collect();
|
||||
let mut status = format!(" {}", editor.mode());
|
||||
let mut status = if editor.visual_linewise() {
|
||||
" VISUAL LINE".into()
|
||||
} else {
|
||||
format!(" {}", editor.mode())
|
||||
};
|
||||
if let Some(sequence) = editor.leader_pending() {
|
||||
write!(status, " leader: {sequence}").unwrap();
|
||||
}
|
||||
if editor.g_pending() {
|
||||
status.push_str(" g");
|
||||
}
|
||||
if let Some(operator) = editor.line_operator_pending() {
|
||||
write!(status, " {operator}").unwrap();
|
||||
}
|
||||
if editor.buffer_count() > 1 {
|
||||
write!(
|
||||
status,
|
||||
@@ -801,13 +831,20 @@ fn draw(
|
||||
&& column >= *left
|
||||
&& column < left.saturating_add(width)
|
||||
{
|
||||
frame.set_cursor_position((
|
||||
let position = Position::new(
|
||||
viewport.x + (column - *left) 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 picker.presentation == FilePresentation::Floating {
|
||||
let panel = floating_panel(area);
|
||||
@@ -897,6 +934,7 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
|
||||
|
||||
#[derive(Default)]
|
||||
struct WheelPanels {
|
||||
completion: Option<Rect>,
|
||||
hover: Option<(Rect, usize)>,
|
||||
hover_syntax: SyntaxCache,
|
||||
files: Option<(Rect, usize, usize)>,
|
||||
@@ -933,6 +971,7 @@ impl WheelPanels {
|
||||
self.manual_files = None;
|
||||
}
|
||||
self.hover = None;
|
||||
self.completion = None;
|
||||
self.files = None;
|
||||
}
|
||||
|
||||
@@ -973,6 +1012,13 @@ impl WheelPanels {
|
||||
return true;
|
||||
}
|
||||
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()
|
||||
&& let Some((panel, height)) = self.hover
|
||||
&& panel.contains(position)
|
||||
@@ -1023,6 +1069,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) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
@@ -1482,6 +1591,8 @@ fn semantic_style(token: &SemanticToken) -> Style {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// These fixtures intentionally contain ranges, not lists of indices.
|
||||
#[allow(clippy::single_range_in_vec_init)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
@@ -1529,6 +1640,156 @@ mod tests {
|
||||
.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![],
|
||||
snippet: None,
|
||||
})
|
||||
.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]
|
||||
fn snippet_highlight_tracks_argument_graphemes_without_filling_the_row() {
|
||||
use crate::plugin::{
|
||||
CompletionEdit, CompletionItem, CompletionSnippet, SnippetTabstop, TextPosition,
|
||||
};
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.handle(Input::Char('i'));
|
||||
editor
|
||||
.show_completion(vec![CompletionItem {
|
||||
label: "call".into(),
|
||||
detail: None,
|
||||
primary_edit: 0,
|
||||
edits: vec![CompletionEdit {
|
||||
start: TextPosition { row: 0, column: 0 },
|
||||
end: TextPosition { row: 0, column: 0 },
|
||||
text: "call(界e\u{301}, tail)".into(),
|
||||
}],
|
||||
snippet: Some(CompletionSnippet {
|
||||
tabstops: vec![
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![5..11],
|
||||
ancestors: vec![vec![]],
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 2,
|
||||
ranges: vec![13..17],
|
||||
ancestors: vec![vec![]],
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: vec![18..18],
|
||||
ancestors: vec![vec![]],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}])
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(28, 6)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
for (key, selected, caret) in [
|
||||
(None, 7..10, Position::new(7, 0)),
|
||||
(Some(Input::Char('\t')), 12..16, Position::new(12, 0)),
|
||||
] {
|
||||
if let Some(key) = key {
|
||||
editor.handle(key);
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
// A wide glyph's leading cell owns its terminal style.
|
||||
for x in (0..28).filter(|x| *x != 8) {
|
||||
assert_eq!(
|
||||
buffer[(x, 0)].bg == Color::Blue,
|
||||
selected.contains(&x),
|
||||
"column {x}, selection {:?}, screen:\n{}",
|
||||
editor.snippet_selections(),
|
||||
screen_text(&terminal),
|
||||
);
|
||||
}
|
||||
assert_eq!(terminal.get_cursor_position().unwrap(), caret);
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
for x in 0..28 {
|
||||
assert_ne!(terminal.backend().buffer()[(x, 0)].bg, Color::Blue);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
@@ -5552,6 +5813,93 @@ end
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linewise_selection_highlights_copied_text_and_newlines_without_filling_rows() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
"\t界e\u{301} \n\n".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('V'));
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Down);
|
||||
let before = (editor.lines().to_vec(), editor.cursor(), editor.carets());
|
||||
for width in [16, 3, 1] {
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 6)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let frame = terminal
|
||||
.draw(|frame| {
|
||||
draw(
|
||||
frame,
|
||||
&editor,
|
||||
&mut top,
|
||||
&mut left,
|
||||
&mut SyntaxCache::new(),
|
||||
&mut DocumentViewport::default(),
|
||||
&mut WheelPanels::default(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = frame.buffer;
|
||||
if width == 16 {
|
||||
let status: String = (0..width).map(|x| buffer[(x, 4)].symbol()).collect();
|
||||
assert!(status.contains("VISUAL LINE"), "{status}");
|
||||
}
|
||||
let gutter = if width > 2 { 2 } else { 0 };
|
||||
for row in 0..3 {
|
||||
for x in 0..gutter {
|
||||
assert_ne!(buffer[(x, row)].bg, Color::Blue);
|
||||
}
|
||||
let selected_width = if row == 0 { 10 } else { 1 };
|
||||
for x in gutter..width {
|
||||
if x - gutter < selected_width {
|
||||
assert_eq!(
|
||||
buffer[(x, row)].bg,
|
||||
Color::Blue,
|
||||
"width {width}, row {row}, x {x}"
|
||||
);
|
||||
} else {
|
||||
assert_ne!(buffer[(x, row)].bg, Color::Blue);
|
||||
}
|
||||
}
|
||||
}
|
||||
for x in 0..width {
|
||||
assert_ne!(buffer[(x, 3)].bg, Color::Blue);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
before,
|
||||
(editor.lines().to_vec(), editor.cursor(), editor.carets())
|
||||
);
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::End);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 3)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw(
|
||||
frame,
|
||||
&editor,
|
||||
&mut top,
|
||||
&mut left,
|
||||
&mut SyntaxCache::new(),
|
||||
&mut DocumentViewport::default(),
|
||||
&mut WheelPanels::default(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(left > 0);
|
||||
let buffer = terminal.backend().buffer();
|
||||
for x in 0..2 {
|
||||
assert_ne!(buffer[(x, 0)].bg, Color::Blue);
|
||||
}
|
||||
for x in 2..5 {
|
||||
assert_eq!(buffer[(x, 0)].bg, Color::Blue);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_selection_highlights_graphemes_newlines_and_clipped_ranges_over_search() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
|
||||
+836
-1
@@ -68,12 +68,13 @@ initialize_id = nil
|
||||
initialized_sent = false
|
||||
held = []
|
||||
semantic_held = []
|
||||
completion_held = []
|
||||
definition_count = 0
|
||||
source_uri = uri(source)
|
||||
documents = {}
|
||||
control_id = nil
|
||||
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
|
||||
when 'numeric' then 1
|
||||
when 'none' then {'openClose' => false, 'change' => 0, 'save' => false}
|
||||
@@ -83,6 +84,7 @@ loop do
|
||||
capabilities = {'positionEncoding' => 'utf-16',
|
||||
'textDocumentSync' => sync, 'definitionProvider' => ['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')
|
||||
capabilities['semanticTokensProvider'] = {
|
||||
'legend' => {'tokenTypes' => ['custom', 'variable', 'function', 'comment', 'type'],
|
||||
@@ -115,6 +117,10 @@ loop do
|
||||
request = semantic_held.delete_at(command.fetch('index', 0))
|
||||
raise 'no held semantic request' unless request
|
||||
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'
|
||||
emit({'id' => "refresh-#{control_id}", 'method' => 'workspace/semanticTokens/refresh'})
|
||||
when 'die'
|
||||
@@ -223,6 +229,21 @@ loop do
|
||||
hover = contents.nil? ? nil : {'contents' => contents, 'range' => range(mode == 'hover-invalid-range' ? 999 : 0, 2, 4)}
|
||||
result(frame['id'], hover)
|
||||
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'
|
||||
if mode.start_with?('controlled')
|
||||
semantic_held << frame
|
||||
@@ -2238,3 +2259,817 @@ 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", "alSnippet"]
|
||||
);
|
||||
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().context().text, "alsnippet 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, "alsnippet 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"], true);
|
||||
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!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": "${1"}]}),
|
||||
json!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": "${1:$1}"}]}),
|
||||
json!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": "${1:e}\u{301}"}]}),
|
||||
json!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": format!("{}x{}", "${1:".repeat(33), "}".repeat(33))}]}),
|
||||
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": []}),
|
||||
]
|
||||
.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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_snippet_preview_mirrors_navigation_final_stop_undo_and_resolved_dot() {
|
||||
let fixture = Fixture::new();
|
||||
fs::write(&fixture.source, " tail\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::Char('c'));
|
||||
session.handle(Input::Char('a'));
|
||||
let original_carets = session.editor().carets();
|
||||
session.handle(Input::Ctrl('n'));
|
||||
until(&mut session, &fixture, |_| {
|
||||
fixture.method_count("textDocument/completion") == 1
|
||||
});
|
||||
completion_reply(
|
||||
&mut session,
|
||||
&fixture,
|
||||
1,
|
||||
json!({"result": [{
|
||||
"label": "call", "insertTextFormat": 2,
|
||||
"insertText": "call(${1:arg}, $1, ${2|left,right|})$0"
|
||||
}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"call(arg, arg, left) tail\n"
|
||||
);
|
||||
assert_eq!(session.editor().snippet_selections().len(), 2);
|
||||
session.handle(Input::Ctrl('n'));
|
||||
assert_eq!(session.editor().context().text, "ca tail\n");
|
||||
assert_eq!(session.editor().carets(), original_carets);
|
||||
assert!(session.editor().snippet_selections().is_empty());
|
||||
session.handle(Input::Ctrl('n'));
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"call(arg, arg, left) tail\n"
|
||||
);
|
||||
session.handle(Input::Char('x'));
|
||||
session.handle(Input::Char('y'));
|
||||
assert_eq!(session.editor().context().text, "call(xy, xy, left) tail\n");
|
||||
session.handle(Input::Char('\t'));
|
||||
assert_eq!(session.editor().snippet_selections().len(), 1);
|
||||
session.handle(Input::BackTab);
|
||||
assert_eq!(session.editor().snippet_selections().len(), 2);
|
||||
session.handle(Input::Char('v'));
|
||||
assert_eq!(session.editor().context().text, "call(v, v, left) tail\n");
|
||||
session.handle(Input::Char('\t'));
|
||||
session.handle(Input::Char('r'));
|
||||
assert_eq!(session.editor().context().text, "call(v, v, r) tail\n");
|
||||
session.handle(Input::Char('\t'));
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 13 });
|
||||
assert!(session.editor().snippet_selections().is_empty());
|
||||
session.handle(Input::Char('\t'));
|
||||
session.handle(Input::Enter);
|
||||
let resolved = "call(v, v, r)\t\n tail\n";
|
||||
assert_eq!(session.editor().context().text, resolved);
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().context().text, " tail\n");
|
||||
session.handle(Input::Char('.'));
|
||||
assert_eq!(session.editor().context().text, resolved);
|
||||
assert_eq!(fixture.method_count("textDocument/completion"), 1);
|
||||
assert_eq!(fixture.method_count("completionItem/resolve"), 0);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().context().text, " tail\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_snippet_primary_edit_preserves_secondary_carets_and_escape_keeps_defaults() {
|
||||
let fixture = Fixture::new();
|
||||
fs::write(&fixture.source, "ca tail\nca 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'));
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Right);
|
||||
session.handle(Input::Right);
|
||||
let original = session.editor().carets();
|
||||
session.handle(Input::Ctrl('n'));
|
||||
until(&mut session, &fixture, |_| {
|
||||
fixture.method_count("textDocument/completion") == 1
|
||||
});
|
||||
completion_reply(
|
||||
&mut session,
|
||||
&fixture,
|
||||
1,
|
||||
json!({"result": [{
|
||||
"label": "call", "insertTextFormat": 2, "insertText": "call(${1:arg}, $1)$0",
|
||||
"additionalTextEdits": [{
|
||||
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
|
||||
"newText": "use X;\n"
|
||||
}]
|
||||
}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"use X;\nca tail\ncall(arg, arg) tail\n"
|
||||
);
|
||||
assert_eq!(session.editor().carets()[0], Cursor { row: 1, column: 2 });
|
||||
session.handle(Input::Ctrl('n'));
|
||||
assert_eq!(session.editor().context().text, "ca tail\nca tail\n");
|
||||
assert_eq!(session.editor().carets(), original);
|
||||
session.handle(Input::Ctrl('n'));
|
||||
session.handle(Input::Char('x'));
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"use X;\nca tail\ncall(x, x) tail\n"
|
||||
);
|
||||
assert_eq!(session.editor().carets()[0], Cursor { row: 1, column: 2 });
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"use X;\nca tail\ncall(x, x) tail\n"
|
||||
);
|
||||
assert!(session.editor().snippet_selections().is_empty());
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().context().text, "ca tail\nca tail\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_snippet_defaults_all_edit_shapes_context_and_unsupported_siblings() {
|
||||
let fixture = Fixture::new();
|
||||
fs::write(&fixture.source, "ca suffix\n// tail\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
|
||||
});
|
||||
let range = json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 2}});
|
||||
completion_reply(
|
||||
&mut session,
|
||||
&fixture,
|
||||
1,
|
||||
json!({"result": {
|
||||
"isIncomplete": false,
|
||||
"itemDefaults": {"editRange": range, "insertTextFormat": 2},
|
||||
"items": [
|
||||
{"label": "unsupported transform", "sortText": "0", "textEditText": "${1/a/b/}"},
|
||||
{"label": "unknown context", "sortText": "0", "textEditText": "$CLIPBOARD"},
|
||||
{"label": "replacement", "sortText": "1", "textEdit": {"range": range, "newText": "call(${1:λ})\r\n$0"},
|
||||
"additionalTextEdits": [{"range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 0}}, "newText": "// $1\n"}]},
|
||||
{"label": "insert replace", "sortText": "2", "textEdit": {"insert": range, "replace": range, "newText": "call(${1:${2:arg}})$0"}},
|
||||
{"label": "default text edit", "sortText": "3", "textEditText": "call(${1|one,two|})$0"},
|
||||
{"label": "default insert text", "sortText": "4", "insertText": "call(${1:${UNKNOWN:${TM_LINE_NUMBER}}})$0"},
|
||||
{"label": "default label ${1:arg}$0", "sortText": "5"}
|
||||
]
|
||||
}}),
|
||||
);
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"call(λ)\n suffix\n// $1\n// tail\n"
|
||||
);
|
||||
session.handle(Input::Ctrl('n'));
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"call(arg) suffix\n// tail\n"
|
||||
);
|
||||
session.handle(Input::Ctrl('n'));
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"call(one) suffix\n// tail\n"
|
||||
);
|
||||
session.handle(Input::Ctrl('n'));
|
||||
assert_eq!(session.editor().context().text, "call(1) suffix\n// tail\n");
|
||||
session.handle(Input::Ctrl('n'));
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"default label arg suffix\n// tail\n"
|
||||
);
|
||||
session.handle(Input::Ctrl('n'));
|
||||
assert_eq!(session.editor().context().text, "ca suffix\n// tail\n");
|
||||
session.handle(Input::Escape);
|
||||
assert!(!session.editor().dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_snippet_escape_preserves_preview_and_ordinary_keys_remain_literal() {
|
||||
let fixture = Fixture::new();
|
||||
fs::write(&fixture.source, "ca\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'));
|
||||
until(&mut session, &fixture, |_| {
|
||||
fixture.method_count("textDocument/completion") == 1
|
||||
});
|
||||
completion_reply(
|
||||
&mut session,
|
||||
&fixture,
|
||||
1,
|
||||
json!({"result": [{
|
||||
"label": "call", "insertTextFormat": 2, "insertText": "call(${1:arg})$0"
|
||||
}]}),
|
||||
);
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||
assert_eq!(session.editor().context().text, "call(arg)\n");
|
||||
assert!(session.editor().snippet_selections().is_empty());
|
||||
session.handle(Input::Char('A'));
|
||||
session.handle(Input::Char('\t'));
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(session.editor().context().text, "call(arg)\t\n\n");
|
||||
assert_eq!(fixture.method_count("textDocument/completion"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_snippet_nested_defaults_update_enclosing_mirrors_then_replace_outer() {
|
||||
let fixture = Fixture::new();
|
||||
fs::write(&fixture.source, "ca\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'));
|
||||
until(&mut session, &fixture, |_| {
|
||||
fixture.method_count("textDocument/completion") == 1
|
||||
});
|
||||
completion_reply(
|
||||
&mut session,
|
||||
&fixture,
|
||||
1,
|
||||
json!({"result": [{
|
||||
"label": "call", "insertTextFormat": 2,
|
||||
"insertText": "call(${2:outer ${1:inner}}, $2)$0"
|
||||
}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"call(outer inner, outer inner)\n"
|
||||
);
|
||||
assert_eq!(session.editor().snippet_selections().len(), 2);
|
||||
session.handle(Input::Char('x'));
|
||||
assert_eq!(session.editor().context().text, "call(outer x, outer x)\n");
|
||||
session.handle(Input::Char('\t'));
|
||||
assert_eq!(session.editor().snippet_selections().len(), 2);
|
||||
session.handle(Input::Char('y'));
|
||||
assert_eq!(session.editor().context().text, "call(y, y)\n");
|
||||
session.handle(Input::Char('\t'));
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 10 });
|
||||
assert!(session.editor().snippet_selections().is_empty());
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().context().text, "ca\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_snippet_ancestry_distinguishes_empty_child_from_adjacent_root() {
|
||||
for (template, nested) in [("${1:foo${2}}-$3$0", true), ("${1:foo}$2-$3$0", false)] {
|
||||
let fixture = Fixture::new();
|
||||
fs::write(&fixture.source, "ca\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'));
|
||||
until(&mut session, &fixture, |_| {
|
||||
fixture.method_count("textDocument/completion") == 1
|
||||
});
|
||||
completion_reply(
|
||||
&mut session,
|
||||
&fixture,
|
||||
1,
|
||||
json!({"result": [{
|
||||
"label": "call", "insertTextFormat": 2, "insertText": template
|
||||
}]}),
|
||||
);
|
||||
assert_eq!(session.editor().context().text, "foo-\n");
|
||||
let snippet = session.editor().completion().unwrap().items[0]
|
||||
.snippet
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
snippet.tabstops[1].ancestors,
|
||||
[if nested { vec![1] } else { vec![] }]
|
||||
);
|
||||
session.handle(Input::Char('X'));
|
||||
assert_eq!(session.editor().context().text, "X-\n");
|
||||
session.handle(Input::Char('\t'));
|
||||
if nested {
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 });
|
||||
} else {
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
|
||||
session.handle(Input::Char('y'));
|
||||
assert_eq!(session.editor().context().text, "Xy-\n");
|
||||
session.handle(Input::Char('\t'));
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 3 });
|
||||
}
|
||||
session.handle(Input::Char('z'));
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
if nested { "X-z\n" } else { "Xy-z\n" }
|
||||
);
|
||||
session.handle(Input::Char('\t'));
|
||||
assert!(session.editor().snippet_selections().is_empty());
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().context().text, "ca\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_snippet_coincident_empty_mirrors_insert_and_delete_each_occurrence() {
|
||||
let fixture = Fixture::new();
|
||||
fs::write(&fixture.source, "ca\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'));
|
||||
until(&mut session, &fixture, |_| {
|
||||
fixture.method_count("textDocument/completion") == 1
|
||||
});
|
||||
completion_reply(
|
||||
&mut session,
|
||||
&fixture,
|
||||
1,
|
||||
json!({"result": [{
|
||||
"label": "call", "insertTextFormat": 2, "insertText": "call(${1}$1)$0"
|
||||
}]}),
|
||||
);
|
||||
assert_eq!(session.editor().context().text, "call()\n");
|
||||
assert_eq!(session.editor().snippet_selections().len(), 2);
|
||||
session.handle(Input::Char('x'));
|
||||
session.handle(Input::Char('y'));
|
||||
assert_eq!(session.editor().context().text, "call(xyxy)\n");
|
||||
session.handle(Input::Backspace);
|
||||
assert_eq!(session.editor().context().text, "call(xx)\n");
|
||||
session.handle(Input::Backspace);
|
||||
assert_eq!(session.editor().context().text, "call()\n");
|
||||
session.handle(Input::Char('z'));
|
||||
assert_eq!(session.editor().context().text, "call(zz)\n");
|
||||
session.handle(Input::Char('\t'));
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 8 });
|
||||
assert!(session.editor().snippet_selections().is_empty());
|
||||
session.handle(Input::Char('\t'));
|
||||
assert_eq!(session.editor().context().text, "call(zz)\t\n");
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().context().text, "ca\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user