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

@@ -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");
}

View File

@@ -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).

View File

@@ -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);
},
'^' => {

View File

@@ -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;

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) };
}

View File

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

View File

@@ -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);
}

View File

@@ -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 => "~",

View File

@@ -356,4 +356,10 @@ false
7
30
hello from testpkg
--- pipe operator ---
7
30
14
hello world
piped ok!
=== DONE ===