Files
mim/src/lsp.zig
T
2026-06-21 14:21:09 +02:00

1394 lines
66 KiB
Zig

const std = @import("std");
const diagnostics = @import("diagnostics.zig");
// 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,
InvalidDiagnostics,
InvalidDiagnosticRow,
InvalidNavigation,
InvalidNavigationRow,
InvalidEdit,
InvalidEditRow,
InvalidHelp,
DuplicateProvider,
InvalidProvider,
MissingProvider,
AmbiguousProvider,
StaleEdit,
OverlappingEdit,
};
pub const ProviderKind = enum {
lsp,
linter,
formatter,
build,
test_runner,
checker,
};
pub const ProviderCapability = enum {
diagnostics,
hover,
signature,
navigation,
format,
code_action,
lint,
build,
tests,
check,
rename,
organize_imports,
};
pub const Provider = struct {
id: []const u8,
name: []const u8,
kind: ProviderKind,
capabilities: []const ProviderCapability,
preferred_for_mutation: bool = false,
document_version: u64 = 0,
alive: bool = true,
pub fn supports(self: Provider, capability: ProviderCapability) bool {
for (self.capabilities) |candidate| if (candidate == capability) return true;
return false;
}
};
pub const ProviderChoice = union(enum) {
missing,
selected: Provider,
ambiguous: []Provider,
};
pub const ProviderRegistry = struct {
allocator: std.mem.Allocator,
providers: std.ArrayList(Provider),
pub fn init(allocator: std.mem.Allocator) ProviderRegistry {
return .{ .allocator = allocator, .providers = .empty };
}
pub fn deinit(self: *ProviderRegistry) void {
self.providers.deinit(self.allocator);
self.* = undefined;
}
pub fn register(self: *ProviderRegistry, provider: Provider) !void {
if (provider.id.len == 0 or provider.name.len == 0) return Error.InvalidProvider;
for (self.providers.items) |existing| {
if (std.mem.eql(u8, existing.id, provider.id)) return Error.DuplicateProvider;
}
try self.providers.append(self.allocator, provider);
}
pub fn nonMutatingProvidersAlloc(self: *const ProviderRegistry, allocator: std.mem.Allocator, capability: ProviderCapability) ![]Provider {
var matches = std.ArrayList(Provider).empty;
errdefer matches.deinit(allocator);
for (self.providers.items) |provider| {
if (provider.alive and provider.supports(capability)) try matches.append(allocator, provider);
}
return matches.toOwnedSlice(allocator);
}
pub fn chooseMutatingProvider(self: *const ProviderRegistry, allocator: std.mem.Allocator, capability: ProviderCapability) !ProviderChoice {
const matches = try self.nonMutatingProvidersAlloc(allocator, capability);
if (matches.len == 0) {
allocator.free(matches);
return .missing;
}
var preferred: ?Provider = null;
for (matches) |provider| if (provider.preferred_for_mutation) {
if (preferred != null) return .{ .ambiguous = matches };
preferred = provider;
};
if (preferred) |provider| {
allocator.free(matches);
return .{ .selected = provider };
}
if (matches.len == 1) {
const provider = matches[0];
allocator.free(matches);
return .{ .selected = provider };
}
return .{ .ambiguous = matches };
}
pub fn isStale(self: *const ProviderRegistry, provider_id: []const u8, document_version: u64) !bool {
for (self.providers.items) |provider| {
if (std.mem.eql(u8, provider.id, provider_id)) return document_version < provider.document_version;
}
return Error.MissingProvider;
}
};
pub fn providerRowsAlloc(allocator: std.mem.Allocator, provider: Provider, rows: []const []const u8) ![][]const u8 {
var labelled = std.ArrayList([]const u8).empty;
errdefer {
for (labelled.items) |row| allocator.free(row);
labelled.deinit(allocator);
}
for (rows) |row| {
try labelled.append(allocator, try std.fmt.allocPrint(allocator, "provider:{s}:{s}", .{ provider.id, row }));
}
return labelled.toOwnedSlice(allocator);
}
pub fn providerIdFromRow(row: []const u8) ![]const u8 {
if (!std.mem.startsWith(u8, row, "provider:")) return Error.InvalidProvider;
const rest = row[9..];
const split = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidProvider;
if (split == 0) return Error.InvalidProvider;
return rest[0..split];
}
pub fn providerPayloadFromRow(row: []const u8) ![]const u8 {
if (!std.mem.startsWith(u8, row, "provider:")) return Error.InvalidProvider;
const rest = row[9..];
const split = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidProvider;
if (split == 0 or split + 1 > rest.len) return Error.InvalidProvider;
return rest[split + 1 ..];
}
pub fn missingProviderRowAlloc(allocator: std.mem.Allocator, capability: ProviderCapability) ![]const u8 {
return std.fmt.allocPrint(allocator, "provider:missing:{s}", .{@tagName(capability)});
}
pub fn compactHoverCardAlloc(allocator: std.mem.Allocator, row: []const u8, width: usize) ![]u8 {
const provider = providerIdFromRow(row) catch "unknown";
const payload = providerPayloadFromRow(row) catch row;
const prefix = "lsp:hover:";
const body = if (std.mem.startsWith(u8, payload, prefix)) payload[prefix.len..] else payload;
const split = std.mem.indexOfScalar(u8, body, '|') orelse body.len;
const headline = body[0..split];
const docs = if (split < body.len) body[split + 1 ..] else "";
const budget = if (width > provider.len + headline.len + 8) width - provider.len - headline.len - 8 else 0;
const excerpt_len = @min(docs.len, budget);
return std.fmt.allocPrint(allocator, "[{s}] {s}{s}{s}", .{ provider, headline, if (excerpt_len > 0) " — " else "", docs[0..excerpt_len] });
}
pub fn expandedHoverRowsAlloc(allocator: std.mem.Allocator, row: []const u8, width: usize) ![][]const u8 {
const provider = providerIdFromRow(row) catch "unknown";
const payload = providerPayloadFromRow(row) catch row;
const prefix = "lsp:hover:";
const body = if (std.mem.startsWith(u8, payload, prefix)) payload[prefix.len..] else payload;
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |item| allocator.free(item);
rows.deinit(allocator);
}
try rows.append(allocator, try std.fmt.allocPrint(allocator, "hover_provider:{s}", .{provider}));
var start: usize = 0;
const wrap = @max(width, 8);
while (start < body.len) {
const end = @min(start + wrap, body.len);
const wrapped = try allocator.dupe(u8, body[start..end]);
for (wrapped) |*byte| {
if (byte.* <= 0x20 or byte.* == '|') byte.* = '_';
}
try rows.append(allocator, wrapped);
start = end;
}
return rows.toOwnedSlice(allocator);
}
pub fn compactSignatureCardAlloc(allocator: std.mem.Allocator, row: []const u8) ![]u8 {
const provider = providerIdFromRow(row) catch "unknown";
const payload = providerPayloadFromRow(row) catch row;
const prefix = "lsp:signature:";
const body = if (std.mem.startsWith(u8, payload, prefix)) payload[prefix.len..] else payload;
return std.fmt.allocPrint(allocator, "[{s}] {s}", .{ provider, body });
}
pub const ProviderEdit = struct {
provider: []const u8,
capability: ProviderCapability,
version: u64,
start: usize,
end: usize,
replacement: []const u8,
};
pub fn providerCapabilityFromName(name: []const u8) !ProviderCapability {
if (std.mem.eql(u8, name, "format")) return .format;
if (std.mem.eql(u8, name, "organize_imports")) return .organize_imports;
if (std.mem.eql(u8, name, "code_action")) return .code_action;
return Error.InvalidProvider;
}
pub fn editRowAlloc(
allocator: std.mem.Allocator,
provider: []const u8,
capability: ProviderCapability,
version: u64,
start: usize,
end: usize,
replacement: []const u8,
) ![]u8 {
if (provider.len == 0 or start > end) return Error.InvalidEdit;
return std.fmt.allocPrint(allocator, "provider:{s}:edit:{s}:{d}:{d}:{d}:{s}", .{ provider, @tagName(capability), version, start, end, replacement });
}
pub fn parseProviderEditRow(row: []const u8) !ProviderEdit {
const provider = try providerIdFromRow(row);
const payload = try providerPayloadFromRow(row);
if (!std.mem.startsWith(u8, payload, "edit:")) return Error.InvalidEdit;
var parts = std.mem.splitScalar(u8, payload, ':');
_ = parts.next() orelse return Error.InvalidEdit;
const capability_name = parts.next() orelse return Error.InvalidEdit;
const version_text = parts.next() orelse return Error.InvalidEdit;
const start_text = parts.next() orelse return Error.InvalidEdit;
const end_text = parts.next() orelse return Error.InvalidEdit;
const replacement = parts.rest();
const start = std.fmt.parseUnsigned(usize, start_text, 10) catch return Error.InvalidEdit;
const end = std.fmt.parseUnsigned(usize, end_text, 10) catch return Error.InvalidEdit;
if (start > end) return Error.InvalidEdit;
return .{
.provider = provider,
.capability = try providerCapabilityFromName(capability_name),
.version = std.fmt.parseUnsigned(u64, version_text, 10) catch return Error.InvalidEdit,
.start = start,
.end = end,
.replacement = replacement,
};
}
pub fn validateEditBatch(rows: []const []const u8, document_version: u64, document_len: usize) !void {
var previous_start: ?usize = null;
var previous_end: ?usize = null;
for (rows) |row| {
const edit = try parseProviderEditRow(row);
if (edit.version != document_version) return Error.StaleEdit;
if (edit.end > document_len) return Error.InvalidEdit;
if (previous_start) |start| {
if (!(edit.end <= start or edit.start >= previous_end.?)) return Error.OverlappingEdit;
}
previous_start = edit.start;
previous_end = edit.end;
}
}
pub fn actionPanelRowAlloc(allocator: std.mem.Allocator, row: []const u8) ![]const u8 {
const edit = try parseProviderEditRow(row);
return std.fmt.allocPrint(allocator, "action:{s}:provider:{s}:scope:{d}-{d}:apply", .{ @tagName(edit.capability), edit.provider, edit.start, edit.end });
}
pub fn providerFromActionPanelRow(row: []const u8) ![]const u8 {
const marker = ":provider:";
const start = std.mem.indexOf(u8, row, marker) orelse return Error.InvalidProvider;
const rest = row[start + marker.len ..];
const end = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidProvider;
if (end == 0) return Error.InvalidProvider;
return rest[0..end];
}
pub fn capabilityFromActionPanelRow(row: []const u8) !ProviderCapability {
if (!std.mem.startsWith(u8, row, "action:")) return Error.InvalidProvider;
const rest = row[7..];
const end = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidProvider;
return providerCapabilityFromName(rest[0..end]);
}
pub fn providerPickerRowsAlloc(allocator: std.mem.Allocator, capability: ProviderCapability, providers: []const Provider) ![][]const u8 {
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
for (providers) |provider| {
try rows.append(allocator, try std.fmt.allocPrint(allocator, "provider:choose:{s}:{s}:{s}", .{ @tagName(capability), provider.id, provider.name }));
}
return rows.toOwnedSlice(allocator);
}
pub const Document = struct {
uri: []const u8,
language_id: []const u8,
text: []const u8,
};
pub const DiagnosticLocation = struct {
uri: []const u8,
line: usize,
character: usize,
};
pub const ParameterDirection = enum { next, previous };
pub fn diagnosticRowsAlloc(allocator: std.mem.Allocator, payload: []const u8) ![][]const u8 {
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return Error.InvalidDiagnostics;
defer parsed.deinit();
const root = parsed.value;
if (root != .object) return Error.InvalidDiagnostics;
const params = objectGet(root, "params") orelse return Error.InvalidDiagnostics;
const uri = stringGet(params, "uri") orelse return Error.InvalidDiagnostics;
const diagnostics_value = objectGet(params, "diagnostics") orelse return Error.InvalidDiagnostics;
if (diagnostics_value != .array) return Error.InvalidDiagnostics;
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:diagnostics:count_{d}", .{diagnostics_value.array.items.len}));
const uri_preview = try previewAlloc(allocator, uri);
defer allocator.free(uri_preview);
for (diagnostics_value.array.items) |diagnostic| {
const range = objectGet(diagnostic, "range") orelse return Error.InvalidDiagnostics;
const start = objectGet(range, "start") orelse return Error.InvalidDiagnostics;
const line = unsignedGet(start, "line") orelse return Error.InvalidDiagnostics;
const character = unsignedGet(start, "character") orelse return Error.InvalidDiagnostics;
const severity = unsignedGet(diagnostic, "severity") orelse 1;
const message = stringGet(diagnostic, "message") orelse return Error.InvalidDiagnostics;
const preview = try previewAlloc(allocator, message);
defer allocator.free(preview);
try rows.append(allocator, try std.fmt.allocPrint(
allocator,
"lsp:diag:{s}:{d}:{d}:{s}:{s}",
.{ severityText(severity), line + 1, character + 1, uri_preview, preview },
));
}
return rows.toOwnedSlice(allocator);
}
pub fn diagnosticLocationFromRow(row: []const u8) !DiagnosticLocation {
if (!std.mem.startsWith(u8, row, "lsp:diag:")) return Error.InvalidDiagnosticRow;
var parts = std.mem.splitScalar(u8, row, ':');
_ = parts.next() orelse return Error.InvalidDiagnosticRow; // lsp
_ = parts.next() orelse return Error.InvalidDiagnosticRow; // diag
_ = parts.next() orelse return Error.InvalidDiagnosticRow; // severity
const line_token = parts.next() orelse return Error.InvalidDiagnosticRow;
const character_token = parts.next() orelse return Error.InvalidDiagnosticRow;
const uri = parts.next() orelse return Error.InvalidDiagnosticRow;
const line = std.fmt.parseUnsigned(usize, line_token, 10) catch return Error.InvalidDiagnosticRow;
const character = std.fmt.parseUnsigned(usize, character_token, 10) catch return Error.InvalidDiagnosticRow;
if (line == 0 or character == 0 or uri.len == 0) return Error.InvalidDiagnosticRow;
return .{ .uri = uri, .line = line, .character = character };
}
pub fn byteOffsetForLineColumn(content: []const u8, line: usize, column: usize) !usize {
if (line == 0 or column == 0) return Error.InvalidDiagnosticRow;
var current_line: usize = 1;
var line_start: usize = 0;
var i: usize = 0;
while (i <= content.len) : (i += 1) {
if (i == content.len or content[i] == '\n') {
if (current_line == line) {
const line_bytes = content[line_start..i];
if (column - 1 > line_bytes.len) return Error.InvalidDiagnosticRow;
return line_start + column - 1;
}
current_line += 1;
line_start = i + 1;
}
}
return Error.InvalidDiagnosticRow;
}
pub fn locationRowsAlloc(allocator: std.mem.Allocator, kind: []const u8, payload: []const u8) ![][]const u8 {
try validateNavKind(kind);
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return Error.InvalidNavigation;
defer parsed.deinit();
const root = parsed.value;
if (root != .object) return Error.InvalidNavigation;
const result = objectGet(root, "result") orelse return Error.InvalidNavigation;
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
if (result == .null) {
try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:nav:{s}:none", .{kind}));
return rows.toOwnedSlice(allocator);
}
if (result == .array) {
for (result.array.items) |location| try appendLocationRow(allocator, &rows, kind, location, "location");
} else {
try appendLocationRow(allocator, &rows, kind, result, "location");
}
if (rows.items.len == 0) try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:nav:{s}:none", .{kind}));
return rows.toOwnedSlice(allocator);
}
pub fn symbolRowsAlloc(allocator: std.mem.Allocator, scope: []const u8, payload: []const u8) ![][]const u8 {
try validateSymbolScope(scope);
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return Error.InvalidNavigation;
defer parsed.deinit();
const root = parsed.value;
if (root != .object) return Error.InvalidNavigation;
const result = objectGet(root, "result") orelse return Error.InvalidNavigation;
if (result != .array) return Error.InvalidNavigation;
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
for (result.array.items) |symbol_value| try appendSymbolRow(allocator, &rows, scope, symbol_value);
if (rows.items.len == 0) try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:symbol:{s}:none", .{scope}));
return rows.toOwnedSlice(allocator);
}
pub fn navigationLocationFromRow(row: []const u8) !DiagnosticLocation {
var parts = std.mem.splitScalar(u8, row, ':');
const lsp = parts.next() orelse return Error.InvalidNavigationRow;
if (!std.mem.eql(u8, lsp, "lsp")) return Error.InvalidNavigationRow;
const family = parts.next() orelse return Error.InvalidNavigationRow;
if (std.mem.eql(u8, family, "nav")) {
_ = parts.next() orelse return Error.InvalidNavigationRow; // kind
const line_token = parts.next() orelse return Error.InvalidNavigationRow;
const character_token = parts.next() orelse return Error.InvalidNavigationRow;
const uri = parts.next() orelse return Error.InvalidNavigationRow;
return parseLocationTokens(uri, line_token, character_token);
}
if (std.mem.eql(u8, family, "symbol")) {
_ = parts.next() orelse return Error.InvalidNavigationRow; // scope
_ = parts.next() orelse return Error.InvalidNavigationRow; // symbol kind
const line_token = parts.next() orelse return Error.InvalidNavigationRow;
const character_token = parts.next() orelse return Error.InvalidNavigationRow;
const uri = parts.next() orelse return Error.InvalidNavigationRow;
return parseLocationTokens(uri, line_token, character_token);
}
return Error.InvalidNavigationRow;
}
pub fn workspaceEditRowsAlloc(allocator: std.mem.Allocator, kind: []const u8, payload: []const u8) ![][]const u8 {
try validateEditKind(kind);
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return Error.InvalidEdit;
defer parsed.deinit();
const root = parsed.value;
if (root != .object) return Error.InvalidEdit;
const result = objectGet(root, "result") orelse return Error.InvalidEdit;
if (result == .null) return singleEditNoneRow(allocator, kind);
return editRowsFromWorkspaceEditValue(allocator, kind, result);
}
pub fn codeActionRowsAlloc(allocator: std.mem.Allocator, payload: []const u8) ![][]const u8 {
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return Error.InvalidEdit;
defer parsed.deinit();
const root = parsed.value;
if (root != .object) return Error.InvalidEdit;
const result = objectGet(root, "result") orelse return Error.InvalidEdit;
if (result != .array) return Error.InvalidEdit;
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
for (result.array.items) |action| {
const title = stringGetValue(action, "title") orelse return Error.InvalidEdit;
const edit = objectGet(action, "edit") orelse {
const title_preview = try previewAlloc(allocator, title);
defer allocator.free(title_preview);
try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:action:none:{s}", .{title_preview}));
continue;
};
try appendRowsFromWorkspaceEditValue(allocator, &rows, "action", edit, title);
}
if (rows.items.len == 0) try rows.append(allocator, try allocator.dupe(u8, "lsp:action:none"));
return rows.toOwnedSlice(allocator);
}
pub fn applyEditRowAlloc(allocator: std.mem.Allocator, content: []const u8, row: []const u8) !struct { bytes: []u8, cursor: usize } {
const parsed = try parseEditRow(allocator, row);
defer allocator.free(parsed.new_text);
const start = byteOffsetForLineColumn(content, parsed.start_line, parsed.start_character) catch return Error.InvalidEditRow;
const end = byteOffsetForLineColumn(content, parsed.end_line, parsed.end_character) catch return Error.InvalidEditRow;
if (end < start) return Error.InvalidEditRow;
var out = std.ArrayList(u8).empty;
errdefer out.deinit(allocator);
try out.appendSlice(allocator, content[0..start]);
try out.appendSlice(allocator, parsed.new_text);
try out.appendSlice(allocator, content[end..]);
return .{ .bytes = try out.toOwnedSlice(allocator), .cursor = start + parsed.new_text.len };
}
pub fn hoverRowsAlloc(allocator: std.mem.Allocator, payload: []const u8, width: usize) ![][]const u8 {
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return Error.InvalidHelp;
defer parsed.deinit();
const root = parsed.value;
if (root != .object) return Error.InvalidHelp;
const result = objectGet(root, "result") orelse return Error.InvalidHelp;
if (result == .null) return singleHelpRow(allocator, "lsp:hover:none");
const text = hoverText(result) orelse return Error.InvalidHelp;
return wrappedRowsAlloc(allocator, "lsp:hover:", text, width);
}
pub fn signatureRowsAlloc(allocator: std.mem.Allocator, payload: []const u8, width: usize) ![][]const u8 {
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return Error.InvalidHelp;
defer parsed.deinit();
const root = parsed.value;
if (root != .object) return Error.InvalidHelp;
const result = objectGet(root, "result") orelse return Error.InvalidHelp;
if (result == .null) return singleHelpRow(allocator, "lsp:signature:none");
const signatures = objectGet(result, "signatures") orelse return Error.InvalidHelp;
if (signatures != .array or signatures.array.items.len == 0) return singleHelpRow(allocator, "lsp:signature:none");
const active_signature = unsignedGet(result, "activeSignature") orelse 0;
const active_parameter = unsignedGet(result, "activeParameter") orelse 0;
const signature = signatures.array.items[@min(active_signature, signatures.array.items.len - 1)];
const label = stringGetValue(signature, "label") orelse return Error.InvalidHelp;
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:signature:active_{d}", .{active_parameter + 1}));
try appendWrappedRows(allocator, &rows, "lsp:signature:label:", label, width);
if (objectGet(signature, "parameters")) |parameters| {
if (parameters != .array) return Error.InvalidHelp;
for (parameters.array.items, 0..) |parameter, index| {
const parameter_label = parameterLabel(parameter) orelse continue;
const state = if (index == active_parameter) "active" else "inactive";
const prefix = try std.fmt.allocPrint(allocator, "lsp:signature:param_{d}:{s}:", .{ index + 1, state });
defer allocator.free(prefix);
try appendWrappedRows(allocator, &rows, prefix, parameter_label, width);
}
}
return rows.toOwnedSlice(allocator);
}
pub fn parameterMoveRowsAlloc(allocator: std.mem.Allocator, content: []const u8, cursor: usize, direction: ParameterDirection) !struct { rows: [][]const u8, cursor: usize } {
const call = findCall(content, cursor) orelse return .{ .rows = try singleHelpRow(allocator, "lsp:param:outside_call"), .cursor = cursor };
if (call.comma_count == 0) return .{ .rows = try singleHelpRow(allocator, "lsp:param:single_parameter"), .cursor = cursor };
const current_index = parameterIndex(call, cursor);
const target_index = switch (direction) {
.next => @min(current_index + 1, call.comma_count),
.previous => if (current_index == 0) 0 else current_index - 1,
};
const target_cursor = parameterStart(call, target_index);
const rows = try allocator.alloc([]const u8, 1);
errdefer allocator.free(rows);
rows[0] = try std.fmt.allocPrint(allocator, "lsp:param:active_{d}:{d}", .{ target_index + 1, target_cursor });
return .{ .rows = rows, .cursor = target_cursor };
}
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)}));
try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, "lsp", diagnostics.process_stdout_limit, diagnostics.process_stderr_limit));
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)}));
try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, "lsp", diagnostics.process_stdout_limit, diagnostics.process_stderr_limit));
_ = 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)}));
try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, "lsp", diagnostics.process_stdout_limit, diagnostics.process_stderr_limit));
return rows.toOwnedSlice(allocator);
};
child.id = null;
try appendTermRow(allocator, &rows, term);
try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, "lsp", diagnostics.process_stdout_limit, diagnostics.process_stderr_limit));
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 hoverText(value: std.json.Value) ?[]const u8 {
if (value == .string) return value.string;
const contents = objectGet(value, "contents") orelse value;
if (contents == .string) return contents.string;
if (contents == .object) return stringGetValue(contents, "value") orelse stringGetValue(contents, "language");
if (contents == .array and contents.array.items.len > 0) return hoverText(contents.array.items[0]);
return null;
}
fn parameterLabel(value: std.json.Value) ?[]const u8 {
const label = objectGet(value, "label") orelse return null;
return switch (label) {
.string => |text| text,
else => null,
};
}
fn singleHelpRow(allocator: std.mem.Allocator, row: []const u8) ![][]const u8 {
const rows = try allocator.alloc([]const u8, 1);
errdefer allocator.free(rows);
rows[0] = try allocator.dupe(u8, row);
return rows;
}
fn wrappedRowsAlloc(allocator: std.mem.Allocator, prefix: []const u8, text: []const u8, width: usize) ![][]const u8 {
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
try appendWrappedRows(allocator, &rows, prefix, text, width);
if (rows.items.len == 0) try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}_", .{prefix}));
return rows.toOwnedSlice(allocator);
}
fn appendWrappedRows(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), prefix: []const u8, text: []const u8, width: usize) !void {
const preview = try previewAlloc(allocator, text);
defer allocator.free(preview);
const budget = if (width > prefix.len + 4) width - prefix.len else 8;
var start: usize = 0;
while (start < preview.len) {
const end = @min(preview.len, start + budget);
try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}{s}", .{ prefix, preview[start..end] }));
start = end;
}
}
const CallInfo = struct {
content: []const u8,
open: usize,
close: usize,
commas: [16]usize,
comma_count: usize,
};
fn findCall(content: []const u8, cursor: usize) ?CallInfo {
const limit = @min(cursor, content.len);
var open: ?usize = null;
var depth: usize = 0;
var i = limit;
while (i > 0) {
i -= 1;
switch (content[i]) {
')' => depth += 1,
'(' => {
if (depth == 0) {
open = i;
break;
}
depth -= 1;
},
'\n' => break,
else => {},
}
}
const open_index = open orelse return null;
var close_index = content.len;
var forward = open_index + 1;
var forward_depth: usize = 0;
while (forward < content.len) : (forward += 1) {
switch (content[forward]) {
'(' => forward_depth += 1,
')' => {
if (forward_depth == 0) {
close_index = forward;
break;
}
forward_depth -= 1;
},
'\n' => break,
else => {},
}
}
if (limit > close_index) return null;
var comma_buf: [16]usize = undefined;
var comma_count: usize = 0;
var j = open_index + 1;
var comma_depth: usize = 0;
while (j < close_index and comma_count < comma_buf.len) : (j += 1) {
switch (content[j]) {
'(' => comma_depth += 1,
')' => {
if (comma_depth > 0) comma_depth -= 1;
},
',' => if (comma_depth == 0) {
comma_buf[comma_count] = j;
comma_count += 1;
},
else => {},
}
}
return .{ .content = content, .open = open_index, .close = close_index, .commas = comma_buf, .comma_count = comma_count };
}
fn parameterIndex(call: CallInfo, cursor: usize) usize {
for (call.commas[0..call.comma_count], 0..) |comma, index| if (cursor <= comma) return index;
return call.comma_count;
}
fn parameterStart(call: CallInfo, index: usize) usize {
var start = if (index == 0) call.open + 1 else call.commas[index - 1] + 1;
while (start < call.close and isSpaceByte(call.content[start])) : (start += 1) {}
return start;
}
fn isSpaceByte(byte: u8) bool {
return byte == ' ' or byte == '\t';
}
fn validateEditKind(kind: []const u8) !void {
if (std.mem.eql(u8, kind, "rename") or std.mem.eql(u8, kind, "edit")) return;
return Error.InvalidEdit;
}
fn editRowsFromWorkspaceEditValue(allocator: std.mem.Allocator, kind: []const u8, edit: std.json.Value) ![][]const u8 {
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
try appendRowsFromWorkspaceEditValue(allocator, &rows, kind, edit, kind);
if (rows.items.len == 0) try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:edit:{s}:none", .{kind}));
return rows.toOwnedSlice(allocator);
}
fn appendRowsFromWorkspaceEditValue(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), kind: []const u8, edit: std.json.Value, label: []const u8) !void {
const changes = objectGet(edit, "changes") orelse return Error.InvalidEdit;
if (changes != .object) return Error.InvalidEdit;
var it = changes.object.iterator();
while (it.next()) |entry| {
const uri = entry.key_ptr.*;
const edits = entry.value_ptr.*;
if (edits != .array) return Error.InvalidEdit;
for (edits.array.items) |text_edit| try appendEditRow(allocator, rows, kind, uri, text_edit, label);
}
}
fn appendEditRow(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), kind: []const u8, uri: []const u8, text_edit: std.json.Value, label: []const u8) !void {
const range = objectGet(text_edit, "range") orelse return Error.InvalidEdit;
const start_value = objectGet(range, "start") orelse return Error.InvalidEdit;
const end_value = objectGet(range, "end") orelse return Error.InvalidEdit;
const start_line = unsignedGet(start_value, "line") orelse return Error.InvalidEdit;
const start_character = unsignedGet(start_value, "character") orelse return Error.InvalidEdit;
const end_line = unsignedGet(end_value, "line") orelse return Error.InvalidEdit;
const end_character = unsignedGet(end_value, "character") orelse return Error.InvalidEdit;
const new_text = stringGetValue(text_edit, "newText") orelse return Error.InvalidEdit;
const encoded = try hexEncodeAlloc(allocator, new_text);
defer allocator.free(encoded);
const uri_preview = try previewAlloc(allocator, uri);
defer allocator.free(uri_preview);
const label_preview = try previewAlloc(allocator, label);
defer allocator.free(label_preview);
try rows.append(allocator, try std.fmt.allocPrint(
allocator,
"lsp:edit:{s}:{d}:{d}:{d}:{d}:{s}:{s}:{s}",
.{ kind, start_line + 1, start_character + 1, end_line + 1, end_character + 1, encoded, uri_preview, label_preview },
));
}
fn singleEditNoneRow(allocator: std.mem.Allocator, kind: []const u8) ![][]const u8 {
const row = try std.fmt.allocPrint(allocator, "lsp:edit:{s}:none", .{kind});
errdefer allocator.free(row);
const rows = try allocator.alloc([]const u8, 1);
rows[0] = row;
return rows;
}
fn parseEditRow(allocator: std.mem.Allocator, row: []const u8) !struct { start_line: usize, start_character: usize, end_line: usize, end_character: usize, new_text: []u8 } {
var parts = std.mem.splitScalar(u8, row, ':');
if (!std.mem.eql(u8, parts.next() orelse return Error.InvalidEditRow, "lsp")) return Error.InvalidEditRow;
if (!std.mem.eql(u8, parts.next() orelse return Error.InvalidEditRow, "edit")) return Error.InvalidEditRow;
_ = parts.next() orelse return Error.InvalidEditRow; // kind
const start_line = std.fmt.parseUnsigned(usize, parts.next() orelse return Error.InvalidEditRow, 10) catch return Error.InvalidEditRow;
const start_character = std.fmt.parseUnsigned(usize, parts.next() orelse return Error.InvalidEditRow, 10) catch return Error.InvalidEditRow;
const end_line = std.fmt.parseUnsigned(usize, parts.next() orelse return Error.InvalidEditRow, 10) catch return Error.InvalidEditRow;
const end_character = std.fmt.parseUnsigned(usize, parts.next() orelse return Error.InvalidEditRow, 10) catch return Error.InvalidEditRow;
const encoded = parts.next() orelse return Error.InvalidEditRow;
if (start_line == 0 or start_character == 0 or end_line == 0 or end_character == 0) return Error.InvalidEditRow;
return .{ .start_line = start_line, .start_character = start_character, .end_line = end_line, .end_character = end_character, .new_text = try hexDecodeAlloc(allocator, encoded) };
}
fn hexEncodeAlloc(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
const encoded = try allocator.alloc(u8, bytes.len * 2);
const alphabet = "0123456789abcdef";
for (bytes, 0..) |byte, i| {
encoded[i * 2] = alphabet[byte >> 4];
encoded[i * 2 + 1] = alphabet[byte & 0x0f];
}
return encoded;
}
fn hexDecodeAlloc(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 {
if (encoded.len % 2 != 0) return Error.InvalidEditRow;
const bytes = try allocator.alloc(u8, encoded.len / 2);
errdefer allocator.free(bytes);
var i: usize = 0;
while (i < bytes.len) : (i += 1) {
const hi = hexValue(encoded[i * 2]) orelse return Error.InvalidEditRow;
const lo = hexValue(encoded[i * 2 + 1]) orelse return Error.InvalidEditRow;
bytes[i] = (hi << 4) | lo;
}
return bytes;
}
fn hexValue(byte: u8) ?u8 {
if (byte >= '0' and byte <= '9') return byte - '0';
if (byte >= 'a' and byte <= 'f') return byte - 'a' + 10;
if (byte >= 'A' and byte <= 'F') return byte - 'A' + 10;
return null;
}
fn validateNavKind(kind: []const u8) !void {
if (std.mem.eql(u8, kind, "definition") or std.mem.eql(u8, kind, "references")) return;
return Error.InvalidNavigation;
}
fn validateSymbolScope(scope: []const u8) !void {
if (std.mem.eql(u8, scope, "document") or std.mem.eql(u8, scope, "workspace")) return;
return Error.InvalidNavigation;
}
fn appendLocationRow(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), kind: []const u8, location: std.json.Value, fallback_label: []const u8) !void {
const uri = stringGetValue(location, "uri") orelse return Error.InvalidNavigation;
const range = objectGet(location, "range") orelse return Error.InvalidNavigation;
const start = objectGet(range, "start") orelse return Error.InvalidNavigation;
const line = unsignedGet(start, "line") orelse return Error.InvalidNavigation;
const character = unsignedGet(start, "character") orelse return Error.InvalidNavigation;
const uri_preview = try previewAlloc(allocator, uri);
defer allocator.free(uri_preview);
const label_preview = try previewAlloc(allocator, fallback_label);
defer allocator.free(label_preview);
try rows.append(allocator, try std.fmt.allocPrint(
allocator,
"lsp:nav:{s}:{d}:{d}:{s}:{s}",
.{ kind, line + 1, character + 1, uri_preview, label_preview },
));
}
fn appendSymbolRow(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), scope: []const u8, symbol_value: std.json.Value) !void {
const name = stringGetValue(symbol_value, "name") orelse return Error.InvalidNavigation;
const kind = unsignedGet(symbol_value, "kind") orelse 0;
const location = if (std.mem.eql(u8, scope, "workspace")) objectGet(symbol_value, "location") orelse return Error.InvalidNavigation else symbol_value;
const uri = stringGetValue(location, "uri") orelse "current";
const range = if (objectGet(symbol_value, "selectionRange")) |selection_range| selection_range else objectGet(location, "range") orelse return Error.InvalidNavigation;
const start = objectGet(range, "start") orelse return Error.InvalidNavigation;
const line = unsignedGet(start, "line") orelse return Error.InvalidNavigation;
const character = unsignedGet(start, "character") orelse return Error.InvalidNavigation;
const uri_preview = try previewAlloc(allocator, uri);
defer allocator.free(uri_preview);
const name_preview = try previewAlloc(allocator, name);
defer allocator.free(name_preview);
try rows.append(allocator, try std.fmt.allocPrint(
allocator,
"lsp:symbol:{s}:kind_{d}:{d}:{d}:{s}:{s}",
.{ scope, kind, line + 1, character + 1, uri_preview, name_preview },
));
}
fn parseLocationTokens(uri: []const u8, line_token: []const u8, character_token: []const u8) !DiagnosticLocation {
const line = std.fmt.parseUnsigned(usize, line_token, 10) catch return Error.InvalidNavigationRow;
const character = std.fmt.parseUnsigned(usize, character_token, 10) catch return Error.InvalidNavigationRow;
if (line == 0 or character == 0 or uri.len == 0) return Error.InvalidNavigationRow;
return .{ .uri = uri, .line = line, .character = character };
}
fn objectGet(value: std.json.Value, key: []const u8) ?std.json.Value {
if (value != .object) return null;
return value.object.get(key);
}
fn stringGetValue(value: std.json.Value, key: []const u8) ?[]const u8 {
const child = objectGet(value, key) orelse return null;
return switch (child) {
.string => |text| text,
else => null,
};
}
fn stringGet(value: std.json.Value, key: []const u8) ?[]const u8 {
const child = objectGet(value, key) orelse return null;
return switch (child) {
.string => |text| text,
else => null,
};
}
fn unsignedGet(value: std.json.Value, key: []const u8) ?usize {
const child = objectGet(value, key) orelse return null;
return switch (child) {
.integer => |number| if (number >= 0) @intCast(number) else null,
else => null,
};
}
fn severityText(severity: usize) []const u8 {
return switch (severity) {
1 => "error",
2 => "warning",
3 => "info",
4 => "hint",
else => "unknown",
};
}
fn previewAlloc(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
var out = std.ArrayList(u8).empty;
errdefer out.deinit(allocator);
var i: usize = 0;
while (i < text.len and out.items.len < 80) {
const len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const end = @min(text.len, i + len);
const slice = text[i..end];
if (slice.len == 1 and (slice[0] <= 0x20 or slice[0] == ':' or slice[0] == '|')) {
try out.append(allocator, '_');
} else {
try out.appendSlice(allocator, slice);
}
i = end;
}
if (out.items.len == 0) try out.append(allocator, '_');
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_"));
}
test "regular: publish diagnostics payload becomes panel rows and location" {
const payload =
\\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///repo/src/main.zig","diagnostics":[{"range":{"start":{"line":1,"character":4},"end":{"line":1,"character":5}},"severity":1,"message":"expected semicolon"},{"range":{"start":{"line":2,"character":0},"end":{"line":2,"character":3}},"severity":2,"message":"unused var"}]}}
;
const rows = try diagnosticRowsAlloc(std.testing.allocator, payload);
defer freeRows(std.testing.allocator, rows);
try std.testing.expectEqualStrings("lsp:diagnostics:count_2", rows[0]);
try std.testing.expectEqualStrings("lsp:diag:error:2:5:file_///repo/src/main.zig:expected_semicolon", rows[1]);
try std.testing.expectEqualStrings("lsp:diag:warning:3:1:file_///repo/src/main.zig:unused_var", rows[2]);
const location = try diagnosticLocationFromRow(rows[1]);
try std.testing.expectEqualStrings("file_///repo/src/main.zig", location.uri);
try std.testing.expectEqual(@as(usize, 2), location.line);
try std.testing.expectEqual(@as(usize, 5), location.character);
}
test "regular: diagnostic location maps to buffer offset" {
try std.testing.expectEqual(@as(usize, 8), try byteOffsetForLineColumn("one\nabcdTARGET\n", 2, 5));
}
test "adversarial: bad diagnostics and rows are rejected" {
try std.testing.expectError(Error.InvalidDiagnostics, diagnosticRowsAlloc(std.testing.allocator, "not json"));
try std.testing.expectError(Error.InvalidDiagnostics, diagnosticRowsAlloc(std.testing.allocator, "{}"));
try std.testing.expectError(Error.InvalidDiagnosticRow, diagnosticLocationFromRow("lsp:diagnostics:count_1"));
try std.testing.expectError(Error.InvalidDiagnosticRow, byteOffsetForLineColumn("short", 9, 1));
}
test "regular: definition and reference locations become navigation rows" {
const definition_payload =
\\{"jsonrpc":"2.0","id":2,"result":{"uri":"file:///repo/src/main.zig","range":{"start":{"line":1,"character":4},"end":{"line":1,"character":8}}}}
;
const definition_rows = try locationRowsAlloc(std.testing.allocator, "definition", definition_payload);
defer freeRows(std.testing.allocator, definition_rows);
try std.testing.expectEqualStrings("lsp:nav:definition:2:5:file_///repo/src/main.zig:location", definition_rows[0]);
const definition_location = try navigationLocationFromRow(definition_rows[0]);
try std.testing.expectEqual(@as(usize, 2), definition_location.line);
try std.testing.expectEqual(@as(usize, 5), definition_location.character);
const references_payload =
\\{"jsonrpc":"2.0","id":3,"result":[{"uri":"file:///repo/src/main.zig","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":3}}},{"uri":"file:///repo/src/lib.zig","range":{"start":{"line":2,"character":1},"end":{"line":2,"character":4}}}]}
;
const reference_rows = try locationRowsAlloc(std.testing.allocator, "references", references_payload);
defer freeRows(std.testing.allocator, reference_rows);
try std.testing.expectEqual(@as(usize, 2), reference_rows.len);
try std.testing.expectEqualStrings("lsp:nav:references:1:1:file_///repo/src/main.zig:location", reference_rows[0]);
}
test "regular: document and workspace symbols become jumpable rows" {
const document_payload =
\\{"jsonrpc":"2.0","id":4,"result":[{"name":"main","kind":12,"range":{"start":{"line":0,"character":0},"end":{"line":3,"character":1}},"selectionRange":{"start":{"line":1,"character":4},"end":{"line":1,"character":8}}}]}
;
const document_rows = try symbolRowsAlloc(std.testing.allocator, "document", document_payload);
defer freeRows(std.testing.allocator, document_rows);
try std.testing.expectEqualStrings("lsp:symbol:document:kind_12:2:5:current:main", document_rows[0]);
const document_location = try navigationLocationFromRow(document_rows[0]);
try std.testing.expectEqual(@as(usize, 2), document_location.line);
const workspace_payload =
\\{"jsonrpc":"2.0","id":5,"result":[{"name":"helper","kind":12,"location":{"uri":"file:///repo/src/lib.zig","range":{"start":{"line":4,"character":2},"end":{"line":4,"character":8}}}}]}
;
const workspace_rows = try symbolRowsAlloc(std.testing.allocator, "workspace", workspace_payload);
defer freeRows(std.testing.allocator, workspace_rows);
try std.testing.expectEqualStrings("lsp:symbol:workspace:kind_12:5:3:file_///repo/src/lib.zig:helper", workspace_rows[0]);
}
test "adversarial: invalid navigation payloads and rows are rejected" {
try std.testing.expectError(Error.InvalidNavigation, locationRowsAlloc(std.testing.allocator, "hover", "{}"));
try std.testing.expectError(Error.InvalidNavigation, locationRowsAlloc(std.testing.allocator, "definition", "{}"));
try std.testing.expectError(Error.InvalidNavigation, symbolRowsAlloc(std.testing.allocator, "project", "{}"));
try std.testing.expectError(Error.InvalidNavigationRow, navigationLocationFromRow("lsp:nav:definition:none"));
try std.testing.expectError(Error.InvalidNavigationRow, navigationLocationFromRow("lsp:symbol:document:none"));
}
test "regular: rename workspace edit row applies to current buffer" {
const payload =
\\{"jsonrpc":"2.0","id":6,"result":{"changes":{"file:///tmp/main.zig":[{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}},"newText":"renamed"}]}}}
;
const rows = try workspaceEditRowsAlloc(std.testing.allocator, "rename", payload);
defer freeRows(std.testing.allocator, rows);
try std.testing.expectEqualStrings("lsp:edit:rename:1:5:1:8:72656e616d6564:file_///tmp/main.zig:rename", rows[0]);
const applied = try applyEditRowAlloc(std.testing.allocator, "pub old = 1;", rows[0]);
defer std.testing.allocator.free(applied.bytes);
try std.testing.expectEqualStrings("pub renamed = 1;", applied.bytes);
try std.testing.expectEqual(@as(usize, 11), applied.cursor);
}
test "regular: code action edit rows preserve title and apply replacement" {
const payload =
\\{"jsonrpc":"2.0","id":7,"result":[{"title":"replace with const","kind":"quickfix","edit":{"changes":{"file:///tmp/main.zig":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":3}},"newText":"const"}]}}},{"title":"command only","command":{"title":"noop","command":"noop"}}]}
;
const rows = try codeActionRowsAlloc(std.testing.allocator, payload);
defer freeRows(std.testing.allocator, rows);
try std.testing.expectEqualStrings("lsp:edit:action:1:1:1:4:636f6e7374:file_///tmp/main.zig:replace_with_const", rows[0]);
try std.testing.expectEqualStrings("lsp:action:none:command_only", rows[1]);
const applied = try applyEditRowAlloc(std.testing.allocator, "var x = 1;", rows[0]);
defer std.testing.allocator.free(applied.bytes);
try std.testing.expectEqualStrings("const x = 1;", applied.bytes);
}
test "adversarial: invalid edits and non-edit rows are rejected" {
try std.testing.expectError(Error.InvalidEdit, workspaceEditRowsAlloc(std.testing.allocator, "hover", "{}"));
try std.testing.expectError(Error.InvalidEdit, workspaceEditRowsAlloc(std.testing.allocator, "rename", "{}"));
try std.testing.expectError(Error.InvalidEdit, codeActionRowsAlloc(std.testing.allocator, "{}"));
try std.testing.expectError(Error.InvalidEditRow, applyEditRowAlloc(std.testing.allocator, "safe", "lsp:action:none:title"));
try std.testing.expectError(Error.InvalidEditRow, applyEditRowAlloc(std.testing.allocator, "safe", "lsp:edit:rename:1:1:9:1:78:file:title"));
}
test "regular: hover rows are viewport bounded and sanitized" {
const payload =
\\{"jsonrpc":"2.0","id":8,"result":{"contents":{"kind":"markdown","value":"pub fn add(lhs: i32, rhs: i32) i32"}}}
;
const rows = try hoverRowsAlloc(std.testing.allocator, payload, 32);
defer freeRows(std.testing.allocator, rows);
try std.testing.expect(rows.len > 1);
for (rows) |row| try std.testing.expect(row.len <= 32);
try std.testing.expect(std.mem.startsWith(u8, rows[0], "lsp:hover:"));
}
test "regular: signature rows mark active parameter" {
const payload =
\\{"jsonrpc":"2.0","id":9,"result":{"activeSignature":0,"activeParameter":1,"signatures":[{"label":"add(lhs: i32, rhs: i32)","parameters":[{"label":"lhs: i32"},{"label":"rhs: i32"}]}]}}
;
const rows = try signatureRowsAlloc(std.testing.allocator, payload, 48);
defer freeRows(std.testing.allocator, rows);
try std.testing.expectEqualStrings("lsp:signature:active_2", rows[0]);
try std.testing.expect(std.mem.indexOf(u8, rows[1], "add(lhs") != null);
try std.testing.expectEqualStrings("lsp:signature:param_2:active:rhs__i32", rows[3]);
}
test "regular: parameter movement finds siblings in a call" {
{
const moved = try parameterMoveRowsAlloc(std.testing.allocator, "add(alpha, beta, gamma)", 5, .next);
defer freeRows(std.testing.allocator, moved.rows);
try std.testing.expectEqual(@as(usize, 11), moved.cursor);
try std.testing.expectEqualStrings("lsp:param:active_2:11", moved.rows[0]);
}
{
const moved = try parameterMoveRowsAlloc(std.testing.allocator, "add(alpha, beta, gamma)", 11, .previous);
defer freeRows(std.testing.allocator, moved.rows);
try std.testing.expectEqual(@as(usize, 4), moved.cursor);
}
}
test "adversarial: help payloads and non-call parameter moves fail safely" {
try std.testing.expectError(Error.InvalidHelp, hoverRowsAlloc(std.testing.allocator, "{}", 40));
try std.testing.expectError(Error.InvalidHelp, signatureRowsAlloc(std.testing.allocator, "not-json", 40));
const moved = try parameterMoveRowsAlloc(std.testing.allocator, "no call here", 3, .next);
defer freeRows(std.testing.allocator, moved.rows);
try std.testing.expectEqualStrings("lsp:param:outside_call", moved.rows[0]);
try std.testing.expectEqual(@as(usize, 3), moved.cursor);
}
test "regular: provider registry preserves source identity for non-mutating rows" {
var registry = ProviderRegistry.init(std.testing.allocator);
defer registry.deinit();
const caps = [_]ProviderCapability{ .diagnostics, .hover };
try registry.register(.{ .id = "zls", .name = "ZLS", .kind = .lsp, .capabilities = &caps, .document_version = 4 });
try registry.register(.{ .id = "lint", .name = "Lint", .kind = .linter, .capabilities = &[_]ProviderCapability{.diagnostics}, .document_version = 4 });
const providers = try registry.nonMutatingProvidersAlloc(std.testing.allocator, .diagnostics);
defer std.testing.allocator.free(providers);
try std.testing.expectEqual(@as(usize, 2), providers.len);
const rows_src = [_][]const u8{"lsp:diag:error:1:1:file.zig:bad"};
const rows = try providerRowsAlloc(std.testing.allocator, providers[0], &rows_src);
defer {
for (rows) |row| std.testing.allocator.free(row);
std.testing.allocator.free(rows);
}
try std.testing.expectEqualStrings("provider:zls:lsp:diag:error:1:1:file.zig:bad", rows[0]);
try std.testing.expectEqualStrings("zls", try providerIdFromRow(rows[0]));
try std.testing.expectEqualStrings("lsp:diag:error:1:1:file.zig:bad", try providerPayloadFromRow(rows[0]));
}
test "regular: mutating provider choice defaults to preferred or reports ambiguity" {
var registry = ProviderRegistry.init(std.testing.allocator);
defer registry.deinit();
try registry.register(.{ .id = "fmt-a", .name = "Formatter A", .kind = .formatter, .capabilities = &[_]ProviderCapability{.format} });
try registry.register(.{ .id = "fmt-b", .name = "Formatter B", .kind = .formatter, .capabilities = &[_]ProviderCapability{.format} });
var choice = try registry.chooseMutatingProvider(std.testing.allocator, .format);
switch (choice) {
.ambiguous => |providers| {
defer std.testing.allocator.free(providers);
try std.testing.expectEqual(@as(usize, 2), providers.len);
const picker = try providerPickerRowsAlloc(std.testing.allocator, .format, providers);
defer {
for (picker) |row| std.testing.allocator.free(row);
std.testing.allocator.free(picker);
}
try std.testing.expectEqualStrings("provider:choose:format:fmt-a:Formatter A", picker[0]);
},
else => return error.ExpectedAmbiguousProvider,
}
var preferred = ProviderRegistry.init(std.testing.allocator);
defer preferred.deinit();
try preferred.register(.{ .id = "fmt-a", .name = "Formatter A", .kind = .formatter, .capabilities = &[_]ProviderCapability{.format}, .preferred_for_mutation = true });
try preferred.register(.{ .id = "fmt-b", .name = "Formatter B", .kind = .formatter, .capabilities = &[_]ProviderCapability{.format} });
choice = try preferred.chooseMutatingProvider(std.testing.allocator, .format);
switch (choice) {
.selected => |provider| try std.testing.expectEqualStrings("fmt-a", provider.id),
else => return error.ExpectedSelectedProvider,
}
}
test "adversarial: provider registry rejects duplicates reports missing and stale" {
var registry = ProviderRegistry.init(std.testing.allocator);
defer registry.deinit();
try registry.register(.{ .id = "zls", .name = "ZLS", .kind = .lsp, .capabilities = &[_]ProviderCapability{.diagnostics}, .document_version = 7 });
try std.testing.expectError(Error.DuplicateProvider, registry.register(.{ .id = "zls", .name = "Other", .kind = .lsp, .capabilities = &[_]ProviderCapability{.hover} }));
const missing_choice = try registry.chooseMutatingProvider(std.testing.allocator, .format);
switch (missing_choice) {
.missing => {},
else => return error.ExpectedMissingProvider,
}
const missing_row = try missingProviderRowAlloc(std.testing.allocator, .format);
defer std.testing.allocator.free(missing_row);
try std.testing.expectEqualStrings("provider:missing:format", missing_row);
try std.testing.expect(try registry.isStale("zls", 6));
try std.testing.expect(!(try registry.isStale("zls", 7)));
try std.testing.expectError(Error.MissingProvider, registry.isStale("none", 1));
}
test "regular: provider-labelled diagnostic rows retain original parser compatibility" {
const payload =
\\{"params":{"uri":"file:///a.zig","diagnostics":[{"range":{"start":{"line":0,"character":2}},"severity":1,"message":"boom"}]}}
;
const rows = try diagnosticRowsAlloc(std.testing.allocator, payload);
defer {
for (rows) |row| std.testing.allocator.free(row);
std.testing.allocator.free(rows);
}
const provider: Provider = .{ .id = "zls", .name = "ZLS", .kind = .lsp, .capabilities = &[_]ProviderCapability{.diagnostics} };
const labelled = try providerRowsAlloc(std.testing.allocator, provider, rows[1..]);
defer {
for (labelled) |row| std.testing.allocator.free(row);
std.testing.allocator.free(labelled);
}
const payload_row = try providerPayloadFromRow(labelled[0]);
const location = try diagnosticLocationFromRow(payload_row);
try std.testing.expectEqual(@as(usize, 1), location.line);
try std.testing.expectEqual(@as(usize, 3), location.character);
}
test "regular: provider rows label hover signature navigation actions and lint uniformly" {
const provider: Provider = .{ .id = "zls", .name = "ZLS", .kind = .lsp, .capabilities = &[_]ProviderCapability{ .hover, .signature, .navigation, .code_action, .lint } };
const raw = [_][]const u8{
"lsp:hover:headline:docs",
"lsp:signature:call(arg):active=1",
"lsp:definition:src/main.zig:10:4",
"lsp:code_action:fix:remove unused",
"lint:warning:src/main.zig:1:1:style",
};
const rows = try providerRowsAlloc(std.testing.allocator, provider, &raw);
defer {
for (rows) |row| std.testing.allocator.free(row);
std.testing.allocator.free(rows);
}
for (rows, raw) |row, expected_payload| {
try std.testing.expectEqualStrings("zls", try providerIdFromRow(row));
try std.testing.expectEqualStrings(expected_payload, try providerPayloadFromRow(row));
}
}