From bb5e9e42d9f56b845a9f40b9738d4e7157cd1cff Mon Sep 17 00:00:00 2001 From: Jooris Hadeler Date: Wed, 11 Mar 2026 15:34:18 +0100 Subject: [PATCH] Feat: add UnboundInt type for integer literal inference Integer literals now produce `Ty::UnboundInt` instead of a hardcoded `i32`. This flexible type promotes to/from any concrete integer, so literals resolve naturally in context (e.g. `n < 2` when `n: u8` works without an explicit cast). `common(UnboundInt, T)` returns `T` when `T` is a concrete integer, so binary ops adopt the concrete operand's type. Includes 8 new tests covering literal coercion, fibonacci-style patterns, and negative cases (literals still don't coerce to float types). Co-Authored-By: Claude Sonnet 4.6 --- fluxc/src/ast.rs | 19 +++++++++- fluxc/src/checker/expr.rs | 18 ++++++--- fluxc/src/checker/tests.rs | 76 ++++++++++++++++++++++++++++++++++---- 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/fluxc/src/ast.rs b/fluxc/src/ast.rs index 59a5dd6..676a2ce 100644 --- a/fluxc/src/ast.rs +++ b/fluxc/src/ast.rs @@ -50,6 +50,10 @@ pub enum Ty { Struct(String), // Internal function signature (not user-facing) FnSig { params: Vec, ret: Box }, + /// Unresolved integer type from a literal or an unannotated let-binding. + /// Compatible with every concrete integer type; defaults to `i32` in + /// error messages when no concrete type can be inferred. + UnboundInt, // Error propagation sentinel Error, } @@ -68,7 +72,7 @@ impl Ty { } pub fn is_integer(&self) -> bool { - self.is_unsigned() || self.is_signed() + self.is_unsigned() || self.is_signed() || matches!(self, Ty::UnboundInt) } pub fn is_float(&self) -> bool { @@ -99,6 +103,11 @@ impl Ty { return true; } match (self, target) { + // UnboundInt (from integer literal / unannotated let) promotes to + // any concrete integer type, and any concrete integer promotes to + // UnboundInt so it can receive a typed value. + (Ty::UnboundInt, t) if t.is_integer() => true, + (t, Ty::UnboundInt) if t.is_integer() => true, // Unsigned widening: same unsigned category, rank strictly increases (a, b) if a.is_unsigned() && b.is_unsigned() => { a.rank().unwrap_or(0) < b.rank().unwrap_or(0) @@ -132,6 +141,13 @@ impl Ty { if a.is_error() || b.is_error() { return Some(Ty::Error); } + // UnboundInt is resolved by the concrete type; concrete type wins. + // common(UnboundInt, UnboundInt) = UnboundInt. + match (a, b) { + (Ty::UnboundInt, _) if b.is_integer() => return Some(b.clone()), + (_, Ty::UnboundInt) if a.is_integer() => return Some(a.clone()), + _ => {} + } if b.promotes_to(a) { return Some(a.clone()); } @@ -195,6 +211,7 @@ impl Ty { let ps: Vec<_> = params.iter().map(|p| p.display()).collect(); format!("fn({}) -> {}", ps.join(", "), ret.display()) } + Ty::UnboundInt => "{integer}".to_string(), Ty::Error => "".to_string(), } } diff --git a/fluxc/src/checker/expr.rs b/fluxc/src/checker/expr.rs index 933cf4b..6800a52 100644 --- a/fluxc/src/checker/expr.rs +++ b/fluxc/src/checker/expr.rs @@ -16,8 +16,8 @@ impl Checker { assigned: &HashSet, ) -> Ty { match &expr.kind { - // T-IntLit → i32 - ExprKind::IntLit(_) => Ty::I32, + // T-IntLit → UnboundInt (resolved from context; defaults to i32) + ExprKind::IntLit(_) => Ty::UnboundInt, // T-FloatLit → f64 ExprKind::FloatLit(_) => Ty::F64, @@ -338,6 +338,10 @@ impl Checker { ) -> Ty { match op { UnaryOp::Neg => { + // UnboundInt is a literal-origin integer; negating keeps it unbound. + if matches!(inner_ty, Ty::UnboundInt) { + return Ty::UnboundInt; + } if !inner_ty.is_signed() && !inner_ty.is_float() { self.emit( Diagnostic::error(format!( @@ -501,7 +505,7 @@ impl Checker { Ty::Bool } - // Ordering: numeric or char + // Ordering: numeric or char (UnboundInt resolves to the other operand's type) BinaryOp::Lt | BinaryOp::Gt | BinaryOp::Le | BinaryOp::Ge => { let lhs_ty = self.check_expr(lhs, env, assigned); let rhs_ty = self.check_expr(rhs, env, assigned); @@ -524,7 +528,7 @@ impl Checker { Ty::Bool } - // Bitwise: require matching integer types + // Bitwise: require integer types (UnboundInt is fine) BinaryOp::BitOr | BinaryOp::BitXor | BinaryOp::BitAnd => { let lhs_ty = self.check_expr(lhs, env, assigned); let rhs_ty = self.check_expr(rhs, env, assigned); @@ -547,7 +551,9 @@ impl Checker { } } - // Shift: LHS integer, RHS any integer; result type = LHS type + // Shift: LHS integer, RHS any integer; result type = LHS type. + // If LHS is UnboundInt and RHS is concrete, result is still UnboundInt + // (it will be resolved when the result is used in context). BinaryOp::Shl | BinaryOp::Shr => { let lhs_ty = self.check_expr(lhs, env, assigned); let rhs_ty = self.check_expr(rhs, env, assigned); @@ -625,6 +631,8 @@ impl Checker { fn check_arith_or_shift(&mut self, lhs_ty: Ty, rhs_ty: Ty, op: BinaryOp, op_span: Span) -> Ty { match Ty::common(&lhs_ty, &rhs_ty) { Some(Ty::Error) => Ty::Error, + // UnboundInt + UnboundInt → still UnboundInt (resolved downstream) + Some(Ty::UnboundInt) => Ty::UnboundInt, Some(ref c) if c.is_numeric() => c.clone(), _ => { let sym = match op { diff --git a/fluxc/src/checker/tests.rs b/fluxc/src/checker/tests.rs index e65a194..1c9f0c7 100644 --- a/fluxc/src/checker/tests.rs +++ b/fluxc/src/checker/tests.rs @@ -120,8 +120,9 @@ mod tests { // ── Literals ────────────────────────────────────────────────────────────── #[test] - fn int_literal_is_i32() { - ok("fn main() { let x: i32 = 42; }"); + fn int_literal_coerces_to_annotated_type() { + // Integer literals have type UnboundInt and coerce to any annotated integer. + ok("fn main() { let a: i32 = 42; let b: u64 = 0; let c: u8 = 255; let d: i8 = -1; }"); } #[test] @@ -139,12 +140,64 @@ mod tests { ok("fn main() { let x: char = 'a'; }"); } - // ── Implicit promotion in let ────────────────────────────────────────────── + // ── Integer literal inference ────────────────────────────────────────────── #[test] - fn i8_promotes_to_i32_in_let() { - // i32 literal (type i32) promotes to i64 - ok("fn main() { let x: i64 = 1; }"); + fn literal_coerces_to_any_integer_size() { + // UnboundInt coerces to all integer types. + ok("fn main() { let a: u8 = 1; let b: u16 = 1; let c: u32 = 1; let d: u64 = 1; }"); + } + + #[test] + fn unannotated_let_infers_from_context() { + // `let x = 0` gets type UnboundInt; used as u8 in comparison. + ok("fn f(n: u8) -> u8 { + let mut x = 0; + while x < n { x += 1; } + return x; + } fn main() { }"); + } + + #[test] + fn literal_in_binary_op_with_concrete_type() { + // `n - 1` where n: u8 — literal 1 resolves to u8. + ok("fn f(n: u8) -> u8 { return n - 1; } fn main() { }"); + } + + #[test] + fn literal_in_comparison_with_concrete_type() { + // `n < 2` where n: u8 — literal 2 resolves to u8. + ok("fn f(n: u8) -> bool { return n < 2; } fn main() { }"); + } + + #[test] + fn unannotated_literal_variable_as_return() { + // `let x = 0;` with no annotation; returned as u64. + ok("fn f() -> u64 { let x = 0; return x; } fn main() { }"); + } + + #[test] + fn fibonacci_rec_typechecks() { + ok("fn fibonacci_rec(n: u8) -> u64 { + if n < 2 { return n; } + return fibonacci_rec(n - 1) + fibonacci_rec(n - 2); + } fn main() { }"); + } + + #[test] + fn fibonacci_iter_typechecks() { + ok("fn fibonacci_iter(n: u8) -> u64 { + let mut counter = 0; + let mut a = 0; + let mut b = 1; + while counter < n { + let temp = a + b; + a = b; + b = temp; + counter += 1; + } + return a; + } fn main() { }"); } #[test] @@ -153,7 +206,7 @@ mod tests { } #[test] - fn cross_category_forbidden_in_let() { + fn int_literal_does_not_coerce_to_float() { err_contains("fn main() { let x: f64 = 1; }", "type mismatch"); } @@ -165,6 +218,15 @@ mod tests { ); } + #[test] + fn cross_category_forbidden_in_let() { + // Annotated-to-annotated cross-category still rejected. + err_contains( + "fn foo(v: u32) { let x: i32 = v; } fn main() { }", + "type mismatch", + ); + } + // ── Type inference ──────────────────────────────────────────────────────── #[test]