//! 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; const max_output_bytes = max_rows * 4096; const max_trace_bytes = 32 * 1024; const max_code_action_bytes = max_rows * 1024; 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, .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 // 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_NOTIME so the two introspection views print `-` /// instead, the same way it pins PARDES_DUMP for the dump tests. ONE flag, /// meaning "no wall clock in user-visible text": message.zig reads the same one /// to blank the transient message row's `HH:MM:SS`. Unset — i.e. every real run /// — shows real numbers. fn hideTime() bool { const v = std.c.getenv("PARDES_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, buf: [max_trace_bytes]u8 = undefined, len: usize = 0, fn note(t: *Trace, comptime fmt: []const u8, args: anytype) void { if (!t.on or t.len == t.buf.len) return; var w: std.Io.Writer = .fixed(t.buf[t.len..]); w.print(fmt ++ "\n", args) catch {}; t.len += w.buffered().len; } fn written(t: *const Trace) []const u8 { return t.buf[0..t.len]; } }; 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); const scratch_buf = try gpa.alloc(u8, max_output_bytes); defer gpa.free(scratch_buf); var scratch: std.Io.Writer = .fixed(scratch_buf); var tr: Trace = .{ .on = req.kind == .explain }; const t0 = nowUs(); var err_name: []const u8 = ""; var failure: ?anyerror = null; run(gpa, arena, req, &scratch, &tr) catch |e| { failure = e; err_name = @errorName(e); tr.note("ERROR: {s}", .{err_name}); }; const us = nowUs() -| t0; const rows = std.mem.count(u8, scratch.buffered(), "\n"); record(req, us, rows, err_name); if (req.kind != .explain) { if (failure) |err| return err; try out.writeAll(scratch.buffered()); return; } try 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), }); try out.writeAll(tr.written()); if (hideTime()) try out.print("\n{d} row(s)\n", .{rows}) else try out.print("\n{d} row(s) in {d}us\n", .{ rows, us }); } 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 (!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, }); 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 — 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, 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; }; 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); // the rows of the kinds that only ever name THIS file spell it once const here = lsp.rel(req.root, req.path); switch (kind) { .definition, .implementation => try goto(arena, &analyser, handle, off, .definition, req.root, out, tr), .declaration => try goto(arena, &analyser, handle, off, .declaration, req.root, out, tr), .type_definition => try goto(arena, &analyser, handle, off, .type_definition, req.root, out, tr), .hover => try hover(arena, &analyser, handle, off, out), .document_symbols => try documentSymbols(gpa, arena, here, &handle.tree, out), .references, .select_refs => try references(arena, &analyser, handle, off, null, req.root, out), .rename => try references(arena, &analyser, handle, off, req.arg, req.root, out), .diagnostics => try diagnostics(gpa, arena, here, &handle.tree, out), .code_action => try codeActions(gpa, arena, &analyser, handle, off, out), .completion => try completion(arena, &analyser, handle, off, req.root, req.source, out, tr), 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 { const count = std.mem.countScalar(u8, src, '\n') + 1; const starts = try arena.alloc(u32, count); starts[0] = 0; var n: usize = 1; for (src, 0..) |c, i| { if (c != '\n') continue; starts[n] = @intCast(i + 1); n += 1; } return .{ .starts = starts, .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`. `base` is `req.root`, the directory the /// row is written relative to (lsp.rel). fn rowForToken(arena: std.mem.Allocator, base: []const u8, 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 = lsp.rel(base, th.handle.uri.toFsPath(arena) catch return); try lsp.sourceSpanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.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, /// req.root: what the rows' paths are written relative to (lsp.rel) base: []const u8, 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)}); const decls = try arena.alloc(Analyser.DeclWithHandle, max_rows); var decl_count: usize = 0; 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; }; decls[decl_count] = d; decl_count += 1; }, .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; } decl_count = @min(found.len, decls.len); @memcpy(decls[0..decl_count], found[0..decl_count]); }, .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; decls[decl_count] = d; decl_count += 1; }, .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; decls[decl_count] = d; decl_count += 1; }, // `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}); try lsp.row(out, lsp.rel(base, 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| try lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch return), 0, 0, str), .many => |us| for (us) |u| try lsp.row(out, lsp.rel(base, 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", .{ decl_count, @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[0..decl_count]) |d| { switch (kind) { .declaration => try rowForToken(arena, base, d.definitionToken(analyser, false) catch continue, out), .definition => try rowForToken(arena, base, 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, base, 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 = lsp.rel(base, nd.handle.uri.toFsPath(arena) catch continue); try lsp.sourceSpanRow(out, path, rr.start.line, rr.start.character, rr.end.line, rr.end.character, lineAt(t2.source, rr.start.line)); }, } } } // ------------------------------------------------------------ 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`). /// /// Choosing between them: reachability (is the dot now an `enum_literal` the /// tree can be walked down to from the root) is a hard FILTER, the parse-error /// count RANKS what survives it, the earlier spelling wins a tie, and a /// candidate at ZERO errors ends the search. 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; } /// ZLS's collector takes a growable list. This equivalent writes directly into /// the query's row-bounded slice and stops in declaration order when it fills. fn collectContainerDecls( analyser: *Analyser, container_type: Analyser.Type, original_handle: *DocumentStore.Handle, instance_access: bool, out: []Analyser.DeclWithHandle, len: *usize, ) !void { const info = switch (container_type.data) { .container => |info| info, .either => |entries| { for (entries) |entry| { if (len.* == out.len) return; const ty: Analyser.Type = .{ .data = entry.type_data, .is_type_val = container_type.is_type_val, }; try collectContainerDecls(analyser, ty, original_handle, instance_access, out, len); } return; }, else => return, }; const container_scope = info.scope_handle; const handle = container_scope.handle; const tree = &handle.tree; const document_scope = try handle.getDocumentScope(); const main_token = tree.nodeMainToken(container_scope.toNode()); const is_enum = tree.tokenTag(main_token) == .keyword_enum; for (document_scope.getScopeDeclarationsConst(container_scope.scope)) |decl_index| { if (len.* == out.len) return; const decl = document_scope.declarations.get(@intFromEnum(decl_index)); const candidate: Analyser.DeclWithHandle = .{ .decl = decl, .handle = handle, .container_type = container_type, }; if (handle != original_handle and !candidate.isPublic()) continue; switch (decl) { .ast_node => |node| switch (tree.nodeTag(node)) { .container_field_init, .container_field_align, .container_field => { if (is_enum) { if (instance_access) continue; const field_name = offsets.tokenToSlice(tree, tree.nodeMainToken(node)); if (std.mem.eql(u8, field_name, "_")) continue; } else if (!instance_access) continue; }, .fn_proto, .fn_proto_multi, .fn_proto_one, .fn_proto_simple, .fn_decl, .global_var_decl, .local_var_decl, .simple_var_decl, .aligned_var_decl, => { if (instance_access) { const alias_type = try candidate.resolveType(analyser) orelse continue; const func_ty = try analyser.resolveFuncProtoOfCallable(alias_type) orelse continue; if (!analyser.firstParamIs(func_ty, .{ .data = .{ .container = info }, .is_type_val = true, })) continue; } }, else => unreachable, }, .label => continue, else => {}, } out[len.*] = candidate; len.* += 1; } } /// 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, /// req.root: what the rows' paths are written relative to (lsp.rel) base: []const u8, /// 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 the collector'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; const decls = try arena.alloc(Analyser.DeclWithHandle, max_rows); var decl_count: usize = 0; try collectContainerDecls(analyser, ty, handle, switch (ctx) { .field_access => !ty.is_type_val, else => in_init or ty.isUnionType(), }, decls, &decl_count); tr.note("{d} candidate(s) in scope", .{decl_count}); // 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[0..decl_count]) |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; } // The WORD, off the decl's own name token. Not the definition token // below it: `definitionToken` resolves an ALIAS through to what it // names, so for `pub const base64 = @import("base64.zig")` it lands on // base64.zig's first token and the "name" would come out as that file's // doc comment. What goes after the dot is what the container calls it. const name_tok = d.nameToken(); if (name_tok >= d.handle.tree.tokens.len) continue; const name = offsets.tokenToSlice(&d.handle.tree, name_tok); 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 = lsp.rel(base, th.handle.uri.toFsPath(arena) catch continue); // The name, then its declaration line: "what goes here" before "where // does it come from", which is the order the question was asked in — // every other kind here answers a WHERE, this one answers a WHAT and // the location is the evidence. Two spaces rather than a padded column, // because the `path:LINE:COL-END` in front of it is already ragged and // there is nothing to align to. const text = try std.fmt.allocPrint(arena, "{s} {s}", .{ name, std.mem.trim(u8, lineAt(if (th.handle == handle) orig else dtree.source, r.start.line), " \t\r\n"), }); if (th.handle == handle and 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); } try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, text); n += 1; } } // ---------------------------------------------------------------- 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; try out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}); } } // ---------------------------------------------------------------- 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 — `Pardes.update` rather than a bare `update`, /// 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; try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.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; const files = try collectZigFiles(arena, io, req.root); var n: usize = 0; for (files) |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, lsp.rel(req.root, 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; try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.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 EDIT: the same resolved tokens become /// half-open byte ranges. The core owns the replacement text and applies every /// range in one undo transaction after checking the source revision. fn references( arena: std.mem.Allocator, analyser: *Analyser, handle: *DocumentStore.Handle, off: usize, new_name: ?[]const u8, /// req.root: what the rows' paths are written relative to (lsp.rel) base: []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; // Rename consumes exact byte ranges. Reference rows need the source line // and displayed path; avoid building either for the mutating response. const lines: ?Lines = if (new_name == null) try .build(arena, tree.source) else null; const path = if (new_name == null) lsp.rel(base, handle.uri.toFsPath(arena) catch return) else ""; var n: usize = 0; for (0..tree.tokens.len) |i| { if (new_name == null and 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 |err| { if (new_name != null) return err; continue; }) orelse continue; if (!d.eql(target)) continue; if (n >= max_rows) return error.TooManyEdits; n += 1; if (new_name != null) { try lsp.edit(out, at, at + want.len); } else { const r = offsets.tokenToRange(tree, tok, enc); try lsp.sourceSpanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lines.?.line(r.start.line)); } } } /// 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) try 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 count_buf: [256]u8 = undefined; var counting: std.Io.Writer.Discarding = .init(&count_buf); tree.renderError(e, &counting.writer) catch continue; const size = std.math.cast(usize, counting.fullCount()) orelse continue; const rendered = try arena.alloc(u8, size); var w: std.Io.Writer = .fixed(rendered); tree.renderError(e, &w) catch continue; const at = tree.tokenStart(e.token); const lc = lsp.lineCol(tree.source, at); n += 1; try lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{ w.buffered(), 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; try 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 { const files = try collectZigFiles(arena, io, req.root); var total: usize = 0; for (files) |path| { const src = readFileZ(arena, io, path) catch continue; var tree: Ast = Ast.parse(arena, src, .zig) catch continue; defer tree.deinit(arena); total += try treeDiagnostics(gpa, arena, lsp.rel(req.root, path), &tree, out); if (total >= max_rows) return; } if (total == 0) try lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.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) { try out.print("no code actions: file does not parse\n", .{}); return; } var bundle = try astCheck(gpa, "", tree); defer bundle.deinit(gpa); // The external builder owns growable collections internally. Confine all // of them and their payloads to one preallocated query-local region. const action_storage = try arena.alloc(u8, max_code_action_bytes); var action_fba: std.heap.FixedBufferAllocator = .init(action_storage); var builder: zls.code_actions.Builder = .{ .arena = action_fba.allocator(), .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[0..@min(builder.actions.items.len, max_rows)]) |a| { try out.print("{s}\n", .{a.title}); } if (builder.actions.items.len == 0) try out.print("no code actions\n", .{}); } // ---------------------------------------------------------------- format /// `=`. The response is `@put` edit records (see lsp.put): one span covering /// everything `zig fmt` would change, which the core substitutes as a single /// undo step — so `=` FORMATS, exactly like helix. The two answers that are /// not edits stay prose rows in `+Lsp`: a file that does not parse cannot be /// formatted, and a file already formatted has nothing to apply. fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { const path = lsp.rel(req.root, req.path); var tree: Ast = try .parse(arena, req.source, .zig); defer tree.deinit(arena); if (tree.errors.len != 0) { try lsp.row(out, path, 0, 0, "cannot format: file does not parse"); return; } var count_buf: [4096]u8 = undefined; var counting: std.Io.Writer.Discarding = .init(&count_buf); try tree.render(arena, &counting.writer, .{}); const size = std.math.cast(usize, counting.fullCount()) orelse return; const render_buf = try arena.alloc(u8, size); var w: std.Io.Writer = .fixed(render_buf); try tree.render(arena, &w, .{}); const formatted = w.buffered(); if (std.mem.eql(u8, formatted, req.source)) { try lsp.row(out, path, 0, 0, "already formatted"); return; } // one record, spanning only what changed: the common prefix and suffix // stay untouched, which is also what keeps the cursor mapping tight var lo: usize = 0; const min_len = @min(req.source.len, formatted.len); while (lo < min_len and req.source[lo] == formatted[lo]) lo += 1; var src_hi = req.source.len; var fmt_hi = formatted.len; while (src_hi > lo and fmt_hi > lo and req.source[src_hi - 1] == formatted[fmt_hi - 1]) { src_hi -= 1; fmt_hi -= 1; } try lsp.put(out, lo, src_hi, formatted[lo..fmt_hi]); } // ------------------------------------------------------------------ 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) ![]const []const u8 { const files = try arena.alloc([]const u8, max_files); var len: usize = 0; if (root.len == 0) return files[0..0]; var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch return files[0..0]; defer dir.close(io); var w = dir.walkSelectively(arena) catch return files[0..0]; defer w.deinit(); while (len < files.len) { 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; files[len] = std.fs.path.join(arena, &.{ root, e.path }) catch continue; len += 1; }, else => {}, } } return files[0..len]; } 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); } test "LSP ZLS format propagates output failure and preserves successful encoding" { const gpa = std.testing.allocator; const req: lsp.Req = .{ .kind = .format, .path = "/file.zig", .source = "const value=1;\n", .offset = 6 }; const expected = "@put 11 12 %20=%20\n"; var buffer: [128]u8 = undefined; for ([_]usize{ 0, expected.len - 1, expected.len }) |capacity| { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); var out: std.Io.Writer = .fixed(buffer[0..capacity]); const result = query(gpa, arena.allocator(), req, &out); if (capacity < expected.len) { try std.testing.expectError(error.WriteFailed, result); } else { try result; try std.testing.expectEqualStrings(expected, out.buffered()); } } } test "LSP ZLS rename refuses a partial edit set beyond its row budget" { const gpa = std.testing.allocator; for ([_]usize{ max_rows - 1, max_rows }) |references_count| { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); var source: std.Io.Writer.Allocating = .init(gpa); defer source.deinit(); try source.writer.writeAll("const value: u32 = 1;\nfn use() void {\n"); for (0..references_count) |_| try source.writer.writeAll(" _ = value;\n"); try source.writer.writeAll("}\n"); const text = try gpa.dupeZ(u8, source.written()); defer gpa.free(text); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); const result = query(gpa, arena.allocator(), .{ .kind = .rename, .path = "/rename.zig", .source = text, .offset = 6, .arg = "renamed", }, &out.writer); if (references_count < max_rows) { try result; try std.testing.expectEqual(max_rows, std.mem.count(u8, out.written(), "@edit ")); } else { try std.testing.expectError(error.TooManyEdits, result); try std.testing.expectEqual(@as(usize, 0), out.written().len); } } } test "LSP source snippets retain native definition and reference indentation" { const source = "const Outer = struct {\n" ++ "\t const value: u32 = 1;\n" ++ "\t fn use() void {\n" ++ "\t\t_ = value;\n" ++ "\t }\n" ++ "};\n"; const gpa = std.testing.allocator; for ([_]lsp.Kind{ .definition, .declaration, .references }) |kind| { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try query(gpa, arena.allocator(), .{ .kind = kind, .path = "/source-snippet.zig", .root = "/", .source = source, .offset = @intCast(std.mem.lastIndexOf(u8, source, "value").?), }, &out.writer); const definition = "source-snippet.zig:2:10-14 \t const value: u32 = 1;\n"; try std.testing.expectEqualStrings(if (kind == .references) definition ++ "source-snippet.zig:4:7-11 \t\t_ = value;\n" else definition, out.written()); } }