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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..87db9aa --- /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: weldengine/setup-zig@v0.1.0 + 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 diff --git a/build.zig b/build.zig index 9088174..4e2e0c5 100644 --- a/build.zig +++ b/build.zig @@ -37,4 +37,32 @@ 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` ---- + // + // 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 root_mod = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + }); + 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/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); diff --git a/src/lsp/client.zig b/src/lsp/client.zig new file mode 100644 index 0000000..8f09b86 --- /dev/null +++ b/src/lsp/client.zig @@ -0,0 +1,598 @@ +//! 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 + + const 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; + }; + 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}); + self.stderr_thread = try std.Thread.spawn(.{}, stderrLoop, .{self}); + + 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 stays set until threads have joined — they hold + // refs into it via &client.child.?. + } + + if (self.reader_thread) |t| { + t.join(); + self.reader_thread = null; + } + if (self.stderr_thread) |t| { + t.join(); + self.stderr_thread = null; + } + + self.child = 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. + 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); + } + + if (self.reader_thread) |t| { + t.join(); + self.reader_thread = null; + } + if (self.stderr_thread) |t| { + t.join(); + self.stderr_thread = null; + } + + // Threads are joined; safe to drop the child now. + self.child = 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); + // 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), + .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); + return e; + }; + self.registry_mutex.unlock(self.io); + + // 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); + } + + const body = try codec.encodeRequest(self.gpa, id, method, params); + defer self.gpa.free(body); + + try self.writeFrameSafe(body); + + // 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)) return LspError.ZlsCrashed; + const now_ns = Io.Clock.awake.now(self.io).nanoseconds; + if (now_ns >= deadline_ns) { + // 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) }); + }; + return LspError.Timeout; + } + } + self.io.sleep(Io.Duration.fromMilliseconds(5), .awake) catch {}; + } + + 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(); + } +}; + +/// 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.?; + 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| { + // 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)}), + } + 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| 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; + }, + }; + + const slot = client.takeSlot(id) orelse { + log.debug("client", "orphan response id={d} (cancelled or timed out)", .{id}); + continue; + }; + + // 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, + 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/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)); +} diff --git a/src/lsp/types.zig b/src/lsp/types.zig new file mode 100644 index 0000000..4d774d0 --- /dev/null +++ b/src/lsp/types.zig @@ -0,0 +1,218 @@ +//! 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 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, + 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. Safe to +/// call on a default-initialized (empty) value. +pub fn deinitServerCapabilities(caps: *ServerCapabilities, gpa: std.mem.Allocator) void { + if (caps.position_encoding.len > 0) 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); +} diff --git a/src/lsp/uri.zig b/src/lsp/uri.zig new file mode 100644 index 0000000..67c2faa --- /dev/null +++ b/src/lsp/uri.zig @@ -0,0 +1,210 @@ +//! `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 `/`). +/// +/// 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); + 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("")); +} 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; } 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/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()); +} 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. 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); +}