diff options
Diffstat (limited to 'src/lsp')
| -rw-r--r-- | src/lsp/lsp.zig | 314 | ||||
| -rw-r--r-- | src/lsp/lsp_client.zig | 156 | ||||
| -rw-r--r-- | src/lsp/lsp_zls.zig | 138 |
3 files changed, 252 insertions, 356 deletions
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig index 0b4f2ba1..ca19009a 100644 --- a/src/lsp/lsp.zig +++ b/src/lsp/lsp.zig @@ -1,216 +1,111 @@ -//! The language-intelligence seam. -//! -//! The core never speaks a protocol and never blocks. It emits an `lsp` Effect -//! naming a Kind, a file and a byte offset; a shell runs `query` on a worker -//! and posts the answer back as an `lsp_resp` Event. That is the whole async -//! execution model — the same shape the pty readers already use, because a -//! language query is just another thing that answers later. -//! -//! Location answers render as `+Search` rows. A location is -//! `path:LINE:COL text` — or `path:LINE:COL-ENDCOL text` where the protocol -//! answered with a real range, which a look then SELECTS — and that is what -//! look.zig already resolves and what n/N already steps, so a multi-result -//! answer IS helix's picker and a single result IS a jump, with no picker UI -//! written for it. Free text (hover, formatting) rides the same buffer. Rename -//! is the one mutating answer: it emits byte ranges through `edit`, and the core -//! applies them atomically only while the source revision is still current. -//! -//! `query` is the ONLY thing an implementation supplies. Swapping backends is -//! swapping this one function, which is also how the three competing -//! implementations are measured against each other: same core, same harness, -//! same rows, different `query`. const std = @import("std"); -/// What the caller wants to know. The helix command each one backs is named -/// alongside, because the keymap is helix's and these are its verbs — helix's -/// bare `<space>X` spelled `SPC l X` here, because `d`, `k`, `s` and `h` were -/// already pardes's own most-pressed leader keys and the rest follow them into -/// the group rather than splitting the menu (see config.leader_path). pub const Kind = enum { - /// gd definition, - /// gD declaration, - /// gy type_definition, - /// gi implementation, - /// gr references, - /// SPC l k hover, - /// SPC l s document_symbols, - /// SPC l S (arg = the query) workspace_symbols, - /// SPC l d, and the list that ]d / [d step diagnostics, - /// SPC l D workspace_diagnostics, - /// SPC l r (arg = the new name) rename, - /// SPC l a code_action, - /// = format, - /// SPC l h select_refs, - /// Tab in insert mode, with a `.` immediately before the cursor. NOT an - /// autocomplete popup — the seam returns locations, so this answers "what - /// could go here, and where is each of those DEFINED": one row per - /// candidate, pointing at its declaration, in the same `+Search` buffer - /// `gr` fills. Nothing is inserted. completion, - - // The two-step hierarchy kinds, LSP 3.16/3.17: prepare at the cursor, - // then walk the item the server handed back. helix has none of these - // four (checked against helix-term/src/keymap/default.rs, which stops at - // the gotos), so they are pardes exceeding parity rather than matching - // it — possible here because the answers are LOCATIONS, and locations - // are the one thing this seam renders for free. - /// SPC l c — who calls the function under the cursor incoming_calls, - /// SPC l C — everything the function under the cursor calls outgoing_calls, - /// SPC l t — the types this one extends/implements supertypes, - /// SPC l T — the types that extend/implement this one subtypes, - - // The two introspection kinds. A backend that answers nothing is - // indistinguishable from a backend that is broken, so these exist to tell - // those apart — they are the only Kinds whose answer is ABOUT the backend - // rather than about the code. - /// SPC l i — configuration, capabilities and the recent-query log status, - /// SPC l w — why the query at the cursor answers what it does. Narrates - /// the REAL resolution path rather than re-deriving it, so it cannot drift - /// away from what `gd` actually did. explain, - - // What an ANSWER becomes — which buffer it opens, whether a single row - // jumps instead, whether n/N walk it — is not here: it is one row per Kind - // in output_pane.traits, beside the same questions asked of `/`, Find, - // Grep and Help. A Kind added above will not compile until it has one. }; -/// One question. `source` is a snapshot of the buffer taken by the shell -/// before the worker starts — the core keeps editing while this is in flight, -/// so a backend must never reach back into core memory. pub const Req = struct { kind: Kind, - /// absolute path of the file the offset is in path: []const u8, - /// the buffer's bytes, NUL-terminated (std.zig.Ast and zls both want a - /// sentinel, and every backend has to parse this same text) source: [:0]const u8, - /// cursor position, a byte offset into `source` offset: u32, - /// kind-specific argument: the new name for a rename, the query for - /// workspace symbols. Empty otherwise. arg: []const u8 = "", - /// where the project starts — the directory of the pane that asked. A - /// backend that indexes more than one file walks from here. root: []const u8 = "", }; -/// How a row SPELLS a path: relative to `base` if it lives UNDER it, its full -/// absolute self otherwise. -/// -/// `base` is `Req.root` — the directory of the file the query was asked about -/// — which is also the directory the results buffer is opened in, so a row -/// shortened here reads as the name that window would have typed and still -/// resolves when looked. `gr` over one file was otherwise the same -/// forty-character absolute prefix repeated down the whole pane, with the part -/// you came to read pushed off the right edge. -/// -/// UNDER, not "shorter": a path outside that tree is left absolute rather than -/// walked up to with `../`. An absolute path resolves from anywhere and says -/// where it is; `../../..` says neither, and the moment the row is read -/// somewhere other than beside its own buffer it is wrong. -/// -/// This is also `look.grep`'s `shown` rule — it calls this function, so the -/// two spellings the docs used to complain about are one. +const backends = if (@import("pardes_config").zls_backend) + .{ @import("lsp_zls.zig"), @import("lsp_client.zig") } +else + .{}; + +pub const backend_name = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc"; + +pub const supports: std.EnumSet(Kind) = blk: { + var s: std.EnumSet(Kind) = .initEmpty(); + for (0..backends.len) |i| s.setUnion(backends[i].supports); + break :blk s; +}; + +pub fn speaks(path: []const u8) bool { + inline for (backends) |b| if (b.speaks(path)) return true; + return false; +} + +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) !void { + if (req.kind == .status) { + inline for (backends) |b| try b.query(gpa, arena, req, out); + return; + } + inline for (backends) |b| { + if (b.speaks(req.path) and b.supports.contains(req.kind)) + return b.query(gpa, arena, req, out); + } + if (req.kind == .explain and backends.len > 0) + try backends[0].query(gpa, arena, req, out); +} + +// Called on server reader threads; the sink must copy text before returning. +pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void { + if (@import("pardes_config").zls_backend) backends[1].setStatusSink(ctx, cb); +} + pub fn rel(base: []const u8, path: []const u8) []const u8 { if (base.len == 0) return path; - const home = std.mem.trimEnd(u8, base, "/"); - if (path.len > home.len and std.mem.startsWith(u8, path, home) and path[home.len] == '/') - return path[home.len + 1 ..]; + const prefix = std.mem.trimEnd(u8, base, "/"); + if (path.len > prefix.len and std.mem.startsWith(u8, path, prefix) and path[prefix.len] == '/') + return path[prefix.len + 1 ..]; return path; } -/// Emit one `path:LINE:COL text` row. Line and column are 1-based, the way -/// every other row in a `+Search` buffer is (and the way look.zig parses one). -/// `path` has already been through `rel`: the caller holds the base. -pub fn row( - out: *std.Io.Writer, - path: []const u8, - line: usize, - col: usize, - text: []const u8, -) void { - out.print("{s}:{d}:{d} {s}\n", .{ +pub fn row(out: *std.Io.Writer, path: []const u8, line: usize, col: usize, text: []const u8) std.Io.Writer.Error!void { + try out.print("{s}:{d}:{d} {s}\n", .{ path, line + 1, col + 1, std.mem.trim(u8, text, " \t\r\n"), - }) catch {}; + }); } -/// The same row for a protocol RANGE: `path:LINE:COL-ENDCOL`, which a look -/// SELECTS rather than parking on its first cell — so `gd` lands on the whole -/// name and a references list steps symbol by symbol with each one highlighted -/// (config.range_sep spells the dash; `-` is written out here for the same -/// reason `:` is). -/// -/// `end_col` is the protocol's own EXCLUSIVE end character, which is already -/// the 1-based inclusive column pardes wants, so the conversion is the absence -/// of one. A span that is empty or crosses lines falls back to the point row: -/// the only multi-line ranges here are whole declarations, and a goto onto one -/// wants the cursor at its name, not its body painted. -pub fn spanRow( - out: *std.Io.Writer, - path: []const u8, - line: usize, - col: usize, - end_line: usize, - end_col: usize, - text: []const u8, -) void { +// Input positions are zero-based and end-exclusive; displayed spans are one-based and inclusive. +pub fn spanRow(out: *std.Io.Writer, path: []const u8, line: usize, col: usize, end_line: usize, end_col: usize, text: []const u8) std.Io.Writer.Error!void { if (end_line != line or end_col <= col) return row(out, path, line, col, text); - out.print("{s}:{d}:{d}-{d} {s}\n", .{ + try out.print("{s}:{d}:{d}-{d} {s}\n", .{ path, line + 1, col + 1, end_col, std.mem.trim(u8, text, " \t\r\n"), - }) catch {}; + }); } -/// Emit one half-open byte range for a mutating response. Rename is the only -/// current user: every other answer remains human-readable rows. Byte offsets -/// avoid converting the displayed 1-based locations back into source offsets -/// in the core, and the prefix makes malformed or mixed responses fail closed. -pub fn edit(out: *std.Io.Writer, start: usize, end: usize) void { - out.print("@edit {d} {d}\n", .{ start, end }) catch {}; +pub fn edit(out: *std.Io.Writer, start: usize, end: usize) std.Io.Writer.Error!void { + try out.print("@edit {d} {d}\n", .{ start, end }); } -/// The general mutating record: a half-open byte range REPLACED BY `text`, -/// which `@edit` cannot say (its replacement is the request's own arg, the -/// same for every range). Rename through a protocol server and `=` both need -/// per-range text, so this carries it — percent-encoded onto the one line a -/// record is allowed to be, because a TextEdit's newText is full of newlines -/// and the record stream is parsed line by line. The core decodes with -/// `parseLspEdits` and applies all records in one undo transaction; malformed, -/// overlapping or out-of-bounds records change nothing, exactly as for @edit. -pub fn put(out: *std.Io.Writer, start: usize, end: usize, text: []const u8) void { - out.print("@put {d} {d} ", .{ start, end }) catch {}; +pub fn put(out: *std.Io.Writer, start: usize, end: usize, text: []const u8) std.Io.Writer.Error!void { + try out.print("@put {d} {d} ", .{ start, end }); for (text) |c| { - // '%' so the encoding round-trips; control bytes so the record stays - // one line; ' ' so the text is one token. Everything else is itself. - if (c == '%' or c == ' ' or c < 0x21) - out.print("%{X:0>2}", .{c}) catch {} + if (c == '%' or c < 0x21) + try out.print("%{X:0>2}", .{c}) else - out.writeByte(c) catch {}; + try out.writeByte(c); } - out.writeByte('\n') catch {}; + try out.writeByte('\n'); } -/// Byte offset -> (line, column), both 0-based. Every backend needs it to turn -/// an AST token into a row, so it lives here rather than three times over. pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usize } { const upto = source[0..@min(offset, source.len)]; const line = std.mem.count(u8, upto, "\n"); @@ -218,82 +113,29 @@ pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usi return .{ .line = line, .col = upto.len - bol }; } -/// Answer `req`, writing rows to `out`. Runs on a worker thread with no -/// access to the core: everything it may read is in `req`. -/// -/// `out` is a plain `std.Io.Writer` — the shell owns the buffer behind it (an -/// `Io.Writer.Allocating`), so a backend never allocates the result, never -/// frees it, and cannot get the allocator wrong. Write failures are the -/// writer's problem; a backend may ignore them. -/// -/// `arena` is freed wholesale when the query returns; `gpa` is for a backend's -/// own longer-lived scratch. Errors are not reported — a backend that cannot -/// answer writes nothing, and the core treats "no rows" as "no result", which -/// is also what a language server still starting up looks like. -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) void { - // `status` is about the BACKENDS, plural: every one reports, in seam - // order, so `SPC l i` shows the analyser and the protocol client side by - // side and a machine with neither prints nothing at all. - if (req.kind == .status) { - inline for (backends) |b| b.query(gpa, arena, req, out); - return; +test "LSP encoders report every insufficient output capacity" { + const cases = [_]struct { kind: enum { row, span, edit, put }, expected: []const u8 }{ + .{ .kind = .row, .expected = "file:1:3 hi\n" }, + .{ .kind = .span, .expected = "file:1:3-5 hi\n" }, + .{ .kind = .edit, .expected = "@edit 1 3\n" }, + .{ .kind = .put, .expected = "@put 1 3 hé%20%25%0A\n" }, + }; + for (cases) |case| { + var buf: [128]u8 = undefined; + for (0..case.expected.len + 1) |capacity| { + var out: std.Io.Writer = .fixed(buf[0..capacity]); + const result = switch (case.kind) { + .row => row(&out, "file", 0, 2, " hi \n"), + .span => spanRow(&out, "file", 0, 2, 0, 5, " hi \n"), + .edit => edit(&out, 1, 3), + .put => put(&out, 1, 3, "hé %\n"), + }; + if (capacity < case.expected.len) { + try std.testing.expectError(error.WriteFailed, result); + } else { + try result; + try std.testing.expectEqualStrings(case.expected, out.buffered()); + } + } } - inline for (backends) |b| { - if (b.speaks(req.path) and b.supports.contains(req.kind)) - return b.query(gpa, arena, req, out); - } - // Nobody spoke the file. `explain` exists precisely to narrate a refusal, - // so it still goes to the first backend, whose trace says WHY it stopped - // ("not a .zig file", "no server for .md") instead of silently no-rowing. - if (req.kind == .explain and backends.len > 0) - backends[0].query(gpa, arena, req, out); } - -/// The compiled-in backends, asked in order; the first one that speaks the -/// file's language AND claims the kind answers. Two on a native build — ZLS -/// linked as a module for Zig (no process, cold is warm), and a real LSP -/// client (lsp_client.zig) speaking JSON-RPC to child servers for everything -/// else: rust-analyzer, clangd, gopls, whatever the spec table names. A -/// FREESTANDING core (web, esp32) compiles in neither: `supports` is then -/// empty, `lspRequest` returns before it emits, and the effect never exists. -const backends = if (@import("pardes_config").zls_backend) - .{ @import("lsp_zls.zig"), @import("lsp_client.zig") } -else - .{}; - -/// 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) = blk: { - var s: std.EnumSet(Kind) = .initEmpty(); - for (0..backends.len) |i| s.setUnion(backends[i].supports); - break :blk s; -}; - -/// Does the backend read this file's LANGUAGE at all? `supports` answers what -/// a backend can do; this answers what it can do it TO, and it exists for the -/// one key that must not be eaten when the answer is no: insert-mode Tab -/// diverts to `completion` after a `.`, so in a README — or in any pane the -/// backend would refuse — it has to indent instead. The core asks rather than -/// knowing, so the list of extensions stays the backend's business. -pub fn speaks(path: []const u8) bool { - inline for (backends) |b| if (b.speaks(path)) return true; - return false; -} - -/// Where a shell registers the one function unsolicited SERVER STATE goes -/// through: "rust-analyzer indexing 3/120", "gopls exited". Called from the -/// client's reader threads, so a sink must be thread-safe and must copy -/// `text` before returning; both native shells post it to their event queue -/// and let the loop hand it to `Pardes.setMessage` — the same transient row a -/// save narrates into, because a server starting up is exactly that kind of -/// news. A build with no client accepts and ignores the registration. -pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void { - if (@import("pardes_config").zls_backend) backends[1].setStatusSink(ctx, cb); -} - -/// Name shown by the harness and in `SPC ?`. This is the SEAM's, not the -/// backend's: a backend does not declare it, so renaming a backend means -/// editing this line. -pub const backend_name = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc"; diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig index b1be590b..8c6a0da9 100644 --- a/src/lsp/lsp_client.zig +++ b/src/lsp/lsp_client.zig @@ -189,7 +189,7 @@ const wedged_strikes = 3; // consecutive timeouts before a restart const max_rows = 2000; const max_doc_bytes = 8 << 20; -const Err = error{ Dead, Timeout, Protocol, OutOfMemory, NoServer }; +const Err = error{ Dead, Timeout, Protocol, OutOfMemory, NoServer, WriteFailed }; /// Long-lived state outlives every query arena and cannot borrow the caller's /// gpa (a different one shows up in the harness than in the shell), so @@ -399,11 +399,9 @@ const Trace = struct { // ------------------------------------------------------------------- query -/// The seam entry point. Never fails, never panics; no rows is the only error -/// rendering there is (`SPC l i` shows what was swallowed). -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) void { +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { _ = gpa; - if (req.kind == .status) return status(req, out) catch {}; + if (req.kind == .status) return status(req, out); var tr: Trace = .{ .on = req.kind == .explain }; const si = specFor(req.path) orelse { @@ -414,33 +412,36 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out }; tr.note("file {s} -> {s} (languageId {s})", .{ std.fs.path.basename(req.path), specs[si].name, specs[si].lang }); - const scratch_buf = arena.alloc(u8, max_rows * 512) catch return; + const scratch_buf = try arena.alloc(u8, max_rows * 512); var scratch: std.Io.Writer = .fixed(scratch_buf); const t0 = nowUs(); var err_name: []const u8 = ""; + var failure: ?anyerror = null; answer(arena, si, req, &scratch, &tr) catch |e| { + failure = e; err_name = @errorName(e); - tr.note("ERROR: {s} — the editor shows this as 'no result'", .{err_name}); + tr.note("ERROR: {s}", .{err_name}); }; const us = nowUs() -| t0; const rows = std.mem.count(u8, scratch.buffered(), "\n"); record(si, req, us, rows, err_name); if (req.kind == .explain) return traceOut(&tr, req, out, rows, us); - out.writeAll(scratch.buffered()) catch {}; + if (failure) |err| return err; + try out.writeAll(scratch.buffered()); } -fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) void { +fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) std.Io.Writer.Error!void { if (req.kind != .explain) return; - out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ + try out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), - }) catch {}; - out.writeAll(tr.buf[0..tr.len]) catch {}; + }); + try out.writeAll(tr.buf[0..tr.len]); if (hideTime()) - out.print("\n{d} row(s)\n", .{rows}) catch {} + try out.print("\n{d} row(s)\n", .{rows}) else - out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {}; + try out.print("\n{d} row(s) in {d}us\n", .{ rows, us }); } fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) Err!void { @@ -470,7 +471,7 @@ fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer } } }, - error.OutOfMemory, error.NoServer => {}, + error.OutOfMemory, error.NoServer, error.WriteFailed => {}, } return e; }; @@ -519,7 +520,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io .select_refs => { const b = try atPos(arena, uri.items, pos); const result = (try call(c, arena, "textDocument/documentHighlight", b.items, deadline)) orelse return; - for (items(result)) |h| emitRange(&cx, c.caps.enc, uri.items, get(h, "range"), ""); + for (items(result)) |h| try emitRange(&cx, c.caps.enc, uri.items, get(h, "range"), ""); }, .hover => { const b = try atPos(arena, uri.items, pos); @@ -530,8 +531,8 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io break :blk ""; }; if (text.len == 0) return; - out.writeAll(std.mem.trim(u8, text, " \t\r\n")) catch {}; - out.writeByte('\n') catch {}; + try out.writeAll(std.mem.trim(u8, text, " \t\r\n")); + try out.writeByte('\n'); }, .document_symbols => { var b: std.ArrayList(u8) = .empty; @@ -539,7 +540,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io try jstr(&b, arena, uri.items); try app(&b, arena, "}"); const result = (try call(c, arena, "textDocument/documentSymbol", b.items, deadline)) orelse return; - walkSymbols(&cx, c.caps.enc, uri.items, result, 0); + try walkSymbols(&cx, c.caps.enc, uri.items, result, 0); }, .workspace_symbols => { var b: std.ArrayList(u8) = .empty; @@ -548,7 +549,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io const result = (try call(c, arena, "workspace/symbol", b.items, deadline)) orelse return; for (items(result)) |sym| { const loc = get(sym, "location") orelse continue; - emitRange(&cx, c.caps.enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), str(get(sym, "name")) orelse ""); + try emitRange(&cx, c.caps.enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), str(get(sym, "name")) orelse ""); } }, .diagnostics => try diagnostics(c, arena, &cx, uri.items, deadline), @@ -576,7 +577,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io const result = (try call(c, arena, "textDocument/codeAction", b.items, deadline)) orelse return; for (items(result)) |ca| { const title = str(get(ca, "title")) orelse continue; - lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), pos.line, 0, flat(arena, title)); + try lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), pos.line, 0, flat(arena, title)); cx.rows += 1; } }, @@ -601,7 +602,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io // no location of its own (unlike the ZLS backend, which points // at declarations), so the honest place is where it would be // inserted. n/N still step the list; Enter goes nowhere new. - lsp.row(cx.out, here, pos.line, byteCol(req.source, pos.line, pos.ch, c.caps.enc), text.items); + try lsp.row(cx.out, here, pos.line, byteCol(req.source, pos.line, pos.ch, c.caps.enc), text.items); n += 1; } }, @@ -618,30 +619,30 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io /// Goto/references result shapes: bare Location, Location[], LocationLink[]. fn locations(cx: *Cx, enc: Enc, result: std.json.Value) Err!void { if (result == .object) { - emitRange(cx, enc, str(get(result, "uri")) orelse return, get(result, "range"), ""); + try emitRange(cx, enc, str(get(result, "uri")) orelse return, get(result, "range"), ""); return; } for (items(result)) |loc| { if (get(loc, "targetUri")) |tu| { const r = get(loc, "targetSelectionRange") orelse get(loc, "targetRange"); - emitRange(cx, enc, str(tu) orelse continue, r, ""); + try emitRange(cx, enc, str(tu) orelse continue, r, ""); } else { - emitRange(cx, enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), ""); + try emitRange(cx, enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), ""); } } } /// DocumentSymbol[] nests (`children`), SymbolInformation[] is flat. -fn walkSymbols(cx: *Cx, enc: Enc, uri: []const u8, node: std.json.Value, depth: u8) void { +fn walkSymbols(cx: *Cx, enc: Enc, uri: []const u8, node: std.json.Value, depth: u8) Err!void { if (depth > 8) return; for (items(node)) |sym| { const name = str(get(sym, "name")) orelse continue; if (get(get(sym, "location"), "range")) |r| { - emitRange(cx, enc, str(get(get(sym, "location"), "uri")) orelse uri, r, name); + try emitRange(cx, enc, str(get(get(sym, "location"), "uri")) orelse uri, r, name); } else { - emitRange(cx, enc, uri, get(sym, "selectionRange") orelse get(sym, "range"), name); + try emitRange(cx, enc, uri, get(sym, "selectionRange") orelse get(sym, "range"), name); } - if (get(sym, "children")) |kids| walkSymbols(cx, enc, uri, kids, depth + 1); + if (get(sym, "children")) |kids| try walkSymbols(cx, enc, uri, kids, depth + 1); } } @@ -655,7 +656,7 @@ fn diagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, uri: []const u8, dea try jstr(&b, arena, uri); try app(&b, arena, "}"); const result = (try call(c, arena, "textDocument/diagnostic", b.items, deadline)) orelse return; - for (items(get(result, "items"))) |dg| emitDiag(cx, c.caps.enc, uri, dg, arena); + for (items(get(result, "items"))) |dg| try emitDiag(cx, c.caps.enc, uri, dg, arena); return; } var stale = true; @@ -664,7 +665,7 @@ fn diagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, uri: []const u8, dea break; }; if (stale) waitFresh(c, uri, nowMs() + diag_ms); - renderStore(c, arena, cx, uri); + try renderStore(c, arena, cx, uri); } fn workspaceDiagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, deadline: i64) Err!void { @@ -672,15 +673,15 @@ fn workspaceDiagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, deadline: i const result = (try call(c, arena, "workspace/diagnostic", "\"previousResultIds\":[]", deadline)) orelse return; for (items(get(result, "items"))) |per| { const uri = str(get(per, "uri")) orelse continue; - for (items(get(per, "items"))) |dg| emitDiag(cx, c.caps.enc, uri, dg, arena); + for (items(get(per, "items"))) |dg| try emitDiag(cx, c.caps.enc, uri, dg, arena); } return; } - renderStore(c, arena, cx, null); + try renderStore(c, arena, cx, null); } /// One diagnostic row: `severity: message`, at the diagnostic's own range. -fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.mem.Allocator) void { +fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.mem.Allocator) Err!void { const sev = num(get(dg, "severity")) orelse 1; const label: []const u8 = switch (sev) { 1 => "error", @@ -688,15 +689,15 @@ fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.m 3 => "info", else => "hint", }; - const msg = std.fmt.allocPrint(arena, "{s}: {s}", .{ label, flat(arena, str(get(dg, "message")) orelse "") }) catch return; - emitRange(cx, enc, uri, get(dg, "range"), msg); + const msg = try std.fmt.allocPrint(arena, "{s}: {s}", .{ label, flat(arena, str(get(dg, "message")) orelse "") }); + try emitRange(cx, enc, uri, get(dg, "range"), msg); } -fn renderStore(c: *Conn, arena: std.mem.Allocator, cx: *Cx, only_uri: ?[]const u8) void { +fn renderStore(c: *Conn, arena: std.mem.Allocator, cx: *Cx, only_uri: ?[]const u8) Err!void { for (c.diags.items) |d| { if (only_uri) |u| if (!std.mem.eql(u8, d.uri, u)) continue; const v = std.json.parseFromSliceLeaky(std.json.Value, arena, d.body, .{}) catch continue; - for (items(get(get(v, "params"), "diagnostics"))) |dg| emitDiag(cx, c.caps.enc, d.uri, dg, arena); + for (items(get(get(v, "params"), "diagnostics"))) |dg| try emitDiag(cx, c.caps.enc, d.uri, dg, arena); } } @@ -740,7 +741,7 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, const span = byteSpan(src, get(ed, "range"), enc) orelse return; const text = str(get(ed, "newText")) orelse return; edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; - } else emitRange(cx, enc, u, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); + } else try emitRange(cx, enc, u, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); } } } else if (get(result, "changes")) |ch| if (ch == .object) { @@ -753,7 +754,7 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, const span = byteSpan(src, get(ed, "range"), enc) orelse return; const text = str(get(ed, "newText")) orelse return; edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; - } else emitRange(cx, enc, e.key_ptr.*, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); + } else try emitRange(cx, enc, e.key_ptr.*, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); } } }; @@ -762,13 +763,13 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, // the preview needs the self-file rows too — the point is the full map for (edits.items) |ed| { const lc = lsp.lineCol(src, ed.start); - lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), lc.line, lc.col, flat(cx.arena, ed.text)); + try lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), lc.line, lc.col, flat(cx.arena, ed.text)); cx.rows += 1; } return; } sortEdits(edits.items); - for (edits.items) |ed| lsp.put(cx.out, ed.start, ed.end, ed.text); + for (edits.items) |ed| try lsp.put(cx.out, ed.start, ed.end, ed.text); } /// TextEdit[] from formatting is by definition about the current document: @@ -783,7 +784,7 @@ fn formatEdits(cx: *Cx, enc: Enc, result: std.json.Value, src: []const u8) Err!v edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; } sortEdits(edits.items); - for (edits.items) |ed| lsp.put(cx.out, ed.start, ed.end, ed.text); + for (edits.items) |ed| try lsp.put(cx.out, ed.start, ed.end, ed.text); } /// One would-be buffer mutation, on its way to an `@put` record. @@ -842,14 +843,14 @@ fn hierarchy(c: *Conn, si: usize, arena: std.mem.Allocator, cx: *Cx, uri: []cons const name = str(get(from, "name")) orelse ""; const ranges = items(get(entry, "fromRanges")); if (ranges.len == 0) { - emitRange(cx, c.caps.enc, fu, get(from, "selectionRange"), name); - } else for (ranges) |r| emitRange(cx, c.caps.enc, fu, r, name); + try emitRange(cx, c.caps.enc, fu, get(from, "selectionRange"), name); + } else for (ranges) |r| try emitRange(cx, c.caps.enc, fu, r, name); }, .outgoing => { const to = get(entry, "to") orelse continue; - emitRange(cx, c.caps.enc, str(get(to, "uri")) orelse continue, get(to, "selectionRange") orelse get(to, "range"), hierText(cx.arena, to)); + try emitRange(cx, c.caps.enc, str(get(to, "uri")) orelse continue, get(to, "selectionRange") orelse get(to, "range"), hierText(cx.arena, to)); }, - .supers, .subs => emitRange(cx, c.caps.enc, str(get(entry, "uri")) orelse continue, get(entry, "selectionRange") orelse get(entry, "range"), hierText(cx.arena, entry)), + .supers, .subs => try emitRange(cx, c.caps.enc, str(get(entry, "uri")) orelse continue, get(entry, "selectionRange") orelse get(entry, "range"), hierText(cx.arena, entry)), }; } } @@ -924,7 +925,7 @@ const Cx = struct { /// decoded, `rel`'d against the asking window), utf-16 columns become byte /// columns, and a single-line range becomes the `path:LINE:COL-ENDCOL` form a /// look SELECTS. `note` overrides the source line as the row's text. -fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: []const u8) void { +fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: []const u8) Err!void { if (cx.rows >= max_rows) return; const path = pathOf(cx.arena, uri) orelse return; if (path.len == 0 or path[0] != '/') return; // rows promise absolute-or-rel-from-base @@ -953,9 +954,9 @@ fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: [ // byte column; converting the exclusive utf-16 end unit yields the // exclusive byte column, which is the same number. const end_col = colBytes(lntext, r.ec, enc); - lsp.spanRow(cx.out, shown, r.sl, col, r.el, end_col, rowtext); + try lsp.spanRow(cx.out, shown, r.sl, col, r.el, end_col, rowtext); } else { - lsp.row(cx.out, shown, r.sl, col, rowtext); + try lsp.row(cx.out, shown, r.sl, col, rowtext); } cx.rows += 1; } @@ -1050,36 +1051,12 @@ fn flat(arena: std.mem.Allocator, s: []const u8) []const u8 { return std.mem.trim(u8, buf.items, " "); } -/// Close-on-exec by fcntl, the darwin route. Same three lines as fuse.zig's -/// and nested.zig's, and here for the same reason they have their own: this -/// file imports neither. fn setCloexec(fd: c_int) void { const FD_CLOEXEC: c_int = 1; _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); } -/// The client's transport: an AF_UNIX stream pair with both ends close-on-exec -/// and, on darwin, the parent end opted out of SIGPIPE. False if the host -/// refused, which is a dead server and not a dead editor. -/// -/// A named function rather than nine lines inside `ensure` because the one -/// thing it encodes is a PLATFORM LIE, and a test has to be able to call -/// exactly what the spawn calls. SOCK_CLOEXEC is a LINUX flag; zig spells -/// `SOCK.CLOEXEC` for darwin too — as 0x10000000, with "does not exist on -/// darwin but is used in std.net" in the comment beside it — and darwin's -/// socketpair(2) validates `type` strictly, so asking for it there returns -/// EPROTONOSUPPORT. Every server spawn on macOS failed on that line, before -/// the fork: no binary probe, no handshake, no message row, just `NoServer` in -/// 100µs from a client that had never once run on the platform it was written -/// on. The end-to-end suite that would have caught it (test/snapshots/ -/// lsp-client.snap) only ever runs against the linux target, where the flag is -/// real. fuse.zig and nested.zig already took the plain-socket-plus-fcntl -/// route; this was the one caller that did not. -/// -/// THE WINDOW THIS LEAVES, the same one host_io.zig states for the pty master: -/// fcntl after socketpair is not atomic, so another thread that forks and -/// execs in between inherits both ends. Linux closes it with the flag; darwin -/// has no socketpair that takes one. +// Darwin needs fcntl after socketpair; another concurrent fork can inherit the pair in that window. fn transportPair(sv: *[2]libc.fd_t) bool { const sock_type = if (comptime builtin.os.tag.isDarwin()) libc.SOCK.STREAM @@ -2065,6 +2042,35 @@ fn coord(v: ?std.json.Value) ?u32 { // ----------------------------------------------------------------- tests +test "LSP client rename and format propagate incomplete edit encoding" { + const gpa = std.testing.allocator; + const uri = "file:///file.c"; + const parsed = try std.json.parseFromSlice(std.json.Value, gpa, + \\{"changes":{"file:///file.c":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":3}},"newText":"A%\n"},{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}},"newText":"B"}]}} + , .{}); + defer parsed.deinit(); + const expected = "@put 0 3 A%25%0A\n@put 4 7 B\n"; + for ([_]bool{ true, false }) |rename| { + var buffer: [128]u8 = undefined; + for (0..expected.len + 1) |capacity| { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.Io.Writer = .fixed(buffer[0..capacity]); + var cx: Cx = .{ .arena = arena.allocator(), .base = "/", .cur_path = "/file.c", .cur_src = "abc xyz", .out = &out }; + const result = if (rename) + renameEdits(&cx, .utf8, uri, parsed.value, cx.cur_src) + else + formatEdits(&cx, .utf8, get(get(parsed.value, "changes"), uri).?, cx.cur_src); + if (capacity < expected.len) { + try std.testing.expectError(error.WriteFailed, result); + } else { + try result; + try std.testing.expectEqualStrings(expected, out.buffered()); + } + } + } +} + test "frameNext distinguishes incomplete, valid and poison frames" { try std.testing.expectEqual(FrameStep.incomplete, frameNext("Content-Length: 5\r\n")); try std.testing.expectEqual(FrameStep.incomplete, frameNext("Content-Length: 5\r\n\r\nhel")); diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig index fb9804e6..47e6a165 100644 --- a/src/lsp/lsp_zls.zig +++ b/src/lsp/lsp_zls.zig @@ -189,47 +189,41 @@ const Trace = struct { } }; -/// 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.Io.Writer) void { +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { // status reads the log; recording it would push a real query out of a // 24-entry ring every time you looked at it. - if (req.kind == .status) return status(arena, req, out) catch {}; + if (req.kind == .status) return status(arena, req, out); - // Rows land in bounded scratch storage first, for two reasons a plain - // Writer cannot serve: the log wants an exact row count, and `explain` - // throws the rows away and prints the narration in their place. A query - // that exceeds the row budget's byte allowance keeps its ordered prefix. - const scratch_buf = gpa.alloc(u8, max_output_bytes) catch return; + const scratch_buf = try gpa.alloc(u8, max_output_bytes); defer gpa.free(scratch_buf); var scratch: std.Io.Writer = .fixed(scratch_buf); var tr: Trace = .{ .on = req.kind == .explain }; const t0 = nowUs(); var err_name: []const u8 = ""; + var failure: ?anyerror = null; run(gpa, arena, req, &scratch, &tr) catch |e| { + failure = e; err_name = @errorName(e); - tr.note("ERROR: {s} — the query threw; the editor shows this as 'no result'", .{err_name}); + tr.note("ERROR: {s}", .{err_name}); }; const us = nowUs() -| t0; const rows = std.mem.count(u8, scratch.buffered(), "\n"); record(req, us, rows, err_name); if (req.kind != .explain) { - out.writeAll(scratch.buffered()) catch {}; + if (failure) |err| return err; + try out.writeAll(scratch.buffered()); return; } - // the question was never "where is it", it was "what did you do" - out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ + try out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), - }) catch {}; - out.writeAll(tr.written()) catch {}; + }); + try out.writeAll(tr.written()); if (hideTime()) - out.print("\n{d} row(s)\n", .{rows}) catch {} + try out.print("\n{d} row(s)\n", .{rows}) else - out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {}; + try out.print("\n{d} row(s) in {d}us\n", .{ rows, us }); } fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) !void { @@ -518,7 +512,7 @@ fn rowForToken(arena: std.mem.Allocator, base: []const u8, th: Analyser.TokenWit if (th.token >= tree.tokens.len) return; const r = offsets.tokenToRange(tree, th.token, enc); const path = lsp.rel(base, th.handle.uri.toFsPath(arena) catch return); - lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(tree.source, r.start.line)); + try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(tree.source, r.start.line)); } // ---------------------------------------------------------------- goto @@ -627,7 +621,7 @@ fn goto( // knows the graph and baked it in; consult that. if (moduleRoot(str)) |path| { tr.note("`{s}` is a build.zig dependency; resolved from the compiled-in module map", .{str}); - lsp.row(out, lsp.rel(base, path), 0, 0, str); + try lsp.row(out, lsp.rel(base, path), 0, 0, str); return; } tr.note("STOP: `{s}` does not resolve to a file. Relative paths, `std` and this", .{str}); @@ -635,8 +629,8 @@ fn goto( tr.note(" OWN internal module names need the build graph we do not run.", .{}); return; }, - .one => |u| lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch return), 0, 0, str), - .many => |us| for (us) |u| lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch continue), 0, 0, str), + .one => |u| try lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch return), 0, 0, str), + .many => |us| for (us) |u| try lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch continue), 0, 0, str), } return; }, @@ -673,7 +667,7 @@ fn goto( const t2 = &nd.handle.tree; const rr = offsets.nodeToRange(t2, nd.node, enc); const path = lsp.rel(base, nd.handle.uri.toFsPath(arena) catch continue); - lsp.spanRow(out, path, rr.start.line, rr.start.character, rr.end.line, rr.end.character, lineAt(t2.source, rr.start.line)); + try lsp.spanRow(out, path, rr.start.line, rr.start.character, rr.end.line, rr.end.character, lineAt(t2.source, rr.start.line)); }, } } @@ -973,7 +967,7 @@ fn completion( r.start.character -= @intCast(pad); if (r.end.line == r.start.line) r.end.character -= @intCast(pad); } - lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, text); + try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, text); n += 1; } } @@ -999,7 +993,7 @@ fn hover( var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |ln| { if (std.mem.startsWith(u8, ln, "```")) continue; - out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}) catch return; + try out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}); } } @@ -1043,7 +1037,7 @@ fn emitSymbols( try std.fmt.allocPrint(arena, "{s} {s}", .{ name, d }) else name; - lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, text); + try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, text); if (s.children) |kids| try emitSymbols(gpa, arena, path, kids, name, n, out); } } @@ -1091,7 +1085,7 @@ fn filterSymbols( try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); if (containsIgnoreCase(s.name, needle)) { n.* += 1; - lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, name); + try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, name); } if (s.children) |kids| try filterSymbols(gpa, arena, path, kids, name, needle, n, out); } @@ -1155,19 +1149,23 @@ fn references( const path = if (new_name == null) lsp.rel(base, handle.uri.toFsPath(arena) catch return) else ""; var n: usize = 0; for (0..tree.tokens.len) |i| { - if (n >= max_rows) return; + if (new_name == null and 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; + const d = (declAt(arena, analyser, handle, at) catch |err| { + if (new_name != null) return err; + continue; + }) orelse continue; if (!d.eql(target)) continue; + if (n >= max_rows) return error.TooManyEdits; n += 1; if (new_name != null) { - lsp.edit(out, at, at + want.len); + try lsp.edit(out, at, at + want.len); } else { const r = offsets.tokenToRange(tree, tok, enc); - lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lines.?.line(r.start.line)); + try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lines.?.line(r.start.line)); } } } @@ -1216,7 +1214,7 @@ fn diagnostics( out: *std.Io.Writer, ) !void { const n = try treeDiagnostics(gpa, arena, path, tree, out); - if (n == 0) lsp.row(out, path, 0, 0, "no diagnostics"); + if (n == 0) try lsp.row(out, path, 0, 0, "no diagnostics"); } /// `zig ast-check`, in this process. ZLS spawns the compiler for this when it @@ -1258,7 +1256,7 @@ fn treeDiagnostics( const at = tree.tokenStart(e.token); const lc = lsp.lineCol(tree.source, at); n += 1; - lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{ + try lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{ w.buffered(), std.mem.trim(u8, lines.line(lc.line), " \t"), })); } @@ -1274,7 +1272,7 @@ fn treeDiagnostics( if (em.src_loc == .none) continue; const sl = bundle.getSourceLocation(em.src_loc); n += 1; - lsp.row(out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{ + try lsp.row(out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{ bundle.nullTerminatedString(em.msg), })); } @@ -1298,10 +1296,10 @@ fn workspaceDiagnostics( 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, lsp.rel(req.root, path), &tree, out) catch continue; + total += try treeDiagnostics(gpa, arena, lsp.rel(req.root, path), &tree, out); if (total >= max_rows) return; } - if (total == 0) lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.len})); + if (total == 0) try lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.len})); } // ----------------------------------------------------------- code actions @@ -1323,7 +1321,7 @@ fn codeActions( ) !void { const tree = &handle.tree; if (tree.errors.len != 0) { - out.print("no code actions: file does not parse\n", .{}) catch {}; + try out.print("no code actions: file does not parse\n", .{}); return; } var bundle = try astCheck(gpa, "", tree); @@ -1345,9 +1343,9 @@ fn codeActions( builder.generateCodeActionsInRange(at) catch {}; for (builder.actions.items[0..@min(builder.actions.items.len, max_rows)]) |a| { - out.print("{s}\n", .{a.title}) catch return; + try out.print("{s}\n", .{a.title}); } - if (builder.actions.items.len == 0) out.print("no code actions\n", .{}) catch {}; + if (builder.actions.items.len == 0) try out.print("no code actions\n", .{}); } // ---------------------------------------------------------------- format @@ -1362,19 +1360,19 @@ fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !voi var tree: Ast = try .parse(arena, req.source, .zig); defer tree.deinit(arena); if (tree.errors.len != 0) { - lsp.row(out, path, 0, 0, "cannot format: file does not parse"); + try lsp.row(out, path, 0, 0, "cannot format: file does not parse"); return; } var count_buf: [4096]u8 = undefined; var counting: std.Io.Writer.Discarding = .init(&count_buf); - tree.render(arena, &counting.writer, .{}) catch return; + try tree.render(arena, &counting.writer, .{}); const size = std.math.cast(usize, counting.fullCount()) orelse return; const render_buf = try arena.alloc(u8, size); var w: std.Io.Writer = .fixed(render_buf); - tree.render(arena, &w, .{}) catch return; + try tree.render(arena, &w, .{}); const formatted = w.buffered(); if (std.mem.eql(u8, formatted, req.source)) { - lsp.row(out, path, 0, 0, "already formatted"); + try lsp.row(out, path, 0, 0, "already formatted"); return; } // one record, spanning only what changed: the common prefix and suffix @@ -1388,7 +1386,7 @@ fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !voi src_hi -= 1; fmt_hi -= 1; } - lsp.put(out, lo, src_hi, formatted[lo..fmt_hi]); + try lsp.put(out, lo, src_hi, formatted[lo..fmt_hi]); } // ------------------------------------------------------------------ files @@ -1426,3 +1424,53 @@ fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8) ![]co 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); } + +test "LSP ZLS format propagates output failure and preserves successful encoding" { + const gpa = std.testing.allocator; + const req: lsp.Req = .{ .kind = .format, .path = "/file.zig", .source = "const value=1;\n", .offset = 6 }; + const expected = "@put 11 12 %20=%20\n"; + var buffer: [128]u8 = undefined; + for ([_]usize{ 0, expected.len - 1, expected.len }) |capacity| { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.Io.Writer = .fixed(buffer[0..capacity]); + const result = query(gpa, arena.allocator(), req, &out); + if (capacity < expected.len) { + try std.testing.expectError(error.WriteFailed, result); + } else { + try result; + try std.testing.expectEqualStrings(expected, out.buffered()); + } + } +} + +test "LSP ZLS rename refuses a partial edit set beyond its row budget" { + const gpa = std.testing.allocator; + for ([_]usize{ max_rows - 1, max_rows }) |references_count| { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var source: std.Io.Writer.Allocating = .init(gpa); + defer source.deinit(); + try source.writer.writeAll("const value: u32 = 1;\nfn use() void {\n"); + for (0..references_count) |_| try source.writer.writeAll(" _ = value;\n"); + try source.writer.writeAll("}\n"); + const text = try gpa.dupeZ(u8, source.written()); + defer gpa.free(text); + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + const result = query(gpa, arena.allocator(), .{ + .kind = .rename, + .path = "/rename.zig", + .source = text, + .offset = 6, + .arg = "renamed", + }, &out.writer); + if (references_count < max_rows) { + try result; + try std.testing.expectEqual(max_rows, std.mem.count(u8, out.written(), "@edit ")); + } else { + try std.testing.expectError(error.TooManyEdits, result); + try std.testing.expectEqual(@as(usize, 0), out.written().len); + } + } +} |
