editor: Completions receive an empty or document-wide `trigger_character`
Description
Two defects in how the editor measures the completion prefix
(InputBaseState::<EditorMode>::handle_completion_trigger) both land in the same
place: the string handed to CompletionProvider::completions as
trigger_character is wrong.
- A replacement is measured from
range.end. The code useslet start = range.end;as "where the text just typed begins". That only holds for a collapsed caret. When a keystroke replaces a span — typing over a selection, or an IME commit — the new text begins atrange.start, so an equally long replacement measures an empty prefix (start == new_offset), while a longer one is dropped before the provider is called at all (new_offset < starttakes the guard below). trigger_start_offsetis a latch that is never invalidated. Once written it is reused by every later keystroke. Nothing clears it — not a deletion, nothide_context_menu()(which is what the arrow-key / click / dismiss paths call), not a document switch. So typing a single character while the caret sits at offset 0 latches it toSome(0), and every subsequent keystroke anywhere in the document measurestext[0..caret].
Both are easy to hit in practice: a placeholder typed over a selection on Windows, and the first character any IME user types.
Environment
- GPUI:
gpui-prev0.3.5 - GPUI Component: v0.6.1 (
9031c5e1) - Platform: Windows 11,
core.autocrlf=true
Steps to Reproduce
First make the prefix visible — the example silently gives up on an empty prefix,
so print what the provider actually receives in examples/editor/src/main.rs:
let trigger_character = trigger.trigger_character.unwrap_or_default();
if dbg!(&trigger_character).is_empty() {
return Task::ready(Ok(CompletionResponse::Array(vec![])));
}A. Stale latch — the whole document becomes the prefix
cargo run -p example-editor- Click at the very start of the document (offset 0).
- Type
x, then Backspace. The document is byte-identical to how it started. - Double-click an empty line and type
a. - The provider is handed everything from offset 0 up to the caret, and the menu never appears:
&trigger_character = "use serde::{Deserialize, Serialize};\r\nuse std::collections::HashMap;\r\nuse std::time::Duration;\r\nuse tokio::time;\r\na"B. Replacement measured from range.end — the prefix comes out empty
cargo run -p example-editor- Double-click an empty line in the fixture (double-click selects the line break).
- Type
a. - The provider is handed
"". Deleting and retyping works, because by then the caret is collapsed — which is why this reads as "the first keystroke after a click never completes, the second one does".
This one is line-ending sensitive: examples/editor/fixtures/test.rs is LF in the
repo, but a core.autocrlf=true checkout turns it into CRLF, and the "word"
select_word picks on a blank line is then the single \r — exactly 1 byte, the
same length as the typed character, so the query collapses to empty. With LF the
same click selects 3 bytes instead and the request is silently dropped by the
guard.
C. Same root cause, every IME commit on Windows
gpui-pre-windows delivers a composition commit as
replace_text_in_range(None, text) while ime_marked_range is still set, so
range degenerates to the marked span. Every committed character is therefore an
equal-length replacement:
[F_ime_commit_a] trigger_character = ""
[F_ime_commit_b] trigger_character = "" (and the previous prefix is gone)Screenshots
N/A — the printed output above is the complete evidence.
Expected
trigger_character is the completion prefix under the caret: the run of text being
typed, starting where that run starts (or where the completion in progress was
anchored), on the current line only.
Actual
Either "", or everything from offset 0 up to the caret — providers either bail
out, or receive the whole document as a trigger character.
Code
crates/base/src/input/editor/lsp/completions.rs at v0.6.1 / 9031c5e1:
| Line | Code | Problem |
|---|---|---|
| 141 | let start = range.end; |
wrong for a replacement, right only for a collapsed caret |
| 152-153 | .trigger_start_offset.unwrap_or(start) |
trusts the latch blindly and ignores this edit's own start |
| 154-156 | if new_offset < start_offset { return; } |
returns without clearing the latch |
| 167-170 | ...trigger_start_offset = Some(start_offset); |
writes the latch, from the very first keystroke on |
| 226-231 | hide_context_menu() |
sets open = false but keeps both the latch and query |
The field itself is crates/base/src/input/editor/lsp/overlay.rs:10; its only other
reader is crates/component/src/input/overlay.rs (completion_start).
Suggested fix
The prefix start has to come from this edit, and the latch may only survive while the edit stays inside the span it recorded:
- start the measured prefix at
range.start, notrange.end; - store the whole span rather than a lone start (
Option<Range<usize>>), so "still typing the same word" becomes decidable: an edit whoserange.startfalls inside the span continues it, anything else re-anchors at the edit; - clear the span when the caret leaves the word (
hide_context_menu(), which covers arrow keys, clicks and dismissal), on a non-trigger, on an unanchored deletion, and when the measured query contains a newline.
Source: longbridge/gpui-component