pipes
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
32
specs.md
32
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).
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
'^' => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
116
src/sema.zig
116
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);
|
||||
}
|
||||
|
||||
@@ -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 => "~",
|
||||
|
||||
@@ -356,4 +356,10 @@ false
|
||||
7
|
||||
30
|
||||
hello from testpkg
|
||||
--- pipe operator ---
|
||||
7
|
||||
30
|
||||
14
|
||||
hello world
|
||||
piped ok!
|
||||
=== DONE ===
|
||||
|
||||
Reference in New Issue
Block a user