diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /test/lspbench.zig | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip | |
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples.
Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'test/lspbench.zig')
| -rw-r--r-- | test/lspbench.zig | 266 |
1 files changed, 130 insertions, 136 deletions
diff --git a/test/lspbench.zig b/test/lspbench.zig index 964d3c8c..59cac155 100644 --- a/test/lspbench.zig +++ b/test/lspbench.zig @@ -1,33 +1,9 @@ -//! The language-backend scoreboard: one binary, three numbers. -//! -//! zig build lspbench -- latency + feature matrix over pardes's own src/ -//! zig build lspbench -- --json -- the same, machine-readable -//! -//! Every competing backend links the same harness against the same corpus and -//! the same query list, so the columns mean the same thing across all of them. -//! What it measures, in the order the evaluation weighs it: -//! -//! FEATURES which lsp.Kind values actually return rows. Claiming support in -//! `lsp.supports` and returning nothing is a FALSE claim and shows -//! up as `claimed-empty` — the harness trusts results, not flags. -//! LATENCY cold (first query, index build included) and warm (median of N) -//! per kind. Cold is what a keypress costs the first time; warm is -//! what it costs forever after. Both matter and they differ by -//! orders of magnitude for an indexing backend. -//! MEMORY peak RSS delta across the run, read from /proc. -//! -//! Lines of code is not measured here — it is `jj diff --stat` against the -//! base commit, which is the honest number (a backend that vendors a library -//! pays for what it vendors only in build time, not in code we maintain). const std = @import("std"); const libc = std.c; const lsp = @import("pardes").lsp; -// 0.16 slimmed std.fs (no cwd(), no realpathAlloc) and this repo goes through -// libc everywhere for exactly that reason — see look.zig. extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; -/// std.time.Timer is gone in 0.16; clock_gettime is what dump.zig already uses. fn nowNs() u64 { var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.MONOTONIC, &ts); @@ -36,53 +12,25 @@ fn nowNs() u64 { pub const std_options: std.Options = .{ .log_level = .err }; -/// One probe. The corpus is pardes's own source: real Zig, in this repo, that -/// every implementation can reach without a fixture tree to keep in sync. -/// -/// The cursor is pinned by SYMBOL, not by line — the needle is searched for at -/// startup — so editing pardes.zig cannot silently rot the bench into probing -/// a blank line and calling the result "no support". -/// -/// `expect` is a substring the rows must contain for the probe to count as -/// CORRECT rather than merely non-empty. A backend that returns a plausible -/// wrong location scores worse than one that returns nothing, and only this -/// field can tell those two apart. +// Coordinates are byte offsets within the first needle occurrence, not source line numbers. const Anchor = struct { file: []const u8, - /// the exact source text to put the cursor on; the FIRST occurrence wins needle: []const u8, - /// byte offset into `needle` where the cursor sits at: u32 = 0, kind: lsp.Kind, expect: []const u8 = "", }; const anchors = [_]Anchor{ - // a call to a function defined in the same file - .{ .file = "src/pardes.zig", .needle = "modal.hxLineCount(text)", .at = 6, .kind = .definition, .expect = "modal.zig" }, - // a plain local/decl reference within one file + .{ .file = "src/pardes.zig", .needle = "modal.cursorLineCount(text)", .at = 6, .kind = .definition, .expect = "modal.zig" }, .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .definition, .expect = "lsp.zig" }, - // a std reference: needs the zig lib dir, which is the first thing a - // single-file backend cannot do .{ .file = "src/lsp/lsp.zig", .needle = "std.mem.count(u8", .at = 8, .kind = .definition, .expect = "mem.zig" }, - // hover over the same symbol .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .hover, .expect = "" }, - // the file's own symbols .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .document_symbols, .expect = "Kind" }, .{ .file = "src/modal.zig", .needle = "pub fn ", .at = 7, .kind = .document_symbols, .expect = "" }, - // references to a symbol used in several places .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .references, .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/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .declaration, .expect = "" }, - // cursor on `out`, whose type is `*std.Io.Writer` — a type_definition probe - // wants a NAME whose type must be resolved, not the type spelled out .{ .file = "src/lsp/lsp.zig", .needle = "out: *std.Io.Writer", .at = 0, .kind = .type_definition, .expect = "" }, .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .implementation, .expect = "" }, .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .select_refs, .expect = "" }, @@ -91,131 +39,179 @@ const anchors = [_]Anchor{ .{ .file = "src/lsp/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .rename, .expect = "" }, .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .workspace_symbols, .expect = "" }, .{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .workspace_diagnostics, .expect = "broken.zig" }, - // The three shapes a `.` can have, each with the cursor IMMEDIATELY AFTER - // the dot, which is where Tab asks from. - // a field access into std: `std.` lists what `std` declares, the same - // "can this backend reach the stdlib at all" test `gd` gets. .{ .file = "src/lsp/lsp.zig", .needle = "std.mem.count(u8", .at = 4, .kind = .completion, .expect = "std.zig" }, - // an enum literal in a FINISHED switch arm. The tree parses, so this - // probes the expected-type resolution and nothing else. .{ .file = "src/lsp/lsp_zls.zig", .needle = ".declaration => try rowForToken", .at = 1, .kind = .completion, .expect = "type_definition" }, - // the same thing on the BIGGEST file in the tree. This one is here for - // its latency column rather than its rows: a completion parses the - // buffer once per placeholder spelling it tries, so pardes.zig is where - // that shows up and where the figure quoted in docs/lsp.md comes from. .{ .file = "src/pardes.zig", .needle = ".definition => .definition,", .at = 1, .kind = .completion, .expect = "" }, - // an enum literal in a HALF-TYPED switch arm, which is what a real - // keypress looks like: the file does not parse and the switch is not in - // the tree at all. `verdigris` appears only on the enum member's own - // line, so a row carrying it came from the DEFINITION and not from the - // line the cursor sits on — and it has to appear TWICE, because a - // completion row carries the candidate's own name (the word that would go - // after the dot) before the declaration line it was read off. + // The repeated name proves the result includes the declaration, not just the cursor line. .{ .file = "test/lspfixture/dotcomplete.zig", .needle = "return switch (s) {\n .", .at = 29, .kind = .completion, .expect = "verdigris verdigris," }, - // ...and the same dot on a line that is ALSO missing its terminator, - // which is one parse error repaired or not. This is the probe that - // catches a repair chosen by error count rather than by whether the dot - // became reachable in the tree. - // (the needle carries its indent: dothalf.zig's own header explains the - // shape and would otherwise be the first match) + // Indentation keeps the fixture header from becoming the first needle match. .{ .file = "test/lspfixture/dothalf.zig", .needle = " const z: Shade = .", .at = 22, .kind = .completion, .expect = "verdigris" }, }; const Result = struct { kind: lsp.Kind, - /// no rows came back empty: bool = true, - /// rows came back AND contained `expect` (or nothing was expected) correct: bool = false, rows: usize = 0, cold_us: u64 = 0, warm_us: u64 = 0, + + fn accept(r: *Result, output: []const u8, expected: []const u8) void { + r.empty = output.len == 0; + r.rows = std.mem.count(u8, output, "\n"); + r.correct = !r.empty and (expected.len == 0 or std.mem.indexOf(u8, output, expected) != null); + } }; const warm_iters = 20; +const Options = struct { + json: bool = false, + check: bool = false, + root: []const u8 = ".", + + fn parse(args: []const []const u8) !Options { + var options: Options = .{}; + var has_root = false; + for (args) |arg| { + if (std.mem.eql(u8, arg, "--json")) { + options.json = true; + } else if (std.mem.eql(u8, arg, "--check")) { + options.check = true; + } else if (std.mem.startsWith(u8, arg, "-")) { + return error.UnknownArgument; + } else { + if (has_root) return error.MultipleRoots; + options.root = arg; + has_root = true; + } + } + return options; + } +}; + +test "lspbench options reject unknown flags and multiple roots before probing" { + const defaults = try Options.parse(&.{}); + try std.testing.expect(!defaults.json and !defaults.check); + try std.testing.expectEqualStrings(".", defaults.root); + for ([_][]const []const u8{ + &.{ "--json", "repo root", "--check" }, + &.{ "repo root", "--check", "--json" }, + }) |args| { + const options = try Options.parse(args); + try std.testing.expect(options.json and options.check); + try std.testing.expectEqualStrings("repo root", options.root); + } + for ([_][]const []const u8{ + &.{ "--reps", "3" }, + &.{ "--json", "--unknown" }, + &.{ "repo", "--check=true" }, + }) |args| try std.testing.expectError(error.UnknownArgument, Options.parse(args)); + try std.testing.expectError(error.MultipleRoots, Options.parse(&.{ "first", "second" })); + try std.testing.expectError(error.MultipleRoots, Options.parse(&.{ "first", "--check", "second" })); +} + pub fn main(init: std.process.Init) !void { - // page_allocator like the other harnesses: this measures the BACKEND, and - // a debug allocator's bookkeeping would land in every number. const gpa = std.heap.page_allocator; const args = try init.minimal.args.toSlice(init.arena.allocator()); - var json = false; - var root: []const u8 = "."; - for (args[1..]) |a| { - if (std.mem.eql(u8, a, "--json")) json = true else root = a; - } + const options = try Options.parse(args[1..]); const rss0 = rssKib(); var results: [anchors.len]Result = undefined; var result_count: usize = 0; for (anchors) |an| { - const path = try std.fs.path.join(gpa, &.{ root, an.file }); - defer gpa.free(path); - const src = readZ(gpa, path) catch continue; - defer gpa.free(src); - const off = std.mem.indexOf(u8, src, an.needle) orelse { - std.debug.print("bench: anchor not found in {s}: `{s}`\n", .{ an.file, an.needle }); + results[result_count] = probe(gpa, options.root, an) catch |err| { + std.debug.print("bench: {s} {s} `{s}`: {s}\n", .{ an.file, @tagName(an.kind), an.needle, @errorName(err) }); continue; }; - var realbuf: [4096]u8 = undefined; - var pz: [4096:0]u8 = undefined; - const pz_s = std.fmt.bufPrintSentinel(&pz, "{s}", .{path}, 0) catch continue; - const abs = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse continue); - const dir = std.fs.path.dirname(abs) orelse "/"; - const req: lsp.Req = .{ - .kind = an.kind, - .path = abs, - .source = src, - .offset = @intCast(off + an.at), - .arg = if (an.kind == .rename) "renamed_by_bench" else if (an.kind == .workspace_symbols) "Kind" else "", - .root = dir, - }; - - var r: Result = .{ .kind = an.kind }; - // COLD: whatever the backend has to build the first time counts. - r.cold_us = runOnce(gpa, req, &r); - // WARM: median of warm_iters, so one scheduling hiccup cannot flatter - // or damn a backend. - var samples: [warm_iters]u64 = undefined; - for (&samples) |*s| { - var throwaway: Result = .{ .kind = an.kind }; - s.* = runOnce(gpa, req, &throwaway); - } - std.mem.sort(u64, &samples, {}, std.sort.asc(u64)); - r.warm_us = samples[warm_iters / 2]; - r.correct = !r.empty and (an.expect.len == 0 or blk: { - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - lsp.query(gpa, arena.allocator(), req, &out.writer); - break :blk std.mem.indexOf(u8, out.written(), an.expect) != null; - }); - results[result_count] = r; result_count += 1; } const rss = rssKib() -| rss0; - if (json) reportJson(results[0..result_count], rss) else reportText(results[0..result_count], rss); + if (options.json) reportJson(results[0..result_count], rss) else reportText(results[0..result_count], rss); + if (options.check) try checkResults(results[0..result_count], anchors.len); +} + +fn probe(gpa: std.mem.Allocator, root: []const u8, an: Anchor) !Result { + const path = try std.fs.path.join(gpa, &.{ root, an.file }); + defer gpa.free(path); + const src = try readZ(gpa, path); + defer gpa.free(src); + const off = std.mem.indexOf(u8, src, an.needle) orelse return error.MissingAnchor; + if (an.at > an.needle.len) return error.InvalidAnchor; + var realbuf: [4096]u8 = undefined; + var pz: [4096:0]u8 = undefined; + const pz_s = try std.fmt.bufPrintSentinel(&pz, "{s}", .{path}, 0); + const abs = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse return error.RealPathFailed); + const req: lsp.Req = .{ + .kind = an.kind, + .path = abs, + .source = src, + .offset = @intCast(off + an.at), + .arg = if (an.kind == .rename) "renamed_by_bench" else if (an.kind == .workspace_symbols) "Kind" else "", + .root = std.fs.path.dirname(abs) orelse "/", + }; + var r: Result = .{ .kind = an.kind }; + r.cold_us = try runOnce(gpa, req, an.expect, &r); + var samples: [warm_iters]u64 = undefined; + for (&samples) |*sample| { + var warm: Result = .{ .kind = an.kind }; + sample.* = try runOnce(gpa, req, an.expect, &warm); + r.correct = r.correct and warm.correct; + } + std.mem.sort(u64, &samples, {}, std.sort.asc(u64)); + r.warm_us = samples[warm_iters / 2]; + return r; +} + +fn checkResults(results: []const Result, required: usize) !void { + if (results.len != required) return error.MissingProbes; + for (results) |r| if (r.empty or !r.correct) return error.IncorrectProbe; } -fn runOnce(gpa: std.mem.Allocator, req: lsp.Req, r: *Result) u64 { +fn runOnce(gpa: std.mem.Allocator, req: lsp.Req, expected: []const u8, r: *Result) !u64 { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); const t0 = nowNs(); - lsp.query(gpa, arena.allocator(), req, &out.writer); + try lsp.query(gpa, arena.allocator(), req, &out.writer); const ns = nowNs() -| t0; - if (out.written().len > 0) { - r.empty = false; - r.rows = std.mem.count(u8, out.written(), "\n"); - } + r.accept(out.written(), expected); return ns / 1000; } +test "lspbench check rejects omitted empty and incorrect probes" { + var result: Result = .{ .kind = .definition }; + try std.testing.expectError(error.MissingProbes, checkResults(&.{}, 1)); + try std.testing.expectError(error.IncorrectProbe, checkResults(&.{result}, 1)); + result.accept("actual.zig:1:1: symbol\n", "expected.zig"); + try std.testing.expect(!result.empty); + try std.testing.expectError(error.IncorrectProbe, checkResults(&.{result}, 1)); + result.accept("actual.zig:1:1: symbol\n", "actual.zig"); + try checkResults(&.{result}, 1); + try std.testing.expectError(error.MissingProbes, checkResults(&.{result}, 2)); + result.accept("", ""); + try std.testing.expectError(error.IncorrectProbe, checkResults(&.{result}, 1)); +} + +test "lspbench reports missing fixture and anchor before querying a backend" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var path_buf: [4096]u8 = undefined; + const root = path_buf[0..try tmp.dir.realPath(std.testing.io, &path_buf)]; + const anchor: Anchor = .{ .file = "source.zig", .needle = "pub const value", .kind = .definition }; + try std.testing.expectError(error.OpenFailed, probe(std.testing.allocator, root, anchor)); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = anchor.file, .data = "const value = 1;\n" }); + try std.testing.expectError(error.MissingAnchor, probe(std.testing.allocator, root, anchor)); + var invalid = anchor; + invalid.needle = "value"; + invalid.at = 6; + try std.testing.expectError(error.InvalidAnchor, probe(std.testing.allocator, root, invalid)); +} + fn reportText(rs: []const Result, rss: u64) void { const o = std.debug.print; o("backend: {s}\n\n", .{lsp.backend_name}); @@ -284,8 +280,6 @@ fn readZ(gpa: std.mem.Allocator, path: []const u8) ![:0]u8 { return buf[0..len :0]; } -/// VmHWM from /proc/self/status — the peak, not the current, so a backend that -/// frees its index before returning still pays for having built it. fn rssKib() u64 { const fd = libc.open("/proc/self/status", .{ .ACCMODE = .RDONLY }); if (fd < 0) return 0; |
