diff --git a/src/diagnostics.zig b/src/diagnostics.zig index a91d2a6..7924e3d 100644 --- a/src/diagnostics.zig +++ b/src/diagnostics.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const lsp = @import("lsp.zig"); // Local v1 guardrails for SSH/iPhone use: visible rows instead of silent stalls. // req: testing/001, testing/002, testing/003, testing/004 @@ -7,6 +8,27 @@ pub const max_search_rows: usize = 64; pub const process_stdout_limit: usize = 256 * 1024; pub const process_stderr_limit: usize = 256 * 1024; +pub const SourceLabel = struct { + provider: ?[]const u8, + row: []const u8, +}; + +pub fn sourceLabel(row: []const u8) SourceLabel { + if (std.mem.startsWith(u8, row, "provider:")) { + return .{ + .provider = lsp.providerIdFromRow(row) catch null, + .row = lsp.providerPayloadFromRow(row) catch row, + }; + } + return .{ .provider = null, .row = row }; +} + +pub fn displayRowAlloc(allocator: std.mem.Allocator, row: []const u8) ![]u8 { + const labelled = sourceLabel(row); + if (labelled.provider) |provider| return std.fmt.allocPrint(allocator, "[{s}] {s}", .{ provider, labelled.row }); + return allocator.dupe(u8, labelled.row); +} + pub fn searchTruncatedRowAlloc(allocator: std.mem.Allocator, shown: usize, limit: usize) ![]u8 { return std.fmt.allocPrint(allocator, "diagnostic:search_truncated:showing_{d}_of_limit_{d}", .{ shown, limit }); } @@ -46,3 +68,19 @@ test "adversarial: unsafe diagnostic reasons are sanitized" { try std.testing.expectEqualStrings("unknown", sanitizeReason("")); try std.testing.expectEqualStrings("spawn_error", sanitizeReason("spawn_error")); } + +test "regular: provider-labelled diagnostic rows display source identity" { + const labelled = sourceLabel("provider:zls:diagnostic:error:boom"); + try std.testing.expectEqualStrings("zls", labelled.provider.?); + try std.testing.expectEqualStrings("diagnostic:error:boom", labelled.row); + + const display = try displayRowAlloc(std.testing.allocator, "provider:zls:diagnostic:error:boom"); + defer std.testing.allocator.free(display); + try std.testing.expectEqualStrings("[zls] diagnostic:error:boom", display); +} + +test "regular: unlabelled diagnostic rows stay compatible" { + const labelled = sourceLabel("diagnostic:error:boom"); + try std.testing.expect(labelled.provider == null); + try std.testing.expectEqualStrings("diagnostic:error:boom", labelled.row); +} diff --git a/src/lsp.zig b/src/lsp.zig index 10adc64..1a069c7 100644 --- a/src/lsp.zig +++ b/src/lsp.zig @@ -19,8 +19,162 @@ pub const Error = error{ InvalidEdit, InvalidEditRow, InvalidHelp, + DuplicateProvider, + InvalidProvider, + MissingProvider, + AmbiguousProvider, }; +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 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, @@ -985,3 +1139,118 @@ test "adversarial: help payloads and non-call parameter moves fail safely" { 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)); + } +}