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 /src/lsp | |
| parent | 0a15af8d98771180e32402e68ea844f9f377cefb (diff) | |
| download | pardes-628aa40f13e9bbd313b51ab625f193110aad8dd0.tar.gz pardes-628aa40f13e9bbd313b51ab625f193110aad8dd0.zip | |
Tab after a dot in insert mode lists what could go there
Diffstat (limited to 'src/lsp')
| -rw-r--r-- | src/lsp/lsp.zig | 19 | ||||
| -rw-r--r-- | src/lsp/lsp_zls.zig | 237 |
2 files changed, 253 insertions, 3 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 |
