This commit is contained in:
agra
2026-02-20 13:28:38 +02:00
parent 5956303366
commit 6f927361aa
9 changed files with 657 additions and 36 deletions

View File

@@ -177,12 +177,27 @@ pub const Server = struct {
// Namespace import member
if (self.findImportByNs(doc, qn.ns)) |imp| {
if (self.documents.get(imp.path)) |imp_doc| {
// Single-file import
if (imp_doc.sema) |imp_sema| {
if (findSymbolByName(imp_sema.symbols, qn.member)) |si| {
const sym = imp_sema.symbols[si];
if (try self.sendSymbolLocation(id_json, imp_doc, sym)) return;
}
}
} else {
// Directory import: search all documents whose path starts with the import directory
const dir_prefix = try std.fmt.allocPrint(self.allocator, "{s}/", .{imp.path});
var it = self.documents.by_path.iterator();
while (it.next()) |entry| {
if (!std.mem.startsWith(u8, entry.key_ptr.*, dir_prefix)) continue;
const dir_doc = entry.value_ptr.*;
if (dir_doc.sema) |dir_sema| {
if (findSymbolByName(dir_sema.symbols, qn.member)) |si| {
const sym = dir_sema.symbols[si];
if (try self.sendSymbolLocation(id_json, dir_doc, sym)) return;
}
}
}
}
}
@@ -210,21 +225,28 @@ pub const Server = struct {
if (try self.sendSymbolLocation(id_json, doc, sym)) return;
}
// 4. #import "path" string → open the file
// 4. #import "path" string → open the file (or directory)
if (findImportPathAtOffset(doc.source, offset)) |import_path| {
const base_dir = sx.imports.dirName(file_path);
const resolved = if (std.mem.eql(u8, base_dir, "."))
import_path
else
try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ base_dir, import_path });
const target_uri = try std.fmt.allocPrint(self.allocator, "file://{s}", .{resolved});
const range = lsp.Range{
.start = .{ .line = 0, .character = 0 },
.end = .{ .line = 0, .character = 0 },
};
const loc_json = try lsp.locationJson(self.allocator, target_uri, range);
try self.sendResponse(id_json, loc_json);
return;
// For directory imports, try to read as file first
if (std.Io.Dir.readFileAlloc(.cwd(), self.io, resolved, self.allocator, .limited(1))) |_| {
// It's a file — navigate to it
const target_uri = try std.fmt.allocPrint(self.allocator, "file://{s}", .{resolved});
const range = lsp.Range{
.start = .{ .line = 0, .character = 0 },
.end = .{ .line = 0, .character = 0 },
};
const loc_json = try lsp.locationJson(self.allocator, target_uri, range);
try self.sendResponse(id_json, loc_json);
return;
} else |_| {
// Might be a directory — no single file to navigate to
}
}
// 5. Fallback: identifier in string interpolation
@@ -1032,6 +1054,7 @@ pub const Server = struct {
.at,
.pipe,
.pipe_equal,
.pipe_arrow,
.caret,
.caret_equal,
.tilde,
@@ -1283,6 +1306,17 @@ pub const Server = struct {
return null;
}
pub fn searchFnDeclInDoc(target_doc: *const Document, fn_name: []const u8) ?sx.ast.FnDecl {
const root = target_doc.root orelse return null;
if (root.data != .root) return null;
for (root.data.root.decls) |decl| {
if (decl.data == .fn_decl and std.mem.eql(u8, decl.data.fn_decl.name, fn_name)) {
return decl.data.fn_decl;
}
}
return null;
}
/// Find a fn_decl by name. Supports dotted names like "ns.func".
fn findFnDeclByName(self: *Server, doc: *const Document, name: []const u8) ?sx.ast.FnDecl {
// Check for dotted name (e.g. "std.print")
@@ -1292,13 +1326,15 @@ pub const Server = struct {
if (self.findImportByNs(doc, ns_name)) |imp| {
if (self.documents.get(imp.path)) |imp_doc| {
if (imp_doc.root) |imp_root| {
if (imp_root.data == .root) {
for (imp_root.data.root.decls) |decl| {
if (decl.data == .fn_decl and std.mem.eql(u8, decl.data.fn_decl.name, fn_name)) {
return decl.data.fn_decl;
}
}
if (searchFnDeclInDoc(imp_doc, fn_name)) |fd| return fd;
} else {
// Directory import: search all documents in the directory
const dir_prefix = std.fmt.allocPrint(self.allocator, "{s}/", .{imp.path}) catch null;
if (dir_prefix) |prefix| {
var it = self.documents.by_path.iterator();
while (it.next()) |entry| {
if (!std.mem.startsWith(u8, entry.key_ptr.*, prefix)) continue;
if (searchFnDeclInDoc(entry.value_ptr.*, fn_name)) |fd| return fd;
}
}
}
@@ -1590,7 +1626,7 @@ pub const Server = struct {
return null;
}
fn positionToOffset(source: []const u8, line: u32, character: u32) ?u32 {
pub fn positionToOffset(source: []const u8, line: u32, character: u32) ?u32 {
var cur_line: u32 = 0;
var cur_col: u32 = 0;
for (source, 0..) |ch, i| {
@@ -1694,7 +1730,7 @@ pub const Server = struct {
return name;
}
fn extractIdentAtOffset(source: []const u8, offset: u32) ?[]const u8 {
pub fn extractIdentAtOffset(source: []const u8, offset: u32) ?[]const u8 {
if (offset >= source.len) return null;
var start: u32 = offset;
while (start > 0 and isIdentChar(source[start - 1])) {
@@ -1712,7 +1748,7 @@ pub const Server = struct {
return (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '_';
}
fn findImportPathAtOffset(source: []const u8, offset: u32) ?[]const u8 {
pub fn findImportPathAtOffset(source: []const u8, offset: u32) ?[]const u8 {
if (offset >= source.len) return null;
var qstart: u32 = offset;
@@ -1739,7 +1775,7 @@ pub const Server = struct {
return source[qstart + 1 .. qend];
}
fn extractQualifiedName(source: []const u8, offset: u32) ?struct { ns: []const u8, member: []const u8 } {
pub fn extractQualifiedName(source: []const u8, offset: u32) ?struct { ns: []const u8, member: []const u8 } {
if (offset >= source.len) return null;
var end: u32 = offset;
@@ -1774,7 +1810,7 @@ pub const Server = struct {
return null;
}
fn findSymbolByName(symbols: []const sx.sema.Symbol, name: []const u8) ?usize {
pub fn findSymbolByName(symbols: []const sx.sema.Symbol, name: []const u8) ?usize {
var i = symbols.len;
while (i > 0) {
i -= 1;
@@ -2126,3 +2162,299 @@ test "findCaptureVariant: no capture" {
const result = Server.findCaptureVariant(source, @intCast(source.len), "e");
try std.testing.expect(result == null);
}
// ---- Helper function tests ----
test "extractQualifiedName: namespace.member at member offset" {
const source = "pkg.mul(3, 4)";
// offset inside "mul" (index 4)
const result = Server.extractQualifiedName(source, 4);
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("pkg", result.?.ns);
try std.testing.expectEqualStrings("mul", result.?.member);
}
test "extractQualifiedName: namespace.member at namespace offset" {
const source = "pkg.mul(3, 4)";
// offset inside "pkg" (index 1)
const result = Server.extractQualifiedName(source, 1);
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("pkg", result.?.ns);
try std.testing.expectEqualStrings("mul", result.?.member);
}
test "extractQualifiedName: bare identifier returns null" {
const source = "hello(42)";
const result = Server.extractQualifiedName(source, 2);
try std.testing.expect(result == null);
}
test "extractQualifiedName: chained a.b.c at c offset" {
const source = "a.b.c(1)";
// offset inside "c" (index 4)
const result = Server.extractQualifiedName(source, 4);
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("b", result.?.ns);
try std.testing.expectEqualStrings("c", result.?.member);
}
test "positionToOffset: line 0 char 0" {
const source = "hello\nworld\n";
const result = Server.positionToOffset(source, 0, 0);
try std.testing.expectEqual(@as(u32, 0), result.?);
}
test "positionToOffset: line 1 char 0" {
const source = "hello\nworld\n";
const result = Server.positionToOffset(source, 1, 0);
try std.testing.expectEqual(@as(u32, 6), result.?);
}
test "positionToOffset: line 0 char 3" {
const source = "hello\nworld\n";
const result = Server.positionToOffset(source, 0, 3);
try std.testing.expectEqual(@as(u32, 3), result.?);
}
test "extractIdentAtOffset: middle of identifier" {
const source = "foo bar baz";
const result = Server.extractIdentAtOffset(source, 5);
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("bar", result.?);
}
test "extractIdentAtOffset: at space returns null" {
const source = "foo bar";
const result = Server.extractIdentAtOffset(source, 3);
try std.testing.expect(result == null);
}
test "findSymbolByName: finds existing symbol" {
const symbols = [_]sx.sema.Symbol{
.{ .name = "add", .kind = .function, .ty = null, .def_span = .{ .start = 0, .end = 3 }, .scope_depth = 0 },
.{ .name = "mul", .kind = .function, .ty = null, .def_span = .{ .start = 10, .end = 13 }, .scope_depth = 0 },
};
const result = Server.findSymbolByName(&symbols, "mul");
try std.testing.expectEqual(@as(usize, 1), result.?);
}
test "findSymbolByName: returns null for missing" {
const symbols = [_]sx.sema.Symbol{
.{ .name = "add", .kind = .function, .ty = null, .def_span = .{ .start = 0, .end = 3 }, .scope_depth = 0 },
};
const result = Server.findSymbolByName(&symbols, "sub");
try std.testing.expect(result == null);
}
test "findImportPathAtOffset: inside import string" {
const source = "#import \"modules/std.sx\";";
// offset inside the string (index 10)
const result = Server.findImportPathAtOffset(source, 10);
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("modules/std.sx", result.?);
}
test "findImportPathAtOffset: outside import returns null" {
const source = "x := 42;";
const result = Server.findImportPathAtOffset(source, 2);
try std.testing.expect(result == null);
}
// ---- Document analysis pipeline tests ----
test "analyzeDocument: parse and sema basic function" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
const src: [:0]const u8 = "add :: (a: s32, b: s32) -> s32 { a + b; }";
const doc = try store.openOrUpdate("/test/main.sx", src, 1);
try store.analyzeDocument(doc);
const sema = doc.sema orelse return error.SkipZigTest;
// Should have "add" as a function symbol
try std.testing.expect(Server.findSymbolByName(sema.symbols, "add") != null);
const idx = Server.findSymbolByName(sema.symbols, "add").?;
try std.testing.expectEqual(sx.sema.SymbolKind.function, sema.symbols[idx].kind);
}
test "analyzeDocument: flat import pre-registers symbols" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_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);
try store.analyzeDocument(lib_doc);
// Load main file with flat import
const main_src: [:0]const u8 =
\\#import "lib.sx";
\\main :: () -> s32 { mul(3, 4); }
;
const main_doc = try store.openOrUpdate("/test/main.sx", main_src, 1);
try store.analyzeDocument(main_doc);
const sema = main_doc.sema orelse return error.SkipZigTest;
// "mul" should be pre-registered from flat import
try std.testing.expect(Server.findSymbolByName(sema.symbols, "mul") != null);
// "main" should be defined locally
try std.testing.expect(Server.findSymbolByName(sema.symbols, "main") != null);
}
test "analyzeDocument: namespaced import registers namespace symbol" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_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);
try store.analyzeDocument(lib_doc);
// Load main file with namespaced import
const main_src: [:0]const u8 =
\\pkg :: #import "lib.sx";
\\main :: () -> s32 { pkg.add(3, 4); }
;
const main_doc = try store.openOrUpdate("/test/main.sx", main_src, 1);
try store.analyzeDocument(main_doc);
const sema = main_doc.sema orelse return error.SkipZigTest;
// "pkg" should be a namespace symbol
try std.testing.expect(Server.findSymbolByName(sema.symbols, "pkg") != null);
const idx = Server.findSymbolByName(sema.symbols, "pkg").?;
try std.testing.expectEqual(sx.sema.SymbolKind.namespace, sema.symbols[idx].kind);
}
test "analyzeDocument: namespaced import fn_signatures have prefix" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_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);
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);
try store.analyzeDocument(main_doc);
const sema = main_doc.sema orelse return error.SkipZigTest;
// fn_signatures should contain "pkg.add"
try std.testing.expect(sema.fn_signatures.contains("pkg.add"));
}
test "analyzeDocument: pipe operator desugars to call" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_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);
try store.analyzeDocument(lib_doc);
// Pipe operator should parse and analyze without errors
const main_src: [:0]const u8 =
\\pkg :: #import "lib.sx";
\\main :: () -> s32 { 3 |> pkg.add(4); }
;
const main_doc = try store.openOrUpdate("/test/main.sx", main_src, 1);
try store.analyzeDocument(main_doc);
const sema = main_doc.sema orelse return error.SkipZigTest;
// Should parse successfully with no sema errors for "add"
try std.testing.expect(Server.findSymbolByName(sema.symbols, "main") != null);
}
test "analyzeDocument: for-loop capture variables are registered" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
const src: [:0]const u8 =
\\main :: () {
\\ arr : [3]s32 = ---;
\\ for arr: (it, ix) {
\\ x := it + ix;
\\ }
\\}
;
const doc = try store.openOrUpdate("/test/main.sx", src, 1);
try store.analyzeDocument(doc);
const sema = doc.sema orelse return error.SkipZigTest;
// Capture variables should be registered — "it" and "ix" should not produce unresolved errors
try std.testing.expect(Server.findSymbolByName(sema.symbols, "it") != null);
try std.testing.expect(Server.findSymbolByName(sema.symbols, "ix") != null);
// "x" should also be registered
try std.testing.expect(Server.findSymbolByName(sema.symbols, "x") != null);
}
test "analyzeDocument: for-loop with underscore capture" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
const src: [:0]const u8 =
\\main :: () {
\\ arr : [3]s32 = ---;
\\ for arr: (_, ix) {
\\ x := ix;
\\ }
\\}
;
const doc = try store.openOrUpdate("/test/main.sx", src, 1);
try store.analyzeDocument(doc);
const sema = doc.sema orelse return error.SkipZigTest;
// "_" should NOT be registered as a symbol
try std.testing.expect(Server.findSymbolByName(sema.symbols, "_") == null);
// "ix" should be registered
try std.testing.expect(Server.findSymbolByName(sema.symbols, "ix") != null);
}
test "analyzeDocument: for-loop value-only capture" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
const src: [:0]const u8 =
\\main :: () {
\\ arr : [3]s32 = ---;
\\ for arr: (val) {
\\ x := val;
\\ }
\\}
;
const doc = try store.openOrUpdate("/test/main.sx", src, 1);
try store.analyzeDocument(doc);
const sema = doc.sema orelse return error.SkipZigTest;
// "val" should be registered
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) };
}