lsp: go-to-definition on a member declaration resolves to itself

Cmd+clicking a struct field / method / enum-variant at its own
declaration returned null, so nothing happened — while find-references on
the same token worked. Resolve a definition-site click to its own
location; the editor then surfaces references on a definition-click
instead of doing nothing. Member uses still resolve to their definition.

Add selfMemberDefAt + a regression test.
This commit is contained in:
agra
2026-05-31 13:42:49 +03:00
parent 292fd937c6
commit 00f6fad51c

View File

@@ -286,6 +286,18 @@ pub const Server = struct {
return buf.items;
}
/// The name-token span of a member declaration (struct field, method, enum
/// variant) at `offset`, or null when `offset` isn't on a declaration. Lets
/// go-to-definition on a definition resolve to itself.
fn selfMemberDefAt(sema: *const SemaResult, offset: u32) ?sx.ast.Span {
for (sema.member_refs) |mr| {
if (!mr.is_def) continue;
if (offset < mr.span.start or offset >= mr.span.end) continue;
return mr.span;
}
return null;
}
fn handleDefinition(self: *Server, id: ?std.json.Value, params: std.json.Value) !void {
const ctx = try self.extractRequest(id, params) orelse return;
const pos = extractPosition(params) orelse return;
@@ -304,6 +316,17 @@ pub const Server = struct {
return try self.sendResponse(id_json, "null");
};
// 0. Cursor sits on a member's own declaration (struct field, method
// name, enum variant). Go-to-definition resolves to itself so the
// editor recognises it as a definition — clicking a definition then
// surfaces references instead of doing nothing.
if (selfMemberDefAt(&sema, offset)) |def_span| {
const self_uri = try std.fmt.allocPrint(self.allocator, "file://{s}", .{doc.path});
const loc = try lsp.locationJson(self.allocator, self_uri, spanToRange(doc.source, def_span));
const arr = try std.fmt.allocPrint(self.allocator, "[{s}]", .{loc});
return try self.sendResponse(id_json, arr);
}
// 1. Qualified name (e.g. "std.print" or UFCS "list.append")
if (extractQualifiedName(doc.source, offset)) |qn| {
const qn_origin = sx.ast.Span{ .start = qn.full_start, .end = qn.full_end };
@@ -3330,6 +3353,28 @@ test "lsp/references: excluding the declaration drops the definition" {
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, without_decl, "\"uri\""));
}
test "lsp/definition: cursor on a member declaration resolves to itself" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, test_io(), &.{});
const src: [:0]const u8 = "Move :: struct { flag: s64; }";
const doc = try store.openOrUpdate("main.sx", src, 1);
try store.analyzeDocument(doc);
const sema = doc.sema orelse return error.SkipZigTest;
// On the `flag` field declaration → resolves to itself (so the editor
// offers references on a definition-click instead of doing nothing).
const flag_off: u32 = @intCast(std.mem.indexOf(u8, src, "flag").?);
const span = Server.selfMemberDefAt(&sema, flag_off) orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(flag_off, span.start);
// On the struct name (not a member declaration) → null, so normal
// resolution still runs.
try std.testing.expect(Server.selfMemberDefAt(&sema, 0) == null);
}
test "lsp/inlayHint: a for-loop capture in a struct method shows its element type" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();