1394 lines
66 KiB
Zig
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));
|
|
}
|
|
}
|