diff --git a/src/lsp/server.zig b/src/lsp/server.zig index 86caaaa..a80af1e 100644 --- a/src/lsp/server.zig +++ b/src/lsp/server.zig @@ -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();