diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-30 23:35:45 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 19f7322100062b7c1adcde3376063ce6c1d8c72d (patch) | |
| tree | b76347dffe0347c29a38b4cad6031678683dba74 /src/lsp_zls.zig | |
| parent | 753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff) | |
| download | pardes-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.zig | 1073 |
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); -} |
