From 0ccd0a99c695181e2a225c72419377d52577c0f7 Mon Sep 17 00:00:00 2001 From: Taylor Holliday Date: Sat, 8 Aug 2026 08:54:09 -0700 Subject: [PATCH] Check return expressions against the function's return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the second half of #23. `Expr::Return` was never constrained against the enclosing function's signature — the checker only unified the *body's* type with the return type, and only when that type wasn't void: let ty = self.check_expr(body, &func_decl.arena, decls); if func_decl.ret != mk_type(Type::Void) { self.eq(ty, func_decl.ret, ...) } So a return was checked only when it happened to sit in tail position, and never at all in a void function. Both gaps produced an internal compiler error rather than a diagnostic: f() { return 0 } // --check passes, JIT panics f(b: bool) -> i32 { if b { return 1.5 } // --check passes, JIT panics 0 } internal compiler error: cranelift IR verification failed: - inst23 (return v12): arguments of return must match function signature The VM backend accepted both without complaint, so the two backends disagreed on whether the program was valid. Track the enclosing return types in a stack and constrain each `Expr::Return` against the innermost one. A return expression now takes on that type rather than its operand's, so a tail-position return doesn't report the same mismatch twice. Lambdas push a fresh variable that the body and any inner returns unify with, since their return type isn't known up front. The parser stamped `Expr::Return` with the location *after* parsing its operand, which pointed the error at the following token — usually the closing brace. Capture the location of the `return` keyword instead, as Break and Continue already do. Two existing tests change: - checker/return_type_mismatch: same error, now located at the return. - generics/unsolved_typevar_error: this program is no longer ambiguous. The extra constraint from `return pool[idx]` is enough to infer T, and it now compiles and runs correctly on both backends — covered by the new generics/generic_global_return_infers. Rewritten to keep guarding the original hang with a case where nothing constrains T. Bare `return` (the first half of #23) is still a parse error; that needs `Expr::Return(Option)` and backend changes, so it's left for a follow-up. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BX9w4k8qecsH9LMgBP8hXc --- src/checker.rs | 47 ++++++++++++++++++- src/parser.rs | 3 +- tests/cases/checker/early_return_ok.lyte | 21 +++++++++ .../checker/early_return_type_mismatch.lyte | 16 +++++++ tests/cases/checker/return_type_mismatch.lyte | 7 +-- .../cases/checker/return_value_from_void.lyte | 14 ++++++ .../generic_global_return_infers.lyte | 25 ++++++++++ .../generics/unsolved_typevar_error.lyte | 19 ++++---- 8 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 tests/cases/checker/early_return_ok.lyte create mode 100644 tests/cases/checker/early_return_type_mismatch.lyte create mode 100644 tests/cases/checker/return_value_from_void.lyte create mode 100644 tests/cases/generics/generic_global_return_infers.lyte diff --git a/src/checker.rs b/src/checker.rs index 63cfd104..42cb61ab 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -55,6 +55,10 @@ pub struct Checker { /// Nesting depth of loops (>0 means we're inside a loop). loop_depth: usize, + + /// Return types of the enclosing functions and lambdas, innermost last. + /// A `return` expression is constrained against the last entry. + ret_types: Vec, } /// Returns true if the type is or contains a borrowed type (`[T]` or `&T`). @@ -187,6 +191,7 @@ impl Checker { constraints: vec![], errors: vec![], loop_depth: 0, + ret_types: vec![], } } @@ -716,7 +721,31 @@ impl Checker { ty } Expr::Arena(block) => self.check_expr(*block, arena, decls), - Expr::Return(expr) => self.check_expr(*expr, arena, decls), + Expr::Return(expr) => { + let ty = self.check_expr(*expr, arena, decls); + + // Constrain against the enclosing function's return type. + // Without this, only a return in tail position is checked + // (via the body's type), so an early return with the wrong + // type reaches codegen and trips the backend's verifier. + match self.ret_types.last() { + Some(&ret) => { + self.eq( + ty, + ret, + arena.locs[id], + "return type must match function return type", + ); + + // Take on the enclosing return type rather than the + // operand's, so a return in tail position doesn't + // report the same mismatch twice (once here, once + // from the body-level check below). + ret + } + None => ty, + } + } Expr::Assume(cond) => { self.check_expr(*cond, arena, decls); mk_type(Type::Void) @@ -955,13 +984,25 @@ impl Checker { param_types.push(ty); } + // The lambda's return type isn't known up front, so bind a + // fresh variable that both the body and any `return` inside + // it unify with. + let lambda_ret = self.fresh(); + self.ret_types.push(lambda_ret); let rt = self.check_expr(*body, arena, decls); + self.ret_types.pop(); + self.eq( + rt, + lambda_ret, + arena.locs[*body], + "return type must match function return type", + ); while self.vars.len() > n { self.vars.pop(); } - func(tuple(param_types), rt) + func(tuple(param_types), lambda_ret) } Expr::Error => self.fresh(), }; @@ -1081,7 +1122,9 @@ impl Checker { } // Check the body of the function. + self.ret_types.push(func_decl.ret); let ty = self.check_expr(body, &func_decl.arena, decls); + self.ret_types.pop(); if func_decl.ret != mk_type(Type::Void) { self.eq( diff --git a/src/parser.rs b/src/parser.rs index f1f84f7b..34fa0057 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -785,9 +785,10 @@ fn parse_stmt(arena: &mut ExprArena, typevars: &[Name], cx: &mut ParseContext) - arena.add(Expr::While(cond, body), cx.lex.loc) } Token::Return => { + let loc = cx.lex.loc; cx.next(); let e = parse_expr(arena, typevars, cx); - arena.add(Expr::Return(e), cx.lex.loc) + arena.add(Expr::Return(e), loc) } Token::Break => { let loc = cx.lex.loc; diff --git a/tests/cases/checker/early_return_ok.lyte b/tests/cases/checker/early_return_ok.lyte new file mode 100644 index 00000000..ac6ff2be --- /dev/null +++ b/tests/cases/checker/early_return_ok.lyte @@ -0,0 +1,21 @@ +// Well-typed early returns, in a value-returning function and in a lambda. +// expected stdout: +// compilation successful +// 0 +// 1 +// 5 +// 3 + +clamp01(x: i32) -> i32 { + if x < 0 { return 0 } + if x > 1 { return 1 } + x +} + +main() { + print(clamp01(-4)) + print(clamp01(7)) + print(clamp01(5) + 4) + let f = |x: i32| { return x + 1 } + print(f(2)) +} diff --git a/tests/cases/checker/early_return_type_mismatch.lyte b/tests/cases/checker/early_return_type_mismatch.lyte new file mode 100644 index 00000000..961e13d7 --- /dev/null +++ b/tests/cases/checker/early_return_type_mismatch.lyte @@ -0,0 +1,16 @@ +// An early return nested in a branch must match the function's return type. +// Only tail-position returns used to be checked, so this reached codegen and +// tripped the Cranelift verifier. + +// args: --check +// expected stdout: +// ❌ ../tests/cases/checker/early_return_type_mismatch.lyte:12:12: return type must match function return type: f32 vs i32 +// if b { return 1.5 } +// ^ + +f(b: bool) -> i32 { + if b { return 1.5 } + 0 +} + +main() { print(f(true)) } diff --git a/tests/cases/checker/return_type_mismatch.lyte b/tests/cases/checker/return_type_mismatch.lyte index 2b0e30ae..203217e4 100644 --- a/tests/cases/checker/return_type_mismatch.lyte +++ b/tests/cases/checker/return_type_mismatch.lyte @@ -1,8 +1,9 @@ // args: --check // expected stdout: -// ❌ ../tests/cases/checker/return_type_mismatch.lyte:6:18: return type must match function return type: f32 vs i32 -// f(x: i32) -> i32 { -// ^ +// ❌ ../tests/cases/checker/return_type_mismatch.lyte:8:5: return type must match function return type: f32 vs i32 +// return 1.0 +// ^ + f(x: i32) -> i32 { return 1.0 } diff --git a/tests/cases/checker/return_value_from_void.lyte b/tests/cases/checker/return_value_from_void.lyte new file mode 100644 index 00000000..4721c5ce --- /dev/null +++ b/tests/cases/checker/return_value_from_void.lyte @@ -0,0 +1,14 @@ +// Returning a value from a function with no declared return type is an +// error. It used to type-check and then trip the Cranelift verifier. + +// args: --check +// expected stdout: +// ❌ ../tests/cases/checker/return_value_from_void.lyte:11:5: return type must match function return type: i32 vs void +// return 0 +// ^ + +f() { + return 0 +} + +main() { f() } diff --git a/tests/cases/generics/generic_global_return_infers.lyte b/tests/cases/generics/generic_global_return_infers.lyte new file mode 100644 index 00000000..f64e06c3 --- /dev/null +++ b/tests/cases/generics/generic_global_return_infers.lyte @@ -0,0 +1,25 @@ +// A `return` constrains its operand against the enclosing function's return +// type, which is enough to infer T for a generic global used without ⟨T⟩ — +// including for an early return inside a branch. +// expected stdout: +// compilation successful +// 42 +// assert(true) +// 0 + +var pool: [T; 4] + +get(idx: i32) -> T { + if idx >= 0 && idx < 4 { + return pool[idx] + } + return pool[0] +} + +main() { + pool⟨i32⟩[2] = 42 + pool⟨f32⟩[2] = 1.5 + print(get⟨i32⟩(2)) + assert(get⟨f32⟩(2) == 1.5) + print(get⟨i32⟩(99)) +} diff --git a/tests/cases/generics/unsolved_typevar_error.lyte b/tests/cases/generics/unsolved_typevar_error.lyte index 2dc6ab0f..28dba28e 100644 --- a/tests/cases/generics/unsolved_typevar_error.lyte +++ b/tests/cases/generics/unsolved_typevar_error.lyte @@ -1,19 +1,20 @@ // Using a generic global without ⟨T⟩ inside a generic function where -// the return type alone can't determine T should be an error, not a hang. +// nothing constrains T should be an error, not a hang. // args: --check // expected stdout: -// ❌ ../tests/cases/generics/unsolved_typevar_error.lyte:14:16: ambiguous constraint: ?4 == ?4 -// return pool[idx] -// ^ +// ❌ ../tests/cases/generics/unsolved_typevar_error.lyte:16:13: ambiguous constraint: ?1 == ?1 +// let x = pool[0] +// ^ +// ❌ ../tests/cases/generics/unsolved_typevar_error.lyte:16:5: ambiguous constraint: ?1 == ?1 +// let x = pool[0] +// ^ var pool: [T; 4] -get(idx: i32) -> T { - if idx >= 0 && idx < 4 { - return pool[idx] - } - return pool[0] +count() -> i32 { + let x = pool[0] + 0 } main {}