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