Merge pull request #8429 from roc-lang/fix-numeral-bug

Fix `expect` bug
This commit is contained in:
Richard Feldman 2025-11-24 22:46:03 -05:00 committed by GitHub
commit 6a0ef40287
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 330 additions and 24 deletions

View file

@ -1022,6 +1022,43 @@ pub fn checkFile(self: *Self) std.mem.Allocator.Error!void {
try self.checkDef(def_idx, &env);
}
// Finally, type-check top-level statements (like expect)
// These are separate from defs and need to be checked after all defs are processed
// so that lookups can find their definitions
for (stmts_slice) |stmt_idx| {
const stmt = self.cir.store.getStatement(stmt_idx);
const stmt_var = ModuleEnv.varFrom(stmt_idx);
const stmt_region = self.cir.store.getNodeRegion(ModuleEnv.nodeIdxFrom(stmt_idx));
switch (stmt) {
.s_expect => |expr_stmt| {
env.reset();
// Enter a new rank for this expect
try env.var_pool.pushRank();
defer env.var_pool.popRank();
// Check the body expression
_ = try self.checkExpr(expr_stmt.body, &env, .no_expectation);
const body_var: Var = ModuleEnv.varFrom(expr_stmt.body);
// Unify with Bool (expects must be bool expressions)
const bool_var = try self.freshBool(&env, stmt_region);
_ = try self.unify(bool_var, body_var, &env);
// Unify statement var with body var
_ = try self.unify(stmt_var, body_var, &env);
// Generalize and check deferred constraints
try self.generalizer.generalize(self.gpa, &env.var_pool, env.rank());
try self.checkDeferredStaticDispatchConstraints(&env);
},
else => {
// Other statement types are handled elsewhere (type decls, defs, etc.)
},
}
}
// Note that we can't use SCCs to determine the order to resolve defs
// because anonymous static dispatch makes function order not knowable
// before type inference

View file

@ -133,3 +133,70 @@ test "infers type for octal literals" {
try test_env.assertLastDefTypeContains("from_numeral");
}
}
test "numeric literal in comparison unifies with typed operand" {
// When comparing a typed variable with a numeric literal,
// the literal should unify to match the variable's type.
// `answer == 42` desugars to `answer.is_eq(42)`, which dispatches to I64.is_eq(answer, 42),
// which should unify 42's flex var with I64.
const source =
\\answer : I64
\\answer = 42
\\
\\result = answer == 42
;
var test_env = try TestEnv.init("Test", source);
defer test_env.deinit();
// First verify no type errors
try test_env.assertNoErrors();
// Verify that `answer` has type I64
try test_env.assertDefType("answer", "I64");
// Verify that `result` has type Bool (the result of ==)
try test_env.assertDefType("result", "Bool");
// Now verify that the binop expression's operands both have I64 type
// Find the `result` definition and check the binop's operand types
const ModuleEnv = @import("can").ModuleEnv;
const defs_slice = test_env.module_env.store.sliceDefs(test_env.module_env.all_defs);
var found_result = false;
for (defs_slice) |def_idx| {
const def = test_env.module_env.store.getDef(def_idx);
const ptrn = test_env.module_env.store.getPattern(def.pattern);
if (ptrn == .assign) {
const def_name = test_env.module_env.getIdentStoreConst().getText(ptrn.assign.ident);
if (std.mem.eql(u8, def_name, "result")) {
found_result = true;
// Get the expression - should be a binop
const expr = test_env.module_env.store.getExpr(def.expr);
try testing.expect(expr == .e_binop);
const binop = expr.e_binop;
// Check LHS type (should be I64)
const lhs_var = ModuleEnv.varFrom(binop.lhs);
try test_env.type_writer.write(lhs_var);
const lhs_type = test_env.type_writer.get();
try testing.expectEqualStrings("I64", lhs_type);
// Check RHS type (the literal 42 - should also be I64 after unification)
const rhs_var = ModuleEnv.varFrom(binop.rhs);
try test_env.type_writer.write(rhs_var);
const rhs_type = test_env.type_writer.get();
try testing.expectEqualStrings("I64", rhs_type);
// Verify that the RHS type var is actually resolved to a nominal type, not flex
// This is what the interpreter's translateTypeVar should see
const rhs_resolved = test_env.module_env.types.resolveVar(rhs_var);
// After type checking, the RHS (numeric literal) should be unified to I64,
// which is a nominal type (structure.nominal_type), NOT a flex var
try testing.expect(rhs_resolved.desc.content == .structure);
try testing.expect(rhs_resolved.desc.content.structure == .nominal_type);
break;
}
}
}
try testing.expect(found_result);
}

View file

@ -2744,15 +2744,31 @@ fn rocTest(allocs: *Allocators, args: cli_args.TestArgs) !void {
env.module_name = module_name;
try env.common.calcLineStarts(allocs.gpa);
// Load builtin modules required by the type checker and interpreter
const builtin_indices = builtin_loading.deserializeBuiltinIndices(allocs.gpa, compiled_builtins.builtin_indices_bin) catch |err| {
try stderr.print("Failed to deserialize builtin indices: {}\n", .{err});
return err;
};
const builtin_source = compiled_builtins.builtin_source;
var builtin_module = builtin_loading.loadCompiledModule(allocs.gpa, compiled_builtins.builtin_bin, "Builtin", builtin_source) catch |err| {
try stderr.print("Failed to load Builtin module: {}\n", .{err});
return err;
};
defer builtin_module.deinit();
// Populate module_envs with Bool, Try, Dict, Set from builtin module
var module_envs = std.AutoHashMap(base.Ident.Idx, Can.AutoImportedType).init(allocs.gpa);
defer module_envs.deinit();
const module_common_idents: Check.CommonIdents = .{
.module_name = try env.insertIdent(base.Ident.for_text(module_name)),
.list = try env.insertIdent(base.Ident.for_text("List")),
.box = try env.insertIdent(base.Ident.for_text("Box")),
.@"try" = try env.insertIdent(base.Ident.for_text("Try")),
.bool_stmt = @enumFromInt(0), // TODO: load from builtin modules
.try_stmt = @enumFromInt(0), // TODO: load from builtin modules
.str_stmt = @enumFromInt(0), // TODO: load from builtin modules
.builtin_module = null,
.bool_stmt = builtin_indices.bool_type,
.try_stmt = builtin_indices.try_type,
.str_stmt = builtin_indices.str_type,
.builtin_module = builtin_module.env,
};
// Parse the source code as a full module
@ -2768,8 +2784,16 @@ fn rocTest(allocs: *Allocators, args: cli_args.TestArgs) !void {
// Initialize CIR fields in ModuleEnv
try env.initCIRFields(allocs.gpa, module_name);
// Populate module_envs with Bool, Try, Dict, Set using shared function
try Can.populateModuleEnvs(
&module_envs,
&env,
builtin_module.env,
builtin_indices,
);
// Create canonicalizer
var canonicalizer = Can.init(&env, &parse_ast, null) catch |err| {
var canonicalizer = Can.init(&env, &parse_ast, &module_envs) catch |err| {
try stderr.print("Failed to initialize canonicalizer: {}\n", .{err});
return err;
};
@ -2787,8 +2811,11 @@ fn rocTest(allocs: *Allocators, args: cli_args.TestArgs) !void {
return err;
};
// Build imported_envs array with builtin module
const imported_envs: []const *const ModuleEnv = &.{builtin_module.env};
// Type check the module
var checker = Check.init(allocs.gpa, &env.types, &env, &.{}, null, &env.store.regions, module_common_idents) catch |err| {
var checker = Check.init(allocs.gpa, &env.types, &env, imported_envs, &module_envs, &env.store.regions, module_common_idents) catch |err| {
try stderr.print("Failed to initialize type checker: {}\n", .{err});
return err;
};
@ -2800,20 +2827,8 @@ fn rocTest(allocs: *Allocators, args: cli_args.TestArgs) !void {
};
// Evaluate all top-level declarations at compile time
// Load builtin modules required by the interpreter
const builtin_indices = builtin_loading.deserializeBuiltinIndices(allocs.gpa, compiled_builtins.builtin_indices_bin) catch |err| {
try stderr.print("Failed to deserialize builtin indices: {}\n", .{err});
return err;
};
const builtin_source = compiled_builtins.builtin_source;
var builtin_module = builtin_loading.loadCompiledModule(allocs.gpa, compiled_builtins.builtin_bin, "Builtin", builtin_source) catch |err| {
try stderr.print("Failed to load Builtin module: {}\n", .{err});
return err;
};
defer builtin_module.deinit();
const builtin_types_for_eval = BuiltinTypes.init(builtin_indices, builtin_module.env, builtin_module.env, builtin_module.env);
var comptime_evaluator = eval.ComptimeEvaluator.init(allocs.gpa, &env, &.{}, &checker.problems, builtin_types_for_eval, builtin_module.env) catch |err| {
var comptime_evaluator = eval.ComptimeEvaluator.init(allocs.gpa, &env, imported_envs, &checker.problems, builtin_types_for_eval, builtin_module.env) catch |err| {
try stderr.print("Failed to create compile-time evaluator: {}\n", .{err});
return err;
};
@ -2826,7 +2841,7 @@ fn rocTest(allocs: *Allocators, args: cli_args.TestArgs) !void {
};
// Create test runner infrastructure for test evaluation (reuse builtin_types_for_eval from above)
var test_runner = TestRunner.init(allocs.gpa, &env, builtin_types_for_eval) catch |err| {
var test_runner = TestRunner.init(allocs.gpa, &env, builtin_types_for_eval, imported_envs, builtin_module.env) catch |err| {
try stderr.print("Failed to create test runner: {}\n", .{err});
return err;
};

View file

@ -184,3 +184,83 @@ test "fx platform stdin simple" {
// stdin_simple reads from stdin and prints to stderr
try testing.expect(std.mem.indexOf(u8, result.stderr, "simple test") != null);
}
test "fx platform expect with main" {
const allocator = testing.allocator;
try ensureRocBinary(allocator);
// Run `roc test` on the app that has both main! and an expect
// Note: `roc test` only evaluates expect statements, it does not run main!
const run_result = try std.process.Child.run(.{
.allocator = allocator,
.argv = &[_][]const u8{
"./zig-out/bin/roc",
"test",
"test/fx/expect_with_main.roc",
},
});
defer allocator.free(run_result.stdout);
defer allocator.free(run_result.stderr);
switch (run_result.term) {
.Exited => |code| {
if (code != 0) {
std.debug.print("Run failed with exit code {}\n", .{code});
std.debug.print("STDOUT: {s}\n", .{run_result.stdout});
std.debug.print("STDERR: {s}\n", .{run_result.stderr});
return error.RunFailed;
}
},
else => {
std.debug.print("Run terminated abnormally: {}\n", .{run_result.term});
std.debug.print("STDOUT: {s}\n", .{run_result.stdout});
std.debug.print("STDERR: {s}\n", .{run_result.stderr});
return error.RunFailed;
},
}
// When all tests pass without --verbose, roc test produces no output
try testing.expectEqualStrings("", run_result.stdout);
try testing.expectEqualStrings("", run_result.stderr);
}
test "fx platform expect with numeric literal" {
const allocator = testing.allocator;
try ensureRocBinary(allocator);
// Run `roc test` on an app that compares a typed variable with a numeric literal
// This tests that numeric literals in top-level expects are properly typed
const run_result = try std.process.Child.run(.{
.allocator = allocator,
.argv = &[_][]const u8{
"./zig-out/bin/roc",
"test",
"test/fx/expect_with_literal.roc",
},
});
defer allocator.free(run_result.stdout);
defer allocator.free(run_result.stderr);
switch (run_result.term) {
.Exited => |code| {
if (code != 0) {
std.debug.print("Run failed with exit code {}\n", .{code});
std.debug.print("STDOUT: {s}\n", .{run_result.stdout});
std.debug.print("STDERR: {s}\n", .{run_result.stderr});
return error.RunFailed;
}
},
else => {
std.debug.print("Run terminated abnormally: {}\n", .{run_result.term});
std.debug.print("STDOUT: {s}\n", .{run_result.stdout});
std.debug.print("STDERR: {s}\n", .{run_result.stderr});
return error.RunFailed;
},
}
// When all tests pass without --verbose, roc test produces no output
try testing.expectEqualStrings("", run_result.stdout);
try testing.expectEqualStrings("", run_result.stderr);
}

View file

@ -5603,6 +5603,7 @@ pub const Interpreter = struct {
) Error!StackValue {
const lhs_ct_var = can.ModuleEnv.varFrom(lhs_expr);
const lhs_rt_var = try self.translateTypeVar(self.env, lhs_ct_var);
const rhs_ct_var = can.ModuleEnv.varFrom(rhs_expr);
const rhs_rt_var = try self.translateTypeVar(self.env, rhs_ct_var);
@ -5621,6 +5622,7 @@ pub const Interpreter = struct {
// Evaluate both operands
var lhs = try self.evalExprMinimal(lhs_expr, roc_ops, lhs_rt_var);
defer lhs.decref(&self.runtime_layout_store, roc_ops);
var rhs = try self.evalExprMinimal(rhs_expr, roc_ops, rhs_rt_var);
defer rhs.decref(&self.runtime_layout_store, roc_ops);

View file

@ -141,11 +141,13 @@ pub const TestRunner = struct {
allocator: std.mem.Allocator,
cir: *ModuleEnv,
builtin_types_param: BuiltinTypes,
other_modules: []const *const can.ModuleEnv,
builtin_module_env: ?*const can.ModuleEnv,
) !TestRunner {
return TestRunner{
.allocator = allocator,
.env = cir,
.interpreter = try Interpreter.init(allocator, cir, builtin_types_param, null, &[_]*const can.ModuleEnv{}),
.interpreter = try Interpreter.init(allocator, cir, builtin_types_param, builtin_module_env, other_modules),
.crash = CrashContext.init(allocator),
.roc_ops = null,
.test_results = std.array_list.Managed(TestResult).init(allocator),

View file

@ -133,6 +133,7 @@ const CompilerStageData = struct {
solver: ?Check = null,
bool_stmt: ?can.CIR.Statement.Idx = null,
builtin_types: ?eval.BuiltinTypes = null,
builtin_module: ?BuiltinModule = null,
// Pre-canonicalization HTML representations
tokens_html: ?[]const u8 = null,
@ -145,6 +146,18 @@ const CompilerStageData = struct {
can_reports: std.array_list.Managed(reporting.Report),
type_reports: std.array_list.Managed(reporting.Report),
const BuiltinModule = struct {
env: *ModuleEnv,
buffer: []align(collections.CompactWriter.SERIALIZATION_ALIGNMENT.toByteUnits()) u8,
gpa: Allocator,
fn deinit(self: *@This()) void {
self.env.imports.map.deinit(self.gpa);
self.gpa.free(self.buffer);
self.gpa.destroy(self.env);
}
};
pub fn init(alloc: Allocator, module_env: *ModuleEnv) CompilerStageData {
return CompilerStageData{
.module_env = module_env,
@ -195,6 +208,11 @@ const CompilerStageData = struct {
// Finally, deinit the ModuleEnv and free its memory
self.module_env.deinit();
allocator.destroy(self.module_env);
// Deinit the builtin module if it was loaded
if (self.builtin_module) |*bm| {
bm.deinit();
}
}
};
@ -1001,8 +1019,13 @@ fn compileSource(source: []const u8) !CompilerStageData {
logDebug("compileSource: Loading Builtin module\n", .{});
const builtin_source = compiled_builtins.builtin_source;
var builtin_module = try LoadedModule.loadCompiledModule(allocator, compiled_builtins.builtin_bin, "Builtin", builtin_source);
defer builtin_module.deinit();
const builtin_module = try LoadedModule.loadCompiledModule(allocator, compiled_builtins.builtin_bin, "Builtin", builtin_source);
// Store in result instead of deferring deinit - we need it for test evaluation
result.builtin_module = .{
.env = builtin_module.env,
.buffer = builtin_module.buffer,
.gpa = builtin_module.gpa,
};
logDebug("compileSource: Builtin module loaded\n", .{});
// Get builtin statement indices from the builtin module
@ -1575,7 +1598,9 @@ fn writeEvaluateTestsResponse(response_buffer: []u8, data: CompilerStageData) Re
};
// Create interpreter infrastructure for test evaluation
var test_runner = TestRunner.init(local_arena.allocator(), env, builtin_types_for_tests) catch {
const empty_modules: []const *const ModuleEnv = &.{};
const builtin_module_env: ?*const ModuleEnv = if (data.builtin_module) |bm| bm.env else null;
var test_runner = TestRunner.init(local_arena.allocator(), env, builtin_types_for_tests, empty_modules, builtin_module_env) catch {
try writeErrorResponse(response_buffer, .ERROR, "Failed to initialize test runner.");
return;
};

View file

@ -0,0 +1,14 @@
app [main!] { pf: platform "./platform/main.roc" }
import pf.Stdout
import pf.Stderr
main! = || {
Stdout.line!("done")
}
answer : I64
answer = 42
# This tests that numeric literals in expect are properly typed
expect answer == 42

View file

@ -0,0 +1,16 @@
app [main!] { pf: platform "./platform/main.roc" }
import pf.Stdout
import pf.Stderr
main! = || {
Stdout.line!("done")
}
answer : I64
answer = 42
fortytwo : I64
fortytwo = 42
expect answer == fortytwo

View file

@ -197,6 +197,7 @@ UNUSED VALUE - fuzz_crash_019.md:86:11:86:17
UNUSED VALUE - fuzz_crash_019.md:98:4:104:3
UNUSED VALUE - fuzz_crash_019.md:105:2:105:54
UNUSED VALUE - fuzz_crash_019.md:105:55:105:85
UNUSED VALUE - fuzz_crash_019.md:119:2:119:10
# PROBLEMS
**PARSE ERROR**
A parsing error occurred: `match_branch_missing_arrow`
@ -998,6 +999,17 @@ This expression produces a value, but it's not being used:
It has the type:
__f_
**UNUSED VALUE**
This expression produces a value, but it's not being used:
**fuzz_crash_019.md:119:2:119:10:**
```roc
foo == 1
```
^^^^^^^^
It has the type:
_Bool_
# TOKENS
~~~zig
KwApp,OpenSquare,LowerIdent,CloseSquare,OpenCurly,LowerIdent,OpColon,KwPlatform,StringStart,StringPart,StringEnd,CloseCurly,

View file

@ -196,6 +196,7 @@ UNUSED VALUE - fuzz_crash_020.md:86:11:86:17
UNUSED VALUE - fuzz_crash_020.md:98:4:104:3
UNUSED VALUE - fuzz_crash_020.md:105:2:105:54
UNUSED VALUE - fuzz_crash_020.md:105:55:105:85
UNUSED VALUE - fuzz_crash_020.md:119:2:119:10
# PROBLEMS
**PARSE ERROR**
A parsing error occurred: `match_branch_missing_arrow`
@ -981,6 +982,17 @@ This expression produces a value, but it's not being used:
It has the type:
__f_
**UNUSED VALUE**
This expression produces a value, but it's not being used:
**fuzz_crash_020.md:119:2:119:10:**
```roc
foo == 1
```
^^^^^^^^
It has the type:
_Bool_
# TOKENS
~~~zig
KwApp,OpenSquare,LowerIdent,CloseSquare,OpenCurly,LowerIdent,OpColon,KwPlatform,StringStart,StringPart,StringEnd,CloseCurly,

View file

@ -92,6 +92,8 @@ MISSING METHOD - Color.md:22:15:22:26
MISSING METHOD - Color.md:29:13:29:26
TYPE MISMATCH - Color.md:32:5:45:6
MISSING METHOD - Color.md:62:8:62:28
MISSING METHOD - Color.md:56:8:56:34
MISSING METHOD - Color.md:57:8:57:40
# PROBLEMS
**MODULE HEADER DEPRECATED**
The `module` header is deprecated.
@ -266,6 +268,28 @@ This **is_named_color** method is being called on the type **Str**, which has no
**Hint: **For this to work, the type would need to have a method named **is_named_color** associated with it in the type's declaration.
**MISSING METHOD**
This **to_str** method is being called on the type **Color**, which has no method with that name:
**Color.md:56:8:56:34:**
```roc
expect rgb(124, 56, 245).to_str() == "rgb(124, 56, 245)"
```
^^^^^^^^^^^^^^^^^^^^^^^^^^
**Hint: **For this to work, the type would need to have a method named **to_str** associated with it in the type's declaration.
**MISSING METHOD**
This **to_str** method is being called on the type **Color**, which has no method with that name:
**Color.md:57:8:57:40:**
```roc
expect rgba(124, 56, 245, 255).to_str() == "rgba(124, 56, 245, 1.0)"
```
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
**Hint: **For this to work, the type would need to have a method named **to_str** associated with it in the type's declaration.
# TOKENS
~~~zig
KwModule,OpenSquare,