feat: expand completion snippets and navigate placeholders
This commit is contained in:
@@ -23,6 +23,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
|
||||
| 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) |
|
||||
| Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous |
|
||||
@@ -45,7 +46,7 @@ Ruby configuration and plugins run with your account's filesystem/process permis
|
||||
|
||||
- One visible buffer at a time; no splits/tabs or graphical frontend.
|
||||
- Modal editing, not full Vim compatibility: characterwise and linewise Visual selections, but no block selection, named registers, or Vim operators. Search is literal, case-sensitive, and single-line.
|
||||
- Native opt-in LSP supports diagnostics, go-to-definition, plain-text hover, manual primary-caret completion, and automatic semantic highlighting—not rename, formatting, or code actions. Completion has no automatic popup, snippets, or acceptance step; other carets are preserved and translated through the chosen edits.
|
||||
- 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).
|
||||
|
||||
@@ -15,6 +15,7 @@ One crate; no workspace or terminal-coordinate types in the editing backend:
|
||||
- `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 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.
|
||||
@@ -37,6 +38,7 @@ Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated act
|
||||
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
|
||||
|
||||
@@ -70,7 +72,7 @@ Language configurations can own an ordered server stack. The first matching lang
|
||||
|
||||
Semantic tokens belong only to the first assigned server. Initialization advertises standard token types/modifiers, relative encoding, full/range requests, and server refresh support; deltas, overlapping/multiline tokens, and dynamic registration are not advertised. Each buffer has at most one request in flight; changes/refreshes coalesce into the latest snapshot. Full requests are preferred, with whole-document range fallback. A linear UTF-16 decoder validates raw coordinates and legend indices before outward grapheme rounding; overlong ends truncate at EOL, unknown types and invisible spans fall back to syntax. Both wire frames and decoded spans are bounded to 4 MiB. Snapshot identity rejects stale/renamed/closed replies before decoding. Invalid semantic replies clear the overlay and report an error without retiring diagnostics/navigation; primary retirement clears its overlays, and sibling retirement leaves them intact.
|
||||
|
||||
Manual completion dispatches the Insert-only `core.lsp.completion` action directly to the session, without the Ex execution path's mode/history transitions. The native worker sends the primary caret as UTF-16 and normalizes results into `CompletionItem { label, detail, primary_edit, edits }`; each `CompletionEdit` uses original-document, end-exclusive grapheme coordinates. Primary and additional edits form one atomic candidate. Session freshness includes the latest request, buffer/revision, primary caret, Insert source mode, and absence of modal overlays; any intervening input invalidates a pending reply. Decoding/validation errors remain completion-local and preserve healthy diagnostics/semantic tokens. Snippets, server commands, indentation adjustment, resolve requests, and automatic triggers are not supported; both frames and aggregate decoded completion text remain bounded.
|
||||
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
|
||||
|
||||
|
||||
@@ -50,12 +50,12 @@ 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 `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.
|
||||
|
||||
@@ -69,7 +69,7 @@ The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visua
|
||||
| Normal | `follow_link` |
|
||||
| Normal | `jump_back`, `jump_forward` |
|
||||
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
||||
| Insert | `lsp.completion` |
|
||||
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
|
||||
| Normal | `search`, `paste_before`, `paste_after` |
|
||||
| Normal | `paste_system_before`, `paste_system_after` |
|
||||
| Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` |
|
||||
@@ -206,11 +206,13 @@ 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 it and act normally, with no accept key: Enter inserts a newline, Tab inserts a tab, and Escape returns to Normal keeping the displayed candidate. Secondary carets are translated, not completed independently. Cycling belongs to the pending Insert undo group; repeat records only the final chosen edits. See [editing behavior](editing.md#manual-lsp-completion).
|
||||
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.
|
||||
|
||||
There are no automatic triggers, snippets, commit characters, server commands, indentation adjustment, or completion-item resolve requests. Items requiring snippet expansion, commands, or indentation adjustment are omitted rather than partly applied. Server-specific availability depends on the installed server's advertised capabilities; configuring a preset does not guarantee completion support.
|
||||
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.
|
||||
|
||||
|
||||
+10
-3
@@ -28,8 +28,9 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
||||
| 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; `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 |
|
||||
|
||||
@@ -41,10 +42,16 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
||||
|
||||
With an enabled server that provides completion, Ctrl+n in Insert requests candidates at the **primary caret only**. The first candidate is inserted immediately and a small list appears near the caret; Ctrl+n or Ctrl+j cycles forward, Ctrl+k backward, with wraparound. There is no acceptance step. The **Original text** row restores the document and carets from before the result was shown, including any additional edits. Completion never broadcasts a candidate to secondary carets: they are preserved and translated through each chosen edit.
|
||||
|
||||
Every candidate is applied from that same immutable original document, not on top of the previous candidate. A candidate's primary replacement and additional text edits are one atomic change. Every other key closes the list and performs its ordinary action: typing continues at the carets, Enter inserts a newline, Tab inserts a literal tab, and Escape returns to Normal retaining the displayed candidate. There is no automatic popup or snippet expansion. Empty, unsupported, or invalid results leave the document unchanged and report a message.
|
||||
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.
|
||||
@@ -192,7 +199,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.
|
||||
|
||||
|
||||
+1147
-24
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;
|
||||
|
||||
+71
-8
@@ -22,6 +22,7 @@ use crate::{
|
||||
DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind,
|
||||
SemanticTokenModifier, TextPosition, UpdateEffect,
|
||||
},
|
||||
snippet,
|
||||
};
|
||||
|
||||
const MAX_BODY: usize = 4 * 1024 * 1024;
|
||||
@@ -683,7 +684,7 @@ impl Runtime {
|
||||
"dynamicRegistration": false, "contextSupport": true,
|
||||
"insertTextMode": 1,
|
||||
"completionItem": {
|
||||
"snippetSupport": false, "commitCharactersSupport": false,
|
||||
"snippetSupport": true, "commitCharactersSupport": false,
|
||||
"insertReplaceSupport": true,
|
||||
"insertTextModeSupport": {"valueSet": [1]},
|
||||
"resolveSupport": {"properties": []}
|
||||
@@ -1030,7 +1031,9 @@ impl Runtime {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
match result.and_then(|value| decode_completion(&snapshot.text, value, &source)) {
|
||||
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 {
|
||||
@@ -2538,6 +2541,7 @@ fn decode_completion(
|
||||
text: &str,
|
||||
result: &Value,
|
||||
source: &Source,
|
||||
path: Option<&str>,
|
||||
) -> Result<Vec<CompletionItem>, String> {
|
||||
if result.is_null() {
|
||||
return Ok(Vec::new());
|
||||
@@ -2611,6 +2615,17 @@ fn decode_completion(
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -2653,8 +2668,8 @@ fn decode_completion(
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(1);
|
||||
// No snippets, indentation adjustment, server commands or lazy edit resolution.
|
||||
if format == 2 || mode == 2 || item.get("command").is_some() {
|
||||
// No indentation adjustment, server commands or lazy edit resolution.
|
||||
if mode == 2 || item.get("command").is_some() {
|
||||
continue;
|
||||
}
|
||||
let explicit = item.get("textEdit");
|
||||
@@ -2669,7 +2684,7 @@ fn decode_completion(
|
||||
(start, end, new_text)
|
||||
} else if let Some(range) = default_range {
|
||||
let (start, end) = completion_replacement(&lines, range, source)?;
|
||||
(start, end, edit_text.unwrap_or(label))
|
||||
(start, end, edit_text.or(insert).unwrap_or(label))
|
||||
} else {
|
||||
(
|
||||
TextPosition {
|
||||
@@ -2693,10 +2708,25 @@ fn decode_completion(
|
||||
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: normalized(new_text).into_owned(),
|
||||
text: new_text,
|
||||
}];
|
||||
if let Some(additional) = item.get("additionalTextEdits") {
|
||||
for edit in additional
|
||||
@@ -2749,6 +2779,7 @@ fn decode_completion(
|
||||
detail: detail.map(str::to_owned),
|
||||
primary_edit,
|
||||
edits,
|
||||
snippet,
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -2756,6 +2787,38 @@ fn decode_completion(
|
||||
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()
|
||||
@@ -3077,7 +3140,7 @@ mod tests {
|
||||
};
|
||||
let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]);
|
||||
assert_eq!(
|
||||
decode_completion("", &at_limit, &source).unwrap()[0].edits[0]
|
||||
decode_completion("", &at_limit, &source, None).unwrap()[0].edits[0]
|
||||
.text
|
||||
.len(),
|
||||
MAX_BODY - 1
|
||||
@@ -3087,7 +3150,7 @@ mod tests {
|
||||
{"label": "y"}
|
||||
]);
|
||||
assert!(
|
||||
decode_completion("", &over_limit, &source)
|
||||
decode_completion("", &over_limit, &source, None)
|
||||
.unwrap_err()
|
||||
.contains("4 MiB")
|
||||
);
|
||||
|
||||
+3
-2
@@ -19,7 +19,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
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)
|
||||
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; Insert Tab unchanged)
|
||||
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
|
||||
Normal/Visual: . repeats last native edit at current targets (one undo group)
|
||||
@@ -32,7 +32,8 @@ Visual: motions extend selections, y copy, d/x/Delete cut, c change
|
||||
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
|
||||
Enter inserts newline, Tab inserts tab, Esc returns to Normal retaining candidate/carets
|
||||
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]
|
||||
|
||||
@@ -113,6 +113,22 @@ pub struct CompletionEdit {
|
||||
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 {
|
||||
@@ -121,6 +137,7 @@ pub struct CompletionItem {
|
||||
/// 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)]
|
||||
|
||||
+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)
|
||||
);
|
||||
}
|
||||
}
|
||||
+81
-1
@@ -340,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,
|
||||
})
|
||||
}
|
||||
@@ -683,7 +684,11 @@ fn draw(
|
||||
}
|
||||
}
|
||||
}
|
||||
for selection in selections.iter().filter(|_| !editor.visual_linewise()) {
|
||||
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);
|
||||
@@ -1583,6 +1588,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::{
|
||||
@@ -1643,6 +1650,7 @@ mod tests {
|
||||
detail: Some("\tTAIL".into()),
|
||||
primary_edit: 0,
|
||||
edits: vec![],
|
||||
snippet: None,
|
||||
})
|
||||
.collect(),
|
||||
selected: 20,
|
||||
@@ -1707,6 +1715,78 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
+366
-3
@@ -2301,7 +2301,7 @@ fn lsp_completion_live_cycle_original_and_ordinary_keys_use_one_request() {
|
||||
.iter()
|
||||
.map(|item| item.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["alpha", "alps"]
|
||||
["alpha", "alps", "alSnippet"]
|
||||
);
|
||||
assert_eq!(popup.items[0].detail.as_deref(), Some("first candidate"));
|
||||
assert_eq!(popup.selected, 1);
|
||||
@@ -2310,9 +2310,13 @@ fn lsp_completion_live_cycle_original_and_ordinary_keys_use_one_request() {
|
||||
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");
|
||||
@@ -2481,7 +2485,7 @@ fn lsp_completion_utf16_text_edit_insert_replace_and_defaults_are_atomic() {
|
||||
.iter()
|
||||
.find(|frame| frame["method"] == "initialize")
|
||||
.unwrap()["params"]["capabilities"]["textDocument"]["completion"];
|
||||
assert_eq!(capabilities["completionItem"]["snippetSupport"], false);
|
||||
assert_eq!(capabilities["completionItem"]["snippetSupport"], true);
|
||||
assert_eq!(capabilities["completionItem"]["insertReplaceSupport"], true);
|
||||
assert_eq!(
|
||||
capabilities["completionItem"]["resolveSupport"]["properties"],
|
||||
@@ -2556,6 +2560,10 @@ fn lsp_completion_feature_errors_keep_healthy_services() {
|
||||
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"}}),
|
||||
];
|
||||
@@ -2623,7 +2631,6 @@ fn lsp_completion_fallback_filter_text_insert_text_and_empty_shapes() {
|
||||
Value::Null,
|
||||
json!([]),
|
||||
json!({"isIncomplete": false, "items": []}),
|
||||
json!([{"label": "alSnippet", "insertTextFormat": 2, "insertText": "${1:al}"}]),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -2710,3 +2717,359 @@ fn lsp_completion_routes_only_to_first_configured_server_without_fallback() {
|
||||
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