diff options
Diffstat (limited to 'src')
| -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 |
4 files changed, 370 insertions, 16 deletions
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 |
