diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-29 00:50:38 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 4e642c1d6688baf3b98f269818b66cc7cc194c5f (patch) | |
| tree | 1ccbfdcc47fd8d6100ab3bd9ed4578b41f378f34 /src/lsp_zls.zig | |
| parent | 841038666a45b40107ee26d1f8e54ed19d0891a0 (diff) | |
| download | pardes-4e642c1d6688baf3b98f269818b66cc7cc194c5f.tar.gz pardes-4e642c1d6688baf3b98f269818b66cc7cc194c5f.zip | |
lsp backend: ZLS as an in-process library
The seam's `query` now calls ZLS's analyser directly, on the worker thread,
in this process. There is no zls binary, no subprocess, no JSON-RPC, no
`initialize` handshake and no `Server` — `gd` is a function call whose answer
comes back as rows. ZLS's build.zig already publishes its guts as an
importable module (`b.modules.put("zls", ...)`), so this is a path dependency
on the local 0.16.x checkout plus one new file, `src/lsp_zls.zig`.
Construction is ZLS's own (tests/analysis_check.zig does exactly this):
InternPool.init -> DiagnosticsCollection -> DocumentStore struct literal ->
Analyser.init. `zig_exe_path` is null on purpose — shelling out to the
compiler is the thing this backend exists to avoid — and `zig_lib_dir` is
baked in from `b.graph.zig_lib_directory`, so `gd` on `std.mem.count` opens
the same mem.zig the compiler used, with ZIG_LIB_DIR overriding at runtime.
Offsets are `.@"utf-8"`, not ZLS's utf-16 default: `+Search` rows are byte
columns and we are not on a wire.
Seventeen probes, seventeen answering, no false claims, 5.9 MiB peak RSS.
The features that were already Server-free are calls (hover, document
symbols, code actions); the ones welded to `*Server` are reimplemented thin
on top of public primitives — goto is gotoHandler minus the protocol,
diagnostics is the in-process `std.zig.AstGen` branch of
getAstCheckDiagnostics, references is symbolReferences' algorithm from the
outside (offer every same-named identifier token back to the analyser and
keep the ones that resolve to the same decl, so a shadowed local is not a
false hit).
What it does not do, deliberately:
- Nothing is cached between queries. Each `query` builds a DocumentStore,
resolves imports and throws it away, because the arena dies on return and
`req.source` is a snapshot of a buffer the user is still typing into. So
cold IS warm — there is no index to warm up. It is also fast enough not to
need one: 124us for a local goto, 2.8ms into the stdlib, 8ms for
references over a 5000-line file. A cross-query cache is a real design
(a global, a mutex, an invalidation story), not a line of code, and it is
the obvious next step rather than something smuggled in here.
- References, rename and select-refs are THIS FILE only. Workspace-wide
means loading every project file into the store and running the analyser
over each; DocumentStore's own workspace iteration has the same limit
(it can only see handles already loaded). Workspace symbols and workspace
diagnostics DO walk the tree, because neither needs the analyser — a
parse and a tree walk each.
- Rename previews, format reports, code actions list. The seam hands back
rows, not edits, so there is no channel through which a backend could
rewrite the buffer. These answer the question the keypress asks and change
nothing.
- Without a zig binary, `@import("builtin")`, `@import("<pkg>")` and
`@cImport` resolve to nothing — silently, which is ZLS's behaviour, not a
bug introduced here. Relative imports and `std` work.
- Non-.zig files answer nothing. The core does not gate the keymap by file
type, 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.
A whole-file report that ran and found nothing says so ("no diagnostics",
"already formatted", "no code actions") rather than returning zero rows,
because in this seam zero rows already means "no backend" — `lspResponse`
opens nothing for an empty answer, so silence cannot also mean "checked,
clean". Location queries keep the opposite rule: unresolvable is no rows.
test/snapshots/lsp.snap covers the round trip end to end — gd jumping on a
single result, SPC k opening +Hover, SPC s opening the +Search list that n
steps, and gd on a keyword answering nothing without opening anything. The
whole backend was also fuzzed at 20k queries over real, truncated and
byte-smashed sources across every kind; that found two crashes (a decl's
name token indexes its own file, not the requesting one, and is not
necessarily an identifier at all on a half-typed line) which are fixed.
emscripten does not get the backend: the web shell has no threads and
no-ops the lsp effect, so it keeps the empty one the base tree shipped.
DEPENDENCY: ZLS is FETCHED by the build system (build.zig.zon .url + .hash,
pinned to commit 3e0d0820 on the 0.16.x branch) rather than a path
dependency on the local genizah checkout, so it lands in zig-pkg/ like
every other dependency and the build is reproducible from the .zon alone.
Also passes -Dversion-string: ZLS's build.zig names itself by shelling out
to `git describe`, and a fetched package is an extracted tarball with no
.git, so every build printed a 'Failed to run git describe' warning. We
pin the commit, so we already know the answer.
Diffstat (limited to 'src/lsp_zls.zig')
| -rw-r--r-- | src/lsp_zls.zig | 745 |
1 files changed, 745 insertions, 0 deletions
diff --git a/src/lsp_zls.zig b/src/lsp_zls.zig new file mode 100644 index 00000000..99d20a10 --- /dev/null +++ b/src/lsp_zls.zig @@ -0,0 +1,745 @@ +//! ZLS as a LIBRARY, not a language server. +//! +//! There is no subprocess, no JSON-RPC, no `initialize` handshake and no +//! `Server`. `gd` runs ZLS's own analyser on this thread and comes back with a +//! token; the whole "protocol" is a struct field. ZLS's build.zig already +//! publishes its guts as an importable module (`b.modules.put("zls", ...)`), +//! and everything under `features/` that is not welded to `*Server` is a plain +//! function over (analyser, arena, handle, offset) — so the parts we want are +//! callable and the parts we don't want cost nothing. +//! +//! What that buys and what it costs: +//! +//! - Cold IS warm. There is no server to start, no workspace to index, no +//! handshake to wait for. The first `gd` after launch pays for parsing this +//! file and whatever it imports, and nothing else. +//! - It also means NOTHING IS CACHED between queries. Every call builds a +//! DocumentStore, resolves imports, and throws it all away, because `query` +//! is handed an arena that dies on return and a `req` that is a snapshot of +//! a buffer the user is still typing into. A cache would have to be a global +//! with its own mutex and its own invalidation story; that is a real design, +//! not a line of code, and it is the obvious next step rather than something +//! smuggled in here. +//! - `zig_exe_path` is null on purpose: shelling out to the compiler is the +//! thing this backend exists to avoid. `zig_lib_dir` is the build's own +//! (`b.graph.zig_lib_directory`), which is what makes `std` resolvable — +//! `gd` on `std.mem.count` lands in the real `mem.zig`. The price is that +//! `@import("builtin")`, `@import("<pkg>")` and `@cImport` resolve to +//! nothing, silently, because those need a build graph we refuse to run. +const std = @import("std"); +const zls = @import("zls"); +const lsp = @import("lsp.zig"); +const cfg = @import("pardes_config"); + +const Analyser = zls.Analyser; +const DocumentStore = zls.DocumentStore; +const InternPool = zls.analyser.InternPool; +const offsets = zls.offsets; +const Uri = zls.Uri; +const types = zls.lsp.types; +const Ast = std.zig.Ast; + +/// BYTE offsets. ZLS defaults to utf-16 because that is what the wire protocol +/// says; we are not on a wire, and `+Search` rows are byte columns. +const enc: offsets.Encoding = .@"utf-8"; + +/// Guards on the workspace walks (workspace symbols / workspace diagnostics). +/// A language query runs on every keypress of `SPC S`; it may not turn into an +/// unbounded tree walk because someone opened a file in `/`. +const max_files = 512; +const max_rows = 2000; + +pub const supports: std.EnumSet(lsp.Kind) = .initMany(&.{ + .definition, + .declaration, + .type_definition, + .implementation, + .references, + .select_refs, + .hover, + .document_symbols, + .workspace_symbols, + .diagnostics, + .workspace_diagnostics, + .rename, + .format, + .code_action, +}); + +/// The seam's contract: never fail, never panic, no rows is a legal answer. +/// Every error path in here — OOM, a cancelled io, a file that vanished — +/// collapses to "appended nothing", which the core already treats as "no +/// result". There is deliberately no error rendering. +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.ArrayList(u8)) void { + run(gpa, arena, req, out) catch {}; +} + +fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.ArrayList(u8)) !void { + // The blocking, single-threaded std.Io — the same one look.zig walks + // directories with. ZLS wants an `Io` for every file read; a worker thread + // that is allowed to block wants the one that just does the syscall. + const io = std.Io.Threaded.global_single_threaded.io(); + + // The two workspace kinds are about the PROJECT, not about this buffer, so + // they answer from any pane and never need the analyser, the store or the + // intern pool — a walk and a parse each. + switch (req.kind) { + .workspace_symbols => return workspaceSymbols(gpa, arena, io, req, out), + .workspace_diagnostics => return workspaceDiagnostics(gpa, arena, io, req, out), + // Everything else is about THIS file, and this backend speaks Zig. The + // 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")) return, + } + // Pure tree, no analyser. + if (req.kind == .format) return formatQuery(gpa, arena, req, out); + + var env: std.process.Environ.Map = .init(arena); + var lib_dir: ?std.Build.Cache.Directory = null; + if (zigLibPath()) |p| { + if (std.Io.Dir.cwd().openDir(io, p, .{})) |h| { + lib_dir = .{ .handle = h, .path = p }; + } else |_| {} + } + defer if (lib_dir) |d| d.handle.close(io); + + var ip: InternPool = try .init(io, gpa); + defer ip.deinit(gpa); + + var diags: zls.DiagnosticsCollection = .{ .io = io, .allocator = gpa }; + defer diags.deinit(); + + var store: DocumentStore = .{ + .io = io, + .allocator = gpa, + .config = .{ + .environ_map = &env, + // no compiler subprocess: that is the entire point of this backend + .zig_exe_path = null, + .zig_lib_dir = lib_dir, + .build_runner_path = null, + .builtin_path = null, + .global_cache_dir = null, + .wasi_preopens = {}, + }, + .diagnostics_collection = &diags, + }; + 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. + const uri: Uri = try .fromPath(arena, req.path); + try store.openLspSyncedDocument(uri, req.source); + const handle = store.getHandle(uri) orelse return; + if (handle.tree.mode == .zon) return; + + var analyser: Analyser = .init(gpa, arena, &store, &ip, handle); + defer analyser.deinit(); + + const off: usize = @min(req.offset, req.source.len); + + switch (req.kind) { + .definition, .implementation => try goto(gpa, arena, &analyser, handle, off, .definition, out), + .declaration => try goto(gpa, arena, &analyser, handle, off, .declaration, out), + .type_definition => try goto(gpa, arena, &analyser, handle, off, .type_definition, out), + .hover => try hover(gpa, arena, &analyser, handle, off, out), + .document_symbols => try documentSymbols(gpa, arena, req.path, &handle.tree, out), + .references, .select_refs => try references(gpa, arena, &analyser, handle, off, null, out), + .rename => try references(gpa, 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), + else => {}, + } +} + +/// The zig lib directory, which is the one thing this backend cannot work out +/// for itself: it is baked in at build time from `b.graph.zig_lib_directory`, +/// i.e. the exact stdlib pardes was compiled against, and ZIG_LIB_DIR wins if +/// the user moved it. Without this, `gd` on anything in `std` finds nothing. +fn zigLibPath() ?[]const u8 { + if (std.c.getenv("ZIG_LIB_DIR")) |p| { + const s = std.mem.span(p); + if (s.len != 0) return s; + } + if (cfg.zig_lib_dir.len != 0) return cfg.zig_lib_dir; + return null; +} + +// ---------------------------------------------------------------- rows + +/// Byte offset -> line, without rescanning the file per row. Built once per +/// query over the file we emit many rows from (symbols, references, +/// diagnostics); a goto that emits two rows just scans. +const Lines = struct { + starts: []const u32, + src: []const u8, + + fn build(arena: std.mem.Allocator, src: []const u8) !Lines { + var starts: std.ArrayList(u32) = .empty; + try starts.append(arena, 0); + for (src, 0..) |c, i| if (c == '\n') try starts.append(arena, @intCast(i + 1)); + return .{ .starts = starts.items, .src = src }; + } + + fn line(l: Lines, n: usize) []const u8 { + if (n >= l.starts.len) return ""; + const a = l.starts[n]; + const b = if (n + 1 < l.starts.len) l.starts[n + 1] - 1 else l.src.len; + return l.src[a..@min(b, l.src.len)]; + } +}; + +/// The source line at a 0-based line number, scanned. For the one-or-two-row +/// answers (a goto lands in a file we opened only to read one line out of). +fn lineAt(src: []const u8, want: usize) []const u8 { + var it = std.mem.splitScalar(u8, src, '\n'); + var i: usize = 0; + while (it.next()) |l| : (i += 1) if (i == want) return l; + return ""; +} + +/// Emit one row for a token in whatever file it lives in — which for a goto +/// into `std` is not the file the cursor was in, so the path comes off the +/// handle's uri rather than off `req`. +fn rowForToken(gpa: std.mem.Allocator, arena: std.mem.Allocator, th: Analyser.TokenWithHandle, out: *std.ArrayList(u8)) !void { + const tree = &th.handle.tree; + if (th.token >= tree.tokens.len) return; + const r = offsets.tokenToRange(tree, th.token, enc); + const path = th.handle.uri.toFsPath(arena) catch return; + lsp.row(gpa, out, path, r.start.line, r.start.character, lineAt(tree.source, r.start.line)); +} + +// ---------------------------------------------------------------- goto + +const GotoKind = enum { declaration, definition, type_definition }; + +/// `goto.zig:gotoHandler` minus the Server, minus the LSP types, minus the +/// linkSupport branch: dispatch on the position context, resolve to a decl, +/// resolve the decl to a token, print the token's line. The five helix gotos +/// collapse onto three ZLS kinds (`gi` behaves as `gd`, which is what ZLS's +/// own implementation handler does too). +fn goto( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, + kind: GotoKind, + out: *std.ArrayList(u8), +) !void { + const tree = &handle.tree; + const ctx = try Analyser.getPositionContext(arena, tree, off, true); + + var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty; + switch (ctx) { + .var_access, .test_doctest_name => { + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; + const name = offsets.locToSlice(tree.source, name_loc); + if (std.mem.eql(u8, name, "_")) return; + if (std.zig.isPrimitive(name)) return; + const d = try analyser.lookupSymbolGlobal(handle, name, off) orelse return; + try decls.append(arena, d); + }, + .field_access => |loc| { + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; + const name = offsets.locToSlice(tree.source, name_loc); + const held = offsets.locMerge(loc, name_loc); + const found = try analyser.getSymbolFieldAccesses(arena, handle, off, held, name) orelse return; + try decls.appendSlice(arena, found); + }, + .label_access, .label_decl => { + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; + const name = offsets.locToSlice(tree.source, name_loc); + const d = try Analyser.lookupLabel(handle, name, off) orelse return; + try decls.append(arena, d); + }, + .enum_literal => { + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; + const name = offsets.locToSlice(tree.source, name_loc); + const d = try analyser.getSymbolEnumLiteral(handle, off, name) orelse return; + try decls.append(arena, d); + }, + // `gd` on `@import("foo.zig")` opens foo.zig. Only relative imports + // resolve — a package name needs the build graph we do not run. + .import_string_literal, .embedfile_string_literal => { + const loc = ctx.stringLiteralContentLoc(tree.source); + if (loc.start == loc.end) return; + const str = offsets.locToSlice(tree.source, loc); + const res = try analyser.store.uriFromImportStr(arena, handle, str); + switch (res) { + .none => return, + .one => |u| lsp.row(gpa, out, u.toFsPath(arena) catch return, 0, 0, str), + .many => |us| for (us) |u| lsp.row(gpa, out, u.toFsPath(arena) catch continue, 0, 0, str), + } + return; + }, + else => return, + } + + // One decl that fails to resolve must not swallow the others: a field + // access can name several and a partial answer beats none. + for (decls.items) |d| { + switch (kind) { + .declaration => try rowForToken(gpa, arena, d.definitionToken(analyser, false) catch continue, out), + .definition => try rowForToken(gpa, arena, d.definitionToken(analyser, true) catch continue, out), + .type_definition => { + // peel error unions / pointers / optionals the way ZLS does, + // then take the container's own name token + if (try d.resolveType(analyser)) |ty| { + var r = ty; + while (true) { + r = try analyser.resolveUnwrapErrorUnionType(r, .payload) orelse + try analyser.resolveDerefType(r) orelse + try analyser.resolveOptionalUnwrap(r) orelse break; + } + if (r.typeDefinitionToken()) |th| { + try rowForToken(gpa, arena, th, out); + continue; + } + } + const nd = try d.typeDeclarationNode() orelse continue; + const t2 = &nd.handle.tree; + const rr = offsets.nodeToRange(t2, nd.node, enc); + const path = nd.handle.uri.toFsPath(arena) catch continue; + lsp.row(gpa, out, path, rr.start.line, rr.start.character, lineAt(t2.source, rr.start.line)); + }, + } + } +} + +// ---------------------------------------------------------------- hover + +/// `hover.hover` is already Server-free, so this is a call and a reformat: the +/// `+Hover` buffer holds prose, not locations, so the markdown comes through +/// as lines and nothing arms the n/N stepper. Fenced-code markers are dropped +/// because a plain-text pane has nothing to do with them. +fn hover( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, + out: *std.ArrayList(u8), +) !void { + const h = try zls.hover.hover(analyser, arena, handle, off, .markdown, enc) orelse return; + const text = switch (h.contents) { + .markup_content => |m| m.value, + else => return, + }; + var it = std.mem.splitScalar(u8, text, '\n'); + while (it.next()) |ln| { + if (std.mem.startsWith(u8, ln, "```")) continue; + out.print(gpa, "{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}) catch return; + } +} + +// ---------------------------------------------------------------- symbols + +/// `document_symbol.getDocumentSymbols` needs an arena, a tree and an encoding +/// — no store, no analyser, no intern pool. That makes `SPC s` the cheapest +/// thing in this file: a parse and a walk. +fn documentSymbols( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + path: []const u8, + tree: *const Ast, + out: *std.ArrayList(u8), +) !void { + const syms = try zls.document_symbol.getDocumentSymbols(arena, tree, enc); + var n: usize = 0; + try emitSymbols(gpa, arena, path, syms, "", &n, out); +} + +/// Depth-first so the rows read in source order, with the parent's name as a +/// qualifier — `Kind.jumpsWhenSingle` rather than a bare `jumpsWhenSingle`, +/// because a flat list of method names is not navigable. +fn emitSymbols( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + path: []const u8, + syms: []const types.DocumentSymbol, + prefix: []const u8, + n: *usize, + out: *std.ArrayList(u8), +) !void { + for (syms) |s| { + if (n.* >= max_rows) return; + n.* += 1; + const name = if (prefix.len == 0) + s.name + else + try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); + const text = if (s.detail) |d| + try std.fmt.allocPrint(arena, "{s} {s}", .{ name, d }) + else + name; + lsp.row(gpa, out, path, s.selectionRange.start.line, s.selectionRange.start.character, text); + if (s.children) |kids| try emitSymbols(gpa, arena, path, kids, name, n, out); + } +} + +/// `SPC S`: every `.zig` under the pane's directory, parsed, walked, filtered +/// by substring. ZLS's own workspace symbols would only see files already in +/// the DocumentStore (which is one), so this walks the tree itself — and +/// because `getDocumentSymbols` needs nothing but a tree, it never has to open +/// a store or resolve an import to do it. +fn workspaceSymbols( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + io: std.Io, + req: lsp.Req, + out: *std.ArrayList(u8), +) !void { + if (req.arg.len == 0) return; + var files: std.ArrayList([]const u8) = .empty; + try collectZigFiles(arena, io, req.root, &files); + var n: usize = 0; + for (files.items) |path| { + if (n >= max_rows) return; + const src = readFileZ(arena, io, path) catch continue; + var tree: Ast = Ast.parse(arena, src, .zig) catch continue; + defer tree.deinit(arena); + const syms = zls.document_symbol.getDocumentSymbols(arena, &tree, enc) catch continue; + try filterSymbols(gpa, arena, path, syms, "", req.arg, &n, out); + } +} + +fn filterSymbols( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + path: []const u8, + syms: []const types.DocumentSymbol, + prefix: []const u8, + needle: []const u8, + n: *usize, + out: *std.ArrayList(u8), +) !void { + for (syms) |s| { + if (n.* >= max_rows) return; + const name = if (prefix.len == 0) + s.name + else + try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); + if (containsIgnoreCase(s.name, needle)) { + n.* += 1; + lsp.row(gpa, out, path, s.selectionRange.start.line, s.selectionRange.start.character, name); + } + if (s.children) |kids| try filterSymbols(gpa, arena, path, kids, name, needle, n, out); + } +} + +fn containsIgnoreCase(hay: []const u8, needle: []const u8) bool { + if (needle.len == 0) return true; + if (needle.len > hay.len) return false; + var i: usize = 0; + outer: while (i + needle.len <= hay.len) : (i += 1) { + for (needle, 0..) |c, j| + if (std.ascii.toLower(hay[i + j]) != std.ascii.toLower(c)) continue :outer; + return true; + } + return false; +} + +// ------------------------------------------------------------- references + +/// `gr` / `SPC h` / `SPC r`. ZLS's `symbolReferences` is private and its +/// `referencesHandler` takes a `*Server`, so this is the same algorithm from +/// the outside: find the decl under the cursor, then offer every identifier +/// token in the file that spells the same name back to the analyser and keep +/// the ones that resolve to the same decl. Resolving rather than matching is +/// what makes a shadowed local not a reference to the outer one. +/// +/// LIMIT: this file only. A workspace-wide answer means loading every project +/// file into the store and running the analyser over each — seconds, not +/// milliseconds, on every keypress, and the store's own workspace iteration +/// has the same restriction (it can only see handles that were loaded). +/// +/// `new_name` non-null makes it a rename PREVIEW: the same rows, annotated +/// with the replacement. The seam returns rows, not edits, so `SPC r` shows +/// what would change and changes nothing — an honest half of rename. +fn references( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, + new_name: ?[]const u8, + out: *std.ArrayList(u8), +) !void { + const tree = &handle.tree; + const target = try declAt(arena, analyser, handle, off) orelse return; + // The decl's name token indexes ITS OWN file, which after an alias resolve + // is not necessarily this one — and on a half-typed file it is not + // necessarily an identifier either, which `identifierTokenToNameSlice` + // asserts. Both of those are crashes on a keystroke, so both are checked. + const decl_tree = &target.handle.tree; + const name_tok = target.nameToken(); + if (name_tok >= decl_tree.tokens.len) return; + if (decl_tree.tokenTag(name_tok) != .identifier) return; + const want = offsets.identifierTokenToNameSlice(decl_tree, name_tok); + if (want.len == 0) return; + + const lines: Lines = try .build(arena, tree.source); + var n: usize = 0; + for (0..tree.tokens.len) |i| { + if (n >= max_rows) return; + const tok: Ast.TokenIndex = @intCast(i); + if (tree.tokenTag(tok) != .identifier) continue; + if (!std.mem.eql(u8, offsets.identifierTokenToNameSlice(tree, tok), want)) continue; + const at = tree.tokenStart(tok); + const d = (declAt(arena, analyser, handle, at) catch continue) orelse continue; + if (!d.eql(target)) continue; + n += 1; + const r = offsets.tokenToRange(tree, tok, enc); + const text = if (new_name) |nn| + try std.fmt.allocPrint(arena, "{s} -> {s} {s}", .{ want, nn, std.mem.trim(u8, lines.line(r.start.line), " \t") }) + else + lines.line(r.start.line); + lsp.row(gpa, out, handle.uri.toFsPath(arena) catch return, r.start.line, r.start.character, text); + } +} + +/// The decl under a byte offset, whatever the surrounding syntax is. Shared by +/// references and by rename, which is references with a label on it. +fn declAt( + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, +) !?Analyser.DeclWithHandle { + const tree = &handle.tree; + const ctx = try Analyser.getPositionContext(arena, tree, off, true); + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return null; + const name = offsets.locToSlice(tree.source, name_loc); + const d = switch (ctx) { + .var_access, .test_doctest_name => try analyser.lookupSymbolGlobal(handle, name, off), + .field_access => |loc| blk: { + const found = try analyser.getSymbolFieldAccesses(arena, handle, off, offsets.locMerge(loc, name_loc), name) orelse break :blk null; + break :blk if (found.len != 0) found[0] else null; + }, + .label_access, .label_decl => try Analyser.lookupLabel(handle, name, off), + .enum_literal => try analyser.getSymbolEnumLiteral(handle, off, name), + else => null, + } orelse return null; + return try analyser.resolveVarDeclAlias(d) orelse d; +} + +// ------------------------------------------------------------ diagnostics + +/// `SPC d` and the list `]d`/`[d` step. ZLS's `getAstCheckDiagnostics` prefers +/// spawning `zig ast-check`; its OTHER branch runs `std.zig.AstGen` in this +/// process, which is the branch a subprocess-free backend wants, so that is +/// what is inlined here (it is ten lines and needs a `*Server` only for the +/// config it reads). +/// +/// A clean file emits ONE row saying so. Silence already means "no backend" in +/// this seam — `lspResponse` opens nothing for zero rows — so a `SPC d` that +/// checked and found nothing has to be able to say the difference. +fn diagnostics( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + path: []const u8, + tree: *const Ast, + out: *std.ArrayList(u8), +) !void { + const n = try treeDiagnostics(gpa, arena, path, tree, out); + if (n == 0) lsp.row(gpa, out, path, 0, 0, "no diagnostics"); +} + +/// `zig ast-check`, in this process. ZLS spawns the compiler for this when it +/// has one; its fallback branch is `std.zig.AstGen` directly, and that fallback +/// is this backend's only branch. Caller owns the bundle. +fn astCheck(gpa: std.mem.Allocator, path: []const u8, tree: *const Ast) !std.zig.ErrorBundle { + if (tree.errors.len != 0) return .empty; + var zir = std.zig.AstGen.generate(gpa, tree.*) catch return .empty; + defer zir.deinit(gpa); + if (!zir.hasCompileErrors()) return .empty; + var wip: std.zig.ErrorBundle.Wip = undefined; + try wip.init(gpa); + defer wip.deinit(); + wip.addZirErrorMessages(zir, tree.*, tree.source, path) catch return .empty; + return wip.toOwnedBundle("") catch .empty; +} + +/// Parse errors first (AstGen cannot run on a tree that did not parse), then +/// the AstGen pass. Returns how many rows were emitted. +fn treeDiagnostics( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + path: []const u8, + tree: *const Ast, + out: *std.ArrayList(u8), +) !usize { + var n: usize = 0; + if (tree.errors.len != 0) { + const lines: Lines = try .build(arena, tree.source); + for (tree.errors) |e| { + if (n >= max_rows) return n; + var buf: std.ArrayList(u8) = .empty; + var w: std.Io.Writer.Allocating = .fromArrayList(arena, &buf); + tree.renderError(e, &w.writer) catch continue; + const at = tree.tokenStart(e.token); + const lc = lsp.lineCol(tree.source, at); + n += 1; + lsp.row(gpa, out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{ + w.written(), std.mem.trim(u8, lines.line(lc.line), " \t"), + })); + } + return n; + } + + var bundle = try astCheck(gpa, path, tree); + defer bundle.deinit(gpa); + if (bundle.errorMessageCount() == 0) return n; + for (bundle.getMessages()) |m| { + if (n >= max_rows) return n; + const em = bundle.getErrorMessage(m); + if (em.src_loc == .none) continue; + const sl = bundle.getSourceLocation(em.src_loc); + n += 1; + lsp.row(gpa, out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{ + bundle.nullTerminatedString(em.msg), + })); + } + return n; +} + +/// `SPC D`: the same check over every `.zig` under the pane's directory. No +/// analyser and no store — AstGen is a per-file pass — so this is bounded by +/// how fast Zig can parse the tree, which for pardes's own `src/` is tens of +/// milliseconds. +fn workspaceDiagnostics( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + io: std.Io, + req: lsp.Req, + out: *std.ArrayList(u8), +) !void { + var files: std.ArrayList([]const u8) = .empty; + try collectZigFiles(arena, io, req.root, &files); + var total: usize = 0; + for (files.items) |path| { + const src = readFileZ(arena, io, path) catch continue; + var tree: Ast = Ast.parse(arena, src, .zig) catch continue; + defer tree.deinit(arena); + total += treeDiagnostics(gpa, arena, path, &tree, out) catch continue; + if (total >= max_rows) return; + } + if (total == 0) lsp.row(gpa, out, req.path, 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.items.len})); +} + +// ----------------------------------------------------------- code actions + +/// `SPC a`. `code_actions.Builder` is one of the Server-free ones — it wants +/// an arena, an analyser, a handle and an ErrorBundle — so the only missing +/// piece is the bundle, which `astCheck` above produces without a compiler. +/// +/// It lists what could be done, and does none of it: the seam hands back rows, +/// not edits, so there is no channel through which a backend could apply a +/// fix. `+Lsp` is prose, so these are lines rather than locations. +fn codeActions( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, + out: *std.ArrayList(u8), +) !void { + const tree = &handle.tree; + if (tree.errors.len != 0) { + out.print(gpa, "no code actions: file does not parse\n", .{}) catch {}; + return; + } + var bundle = try astCheck(gpa, "", tree); + defer bundle.deinit(gpa); + + var builder: zls.code_actions.Builder = .{ + .arena = arena, + .analyser = analyser, + .handle = handle, + .offset_encoding = enc, + .only_kinds = null, + }; + builder.generateCodeAction(bundle) catch {}; + const at = offsets.locToRange(tree.source, .{ .start = off, .end = off }, enc); + builder.generateCodeActionsInRange(at) catch {}; + + for (builder.actions.items) |a| { + out.print(gpa, "{s}\n", .{a.title}) catch return; + } + if (builder.actions.items.len == 0) out.print(gpa, "no code actions\n", .{}) catch {}; +} + +// ---------------------------------------------------------------- format + +/// `=`. The seam returns ROWS, not edits — there is no channel through which a +/// backend can rewrite the buffer — so format reports rather than formats: one +/// row per line `zig fmt` would change, in the `+Lsp` buffer. That is the +/// honest shape of "format" behind a read-only seam, and it is still the +/// answer to the question the keypress asks. +fn formatQuery(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.ArrayList(u8)) !void { + var tree: Ast = try .parse(arena, req.source, .zig); + defer tree.deinit(arena); + if (tree.errors.len != 0) { + lsp.row(gpa, out, req.path, 0, 0, "cannot format: file does not parse"); + return; + } + var buf: std.ArrayList(u8) = .empty; + var w: std.Io.Writer.Allocating = .fromArrayList(arena, &buf); + tree.render(arena, &w.writer, .{}) catch return; + const formatted = w.written(); + if (std.mem.eql(u8, formatted, req.source)) { + lsp.row(gpa, out, req.path, 0, 0, "already formatted"); + return; + } + var a = std.mem.splitScalar(u8, req.source, '\n'); + var b = std.mem.splitScalar(u8, formatted, '\n'); + var ln: usize = 0; + var n: usize = 0; + while (n < max_rows) : (ln += 1) { + const x = a.next(); + const y = b.next(); + if (x == null and y == null) break; + const xs = x orelse ""; + const ys = y orelse ""; + if (std.mem.eql(u8, xs, ys)) continue; + n += 1; + lsp.row(gpa, out, req.path, ln, 0, try std.fmt.allocPrint(arena, "- {s} + {s}", .{ + std.mem.trim(u8, xs, " \t"), std.mem.trim(u8, ys, " \t"), + })); + } +} + +// ------------------------------------------------------------------ files + +/// Every `.zig` under `root`, skipping the directories a source walk has no +/// business in. Capped, because this runs on a keypress. +fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8, out: *std.ArrayList([]const u8)) !void { + if (root.len == 0) return; + var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch return; + defer dir.close(io); + var w = dir.walkSelectively(arena) catch return; + defer w.deinit(); + while (out.items.len < max_files) { + const e = (w.next(io) catch break) orelse break; + switch (e.kind) { + .directory => { + if (e.basename.len != 0 and e.basename[0] == '.') continue; + if (std.mem.eql(u8, e.basename, "zig-out")) continue; + if (std.mem.eql(u8, e.basename, "zig-cache")) continue; + w.enter(io, e) catch {}; + }, + .file => { + if (!std.mem.endsWith(u8, e.basename, ".zig")) continue; + const p = std.fs.path.join(arena, &.{ root, e.path }) catch continue; + try out.append(arena, p); + }, + else => {}, + } + } +} + +fn readFileZ(arena: std.mem.Allocator, io: std.Io, path: []const u8) ![:0]u8 { + return std.Io.Dir.cwd().readFileAllocOptions(io, path, arena, .limited(4 * 1024 * 1024), .of(u8), 0); +} |
