Add LSP document sync transport
This commit is contained in:
+223
@@ -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_"));
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user