Add LSP hover and signature panels

This commit is contained in:
slhx agent
2026-06-21 04:50:49 +02:00
parent 970d1bb4ec
commit d1141c89a2
2 changed files with 334 additions and 0 deletions
+238
View File
@@ -17,6 +17,7 @@ pub const Error = error{
InvalidNavigationRow,
InvalidEdit,
InvalidEditRow,
InvalidHelp,
};
pub const Document = struct {
@@ -31,6 +32,8 @@ pub const DiagnosticLocation = struct {
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();
@@ -220,6 +223,66 @@ pub fn applyEditRowAlloc(allocator: std.mem.Allocator, content: []const u8, row:
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,
@@ -310,6 +373,135 @@ pub fn transcriptAlloc(allocator: std.mem.Allocator, document: Document) ![]u8 {
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;
@@ -742,3 +934,49 @@ test "adversarial: invalid edits and non-edit rows are rejected" {
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);
}
+96
View File
@@ -160,6 +160,10 @@ pub const Client = struct {
if (std.mem.startsWith(u8, line, "lsp_rename ")) return self.openLspWorkspaceEdit("rename", line[11..]);
if (std.mem.startsWith(u8, line, "lsp_code_actions ")) return self.openLspCodeActions(line[17..]);
if (std.mem.eql(u8, line, "lsp_edit_apply_selected")) return self.applySelectedLspEdit();
if (std.mem.startsWith(u8, line, "lsp_hover ")) return self.openLspHover(line[10..]);
if (std.mem.startsWith(u8, line, "lsp_signature ")) return self.openLspSignature(line[14..]);
if (std.mem.eql(u8, line, "lsp_param_next")) return self.moveLspParameter(.next);
if (std.mem.eql(u8, line, "lsp_param_previous")) return self.moveLspParameter(.previous);
if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line);
if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line);
if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line);
@@ -508,6 +512,31 @@ pub const Client = struct {
self.message = null;
}
fn openLspHover(self: *Client, payload: []const u8) !void {
const rows = lsp_mod.hoverRowsAlloc(self.allocator, payload, self.viewport.width) catch return Error.ProtocolRejected;
defer freeOwnedRows(self.allocator, rows);
try self.session.openListPanel("lsp-hover", rows);
self.message = null;
}
fn openLspSignature(self: *Client, payload: []const u8) !void {
const rows = lsp_mod.signatureRowsAlloc(self.allocator, payload, self.viewport.width) catch return Error.ProtocolRejected;
defer freeOwnedRows(self.allocator, rows);
try self.session.openListPanel("lsp-signature", rows);
self.message = null;
}
fn moveLspParameter(self: *Client, direction: lsp_mod.ParameterDirection) !void {
const snap = try self.session.snapshot();
const moved = lsp_mod.parameterMoveRowsAlloc(self.allocator, snap.bytes, snap.cursor_byte, direction) catch return Error.ProtocolRejected;
defer freeOwnedRows(self.allocator, moved.rows);
const bytes = try self.allocator.dupe(u8, snap.bytes);
defer self.allocator.free(bytes);
try self.session.openFixtureAt(bytes, moved.cursor);
try self.session.openListPanel("lsp-parameter", moved.rows);
self.message = null;
}
fn jumpCurrentBufferToLspLocation(self: *Client, location: lsp_mod.DiagnosticLocation) !void {
const snap = try self.session.snapshot();
const bytes = try self.allocator.dupe(u8, snap.bytes);
@@ -1858,3 +1887,70 @@ test "adversarial: lsp edit failures do not corrupt current buffer" {
const snap = try client.session.snapshot();
try std.testing.expectEqualStrings("safe", snap.bytes);
}
test "regular: lsp hover wraps inside narrow viewport" {
var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 7 });
defer client.deinit();
const payload =
\\{"jsonrpc":"2.0","id":8,"result":{"contents":{"kind":"markdown","value":"pub fn add(lhs: i32, rhs: i32) i32"}}}
;
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_hover {s}", .{payload});
defer std.testing.allocator.free(command);
try client.handleTraceLine(command);
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-hover") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:hover:") != null);
try assertLinesFit(frame, 32);
}
test "regular: lsp signature shows active parameter in shared panel" {
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 7 });
defer client.deinit();
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 command = try std.fmt.allocPrint(std.testing.allocator, "lsp_signature {s}", .{payload});
defer std.testing.allocator.free(command);
try client.handleTraceLine(command);
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-signature") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:signature:active_2") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:signature:param_2:active") != null);
}
test "regular: lsp parameter movement updates cursor and reports active parameter" {
var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 6 });
defer client.deinit();
try client.session.openFixtureAt("add(alpha, beta, gamma)", 5);
try client.handleTraceLine("lsp_param_next");
var snap = try client.session.snapshot();
try std.testing.expectEqual(@as(usize, 11), snap.cursor_byte);
{
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:param:active_2:11") != null);
}
try client.handleTraceLine("lsp_param_previous");
snap = try client.session.snapshot();
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
}
test "adversarial: malformed hover signature and non-call parameter movement are safe" {
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 5 });
defer client.deinit();
try client.handleTraceLine("open safe");
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_hover not-json"));
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_signature not-json"));
try client.handleTraceLine("lsp_param_next");
const snap = try client.session.snapshot();
try std.testing.expectEqualStrings("safe", snap.bytes);
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:param:outside_call") != null);
}