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_"));
}