diff --git a/src/lsp.zig b/src/lsp.zig new file mode 100644 index 0000000..b18abaf --- /dev/null +++ b/src/lsp.zig @@ -0,0 +1,223 @@ +const std = @import("std"); + +// Minimal LSP process lifecycle + document sync transport. +// req: coding/003, repo/002, testing/001, testing/002, testing/003, testing/004 + +test { + _ = runDocumentSyncRowsAlloc; +} + +pub const Error = error{ + InvalidArgv, + InvalidCwd, + InvalidDocument, +}; + +pub const Document = struct { + uri: []const u8, + language_id: []const u8, + text: []const u8, +}; + +pub fn runDocumentSyncRowsAlloc( + allocator: std.mem.Allocator, + io: std.Io, + cwd: []const u8, + argv: []const []const u8, + document: Document, +) ![][]const u8 { + try validateCwd(cwd); + try validateArgv(argv); + try validateDocument(document); + const transcript = try transcriptAlloc(allocator, document); + defer allocator.free(transcript); + + var rows = std.ArrayList([]const u8).empty; + errdefer { + for (rows.items) |row| allocator.free(row); + rows.deinit(allocator); + } + const preview = try commandPreviewAlloc(allocator, argv); + defer allocator.free(preview); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:spawned:{s}", .{preview})); + + var child = std.process.spawn(io, .{ + .argv = argv, + .cwd = .{ .path = cwd }, + .stdin = .pipe, + .stdout = .ignore, + .stderr = .ignore, + }) catch |err| { + try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:status:spawn_error_{s}", .{@errorName(err)})); + return rows.toOwnedSlice(allocator); + }; + defer child.kill(io); + + child.stdin.?.writeStreamingAll(io, transcript) catch |err| { + child.stdin.?.close(io); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:status:write_error_{s}", .{@errorName(err)})); + _ = child.wait(io) catch {}; + return rows.toOwnedSlice(allocator); + }; + child.stdin.?.close(io); + child.stdin = null; + + try rows.append(allocator, try allocator.dupe(u8, "lsp:sent:initialize")); + try rows.append(allocator, try allocator.dupe(u8, "lsp:sent:textDocument/didOpen")); + try rows.append(allocator, try allocator.dupe(u8, "lsp:sent:textDocument/didChange")); + try rows.append(allocator, try allocator.dupe(u8, "lsp:sent:textDocument/didSave")); + + const term = child.wait(io) catch |err| { + try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:status:wait_error_{s}", .{@errorName(err)})); + return rows.toOwnedSlice(allocator); + }; + child.id = null; + try appendTermRow(allocator, &rows, term); + return rows.toOwnedSlice(allocator); +} + +pub fn transcriptAlloc(allocator: std.mem.Allocator, document: Document) ![]u8 { + try validateDocument(document); + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + try appendFramed(allocator, &out, try std.fmt.allocPrint( + allocator, + "{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{{\"capabilities\":{{}}}}}}", + .{}, + )); + const uri_json = try jsonEscapeAlloc(allocator, document.uri); + defer allocator.free(uri_json); + const language_json = try jsonEscapeAlloc(allocator, document.language_id); + defer allocator.free(language_json); + const text_json = try jsonEscapeAlloc(allocator, document.text); + defer allocator.free(text_json); + try appendFramed(allocator, &out, try std.fmt.allocPrint( + allocator, + "{{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didOpen\",\"params\":{{\"textDocument\":{{\"uri\":\"{s}\",\"languageId\":\"{s}\",\"version\":1,\"text\":\"{s}\"}}}}}}", + .{ uri_json, language_json, text_json }, + )); + try appendFramed(allocator, &out, try std.fmt.allocPrint( + allocator, + "{{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didChange\",\"params\":{{\"textDocument\":{{\"uri\":\"{s}\",\"version\":2}},\"contentChanges\":[{{\"text\":\"{s}\"}}]}}}}", + .{ uri_json, text_json }, + )); + try appendFramed(allocator, &out, try std.fmt.allocPrint( + allocator, + "{{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didSave\",\"params\":{{\"textDocument\":{{\"uri\":\"{s}\"}},\"text\":\"{s}\"}}}}", + .{ uri_json, text_json }, + )); + return out.toOwnedSlice(allocator); +} + +fn appendFramed(allocator: std.mem.Allocator, out: *std.ArrayList(u8), body: []u8) !void { + defer allocator.free(body); + const header = try std.fmt.allocPrint(allocator, "Content-Length: {d}\r\n\r\n", .{body.len}); + defer allocator.free(header); + try out.appendSlice(allocator, header); + try out.appendSlice(allocator, body); +} + +fn jsonEscapeAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + for (value) |byte| switch (byte) { + '\\' => try out.appendSlice(allocator, "\\\\"), + '"' => try out.appendSlice(allocator, "\\\""), + '\n' => try out.appendSlice(allocator, "\\n"), + '\r' => try out.appendSlice(allocator, "\\r"), + '\t' => try out.appendSlice(allocator, "\\t"), + else => try out.append(allocator, byte), + }; + return out.toOwnedSlice(allocator); +} + +fn validateCwd(cwd: []const u8) !void { + if (cwd.len == 0 or !std.unicode.utf8ValidateSlice(cwd)) return Error.InvalidCwd; + for (cwd) |byte| if (byte == 0 or byte == '\n' or byte == '\r' or byte == '|') return Error.InvalidCwd; +} + +fn validateArgv(argv: []const []const u8) !void { + if (argv.len == 0) return Error.InvalidArgv; + for (argv) |arg| { + if (arg.len == 0 or !std.unicode.utf8ValidateSlice(arg)) return Error.InvalidArgv; + for (arg) |byte| if (byte == 0 or byte == '\n' or byte == '\r') return Error.InvalidArgv; + } +} + +fn validateDocument(document: Document) !void { + if (document.uri.len == 0 or document.language_id.len == 0) return Error.InvalidDocument; + if (!std.mem.startsWith(u8, document.uri, "file://")) return Error.InvalidDocument; + if (!std.unicode.utf8ValidateSlice(document.uri) or !std.unicode.utf8ValidateSlice(document.language_id) or !std.unicode.utf8ValidateSlice(document.text)) return Error.InvalidDocument; + for (document.uri) |byte| if (byte == 0 or byte == '\n' or byte == '\r' or byte == ' ') return Error.InvalidDocument; + for (document.language_id) |byte| if (byte == 0 or byte <= 0x20 or byte == '|') return Error.InvalidDocument; +} + +fn appendTermRow(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), term: std.process.Child.Term) !void { + const row = switch (term) { + .exited => |code| try std.fmt.allocPrint(allocator, "lsp:status:exit_{d}", .{code}), + .signal => |sig| try std.fmt.allocPrint(allocator, "lsp:status:signal_{d}", .{@intFromEnum(sig)}), + .stopped => |sig| try std.fmt.allocPrint(allocator, "lsp:status:stopped_{d}", .{@intFromEnum(sig)}), + .unknown => |code| try std.fmt.allocPrint(allocator, "lsp:status:unknown_{d}", .{code}), + }; + try rows.append(allocator, row); +} + +fn commandPreviewAlloc(allocator: std.mem.Allocator, argv: []const []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + for (argv, 0..) |arg, index| { + if (index != 0) try out.append(allocator, '_'); + for (arg) |byte| try out.append(allocator, if (byte <= 0x20 or byte == '|') '_' else byte); + if (out.items.len >= 120) break; + } + return out.toOwnedSlice(allocator); +} + +fn freeRows(allocator: std.mem.Allocator, rows: []const []const u8) void { + for (rows) |row| allocator.free(row); + allocator.free(rows); +} + +test "regular: transcript frames initialize open change and save" { + const transcript = try transcriptAlloc(std.testing.allocator, .{ .uri = "file:///repo/src/main.zig", .language_id = "zig", .text = "const x = 1;\n" }); + defer std.testing.allocator.free(transcript); + try std.testing.expect(std.mem.indexOf(u8, transcript, "Content-Length:") != null); + try std.testing.expect(std.mem.indexOf(u8, transcript, "\"method\":\"initialize\"") != null); + try std.testing.expect(std.mem.indexOf(u8, transcript, "textDocument/didOpen") != null); + try std.testing.expect(std.mem.indexOf(u8, transcript, "textDocument/didChange") != null); + try std.testing.expect(std.mem.indexOf(u8, transcript, "textDocument/didSave") != null); +} + +test "regular: fake lsp server observes document sync on stdin" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const cwd = try std.fmt.allocPrint(std.testing.allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path}); + defer std.testing.allocator.free(cwd); + + const rows = try runDocumentSyncRowsAlloc(std.testing.allocator, std.testing.io, cwd, &.{ "sh", "-c", "cat>observed.lsp" }, .{ + .uri = "file:///tmp/main.zig", + .language_id = "zig", + .text = "pub const x = 1;\n", + }); + defer freeRows(std.testing.allocator, rows); + try std.testing.expectEqualStrings("lsp:sent:initialize", rows[1]); + try std.testing.expectEqualStrings("lsp:sent:textDocument/didOpen", rows[2]); + try std.testing.expectEqualStrings("lsp:sent:textDocument/didChange", rows[3]); + try std.testing.expectEqualStrings("lsp:sent:textDocument/didSave", rows[4]); + try std.testing.expectEqualStrings("lsp:status:exit_0", rows[5]); + + const observed = try tmp.dir.readFileAlloc(std.testing.io, "observed.lsp", std.testing.allocator, .limited(64 * 1024)); + defer std.testing.allocator.free(observed); + try std.testing.expect(std.mem.indexOf(u8, observed, "textDocument/didOpen") != null); + try std.testing.expect(std.mem.indexOf(u8, observed, "textDocument/didChange") != null); + try std.testing.expect(std.mem.indexOf(u8, observed, "textDocument/didSave") != null); +} + +test "adversarial: lsp validation and spawn failures are explicit" { + try std.testing.expectError(Error.InvalidArgv, runDocumentSyncRowsAlloc(std.testing.allocator, std.testing.io, ".", &.{}, .{ .uri = "file:///x", .language_id = "zig", .text = "" })); + try std.testing.expectError(Error.InvalidDocument, transcriptAlloc(std.testing.allocator, .{ .uri = "file with spaces", .language_id = "zig", .text = "" })); + + const rows = try runDocumentSyncRowsAlloc(std.testing.allocator, std.testing.io, ".", &.{"definitely-not-a-mim-lsp"}, .{ .uri = "file:///x", .language_id = "zig", .text = "" }); + defer freeRows(std.testing.allocator, rows); + try std.testing.expect(std.mem.startsWith(u8, rows[1], "lsp:status:spawn_error_")); +} diff --git a/src/main.zig b/src/main.zig index 69e9dc4..6ba755a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,6 +3,7 @@ const input = @import("input.zig"); const job = @import("job.zig"); const layout = @import("layout.zig"); const leader = @import("leader.zig"); +const lsp = @import("lsp.zig"); const mobile_acceptance = @import("mobile_acceptance.zig"); const panel = @import("panel.zig"); const protocol = @import("protocol.zig"); @@ -129,6 +130,7 @@ test { _ = job; _ = layout; _ = leader; + _ = lsp; _ = mobile_acceptance; _ = panel; _ = protocol; diff --git a/src/tui.zig b/src/tui.zig index 1523195..6d6ed12 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -2,6 +2,7 @@ const std = @import("std"); const input = @import("input.zig"); const job_mod = @import("job.zig"); const leader_mod = @import("leader.zig"); +const lsp_mod = @import("lsp.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const repo_mod = @import("repo.zig"); @@ -148,6 +149,7 @@ pub const Client = struct { if (std.mem.eql(u8, line, "terminal_cancel")) return self.openStaticJobRow("terminal-status", "terminal_cancel:no_running_terminal"); if (std.mem.eql(u8, line, "terminal_exit")) return self.closeTerminalPanel(); if (std.mem.startsWith(u8, line, "syntax_spans ")) return self.openSyntaxSpans(line[13..]); + if (std.mem.startsWith(u8, line, "lsp_sync ")) return self.openLspSync(line[9..]); if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line); @@ -416,6 +418,27 @@ pub const Client = struct { self.message = null; } + fn openLspSync(self: *Client, args: []const u8) !void { + const io = self.io orelse return Error.ProtocolRejected; + const parsed = parseLspSyncArgs(args) orelse return Error.ProtocolRejected; + const snap = try self.session.snapshot(); + var argv = std.ArrayList([]const u8).empty; + defer argv.deinit(self.allocator); + var parts = std.mem.splitScalar(u8, parsed.command, ' '); + while (parts.next()) |part| { + if (part.len == 0) continue; + try argv.append(self.allocator, part); + } + const rows = lsp_mod.runDocumentSyncRowsAlloc(self.allocator, io, parsed.cwd, argv.items, .{ + .uri = parsed.uri, + .language_id = parsed.language_id, + .text = snap.bytes, + }) catch return Error.ProtocolRejected; + defer freeOwnedRows(self.allocator, rows); + try self.session.openListPanel("lsp-sync", rows); + self.message = null; + } + fn openStaticJobRow(self: *Client, title: []const u8, row: []const u8) !void { try self.session.openListPanel(title, &.{row}); self.message = null; @@ -669,6 +692,20 @@ fn splitCwdAndCommand(cwd_and_command: []const u8) ?struct { cwd: []const u8, co return .{ .cwd = cwd, .command = command }; } +fn parseLspSyncArgs(args: []const u8) ?struct { cwd: []const u8, uri: []const u8, language_id: []const u8, command: []const u8 } { + const first = std.mem.indexOfScalar(u8, args, ' ') orelse return null; + const cwd = args[0..first]; + const rest = std.mem.trim(u8, args[first + 1 ..], " "); + const second = std.mem.indexOfScalar(u8, rest, ' ') orelse return null; + const uri = rest[0..second]; + const rest2 = std.mem.trim(u8, rest[second + 1 ..], " "); + const third = std.mem.indexOfScalar(u8, rest2, ' ') orelse return null; + const language_id = rest2[0..third]; + const command = std.mem.trim(u8, rest2[third + 1 ..], " "); + if (cwd.len == 0 or uri.len == 0 or language_id.len == 0 or command.len == 0) return null; + return .{ .cwd = cwd, .uri = uri, .language_id = language_id, .command = command }; +} + test "regular: scripted narrow terminal trace edits saves exits and replays saved bytes" { const trace = \\open abc @@ -1505,3 +1542,51 @@ test "adversarial: unsupported syntax language is rejected without changing buff const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); } + +fn makeTuiLspFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, cwd: []u8 } { + var tmp = std.testing.tmpDir(.{}); + errdefer tmp.cleanup(); + const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path}); + errdefer allocator.free(cwd); + return .{ .tmp = tmp, .cwd = cwd }; +} + +test "regular: lsp sync panel sends current buffer to fake server" { + var fixture = try makeTuiLspFixture(std.testing.allocator); + defer { + std.testing.allocator.free(fixture.cwd); + fixture.tmp.cleanup(); + } + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 9 }, std.testing.io); + defer client.deinit(); + + try client.handleTraceLine("open pub const x = 1;"); + const sync = try std.fmt.allocPrint(std.testing.allocator, "lsp_sync {s} file:///tmp/main.zig zig sh -c cat>observed.lsp", .{fixture.cwd}); + defer std.testing.allocator.free(sync); + try client.handleTraceLine(sync); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-sync") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:initialize") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:textDocument/didOpen") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:textDocument/didChange") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:textDocument/didSave") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:status:exit_0") != null); + } + const observed = try fixture.tmp.dir.readFileAlloc(std.testing.io, "observed.lsp", std.testing.allocator, .limited(64 * 1024)); + defer std.testing.allocator.free(observed); + try std.testing.expect(std.mem.indexOf(u8, observed, "pub const x = 1;") != null); + try std.testing.expect(std.mem.indexOf(u8, observed, "textDocument/didOpen") != null); +} + +test "adversarial: lsp sync rejection does not corrupt current buffer" { + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 5 }, std.testing.io); + defer client.deinit(); + + try client.handleTraceLine("open safe"); + try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_sync .")); + try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_sync . bad uri zig sh -c cat")); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("safe", snap.bytes); +}