summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/lsp.md89
-rw-r--r--src/lsp/lsp.zig19
-rw-r--r--src/lsp/lsp_zls.zig237
-rw-r--r--src/output_pane.zig8
-rw-r--r--src/pardes.zig122
-rw-r--r--test/lspbench.zig26
-rw-r--r--test/lspfixture/dotcomplete.zig28
-rw-r--r--test/lspfixture/dothalf.zig24
-rw-r--r--test/snapshots/lspcomplete.golden366
-rw-r--r--test/snapshots/lspcomplete.snap183
10 files changed, 1085 insertions, 17 deletions
diff --git a/docs/lsp.md b/docs/lsp.md
index c89c42f7..0c6a2f97 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -71,6 +71,76 @@ locations (`hover`, `code_action`, `format`, `rename`) open `+Hover`/`+Lsp`
instead and do not arm the stepper — `n` over a documentation blurb would step
to nowhere.
+`completion` is the kind this shape changes the most. Every other editor answers
+a dot with a popup of NAMES to insert; a seam that returns locations cannot
+insert anything, so this one answers with the candidates' **declarations** —
+one `path:LINE:COL-ENDCOL` row each, in the same `+Search` buffer, steppable
+with `n`. That is a different and arguably better answer to "what goes here":
+you read the definitions rather than a list of words. It is the one location
+kind that does NOT jump on a single row, because with one candidate you still
+want to see the list rather than be teleported into it.
+
+A results buffer is REFILLED rather than reopened when the same kind is asked
+again — the rule `runSearch` always had, and which the language path was
+missing. It survived being missing while every query was a deliberate press
+(`gr` twice left two identical lists and you closed one); Tab after a dot is an
+ordinary typing keystroke, and measured, twenty of them stacked **fifteen**
+byte-identical `+Search` panes, crushed the file to one visible line, and then
+ran `freeSlot` out so the key was silently eaten for the rest of the session.
+Unlike a search the ARGUMENT is not part of the identity: a language query is
+asked about a different symbol every time with the same (usually empty) arg, so
+the kind is the unit.
+
+Making it work needed one trick. A completion is asked for exactly when the
+line is half-typed, and a half-typed line does not parse: `switch (e) { . }`
+loses the whole switch to the parser's error recovery, taking with it every
+ancestor an expected-type resolution needs. ZLS answers this with a private
+token scanner welded to its `*Server`. `lsp_zls.completionSource` instead makes
+the tree PARSE — it splices a placeholder in after the dot, in the six
+spellings a half-typed line can need (an identifier, and the same again closing
+a prong, a statement, a paren or a brace), and keeps the one that both makes
+the dot reachable in the tree and leaves the fewest parse errors. Everything
+after that is ZLS's ordinary public resolution over an ordinary tree.
+
+**A Tab the backend cannot answer still indents.** The keystroke has already
+diverted by the time "no rows" comes back, so `lspResponse` performs the indent
+the Tab prong skipped — on the condition that the cursor has not moved since,
+so nobody who kept typing gets four spaces landing behind their hands. Without
+that, a dot in a comment, in a string, or on a line nothing can be made of ate
+the keystroke outright. With several cursors Tab never diverts at all: a
+language query is a per-keystroke action inside a per-selection replay, so
+asking would stop the replay dead and collapse the multicursor.
+
+### What it costs, and what it cannot do
+
+Per press, measured by `zig build lspbench` on this repo:
+
+| | ReleaseFast | Debug (what `zig build` installs) |
+|---|---|---|
+| a switch arm in `src/pardes.zig` (12.8k lines) | 8.8 ms | 87 ms |
+| `std.` — 91 candidates, each alias-resolved into the stdlib | 26 ms | 204 ms |
+
+It is a worker thread, so the editor does not block; but the second press of
+Tab joins the first query on the UI thread (`old.cancel(io)` in the shell) and
+that wait is real. Pre-existing and shared by every LSP kind — not this
+feature's to fix, but it is what a fast double-Tab feels like.
+
+Known limitations, in the order you will meet them:
+
+- **`@This()` anywhere in a container makes the whole container unresolvable**,
+ so `var list: std.ArrayList(u8) = .` — the most common decl literal in this
+ codebase — answers nothing. This is not the completion filter: `hover` and a
+ plain field access on the same struct return nothing either. It is the case a
+ user hits first, and it is upstream of everything here.
+- **Only the break AT THE CURSOR is repaired.** Zig's error recovery runs
+ forward, so an unrepaired break earlier in the file swallows the declaration
+ the cursor is in and the answer is empty. While typing you normally have one
+ broken spot, which is the case this works for.
+- **A dependency module** (`@import("vaxis")`) cannot be typed at all, for the
+ same reason `gd` on `vaxis.init` finds nothing.
+- **`error.`** is not handled — the position context is `.error_access`, which
+ no branch claims.
+
## The keymap is helix's, exactly
Verified against `helix-term/src/keymap/default.rs`, not from memory.
@@ -92,6 +162,18 @@ Verified against `helix-term/src/keymap/default.rs`, not from memory.
| `]D` / `[D` | last / first diagnostic | |
| `=` | format | |
| `Ctrl`+left-click | definition | the mouse spelling of `gd` |
+| `Tab` in INSERT mode, right after a `.` | completion | what could go here, and where each of those is defined |
+
+Tab is the one key here that is not helix's and not a goto. helix's `Tab`
+completes; pardes's shows you the CANDIDATES' DECLARATIONS in a `+Search`
+buffer and inserts nothing, because that is what a seam returning locations can
+honestly do — see below. It only diverts where an answer is possible: on a
+terminal, in an output buffer, or in a file the backend does not speak
+(`lsp.speaks`, which the core asks and the backend answers), Tab indents
+exactly as it always did. A Tab that silently does nothing would be worse than
+not having the feature. Nothing about the mode changes either — the pane is
+still in insert, so typing goes on and walking the answer with `n` means
+pressing `Esc` first, the same as for every other results buffer.
Ctrl-click rides the ordinary left-click drag rather than firing on the press:
a click does not place the modal cursor until RELEASE, so a query asked at
@@ -136,10 +218,15 @@ nothing else:
```zig
pub fn query(gpa, arena, req: Req, out: *std.Io.Writer) void
+pub fn speaks(path: []const u8) bool
pub const supports: std.EnumSet(Kind)
pub const backend_name = "..."
```
+`supports` says what a backend can do; `speaks` says what it can do it TO, and
+exists for the one key that must not be eaten when the answer is no — see the
+Tab note above.
+
`query` runs on a worker thread with no access to the core — everything it may
read is in `req` (`path`, `source` (NUL-terminated), `offset`, `arg`, `root`).
`out` is a plain `std.Io.Writer`: the shell owns the buffer behind it (an
@@ -151,7 +238,7 @@ byte-identical in shape.
## How the implementations are judged
-`zig build lspbench` — same harness, same corpus (pardes's own `src/`), same 17
+`zig build lspbench` — same harness, same corpus (pardes's own `src/`), same 22
probes, every backend.
- **Feature completeness.** Which kinds return rows, and whether the rows
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
index b8356724..0ce70869 100644
--- a/src/lsp/lsp.zig
+++ b/src/lsp/lsp.zig
@@ -51,6 +51,12 @@ pub const Kind = enum {
format,
/// SPC h
select_refs,
+ /// Tab in insert mode, with a `.` immediately before the cursor. NOT an
+ /// autocomplete popup — the seam returns locations, so this answers "what
+ /// could go here, and where is each of those DEFINED": one row per
+ /// candidate, pointing at its declaration, in the same `+Search` buffer
+ /// `gr` fills. Nothing is inserted.
+ completion,
// The two introspection kinds. A backend that answers nothing is
// indistinguishable from a backend that is broken, so these exist to tell
@@ -160,6 +166,9 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *s
/// base tree shipped with.
const backend = if (@import("pardes_config").zls_backend) @import("lsp_zls.zig") else struct {
pub fn query(_: std.mem.Allocator, _: std.mem.Allocator, _: Req, _: *std.Io.Writer) void {}
+ pub fn speaks(_: []const u8) bool {
+ return false;
+ }
pub const supports: std.EnumSet(Kind) = .initEmpty();
};
@@ -169,5 +178,15 @@ const backend = if (@import("pardes_config").zls_backend) @import("lsp_zls.zig")
/// does not have shows up immediately in the harness's matrix.
pub const supports: std.EnumSet(Kind) = backend.supports;
+/// Does the backend read this file's LANGUAGE at all? `supports` answers what
+/// a backend can do; this answers what it can do it TO, and it exists for the
+/// one key that must not be eaten when the answer is no: insert-mode Tab
+/// diverts to `completion` after a `.`, so in a README — or in any pane the
+/// backend would refuse — it has to indent instead. The core asks rather than
+/// knowing, so the list of extensions stays the backend's business.
+pub fn speaks(path: []const u8) bool {
+ return backend.speaks(path);
+}
+
/// Name shown by the harness and in `SPC ?`. Each implementation renames it.
pub const backend_name = "zls-inproc";
diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig
index 02b72f10..eeacb5e3 100644
--- a/src/lsp/lsp_zls.zig
+++ b/src/lsp/lsp_zls.zig
@@ -76,10 +76,19 @@ pub const supports: std.EnumSet(lsp.Kind) = .initMany(&.{
.rename,
.format,
.code_action,
+ .completion,
.status,
.explain,
});
+/// The language gate, hoisted out of `run` so the core can ask it too: a `.zig`
+/// file and nothing else. `gd` in a README has to find nothing rather than
+/// parse prose as Zig, and insert-mode Tab has to keep indenting there rather
+/// than divert into a query that could never answer.
+pub fn speaks(path: []const u8) bool {
+ return std.mem.endsWith(u8, path, ".zig");
+}
+
// ------------------------------------------------------------ introspection
//
// Everything below exists because of the contract two lines down: this backend
@@ -233,7 +242,7 @@ fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std
// core does not gate the keymap by file type (a pane is a pane), so
// the gate is here: `gd` in a README must find nothing rather than
// parse prose as Zig and confidently resolve a word out of it.
- else => if (!std.mem.endsWith(u8, req.path, ".zig")) {
+ else => if (!speaks(req.path)) {
tr.note("STOP: not a .zig file ({s}) — this backend only reads Zig", .{
if (req.path.len == 0) "no path: this pane has no file behind it" else req.path,
});
@@ -277,9 +286,11 @@ fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std
defer store.deinit();
// The buffer the user is looking at, not the file on disk: `source` is the
- // shell's snapshot, so unsaved edits are what gets analysed.
+ // shell's snapshot, so unsaved edits are what gets analysed — except for a
+ // completion, which is asked ABOUT a half-typed line and has to repair it
+ // first (see completionSource).
const uri: Uri = try .fromPath(arena, req.path);
- try store.openLspSyncedDocument(uri, req.source);
+ try store.openLspSyncedDocument(uri, if (kind == .completion) completionSource(arena, req, tr) else req.source);
const handle = store.getHandle(uri) orelse {
tr.note("STOP: the document store would not take this buffer", .{});
return;
@@ -309,6 +320,7 @@ fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std
.rename => try references(arena, &analyser, handle, off, req.arg, out),
.diagnostics => try diagnostics(gpa, arena, req.path, &handle.tree, out),
.code_action => try codeActions(gpa, arena, &analyser, handle, off, out),
+ .completion => try completion(arena, &analyser, handle, off, req.source, out, tr),
else => {},
}
}
@@ -640,6 +652,225 @@ fn goto(
}
}
+// ------------------------------------------------------------ completion
+
+/// The text a completion query actually analyses.
+///
+/// The user has just typed the `.`, so the buffer DOES NOT PARSE, and not
+/// locally: `switch (e) { . }` loses the entire switch to the parser's error
+/// recovery — the tree keeps the function's block and nothing inside it —
+/// which takes with it every ancestor an expected-type resolution needs. ZLS
+/// answers this with a private token scanner (`getSwitchOrStructInitContext`,
+/// ~200 lines welded to a `*Server`) that re-derives the context by hand. This
+/// backend instead makes the tree PARSE, by splicing a placeholder in after
+/// the dot. Six spellings, because a half-typed line is short of two different
+/// things — the identifier the dot needs, and whatever the user has not closed
+/// yet — and the second half is not optional: a missing closer makes Zig's
+/// recovery discard the whole enclosing declaration, which no placeholder AT
+/// the dot can survive.
+///
+/// `_p => {},` a switch prong: not a prong at all without its arrow, so
+/// no bare identifier can rescue one
+/// `_p;` a statement or declaration still missing its terminator,
+/// which is what `const z: E = .` is the instant it is typed
+/// `_p` everything already closed: `f(.)`, `x = .;`, `.a = .`
+/// `_p => {}, }` ...and the same three again for a construct still hanging
+/// `_p)` open — `switch (e) {`, `g(`, `.{` with no closer yet,
+/// `_p }` which is what the first second of typing looks like
+///
+/// No spelling may contain a NEWLINE: the rows depend on line numbers being
+/// identical between the repaired copy and the user's buffer (see `completion`).
+///
+/// Picking between them takes TWO signals, and getting that wrong was the first
+/// version's bug twice over.
+///
+/// The parse-error count alone does not work. A repair that supplies the
+/// identifier without closing the outer construct merely TIES with the broken
+/// buffer — measured, `const z: E = .` is one error either way — so demanding
+/// a strict improvement threw the repair away in exactly the cases it was
+/// written for, and the feature only worked where the syntax was already
+/// finished. Accepting ties does not save it either: for an unclosed
+/// `switch (e) {` the bare identifier and the arrow BOTH tie at one error and
+/// only the arrow produces a switch node, so the count cannot say which helped.
+///
+/// Reachability alone does not work either. "Is the dot now an `enum_literal`
+/// the tree can be walked down to from the root" is exactly the question the
+/// repair asks, and an orphaned node answers it correctly — but several
+/// spellings can answer yes while leaving trees of very different quality, and
+/// taking the first loses candidates the cleanest spelling would have found.
+///
+/// So: reachability is a hard FILTER, the error count RANKS what survives it,
+/// the earlier spelling wins a tie, and a candidate that reaches ZERO errors
+/// ends the search because nothing can beat it. That last clause is why six
+/// spellings cost less than the three that came before them: a switch arm —
+/// the case this feature exists for — is answered by the first candidate, and
+/// only a buffer nothing repairs pays for all six. A buffer that already
+/// parses is left alone, and a dot no spelling can reach is the seam's normal
+/// "no result".
+/// ponytail: one parse per spelling; caching the base parse is the obvious
+/// next step if it ever shows up.
+fn completionSource(arena: std.mem.Allocator, req: lsp.Req, tr: *Trace) [:0]const u8 {
+ if (req.offset == 0 or req.offset > req.source.len) return req.source;
+ if (req.source[req.offset - 1] != '.') return req.source;
+ var base: Ast = Ast.parse(arena, req.source, .zig) catch return req.source;
+ // Nothing to repair: a bare `.` is always a parse error, so a clean tree
+ // means the dot is already part of a node — the placeholder's whole job.
+ if (base.errors.len == 0) return req.source;
+ // A field access resolves off the position context and its own tokenizer,
+ // never off the tree, so `foo.` needs no placeholder and pays no shift.
+ const ctx = Analyser.getPositionContext(arena, &base, req.offset, false) catch return req.source;
+ if (ctx != .enum_literal) return req.source;
+
+ var best = req.source;
+ var chose: []const u8 = "";
+ var fewest: usize = std.math.maxInt(usize);
+ for ([_][]const u8{ "_p => {},", "_p;", "_p", "_p => {}, }", "_p)", "_p }" }) |fill| {
+ const cand = std.fmt.allocPrintSentinel(arena, "{s}{s}{s}", .{
+ req.source[0..req.offset], fill, req.source[req.offset..],
+ }, 0) catch continue;
+ var t: Ast = Ast.parse(arena, cand, .zig) catch continue;
+ if (t.errors.len >= fewest) continue;
+ const nodes = zls.ast.nodesOverlappingIndex(arena, &t, req.offset) catch continue;
+ if (nodes.len < 2 or t.nodeTag(nodes[0]) != .enum_literal) continue;
+ fewest = t.errors.len;
+ best = cand;
+ chose = fill;
+ if (fewest == 0) break;
+ }
+ if (chose.len == 0)
+ tr.note("STOP: no placeholder made the dot reachable in the tree ({d} parse error(s))", .{base.errors.len})
+ else
+ tr.note("placeholder `{s}`: the dot is an enum_literal again, {d} parse error(s) left (was {d})", .{ chose, fewest, base.errors.len });
+ return best;
+}
+
+/// Tab after a `.`. NOT an autocomplete popup: the seam answers with
+/// locations, so the question it asks is "what could go here, and where is
+/// each of those DEFINED" — one `+Search` row per candidate, pointing at its
+/// declaration, the same rows `gr` emits and the same `n`/`N` that step them.
+/// Nothing is inserted; picking is looking at a row.
+///
+/// A dot has two meanings and both come off the public analyser:
+/// `foo.` a field access — resolve the left side's type, list its members.
+/// `.` an enum literal — resolve the type EXPECTED at this position (a
+/// switch's condition, a call's parameter, a variable's annotation,
+/// an initialiser's field) and list that type's members.
+/// The second exists only because `completionSource` made the tree parse.
+fn completion(
+ arena: std.mem.Allocator,
+ analyser: *Analyser,
+ handle: *DocumentStore.Handle,
+ off: usize,
+ /// the buffer as the USER has it, before completionSource repaired it. The
+ /// rows are read out of THIS one; the handle's tree is the repaired copy
+ /// and its text is not what is on screen.
+ orig: []const u8,
+ out: *std.Io.Writer,
+ tr: *Trace,
+) !void {
+ const tree = &handle.tree;
+ // lookahead FALSE, the way ZLS's own completion asks: the cursor sits at
+ // the END of what was typed and there is nothing to its right to read.
+ const ctx = try Analyser.getPositionContext(arena, tree, off, false);
+ tr.note("position context: .{s}", .{@tagName(ctx)});
+ // naming a field INSIDE an initialiser (`.{ .`, `S{ .`) rather than
+ // writing a whole value — a different question with a different answer,
+ // and the only one the ancestors cannot state for themselves
+ var in_init = false;
+ const found: ?Analyser.Type = switch (ctx) {
+ .field_access => |loc| try analyser.getFieldAccessType(handle, off, loc),
+ .enum_literal => blk: {
+ const nodes = try zls.ast.nodesOverlappingIndex(arena, tree, off);
+ if (nodes.len == 0) break :blk null;
+ if (try analyser.resolveExpressionTypeFromAncestors(handle, nodes[0], nodes[1..])) |t| break :blk t;
+ // `.{ .` and `S{ .` parse as an ARRAY init holding one enum
+ // literal, and an array's ELEMENT type is not what is being named
+ // there — the user is naming a FIELD, so the type wanted is the
+ // initialiser's own.
+ var buf: [2]Ast.Node.Index = undefined;
+ if (nodes.len > 1 and tree.fullArrayInit(&buf, nodes[1]) != null) {
+ in_init = true;
+ break :blk try analyser.resolveExpressionType(handle, nodes[1], nodes[2..]);
+ }
+ break :blk null;
+ },
+ // a dot in a comment or a string is a dot in prose
+ else => null,
+ };
+ var ty = found orelse {
+ tr.note("STOP: nothing expected here that this backend can name", .{});
+ return;
+ };
+ // the same peel `gy` does: what can go in a `?E`, an `E!T` or a `*E` slot
+ // is what can go in an `E` one
+ while (true) {
+ ty = try analyser.resolveUnwrapErrorUnionType(ty, .payload) orelse
+ try analyser.resolveDerefType(ty) orelse
+ try analyser.resolveOptionalUnwrap(ty) orelse break;
+ }
+ // `instance_access` is collectDeclarationsOfContainer's one knob and it
+ // means different things per container kind: it is what hides an enum's
+ // members and what reveals a struct's fields. So: a field access asks
+ // whichever side the left-hand expression already was; a field named
+ // inside an initialiser asks the instance side, which is where fields
+ // live; and everywhere else — a switch arm, an argument, `= .` — asks the
+ // type side, which is where an enum's members are. A union is a struct
+ // whose fields are also its tags, so it wants the instance side wherever
+ // it appears.
+ const value_pos = ctx == .enum_literal and !in_init;
+ var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty;
+ try analyser.collectDeclarationsOfContainer(ty, handle, switch (ctx) {
+ .field_access => !ty.is_type_val,
+ else => in_init or ty.isUnionType(),
+ }, &decls);
+ tr.note("{d} candidate(s) in scope", .{decls.items.len});
+ // What completionSource spliced in at `off`, which the ROWS have to take
+ // back out. The tree being read is the REPAIRED copy, so for a candidate
+ // declared in this same file two things are wrong with the naive row: its
+ // line text is the repaired line, so the user reads `= ._p;` back out of a
+ // file that says no such thing; and every column on the cursor's line to
+ // the right of the dot is `pad` too far right, so a look on the row
+ // selects `", "` where it should select `qq`. Line numbers need no
+ // correction at all — no spelling contains a newline — and a candidate in
+ // ANOTHER file is untouched by any of this.
+ const pad = tree.source.len - orig.len;
+ const dot = lsp.lineCol(orig, off);
+ var n: usize = 0;
+ for (decls.items) |d| {
+ if (n >= max_rows) return;
+ // In a value position the answer is the container's MEMBERS plus its
+ // DECL LITERALS (`.empty`, `.init`) — never a method, an unrelated
+ // const or a type ALIAS, none of which can go after that dot, and
+ // offering one is worse than offering nothing. So every non-member
+ // decl is resolved, called, and kept only if what comes back IS this
+ // container. `typeOf` on both sides is what excludes the alias: a
+ // `pub const Alias = E` resolves to a TYPE VALUE, whose type is
+ // `type` and not the container. (ZLS's own rule, and its own reason.)
+ const member = d.decl == .ast_node and d.handle.tree.nodeTag(d.decl.ast_node).isContainerField();
+ if (value_pos and !member) {
+ var lit = try d.resolveType(analyser) orelse continue;
+ lit = try analyser.resolveReturnType(lit) orelse lit;
+ lit = lit.resolveDeclLiteralResultType();
+ if (!(try lit.typeOf(analyser)).eql(try ty.typeOf(analyser))) continue;
+ }
+ const th = d.definitionToken(analyser, true) catch continue;
+ const dtree = &th.handle.tree;
+ if (th.token >= dtree.tokens.len) continue;
+ var r = offsets.tokenToRange(dtree, th.token, enc);
+ const path = th.handle.uri.toFsPath(arena) catch continue;
+ if (th.handle != handle) {
+ lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(dtree.source, r.start.line));
+ } else {
+ if (pad != 0 and r.start.line == dot.line and @as(usize, r.start.character) >= dot.col + pad) {
+ r.start.character -= @intCast(pad);
+ if (r.end.line == r.start.line) r.end.character -= @intCast(pad);
+ }
+ lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(orig, r.start.line));
+ }
+ n += 1;
+ }
+}
+
// ---------------------------------------------------------------- hover
/// `hover.hover` is already Server-free, so this is a call and a reformat: the
diff --git a/src/output_pane.zig b/src/output_pane.zig
index 49af86af..bce3d503 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -166,7 +166,13 @@ pub fn traits(o: Origin) Traits {
.steps = true,
.jumps = true,
},
- .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs => .{
+ // completion lists WHAT COULD GO HERE, one row per candidate's
+ // definition. It does not jump on a single row where the gotos do:
+ // a goto answers a question whose answer is a place, so landing
+ // there IS the answer, whereas the question here is "what can I
+ // write", and being teleported into the one candidate's
+ // declaration instead of being shown it is not that.
+ .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{
.name = config.search_buffer,
.steps = true,
},
diff --git a/src/pardes.zig b/src/pardes.zig
index 2ea52e15..0dba0abf 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4195,8 +4195,13 @@ pub const Pardes = struct {
/// `arg` rides along only so the buffer the answer opens can record what
/// was asked (a rename's new name, a symbol query) — the query itself has
/// it in the effect already.
+ /// `row`/`col` are where the cursor was when the question was asked. Only
+ /// `completion` reads them, and only to undo itself: Tab diverted instead
+ /// of indenting, so an empty answer has to put the indent back — but only
+ /// if the cursor has not moved since, or four spaces appear under someone
+ /// who kept typing.
lsp_seq: u32 = 0,
- lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize, arg: Effect.Buf(128) } = null,
+ lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize, arg: Effect.Buf(128), row: i32 = 0, col: i32 = 0 } = null,
/// One current shell-filter request. A newer submit frees and supersedes
/// it; old worker answers then fail the id check. The request itself owns
@@ -7445,7 +7450,14 @@ pub const Pardes = struct {
.col = @intCast(@max(0, pane.cur_col)),
}) else 0;
p.lsp_seq +%= 1;
- p.lsp_wait = .{ .id = p.lsp_seq, .kind = kind, .pane = id, .arg = .from(arg) };
+ p.lsp_wait = .{
+ .id = p.lsp_seq,
+ .kind = kind,
+ .pane = id,
+ .arg = .from(arg),
+ .row = pane.cur_row,
+ .col = pane.cur_col,
+ };
p.emit(.{ .lsp = .{
.id = p.lsp_seq,
.kind = kind,
@@ -7466,7 +7478,20 @@ pub const Pardes = struct {
if (w.id != id) return; // superseded by a newer press, or the pane died
p.lsp_wait = null;
const pane = p.panes[w.pane] orelse return;
- if (rows.len == 0) return;
+ if (rows.len == 0) {
+ // No rows is a legal answer everywhere except here. Tab DIVERTED
+ // instead of indenting, so an empty answer would eat the keystroke
+ // — a dot in a comment, a dot in a string, a half-typed line
+ // nothing can be made of — and a Tab that silently does nothing is
+ // worse than not having the feature. So the indent happens now,
+ // late, on the condition that nothing has moved: same pane, still
+ // in insert, one cursor, and the cursor still on the cell the Tab
+ // was pressed at. Anyone who kept typing during the query gets
+ // nothing rather than four spaces landing 300ms behind their hands.
+ if (w.kind == .completion and pane.mode == .insert and pane.nsel == 0 and
+ pane.cur_row == w.row and pane.cur_col == w.col) p.insertTab(pane);
+ return;
+ }
const from: output_pane.Origin = .{ .query = w.kind };
const nrows = std.mem.count(u8, rows, "\n");
@@ -7484,6 +7509,44 @@ pub const Pardes = struct {
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
const content = p.gpa.dupe(u8, rows) catch return;
+ // The same KIND asked again REFILLS the buffer it already opened, the
+ // rule runSearch has always had. It was missing here and that was
+ // survivable while every language query was a deliberate press: `gr`
+ // twice left two identical lists and you closed one. Tab after a dot
+ // is an ordinary typing keystroke, which turns the same bug fatal —
+ // measured, twenty Tabs stacked fifteen byte-identical `+Search` panes
+ // under the file, crushed it to one visible line, and from the
+ // sixteenth on freeSlot returned null and the key was eaten for the
+ // rest of the session with nothing said. One code path, so every kind
+ // that lands in a results buffer gets the fix.
+ //
+ // Unlike runSearch this does NOT key on the ARGUMENT. A search is
+ // identified by its pattern; a language query is asked about a
+ // different symbol every time with the same (usually empty) arg, so
+ // the arg cannot tell two lists apart and the KIND is the natural
+ // unit: a second `gr` replaces the first list rather than growing a
+ // stack of them.
+ for (p.panes, 0..) |slot, i| {
+ if (i == w.pane) continue;
+ const rp = slot orelse continue;
+ const rf = if (rp.file) |*f| f else continue;
+ const o = rf.output orelse continue;
+ if (!std.meta.eql(o.from, from)) continue;
+ if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
+ output_pane.setArg(&rf.output.?, w.arg.slice());
+ // a refill that changes nothing keeps its place (runSearch's rule,
+ // and the same reason): re-asking about a symbol you are already
+ // stepping must not throw the list back to the top
+ const same = std.mem.eql(u8, rf.content, content);
+ file_pane.setContent(p, rf, content);
+ if (!same) rf.scroll = 0;
+ p.active = w.pane;
+ if (output_pane.traits(from).steps) {
+ pane.search_pane = i;
+ pane.search_row = null;
+ }
+ return;
+ }
const free = p.freeSlot() orelse {
p.gpa.free(content);
return;
@@ -7941,20 +8004,55 @@ pub const Pardes = struct {
pane.cur_pinned = true;
},
Key.tab => {
- // helix insert_tab with a Spaces indent style: spaces to
- // the next tab stop (smart-tab machinery skipped)
- const pad = modal.INDENT_W - (c.col % modal.INDENT_W);
- const spaces = " ";
- const new = modal.insertAt(p.gpa, text, c, spaces[0..pad]) catch return;
- p.setEditText(pane, new);
- pane.cur_col += @intCast(pad);
- pane.cur_pinned = true;
- pane.ensureCursorVisible();
+ // Tab straight after a `.` asks the language backend what
+ // could go there — an output buffer of DEFINITIONS, one row
+ // per candidate, not an autocomplete popup and not an
+ // insertion. Only where an answer is possible: a terminal, an
+ // output buffer or a file the backend does not speak still
+ // indents, because a Tab that silently does nothing is worse
+ // than not having the feature. The extension list stays the
+ // backend's (lsp.speaks); this only asks. (An answer that
+ // comes back EMPTY indents too, late — see lspResponse.)
+ //
+ // Never with several cursors. A language query is a
+ // per-KEYSTROKE action inside a per-SELECTION replay, so
+ // multiOnce would stop the replay dead: the other cursors
+ // would neither ask nor indent and the whole multicursor would
+ // collapse on a Tab. Every other insert key applies to all of
+ // them, and so does this one — by indenting.
+ const ln = modal.lineSlice(text, c.row);
+ if (!p.multi_on and c.col > 0 and c.col <= ln.len and ln[c.col - 1] == '.') dot: {
+ const f = pane.file orelse break :dot;
+ if (f.output != null or !lsp.speaks(f.path)) break :dot;
+ return p.lspRequest(p.active, .completion, "");
+ }
+ p.insertTab(pane);
},
else => {},
}
}
+ /// helix insert_tab with a Spaces indent style: spaces to the next tab
+ /// stop (smart-tab machinery skipped). It is a function rather than the
+ /// five lines it used to be inside the Tab prong because Tab after a `.`
+ /// asks the language backend FIRST and indents only if the answer comes
+ /// back empty — which happens on another turn of the loop entirely, so
+ /// lspResponse needs to be able to press the same key.
+ fn insertTab(p: *Pardes, pane: *Pane) void {
+ const eb = p.editText(pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return;
+ const c: modal.Cursor = .{
+ .row = @intCast(@max(0, pane.cur_row - eb.row0)),
+ .col = @intCast(@max(0, pane.cur_col)),
+ };
+ const pad = modal.INDENT_W - (c.col % modal.INDENT_W);
+ const spaces = " ";
+ const new = modal.insertAt(p.gpa, eb.text, c, spaces[0..pad]) catch return;
+ p.setEditText(pane, new);
+ pane.cur_col += @intCast(pad);
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ }
+
const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 };
/// a range's two cells, normalized to document order
diff --git a/test/lspbench.zig b/test/lspbench.zig
index ffea0e20..b294e60c 100644
--- a/test/lspbench.zig
+++ b/test/lspbench.zig
@@ -91,6 +91,32 @@ const anchors = [_]Anchor{
.{ .file = "src/lsp/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .rename, .expect = "" },
.{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .workspace_symbols, .expect = "" },
.{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .workspace_diagnostics, .expect = "broken.zig" },
+ // The three shapes a `.` can have, each with the cursor IMMEDIATELY AFTER
+ // the dot, which is where Tab asks from.
+ // a field access into std: `std.` lists what `std` declares, the same
+ // "can this backend reach the stdlib at all" test `gd` gets.
+ .{ .file = "src/lsp/lsp.zig", .needle = "std.mem.count(u8", .at = 4, .kind = .completion, .expect = "std.zig" },
+ // an enum literal in a FINISHED switch arm. The tree parses, so this
+ // probes the expected-type resolution and nothing else.
+ .{ .file = "src/lsp/lsp_zls.zig", .needle = ".declaration => try rowForToken", .at = 1, .kind = .completion, .expect = "type_definition" },
+ // the same thing on the BIGGEST file in the tree. This one is here for
+ // its latency column rather than its rows: a completion parses the
+ // buffer once per placeholder spelling it tries, so pardes.zig is where
+ // that shows up and where the figure quoted in docs/lsp.md comes from.
+ .{ .file = "src/pardes.zig", .needle = ".definition => .definition,", .at = 1, .kind = .completion, .expect = "" },
+ // an enum literal in a HALF-TYPED switch arm, which is what a real
+ // keypress looks like: the file does not parse and the switch is not in
+ // the tree at all. `verdigris` appears only on the enum member's own
+ // line, so a row carrying it came from the DEFINITION and not from the
+ // line the cursor sits on.
+ .{ .file = "test/lspfixture/dotcomplete.zig", .needle = "return switch (s) {\n .", .at = 29, .kind = .completion, .expect = "verdigris" },
+ // ...and the same dot on a line that is ALSO missing its terminator,
+ // which is one parse error repaired or not. This is the probe that
+ // catches a repair chosen by error count rather than by whether the dot
+ // became reachable in the tree.
+ // (the needle carries its indent: dothalf.zig's own header explains the
+ // shape and would otherwise be the first match)
+ .{ .file = "test/lspfixture/dothalf.zig", .needle = " const z: Shade = .", .at = 22, .kind = .completion, .expect = "verdigris" },
};
const Result = struct {
diff --git a/test/lspfixture/dotcomplete.zig b/test/lspfixture/dotcomplete.zig
new file mode 100644
index 00000000..484f6b86
--- /dev/null
+++ b/test/lspfixture/dotcomplete.zig
@@ -0,0 +1,28 @@
+//! A file caught MID-KEYSTROKE, for the lspbench completion probe. Like
+//! broken.zig it is NOT built and NOT imported by anything, and like broken.zig
+//! its defect is the point.
+//!
+//! `Tab` after a `.` is the one language query whose normal input does not
+//! parse: the user has typed the dot and nothing else, so `switch (s) { . }`
+//! loses the entire switch to the parser's error recovery and every ancestor
+//! an expected-type resolution needs goes with it. A probe pointed at a
+//! FINISHED `.member` would exercise the resolution and none of the repair,
+//! and would pass on a backend that cannot answer a real keypress at all.
+//!
+//! Nothing here comes from std, so what the probe checks does not encode this
+//! machine's stdlib. The member names are nonsense words on purpose: they
+//! appear on their DECLARATION lines and nowhere else, so a row containing one
+//! can only have come from the enum's definition and never from the switch the
+//! cursor is sitting in.
+pub const Shade = enum {
+ verdigris,
+ cinnabar,
+ orpiment,
+};
+
+pub fn pick(s: Shade) u8 {
+ return switch (s) {
+ .
+ };
+}
+
diff --git a/test/lspfixture/dothalf.zig b/test/lspfixture/dothalf.zig
new file mode 100644
index 00000000..69d407fa
--- /dev/null
+++ b/test/lspfixture/dothalf.zig
@@ -0,0 +1,24 @@
+//! The other half of a real keypress, for the lspbench completion probe. Like
+//! broken.zig and dotcomplete.zig it is NOT built and NOT imported by anything.
+//!
+//! dotcomplete.zig types its dot into a switch whose braces are already there,
+//! which turns out to be the EASY half: the dot is the only thing wrong with
+//! the file, so any repair at all makes the tree whole. Mid-typing a line is
+//! short of its terminator as well, and `const z: Shade = .` is then exactly
+//! ONE parse error whether a placeholder identifier is spliced in or not. A
+//! repair chosen by counting parse errors therefore discards itself here, and
+//! the feature silently only worked on finished syntax — which is what this
+//! file exists to stop happening again.
+//!
+//! It is its own file rather than another function in dotcomplete.zig, and
+//! that is load-bearing: Zig's error recovery runs FORWARD, so an unrepaired
+//! break earlier in a file swallows the declaration the cursor is in. Only the
+//! break at the cursor is repaired, so a fixture may hold only one.
+pub const Shade = enum {
+ verdigris,
+ cinnabar,
+};
+
+pub fn unterminated() void {
+ const z: Shade = .
+}
diff --git a/test/snapshots/lspcomplete.golden b/test/snapshots/lspcomplete.golden
new file mode 100644
index 00000000..5c92b71a
--- /dev/null
+++ b/test/snapshots/lspcomplete.golden
@@ -0,0 +1,366 @@
+== snap opened grid=120x40 cursor=7,2
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/lspcomplete/cwd/a.zig Save New Del
+| 1 const Shade = enum {
+| 2 verdigris,
+| 3 cinnabar,
+| 4 };
+| 5
+| 6 fn pick(s: Shade) u8 {
+| 7 return switch (s) {
+| 8
+| 9 };
+| 10 }
+| 11
+| 12 // note. see below
+| 13
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap indent grid=120x40 cursor=11,11
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|^ /tmp/pardes-snap/lspcomplete/cwd/a.zig Save New Del
+| 1 const Shade = enum {
+| 2 verdigris,
+| 3 cinnabar,
+| 4 };
+| 5
+| 6 fn pick(s: Shade) u8 {
+| 7 return switch (s) {
+| 8
+| 9 };
+| 10 }
+| 11
+| 12 // note. see below
+| 13
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap commentindent grid=120x40 cursor=19,13
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|^ /tmp/pardes-snap/lspcomplete/cwd/a.zig Save New Del
+| 1 const Shade = enum {
+| 2 verdigris,
+| 3 cinnabar,
+| 4 };
+| 5
+| 6 fn pick(s: Shade) u8 {
+| 7 return switch (s) {
+| 8
+| 9 };
+| 10 }
+| 11
+| 12 // note. see below
+| 13
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap athedot grid=120x40 cursor=16,9
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|^ /tmp/pardes-snap/lspcomplete/cwd/a.zig Save New Del
+| 1 const Shade = enum {
+| 2 verdigris,
+| 3 cinnabar,
+| 4 };
+| 5
+| 6 fn pick(s: Shade) u8 {
+| 7 return switch (s) {
+| 8 .
+| 9 };
+| 10 }
+| 11
+| 12 // note. see below
+| 13
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap completion grid=120x40 cursor=16,9
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|^ /tmp/pardes-snap/lspcomplete/cwd/a.zig Save New Del
+| 1 const Shade = enum {
+| 2 verdigris,
+| 3 cinnabar,
+| 4 };
+| 5
+| 6 fn pick(s: Shade) u8 {
+| 7 return switch (s) {
+| 8 .
+| 9 };
+| 10 }
+| 11
+| 12 // note. see below
+| 13
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/lspcomplete/cwd/+Search New Del
+| 1 /tmp/pardes-snap/lspcomplete/cwd/a.zig:2:5-13 verdigris,
+| 2 /tmp/pardes-snap/lspcomplete/cwd/a.zig:3:5-12 cinnabar,
+== snap twentytabs grid=120x40 cursor=16,9
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|^ /tmp/pardes-snap/lspcomplete/cwd/a.zig Save New Del
+| 1 const Shade = enum {
+| 2 verdigris,
+| 3 cinnabar,
+| 4 };
+| 5
+| 6 fn pick(s: Shade) u8 {
+| 7 return switch (s) {
+| 8 .
+| 9 };
+| 10 }
+| 11
+| 12 // note. see below
+| 13
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/lspcomplete/cwd/+Search New Del
+| 1 /tmp/pardes-snap/lspcomplete/cwd/a.zig:2:5-13 verdigris,
+| 2 /tmp/pardes-snap/lspcomplete/cwd/a.zig:3:5-12 cinnabar,
+== snap step grid=120x40 cursor=19,3
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/lspcomplete/cwd/a.zig Save New Del
+| 1 const Shade = enum {
+| 2 verdigris,
+| 3 cinnabar,
+| 4 };
+| 5
+| 6 fn pick(s: Shade) u8 {
+| 7 return switch (s) {
+| 8 .
+| 9 };
+| 10 }
+| 11
+| 12 // note. see below
+| 13
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/lspcomplete/cwd/+Search New Del
+| 1 /tmp/pardes-snap/lspcomplete/cwd/a.zig:2:5-13 verdigris,
+| 2 /tmp/pardes-snap/lspcomplete/cwd/a.zig:3:5-12 cinnabar,
+== snap txtindent grid=120x40 cursor=15,2
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|^ /tmp/pardes-snap/lspcomplete/cwd/notes.txt Save New Del
+| 1 abc. def ghi
+| 2
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap nosemi grid=100x18 cursor=29,8
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|^ /tmp/pardes-snap/lspcomplete/cwd/c.zig Save New Del
+| 1 const Shade = enum {
+| 2 verdigris,
+| 3 cinnabar,
+| 4 };
+| 5
+| 6 pub fn nosemi() void {
+| 7 const z: Shade = .
+| 8 }
+| 9
+|
+|
+|
+|
+| /tmp/pardes-snap/lspcomplete/cwd/+Search New Del
+| 1 /tmp/pardes-snap/lspcomplete/cwd/c.zig:2:5-13 verdigris,
+| 2 /tmp/pardes-snap/lspcomplete/cwd/c.zig:3:5-12 cinnabar,
+== snap nobrace grid=100x18 cursor=16,9
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|^ /tmp/pardes-snap/lspcomplete/cwd/d.zig Save New Del
+| 1 const Shade = enum {
+| 2 verdigris,
+| 3 cinnabar,
+| 4 };
+| 5
+| 6 pub fn nobrace(s: Shade) void {
+| 7 switch (s) {
+| 8 .
+| 9 }
+| 10
+|
+|
+|
+| /tmp/pardes-snap/lspcomplete/cwd/+Search New Del
+| 1 /tmp/pardes-snap/lspcomplete/cwd/d.zig:2:5-13 verdigris,
+| 2 /tmp/pardes-snap/lspcomplete/cwd/d.zig:3:5-12 cinnabar,
diff --git a/test/snapshots/lspcomplete.snap b/test/snapshots/lspcomplete.snap
new file mode 100644
index 00000000..7393b209
--- /dev/null
+++ b/test/snapshots/lspcomplete.snap
@@ -0,0 +1,183 @@
+# Tab after a dot in insert mode: "what could go here, and where is each of
+# those defined". The answer is a `+Search` buffer of DEFINITION rows, the same
+# rows `gr` fills it with — nothing is inserted and nothing pops up.
+#
+# a.zig is std-free for the reason lsp.snap's is: these goldens must not encode
+# this machine's stdlib version or its install path. Its enum members are
+# nonsense words, and the dot is TYPED into an empty switch body, so a row
+# naming line 2 or 3 can only have come from a member's declaration — the line
+# the cursor is on is line 8 and holds nothing but the dot.
+#
+# Typing it is the point rather than pointing at one already there: the moment
+# the dot lands the file STOPS PARSING (`switch (s) { . }` loses the whole
+# switch to the parser's error recovery), and that is the normal input to a
+# completion, not a corner case.
+#
+# The helix corpora cannot reach any of this — hxOpenFileContent names its pane
+# `/hxcase.txt`, so lsp.speaks is false for all 1042 cases — which makes this
+# script the only end-to-end coverage the feature has.
+file a.zig const Shade = enum {\n verdigris,\n cinnabar,\n};\n\nfn pick(s: Shade) u8 {\n return switch (s) {\n \n };\n}\n\n// note. see below\n
+file notes.txt abc.def ghi\n
+start 40 120 a.zig
+wait 8000 verdigris
+stable 700 20000
+snap opened
+# Tab with no dot before it is still Tab: line 10 is the closing `}`, and this
+# is the plain insert_tab path putting spaces in front of it.
+press left 8 12
+release left 8 12
+key i
+key tab
+stable 400 5000
+snap indent
+key esc
+settle 100
+# A dot the backend cannot answer must STILL indent. Line 12 is a comment, so
+# the position context is `.comment` and no candidate can exist — and because
+# Tab had already diverted to the backend, the indent has to happen
+# retroactively when the empty answer arrives (lspResponse), on the condition
+# that the cursor has not moved meanwhile. Four spaces go in after `// note.`
+# and the keystroke is not eaten, which is the whole contract.
+press left 16 14
+release left 16 14
+stable 400 5000
+key i
+key tab
+stable 700 15000
+snap commentindent
+key esc
+settle 100
+# Line 8 (grid row 9, 1-based click row 10) is the switch's empty body. End
+# puts the cursor past its indent, and then the dot is typed.
+press left 8 10
+release left 8 10
+stable 400 5000
+key i
+key end
+text .
+stable 400 5000
+snap athedot
+key tab
+# The row that proves it: `a.zig:2:` is `verdigris`'s own declaration line.
+# The cursor is on line 8; nothing about line 2 is reachable from the text
+# under it, so this row can only have come from resolving the switch's
+# condition to `Shade` and asking `Shade` what it declares. Line 8 still reads
+# `.` and nothing else — a completion INSERTS NOTHING.
+wait 15000 a.zig:2:
+stable 700 15000
+snap completion
+# Nineteen more Tabs. Every one of them answers, and every answer REFILLS the
+# one `+Search` rather than opening its twin: before that fix six presses left
+# six byte-identical panes, twenty left fifteen with the file crushed to a
+# single visible line, and from the sixteenth on freeSlot returned null and the
+# keystroke was eaten for the rest of the session with nothing said. The golden
+# below is the assertion — one file pane, one results pane, same as after the
+# first press.
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+settle 150
+key tab
+stable 700 20000
+snap twentytabs
+# The pane is still in insert mode, which is right — the query answered beside
+# the cursor and typing goes on — so walking the answer means leaving it first,
+# the same as for any other results buffer.
+key esc
+settle 100
+# Several rows, so the buffer IS the picker and n walks it; completion does not
+# jump the way a goto with one row does. The first step looks row 1 and lands
+# SELECTING `verdigris` on line 2 of a.zig.
+key n
+stable 700 15000
+snap step
+# A pane the backend does not speak: same keystrokes, same dot before the
+# cursor, and Tab must still indent — synchronously this time, since the core
+# never asks. The gate is lsp.speaks, which the core consults and the backend
+# owns.
+start 40 120 notes.txt
+wait 8000 abc.def
+stable 700 20000
+press left 12 3
+release left 12 3
+key i
+key tab
+stable 400 5000
+snap txtindent
+# ---- the two genuinely INCOMPLETE states ----
+#
+# Everything above types its dot into syntax that is otherwise finished, which
+# is the easy half and is why a repair chosen by parse-error count looked like
+# it worked. Mid-typing, the line is short of its terminator too.
+#
+# `const z: Shade = .` is ONE parse error with or without a placeholder
+# identifier spliced in, so the repair has to be chosen by whether it makes the
+# dot reachable in the tree, never by counting errors.
+file c.zig const Shade = enum {\n verdigris,\n cinnabar,\n};\n\npub fn nosemi() void {\n const z: Shade = \n}\n
+start 18 100 c.zig
+wait 8000 nosemi
+stable 700 20000
+press left 8 9
+release left 8 9
+stable 400 5000
+key i
+key end
+text .
+stable 400 5000
+key tab
+wait 15000 c.zig:2:
+stable 700 15000
+snap nosemi
+# ...and a construct still hanging OPEN. The switch has no closing brace at
+# all — the one `}` in the file closes it and leaves the function unclosed —
+# which makes Zig's recovery throw the whole declaration away. No placeholder
+# sitting at the dot can survive that, so the repair has to close the construct
+# as well, on the same line (a spelling containing a newline would move every
+# line number the rows are built from).
+file d.zig const Shade = enum {\n verdigris,\n cinnabar,\n};\n\npub fn nobrace(s: Shade) void {\n switch (s) {\n \n}\n
+start 18 100 d.zig
+wait 8000 nobrace
+stable 700 20000
+press left 8 10
+release left 8 10
+stable 400 5000
+key i
+key end
+text .
+stable 400 5000
+key tab
+wait 15000 d.zig:2:
+stable 700 15000
+snap nobrace