From ae9325a5cb128d0d952afb8f9feaaca68e5e37a2 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 1 Sep 2026 09:23:53 -0300 Subject: lsp: a protocol client for every other language, narrated on the message row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam grows a second backend: src/lsp/lsp_client.zig speaks JSON-RPC to child language servers — rust-analyzer, clangd, gopls, tsserver, pyright are rows in a spec table — while the in-process ZLS analyser keeps .zig. One reader thread per server owns the socket, routes responses to a mailbox under the conn mutex (monotonic condvar), answers server-to-client requests, feeds the diagnostics store, and narrates $/progress and state changes through a status sink both native shells post to the transient message row: "rust-analyzer: cargo check 88% 955/1083" lands where a save narrates, with the same clock. Chatty progress is throttled and deduplicated; settled states always land, which is also what makes the goldens deterministic. Nothing wedges and nothing healthy dies: waits are deadline-bounded, a timeout cancels and returns no rows, three consecutive timeouts restart the server ONLY while it is idle (an indexing server is narrating its own excuse), spawn and handshake failures back off 10s to 2min, a crash shortly after ready counts as a failure, and only a missing binary disables a spec. PARDES_LSP_{RS,C,GO,TS,PY} override binaries; empty disables; the snapshot harness pins RS to test/lspmock.zig and empties the rest. Mutating answers really mutate now: the @put record beside rename @edit carries per-range text, so = applies the formatter (both backends) and a same-file WorkspaceEdit rename applies atomically, one undo step, narrated ("renamed 2 range(s)"); a multi-file rename previews as rows instead of half-applying. Malformed responses fail closed: coordinates validated not clamped, one bad TextEdit poisons the whole edit set, poison frames kill the connection instead of buffering forever, decoded control bytes reject a uri, hierarchy items too deep to reserialize are skipped. Four kinds helix does not have, on SPC l: c/C incoming/outgoing calls (rows are call sites), t/T super/subtypes. Pull diagnostics (3.17) preferred when advertised. Help gains a language-keys footer for the motions no builtin row could carry; lsp.rel and look.grep now share one path-shortening rule. zig build lspprobe drives the seam from the CLI (comma-separated kinds share one server); measured against a 1083-crate workspace warm: gd 26ms, gr 213 rows 165ms, incoming calls 212 sites 197ms, document symbols 670 rows 347ms. docs/lsp.md tells the whole story; lsp-evaluation.md gets an addendum. --- tools/lspprobe.zig | 184 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 tools/lspprobe.zig (limited to 'tools/lspprobe.zig') diff --git a/tools/lspprobe.zig b/tools/lspprobe.zig new file mode 100644 index 00000000..8b953cec --- /dev/null +++ b/tools/lspprobe.zig @@ -0,0 +1,184 @@ +//! One language query against the REAL seam, from the command line. +//! +//! pardes-lspprobe : [arg] [--reps N] +//! +//! `kind` is an `lsp.Kind` tag (`definition`, `references`, ...) or its helix +//! key (`gd`, `gr`, `gy`, `gi`, `gD`). Line and column are 1-based, the way +//! the rows print them. Rows go to stdout; the timing line goes to stderr so +//! `lspprobe gd f.rs 10:5 | head` still composes. +//! +//! This is the development probe for the PROTOCOL CLIENT: point it at a file +//! in any workspace (a rust crate, a C tree with compile_commands.json) and it +//! exercises exactly what `gd` in the editor would — same seam, same client, +//! same child server. The server is spawned fresh per invocation and kept for +//! the process, so rep 0 is the true cold cost (spawn + handshake + whatever +//! indexing the deadline allows) and later reps are the warm per-press cost. +//! Generic on purpose: no project names anywhere — the workspace is whatever +//! the file's root markers say. +const std = @import("std"); +const lsp = @import("pardes").lsp; + +extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; + +pub const std_options: std.Options = .{ .log_level = .err }; + +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; +} + +const aliases = .{ + .{ "gd", lsp.Kind.definition }, + .{ "gD", lsp.Kind.declaration }, + .{ "gy", lsp.Kind.type_definition }, + .{ "gi", lsp.Kind.implementation }, + .{ "gr", lsp.Kind.references }, +}; + +fn usage() noreturn { + std.debug.print( + \\usage: pardes-lspprobe : [arg] [--reps N] [--warm S] + \\ kind: gd gD gy gi gr, or any lsp.Kind tag (hover, references, + \\ document_symbols, workspace_symbols, incoming_calls, ...) + \\ arg: the new name for rename, the query for workspace_symbols + \\ --warm sleep S seconds after the first (cold) query — long enough + \\ for an indexing server to reach ready, narrated on stderr + \\ + , .{}); + std.process.exit(2); +} + +/// The editor's message row, here: the client's reader threads narrate server +/// state through the same sink the shells register, printed to stderr so the +/// rows on stdout stay clean. +fn statusSink(ctx: ?*anyopaque, text: []const u8) void { + _ = ctx; + std.debug.print("# status: {s}\n", .{text}); +} + +extern "c" fn usleep(usec: c_uint) c_int; + +pub fn main(init: std.process.Init) !void { + const gpa = std.heap.page_allocator; + const arena = init.arena.allocator(); + const io = std.Io.Threaded.global_single_threaded.io(); + + const args = try init.minimal.args.toSlice(arena); + if (args.len < 4) usage(); + + // comma-separated kinds share ONE server instance and its index — the + // whole battery against a big workspace pays the cold cost once + var kinds: [16]lsp.Kind = undefined; + var nkinds: usize = 0; + var kit = std.mem.tokenizeScalar(u8, args[1], ','); + while (kit.next()) |word| { + if (nkinds == kinds.len) usage(); + kinds[nkinds] = blk: { + inline for (aliases) |a| if (std.mem.eql(u8, word, a[0])) break :blk a[1]; + break :blk std.meta.stringToEnum(lsp.Kind, word) orelse usage(); + }; + nkinds += 1; + } + if (nkinds == 0) usage(); + + var pz: [4096:0]u8 = undefined; + const pz_s = std.fmt.bufPrintSentinel(&pz, "{s}", .{args[2]}, 0) catch usage(); + var realbuf: [4096]u8 = undefined; + const path = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse { + std.debug.print("cannot resolve {s}\n", .{args[2]}); + std.process.exit(1); + }); + const source = std.Io.Dir.cwd().readFileAllocOptions( + io, + path, + arena, + .limited(64 << 20), + .of(u8), + 0, + ) catch { + std.debug.print("cannot read {s}\n", .{path}); + std.process.exit(1); + }; + + const colon = std.mem.indexOfScalar(u8, args[3], ':') orelse usage(); + const line = std.fmt.parseInt(u32, args[3][0..colon], 10) catch usage(); + const col = std.fmt.parseInt(u32, args[3][colon + 1 ..], 10) catch usage(); + if (line == 0 or col == 0) usage(); + + var arg: []const u8 = ""; + var reps: u32 = 1; + var warm_s: u32 = 0; + var i: usize = 4; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--reps")) { + i += 1; + if (i >= args.len) usage(); + reps = @max(1, std.fmt.parseInt(u32, args[i], 10) catch usage()); + } else if (std.mem.eql(u8, args[i], "--warm")) { + i += 1; + if (i >= args.len) usage(); + warm_s = std.fmt.parseInt(u32, args[i], 10) catch usage(); + } else arg = args[i]; + } + + lsp.setStatusSink(null, statusSink); + + // 1-based line:col -> byte offset + var off: usize = 0; + var ln: u32 = 1; + while (ln < line and off < source.len) : (off += 1) { + if (source[off] == '\n') ln += 1; + } + off = @min(off + col - 1, source.len); + + const root = std.fs.path.dirname(path) orelse "/"; + const timings = try arena.alloc(u64, reps); + var stdout_buf: [64 * 1024]u8 = undefined; + var stdout = std.Io.File.stdout().writer(io, &stdout_buf); + var first_query = true; + + for (kinds[0..nkinds]) |kind| { + var rows: []const u8 = ""; + for (0..reps) |r| { + // one warm pause for the whole battery, right after the very + // first (cold, server-spawning) query — the status lines on + // stderr narrate what the server does with the time + var out: std.Io.Writer.Allocating = .init(gpa); + var scratch: std.heap.ArenaAllocator = .init(gpa); + defer scratch.deinit(); + const t0 = nowUs(); + lsp.query(gpa, scratch.allocator(), .{ + .kind = kind, + .path = path, + .source = source, + .offset = @intCast(off), + .arg = arg, + .root = root, + }, &out.writer); + timings[r] = nowUs() -| t0; + rows = try arena.dupe(u8, out.written()); + out.deinit(); + if (first_query) { + first_query = false; + if (warm_s > 0) for (0..warm_s * 10) |_| { + _ = usleep(100_000); + }; + } + } + + try stdout.interface.print("## {t}\n", .{kind}); + try stdout.interface.writeAll(rows); + try stdout.interface.flush(); + + const n = std.mem.count(u8, rows, "\n"); + const cold = timings[0]; + std.mem.sort(u64, timings[0..reps], {}, std.sort.asc(u64)); + if (reps == 1) + std.debug.print("# {t}: {d} row(s) in {d}us\n", .{ kind, n, cold }) + else + std.debug.print("# {t}: {d} row(s); first {d}us, min {d}us, median {d}us ({d} reps)\n", .{ + kind, n, cold, timings[0], timings[reps / 2], reps, + }); + } +} -- cgit v1.3