diff options
Diffstat (limited to 'src/lsp')
| -rw-r--r-- | src/lsp/lsp.zig | 17 | ||||
| -rw-r--r-- | src/lsp/lsp_client.zig | 18 | ||||
| -rw-r--r-- | src/lsp/lsp_zls.zig | 1522 |
3 files changed, 22 insertions, 1535 deletions
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig index 7e5595fd..43f94f95 100644 --- a/src/lsp/lsp.zig +++ b/src/lsp/lsp.zig @@ -33,12 +33,14 @@ pub const Req = struct { root: []const u8 = "", }; -const backends = if (@import("pardes_config").zls_backend) - .{ @import("lsp_zls.zig"), @import("lsp_client.zig") } +/// Every language, Zig's (zls) included, through the protocol client, which +/// runs its server as a child process. None on the web or the board. +const backends = if (@import("pardes_config").platform == .web or @import("pardes_config").platform == .esp32p4) + .{} else - .{}; + .{@import("lsp_client.zig")}; -pub const backend_name = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc"; +pub const backend_name = "lsp-client"; pub const supports: std.EnumSet(Kind) = blk: { var s: std.EnumSet(Kind) = .initEmpty(); @@ -53,6 +55,7 @@ pub fn speaks(path: []const u8) bool { pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) !void { if (req.kind == .status) { + try out.print("backend: {s}\n", .{backend_name}); inline for (backends) |b| try b.query(gpa, arena, req, out); return; } @@ -77,7 +80,7 @@ pub fn none(out: *std.Io.Writer, comptime fmt: []const u8, args: anytype) std.Io // Called on server reader threads; the sink must copy text before returning. pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void { - if (@import("pardes_config").zls_backend) backends[1].setStatusSink(ctx, cb); + inline for (backends) |b| b.setStatusSink(ctx, cb); } pub fn rel(base: []const u8, path: []const u8) []const u8 { @@ -164,7 +167,7 @@ test "a question no backend answers for a file says why rather than find nothing defer arena.deinit(); var buf: [256]u8 = undefined; var out: std.Io.Writer = .fixed(&buf); - // The in-process ZLS answers no call hierarchy for a .zig file. - try query(std.testing.allocator, arena.allocator(), .{ .kind = .incoming_calls, .path = "/x/main.zig", .source = "", .offset = 0 }, &out); + // A file no server speaks for is answered, not left empty. + try query(std.testing.allocator, arena.allocator(), .{ .kind = .incoming_calls, .path = "/x/notes.txt", .source = "", .offset = 0 }, &out); try std.testing.expectStringStartsWith(out.buffered(), "@none "); } diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig index 7c1980f6..7704d0ad 100644 --- a/src/lsp/lsp_client.zig +++ b/src/lsp/lsp_client.zig @@ -1,9 +1,8 @@ //! A real Language Server Protocol client: child processes spoken to over //! JSON-RPC 2.0 with `Content-Length` framing. Nothing here knows any single //! language — `specs` is a table of (binary, languageId, extensions, root -//! markers), and rust-analyzer, clangd and gopls are rows in it. The in-process -//! ZLS backend keeps `.zig`; this file is every language pardes highlights but -//! could not answer questions about. +//! markers), and zls, rust-analyzer, clangd and gopls are rows in it: every +//! language pardes answers questions about. //! //! THE PROCESS LIFECYCLE IS THE DESIGN. `lsp.query` is a synchronous call on a //! worker thread, and a language server costs tens of milliseconds to start @@ -104,6 +103,14 @@ const Spec = struct { pub const specs = [_]Spec{ .{ + .name = "zls", + .bin = "zls", + .lang = "zig", + .exts = &.{".zig"}, + .markers = &.{ "build.zig", "build.zig.zon" }, + .env = "PARDES_LSP_ZIG", + }, + .{ .name = "rust-analyzer", .bin = "rust-analyzer", .lang = "rust", @@ -147,8 +154,7 @@ pub const specs = [_]Spec{ }, }; -/// Everything, including the four hierarchy kinds the in-process backend has -/// no analyser for. Whether one SERVER can answer is a capability question +/// Everything. Whether one SERVER can answer is a capability question /// answered per connection; a kind its server never advertised simply returns /// no rows, which the harness reports as CLAIMED-EMPTY per language — honest, /// since the claim here is about the protocol, not about every server. @@ -2175,9 +2181,9 @@ test "rootOf takes the top-most marker and falls back to git then dir" { test "specFor routes extensions and honours the disable env" { try std.testing.expect(specFor("/x/main.rs") != null); - try std.testing.expect(specFor("/x/main.zig") == null); try std.testing.expect(specFor("/x/README.md") == null); try std.testing.expectEqualStrings("rust-analyzer", specs[specFor("/x/main.rs").?].name); + if (getenv("PARDES_LSP_ZIG") == null) try std.testing.expectEqualStrings("zls", specs[specFor("/x/main.zig").?].name); } test "the transport this host actually gives us is a pair, and both ends are close-on-exec" { diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig deleted file mode 100644 index 2311ec47..00000000 --- a/src/lsp/lsp_zls.zig +++ /dev/null @@ -1,1522 +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; -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. - // pardes's own build's imports: listed for a file in its tree, none for - // any other project's file, which does not have them. - if (inProject(req.path)) { - 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 }); - } - } else try out.print("\ndependency imports gd can follow: none (this file is not in the tree pardes was built from)\n", .{}); - 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. -/// Whether `path` is in the tree pardes was built from, whose build's -/// imports `cfg.module_names` are. -fn inProject(path: []const u8) bool { - const project = std.mem.trimEnd(u8, cfg.project_root, "/"); - return project.len > 0 and path.len > project.len and std.mem.startsWith(u8, path, project) and path[project.len] == '/'; -} - -test "only a file in pardes's own tree is told pardes's dependency imports" { - var buf: [4096]u8 = undefined; - const inside = try std.fmt.bufPrint(&buf, "{s}/src/main.zig", .{std.mem.trimEnd(u8, cfg.project_root, "/")}); - try std.testing.expect(inProject(inside)); - try std.testing.expect(!inProject("/tmp/elsewhere/main.zig")); -} - -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()); - } -} |
