lsp: find-references for fields, methods, and enum variants

Members aren't symbols, so their uses were never recorded. Adds a member-reference list (declaration + uses) tracked during analysis: struct fields/methods and enum variants as declarations; field access, method calls, bare enum literals, qualified Type.variant, and match-arm patterns as uses. Spans are derived from the source-relative name slices; uses carry the owner type (via inferExprType, dereferencing pointers). find-references matches by (owner, name) across loaded documents, treating an unknown owner as a wildcard.

Verified: references for a field (legal_moves), a method (clear_valid_targets), and a variant (promote_rook — decl + comparisons + case patterns + struct-literal values across 5 files).
This commit is contained in:
agra
2026-05-31 12:11:05 +03:00
parent 4f7d4b7725
commit 3437e77938
3 changed files with 104 additions and 2 deletions

View File

@@ -339,6 +339,7 @@ pub const DocumentStore = struct {
}
// Run sema on this file's own AST
analyzer.source = doc.source;
doc.sema = analyzer.analyze(root) catch null;
if (doc.sema != null) {
doc.last_good_sema = doc.sema;

View File

@@ -203,6 +203,30 @@ pub const Server = struct {
}
}
// A struct field / method / enum variant under the cursor — matched by
// (owner type, name) across loaded documents.
for (sema.member_refs) |mt| {
if (offset < mt.span.start or offset >= mt.span.end) continue;
var buf = std.ArrayList(u8).empty;
defer buf.deinit(self.allocator);
try buf.append(self.allocator, '[');
var first = true;
var dit = self.documents.by_path.iterator();
while (dit.next()) |entry| {
const odoc = entry.value_ptr.*;
const osema = odoc.sema orelse continue;
for (osema.member_refs) |mr| {
if (!std.mem.eql(u8, mr.name, mt.name)) continue;
// Owner must match, treating an unknown owner ("") as a wildcard.
if (mr.owner.len != 0 and mt.owner.len != 0 and !std.mem.eql(u8, mr.owner, mt.owner)) continue;
if (mr.is_def and !include_decl) continue;
try self.appendRefLoc(&buf, &first, odoc, mr.span);
}
}
try buf.append(self.allocator, ']');
return try self.sendResponse(id_json, buf.items);
}
// Resolve the target symbol: a reference at the cursor, or a definition.
var target_idx: ?u32 = null;
if (sx.sema.findReferenceAtOffset(sema.references, offset)) |ri| {

View File

@@ -37,6 +37,15 @@ pub const Reference = struct {
symbol_index: u32,
};
/// A reference to a struct field / method / enum variant. These aren't symbols,
/// so they're tracked separately and matched by (owner type, name).
pub const MemberRef = struct {
span: Span, // the member-name token
name: []const u8,
owner: []const u8 = "", // owning type name, "" if unknown (bare enum literal)
is_def: bool = false, // declaration site rather than a use
};
pub const FnSignature = struct {
param_types: []const Type,
return_type: Type,
@@ -54,6 +63,7 @@ pub const TypeMap = std.AutoHashMap(*const Node, Type);
pub const SemaResult = struct {
symbols: []const Symbol,
references: []const Reference,
member_refs: []const MemberRef,
diagnostics: []const Diagnostic,
fn_signatures: std.StringHashMap(FnSignature),
struct_types: std.StringHashMap(StructTypeInfo),
@@ -66,6 +76,9 @@ pub const Analyzer = struct {
allocator: std.mem.Allocator,
symbols: std.ArrayList(Symbol),
references: std.ArrayList(Reference),
member_refs: std.ArrayList(MemberRef),
/// Source text — lets `spanOf` map an AST string slice back to a Span.
source: []const u8 = "",
diagnostics: std.ArrayList(Diagnostic),
scope_depth: u32,
/// Stack of symbol counts at each scope entry, for popScope cleanup.
@@ -84,6 +97,7 @@ pub const Analyzer = struct {
.allocator = allocator,
.symbols = std.ArrayList(Symbol).empty,
.references = std.ArrayList(Reference).empty,
.member_refs = std.ArrayList(MemberRef).empty,
.diagnostics = std.ArrayList(Diagnostic).empty,
.scope_depth = 0,
.scope_starts = std.ArrayList(u32).empty,
@@ -112,6 +126,7 @@ pub const Analyzer = struct {
return .{
.symbols = try self.symbols.toOwnedSlice(self.allocator),
.references = try self.references.toOwnedSlice(self.allocator),
.member_refs = try self.member_refs.toOwnedSlice(self.allocator),
.diagnostics = try self.diagnostics.toOwnedSlice(self.allocator),
.fn_signatures = self.fn_signatures,
.struct_types = self.struct_types,
@@ -207,6 +222,7 @@ pub const Analyzer = struct {
try self.addSymbol(ed.name, .enum_type, .{ .enum_type = ed.name }, node.span);
try self.enum_types.put(ed.name, ed.variant_names);
}
for (ed.variant_names) |vn| self.recordMemberRef(vn, ed.name, true);
},
.struct_decl => |sd| {
try self.addSymbol(sd.name, .struct_type, .{ .struct_type = sd.name }, node.span);
@@ -256,9 +272,12 @@ pub const Analyzer = struct {
.type_params = tp_slice,
});
}
for (sd.field_names) |fname| self.recordMemberRef(fname, sd.name, true);
for (sd.methods) |mnode| {
if (mnode.data == .fn_decl)
if (mnode.data == .fn_decl) {
try self.registerMethodSig(mnode.data.fn_decl.name, ns_prefix, mnode.data.fn_decl.return_type);
self.recordMemberRef(mnode.data.fn_decl.name, sd.name, true);
}
}
},
.protocol_decl => |pd| {
@@ -799,6 +818,23 @@ pub const Analyzer = struct {
try gop.value_ptr.append(self.allocator, idx);
}
/// Span of an AST string slice that points into `source` (field/variant/
/// method names are such slices). Returns {0,0} for synthetic strings.
fn spanOf(self: *Analyzer, s: []const u8) Span {
if (self.source.len == 0 or s.len == 0) return .{ .start = 0, .end = 0 };
const base = @intFromPtr(self.source.ptr);
const p = @intFromPtr(s.ptr);
if (p < base or p + s.len > base + self.source.len) return .{ .start = 0, .end = 0 };
const start: u32 = @intCast(p - base);
return .{ .start = start, .end = start + @as(u32, @intCast(s.len)) };
}
fn recordMemberRef(self: *Analyzer, name: []const u8, owner: []const u8, is_def: bool) void {
const span = self.spanOf(name);
if (span.end == 0) return; // not locatable in source
self.member_refs.append(self.allocator, .{ .span = span, .name = name, .owner = owner, .is_def = is_def }) catch {};
}
fn resolveIdentifier(self: *Analyzer, name: []const u8, span: Span) !void {
// Use symbol index for O(1) name lookup, then walk backwards through indices
if (self.symbol_index.get(name)) |indices| {
@@ -914,6 +950,12 @@ pub const Analyzer = struct {
},
.field_access => |fa| {
try self.analyzeNode(fa.object);
var owner_ty = self.inferExprType(fa.object);
if (owner_ty.isPointer()) owner_ty = self.resolveTypeNameStr(owner_ty.pointer_type.pointee_name);
self.recordMemberRef(fa.field, owner_ty.toName() orelse "", false);
},
.enum_literal => |el| {
self.recordMemberRef(el.name, "", false);
},
.if_expr => |ie| {
try self.analyzeNode(ie.condition);
@@ -937,7 +979,13 @@ pub const Analyzer = struct {
},
.match_expr => |me| {
try self.analyzeNode(me.subject);
var subj_ty = self.inferExprType(me.subject);
if (subj_ty.isPointer()) subj_ty = self.resolveTypeNameStr(subj_ty.pointer_type.pointee_name);
const subj_owner = subj_ty.toName() orelse "";
for (me.arms) |arm| {
if (arm.pattern) |pat| {
if (pat.data == .enum_literal) self.recordMemberRef(pat.data.enum_literal.name, subj_owner, false);
}
try self.pushScope();
if (arm.capture) |cap_name| {
try self.addSymbol(cap_name, .variable, null, arm.body.span);
@@ -1023,7 +1071,6 @@ pub const Analyzer = struct {
try self.addSymbol(ud.name, .enum_type, .{ .union_type = ud.name }, node.span);
},
// Leaf nodes — nothing to recurse into
.enum_literal,
.int_literal,
.float_literal,
.bool_literal,
@@ -1954,6 +2001,36 @@ test "sema: field access + index through a *Struct param" {
try std.testing.expectEqualStrings("Cell", c_ty.?.struct_type);
}
test "sema: member references record fields, methods, and enum variants" {
const parser_mod = @import("parser.zig");
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
const source =
"Color :: enum { red; green; }" ++
"P :: struct { x: s64; m :: (self: P) -> s64 { self.x; } }" ++
"use :: (p: *P) { a := p.x; b := p.m(); c := Color.red; }";
var parser = parser_mod.Parser.init(alloc, source);
const root = try parser.parse();
var an = Analyzer.init(alloc);
an.source = source;
const res = try an.analyze(root);
var x_use = false;
var m_use = false;
var red_use = false;
for (res.member_refs) |mr| {
if (mr.is_def) continue;
if (std.mem.eql(u8, mr.name, "x") and std.mem.eql(u8, mr.owner, "P")) x_use = true;
if (std.mem.eql(u8, mr.name, "m") and std.mem.eql(u8, mr.owner, "P")) m_use = true;
if (std.mem.eql(u8, mr.name, "red") and std.mem.eql(u8, mr.owner, "Color")) red_use = true;
}
try std.testing.expect(x_use);
try std.testing.expect(m_use);
try std.testing.expect(red_use);
}
test "sema: variable shadowing in same scope is allowed" {
const parser_mod = @import("parser.zig");