diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-29 00:13:08 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 841038666a45b40107ee26d1f8e54ed19d0891a0 (patch) | |
| tree | 3cc4fb3b562d3fa8422f9ffe754e87c75a555862 | |
| parent | f43c1e11b44e2464f0bb0b635e0abcaf3c717e23 (diff) | |
| download | pardes-841038666a45b40107ee26d1f8e54ed19d0891a0.tar.gz pardes-841038666a45b40107ee26d1f8e54ed19d0891a0.zip | |
lspbench: probe diagnostics and format against a BROKEN fixture
Plus docs/lsp-evaluation.md: the three backends measured head to head.
| -rw-r--r-- | docs/lsp-evaluation.md | 103 | ||||
| -rw-r--r-- | src/pardes.zig | 7 | ||||
| -rw-r--r-- | test/lspbench.zig | 13 | ||||
| -rw-r--r-- | test/lspfixture/broken.zig | 25 |
4 files changed, 142 insertions, 6 deletions
diff --git a/docs/lsp-evaluation.md b/docs/lsp-evaluation.md new file mode 100644 index 00000000..ae822213 --- /dev/null +++ b/docs/lsp-evaluation.md @@ -0,0 +1,103 @@ +# Three language backends, measured + +Same core, same seam (`src/lsp.zig`), same 17-probe harness (`zig build lspbench`) +over the same corpus. Three jj workspaces, three independent implementations, +one function each. + +| | **A · stdlib** | **B · client** | **C · in-process** | +|---|---|---|---| +| workspace | `pardes-ws-ast` | `pardes-ws-proto` | `pardes-ws-inproc` | +| what it is | `std.zig.Ast` + `AstGen`, hand-rolled scope walk | `zls` as a child process, JSON-RPC over a socketpair | ZLS linked as a Zig module, analyser called directly | +| **probes correct** | 12 / 17 | **17 / 17** | **17 / 17** | +| **false claims** | 0 | 0 | 0 | +| **implementation LOC** | 622 (+307 test) | 721 | **540** (+29 build) | +| new dependency | **none** | a `zls` binary at runtime | ZLS source (path dep) | +| binary size vs base | +6 MiB | +14 MiB | +35 MiB | +| memory | 3.7 MiB | 1.1 MiB **+ 37 MiB in the child** | 5.5 MiB | +| processes | **1** | 2 | **1** | + +### Latency (warm, µs unless noted) + +| operation | A · stdlib | B · client | C · in-process | +|---|---|---|---| +| goto, same file | **22** | 59 | 127 | +| goto, into `std` | 1 433 | **154** | 2 873 | +| goto, 5 000-line file | 2 699 | 182 | 4 181 | +| hover | **24** | 49 | 125 | +| document symbols | **28** | 196 | 89 | +| references | 6 624 | **65** | 168 | +| workspace symbols | 15 042 | **985** | 6 746 | +| diagnostics | 37 | **9** | 78 | +| **first query of the session** | 32 µs | **33 ms** | 4.4 ms | + +## What the numbers say + +**A is fastest at the thing you do most and slowest at everything that needs an +index.** A same-file `gd` in 22 µs is below the frame budget by three orders of +magnitude, and cold *is* warm because there is no index to build — but +references (6.6 ms) and workspace symbols (15 ms) are linear walks that will +grow with the tree. It answers 12 of 17 kinds and is honest about the other +five: field access, method calls and generics need a type resolver, and it is +not one. `gy`/`gi`/`=`/`SPC a`/`SPC r` stay inert rather than guessing. + +**B is the only one with a real index, and the only one that generalizes.** +References at 65 µs and workspace symbols at 985 µs are 100× and 15× the others +because zls maintains state across queries and pardes does not. It pays 33 ms +once per session for fork+exec+handshake, then 59–182 µs forever. Nothing in +`lsp_client.zig` knows Zig except a binary name and a `languageId` string — +pointing it at rust-analyzer or gopls is a table edit. Its costs are a second +process, 37 MiB of RSS that this harness does not charge it, and an external +binary that must exist and match the toolchain. + +**C is the smallest and the most complete.** 540 lines buy all 17 probes with no +subprocess, no JSON, no handshake and no version skew — the analyser is compiled +in. This is the "gut ZLS and call it directly" idea, and it works. Its latency is +middling and flat for the same reason A's is: it builds a `DocumentStore`, +answers, and throws it away, so there is no cache to be warm. A cross-query cache +is the obvious next step and is a real design (a global, a mutex, an invalidation +story), not a line of code. It costs +35 MiB of binary and couples pardes to a +ZLS checkout. + +## Verification notes + +Every number above was re-measured independently, not taken from the +implementers' reports. Three corrections came out of that: + +1. **The harness was wrong, and B caught it.** The diagnostics and format probes + originally pointed at clean, already-formatted source, where the correct + answer is nothing — indistinguishable from a backend that has no diagnostics. + That rewarded A and C for emitting a filler "no diagnostics" row and scored B + with 3 false claims for answering honestly. `test/lspfixture/broken.zig` now + gives those probes real work; B scores 17/17 on the corrected harness. +2. **A's unit tests never ran.** 23 tests compiled but were not collected — + `zig build unit-test` roots at `pardes.zig`, and Zig only collects tests from + files it actually analyzes, so the lazy `pub const lsp = @import(...)` reached + nothing. Once wired, 16 of 23 crashed on an invalid free in a test helper that + passed one arena as both the `gpa` and `arena` parameters. Fixed; 71/71 pass. +3. **C fuzzed itself and found two real crashes** (a decl's name token indexes + its own file, not the requesting one; and it is not necessarily an + `.identifier` on a half-typed line). Both fixed before reporting. + +All three keep the 56 existing snapshot scripts green, add a 57th driving the +real binary through the helix keymap, and pass `hxdiff` (360) and `hxparity` +(440) with no new waivers. + +## Recommendation + +**C (in-process), with B as the answer to a question pardes has not asked yet.** + +On the stated criteria — fewest lines, feature completeness, performance — C +wins: it is the smallest implementation, it answers everything, it needs no +second process, and its weakest number is a cache that does not exist rather +than a limit that cannot be lifted. + +The one thing that should override that: **pardes ships tree-sitter grammars for +25 languages.** C is Zig-only forever; B is the only implementation that will +ever answer `gd` in a Rust or Go buffer. If language intelligence is meant to +follow the syntax highlighting, B is the strategic choice and its 721 lines are +the cheapest multi-language client anyone will write. + +A is not the answer to "add LSP support", but it is a genuinely good answer to a +different question — 622 lines and zero dependencies for instant same-file +navigation and real semantic diagnostics. It is the one to keep if the ZLS +coupling in B and C ever becomes a problem. diff --git a/src/pardes.zig b/src/pardes.zig index 32de2f1b..682b0e3e 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -2935,8 +2935,11 @@ pub const Pardes = struct { const ln = std.mem.trimEnd(u8, rows, "\n"); var hi: usize = 0; while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1; - // push the jumplist the way helix does before navigating away - pane.pinCursor(); + // exactly what a `/` result row does when n steps onto it: look the + // `path:LINE:COL` token from the pane that asked, so placement, + // dedup-onto-an-open-pane and centering are the ONE look path. + // (helix would also push its jumplist here; pardes has none, so + // there is nothing to push — do not read this as one.) return p.actOnSelection(.right, w.pane, ln[0..hi], null); } diff --git a/test/lspbench.zig b/test/lspbench.zig index 73b82e2b..307bdc49 100644 --- a/test/lspbench.zig +++ b/test/lspbench.zig @@ -72,18 +72,23 @@ const anchors = [_]Anchor{ .{ .file = "src/modal.zig", .needle = "pub fn ", .at = 7, .kind = .document_symbols, .expect = "" }, // references to a symbol used in several places .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .references, .expect = "" }, - // diagnostics on a file that should have none - .{ .file = "src/lsp.zig", .needle = "const std", .at = 6, .kind = .diagnostics, .expect = "" }, + // Diagnostics and format probe a DELIBERATELY BROKEN fixture, never the + // real source. On a clean corpus the correct answer is nothing, which is + // byte-identical to "this backend has no diagnostics" — a blind spot that + // rewards emitting a filler row and punishes honesty. test/lspfixture has + // an unused local (semantic: needs a compiler front end, not a tokenizer) + // and a misformatted fn (needs a formatter), so both probes have real work. + .{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .diagnostics, .expect = "broken.zig" }, // the remaining kinds, probed once each so the matrix is complete .{ .file = "src/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .declaration, .expect = "" }, .{ .file = "src/lsp.zig", .needle = "out: *std.ArrayList(u8)", .at = 10, .kind = .type_definition, .expect = "" }, .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .implementation, .expect = "" }, .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .select_refs, .expect = "" }, - .{ .file = "src/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .format, .expect = "" }, + .{ .file = "test/lspfixture/broken.zig", .needle = "badly_spaced", .at = 0, .kind = .format, .expect = "" }, .{ .file = "src/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .code_action, .expect = "" }, .{ .file = "src/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .rename, .expect = "" }, .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .workspace_symbols, .expect = "" }, - .{ .file = "src/lsp.zig", .needle = "const std", .at = 6, .kind = .workspace_diagnostics, .expect = "" }, + .{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .workspace_diagnostics, .expect = "broken.zig" }, }; const Result = struct { diff --git a/test/lspfixture/broken.zig b/test/lspfixture/broken.zig new file mode 100644 index 00000000..22c86c9b --- /dev/null +++ b/test/lspfixture/broken.zig @@ -0,0 +1,25 @@ +//! A deliberately defective file, for the lspbench diagnostics and format +//! probes. It is NOT built and NOT imported by anything — `zig build` never +//! sees it, and the snapshot corpus never opens it. +//! +//! Why it exists: on a clean, already-formatted corpus the CORRECT answer to +//! "what is wrong with this file" is nothing, which the harness cannot tell +//! apart from a backend that has no diagnostics at all. That blind spot +//! rewards a backend for emitting a filler row and punishes one for being +//! honest, so the probes point here instead, where there is something real to +//! find: +//! +//! - `unused_local` is a SEMANTIC error. A tokenizer cannot see it; it takes +//! a compiler front end (AstGen/ZIR, or `zig ast-check`). +//! - the spacing in `badly_spaced` is a FORMATTING difference, which only a +//! formatter reports. +//! +//! Keep both. Removing either silently turns a probe back into a blind one. +pub fn hasUnusedLocal() u32 { + const unused_local = 41; + return 7; +} + +pub fn badly_spaced ( a : u32 , b : u32 ) u32 { + return a+b; +} |
