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

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