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 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:34:18 +01:00
parent 1f3d64f97c
commit bb5e9e42d9
3 changed files with 100 additions and 13 deletions

View File

@@ -50,6 +50,10 @@ pub enum Ty {
Struct(String),
// Internal function signature (not user-facing)
FnSig { params: Vec<Ty>, ret: Box<Ty> },
/// 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 => "<error>".to_string(),
}
}

View File

@@ -16,8 +16,8 @@ impl Checker {
assigned: &HashSet<String>,
) -> 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 {

View File

@@ -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]