diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-29 00:50:38 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 4e642c1d6688baf3b98f269818b66cc7cc194c5f (patch) | |
| tree | 1ccbfdcc47fd8d6100ab3bd9ed4578b41f378f34 /build.zig | |
| parent | 841038666a45b40107ee26d1f8e54ed19d0891a0 (diff) | |
| download | pardes-4e642c1d6688baf3b98f269818b66cc7cc194c5f.tar.gz pardes-4e642c1d6688baf3b98f269818b66cc7cc194c5f.zip | |
lsp backend: ZLS as an in-process library
The seam's `query` now calls ZLS's analyser directly, on the worker thread,
in this process. There is no zls binary, no subprocess, no JSON-RPC, no
`initialize` handshake and no `Server` — `gd` is a function call whose answer
comes back as rows. ZLS's build.zig already publishes its guts as an
importable module (`b.modules.put("zls", ...)`), so this is a path dependency
on the local 0.16.x checkout plus one new file, `src/lsp_zls.zig`.
Construction is ZLS's own (tests/analysis_check.zig does exactly this):
InternPool.init -> DiagnosticsCollection -> DocumentStore struct literal ->
Analyser.init. `zig_exe_path` is null on purpose — shelling out to the
compiler is the thing this backend exists to avoid — and `zig_lib_dir` is
baked in from `b.graph.zig_lib_directory`, so `gd` on `std.mem.count` opens
the same mem.zig the compiler used, with ZIG_LIB_DIR overriding at runtime.
Offsets are `.@"utf-8"`, not ZLS's utf-16 default: `+Search` rows are byte
columns and we are not on a wire.
Seventeen probes, seventeen answering, no false claims, 5.9 MiB peak RSS.
The features that were already Server-free are calls (hover, document
symbols, code actions); the ones welded to `*Server` are reimplemented thin
on top of public primitives — goto is gotoHandler minus the protocol,
diagnostics is the in-process `std.zig.AstGen` branch of
getAstCheckDiagnostics, references is symbolReferences' algorithm from the
outside (offer every same-named identifier token back to the analyser and
keep the ones that resolve to the same decl, so a shadowed local is not a
false hit).
What it does not do, deliberately:
- Nothing is cached between queries. Each `query` builds a DocumentStore,
resolves imports and throws it away, because the arena dies on return and
`req.source` is a snapshot of a buffer the user is still typing into. So
cold IS warm — there is no index to warm up. It is also fast enough not to
need one: 124us for a local goto, 2.8ms into the stdlib, 8ms for
references over a 5000-line file. A cross-query cache is a real design
(a global, a mutex, an invalidation story), not a line of code, and it is
the obvious next step rather than something smuggled in here.
- References, rename and select-refs are THIS FILE only. Workspace-wide
means loading every project file into the store and running the analyser
over each; DocumentStore's own workspace iteration has the same limit
(it can only see handles already loaded). Workspace symbols and workspace
diagnostics DO walk the tree, because neither needs the analyser — a
parse and a tree walk each.
- Rename previews, format reports, code actions list. The seam hands back
rows, not edits, so there is no channel through which a backend could
rewrite the buffer. These answer the question the keypress asks and change
nothing.
- Without a zig binary, `@import("builtin")`, `@import("<pkg>")` and
`@cImport` resolve to nothing — silently, which is ZLS's behaviour, not a
bug introduced here. Relative imports and `std` work.
- Non-.zig files answer nothing. The core does not gate the keymap by file
type, 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.
A whole-file report that ran and found nothing says so ("no diagnostics",
"already formatted", "no code actions") rather than returning zero rows,
because in this seam zero rows already means "no backend" — `lspResponse`
opens nothing for an empty answer, so silence cannot also mean "checked,
clean". Location queries keep the opposite rule: unresolvable is no rows.
test/snapshots/lsp.snap covers the round trip end to end — gd jumping on a
single result, SPC k opening +Hover, SPC s opening the +Search list that n
steps, and gd on a keyword answering nothing without opening anything. The
whole backend was also fuzzed at 20k queries over real, truncated and
byte-smashed sources across every kind; that found two crashes (a decl's
name token indexes its own file, not the requesting one, and is not
necessarily an identifier at all on a half-typed line) which are fixed.
emscripten does not get the backend: the web shell has no threads and
no-ops the lsp effect, so it keeps the empty one the base tree shipped.
DEPENDENCY: ZLS is FETCHED by the build system (build.zig.zon .url + .hash,
pinned to commit 3e0d0820 on the 0.16.x branch) rather than a path
dependency on the local genizah checkout, so it lands in zig-pkg/ like
every other dependency and the build is reproducible from the .zon alone.
Also passes -Dversion-string: ZLS's build.zig names itself by shelling out
to `git describe`, and a fetched package is an extracted tarball with no
.git, so every build printed a 'Failed to run git describe' warning. We
pin the commit, so we already know the answer.
Diffstat (limited to 'build.zig')
| -rw-r--r-- | build.zig | 34 |
1 files changed, 34 insertions, 0 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 |
