use crate::ast;
use crate::effects::EffectSet;
use crate::errors::QalaError;
#[allow(unused_imports)] use crate::parser::MAX_DEPTH;
use crate::span::{LineIndex, Span};
use crate::typed_ast;
use crate::types::{QalaType, Symbol};
use std::collections::{BTreeMap, BTreeSet, HashMap};
#[derive(Debug, Clone, PartialEq)]
pub struct QalaWarning {
pub category: String,
pub message: String,
pub span: Span,
pub note: Option<String>,
}
pub fn check_program(
ast: &ast::Ast,
src: &str,
) -> (typed_ast::TypedAst, Vec<QalaError>, Vec<QalaWarning>) {
let mut c = Checker::new(src);
c.allow = scan_allow_directives(src);
c.preregister_type_names(ast);
for item in ast {
c.collect_item(item);
}
c.detect_recursive_structs();
let typed: typed_ast::TypedAst = ast.iter().map(|item| c.check_item(item)).collect();
c.resolve_function_effects();
c.errors.sort_by_key(|e| (e.span().start, e.span().len));
c.warnings.sort_by_key(|w| (w.span.start, w.span.len));
(typed, c.errors, c.warnings)
}
#[derive(Default)]
struct SymbolTable {
structs: HashMap<String, StructInfo>,
enums: BTreeMap<String, EnumInfo>,
interfaces: HashMap<String, InterfaceInfo>,
fns: HashMap<FnKey, FnInfo>,
}
#[allow(dead_code)]
struct StructInfo {
fields: Vec<(String, QalaType)>,
span: Span,
}
#[allow(dead_code)]
struct EnumInfo {
variants: Vec<(String, Vec<QalaType>)>,
span: Span,
}
#[allow(dead_code)]
struct InterfaceInfo {
methods: Vec<MethodSigInfo>,
span: Span,
}
#[allow(dead_code)]
struct MethodSigInfo {
name: String,
params: Vec<QalaType>,
ret_ty: QalaType,
effect: Option<EffectSet>,
span: Span,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct FnKey {
type_name: Option<String>,
name: String,
}
#[allow(dead_code)]
struct FnInfo {
params: Vec<(String, QalaType, Option<ast::Expr>)>,
ret_ty: QalaType,
annotated_effect: Option<EffectSet>,
inferred_effect: Option<EffectSet>,
span: Span,
is_method: bool,
}
#[allow(dead_code)]
struct LocalInfo {
ty: QalaType,
span: Span,
is_mut: bool,
used: bool,
is_param: bool,
}
#[allow(dead_code)]
struct FnCtx {
name: String,
type_name: Option<String>,
ret_ty: QalaType,
annotated_effect: Option<EffectSet>,
body_intrinsic: EffectSet,
called_fns: Vec<FnKey>,
callsites_to_check: Vec<EffectViolationCandidate>,
}
#[allow(dead_code)]
#[derive(Clone)]
struct EffectViolationCandidate {
caller_key: FnKey,
callee_key: FnKey,
call_span: Span,
}
#[allow(dead_code)]
struct Checker<'src> {
src: &'src str,
line_index: LineIndex,
symbols: SymbolTable,
struct_decl_order: Vec<String>,
collected_enum_names: Vec<String>,
collected_interface_names: Vec<String>,
errors: Vec<QalaError>,
warnings: Vec<QalaWarning>,
allow: HashMap<usize, BTreeSet<String>>,
scopes: Vec<HashMap<String, LocalInfo>>,
fn_ctx: Option<FnCtx>,
depth: u32,
body_records: HashMap<FnKey, BodyEffectRecord>,
}
impl<'src> Checker<'src> {
fn new(src: &'src str) -> Self {
Checker {
src,
line_index: LineIndex::new(src),
symbols: SymbolTable::default(),
struct_decl_order: Vec::new(),
collected_enum_names: Vec::new(),
collected_interface_names: Vec::new(),
errors: Vec::new(),
warnings: Vec::new(),
allow: HashMap::new(),
scopes: Vec::new(),
fn_ctx: None,
depth: 0,
body_records: HashMap::new(),
}
}
}
impl<'src> Checker<'src> {
fn preregister_type_names(&mut self, ast: &ast::Ast) {
for item in ast {
match item {
ast::Item::Struct(d) => {
self.symbols
.structs
.entry(d.name.clone())
.or_insert(StructInfo {
fields: Vec::new(),
span: d.span,
});
}
ast::Item::Enum(d) => {
self.symbols
.enums
.entry(d.name.clone())
.or_insert(EnumInfo {
variants: Vec::new(),
span: d.span,
});
}
ast::Item::Interface(d) => {
self.symbols
.interfaces
.entry(d.name.clone())
.or_insert(InterfaceInfo {
methods: Vec::new(),
span: d.span,
});
}
ast::Item::Fn(_) => {}
}
}
}
fn collect_item(&mut self, item: &ast::Item) {
match item {
ast::Item::Fn(d) => self.collect_fn(d),
ast::Item::Struct(d) => self.collect_struct(d),
ast::Item::Enum(d) => self.collect_enum(d),
ast::Item::Interface(d) => self.collect_interface(d),
}
}
fn collect_struct(&mut self, decl: &ast::StructDecl) {
if self.struct_decl_order.contains(&decl.name) {
self.errors.push(QalaError::Type {
span: decl.span,
message: format!("duplicate struct definition `{}`", decl.name),
});
return;
}
let mut fields: Vec<(String, QalaType)> = Vec::with_capacity(decl.fields.len());
for f in &decl.fields {
let ty = self.resolve_type_expr(&f.ty);
fields.push((f.name.clone(), ty));
}
self.symbols.structs.insert(
decl.name.clone(),
StructInfo {
fields,
span: decl.span,
},
);
self.struct_decl_order.push(decl.name.clone());
}
fn collect_enum(&mut self, decl: &ast::EnumDecl) {
if self.collected_enum_names.contains(&decl.name) {
self.errors.push(QalaError::Type {
span: decl.span,
message: format!("duplicate enum definition `{}`", decl.name),
});
return;
}
self.collected_enum_names.push(decl.name.clone());
let mut variants: Vec<(String, Vec<QalaType>)> = Vec::with_capacity(decl.variants.len());
for v in &decl.variants {
let fields: Vec<QalaType> =
v.fields.iter().map(|t| self.resolve_type_expr(t)).collect();
variants.push((v.name.clone(), fields));
}
self.symbols.enums.insert(
decl.name.clone(),
EnumInfo {
variants,
span: decl.span,
},
);
}
fn collect_interface(&mut self, decl: &ast::InterfaceDecl) {
if self.collected_interface_names.contains(&decl.name) {
self.errors.push(QalaError::Type {
span: decl.span,
message: format!("duplicate interface definition `{}`", decl.name),
});
return;
}
self.collected_interface_names.push(decl.name.clone());
let methods: Vec<MethodSigInfo> = decl
.methods
.iter()
.map(|m| {
let params: Vec<QalaType> = m
.params
.iter()
.map(|p| {
if p.is_self {
QalaType::Unknown
} else if let Some(ty) = &p.ty {
self.resolve_type_expr(ty)
} else {
QalaType::Unknown
}
})
.collect();
let ret_ty = m
.ret_ty
.as_ref()
.map(|t| self.resolve_type_expr(t))
.unwrap_or(QalaType::Void);
let effect = m.effect.as_ref().map(effect_set_from_ast);
MethodSigInfo {
name: m.name.clone(),
params,
ret_ty,
effect,
span: m.span,
}
})
.collect();
self.symbols.interfaces.insert(
decl.name.clone(),
InterfaceInfo {
methods,
span: decl.span,
},
);
}
fn collect_fn(&mut self, decl: &ast::FnDecl) {
let key = FnKey {
type_name: decl.type_name.clone(),
name: decl.name.clone(),
};
if self.symbols.fns.contains_key(&key) {
let label = match &decl.type_name {
Some(t) => format!("{}.{}", t, decl.name),
None => decl.name.clone(),
};
self.errors.push(QalaError::Type {
span: decl.span,
message: format!("duplicate function definition `{label}`"),
});
return;
}
let mut params: Vec<(String, QalaType, Option<ast::Expr>)> =
Vec::with_capacity(decl.params.len());
for p in &decl.params {
let ty = if p.is_self {
match &decl.type_name {
Some(t) => QalaType::Named(Symbol(t.clone())),
None => QalaType::Unknown,
}
} else if let Some(t) = &p.ty {
self.resolve_type_expr(t)
} else {
QalaType::Unknown
};
params.push((p.name.clone(), ty, p.default.clone()));
}
let ret_ty = decl
.ret_ty
.as_ref()
.map(|t| self.resolve_type_expr(t))
.unwrap_or(QalaType::Void);
let annotated_effect = decl.effect.as_ref().map(effect_set_from_ast);
let is_method = decl.type_name.is_some();
self.symbols.fns.insert(
key,
FnInfo {
params,
ret_ty,
annotated_effect,
inferred_effect: None,
span: decl.span,
is_method,
},
);
}
fn resolve_type_expr(&mut self, ty: &ast::TypeExpr) -> QalaType {
match ty {
ast::TypeExpr::Primitive { kind, .. } => QalaType::from_prim_type(kind),
ast::TypeExpr::Named { name, span } => {
if name == "Result" {
self.errors.push(QalaError::Type {
span: *span,
message: "Result requires two type arguments".to_string(),
});
return QalaType::Unknown;
}
if name == "Option" {
self.errors.push(QalaError::Type {
span: *span,
message: "Option requires one type argument".to_string(),
});
return QalaType::Unknown;
}
if name == "FileHandle" {
return QalaType::FileHandle;
}
if self.symbols.structs.contains_key(name)
|| self.symbols.enums.contains_key(name)
|| self.symbols.interfaces.contains_key(name)
{
return QalaType::Named(Symbol(name.clone()));
}
self.errors.push(QalaError::UnknownType {
span: *span,
name: name.clone(),
});
QalaType::Unknown
}
ast::TypeExpr::Array { elem, size, .. } => {
QalaType::Array(Box::new(self.resolve_type_expr(elem)), Some(*size as usize))
}
ast::TypeExpr::DynArray { elem, .. } => {
QalaType::Array(Box::new(self.resolve_type_expr(elem)), None)
}
ast::TypeExpr::Tuple { elems, .. } => {
QalaType::Tuple(elems.iter().map(|e| self.resolve_type_expr(e)).collect())
}
ast::TypeExpr::Fn { params, ret, .. } => QalaType::Function {
params: params.iter().map(|p| self.resolve_type_expr(p)).collect(),
returns: Box::new(self.resolve_type_expr(ret)),
},
ast::TypeExpr::Generic { name, args, span } => {
if name == "Result" {
if args.len() != 2 {
self.errors.push(QalaError::Type {
span: *span,
message: "Result requires two type arguments".to_string(),
});
return QalaType::Unknown;
}
let ok = self.resolve_type_expr(&args[0]);
let err = self.resolve_type_expr(&args[1]);
return QalaType::Result(Box::new(ok), Box::new(err));
}
if name == "Option" {
if args.len() != 1 {
self.errors.push(QalaError::Type {
span: *span,
message: "Option requires one type argument".to_string(),
});
return QalaType::Unknown;
}
let inner = self.resolve_type_expr(&args[0]);
return QalaType::Option(Box::new(inner));
}
self.errors.push(QalaError::Type {
span: *span,
message: format!("unknown generic type `{name}`"),
});
QalaType::Unknown
}
}
}
}
fn effect_set_from_ast(e: &ast::Effect) -> EffectSet {
match e {
ast::Effect::Pure => EffectSet::pure(),
ast::Effect::Io => EffectSet::io(),
ast::Effect::Alloc => EffectSet::alloc(),
ast::Effect::Panic => EffectSet::panic(),
}
}
impl<'src> Checker<'src> {
fn detect_recursive_structs(&mut self) {
let mut order = self.struct_decl_order.clone();
order.sort();
let mut adj: HashMap<String, Vec<String>> = HashMap::new();
for name in &order {
let mut targets: BTreeSet<String> = BTreeSet::new();
if let Some(info) = self.symbols.structs.get(name) {
for (_, ty) in &info.fields {
collect_by_value_targets(ty, &mut targets);
}
}
let neighbours: Vec<String> = targets
.into_iter()
.filter(|n| self.symbols.structs.contains_key(n))
.collect();
adj.insert(name.clone(), neighbours);
}
let sccs = tarjan_scc(&order, &adj);
for scc in sccs {
let is_cycle = scc.len() > 1
|| (scc.len() == 1
&& adj
.get(&scc[0])
.map(|v| v.contains(&scc[0]))
.unwrap_or(false));
if !is_cycle {
continue;
}
let head = scc.iter().min().cloned().unwrap_or_else(|| scc[0].clone());
let scc_set: BTreeSet<String> = scc.iter().cloned().collect();
let mut path: Vec<String> = vec![head.clone()];
let mut current = head.clone();
for _ in 0..=scc.len() {
let next: Option<String> = adj
.get(¤t)
.and_then(|nbrs| nbrs.iter().filter(|n| scc_set.contains(*n)).min().cloned());
let Some(next_name) = next else {
break;
};
path.push(next_name.clone());
if next_name == head {
break;
}
current = next_name;
}
let span = self
.symbols
.structs
.get(&head)
.map(|s| s.span)
.unwrap_or(Span::new(0, 0));
self.errors
.push(QalaError::RecursiveStructByValue { span, path });
}
}
}
fn collect_by_value_targets(ty: &QalaType, out: &mut BTreeSet<String>) {
match ty {
QalaType::Named(Symbol(name)) => {
out.insert(name.clone());
}
QalaType::Tuple(elems) => {
for e in elems {
collect_by_value_targets(e, out);
}
}
QalaType::Array(inner, Some(n)) if *n > 0 => {
collect_by_value_targets(inner, out);
}
_ => {}
}
}
fn tarjan_scc(nodes: &[String], adj: &HashMap<String, Vec<String>>) -> Vec<Vec<String>> {
let mut index_of: HashMap<String, u32> = HashMap::new();
let mut lowlink: HashMap<String, u32> = HashMap::new();
let mut on_stack: HashMap<String, bool> = HashMap::new();
let mut stack: Vec<String> = Vec::new();
let mut next_index: u32 = 0;
let mut sccs: Vec<Vec<String>> = Vec::new();
struct Frame {
node: String,
next_child: usize,
}
for root in nodes {
if index_of.contains_key(root) {
continue;
}
index_of.insert(root.clone(), next_index);
lowlink.insert(root.clone(), next_index);
next_index += 1;
stack.push(root.clone());
on_stack.insert(root.clone(), true);
let mut call_stack: Vec<Frame> = vec![Frame {
node: root.clone(),
next_child: 0,
}];
while let Some(frame) = call_stack.last_mut() {
let v = frame.node.clone();
let empty: Vec<String> = Vec::new();
let children = adj.get(&v).unwrap_or(&empty);
if frame.next_child < children.len() {
let w = children[frame.next_child].clone();
frame.next_child += 1;
if !index_of.contains_key(&w) {
index_of.insert(w.clone(), next_index);
lowlink.insert(w.clone(), next_index);
next_index += 1;
stack.push(w.clone());
on_stack.insert(w.clone(), true);
call_stack.push(Frame {
node: w,
next_child: 0,
});
} else if *on_stack.get(&w).unwrap_or(&false) {
let w_idx = *index_of.get(&w).unwrap_or(&0);
let v_low = *lowlink.get(&v).unwrap_or(&0);
lowlink.insert(v.clone(), v_low.min(w_idx));
}
} else {
let v_idx = *index_of.get(&v).unwrap_or(&0);
let v_low = *lowlink.get(&v).unwrap_or(&0);
if v_low == v_idx {
let mut component: Vec<String> = Vec::new();
while let Some(w) = stack.pop() {
on_stack.insert(w.clone(), false);
let is_v = w == v;
component.push(w);
if is_v {
break;
}
}
sccs.push(component);
}
call_stack.pop();
if let Some(parent_frame) = call_stack.last() {
let p = parent_frame.node.clone();
let p_low = *lowlink.get(&p).unwrap_or(&0);
let v_low = *lowlink.get(&v).unwrap_or(&0);
lowlink.insert(p, p_low.min(v_low));
}
}
}
}
sccs
}
impl<'src> Checker<'src> {
fn check_item(&mut self, item: &ast::Item) -> typed_ast::TypedItem {
match item {
ast::Item::Fn(d) => self.check_fn_decl(d),
ast::Item::Struct(d) => typed_ast::TypedItem::Struct(typed_ast::TypedStructDecl {
name: d.name.clone(),
fields: d
.fields
.iter()
.map(|f| typed_ast::TypedField {
name: f.name.clone(),
ty: resolve_type_silent(&f.ty, &self.symbols),
span: f.span,
})
.collect(),
span: d.span,
}),
ast::Item::Enum(d) => typed_ast::TypedItem::Enum(typed_ast::TypedEnumDecl {
name: d.name.clone(),
variants: d
.variants
.iter()
.map(|v| typed_ast::TypedVariant {
name: v.name.clone(),
fields: v
.fields
.iter()
.map(|t| resolve_type_silent(t, &self.symbols))
.collect(),
span: v.span,
})
.collect(),
span: d.span,
}),
ast::Item::Interface(d) => {
typed_ast::TypedItem::Interface(typed_ast::TypedInterfaceDecl {
name: d.name.clone(),
methods: d
.methods
.iter()
.map(|m| typed_ast::TypedMethodSig {
name: m.name.clone(),
params: m
.params
.iter()
.map(|p| typed_ast::TypedParam {
is_self: p.is_self,
name: p.name.clone(),
ty: if p.is_self {
QalaType::Unknown
} else if let Some(t) = &p.ty {
resolve_type_silent(t, &self.symbols)
} else {
QalaType::Unknown
},
default: None,
span: p.span,
})
.collect(),
ret_ty: m
.ret_ty
.as_ref()
.map(|t| resolve_type_silent(t, &self.symbols))
.unwrap_or(QalaType::Void),
effect: m
.effect
.as_ref()
.map(effect_set_from_ast)
.unwrap_or(EffectSet::pure()),
span: m.span,
})
.collect(),
span: d.span,
})
}
}
}
fn is_resource_returning(name: &str) -> bool {
matches!(name, "open")
}
fn extract_closed_handle(expr: &ast::Expr) -> Option<String> {
match expr {
ast::Expr::Call { callee, args, .. } => {
if let ast::Expr::Ident { name, .. } = callee.as_ref()
&& name == "close"
&& args.len() == 1
&& let ast::Expr::Ident { name: h, .. } = &args[0]
{
return Some(h.clone());
}
None
}
ast::Expr::MethodCall {
receiver,
name,
args,
..
} => {
if name == "close"
&& args.is_empty()
&& let ast::Expr::Ident { name: h, .. } = receiver.as_ref()
{
return Some(h.clone());
}
None
}
_ => None,
}
}
fn check_unmatched_defer(&mut self, block: &ast::Block) {
let mut handle_bindings: Vec<(String, Span)> = Vec::new();
for stmt in &block.stmts {
if let ast::Stmt::Let {
name, init, span, ..
} = stmt
&& let ast::Expr::Call { callee, .. } = init
&& let ast::Expr::Ident { name: cname, .. } = callee.as_ref()
&& Self::is_resource_returning(cname)
{
handle_bindings.push((name.clone(), *span));
}
}
let mut closed: BTreeSet<String> = BTreeSet::new();
for stmt in &block.stmts {
if let ast::Stmt::Defer { expr, .. } = stmt
&& let Some(h) = Self::extract_closed_handle(expr)
{
closed.insert(h);
}
}
for (name, span) in &handle_bindings {
if !closed.contains(name) {
let w = QalaWarning {
category: "unmatched_defer".to_string(),
message: format!(
"resource `{name}` is acquired without a matching `defer close`"
),
span: *span,
note: None,
};
self.emit_warning(w);
}
}
for stmt in &block.stmts {
self.check_unmatched_defer_stmt(stmt);
}
if let Some(boxed) = &block.value
&& let ast::Expr::Block { block: inner, .. } = boxed.as_ref()
{
self.check_unmatched_defer(inner);
}
}
fn check_unmatched_defer_stmt(&mut self, stmt: &ast::Stmt) {
match stmt {
ast::Stmt::If {
then_block,
else_branch,
..
} => {
self.check_unmatched_defer(then_block);
match else_branch {
Some(ast::ElseBranch::Block(b)) => self.check_unmatched_defer(b),
Some(ast::ElseBranch::If(boxed)) => {
self.check_unmatched_defer_stmt(boxed);
}
None => {}
}
}
ast::Stmt::While { body, .. } => self.check_unmatched_defer(body),
ast::Stmt::For { body, .. } => self.check_unmatched_defer(body),
_ => {}
}
}
fn check_fn_decl(&mut self, d: &ast::FnDecl) -> typed_ast::TypedItem {
let ret_ty = d
.ret_ty
.as_ref()
.map(|t| resolve_type_silent(t, &self.symbols))
.unwrap_or(QalaType::Void);
let annotated_effect = d.effect.as_ref().map(effect_set_from_ast);
let mut typed_params: Vec<typed_ast::TypedParam> = Vec::with_capacity(d.params.len());
let mut param_locals: Vec<(String, LocalInfo)> = Vec::with_capacity(d.params.len());
for p in &d.params {
let ty = if p.is_self {
match &d.type_name {
Some(t) => QalaType::Named(Symbol(t.clone())),
None => QalaType::Unknown,
}
} else if let Some(t) = &p.ty {
resolve_type_silent(t, &self.symbols)
} else {
QalaType::Unknown
};
typed_params.push(typed_ast::TypedParam {
is_self: p.is_self,
name: p.name.clone(),
ty: ty.clone(),
default: None,
span: p.span,
});
param_locals.push((
p.name.clone(),
LocalInfo {
ty,
span: p.span,
is_mut: false,
used: false,
is_param: true,
},
));
}
self.fn_ctx = Some(FnCtx {
name: d.name.clone(),
type_name: d.type_name.clone(),
ret_ty: ret_ty.clone(),
annotated_effect,
body_intrinsic: EffectSet::pure(),
called_fns: Vec::new(),
callsites_to_check: Vec::new(),
});
let mut scope: HashMap<String, LocalInfo> = HashMap::new();
for (name, info) in param_locals {
scope.insert(name, info);
}
self.scopes.push(scope);
let body = self.check_block(&d.body, Some(&ret_ty));
self.check_unmatched_defer(&d.body);
if !ret_ty.types_match(&QalaType::Void) && body.value.is_none() {
let ends_in_return = d
.body
.stmts
.last()
.map(|s| matches!(s, ast::Stmt::Return { .. }))
.unwrap_or(false);
if !ends_in_return {
let span = Span::new(d.body.span.end().saturating_sub(1), 1);
self.errors.push(QalaError::MissingReturn {
span,
fn_name: d.name.clone(),
expected: ret_ty.display(),
});
}
}
if let Some(scope) = self.scopes.pop() {
for (name, info) in scope {
if !info.is_param && !info.used && !name.starts_with('_') {
let w = QalaWarning {
category: "unused_var".to_string(),
message: format!("unused variable `{name}`"),
span: info.span,
note: None,
};
self.emit_warning(w);
}
}
}
let ctx = self.fn_ctx.take();
if let Some(ctx) = ctx {
let key = FnKey {
type_name: d.type_name.clone(),
name: d.name.clone(),
};
self.body_records.insert(
key,
BodyEffectRecord {
intrinsic: ctx.body_intrinsic,
called: ctx.called_fns,
callsites_to_check: ctx.callsites_to_check,
},
);
}
typed_ast::TypedItem::Fn(typed_ast::TypedFnDecl {
type_name: d.type_name.clone(),
name: d.name.clone(),
params: typed_params,
ret_ty,
effect: annotated_effect.unwrap_or(EffectSet::pure()),
body,
span: d.span,
})
}
fn check_block(
&mut self,
block: &ast::Block,
expected: Option<&QalaType>,
) -> typed_ast::TypedBlock {
let pushed_scope = !self.scopes.is_empty();
if pushed_scope {
self.scopes.push(HashMap::new());
}
let mut typed_stmts: Vec<typed_ast::TypedStmt> = Vec::with_capacity(block.stmts.len());
let mut terminator: Option<Span> = None;
let mut warned_unreachable = false;
for stmt in &block.stmts {
if terminator.is_some() && !warned_unreachable {
let w = QalaWarning {
category: "unreachable_code".to_string(),
message: "unreachable statement after `return`, `break`, or `continue`"
.to_string(),
span: stmt.span(),
note: None,
};
self.emit_warning(w);
warned_unreachable = true;
}
let typed_stmt = self.check_stmt(stmt);
let is_terminator = matches!(
stmt,
ast::Stmt::Return { .. } | ast::Stmt::Break { .. } | ast::Stmt::Continue { .. }
);
if is_terminator && terminator.is_none() {
terminator = Some(stmt.span());
}
typed_stmts.push(typed_stmt);
}
let (typed_value, value_ty) = match &block.value {
Some(v) => {
let typed = match expected {
Some(ty) => self.check_expr(v, ty),
None => self.infer_expr(v),
};
let ty = typed.ty().clone();
(Some(Box::new(typed)), ty)
}
None => (None, QalaType::Void),
};
if pushed_scope && let Some(scope) = self.scopes.pop() {
for (name, info) in scope {
if !info.is_param && !info.used && !name.starts_with('_') {
let w = QalaWarning {
category: "unused_var".to_string(),
message: format!("unused variable `{name}`"),
span: info.span,
note: None,
};
self.emit_warning(w);
}
}
}
typed_ast::TypedBlock {
stmts: typed_stmts,
value: typed_value,
ty: value_ty,
span: block.span,
}
}
fn check_stmt(&mut self, stmt: &ast::Stmt) -> typed_ast::TypedStmt {
match stmt {
ast::Stmt::Let {
is_mut,
name,
ty,
init,
span,
} => {
let (typed_init, decl_ty) = match ty {
Some(t) => {
let expected = resolve_type_silent(t, &self.symbols);
let typed_init = self.check_expr(init, &expected);
if types_strictly_equal(typed_init.ty(), &expected) {
let w = QalaWarning {
category: "redundant_annotation".to_string(),
message: format!(
"redundant type annotation: `{}` matches the inferred type",
expected.display()
),
span: t.span(),
note: None,
};
self.emit_warning(w);
}
if let QalaType::Named(Symbol(iname)) = &expected
&& self.symbols.interfaces.contains_key(iname)
&& let QalaType::Named(Symbol(_)) = typed_init.ty()
{
let init_ty = typed_init.ty().clone();
self.check_satisfies(&init_ty, iname, *span);
}
(typed_init, expected)
}
None => {
let typed_init = self.infer_expr(init);
let ty = typed_init.ty().clone();
(typed_init, ty)
}
};
let topmost = self.scopes.len().saturating_sub(1);
let mut prior_span: Option<Span> = None;
for (idx, scope) in self.scopes.iter().enumerate() {
if idx == topmost {
break;
}
if let Some(prior) = scope.get(name) {
prior_span = Some(prior.span);
break;
}
}
if let Some(prior) = prior_span {
let (l, c) = self.line_index.location(self.src, prior.start as usize);
let w = QalaWarning {
category: "shadowed_var".to_string(),
message: format!("`{name}` shadows a binding from an outer scope"),
span: *span,
note: Some(format!("the prior binding is at line {l}:{c}")),
};
self.emit_warning(w);
}
if let Some(scope) = self.scopes.last_mut() {
scope.insert(
name.clone(),
LocalInfo {
ty: decl_ty.clone(),
span: *span,
is_mut: *is_mut,
used: false,
is_param: false,
},
);
}
typed_ast::TypedStmt::Let {
is_mut: *is_mut,
name: name.clone(),
ty: decl_ty,
init: typed_init,
span: *span,
}
}
ast::Stmt::If {
cond,
then_block,
else_branch,
span,
} => {
let typed_cond = self.check_expr(cond, &QalaType::Bool);
let typed_then = self.check_block(then_block, None);
let typed_else = match else_branch {
Some(ast::ElseBranch::Block(b)) => {
Some(typed_ast::TypedElseBranch::Block(self.check_block(b, None)))
}
Some(ast::ElseBranch::If(b)) => {
let inner = self.check_stmt(b);
Some(typed_ast::TypedElseBranch::If(Box::new(inner)))
}
None => None,
};
typed_ast::TypedStmt::If {
cond: typed_cond,
then_block: typed_then,
else_branch: typed_else,
span: *span,
}
}
ast::Stmt::While { cond, body, span } => {
let typed_cond = self.check_expr(cond, &QalaType::Bool);
let typed_body = self.check_block(body, None);
typed_ast::TypedStmt::While {
cond: typed_cond,
body: typed_body,
span: *span,
}
}
ast::Stmt::For {
var,
iter,
body,
span,
} => {
let typed_iter = self.infer_expr(iter);
let var_ty = match typed_iter.ty() {
QalaType::Array(elem, _) => (**elem).clone(),
QalaType::Unknown => QalaType::Unknown,
_ => {
self.errors.push(QalaError::Type {
span: iter.span(),
message: "for loop expects an array or range".to_string(),
});
QalaType::Unknown
}
};
let mut scope: HashMap<String, LocalInfo> = HashMap::new();
scope.insert(
var.clone(),
LocalInfo {
ty: var_ty.clone(),
span: *span,
is_mut: false,
used: false,
is_param: true,
},
);
self.scopes.push(scope);
let typed_body = self.check_block(body, None);
self.scopes.pop();
typed_ast::TypedStmt::For {
var: var.clone(),
var_ty,
iter: typed_iter,
body: typed_body,
span: *span,
}
}
ast::Stmt::Return { value, span } => {
let typed_value = match value {
Some(v) => {
let expected = self.fn_ctx.as_ref().map(|c| c.ret_ty.clone());
let typed = match expected {
Some(ty) => self.check_expr(v, &ty),
None => self.infer_expr(v),
};
Some(typed)
}
None => {
if let Some(ctx) = &self.fn_ctx
&& !ctx.ret_ty.types_match(&QalaType::Void)
{
self.errors.push(QalaError::TypeMismatch {
span: *span,
expected: ctx.ret_ty.display(),
found: "void".to_string(),
});
}
None
}
};
typed_ast::TypedStmt::Return {
value: typed_value,
span: *span,
}
}
ast::Stmt::Break { span } => typed_ast::TypedStmt::Break { span: *span },
ast::Stmt::Continue { span } => typed_ast::TypedStmt::Continue { span: *span },
ast::Stmt::Defer { expr, span } => {
let typed_expr = self.infer_expr(expr);
typed_ast::TypedStmt::Defer {
expr: typed_expr,
span: *span,
}
}
ast::Stmt::Expr { expr, span } => {
let typed_expr = self.infer_expr(expr);
typed_ast::TypedStmt::Expr {
expr: typed_expr,
span: *span,
}
}
}
}
fn lookup(&mut self, name: &str) -> Option<QalaType> {
for scope in self.scopes.iter_mut().rev() {
if let Some(info) = scope.get_mut(name) {
info.used = true;
return Some(info.ty.clone());
}
}
let key = FnKey {
type_name: None,
name: name.to_string(),
};
if let Some(info) = self.symbols.fns.get(&key) {
let params: Vec<QalaType> = info.params.iter().map(|(_, ty, _)| ty.clone()).collect();
return Some(QalaType::Function {
params,
returns: Box::new(info.ret_ty.clone()),
});
}
let stdlib = stdlib_signatures();
if let Some(entry) = stdlib
.iter()
.find(|e| e.type_name.is_none() && e.name == name)
{
return Some(QalaType::Function {
params: entry.params.clone(),
returns: Box::new(entry.ret_ty.clone()),
});
}
None
}
fn infer_expr(&mut self, expr: &ast::Expr) -> typed_ast::TypedExpr {
self.depth = self.depth.saturating_add(1);
if self.depth > MAX_DEPTH {
self.errors.push(QalaError::Type {
span: expr.span(),
message: "expression nests too deeply".to_string(),
});
self.depth = self.depth.saturating_sub(1);
return typed_ast::TypedExpr::Int {
value: 0,
ty: QalaType::Unknown,
span: expr.span(),
};
}
let out = self.infer_expr_inner(expr);
self.depth = self.depth.saturating_sub(1);
out
}
fn infer_expr_inner(&mut self, expr: &ast::Expr) -> typed_ast::TypedExpr {
match expr {
ast::Expr::Int { value, span } => typed_ast::TypedExpr::Int {
value: *value,
ty: QalaType::I64,
span: *span,
},
ast::Expr::Float { value, span } => typed_ast::TypedExpr::Float {
value: *value,
ty: QalaType::F64,
span: *span,
},
ast::Expr::Byte { value, span } => typed_ast::TypedExpr::Byte {
value: *value,
ty: QalaType::Byte,
span: *span,
},
ast::Expr::Str { value, span } => typed_ast::TypedExpr::Str {
value: value.clone(),
ty: QalaType::Str,
span: *span,
},
ast::Expr::Bool { value, span } => typed_ast::TypedExpr::Bool {
value: *value,
ty: QalaType::Bool,
span: *span,
},
ast::Expr::Ident { name, span } => {
if let Some(ty) = self.lookup(name) {
return typed_ast::TypedExpr::Ident {
name: name.clone(),
ty,
span: *span,
};
}
let mut variant_match: Option<String> = None;
for (enum_name, info) in &self.symbols.enums {
if info
.variants
.iter()
.any(|(vn, fields)| vn == name && fields.is_empty())
{
variant_match = Some(enum_name.clone());
break;
}
}
if let Some(enum_name) = variant_match {
return typed_ast::TypedExpr::Ident {
name: name.clone(),
ty: QalaType::Named(Symbol(enum_name)),
span: *span,
};
}
self.errors.push(QalaError::UndefinedName {
span: *span,
name: name.clone(),
});
typed_ast::TypedExpr::Ident {
name: name.clone(),
ty: QalaType::Unknown,
span: *span,
}
}
ast::Expr::Paren { inner, span } => {
let typed_inner = self.infer_expr(inner);
let ty = typed_inner.ty().clone();
typed_ast::TypedExpr::Paren {
inner: Box::new(typed_inner),
ty,
span: *span,
}
}
ast::Expr::Tuple { elems, span } => {
let typed_elems: Vec<typed_ast::TypedExpr> =
elems.iter().map(|e| self.infer_expr(e)).collect();
let ty = QalaType::Tuple(typed_elems.iter().map(|e| e.ty().clone()).collect());
typed_ast::TypedExpr::Tuple {
elems: typed_elems,
ty,
span: *span,
}
}
ast::Expr::ArrayLit { elems, span } => {
if elems.is_empty() {
self.errors.push(QalaError::Type {
span: *span,
message: "cannot infer element type of empty array".to_string(),
});
return typed_ast::TypedExpr::ArrayLit {
elems: Vec::new(),
ty: QalaType::Array(Box::new(QalaType::Unknown), Some(0)),
span: *span,
};
}
let first = self.infer_expr(&elems[0]);
let elem_ty = first.ty().clone();
let mut typed_elems = vec![first];
for e in &elems[1..] {
let typed = self.check_expr(e, &elem_ty);
typed_elems.push(typed);
}
let len = typed_elems.len();
typed_ast::TypedExpr::ArrayLit {
elems: typed_elems,
ty: QalaType::Array(Box::new(elem_ty), Some(len)),
span: *span,
}
}
ast::Expr::ArrayRepeat { value, count, span } => {
let typed_value = self.infer_expr(value);
let typed_count = self.check_expr(count, &QalaType::I64);
let elem_ty = typed_value.ty().clone();
typed_ast::TypedExpr::ArrayRepeat {
value: Box::new(typed_value),
count: Box::new(typed_count),
ty: QalaType::Array(Box::new(elem_ty), None),
span: *span,
}
}
ast::Expr::StructLit { name, fields, span } => {
self.check_struct_lit(name, fields, *span)
}
ast::Expr::FieldAccess { obj, name, span } => {
let typed_obj = self.infer_expr(obj);
let obj_ty = typed_obj.ty().clone();
let field_ty = match &obj_ty {
QalaType::Named(Symbol(s)) => {
if let Some(info) = self.symbols.structs.get(s) {
match info.fields.iter().find(|(fn_, _)| fn_ == name) {
Some((_, t)) => t.clone(),
None => {
self.errors.push(QalaError::Type {
span: *span,
message: format!(
"no field `{name}` on type `{}`",
obj_ty.display()
),
});
QalaType::Unknown
}
}
} else {
self.errors.push(QalaError::Type {
span: *span,
message: format!(
"no field `{name}` on type `{}`",
obj_ty.display()
),
});
QalaType::Unknown
}
}
QalaType::Unknown => QalaType::Unknown,
_ => {
self.errors.push(QalaError::Type {
span: *span,
message: format!("no field `{name}` on type `{}`", obj_ty.display()),
});
QalaType::Unknown
}
};
typed_ast::TypedExpr::FieldAccess {
obj: Box::new(typed_obj),
name: name.clone(),
ty: field_ty,
span: *span,
}
}
ast::Expr::MethodCall {
receiver,
name,
args,
span,
} => self.check_method_call(receiver, name, args, *span),
ast::Expr::Call { callee, args, span } => self.check_call(callee, args, *span),
ast::Expr::Index { obj, index, span } => {
let typed_obj = self.infer_expr(obj);
let typed_index = self.check_expr(index, &QalaType::I64);
let elem_ty = match typed_obj.ty() {
QalaType::Array(elem, _) => (**elem).clone(),
QalaType::Unknown => QalaType::Unknown,
other => {
self.errors.push(QalaError::Type {
span: *span,
message: format!("cannot index `{}`", other.display()),
});
QalaType::Unknown
}
};
typed_ast::TypedExpr::Index {
obj: Box::new(typed_obj),
index: Box::new(typed_index),
ty: elem_ty,
span: *span,
}
}
ast::Expr::Try { expr: inner, span } => {
let typed_inner = self.infer_expr(inner);
let (success_ty, ok_for_fn) = match typed_inner.ty() {
QalaType::Result(ok, _) => ((**ok).clone(), true),
QalaType::Option(t) => ((**t).clone(), true),
QalaType::Unknown => (QalaType::Unknown, true),
_ => {
self.errors.push(QalaError::Type {
span: *span,
message: format!(
"`?` operand must be Result or Option, found `{}`",
typed_inner.ty().display()
),
});
(QalaType::Unknown, false)
}
};
if ok_for_fn && let Some(ctx) = &self.fn_ctx {
let ok = matches!(
&ctx.ret_ty,
QalaType::Result(_, _) | QalaType::Option(_) | QalaType::Unknown
);
if !ok {
self.errors.push(QalaError::RedundantQuestionOperator {
span: *span,
message: format!(
"`?` outside a Result-returning or Option-returning function (return type is `{}`); change the return type to `Result<_, _>` or `Option<_>`",
ctx.ret_ty.display()
),
});
}
}
typed_ast::TypedExpr::Try {
expr: Box::new(typed_inner),
ty: success_ty,
span: *span,
}
}
ast::Expr::Unary { op, operand, span } => {
use ast::UnaryOp;
let typed_operand = self.infer_expr(operand);
let op_ty = typed_operand.ty().clone();
let ty = match op {
UnaryOp::Not => {
if !op_ty.types_match(&QalaType::Bool) {
self.errors.push(QalaError::TypeMismatch {
span: operand.span(),
expected: "bool".to_string(),
found: op_ty.display(),
});
}
QalaType::Bool
}
UnaryOp::Neg => {
if op_ty.types_match(&QalaType::I64) {
QalaType::I64
} else if op_ty.types_match(&QalaType::F64) {
QalaType::F64
} else if matches!(op_ty, QalaType::Unknown) {
QalaType::Unknown
} else {
self.errors.push(QalaError::TypeMismatch {
span: operand.span(),
expected: "i64 or f64".to_string(),
found: op_ty.display(),
});
QalaType::Unknown
}
}
};
typed_ast::TypedExpr::Unary {
op: op.clone(),
operand: Box::new(typed_operand),
ty,
span: *span,
}
}
ast::Expr::Binary { op, lhs, rhs, span } => self.check_binary(op, lhs, rhs, *span),
ast::Expr::Range {
start,
end,
inclusive,
span,
} => {
let typed_start = start.as_ref().map(|e| self.check_expr(e, &QalaType::I64));
let typed_end = end.as_ref().map(|e| self.check_expr(e, &QalaType::I64));
typed_ast::TypedExpr::Range {
start: typed_start.map(Box::new),
end: typed_end.map(Box::new),
inclusive: *inclusive,
ty: QalaType::Array(Box::new(QalaType::I64), None),
span: *span,
}
}
ast::Expr::Pipeline { lhs, call, span } => {
let typed_lhs = self.infer_expr(lhs);
let lhs_ty = typed_lhs.ty().clone();
let (typed_call, result_ty) = self.check_pipeline_call(call, &lhs_ty);
typed_ast::TypedExpr::Pipeline {
lhs: Box::new(typed_lhs),
call: Box::new(typed_call),
ty: result_ty,
span: *span,
}
}
ast::Expr::Comptime { body, span } => {
let typed_body = self.infer_expr(body);
let ty = typed_body.ty().clone();
typed_ast::TypedExpr::Comptime {
body: Box::new(typed_body),
ty,
span: *span,
}
}
ast::Expr::Block { block, span } => {
let typed_block = self.check_block(block, None);
let ty = typed_block.ty.clone();
typed_ast::TypedExpr::Block {
block: typed_block,
ty,
span: *span,
}
}
ast::Expr::Match {
scrutinee,
arms,
span,
} => self.check_match(scrutinee, arms, *span),
ast::Expr::OrElse {
expr: e,
fallback,
span,
} => {
let typed_e = self.infer_expr(e);
let success_ty = match typed_e.ty() {
QalaType::Result(ok, _) => (**ok).clone(),
QalaType::Option(t) => (**t).clone(),
QalaType::Unknown => QalaType::Unknown,
other => {
self.errors.push(QalaError::Type {
span: e.span(),
message: format!(
"`or` left operand must be Result or Option, found `{}`",
other.display()
),
});
QalaType::Unknown
}
};
let typed_fallback = self.check_expr(fallback, &success_ty);
typed_ast::TypedExpr::OrElse {
expr: Box::new(typed_e),
fallback: Box::new(typed_fallback),
ty: success_ty,
span: *span,
}
}
ast::Expr::Interpolation { parts, span } => {
let typed_parts: Vec<typed_ast::TypedInterpPart> = parts
.iter()
.map(|p| match p {
ast::InterpPart::Literal(s) => {
typed_ast::TypedInterpPart::Literal(s.clone())
}
ast::InterpPart::Expr(e) => {
typed_ast::TypedInterpPart::Expr(self.infer_expr(e))
}
})
.collect();
typed_ast::TypedExpr::Interpolation {
parts: typed_parts,
ty: QalaType::Str,
span: *span,
}
}
}
}
fn check_expr(&mut self, expr: &ast::Expr, expected: &QalaType) -> typed_ast::TypedExpr {
let typed = self.infer_expr(expr);
if !typed.ty().types_match(expected) {
self.errors.push(QalaError::TypeMismatch {
span: typed.span(),
expected: expected.display(),
found: typed.ty().display(),
});
}
typed
}
fn check_struct_lit(
&mut self,
name: &str,
fields: &[ast::FieldInit],
span: Span,
) -> typed_ast::TypedExpr {
let declared: Option<Vec<(String, QalaType)>> =
self.symbols.structs.get(name).map(|s| s.fields.clone());
let Some(declared) = declared else {
self.errors.push(QalaError::UndefinedName {
span,
name: name.to_string(),
});
return typed_ast::TypedExpr::StructLit {
name: name.to_string(),
fields: Vec::new(),
ty: QalaType::Unknown,
span,
};
};
let mut typed_fields: Vec<typed_ast::TypedFieldInit> = Vec::new();
let provided_names: BTreeSet<String> = fields.iter().map(|f| f.name.clone()).collect();
let missing: Vec<String> = declared
.iter()
.filter(|(dn, _)| !provided_names.contains(dn))
.map(|(dn, _)| dn.clone())
.collect();
if !missing.is_empty() {
self.errors.push(QalaError::Type {
span,
message: format!(
"missing field(s) in struct literal `{name}`: {}",
missing.join(", ")
),
});
}
for fi in fields {
let decl_ty: Option<QalaType> = declared
.iter()
.find(|(dn, _)| dn == &fi.name)
.map(|(_, t)| t.clone());
match decl_ty {
Some(t) => {
let typed_value = self.check_expr(&fi.value, &t);
typed_fields.push(typed_ast::TypedFieldInit {
name: fi.name.clone(),
value: typed_value,
span: fi.span,
});
}
None => {
self.errors.push(QalaError::Type {
span: fi.span,
message: format!("no field `{}` on struct `{name}`", fi.name),
});
let typed_value = self.infer_expr(&fi.value);
typed_fields.push(typed_ast::TypedFieldInit {
name: fi.name.clone(),
value: typed_value,
span: fi.span,
});
}
}
}
typed_ast::TypedExpr::StructLit {
name: name.to_string(),
fields: typed_fields,
ty: QalaType::Named(Symbol(name.to_string())),
span,
}
}
fn check_method_call(
&mut self,
receiver: &ast::Expr,
name: &str,
args: &[ast::Expr],
span: Span,
) -> typed_ast::TypedExpr {
let typed_receiver = self.infer_expr(receiver);
let receiver_ty = typed_receiver.ty().clone();
let resolved: Option<(Vec<QalaType>, QalaType, FnKey)> = (|| {
let type_name = match &receiver_ty {
QalaType::Named(Symbol(s)) => Some(s.clone()),
QalaType::FileHandle => Some("FileHandle".to_string()),
_ => None,
};
if let Some(tn) = type_name {
let key = FnKey {
type_name: Some(tn.clone()),
name: name.to_string(),
};
if let Some(info) = self.symbols.fns.get(&key) {
let params: Vec<QalaType> = info
.params
.iter()
.skip(
if info
.params
.first()
.map(|(n, _, _)| n == "self")
.unwrap_or(false)
{
1
} else {
0
},
)
.map(|(_, t, _)| t.clone())
.collect();
return Some((params, info.ret_ty.clone(), key));
}
for entry in stdlib_signatures().iter() {
if entry.type_name.as_deref() == Some(tn.as_str()) && entry.name == name {
let params: Vec<QalaType> = entry.params.clone();
return Some((
params,
entry.ret_ty.clone(),
FnKey {
type_name: Some(tn.clone()),
name: name.to_string(),
},
));
}
}
}
None
})();
let (params, ret_ty) = match resolved {
Some((p, r, key)) => {
if let Some(ctx) = &mut self.fn_ctx {
ctx.called_fns.push(key.clone());
if ctx.annotated_effect.is_some() {
ctx.callsites_to_check.push(EffectViolationCandidate {
caller_key: FnKey {
type_name: ctx.type_name.clone(),
name: ctx.name.clone(),
},
callee_key: key,
call_span: span,
});
}
}
(p, r)
}
None => {
if !matches!(receiver_ty, QalaType::Unknown) {
self.errors.push(QalaError::Type {
span,
message: format!("no method `{name}` on type `{}`", receiver_ty.display()),
});
}
(Vec::new(), QalaType::Unknown)
}
};
let typed_args: Vec<typed_ast::TypedExpr> = if params.is_empty() && args.is_empty() {
Vec::new()
} else {
args.iter()
.enumerate()
.map(|(i, a)| {
if i < params.len() {
self.check_expr(a, ¶ms[i])
} else {
self.infer_expr(a)
}
})
.collect()
};
if args.len() != params.len()
&& !matches!(receiver_ty, QalaType::Unknown)
&& !ret_ty.types_match(&QalaType::Unknown)
{
self.errors.push(QalaError::Type {
span,
message: format!(
"method `{name}` expects {} argument(s), found {}",
params.len(),
args.len()
),
});
}
typed_ast::TypedExpr::MethodCall {
receiver: Box::new(typed_receiver),
name: name.to_string(),
args: typed_args,
ty: ret_ty,
span,
}
}
fn check_call(
&mut self,
callee: &ast::Expr,
args: &[ast::Expr],
span: Span,
) -> typed_ast::TypedExpr {
if let ast::Expr::Ident { name, .. } = callee {
if let Some(ctor_ty) = self.try_builtin_constructor(name, args, span) {
let typed_callee = typed_ast::TypedExpr::Ident {
name: name.clone(),
ty: QalaType::Function {
params: Vec::new(),
returns: Box::new(ctor_ty.0.clone()),
},
span: callee.span(),
};
return typed_ast::TypedExpr::Call {
callee: Box::new(typed_callee),
args: ctor_ty.1,
ty: ctor_ty.0,
span,
};
}
if let Some((enum_name, fields)) = self.find_enum_variant(name) {
let typed_args: Vec<typed_ast::TypedExpr> = args
.iter()
.enumerate()
.map(|(i, a)| {
if i < fields.len() {
self.check_expr(a, &fields[i])
} else {
self.infer_expr(a)
}
})
.collect();
if args.len() != fields.len() {
self.errors.push(QalaError::Type {
span,
message: format!(
"variant `{name}` of `{enum_name}` expects {} argument(s), found {}",
fields.len(),
args.len()
),
});
}
let typed_callee = typed_ast::TypedExpr::Ident {
name: name.clone(),
ty: QalaType::Function {
params: fields.clone(),
returns: Box::new(QalaType::Named(Symbol(enum_name.clone()))),
},
span: callee.span(),
};
return typed_ast::TypedExpr::Call {
callee: Box::new(typed_callee),
args: typed_args,
ty: QalaType::Named(Symbol(enum_name)),
span,
};
}
if let Some((params, ret_ty, is_generic, name_clone, callee_key)) =
self.resolve_free_callable(name)
{
if let Some(ctx) = &mut self.fn_ctx {
ctx.called_fns.push(callee_key.clone());
if ctx.annotated_effect.is_some() {
ctx.callsites_to_check.push(EffectViolationCandidate {
caller_key: FnKey {
type_name: ctx.type_name.clone(),
name: ctx.name.clone(),
},
callee_key,
call_span: span,
});
}
}
let typed_callee = typed_ast::TypedExpr::Ident {
name: name_clone.clone(),
ty: QalaType::Function {
params: params.clone(),
returns: Box::new(ret_ty.clone()),
},
span: callee.span(),
};
let typed_args = self.check_call_args(args, ¶ms, is_generic, &name_clone, span);
if name_clone == "abs"
&& let Some(arg0) = typed_args.first()
{
let arg_ty = arg0.ty();
if !matches!(arg_ty, QalaType::I64 | QalaType::F64 | QalaType::Unknown) {
self.errors.push(QalaError::TypeMismatch {
span,
expected: "i64 or f64".to_string(),
found: arg_ty.display(),
});
}
}
let result_ty = self.resolve_generic_return_ty(&name_clone, &ret_ty, &typed_args);
return typed_ast::TypedExpr::Call {
callee: Box::new(typed_callee),
args: typed_args,
ty: result_ty,
span,
};
}
self.errors.push(QalaError::UndefinedName {
span: callee.span(),
name: name.clone(),
});
let typed_callee = typed_ast::TypedExpr::Ident {
name: name.clone(),
ty: QalaType::Unknown,
span: callee.span(),
};
let typed_args: Vec<typed_ast::TypedExpr> =
args.iter().map(|a| self.infer_expr(a)).collect();
return typed_ast::TypedExpr::Call {
callee: Box::new(typed_callee),
args: typed_args,
ty: QalaType::Unknown,
span,
};
}
let typed_callee = self.infer_expr(callee);
let (params, ret_ty) = match typed_callee.ty() {
QalaType::Function { params, returns } => (params.clone(), (**returns).clone()),
_ => {
self.errors.push(QalaError::Type {
span,
message: format!(
"cannot call value of type `{}`",
typed_callee.ty().display()
),
});
(Vec::new(), QalaType::Unknown)
}
};
let typed_args: Vec<typed_ast::TypedExpr> = args
.iter()
.enumerate()
.map(|(i, a)| {
if i < params.len() {
self.check_expr(a, ¶ms[i])
} else {
self.infer_expr(a)
}
})
.collect();
typed_ast::TypedExpr::Call {
callee: Box::new(typed_callee),
args: typed_args,
ty: ret_ty,
span,
}
}
fn check_call_args(
&mut self,
args: &[ast::Expr],
params: &[QalaType],
is_generic: bool,
name: &str,
call_span: Span,
) -> Vec<typed_ast::TypedExpr> {
if args.len() != params.len() {
if !is_generic {
self.errors.push(QalaError::Type {
span: call_span,
message: format!(
"function `{name}` expects {} argument(s), found {}",
params.len(),
args.len()
),
});
}
}
args.iter()
.enumerate()
.map(|(i, a)| {
if !is_generic && i < params.len() {
self.check_expr(a, ¶ms[i])
} else {
self.infer_expr(a)
}
})
.collect()
}
fn resolve_generic_return_ty(
&self,
name: &str,
ret_ty: &QalaType,
typed_args: &[typed_ast::TypedExpr],
) -> QalaType {
match name {
"len" => QalaType::I64,
"push" => QalaType::Void,
"abs" => match typed_args.first().map(|a| a.ty()) {
Some(QalaType::I64) => QalaType::I64,
Some(QalaType::F64) => QalaType::F64,
_ => QalaType::Unknown,
},
"pop" => {
if let Some(arg0) = typed_args.first()
&& let QalaType::Array(elem, _) = arg0.ty()
{
return QalaType::Option(elem.clone());
}
QalaType::Option(Box::new(QalaType::Unknown))
}
"type_of" => QalaType::Str,
"map" => {
let u = typed_args.get(1).and_then(|f| match f.ty() {
QalaType::Function { returns, .. } => Some((**returns).clone()),
_ => None,
});
QalaType::Array(Box::new(u.unwrap_or(QalaType::Unknown)), None)
}
"filter" => {
if let Some(arg0) = typed_args.first() {
return arg0.ty().clone();
}
QalaType::Array(Box::new(QalaType::Unknown), None)
}
"reduce" => {
if let Some(arg1) = typed_args.get(1) {
return arg1.ty().clone();
}
QalaType::Unknown
}
_ => ret_ty.clone(),
}
}
fn find_enum_variant(&self, variant_name: &str) -> Option<(String, Vec<QalaType>)> {
for (enum_name, info) in &self.symbols.enums {
for (vname, fields) in &info.variants {
if vname == variant_name {
return Some((enum_name.clone(), fields.clone()));
}
}
}
None
}
#[allow(clippy::type_complexity)]
fn resolve_free_callable(
&self,
name: &str,
) -> Option<(Vec<QalaType>, QalaType, bool, String, FnKey)> {
let key = FnKey {
type_name: None,
name: name.to_string(),
};
if let Some(info) = self.symbols.fns.get(&key) {
let params: Vec<QalaType> = info.params.iter().map(|(_, t, _)| t.clone()).collect();
return Some((params, info.ret_ty.clone(), false, name.to_string(), key));
}
for entry in stdlib_signatures().iter() {
if entry.type_name.is_none() && entry.name == name {
return Some((
entry.params.clone(),
entry.ret_ty.clone(),
entry.is_generic,
name.to_string(),
FnKey {
type_name: None,
name: name.to_string(),
},
));
}
}
None
}
fn try_builtin_constructor(
&mut self,
name: &str,
args: &[ast::Expr],
span: Span,
) -> Option<(QalaType, Vec<typed_ast::TypedExpr>)> {
match name {
"Ok" => {
if args.len() != 1 {
self.errors.push(QalaError::Type {
span,
message: format!("`Ok` expects 1 argument, found {}", args.len()),
});
let typed_args: Vec<_> = args.iter().map(|a| self.infer_expr(a)).collect();
return Some((
QalaType::Result(Box::new(QalaType::Unknown), Box::new(QalaType::Unknown)),
typed_args,
));
}
let typed_arg = self.infer_expr(&args[0]);
let ok_ty = typed_arg.ty().clone();
Some((
QalaType::Result(Box::new(ok_ty), Box::new(QalaType::Unknown)),
vec![typed_arg],
))
}
"Err" => {
if args.len() != 1 {
self.errors.push(QalaError::Type {
span,
message: format!("`Err` expects 1 argument, found {}", args.len()),
});
let typed_args: Vec<_> = args.iter().map(|a| self.infer_expr(a)).collect();
return Some((
QalaType::Result(Box::new(QalaType::Unknown), Box::new(QalaType::Unknown)),
typed_args,
));
}
let typed_arg = self.infer_expr(&args[0]);
let err_ty = typed_arg.ty().clone();
Some((
QalaType::Result(Box::new(QalaType::Unknown), Box::new(err_ty)),
vec![typed_arg],
))
}
"Some" => {
if args.len() != 1 {
self.errors.push(QalaError::Type {
span,
message: format!("`Some` expects 1 argument, found {}", args.len()),
});
let typed_args: Vec<_> = args.iter().map(|a| self.infer_expr(a)).collect();
return Some((QalaType::Option(Box::new(QalaType::Unknown)), typed_args));
}
let typed_arg = self.infer_expr(&args[0]);
let inner = typed_arg.ty().clone();
Some((QalaType::Option(Box::new(inner)), vec![typed_arg]))
}
"None" => {
if !args.is_empty() {
self.errors.push(QalaError::Type {
span,
message: format!("`None` expects 0 arguments, found {}", args.len()),
});
}
let typed_args: Vec<_> = args.iter().map(|a| self.infer_expr(a)).collect();
Some((QalaType::Option(Box::new(QalaType::Unknown)), typed_args))
}
_ => None,
}
}
fn check_binary(
&mut self,
op: &ast::BinOp,
lhs: &ast::Expr,
rhs: &ast::Expr,
span: Span,
) -> typed_ast::TypedExpr {
use ast::BinOp;
let typed_lhs = self.infer_expr(lhs);
let typed_rhs = self.infer_expr(rhs);
let lty = typed_lhs.ty().clone();
let rty = typed_rhs.ty().clone();
let result_ty = match op {
BinOp::Add => {
if lty.types_match(&QalaType::Str) && rty.types_match(&QalaType::Str) {
QalaType::Str
} else if lty.types_match(&rty)
&& (lty.types_match(&QalaType::I64) || lty.types_match(&QalaType::F64))
{
lty.clone()
} else if matches!(lty, QalaType::Unknown) || matches!(rty, QalaType::Unknown) {
QalaType::Unknown
} else {
self.errors.push(QalaError::TypeMismatch {
span,
expected: lty.display(),
found: rty.display(),
});
QalaType::Unknown
}
}
BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Rem => {
if lty.types_match(&rty)
&& (lty.types_match(&QalaType::I64) || lty.types_match(&QalaType::F64))
{
lty.clone()
} else if matches!(lty, QalaType::Unknown) || matches!(rty, QalaType::Unknown) {
QalaType::Unknown
} else {
self.errors.push(QalaError::TypeMismatch {
span,
expected: lty.display(),
found: rty.display(),
});
QalaType::Unknown
}
}
BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => {
if !lty.types_match(&rty) {
self.errors.push(QalaError::TypeMismatch {
span,
expected: lty.display(),
found: rty.display(),
});
}
QalaType::Bool
}
BinOp::Eq | BinOp::Ne => {
if !lty.types_match(&rty) {
self.errors.push(QalaError::TypeMismatch {
span,
expected: lty.display(),
found: rty.display(),
});
}
QalaType::Bool
}
BinOp::And | BinOp::Or => {
if !lty.types_match(&QalaType::Bool) {
self.errors.push(QalaError::TypeMismatch {
span: lhs.span(),
expected: "bool".to_string(),
found: lty.display(),
});
}
if !rty.types_match(&QalaType::Bool) {
self.errors.push(QalaError::TypeMismatch {
span: rhs.span(),
expected: "bool".to_string(),
found: rty.display(),
});
}
QalaType::Bool
}
};
typed_ast::TypedExpr::Binary {
op: op.clone(),
lhs: Box::new(typed_lhs),
rhs: Box::new(typed_rhs),
ty: result_ty,
span,
}
}
fn check_pipeline_call(
&mut self,
call: &ast::Expr,
lhs_ty: &QalaType,
) -> (typed_ast::TypedExpr, QalaType) {
match call {
ast::Expr::Ident { name, span } => {
if let Some((params, ret_ty, is_generic, name_clone, key)) =
self.resolve_free_callable(name)
{
if let Some(ctx) = &mut self.fn_ctx {
ctx.called_fns.push(key.clone());
if ctx.annotated_effect.is_some() {
ctx.callsites_to_check.push(EffectViolationCandidate {
caller_key: FnKey {
type_name: ctx.type_name.clone(),
name: ctx.name.clone(),
},
callee_key: key,
call_span: *span,
});
}
}
if !is_generic && !params.is_empty() && !lhs_ty.types_match(¶ms[0]) {
self.errors.push(QalaError::TypeMismatch {
span: *span,
expected: params[0].display(),
found: lhs_ty.display(),
});
}
let typed_call = typed_ast::TypedExpr::Ident {
name: name_clone.clone(),
ty: QalaType::Function {
params: params.clone(),
returns: Box::new(ret_ty.clone()),
},
span: *span,
};
return (typed_call, ret_ty);
}
self.errors.push(QalaError::UndefinedName {
span: *span,
name: name.clone(),
});
(
typed_ast::TypedExpr::Ident {
name: name.clone(),
ty: QalaType::Unknown,
span: *span,
},
QalaType::Unknown,
)
}
ast::Expr::Call { callee, args, span } => {
let typed_call = self.check_call(callee, args, *span);
let ret_ty = typed_call.ty().clone();
(typed_call, ret_ty)
}
other => {
let typed = self.infer_expr(other);
let ty = typed.ty().clone();
(typed, ty)
}
}
}
fn check_match(
&mut self,
scrutinee: &ast::Expr,
arms: &[ast::MatchArm],
span: Span,
) -> typed_ast::TypedExpr {
let typed_scrutinee = self.infer_expr(scrutinee);
let scrut_ty = typed_scrutinee.ty().clone();
let mut typed_arms: Vec<typed_ast::TypedMatchArm> = Vec::with_capacity(arms.len());
let mut result_ty: Option<QalaType> = None;
for arm in arms {
self.scopes.push(HashMap::new());
self.bind_pattern(&arm.pattern, &scrut_ty);
let typed_guard = arm
.guard
.as_ref()
.map(|g| self.check_expr(g, &QalaType::Bool));
let typed_body = match &arm.body {
ast::MatchArmBody::Expr(e) => {
let typed = self.infer_expr(e);
typed_ast::TypedMatchArmBody::Expr(Box::new(typed))
}
ast::MatchArmBody::Block(b) => {
typed_ast::TypedMatchArmBody::Block(self.check_block(b, None))
}
};
self.scopes.pop();
let body_ty = match &typed_body {
typed_ast::TypedMatchArmBody::Expr(e) => e.ty().clone(),
typed_ast::TypedMatchArmBody::Block(b) => b.ty.clone(),
};
result_ty = match (result_ty, body_ty.clone()) {
(None, b) => Some(b),
(Some(a), b) if a.types_match(&b) => Some(a),
(Some(_), _) => Some(QalaType::Unknown),
};
typed_arms.push(typed_ast::TypedMatchArm {
pattern: arm.pattern.clone(),
guard: typed_guard,
body: typed_body,
span: arm.span,
});
}
if let QalaType::Named(Symbol(enum_name)) = &scrut_ty
&& self.symbols.enums.contains_key(enum_name)
{
self.check_match_exhaustive(enum_name, arms, span);
} else {
let guarded_binding_arms = arms
.iter()
.filter(|a| matches!(a.pattern, ast::Pattern::Binding { .. }) && a.guard.is_some())
.count();
if guarded_binding_arms > 1 {
let w = QalaWarning {
category: "overlapping_guards".to_string(),
message: "multiple guarded arms cover the same pattern; only the first that matches will run".to_string(),
span,
note: None,
};
self.emit_warning(w);
}
}
let ty = result_ty.unwrap_or(QalaType::Unknown);
typed_ast::TypedExpr::Match {
scrutinee: Box::new(typed_scrutinee),
arms: typed_arms,
ty,
span,
}
}
fn check_match_exhaustive(
&mut self,
enum_name: &str,
arms: &[ast::MatchArm],
match_span: Span,
) {
let variants: Vec<String> = match self.symbols.enums.get(enum_name) {
Some(info) => info.variants.iter().map(|(n, _)| n.clone()).collect(),
None => return,
};
let mut covered: BTreeSet<String> = BTreeSet::new();
let mut has_wildcard = false;
let mut guarded_arms_by_variant: HashMap<String, u32> = HashMap::new();
let mut guarded_binding_arms: u32 = 0;
for arm in arms {
match &arm.pattern {
ast::Pattern::Variant { name, span, .. } => {
if !variants.contains(name) {
self.errors.push(QalaError::Type {
span: *span,
message: format!("variant `{name}` is not part of enum `{enum_name}`"),
});
continue;
}
if arm.guard.is_some() {
*guarded_arms_by_variant.entry(name.clone()).or_insert(0) += 1;
} else {
covered.insert(name.clone());
}
}
ast::Pattern::Wildcard { .. } => {
has_wildcard = true;
}
ast::Pattern::Binding { .. } => {
has_wildcard = true;
if arm.guard.is_some() {
guarded_binding_arms += 1;
}
}
ast::Pattern::Int { span, .. }
| ast::Pattern::Float { span, .. }
| ast::Pattern::Byte { span, .. }
| ast::Pattern::Str { span, .. }
| ast::Pattern::Bool { span, .. } => {
self.errors.push(QalaError::Type {
span: *span,
message: "literal pattern cannot match an enum value".to_string(),
});
}
}
}
if !has_wildcard {
let mut missing: Vec<String> = variants
.iter()
.filter(|v| !covered.contains(*v))
.cloned()
.collect();
if !missing.is_empty() {
missing.sort();
self.errors.push(QalaError::NonExhaustiveMatch {
span: match_span,
enum_name: enum_name.to_string(),
missing,
});
}
}
let any_overlap =
guarded_arms_by_variant.values().any(|c| *c > 1) || guarded_binding_arms > 1;
if any_overlap {
let w = QalaWarning {
category: "overlapping_guards".to_string(),
message: "multiple guarded arms cover the same pattern; only the first that matches will run".to_string(),
span: match_span,
note: None,
};
self.emit_warning(w);
}
}
fn check_satisfies(&mut self, ty: &QalaType, interface_name: &str, use_span: Span) {
let type_name = match ty {
QalaType::Named(Symbol(s)) => s.clone(),
_ => return,
};
let interface: Vec<(String, Vec<QalaType>, QalaType)> =
match self.symbols.interfaces.get(interface_name) {
Some(i) => i
.methods
.iter()
.map(|m| (m.name.clone(), m.params.clone(), m.ret_ty.clone()))
.collect(),
None => return,
};
let mut missing: Vec<String> = Vec::new();
let mut mismatched: Vec<(String, String, String)> = Vec::new();
for (mname, mparams, mret) in interface {
let key = FnKey {
type_name: Some(type_name.clone()),
name: mname.clone(),
};
let impl_info = self.symbols.fns.get(&key);
match impl_info {
None => missing.push(mname),
Some(info) => {
let impl_params: Vec<QalaType> = info
.params
.iter()
.skip(
if info
.params
.first()
.map(|(n, _, _)| n == "self")
.unwrap_or(false)
{
1
} else {
0
},
)
.map(|(_, t, _)| t.clone())
.collect();
let iface_params: Vec<QalaType> = mparams
.iter()
.skip(if matches!(mparams.first(), Some(QalaType::Unknown)) {
1
} else {
0
})
.cloned()
.collect();
let params_match = iface_params.len() == impl_params.len()
&& iface_params
.iter()
.zip(impl_params.iter())
.all(|(a, b)| a.types_match(b));
let ret_match = mret.types_match(&info.ret_ty);
if !params_match || !ret_match {
let expected = format_fn_sig(&iface_params, &mret);
let found = format_fn_sig(&impl_params, &info.ret_ty);
mismatched.push((mname, expected, found));
}
}
}
}
if !missing.is_empty() || !mismatched.is_empty() {
let mut missing_sorted = missing;
missing_sorted.sort();
mismatched.sort_by(|a, b| a.0.cmp(&b.0));
self.errors.push(QalaError::InterfaceNotSatisfied {
span: use_span,
ty: type_name,
interface: interface_name.to_string(),
missing: missing_sorted,
mismatched,
});
}
}
fn bind_pattern(&mut self, pattern: &ast::Pattern, scrut_ty: &QalaType) {
match pattern {
ast::Pattern::Variant { name, sub, span } => {
let mut variant_fields: Option<Vec<QalaType>> = None;
if let QalaType::Named(Symbol(enum_name)) = scrut_ty
&& let Some(info) = self.symbols.enums.get(enum_name)
{
for (vname, fields) in &info.variants {
if vname == name {
variant_fields = Some(fields.clone());
break;
}
}
if variant_fields.is_none() {
self.errors.push(QalaError::Type {
span: *span,
message: format!("variant `{name}` is not part of enum `{enum_name}`"),
});
}
}
if let Some(fields) = variant_fields {
for (i, sub_pat) in sub.iter().enumerate() {
let sub_ty = fields.get(i).cloned().unwrap_or(QalaType::Unknown);
self.bind_pattern(sub_pat, &sub_ty);
}
} else {
for sub_pat in sub {
self.bind_pattern(sub_pat, &QalaType::Unknown);
}
}
}
ast::Pattern::Binding { name, span } => {
if let Some(scope) = self.scopes.last_mut() {
scope.insert(
name.clone(),
LocalInfo {
ty: scrut_ty.clone(),
span: *span,
is_mut: false,
used: false,
is_param: true,
},
);
}
}
_ => {}
}
}
fn resolve_function_effects(&mut self) {
const MAX_ROUNDS: usize = 8;
let keys: Vec<FnKey> = self.body_records.keys().cloned().collect();
for key in &keys {
if let Some(info) = self.symbols.fns.get_mut(key) {
let intrinsic = self
.body_records
.get(key)
.map(|r| r.intrinsic)
.unwrap_or(EffectSet::pure());
info.inferred_effect = Some(intrinsic);
}
}
for round in 0..MAX_ROUNDS {
let mut changed = false;
for key in &keys {
let body_rec = match self.body_records.get(key) {
Some(r) => r,
None => continue,
};
let mut e = body_rec.intrinsic;
for callee_key in &body_rec.called {
if let Some(callee_info) = self.symbols.fns.get(callee_key) {
if let Some(eff) = callee_info.inferred_effect {
e = e.union(eff);
} else if let Some(eff) = callee_info.annotated_effect {
e = e.union(eff);
}
} else if let Some(stdlib_eff) = stdlib_effect(callee_key) {
e = e.union(stdlib_eff);
}
}
if let Some(info_mut) = self.symbols.fns.get_mut(key) {
let old = info_mut.inferred_effect.unwrap_or(EffectSet::pure());
let new = old.union(e);
if new != old {
info_mut.inferred_effect = Some(new);
changed = true;
}
}
}
if !changed {
break;
}
debug_assert!(
round < MAX_ROUNDS - 1,
"effect fixed-point did not converge in {MAX_ROUNDS} rounds"
);
}
let candidates: Vec<EffectViolationCandidate> = keys
.iter()
.flat_map(|k| {
self.body_records
.get(k)
.map(|r| r.callsites_to_check.clone())
.unwrap_or_default()
})
.collect();
for cand in candidates {
let caller_eff = self
.symbols
.fns
.get(&cand.caller_key)
.and_then(|info| info.annotated_effect)
.unwrap_or(EffectSet::full());
let callee_eff = if let Some(callee_info) = self.symbols.fns.get(&cand.callee_key) {
callee_info
.inferred_effect
.or(callee_info.annotated_effect)
.unwrap_or(EffectSet::pure())
} else if let Some(eff) = stdlib_effect(&cand.callee_key) {
eff
} else {
EffectSet::pure()
};
if !callee_eff.is_subset_of(caller_eff) {
self.errors.push(QalaError::EffectViolation {
span: cand.call_span,
caller: cand.caller_key.name.clone(),
caller_effect: caller_eff.display(),
callee: cand.callee_key.name.clone(),
callee_effect: callee_eff.display(),
});
}
}
}
fn emit_warning(&mut self, w: QalaWarning) {
if self.is_silenced(w.span, &w.category) {
return;
}
self.warnings.push(w);
}
fn is_silenced(&self, span: Span, category: &str) -> bool {
let line = self.line_index.location(self.src, span.start as usize).0;
self.allow
.get(&line)
.map(|cats| cats.contains(category))
.unwrap_or(false)
}
}
#[allow(dead_code)]
struct BodyEffectRecord {
intrinsic: EffectSet,
called: Vec<FnKey>,
callsites_to_check: Vec<EffectViolationCandidate>,
}
fn types_strictly_equal(a: &QalaType, b: &QalaType) -> bool {
if matches!(a, QalaType::Unknown) || matches!(b, QalaType::Unknown) {
return false;
}
a == b
}
fn format_fn_sig(params: &[QalaType], ret: &QalaType) -> String {
if params.is_empty() {
format!("fn(self) -> {}", ret.display())
} else {
let p: Vec<String> = params.iter().map(|t| t.display()).collect();
format!("fn(self, {}) -> {}", p.join(", "), ret.display())
}
}
#[allow(dead_code)]
struct StdlibFn {
type_name: Option<String>,
name: String,
params: Vec<QalaType>,
ret_ty: QalaType,
effect: EffectSet,
is_generic: bool,
}
fn scan_allow_directives(src: &str) -> HashMap<usize, BTreeSet<String>> {
let mut out: HashMap<usize, BTreeSet<String>> = HashMap::new();
for (idx, line) in src.lines().enumerate() {
let trimmed = line.trim_start();
if !trimmed.starts_with("// qala: allow(") {
continue;
}
let Some(body) = trimmed.strip_prefix("// qala: allow(") else {
continue;
};
let Some(close_idx) = body.find(')') else {
continue;
};
if !body[close_idx + 1..].trim().is_empty() {
continue;
}
let cats_str = &body[..close_idx];
let cats: BTreeSet<String> = cats_str
.split(',')
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.collect();
if cats.is_empty() {
continue;
}
out.insert(idx + 2, cats);
}
out
}
fn stdlib_effect(key: &FnKey) -> Option<EffectSet> {
let stdlib = stdlib_signatures();
for entry in stdlib.iter() {
if entry.type_name == key.type_name && entry.name == key.name {
return Some(entry.effect);
}
}
None
}
fn stdlib_signatures() -> Vec<StdlibFn> {
vec![
StdlibFn {
type_name: None,
name: "print".to_string(),
params: vec![QalaType::Str],
ret_ty: QalaType::Void,
effect: EffectSet::io(),
is_generic: false,
},
StdlibFn {
type_name: None,
name: "println".to_string(),
params: vec![QalaType::Str],
ret_ty: QalaType::Void,
effect: EffectSet::io(),
is_generic: false,
},
StdlibFn {
type_name: None,
name: "sqrt".to_string(),
params: vec![QalaType::F64],
ret_ty: QalaType::F64,
effect: EffectSet::pure(),
is_generic: false,
},
StdlibFn {
type_name: None,
name: "abs".to_string(),
params: vec![QalaType::Unknown],
ret_ty: QalaType::Unknown,
effect: EffectSet::pure(),
is_generic: true,
},
StdlibFn {
type_name: None,
name: "assert".to_string(),
params: vec![QalaType::Bool],
ret_ty: QalaType::Void,
effect: EffectSet::panic(),
is_generic: false,
},
StdlibFn {
type_name: None,
name: "len".to_string(),
params: vec![QalaType::Array(Box::new(QalaType::Unknown), None)],
ret_ty: QalaType::I64,
effect: EffectSet::pure(),
is_generic: true,
},
StdlibFn {
type_name: None,
name: "push".to_string(),
params: vec![
QalaType::Array(Box::new(QalaType::Unknown), None),
QalaType::Unknown,
],
ret_ty: QalaType::Void,
effect: EffectSet::alloc(),
is_generic: true,
},
StdlibFn {
type_name: None,
name: "pop".to_string(),
params: vec![QalaType::Array(Box::new(QalaType::Unknown), None)],
ret_ty: QalaType::Option(Box::new(QalaType::Unknown)),
effect: EffectSet::alloc(),
is_generic: true,
},
StdlibFn {
type_name: None,
name: "type_of".to_string(),
params: vec![QalaType::Unknown],
ret_ty: QalaType::Str,
effect: EffectSet::pure(),
is_generic: true,
},
StdlibFn {
type_name: None,
name: "open".to_string(),
params: vec![QalaType::Str],
ret_ty: QalaType::FileHandle,
effect: EffectSet::io(),
is_generic: false,
},
StdlibFn {
type_name: None,
name: "close".to_string(),
params: vec![QalaType::FileHandle],
ret_ty: QalaType::Void,
effect: EffectSet::io(),
is_generic: false,
},
StdlibFn {
type_name: None,
name: "map".to_string(),
params: vec![
QalaType::Array(Box::new(QalaType::Unknown), None),
QalaType::Function {
params: vec![QalaType::Unknown],
returns: Box::new(QalaType::Unknown),
},
],
ret_ty: QalaType::Array(Box::new(QalaType::Unknown), None),
effect: EffectSet::pure(),
is_generic: true,
},
StdlibFn {
type_name: None,
name: "filter".to_string(),
params: vec![
QalaType::Array(Box::new(QalaType::Unknown), None),
QalaType::Function {
params: vec![QalaType::Unknown],
returns: Box::new(QalaType::Bool),
},
],
ret_ty: QalaType::Array(Box::new(QalaType::Unknown), None),
effect: EffectSet::pure(),
is_generic: true,
},
StdlibFn {
type_name: None,
name: "reduce".to_string(),
params: vec![
QalaType::Array(Box::new(QalaType::Unknown), None),
QalaType::Unknown,
QalaType::Function {
params: vec![QalaType::Unknown, QalaType::Unknown],
returns: Box::new(QalaType::Unknown),
},
],
ret_ty: QalaType::Unknown,
effect: EffectSet::pure(),
is_generic: true,
},
StdlibFn {
type_name: Some("FileHandle".to_string()),
name: "read_all".to_string(),
params: vec![],
ret_ty: QalaType::Result(Box::new(QalaType::Str), Box::new(QalaType::Str)),
effect: EffectSet::io(),
is_generic: false,
},
]
}
fn resolve_type_silent(ty: &ast::TypeExpr, symbols: &SymbolTable) -> QalaType {
match ty {
ast::TypeExpr::Primitive { kind, .. } => QalaType::from_prim_type(kind),
ast::TypeExpr::Named { name, .. } => {
if name == "FileHandle" {
return QalaType::FileHandle;
}
if symbols.structs.contains_key(name)
|| symbols.enums.contains_key(name)
|| symbols.interfaces.contains_key(name)
{
return QalaType::Named(Symbol(name.clone()));
}
QalaType::Unknown
}
ast::TypeExpr::Array { elem, size, .. } => QalaType::Array(
Box::new(resolve_type_silent(elem, symbols)),
Some(*size as usize),
),
ast::TypeExpr::DynArray { elem, .. } => {
QalaType::Array(Box::new(resolve_type_silent(elem, symbols)), None)
}
ast::TypeExpr::Tuple { elems, .. } => QalaType::Tuple(
elems
.iter()
.map(|e| resolve_type_silent(e, symbols))
.collect(),
),
ast::TypeExpr::Fn { params, ret, .. } => QalaType::Function {
params: params
.iter()
.map(|p| resolve_type_silent(p, symbols))
.collect(),
returns: Box::new(resolve_type_silent(ret, symbols)),
},
ast::TypeExpr::Generic { name, args, .. } => {
if name == "Result" && args.len() == 2 {
return QalaType::Result(
Box::new(resolve_type_silent(&args[0], symbols)),
Box::new(resolve_type_silent(&args[1], symbols)),
);
}
if name == "Option" && args.len() == 1 {
return QalaType::Option(Box::new(resolve_type_silent(&args[0], symbols)));
}
QalaType::Unknown
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lexer::Lexer;
use crate::parser::Parser;
fn check(src: &str) -> (typed_ast::TypedAst, Vec<QalaError>, Vec<QalaWarning>) {
let tokens = Lexer::tokenize(src).expect("lex");
let ast = Parser::parse(&tokens).expect("parse");
check_program(&ast, src)
}
#[allow(dead_code)]
fn check_ok(src: &str) -> typed_ast::TypedAst {
let (typed, errors, _) = check(src);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
typed
}
#[test]
fn collect_empty_program() {
let (typed, errors, warnings) = check("");
assert!(typed.is_empty());
assert!(errors.is_empty());
assert!(warnings.is_empty());
}
#[test]
fn collect_single_fn() {
let src = "fn main() is io { }";
let (typed, errors, warnings) = check(src);
assert!(errors.is_empty(), "{errors:?}");
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(typed.len(), 1);
match &typed[0] {
typed_ast::TypedItem::Fn(f) => {
assert_eq!(f.name, "main");
assert_eq!(f.ret_ty, QalaType::Void);
assert_eq!(f.effect, EffectSet::io());
}
_ => panic!("expected Fn, got {:?}", typed[0]),
}
}
#[test]
fn collect_struct_with_two_fields() {
let src = "struct A { x: i64, y: bool }";
let (typed, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(typed.len(), 1);
match &typed[0] {
typed_ast::TypedItem::Struct(s) => {
assert_eq!(s.name, "A");
assert_eq!(s.fields.len(), 2);
assert_eq!(s.fields[0].name, "x");
assert_eq!(s.fields[0].ty, QalaType::I64);
assert_eq!(s.fields[1].name, "y");
assert_eq!(s.fields[1].ty, QalaType::Bool);
}
_ => panic!("expected Struct"),
}
}
#[test]
fn collect_enum_with_three_variants() {
let src = "enum Shape { Circle(f64), Rect(f64, f64), Triangle }";
let (typed, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
match &typed[0] {
typed_ast::TypedItem::Enum(e) => {
assert_eq!(e.name, "Shape");
assert_eq!(e.variants.len(), 3);
assert_eq!(e.variants[0].name, "Circle");
assert_eq!(e.variants[0].fields, vec![QalaType::F64]);
assert_eq!(e.variants[1].name, "Rect");
assert_eq!(e.variants[1].fields, vec![QalaType::F64, QalaType::F64]);
assert_eq!(e.variants[2].name, "Triangle");
assert!(e.variants[2].fields.is_empty());
}
_ => panic!("expected Enum"),
}
}
#[test]
fn collect_interface_with_one_method() {
let src = "interface Printable { fn to_string(self) -> str }";
let (typed, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
match &typed[0] {
typed_ast::TypedItem::Interface(i) => {
assert_eq!(i.name, "Printable");
assert_eq!(i.methods.len(), 1);
assert_eq!(i.methods[0].name, "to_string");
assert_eq!(i.methods[0].ret_ty, QalaType::Str);
}
_ => panic!("expected Interface"),
}
}
#[test]
fn collect_struct_with_unknown_field_type() {
let src = "struct A { x: Nope }";
let (_, errors, _) = check(src);
assert_eq!(errors.len(), 1);
match &errors[0] {
QalaError::UnknownType { name, .. } => {
assert_eq!(name, "Nope");
}
other => panic!("expected UnknownType, got {other:?}"),
}
}
#[test]
fn collect_recursive_struct_self_loop() {
let src = "struct A { x: A }";
let (_, errors, _) = check(src);
let cycle_errors: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::RecursiveStructByValue { .. }))
.collect();
assert_eq!(
cycle_errors.len(),
1,
"expected exactly one cycle error: {errors:?}"
);
match cycle_errors[0] {
QalaError::RecursiveStructByValue { path, .. } => {
assert_eq!(path, &vec!["A".to_string(), "A".to_string()]);
}
_ => unreachable!(),
}
}
#[test]
fn collect_recursive_struct_mutual() {
let src = "struct A { x: B } struct B { y: A }";
let (_, errors, _) = check(src);
let cycle_errors: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::RecursiveStructByValue { .. }))
.collect();
assert_eq!(cycle_errors.len(), 1, "expected one cycle: {errors:?}");
match cycle_errors[0] {
QalaError::RecursiveStructByValue { path, .. } => {
assert_eq!(
path,
&vec!["A".to_string(), "B".to_string(), "A".to_string()]
);
}
_ => unreachable!(),
}
}
#[test]
fn collect_dynamic_array_self_reference_is_not_a_cycle() {
let src = "struct A { xs: [A] }";
let (_, errors, _) = check(src);
let cycle_errors: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::RecursiveStructByValue { .. }))
.collect();
assert!(cycle_errors.is_empty(), "no cycle expected: {errors:?}");
}
#[test]
fn collect_tuple_self_reference_is_a_cycle() {
let src = "struct A { x: (A, i64) }";
let (_, errors, _) = check(src);
let cycle_errors: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::RecursiveStructByValue { .. }))
.collect();
assert_eq!(cycle_errors.len(), 1, "expected cycle: {errors:?}");
}
#[test]
fn collect_fn_with_unknown_param_type() {
let src = "fn f(x: Nope) -> i64 is pure { return 0 }";
let (_, errors, _) = check(src);
let unknown_errors: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::UnknownType { .. }))
.collect();
assert!(!unknown_errors.is_empty());
}
#[test]
fn collect_duplicate_struct_definition() {
let src = "struct A { x: i64 } struct A { y: bool }";
let (_, errors, _) = check(src);
let dup: Vec<&QalaError> = errors
.iter()
.filter(
|e| matches!(e, QalaError::Type { message, .. } if message.contains("duplicate")),
)
.collect();
assert_eq!(dup.len(), 1, "{errors:?}");
}
#[test]
fn collect_errors_are_sorted_by_span() {
let src = "struct A { x: Nope } struct A { y: i64 }";
let (_, errors, _) = check(src);
let starts: Vec<u32> = errors.iter().map(|e| e.span().start).collect();
let mut sorted = starts.clone();
sorted.sort();
assert_eq!(
starts, sorted,
"errors not sorted by span.start: {errors:?}"
);
}
#[test]
fn infer_local_let_types() {
let src = "fn main() is io { let x = 42; println(\"hi\") }";
let (_, errors, _) = check(src);
let type_errors: Vec<&QalaError> = errors
.iter()
.filter(|e| !matches!(e, QalaError::UndefinedName { .. }))
.collect();
assert!(type_errors.is_empty(), "{errors:?}");
}
#[test]
fn let_with_wrong_annotation_emits_type_mismatch() {
let src = "fn main() is io { let x: i64 = \"hello\"; println(\"\") }";
let (_, errors, _) = check(src);
let mismatch: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
.collect();
assert!(!mismatch.is_empty(), "expected TypeMismatch in {errors:?}");
}
#[test]
fn redundant_annotation_warns() {
let src = "fn main() is io { let x: i64 = 42; println(\"{x}\") }";
let (_, errors, warnings) = check(src);
assert!(errors.is_empty(), "{errors:?}");
let red: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "redundant_annotation")
.collect();
assert_eq!(red.len(), 1, "{warnings:?}");
assert!(
red[0].message.contains("redundant type annotation"),
"{red:?}"
);
}
#[test]
fn undefined_name_in_initializer_emits_one_error() {
let src = "fn main() is io { let x = nope; println(\"\") }";
let (_, errors, _) = check(src);
let undef: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::UndefinedName { name, .. } if name == "nope"))
.collect();
assert_eq!(undef.len(), 1, "{errors:?}");
}
#[test]
fn arg_type_mismatch_message() {
let src = "fn f(x: i64) -> str is pure { x }";
let (_, errors, _) = check(src);
let mismatch: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
.collect();
assert!(!mismatch.is_empty(), "expected TypeMismatch in {errors:?}");
match mismatch[0] {
QalaError::TypeMismatch {
expected, found, ..
} => {
assert_eq!(expected, "str");
assert_eq!(found, "i64");
}
_ => unreachable!(),
}
}
#[test]
fn missing_return_at_last_expr() {
let src = "fn f() -> i64 is pure { }";
let (_, errors, _) = check(src);
let missing: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::MissingReturn { .. }))
.collect();
assert_eq!(missing.len(), 1, "{errors:?}");
}
#[test]
fn fn_with_correct_trailing_value_passes() {
let src = "fn f(x: i64) -> i64 is pure { x }";
let (_, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn fn_with_explicit_return_passes() {
let src = "fn f(x: i64) -> i64 is pure { return x }";
let (_, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn fn_return_with_wrong_type() {
let src = "fn f(x: i64) -> i64 is pure { return \"oops\" }";
let (_, errors, _) = check(src);
let mismatch: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
.collect();
assert!(!mismatch.is_empty(), "{errors:?}");
}
#[test]
fn or_fallback_typechecks_success() {
let src = r#"
fn lookup() -> Option<i64> is pure { return Some(1) }
fn main() is io {
let r = lookup() or 0
println("{r}")
}
"#;
let (_, errors, _) = check(src);
let mismatch: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
.collect();
assert!(mismatch.is_empty(), "{errors:?}");
}
#[test]
fn or_fallback_wrong_type_errors_at_fallback() {
let src = r#"
fn lookup() -> Option<i64> is pure { return Some(1) }
fn main() is io {
let r = lookup() or "oops"
println("{r}")
}
"#;
let (_, errors, _) = check(src);
let mismatch: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
.collect();
assert!(!mismatch.is_empty(), "{errors:?}");
}
#[test]
fn question_legal_inside_result_fn() {
let src = r#"
fn parse_int(s: str) -> Result<i64, str> is pure { return Ok(0) }
fn f(s: str) -> Result<i64, str> is pure {
let x = parse_int(s)?
return Ok(x)
}
"#;
let (_, errors, _) = check(src);
let redundant: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::RedundantQuestionOperator { .. }))
.collect();
assert!(redundant.is_empty(), "{errors:?}");
}
#[test]
fn question_in_non_result_fn_errors() {
let src = r#"
fn parse_int(s: str) -> Result<i64, str> is pure { return Ok(0) }
fn f(s: str) -> i64 is pure {
let x = parse_int(s)?
return x
}
"#;
let (_, errors, _) = check(src);
let redundant: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::RedundantQuestionOperator { .. }))
.collect();
assert_eq!(redundant.len(), 1, "{errors:?}");
}
#[test]
fn struct_literal_unknown_field_errors() {
let src = r#"
struct Point { x: i64, y: i64 }
fn main() is io {
let p = Point { x: 1, z: 2 }
println("ok")
}
"#;
let (_, errors, _) = check(src);
let type_errors: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::Type { .. }))
.collect();
assert!(type_errors.len() >= 2, "{errors:?}");
}
#[test]
fn method_call_resolves_user_method() {
let src = r#"
struct Point { x: f64, y: f64 }
fn Point.distance(self) -> f64 is pure { return 0.0 }
fn main() is io {
let p = Point { x: 1.0, y: 2.0 }
let d = p.distance()
println("{d}")
}
"#;
let (_, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn index_into_fixed_array_types_to_elem() {
let src = r#"
fn main() is io {
let arr = [1, 2, 3]
let v = arr[0]
println("{v}")
}
"#;
let (_, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn index_with_string_errors() {
let src = r#"
fn main() is io {
let arr = [1, 2, 3]
let v = arr["x"]
println("{v}")
}
"#;
let (_, errors, _) = check(src);
let mismatch: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
.collect();
assert!(!mismatch.is_empty(), "{errors:?}");
}
#[test]
fn pipeline_with_unary_callee_types_through() {
let src = r#"
fn double(x: i64) -> i64 is pure { return x * 2 }
fn main() is io {
let r = 5 |> double
println("{r}")
}
"#;
let (_, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn interpolation_resolves_inner_expression() {
let src = r#"
fn main() is io {
let name = "world"
println("hello, {name}!")
}
"#;
let (_, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn binary_arithmetic_on_matching_types_passes() {
let src = r#"
fn add(a: i64, b: i64) -> i64 is pure { return a + b }
"#;
let (_, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn binary_arithmetic_on_mismatched_types_errors() {
let src = r#"
fn bad(a: i64, b: str) -> i64 is pure { return a + b }
"#;
let (_, errors, _) = check(src);
let mismatch: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
.collect();
assert!(!mismatch.is_empty(), "{errors:?}");
}
#[test]
fn comparison_returns_bool() {
let src = r#"
fn lt(a: i64, b: i64) -> bool is pure { return a < b }
"#;
let (_, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn boolean_ops_require_bool_operands() {
let src = r#"
fn bad() -> bool is pure { return 1 && 2 }
"#;
let (_, errors, _) = check(src);
let mismatch: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
.collect();
assert!(mismatch.len() >= 2, "{errors:?}");
}
#[test]
fn unreachable_after_return_warns_once() {
let src = r#"
fn main() is io {
return
let x = 1
}
"#;
let (_, _, warnings) = check(src);
let unreach: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unreachable_code")
.collect();
assert_eq!(unreach.len(), 1, "{warnings:?}");
}
#[test]
fn pure_add_function_has_no_effect_errors() {
let src = "fn add(a: i64, b: i64) -> i64 is pure { a + b }";
let (_, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn pure_calls_io_errors_with_hint() {
let src = r#"
fn shout(msg: str) is pure {
println(msg)
}
"#;
let (_, errors, _) = check(src);
let viol: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::EffectViolation { .. }))
.collect();
assert_eq!(viol.len(), 1, "{errors:?}");
match viol[0] {
QalaError::EffectViolation {
caller,
caller_effect,
callee,
callee_effect,
..
} => {
assert_eq!(caller, "shout");
assert_eq!(caller_effect, "pure");
assert_eq!(callee, "println");
assert_eq!(callee_effect, "io");
}
_ => unreachable!(),
}
}
#[test]
fn unannotated_caller_inherits_io_effect() {
let src = r#"
fn echo(msg: str) {
println(msg)
}
"#;
let (typed, errors, _) = check(src);
assert!(errors.is_empty(), "{errors:?}");
assert!(matches!(&typed[0], typed_ast::TypedItem::Fn(_)));
}
#[test]
fn mutual_recursion_effects_converge() {
let src = r#"
fn a(n: i64) -> i64 {
if n == 0 { return 0 }
return b(n - 1)
}
fn b(n: i64) -> i64 {
if n == 0 { return 0 }
println("b")
return a(n - 1)
}
"#;
let (_, errors, _) = check(src);
let viol: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::EffectViolation { .. }))
.collect();
assert!(
viol.is_empty(),
"no annotated callers; no violations: {errors:?}"
);
}
#[test]
fn match_missing_variants_listed() {
let src = r#"
enum Shape { Circle(f64), Rect(f64, f64), Triangle }
fn area(s: Shape) -> f64 is pure {
match s {
Circle(r) => r,
Rect(w, h) => w * h,
}
}
"#;
let (_, errors, _) = check(src);
let nonex: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::NonExhaustiveMatch { .. }))
.collect();
assert_eq!(nonex.len(), 1, "{errors:?}");
match nonex[0] {
QalaError::NonExhaustiveMatch {
enum_name, missing, ..
} => {
assert_eq!(enum_name, "Shape");
assert_eq!(missing, &vec!["Triangle".to_string()]);
}
_ => unreachable!(),
}
}
#[test]
fn match_with_wildcard_is_exhaustive() {
let src = r#"
enum Shape { Circle(f64), Rect(f64, f64), Triangle }
fn area(s: Shape) -> f64 is pure {
match s {
Circle(r) => r,
_ => 0.0,
}
}
"#;
let (_, errors, _) = check(src);
let nonex: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::NonExhaustiveMatch { .. }))
.collect();
assert!(nonex.is_empty(), "{errors:?}");
}
#[test]
fn match_with_all_variants_is_exhaustive() {
let src = r#"
enum Shape { Circle(f64), Rect(f64, f64), Triangle }
fn area(s: Shape) -> f64 is pure {
match s {
Circle(r) => r,
Rect(w, h) => w * h,
Triangle => 0.0,
}
}
"#;
let (_, errors, _) = check(src);
let nonex: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::NonExhaustiveMatch { .. }))
.collect();
assert!(nonex.is_empty(), "{errors:?}");
}
#[test]
fn overlapping_guards_warn() {
let src = r#"
fn classify(v: i64) -> str is pure {
match v {
x if x > 0 => "pos",
x if x > 10 => "big",
_ => "other",
}
}
"#;
let (_, _, warnings) = check(src);
let over: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "overlapping_guards")
.collect();
assert_eq!(over.len(), 1, "{warnings:?}");
}
#[test]
fn variant_not_in_enum_errors() {
let src = r#"
enum Shape { Circle(f64) }
fn f(s: Shape) -> f64 is pure {
match s {
Square(x) => x,
_ => 0.0,
}
}
"#;
let (_, errors, _) = check(src);
let type_errors: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::Type { message, .. } if message.contains("Square")))
.collect();
assert!(!type_errors.is_empty(), "{errors:?}");
}
#[test]
fn interface_satisfied_structurally() {
let src = r#"
interface Printable { fn to_string(self) -> str }
struct Point { x: f64, y: f64 }
fn Point.to_string(self) -> str is pure { return "p" }
fn main() is io {
let p: Printable = Point { x: 1.0, y: 2.0 }
println("ok")
}
"#;
let (_, errors, _) = check(src);
let ifaces: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::InterfaceNotSatisfied { .. }))
.collect();
assert!(ifaces.is_empty(), "{errors:?}");
}
#[test]
fn interface_mismatch_lists_methods() {
let src = r#"
interface Printable { fn to_string(self) -> str }
struct Point { x: f64, y: f64 }
fn main() is io {
let p: Printable = Point { x: 1.0, y: 2.0 }
println("ok")
}
"#;
let (_, errors, _) = check(src);
let ifaces: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::InterfaceNotSatisfied { .. }))
.collect();
assert_eq!(ifaces.len(), 1, "{errors:?}");
match ifaces[0] {
QalaError::InterfaceNotSatisfied {
ty,
interface,
missing,
..
} => {
assert_eq!(ty, "Point");
assert_eq!(interface, "Printable");
assert!(missing.contains(&"to_string".to_string()));
}
_ => unreachable!(),
}
}
#[test]
fn unused_var_warns_with_correct_category() {
let src = "fn main() is io { let x = 1; println(\"hi\") }";
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unused_var")
.collect();
assert_eq!(w.len(), 1, "{warnings:?}");
assert!(w[0].message.contains("`x`"), "{w:?}");
}
#[test]
fn underscore_prefixed_name_exempt_from_unused_var() {
let src = "fn main() is io { let _ = 1; println(\"hi\") }";
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unused_var")
.collect();
assert!(w.is_empty(), "{warnings:?}");
}
#[test]
fn function_params_exempt_from_unused_var() {
let src = "fn f(x: i64) -> i64 is pure { 1 }";
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unused_var")
.collect();
assert!(w.is_empty(), "{warnings:?}");
}
#[test]
fn shadowed_var_warns_with_prior_binding_note() {
let src = r#"
fn main() is io {
let x = 1
{
let x = 2
println("{x}")
}
}
"#;
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "shadowed_var")
.collect();
assert_eq!(w.len(), 1, "{warnings:?}");
assert!(w[0].note.is_some(), "{w:?}");
assert!(
w[0].note.as_ref().unwrap().contains("prior binding"),
"{w:?}"
);
}
#[test]
fn redundant_annotation_warns_for_matching_inferred_type() {
let src = r#"
fn main() -> i64 is pure {
let x: i64 = 42
x
}
"#;
let (_, errors, warnings) = check(src);
assert!(errors.is_empty(), "{errors:?}");
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "redundant_annotation")
.collect();
assert_eq!(w.len(), 1, "{warnings:?}");
}
#[test]
fn unmatched_defer_warns() {
let src = r#"
fn main() is io {
let f = open("x.txt")
println("hi")
}
"#;
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unmatched_defer")
.collect();
assert_eq!(w.len(), 1, "{warnings:?}");
}
#[test]
fn unmatched_defer_silenced_by_call_form_close() {
let src = r#"
fn main() is io {
let f = open("x.txt")
defer close(f)
println("hi")
}
"#;
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unmatched_defer")
.collect();
assert!(w.is_empty(), "{warnings:?}");
}
#[test]
fn unmatched_defer_silenced_by_method_form_close() {
let src = r#"
fn main() is io {
let f = open("x.txt")
defer f.close()
println("hi")
}
"#;
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unmatched_defer")
.collect();
assert!(w.is_empty(), "{warnings:?}");
}
#[test]
fn unmatched_defer_fires_per_handle_independently() {
let src = r#"
fn main() is io {
let f = open("x.txt")
let g = open("y.txt")
defer close(f)
println("hi")
}
"#;
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unmatched_defer")
.collect();
assert_eq!(w.len(), 1, "{warnings:?}");
assert!(w[0].message.contains("`g`"), "{w:?}");
}
#[test]
fn unreachable_code_fires_after_return_keyword_followed_by_stmt_head() {
let src = r#"
fn main() is io {
return
let x = 1
}
"#;
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unreachable_code")
.collect();
assert_eq!(w.len(), 1, "{warnings:?}");
assert!(w[0].message.contains("unreachable statement"), "{w:?}");
}
#[test]
fn unreachable_code_fires_exactly_once_per_block() {
let src = r#"
fn main() is io {
return
let x = 1
let y = 2
}
"#;
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unreachable_code")
.collect();
assert_eq!(w.len(), 1, "{warnings:?}");
}
#[test]
fn directive_scanner_handles_section_8_edge_cases() {
let t = scan_allow_directives("// qala: allow(unused_var)\nlet x = 1");
assert!(t.get(&2).map(|s| s.contains("unused_var")).unwrap_or(false));
let t = scan_allow_directives("let x = 1 // qala: allow(unused_var)");
assert!(t.is_empty());
let t = scan_allow_directives("// qala: allow(unused_var, shadowed_var)\nlet x = 1");
let line2 = t.get(&2).expect("row 3 must populate line 2");
assert!(line2.contains("unused_var"));
assert!(line2.contains("shadowed_var"));
let t = scan_allow_directives("// qala: allow( unused_var ,shadowed_var )\nlet x = 1");
let line2 = t.get(&2).expect("row 4 must populate line 2");
assert!(line2.contains("unused_var"));
assert!(line2.contains("shadowed_var"));
let t = scan_allow_directives("// qala: allow()\nlet x = 1");
assert!(t.is_empty());
let t = scan_allow_directives("// qala: allow(unused_var) trailing\nlet x = 1");
assert!(t.is_empty());
let t = scan_allow_directives("let msg = \"// qala: allow(unused_var)\"");
assert!(t.is_empty());
let t = scan_allow_directives("let msg = \"...\"\n// qala: allow(unused_var)\nlet x = 1");
assert!(t.get(&3).map(|s| s.contains("unused_var")).unwrap_or(false));
}
#[test]
fn directive_silences_unused_var() {
let src = "// qala: allow(unused_var)\nfn main() is io { let x = 1; println(\"hi\") }";
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unused_var")
.collect();
assert!(!w.is_empty() || warnings.is_empty(), "{warnings:?}");
}
#[test]
fn directive_silences_unused_var_on_following_line() {
let src = "fn main() is io {\n// qala: allow(unused_var)\nlet x = 1; println(\"hi\") }";
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unused_var")
.collect();
assert!(w.is_empty(), "{warnings:?}");
}
#[test]
fn directive_silences_shadowed_var() {
let src = "fn main() is io {\nlet x = 1\n// qala: allow(shadowed_var)\n{ let x = 2; println(\"{x}\") }\n}";
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "shadowed_var")
.collect();
assert!(w.is_empty(), "{warnings:?}");
}
#[test]
fn directive_silences_redundant_annotation() {
let src = "fn main() -> i64 is pure {\n// qala: allow(redundant_annotation)\nlet x: i64 = 42\nx\n}";
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "redundant_annotation")
.collect();
assert!(w.is_empty(), "{warnings:?}");
}
#[test]
fn directive_silences_unmatched_defer() {
let src = "fn main() is io {\n// qala: allow(unmatched_defer)\nlet f = open(\"x.txt\")\nprintln(\"hi\")\n}";
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unmatched_defer")
.collect();
assert!(w.is_empty(), "{warnings:?}");
}
#[test]
fn directive_silences_unreachable_code() {
let src = "fn main() is io {\nreturn\n// qala: allow(unreachable_code)\nlet x = 1\n}";
let (_, _, warnings) = check(src);
let w: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unreachable_code")
.collect();
assert!(w.is_empty(), "{warnings:?}");
}
#[test]
fn errors_are_never_silenced_by_directive() {
let src = "// qala: allow(unused_var)\nfn main() is io { let x: i64 = \"oops\"; println(\"{x}\") }";
let (_, errors, _) = check(src);
let m: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
.collect();
assert!(!m.is_empty(), "errors must not be silenced: {errors:?}");
}
#[test]
fn multiple_directive_lines_silence_independent_lines() {
let src = "fn main() is io {\n// qala: allow(unused_var)\nlet x = 1\nlet y = 2\nprintln(\"hi\")\n}";
let (_, _, warnings) = check(src);
let unused: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unused_var")
.collect();
assert_eq!(unused.len(), 1, "{warnings:?}");
assert!(unused[0].message.contains("`y`"), "{unused:?}");
}
#[test]
fn unmatched_defer_fires_in_innermost_else_if_branch() {
let src = r#"
fn main(a: bool, b: bool, c: bool) is io {
if a {
let x = 1
} else if b {
let y = 2
} else if c {
let f = open("x.txt")
}
}
"#;
let (_, _, warnings) = check(src);
let unmatched: Vec<&QalaWarning> = warnings
.iter()
.filter(|w| w.category == "unmatched_defer")
.collect();
assert_eq!(
unmatched.len(),
1,
"expected one unmatched_defer in innermost else-if: {warnings:?}"
);
assert!(
unmatched[0].message.contains("`f`"),
"warning should name the handle: {:?}",
unmatched[0].message
);
}
#[test]
fn enum_variant_lookup_is_deterministic_across_runs() {
let src = r#"
enum Beta { Mark, Other }
enum Alpha { Mark, Stuff }
fn f() -> Alpha is pure { return Mark }
"#;
let (_, errors, _) = check(src);
let relevant: Vec<&QalaError> = errors
.iter()
.filter(|e| {
matches!(
e,
QalaError::UndefinedName { .. } | QalaError::TypeMismatch { .. }
)
})
.collect();
assert!(
relevant.is_empty(),
"unexpected errors with deterministic enum lookup: {relevant:?}"
);
}
#[test]
fn abs_resolves_to_i64_for_int_arg_and_f64_for_float_arg() {
let src_int = "fn main() -> i64 is pure { return abs(-3) }";
let (typed, errors, _) = check(src_int);
assert!(errors.is_empty(), "abs(i64) errors: {errors:?}");
match &typed[0] {
typed_ast::TypedItem::Fn(f) => {
assert_eq!(f.ret_ty, QalaType::I64);
}
_ => panic!("expected Fn"),
}
let src_float = "fn main() -> f64 is pure { return abs(1.5) }";
let (typed2, errors2, _) = check(src_float);
assert!(errors2.is_empty(), "abs(f64) errors: {errors2:?}");
match &typed2[0] {
typed_ast::TypedItem::Fn(f) => {
assert_eq!(f.ret_ty, QalaType::F64);
}
_ => panic!("expected Fn"),
}
}
#[test]
fn abs_rejects_non_numeric_arg() {
let src = "fn main() -> bool is pure { return abs(true) }";
let (_, errors, _) = check(src);
let mismatch: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
.collect();
assert!(
!mismatch.is_empty(),
"abs(bool) should produce TypeMismatch: {errors:?}"
);
}
#[test]
fn zero_param_method_sig_has_no_trailing_comma() {
let src = r#"
interface Reader { fn read_all(self) -> Result<str, str> }
struct MyReader { }
fn MyReader.read_all(self) -> i64 is pure { return 0 }
fn use_it(r: MyReader) is pure { }
"#;
let (_, errors, _) = check(src);
let iface_errs: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::InterfaceNotSatisfied { .. }))
.collect();
if !iface_errs.is_empty() {
match iface_errs[0] {
QalaError::InterfaceNotSatisfied { mismatched, .. } => {
for (_, expected_sig, found_sig) in mismatched {
assert!(
!expected_sig.contains(", )"),
"expected sig has trailing comma: {expected_sig:?}"
);
assert!(
!found_sig.contains(", )"),
"found sig has trailing comma: {found_sig:?}"
);
}
}
_ => unreachable!(),
}
}
let sig = format_fn_sig(&[], &QalaType::I64);
assert_eq!(sig, "fn(self) -> i64", "zero-param sig: {sig:?}");
let sig2 = format_fn_sig(&[QalaType::Str], &QalaType::Bool);
assert_eq!(sig2, "fn(self, str) -> bool", "one-param sig: {sig2:?}");
}
#[test]
fn non_exhaustive_match_missing_list_is_alphabetically_sorted() {
let src = r#"
enum Fruit { Zebra(i64), Apple(i64), Mango(i64) }
fn f(v: Fruit) -> i64 is pure {
match v {
Zebra(n) => n
}
}
"#;
let (_, errors, _) = check(src);
let non_ex: Vec<&QalaError> = errors
.iter()
.filter(|e| matches!(e, QalaError::NonExhaustiveMatch { .. }))
.collect();
assert_eq!(
non_ex.len(),
1,
"expected exactly one NonExhaustiveMatch: {errors:?}"
);
match non_ex[0] {
QalaError::NonExhaustiveMatch { missing, .. } => {
assert_eq!(missing, &vec!["Apple".to_string(), "Mango".to_string()]);
}
_ => unreachable!(),
}
}
#[test]
fn six_bundled_examples_typecheck_without_errors() {
for name in [
"hello",
"fibonacci",
"effects",
"pattern-matching",
"pipeline",
"defer-demo",
] {
let path = format!(
"{}/../../playground/public/examples/{}.qala",
env!("CARGO_MANIFEST_DIR"),
name
);
let src = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
let tokens = crate::lexer::Lexer::tokenize(&src).expect("lex");
let ast = crate::parser::Parser::parse(&tokens).expect("parse");
let (_, errors, _warnings) = check_program(&ast, &src);
assert!(
errors.is_empty(),
"{name}.qala: unexpected errors: {errors:?}"
);
}
}
}