Files
flux/fluxc/src/checker/expr.rs
Jooris Hadeler bb5e9e42d9 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>
2026-03-11 15:34:18 +01:00

750 lines
29 KiB
Rust

use std::collections::HashSet;
use super::Checker;
use super::env::TypeEnv;
use crate::ast::{self, BinaryOp, CompoundAssignOp, ExprKind, Parsed, Ty, UnaryOp};
use crate::diagnostics::{Diagnostic, Label};
use crate::token::Span;
impl Checker {
/// Type-check `expr` in environment `env` with definite-assignment set `assigned`.
/// Returns the resolved type. Does NOT mutate `env` or `assigned`.
pub fn check_expr(
&mut self,
expr: &ast::Expr<Parsed>,
env: &TypeEnv,
assigned: &HashSet<String>,
) -> Ty {
match &expr.kind {
// T-IntLit → UnboundInt (resolved from context; defaults to i32)
ExprKind::IntLit(_) => Ty::UnboundInt,
// T-FloatLit → f64
ExprKind::FloatLit(_) => Ty::F64,
// T-StringLit → *char
ExprKind::StringLit(_) => Ty::Ptr {
mutable: false,
pointee: Box::new(Ty::Char),
},
// T-CharLit → char
ExprKind::CharLit(_) => Ty::Char,
// T-Bool → bool
ExprKind::Bool(_) => Ty::Bool,
// T-Ident
ExprKind::Ident(name) => match env.lookup(name) {
Some((ty, _)) => {
if !assigned.contains(name) {
self.emit(
Diagnostic::error(format!(
"use of potentially uninitialized variable `{name}`"
))
.with_label(Label::primary(expr.span)),
);
Ty::Error
} else {
ty.clone()
}
}
None => {
self.emit(
Diagnostic::error(format!("undefined variable `{name}`"))
.with_label(Label::primary(expr.span)),
);
Ty::Error
}
},
// T-StructLit
ExprKind::StructLit {
name,
name_span,
fields,
} => {
if !self.sigma.contains(name) {
self.emit(
Diagnostic::error(format!("undefined struct `{name}`"))
.with_label(Label::primary(*name_span)),
);
// Still check field expressions for further errors
for sf in fields {
self.check_expr(&sf.value, env, assigned);
}
return Ty::Error;
}
let expected: Vec<(String, Ty)> = self
.sigma
.fields(name)
.unwrap_or(&[])
.iter()
.map(|f| (f.name.clone(), f.ty.clone()))
.collect();
let mut provided: HashSet<String> = HashSet::new();
for sf in fields {
let val_ty = self.check_expr(&sf.value, env, assigned);
if provided.contains(&sf.name) {
self.emit(
Diagnostic::error(format!(
"field `{}` specified more than once",
sf.name
))
.with_label(Label::primary(sf.name_span)),
);
continue;
}
provided.insert(sf.name.clone());
match expected.iter().find(|(n, _)| *n == sf.name) {
Some((_, exp_ty)) => {
if !val_ty.is_error()
&& !exp_ty.is_error()
&& !val_ty.promotes_to(exp_ty)
{
self.emit(
Diagnostic::error(format!(
"field `{}`: expected `{}`, got `{}`",
sf.name,
exp_ty.display(),
val_ty.display()
))
.with_label(Label::primary(sf.value.span)),
);
}
}
None => {
self.emit(
Diagnostic::error(format!(
"struct `{name}` has no field `{}`",
sf.name
))
.with_label(Label::primary(sf.name_span)),
);
}
}
}
for (fname, _) in &expected {
if !provided.contains(fname) {
self.emit(
Diagnostic::error(format!(
"missing field `{fname}` in struct literal `{name}`"
))
.with_label(Label::primary(*name_span)),
);
}
}
Ty::Struct(name.clone())
}
// T-Unary
ExprKind::Unary {
op,
op_span,
expr: inner,
} => {
let inner_ty = self.check_expr(inner, env, assigned);
if inner_ty.is_error() {
return Ty::Error;
}
self.check_unary(*op, *op_span, inner, inner_ty, env)
}
// T-Binary
ExprKind::Binary {
op,
op_span,
lhs,
rhs,
} => self.check_binary(*op, *op_span, lhs, rhs, env, assigned),
// T-CompoundAssign: lhs op= rhs (expands to lhs = lhs op rhs)
ExprKind::CompoundAssign {
op,
op_span,
lhs,
rhs,
} => {
if !self.is_mutable_place(lhs, env) {
self.emit(
Diagnostic::error(
"left-hand side of compound assignment must be a mutable place",
)
.with_label(Label::primary(lhs.span)),
);
}
let lhs_ty = self.check_expr(lhs, env, assigned);
let rhs_ty = self.check_expr(rhs, env, assigned);
if !lhs_ty.is_error() && !rhs_ty.is_error() {
let bin_op = compound_to_binary(*op);
self.check_arith_or_shift(lhs_ty, rhs_ty, bin_op, *op_span);
}
Ty::Unit
}
// T-Field
ExprKind::Field {
expr: inner,
field,
field_span,
} => {
let inner_ty = self.check_expr(inner, env, assigned);
if inner_ty.is_error() {
return Ty::Error;
}
let struct_name = match &inner_ty {
Ty::Struct(n) => n.clone(),
_ => {
self.emit(
Diagnostic::error(format!(
"field access on non-struct type `{}`",
inner_ty.display()
))
.with_label(Label::primary(*field_span)),
);
return Ty::Error;
}
};
match self.sigma.field_ty(&struct_name, field) {
Some(ty) => ty.clone(),
None => {
self.emit(
Diagnostic::error(format!(
"struct `{struct_name}` has no field `{field}`"
))
.with_label(Label::primary(*field_span)),
);
Ty::Error
}
}
}
// T-Index
ExprKind::Index { expr: inner, index } => {
let inner_ty = self.check_expr(inner, env, assigned);
let idx_ty = self.check_expr(index, env, assigned);
if inner_ty.is_error() {
return Ty::Error;
}
if !idx_ty.is_error() && !idx_ty.is_integer() {
self.emit(
Diagnostic::error(format!(
"index must be an integer type, got `{}`",
idx_ty.display()
))
.with_label(Label::primary(index.span)),
);
}
match inner_ty {
Ty::Array { elem, .. } => *elem,
Ty::Ptr { pointee, .. } => *pointee,
_ => {
self.emit(
Diagnostic::error(format!(
"indexing requires array or pointer, got `{}`",
inner_ty.display()
))
.with_label(Label::primary(inner.span)),
);
Ty::Error
}
}
}
// T-Call
ExprKind::Call { callee, args } => {
let func_name = match &callee.kind {
ExprKind::Ident(name) => name.clone(),
_ => {
self.emit(
Diagnostic::error("callee must be a function name")
.with_label(Label::primary(callee.span)),
);
for arg in args {
self.check_expr(arg, env, assigned);
}
return Ty::Error;
}
};
let (param_tys, ret_ty) = match self.phi.get(&func_name) {
Some(entry) => {
let pts: Vec<Ty> = entry.params.iter().map(|p| p.ty.clone()).collect();
let ret = entry.ret.clone();
(pts, ret)
}
None => {
self.emit(
Diagnostic::error(format!("undefined function `{func_name}`"))
.with_label(Label::primary(callee.span)),
);
for arg in args {
self.check_expr(arg, env, assigned);
}
return Ty::Error;
}
};
if args.len() != param_tys.len() {
self.emit(
Diagnostic::error(format!(
"`{func_name}` expects {} argument(s), got {}",
param_tys.len(),
args.len()
))
.with_label(Label::primary(callee.span)),
);
}
for (i, arg) in args.iter().enumerate() {
let arg_ty = self.check_expr(arg, env, assigned);
if let Some(exp) = param_tys.get(i) {
if !arg_ty.is_error() && !exp.is_error() && !arg_ty.promotes_to(exp) {
self.emit(
Diagnostic::error(format!(
"argument {}: expected `{}`, got `{}`",
i + 1,
exp.display(),
arg_ty.display()
))
.with_label(Label::primary(arg.span)),
);
}
}
}
ret_ty
}
ExprKind::Group(inner) => self.check_expr(inner, env, assigned),
ExprKind::Error => Ty::Error,
}
}
// ── Unary helper ──────────────────────────────────────────────────────────
fn check_unary(
&mut self,
op: UnaryOp,
op_span: Span,
inner: &ast::Expr<Parsed>,
inner_ty: Ty,
env: &TypeEnv,
) -> 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!(
"unary `-` requires a signed integer or float, got `{}`",
inner_ty.display()
))
.with_label(Label::primary(op_span)),
);
Ty::Error
} else {
inner_ty
}
}
UnaryOp::Not => {
if inner_ty != Ty::Bool {
self.emit(
Diagnostic::error(format!(
"unary `!` requires `bool`, got `{}`",
inner_ty.display()
))
.with_label(Label::primary(op_span)),
);
Ty::Error
} else {
Ty::Bool
}
}
UnaryOp::BitNot => {
if !inner_ty.is_integer() {
self.emit(
Diagnostic::error(format!(
"unary `~` requires an integer type, got `{}`",
inner_ty.display()
))
.with_label(Label::primary(op_span)),
);
Ty::Error
} else {
inner_ty
}
}
UnaryOp::Deref => match &inner_ty {
Ty::Ptr { pointee, .. } => *pointee.clone(),
Ty::OpaquePtr { .. } => {
self.emit(
Diagnostic::error("cannot dereference an opaque pointer")
.with_label(Label::primary(op_span)),
);
Ty::Error
}
_ => {
self.emit(
Diagnostic::error(format!(
"unary `*` requires a pointer, got `{}`",
inner_ty.display()
))
.with_label(Label::primary(op_span)),
);
Ty::Error
}
},
UnaryOp::AddrOf => {
if !self.is_place(inner, env) {
self.emit(
Diagnostic::error("cannot take address of a non-place expression")
.with_label(Label::primary(op_span)),
);
return Ty::Error;
}
let mutable = self.is_mutable_place(inner, env);
Ty::Ptr {
mutable,
pointee: Box::new(inner_ty),
}
}
}
}
// ── Binary helper ─────────────────────────────────────────────────────────
fn check_binary(
&mut self,
op: BinaryOp,
op_span: Span,
lhs: &ast::Expr<Parsed>,
rhs: &ast::Expr<Parsed>,
env: &TypeEnv,
assigned: &HashSet<String>,
) -> Ty {
match op {
// T-Assign
BinaryOp::Assign => {
if !self.is_mutable_place(lhs, env) {
self.emit(
Diagnostic::error("left-hand side of `=` must be a mutable place")
.with_label(Label::primary(lhs.span)),
);
}
// For a bare identifier on the LHS we only need its declared
// type, not a read — don't emit "uninitialized" for the target
// of the assignment itself.
let lhs_ty = self.lhs_ty(lhs, env, assigned);
let rhs_ty = self.check_expr(rhs, env, assigned);
if !lhs_ty.is_error() && !rhs_ty.is_error() && !rhs_ty.promotes_to(&lhs_ty) {
self.emit(
Diagnostic::error(format!(
"type mismatch: expected `{}`, got `{}`",
lhs_ty.display(),
rhs_ty.display()
))
.with_label(Label::primary(rhs.span)),
);
}
Ty::Unit
}
// Logical
BinaryOp::Or | BinaryOp::And => {
let lhs_ty = self.check_expr(lhs, env, assigned);
let rhs_ty = self.check_expr(rhs, env, assigned);
let kw = if op == BinaryOp::Or { "or" } else { "and" };
if !lhs_ty.is_error() && lhs_ty != Ty::Bool {
self.emit(
Diagnostic::error(format!(
"`{kw}` requires `bool` operands, left side is `{}`",
lhs_ty.display()
))
.with_label(Label::primary(lhs.span)),
);
}
if !rhs_ty.is_error() && rhs_ty != Ty::Bool {
self.emit(
Diagnostic::error(format!(
"`{kw}` requires `bool` operands, right side is `{}`",
rhs_ty.display()
))
.with_label(Label::primary(rhs.span)),
);
}
Ty::Bool
}
// Equality: compatible types OR pointer-compatible
BinaryOp::Eq | BinaryOp::Ne => {
let lhs_ty = self.check_expr(lhs, env, assigned);
let rhs_ty = self.check_expr(rhs, env, assigned);
if !lhs_ty.is_error() && !rhs_ty.is_error() {
let ok = Ty::common(&lhs_ty, &rhs_ty).is_some()
|| Ty::ptr_common(&lhs_ty, &rhs_ty).is_some();
if !ok {
self.emit(
Diagnostic::error(format!(
"cannot compare `{}` with `{}`",
lhs_ty.display(),
rhs_ty.display()
))
.with_label(Label::primary(op_span)),
);
}
}
Ty::Bool
}
// 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);
if !lhs_ty.is_error() && !rhs_ty.is_error() {
let ok = match Ty::common(&lhs_ty, &rhs_ty) {
Some(ref c) => c.is_numeric() || *c == Ty::Char,
None => false,
};
if !ok {
self.emit(
Diagnostic::error(format!(
"cannot order `{}` and `{}`",
lhs_ty.display(),
rhs_ty.display()
))
.with_label(Label::primary(op_span)),
);
}
}
Ty::Bool
}
// 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);
if lhs_ty.is_error() || rhs_ty.is_error() {
return Ty::Error;
}
match Ty::common(&lhs_ty, &rhs_ty) {
Some(ref c) if c.is_integer() => c.clone(),
_ => {
self.emit(
Diagnostic::error(format!(
"bitwise operator requires integer operands, got `{}` and `{}`",
lhs_ty.display(),
rhs_ty.display()
))
.with_label(Label::primary(op_span)),
);
Ty::Error
}
}
}
// 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);
if lhs_ty.is_error() || rhs_ty.is_error() {
return Ty::Error;
}
if !lhs_ty.is_integer() {
self.emit(
Diagnostic::error(format!(
"shift requires an integer LHS, got `{}`",
lhs_ty.display()
))
.with_label(Label::primary(lhs.span)),
);
return Ty::Error;
}
if !rhs_ty.is_integer() {
self.emit(
Diagnostic::error(format!(
"shift amount must be an integer, got `{}`",
rhs_ty.display()
))
.with_label(Label::primary(rhs.span)),
);
return Ty::Error;
}
lhs_ty
}
// Arithmetic
BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Rem => {
let lhs_ty = self.check_expr(lhs, env, assigned);
let rhs_ty = self.check_expr(rhs, env, assigned);
if lhs_ty.is_error() || rhs_ty.is_error() {
return Ty::Error;
}
self.check_arith_or_shift(lhs_ty, rhs_ty, op, op_span)
}
}
}
/// Return the declared type of `expr` as a write-side place, without
/// checking that it has been initialised first. For a bare identifier
/// this avoids a spurious "uninitialized" diagnostic on the target of
/// an assignment like `x = 5` when `x` is being given its first value.
///
/// For all other place forms (deref, field, index) the sub-expression IS
/// read, so the normal `check_expr` path (with assigned checking) is used.
fn lhs_ty(
&mut self,
expr: &ast::Expr<Parsed>,
env: &TypeEnv,
assigned: &HashSet<String>,
) -> Ty {
match &expr.kind {
ExprKind::Ident(name) => match env.lookup(name) {
Some((ty, _)) => ty.clone(),
None => {
self.emit(
Diagnostic::error(format!("undefined variable `{name}`"))
.with_label(Label::primary(expr.span)),
);
Ty::Error
}
},
ExprKind::Group(inner) => self.lhs_ty(inner, env, assigned),
// All other place forms (deref, field, index) involve a read of
// the sub-expression — use the full check.
_ => self.check_expr(expr, env, assigned),
}
}
/// Check that `lhs_ty op rhs_ty` is a valid arithmetic expression;
/// returns the result type.
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 {
BinaryOp::Add => "+",
BinaryOp::Sub => "-",
BinaryOp::Mul => "*",
BinaryOp::Div => "/",
BinaryOp::Rem => "%",
_ => "op",
};
self.emit(
Diagnostic::error(format!(
"`{sym}` requires numeric operands, got `{}` and `{}`",
lhs_ty.display(),
rhs_ty.display()
))
.with_label(Label::primary(op_span)),
);
Ty::Error
}
}
}
// ── Place predicates ──────────────────────────────────────────────────────
/// Returns true if `expr` is a syntactic place (lvalue).
pub fn is_place(&self, expr: &ast::Expr<Parsed>, env: &TypeEnv) -> bool {
match &expr.kind {
ExprKind::Ident(_) => true,
ExprKind::Unary {
op: UnaryOp::Deref, ..
} => true,
ExprKind::Field { expr: inner, .. } => self.is_place(inner, env),
ExprKind::Index { expr: inner, .. } => self.is_place(inner, env),
ExprKind::Group(inner) => self.is_place(inner, env),
_ => false,
}
}
/// Returns true if `expr` is a mutable place.
///
/// Rules (§6.3):
/// - `x` is mutable iff `x` is declared `mut`.
/// - `*e` is mutable iff `e` has type `*mut T`.
/// - `e.f` / `e[i]` is mutable iff `e` is mutable.
pub fn is_mutable_place(&self, expr: &ast::Expr<Parsed>, env: &TypeEnv) -> bool {
match &expr.kind {
ExprKind::Ident(name) => env.lookup(name).map(|(_, m)| m).unwrap_or(false),
ExprKind::Unary {
op: UnaryOp::Deref,
expr: inner,
..
} => {
// Mutable iff inner's type is *mut T.
// We resolve the type of inner from env without emitting errors.
matches!(
self.peek_ty(inner, env),
Some(Ty::Ptr { mutable: true, .. })
)
}
ExprKind::Field { expr: inner, .. } => self.is_mutable_place(inner, env),
ExprKind::Index { expr: inner, .. } => self.is_mutable_place(inner, env),
ExprKind::Group(inner) => self.is_mutable_place(inner, env),
_ => false,
}
}
/// Lightweight type inference for place mutability — does NOT emit diagnostics.
fn peek_ty(&self, expr: &ast::Expr<Parsed>, env: &TypeEnv) -> Option<Ty> {
match &expr.kind {
ExprKind::Ident(name) => env.lookup(name).map(|(ty, _)| ty.clone()),
ExprKind::Group(inner) => self.peek_ty(inner, env),
ExprKind::Unary {
op: UnaryOp::Deref,
expr: inner,
..
} => match self.peek_ty(inner, env)? {
Ty::Ptr { pointee, .. } => Some(*pointee),
_ => None,
},
ExprKind::Field {
expr: inner, field, ..
} => {
let Ty::Struct(sname) = self.peek_ty(inner, env)? else {
return None;
};
self.sigma.field_ty(&sname, field).cloned()
}
ExprKind::Index { expr: inner, .. } => match self.peek_ty(inner, env)? {
Ty::Array { elem, .. } => Some(*elem),
Ty::Ptr { pointee, .. } => Some(*pointee),
_ => None,
},
_ => None,
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
fn compound_to_binary(op: CompoundAssignOp) -> BinaryOp {
match op {
CompoundAssignOp::Add => BinaryOp::Add,
CompoundAssignOp::Sub => BinaryOp::Sub,
CompoundAssignOp::Mul => BinaryOp::Mul,
CompoundAssignOp::Div => BinaryOp::Div,
CompoundAssignOp::Rem => BinaryOp::Rem,
CompoundAssignOp::BitAnd => BinaryOp::BitAnd,
CompoundAssignOp::BitOr => BinaryOp::BitOr,
CompoundAssignOp::BitXor => BinaryOp::BitXor,
CompoundAssignOp::Shl => BinaryOp::Shl,
CompoundAssignOp::Shr => BinaryOp::Shr,
}
}