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
+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);
}