From c8399947f9b040518f687089b49b83097176c4fa Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 20 May 2026 21:58:59 +0200 Subject: [PATCH 01/19] feat(lsp): add Content-Length framing and JSON-RPC codec src/lsp/codec.zig handles two concerns: - Framing: readFrame/writeFrame move framed bodies between Io.Reader /Io.Writer and []u8. Recognizes Content-Length, ignores other headers. - Parsing helpers: extractId, extractMethod, hasResult, hasErrorField, extractError inspect a parsed std.json.Value to route LSP messages. Encode helpers (encodeRequest, encodeNotification) build JSON-RPC bodies from a method name plus params Value. Server error codes enum mirrors SPEC.md "Locked-in technical choices". 10 unit tests cover framing roundtrip, header malformations, encode roundtrip, and Id equality. --- src/lsp/codec.zig | 307 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 src/lsp/codec.zig diff --git a/src/lsp/codec.zig b/src/lsp/codec.zig new file mode 100644 index 0000000..7a3a30d --- /dev/null +++ b/src/lsp/codec.zig @@ -0,0 +1,307 @@ +//! LSP Content-Length framing and JSON-RPC 2.0 encode/decode helpers. +//! +//! Split in two layers: +//! - Framing: `readFrame` / `writeFrame` move framed body bytes between an +//! `Io.Reader` / `Io.Writer` and a `[]u8`. +//! - Parsing: helpers that inspect a parsed `std.json.Value` to extract the +//! id, method, result, or error of an incoming message. The bare minimum +//! the client needs to route a response. +//! +//! Encode helpers (`encodeRequest`, `encodeNotification`) build a JSON-RPC +//! body from a method name plus params Value, returning owned bytes. + +const std = @import("std"); +const Io = std.Io; + +pub const JSON_RPC_VERSION = "2.0"; + +/// JSON-RPC id field. The LSP spec allows numbers or strings; null appears +/// in responses to notifications-treated-as-requests on some servers. +pub const Id = union(enum) { + int: i64, + /// Slice borrowed from the parsed JSON tree. + string: []const u8, + none, + + pub fn eql(a: Id, b: Id) bool { + return switch (a) { + .int => |ai| switch (b) { + .int => |bi| ai == bi, + else => false, + }, + .string => |as| switch (b) { + .string => |bs| std.mem.eql(u8, as, bs), + else => false, + }, + .none => switch (b) { + .none => true, + else => false, + }, + }; + } +}; + +/// JSON-RPC error object. `message` is borrowed from the parsed JSON tree. +pub const ErrorObject = struct { + code: i32, + message: []const u8, +}; + +/// Standard and custom server error codes referenced from SPEC.md +/// "Locked-in technical choices". +pub const ServerErrorCode = enum(i32) { + parse_error = -32700, + invalid_request = -32600, + method_not_found = -32601, + invalid_params = -32602, + internal_error = -32603, + // Custom MCP-side codes + zls_unavailable = -32001, + workspace_not_initialized = -32002, + symbol_ambiguous = -32003, +}; + +pub const FramingError = error{ + /// Stream closed before headers or body were complete. + EndOfStream, + /// Headers were present but no Content-Length was found. + NoContentLength, + /// Content-Length header was present but its value did not parse as an integer. + InvalidContentLength, + /// A header line was malformed (missing `:` or exceeded the reader buffer). + HeaderTooLong, + /// Underlying reader returned an error. + ReadFailed, + OutOfMemory, +}; + +/// Read one Content-Length framed body from `reader`. Returns body bytes +/// allocated with `gpa`. Caller owns the slice. +/// +/// Recognizes the `Content-Length` header (case-insensitive). All other +/// headers are silently ignored. +pub fn readFrame(reader: *Io.Reader, gpa: std.mem.Allocator) FramingError![]u8 { + var content_length: ?usize = null; + while (true) { + const line = reader.takeDelimiterInclusive('\n') catch |err| switch (err) { + error.EndOfStream => return error.EndOfStream, + error.ReadFailed => return error.ReadFailed, + error.StreamTooLong => return error.HeaderTooLong, + }; + const trimmed = std.mem.trimEnd(u8, line, "\r\n"); + if (trimmed.len == 0) break; // blank line ends headers + const colon = std.mem.indexOfScalar(u8, trimmed, ':') orelse return error.HeaderTooLong; + const key = std.mem.trim(u8, trimmed[0..colon], " \t"); + const value = std.mem.trim(u8, trimmed[colon + 1 ..], " \t"); + if (std.ascii.eqlIgnoreCase(key, "Content-Length")) { + content_length = std.fmt.parseInt(usize, value, 10) catch return error.InvalidContentLength; + } + } + const len = content_length orelse return error.NoContentLength; + const body = try gpa.alloc(u8, len); + errdefer gpa.free(body); + reader.readSliceAll(body) catch |err| switch (err) { + error.EndOfStream => return error.EndOfStream, + error.ReadFailed => return error.ReadFailed, + }; + return body; +} + +/// Write `body` framed with `Content-Length: N\r\n\r\n` to `writer`. +/// Does not flush; caller flushes when ready. +pub fn writeFrame(writer: *Io.Writer, body: []const u8) Io.Writer.Error!void { + var hdr_buf: [64]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "Content-Length: {d}\r\n\r\n", .{body.len}) catch unreachable; + try writer.writeAll(hdr); + try writer.writeAll(body); +} + +/// Build a JSON-RPC request body +/// `{"jsonrpc":"2.0","id":N,"method":...,"params":...}`. Caller owns the +/// returned bytes. +pub fn encodeRequest( + gpa: std.mem.Allocator, + id: u32, + method: []const u8, + params: std.json.Value, +) std.mem.Allocator.Error![]u8 { + var obj: std.json.ObjectMap = .empty; + defer obj.deinit(gpa); + try obj.put(gpa, "jsonrpc", .{ .string = JSON_RPC_VERSION }); + try obj.put(gpa, "id", .{ .integer = id }); + try obj.put(gpa, "method", .{ .string = method }); + try obj.put(gpa, "params", params); + return std.json.Stringify.valueAlloc(gpa, std.json.Value{ .object = obj }, .{}); +} + +/// Build a JSON-RPC notification body +/// `{"jsonrpc":"2.0","method":...,"params":...}`. Caller owns the returned +/// bytes. +pub fn encodeNotification( + gpa: std.mem.Allocator, + method: []const u8, + params: std.json.Value, +) std.mem.Allocator.Error![]u8 { + var obj: std.json.ObjectMap = .empty; + defer obj.deinit(gpa); + try obj.put(gpa, "jsonrpc", .{ .string = JSON_RPC_VERSION }); + try obj.put(gpa, "method", .{ .string = method }); + try obj.put(gpa, "params", params); + return std.json.Stringify.valueAlloc(gpa, std.json.Value{ .object = obj }, .{}); +} + +/// Extract the "id" field from a parsed JSON-RPC message. Returns null if +/// the field is absent. The string variant references the parsed tree. +pub fn extractId(value: std.json.Value) ?Id { + if (value != .object) return null; + const id_val = value.object.get("id") orelse return null; + return switch (id_val) { + .integer => |i| Id{ .int = i }, + .string => |s| Id{ .string = s }, + .null => Id.none, + else => null, + }; +} + +/// Extract the "method" field as a string. Returns null if absent or wrong +/// type. The string references the parsed tree. +pub fn extractMethod(value: std.json.Value) ?[]const u8 { + if (value != .object) return null; + const m = value.object.get("method") orelse return null; + return switch (m) { + .string => |s| s, + else => null, + }; +} + +/// True if the parsed value has a "result" key (i.e. is a successful response). +pub fn hasResult(value: std.json.Value) bool { + return value == .object and value.object.contains("result"); +} + +/// True if the parsed value has an "error" key (i.e. is an error response). +pub fn hasErrorField(value: std.json.Value) bool { + return value == .object and value.object.contains("error"); +} + +/// Extract the "error" object from a response. Returns null if the field is +/// absent or malformed. +pub fn extractError(value: std.json.Value) ?ErrorObject { + if (value != .object) return null; + const err_val = value.object.get("error") orelse return null; + if (err_val != .object) return null; + const code_val = err_val.object.get("code") orelse return null; + if (code_val != .integer) return null; + const msg_val = err_val.object.get("message") orelse return null; + if (msg_val != .string) return null; + return ErrorObject{ + .code = @intCast(code_val.integer), + .message = msg_val.string, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "writeFrame + readFrame roundtrip" { + var write_buf: [4096]u8 = undefined; + var writer = Io.Writer.fixed(&write_buf); + const body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"hello\"}"; + try writeFrame(&writer, body); + const framed = writer.buffered(); + + var reader = Io.Reader.fixed(framed); + const got = try readFrame(&reader, testing.allocator); + defer testing.allocator.free(got); + try testing.expectEqualStrings(body, got); +} + +test "readFrame accepts and ignores Content-Type header" { + const input = "Content-Length: 2\r\nContent-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n{}"; + var reader = Io.Reader.fixed(input); + const got = try readFrame(&reader, testing.allocator); + defer testing.allocator.free(got); + try testing.expectEqualStrings("{}", got); +} + +test "readFrame: invalid Content-Length yields InvalidContentLength" { + const input = "Content-Length: abc\r\n\r\n{}"; + var reader = Io.Reader.fixed(input); + try testing.expectError(error.InvalidContentLength, readFrame(&reader, testing.allocator)); +} + +test "readFrame: missing Content-Length yields NoContentLength" { + const input = "Content-Type: application/json\r\n\r\n{}"; + var reader = Io.Reader.fixed(input); + try testing.expectError(error.NoContentLength, readFrame(&reader, testing.allocator)); +} + +test "readFrame: empty stream yields EndOfStream" { + var reader = Io.Reader.fixed(""); + try testing.expectError(error.EndOfStream, readFrame(&reader, testing.allocator)); +} + +test "encodeRequest roundtrip via parseFromSlice" { + const params = std.json.Value{ .integer = 42 }; + const body = try encodeRequest(testing.allocator, 7, "test/method", params); + defer testing.allocator.free(body); + + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, body, .{}); + defer parsed.deinit(); + const id = extractId(parsed.value) orelse return error.TestExpectedId; + try testing.expectEqual(@as(i64, 7), id.int); + const method = extractMethod(parsed.value) orelse return error.TestExpectedMethod; + try testing.expectEqualStrings("test/method", method); + try testing.expectEqualStrings("2.0", parsed.value.object.get("jsonrpc").?.string); +} + +test "encodeNotification has no id" { + const params = std.json.Value{ .null = {} }; + const body = try encodeNotification(testing.allocator, "exit", params); + defer testing.allocator.free(body); + + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, body, .{}); + defer parsed.deinit(); + try testing.expect(extractId(parsed.value) == null); + try testing.expectEqualStrings("exit", extractMethod(parsed.value).?); +} + +test "encodeRequest then writeFrame then readFrame then parse" { + var write_buf: [4096]u8 = undefined; + var writer = Io.Writer.fixed(&write_buf); + + const params = std.json.Value{ .string = "hello" }; + const body = try encodeRequest(testing.allocator, 3, "ping", params); + defer testing.allocator.free(body); + try writeFrame(&writer, body); + + var reader = Io.Reader.fixed(writer.buffered()); + const decoded = try readFrame(&reader, testing.allocator); + defer testing.allocator.free(decoded); + + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, decoded, .{}); + defer parsed.deinit(); + try testing.expectEqual(@as(i64, 3), extractId(parsed.value).?.int); + try testing.expectEqualStrings("ping", extractMethod(parsed.value).?); +} + +test "extractError parses standard error response" { + const body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601,\"message\":\"method not found\"}}"; + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, body, .{}); + defer parsed.deinit(); + try testing.expect(hasErrorField(parsed.value)); + const err = extractError(parsed.value) orelse return error.TestExpectedError; + try testing.expectEqual(@as(i32, -32601), err.code); + try testing.expectEqualStrings("method not found", err.message); +} + +test "Id.eql variants" { + try testing.expect(Id.eql(.{ .int = 5 }, .{ .int = 5 })); + try testing.expect(!Id.eql(.{ .int = 5 }, .{ .int = 6 })); + try testing.expect(Id.eql(.{ .string = "abc" }, .{ .string = "abc" })); + try testing.expect(!Id.eql(.{ .string = "abc" }, .{ .int = 1 })); + try testing.expect(Id.eql(.none, .none)); +} From 12d2df97c390f15521f46cb1ef41386456fedabc Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 20 May 2026 22:00:23 +0200 Subject: [PATCH 02/19] feat(lsp): add URI <-> path helpers cross-platform src/lsp/uri.zig converts filesystem paths to LSP "file://" URIs and back. POSIX paths /foo/bar map to file:///foo/bar. Windows-style paths C:\foo\bar (or C:/foo) map to file:///C:/foo/bar with the canonical triple slash before the drive letter. Detection is shape-based, not host-OS-based: a path starting with : is treated as Windows on any host. This lets us test both shapes on a single platform. Percent-encoding follows RFC 3986. Decoding rejects malformed %XX sequences with MalformedPercent. 7 unit tests cover both roundtrips, special characters, and error paths. --- src/lsp/uri.zig | 206 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 src/lsp/uri.zig diff --git a/src/lsp/uri.zig b/src/lsp/uri.zig new file mode 100644 index 0000000..7728092 --- /dev/null +++ b/src/lsp/uri.zig @@ -0,0 +1,206 @@ +//! `file://` URI <-> filesystem path conversion. +//! +//! POSIX path `/foo/bar` <-> `file:///foo/bar`. +//! Windows path `C:\foo\bar` <-> `file:///C:/foo/bar` (triple slash before +//! the drive letter, forward slashes inside the URI). +//! +//! Detection is shape-based, not host-OS-based: if a path begins with +//! `:` it is treated as a Windows path regardless of where +//! the code runs. This means tests for both shapes run on any host. +//! +//! Percent-encoding follows RFC 3986: the unreserved set +//! `ALPHA / DIGIT / "-" / "." / "_" / "~"` and the path separator `/` +//! pass through, everything else is `%XX` encoded. + +const std = @import("std"); + +const URI_SCHEME = "file://"; + +fn isAsciiAlpha(c: u8) bool { + return (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z'); +} + +fn isUnreservedPathChar(c: u8) bool { + return (c >= 'A' and c <= 'Z') or + (c >= 'a' and c <= 'z') or + (c >= '0' and c <= '9') or + c == '/' or c == '-' or c == '_' or c == '.' or c == '~'; +} + +/// Returns true when `path` has the shape `:...`, indicating +/// a Windows-style absolute path with a drive letter. +fn hasDriveLetter(path: []const u8) bool { + if (path.len < 2) return false; + if (!isAsciiAlpha(path[0])) return false; + if (path[1] != ':') return false; + if (path.len == 2) return true; + return path[2] == '/' or path[2] == '\\'; +} + +/// Convert a filesystem path to a `file://` URI. Caller owns the result. +/// +/// On POSIX-shaped paths (`/...`) the URI becomes `file:///...`. +/// On Windows-shaped paths (`X:\...` or `X:/...`) the URI becomes +/// `file:///X:/...` (triple slash, drive letter preserved, separators +/// converted to `/`). +pub fn pathToUri(gpa: std.mem.Allocator, path: []const u8) std.mem.Allocator.Error![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(gpa); + try out.appendSlice(gpa, URI_SCHEME); + + if (hasDriveLetter(path)) { + try out.append(gpa, '/'); + try out.append(gpa, path[0]); + try out.append(gpa, ':'); + try encodeRest(gpa, &out, path[2..]); + } else { + try encodeRest(gpa, &out, path); + } + + return out.toOwnedSlice(gpa); +} + +fn encodeRest( + gpa: std.mem.Allocator, + out: *std.ArrayList(u8), + path: []const u8, +) std.mem.Allocator.Error!void { + for (path) |c| { + if (c == '\\') { + try out.append(gpa, '/'); + } else if (isUnreservedPathChar(c)) { + try out.append(gpa, c); + } else { + var buf: [3]u8 = undefined; + const slice = std.fmt.bufPrint(&buf, "%{X:0>2}", .{c}) catch unreachable; + try out.appendSlice(gpa, slice); + } + } +} + +pub const UriError = error{ + NotFileUri, + MalformedUri, + MalformedPercent, + OutOfMemory, +}; + +/// Convert a `file://` URI to a filesystem path. Caller owns the result. +/// +/// If the URI carries a drive-letter prefix (`file:///X:/...`), the result +/// is a Windows-style path with backslash separators (`X:\...`). Otherwise +/// the result is the POSIX path embedded in the URI (`/...`). +pub fn uriToPath(gpa: std.mem.Allocator, uri: []const u8) UriError![]u8 { + if (!std.mem.startsWith(u8, uri, URI_SCHEME)) return error.NotFileUri; + const after_scheme = uri[URI_SCHEME.len..]; + + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(gpa); + + // Drive-letter shape: `/X:` then optional rest. Triple slash means + // after_scheme starts with `/X:`. + if (after_scheme.len >= 3 and + after_scheme[0] == '/' and + isAsciiAlpha(after_scheme[1]) and + after_scheme[2] == ':') + { + try out.append(gpa, after_scheme[1]); + try out.append(gpa, ':'); + try decodeRest(gpa, &out, after_scheme[3..], true); + } else { + try decodeRest(gpa, &out, after_scheme, false); + } + + return out.toOwnedSlice(gpa); +} + +fn decodeRest( + gpa: std.mem.Allocator, + out: *std.ArrayList(u8), + rest: []const u8, + windows_style: bool, +) UriError!void { + var i: usize = 0; + while (i < rest.len) { + const c = rest[i]; + if (c == '%') { + if (i + 2 >= rest.len) return error.MalformedPercent; + const hi = std.fmt.charToDigit(rest[i + 1], 16) catch return error.MalformedPercent; + const lo = std.fmt.charToDigit(rest[i + 2], 16) catch return error.MalformedPercent; + try out.append(gpa, @intCast((@as(u16, hi) << 4) | @as(u16, lo))); + i += 3; + } else if (c == '/' and windows_style) { + try out.append(gpa, '\\'); + i += 1; + } else { + try out.append(gpa, c); + i += 1; + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "POSIX path roundtrip" { + const path = "/Users/guy/project"; + const uri = try pathToUri(testing.allocator, path); + defer testing.allocator.free(uri); + try testing.expectEqualStrings("file:///Users/guy/project", uri); + + const back = try uriToPath(testing.allocator, uri); + defer testing.allocator.free(back); + try testing.expectEqualStrings(path, back); +} + +test "Windows path with backslashes roundtrip" { + const path = "C:\\foo\\bar"; + const uri = try pathToUri(testing.allocator, path); + defer testing.allocator.free(uri); + try testing.expectEqualStrings("file:///C:/foo/bar", uri); + + const back = try uriToPath(testing.allocator, uri); + defer testing.allocator.free(back); + try testing.expectEqualStrings(path, back); +} + +test "Windows path with forward slashes maps to canonical URI" { + const path = "D:/Projects/zls-mcp"; + const uri = try pathToUri(testing.allocator, path); + defer testing.allocator.free(uri); + try testing.expectEqualStrings("file:///D:/Projects/zls-mcp", uri); +} + +test "special characters are percent-encoded" { + // Space, accented character (UTF-8 bytes), percent sign. + const path = "/tmp/a b/é/100%"; + const uri = try pathToUri(testing.allocator, path); + defer testing.allocator.free(uri); + // 'é' in UTF-8 is 0xC3 0xA9 → "%C3%A9" + try testing.expectEqualStrings("file:///tmp/a%20b/%C3%A9/100%25", uri); + + const back = try uriToPath(testing.allocator, uri); + defer testing.allocator.free(back); + try testing.expectEqualStrings(path, back); +} + +test "uriToPath rejects non-file scheme" { + try testing.expectError(error.NotFileUri, uriToPath(testing.allocator, "http://example.com/")); +} + +test "uriToPath rejects malformed percent encoding" { + try testing.expectError(error.MalformedPercent, uriToPath(testing.allocator, "file:///foo%2")); + try testing.expectError(error.MalformedPercent, uriToPath(testing.allocator, "file:///foo%ZZ")); +} + +test "hasDriveLetter shape detection" { + try testing.expect(hasDriveLetter("C:\\foo")); + try testing.expect(hasDriveLetter("C:/foo")); + try testing.expect(hasDriveLetter("C:")); + try testing.expect(!hasDriveLetter("/foo/bar")); + try testing.expect(!hasDriveLetter("foo")); + try testing.expect(!hasDriveLetter("")); +} From 97543d68d759e76aa5915130203071e2ec5c987d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 20 May 2026 22:02:41 +0200 Subject: [PATCH 03/19] feat(util): add stderr logger with level via env src/util/log.zig: levels {debug, info, warn, err, off} stored in an atomic global. setLevel/getLevel/parseLevel public API. Helper functions debug/info/warn/err take a comptime component name and format args. Output goes through std.debug.print which writes to stderr without needing an Io handle. Phase 1 trade-off: no ISO8601 timestamp prefix yet. Adding wall-clock time in Zig 0.16 requires Io.Clock.real.now(io), which is not yet threaded through every call site. Will be added when the MCP server main loop owns a shared Io. --- src/util/log.zig | 125 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/util/log.zig diff --git a/src/util/log.zig b/src/util/log.zig new file mode 100644 index 0000000..d12840f --- /dev/null +++ b/src/util/log.zig @@ -0,0 +1,125 @@ +//! Minimal stderr logger for zls-mcp. +//! +//! All output goes to stderr (stdout is reserved for the MCP protocol). +//! The current level is stored in an atomic global, configured once at +//! startup by `main` (typically by parsing the `ZLS_MCP_LOG` env var). +//! +//! Format: `[level] [component] message` followed by a newline. +//! +//! Phase 1 note: no ISO8601 timestamp prefix yet. Adding wall-clock time +//! in Zig 0.16 requires an `Io` handle (`Io.Clock.real.now(io)`), which +//! is not yet threaded through every log call site. Timestamps will be +//! added when the MCP server's main loop owns an Io it can share. + +const std = @import("std"); + +pub const Level = enum(u8) { + debug = 0, + info = 1, + warn = 2, + err = 3, + off = 4, +}; + +var current_level: std.atomic.Value(u8) = .init(@intFromEnum(Level.info)); + +/// Override the active level. Thread-safe. +pub fn setLevel(l: Level) void { + current_level.store(@intFromEnum(l), .seq_cst); +} + +/// Read the active level. Thread-safe. +pub fn getLevel() Level { + return @enumFromInt(current_level.load(.acquire)); +} + +/// Parse a level string (case-insensitive). Returns null on unknown input. +/// Recognizes: `debug`, `info`, `warn`, `warning`, `err`, `error`, `off`, `none`. +pub fn parseLevel(s: []const u8) ?Level { + if (std.ascii.eqlIgnoreCase(s, "debug")) return .debug; + if (std.ascii.eqlIgnoreCase(s, "info")) return .info; + if (std.ascii.eqlIgnoreCase(s, "warn")) return .warn; + if (std.ascii.eqlIgnoreCase(s, "warning")) return .warn; + if (std.ascii.eqlIgnoreCase(s, "err")) return .err; + if (std.ascii.eqlIgnoreCase(s, "error")) return .err; + if (std.ascii.eqlIgnoreCase(s, "off")) return .off; + if (std.ascii.eqlIgnoreCase(s, "none")) return .off; + return null; +} + +/// Log at `debug` level. +pub fn debug(comptime component: []const u8, comptime fmt: []const u8, args: anytype) void { + emit(.debug, component, fmt, args); +} + +/// Log at `info` level. +pub fn info(comptime component: []const u8, comptime fmt: []const u8, args: anytype) void { + emit(.info, component, fmt, args); +} + +/// Log at `warn` level. +pub fn warn(comptime component: []const u8, comptime fmt: []const u8, args: anytype) void { + emit(.warn, component, fmt, args); +} + +/// Log at `error` level. +pub fn err(comptime component: []const u8, comptime fmt: []const u8, args: anytype) void { + emit(.err, component, fmt, args); +} + +fn emit( + comptime level: Level, + comptime component: []const u8, + comptime fmt: []const u8, + args: anytype, +) void { + const cur = current_level.load(.acquire); + if (@intFromEnum(level) < cur) return; + std.debug.print("[" ++ comptime levelTag(level) ++ "] [" ++ component ++ "] " ++ fmt ++ "\n", args); +} + +fn levelTag(comptime l: Level) []const u8 { + return switch (l) { + .debug => "debug", + .info => "info", + .warn => "warn", + .err => "error", + .off => "off", + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "parseLevel recognizes all canonical spellings" { + try testing.expectEqual(Level.debug, parseLevel("debug").?); + try testing.expectEqual(Level.info, parseLevel("info").?); + try testing.expectEqual(Level.warn, parseLevel("warn").?); + try testing.expectEqual(Level.warn, parseLevel("warning").?); + try testing.expectEqual(Level.err, parseLevel("err").?); + try testing.expectEqual(Level.err, parseLevel("error").?); + try testing.expectEqual(Level.off, parseLevel("off").?); + try testing.expectEqual(Level.off, parseLevel("none").?); +} + +test "parseLevel is case insensitive" { + try testing.expectEqual(Level.debug, parseLevel("DEBUG").?); + try testing.expectEqual(Level.warn, parseLevel("Warn").?); +} + +test "parseLevel rejects unknown input" { + try testing.expectEqual(@as(?Level, null), parseLevel("trace")); + try testing.expectEqual(@as(?Level, null), parseLevel("")); +} + +test "setLevel + getLevel roundtrip" { + const previous = getLevel(); + defer setLevel(previous); + setLevel(.warn); + try testing.expectEqual(Level.warn, getLevel()); + setLevel(.debug); + try testing.expectEqual(Level.debug, getLevel()); +} From e839f08d3c4e24d02d9b6088ff16ead8f9a9ec87 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 20 May 2026 22:03:58 +0200 Subject: [PATCH 04/19] feat(lsp): add LSP types needed for initialize handshake src/lsp/types.zig holds the Phase 1 LSP type subset: Position, Range, Location, WorkspaceFolder, ClientInfo, ServerInfo, ServerCapabilities, StartOptions. ServerCapabilities collapses the LSP "bool | object | absent" provider shapes into plain bools, which is enough for the Phase 1 client. parseServerCapabilities / parseServerInfo extract fields from a parsed std.json.Value, duplicating strings into the Client's allocator so they survive the response tree being freed. Full LSP type universe deliberately not modelled: methods that take opaque params (definition, hover, etc.) keep passing std.json.Value through, avoiding upfront tedium. --- src/lsp/types.zig | 215 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 src/lsp/types.zig diff --git a/src/lsp/types.zig b/src/lsp/types.zig new file mode 100644 index 0000000..2b7fe5f --- /dev/null +++ b/src/lsp/types.zig @@ -0,0 +1,215 @@ +//! LSP type subset for the Phase 1 initialize handshake. +//! +//! Only enough to send `initialize` + `initialized` + `shutdown` + `exit` +//! and to extract the server capabilities we care about. The full LSP +//! type universe lives elsewhere (or stays unstructured behind +//! `std.json.Value`) — the philosophy is: type the things we own +//! (StartOptions, parsed ServerCapabilities), keep the rest as JSON +//! values. + +const std = @import("std"); + +pub const Position = struct { + line: u32, + character: u32, +}; + +pub const Range = struct { + start: Position, + end: Position, +}; + +pub const Location = struct { + uri: []const u8, + range: Range, +}; + +pub const WorkspaceFolder = struct { + uri: []const u8, + name: []const u8, +}; + +pub const ClientInfo = struct { + name: []const u8, + version: ?[]const u8 = null, +}; + +pub const ServerInfo = struct { + /// Owned by the `Client`'s arena. Null until `initialize` returns. + name: ?[]const u8 = null, + version: ?[]const u8 = null, +}; + +/// Parsed subset of the LSP `ServerCapabilities`. The booleans collapse +/// the `bool | object | absent` LSP shapes into a single "is the provider +/// active" answer that's enough for the Phase 1 client. +pub const ServerCapabilities = struct { + /// Negotiated encoding (e.g. "utf-8"). Owned by the `Client`'s arena + /// for the lifetime of the connection. + position_encoding: []const u8 = "utf-16", + workspace_symbol_provider: bool = false, + call_hierarchy_provider: bool = false, + references_provider: bool = false, + document_symbol_provider: bool = false, + hover_provider: bool = false, + definition_provider: bool = false, + diagnostic_provider: bool = false, +}; + +/// Caller-supplied parameters for `Client.start`. The slices must outlive +/// the call. +pub const StartOptions = struct { + workspace_folders: []const WorkspaceFolder, + client_info: ClientInfo, +}; + +/// A provider entry can be `bool`, `object`, or absent. We treat any +/// truthy or object presence as "active". +fn providerToBool(v: ?std.json.Value) bool { + const val = v orelse return false; + return switch (val) { + .bool => |b| b, + .object => true, + else => false, + }; +} + +/// Extract `ServerCapabilities` from an `initialize` response. +/// `init_response` is the `result` Value of the response (i.e. the object +/// containing `capabilities`, `serverInfo`). +/// +/// `position_encoding` is duplicated into `gpa` so it survives the +/// dropping of the parsed response tree. All booleans are read by shape. +pub fn parseServerCapabilities( + gpa: std.mem.Allocator, + init_result: std.json.Value, +) !ServerCapabilities { + if (init_result != .object) return error.InvalidInitializeResult; + const caps_val = init_result.object.get("capabilities") orelse return error.MissingCapabilities; + if (caps_val != .object) return error.InvalidCapabilities; + const caps = caps_val.object; + + var encoding: []const u8 = try gpa.dupe(u8, "utf-16"); + if (caps.get("positionEncoding")) |pe| { + if (pe == .string) { + gpa.free(encoding); + encoding = try gpa.dupe(u8, pe.string); + } + } + + return ServerCapabilities{ + .position_encoding = encoding, + .workspace_symbol_provider = providerToBool(caps.get("workspaceSymbolProvider")), + .call_hierarchy_provider = providerToBool(caps.get("callHierarchyProvider")), + .references_provider = providerToBool(caps.get("referencesProvider")), + .document_symbol_provider = providerToBool(caps.get("documentSymbolProvider")), + .hover_provider = providerToBool(caps.get("hoverProvider")), + .definition_provider = providerToBool(caps.get("definitionProvider")), + .diagnostic_provider = providerToBool(caps.get("diagnosticProvider")), + }; +} + +/// Extract `ServerInfo` from an `initialize` response. Strings are +/// duplicated into `gpa`. Returns an empty `ServerInfo` if the field is +/// absent. +pub fn parseServerInfo( + gpa: std.mem.Allocator, + init_result: std.json.Value, +) !ServerInfo { + if (init_result != .object) return ServerInfo{}; + const si_val = init_result.object.get("serverInfo") orelse return ServerInfo{}; + if (si_val != .object) return ServerInfo{}; + const si = si_val.object; + + var info = ServerInfo{}; + if (si.get("name")) |n| { + if (n == .string) info.name = try gpa.dupe(u8, n.string); + } + if (si.get("version")) |v| { + if (v == .string) info.version = try gpa.dupe(u8, v.string); + } + return info; +} + +/// Free the strings owned by a `ServerCapabilities` instance. +pub fn deinitServerCapabilities(caps: *ServerCapabilities, gpa: std.mem.Allocator) void { + gpa.free(caps.position_encoding); + caps.position_encoding = ""; +} + +/// Free the strings owned by a `ServerInfo` instance. +pub fn deinitServerInfo(info: *ServerInfo, gpa: std.mem.Allocator) void { + if (info.name) |n| gpa.free(n); + if (info.version) |v| gpa.free(v); + info.name = null; + info.version = null; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "parseServerCapabilities parses ZLS 0.16 shape" { + const body = + \\{"capabilities":{"positionEncoding":"utf-8","workspaceSymbolProvider":true,"hoverProvider":true,"definitionProvider":true,"referencesProvider":true,"documentSymbolProvider":true}} + ; + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, body, .{}); + defer parsed.deinit(); + + var caps = try parseServerCapabilities(testing.allocator, parsed.value); + defer deinitServerCapabilities(&caps, testing.allocator); + + try testing.expectEqualStrings("utf-8", caps.position_encoding); + try testing.expect(caps.workspace_symbol_provider); + try testing.expect(caps.hover_provider); + try testing.expect(caps.definition_provider); + try testing.expect(caps.references_provider); + try testing.expect(caps.document_symbol_provider); + try testing.expect(!caps.call_hierarchy_provider); // absent → false + try testing.expect(!caps.diagnostic_provider); // absent → false +} + +test "parseServerCapabilities defaults positionEncoding to utf-16" { + const body = "{\"capabilities\":{}}"; + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, body, .{}); + defer parsed.deinit(); + + var caps = try parseServerCapabilities(testing.allocator, parsed.value); + defer deinitServerCapabilities(&caps, testing.allocator); + try testing.expectEqualStrings("utf-16", caps.position_encoding); +} + +test "parseServerCapabilities recognizes object-shaped providers" { + const body = + \\{"capabilities":{"callHierarchyProvider":{"workDoneProgress":true}}} + ; + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, body, .{}); + defer parsed.deinit(); + + var caps = try parseServerCapabilities(testing.allocator, parsed.value); + defer deinitServerCapabilities(&caps, testing.allocator); + try testing.expect(caps.call_hierarchy_provider); +} + +test "parseServerInfo reads name and version" { + const body = "{\"serverInfo\":{\"name\":\"zls\",\"version\":\"0.16.0\"}}"; + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, body, .{}); + defer parsed.deinit(); + + var info = try parseServerInfo(testing.allocator, parsed.value); + defer deinitServerInfo(&info, testing.allocator); + + try testing.expectEqualStrings("zls", info.name.?); + try testing.expectEqualStrings("0.16.0", info.version.?); +} + +test "parseServerInfo missing returns empty" { + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{}", .{}); + defer parsed.deinit(); + var info = try parseServerInfo(testing.allocator, parsed.value); + defer deinitServerInfo(&info, testing.allocator); + try testing.expect(info.name == null); + try testing.expect(info.version == null); +} From a1f295b10c5e113a0f3432e2e6b3c829c6fa4638 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 20 May 2026 22:12:45 +0200 Subject: [PATCH 05/19] chore(build): add test step that aggregates inline tests build.zig: new `zig build test` step that compiles src/test_root.zig and runs every inline `test "..."` block reachable from it. src/test_root.zig: comptime imports every file under src/ so the test runner finds their inline tests. Each feat commit that adds a new src/ module must extend the import list. Running `zig build test` now executes the 26 tests in codec.zig (10) + uri.zig (7) + types.zig (5) + log.zig (4). --- build.zig | 15 +++++++++++++++ src/test_root.zig | 12 ++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 src/test_root.zig diff --git a/build.zig b/build.zig index 9088174..61fb254 100644 --- a/build.zig +++ b/build.zig @@ -37,4 +37,19 @@ pub fn build(b: *std.Build) void { if (b.args) |args| run_probe.addArgs(args); const probe_step = b.step("probe", "Run the ZLS capability probe"); probe_step.dependOn(&run_probe.step); + + // ---- `zig build test` ---- + // + // Aggregates inline `test "..."` blocks from every file imported by + // src/test_root.zig. Each new src/ module must be added to that + // aggregator. + const test_step = b.step("test", "Run all unit and integration tests"); + + const src_test_mod = b.createModule(.{ + .root_source_file = b.path("src/test_root.zig"), + .target = target, + .optimize = optimize, + }); + const src_test = b.addTest(.{ .root_module = src_test_mod }); + test_step.dependOn(&b.addRunArtifact(src_test).step); } diff --git a/src/test_root.zig b/src/test_root.zig new file mode 100644 index 0000000..7498501 --- /dev/null +++ b/src/test_root.zig @@ -0,0 +1,12 @@ +//! Test root for the src/ module. +//! +//! Imports every file under src/ at comptime so the `zig build test` +//! runner picks up their inline `test "..."` blocks. Each feat commit +//! that adds a new module must extend this list. + +comptime { + _ = @import("lsp/codec.zig"); + _ = @import("lsp/uri.zig"); + _ = @import("lsp/types.zig"); + _ = @import("util/log.zig"); +} From 6e877cb44518bd51f1fb9faf72a90fa5abee1081 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 20 May 2026 22:14:05 +0200 Subject: [PATCH 06/19] feat(lsp): add ZLS client with subprocess spawn and request registry src/lsp/client.zig: Client struct that spawns ZLS as a subprocess and routes JSON-RPC requests/responses by id. Threading: - main thread: sendRequest / sendNotification; polls slot.done with io.sleep until response or deadline - reader thread: owns child.stdout, frames + parses messages, takes the matching slot under the registry mutex, fills it, signals done - stderr thread: forwards every line to the debug logger as [zls-stderr] Slot ownership: each ResponseSlot lives on the gpa. Whoever removes it from the registry owns it. On timeout, the caller wins the race and emits $/cancelRequest before freeing. If the reader wins (response arrived just in time), the caller falls back to the normal "read result + free" path. LspError set: ZlsSpawnFailed, ZlsCrashed, Timeout, ProtocolViolation, ServerError, ClientStopped. ServerCapabilities (types.zig) field defaults to empty string to avoid free-on-literal in client.deinit of a never-started client. Public API in this commit: init, deinit, spawn, terminate, sendRequest, sendNotification. The full handshake (start / stop) is added in the next commit. 3 inline tests cover init/deinit and "send on non-started client". --- src/lsp/client.zig | 428 +++++++++++++++++++++++++++++++++++++++++++++ src/lsp/types.zig | 13 +- src/test_root.zig | 1 + 3 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 src/lsp/client.zig diff --git a/src/lsp/client.zig b/src/lsp/client.zig new file mode 100644 index 0000000..c818bdb --- /dev/null +++ b/src/lsp/client.zig @@ -0,0 +1,428 @@ +//! ZLS LSP client: subprocess management, request/response routing, +//! and request timeout with `$/cancelRequest`. +//! +//! Threading: +//! - The main thread (the test or future MCP server) calls `sendRequest` +//! and `sendNotification`. It polls a `ResponseSlot.done` flag with +//! `io.sleep` until the response arrives or the deadline passes. +//! - A reader thread owns `child.stdout`. It frames + parses each +//! incoming message, takes the matching slot under the registry +//! mutex, fills it, and signals `done`. +//! - A stderr thread owns `child.stderr` and forwards every line to the +//! debug logger as `[zls-stderr]`. +//! +//! Slot ownership: each `ResponseSlot` is heap-allocated by the gpa. +//! Whoever takes the slot out of the registry owns it. The reader takes +//! ownership when a response arrives and signals `done`; the caller +//! then drains the result and frees the slot. On timeout, the caller +//! removes the slot from the registry (winning the race against a +//! late-arriving response) and frees it immediately after emitting +//! `$/cancelRequest`. If the reader wins the race, the caller falls +//! back to the normal "wait for done" path. + +const std = @import("std"); +const Io = std.Io; + +const codec = @import("codec.zig"); +const types = @import("types.zig"); +const log = @import("../util/log.zig"); + +pub const LspError = error{ + ZlsSpawnFailed, + ZlsCrashed, + Timeout, + ProtocolViolation, + ServerError, + ClientStopped, +}; + +const SlotResult = union(enum) { + /// Response not yet received. + pending, + /// JSON-RPC `result` field, parsed into the caller's arena. + success: std.json.Value, + /// JSON-RPC `error` field, parsed into the caller's arena. + server_error: codec.ErrorObject, + /// Transport-level failure (crash, parse error, timeout, etc.). + transport_error: LspError, +}; + +const ResponseSlot = struct { + id: u32, + done: std.atomic.Value(bool), + /// Caller-provided arena allocator. The reader thread parses the + /// response body into this allocator so the returned Value's lifetime + /// is tied to the caller's arena. + arena_alloc: std.mem.Allocator, + result: SlotResult, +}; + +pub const Client = struct { + gpa: std.mem.Allocator, + io: Io, + + child: ?std.process.Child = null, + + next_id: std.atomic.Value(u32), + registry_mutex: std.Io.Mutex, + registry: std.AutoHashMap(u32, *ResponseSlot), + + stdin_mutex: std.Io.Mutex, + + reader_thread: ?std.Thread = null, + stderr_thread: ?std.Thread = null, + + stop_flag: std.atomic.Value(bool), + crashed: std.atomic.Value(bool), + + /// Populated after `start` (Phase 1 commit 6). Strings inside are + /// owned by `gpa` until `deinit`. + server_capabilities: types.ServerCapabilities, + server_info: types.ServerInfo, + + /// Allocate a new `Client` on `gpa`. Caller owns the returned pointer + /// and must call `deinit` when done. + /// + /// The `Io` instance is borrowed for the entire lifetime of the + /// client and is used for every subprocess, file, sleep, and mutex + /// operation. In Juicy Main this is `init.io`; in tests it is + /// `std.testing.io`. + pub fn init(gpa: std.mem.Allocator, io: Io) std.mem.Allocator.Error!*Client { + const self = try gpa.create(Client); + errdefer gpa.destroy(self); + self.* = .{ + .gpa = gpa, + .io = io, + .next_id = .init(1), + .registry_mutex = .init, + .registry = .init(gpa), + .stdin_mutex = .init, + .stop_flag = .init(false), + .crashed = .init(false), + .server_capabilities = .{}, + .server_info = .{}, + }; + return self; + } + + /// Free all resources owned by the client. Idempotent on already-stopped + /// clients; calls `terminate` first if the subprocess is still alive. + pub fn deinit(self: *Client) void { + if (self.child != null) self.terminate(); + types.deinitServerCapabilities(&self.server_capabilities, self.gpa); + types.deinitServerInfo(&self.server_info, self.gpa); + self.registry.deinit(); + self.gpa.destroy(self); + } + + /// Spawn the ZLS subprocess and launch the reader + stderr threads. + /// Does NOT perform the LSP initialize handshake — that lives in + /// `start`, added in a follow-up commit. + pub fn spawn(self: *Client) !void { + if (self.child != null) return; // idempotent + + var child = std.process.spawn(self.io, .{ + .argv = &.{"zls"}, + .stdin = .pipe, + .stdout = .pipe, + .stderr = .pipe, + }) catch |e| { + log.err("client", "failed to spawn zls: {s}", .{@errorName(e)}); + return LspError.ZlsSpawnFailed; + }; + errdefer { + child.kill(self.io); + } + self.child = child; + + self.reader_thread = try std.Thread.spawn(.{}, readerLoop, .{self}); + errdefer if (self.reader_thread) |t| { + self.stop_flag.store(true, .release); + t.join(); + self.reader_thread = null; + }; + + self.stderr_thread = try std.Thread.spawn(.{}, stderrLoop, .{self}); + + log.debug("client", "zls subprocess spawned, reader+stderr threads running", .{}); + } + + /// Forcibly terminate ZLS, join threads, fail all pending requests. + /// Use this when the graceful `stop` path is not appropriate (e.g. + /// during error recovery). Idempotent. + pub fn terminate(self: *Client) void { + self.stop_flag.store(true, .release); + + if (self.child) |*c| { + c.kill(self.io); // kill blocks until terminated and cleans up pipes + self.child = null; + } + + if (self.reader_thread) |t| { + t.join(); + self.reader_thread = null; + } + if (self.stderr_thread) |t| { + t.join(); + self.stderr_thread = null; + } + + self.failAllPending(LspError.ClientStopped); + } + + /// Send a JSON-RPC request and synchronously wait for the response. + /// The response Value is allocated in `arena`; caller's arena must + /// outlive the use of the returned value. + pub fn sendRequest( + self: *Client, + arena: std.mem.Allocator, + method: []const u8, + params: std.json.Value, + timeout_ms: u32, + ) !std.json.Value { + if (self.child == null) return LspError.ClientStopped; + if (self.crashed.load(.acquire)) return LspError.ZlsCrashed; + + const id = self.next_id.fetchAdd(1, .monotonic); + + const slot = try self.gpa.create(ResponseSlot); + slot.* = .{ + .id = id, + .done = .init(false), + .arena_alloc = arena, + .result = .pending, + }; + + // Register before sending so the reader can find this id. + self.registry_mutex.lockUncancelable(self.io); + self.registry.put(id, slot) catch |e| { + self.registry_mutex.unlock(self.io); + self.gpa.destroy(slot); + return e; + }; + self.registry_mutex.unlock(self.io); + + const body = codec.encodeRequest(self.gpa, id, method, params) catch |e| { + _ = self.takeSlot(id); + self.gpa.destroy(slot); + return e; + }; + defer self.gpa.free(body); + + self.writeFrameSafe(body) catch |e| { + _ = self.takeSlot(id); + self.gpa.destroy(slot); + return e; + }; + + // Poll for done with deadline. + const start_ns = Io.Clock.awake.now(self.io).nanoseconds; + const timeout_ns: i96 = @as(i96, timeout_ms) * @as(i96, std.time.ns_per_ms); + const deadline_ns: i96 = start_ns + timeout_ns; + + while (!slot.done.load(.acquire)) { + if (self.crashed.load(.acquire)) { + if (self.takeSlot(id)) |owned| self.gpa.destroy(owned); + return LspError.ZlsCrashed; + } + const now_ns = Io.Clock.awake.now(self.io).nanoseconds; + if (now_ns >= deadline_ns) { + if (self.takeSlot(id)) |owned| { + self.sendCancelRequest(id) catch |e| { + log.warn("client", "cancelRequest failed for id={d}: {s}", .{ id, @errorName(e) }); + }; + self.gpa.destroy(owned); + return LspError.Timeout; + } + // Reader won the race; loop will see done shortly. + } + self.io.sleep(Io.Duration.fromMilliseconds(5), .awake) catch {}; + } + + defer self.gpa.destroy(slot); + return switch (slot.result) { + .success => |v| v, + .server_error => LspError.ServerError, + .transport_error => |e| e, + .pending => unreachable, // done was true + }; + } + + /// Send a JSON-RPC notification (no id, no response). + pub fn sendNotification( + self: *Client, + method: []const u8, + params: std.json.Value, + ) !void { + if (self.child == null) return LspError.ClientStopped; + if (self.crashed.load(.acquire)) return LspError.ZlsCrashed; + + const body = try codec.encodeNotification(self.gpa, method, params); + defer self.gpa.free(body); + try self.writeFrameSafe(body); + } + + fn sendCancelRequest(self: *Client, id: u32) !void { + var obj: std.json.ObjectMap = .empty; + defer obj.deinit(self.gpa); + try obj.put(self.gpa, "id", .{ .integer = id }); + try self.sendNotification("$/cancelRequest", .{ .object = obj }); + } + + /// Remove a slot from the registry atomically. Returns the slot pointer + /// if found, null if absent (e.g. already taken by the reader). + fn takeSlot(self: *Client, id: u32) ?*ResponseSlot { + self.registry_mutex.lockUncancelable(self.io); + defer self.registry_mutex.unlock(self.io); + const kv = self.registry.fetchRemove(id) orelse return null; + return kv.value; + } + + fn writeFrameSafe(self: *Client, body: []const u8) !void { + self.stdin_mutex.lockUncancelable(self.io); + defer self.stdin_mutex.unlock(self.io); + + const child = &(self.child orelse return LspError.ClientStopped); + const stdin = child.stdin orelse return LspError.ClientStopped; + var hdr_buf: [64]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "Content-Length: {d}\r\n\r\n", .{body.len}) catch unreachable; + try stdin.writeStreamingAll(self.io, hdr); + try stdin.writeStreamingAll(self.io, body); + } + + /// Mark every pending slot as failed and remove it from the registry. + /// Called on transport collapse (EOF, crash) and on terminate. + fn failAllPending(self: *Client, err: LspError) void { + self.registry_mutex.lockUncancelable(self.io); + defer self.registry_mutex.unlock(self.io); + var iter = self.registry.iterator(); + while (iter.next()) |entry| { + const slot = entry.value_ptr.*; + slot.result = .{ .transport_error = err }; + slot.done.store(true, .release); + } + self.registry.clearRetainingCapacity(); + } +}; + +fn readerLoop(client: *Client) void { + var read_buf: [16 * 1024]u8 = undefined; + const child = &client.child.?; + const stdout = child.stdout orelse return; + var fr = stdout.reader(client.io, &read_buf); + const r = &fr.interface; + + while (!client.stop_flag.load(.acquire)) { + const body = codec.readFrame(r, client.gpa) catch |err| { + switch (err) { + error.EndOfStream => log.warn("client", "zls stdout EOF, marking crashed", .{}), + else => log.err("client", "readFrame error: {s}", .{@errorName(err)}), + } + client.crashed.store(true, .release); + client.failAllPending(LspError.ZlsCrashed); + return; + }; + defer client.gpa.free(body); + + const parsed = std.json.parseFromSlice(std.json.Value, client.gpa, body, .{}) catch |err| { + log.err("client", "json parse error in response: {s}", .{@errorName(err)}); + continue; + }; + defer parsed.deinit(); + + // Notification or server-initiated request: log and ignore (Phase 1). + if (!codec.hasResult(parsed.value) and !codec.hasErrorField(parsed.value)) { + if (codec.extractMethod(parsed.value)) |m| { + log.debug("client", "received notification/server-request: {s}", .{m}); + } + continue; + } + + const id_val = codec.extractId(parsed.value) orelse { + log.debug("client", "response with no id, dropping", .{}); + continue; + }; + const id: u32 = switch (id_val) { + .int => |i| @intCast(i), + else => { + log.debug("client", "response id is not an integer, dropping", .{}); + continue; + }, + }; + + const slot = client.takeSlot(id) orelse { + log.debug("client", "orphan response id={d} (cancelled or timed out)", .{id}); + continue; + }; + + // Re-parse into the slot's caller-provided arena so the Value + // outlives the local `parsed` tree (which we deinit at scope end). + const arena_parsed = std.json.parseFromSliceLeaky( + std.json.Value, + slot.arena_alloc, + body, + .{}, + ) catch |err| { + log.err("client", "arena parse error for id={d}: {s}", .{ id, @errorName(err) }); + slot.result = .{ .transport_error = LspError.ProtocolViolation }; + slot.done.store(true, .release); + continue; + }; + + if (codec.hasErrorField(arena_parsed)) { + const eobj = codec.extractError(arena_parsed) orelse { + slot.result = .{ .transport_error = LspError.ProtocolViolation }; + slot.done.store(true, .release); + continue; + }; + slot.result = .{ .server_error = eobj }; + } else { + const result_val = arena_parsed.object.get("result") orelse std.json.Value{ .null = {} }; + slot.result = .{ .success = result_val }; + } + slot.done.store(true, .release); + } +} + +fn stderrLoop(client: *Client) void { + var buf: [4096]u8 = undefined; + const child = &client.child.?; + const stderr = child.stderr orelse return; + while (!client.stop_flag.load(.acquire)) { + const n = stderr.readStreaming(client.io, &.{&buf}) catch return; + if (n == 0) return; + log.debug("zls-stderr", "{s}", .{buf[0..n]}); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "Client.init / Client.deinit without spawn" { + const client = try Client.init(testing.allocator, testing.io); + defer client.deinit(); + try testing.expect(client.child == null); +} + +test "sendRequest on non-started client returns ClientStopped" { + const client = try Client.init(testing.allocator, testing.io); + defer client.deinit(); + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + try testing.expectError( + LspError.ClientStopped, + client.sendRequest(arena.allocator(), "foo", .{ .null = {} }, 100), + ); +} + +test "sendNotification on non-started client returns ClientStopped" { + const client = try Client.init(testing.allocator, testing.io); + defer client.deinit(); + try testing.expectError( + LspError.ClientStopped, + client.sendNotification("foo", .{ .null = {} }), + ); +} diff --git a/src/lsp/types.zig b/src/lsp/types.zig index 2b7fe5f..4d774d0 100644 --- a/src/lsp/types.zig +++ b/src/lsp/types.zig @@ -44,9 +44,11 @@ pub const ServerInfo = struct { /// the `bool | object | absent` LSP shapes into a single "is the provider /// active" answer that's enough for the Phase 1 client. pub const ServerCapabilities = struct { - /// Negotiated encoding (e.g. "utf-8"). Owned by the `Client`'s arena - /// for the lifetime of the connection. - position_encoding: []const u8 = "utf-16", + /// Negotiated encoding (e.g. "utf-8"). Owned by the `Client`'s gpa + /// for the lifetime of the connection. Empty string when not yet + /// populated by `parseServerCapabilities`; `deinitServerCapabilities` + /// skips empty slices. + position_encoding: []const u8 = "", workspace_symbol_provider: bool = false, call_hierarchy_provider: bool = false, references_provider: bool = false, @@ -131,9 +133,10 @@ pub fn parseServerInfo( return info; } -/// Free the strings owned by a `ServerCapabilities` instance. +/// Free the strings owned by a `ServerCapabilities` instance. Safe to +/// call on a default-initialized (empty) value. pub fn deinitServerCapabilities(caps: *ServerCapabilities, gpa: std.mem.Allocator) void { - gpa.free(caps.position_encoding); + if (caps.position_encoding.len > 0) gpa.free(caps.position_encoding); caps.position_encoding = ""; } diff --git a/src/test_root.zig b/src/test_root.zig index 7498501..cd4aaee 100644 --- a/src/test_root.zig +++ b/src/test_root.zig @@ -8,5 +8,6 @@ comptime { _ = @import("lsp/codec.zig"); _ = @import("lsp/uri.zig"); _ = @import("lsp/types.zig"); + _ = @import("lsp/client.zig"); _ = @import("util/log.zig"); } From 608f37320f7acc9426d8bf12fa280cc0fb7bdb73 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 20 May 2026 22:16:41 +0200 Subject: [PATCH 07/19] feat(lsp): implement initialize + initialized + shutdown handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client.start runs the LSP handshake end-to-end: spawn → initialize (5s timeout) → parse ServerCapabilities / ServerInfo → initialized notification. buildInitializeParams constructs the params Value tree on an arena. The capabilities sent match SPEC.md "LSP critical details": - general.positionEncodings = ["utf-8", "utf-16"] - workspace.workspaceFolders = true - workspace.symbol.dynamicRegistration = true - textDocument.callHierarchy.dynamicRegistration = true - textDocument.synchronization.{ dynamicRegistration: false, didSave: true } Both rootUri (legacy) and workspaceFolders (modern) are sent — Phase 0 confirmed ZLS needs workspaceFolders for workspace/symbol to work. Client.stop runs the LSP graceful shutdown: shutdown request (2s timeout) → exit notification → child.wait (falls back to kill if wait fails) → join threads. src/util/utf.zig: empty stub with a Phase 1 note. The helper will be implemented if a future ZLS version forces UTF-16 (Phase 0 confirmed UTF-8 today). --- src/lsp/client.zig | 150 +++++++++++++++++++++++++++++++++++++++++++++ src/test_root.zig | 1 + src/util/utf.zig | 2 + 3 files changed, 153 insertions(+) create mode 100644 src/util/utf.zig diff --git a/src/lsp/client.zig b/src/lsp/client.zig index c818bdb..4cd48da 100644 --- a/src/lsp/client.zig +++ b/src/lsp/client.zig @@ -147,6 +147,79 @@ pub const Client = struct { log.debug("client", "zls subprocess spawned, reader+stderr threads running", .{}); } + /// Spawn ZLS, run the `initialize` handshake, store the negotiated + /// `ServerCapabilities` and `ServerInfo`, and send the `initialized` + /// notification. The client is ready for `sendRequest` calls once + /// this returns. + /// + /// On any error mid-handshake the subprocess is terminated and + /// threads joined. + pub fn start(self: *Client, opts: types.StartOptions) !void { + try self.spawn(); + errdefer self.terminate(); + + var arena: std.heap.ArenaAllocator = .init(self.gpa); + defer arena.deinit(); + + const params = try buildInitializeParams(arena.allocator(), opts); + const result = try self.sendRequest(arena.allocator(), "initialize", params, 5000); + + var caps = try types.parseServerCapabilities(self.gpa, result); + errdefer types.deinitServerCapabilities(&caps, self.gpa); + var info = try types.parseServerInfo(self.gpa, result); + errdefer types.deinitServerInfo(&info, self.gpa); + + self.server_capabilities = caps; + self.server_info = info; + + log.info( + "client", + "ZLS connected: name={?s} version={?s} positionEncoding={s}", + .{ info.name, info.version, caps.position_encoding }, + ); + + // `initialized` carries an empty object as params per LSP spec. + try self.sendNotification("initialized", .{ .object = std.json.ObjectMap.empty }); + } + + /// Graceful shutdown: send `shutdown` (request), `exit` (notification), + /// wait for ZLS to terminate naturally, join threads. On transport + /// failure falls back to forced `kill`. Safe to call after `terminate`. + pub fn stop(self: *Client) !void { + if (self.child == null) return; + + var arena: std.heap.ArenaAllocator = .init(self.gpa); + defer arena.deinit(); + + _ = self.sendRequest(arena.allocator(), "shutdown", .{ .null = {} }, 2000) catch |e| { + log.warn("client", "shutdown request failed: {s}", .{@errorName(e)}); + }; + self.sendNotification("exit", .{ .null = {} }) catch |e| { + log.warn("client", "exit notification failed: {s}", .{@errorName(e)}); + }; + + self.stop_flag.store(true, .release); + + if (self.child) |*c| { + _ = c.wait(self.io) catch |e| { + log.warn("client", "wait failed ({s}), forcing kill", .{@errorName(e)}); + c.kill(self.io); + }; + self.child = null; + } + + if (self.reader_thread) |t| { + t.join(); + self.reader_thread = null; + } + if (self.stderr_thread) |t| { + t.join(); + self.stderr_thread = null; + } + + self.failAllPending(LspError.ClientStopped); + } + /// Forcibly terminate ZLS, join threads, fail all pending requests. /// Use this when the graceful `stop` path is not appropriate (e.g. /// during error recovery). Idempotent. @@ -305,6 +378,83 @@ pub const Client = struct { } }; +/// Build the `initialize` params object as a `std.json.Value`. The result +/// references slices owned by `arena` (and by the caller's StartOptions). +fn buildInitializeParams( + arena: std.mem.Allocator, + opts: types.StartOptions, +) !std.json.Value { + // general.positionEncodings = ["utf-8", "utf-16"] + var pos_encodings = std.json.Array.init(arena); + try pos_encodings.append(.{ .string = "utf-8" }); + try pos_encodings.append(.{ .string = "utf-16" }); + + var general: std.json.ObjectMap = .empty; + try general.put(arena, "positionEncodings", .{ .array = pos_encodings }); + + // workspace.symbol.dynamicRegistration = true + var sym_caps: std.json.ObjectMap = .empty; + try sym_caps.put(arena, "dynamicRegistration", .{ .bool = true }); + + var workspace_caps: std.json.ObjectMap = .empty; + try workspace_caps.put(arena, "workspaceFolders", .{ .bool = true }); + try workspace_caps.put(arena, "symbol", .{ .object = sym_caps }); + + // textDocument.callHierarchy.dynamicRegistration = true + var ch_caps: std.json.ObjectMap = .empty; + try ch_caps.put(arena, "dynamicRegistration", .{ .bool = true }); + + // textDocument.synchronization.{ dynamicRegistration: false, didSave: true } + var sync_caps: std.json.ObjectMap = .empty; + try sync_caps.put(arena, "dynamicRegistration", .{ .bool = false }); + try sync_caps.put(arena, "didSave", .{ .bool = true }); + + var text_doc_caps: std.json.ObjectMap = .empty; + try text_doc_caps.put(arena, "callHierarchy", .{ .object = ch_caps }); + try text_doc_caps.put(arena, "synchronization", .{ .object = sync_caps }); + + var capabilities: std.json.ObjectMap = .empty; + try capabilities.put(arena, "general", .{ .object = general }); + try capabilities.put(arena, "workspace", .{ .object = workspace_caps }); + try capabilities.put(arena, "textDocument", .{ .object = text_doc_caps }); + + // workspaceFolders array + var folders_arr = std.json.Array.init(arena); + for (opts.workspace_folders) |wf| { + var folder: std.json.ObjectMap = .empty; + try folder.put(arena, "uri", .{ .string = wf.uri }); + try folder.put(arena, "name", .{ .string = wf.name }); + try folders_arr.append(.{ .object = folder }); + } + + // clientInfo + var client_info: std.json.ObjectMap = .empty; + try client_info.put(arena, "name", .{ .string = opts.client_info.name }); + if (opts.client_info.version) |v| { + try client_info.put(arena, "version", .{ .string = v }); + } + + // Root params + var params: std.json.ObjectMap = .empty; + try params.put(arena, "processId", .{ .null = {} }); + + // Legacy rootUri for older LSP clients; the modern workspaceFolders is + // what ZLS actually consults (Phase 0 confirmed: `workspaceFolders` + // alone makes `workspace/symbol` produce results; `rootUri` alone is + // not enough). + if (opts.workspace_folders.len > 0) { + try params.put(arena, "rootUri", .{ .string = opts.workspace_folders[0].uri }); + } else { + try params.put(arena, "rootUri", .{ .null = {} }); + } + + try params.put(arena, "workspaceFolders", .{ .array = folders_arr }); + try params.put(arena, "capabilities", .{ .object = capabilities }); + try params.put(arena, "clientInfo", .{ .object = client_info }); + + return .{ .object = params }; +} + fn readerLoop(client: *Client) void { var read_buf: [16 * 1024]u8 = undefined; const child = &client.child.?; diff --git a/src/test_root.zig b/src/test_root.zig index cd4aaee..b22fdb6 100644 --- a/src/test_root.zig +++ b/src/test_root.zig @@ -10,4 +10,5 @@ comptime { _ = @import("lsp/types.zig"); _ = @import("lsp/client.zig"); _ = @import("util/log.zig"); + _ = @import("util/utf.zig"); } diff --git a/src/util/utf.zig b/src/util/utf.zig new file mode 100644 index 0000000..e9653de --- /dev/null +++ b/src/util/utf.zig @@ -0,0 +1,2 @@ +// Phase 1: UTF-8 negotiated, conversion not needed. +// Helper will be implemented if a future ZLS version forces UTF-16. From 7ceb5df735c3cd898ca5ef16a955a4b80d4a793e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 20 May 2026 22:17:50 +0200 Subject: [PATCH 08/19] feat(main): switch entry point to Juicy Main pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/main.zig now uses Zig 0.16's `pub fn main(init: std.process.Init)` signature so future phases can pull `init.gpa`, `init.io`, and `init.environ_map` straight from the runtime instead of constructing them manually. Phase 1 behavior: parse ZLS_MCP_LOG env var → set log level → emit a single info line → exit. The MCP stdio loop arrives in Phase 3. Verified manually: ./zig-out/bin/zls-mcp → "[info] [main] zls-mcp v0.0.0 ..." ZLS_MCP_LOG=warn ./zls-mcp → (no output, info filtered) ZLS_MCP_LOG=debug ./zls-mcp → info message shown --- src/main.zig | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main.zig b/src/main.zig index da72369..fd2f46a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,23 @@ +//! zls-mcp entry point — Juicy Main pattern (Zig 0.16). +//! +//! Phase 1 stub: configures the log level from `ZLS_MCP_LOG` and exits. +//! The MCP stdio server loop is implemented in Phase 3, at which point +//! this `main` will spin up a `Client` (Phase 1) plus the MCP dispatcher +//! and run until stdin closes. + const std = @import("std"); -pub fn main() !void { - std.debug.print("zls-mcp v0.0.0\n", .{}); +const log = @import("util/log.zig"); + +pub fn main(init: std.process.Init) !void { + // Configure log level from ZLS_MCP_LOG. Default in log.zig is `info`. + if (init.environ_map.get("ZLS_MCP_LOG")) |val| { + if (log.parseLevel(val)) |lvl| log.setLevel(lvl); + } + + log.info("main", "zls-mcp v0.0.0 (Phase 1 stub — no MCP loop yet)", .{}); + + // Suppress unused-parameter warnings until the Phase 3 loop wires them in. + _ = init.gpa; + _ = init.io; } From 6afd56b21514d0e3f64ec8ae4cee768324d1dbe6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 20 May 2026 22:21:22 +0200 Subject: [PATCH 09/19] test(lsp): add integration test running real ZLS handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/integration_test.zig drives a full Client.start → assert capabilities → Client.stop round trip against an actual zls binary. The test is skipped via error.SkipZigTest if `zls` is not on PATH so a fresh checkout without ZLS still passes `zig build test`. Asserted capabilities (Phase 0 confirmed in PROBE_REPORT.md): - positionEncoding == "utf-8" - workspace_symbol_provider, hover_provider, definition_provider, references_provider, document_symbol_provider all true - call_hierarchy_provider, diagnostic_provider both false (documents what we're working around server-side) - server_info.name / version populated tests/fixtures/phase1_minimal/ is a trivial Zig project (build.zig + main.zig) used as the LSP workspace folder. Infrastructure: - src/test_root.zig renamed to src/root.zig with `pub const` re-exports so external test targets can `@import("zlsmcp")` and reach `Client`, `pathToUri`, etc. - build.zig declares the root module and creates a second test target rooted at tests/integration_test.zig with the `zlsmcp` import wired up. - client.zig: reader thread distinguishes expected EOF (during shutdown) from a real crash by checking stop_flag. 30/30 tests pass locally on macOS aarch64 with ZLS 0.16.0. --- build.zig | 25 ++++++++--- src/lsp/client.zig | 6 +++ src/root.zig | 24 ++++++++++ src/test_root.zig | 14 ------ tests/fixtures/phase1_minimal/build.zig | 17 +++++++ tests/fixtures/phase1_minimal/main.zig | 1 + tests/integration_test.zig | 59 +++++++++++++++++++++++++ 7 files changed, 126 insertions(+), 20 deletions(-) create mode 100644 src/root.zig delete mode 100644 src/test_root.zig create mode 100644 tests/fixtures/phase1_minimal/build.zig create mode 100644 tests/fixtures/phase1_minimal/main.zig create mode 100644 tests/integration_test.zig diff --git a/build.zig b/build.zig index 61fb254..4e2e0c5 100644 --- a/build.zig +++ b/build.zig @@ -40,16 +40,29 @@ pub fn build(b: *std.Build) void { // ---- `zig build test` ---- // - // Aggregates inline `test "..."` blocks from every file imported by - // src/test_root.zig. Each new src/ module must be added to that - // aggregator. + // src/root.zig serves two purposes: + // 1. As the test root, its comptime imports ensure every inline + // `test "..."` block under src/ runs. + // 2. As the public root, it re-exports the library so external + // test targets (tests/) can `@import("zlsmcp")`. const test_step = b.step("test", "Run all unit and integration tests"); - const src_test_mod = b.createModule(.{ - .root_source_file = b.path("src/test_root.zig"), + const root_mod = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), .target = target, .optimize = optimize, }); - const src_test = b.addTest(.{ .root_module = src_test_mod }); + const src_test = b.addTest(.{ .root_module = root_mod }); test_step.dependOn(&b.addRunArtifact(src_test).step); + + const integ_mod = b.createModule(.{ + .root_source_file = b.path("tests/integration_test.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zlsmcp", .module = root_mod }, + }, + }); + const integ_test = b.addTest(.{ .root_module = integ_mod }); + test_step.dependOn(&b.addRunArtifact(integ_test).step); } diff --git a/src/lsp/client.zig b/src/lsp/client.zig index 4cd48da..fcfabcf 100644 --- a/src/lsp/client.zig +++ b/src/lsp/client.zig @@ -464,6 +464,12 @@ fn readerLoop(client: *Client) void { while (!client.stop_flag.load(.acquire)) { const body = codec.readFrame(r, client.gpa) catch |err| { + // Distinguish expected EOF (after stop_flag is set by stop/terminate) + // from unexpected EOF (real ZLS crash). + if (client.stop_flag.load(.acquire)) { + log.debug("client", "reader exiting after shutdown ({s})", .{@errorName(err)}); + return; + } switch (err) { error.EndOfStream => log.warn("client", "zls stdout EOF, marking crashed", .{}), else => log.err("client", "readFrame error: {s}", .{@errorName(err)}), diff --git a/src/root.zig b/src/root.zig new file mode 100644 index 0000000..d7f9c86 --- /dev/null +++ b/src/root.zig @@ -0,0 +1,24 @@ +//! Public root of the zls-mcp library. +//! +//! Two responsibilities: +//! 1. Re-export every submodule under a stable name so external test +//! targets (e.g. `tests/integration_test.zig`) can `@import("zlsmcp")` +//! and reach `Client`, `pathToUri`, etc. +//! 2. Comptime-reference each submodule so that when this file is the +//! test root, the test runner discovers every inline `test "..."`. + +pub const codec = @import("lsp/codec.zig"); +pub const uri = @import("lsp/uri.zig"); +pub const types = @import("lsp/types.zig"); +pub const client = @import("lsp/client.zig"); +pub const log = @import("util/log.zig"); +pub const utf = @import("util/utf.zig"); + +comptime { + _ = codec; + _ = uri; + _ = types; + _ = client; + _ = log; + _ = utf; +} diff --git a/src/test_root.zig b/src/test_root.zig deleted file mode 100644 index b22fdb6..0000000 --- a/src/test_root.zig +++ /dev/null @@ -1,14 +0,0 @@ -//! Test root for the src/ module. -//! -//! Imports every file under src/ at comptime so the `zig build test` -//! runner picks up their inline `test "..."` blocks. Each feat commit -//! that adds a new module must extend this list. - -comptime { - _ = @import("lsp/codec.zig"); - _ = @import("lsp/uri.zig"); - _ = @import("lsp/types.zig"); - _ = @import("lsp/client.zig"); - _ = @import("util/log.zig"); - _ = @import("util/utf.zig"); -} diff --git a/tests/fixtures/phase1_minimal/build.zig b/tests/fixtures/phase1_minimal/build.zig new file mode 100644 index 0000000..793788e --- /dev/null +++ b/tests/fixtures/phase1_minimal/build.zig @@ -0,0 +1,17 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const mod = b.createModule(.{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + const exe = b.addExecutable(.{ + .name = "phase1_minimal", + .root_module = mod, + }); + b.installArtifact(exe); +} diff --git a/tests/fixtures/phase1_minimal/main.zig b/tests/fixtures/phase1_minimal/main.zig new file mode 100644 index 0000000..902b554 --- /dev/null +++ b/tests/fixtures/phase1_minimal/main.zig @@ -0,0 +1 @@ +pub fn main() void {} diff --git a/tests/integration_test.zig b/tests/integration_test.zig new file mode 100644 index 0000000..0a674c9 --- /dev/null +++ b/tests/integration_test.zig @@ -0,0 +1,59 @@ +//! Phase 1 integration test: run a full LSP `initialize` handshake +//! against a real ZLS subprocess on a minimal fixture and verify the +//! negotiated `ServerCapabilities`. +//! +//! Skipped (via `error.SkipZigTest`) if `zls` is not on PATH so a fresh +//! checkout on a machine without ZLS still passes `zig build test`. + +const std = @import("std"); +const testing = std.testing; + +const zlsmcp = @import("zlsmcp"); +const Client = zlsmcp.client.Client; +const LspError = zlsmcp.client.LspError; +const pathToUri = zlsmcp.uri.pathToUri; +const WorkspaceFolder = zlsmcp.types.WorkspaceFolder; + +test "phase 1 — initialize handshake against real ZLS" { + const gpa = testing.allocator; + const io = testing.io; + + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + + const cwd = try std.process.currentPathAlloc(io, a); + const fixture_path = try std.fs.path.resolve(a, &.{ cwd, "tests/fixtures/phase1_minimal" }); + const fixture_uri = try pathToUri(a, fixture_path); + + var client = try Client.init(gpa, io); + defer client.deinit(); + + const folders = [_]WorkspaceFolder{.{ .uri = fixture_uri, .name = "phase1_minimal" }}; + client.start(.{ + .workspace_folders = &folders, + .client_info = .{ .name = "zls-mcp-test", .version = "0.0.0" }, + }) catch |err| switch (err) { + LspError.ZlsSpawnFailed => return error.SkipZigTest, + else => return err, + }; + defer client.stop() catch {}; + + // Capabilities verified in Phase 0 (PROBE_REPORT.md). If any of these + // regress on a new ZLS, rerun the probe and patch the spec. + try testing.expectEqualStrings("utf-8", client.server_capabilities.position_encoding); + try testing.expect(client.server_capabilities.workspace_symbol_provider); + try testing.expect(client.server_capabilities.hover_provider); + try testing.expect(client.server_capabilities.definition_provider); + try testing.expect(client.server_capabilities.references_provider); + try testing.expect(client.server_capabilities.document_symbol_provider); + + // ZLS 0.16 deliberately doesn't expose these. Asserting absence + // documents what we're working around server-side. + try testing.expect(!client.server_capabilities.call_hierarchy_provider); + try testing.expect(!client.server_capabilities.diagnostic_provider); + + // serverInfo must be populated. + try testing.expect(client.server_info.name != null); + try testing.expect(client.server_info.version != null); +} From 0aefcc02968156ae68d86acc0e6fed67ef6091fc Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 20 May 2026 22:22:41 +0200 Subject: [PATCH 10/19] style(probe): apply zig fmt to probe/probe.zig Inline chained `else if` expressions onto a single line per zig fmt's current style. Pre-existing formatting drift surfaced by adding the `zig fmt --check` step in CI. --- probe/probe.zig | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/probe/probe.zig b/probe/probe.zig index 5fe5a55..e7f6ffe 100644 --- a/probe/probe.zig +++ b/probe/probe.zig @@ -515,9 +515,7 @@ fn writeReport( { const ch_advertised = !std.mem.eql(u8, caps.call_hierarchy_provider, "false") and !std.mem.eql(u8, caps.call_hierarchy_provider, "absent"); const refs_advertised = !std.mem.eql(u8, caps.references_provider, "false") and !std.mem.eql(u8, caps.references_provider, "absent"); - const verdict = if (ch_advertised and prep_ok and incoming_ok) "OK avec callHierarchy" - else if (refs_advertised) "fallback references" - else "KO"; + const verdict = if (ch_advertised and prep_ok and incoming_ok) "OK avec callHierarchy" else if (refs_advertised) "fallback references" else "KO"; try w.print("- `zig_callers_of` / `zig_callees_of` : **{s}** (prepareCallHierarchy={s}, incomingCalls={s})\n", .{ verdict, if (prep_ok) "OK" else "no", @@ -534,10 +532,7 @@ fn writeReport( // dans le workspace ?". const one_level = o_ok; // Physics(f32).World const two_levels = q1_ok; // Outer(u32).Inner (composition à un niveau de plus) - const verdict = if (one_level and two_levels) "OK — comptime résolu pour types retournés (Foo(T).Member)" - else if (one_level and !two_levels) "Best-effort — fonctionne pour un niveau de composition (Foo(T).X), échoue au-delà" - else if (!one_level and def_call_ok) "Partiel — fonctionne pour les méthodes via call-site definition, échoue pour les types retournés" - else "KO"; + const verdict = if (one_level and two_levels) "OK — comptime résolu pour types retournés (Foo(T).Member)" else if (one_level and !two_levels) "Best-effort — fonctionne pour un niveau de composition (Foo(T).X), échoue au-delà" else if (!one_level and def_call_ok) "Partiel — fonctionne pour les méthodes via call-site definition, échoue pour les types retournés" else "KO"; try w.print("- `zig_instantiation_members` : **{s}** (o={s}, p={s}, q1={s}, q2={s}, q3={s})\n", .{ verdict, if (o_ok) "OK" else "no", @@ -850,16 +845,17 @@ pub fn main() !void { const caps = if (init_resp_owned) |ir| try extractCaps(gpa, ir) - else Caps{ - .position_encoding = try gpa.dupe(u8, ""), - .workspace_symbol_provider = try gpa.dupe(u8, ""), - .call_hierarchy_provider = try gpa.dupe(u8, ""), - .references_provider = try gpa.dupe(u8, ""), - .document_symbol_provider = try gpa.dupe(u8, ""), - .hover_provider = try gpa.dupe(u8, ""), - .definition_provider = try gpa.dupe(u8, ""), - .diagnostic_provider = try gpa.dupe(u8, ""), - }; + else + Caps{ + .position_encoding = try gpa.dupe(u8, ""), + .workspace_symbol_provider = try gpa.dupe(u8, ""), + .call_hierarchy_provider = try gpa.dupe(u8, ""), + .references_provider = try gpa.dupe(u8, ""), + .document_symbol_provider = try gpa.dupe(u8, ""), + .hover_provider = try gpa.dupe(u8, ""), + .definition_provider = try gpa.dupe(u8, ""), + .diagnostic_provider = try gpa.dupe(u8, ""), + }; defer { gpa.free(caps.position_encoding); gpa.free(caps.workspace_symbol_provider); From 004be697874f596471304692f985d94d08915af0 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 20 May 2026 22:22:51 +0200 Subject: [PATCH 11/19] chore(build): add CI matrix with ZLS install .github/workflows/ci.yml runs `zig build`, `zig build test`, and `zig fmt --check` on a matrix of {ubuntu-latest, macos-latest, windows-latest} with Zig 0.16.0 (via mlugg/setup-zig) and ZLS 0.16.0 installed from the official zigtools/zls GitHub release tarballs: Linux x86_64 : zls-x86_64-linux.tar.xz macOS aarch64 : zls-aarch64-macos.tar.xz Windows x86_64 : zls-x86_64-windows.zip The integration test self-skips via error.SkipZigTest if zls is not on PATH, so this matrix can run unattended on contributors' forks without breaking. The "test step" infrastructure in build.zig was introduced earlier in the phase; this commit only adds the CI layer on top. --- .github/workflows/ci.yml | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..629546e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install Zig 0.16.0 + uses: mlugg/setup-zig@v1 + with: + version: 0.16.0 + + - name: Install ZLS 0.16.0 (Linux) + if: runner.os == 'Linux' + run: | + curl -L https://github.com/zigtools/zls/releases/download/0.16.0/zls-x86_64-linux.tar.xz | tar xJ + sudo mv zls /usr/local/bin/ + zls --version + + - name: Install ZLS 0.16.0 (macOS) + if: runner.os == 'macOS' + run: | + curl -L https://github.com/zigtools/zls/releases/download/0.16.0/zls-aarch64-macos.tar.xz | tar xJ + sudo mv zls /usr/local/bin/ + zls --version + + - name: Install ZLS 0.16.0 (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Invoke-WebRequest -Uri https://github.com/zigtools/zls/releases/download/0.16.0/zls-x86_64-windows.zip -OutFile zls.zip + Expand-Archive zls.zip -DestinationPath C:\zls + echo "C:\zls" >> $env:GITHUB_PATH + C:\zls\zls.exe --version + + - name: zig version + run: zig version + + - name: Build + run: zig build + + - name: Test + run: zig build test + + - name: Check formatting + run: zig fmt --check src/ tests/ probe/ build.zig From b13220a5258c7e1f0999f008125fe7c3d63d4220 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 21 May 2026 00:14:36 +0200 Subject: [PATCH 12/19] fix(lsp): join reader/stderr threads before nulling self.child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader and stderr loops hold a pointer into self.child via &client.child.? captured at thread entry. Nulling self.child before the threads observe stop_flag and exit was a UAF window. Reorder so that: 1. stop_flag = true 2. kill (or wait, in stop()) — closes pipes, unblocks readers 3. join both threads 4. only now: self.child = null --- src/lsp/client.zig | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/lsp/client.zig b/src/lsp/client.zig index fcfabcf..4bb74ef 100644 --- a/src/lsp/client.zig +++ b/src/lsp/client.zig @@ -205,7 +205,8 @@ pub const Client = struct { log.warn("client", "wait failed ({s}), forcing kill", .{@errorName(e)}); c.kill(self.io); }; - self.child = null; + // self.child stays set until threads have joined — they hold + // refs into it via &client.child.?. } if (self.reader_thread) |t| { @@ -217,6 +218,8 @@ pub const Client = struct { self.stderr_thread = null; } + self.child = null; + self.failAllPending(LspError.ClientStopped); } @@ -226,9 +229,12 @@ pub const Client = struct { pub fn terminate(self: *Client) void { self.stop_flag.store(true, .release); + // Kill closes the pipes, which unblocks the reader/stderr threads + // sitting on readFrame / readStreaming so they can observe + // stop_flag and exit. Do NOT null out self.child yet — the + // threads still hold refs into it via &client.child.?. if (self.child) |*c| { - c.kill(self.io); // kill blocks until terminated and cleans up pipes - self.child = null; + c.kill(self.io); } if (self.reader_thread) |t| { @@ -240,6 +246,9 @@ pub const Client = struct { self.stderr_thread = null; } + // Threads are joined; safe to drop the child now. + self.child = null; + self.failAllPending(LspError.ClientStopped); } From 9f14b679e4022bbb3b72304ff5875cff383bc5ea Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 21 May 2026 00:15:21 +0200 Subject: [PATCH 13/19] fix(lsp): consolidate spawn() error path through terminate() The previous errdefer chain ordered kill after thread joins, deadlocking on errors that occur between reader spawn and stderr spawn (the reader sits on readFrame because pipes are open). Replace with a single errdefer that calls terminate(), which now (after the previous commit) performs the steps in the correct order: stop_flag, kill, join, null. --- src/lsp/client.zig | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/lsp/client.zig b/src/lsp/client.zig index 4bb74ef..1fa36c4 100644 --- a/src/lsp/client.zig +++ b/src/lsp/client.zig @@ -121,7 +121,7 @@ pub const Client = struct { pub fn spawn(self: *Client) !void { if (self.child != null) return; // idempotent - var child = std.process.spawn(self.io, .{ + const child = std.process.spawn(self.io, .{ .argv = &.{"zls"}, .stdin = .pipe, .stdout = .pipe, @@ -130,18 +130,14 @@ pub const Client = struct { log.err("client", "failed to spawn zls: {s}", .{@errorName(e)}); return LspError.ZlsSpawnFailed; }; - errdefer { - child.kill(self.io); - } self.child = child; + // Single cleanup path through terminate(): it performs the steps + // in the correct order (stop_flag, kill, join reader+stderr, + // null child, failAllPending) and is idempotent on a + // half-initialized client (e.g. reader_thread still null). + errdefer self.terminate(); self.reader_thread = try std.Thread.spawn(.{}, readerLoop, .{self}); - errdefer if (self.reader_thread) |t| { - self.stop_flag.store(true, .release); - t.join(); - self.reader_thread = null; - }; - self.stderr_thread = try std.Thread.spawn(.{}, stderrLoop, .{self}); log.debug("client", "zls subprocess spawned, reader+stderr threads running", .{}); From cf9bde481b1e057bf4735126a1b534580382afc5 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 21 May 2026 00:16:13 +0200 Subject: [PATCH 14/19] fix(lsp): guarantee slot is freed on every return path Previously, when self.crashed became true and the reader had already called failAllPending (which removes slots from the registry), the caller's branch `if (self.takeSlot(id)) |owned| destroy(owned)` would hit null and return without freeing the slot. Replace the manual destroy chain with a single defer covering all exit paths, plus a companion defer that removes from the registry idempotently. Also simplifies the encode/write error handling to plain `try` since both defers handle cleanup uniformly. --- src/lsp/client.zig | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/lsp/client.zig b/src/lsp/client.zig index 1fa36c4..268c472 100644 --- a/src/lsp/client.zig +++ b/src/lsp/client.zig @@ -264,6 +264,9 @@ pub const Client = struct { const id = self.next_id.fetchAdd(1, .monotonic); const slot = try self.gpa.create(ResponseSlot); + // Covers every return path below, including the .success branch + // after the poll loop exits. + defer self.gpa.destroy(slot); slot.* = .{ .id = id, .done = .init(false), @@ -275,23 +278,22 @@ pub const Client = struct { self.registry_mutex.lockUncancelable(self.io); self.registry.put(id, slot) catch |e| { self.registry_mutex.unlock(self.io); - self.gpa.destroy(slot); return e; }; self.registry_mutex.unlock(self.io); - const body = codec.encodeRequest(self.gpa, id, method, params) catch |e| { + // From this point on the slot is in the registry. Every exit + // path must remove it before destroying the memory. takeSlot is + // idempotent (no-op if the reader has already taken it, e.g. + // through failAllPending on a crash). + defer { _ = self.takeSlot(id); - self.gpa.destroy(slot); - return e; - }; + } + + const body = try codec.encodeRequest(self.gpa, id, method, params); defer self.gpa.free(body); - self.writeFrameSafe(body) catch |e| { - _ = self.takeSlot(id); - self.gpa.destroy(slot); - return e; - }; + try self.writeFrameSafe(body); // Poll for done with deadline. const start_ns = Io.Clock.awake.now(self.io).nanoseconds; @@ -299,25 +301,23 @@ pub const Client = struct { const deadline_ns: i96 = start_ns + timeout_ns; while (!slot.done.load(.acquire)) { - if (self.crashed.load(.acquire)) { - if (self.takeSlot(id)) |owned| self.gpa.destroy(owned); - return LspError.ZlsCrashed; - } + if (self.crashed.load(.acquire)) return LspError.ZlsCrashed; const now_ns = Io.Clock.awake.now(self.io).nanoseconds; if (now_ns >= deadline_ns) { - if (self.takeSlot(id)) |owned| { + // Try to claim the slot before sending cancel so the + // reader can't fill it after we return. If we lose the + // race, the reader has already completed; loop will + // pick up `done` on the next iteration. + if (self.takeSlot(id) != null) { self.sendCancelRequest(id) catch |e| { log.warn("client", "cancelRequest failed for id={d}: {s}", .{ id, @errorName(e) }); }; - self.gpa.destroy(owned); return LspError.Timeout; } - // Reader won the race; loop will see done shortly. } self.io.sleep(Io.Duration.fromMilliseconds(5), .awake) catch {}; } - defer self.gpa.destroy(slot); return switch (slot.result) { .success => |v| v, .server_error => LspError.ServerError, From eca52e5b417618b3d5c94c7fb57ea8dfb9c65490 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 21 May 2026 00:16:52 +0200 Subject: [PATCH 15/19] fix(lsp): guard response id cast against negative / out-of-range values @intCast on a negative i64 or one above u32 max is UB in safe mode. Drop the message with a debug log instead of crashing. --- src/lsp/client.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lsp/client.zig b/src/lsp/client.zig index 268c472..59a5716 100644 --- a/src/lsp/client.zig +++ b/src/lsp/client.zig @@ -504,7 +504,12 @@ fn readerLoop(client: *Client) void { continue; }; const id: u32 = switch (id_val) { - .int => |i| @intCast(i), + .int => |i| if (i >= 0 and i <= std.math.maxInt(u32)) + @intCast(i) + else { + log.debug("client", "response id out of u32 range: {d}", .{i}); + continue; + }, else => { log.debug("client", "response id is not an integer, dropping", .{}); continue; From 57948d4e088eec04f0b9480f76a2a53d5a2c45f0 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 21 May 2026 00:17:38 +0200 Subject: [PATCH 16/19] docs(lsp): annotate double-parse cost and pathToUri backslash limitation Both surfaced in PR #1 review. Document them in-code so future readers don't reinvent the diagnosis: - readerLoop: the reader parses each response body twice (once into client.gpa for id routing, once into the slot's arena for caller ownership). A future optimization could peek the id with a streaming parser, then parse once into the right arena. - pathToUri: backslashes in POSIX paths are converted to `/` rather than percent-encoded as `%5C`, so a POSIX filename containing `\` does not round-trip. --- src/lsp/client.zig | 8 ++++++-- src/lsp/uri.zig | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lsp/client.zig b/src/lsp/client.zig index 59a5716..8f09b86 100644 --- a/src/lsp/client.zig +++ b/src/lsp/client.zig @@ -521,8 +521,12 @@ fn readerLoop(client: *Client) void { continue; }; - // Re-parse into the slot's caller-provided arena so the Value - // outlives the local `parsed` tree (which we deinit at scope end). + // Double-parse: first into client.gpa for routing inspection + // (see `parsed` above, which we deinit at scope end), then into + // the slot's caller-provided arena so the Value's lifetime is + // tied to the caller. A future optimization would peek just the + // id from `body` with a streaming parser, then parse the full + // body once into the right arena. const arena_parsed = std.json.parseFromSliceLeaky( std.json.Value, slot.arena_alloc, diff --git a/src/lsp/uri.zig b/src/lsp/uri.zig index 7728092..67c2faa 100644 --- a/src/lsp/uri.zig +++ b/src/lsp/uri.zig @@ -43,6 +43,10 @@ fn hasDriveLetter(path: []const u8) bool { /// On Windows-shaped paths (`X:\...` or `X:/...`) the URI becomes /// `file:///X:/...` (triple slash, drive letter preserved, separators /// converted to `/`). +/// +/// Limitation: backslashes in POSIX paths are converted to `/` rather +/// than percent-encoded as `%5C`. This means a legal-but-rare POSIX +/// filename containing `\` does not round-trip. Out of scope for v1. pub fn pathToUri(gpa: std.mem.Allocator, path: []const u8) std.mem.Allocator.Error![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); From 45a41594b6ec90c5befc132bbbe33bd3220dafe0 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 21 May 2026 06:31:51 +0200 Subject: [PATCH 17/19] fix(build): bump mlugg/setup-zig to v2 for Zig 0.16.0 download v1 queries ziglang.org/builds, which only hosts dev/master tarballs. Tagged releases like 0.16.0 live under ziglang.org/download//. v2 uses the correct endpoint and the official download index, fixing the 404 from every mirror that broke CI on all three OS runners. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 629546e..84c5aa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Zig 0.16.0 - uses: mlugg/setup-zig@v1 + uses: mlugg/setup-zig@v2 with: version: 0.16.0 From 594f8ce09cb860be2c8a5ccf4fa95cc32a960112 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 21 May 2026 06:33:58 +0200 Subject: [PATCH 18/19] fix(build): force LF line endings via .gitattributes GitHub Windows runners default to core.autocrlf=true, so files are checked out with CRLF. Zig's formatter normalizes to LF, which made `zig fmt --check` flag every file on the Windows job. Pinning LF in .gitattributes keeps the working tree consistent across platforms and unblocks the format gate. --- .gitattributes | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f10a7d8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Force LF line endings on checkout, on all platforms. +# Zig's formatter normalizes to LF; without this, Windows runners with +# `core.autocrlf=true` (the default) check out CRLF and `zig fmt --check` +# flags every file as needing reformatting. +* text=auto eol=lf From 8d71cbc364925098aede71439cd28695d9ef7fb3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 21 May 2026 07:08:28 +0200 Subject: [PATCH 19/19] chore(build): switch to weldengine/setup-zig@v0.1.0 Use our own pinned fork of the Zig installer action so we control its update cadence and aren't exposed to upstream behavioural changes. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84c5aa6..87db9aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Zig 0.16.0 - uses: mlugg/setup-zig@v2 + uses: weldengine/setup-zig@v0.1.0 with: version: 0.16.0