summaryrefslogtreecommitdiff
path: root/src/lsp_zls.zig
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_zls.zig
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_zls.zig')
-rw-r--r--src/lsp_zls.zig1073
1 files changed, 0 insertions, 1073 deletions
diff --git a/src/lsp_zls.zig b/src/lsp_zls.zig
deleted file mode 100644
index 6f5bbcdd..00000000
--- a/src/lsp_zls.zig
+++ /dev/null
@@ -1,1073 +0,0 @@
-//! 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);
-}