From 6f927361aa6635283ec0e664dfae0ac9706faf13 Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 20 Feb 2026 13:28:38 +0200 Subject: [PATCH] pipes --- examples/50-smoke.sx | 17 ++ specs.md | 32 ++- src/lexer.zig | 4 + src/lsp/document.zig | 94 ++++++++- src/lsp/server.zig | 374 ++++++++++++++++++++++++++++++++++-- src/parser.zig | 48 ++++- src/sema.zig | 116 ++++++++++- src/token.zig | 2 + tests/expected/50-smoke.txt | 6 + 9 files changed, 657 insertions(+), 36 deletions(-) diff --git a/examples/50-smoke.sx b/examples/50-smoke.sx index 99a9f08..57e897f 100644 --- a/examples/50-smoke.sx +++ b/examples/50-smoke.sx @@ -1360,5 +1360,22 @@ END; print("{}\n", pkg.hello()); // hello from testpkg } + // --- Pipe operator --- + { + print("--- pipe operator ---\n"); + // Basic: a |> f(b) → f(a, b) + print("{}\n", 3 |> pkg.add(4)); // 7 + print("{}\n", 5 |> pkg.mul(6)); // 30 + + // Chaining: a |> f(b) |> g(c) → g(f(a, b), c) + print("{}\n", 3 |> pkg.add(4) |> pkg.mul(2)); // 14 + + // With non-namespaced functions + print("{}\n", "hello" |> concat(" world")); // hello world + + // Chained string ops + print("{}\n", "piped" |> concat(" ok") |> concat("!")); // piped ok! + } + print("=== DONE ===\n"); } diff --git a/specs.md b/specs.md index 8bda742..4fb5602 100644 --- a/specs.md +++ b/specs.md @@ -72,6 +72,7 @@ GLSL; | `and` | logical AND (short-circuit) | | `or` | logical OR (short-circuit) | | `in` | membership test (tuples) | +| `\|>` | pipe (function application) | | `+=` | add-assign | | `-=` | sub-assign | | `*=` | mul-assign | @@ -1024,6 +1025,26 @@ calc :: ufcs compute; 1.calc(2, 3, 4); // normal UFCS → compute(1, 2, 3, 4) ``` +### Pipe Operator + +The pipe operator `|>` inserts the left-hand side as the first argument of the right-hand side call. It is desugared at parse time. + +```sx +a |> f(b, c) // → f(a, b, c) +a |> f // → f(a) +a |> f(b) |> g(c) // → g(f(a, b), c) +``` + +The pipe is left-associative with the lowest precedence of all binary operators, so expressions like `x + 1 |> f(2)` are parsed as `f(x + 1, 2)`. + +This is especially useful with namespaced imports: +```sx +pkg :: #import "modules/math"; + +3 |> pkg.add(4) // → pkg.add(3, 4) → 7 +3 |> pkg.add(4) |> pkg.mul(2) // → pkg.mul(pkg.add(3, 4), 2) → 14 +``` + ### Field Access ```sx object.field @@ -1214,7 +1235,7 @@ This works for any function, not just `format`. The mechanism is general: the VM ### `#import` Directive -The `#import` directive brings declarations from another `.sx` file into the current file. Paths are resolved relative to the importing file's directory. +The `#import` directive brings declarations from another `.sx` file or directory into the current file. Paths are resolved relative to the importing file's directory. **Flat import** — splices all declarations from the imported file into the current scope: ```sx @@ -1226,6 +1247,14 @@ The `#import` directive brings declarations from another `.sx` file into the cur std :: #import "modules/std.sx"; ``` +**Directory import** — when the path refers to a directory, all `.sx` files in that directory are aggregated into a single module: +```sx +pkg :: #import "modules/testpkg"; // namespaced — all .sx files merged under pkg +#import "modules/testpkg"; // flat — all declarations spliced into scope +``` + +Directory imports scan only the top level of the specified directory (non-recursive). Files are processed in alphabetical order for deterministic builds. Files within the directory may `#import` each other or external files. + Namespaced declarations are accessed with dot notation: ```sx std.print("hello"); @@ -1235,6 +1264,7 @@ std.print("hello"); - Imports are resolved after parsing and before code generation. - Paths are relative to the directory of the file containing the `#import`. +- If the path resolves to a file, it is imported directly. If it resolves to a directory, all `.sx` files in that directory are aggregated. - Nested imports are supported (imported files may themselves contain `#import`). - Circular imports are detected and silently skipped (each file is imported at most once). - Generic functions in namespaced imports are supported (e.g., `std.mul(5, 2)` where `mul` is generic). diff --git a/src/lexer.zig b/src/lexer.zig index 2570da3..b51947e 100644 --- a/src/lexer.zig +++ b/src/lexer.zig @@ -186,6 +186,10 @@ pub const Lexer = struct { self.index += 1; return self.makeToken(.pipe_equal, start, self.index); } + if (self.peek() == '>') { + self.index += 1; + return self.makeToken(.pipe_arrow, start, self.index); + } return self.makeToken(.pipe, start, self.index); }, '^' => { diff --git a/src/lsp/document.zig b/src/lsp/document.zig index 607333b..c27bea5 100644 --- a/src/lsp/document.zig +++ b/src/lsp/document.zig @@ -60,6 +60,30 @@ pub const DocumentStore = struct { return self.createDocument(path, source, -1); } + /// Try to list .sx files in a directory. Returns null if path is not a directory. + pub fn listDirectoryFiles(self: *DocumentStore, dir_path: []const u8) ?[]const []const u8 { + const dir = std.Io.Dir.openDir(.cwd(), self.io, dir_path, .{ .iterate = true }) catch return null; + defer dir.close(self.io); + + var file_paths = std.ArrayList([]const u8).empty; + var it = dir.iterate(); + while (it.next(self.io) catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ".sx")) continue; + const full_path = std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ dir_path, entry.name }) catch continue; + file_paths.append(self.allocator, full_path) catch continue; + } + + // Sort for deterministic order + std.mem.sort([]const u8, file_paths.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + }.lessThan); + + return file_paths.toOwnedSlice(self.allocator) catch null; + } + /// 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| { @@ -125,7 +149,75 @@ pub const DocumentStore = struct { try cycle_guard.put(doc.path, {}); for (doc.imports) |imp| { - const imp_doc = self.getOrLoad(imp.path) catch continue; + // Try as file first; if that fails, try as directory import + const imp_doc = self.getOrLoad(imp.path) catch { + // Directory import: load each .sx file and merge their symbols + const dir_files = self.listDirectoryFiles(imp.path) orelse continue; + for (dir_files) |file_path| { + const file_doc = self.getOrLoad(file_path) catch continue; + if (cycle_guard.contains(file_path)) continue; + if (file_doc.sema == null) { + try cycle_guard.put(file_path, {}); + self.analyzeDocument(file_doc) catch {}; + _ = cycle_guard.remove(file_path); + } + const file_sema = file_doc.sema orelse continue; + if (imp.ns) |ns_name| { + // Only register namespace symbol once (first file) + if (!analyzer.hasSymbol(ns_name)) { + try analyzer.preRegisterSymbol(.{ + .name = ns_name, + .kind = .namespace, + .ty = null, + .def_span = .{ .start = 0, .end = 0 }, + .scope_depth = 0, + .origin = imp.path, + }); + } + var sig_it = file_sema.fn_signatures.iterator(); + while (sig_it.next()) |entry| { + const prefixed = try std.fmt.allocPrint(self.allocator, "{s}.{s}", .{ ns_name, entry.key_ptr.* }); + try analyzer.fn_signatures.put(prefixed, entry.value_ptr.*); + } + var struct_it = file_sema.struct_types.iterator(); + while (struct_it.next()) |entry| { + const prefixed = try std.fmt.allocPrint(self.allocator, "{s}.{s}", .{ ns_name, entry.key_ptr.* }); + try analyzer.struct_types.put(prefixed, entry.value_ptr.*); + } + var enum_it = file_sema.enum_types.iterator(); + while (enum_it.next()) |entry| { + const prefixed = try std.fmt.allocPrint(self.allocator, "{s}.{s}", .{ ns_name, entry.key_ptr.* }); + try analyzer.enum_types.put(prefixed, entry.value_ptr.*); + } + } else { + for (file_sema.symbols) |sym| { + if (sym.scope_depth == 0) { + try analyzer.preRegisterSymbol(.{ + .name = sym.name, + .kind = sym.kind, + .ty = sym.ty, + .def_span = sym.def_span, + .scope_depth = 0, + .origin = file_path, + }); + } + } + var sig_it = file_sema.fn_signatures.iterator(); + while (sig_it.next()) |entry| { + try analyzer.fn_signatures.put(entry.key_ptr.*, entry.value_ptr.*); + } + var struct_it = file_sema.struct_types.iterator(); + while (struct_it.next()) |entry| { + try analyzer.struct_types.put(entry.key_ptr.*, entry.value_ptr.*); + } + var enum_it = file_sema.enum_types.iterator(); + while (enum_it.next()) |entry| { + try analyzer.enum_types.put(entry.key_ptr.*, entry.value_ptr.*); + } + } + } + continue; + }; // Cycle detection if (cycle_guard.contains(imp.path)) continue; diff --git a/src/lsp/server.zig b/src/lsp/server.zig index aa5d809..9da9c65 100644 --- a/src/lsp/server.zig +++ b/src/lsp/server.zig @@ -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) }; +} diff --git a/src/parser.zig b/src/parser.zig index 39e4fb6..f5e85fe 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -1037,6 +1037,35 @@ pub const Parser = struct { var lhs = initial_lhs; while (true) { + // Pipe operator: desugar a |> f(args) → f(a, args), a |> f → f(a) + if (self.current.tag == .pipe_arrow and Prec.pipe >= min_prec) { + self.advance(); + // Parse the RHS as a full call expression (higher precedence) + const rhs = try self.parseBinary(Prec.pipe + 1); + // Desugar based on RHS shape + if (rhs.data == .call) { + // a |> f(args) → f(a, args...) + var new_args = std.ArrayList(*Node).empty; + try new_args.append(self.allocator, lhs); + for (rhs.data.call.args) |arg| { + try new_args.append(self.allocator, arg); + } + lhs = try self.createNode(lhs.span.start, .{ .call = .{ + .callee = rhs.data.call.callee, + .args = try new_args.toOwnedSlice(self.allocator), + } }); + } else { + // a |> f → f(a) + const args = try self.allocator.alloc(*Node, 1); + args[0] = lhs; + lhs = try self.createNode(lhs.span.start, .{ .call = .{ + .callee = rhs, + .args = args, + } }); + } + continue; + } + const prec = self.binaryPrec(); if (prec == 0 or prec < min_prec) break; @@ -1842,15 +1871,16 @@ pub const Parser = struct { const Prec = struct { const none: u8 = 0; - const logical_or: u8 = 1; // or - const logical_and: u8 = 2; // and - const bit_or: u8 = 3; // | - const bit_xor: u8 = 4; // ^ - const bit_and: u8 = 5; // & - const comparison: u8 = 6; // == != < <= > >= in - const shift: u8 = 7; // << >> - const additive: u8 = 8; // + - - const multiplicative: u8 = 9; // * / % + const pipe: u8 = 1; // |> + const logical_or: u8 = 2; // or + const logical_and: u8 = 3; // and + const bit_or: u8 = 4; // | + const bit_xor: u8 = 5; // ^ + const bit_and: u8 = 6; // & + const comparison: u8 = 7; // == != < <= > >= in + const shift: u8 = 8; // << >> + const additive: u8 = 9; // + - + const multiplicative: u8 = 10; // * / % }; fn binaryPrec(self: *const Parser) u8 { diff --git a/src/sema.zig b/src/sema.zig index 96a4279..180c698 100644 --- a/src/sema.zig +++ b/src/sema.zig @@ -250,6 +250,9 @@ pub const Analyzer = struct { } self.popScope(); }, + .ufcs_alias => |ua| { + try self.addSymbol(ua.name, .function, null, node.span); + }, else => {}, } } @@ -505,7 +508,7 @@ pub const Analyzer = struct { try self.analyzeNode(val); } }, - .enum_decl, .struct_decl, .union_decl, .array_type_expr, .slice_type_expr, .array_literal, .parameterized_type_expr, .index_expr, .slice_expr, .insert_expr => {}, + .enum_decl, .struct_decl, .union_decl, .array_type_expr, .slice_type_expr, .array_literal, .parameterized_type_expr, .index_expr, .slice_expr, .insert_expr, .ufcs_alias => {}, .namespace_decl => |ns| { try self.pushScope(); for (ns.decls) |d| { @@ -543,7 +546,8 @@ pub const Analyzer = struct { fn addSymbol(self: *Analyzer, name: []const u8, kind: SymbolKind, ty: ?Type, span: Span) !void { // Check for duplicate using the symbol index - if (self.symbol_index.get(name)) |indices| { + // Variables are allowed to shadow in the same scope (sx semantics) + if (kind != .variable) if (self.symbol_index.get(name)) |indices| { const scope_start: u32 = if (self.scope_starts.items.len > 0) self.scope_starts.items[self.scope_starts.items.len - 1] else @@ -561,7 +565,7 @@ pub const Analyzer = struct { } } } - } + }; try self.symbols.append(self.allocator, .{ .name = name, @@ -579,6 +583,11 @@ pub const Analyzer = struct { try gop.value_ptr.append(self.allocator, idx); } + /// Check if a symbol name has been registered. + pub fn hasSymbol(self: *const Analyzer, name: []const u8) bool { + return self.symbol_index.contains(name); + } + /// Pre-register an imported symbol so references in this file can resolve to it. pub fn preRegisterSymbol(self: *Analyzer, sym: Symbol) !void { try self.symbols.append(self.allocator, sym); @@ -710,7 +719,17 @@ pub const Analyzer = struct { }, .for_expr => |fe| { try self.analyzeNode(fe.iterable); + try self.pushScope(); + if (!std.mem.eql(u8, fe.capture_name, "_")) { + try self.addSymbol(fe.capture_name, .variable, null, node.span); + } + if (fe.index_name) |idx_name| { + if (!std.mem.eql(u8, idx_name, "_")) { + try self.addSymbol(idx_name, .variable, .{ .signed = 64 }, node.span); + } + } try self.analyzeNode(fe.body); + self.popScope(); }, .spread_expr => |se| try self.analyzeNode(se.operand), .break_expr, .continue_expr => {}, @@ -784,8 +803,12 @@ pub const Analyzer = struct { .index_expr, .slice_expr, .tuple_type_expr, - .ufcs_alias, => {}, + .ufcs_alias => |ua| { + // Register the alias name as a function and resolve the target + try self.addSymbol(ua.name, .function, null, node.span); + try self.resolveIdentifier(ua.target, node.span); + }, .tuple_literal => |tl| { for (tl.elements) |elem| { try self.analyzeNode(elem.value); @@ -1329,3 +1352,88 @@ test "sema: var_decl infers struct type from parameterized call literal" { } try std.testing.expect(found_list); } + +test "sema: variable shadowing in same scope is allowed" { + const parser_mod = @import("parser.zig"); + + // Two variables with the same name in the same function body — sx allows this + const source = "main :: () { x : s64 = 1; x : f64 = 2.0; }"; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var parser = parser_mod.Parser.init(alloc, source); + const root = try parser.parse(); + + var analyzer = Analyzer.init(alloc); + const result = try analyzer.analyze(root); + + // Should have NO diagnostics — variable shadowing is allowed + for (result.diagnostics) |d| { + if (std.mem.eql(u8, d.message, "duplicate declaration")) { + return error.TestUnexpectedResult; + } + } +} + +test "sema: ufcs_alias registers symbol" { + const parser_mod = @import("parser.zig"); + + const source = "add :: (a: s64, b: s64) -> s64 { a + b; } main :: () { sum :: ufcs add; sum(1, 2); }"; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var parser = parser_mod.Parser.init(alloc, source); + const root = try parser.parse(); + + var analyzer = Analyzer.init(alloc); + const result = try analyzer.analyze(root); + + // `sum` should be registered as a symbol — no "undefined variable" diagnostic + for (result.diagnostics) |d| { + if (std.mem.eql(u8, d.message, "undefined variable")) { + return error.TestUnexpectedResult; + } + } + + // Should find `sum` in symbols + var found_sum = false; + for (result.symbols) |sym| { + if (std.mem.eql(u8, sym.name, "sum")) { + found_sum = true; + try std.testing.expectEqual(SymbolKind.function, sym.kind); + break; + } + } + try std.testing.expect(found_sum); +} + +test "sema: top-level ufcs_alias registers symbol" { + const parser_mod = @import("parser.zig"); + + const source = "add :: (a: s64, b: s64) -> s64 { a + b; } sum :: ufcs add;"; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var parser = parser_mod.Parser.init(alloc, source); + const root = try parser.parse(); + + var analyzer = Analyzer.init(alloc); + const result = try analyzer.analyze(root); + + // No diagnostics + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.len); + + // Should find `sum` as function symbol + var found_sum = false; + for (result.symbols) |sym| { + if (std.mem.eql(u8, sym.name, "sum")) { + found_sum = true; + try std.testing.expectEqual(SymbolKind.function, sym.kind); + break; + } + } + try std.testing.expect(found_sum); +} diff --git a/src/token.zig b/src/token.zig index aa940d5..af1fed4 100644 --- a/src/token.zig +++ b/src/token.zig @@ -67,6 +67,7 @@ pub const Tag = enum { at, // @ pipe, // | pipe_equal, // |= + pipe_arrow, // |> caret, // ^ caret_equal, // ^= tilde, // ~ @@ -134,6 +135,7 @@ pub const Tag = enum { .at => "@", .pipe => "|", .pipe_equal => "|=", + .pipe_arrow => "|>", .caret => "^", .caret_equal => "^=", .tilde => "~", diff --git a/tests/expected/50-smoke.txt b/tests/expected/50-smoke.txt index 6eb2826..da58368 100644 --- a/tests/expected/50-smoke.txt +++ b/tests/expected/50-smoke.txt @@ -356,4 +356,10 @@ false 7 30 hello from testpkg +--- pipe operator --- +7 +30 +14 +hello world +piped ok! === DONE ===