This commit is contained in:
Luke Boswell 2025-08-13 14:58:19 +10:00
parent d711c5c452
commit 8bbb6d3bb8
No known key found for this signature in database
GPG key ID: 54A7324B1B975757
5 changed files with 1895 additions and 1 deletions

View file

@ -32,4 +32,6 @@ test "compile tests" {
std.testing.refAllDecls(@import("test/int_test.zig"));
std.testing.refAllDecls(@import("test/node_store_test.zig"));
std.testing.refAllDecls(@import("test/import_store_test.zig"));
std.testing.refAllDecls(@import("test/scope_test.zig"));
std.testing.refAllDecls(@import("test/record_test.zig"));
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,289 @@
// test "record literal uses record_unbound" {
// const gpa = std.testing.allocator;
// // Test a simple record literal
// {
// const source1 = "{ x: 42, y: \"hello\" }";
// var common_env = try base.CommonEnv.init(gpa, source1);
// // Module env takes ownership of Common env -- no need to deinit here
// var env = try ModuleEnv.init(gpa, &common_env);
// defer env.deinit();
// try env.initCIRFields(gpa, "test");
// var ast = try parse.parseExpr(&env, gpa);
// defer ast.deinit(gpa);
// var can = try Self.init(&env, &ast, null);
// defer can.deinit();
// const expr_idx: parse.AST.Expr.Idx = @enumFromInt(ast.root_node_idx);
// const canonical_expr_idx = try can.canonicalizeExpr(expr_idx) orelse {
// return error.CanonicalizeError;
// };
// // Get the type of the expression
// const expr_var = @as(types.Var, @enumFromInt(@intFromEnum(canonical_expr_idx.get_idx())));
// const resolved = env.types.resolveVar(expr_var);
// // Check that it's a record_unbound
// switch (resolved.desc.content) {
// .structure => |structure| switch (structure) {
// .record_unbound => |fields| {
// // Success! The record literal created a record_unbound type
// try testing.expect(fields.len() == 2);
// },
// else => return error.ExpectedRecordUnbound,
// },
// else => return error.ExpectedStructure,
// }
// }
// // Test an empty record literal
// {
// const source2 = "{}";
// var env = try ModuleEnv.init(gpa, source2);
// defer env.deinit();
// try env.initCIRFields(gpa, "test");
// var ast = try parse.parseExpr(&env, gpa);
// defer ast.deinit(gpa);
// var can = try Self.init(&env, &ast, null);
// defer can.deinit();
// const expr_idx: parse.AST.Expr.Idx = @enumFromInt(ast.root_node_idx);
// const canonical_expr_idx = try can.canonicalizeExpr(expr_idx) orelse {
// return error.CanonicalizeError;
// };
// // Get the type of the expression
// const expr_var = @as(types.Var, @enumFromInt(@intFromEnum(canonical_expr_idx.get_idx())));
// const resolved = env.types.resolveVar(expr_var);
// // Check that it's an empty_record
// switch (resolved.desc.content) {
// .structure => |structure| switch (structure) {
// .empty_record => {
// // Success! Empty record literal created empty_record type
// },
// else => return error.ExpectedEmptyRecord,
// },
// else => return error.ExpectedStructure,
// }
// }
// // Test a record with a single field
// // Test a nested record literal
// {
// const source3 = "{ value: 123 }";
// var env = try ModuleEnv.init(gpa, source3);
// defer env.deinit();
// try env.initCIRFields(gpa, "test");
// var ast = try parse.parseExpr(&env, gpa, gpa);
// defer ast.deinit(gpa);
// var can = try Self.init(&env, &ast, null);
// defer can.deinit();
// const expr_idx: parse.AST.Expr.Idx = @enumFromInt(ast.root_node_idx);
// const canonical_expr_idx = try can.canonicalizeExpr(expr_idx) orelse {
// return error.CanonicalizeError;
// };
// // Get the type of the expression
// const expr_var = @as(types.Var, @enumFromInt(@intFromEnum(canonical_expr_idx.get_idx())));
// const resolved = env.types.resolveVar(expr_var);
// // Check that it's a record_unbound
// switch (resolved.desc.content) {
// .structure => |structure| switch (structure) {
// .record_unbound => |fields| {
// // Success! The record literal created a record_unbound type
// try testing.expect(fields.len() == 1);
// // Check the field
// const fields_slice = env.types.getRecordFieldsSlice(fields);
// const field_name = env.getIdent(fields_slice.get(0).name);
// try testing.expectEqualStrings("value", field_name);
// },
// else => return error.ExpectedRecordUnbound,
// },
// else => return error.ExpectedStructure,
// }
// }
// }
// test "record_unbound basic functionality" {
// const gpa = std.testing.allocator;
// const source = "{ x: 42, y: 99 }";
// var common_env = try base.CommonEnv.init(gpa, source);
// // Module env takes ownership of Common env -- no need to deinit here
// // Test that record literals create record_unbound types
// var env = try ModuleEnv.init(gpa, &common_env);
// defer env.deinit();
// try env.initCIRFields(gpa, "test");
// var ast = try parse.parseExpr(&common_env, gpa);
// defer ast.deinit(gpa);
// var can = try Self.init(&env, &ast, null);
// defer can.deinit();
// const expr_idx: parse.AST.Expr.Idx = @enumFromInt(ast.root_node_idx);
// const canonical_expr_idx = try can.canonicalizeExpr(expr_idx) orelse {
// return error.CanonicalizeError;
// };
// // Get the type of the expression
// const expr_var = @as(types.Var, @enumFromInt(@intFromEnum(canonical_expr_idx.get_idx())));
// const resolved = env.types.resolveVar(expr_var);
// // Verify it starts as record_unbound
// switch (resolved.desc.content) {
// .structure => |structure| switch (structure) {
// .record_unbound => |fields| {
// // Success! Record literal created record_unbound type
// try testing.expect(fields.len() == 2);
// // Check field names
// const field_slice = env.types.getRecordFieldsSlice(fields);
// try testing.expectEqualStrings("x", env.getIdent(field_slice.get(0).name));
// try testing.expectEqualStrings("y", env.getIdent(field_slice.get(1).name));
// },
// else => return error.ExpectedRecordUnbound,
// },
// else => return error.ExpectedStructure,
// }
// }
// test "record_unbound with multiple fields" {
// const gpa = std.testing.allocator;
// const source = "{ a: 123, b: 456, c: 789 }";
// var common_env = try base.CommonEnv.init(gpa, source);
// // Module env takes ownership of Common env -- no need to deinit here
// var env = try ModuleEnv.init(gpa, &common_env);
// defer env.deinit();
// try env.initCIRFields(gpa, "test");
// // Create record_unbound with multiple fields
// var ast = try parse.parseExpr(&common_env, gpa);
// defer ast.deinit(gpa);
// var can = try Self.init(&env, &ast, null);
// defer can.deinit();
// const expr_idx: parse.AST.Expr.Idx = @enumFromInt(ast.root_node_idx);
// const canonical_expr_idx = try can.canonicalizeExpr(expr_idx) orelse {
// return error.CanonicalizeError;
// };
// const expr_var = @as(types.Var, @enumFromInt(@intFromEnum(canonical_expr_idx.get_idx())));
// const resolved = env.types.resolveVar(expr_var);
// // Should be record_unbound
// switch (resolved.desc.content) {
// .structure => |s| switch (s) {
// .record_unbound => |fields| {
// try testing.expect(fields.len() == 3);
// // Check field names
// const field_slice = env.types.getRecordFieldsSlice(fields);
// try testing.expectEqualStrings("a", env.getIdent(field_slice.get(0).name));
// try testing.expectEqualStrings("b", env.getIdent(field_slice.get(1).name));
// try testing.expectEqualStrings("c", env.getIdent(field_slice.get(2).name));
// },
// else => return error.ExpectedRecordUnbound,
// },
// else => return error.ExpectedStructure,
// }
// }
// test "record with extension variable" {
// const gpa = std.testing.allocator;
// var common_env = try base.CommonEnv.init(gpa, "");
// // Module env takes ownership of Common env -- no need to deinit here
// var env = try ModuleEnv.init(gpa, &common_env);
// defer env.deinit();
// try env.initCIRFields(gpa, "test");
// // Test that regular records have extension variables
// // Create { x: 42, y: "hi" }* (open record)
// const num_var = try env.types.freshFromContent(Content{ .structure = .{ .num = .{ .int_precision = .i32 } } });
// const str_var = try env.types.freshFromContent(Content{ .structure = .str });
// const fields = [_]types.RecordField{
// .{ .name = try env.insertIdent( base.Ident.for_text("x")), .var_ = num_var },
// .{ .name = try env.insertIdent( base.Ident.for_text("y")), .var_ = str_var },
// };
// const fields_range = try env.types.appendRecordFields(&fields);
// const ext_var = try env.types.fresh(); // Open extension
// const record_content = Content{ .structure = .{ .record = .{ .fields = fields_range, .ext = ext_var } } };
// const record_var = try env.types.freshFromContent(record_content);
// // Verify the record has an extension variable
// const resolved = env.types.resolveVar(record_var);
// switch (resolved.desc.content) {
// .structure => |structure| switch (structure) {
// .record => |record| {
// try testing.expect(record.fields.len() == 2);
// // Check that extension is a flex var (open record)
// const ext_resolved = env.types.resolveVar(record.ext);
// switch (ext_resolved.desc.content) {
// .flex_var => {
// // Success! The record has an open extension
// },
// else => return error.ExpectedFlexVar,
// }
// },
// else => return error.ExpectedRecord,
// },
// else => return error.ExpectedStructure,
// }
// // Now test a closed record
// const closed_ext_var = try env.types.freshFromContent(Content{ .structure = .empty_record });
// const closed_record_content = Content{ .structure = .{ .record = .{ .fields = fields_range, .ext = closed_ext_var } } };
// const closed_record_var = try env.types.freshFromContent(closed_record_content);
// // Verify the closed record has empty_record as extension
// const closed_resolved = env.types.resolveVar(closed_record_var);
// switch (closed_resolved.desc.content) {
// .structure => |structure| switch (structure) {
// .record => |record| {
// try testing.expect(record.fields.len() == 2);
// // Check that extension is empty_record (closed record)
// const ext_resolved = env.types.resolveVar(record.ext);
// switch (ext_resolved.desc.content) {
// .structure => |ext_structure| switch (ext_structure) {
// .empty_record => {
// // Success! The record is closed
// },
// else => return error.ExpectedEmptyRecord,
// },
// else => return error.ExpectedStructure,
// }
// },
// else => return error.ExpectedRecord,
// },
// else => return error.ExpectedStructure,
// }
// }

View file

@ -0,0 +1,349 @@
const std = @import("std");
const can = @import("can");
const base = @import("base");
const parse = @import("parse");
const CIR = @import("../CIR.zig");
const ModuleEnv = can.ModuleEnv;
const Ident = base.Ident;
const Region = base.Region;
const Scope = CIR.Scope;
const Pattern = CIR.Pattern;
const TypeAnno = CIR.TypeAnno;
/// Context helper for Scope tests
const ScopeTestContext = struct {
self: CIR,
module_env: *ModuleEnv,
gpa: std.mem.Allocator,
fn init(gpa: std.mem.Allocator) !ScopeTestContext {
// heap allocate ModuleEnv for testing
const module_env = try gpa.create(ModuleEnv);
module_env.* = try ModuleEnv.init(gpa, "");
try module_env.initCIRFields(gpa, "test");
return ScopeTestContext{
.self = try CIR.init(module_env, undefined, null),
.module_env = module_env,
.gpa = gpa,
};
}
fn deinit(ctx: *ScopeTestContext) void {
ctx.self.deinit();
ctx.module_env.deinit();
ctx.gpa.destroy(ctx.module_env);
}
};
test "basic scope initialization" {
const gpa = std.testing.allocator;
var ctx = try ScopeTestContext.init(gpa);
defer ctx.deinit();
// Test that we start with one scope (top-level)
try std.testing.expect(ctx.self.scopes.items.len == 1);
}
test "empty scope has no items" {
const gpa = std.testing.allocator;
var ctx = try ScopeTestContext.init(gpa);
defer ctx.deinit();
const foo_ident = try ctx.module_env.insertIdent(Ident.for_text("foo"));
const result = ctx.self.scopeLookup(.ident, foo_ident);
try std.testing.expectEqual(Scope.LookupResult{ .not_found = {} }, result);
}
test "can add and lookup idents at top level" {
const gpa = std.testing.allocator;
var ctx = try ScopeTestContext.init(gpa);
defer ctx.deinit();
const foo_ident = try ctx.module_env.insertIdent(Ident.for_text("foo"));
const bar_ident = try ctx.module_env.insertIdent(Ident.for_text("bar"));
const foo_pattern: Pattern.Idx = @enumFromInt(1);
const bar_pattern: Pattern.Idx = @enumFromInt(2);
// Add identifiers
const foo_result = ctx.self.scopeIntroduceInternal(gpa, .ident, foo_ident, foo_pattern, false, true);
const bar_result = ctx.self.scopeIntroduceInternal(gpa, .ident, bar_ident, bar_pattern, false, true);
try std.testing.expectEqual(Scope.IntroduceResult{ .success = {} }, foo_result);
try std.testing.expectEqual(Scope.IntroduceResult{ .success = {} }, bar_result);
// Lookup should find them
const foo_lookup = ctx.self.scopeLookup(.ident, foo_ident);
const bar_lookup = ctx.self.scopeLookup(.ident, bar_ident);
try std.testing.expectEqual(Scope.LookupResult{ .found = foo_pattern }, foo_lookup);
try std.testing.expectEqual(Scope.LookupResult{ .found = bar_pattern }, bar_lookup);
}
test "nested scopes shadow outer scopes" {
const gpa = std.testing.allocator;
var ctx = try ScopeTestContext.init(gpa);
defer ctx.deinit();
const x_ident = try ctx.module_env.insertIdent(Ident.for_text("x"));
const outer_pattern: Pattern.Idx = @enumFromInt(1);
const inner_pattern: Pattern.Idx = @enumFromInt(2);
// Add x to outer scope
const outer_result = ctx.self.scopeIntroduceInternal(gpa, .ident, x_ident, outer_pattern, false, true);
try std.testing.expectEqual(Scope.IntroduceResult{ .success = {} }, outer_result);
// Enter new scope
try ctx.self.scopeEnter(gpa, false);
// x from outer scope should still be visible
const outer_lookup = ctx.self.scopeLookup(.ident, x_ident);
try std.testing.expectEqual(Scope.LookupResult{ .found = outer_pattern }, outer_lookup);
// Add x to inner scope (shadows outer)
const inner_result = ctx.self.scopeIntroduceInternal(gpa, .ident, x_ident, inner_pattern, false, true);
try std.testing.expectEqual(Scope.IntroduceResult{ .shadowing_warning = outer_pattern }, inner_result);
// Now x should resolve to inner scope
const inner_lookup = ctx.self.scopeLookup(.ident, x_ident);
try std.testing.expectEqual(Scope.LookupResult{ .found = inner_pattern }, inner_lookup);
// Exit inner scope
try ctx.self.scopeExit(gpa);
// x should resolve to outer scope again
const after_exit_lookup = ctx.self.scopeLookup(.ident, x_ident);
try std.testing.expectEqual(Scope.LookupResult{ .found = outer_pattern }, after_exit_lookup);
}
test "top level var error" {
const gpa = std.testing.allocator;
var ctx = try ScopeTestContext.init(gpa);
defer ctx.deinit();
const var_ident = try ctx.module_env.insertIdent(Ident.for_text("count_"));
const pattern: Pattern.Idx = @enumFromInt(1);
// Should fail to introduce var at top level
const result = ctx.self.scopeIntroduceInternal(gpa, .ident, var_ident, pattern, true, true);
try std.testing.expectEqual(Scope.IntroduceResult{ .top_level_var_error = {} }, result);
}
test "type variables are tracked separately from value identifiers" {
const gpa = std.testing.allocator;
var ctx = try ScopeTestContext.init(gpa);
defer ctx.deinit();
// Create identifiers for 'a' - one for value, one for type
const a_ident = try ctx.module_env.insertIdent(Ident.for_text("a"));
const pattern: Pattern.Idx = @enumFromInt(1);
const type_anno: TypeAnno.Idx = @enumFromInt(1);
// Introduce 'a' as a value identifier
const value_result = ctx.self.scopeIntroduceInternal(gpa, .ident, a_ident, pattern, false, true);
try std.testing.expectEqual(Scope.IntroduceResult{ .success = {} }, value_result);
// Introduce 'a' as a type variable - should succeed because they're in separate namespaces
const current_scope = &ctx.self.scopes.items[ctx.self.scopes.items.len - 1];
const type_result = current_scope.introduceTypeVar(gpa, a_ident, type_anno, null);
try std.testing.expectEqual(Scope.TypeVarIntroduceResult{ .success = {} }, type_result);
// Lookup 'a' as value should find the pattern
const value_lookup = ctx.self.scopeLookup(.ident, a_ident);
try std.testing.expectEqual(Scope.LookupResult{ .found = pattern }, value_lookup);
// Lookup 'a' as type variable should find the type annotation
const type_lookup = current_scope.lookupTypeVar(a_ident);
try std.testing.expectEqual(Scope.TypeVarLookupResult{ .found = type_anno }, type_lookup);
}
test "var reassignment within same function" {
const gpa = std.testing.allocator;
var ctx = try ScopeTestContext.init(gpa);
defer ctx.deinit();
// Enter function scope
try ctx.self.scopeEnter(gpa, true);
const count_ident = try ctx.module_env.insertIdent(Ident.for_text("count_"));
const pattern1: Pattern.Idx = @enumFromInt(1);
const pattern2: Pattern.Idx = @enumFromInt(2);
// Declare var
const declare_result = ctx.self.scopeIntroduceInternal(gpa, .ident, count_ident, pattern1, true, true);
try std.testing.expectEqual(Scope.IntroduceResult{ .success = {} }, declare_result);
// Reassign var (not a declaration)
const reassign_result = ctx.self.scopeIntroduceInternal(gpa, .ident, count_ident, pattern2, true, false);
try std.testing.expectEqual(Scope.IntroduceResult{ .success = {} }, reassign_result);
// Should resolve to the reassigned value
const lookup_result = ctx.self.scopeLookup(.ident, count_ident);
try std.testing.expectEqual(Scope.LookupResult{ .found = pattern2 }, lookup_result);
}
test "var reassignment across function boundary fails" {
const gpa = std.testing.allocator;
var ctx = try ScopeTestContext.init(gpa);
defer ctx.deinit();
// Enter first function scope
try ctx.self.scopeEnter(gpa, true);
const count_ident = try ctx.module_env.insertIdent(Ident.for_text("count_"));
const pattern1: Pattern.Idx = @enumFromInt(1);
const pattern2: Pattern.Idx = @enumFromInt(2);
// Declare var in first function
const declare_result = ctx.self.scopeIntroduceInternal(gpa, .ident, count_ident, pattern1, true, true);
try std.testing.expectEqual(Scope.IntroduceResult{ .success = {} }, declare_result);
// Enter second function scope (function boundary)
try ctx.self.scopeEnter(gpa, true);
// Try to reassign var from different function - should fail
const reassign_result = ctx.self.scopeIntroduceInternal(gpa, .ident, count_ident, pattern2, true, false);
try std.testing.expectEqual(Scope.IntroduceResult{ .var_across_function_boundary = pattern1 }, reassign_result);
}
test "identifiers with and without underscores are different" {
const gpa = std.testing.allocator;
var ctx = try ScopeTestContext.init(gpa);
defer ctx.deinit();
const sum_ident = try ctx.module_env.insertIdent(Ident.for_text("sum"));
const sum_underscore_ident = try ctx.module_env.insertIdent(Ident.for_text("sum_"));
const pattern1: Pattern.Idx = @enumFromInt(1);
const pattern2: Pattern.Idx = @enumFromInt(2);
// Enter function scope so we can use var
try ctx.self.scopeEnter(gpa, true);
// Introduce regular identifier
const regular_result = ctx.self.scopeIntroduceInternal(gpa, .ident, sum_ident, pattern1, false, true);
try std.testing.expectEqual(Scope.IntroduceResult{ .success = {} }, regular_result);
// Introduce var with underscore - should not conflict
const var_result = ctx.self.scopeIntroduceInternal(gpa, .ident, sum_underscore_ident, pattern2, true, true);
try std.testing.expectEqual(Scope.IntroduceResult{ .success = {} }, var_result);
// Both should be found independently
const regular_lookup = ctx.self.scopeLookup(.ident, sum_ident);
const var_lookup = ctx.self.scopeLookup(.ident, sum_underscore_ident);
try std.testing.expectEqual(Scope.LookupResult{ .found = pattern1 }, regular_lookup);
try std.testing.expectEqual(Scope.LookupResult{ .found = pattern2 }, var_lookup);
}
test "aliases work separately from idents" {
const gpa = std.testing.allocator;
var ctx = try ScopeTestContext.init(gpa);
defer ctx.deinit();
const foo_ident = try ctx.module_env.insertIdent(Ident.for_text("Foo"));
const ident_pattern: Pattern.Idx = @enumFromInt(1);
const alias_pattern: Pattern.Idx = @enumFromInt(2);
// Add as both ident and alias (they're in separate namespaces)
const ident_result = ctx.self.scopeIntroduceInternal(gpa, .ident, foo_ident, ident_pattern, false, true);
const alias_result = ctx.self.scopeIntroduceInternal(gpa, .alias, foo_ident, alias_pattern, false, true);
try std.testing.expectEqual(Scope.IntroduceResult{ .success = {} }, ident_result);
try std.testing.expectEqual(Scope.IntroduceResult{ .success = {} }, alias_result);
// Both should be found in their respective namespaces
const ident_lookup = ctx.self.scopeLookup(.ident, foo_ident);
const alias_lookup = ctx.self.scopeLookup(.alias, foo_ident);
try std.testing.expectEqual(Scope.LookupResult{ .found = ident_pattern }, ident_lookup);
try std.testing.expectEqual(Scope.LookupResult{ .found = alias_pattern }, alias_lookup);
}
test "unused variables are sorted by region" {
const gpa = std.testing.allocator;
// Create a test program with unused variables in non-alphabetical order
const source =
\\app [main!] { pf: platform "../basic-cli/main.roc" }
\\
\\func = |_| {
\\ zebra = 5 # Line 3 - should be reported first
\\ apple = 10 # Line 4 - should be reported second
\\ monkey = 15 # Line 5 - should be reported third
\\ used = 20 # Line 6 - this one is used
\\ used
\\}
\\
\\main! = |_| func({})
;
var ctx = try ScopeTestContext.init(gpa);
defer ctx.deinit();
// Parse the source
const ast = try parse.AST.parseFromStr(gpa, source, "test.roc", &ctx.module_env.string_interner);
defer ast.deinit();
// Canonicalize the AST
const parsed_module = ast.parsed_module;
var self = try CIR.initFromAST(parsed_module, &ctx.module_env, source);
try self.canonicalizeModule();
defer self.deinit();
// Check that we have unused variable diagnostics
var unused_var_diagnostics = std.ArrayList(struct {
ident: Ident.Idx,
region: Region,
}).init(gpa);
defer unused_var_diagnostics.deinit();
// Collect all unused variable diagnostics
for (ctx.module_env.diagnostics.items) |diagnostic| {
switch (diagnostic) {
.unused_variable => |data| {
try unused_var_diagnostics.append(.{
.ident = data.ident,
.region = data.region,
});
},
else => continue,
}
}
// We should have exactly 3 unused variables (zebra, apple, monkey)
try std.testing.expectEqual(@as(usize, 3), unused_var_diagnostics.items.len);
// Check that they are sorted by region (line number)
// The source positions should be in increasing order
var prev_offset: u32 = 0;
for (unused_var_diagnostics.items) |diagnostic| {
const current_offset = diagnostic.region.start.offset;
// Each unused variable should appear after the previous one in the source
try std.testing.expect(current_offset > prev_offset);
prev_offset = current_offset;
// Also verify the names are in the expected order (zebra, apple, monkey)
const ident_text = ctx.module_env.getIdent(diagnostic.ident);
if (unused_var_diagnostics.items[0].ident.idx == diagnostic.ident.idx) {
try std.testing.expectEqualStrings("zebra", ident_text);
} else if (unused_var_diagnostics.items[1].ident.idx == diagnostic.ident.idx) {
try std.testing.expectEqualStrings("apple", ident_text);
} else if (unused_var_diagnostics.items[2].ident.idx == diagnostic.ident.idx) {
try std.testing.expectEqualStrings("monkey", ident_text);
}
}
}

View file

@ -913,7 +913,6 @@ test "SafeList edge cases serialization" {
}
}
// TODO FIXME
test "SafeList CompactWriter verify offset calculation" {
const gpa = testing.allocator;