Add LSP document sync transport

This commit is contained in:
slhx agent
2026-06-21 04:28:59 +02:00
parent a90c35e542
commit c90160f90d
3 changed files with 310 additions and 0 deletions
+223
View File
@@ -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_"));
}
+2
View File
@@ -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;
+85
View File
@@ -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);
}