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" {