diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-09 04:20:20 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 628aa40f13e9bbd313b51ab625f193110aad8dd0 (patch) | |
| tree | e60902495e308505959361d8719c382401b88295 | |
| parent | 0a15af8d98771180e32402e68ea844f9f377cefb (diff) | |
| download | pardes-628aa40f13e9bbd313b51ab625f193110aad8dd0.tar.gz pardes-628aa40f13e9bbd313b51ab625f193110aad8dd0.zip | |
Tab after a dot in insert mode lists what could go there
| -rw-r--r-- | docs/lsp.md | 89 | ||||
| -rw-r--r-- | src/lsp/lsp.zig | 19 | ||||
| -rw-r--r-- | src/lsp/lsp_zls.zig | 237 | ||||
| -rw-r--r-- | src/output_pane.zig | 8 | ||||
| -rw-r--r-- | src/pardes.zig | 122 | ||||
| -rw-r--r-- | test/lspbench.zig | 26 | ||||
| -rw-r--r-- | test/lspfixture/dotcomplete.zig | 28 | ||||
| -rw-r--r-- | test/lspfixture/dothalf.zig | 24 | ||||
| -rw-r--r-- | test/snapshots/lspcomplete.golden | 366 | ||||
| -rw-r--r-- | test/snapshots/lspcomplete.snap | 183 |
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 |
