summaryrefslogtreecommitdiff
path: root/src/lsp
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-30 23:35:45 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit19f7322100062b7c1adcde3376063ce6c1d8c72d (patch)
treeb76347dffe0347c29a38b4cad6031678683dba74 /src/lsp
parent753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff)
downloadpardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.tar.gz
pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.zip
structure: backends into src/{tty,gui,lsp}, pane kinds and builtins into their own files
The core now lies FLAT at src/ and every subdirectory is one backend, so a file being in no directory at all is what says it is core. Pane-kind bodies leave pardes.zig for term_pane.zig / file_pane.zig / output_pane.zig, leaving it the layout, the event/effect machine and the generic render loop. Builtins are one struct each in builtins.zig, and the enum is folded out of the file's own declaration list at comptime — a zig file IS a struct, so the list of builtins and the builtins themselves are the same text. Adding one is writing a struct. Key paths deliberately stay one table for the config pass. Pure refactor: no golden moved.
Diffstat (limited to 'src/lsp')
-rw-r--r--src/lsp/lsp.zig162
-rw-r--r--src/lsp/lsp_zls.zig1073
2 files changed, 1235 insertions, 0 deletions
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
new file mode 100644
index 00000000..536047a3
--- /dev/null
+++ b/src/lsp/lsp.zig
@@ -0,0 +1,162 @@
+//! The language-intelligence seam.
+//!
+//! The core never speaks a protocol and never blocks. It emits an `lsp` Effect
+//! naming a Kind, a file and a byte offset; a shell runs `query` on a worker
+//! and posts the answer back as an `lsp_resp` Event. That is the whole async
+//! execution model — the same shape the pty readers already use, because a
+//! language query is just another thing that answers later.
+//!
+//! Every backend renders into ONE format: `+Search` rows. A location is
+//! `path:LINE:COL text`, which is what look.zig already resolves and what n/N
+//! already steps, so a multi-result answer IS helix's picker and a single
+//! result IS a jump — with no picker UI written for it. Free text (hover,
+//! a rename's diff) rides the same buffer as plain lines.
+//!
+//! `query` is the ONLY thing an implementation supplies. Swapping backends is
+//! swapping this one function, which is also how the three competing
+//! implementations are measured against each other: same core, same harness,
+//! same rows, different `query`.
+const std = @import("std");
+
+/// What the caller wants to know. The helix command each one backs is named
+/// alongside, because the keymap is helix's and these are its verbs.
+pub const Kind = enum {
+ /// gd
+ definition,
+ /// gD
+ declaration,
+ /// gy
+ type_definition,
+ /// gi
+ implementation,
+ /// gr
+ references,
+ /// SPC k
+ hover,
+ /// SPC s
+ document_symbols,
+ /// SPC S (arg = the query)
+ workspace_symbols,
+ /// SPC d, and the list that ]d / [d step
+ diagnostics,
+ /// SPC D
+ workspace_diagnostics,
+ /// SPC r (arg = the new name)
+ rename,
+ /// SPC a
+ code_action,
+ /// =
+ format,
+ /// SPC h
+ select_refs,
+
+ // The two introspection kinds. A backend that answers nothing is
+ // indistinguishable from a backend that is broken, so these exist to tell
+ // those apart — they are the only Kinds whose answer is ABOUT the backend
+ // rather than about the code.
+ /// SPC l i — configuration, capabilities and the recent-query log
+ status,
+ /// SPC l w — why the query at the cursor answers what it does. Narrates
+ /// the REAL resolution path rather than re-deriving it, so it cannot drift
+ /// away from what `gd` actually did.
+ explain,
+
+ /// Whether an answer of exactly one row should JUMP rather than open a
+ /// results buffer. Helix: the five gotos jump on a single location and
+ /// show a picker on several; a symbol list is always a picker.
+ pub fn jumpsWhenSingle(k: Kind) bool {
+ return switch (k) {
+ .definition, .declaration, .type_definition, .implementation, .references => true,
+ else => false,
+ };
+ }
+
+ /// The buffer an answer opens. Kept distinct from `+Search` only where the
+ /// content is not a list of locations — n/N over prose is nonsense.
+ pub fn bufferName(k: Kind) []const u8 {
+ return switch (k) {
+ .hover => "+Hover",
+ .code_action, .format, .rename => "+Lsp",
+ // prose about the backend, never a list of locations
+ .status, .explain => "+Lsp",
+ else => "+Search",
+ };
+ }
+};
+
+/// One question. `source` is a snapshot of the buffer taken by the shell
+/// before the worker starts — the core keeps editing while this is in flight,
+/// so a backend must never reach back into core memory.
+pub const Req = struct {
+ kind: Kind,
+ /// absolute path of the file the offset is in
+ path: []const u8,
+ /// the buffer's bytes, NUL-terminated (std.zig.Ast and zls both want a
+ /// sentinel, and every backend has to parse this same text)
+ source: [:0]const u8,
+ /// cursor position, a byte offset into `source`
+ offset: u32,
+ /// kind-specific argument: the new name for a rename, the query for
+ /// workspace symbols. Empty otherwise.
+ arg: []const u8 = "",
+ /// where the project starts — the directory of the pane that asked. A
+ /// backend that indexes more than one file walks from here.
+ root: []const u8 = "",
+};
+
+/// Emit one `path:LINE:COL text` row. Line and column are 1-based, the way
+/// every other row in a `+Search` buffer is (and the way look.zig parses one).
+pub fn row(
+ out: *std.Io.Writer,
+ path: []const u8,
+ line: usize,
+ col: usize,
+ text: []const u8,
+) void {
+ out.print("{s}:{d}:{d} {s}\n", .{
+ path, line + 1, col + 1, std.mem.trim(u8, text, " \t\r\n"),
+ }) catch {};
+}
+
+/// Byte offset -> (line, column), both 0-based. Every backend needs it to turn
+/// an AST token into a row, so it lives here rather than three times over.
+pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usize } {
+ const upto = source[0..@min(offset, source.len)];
+ const line = std.mem.count(u8, upto, "\n");
+ const bol = if (std.mem.lastIndexOfScalar(u8, upto, '\n')) |i| i + 1 else 0;
+ return .{ .line = line, .col = upto.len - bol };
+}
+
+/// Answer `req`, writing rows to `out`. Runs on a worker thread with no
+/// access to the core: everything it may read is in `req`.
+///
+/// `out` is a plain `std.Io.Writer` — the shell owns the buffer behind it (an
+/// `Io.Writer.Allocating`), so a backend never allocates the result, never
+/// frees it, and cannot get the allocator wrong. Write failures are the
+/// writer's problem; a backend may ignore them.
+///
+/// `arena` is freed wholesale when the query returns; `gpa` is for a backend's
+/// own longer-lived scratch. Errors are not reported — a backend that cannot
+/// answer writes nothing, and the core treats "no rows" as "no result", which
+/// is also what a language server still starting up looks like.
+pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) void {
+ backend.query(gpa, arena, req, out);
+}
+
+/// ZLS, linked in as a module and called directly — no subprocess, no
+/// JSON-RPC. See `lsp_zls.zig`. The web shell has no threads, never emits the
+/// effect, and cannot build ZLS anyway, so there it is the empty backend the
+/// 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 const supports: std.EnumSet(Kind) = .initEmpty();
+};
+
+/// What this backend can actually answer, for the evaluation harness and for
+/// the core (a Kind that is not supported never leaves the keymap). An
+/// implementation narrows this to what it really does — claiming a feature it
+/// does not have shows up immediately in the harness's matrix.
+pub const supports: std.EnumSet(Kind) = backend.supports;
+
+/// 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
new file mode 100644
index 00000000..6f5bbcdd
--- /dev/null
+++ b/src/lsp/lsp_zls.zig
@@ -0,0 +1,1073 @@
+//! 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 DEPENDENCIES, and it is worth being exact about how far it
+//! goes. ZLS resolves a non-`std`, non-relative import only through a
+//! `BuildFile` whose config came from running `zig build --build-runner`, so
+//! with no compiler it resolves none of them. We paper over ONE case of
+//! that: `gd` on the import string itself (`moduleRoot`, fed by build.zig).
+//! That is a fallback in OUR handler and ZLS cannot see it, so the analyser
+//! still cannot type `vaxis`, and `gd` on `vaxis.init` still finds nothing.
+//! Making member access work means giving ZLS a real build config — either
+//! by letting it run the build runner (a subprocess, and with no cross-query
+//! cache that is once PER KEYPRESS) or by synthesizing a BuildConfig and
+//! reaching into `BuildFile.impl`. Both are real work; neither is smuggled
+//! in here. `@import("builtin")` and `@cImport` are unresolved for the same
+//! reason.
+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,
+ .status,
+ .explain,
+});
+
+// ------------------------------------------------------------ introspection
+//
+// Everything below exists because of the contract two lines down: this backend
+// never fails loudly. That is right for an editor — a thrown analyser must not
+// take the process with it — but it means a broken backend and a correct one
+// that found nothing look identical from the outside. So every query leaves a
+// record, and `SPC l i` reads them back.
+
+/// One finished query. Fixed-size and inline: this array is process-global and
+/// lives forever, so it must not own heap.
+const LogEntry = struct {
+ used: bool = false,
+ kind: lsp.Kind = .definition,
+ us: u64 = 0,
+ rows: usize = 0,
+ offset: u32 = 0,
+ /// the error `run` returned, or empty when it returned normally. THIS is
+ /// the thing you cannot see any other way.
+ err: [40]u8 = @splat(0),
+ err_len: u8 = 0,
+ /// basename only — a full path would need most of this struct
+ file: [64]u8 = @splat(0),
+ file_len: u8 = 0,
+
+ fn errName(e: *const LogEntry) []const u8 {
+ return e.err[0..e.err_len];
+ }
+ fn fileName(e: *const LogEntry) []const u8 {
+ return e.file[0..e.file_len];
+ }
+};
+
+const log_cap = 24;
+var log_buf: [log_cap]LogEntry = @splat(.{});
+var log_next: usize = 0;
+var log_total: u64 = 0;
+/// 0.16 has no std.Thread.Mutex; the critical sections here are a memcpy, so
+/// spinning on the lock-free one is enough — the same call gui.zig's pty queue
+/// makes. Only WORKER threads touch this; the core never does.
+var log_mu: std.atomic.Mutex = .unlocked;
+
+fn logLock() void {
+ while (!log_mu.tryLock()) std.atomic.spinLoopHint();
+}
+
+fn record(req: lsp.Req, us: u64, rows: usize, err: []const u8) void {
+ logLock();
+ defer log_mu.unlock();
+ const e = &log_buf[log_next];
+ e.* = .{ .used = true, .kind = req.kind, .us = us, .rows = rows, .offset = req.offset };
+ const base = std.fs.path.basename(req.path);
+ e.file_len = @intCast(@min(base.len, e.file.len));
+ @memcpy(e.file[0..e.file_len], base[0..e.file_len]);
+ e.err_len = @intCast(@min(err.len, e.err.len));
+ @memcpy(e.err[0..e.err_len], err[0..e.err_len]);
+ log_next = (log_next + 1) % log_cap;
+ log_total += 1;
+}
+
+/// A duration cannot go in a snapshot golden — it differs every run. The
+/// snapshot harness sets PARDES_LSP_NOTIME so the two introspection views
+/// print `-` instead, the same way it pins PARDES_DUMP for the dump tests.
+/// Nothing else reads it, and unset (i.e. every real run) shows real numbers.
+fn hideTime() bool {
+ const v = std.c.getenv("PARDES_LSP_NOTIME") orelse return false;
+ return std.mem.span(v).len != 0;
+}
+
+fn nowUs() u64 {
+ var ts: std.c.timespec = undefined;
+ _ = std.c.clock_gettime(.MONOTONIC, &ts);
+ return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000;
+}
+
+/// The narration `SPC l w` prints. It is threaded through the REAL resolution
+/// path rather than re-derived beside it: a debug view that reimplements the
+/// logic is a debug view that can disagree with it, and the whole point is to
+/// find out what actually happened.
+const Trace = struct {
+ on: bool = false,
+ arena: std.mem.Allocator,
+ buf: std.ArrayList(u8) = .empty,
+
+ fn note(t: *Trace, comptime fmt: []const u8, args: anytype) void {
+ if (!t.on) return;
+ t.buf.print(t.arena, fmt ++ "\n", args) catch {};
+ }
+};
+
+/// 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.Io.Writer) void {
+ // status reads the log; recording it would push a real query out of a
+ // 24-entry ring every time you looked at it.
+ if (req.kind == .status) return status(arena, req, out) catch {};
+
+ // Rows land in a scratch buffer first, for two reasons a plain Writer
+ // cannot serve: the log wants an exact row count, and `explain` throws the
+ // rows away and prints the narration in their place — neither a count nor
+ // a rewind exists on a Writer you do not own.
+ var scratch: std.Io.Writer.Allocating = .init(gpa);
+ defer scratch.deinit();
+
+ var tr: Trace = .{ .arena = arena, .on = req.kind == .explain };
+ const t0 = nowUs();
+ var err_name: []const u8 = "";
+ run(gpa, arena, req, &scratch.writer, &tr) catch |e| {
+ err_name = @errorName(e);
+ tr.note("ERROR: {s} — the query threw; the editor shows this as 'no result'", .{err_name});
+ };
+ const us = nowUs() -| t0;
+ const rows = std.mem.count(u8, scratch.written(), "\n");
+ record(req, us, rows, err_name);
+
+ if (req.kind != .explain) {
+ out.writeAll(scratch.written()) catch {};
+ return;
+ }
+ // the question was never "where is it", it was "what did you do"
+ out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{
+ req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path),
+ }) catch {};
+ out.writeAll(tr.buf.items) catch {};
+ if (hideTime())
+ out.print("\n{d} row(s)\n", .{rows}) catch {}
+ else
+ out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {};
+}
+
+fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) !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.
+ // `explain` narrates the definition query; everywhere below dispatches on
+ // the effective kind, so the trace follows the code `gd` really runs.
+ const kind: lsp.Kind = if (req.kind == .explain) .definition else req.kind;
+
+ switch (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")) {
+ 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,
+ });
+ return;
+ },
+ }
+ // Pure tree, no analyser.
+ if (kind == .format) return formatQuery(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 };
+ tr.note("zig lib dir: {s} (open) — `std` imports resolve", .{p});
+ } else |e| tr.note("zig lib dir: {s} FAILED to open ({s}) — nothing in `std` will resolve", .{ p, @errorName(e) });
+ } else tr.note("zig lib dir: NOT SET — nothing in `std` will resolve", .{});
+ 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 {
+ tr.note("STOP: the document store would not take this buffer", .{});
+ return;
+ };
+ if (handle.tree.mode == .zon) {
+ tr.note("STOP: parsed as .zon, not Zig code — there is nothing to resolve", .{});
+ return;
+ }
+ tr.note("parsed {d} bytes, {d} syntax error(s){s}", .{
+ req.source.len,
+ handle.tree.errors.len,
+ if (handle.tree.errors.len == 0) "" else " — a broken tree resolves badly or not at all",
+ });
+
+ var analyser: Analyser = .init(gpa, arena, &store, &ip, handle);
+ defer analyser.deinit();
+
+ const off: usize = @min(req.offset, req.source.len);
+
+ switch (kind) {
+ .definition, .implementation => try goto(arena, &analyser, handle, off, .definition, out, tr),
+ .declaration => try goto(arena, &analyser, handle, off, .declaration, out, tr),
+ .type_definition => try goto(arena, &analyser, handle, off, .type_definition, out, tr),
+ .hover => try hover(arena, &analyser, handle, off, out),
+ .document_symbols => try documentSymbols(gpa, arena, req.path, &handle.tree, out),
+ .references, .select_refs => try references(arena, &analyser, handle, off, null, out),
+ .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),
+ else => {},
+ }
+}
+
+/// `SPC l i`. What the backend IS: which ZLS, which stdlib, what it will and
+/// will not answer, and the last two dozen queries with their timings and —
+/// the point of the whole exercise — the errors that `query` swallowed.
+fn status(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void {
+ const io = std.Io.Threaded.global_single_threaded.io();
+ try out.print("backend: {s}\nzls: {s} (compiled in — no server process, no JSON-RPC)\n", .{
+ lsp.backend_name, cfg.zls_version,
+ });
+
+ // The single most common cause of "gd does nothing in std": say whether it
+ // is actually openable, not just what it is set to.
+ if (zigLibPath()) |p| {
+ if (std.Io.Dir.cwd().openDir(io, p, .{})) |h| {
+ var d = h;
+ d.close(io);
+ try out.print("zig lib dir: {s} [OK]\n", .{p});
+ } else |e| try out.print("zig lib dir: {s} [CANNOT OPEN: {s}] — nothing in `std` will resolve\n", .{ p, @errorName(e) });
+ } else try out.print("zig lib dir: NOT SET — nothing in `std` will resolve\n", .{});
+ try out.print("offsets: {s} walk caps: {d} files, {d} rows\n", .{ @tagName(enc), max_files, max_rows });
+ // The dependency module map, which is the answer to "why does gd work on
+ // std and not on @import(\"vaxis\")" — if a name is not on this list, the
+ // build did not import it and nothing can resolve it.
+ try out.print("\ndependency imports gd can follow ({d}):\n", .{cfg.module_names.len});
+ for (cfg.module_names, cfg.module_roots) |n, r| {
+ try out.print(" {s:<14} {s}\n", .{ n, r });
+ }
+ try out.print("asked from: {s}\n", .{if (req.path.len == 0) "a pane with no file" else req.path});
+
+ try out.print("\nanswers:", .{});
+ var it = supports.iterator();
+ var n: usize = 0;
+ while (it.next()) |k| : (n += 1) {
+ if (k == .status or k == .explain) continue; // these two are the debugger, not a feature
+ try out.print("{s}{s}", .{ if (n == 0) " " else ", ", @tagName(k) });
+ }
+ try out.print("\nrefuses: ", .{});
+ n = 0;
+ for (std.enums.values(lsp.Kind)) |k| {
+ if (supports.contains(k)) continue;
+ try out.print("{s}{s}", .{ if (n == 0) "" else ", ", @tagName(k) });
+ n += 1;
+ }
+ if (n == 0) try out.print("(nothing)", .{});
+ try out.print("\n", .{});
+
+ // The log. Oldest first, so it reads like a transcript.
+ logLock();
+ defer log_mu.unlock();
+ try out.print("\nlast queries ({d} total, keeping {d}):\n", .{ log_total, log_cap });
+ var shown: usize = 0;
+ for (0..log_cap) |i| {
+ const e = &log_buf[(log_next + i) % log_cap];
+ if (!e.used) continue;
+ shown += 1;
+ if (hideTime()) {
+ try out.print(" {s:<22} {s:<20} @{d:<7} {d:>4} row(s){s}{s}\n", .{
+ @tagName(e.kind), e.fileName(), e.offset, e.rows,
+ if (e.err_len == 0) "" else " ERROR: ", e.errName(),
+ });
+ } else {
+ try out.print(" {s:<22} {s:<20} @{d:<7} {d:>7}us {d:>4} row(s){s}{s}\n", .{
+ @tagName(e.kind), e.fileName(), e.offset, e.us, e.rows,
+ if (e.err_len == 0) "" else " ERROR: ", e.errName(),
+ });
+ }
+ }
+ if (shown == 0) try out.print(" (none yet — press gd somewhere, then ask again)\n", .{});
+ _ = arena;
+}
+
+/// The root source file of a module this build imports, or null.
+///
+/// ZLS answers `@import("std")` from `zig_lib_dir` and a relative `.zig` path
+/// from the filesystem, but ANY OTHER name — every dependency in
+/// build.zig.zon — it can only resolve by running `zig build --build-runner`
+/// to discover the module graph. This backend sets `zig_exe_path = null` on
+/// purpose, so that branch always returned nothing: `gd` worked perfectly into
+/// `std` and silently did nothing on `@import("vaxis")`.
+///
+/// We never needed the compiler for this. build.zig IS the module graph, so it
+/// folds its own `root_mod.import_table` into these two parallel arrays at
+/// configure time. Correct by construction: a dependency added or renamed in
+/// build.zig cannot forget to appear here.
+///
+/// LIMITS, in the order you will hit them:
+/// 1. This resolves the IMPORT STRING only. It is consulted from our own
+/// `goto`, not from inside ZLS, so the analyser still cannot type the
+/// `vaxis` const — `gd` on `vaxis.init` finds nothing. Fixing that means
+/// giving ZLS a BuildConfig; see the note at the top of this file.
+/// 2. These are THIS build's imports. A file inside a dependency importing
+/// that dependency's own internal module name is a miss — that would mean
+/// running ITS build.zig.
+/// 3. A module whose root source is a GENERATED file is absent from the
+/// table: it has no path until make() runs.
+fn moduleRoot(name: []const u8) ?[]const u8 {
+ comptime std.debug.assert(cfg.module_names.len == cfg.module_roots.len);
+ for (cfg.module_names, cfg.module_roots) |n, r| {
+ if (std.mem.eql(u8, n, name)) return r;
+ }
+ return null;
+}
+
+/// Why a field access came back empty. Worth its own function because ONE of
+/// the reasons is a known, explainable hole rather than "no idea": if the left
+/// side starts with a dependency module name, the analyser could not type it
+/// because ZLS has no build config, and no amount of staring at the code will
+/// reveal that.
+fn explainFieldMiss(tr: *Trace, lhs: []const u8) void {
+ var head: usize = 0;
+ while (head < lhs.len and (std.ascii.isAlphanumeric(lhs[head]) or lhs[head] == '_')) head += 1;
+ if (head != 0 and moduleRoot(lhs[0..head]) != null) {
+ tr.note(" `{s}` IS a build.zig dependency, and this is the known hole: the module", .{lhs[0..head]});
+ tr.note(" map is consulted by our @import handler, not by ZLS, so the analyser cannot", .{});
+ tr.note(" type it. `gd` on the import string works; `gd` on a member does not.", .{});
+ return;
+ }
+ tr.note(" (a value whose type needs comptime, an unresolved @import, or genuinely absent)", .{});
+}
+
+/// 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(arena: std.mem.Allocator, th: Analyser.TokenWithHandle, out: *std.Io.Writer) !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(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(
+ arena: std.mem.Allocator,
+ analyser: *Analyser,
+ handle: *DocumentStore.Handle,
+ off: usize,
+ kind: GotoKind,
+ out: *std.Io.Writer,
+ tr: *Trace,
+) !void {
+ const tree = &handle.tree;
+ const ctx = try Analyser.getPositionContext(arena, tree, off, true);
+ tr.note("position context: .{s}", .{@tagName(ctx)});
+
+ var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty;
+ switch (ctx) {
+ .var_access, .test_doctest_name => {
+ const name_loc = offsets.identifierLocFromIndex(tree, off) orelse {
+ tr.note("STOP: no identifier under the cursor", .{});
+ return;
+ };
+ const name = offsets.locToSlice(tree.source, name_loc);
+ tr.note("identifier: `{s}` — a plain name, so: innermost scope outward", .{name});
+ if (std.mem.eql(u8, name, "_")) {
+ tr.note("STOP: `_` is the discard, it declares nothing", .{});
+ return;
+ }
+ if (std.zig.isPrimitive(name)) {
+ tr.note("STOP: `{s}` is a builtin primitive — it has no declaration in any file", .{name});
+ return;
+ }
+ const d = try analyser.lookupSymbolGlobal(handle, name, off) orelse {
+ tr.note("STOP: lookupSymbolGlobal found no `{s}` in scope here", .{name});
+ return;
+ };
+ try decls.append(arena, d);
+ },
+ .field_access => |loc| {
+ const name_loc = offsets.identifierLocFromIndex(tree, off) orelse {
+ tr.note("STOP: no identifier under the cursor", .{});
+ return;
+ };
+ const name = offsets.locToSlice(tree.source, name_loc);
+ const held = offsets.locMerge(loc, name_loc);
+ tr.note("field access: `{s}` on `{s}` — needs the TYPE of the left side", .{
+ name, offsets.locToSlice(tree.source, loc),
+ });
+ // NOTE both misses: a null result AND an empty one. ZLS returns an
+ // empty slice when it typed the left side but found no such member,
+ // and null when it could not type it at all — from the outside both
+ // are "gd did nothing", so both have to be explained.
+ const found = try analyser.getSymbolFieldAccesses(arena, handle, off, held, name) orelse {
+ tr.note("STOP: could not resolve the left side to a type at all", .{});
+ explainFieldMiss(tr, offsets.locToSlice(tree.source, loc));
+ return;
+ };
+ if (found.len == 0) {
+ tr.note("STOP: the left side resolved, but nothing named `{s}` was found on it", .{name});
+ explainFieldMiss(tr, offsets.locToSlice(tree.source, loc));
+ 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);
+ tr.note("import string: \"{s}\"", .{str});
+ const res = try analyser.store.uriFromImportStr(arena, handle, str);
+ switch (res) {
+ .none => {
+ // ZLS gave up, which for a DEPENDENCY name is not a real
+ // dead end — it just means it wanted the module graph and
+ // we would not start a compiler to get one. Our build.zig
+ // knows the graph and baked it in; consult that.
+ if (moduleRoot(str)) |path| {
+ tr.note("`{s}` is a build.zig dependency; resolved from the compiled-in module map", .{str});
+ lsp.row(out, path, 0, 0, str);
+ return;
+ }
+ tr.note("STOP: `{s}` does not resolve to a file. Relative paths, `std` and this", .{str});
+ tr.note(" build's own dependencies work; `builtin`, `root` and a dependency's", .{});
+ tr.note(" OWN internal module names need the build graph we do not run.", .{});
+ return;
+ },
+ .one => |u| lsp.row(out, u.toFsPath(arena) catch return, 0, 0, str),
+ .many => |us| for (us) |u| lsp.row(out, u.toFsPath(arena) catch continue, 0, 0, str),
+ }
+ return;
+ },
+ else => {
+ tr.note("STOP: nothing to resolve in a .{s} context — `gd` only answers on", .{@tagName(ctx)});
+ tr.note(" an identifier, a field access, a label, an enum literal or an @import string.", .{});
+ return;
+ },
+ }
+
+ tr.note("resolved to {d} declaration(s); asking each for its {s} token", .{ decls.items.len, @tagName(kind) });
+ // 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(arena, d.definitionToken(analyser, false) catch continue, out),
+ .definition => try rowForToken(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(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(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(
+ arena: std.mem.Allocator,
+ analyser: *Analyser,
+ handle: *DocumentStore.Handle,
+ off: usize,
+ out: *std.Io.Writer,
+) !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("{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.Io.Writer,
+) !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.Io.Writer,
+) !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(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.Io.Writer,
+) !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.Io.Writer,
+) !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(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(
+ arena: std.mem.Allocator,
+ analyser: *Analyser,
+ handle: *DocumentStore.Handle,
+ off: usize,
+ new_name: ?[]const u8,
+ out: *std.Io.Writer,
+) !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(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.Io.Writer,
+) !void {
+ const n = try treeDiagnostics(gpa, arena, path, tree, out);
+ if (n == 0) lsp.row(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.Io.Writer,
+) !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(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(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.Io.Writer,
+) !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(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.Io.Writer,
+) !void {
+ const tree = &handle.tree;
+ if (tree.errors.len != 0) {
+ out.print("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("{s}\n", .{a.title}) catch return;
+ }
+ if (builder.actions.items.len == 0) out.print("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(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void {
+ var tree: Ast = try .parse(arena, req.source, .zig);
+ defer tree.deinit(arena);
+ if (tree.errors.len != 0) {
+ lsp.row(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(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(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);
+}