diff options
| -rw-r--r-- | build.zig | 34 | ||||
| -rw-r--r-- | build.zig.zon | 14 | ||||
| -rw-r--r-- | src/lsp.zig | 20 | ||||
| -rw-r--r-- | src/lsp_zls.zig | 745 | ||||
| -rw-r--r-- | test/snapshots/lsp.golden | 287 | ||||
| -rw-r--r-- | test/snapshots/lsp.snap | 53 |
6 files changed, 1145 insertions, 8 deletions
@@ -116,6 +116,13 @@ pub fn build(b: *std.Build) void { if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd) root_mod.linkSystemLibrary("util", .{}); + // The language backend (src/lsp_zls.zig) links ZLS as a MODULE — no zls + // binary, no JSON-RPC, no protocol. Everything except the browser gets it; + // emscripten does not, because the web shell has no threads and no-ops the + // lsp effect anyway, so paying to compile an analyser it can never call + // would be pure wasm. + const zls_backend = !is_emscripten; + const opts = b.addOptions(); opts.addOption(Platform, "platform", platform); opts.addOption(bool, "syntax_highlighting", tree_sitter_grammars != .disabled); @@ -123,9 +130,36 @@ pub fn build(b: *std.Build) void { opts.addOption(bool, "syntax_minimal_grammars", tree_sitter_grammars == .minimal or tree_sitter_grammars == .full); opts.addOption(bool, "syntax_full_grammars", tree_sitter_grammars == .full); opts.addOption(bool, "enable_tracy", tracy != null); + opts.addOption(bool, "zls_backend", zls_backend); + // The stdlib this binary was compiled against, so `gd` on `std.mem.count` + // can open the same mem.zig the compiler used. ZLS resolves `@import("std")` + // through `zig_lib_dir` and nothing else; without it every std symbol is a + // silent miss, and asking the `zig` binary for it is the subprocess this + // whole backend exists to avoid. ZIG_LIB_DIR overrides it at runtime. + opts.addOption([]const u8, "zig_lib_dir", b.graph.zig_lib_directory.path orelse ""); root_mod.addOptions("pardes_config", opts); hx_core_mod.addOptions("pardes_config", opts); + if (zls_backend) { + // .target/.optimize are mandatory: createZLSModule bakes them into the + // module it registers, so a mismatch here is a second compilation of + // the whole analyser rather than an error. + // `.target`/`.optimize` are MANDATORY: createZLSModule bakes them in, + // so a module built without them mismatches ours at link time. + // `version-string` is not cosmetic either — ZLS's build.zig shells out + // to `git describe` to name itself, and a package the build system + // fetched is an extracted tarball with no .git, so every single build + // printed a "Failed to run git describe" warning. We pin the commit in + // build.zig.zon, so we already know the answer. + const zls_dep = b.dependency("zls", .{ + .target = target, + .optimize = optimize, + .@"version-string" = @as([]const u8, "0.16.1-dev"), + }); + root_mod.addImport("zls", zls_dep.module("zls")); + hx_core_mod.addImport("zls", zls_dep.module("zls")); + } + // Tracy zones (src/tracy.zig): compile the client into the binary only when // -Dtracy=<path> names a Tracy checkout; otherwise every zone is a no-op. // Sampling/callstacks/system tracing stay off: tracy's symbol worker diff --git a/build.zig.zon b/build.zig.zon index 121542c3..b1c3cafe 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,6 +2,20 @@ .name = .pardes, .version = "0.0.1", .dependencies = .{ + // ZLS as a LIBRARY, not a language server: src/lsp_zls.zig imports the + // `zls` module its build.zig publishes and calls the analyser in + // process — no zls binary, no JSON-RPC, no handshake. + // + // Fetched like every other dependency, so it lands in zig-pkg/ and the + // build is reproducible from the .zon alone. Pinned to a COMMIT on the + // 0.16.x branch rather than a tag: master requires Zig 0.17-dev, and no + // tagged release both builds on 0.16 and exports these internals. + // 0.16.x pins `minimum_zig_version = "0.16.0"` exactly, which is why + // this needs none of the patches an older checkout did. + .zls = .{ + .url = "git+https://github.com/zigtools/zls#3e0d082084be43e36865136a138c1fe2023b33ca", + .hash = "zls-0.16.1-dev-rmm5ftfOJgAzIdWUGVB08ycFbud2O3viKnGF1HZgfKZJ", + }, // Pinned to the exact libvaxis commit ghostty is built against, so it // is compatible with ghostty's uucode configuration (the one we share // with it via `external_uucode` in build.zig). diff --git a/src/lsp.zig b/src/lsp.zig index 62b5e1bb..fe492c62 100644 --- a/src/lsp.zig +++ b/src/lsp.zig @@ -123,19 +123,23 @@ pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usi /// appends nothing, and the core treats "no rows" as "no result", which is /// also what a language server that is still starting up looks like. pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.ArrayList(u8)) void { - // ponytail: the base tree has no backend on purpose — this is the seam the - // competing implementations fill in, and an empty answer is a legal one. - _ = gpa; - _ = arena; - _ = req; - _ = out; + backend.query(gpa, arena, req, out); } +/// ZLS, linked in as a module and called directly — no subprocess, no +/// JSON-RPC. See `lsp_zls.zig`. The web shell has no threads, never emits the +/// effect, and cannot build ZLS anyway, so there it is the empty backend the +/// base tree shipped with. +const backend = if (@import("pardes_config").zls_backend) @import("lsp_zls.zig") else struct { + pub fn query(_: std.mem.Allocator, _: std.mem.Allocator, _: Req, _: *std.ArrayList(u8)) void {} + pub const supports: std.EnumSet(Kind) = .initEmpty(); +}; + /// What this backend can actually answer, for the evaluation harness and for /// the core (a Kind that is not supported never leaves the keymap). An /// implementation narrows this to what it really does — claiming a feature it /// does not have shows up immediately in the harness's matrix. -pub const supports: std.EnumSet(Kind) = .initEmpty(); +pub const supports: std.EnumSet(Kind) = backend.supports; /// Name shown by the harness and in `SPC ?`. Each implementation renames it. -pub const backend_name = "none"; +pub const backend_name = "zls-inproc"; diff --git a/src/lsp_zls.zig b/src/lsp_zls.zig new file mode 100644 index 00000000..99d20a10 --- /dev/null +++ b/src/lsp_zls.zig @@ -0,0 +1,745 @@ +//! 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 that +//! `@import("builtin")`, `@import("<pkg>")` and `@cImport` resolve to +//! nothing, silently, because those need a build graph we refuse to run. +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, +}); + +/// 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.ArrayList(u8)) void { + run(gpa, arena, req, out) catch {}; +} + +fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.ArrayList(u8)) !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. + switch (req.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")) return, + } + // Pure tree, no analyser. + if (req.kind == .format) return formatQuery(gpa, 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 }; + } else |_| {} + } + 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 return; + if (handle.tree.mode == .zon) return; + + var analyser: Analyser = .init(gpa, arena, &store, &ip, handle); + defer analyser.deinit(); + + const off: usize = @min(req.offset, req.source.len); + + switch (req.kind) { + .definition, .implementation => try goto(gpa, arena, &analyser, handle, off, .definition, out), + .declaration => try goto(gpa, arena, &analyser, handle, off, .declaration, out), + .type_definition => try goto(gpa, arena, &analyser, handle, off, .type_definition, out), + .hover => try hover(gpa, arena, &analyser, handle, off, out), + .document_symbols => try documentSymbols(gpa, arena, req.path, &handle.tree, out), + .references, .select_refs => try references(gpa, arena, &analyser, handle, off, null, out), + .rename => try references(gpa, 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 => {}, + } +} + +/// 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(gpa: std.mem.Allocator, arena: std.mem.Allocator, th: Analyser.TokenWithHandle, out: *std.ArrayList(u8)) !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(gpa, 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( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, + kind: GotoKind, + out: *std.ArrayList(u8), +) !void { + const tree = &handle.tree; + const ctx = try Analyser.getPositionContext(arena, tree, off, true); + + var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty; + switch (ctx) { + .var_access, .test_doctest_name => { + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; + const name = offsets.locToSlice(tree.source, name_loc); + if (std.mem.eql(u8, name, "_")) return; + if (std.zig.isPrimitive(name)) return; + const d = try analyser.lookupSymbolGlobal(handle, name, off) orelse return; + try decls.append(arena, d); + }, + .field_access => |loc| { + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; + const name = offsets.locToSlice(tree.source, name_loc); + const held = offsets.locMerge(loc, name_loc); + const found = try analyser.getSymbolFieldAccesses(arena, handle, off, held, name) orelse 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); + const res = try analyser.store.uriFromImportStr(arena, handle, str); + switch (res) { + .none => return, + .one => |u| lsp.row(gpa, out, u.toFsPath(arena) catch return, 0, 0, str), + .many => |us| for (us) |u| lsp.row(gpa, out, u.toFsPath(arena) catch continue, 0, 0, str), + } + return; + }, + else => return, + } + + // 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(gpa, arena, d.definitionToken(analyser, false) catch continue, out), + .definition => try rowForToken(gpa, 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(gpa, 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(gpa, 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( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, + out: *std.ArrayList(u8), +) !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(gpa, "{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.ArrayList(u8), +) !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.ArrayList(u8), +) !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(gpa, 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.ArrayList(u8), +) !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.ArrayList(u8), +) !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(gpa, 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( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, + new_name: ?[]const u8, + out: *std.ArrayList(u8), +) !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(gpa, 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.ArrayList(u8), +) !void { + const n = try treeDiagnostics(gpa, arena, path, tree, out); + if (n == 0) lsp.row(gpa, 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.ArrayList(u8), +) !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(gpa, 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(gpa, 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.ArrayList(u8), +) !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(gpa, 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.ArrayList(u8), +) !void { + const tree = &handle.tree; + if (tree.errors.len != 0) { + out.print(gpa, "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(gpa, "{s}\n", .{a.title}) catch return; + } + if (builder.actions.items.len == 0) out.print(gpa, "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(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.ArrayList(u8)) !void { + var tree: Ast = try .parse(arena, req.source, .zig); + defer tree.deinit(arena); + if (tree.errors.len != 0) { + lsp.row(gpa, 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(gpa, 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(gpa, 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); +} diff --git a/test/snapshots/lsp.golden b/test/snapshots/lsp.golden new file mode 100644 index 00000000..c85a412b --- /dev/null +++ b/test/snapshots/lsp.golden @@ -0,0 +1,287 @@ +== snap opened grid=120x40 cursor=7,2 +|Kill Newcol Tutor Debug NextColor Dump Find Grep +| NOR /tmp/pardes-snap/lsp/cwd/a.zig Save Del NOR /tmp/pardes-snap/lsp/cwd Del +| 1 const std = @import("std"); +| 2 a.zig +| 3 fn helper(x: u32) u32 { +| 4 return x + 1; +| 5 } +| 6 +| 7 pub fn main() void { +| 8 _ = helper(41); +| 9 } +| 10 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap cursor grid=120x40 cursor=17,9 +|Kill Newcol Tutor Debug NextColor Dump Find Grep +| NOR /tmp/pardes-snap/lsp/cwd/a.zig Save Del NOR /tmp/pardes-snap/lsp/cwd Del +| 1 const std = @import("std"); +| 2 a.zig +| 3 fn helper(x: u32) u32 { +| 4 return x + 1; +| 5 } +| 6 +| 7 pub fn main() void { +| 8 _ = helper(41); +| 9 } +| 10 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap definition grid=120x40 cursor=10,4 +|Kill Newcol Tutor Debug NextColor Dump Find Grep +| NOR /tmp/pardes-snap/lsp/cwd/a.zig Save Del NOR /tmp/pardes-snap/lsp/cwd Del +| 1 const std = @import("std"); +| 2 a.zig +| 3 fn helper(x: u32) u32 { +| 4 return x + 1; +| 5 } +| 6 +| 7 pub fn main() void { +| 8 _ = helper(41); +| 9 } +| 10 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap hover grid=120x40 cursor=10,4 +|Kill Newcol Tutor Debug NextColor Dump Find Grep +| NOR /tmp/pardes-snap/lsp/cwd/a.zig Save Del NOR /tmp/pardes-snap/lsp/cwd Del +| 1 const std = @import("std"); +| 2 a.zig +| 3 fn helper(x: u32) u32 { +| 4 return x + 1; +| 5 } +| 6 +| 7 pub fn main() void { +| 8 _ = helper(41); +| 9 } +| 10 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/lsp/cwd/+Hover Del +| 1 fn helper(x: u32) u32 +| 2 (fn (u32) u32) +== snap symbols grid=120x40 cursor=10,4 +|Kill Newcol Tutor Debug NextColor Dump Find Grep +| NOR /tmp/pardes-snap/lsp/cwd/a.zig Save Del NOR /tmp/pardes-snap/lsp/cwd Del +| 1 const std = @import("std"); +| 2 a.zig +| 3 fn helper(x: u32) u32 { +| 4 return x + 1; +| 5 } +| 6 +| 7 pub fn main() void { +| 8 _ = helper(41); +| 9 } +| 10 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/lsp/cwd/+Search Del +| 1 /tmp/pardes-snap/lsp/cwd/a.zig:1:7 std +| 2 /tmp/pardes-snap/lsp/cwd/a.zig:3:4 helper fn helper( +| 3 /tmp/pardes-snap/lsp/cwd/a.zig:7:8 main fn main() vo +| NOR /tmp/pardes-snap/lsp/cwd/+Hover Del +| 1 fn helper(x: u32) u32 +| 2 (fn (u32) u32) +== snap step grid=120x40 cursor=13,2 +|Kill Newcol Tutor Debug NextColor Dump Find Grep +| NOR /tmp/pardes-snap/lsp/cwd/a.zig Save Del NOR /tmp/pardes-snap/lsp/cwd Del +| 1 const std = @import("std"); +| 2 a.zig +| 3 fn helper(x: u32) u32 { +| 4 return x + 1; +| 5 } +| 6 +| 7 pub fn main() void { +| 8 _ = helper(41); +| 9 } +| 10 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/lsp/cwd/+Search Del +| 1 /tmp/pardes-snap/lsp/cwd/a.zig:1:7 std +| 2 /tmp/pardes-snap/lsp/cwd/a.zig:3:4 helper fn helper( +| 3 /tmp/pardes-snap/lsp/cwd/a.zig:7:8 main fn main() vo +| NOR /tmp/pardes-snap/lsp/cwd/+Hover Del +| 1 fn helper(x: u32) u32 +| 2 (fn (u32) u32) +== snap nothing grid=120x40 cursor=7,4 +|Kill Newcol Tutor Debug NextColor Dump Find Grep +| NOR /tmp/pardes-snap/lsp/cwd/a.zig Save Del NOR /tmp/pardes-snap/lsp/cwd Del +| 1 const std = @import("std"); +| 2 a.zig +| 3 fn helper(x: u32) u32 { +| 4 return x + 1; +| 5 } +| 6 +| 7 pub fn main() void { +| 8 _ = helper(41); +| 9 } +| 10 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/lsp/cwd/+Search Del +| 1 /tmp/pardes-snap/lsp/cwd/a.zig:1:7 std +| 2 /tmp/pardes-snap/lsp/cwd/a.zig:3:4 helper fn helper( +| 3 /tmp/pardes-snap/lsp/cwd/a.zig:7:8 main fn main() vo +| NOR /tmp/pardes-snap/lsp/cwd/+Hover Del +| 1 fn helper(x: u32) u32 +| 2 (fn (u32) u32) diff --git a/test/snapshots/lsp.snap b/test/snapshots/lsp.snap new file mode 100644 index 00000000..9ea84e1f --- /dev/null +++ b/test/snapshots/lsp.snap @@ -0,0 +1,53 @@ +# Language intelligence end to end: the helix keymap, the async round trip and +# the two dispositions of an answer. +# +# The backend behind the seam here is ZLS linked in as a LIBRARY +# (src/lsp_zls.zig) — no server process, no JSON-RPC — so `gd` is a function +# call on a worker thread whose result comes back as an ordinary event, the +# same shape a pty read already had. Nothing in this script knows that: it +# presses keys and reads the grid, which is the point of the seam. +# +# a.zig is deliberately self-contained — a definition, a call site, and not one +# symbol from std — so these goldens do not encode this machine's stdlib +# version or its install path. +file a.zig const std = @import("std");\n\nfn helper(x: u32) u32 {\n return x + 1;\n}\n\npub fn main() void {\n _ = helper(41);\n}\n +start 40 120 a.zig +wait 8000 helper +stable 700 20000 +snap opened +# park the cursor inside the CALL `helper(41)` (file line 8, grid row 9) +press left 18 10 +release left 18 10 +stable 400 5000 +snap cursor +# gd on a call: exactly one location comes back, so it JUMPS rather than +# opening a picker — the cursor lands on the definition's name token, line 3. +# The jumplist is pinned first, so ctrl-o comes back. +key g +key d +stable 700 15000 +snap definition +# SPC k is hover (K is keep_selections in helix and stays that way). The answer +# is prose, not locations, so it opens +Hover and does NOT arm the n/N stepper. +key space +key k +stable 700 15000 +snap hover +# SPC s lists the file's own symbols. Several rows, so it opens a +Search +# buffer — which IS the picker: n steps a row and looks it, exactly as the Find +# and `/` results already do, with no picker code of its own. +key space +key s +stable 700 15000 +snap symbols +key n +stable 700 15000 +snap step +# gd on something with no declaration behind it (a keyword) answers nothing, +# and nothing is a legal answer: no buffer opens, no error is rendered. +press left 8 5 +release left 8 5 +key g +key d +stable 700 10000 +snap nothing |
