//! A deterministic language server for the protocol client's tests. //! //! Speaks just enough LSP over stdin/stdout to pin the client end to end //! WITHOUT a real analyser in the loop: every answer is a fixed shape over //! the request's own uri, so a golden can quote it byte for byte. The //! fixture it narrates is test/lspfixture/client/ — the ranges below are //! POSITIONS IN THOSE FILES, and moving either without the other breaks the //! snapshot, which is the point. //! //! The snapshot harness points the client at this binary with //! `PARDES_LSP_RS=`; nothing outside a test should ever run it. //! //! `LSPMOCK_MODE` selects a failure personality for the degradation tests: //! (unset) answer everything //! mute handshake, then never answer another request (timeout path) //! die exit right after the handshake (dead-server path) //! utf16 refuse the utf-8 negotiation (column-conversion path) const std = @import("std"); const libc = std.c; extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8; var mode: enum { normal, mute, die, utf16 } = .normal; /// stdout, written with plain libc write(2): the transport is a socketpair /// and the frames are small, so partial writes are retried and nothing else /// is needed. fn send(body: []const u8) void { var hdr_buf: [64]u8 = undefined; const hdr = std.fmt.bufPrint(&hdr_buf, "Content-Length: {d}\r\n\r\n", .{body.len}) catch return; writeAll(hdr); writeAll(body); } fn writeAll(bytes: []const u8) void { var off: usize = 0; while (off < bytes.len) { const n = libc.write(1, bytes.ptr + off, bytes.len - off); if (n < 0) { if (libc.errno(n) == .INTR) continue; std.process.exit(0); // client hung up; nothing left to say } off += @intCast(n); } } fn reply(a: std.mem.Allocator, id: i64, comptime result_fmt: []const u8, args: anytype) void { const body = std.fmt.allocPrint(a, "{{\"jsonrpc\":\"2.0\",\"id\":{d},\"result\":" ++ result_fmt ++ "}}", .{id} ++ args) catch return; send(body); } fn notify(a: std.mem.Allocator, comptime method: []const u8, comptime params_fmt: []const u8, args: anytype) void { const body = std.fmt.allocPrint(a, "{{\"jsonrpc\":\"2.0\",\"method\":\"" ++ method ++ "\",\"params\":" ++ params_fmt ++ "}}", args) catch return; send(body); } // std.json.Value navigation, the client's own idiom. fn get(v: ?std.json.Value, key: []const u8) ?std.json.Value { const o = v orelse return null; if (o != .object) return null; return o.object.get(key); } fn str(v: ?std.json.Value) ?[]const u8 { const o = v orelse return null; return if (o == .string) o.string else null; } fn num(v: ?std.json.Value) ?i64 { const o = v orelse return null; return if (o == .integer) o.integer else null; } /// The request's document uri — under `textDocument` for most methods, under /// `item` for the hierarchy follow-ups. Every cross-file answer derives from /// it, so the mock stays inside the caller's own fixture tree. fn reqUri(params: ?std.json.Value) []const u8 { return str(get(get(params, "textDocument"), "uri")) orelse str(get(get(params, "item"), "uri")) orelse "file:///dev/null"; } /// The same uri with its basename replaced. fn sibling(a: std.mem.Allocator, uri: []const u8, name: []const u8) []const u8 { const cut = std.mem.lastIndexOfScalar(u8, uri, '/') orelse return uri; return std.fmt.allocPrint(a, "{s}/{s}", .{ uri[0..cut], name }) catch uri; } /// `{"start":{"line":L,"character":A},"end":{"line":L,"character":B}}` fn range(a: std.mem.Allocator, line: u32, from: u32, to: u32) []const u8 { return std.fmt.allocPrint( a, "{{\"start\":{{\"line\":{d},\"character\":{d}}},\"end\":{{\"line\":{d},\"character\":{d}}}}}", .{ line, from, line, to }, ) catch "{}"; } /// rootUri captured at initialize — workspace/symbol has no textDocument, so /// its answers derive from here instead of the /dev/null fallback. var root_uri: [512]u8 = undefined; var root_uri_len: usize = 0; pub fn main(init: std.process.Init) !void { _ = init; if (getenv("LSPMOCK_MODE")) |m| { const s = std.mem.span(m); if (std.mem.eql(u8, s, "mute")) mode = .mute; if (std.mem.eql(u8, s, "die")) mode = .die; if (std.mem.eql(u8, s, "utf16")) mode = .utf16; } const gpa = std.heap.page_allocator; var buf = try gpa.alloc(u8, 1 << 20); var len: usize = 0; while (true) { // one Content-Length frame at the head of buf const msg: ?[]const u8 = blk: { const sep = std.mem.indexOf(u8, buf[0..len], "\r\n\r\n") orelse break :blk null; var clen: ?usize = null; var it = std.mem.splitSequence(u8, buf[0..sep], "\r\n"); while (it.next()) |ln| { const colon = std.mem.indexOfScalar(u8, ln, ':') orelse continue; if (std.ascii.eqlIgnoreCase(std.mem.trim(u8, ln[0..colon], " "), "content-length")) clen = std.fmt.parseInt(usize, std.mem.trim(u8, ln[colon + 1 ..], " "), 10) catch null; } const n = clen orelse std.process.exit(1); if (len < sep + 4 + n) break :blk null; break :blk buf[sep + 4 .. sep + 4 + n]; }; const raw = msg orelse { if (len == buf.len) buf = try gpa.realloc(buf, buf.len * 2); const got = libc.read(0, buf.ptr + len, buf.len - len); if (got < 0) { if (libc.errno(got) == .INTR) continue; return; } if (got == 0) return; // editor hung up len += @intCast(got); continue; }; var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const a = arena_state.allocator(); handle(a, raw); // compact the consumed frame away const consumed = (@intFromPtr(raw.ptr) - @intFromPtr(buf.ptr)) + raw.len; std.mem.copyForwards(u8, buf[0 .. len - consumed], buf[consumed..len]); len -= consumed; } } fn handle(a: std.mem.Allocator, raw: []const u8) void { const v = std.json.parseFromSliceLeaky(std.json.Value, a, raw, .{}) catch return; const method = str(get(v, "method")) orelse ""; const id = num(get(v, "id")); const params = get(v, "params"); if (std.mem.eql(u8, method, "initialize")) { if (str(get(params, "rootUri"))) |r| { root_uri_len = @min(r.len, root_uri.len); @memcpy(root_uri[0..root_uri_len], r[0..root_uri_len]); } const enc: []const u8 = if (mode == .utf16) "utf-16" else "utf-8"; reply(a, id orelse 0, \\{{"capabilities":{{"positionEncoding":"{s}", \\"textDocumentSync":1, \\"definitionProvider":true,"declarationProvider":true, \\"typeDefinitionProvider":true,"implementationProvider":true, \\"referencesProvider":true,"documentHighlightProvider":true, \\"hoverProvider":true,"documentSymbolProvider":true, \\"workspaceSymbolProvider":true,"renameProvider":true, \\"documentFormattingProvider":true,"codeActionProvider":true, \\"completionProvider":{{}}, \\"callHierarchyProvider":true,"typeHierarchyProvider":true, \\"diagnosticProvider":{{"interFileDependencies":false,"workspaceDiagnostics":false}}, \\"workspace":{{"workspaceFolders":{{"supported":true}}}}}}, \\"serverInfo":{{"name":"lspmock"}}}} , .{enc}); return; } if (std.mem.eql(u8, method, "initialized")) { if (mode == .die) std.process.exit(0); // the progress trio, so the status pipeline has something to narrate notify(a, "$/progress", "{{\"token\":\"t1\",\"value\":{{\"kind\":\"begin\",\"title\":\"mocking\"}}}}", .{}); notify(a, "$/progress", "{{\"token\":\"t1\",\"value\":{{\"kind\":\"report\",\"percentage\":50,\"message\":\"halfway\"}}}}", .{}); notify(a, "$/progress", "{{\"token\":\"t1\",\"value\":{{\"kind\":\"end\"}}}}", .{}); return; } if (id == null) return; // didOpen/didChange/$/cancelRequest: nothing to say if (mode == .mute) return; // swallow every request after the handshake const rid = id.?; const uri = reqUri(params); // ---- fixture geometry (0-based lines/chars, over lspfixture/client/) -- // main.rs L3/L4: ` let x = add(1, 2);` — "add" at 12..15 // lib.rs L1: `pub fn add(a: i32, b: i32) -> i32 {` — "add" at 7..10 // main.rs L2: `fn main() {` — "main" at 3..7 const lib = sibling(a, uri, "lib.rs"); const mainrs = sibling(a, uri, "main.rs"); if (std.mem.eql(u8, method, "textDocument/definition") or std.mem.eql(u8, method, "textDocument/declaration") or std.mem.eql(u8, method, "textDocument/typeDefinition") or std.mem.eql(u8, method, "textDocument/implementation")) { reply(a, rid, "[{{\"uri\":\"{s}\",\"range\":{s}}}]", .{ lib, range(a, 1, 7, 10) }); } else if (std.mem.eql(u8, method, "textDocument/references")) { reply(a, rid, "[{{\"uri\":\"{s}\",\"range\":{s}}},{{\"uri\":\"{s}\",\"range\":{s}}},{{\"uri\":\"{s}\",\"range\":{s}}}]", .{ mainrs, range(a, 3, 12, 15), mainrs, range(a, 4, 12, 15), lib, range(a, 1, 7, 10), }); } else if (std.mem.eql(u8, method, "textDocument/documentHighlight")) { reply(a, rid, "[{{\"range\":{s}}},{{\"range\":{s}}}]", .{ range(a, 3, 12, 15), range(a, 4, 12, 15) }); } else if (std.mem.eql(u8, method, "textDocument/hover")) { reply(a, rid, "{{\"contents\":{{\"kind\":\"markdown\",\"value\":\"mock hover: pub fn add\"}}}}", .{}); } else if (std.mem.eql(u8, method, "textDocument/documentSymbol")) { reply(a, rid, "[{{\"name\":\"main\",\"kind\":12,\"range\":{s},\"selectionRange\":{s}}}]", .{ range(a, 2, 0, 11), range(a, 2, 3, 7), }); } else if (std.mem.eql(u8, method, "workspace/symbol")) { const q = str(get(params, "query")) orelse ""; reply(a, rid, "[{{\"name\":\"{s}_sym\",\"kind\":12,\"location\":{{\"uri\":\"{s}/lib.rs\",\"range\":{s}}}}}]", .{ q, root_uri[0..root_uri_len], range(a, 1, 7, 10), }); } else if (std.mem.eql(u8, method, "textDocument/rename")) { const nn = str(get(params, "newName")) orelse "renamed"; reply(a, rid, "{{\"changes\":{{\"{s}\":[{{\"range\":{s},\"newText\":\"{s}\"}},{{\"range\":{s},\"newText\":\"{s}\"}}]}}}}", .{ uri, range(a, 3, 12, 15), nn, range(a, 4, 12, 15), nn, }); } else if (std.mem.eql(u8, method, "textDocument/formatting")) { reply(a, rid, "[{{\"range\":{s},\"newText\":\"// formatted by mock\\n\"}}]", .{range(a, 0, 0, 0)}); } else if (std.mem.eql(u8, method, "textDocument/codeAction")) { reply(a, rid, "[{{\"title\":\"mock action: extract\"}}]", .{}); } else if (std.mem.eql(u8, method, "textDocument/completion")) { reply(a, rid, "{{\"items\":[{{\"label\":\"alpha\",\"detail\":\"fn alpha()\"}},{{\"label\":\"beta\",\"detail\":\"fn beta()\"}}]}}", .{}); } else if (std.mem.eql(u8, method, "textDocument/diagnostic")) { reply(a, rid, "{{\"kind\":\"full\",\"items\":[{{\"range\":{s},\"severity\":2,\"message\":\"mock diagnostic\"}}]}}", .{range(a, 0, 0, 7)}); } else if (std.mem.eql(u8, method, "textDocument/prepareCallHierarchy")) { reply(a, rid, "[{{\"name\":\"add\",\"kind\":12,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{ lib, range(a, 1, 0, 35), range(a, 1, 7, 10), }); } else if (std.mem.eql(u8, method, "callHierarchy/incomingCalls")) { reply(a, rid, "[{{\"from\":{{\"name\":\"main\",\"kind\":12,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}},\"fromRanges\":[{s},{s}]}}]", .{ mainrs, range(a, 2, 0, 11), range(a, 2, 3, 7), range(a, 3, 12, 15), range(a, 4, 12, 15), }); } else if (std.mem.eql(u8, method, "callHierarchy/outgoingCalls")) { reply(a, rid, "[{{\"to\":{{\"name\":\"callee\",\"kind\":12,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}},\"fromRanges\":[]}}]", .{ lib, range(a, 1, 0, 35), range(a, 1, 7, 10), }); } else if (std.mem.eql(u8, method, "textDocument/prepareTypeHierarchy")) { reply(a, rid, "[{{\"name\":\"MockType\",\"kind\":5,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{ uri, range(a, 2, 3, 7), range(a, 2, 3, 7), }); } else if (std.mem.eql(u8, method, "typeHierarchy/supertypes")) { reply(a, rid, "[{{\"name\":\"SuperMock\",\"kind\":5,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{ lib, range(a, 1, 7, 10), range(a, 1, 7, 10), }); } else if (std.mem.eql(u8, method, "typeHierarchy/subtypes")) { reply(a, rid, "[{{\"name\":\"SubMock\",\"kind\":5,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{ mainrs, range(a, 2, 3, 7), range(a, 2, 3, 7), }); } else if (std.mem.eql(u8, method, "shutdown")) { reply(a, rid, "null", .{}); } else { reply(a, rid, "null", .{}); } }