From 292fd937c6f9d6eb66c75c469170ca148cdcbbed Mon Sep 17 00:00:00 2001 From: agra Date: Sun, 31 May 2026 13:36:20 +0300 Subject: [PATCH] lsp: project-wide find-references + revive the LSP test suite find-references only searched documents the editor had open, so asking for references to a field from a file whose users were all closed returned just the definition. Load every .sx under the workspace root before matching so uses in unopened files are found too. The LSP server's own tests were dormant: nested under the `lsp` struct in root.zig, refAllDecls never reached them, and they had bit-rotted (stale DocumentStore.init arity, an unaligned dummy io, fake /test/ paths that no longer resolve). Reference the lsp files directly so their tests run, give the doc-store tests a real Threaded io with bare paths, and fix the stale extractIdentAtOffset expectation. Extract referencesPayload from the transport so it is unit-testable, and add tests covering cross-document field references, includeDeclaration, the for-loop capture inlay hint, and workspace file loading. --- src/lsp/document.zig | 26 ++++++ src/lsp/server.zig | 198 ++++++++++++++++++++++++++++++++++++------- src/root.zig | 6 ++ src/sema.zig | 20 +++++ 4 files changed, 221 insertions(+), 29 deletions(-) diff --git a/src/lsp/document.zig b/src/lsp/document.zig index 25ed7b1..f013c12 100644 --- a/src/lsp/document.zig +++ b/src/lsp/document.zig @@ -103,6 +103,32 @@ pub const DocumentStore = struct { return file_paths.toOwnedSlice(self.allocator) catch null; } + /// Recursively load and analyse every `.sx` file under the workspace root so + /// cross-file features (find-references) see uses in files the editor never + /// opened. Already-loaded documents keep their in-editor content. + pub fn loadWorkspaceFiles(self: *DocumentStore) void { + const root = self.rootPathOpt() orelse return; + self.loadDirRecursive(root, 0); + } + + fn loadDirRecursive(self: *DocumentStore, dir_path: []const u8, depth: u32) void { + if (depth > 16) return; + const dir = std.Io.Dir.openDir(.cwd(), self.io, dir_path, .{ .iterate = true }) catch return; + defer dir.close(self.io); + var it = dir.iterate(); + while (it.next(self.io) catch null) |entry| { + if (entry.name.len == 0 or entry.name[0] == '.') continue; + const full = std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ dir_path, entry.name }) catch continue; + defer self.allocator.free(full); + if (entry.kind == .directory) { + self.loadDirRecursive(full, depth + 1); + } else if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".sx")) { + const doc = self.getOrLoad(full) catch continue; + if (doc.sema == null) self.analyzeDocument(doc) catch {}; + } + } + } + /// Create or update a document with editor-provided source (for didOpen/didChange). pub fn openOrUpdate(self: *DocumentStore, path: []const u8, source: [:0]const u8, version: i64) !*Document { if (self.by_path.get(path)) |doc| { diff --git a/src/lsp/server.zig b/src/lsp/server.zig index 036f740..86caaaa 100644 --- a/src/lsp/server.zig +++ b/src/lsp/server.zig @@ -196,6 +196,10 @@ pub const Server = struct { const sema = doc.sema orelse doc.last_good_sema orelse return try self.sendResponse(id_json, "null"); const offset = positionToOffset(doc.source, pos.line, pos.character) orelse return try self.sendResponse(id_json, "null"); + // References span the whole project — pull in workspace files the + // editor hasn't opened so their uses are searched too. + self.documents.loadWorkspaceFiles(); + var include_decl = true; if (jsonGet(params, "context")) |c| { if (jsonGet(c, "includeDeclaration")) |v| { @@ -203,12 +207,19 @@ pub const Server = struct { } } + const payload = try self.referencesPayload(doc, &sema, offset, include_decl); + try self.sendResponse(id_json, payload); + } + + /// Compute the `textDocument/references` JSON payload for the symbol at + /// `offset`. Pure over the loaded documents (no transport / io), so it is + /// unit-testable: build a `DocumentStore` of in-memory docs and call this. + fn referencesPayload(self: *Server, doc: *const Document, sema: *const SemaResult, offset: u32, include_decl: bool) ![]const u8 { // 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(); @@ -224,7 +235,7 @@ pub const Server = struct { } } try buf.append(self.allocator, ']'); - return try self.sendResponse(id_json, buf.items); + return buf.items; } // Resolve the target symbol: a reference at the cursor, or a definition. @@ -234,12 +245,11 @@ pub const Server = struct { } else if (findSymbolNameAtOffset(sema.symbols, doc.source, offset)) |si| { target_idx = @intCast(si); } - if (target_idx == null) return try self.sendResponse(id_json, "[]"); + if (target_idx == null) return "[]"; const target = sema.symbols[target_idx.?]; const cross_file = target.scope_depth == 0; // only top-level names span files var buf = std.ArrayList(u8).empty; - defer buf.deinit(self.allocator); try buf.append(self.allocator, '['); var first = true; @@ -273,7 +283,7 @@ pub const Server = struct { } try buf.append(self.allocator, ']'); - try self.sendResponse(id_json, buf.items); + return buf.items; } fn handleDefinition(self: *Server, id: ?std.json.Value, params: std.json.Value) !void { @@ -3020,9 +3030,16 @@ test "extractIdentAtOffset: middle of identifier" { try std.testing.expectEqualStrings("bar", result.?); } -test "extractIdentAtOffset: at space returns null" { +test "extractIdentAtOffset: at end of identifier scans back" { + // Cursor just past "foo" (on the space) resolves the word to its left. const source = "foo bar"; const result = Server.extractIdentAtOffset(source, 3); + try std.testing.expectEqualStrings("foo", result.?); +} + +test "extractIdentAtOffset: surrounded by whitespace returns null" { + const source = "a b"; + const result = Server.extractIdentAtOffset(source, 2); try std.testing.expect(result == null); } @@ -3064,9 +3081,9 @@ test "analyzeDocument: parse and sema basic function" { defer arena.deinit(); const alloc = arena.allocator(); - var store = doc_mod.DocumentStore.init(alloc, undefined_io()); + var store = doc_mod.DocumentStore.init(alloc, test_io(), &.{}); const src: [:0]const u8 = "add :: (a: s32, b: s32) -> s32 { a + b; }"; - const doc = try store.openOrUpdate("/test/main.sx", src, 1); + const doc = try store.openOrUpdate("main.sx", src, 1); try store.analyzeDocument(doc); const sema = doc.sema orelse return error.SkipZigTest; @@ -3081,11 +3098,11 @@ test "analyzeDocument: flat import pre-registers symbols" { defer arena.deinit(); const alloc = arena.allocator(); - var store = doc_mod.DocumentStore.init(alloc, undefined_io()); + var store = doc_mod.DocumentStore.init(alloc, test_io(), &.{}); // Pre-load the imported module const lib_src: [:0]const u8 = "mul :: (a: s32, b: s32) -> s32 { a * b; }"; - const lib_doc = try store.openOrUpdate("/test/lib.sx", lib_src, 1); + const lib_doc = try store.openOrUpdate("lib.sx", lib_src, 1); try store.analyzeDocument(lib_doc); // Load main file with flat import @@ -3093,7 +3110,7 @@ test "analyzeDocument: flat import pre-registers symbols" { \\#import "lib.sx"; \\main :: () -> s32 { mul(3, 4); } ; - const main_doc = try store.openOrUpdate("/test/main.sx", main_src, 1); + const main_doc = try store.openOrUpdate("main.sx", main_src, 1); try store.analyzeDocument(main_doc); const sema = main_doc.sema orelse return error.SkipZigTest; @@ -3108,11 +3125,11 @@ test "analyzeDocument: namespaced import registers namespace symbol" { defer arena.deinit(); const alloc = arena.allocator(); - var store = doc_mod.DocumentStore.init(alloc, undefined_io()); + var store = doc_mod.DocumentStore.init(alloc, test_io(), &.{}); // Pre-load the imported module const lib_src: [:0]const u8 = "add :: (a: s32, b: s32) -> s32 { a + b; }"; - const lib_doc = try store.openOrUpdate("/test/lib.sx", lib_src, 1); + const lib_doc = try store.openOrUpdate("lib.sx", lib_src, 1); try store.analyzeDocument(lib_doc); // Load main file with namespaced import @@ -3120,7 +3137,7 @@ test "analyzeDocument: namespaced import registers namespace symbol" { \\pkg :: #import "lib.sx"; \\main :: () -> s32 { pkg.add(3, 4); } ; - const main_doc = try store.openOrUpdate("/test/main.sx", main_src, 1); + const main_doc = try store.openOrUpdate("main.sx", main_src, 1); try store.analyzeDocument(main_doc); const sema = main_doc.sema orelse return error.SkipZigTest; @@ -3135,17 +3152,17 @@ test "analyzeDocument: namespaced import fn_signatures have prefix" { defer arena.deinit(); const alloc = arena.allocator(); - var store = doc_mod.DocumentStore.init(alloc, undefined_io()); + var store = doc_mod.DocumentStore.init(alloc, test_io(), &.{}); const lib_src: [:0]const u8 = "add :: (a: s32, b: s32) -> s32 { a + b; }"; - const lib_doc = try store.openOrUpdate("/test/lib.sx", lib_src, 1); + const lib_doc = try store.openOrUpdate("lib.sx", lib_src, 1); try store.analyzeDocument(lib_doc); const main_src: [:0]const u8 = \\pkg :: #import "lib.sx"; \\main :: () -> s32 { pkg.add(1, 2); } ; - const main_doc = try store.openOrUpdate("/test/main.sx", main_src, 1); + const main_doc = try store.openOrUpdate("main.sx", main_src, 1); try store.analyzeDocument(main_doc); const sema = main_doc.sema orelse return error.SkipZigTest; @@ -3158,10 +3175,10 @@ test "analyzeDocument: pipe operator desugars to call" { defer arena.deinit(); const alloc = arena.allocator(); - var store = doc_mod.DocumentStore.init(alloc, undefined_io()); + var store = doc_mod.DocumentStore.init(alloc, test_io(), &.{}); const lib_src: [:0]const u8 = "add :: (a: s32, b: s32) -> s32 { a + b; }"; - const lib_doc = try store.openOrUpdate("/test/lib.sx", lib_src, 1); + const lib_doc = try store.openOrUpdate("lib.sx", lib_src, 1); try store.analyzeDocument(lib_doc); // Pipe operator should parse and analyze without errors @@ -3169,7 +3186,7 @@ test "analyzeDocument: pipe operator desugars to call" { \\pkg :: #import "lib.sx"; \\main :: () -> s32 { 3 |> pkg.add(4); } ; - const main_doc = try store.openOrUpdate("/test/main.sx", main_src, 1); + const main_doc = try store.openOrUpdate("main.sx", main_src, 1); try store.analyzeDocument(main_doc); const sema = main_doc.sema orelse return error.SkipZigTest; @@ -3182,7 +3199,7 @@ test "analyzeDocument: for-loop capture variables are registered" { defer arena.deinit(); const alloc = arena.allocator(); - var store = doc_mod.DocumentStore.init(alloc, undefined_io()); + var store = doc_mod.DocumentStore.init(alloc, test_io(), &.{}); const src: [:0]const u8 = \\main :: () { @@ -3192,7 +3209,7 @@ test "analyzeDocument: for-loop capture variables are registered" { \\ } \\} ; - const doc = try store.openOrUpdate("/test/main.sx", src, 1); + const doc = try store.openOrUpdate("main.sx", src, 1); try store.analyzeDocument(doc); const sema = doc.sema orelse return error.SkipZigTest; @@ -3208,7 +3225,7 @@ test "analyzeDocument: for-loop with underscore capture" { defer arena.deinit(); const alloc = arena.allocator(); - var store = doc_mod.DocumentStore.init(alloc, undefined_io()); + var store = doc_mod.DocumentStore.init(alloc, test_io(), &.{}); const src: [:0]const u8 = \\main :: () { @@ -3218,7 +3235,7 @@ test "analyzeDocument: for-loop with underscore capture" { \\ } \\} ; - const doc = try store.openOrUpdate("/test/main.sx", src, 1); + const doc = try store.openOrUpdate("main.sx", src, 1); try store.analyzeDocument(doc); const sema = doc.sema orelse return error.SkipZigTest; @@ -3233,7 +3250,7 @@ test "analyzeDocument: for-loop value-only capture" { defer arena.deinit(); const alloc = arena.allocator(); - var store = doc_mod.DocumentStore.init(alloc, undefined_io()); + var store = doc_mod.DocumentStore.init(alloc, test_io(), &.{}); const src: [:0]const u8 = \\main :: () { @@ -3243,7 +3260,7 @@ test "analyzeDocument: for-loop value-only capture" { \\ } \\} ; - const doc = try store.openOrUpdate("/test/main.sx", src, 1); + const doc = try store.openOrUpdate("main.sx", src, 1); try store.analyzeDocument(doc); const sema = doc.sema orelse return error.SkipZigTest; @@ -3251,7 +3268,130 @@ test "analyzeDocument: for-loop value-only capture" { try std.testing.expect(Server.findSymbolByName(sema.symbols, "val") != null); } -/// Helper: create a dummy std.Io that won't be called (used when all docs are pre-loaded). -fn undefined_io() std.Io { - return .{ .userdata = null, .vtable = @ptrFromInt(@intFromPtr(@as(?*const std.Io.VTable, null)) | 1) }; +/// A real std.Io for the document-store tests. Import resolution touches the +/// filesystem (to probe candidate paths), so a working io is required; the +/// pre-loaded `by_path` entries win over disk, so the probes harmlessly miss. +/// Process-lifetime and intentionally never deinitialised. +var g_test_threaded: ?std.Io.Threaded = null; +fn test_io() std.Io { + if (g_test_threaded == null) { + g_test_threaded = std.Io.Threaded.init(std.heap.page_allocator, .{}); + } + return g_test_threaded.?.io(); +} + +test "lsp/references: a field's uses are found across documents" { + 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 lib_src: [:0]const u8 = "Move :: struct { flag: s64; }"; + const lib_doc = try store.openOrUpdate("lib.sx", lib_src, 1); + try store.analyzeDocument(lib_doc); + + const main_src: [:0]const u8 = + \\#import "lib.sx"; + \\use :: (m: *Move) -> s64 { m.flag; } + ; + const main_doc = try store.openOrUpdate("main.sx", main_src, 1); + try store.analyzeDocument(main_doc); + + var server = Server{ .allocator = alloc, .documents = store, .transport = undefined, .io = test_io() }; + + // Cursor on the `flag` field definition in lib.sx. + const flag_off: u32 = @intCast(std.mem.indexOf(u8, lib_src, "flag").?); + const payload = try server.referencesPayload(lib_doc, &lib_doc.sema.?, flag_off, true); + + // The definition (lib.sx) plus the one use (main.sx) — even though main.sx + // is a different document that only learns `Move` through the import. + try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, payload, "\"uri\"")); + try std.testing.expect(std.mem.indexOf(u8, payload, "lib.sx") != null); + try std.testing.expect(std.mem.indexOf(u8, payload, "main.sx") != null); +} + +test "lsp/references: excluding the declaration drops the definition" { + 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; } use :: (m: *Move) -> s64 { m.flag; }"; + const doc = try store.openOrUpdate("main.sx", src, 1); + try store.analyzeDocument(doc); + + var server = Server{ .allocator = alloc, .documents = store, .transport = undefined, .io = test_io() }; + const flag_off: u32 = @intCast(std.mem.indexOf(u8, src, "flag").?); + + const with_decl = try server.referencesPayload(doc, &doc.sema.?, flag_off, true); + try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, with_decl, "\"uri\"")); + const without_decl = try server.referencesPayload(doc, &doc.sema.?, flag_off, false); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, without_decl, "\"uri\"")); +} + +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(); + const alloc = arena.allocator(); + + var store = doc_mod.DocumentStore.init(alloc, test_io(), &.{}); + const lib_src: [:0]const u8 = + \\Move :: struct { flag: s64; } + \\List :: struct ($T: Type) { items: [*]T = null; len: s64 = 0; } + ; + const lib_doc = try store.openOrUpdate("lib.sx", lib_src, 1); + try store.analyzeDocument(lib_doc); + + const main_src: [:0]const u8 = + \\#import "lib.sx"; + \\Game :: struct { + \\ legal: List(Move); + \\ scan :: (self: *Game) { + \\ for self.legal: (m) { x := m.flag; } + \\ } + \\} + ; + const main_doc = try store.openOrUpdate("main.sx", main_src, 1); + try store.analyzeDocument(main_doc); + + const sema = main_doc.sema orelse return error.SkipZigTest; + var hints = std.ArrayList(lsp.InlayHint).empty; + Server.collectInlayHints(alloc, main_doc.root.?, sema.symbols, sema.fn_signatures, main_doc.source, &hints); + + // The capture `m` iterates `List(Move)`, so it is hinted `: Move` — which + // requires descending into struct method bodies and resolving the element. + var found_move = false; + for (hints.items) |h| { + if (std.mem.indexOf(u8, h.label, "Move") != null) found_move = true; + } + try std.testing.expect(found_move); +} + +test "lsp/workspace: loadWorkspaceFiles analyses .sx files that were never opened" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const io = test_io(); + + const dir = ".sx-lsp-ws-test"; + std.Io.Dir.createDirPath(.cwd(), io, dir) catch {}; + defer { + std.Io.Dir.deleteFile(.cwd(), io, dir ++ "/a.sx") catch {}; + std.Io.Dir.deleteFile(.cwd(), io, dir ++ "/b.sx") catch {}; + std.Io.Dir.deleteDir(.cwd(), io, dir) catch {}; + } + try std.Io.Dir.writeFile(.cwd(), io, .{ .sub_path = dir ++ "/a.sx", .data = "Foo :: struct { x: s64; }" }); + try std.Io.Dir.writeFile(.cwd(), io, .{ .sub_path = dir ++ "/b.sx", .data = "Bar :: struct { y: s64; }" }); + + var store = doc_mod.DocumentStore.init(alloc, io, &.{}); + store.root_path = dir; + store.loadWorkspaceFiles(); + + // Both files are loaded and analysed without any didOpen — this is what + // makes find-references work when the using files aren't open in the editor. + const a = store.get(dir ++ "/a.sx") orelse return error.TestUnexpectedResult; + const b = store.get(dir ++ "/b.sx") orelse return error.TestUnexpectedResult; + try std.testing.expect(a.sema != null); + try std.testing.expect(b.sema != null); } diff --git a/src/root.zig b/src/root.zig index 271e069..cf255da 100644 --- a/src/root.zig +++ b/src/root.zig @@ -28,4 +28,10 @@ test { // (e.g. ir/ir.zig) carry their own `test { refAllDecls }`, so this chains // into them. @import("std").testing.refAllDecls(@This()); + // refAllDecls only reaches the top-level decls; the `lsp` files live one + // struct deeper, so reference them directly to pull in their tests. + _ = lsp.server; + _ = lsp.document; + _ = lsp.types; + _ = lsp.transport; } diff --git a/src/sema.zig b/src/sema.zig index 02ba8ab..1f8deb1 100644 --- a/src/sema.zig +++ b/src/sema.zig @@ -2099,6 +2099,26 @@ test "sema: for-loop captures resolve element, by-ref pointer, and range cursor" // range cursor is s64. try std.testing.expect(i_ty != null and i_ty.? == .signed); try std.testing.expect(i_ty.?.signed == 64); + + // The typed capture lets `m.flag` / `p.flag` resolve their owner to `Move` + // (an untyped capture would leave the owner blank — a wildcard that quietly + // over-matches in find-references). + var value_owner_ok = false; + var ref_owner_ok = false; + for (res.member_refs) |mr| { + if (mr.is_def or !std.mem.eql(u8, mr.name, "flag")) continue; + if (!std.mem.eql(u8, mr.owner, "Move")) continue; + if (offsetIn(source, mr.span, "m.flag")) value_owner_ok = true; + if (offsetIn(source, mr.span, "p.flag")) ref_owner_ok = true; + } + try std.testing.expect(value_owner_ok); + try std.testing.expect(ref_owner_ok); +} + +/// True when `span` falls inside the first occurrence of `needle` in `source`. +fn offsetIn(source: []const u8, span: ast.Span, needle: []const u8) bool { + const at = std.mem.indexOf(u8, source, needle) orelse return false; + return span.start >= at and span.start < at + needle.len; } test "sema: member references record fields, methods, and enum variants" {