summaryrefslogtreecommitdiff
path: root/src/lsp
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-09 04:20:20 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit628aa40f13e9bbd313b51ab625f193110aad8dd0 (patch)
treee60902495e308505959361d8719c382401b88295 /src/lsp
parent0a15af8d98771180e32402e68ea844f9f377cefb (diff)
downloadpardes-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.zig19
-rw-r--r--src/lsp/lsp_zls.zig237
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