Skip to main content

qala_compiler/
typechecker.rs

1//! the type and effect checker: [`check_program`] turns the parser's untyped
2//! AST + the source text into a typed AST plus a vector of errors and a vector
3//! of warnings. it does NOT abort on the first error -- it accumulates and
4//! recovers locally via [`QalaType::Unknown`] poison-propagation, so a user's
5//! typo doesn't cascade to twenty errors.
6//!
7//! two top-level passes plus a third effect-fixed-point pass:
8//! - pass 1 walks every top-level item building a [`SymbolTable`] (structs,
9//!   enums, interfaces, free fns, methods).
10//! - pass 2 walks every function body checking expressions and producing typed
11//!   nodes.
12//! - pass 3 iterates the call graph until every function's inferred
13//!   [`EffectSet`] stabilises (proven to converge in <= 3 monotonic-ascent
14//!   rounds on a 4-level lattice), then validates annotated functions against
15//!   their annotation.
16//!
17//! determinism: errors and warnings are sorted by `(span.start, span.len)`
18//! before returning; user-visible lists (missing variants, missing methods)
19//! are sorted alphabetically via [`BTreeSet`]; the stdlib signatures table
20//! is a [`Vec`], not a [`HashMap`], so iteration is source-order. no panics on
21//! a malformed but well-formed-by-the-parser AST -- this discipline is
22//! load-bearing for Phase 6 where the typechecker runs in WASM.
23
24use crate::ast;
25use crate::effects::EffectSet;
26use crate::errors::QalaError;
27#[allow(unused_imports)] // task 2 uses this in the depth-cap check.
28use crate::parser::MAX_DEPTH;
29use crate::span::{LineIndex, Span};
30use crate::typed_ast;
31use crate::types::{QalaType, Symbol};
32use std::collections::{BTreeMap, BTreeSet, HashMap};
33
34// ---- public surface --------------------------------------------------------
35
36/// one warning produced by the type checker.
37///
38/// derives `Debug, Clone, PartialEq`. categories are the locked snake_case
39/// identifiers also accepted by the `// qala: allow(...)` directive scanner:
40/// `unused_var`, `unreachable_code`, `unmatched_defer`, `shadowed_var`,
41/// `redundant_annotation`, `overlapping_guards`. errors are never silenced;
42/// warnings are silenceable by the directive table built once in
43/// [`scan_allow_directives`].
44#[derive(Debug, Clone, PartialEq)]
45pub struct QalaWarning {
46    /// one of the locked snake_case category strings.
47    pub category: String,
48    /// the human-readable one-line message.
49    pub message: String,
50    /// the source span the warning points at.
51    pub span: Span,
52    /// optional extra information, like a `shadowed_var`'s prior-binding
53    /// location ("the prior binding is at line {l}:{c}").
54    pub note: Option<String>,
55}
56
57// ---- entry point -----------------------------------------------------------
58
59/// check a parsed program against the type and effect rules, producing a
60/// fully-typed AST plus accumulated errors and warnings.
61///
62/// the typechecker does NOT fail-fast: it recovers locally via
63/// [`QalaType::Unknown`] poison-propagation and keeps going so a typo at the
64/// top of a function does not cascade to a wall of follow-on errors. errors
65/// and warnings are sorted by `(span.start, span.len)` before returning so
66/// downstream renderers can rely on byte-deterministic order.
67pub fn check_program(
68    ast: &ast::Ast,
69    src: &str,
70) -> (typed_ast::TypedAst, Vec<QalaError>, Vec<QalaWarning>) {
71    let mut c = Checker::new(src);
72    // scan `// qala: allow(...)` directives once over the source text; the
73    // resulting map is consulted by every `emit_warning` call.
74    c.allow = scan_allow_directives(src);
75    // pass 1a: pre-register every top-level type name so forward and mutual
76    // references (`struct A { x: B } struct B { y: A }`) resolve correctly
77    // when pass 1b walks field-type expressions. without this stage,
78    // resolve_type_expr would emit a spurious `UnknownType` for the
79    // forward-referenced name.
80    c.preregister_type_names(ast);
81    // pass 1b: collect every top-level declaration into the symbol table.
82    for item in ast {
83        c.collect_item(item);
84    }
85    // detect any recursive-struct cycles before pass 2 runs.
86    c.detect_recursive_structs();
87    // pass 2 placeholder: build a TypedAst by translating each item. tasks
88    // 2-5 fill in the real check_item that walks bodies and types
89    // expressions; for task 1 this returns a minimal-but-valid typed shape.
90    let typed: typed_ast::TypedAst = ast.iter().map(|item| c.check_item(item)).collect();
91    // pass 3 placeholder: task 3 implements the effect fixed-point.
92    c.resolve_function_effects();
93    // deterministic sort by (span.start, span.len).
94    c.errors.sort_by_key(|e| (e.span().start, e.span().len));
95    c.warnings.sort_by_key(|w| (w.span.start, w.span.len));
96    (typed, c.errors, c.warnings)
97}
98
99// ---- symbol table ----------------------------------------------------------
100
101/// the top-level declarations the typechecker collected during pass 1.
102///
103/// declaration order is preserved separately in [`Checker::struct_decl_order`]
104/// for deterministic cycle reporting; iteration of these `HashMap`s is never
105/// user-visible.
106#[derive(Default)]
107struct SymbolTable {
108    /// every `struct Decl { ... }` keyed by name.
109    structs: HashMap<String, StructInfo>,
110    /// every `enum Decl { ... }` keyed by name. `BTreeMap` so iteration
111    /// order is deterministic (alphabetical) across all enum lookups.
112    enums: BTreeMap<String, EnumInfo>,
113    /// every `interface Decl { ... }` keyed by name.
114    interfaces: HashMap<String, InterfaceInfo>,
115    /// every `fn` or `fn Type.method` declaration keyed by [`FnKey`].
116    fns: HashMap<FnKey, FnInfo>,
117}
118
119/// a resolved struct declaration. fields are populated in pass 1 and
120/// consumed by the bidirectional checker in task 2 plus the
121/// recursive-struct detector below.
122#[allow(dead_code)]
123struct StructInfo {
124    /// the fields in declaration order: name + resolved type.
125    fields: Vec<(String, QalaType)>,
126    /// the declaration's source span.
127    span: Span,
128}
129
130/// a resolved enum declaration. fields are populated in pass 1 and consumed
131/// by the exhaustiveness checker in task 3.
132#[allow(dead_code)]
133struct EnumInfo {
134    /// the variants in declaration order: name + resolved field types.
135    variants: Vec<(String, Vec<QalaType>)>,
136    /// the declaration's source span.
137    span: Span,
138}
139
140/// a resolved interface declaration. fields are populated in pass 1 and
141/// consumed by the structural-satisfaction checker in task 3.
142#[allow(dead_code)]
143struct InterfaceInfo {
144    /// the required method signatures in declaration order.
145    methods: Vec<MethodSigInfo>,
146    /// the declaration's source span.
147    span: Span,
148}
149
150/// a resolved interface-method signature. fields are populated in pass 1
151/// and consumed by the structural-satisfaction checker in task 3.
152#[allow(dead_code)]
153struct MethodSigInfo {
154    /// the method name.
155    name: String,
156    /// the parameter types in declaration order (including `self` typed as
157    /// the receiver if present).
158    params: Vec<QalaType>,
159    /// the return type.
160    ret_ty: QalaType,
161    /// the annotated effect, or `None` for unannotated.
162    effect: Option<EffectSet>,
163    /// the signature's source span.
164    span: Span,
165}
166
167/// the key under which a `fn` or `fn Type.method` is stored.
168///
169/// `(None, name)` is a free function; `(Some(T), name)` is the `fn T.method`
170/// form. derives `Eq + Hash` so it can be a `HashMap` key.
171#[derive(Debug, Clone, PartialEq, Eq, Hash)]
172struct FnKey {
173    /// `Some(T)` for `fn T.method`; `None` for a free function.
174    type_name: Option<String>,
175    /// the function or method name.
176    name: String,
177}
178
179/// a resolved function declaration. fields are populated in pass 1 and
180/// consumed by the bidirectional checker (task 2), the effect fixed-point
181/// (task 3), and the structural-satisfaction checker (task 3).
182#[allow(dead_code)]
183struct FnInfo {
184    /// the parameter list: name + resolved type + an unevaluated default if
185    /// the parser captured one (the typechecker does not yet evaluate
186    /// defaults; codegen does).
187    params: Vec<(String, QalaType, Option<ast::Expr>)>,
188    /// the return type. `void` is the resolved-form of an omitted `-> T`.
189    ret_ty: QalaType,
190    /// the annotated effect, or `None` for unannotated.
191    annotated_effect: Option<EffectSet>,
192    /// the inferred effect set, filled in by pass 3
193    /// ([`Checker::resolve_function_effects`]).
194    inferred_effect: Option<EffectSet>,
195    /// the declaration's source span.
196    span: Span,
197    /// true for `fn T.method` declarations.
198    is_method: bool,
199}
200
201// ---- local-scope info ------------------------------------------------------
202
203/// metadata for one local binding (a `let` or function parameter) currently
204/// in scope.
205#[allow(dead_code)]
206struct LocalInfo {
207    /// the resolved type of this binding.
208    ty: QalaType,
209    /// the binding's source span (the `name` token).
210    span: Span,
211    /// whether the binding was declared `let mut`.
212    is_mut: bool,
213    /// whether the binding has been read at least once. tasks 4-5 use this
214    /// to fire the `unused_var` warning.
215    used: bool,
216    /// whether this is a function parameter (exempt from `unused_var` per
217    /// open question 4).
218    is_param: bool,
219}
220
221// ---- function context ------------------------------------------------------
222
223/// per-function state the typechecker maintains during pass 2.
224#[allow(dead_code)]
225struct FnCtx {
226    /// the function name.
227    name: String,
228    /// `Some(T)` for `fn T.method`; `None` for a free function.
229    type_name: Option<String>,
230    /// the resolved return type.
231    ret_ty: QalaType,
232    /// the annotated effect, or `None` for unannotated.
233    annotated_effect: Option<EffectSet>,
234    /// the intrinsic effect set of the body (the union of every non-call
235    /// statement's intrinsic effect). pass 3 unions this with the inferred
236    /// effects of every called function.
237    body_intrinsic: EffectSet,
238    /// the keys of every function called from this body. pass 3 walks these
239    /// during the fixed-point iteration.
240    called_fns: Vec<FnKey>,
241    /// every call site of this function that may need an effect-violation
242    /// check after pass 3 settles. populated when the caller has an
243    /// annotated effect.
244    callsites_to_check: Vec<EffectViolationCandidate>,
245}
246
247/// a recorded call site that may produce an [`QalaError::EffectViolation`]
248/// after pass 3 resolves every function's inferred effect.
249#[allow(dead_code)]
250#[derive(Clone)]
251struct EffectViolationCandidate {
252    /// the caller's [`FnKey`].
253    caller_key: FnKey,
254    /// the callee's [`FnKey`].
255    callee_key: FnKey,
256    /// the source span of the call expression.
257    call_span: Span,
258}
259
260// ---- checker state ---------------------------------------------------------
261
262/// the type checker's working state. one is constructed per
263/// [`check_program`] call.
264#[allow(dead_code)]
265struct Checker<'src> {
266    /// the source text, kept by reference for the directive scanner and the
267    /// line-index lookup. the typechecker never copies it.
268    src: &'src str,
269    /// the line-start table for converting byte offsets to line / column.
270    line_index: LineIndex,
271    /// the top-level declarations collected in pass 1.
272    symbols: SymbolTable,
273    /// struct names in declaration order, for deterministic SCC traversal.
274    /// also doubles as the duplicate-detection set for structs (the symbols
275    /// map is pre-populated with placeholders by `preregister_type_names`).
276    struct_decl_order: Vec<String>,
277    /// enum names that `collect_enum` has already processed; the symbols map
278    /// is pre-populated with placeholders, so a contains-check there cannot
279    /// distinguish first-vs-duplicate.
280    collected_enum_names: Vec<String>,
281    /// interface names that `collect_interface` has already processed.
282    collected_interface_names: Vec<String>,
283    /// the accumulated errors. sorted by `(span.start, span.len)` before
284    /// returning.
285    errors: Vec<QalaError>,
286    /// the accumulated warnings. sorted by `(span.start, span.len)` before
287    /// returning.
288    warnings: Vec<QalaWarning>,
289    /// the `// qala: allow(...)` directive table built once at the start of
290    /// [`check_program`]. tasks 4-5 use it to silence warnings per-line.
291    allow: HashMap<usize, BTreeSet<String>>,
292    /// the active lexical scopes. each scope is a name -> local-info map;
293    /// the topmost is the innermost.
294    scopes: Vec<HashMap<String, LocalInfo>>,
295    /// the active function context, or `None` outside a function body.
296    fn_ctx: Option<FnCtx>,
297    /// recursive-depth counter for [`Checker::infer_expr`]. mirrors the
298    /// parser's [`MAX_DEPTH`] guard.
299    depth: u32,
300    /// per-function record (intrinsic body effect, called fns, candidate
301    /// call sites). populated during pass 2's `check_fn_decl`; consumed by
302    /// pass 3's `resolve_function_effects` in task 3.
303    body_records: HashMap<FnKey, BodyEffectRecord>,
304}
305
306impl<'src> Checker<'src> {
307    /// construct a fresh checker for the given source text.
308    fn new(src: &'src str) -> Self {
309        Checker {
310            src,
311            line_index: LineIndex::new(src),
312            symbols: SymbolTable::default(),
313            struct_decl_order: Vec::new(),
314            collected_enum_names: Vec::new(),
315            collected_interface_names: Vec::new(),
316            errors: Vec::new(),
317            warnings: Vec::new(),
318            // task 5 replaces this empty map with the real scanner output.
319            allow: HashMap::new(),
320            scopes: Vec::new(),
321            fn_ctx: None,
322            depth: 0,
323            body_records: HashMap::new(),
324        }
325    }
326}
327
328// ---- pass 1: declaration collection ----------------------------------------
329
330impl<'src> Checker<'src> {
331    /// register every top-level type name (struct / enum / interface) with
332    /// an empty placeholder entry so forward and mutual references in field
333    /// type positions resolve without emitting a spurious `UnknownType`.
334    ///
335    /// the placeholder is replaced by the real info in `collect_*`. duplicate
336    /// names are NOT diagnosed here; `collect_*` does that against the
337    /// already-populated map.
338    fn preregister_type_names(&mut self, ast: &ast::Ast) {
339        for item in ast {
340            match item {
341                ast::Item::Struct(d) => {
342                    self.symbols
343                        .structs
344                        .entry(d.name.clone())
345                        .or_insert(StructInfo {
346                            fields: Vec::new(),
347                            span: d.span,
348                        });
349                }
350                ast::Item::Enum(d) => {
351                    self.symbols
352                        .enums
353                        .entry(d.name.clone())
354                        .or_insert(EnumInfo {
355                            variants: Vec::new(),
356                            span: d.span,
357                        });
358                }
359                ast::Item::Interface(d) => {
360                    self.symbols
361                        .interfaces
362                        .entry(d.name.clone())
363                        .or_insert(InterfaceInfo {
364                            methods: Vec::new(),
365                            span: d.span,
366                        });
367                }
368                ast::Item::Fn(_) => {}
369            }
370        }
371    }
372
373    /// dispatch on an item kind and call the matching collector.
374    fn collect_item(&mut self, item: &ast::Item) {
375        match item {
376            ast::Item::Fn(d) => self.collect_fn(d),
377            ast::Item::Struct(d) => self.collect_struct(d),
378            ast::Item::Enum(d) => self.collect_enum(d),
379            ast::Item::Interface(d) => self.collect_interface(d),
380        }
381    }
382
383    /// collect a struct declaration into the symbol table.
384    ///
385    /// resolves each field's `TypeExpr` to a [`QalaType`]; emits an
386    /// `UnknownType` per unresolvable field type so pass 2 can keep going
387    /// with `Unknown` in the unresolved slot. emits a generic `Type` error
388    /// on a duplicate struct name (the first declaration wins, so the
389    /// placeholder kept the first decl's span and the second triggers the
390    /// duplicate-check below). detection uses `struct_decl_order` because
391    /// `preregister_type_names` already populated the symbols map with
392    /// placeholders.
393    fn collect_struct(&mut self, decl: &ast::StructDecl) {
394        if self.struct_decl_order.contains(&decl.name) {
395            self.errors.push(QalaError::Type {
396                span: decl.span,
397                message: format!("duplicate struct definition `{}`", decl.name),
398            });
399            return;
400        }
401        let mut fields: Vec<(String, QalaType)> = Vec::with_capacity(decl.fields.len());
402        for f in &decl.fields {
403            let ty = self.resolve_type_expr(&f.ty);
404            fields.push((f.name.clone(), ty));
405        }
406        self.symbols.structs.insert(
407            decl.name.clone(),
408            StructInfo {
409                fields,
410                span: decl.span,
411            },
412        );
413        self.struct_decl_order.push(decl.name.clone());
414    }
415
416    /// collect an enum declaration into the symbol table.
417    fn collect_enum(&mut self, decl: &ast::EnumDecl) {
418        if self.collected_enum_names.contains(&decl.name) {
419            self.errors.push(QalaError::Type {
420                span: decl.span,
421                message: format!("duplicate enum definition `{}`", decl.name),
422            });
423            return;
424        }
425        self.collected_enum_names.push(decl.name.clone());
426        let mut variants: Vec<(String, Vec<QalaType>)> = Vec::with_capacity(decl.variants.len());
427        for v in &decl.variants {
428            let fields: Vec<QalaType> =
429                v.fields.iter().map(|t| self.resolve_type_expr(t)).collect();
430            variants.push((v.name.clone(), fields));
431        }
432        self.symbols.enums.insert(
433            decl.name.clone(),
434            EnumInfo {
435                variants,
436                span: decl.span,
437            },
438        );
439    }
440
441    /// collect an interface declaration into the symbol table.
442    fn collect_interface(&mut self, decl: &ast::InterfaceDecl) {
443        if self.collected_interface_names.contains(&decl.name) {
444            self.errors.push(QalaError::Type {
445                span: decl.span,
446                message: format!("duplicate interface definition `{}`", decl.name),
447            });
448            return;
449        }
450        self.collected_interface_names.push(decl.name.clone());
451        let methods: Vec<MethodSigInfo> = decl
452            .methods
453            .iter()
454            .map(|m| {
455                // a method signature's params include `self` if present; we
456                // resolve self to the receiver -- but at the interface
457                // declaration site we don't yet know the receiver type, so
458                // we treat `self` as `QalaType::Unknown` here. structural
459                // satisfaction (task 3) ignores the self slot when matching
460                // against an impl's `fn Type.method`.
461                let params: Vec<QalaType> = m
462                    .params
463                    .iter()
464                    .map(|p| {
465                        if p.is_self {
466                            QalaType::Unknown
467                        } else if let Some(ty) = &p.ty {
468                            self.resolve_type_expr(ty)
469                        } else {
470                            QalaType::Unknown
471                        }
472                    })
473                    .collect();
474                let ret_ty = m
475                    .ret_ty
476                    .as_ref()
477                    .map(|t| self.resolve_type_expr(t))
478                    .unwrap_or(QalaType::Void);
479                let effect = m.effect.as_ref().map(effect_set_from_ast);
480                MethodSigInfo {
481                    name: m.name.clone(),
482                    params,
483                    ret_ty,
484                    effect,
485                    span: m.span,
486                }
487            })
488            .collect();
489        self.symbols.interfaces.insert(
490            decl.name.clone(),
491            InterfaceInfo {
492                methods,
493                span: decl.span,
494            },
495        );
496    }
497
498    /// collect a function (free function or `fn T.method`) into the symbol
499    /// table.
500    fn collect_fn(&mut self, decl: &ast::FnDecl) {
501        let key = FnKey {
502            type_name: decl.type_name.clone(),
503            name: decl.name.clone(),
504        };
505        if self.symbols.fns.contains_key(&key) {
506            // first declaration wins; second is the duplicate.
507            let label = match &decl.type_name {
508                Some(t) => format!("{}.{}", t, decl.name),
509                None => decl.name.clone(),
510            };
511            self.errors.push(QalaError::Type {
512                span: decl.span,
513                message: format!("duplicate function definition `{label}`"),
514            });
515            return;
516        }
517        let mut params: Vec<(String, QalaType, Option<ast::Expr>)> =
518            Vec::with_capacity(decl.params.len());
519        for p in &decl.params {
520            let ty = if p.is_self {
521                // self's type is the receiver type, when present.
522                match &decl.type_name {
523                    Some(t) => QalaType::Named(Symbol(t.clone())),
524                    None => QalaType::Unknown,
525                }
526            } else if let Some(t) = &p.ty {
527                self.resolve_type_expr(t)
528            } else {
529                // a non-self param without a type is malformed; the parser
530                // should reject this, but recovery is `Unknown`.
531                QalaType::Unknown
532            };
533            params.push((p.name.clone(), ty, p.default.clone()));
534        }
535        let ret_ty = decl
536            .ret_ty
537            .as_ref()
538            .map(|t| self.resolve_type_expr(t))
539            .unwrap_or(QalaType::Void);
540        let annotated_effect = decl.effect.as_ref().map(effect_set_from_ast);
541        let is_method = decl.type_name.is_some();
542        self.symbols.fns.insert(
543            key,
544            FnInfo {
545                params,
546                ret_ty,
547                annotated_effect,
548                inferred_effect: None,
549                span: decl.span,
550                is_method,
551            },
552        );
553    }
554
555    /// resolve a `TypeExpr` to a [`QalaType`], emitting `UnknownType` on a
556    /// name that does not match a primitive, a known struct / enum /
557    /// interface, the built-in `FileHandle`, or a known built-in generic.
558    fn resolve_type_expr(&mut self, ty: &ast::TypeExpr) -> QalaType {
559        match ty {
560            ast::TypeExpr::Primitive { kind, .. } => QalaType::from_prim_type(kind),
561            ast::TypeExpr::Named { name, span } => {
562                // Result / Option without generic args is an explicit error
563                // -- the parser allows the bare name here, but the type
564                // system requires the arguments.
565                if name == "Result" {
566                    self.errors.push(QalaError::Type {
567                        span: *span,
568                        message: "Result requires two type arguments".to_string(),
569                    });
570                    return QalaType::Unknown;
571                }
572                if name == "Option" {
573                    self.errors.push(QalaError::Type {
574                        span: *span,
575                        message: "Option requires one type argument".to_string(),
576                    });
577                    return QalaType::Unknown;
578                }
579                if name == "FileHandle" {
580                    return QalaType::FileHandle;
581                }
582                if self.symbols.structs.contains_key(name)
583                    || self.symbols.enums.contains_key(name)
584                    || self.symbols.interfaces.contains_key(name)
585                {
586                    return QalaType::Named(Symbol(name.clone()));
587                }
588                self.errors.push(QalaError::UnknownType {
589                    span: *span,
590                    name: name.clone(),
591                });
592                QalaType::Unknown
593            }
594            ast::TypeExpr::Array { elem, size, .. } => {
595                QalaType::Array(Box::new(self.resolve_type_expr(elem)), Some(*size as usize))
596            }
597            ast::TypeExpr::DynArray { elem, .. } => {
598                QalaType::Array(Box::new(self.resolve_type_expr(elem)), None)
599            }
600            ast::TypeExpr::Tuple { elems, .. } => {
601                QalaType::Tuple(elems.iter().map(|e| self.resolve_type_expr(e)).collect())
602            }
603            ast::TypeExpr::Fn { params, ret, .. } => QalaType::Function {
604                params: params.iter().map(|p| self.resolve_type_expr(p)).collect(),
605                returns: Box::new(self.resolve_type_expr(ret)),
606            },
607            ast::TypeExpr::Generic { name, args, span } => {
608                if name == "Result" {
609                    if args.len() != 2 {
610                        self.errors.push(QalaError::Type {
611                            span: *span,
612                            message: "Result requires two type arguments".to_string(),
613                        });
614                        return QalaType::Unknown;
615                    }
616                    let ok = self.resolve_type_expr(&args[0]);
617                    let err = self.resolve_type_expr(&args[1]);
618                    return QalaType::Result(Box::new(ok), Box::new(err));
619                }
620                if name == "Option" {
621                    if args.len() != 1 {
622                        self.errors.push(QalaError::Type {
623                            span: *span,
624                            message: "Option requires one type argument".to_string(),
625                        });
626                        return QalaType::Unknown;
627                    }
628                    let inner = self.resolve_type_expr(&args[0]);
629                    return QalaType::Option(Box::new(inner));
630                }
631                self.errors.push(QalaError::Type {
632                    span: *span,
633                    message: format!("unknown generic type `{name}`"),
634                });
635                QalaType::Unknown
636            }
637        }
638    }
639}
640
641/// bridge from the parser's `Effect` (records *which keyword appeared*) to an
642/// [`EffectSet`] (the bitfield the checker actually reasons about).
643fn effect_set_from_ast(e: &ast::Effect) -> EffectSet {
644    match e {
645        ast::Effect::Pure => EffectSet::pure(),
646        ast::Effect::Io => EffectSet::io(),
647        ast::Effect::Alloc => EffectSet::alloc(),
648        ast::Effect::Panic => EffectSet::panic(),
649    }
650}
651
652// ---- recursive struct detection -------------------------------------------
653
654impl<'src> Checker<'src> {
655    /// detect every cycle in the by-value struct graph and emit one
656    /// [`QalaError::RecursiveStructByValue`] per cycle.
657    ///
658    /// implementation: Tarjan's strongly-connected-components algorithm on the
659    /// adjacency built from each struct's by-value field-type targets. an SCC
660    /// of size > 1 or a singleton with a self-edge is a cycle. the cycle's
661    /// lexicographically-smallest struct name is the head; the path walks the
662    /// cycle starting and ending at the head so the message reads as a closed
663    /// loop when joined with arrows (`A -> B -> A`).
664    ///
665    /// "by value" excludes `Option<_>`, `Result<_, _>`, dynamic arrays (`[T]`,
666    /// stored Some-with-no-length), and zero-size fixed arrays. fixed arrays
667    /// of size > 0 and tuples DO carry their element types by value, so they
668    /// contribute edges.
669    fn detect_recursive_structs(&mut self) {
670        // alphabetical traversal order makes the chosen head deterministic
671        // when multiple structs participate in a single SCC.
672        let mut order = self.struct_decl_order.clone();
673        order.sort();
674
675        // adjacency: struct name -> sorted list of by-value struct neighbours.
676        let mut adj: HashMap<String, Vec<String>> = HashMap::new();
677        for name in &order {
678            let mut targets: BTreeSet<String> = BTreeSet::new();
679            if let Some(info) = self.symbols.structs.get(name) {
680                for (_, ty) in &info.fields {
681                    collect_by_value_targets(ty, &mut targets);
682                }
683            }
684            let neighbours: Vec<String> = targets
685                .into_iter()
686                .filter(|n| self.symbols.structs.contains_key(n))
687                .collect();
688            adj.insert(name.clone(), neighbours);
689        }
690
691        // Tarjan SCC.
692        let sccs = tarjan_scc(&order, &adj);
693
694        for scc in sccs {
695            // a singleton SCC is a cycle iff there is a self-edge.
696            let is_cycle = scc.len() > 1
697                || (scc.len() == 1
698                    && adj
699                        .get(&scc[0])
700                        .map(|v| v.contains(&scc[0]))
701                        .unwrap_or(false));
702            if !is_cycle {
703                continue;
704            }
705            // the head is the lexicographically smallest name in the SCC.
706            let head = scc.iter().min().cloned().unwrap_or_else(|| scc[0].clone());
707            // build the path by DFS from head along the SCC's adjacency
708            // until we return to head. for a self-loop the path is `[head,
709            // head]`.
710            let scc_set: BTreeSet<String> = scc.iter().cloned().collect();
711            let mut path: Vec<String> = vec![head.clone()];
712            let mut current = head.clone();
713            // bounded by scc.len() + 1 -- a cycle of N nodes returns to head
714            // in N steps.
715            for _ in 0..=scc.len() {
716                let next: Option<String> = adj
717                    .get(&current)
718                    .and_then(|nbrs| nbrs.iter().filter(|n| scc_set.contains(*n)).min().cloned());
719                let Some(next_name) = next else {
720                    break;
721                };
722                path.push(next_name.clone());
723                if next_name == head {
724                    break;
725                }
726                current = next_name;
727            }
728            // span: the head struct's declaration site.
729            let span = self
730                .symbols
731                .structs
732                .get(&head)
733                .map(|s| s.span)
734                .unwrap_or(Span::new(0, 0));
735            self.errors
736                .push(QalaError::RecursiveStructByValue { span, path });
737        }
738    }
739}
740
741/// collect the names of every "by value" struct reachable from a type, for the
742/// recursive-struct-by-value detector. tuples and fixed arrays of nonzero size
743/// pass their element type through unchanged; dynamic arrays, `Option`,
744/// `Result`, function types, zero-sized fixed arrays, and primitives are all
745/// "by reference" (or carry no struct names) and contribute nothing.
746fn collect_by_value_targets(ty: &QalaType, out: &mut BTreeSet<String>) {
747    match ty {
748        QalaType::Named(Symbol(name)) => {
749            out.insert(name.clone());
750        }
751        QalaType::Tuple(elems) => {
752            for e in elems {
753                collect_by_value_targets(e, out);
754            }
755        }
756        QalaType::Array(inner, Some(n)) if *n > 0 => {
757            collect_by_value_targets(inner, out);
758        }
759        // dynamic arrays, zero-size fixed arrays, function types, Option,
760        // Result, FileHandle, primitives, Unknown -- none of these carry the
761        // contained type by value at the struct-layout level.
762        _ => {}
763    }
764}
765
766/// Tarjan's strongly-connected-components algorithm, iterative form.
767///
768/// returns the list of SCCs in the order Tarjan emits them (reverse
769/// topological -- leaves first). each SCC is a `Vec<String>` of node names.
770/// the caller iterates the SCCs in the order returned; for cycle reporting
771/// the per-SCC head is chosen as the lexicographically smallest name, so the
772/// emit order is by Tarjan's discovery, not by name.
773///
774/// the adjacency lists are expected to be sorted (callers iterate them in
775/// determined order). nodes is the universe in alphabetical order, so the
776/// traversal is reproducible across runs.
777fn tarjan_scc(nodes: &[String], adj: &HashMap<String, Vec<String>>) -> Vec<Vec<String>> {
778    // node -> Tarjan index, lowlink, on-stack flag.
779    let mut index_of: HashMap<String, u32> = HashMap::new();
780    let mut lowlink: HashMap<String, u32> = HashMap::new();
781    let mut on_stack: HashMap<String, bool> = HashMap::new();
782    let mut stack: Vec<String> = Vec::new();
783    let mut next_index: u32 = 0;
784    let mut sccs: Vec<Vec<String>> = Vec::new();
785
786    // iterative DFS: each Frame tracks where we are in the children list.
787    struct Frame {
788        node: String,
789        next_child: usize,
790    }
791
792    for root in nodes {
793        if index_of.contains_key(root) {
794            continue;
795        }
796        // begin a DFS rooted at `root`.
797        index_of.insert(root.clone(), next_index);
798        lowlink.insert(root.clone(), next_index);
799        next_index += 1;
800        stack.push(root.clone());
801        on_stack.insert(root.clone(), true);
802
803        let mut call_stack: Vec<Frame> = vec![Frame {
804            node: root.clone(),
805            next_child: 0,
806        }];
807
808        while let Some(frame) = call_stack.last_mut() {
809            let v = frame.node.clone();
810            let empty: Vec<String> = Vec::new();
811            let children = adj.get(&v).unwrap_or(&empty);
812            if frame.next_child < children.len() {
813                let w = children[frame.next_child].clone();
814                frame.next_child += 1;
815                if !index_of.contains_key(&w) {
816                    // recurse into w.
817                    index_of.insert(w.clone(), next_index);
818                    lowlink.insert(w.clone(), next_index);
819                    next_index += 1;
820                    stack.push(w.clone());
821                    on_stack.insert(w.clone(), true);
822                    call_stack.push(Frame {
823                        node: w,
824                        next_child: 0,
825                    });
826                } else if *on_stack.get(&w).unwrap_or(&false) {
827                    // back-edge: update v's lowlink.
828                    let w_idx = *index_of.get(&w).unwrap_or(&0);
829                    let v_low = *lowlink.get(&v).unwrap_or(&0);
830                    lowlink.insert(v.clone(), v_low.min(w_idx));
831                }
832            } else {
833                // finished v. if v is a root of an SCC, pop one.
834                let v_idx = *index_of.get(&v).unwrap_or(&0);
835                let v_low = *lowlink.get(&v).unwrap_or(&0);
836                if v_low == v_idx {
837                    let mut component: Vec<String> = Vec::new();
838                    // Tarjan invariant: v must be on the stack when we
839                    // find it is an SCC root. `while let` terminates
840                    // naturally on an empty stack (invariant violation)
841                    // rather than looping forever.
842                    while let Some(w) = stack.pop() {
843                        on_stack.insert(w.clone(), false);
844                        let is_v = w == v;
845                        component.push(w);
846                        if is_v {
847                            break;
848                        }
849                    }
850                    sccs.push(component);
851                }
852                call_stack.pop();
853                // propagate lowlink to the parent (the new top of call_stack).
854                if let Some(parent_frame) = call_stack.last() {
855                    let p = parent_frame.node.clone();
856                    let p_low = *lowlink.get(&p).unwrap_or(&0);
857                    let v_low = *lowlink.get(&v).unwrap_or(&0);
858                    lowlink.insert(p, p_low.min(v_low));
859                }
860            }
861        }
862    }
863    sccs
864}
865
866// ---- pass 2: bidirectional type checking -----------------------------------
867
868impl<'src> Checker<'src> {
869    /// translate an untyped item into a typed one, fully checking bodies.
870    ///
871    /// for an `Item::Fn`, this opens a fresh scope, binds each parameter as
872    /// a `LocalInfo`, checks the body against the declared return type,
873    /// closes the scope (firing any per-scope warnings), and constructs the
874    /// `TypedFnDecl` with a placeholder effect that pass 3 finalises.
875    /// for the data-only item kinds (struct / enum / interface), the typed
876    /// shape is constructed directly from the resolved symbol-table entries.
877    fn check_item(&mut self, item: &ast::Item) -> typed_ast::TypedItem {
878        match item {
879            ast::Item::Fn(d) => self.check_fn_decl(d),
880            ast::Item::Struct(d) => typed_ast::TypedItem::Struct(typed_ast::TypedStructDecl {
881                name: d.name.clone(),
882                fields: d
883                    .fields
884                    .iter()
885                    .map(|f| typed_ast::TypedField {
886                        name: f.name.clone(),
887                        ty: resolve_type_silent(&f.ty, &self.symbols),
888                        span: f.span,
889                    })
890                    .collect(),
891                span: d.span,
892            }),
893            ast::Item::Enum(d) => typed_ast::TypedItem::Enum(typed_ast::TypedEnumDecl {
894                name: d.name.clone(),
895                variants: d
896                    .variants
897                    .iter()
898                    .map(|v| typed_ast::TypedVariant {
899                        name: v.name.clone(),
900                        fields: v
901                            .fields
902                            .iter()
903                            .map(|t| resolve_type_silent(t, &self.symbols))
904                            .collect(),
905                        span: v.span,
906                    })
907                    .collect(),
908                span: d.span,
909            }),
910            ast::Item::Interface(d) => {
911                typed_ast::TypedItem::Interface(typed_ast::TypedInterfaceDecl {
912                    name: d.name.clone(),
913                    methods: d
914                        .methods
915                        .iter()
916                        .map(|m| typed_ast::TypedMethodSig {
917                            name: m.name.clone(),
918                            params: m
919                                .params
920                                .iter()
921                                .map(|p| typed_ast::TypedParam {
922                                    is_self: p.is_self,
923                                    name: p.name.clone(),
924                                    ty: if p.is_self {
925                                        QalaType::Unknown
926                                    } else if let Some(t) = &p.ty {
927                                        resolve_type_silent(t, &self.symbols)
928                                    } else {
929                                        QalaType::Unknown
930                                    },
931                                    default: None,
932                                    span: p.span,
933                                })
934                                .collect(),
935                            ret_ty: m
936                                .ret_ty
937                                .as_ref()
938                                .map(|t| resolve_type_silent(t, &self.symbols))
939                                .unwrap_or(QalaType::Void),
940                            effect: m
941                                .effect
942                                .as_ref()
943                                .map(effect_set_from_ast)
944                                .unwrap_or(EffectSet::pure()),
945                            span: m.span,
946                        })
947                        .collect(),
948                    span: d.span,
949                })
950            }
951        }
952    }
953
954    /// check whether a name names a stdlib resource-returning call. v1's
955    /// allow-list is `open` only; conservative: false negatives are
956    /// acceptable, false positives are not.
957    fn is_resource_returning(name: &str) -> bool {
958        matches!(name, "open")
959    }
960
961    /// extract the handle name closed by a defer's body. recognizes
962    /// `close(name)` and `name.close()`. returns `None` for any other
963    /// shape.
964    fn extract_closed_handle(expr: &ast::Expr) -> Option<String> {
965        match expr {
966            ast::Expr::Call { callee, args, .. } => {
967                if let ast::Expr::Ident { name, .. } = callee.as_ref()
968                    && name == "close"
969                    && args.len() == 1
970                    && let ast::Expr::Ident { name: h, .. } = &args[0]
971                {
972                    return Some(h.clone());
973                }
974                None
975            }
976            ast::Expr::MethodCall {
977                receiver,
978                name,
979                args,
980                ..
981            } => {
982                if name == "close"
983                    && args.is_empty()
984                    && let ast::Expr::Ident { name: h, .. } = receiver.as_ref()
985                {
986                    return Some(h.clone());
987                }
988                None
989            }
990            _ => None,
991        }
992    }
993
994    /// scan a block (and recursively, its nested blocks) for resource
995    /// bindings without a matching `defer close`.
996    fn check_unmatched_defer(&mut self, block: &ast::Block) {
997        // collect bindings of the form `let h = open(...)` in this block's
998        // stmts -- only at this level; nested blocks are handled by the
999        // recursion below.
1000        let mut handle_bindings: Vec<(String, Span)> = Vec::new();
1001        for stmt in &block.stmts {
1002            if let ast::Stmt::Let {
1003                name, init, span, ..
1004            } = stmt
1005                && let ast::Expr::Call { callee, .. } = init
1006                && let ast::Expr::Ident { name: cname, .. } = callee.as_ref()
1007                && Self::is_resource_returning(cname)
1008            {
1009                handle_bindings.push((name.clone(), *span));
1010            }
1011        }
1012        // collect closed handle names from same-block defer statements.
1013        let mut closed: BTreeSet<String> = BTreeSet::new();
1014        for stmt in &block.stmts {
1015            if let ast::Stmt::Defer { expr, .. } = stmt
1016                && let Some(h) = Self::extract_closed_handle(expr)
1017            {
1018                closed.insert(h);
1019            }
1020        }
1021        // fire unmatched_defer for any handle binding with no matching close.
1022        for (name, span) in &handle_bindings {
1023            if !closed.contains(name) {
1024                let w = QalaWarning {
1025                    category: "unmatched_defer".to_string(),
1026                    message: format!(
1027                        "resource `{name}` is acquired without a matching `defer close`"
1028                    ),
1029                    span: *span,
1030                    note: None,
1031                };
1032                self.emit_warning(w);
1033            }
1034        }
1035        // recurse into nested blocks.
1036        for stmt in &block.stmts {
1037            self.check_unmatched_defer_stmt(stmt);
1038        }
1039        // also recurse into the trailing value's block, if it is one.
1040        if let Some(boxed) = &block.value
1041            && let ast::Expr::Block { block: inner, .. } = boxed.as_ref()
1042        {
1043            self.check_unmatched_defer(inner);
1044        }
1045    }
1046
1047    /// recurse the defer-mismatch scan through one statement. for `if`,
1048    /// visits the then-block and the full else chain (including arbitrarily
1049    /// deep `else if` nesting) so every branch is checked.
1050    fn check_unmatched_defer_stmt(&mut self, stmt: &ast::Stmt) {
1051        match stmt {
1052            ast::Stmt::If {
1053                then_block,
1054                else_branch,
1055                ..
1056            } => {
1057                self.check_unmatched_defer(then_block);
1058                match else_branch {
1059                    Some(ast::ElseBranch::Block(b)) => self.check_unmatched_defer(b),
1060                    Some(ast::ElseBranch::If(boxed)) => {
1061                        // recurse into the nested if-stmt so else-if chains
1062                        // of any depth are all visited.
1063                        self.check_unmatched_defer_stmt(boxed);
1064                    }
1065                    None => {}
1066                }
1067            }
1068            ast::Stmt::While { body, .. } => self.check_unmatched_defer(body),
1069            ast::Stmt::For { body, .. } => self.check_unmatched_defer(body),
1070            _ => {}
1071        }
1072    }
1073
1074    /// check a single fn declaration: open a scope, bind params, walk the
1075    /// body, close the scope, build the `TypedFnDecl`.
1076    fn check_fn_decl(&mut self, d: &ast::FnDecl) -> typed_ast::TypedItem {
1077        let ret_ty = d
1078            .ret_ty
1079            .as_ref()
1080            .map(|t| resolve_type_silent(t, &self.symbols))
1081            .unwrap_or(QalaType::Void);
1082        let annotated_effect = d.effect.as_ref().map(effect_set_from_ast);
1083        // typed-params: resolve each type and bind it as a LocalInfo with
1084        // is_param=true so the `unused_var` rule never fires on a param.
1085        let mut typed_params: Vec<typed_ast::TypedParam> = Vec::with_capacity(d.params.len());
1086        let mut param_locals: Vec<(String, LocalInfo)> = Vec::with_capacity(d.params.len());
1087        for p in &d.params {
1088            let ty = if p.is_self {
1089                match &d.type_name {
1090                    Some(t) => QalaType::Named(Symbol(t.clone())),
1091                    None => QalaType::Unknown,
1092                }
1093            } else if let Some(t) = &p.ty {
1094                resolve_type_silent(t, &self.symbols)
1095            } else {
1096                QalaType::Unknown
1097            };
1098            typed_params.push(typed_ast::TypedParam {
1099                is_self: p.is_self,
1100                name: p.name.clone(),
1101                ty: ty.clone(),
1102                default: None,
1103                span: p.span,
1104            });
1105            param_locals.push((
1106                p.name.clone(),
1107                LocalInfo {
1108                    ty,
1109                    span: p.span,
1110                    is_mut: false,
1111                    used: false,
1112                    is_param: true,
1113                },
1114            ));
1115        }
1116        // open the function-body scope, bind parameters.
1117        self.fn_ctx = Some(FnCtx {
1118            name: d.name.clone(),
1119            type_name: d.type_name.clone(),
1120            ret_ty: ret_ty.clone(),
1121            annotated_effect,
1122            body_intrinsic: EffectSet::pure(),
1123            called_fns: Vec::new(),
1124            callsites_to_check: Vec::new(),
1125        });
1126        let mut scope: HashMap<String, LocalInfo> = HashMap::new();
1127        for (name, info) in param_locals {
1128            scope.insert(name, info);
1129        }
1130        self.scopes.push(scope);
1131
1132        // check the body against the declared return type.
1133        let body = self.check_block(&d.body, Some(&ret_ty));
1134        // unmatched_defer scan: walk the body looking for `let h = open(...)`
1135        // without a same-scope `defer close(h)`.
1136        self.check_unmatched_defer(&d.body);
1137
1138        // missing-return check: a function declared to return a non-Void type
1139        // must produce a value (a trailing block expression or a return
1140        // statement on every path). v1's check is simple: if ret_ty != Void
1141        // and the body's trailing value is None AND the body does not end
1142        // with a Return statement, emit MissingReturn.
1143        if !ret_ty.types_match(&QalaType::Void) && body.value.is_none() {
1144            let ends_in_return = d
1145                .body
1146                .stmts
1147                .last()
1148                .map(|s| matches!(s, ast::Stmt::Return { .. }))
1149                .unwrap_or(false);
1150            // also accept an if-with-returns or a while-with-returns -- v1
1151            // does not do dataflow, so any non-Return tail with a Void
1152            // trailing value triggers the warning. picking up the trailing
1153            // brace as the span: the closing token is the body's span.end.
1154            if !ends_in_return {
1155                let span = Span::new(d.body.span.end().saturating_sub(1), 1);
1156                self.errors.push(QalaError::MissingReturn {
1157                    span,
1158                    fn_name: d.name.clone(),
1159                    expected: ret_ty.display(),
1160                });
1161            }
1162        }
1163
1164        // pop the function scope; fire any unused_var warnings.
1165        if let Some(scope) = self.scopes.pop() {
1166            for (name, info) in scope {
1167                if !info.is_param && !info.used && !name.starts_with('_') {
1168                    let w = QalaWarning {
1169                        category: "unused_var".to_string(),
1170                        message: format!("unused variable `{name}`"),
1171                        span: info.span,
1172                        note: None,
1173                    };
1174                    self.emit_warning(w);
1175                }
1176            }
1177        }
1178
1179        // pull the FnCtx; pass 3 will read body_intrinsic / called_fns /
1180        // callsites_to_check to settle effect inference.
1181        let ctx = self.fn_ctx.take();
1182        if let Some(ctx) = ctx {
1183            // record the per-fn body-effect record for pass 3.
1184            let key = FnKey {
1185                type_name: d.type_name.clone(),
1186                name: d.name.clone(),
1187            };
1188            self.body_records.insert(
1189                key,
1190                BodyEffectRecord {
1191                    intrinsic: ctx.body_intrinsic,
1192                    called: ctx.called_fns,
1193                    callsites_to_check: ctx.callsites_to_check,
1194                },
1195            );
1196        }
1197
1198        typed_ast::TypedItem::Fn(typed_ast::TypedFnDecl {
1199            type_name: d.type_name.clone(),
1200            name: d.name.clone(),
1201            params: typed_params,
1202            ret_ty,
1203            effect: annotated_effect.unwrap_or(EffectSet::pure()),
1204            body,
1205            span: d.span,
1206        })
1207    }
1208
1209    /// check a block: open a scope, walk each statement, then either check
1210    /// or infer the trailing value. fire unreachable-code warnings inside
1211    /// the loop; fire unused-var warnings when the scope is popped.
1212    fn check_block(
1213        &mut self,
1214        block: &ast::Block,
1215        expected: Option<&QalaType>,
1216    ) -> typed_ast::TypedBlock {
1217        // a top-level fn body's scope is already on the stack (with params);
1218        // for any nested block we push a fresh scope so name resolution and
1219        // unused_var fire correctly.
1220        let pushed_scope = !self.scopes.is_empty();
1221        if pushed_scope {
1222            self.scopes.push(HashMap::new());
1223        }
1224        let mut typed_stmts: Vec<typed_ast::TypedStmt> = Vec::with_capacity(block.stmts.len());
1225        let mut terminator: Option<Span> = None;
1226        let mut warned_unreachable = false;
1227        for stmt in &block.stmts {
1228            if terminator.is_some() && !warned_unreachable {
1229                let w = QalaWarning {
1230                    category: "unreachable_code".to_string(),
1231                    message: "unreachable statement after `return`, `break`, or `continue`"
1232                        .to_string(),
1233                    span: stmt.span(),
1234                    note: None,
1235                };
1236                self.emit_warning(w);
1237                warned_unreachable = true;
1238                // still walk the stmt so types resolve; the warning fires
1239                // exactly once per block.
1240            }
1241            let typed_stmt = self.check_stmt(stmt);
1242            let is_terminator = matches!(
1243                stmt,
1244                ast::Stmt::Return { .. } | ast::Stmt::Break { .. } | ast::Stmt::Continue { .. }
1245            );
1246            if is_terminator && terminator.is_none() {
1247                terminator = Some(stmt.span());
1248            }
1249            typed_stmts.push(typed_stmt);
1250        }
1251        let (typed_value, value_ty) = match &block.value {
1252            Some(v) => {
1253                let typed = match expected {
1254                    Some(ty) => self.check_expr(v, ty),
1255                    None => self.infer_expr(v),
1256                };
1257                let ty = typed.ty().clone();
1258                (Some(Box::new(typed)), ty)
1259            }
1260            None => (None, QalaType::Void),
1261        };
1262        // pop the nested scope, fire unused_var.
1263        if pushed_scope && let Some(scope) = self.scopes.pop() {
1264            for (name, info) in scope {
1265                if !info.is_param && !info.used && !name.starts_with('_') {
1266                    let w = QalaWarning {
1267                        category: "unused_var".to_string(),
1268                        message: format!("unused variable `{name}`"),
1269                        span: info.span,
1270                        note: None,
1271                    };
1272                    self.emit_warning(w);
1273                }
1274            }
1275        }
1276        typed_ast::TypedBlock {
1277            stmts: typed_stmts,
1278            value: typed_value,
1279            ty: value_ty,
1280            span: block.span,
1281        }
1282    }
1283
1284    /// check a single statement.
1285    fn check_stmt(&mut self, stmt: &ast::Stmt) -> typed_ast::TypedStmt {
1286        match stmt {
1287            ast::Stmt::Let {
1288                is_mut,
1289                name,
1290                ty,
1291                init,
1292                span,
1293            } => {
1294                let (typed_init, decl_ty) = match ty {
1295                    Some(t) => {
1296                        let expected = resolve_type_silent(t, &self.symbols);
1297                        let typed_init = self.check_expr(init, &expected);
1298                        // redundant_annotation: fire iff the inferred type
1299                        // strictly equals the declared type (not via the
1300                        // Unknown wildcard, which would let typos slip).
1301                        if types_strictly_equal(typed_init.ty(), &expected) {
1302                            let w = QalaWarning {
1303                                category: "redundant_annotation".to_string(),
1304                                message: format!(
1305                                    "redundant type annotation: `{}` matches the inferred type",
1306                                    expected.display()
1307                                ),
1308                                span: t.span(),
1309                                note: None,
1310                            };
1311                            self.emit_warning(w);
1312                        }
1313                        // structural-interface check: when the declared type
1314                        // is an interface and the initializer types to a
1315                        // named-struct, ensure the struct satisfies the
1316                        // interface.
1317                        if let QalaType::Named(Symbol(iname)) = &expected
1318                            && self.symbols.interfaces.contains_key(iname)
1319                            && let QalaType::Named(Symbol(_)) = typed_init.ty()
1320                        {
1321                            let init_ty = typed_init.ty().clone();
1322                            self.check_satisfies(&init_ty, iname, *span);
1323                        }
1324                        (typed_init, expected)
1325                    }
1326                    None => {
1327                        let typed_init = self.infer_expr(init);
1328                        let ty = typed_init.ty().clone();
1329                        (typed_init, ty)
1330                    }
1331                };
1332                // shadowed_var: if any outer scope (not the topmost) has a
1333                // binding with the same name, warn.
1334                let topmost = self.scopes.len().saturating_sub(1);
1335                let mut prior_span: Option<Span> = None;
1336                for (idx, scope) in self.scopes.iter().enumerate() {
1337                    if idx == topmost {
1338                        break;
1339                    }
1340                    if let Some(prior) = scope.get(name) {
1341                        prior_span = Some(prior.span);
1342                        break;
1343                    }
1344                }
1345                if let Some(prior) = prior_span {
1346                    let (l, c) = self.line_index.location(self.src, prior.start as usize);
1347                    let w = QalaWarning {
1348                        category: "shadowed_var".to_string(),
1349                        message: format!("`{name}` shadows a binding from an outer scope"),
1350                        span: *span,
1351                        note: Some(format!("the prior binding is at line {l}:{c}")),
1352                    };
1353                    self.emit_warning(w);
1354                }
1355                // bind in the topmost scope.
1356                if let Some(scope) = self.scopes.last_mut() {
1357                    scope.insert(
1358                        name.clone(),
1359                        LocalInfo {
1360                            ty: decl_ty.clone(),
1361                            span: *span,
1362                            is_mut: *is_mut,
1363                            used: false,
1364                            is_param: false,
1365                        },
1366                    );
1367                }
1368                typed_ast::TypedStmt::Let {
1369                    is_mut: *is_mut,
1370                    name: name.clone(),
1371                    ty: decl_ty,
1372                    init: typed_init,
1373                    span: *span,
1374                }
1375            }
1376            ast::Stmt::If {
1377                cond,
1378                then_block,
1379                else_branch,
1380                span,
1381            } => {
1382                let typed_cond = self.check_expr(cond, &QalaType::Bool);
1383                let typed_then = self.check_block(then_block, None);
1384                let typed_else = match else_branch {
1385                    Some(ast::ElseBranch::Block(b)) => {
1386                        Some(typed_ast::TypedElseBranch::Block(self.check_block(b, None)))
1387                    }
1388                    Some(ast::ElseBranch::If(b)) => {
1389                        let inner = self.check_stmt(b);
1390                        Some(typed_ast::TypedElseBranch::If(Box::new(inner)))
1391                    }
1392                    None => None,
1393                };
1394                typed_ast::TypedStmt::If {
1395                    cond: typed_cond,
1396                    then_block: typed_then,
1397                    else_branch: typed_else,
1398                    span: *span,
1399                }
1400            }
1401            ast::Stmt::While { cond, body, span } => {
1402                let typed_cond = self.check_expr(cond, &QalaType::Bool);
1403                let typed_body = self.check_block(body, None);
1404                typed_ast::TypedStmt::While {
1405                    cond: typed_cond,
1406                    body: typed_body,
1407                    span: *span,
1408                }
1409            }
1410            ast::Stmt::For {
1411                var,
1412                iter,
1413                body,
1414                span,
1415            } => {
1416                let typed_iter = self.infer_expr(iter);
1417                // element type: an Array's elem, or i64 for a Range
1418                // (Range expressions resolve their ty to Array(elem=I64, None)
1419                // per the convention chosen below in infer_expr).
1420                let var_ty = match typed_iter.ty() {
1421                    QalaType::Array(elem, _) => (**elem).clone(),
1422                    QalaType::Unknown => QalaType::Unknown,
1423                    _ => {
1424                        self.errors.push(QalaError::Type {
1425                            span: iter.span(),
1426                            message: "for loop expects an array or range".to_string(),
1427                        });
1428                        QalaType::Unknown
1429                    }
1430                };
1431                // open a one-binding scope holding the loop variable.
1432                let mut scope: HashMap<String, LocalInfo> = HashMap::new();
1433                scope.insert(
1434                    var.clone(),
1435                    LocalInfo {
1436                        ty: var_ty.clone(),
1437                        span: *span,
1438                        is_mut: false,
1439                        // the loop variable is treated like a parameter
1440                        // (open question 4 style) -- never fires unused_var.
1441                        used: false,
1442                        is_param: true,
1443                    },
1444                );
1445                self.scopes.push(scope);
1446                let typed_body = self.check_block(body, None);
1447                self.scopes.pop();
1448                typed_ast::TypedStmt::For {
1449                    var: var.clone(),
1450                    var_ty,
1451                    iter: typed_iter,
1452                    body: typed_body,
1453                    span: *span,
1454                }
1455            }
1456            ast::Stmt::Return { value, span } => {
1457                let typed_value = match value {
1458                    Some(v) => {
1459                        let expected = self.fn_ctx.as_ref().map(|c| c.ret_ty.clone());
1460                        let typed = match expected {
1461                            Some(ty) => self.check_expr(v, &ty),
1462                            None => self.infer_expr(v),
1463                        };
1464                        Some(typed)
1465                    }
1466                    None => {
1467                        // bare `return` is legal iff ret_ty is Void.
1468                        if let Some(ctx) = &self.fn_ctx
1469                            && !ctx.ret_ty.types_match(&QalaType::Void)
1470                        {
1471                            self.errors.push(QalaError::TypeMismatch {
1472                                span: *span,
1473                                expected: ctx.ret_ty.display(),
1474                                found: "void".to_string(),
1475                            });
1476                        }
1477                        None
1478                    }
1479                };
1480                typed_ast::TypedStmt::Return {
1481                    value: typed_value,
1482                    span: *span,
1483                }
1484            }
1485            ast::Stmt::Break { span } => typed_ast::TypedStmt::Break { span: *span },
1486            ast::Stmt::Continue { span } => typed_ast::TypedStmt::Continue { span: *span },
1487            ast::Stmt::Defer { expr, span } => {
1488                let typed_expr = self.infer_expr(expr);
1489                typed_ast::TypedStmt::Defer {
1490                    expr: typed_expr,
1491                    span: *span,
1492                }
1493            }
1494            ast::Stmt::Expr { expr, span } => {
1495                let typed_expr = self.infer_expr(expr);
1496                typed_ast::TypedStmt::Expr {
1497                    expr: typed_expr,
1498                    span: *span,
1499                }
1500            }
1501        }
1502    }
1503
1504    /// look a name up in the active scope chain (top -> outer); on a hit,
1505    /// mark the LocalInfo as used and return its type. on a miss in scopes,
1506    /// fall back to the user fn table; on a miss there, fall back to the
1507    /// stdlib signatures table. returns `None` if nothing matched.
1508    fn lookup(&mut self, name: &str) -> Option<QalaType> {
1509        for scope in self.scopes.iter_mut().rev() {
1510            if let Some(info) = scope.get_mut(name) {
1511                info.used = true;
1512                return Some(info.ty.clone());
1513            }
1514        }
1515        // user-defined free function?
1516        let key = FnKey {
1517            type_name: None,
1518            name: name.to_string(),
1519        };
1520        if let Some(info) = self.symbols.fns.get(&key) {
1521            let params: Vec<QalaType> = info.params.iter().map(|(_, ty, _)| ty.clone()).collect();
1522            return Some(QalaType::Function {
1523                params,
1524                returns: Box::new(info.ret_ty.clone()),
1525            });
1526        }
1527        // stdlib?
1528        let stdlib = stdlib_signatures();
1529        if let Some(entry) = stdlib
1530            .iter()
1531            .find(|e| e.type_name.is_none() && e.name == name)
1532        {
1533            return Some(QalaType::Function {
1534                params: entry.params.clone(),
1535                returns: Box::new(entry.ret_ty.clone()),
1536            });
1537        }
1538        None
1539    }
1540
1541    /// infer the type of an expression, producing a typed node.
1542    ///
1543    /// every variant of `ast::Expr` is dispatched; type errors emit a
1544    /// `QalaError` and the result carries `QalaType::Unknown` so subsequent
1545    /// `types_match` calls don't cascade. recursion is capped at
1546    /// [`MAX_DEPTH`] -- past the cap, a `QalaError::Type` is emitted and a
1547    /// poison-typed `Int` placeholder returned so the walk unwinds cleanly.
1548    fn infer_expr(&mut self, expr: &ast::Expr) -> typed_ast::TypedExpr {
1549        self.depth = self.depth.saturating_add(1);
1550        if self.depth > MAX_DEPTH {
1551            self.errors.push(QalaError::Type {
1552                span: expr.span(),
1553                message: "expression nests too deeply".to_string(),
1554            });
1555            self.depth = self.depth.saturating_sub(1);
1556            return typed_ast::TypedExpr::Int {
1557                value: 0,
1558                ty: QalaType::Unknown,
1559                span: expr.span(),
1560            };
1561        }
1562        let out = self.infer_expr_inner(expr);
1563        self.depth = self.depth.saturating_sub(1);
1564        out
1565    }
1566
1567    /// the body of [`Self::infer_expr`]; split so the depth guard is exactly
1568    /// once at the top.
1569    fn infer_expr_inner(&mut self, expr: &ast::Expr) -> typed_ast::TypedExpr {
1570        match expr {
1571            ast::Expr::Int { value, span } => typed_ast::TypedExpr::Int {
1572                value: *value,
1573                ty: QalaType::I64,
1574                span: *span,
1575            },
1576            ast::Expr::Float { value, span } => typed_ast::TypedExpr::Float {
1577                value: *value,
1578                ty: QalaType::F64,
1579                span: *span,
1580            },
1581            ast::Expr::Byte { value, span } => typed_ast::TypedExpr::Byte {
1582                value: *value,
1583                ty: QalaType::Byte,
1584                span: *span,
1585            },
1586            ast::Expr::Str { value, span } => typed_ast::TypedExpr::Str {
1587                value: value.clone(),
1588                ty: QalaType::Str,
1589                span: *span,
1590            },
1591            ast::Expr::Bool { value, span } => typed_ast::TypedExpr::Bool {
1592                value: *value,
1593                ty: QalaType::Bool,
1594                span: *span,
1595            },
1596            ast::Expr::Ident { name, span } => {
1597                // Some/None/Ok/Err names that appear bare (no call) are
1598                // also legal -- they look like idents to the parser. handle
1599                // None/Triangle-like constructors specially: lookup misses
1600                // are an UndefinedName unless the name is a known
1601                // zero-arg constructor.
1602                if let Some(ty) = self.lookup(name) {
1603                    return typed_ast::TypedExpr::Ident {
1604                        name: name.clone(),
1605                        ty,
1606                        span: *span,
1607                    };
1608                }
1609                // is this a zero-data enum variant from any known enum?
1610                let mut variant_match: Option<String> = None;
1611                for (enum_name, info) in &self.symbols.enums {
1612                    if info
1613                        .variants
1614                        .iter()
1615                        .any(|(vn, fields)| vn == name && fields.is_empty())
1616                    {
1617                        variant_match = Some(enum_name.clone());
1618                        break;
1619                    }
1620                }
1621                if let Some(enum_name) = variant_match {
1622                    return typed_ast::TypedExpr::Ident {
1623                        name: name.clone(),
1624                        ty: QalaType::Named(Symbol(enum_name)),
1625                        span: *span,
1626                    };
1627                }
1628                self.errors.push(QalaError::UndefinedName {
1629                    span: *span,
1630                    name: name.clone(),
1631                });
1632                typed_ast::TypedExpr::Ident {
1633                    name: name.clone(),
1634                    ty: QalaType::Unknown,
1635                    span: *span,
1636                }
1637            }
1638            ast::Expr::Paren { inner, span } => {
1639                let typed_inner = self.infer_expr(inner);
1640                let ty = typed_inner.ty().clone();
1641                typed_ast::TypedExpr::Paren {
1642                    inner: Box::new(typed_inner),
1643                    ty,
1644                    span: *span,
1645                }
1646            }
1647            ast::Expr::Tuple { elems, span } => {
1648                let typed_elems: Vec<typed_ast::TypedExpr> =
1649                    elems.iter().map(|e| self.infer_expr(e)).collect();
1650                let ty = QalaType::Tuple(typed_elems.iter().map(|e| e.ty().clone()).collect());
1651                typed_ast::TypedExpr::Tuple {
1652                    elems: typed_elems,
1653                    ty,
1654                    span: *span,
1655                }
1656            }
1657            ast::Expr::ArrayLit { elems, span } => {
1658                if elems.is_empty() {
1659                    self.errors.push(QalaError::Type {
1660                        span: *span,
1661                        message: "cannot infer element type of empty array".to_string(),
1662                    });
1663                    return typed_ast::TypedExpr::ArrayLit {
1664                        elems: Vec::new(),
1665                        ty: QalaType::Array(Box::new(QalaType::Unknown), Some(0)),
1666                        span: *span,
1667                    };
1668                }
1669                let first = self.infer_expr(&elems[0]);
1670                let elem_ty = first.ty().clone();
1671                let mut typed_elems = vec![first];
1672                for e in &elems[1..] {
1673                    let typed = self.check_expr(e, &elem_ty);
1674                    typed_elems.push(typed);
1675                }
1676                let len = typed_elems.len();
1677                typed_ast::TypedExpr::ArrayLit {
1678                    elems: typed_elems,
1679                    ty: QalaType::Array(Box::new(elem_ty), Some(len)),
1680                    span: *span,
1681                }
1682            }
1683            ast::Expr::ArrayRepeat { value, count, span } => {
1684                let typed_value = self.infer_expr(value);
1685                let typed_count = self.check_expr(count, &QalaType::I64);
1686                let elem_ty = typed_value.ty().clone();
1687                typed_ast::TypedExpr::ArrayRepeat {
1688                    value: Box::new(typed_value),
1689                    count: Box::new(typed_count),
1690                    ty: QalaType::Array(Box::new(elem_ty), None),
1691                    span: *span,
1692                }
1693            }
1694            ast::Expr::StructLit { name, fields, span } => {
1695                // check_struct_lit handles the field-set comparison.
1696                self.check_struct_lit(name, fields, *span)
1697            }
1698            ast::Expr::FieldAccess { obj, name, span } => {
1699                let typed_obj = self.infer_expr(obj);
1700                let obj_ty = typed_obj.ty().clone();
1701                let field_ty = match &obj_ty {
1702                    QalaType::Named(Symbol(s)) => {
1703                        if let Some(info) = self.symbols.structs.get(s) {
1704                            match info.fields.iter().find(|(fn_, _)| fn_ == name) {
1705                                Some((_, t)) => t.clone(),
1706                                None => {
1707                                    self.errors.push(QalaError::Type {
1708                                        span: *span,
1709                                        message: format!(
1710                                            "no field `{name}` on type `{}`",
1711                                            obj_ty.display()
1712                                        ),
1713                                    });
1714                                    QalaType::Unknown
1715                                }
1716                            }
1717                        } else {
1718                            self.errors.push(QalaError::Type {
1719                                span: *span,
1720                                message: format!(
1721                                    "no field `{name}` on type `{}`",
1722                                    obj_ty.display()
1723                                ),
1724                            });
1725                            QalaType::Unknown
1726                        }
1727                    }
1728                    QalaType::Unknown => QalaType::Unknown,
1729                    _ => {
1730                        self.errors.push(QalaError::Type {
1731                            span: *span,
1732                            message: format!("no field `{name}` on type `{}`", obj_ty.display()),
1733                        });
1734                        QalaType::Unknown
1735                    }
1736                };
1737                typed_ast::TypedExpr::FieldAccess {
1738                    obj: Box::new(typed_obj),
1739                    name: name.clone(),
1740                    ty: field_ty,
1741                    span: *span,
1742                }
1743            }
1744            ast::Expr::MethodCall {
1745                receiver,
1746                name,
1747                args,
1748                span,
1749            } => self.check_method_call(receiver, name, args, *span),
1750            ast::Expr::Call { callee, args, span } => self.check_call(callee, args, *span),
1751            ast::Expr::Index { obj, index, span } => {
1752                let typed_obj = self.infer_expr(obj);
1753                let typed_index = self.check_expr(index, &QalaType::I64);
1754                let elem_ty = match typed_obj.ty() {
1755                    QalaType::Array(elem, _) => (**elem).clone(),
1756                    QalaType::Unknown => QalaType::Unknown,
1757                    other => {
1758                        self.errors.push(QalaError::Type {
1759                            span: *span,
1760                            message: format!("cannot index `{}`", other.display()),
1761                        });
1762                        QalaType::Unknown
1763                    }
1764                };
1765                typed_ast::TypedExpr::Index {
1766                    obj: Box::new(typed_obj),
1767                    index: Box::new(typed_index),
1768                    ty: elem_ty,
1769                    span: *span,
1770                }
1771            }
1772            ast::Expr::Try { expr: inner, span } => {
1773                let typed_inner = self.infer_expr(inner);
1774                let (success_ty, ok_for_fn) = match typed_inner.ty() {
1775                    QalaType::Result(ok, _) => ((**ok).clone(), true),
1776                    QalaType::Option(t) => ((**t).clone(), true),
1777                    QalaType::Unknown => (QalaType::Unknown, true),
1778                    _ => {
1779                        self.errors.push(QalaError::Type {
1780                            span: *span,
1781                            message: format!(
1782                                "`?` operand must be Result or Option, found `{}`",
1783                                typed_inner.ty().display()
1784                            ),
1785                        });
1786                        (QalaType::Unknown, false)
1787                    }
1788                };
1789                // also require the enclosing function's ret_ty to be a
1790                // Result/Option. emit one error per the locked policy.
1791                if ok_for_fn && let Some(ctx) = &self.fn_ctx {
1792                    let ok = matches!(
1793                        &ctx.ret_ty,
1794                        QalaType::Result(_, _) | QalaType::Option(_) | QalaType::Unknown
1795                    );
1796                    if !ok {
1797                        self.errors.push(QalaError::RedundantQuestionOperator {
1798                            span: *span,
1799                            message: format!(
1800                                "`?` outside a Result-returning or Option-returning function (return type is `{}`); change the return type to `Result<_, _>` or `Option<_>`",
1801                                ctx.ret_ty.display()
1802                            ),
1803                        });
1804                    }
1805                }
1806                typed_ast::TypedExpr::Try {
1807                    expr: Box::new(typed_inner),
1808                    ty: success_ty,
1809                    span: *span,
1810                }
1811            }
1812            ast::Expr::Unary { op, operand, span } => {
1813                use ast::UnaryOp;
1814                let typed_operand = self.infer_expr(operand);
1815                let op_ty = typed_operand.ty().clone();
1816                let ty = match op {
1817                    UnaryOp::Not => {
1818                        if !op_ty.types_match(&QalaType::Bool) {
1819                            self.errors.push(QalaError::TypeMismatch {
1820                                span: operand.span(),
1821                                expected: "bool".to_string(),
1822                                found: op_ty.display(),
1823                            });
1824                        }
1825                        QalaType::Bool
1826                    }
1827                    UnaryOp::Neg => {
1828                        if op_ty.types_match(&QalaType::I64) {
1829                            QalaType::I64
1830                        } else if op_ty.types_match(&QalaType::F64) {
1831                            QalaType::F64
1832                        } else if matches!(op_ty, QalaType::Unknown) {
1833                            QalaType::Unknown
1834                        } else {
1835                            self.errors.push(QalaError::TypeMismatch {
1836                                span: operand.span(),
1837                                expected: "i64 or f64".to_string(),
1838                                found: op_ty.display(),
1839                            });
1840                            QalaType::Unknown
1841                        }
1842                    }
1843                };
1844                typed_ast::TypedExpr::Unary {
1845                    op: op.clone(),
1846                    operand: Box::new(typed_operand),
1847                    ty,
1848                    span: *span,
1849                }
1850            }
1851            ast::Expr::Binary { op, lhs, rhs, span } => self.check_binary(op, lhs, rhs, *span),
1852            ast::Expr::Range {
1853                start,
1854                end,
1855                inclusive,
1856                span,
1857            } => {
1858                let typed_start = start.as_ref().map(|e| self.check_expr(e, &QalaType::I64));
1859                let typed_end = end.as_ref().map(|e| self.check_expr(e, &QalaType::I64));
1860                // ranges iterate as `[i64]` so for-loops bind the element to i64.
1861                typed_ast::TypedExpr::Range {
1862                    start: typed_start.map(Box::new),
1863                    end: typed_end.map(Box::new),
1864                    inclusive: *inclusive,
1865                    ty: QalaType::Array(Box::new(QalaType::I64), None),
1866                    span: *span,
1867                }
1868            }
1869            ast::Expr::Pipeline { lhs, call, span } => {
1870                // type as if the lhs were prepended to the call's arg list.
1871                // implementation: infer lhs, then build a virtual call AST
1872                // node and recurse. but we want to keep the typed Pipeline
1873                // node faithful, so we manually re-implement the lookup.
1874                let typed_lhs = self.infer_expr(lhs);
1875                let lhs_ty = typed_lhs.ty().clone();
1876                // resolve the call target: either Call{callee: Ident, args}
1877                // or Ident (zero-extra-args).
1878                let (typed_call, result_ty) = self.check_pipeline_call(call, &lhs_ty);
1879                typed_ast::TypedExpr::Pipeline {
1880                    lhs: Box::new(typed_lhs),
1881                    call: Box::new(typed_call),
1882                    ty: result_ty,
1883                    span: *span,
1884                }
1885            }
1886            ast::Expr::Comptime { body, span } => {
1887                let typed_body = self.infer_expr(body);
1888                let ty = typed_body.ty().clone();
1889                typed_ast::TypedExpr::Comptime {
1890                    body: Box::new(typed_body),
1891                    ty,
1892                    span: *span,
1893                }
1894            }
1895            ast::Expr::Block { block, span } => {
1896                let typed_block = self.check_block(block, None);
1897                let ty = typed_block.ty.clone();
1898                typed_ast::TypedExpr::Block {
1899                    block: typed_block,
1900                    ty,
1901                    span: *span,
1902                }
1903            }
1904            ast::Expr::Match {
1905                scrutinee,
1906                arms,
1907                span,
1908            } => self.check_match(scrutinee, arms, *span),
1909            ast::Expr::OrElse {
1910                expr: e,
1911                fallback,
1912                span,
1913            } => {
1914                let typed_e = self.infer_expr(e);
1915                let success_ty = match typed_e.ty() {
1916                    QalaType::Result(ok, _) => (**ok).clone(),
1917                    QalaType::Option(t) => (**t).clone(),
1918                    QalaType::Unknown => QalaType::Unknown,
1919                    other => {
1920                        self.errors.push(QalaError::Type {
1921                            span: e.span(),
1922                            message: format!(
1923                                "`or` left operand must be Result or Option, found `{}`",
1924                                other.display()
1925                            ),
1926                        });
1927                        QalaType::Unknown
1928                    }
1929                };
1930                let typed_fallback = self.check_expr(fallback, &success_ty);
1931                typed_ast::TypedExpr::OrElse {
1932                    expr: Box::new(typed_e),
1933                    fallback: Box::new(typed_fallback),
1934                    ty: success_ty,
1935                    span: *span,
1936                }
1937            }
1938            ast::Expr::Interpolation { parts, span } => {
1939                let typed_parts: Vec<typed_ast::TypedInterpPart> = parts
1940                    .iter()
1941                    .map(|p| match p {
1942                        ast::InterpPart::Literal(s) => {
1943                            typed_ast::TypedInterpPart::Literal(s.clone())
1944                        }
1945                        ast::InterpPart::Expr(e) => {
1946                            typed_ast::TypedInterpPart::Expr(self.infer_expr(e))
1947                        }
1948                    })
1949                    .collect();
1950                typed_ast::TypedExpr::Interpolation {
1951                    parts: typed_parts,
1952                    ty: QalaType::Str,
1953                    span: *span,
1954                }
1955            }
1956        }
1957    }
1958
1959    /// check an expression against an expected type, emitting `TypeMismatch`
1960    /// when they disagree. unknown-poison is a wildcard, so a chain of
1961    /// expressions after the first error does not cascade.
1962    fn check_expr(&mut self, expr: &ast::Expr, expected: &QalaType) -> typed_ast::TypedExpr {
1963        let typed = self.infer_expr(expr);
1964        if !typed.ty().types_match(expected) {
1965            self.errors.push(QalaError::TypeMismatch {
1966                span: typed.span(),
1967                expected: expected.display(),
1968                found: typed.ty().display(),
1969            });
1970        }
1971        typed
1972    }
1973
1974    /// type-check a struct literal: look up the struct, check declared and
1975    /// provided field sets match (missing fields named in one error, excess
1976    /// fields named individually), check each provided value against the
1977    /// declared field type.
1978    fn check_struct_lit(
1979        &mut self,
1980        name: &str,
1981        fields: &[ast::FieldInit],
1982        span: Span,
1983    ) -> typed_ast::TypedExpr {
1984        // capture declared fields as (name, type) so we can iterate without
1985        // re-borrowing self.
1986        let declared: Option<Vec<(String, QalaType)>> =
1987            self.symbols.structs.get(name).map(|s| s.fields.clone());
1988        let Some(declared) = declared else {
1989            self.errors.push(QalaError::UndefinedName {
1990                span,
1991                name: name.to_string(),
1992            });
1993            return typed_ast::TypedExpr::StructLit {
1994                name: name.to_string(),
1995                fields: Vec::new(),
1996                ty: QalaType::Unknown,
1997                span,
1998            };
1999        };
2000        let mut typed_fields: Vec<typed_ast::TypedFieldInit> = Vec::new();
2001        // missing-field detection.
2002        let provided_names: BTreeSet<String> = fields.iter().map(|f| f.name.clone()).collect();
2003        let missing: Vec<String> = declared
2004            .iter()
2005            .filter(|(dn, _)| !provided_names.contains(dn))
2006            .map(|(dn, _)| dn.clone())
2007            .collect();
2008        if !missing.is_empty() {
2009            self.errors.push(QalaError::Type {
2010                span,
2011                message: format!(
2012                    "missing field(s) in struct literal `{name}`: {}",
2013                    missing.join(", ")
2014                ),
2015            });
2016        }
2017        for fi in fields {
2018            let decl_ty: Option<QalaType> = declared
2019                .iter()
2020                .find(|(dn, _)| dn == &fi.name)
2021                .map(|(_, t)| t.clone());
2022            match decl_ty {
2023                Some(t) => {
2024                    let typed_value = self.check_expr(&fi.value, &t);
2025                    typed_fields.push(typed_ast::TypedFieldInit {
2026                        name: fi.name.clone(),
2027                        value: typed_value,
2028                        span: fi.span,
2029                    });
2030                }
2031                None => {
2032                    self.errors.push(QalaError::Type {
2033                        span: fi.span,
2034                        message: format!("no field `{}` on struct `{name}`", fi.name),
2035                    });
2036                    // still type the value so cascading errors don't fire.
2037                    let typed_value = self.infer_expr(&fi.value);
2038                    typed_fields.push(typed_ast::TypedFieldInit {
2039                        name: fi.name.clone(),
2040                        value: typed_value,
2041                        span: fi.span,
2042                    });
2043                }
2044            }
2045        }
2046        typed_ast::TypedExpr::StructLit {
2047            name: name.to_string(),
2048            fields: typed_fields,
2049            ty: QalaType::Named(Symbol(name.to_string())),
2050            span,
2051        }
2052    }
2053
2054    /// type-check a method call. resolves the method via the symbol table's
2055    /// `FnKey { type_name: Some(receiver_type_name), name }` first, then
2056    /// falls back to the stdlib table (e.g. `FileHandle.read_all`).
2057    fn check_method_call(
2058        &mut self,
2059        receiver: &ast::Expr,
2060        name: &str,
2061        args: &[ast::Expr],
2062        span: Span,
2063    ) -> typed_ast::TypedExpr {
2064        let typed_receiver = self.infer_expr(receiver);
2065        let receiver_ty = typed_receiver.ty().clone();
2066        // resolve method signature: user method via FnKey, else stdlib.
2067        let resolved: Option<(Vec<QalaType>, QalaType, FnKey)> = (|| {
2068            // user fn T.method
2069            let type_name = match &receiver_ty {
2070                QalaType::Named(Symbol(s)) => Some(s.clone()),
2071                QalaType::FileHandle => Some("FileHandle".to_string()),
2072                _ => None,
2073            };
2074            if let Some(tn) = type_name {
2075                let key = FnKey {
2076                    type_name: Some(tn.clone()),
2077                    name: name.to_string(),
2078                };
2079                if let Some(info) = self.symbols.fns.get(&key) {
2080                    // params after self.
2081                    let params: Vec<QalaType> = info
2082                        .params
2083                        .iter()
2084                        .skip(
2085                            if info
2086                                .params
2087                                .first()
2088                                .map(|(n, _, _)| n == "self")
2089                                .unwrap_or(false)
2090                            {
2091                                1
2092                            } else {
2093                                0
2094                            },
2095                        )
2096                        .map(|(_, t, _)| t.clone())
2097                        .collect();
2098                    return Some((params, info.ret_ty.clone(), key));
2099                }
2100                // stdlib?
2101                for entry in stdlib_signatures().iter() {
2102                    if entry.type_name.as_deref() == Some(tn.as_str()) && entry.name == name {
2103                        let params: Vec<QalaType> = entry.params.clone();
2104                        return Some((
2105                            params,
2106                            entry.ret_ty.clone(),
2107                            FnKey {
2108                                type_name: Some(tn.clone()),
2109                                name: name.to_string(),
2110                            },
2111                        ));
2112                    }
2113                }
2114            }
2115            None
2116        })();
2117        let (params, ret_ty) = match resolved {
2118            Some((p, r, key)) => {
2119                // record for pass 3's effect resolution.
2120                if let Some(ctx) = &mut self.fn_ctx {
2121                    ctx.called_fns.push(key.clone());
2122                    if ctx.annotated_effect.is_some() {
2123                        ctx.callsites_to_check.push(EffectViolationCandidate {
2124                            caller_key: FnKey {
2125                                type_name: ctx.type_name.clone(),
2126                                name: ctx.name.clone(),
2127                            },
2128                            callee_key: key,
2129                            call_span: span,
2130                        });
2131                    }
2132                }
2133                (p, r)
2134            }
2135            None => {
2136                if !matches!(receiver_ty, QalaType::Unknown) {
2137                    self.errors.push(QalaError::Type {
2138                        span,
2139                        message: format!("no method `{name}` on type `{}`", receiver_ty.display()),
2140                    });
2141                }
2142                (Vec::new(), QalaType::Unknown)
2143            }
2144        };
2145        let typed_args: Vec<typed_ast::TypedExpr> = if params.is_empty() && args.is_empty() {
2146            Vec::new()
2147        } else {
2148            args.iter()
2149                .enumerate()
2150                .map(|(i, a)| {
2151                    if i < params.len() {
2152                        self.check_expr(a, &params[i])
2153                    } else {
2154                        self.infer_expr(a)
2155                    }
2156                })
2157                .collect()
2158        };
2159        if args.len() != params.len()
2160            && !matches!(receiver_ty, QalaType::Unknown)
2161            && !ret_ty.types_match(&QalaType::Unknown)
2162        {
2163            // arity mismatch -- emit a generic Type error.
2164            self.errors.push(QalaError::Type {
2165                span,
2166                message: format!(
2167                    "method `{name}` expects {} argument(s), found {}",
2168                    params.len(),
2169                    args.len()
2170                ),
2171            });
2172        }
2173        typed_ast::TypedExpr::MethodCall {
2174            receiver: Box::new(typed_receiver),
2175            name: name.to_string(),
2176            args: typed_args,
2177            ty: ret_ty,
2178            span,
2179        }
2180    }
2181
2182    /// type-check a plain call expression `callee(args)`. handles built-in
2183    /// constructors `Ok`/`Err`/`Some`/`None`, user-defined free functions,
2184    /// stdlib free functions (including the generic ones), and identifier
2185    /// callees in general.
2186    fn check_call(
2187        &mut self,
2188        callee: &ast::Expr,
2189        args: &[ast::Expr],
2190        span: Span,
2191    ) -> typed_ast::TypedExpr {
2192        // special-case the built-in constructor names appearing in callee
2193        // position as a bare ident.
2194        if let ast::Expr::Ident { name, .. } = callee {
2195            if let Some(ctor_ty) = self.try_builtin_constructor(name, args, span) {
2196                let typed_callee = typed_ast::TypedExpr::Ident {
2197                    name: name.clone(),
2198                    ty: QalaType::Function {
2199                        params: Vec::new(),
2200                        returns: Box::new(ctor_ty.0.clone()),
2201                    },
2202                    span: callee.span(),
2203                };
2204                return typed_ast::TypedExpr::Call {
2205                    callee: Box::new(typed_callee),
2206                    args: ctor_ty.1,
2207                    ty: ctor_ty.0,
2208                    span,
2209                };
2210            }
2211            // enum variant constructor: `Circle(5.0)` where `Circle` is a
2212            // variant of some enum.
2213            if let Some((enum_name, fields)) = self.find_enum_variant(name) {
2214                let typed_args: Vec<typed_ast::TypedExpr> = args
2215                    .iter()
2216                    .enumerate()
2217                    .map(|(i, a)| {
2218                        if i < fields.len() {
2219                            self.check_expr(a, &fields[i])
2220                        } else {
2221                            self.infer_expr(a)
2222                        }
2223                    })
2224                    .collect();
2225                if args.len() != fields.len() {
2226                    self.errors.push(QalaError::Type {
2227                        span,
2228                        message: format!(
2229                            "variant `{name}` of `{enum_name}` expects {} argument(s), found {}",
2230                            fields.len(),
2231                            args.len()
2232                        ),
2233                    });
2234                }
2235                let typed_callee = typed_ast::TypedExpr::Ident {
2236                    name: name.clone(),
2237                    ty: QalaType::Function {
2238                        params: fields.clone(),
2239                        returns: Box::new(QalaType::Named(Symbol(enum_name.clone()))),
2240                    },
2241                    span: callee.span(),
2242                };
2243                return typed_ast::TypedExpr::Call {
2244                    callee: Box::new(typed_callee),
2245                    args: typed_args,
2246                    ty: QalaType::Named(Symbol(enum_name)),
2247                    span,
2248                };
2249            }
2250            // user-defined free fn, or stdlib free fn (incl. virtual generics).
2251            if let Some((params, ret_ty, is_generic, name_clone, callee_key)) =
2252                self.resolve_free_callable(name)
2253            {
2254                // record for pass 3.
2255                if let Some(ctx) = &mut self.fn_ctx {
2256                    ctx.called_fns.push(callee_key.clone());
2257                    if ctx.annotated_effect.is_some() {
2258                        ctx.callsites_to_check.push(EffectViolationCandidate {
2259                            caller_key: FnKey {
2260                                type_name: ctx.type_name.clone(),
2261                                name: ctx.name.clone(),
2262                            },
2263                            callee_key,
2264                            call_span: span,
2265                        });
2266                    }
2267                }
2268                let typed_callee = typed_ast::TypedExpr::Ident {
2269                    name: name_clone.clone(),
2270                    ty: QalaType::Function {
2271                        params: params.clone(),
2272                        returns: Box::new(ret_ty.clone()),
2273                    },
2274                    span: callee.span(),
2275                };
2276                let typed_args = self.check_call_args(args, &params, is_generic, &name_clone, span);
2277                // abs only accepts i64 or f64; the generic param placeholder lets
2278                // non-numeric types through check_call_args, so we guard here.
2279                if name_clone == "abs"
2280                    && let Some(arg0) = typed_args.first()
2281                {
2282                    let arg_ty = arg0.ty();
2283                    if !matches!(arg_ty, QalaType::I64 | QalaType::F64 | QalaType::Unknown) {
2284                        self.errors.push(QalaError::TypeMismatch {
2285                            span,
2286                            expected: "i64 or f64".to_string(),
2287                            found: arg_ty.display(),
2288                        });
2289                    }
2290                }
2291                let result_ty = self.resolve_generic_return_ty(&name_clone, &ret_ty, &typed_args);
2292                return typed_ast::TypedExpr::Call {
2293                    callee: Box::new(typed_callee),
2294                    args: typed_args,
2295                    ty: result_ty,
2296                    span,
2297                };
2298            }
2299            // unresolved free name in callee position.
2300            self.errors.push(QalaError::UndefinedName {
2301                span: callee.span(),
2302                name: name.clone(),
2303            });
2304            let typed_callee = typed_ast::TypedExpr::Ident {
2305                name: name.clone(),
2306                ty: QalaType::Unknown,
2307                span: callee.span(),
2308            };
2309            let typed_args: Vec<typed_ast::TypedExpr> =
2310                args.iter().map(|a| self.infer_expr(a)).collect();
2311            return typed_ast::TypedExpr::Call {
2312                callee: Box::new(typed_callee),
2313                args: typed_args,
2314                ty: QalaType::Unknown,
2315                span,
2316            };
2317        }
2318        // non-identifier callee (rare in v1): infer it and treat its type as
2319        // a function shape.
2320        let typed_callee = self.infer_expr(callee);
2321        let (params, ret_ty) = match typed_callee.ty() {
2322            QalaType::Function { params, returns } => (params.clone(), (**returns).clone()),
2323            _ => {
2324                self.errors.push(QalaError::Type {
2325                    span,
2326                    message: format!(
2327                        "cannot call value of type `{}`",
2328                        typed_callee.ty().display()
2329                    ),
2330                });
2331                (Vec::new(), QalaType::Unknown)
2332            }
2333        };
2334        let typed_args: Vec<typed_ast::TypedExpr> = args
2335            .iter()
2336            .enumerate()
2337            .map(|(i, a)| {
2338                if i < params.len() {
2339                    self.check_expr(a, &params[i])
2340                } else {
2341                    self.infer_expr(a)
2342                }
2343            })
2344            .collect();
2345        typed_ast::TypedExpr::Call {
2346            callee: Box::new(typed_callee),
2347            args: typed_args,
2348            ty: ret_ty,
2349            span,
2350        }
2351    }
2352
2353    /// helper: check a call's args against a parameter list. for generic
2354    /// virtual stdlib calls (`len<T>(coll: [T]) -> i64`) the typechecker
2355    /// accepts any matching concrete instantiation -- it infers each arg
2356    /// and applies a loose check.
2357    fn check_call_args(
2358        &mut self,
2359        args: &[ast::Expr],
2360        params: &[QalaType],
2361        is_generic: bool,
2362        name: &str,
2363        call_span: Span,
2364    ) -> Vec<typed_ast::TypedExpr> {
2365        if args.len() != params.len() {
2366            // emit one arity error, then infer args.
2367            // skip arity check when the generic flag is set AND the
2368            // signature was a placeholder (params.len() may be sentinel).
2369            if !is_generic {
2370                self.errors.push(QalaError::Type {
2371                    span: call_span,
2372                    message: format!(
2373                        "function `{name}` expects {} argument(s), found {}",
2374                        params.len(),
2375                        args.len()
2376                    ),
2377                });
2378            }
2379        }
2380        args.iter()
2381            .enumerate()
2382            .map(|(i, a)| {
2383                if !is_generic && i < params.len() {
2384                    self.check_expr(a, &params[i])
2385                } else {
2386                    self.infer_expr(a)
2387                }
2388            })
2389            .collect()
2390    }
2391
2392    /// helper: compute the result type of a stdlib generic call once its
2393    /// args are typed. for non-generic functions, returns the declared
2394    /// `ret_ty` unchanged. handles `len`, `push`, `pop`, `type_of`, `map`,
2395    /// `filter`, `reduce`.
2396    fn resolve_generic_return_ty(
2397        &self,
2398        name: &str,
2399        ret_ty: &QalaType,
2400        typed_args: &[typed_ast::TypedExpr],
2401    ) -> QalaType {
2402        match name {
2403            "len" => QalaType::I64,
2404            "push" => QalaType::Void,
2405            // abs mirrors its input type for i64/f64; non-numeric args are
2406            // rejected upstream in check_call, so Unknown (poison) propagates
2407            // here when the arg type is neither I64 nor F64.
2408            "abs" => match typed_args.first().map(|a| a.ty()) {
2409                Some(QalaType::I64) => QalaType::I64,
2410                Some(QalaType::F64) => QalaType::F64,
2411                _ => QalaType::Unknown,
2412            },
2413            "pop" => {
2414                // pop returns Option<T> where T is the element type of arg 0.
2415                if let Some(arg0) = typed_args.first()
2416                    && let QalaType::Array(elem, _) = arg0.ty()
2417                {
2418                    return QalaType::Option(elem.clone());
2419                }
2420                QalaType::Option(Box::new(QalaType::Unknown))
2421            }
2422            "type_of" => QalaType::Str,
2423            "map" => {
2424                // map(coll: [T], f: fn(T) -> U) -> [U]
2425                let u = typed_args.get(1).and_then(|f| match f.ty() {
2426                    QalaType::Function { returns, .. } => Some((**returns).clone()),
2427                    _ => None,
2428                });
2429                QalaType::Array(Box::new(u.unwrap_or(QalaType::Unknown)), None)
2430            }
2431            "filter" => {
2432                // filter(coll: [T], pred: fn(T) -> bool) -> [T]
2433                if let Some(arg0) = typed_args.first() {
2434                    return arg0.ty().clone();
2435                }
2436                QalaType::Array(Box::new(QalaType::Unknown), None)
2437            }
2438            "reduce" => {
2439                // reduce(coll: [T], init: U, f: fn(U, T) -> U) -> U
2440                if let Some(arg1) = typed_args.get(1) {
2441                    return arg1.ty().clone();
2442                }
2443                QalaType::Unknown
2444            }
2445            _ => ret_ty.clone(),
2446        }
2447    }
2448
2449    /// helper: find the enum that contains a variant with the given name,
2450    /// returning the enum name and the variant's field types. on a miss,
2451    /// returns `None`. linear search over enums; deterministic via
2452    /// declaration order.
2453    fn find_enum_variant(&self, variant_name: &str) -> Option<(String, Vec<QalaType>)> {
2454        for (enum_name, info) in &self.symbols.enums {
2455            for (vname, fields) in &info.variants {
2456                if vname == variant_name {
2457                    return Some((enum_name.clone(), fields.clone()));
2458                }
2459            }
2460        }
2461        None
2462    }
2463
2464    /// helper: resolve a callable name (user fn, then stdlib). returns
2465    /// `(param-types, ret-ty, is-generic, name, key)`.
2466    #[allow(clippy::type_complexity)]
2467    fn resolve_free_callable(
2468        &self,
2469        name: &str,
2470    ) -> Option<(Vec<QalaType>, QalaType, bool, String, FnKey)> {
2471        let key = FnKey {
2472            type_name: None,
2473            name: name.to_string(),
2474        };
2475        if let Some(info) = self.symbols.fns.get(&key) {
2476            let params: Vec<QalaType> = info.params.iter().map(|(_, t, _)| t.clone()).collect();
2477            return Some((params, info.ret_ty.clone(), false, name.to_string(), key));
2478        }
2479        for entry in stdlib_signatures().iter() {
2480            if entry.type_name.is_none() && entry.name == name {
2481                return Some((
2482                    entry.params.clone(),
2483                    entry.ret_ty.clone(),
2484                    entry.is_generic,
2485                    name.to_string(),
2486                    FnKey {
2487                        type_name: None,
2488                        name: name.to_string(),
2489                    },
2490                ));
2491            }
2492        }
2493        None
2494    }
2495
2496    /// type-check the built-in constructors Ok/Err/Some/None when seen in
2497    /// a call-position bare-ident shape. returns the (result-type, typed-args)
2498    /// tuple on a hit, None otherwise.
2499    fn try_builtin_constructor(
2500        &mut self,
2501        name: &str,
2502        args: &[ast::Expr],
2503        span: Span,
2504    ) -> Option<(QalaType, Vec<typed_ast::TypedExpr>)> {
2505        match name {
2506            "Ok" => {
2507                if args.len() != 1 {
2508                    self.errors.push(QalaError::Type {
2509                        span,
2510                        message: format!("`Ok` expects 1 argument, found {}", args.len()),
2511                    });
2512                    let typed_args: Vec<_> = args.iter().map(|a| self.infer_expr(a)).collect();
2513                    return Some((
2514                        QalaType::Result(Box::new(QalaType::Unknown), Box::new(QalaType::Unknown)),
2515                        typed_args,
2516                    ));
2517                }
2518                let typed_arg = self.infer_expr(&args[0]);
2519                let ok_ty = typed_arg.ty().clone();
2520                Some((
2521                    QalaType::Result(Box::new(ok_ty), Box::new(QalaType::Unknown)),
2522                    vec![typed_arg],
2523                ))
2524            }
2525            "Err" => {
2526                if args.len() != 1 {
2527                    self.errors.push(QalaError::Type {
2528                        span,
2529                        message: format!("`Err` expects 1 argument, found {}", args.len()),
2530                    });
2531                    let typed_args: Vec<_> = args.iter().map(|a| self.infer_expr(a)).collect();
2532                    return Some((
2533                        QalaType::Result(Box::new(QalaType::Unknown), Box::new(QalaType::Unknown)),
2534                        typed_args,
2535                    ));
2536                }
2537                let typed_arg = self.infer_expr(&args[0]);
2538                let err_ty = typed_arg.ty().clone();
2539                Some((
2540                    QalaType::Result(Box::new(QalaType::Unknown), Box::new(err_ty)),
2541                    vec![typed_arg],
2542                ))
2543            }
2544            "Some" => {
2545                if args.len() != 1 {
2546                    self.errors.push(QalaError::Type {
2547                        span,
2548                        message: format!("`Some` expects 1 argument, found {}", args.len()),
2549                    });
2550                    let typed_args: Vec<_> = args.iter().map(|a| self.infer_expr(a)).collect();
2551                    return Some((QalaType::Option(Box::new(QalaType::Unknown)), typed_args));
2552                }
2553                let typed_arg = self.infer_expr(&args[0]);
2554                let inner = typed_arg.ty().clone();
2555                Some((QalaType::Option(Box::new(inner)), vec![typed_arg]))
2556            }
2557            "None" => {
2558                if !args.is_empty() {
2559                    self.errors.push(QalaError::Type {
2560                        span,
2561                        message: format!("`None` expects 0 arguments, found {}", args.len()),
2562                    });
2563                }
2564                let typed_args: Vec<_> = args.iter().map(|a| self.infer_expr(a)).collect();
2565                Some((QalaType::Option(Box::new(QalaType::Unknown)), typed_args))
2566            }
2567            _ => None,
2568        }
2569    }
2570
2571    /// type a binary expression. arithmetic ops produce the operand type
2572    /// (with `str + str = str` as the special-case); comparison and
2573    /// equality produce bool; logical ops require bool operands.
2574    fn check_binary(
2575        &mut self,
2576        op: &ast::BinOp,
2577        lhs: &ast::Expr,
2578        rhs: &ast::Expr,
2579        span: Span,
2580    ) -> typed_ast::TypedExpr {
2581        use ast::BinOp;
2582        let typed_lhs = self.infer_expr(lhs);
2583        let typed_rhs = self.infer_expr(rhs);
2584        let lty = typed_lhs.ty().clone();
2585        let rty = typed_rhs.ty().clone();
2586        let result_ty = match op {
2587            BinOp::Add => {
2588                // str + str = str (concatenation).
2589                if lty.types_match(&QalaType::Str) && rty.types_match(&QalaType::Str) {
2590                    QalaType::Str
2591                } else if lty.types_match(&rty)
2592                    && (lty.types_match(&QalaType::I64) || lty.types_match(&QalaType::F64))
2593                {
2594                    lty.clone()
2595                } else if matches!(lty, QalaType::Unknown) || matches!(rty, QalaType::Unknown) {
2596                    QalaType::Unknown
2597                } else {
2598                    self.errors.push(QalaError::TypeMismatch {
2599                        span,
2600                        expected: lty.display(),
2601                        found: rty.display(),
2602                    });
2603                    QalaType::Unknown
2604                }
2605            }
2606            BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Rem => {
2607                if lty.types_match(&rty)
2608                    && (lty.types_match(&QalaType::I64) || lty.types_match(&QalaType::F64))
2609                {
2610                    lty.clone()
2611                } else if matches!(lty, QalaType::Unknown) || matches!(rty, QalaType::Unknown) {
2612                    QalaType::Unknown
2613                } else {
2614                    self.errors.push(QalaError::TypeMismatch {
2615                        span,
2616                        expected: lty.display(),
2617                        found: rty.display(),
2618                    });
2619                    QalaType::Unknown
2620                }
2621            }
2622            BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => {
2623                if !lty.types_match(&rty) {
2624                    self.errors.push(QalaError::TypeMismatch {
2625                        span,
2626                        expected: lty.display(),
2627                        found: rty.display(),
2628                    });
2629                }
2630                QalaType::Bool
2631            }
2632            BinOp::Eq | BinOp::Ne => {
2633                if !lty.types_match(&rty) {
2634                    self.errors.push(QalaError::TypeMismatch {
2635                        span,
2636                        expected: lty.display(),
2637                        found: rty.display(),
2638                    });
2639                }
2640                QalaType::Bool
2641            }
2642            BinOp::And | BinOp::Or => {
2643                if !lty.types_match(&QalaType::Bool) {
2644                    self.errors.push(QalaError::TypeMismatch {
2645                        span: lhs.span(),
2646                        expected: "bool".to_string(),
2647                        found: lty.display(),
2648                    });
2649                }
2650                if !rty.types_match(&QalaType::Bool) {
2651                    self.errors.push(QalaError::TypeMismatch {
2652                        span: rhs.span(),
2653                        expected: "bool".to_string(),
2654                        found: rty.display(),
2655                    });
2656                }
2657                QalaType::Bool
2658            }
2659        };
2660        typed_ast::TypedExpr::Binary {
2661            op: op.clone(),
2662            lhs: Box::new(typed_lhs),
2663            rhs: Box::new(typed_rhs),
2664            ty: result_ty,
2665            span,
2666        }
2667    }
2668
2669    /// resolve a pipeline `lhs |> call` -- the call is either an Ident
2670    /// (zero extra args) or a Call (1+ extra args). type as if `lhs` were
2671    /// the first arg of the resolved function. returns (typed call, result-ty).
2672    fn check_pipeline_call(
2673        &mut self,
2674        call: &ast::Expr,
2675        lhs_ty: &QalaType,
2676    ) -> (typed_ast::TypedExpr, QalaType) {
2677        // build the virtual argument list = [lhs_ty] + extra arg types,
2678        // and look up the call target's signature.
2679        match call {
2680            ast::Expr::Ident { name, span } => {
2681                // zero extra args: signature expects 1 param matching lhs_ty.
2682                if let Some((params, ret_ty, is_generic, name_clone, key)) =
2683                    self.resolve_free_callable(name)
2684                {
2685                    if let Some(ctx) = &mut self.fn_ctx {
2686                        ctx.called_fns.push(key.clone());
2687                        if ctx.annotated_effect.is_some() {
2688                            ctx.callsites_to_check.push(EffectViolationCandidate {
2689                                caller_key: FnKey {
2690                                    type_name: ctx.type_name.clone(),
2691                                    name: ctx.name.clone(),
2692                                },
2693                                callee_key: key,
2694                                call_span: *span,
2695                            });
2696                        }
2697                    }
2698                    // check param 0 against lhs_ty (when not generic).
2699                    if !is_generic && !params.is_empty() && !lhs_ty.types_match(&params[0]) {
2700                        self.errors.push(QalaError::TypeMismatch {
2701                            span: *span,
2702                            expected: params[0].display(),
2703                            found: lhs_ty.display(),
2704                        });
2705                    }
2706                    // result type: generic-aware.
2707                    let typed_call = typed_ast::TypedExpr::Ident {
2708                        name: name_clone.clone(),
2709                        ty: QalaType::Function {
2710                            params: params.clone(),
2711                            returns: Box::new(ret_ty.clone()),
2712                        },
2713                        span: *span,
2714                    };
2715                    return (typed_call, ret_ty);
2716                }
2717                // unresolved.
2718                self.errors.push(QalaError::UndefinedName {
2719                    span: *span,
2720                    name: name.clone(),
2721                });
2722                (
2723                    typed_ast::TypedExpr::Ident {
2724                        name: name.clone(),
2725                        ty: QalaType::Unknown,
2726                        span: *span,
2727                    },
2728                    QalaType::Unknown,
2729                )
2730            }
2731            ast::Expr::Call { callee, args, span } => {
2732                // delegate to the normal call handler, but prepend lhs
2733                // virtually. simplest implementation: infer the call as
2734                // written (the parser has already collected the actual
2735                // args), then re-check the first param against lhs_ty. for
2736                // a generic stdlib call (`map`, `filter`, ...), accept
2737                // whatever the resolver computed.
2738                let typed_call = self.check_call(callee, args, *span);
2739                // the type of the typed call is the result type of the
2740                // pipeline (lhs flows into the existing first param).
2741                let ret_ty = typed_call.ty().clone();
2742                (typed_call, ret_ty)
2743            }
2744            other => {
2745                // any other call shape is unsupported.
2746                let typed = self.infer_expr(other);
2747                let ty = typed.ty().clone();
2748                (typed, ty)
2749            }
2750        }
2751    }
2752
2753    /// type-check a match expression.
2754    ///
2755    /// task 2 implements the basic typing rule: infer the scrutinee, type
2756    /// each arm body, require a common arm body type (Unknown counts as
2757    /// poison). exhaustiveness checking is task 3.
2758    fn check_match(
2759        &mut self,
2760        scrutinee: &ast::Expr,
2761        arms: &[ast::MatchArm],
2762        span: Span,
2763    ) -> typed_ast::TypedExpr {
2764        let typed_scrutinee = self.infer_expr(scrutinee);
2765        let scrut_ty = typed_scrutinee.ty().clone();
2766        let mut typed_arms: Vec<typed_ast::TypedMatchArm> = Vec::with_capacity(arms.len());
2767        let mut result_ty: Option<QalaType> = None;
2768        for arm in arms {
2769            // check the pattern in a fresh scope so any bindings it
2770            // introduces are local to the arm.
2771            self.scopes.push(HashMap::new());
2772            self.bind_pattern(&arm.pattern, &scrut_ty);
2773            let typed_guard = arm
2774                .guard
2775                .as_ref()
2776                .map(|g| self.check_expr(g, &QalaType::Bool));
2777            let typed_body = match &arm.body {
2778                ast::MatchArmBody::Expr(e) => {
2779                    let typed = self.infer_expr(e);
2780                    typed_ast::TypedMatchArmBody::Expr(Box::new(typed))
2781                }
2782                ast::MatchArmBody::Block(b) => {
2783                    typed_ast::TypedMatchArmBody::Block(self.check_block(b, None))
2784                }
2785            };
2786            self.scopes.pop();
2787            // accumulate the common arm result type.
2788            let body_ty = match &typed_body {
2789                typed_ast::TypedMatchArmBody::Expr(e) => e.ty().clone(),
2790                typed_ast::TypedMatchArmBody::Block(b) => b.ty.clone(),
2791            };
2792            result_ty = match (result_ty, body_ty.clone()) {
2793                (None, b) => Some(b),
2794                (Some(a), b) if a.types_match(&b) => Some(a),
2795                (Some(_), _) => Some(QalaType::Unknown),
2796            };
2797            typed_arms.push(typed_ast::TypedMatchArm {
2798                pattern: arm.pattern.clone(),
2799                guard: typed_guard,
2800                body: typed_body,
2801                span: arm.span,
2802            });
2803        }
2804        // exhaustiveness check (task 3): only against an enum scrutinee.
2805        if let QalaType::Named(Symbol(enum_name)) = &scrut_ty
2806            && self.symbols.enums.contains_key(enum_name)
2807        {
2808            self.check_match_exhaustive(enum_name, arms, span);
2809        } else {
2810            // non-enum scrutinee: still warn on multiple guarded
2811            // Binding-pattern arms, since they all match the same value
2812            // and only the first wins.
2813            let guarded_binding_arms = arms
2814                .iter()
2815                .filter(|a| matches!(a.pattern, ast::Pattern::Binding { .. }) && a.guard.is_some())
2816                .count();
2817            if guarded_binding_arms > 1 {
2818                let w = QalaWarning {
2819                    category: "overlapping_guards".to_string(),
2820                    message: "multiple guarded arms cover the same pattern; only the first that matches will run".to_string(),
2821                    span,
2822                    note: None,
2823                };
2824                self.emit_warning(w);
2825            }
2826        }
2827        let ty = result_ty.unwrap_or(QalaType::Unknown);
2828        typed_ast::TypedExpr::Match {
2829            scrutinee: Box::new(typed_scrutinee),
2830            arms: typed_arms,
2831            ty,
2832            span,
2833        }
2834    }
2835
2836    /// check the match arms against the scrutinee's enum variants. emits
2837    /// `NonExhaustiveMatch` when a variant is missing and no wildcard
2838    /// covers it. emits a `Type` error for a literal pattern against an
2839    /// enum scrutinee. warns `overlapping_guards` when multiple guarded
2840    /// arms target the same variant (or the same all-bindings shape).
2841    fn check_match_exhaustive(
2842        &mut self,
2843        enum_name: &str,
2844        arms: &[ast::MatchArm],
2845        match_span: Span,
2846    ) {
2847        let variants: Vec<String> = match self.symbols.enums.get(enum_name) {
2848            Some(info) => info.variants.iter().map(|(n, _)| n.clone()).collect(),
2849            None => return,
2850        };
2851        let mut covered: BTreeSet<String> = BTreeSet::new();
2852        let mut has_wildcard = false;
2853        let mut guarded_arms_by_variant: HashMap<String, u32> = HashMap::new();
2854        let mut guarded_binding_arms: u32 = 0;
2855        for arm in arms {
2856            match &arm.pattern {
2857                ast::Pattern::Variant { name, span, .. } => {
2858                    if !variants.contains(name) {
2859                        self.errors.push(QalaError::Type {
2860                            span: *span,
2861                            message: format!("variant `{name}` is not part of enum `{enum_name}`"),
2862                        });
2863                        continue;
2864                    }
2865                    if arm.guard.is_some() {
2866                        *guarded_arms_by_variant.entry(name.clone()).or_insert(0) += 1;
2867                    } else {
2868                        // an unguarded variant arm covers the variant.
2869                        covered.insert(name.clone());
2870                    }
2871                }
2872                ast::Pattern::Wildcard { .. } => {
2873                    has_wildcard = true;
2874                }
2875                ast::Pattern::Binding { .. } => {
2876                    has_wildcard = true;
2877                    if arm.guard.is_some() {
2878                        guarded_binding_arms += 1;
2879                    }
2880                }
2881                // literal patterns against an enum scrutinee.
2882                ast::Pattern::Int { span, .. }
2883                | ast::Pattern::Float { span, .. }
2884                | ast::Pattern::Byte { span, .. }
2885                | ast::Pattern::Str { span, .. }
2886                | ast::Pattern::Bool { span, .. } => {
2887                    self.errors.push(QalaError::Type {
2888                        span: *span,
2889                        message: "literal pattern cannot match an enum value".to_string(),
2890                    });
2891                }
2892            }
2893        }
2894        if !has_wildcard {
2895            let mut missing: Vec<String> = variants
2896                .iter()
2897                .filter(|v| !covered.contains(*v))
2898                .cloned()
2899                .collect();
2900            if !missing.is_empty() {
2901                missing.sort();
2902                self.errors.push(QalaError::NonExhaustiveMatch {
2903                    span: match_span,
2904                    enum_name: enum_name.to_string(),
2905                    missing,
2906                });
2907            }
2908        }
2909        // overlapping_guards: per-variant counts > 1 OR multiple binding
2910        // arms with guards (all matching everything).
2911        let any_overlap =
2912            guarded_arms_by_variant.values().any(|c| *c > 1) || guarded_binding_arms > 1;
2913        if any_overlap {
2914            let w = QalaWarning {
2915                category: "overlapping_guards".to_string(),
2916                message: "multiple guarded arms cover the same pattern; only the first that matches will run".to_string(),
2917                span: match_span,
2918                note: None,
2919            };
2920            self.emit_warning(w);
2921        }
2922    }
2923
2924    /// structural-interface satisfaction check.
2925    ///
2926    /// looks up the interface's required methods; for each, finds a
2927    /// matching `fn TypeName.method` in the symbol table whose
2928    /// (param-types, return-type) match (effects NOT compared per open
2929    /// question 2). emits one `InterfaceNotSatisfied` carrying the
2930    /// `missing` and `mismatched` lists.
2931    fn check_satisfies(&mut self, ty: &QalaType, interface_name: &str, use_span: Span) {
2932        let type_name = match ty {
2933            QalaType::Named(Symbol(s)) => s.clone(),
2934            _ => return,
2935        };
2936        let interface: Vec<(String, Vec<QalaType>, QalaType)> =
2937            match self.symbols.interfaces.get(interface_name) {
2938                Some(i) => i
2939                    .methods
2940                    .iter()
2941                    .map(|m| (m.name.clone(), m.params.clone(), m.ret_ty.clone()))
2942                    .collect(),
2943                None => return,
2944            };
2945        let mut missing: Vec<String> = Vec::new();
2946        let mut mismatched: Vec<(String, String, String)> = Vec::new();
2947        for (mname, mparams, mret) in interface {
2948            let key = FnKey {
2949                type_name: Some(type_name.clone()),
2950                name: mname.clone(),
2951            };
2952            let impl_info = self.symbols.fns.get(&key);
2953            match impl_info {
2954                None => missing.push(mname),
2955                Some(info) => {
2956                    // collect impl params, skipping the self slot.
2957                    let impl_params: Vec<QalaType> = info
2958                        .params
2959                        .iter()
2960                        .skip(
2961                            if info
2962                                .params
2963                                .first()
2964                                .map(|(n, _, _)| n == "self")
2965                                .unwrap_or(false)
2966                            {
2967                                1
2968                            } else {
2969                                0
2970                            },
2971                        )
2972                        .map(|(_, t, _)| t.clone())
2973                        .collect();
2974                    // interface method's params skip self (interface
2975                    // representations carry Unknown for self -- skip the
2976                    // self slot too).
2977                    let iface_params: Vec<QalaType> = mparams
2978                        .iter()
2979                        .skip(if matches!(mparams.first(), Some(QalaType::Unknown)) {
2980                            1
2981                        } else {
2982                            0
2983                        })
2984                        .cloned()
2985                        .collect();
2986                    let params_match = iface_params.len() == impl_params.len()
2987                        && iface_params
2988                            .iter()
2989                            .zip(impl_params.iter())
2990                            .all(|(a, b)| a.types_match(b));
2991                    let ret_match = mret.types_match(&info.ret_ty);
2992                    if !params_match || !ret_match {
2993                        // build expected / found signature strings.
2994                        let expected = format_fn_sig(&iface_params, &mret);
2995                        let found = format_fn_sig(&impl_params, &info.ret_ty);
2996                        mismatched.push((mname, expected, found));
2997                    }
2998                }
2999            }
3000        }
3001        if !missing.is_empty() || !mismatched.is_empty() {
3002            // sort missing alphabetically for determinism.
3003            let mut missing_sorted = missing;
3004            missing_sorted.sort();
3005            mismatched.sort_by(|a, b| a.0.cmp(&b.0));
3006            self.errors.push(QalaError::InterfaceNotSatisfied {
3007                span: use_span,
3008                ty: type_name,
3009                interface: interface_name.to_string(),
3010                missing: missing_sorted,
3011                mismatched,
3012            });
3013        }
3014    }
3015
3016    /// bind any names introduced by a pattern into the topmost scope, with
3017    /// types derived from the scrutinee type.
3018    ///
3019    /// task 2's minimal implementation: for `Variant { sub }`, look up the
3020    /// variant in any enum and use its field types; for `Binding`, bind
3021    /// the scrutinee type; for literal / wildcard patterns, nothing.
3022    fn bind_pattern(&mut self, pattern: &ast::Pattern, scrut_ty: &QalaType) {
3023        match pattern {
3024            ast::Pattern::Variant { name, sub, span } => {
3025                // find the matching variant. for an enum scrutinee, look in
3026                // that enum specifically. otherwise look in any enum.
3027                let mut variant_fields: Option<Vec<QalaType>> = None;
3028                if let QalaType::Named(Symbol(enum_name)) = scrut_ty
3029                    && let Some(info) = self.symbols.enums.get(enum_name)
3030                {
3031                    for (vname, fields) in &info.variants {
3032                        if vname == name {
3033                            variant_fields = Some(fields.clone());
3034                            break;
3035                        }
3036                    }
3037                    if variant_fields.is_none() {
3038                        self.errors.push(QalaError::Type {
3039                            span: *span,
3040                            message: format!("variant `{name}` is not part of enum `{enum_name}`"),
3041                        });
3042                    }
3043                }
3044                if let Some(fields) = variant_fields {
3045                    for (i, sub_pat) in sub.iter().enumerate() {
3046                        let sub_ty = fields.get(i).cloned().unwrap_or(QalaType::Unknown);
3047                        self.bind_pattern(sub_pat, &sub_ty);
3048                    }
3049                } else {
3050                    // recurse with Unknown for each sub-pattern.
3051                    for sub_pat in sub {
3052                        self.bind_pattern(sub_pat, &QalaType::Unknown);
3053                    }
3054                }
3055            }
3056            ast::Pattern::Binding { name, span } => {
3057                if let Some(scope) = self.scopes.last_mut() {
3058                    scope.insert(
3059                        name.clone(),
3060                        LocalInfo {
3061                            ty: scrut_ty.clone(),
3062                            span: *span,
3063                            is_mut: false,
3064                            // pattern bindings are scoped to the arm; treat
3065                            // them like loop variables (no unused_var).
3066                            used: false,
3067                            is_param: true,
3068                        },
3069                    );
3070                }
3071            }
3072            // literal / wildcard patterns introduce no bindings.
3073            _ => {}
3074        }
3075    }
3076
3077    /// pass 3: resolve every function's inferred effect via fixed-point
3078    /// iteration over the call graph, then check annotated functions
3079    /// against their annotations.
3080    ///
3081    /// the effect lattice has 4 elements
3082    /// (`{}` ⊂ `{io}` ⊂ `{io, alloc}` ⊂ `{io, alloc, panic}` is the worst
3083    /// chain) so monotonic ascent on a finite lattice converges in at most
3084    /// 3 rounds (Davey & Priestley). [`MAX_ROUNDS`] = 8 is a generous
3085    /// development-time sentinel; a debug_assert fires if we hit it, but
3086    /// production code is bounded by the proof.
3087    fn resolve_function_effects(&mut self) {
3088        const MAX_ROUNDS: usize = 8;
3089        // initialise every FnInfo's inferred_effect with the body intrinsic
3090        // (annotated effects are checked against, not initialised from).
3091        let keys: Vec<FnKey> = self.body_records.keys().cloned().collect();
3092        for key in &keys {
3093            if let Some(info) = self.symbols.fns.get_mut(key) {
3094                let intrinsic = self
3095                    .body_records
3096                    .get(key)
3097                    .map(|r| r.intrinsic)
3098                    .unwrap_or(EffectSet::pure());
3099                info.inferred_effect = Some(intrinsic);
3100            }
3101        }
3102        // outer loop: iterate until no FnInfo's inferred_effect changes.
3103        for round in 0..MAX_ROUNDS {
3104            let mut changed = false;
3105            for key in &keys {
3106                let body_rec = match self.body_records.get(key) {
3107                    Some(r) => r,
3108                    None => continue,
3109                };
3110                let mut e = body_rec.intrinsic;
3111                for callee_key in &body_rec.called {
3112                    if let Some(callee_info) = self.symbols.fns.get(callee_key) {
3113                        if let Some(eff) = callee_info.inferred_effect {
3114                            e = e.union(eff);
3115                        } else if let Some(eff) = callee_info.annotated_effect {
3116                            e = e.union(eff);
3117                        }
3118                    } else if let Some(stdlib_eff) = stdlib_effect(callee_key) {
3119                        e = e.union(stdlib_eff);
3120                    }
3121                }
3122                if let Some(info_mut) = self.symbols.fns.get_mut(key) {
3123                    let old = info_mut.inferred_effect.unwrap_or(EffectSet::pure());
3124                    let new = old.union(e);
3125                    if new != old {
3126                        info_mut.inferred_effect = Some(new);
3127                        changed = true;
3128                    }
3129                }
3130            }
3131            if !changed {
3132                break;
3133            }
3134            // safety sentinel: the lattice proof says <= 3 rounds. if we
3135            // hit MAX_ROUNDS - 1, something is wrong.
3136            debug_assert!(
3137                round < MAX_ROUNDS - 1,
3138                "effect fixed-point did not converge in {MAX_ROUNDS} rounds"
3139            );
3140        }
3141        // after settling: check annotated callers against their callees.
3142        // candidates were recorded at every call site whose enclosing fn had
3143        // an annotated_effect. emit EffectViolation when the callee's
3144        // (inferred or annotated) effect is not a subset of the caller's
3145        // annotation.
3146        let candidates: Vec<EffectViolationCandidate> = keys
3147            .iter()
3148            .flat_map(|k| {
3149                self.body_records
3150                    .get(k)
3151                    .map(|r| r.callsites_to_check.clone())
3152                    .unwrap_or_default()
3153            })
3154            .collect();
3155        for cand in candidates {
3156            let caller_eff = self
3157                .symbols
3158                .fns
3159                .get(&cand.caller_key)
3160                .and_then(|info| info.annotated_effect)
3161                .unwrap_or(EffectSet::full());
3162            let callee_eff = if let Some(callee_info) = self.symbols.fns.get(&cand.callee_key) {
3163                callee_info
3164                    .inferred_effect
3165                    .or(callee_info.annotated_effect)
3166                    .unwrap_or(EffectSet::pure())
3167            } else if let Some(eff) = stdlib_effect(&cand.callee_key) {
3168                eff
3169            } else {
3170                EffectSet::pure()
3171            };
3172            if !callee_eff.is_subset_of(caller_eff) {
3173                self.errors.push(QalaError::EffectViolation {
3174                    span: cand.call_span,
3175                    caller: cand.caller_key.name.clone(),
3176                    caller_effect: caller_eff.display(),
3177                    callee: cand.callee_key.name.clone(),
3178                    callee_effect: callee_eff.display(),
3179                });
3180            }
3181        }
3182    }
3183
3184    /// emit a warning, honouring the per-line directive table.
3185    fn emit_warning(&mut self, w: QalaWarning) {
3186        if self.is_silenced(w.span, &w.category) {
3187            return;
3188        }
3189        self.warnings.push(w);
3190    }
3191
3192    /// is this warning category silenced at the given span's line?
3193    fn is_silenced(&self, span: Span, category: &str) -> bool {
3194        let line = self.line_index.location(self.src, span.start as usize).0;
3195        self.allow
3196            .get(&line)
3197            .map(|cats| cats.contains(category))
3198            .unwrap_or(false)
3199    }
3200}
3201
3202/// the per-function record pass 3 reads to settle effect inference.
3203#[allow(dead_code)]
3204struct BodyEffectRecord {
3205    /// the intrinsic effect of the body (the union of every non-call
3206    /// statement's intrinsic effect set).
3207    intrinsic: EffectSet,
3208    /// every function this body calls.
3209    called: Vec<FnKey>,
3210    /// call sites that need an effect-violation check after pass 3 settles.
3211    callsites_to_check: Vec<EffectViolationCandidate>,
3212}
3213
3214/// strict type equality: a wildcard `Unknown` does NOT match anything. used
3215/// by the `redundant_annotation` warning so a typo's `Unknown` does not
3216/// fire the warning by accident.
3217fn types_strictly_equal(a: &QalaType, b: &QalaType) -> bool {
3218    if matches!(a, QalaType::Unknown) || matches!(b, QalaType::Unknown) {
3219        return false;
3220    }
3221    a == b
3222}
3223
3224/// format a method signature as `fn(self) -> R` (zero params) or
3225/// `fn(self, P1, P2) -> R` for the `InterfaceNotSatisfied` mismatched
3226/// payload. params are the method's param types without the self slot.
3227fn format_fn_sig(params: &[QalaType], ret: &QalaType) -> String {
3228    if params.is_empty() {
3229        format!("fn(self) -> {}", ret.display())
3230    } else {
3231        let p: Vec<String> = params.iter().map(|t| t.display()).collect();
3232        format!("fn(self, {}) -> {}", p.join(", "), ret.display())
3233    }
3234}
3235
3236/// one entry in the stdlib signature table. task 3 fills in the actual
3237/// list; task 2 only needs the shape so dependent code compiles.
3238#[allow(dead_code)]
3239struct StdlibFn {
3240    /// `None` for a free function; `Some(T)` for `fn T.method` (e.g.
3241    /// `FileHandle.read_all`).
3242    type_name: Option<String>,
3243    /// the function name.
3244    name: String,
3245    /// parameter types (or sentinels for the virtual generics).
3246    params: Vec<QalaType>,
3247    /// return type.
3248    ret_ty: QalaType,
3249    /// the function's effect.
3250    effect: EffectSet,
3251    /// true for virtual-generic functions (`len`, `push`, `pop`, `type_of`,
3252    /// `map`, `filter`, `reduce`, `Ok`, `Err`, `Some`, `None`). the
3253    /// call-site checker accepts any concrete instantiation.
3254    is_generic: bool,
3255}
3256
3257/// scan the source for `// qala: allow(...)` line directives, returning a
3258/// map from 1-based line number to the set of silenced categories.
3259///
3260/// the directive must be the SOLE non-whitespace content on its source
3261/// line (open question 1). it applies to the FOLLOWING line. trailing
3262/// whitespace inside the comment is tolerated; trailing non-whitespace
3263/// content is not (the directive is rejected). multiple categories per
3264/// directive are accepted, comma-separated, with arbitrary whitespace
3265/// between them.
3266///
3267/// the result map uses `BTreeSet<String>` over the categories (not
3268/// `HashSet`) so the data type is defensively deterministic even though
3269/// the typechecker only `contains()`-checks it.
3270fn scan_allow_directives(src: &str) -> HashMap<usize, BTreeSet<String>> {
3271    let mut out: HashMap<usize, BTreeSet<String>> = HashMap::new();
3272    for (idx, line) in src.lines().enumerate() {
3273        let trimmed = line.trim_start();
3274        if !trimmed.starts_with("// qala: allow(") {
3275            continue;
3276        }
3277        let Some(body) = trimmed.strip_prefix("// qala: allow(") else {
3278            continue;
3279        };
3280        let Some(close_idx) = body.find(')') else {
3281            continue;
3282        };
3283        // anything after the `)` (besides whitespace) means the directive
3284        // is rejected.
3285        if !body[close_idx + 1..].trim().is_empty() {
3286            continue;
3287        }
3288        let cats_str = &body[..close_idx];
3289        let cats: BTreeSet<String> = cats_str
3290            .split(',')
3291            .map(|c| c.trim().to_string())
3292            .filter(|c| !c.is_empty())
3293            .collect();
3294        if cats.is_empty() {
3295            continue;
3296        }
3297        // the directive on source line `idx + 1` (1-based) applies to the
3298        // FOLLOWING line, which is `idx + 2`.
3299        out.insert(idx + 2, cats);
3300    }
3301    out
3302}
3303
3304/// look up a callee's effect in the stdlib table by FnKey. returns `None`
3305/// if the key does not name a stdlib function. used by pass 3 when a
3306/// called name was not found in the user symbol table.
3307fn stdlib_effect(key: &FnKey) -> Option<EffectSet> {
3308    let stdlib = stdlib_signatures();
3309    for entry in stdlib.iter() {
3310        if entry.type_name == key.type_name && entry.name == key.name {
3311            return Some(entry.effect);
3312        }
3313    }
3314    None
3315}
3316
3317/// the hardcoded stdlib signature table. task 3 finalises this; task 2's
3318/// stub gives enough entries that pass 2 tests resolve the names they need
3319/// (println, print, parse_int-shaped placeholders are NOT in the stdlib;
3320/// the test fixtures define those as user functions when needed).
3321fn stdlib_signatures() -> Vec<StdlibFn> {
3322    vec![
3323        StdlibFn {
3324            type_name: None,
3325            name: "print".to_string(),
3326            params: vec![QalaType::Str],
3327            ret_ty: QalaType::Void,
3328            effect: EffectSet::io(),
3329            is_generic: false,
3330        },
3331        StdlibFn {
3332            type_name: None,
3333            name: "println".to_string(),
3334            params: vec![QalaType::Str],
3335            ret_ty: QalaType::Void,
3336            effect: EffectSet::io(),
3337            is_generic: false,
3338        },
3339        StdlibFn {
3340            type_name: None,
3341            name: "sqrt".to_string(),
3342            params: vec![QalaType::F64],
3343            ret_ty: QalaType::F64,
3344            effect: EffectSet::pure(),
3345            is_generic: false,
3346        },
3347        StdlibFn {
3348            type_name: None,
3349            name: "abs".to_string(),
3350            // generic: accepts i64 or f64; resolve_generic_return_ty
3351            // returns the actual argument type so abs(-3) -> i64 and
3352            // abs(1.5) -> f64 both typecheck correctly.
3353            params: vec![QalaType::Unknown],
3354            ret_ty: QalaType::Unknown,
3355            effect: EffectSet::pure(),
3356            is_generic: true,
3357        },
3358        StdlibFn {
3359            type_name: None,
3360            name: "assert".to_string(),
3361            params: vec![QalaType::Bool],
3362            ret_ty: QalaType::Void,
3363            effect: EffectSet::panic(),
3364            is_generic: false,
3365        },
3366        StdlibFn {
3367            type_name: None,
3368            name: "len".to_string(),
3369            params: vec![QalaType::Array(Box::new(QalaType::Unknown), None)],
3370            ret_ty: QalaType::I64,
3371            effect: EffectSet::pure(),
3372            is_generic: true,
3373        },
3374        StdlibFn {
3375            type_name: None,
3376            name: "push".to_string(),
3377            params: vec![
3378                QalaType::Array(Box::new(QalaType::Unknown), None),
3379                QalaType::Unknown,
3380            ],
3381            ret_ty: QalaType::Void,
3382            effect: EffectSet::alloc(),
3383            is_generic: true,
3384        },
3385        StdlibFn {
3386            type_name: None,
3387            name: "pop".to_string(),
3388            params: vec![QalaType::Array(Box::new(QalaType::Unknown), None)],
3389            ret_ty: QalaType::Option(Box::new(QalaType::Unknown)),
3390            effect: EffectSet::alloc(),
3391            is_generic: true,
3392        },
3393        StdlibFn {
3394            type_name: None,
3395            name: "type_of".to_string(),
3396            params: vec![QalaType::Unknown],
3397            ret_ty: QalaType::Str,
3398            effect: EffectSet::pure(),
3399            is_generic: true,
3400        },
3401        StdlibFn {
3402            type_name: None,
3403            name: "open".to_string(),
3404            params: vec![QalaType::Str],
3405            ret_ty: QalaType::FileHandle,
3406            effect: EffectSet::io(),
3407            is_generic: false,
3408        },
3409        StdlibFn {
3410            type_name: None,
3411            name: "close".to_string(),
3412            params: vec![QalaType::FileHandle],
3413            ret_ty: QalaType::Void,
3414            effect: EffectSet::io(),
3415            is_generic: false,
3416        },
3417        StdlibFn {
3418            type_name: None,
3419            name: "map".to_string(),
3420            params: vec![
3421                QalaType::Array(Box::new(QalaType::Unknown), None),
3422                QalaType::Function {
3423                    params: vec![QalaType::Unknown],
3424                    returns: Box::new(QalaType::Unknown),
3425                },
3426            ],
3427            ret_ty: QalaType::Array(Box::new(QalaType::Unknown), None),
3428            effect: EffectSet::pure(),
3429            is_generic: true,
3430        },
3431        StdlibFn {
3432            type_name: None,
3433            name: "filter".to_string(),
3434            params: vec![
3435                QalaType::Array(Box::new(QalaType::Unknown), None),
3436                QalaType::Function {
3437                    params: vec![QalaType::Unknown],
3438                    returns: Box::new(QalaType::Bool),
3439                },
3440            ],
3441            ret_ty: QalaType::Array(Box::new(QalaType::Unknown), None),
3442            effect: EffectSet::pure(),
3443            is_generic: true,
3444        },
3445        StdlibFn {
3446            type_name: None,
3447            name: "reduce".to_string(),
3448            params: vec![
3449                QalaType::Array(Box::new(QalaType::Unknown), None),
3450                QalaType::Unknown,
3451                QalaType::Function {
3452                    params: vec![QalaType::Unknown, QalaType::Unknown],
3453                    returns: Box::new(QalaType::Unknown),
3454                },
3455            ],
3456            ret_ty: QalaType::Unknown,
3457            effect: EffectSet::pure(),
3458            is_generic: true,
3459        },
3460        // FileHandle.read_all -- the only stdlib method in v1.
3461        StdlibFn {
3462            type_name: Some("FileHandle".to_string()),
3463            name: "read_all".to_string(),
3464            params: vec![],
3465            ret_ty: QalaType::Result(Box::new(QalaType::Str), Box::new(QalaType::Str)),
3466            effect: EffectSet::io(),
3467            is_generic: false,
3468        },
3469    ]
3470}
3471
3472/// resolve a `TypeExpr` to a [`QalaType`] without emitting errors. used by
3473/// [`Checker::check_item`] when re-materialising types onto the typed AST
3474/// from the already-populated symbol table; the collection pass already
3475/// surfaced any `UnknownType` faults, so a duplicate emit here would
3476/// double-count.
3477fn resolve_type_silent(ty: &ast::TypeExpr, symbols: &SymbolTable) -> QalaType {
3478    match ty {
3479        ast::TypeExpr::Primitive { kind, .. } => QalaType::from_prim_type(kind),
3480        ast::TypeExpr::Named { name, .. } => {
3481            if name == "FileHandle" {
3482                return QalaType::FileHandle;
3483            }
3484            if symbols.structs.contains_key(name)
3485                || symbols.enums.contains_key(name)
3486                || symbols.interfaces.contains_key(name)
3487            {
3488                return QalaType::Named(Symbol(name.clone()));
3489            }
3490            QalaType::Unknown
3491        }
3492        ast::TypeExpr::Array { elem, size, .. } => QalaType::Array(
3493            Box::new(resolve_type_silent(elem, symbols)),
3494            Some(*size as usize),
3495        ),
3496        ast::TypeExpr::DynArray { elem, .. } => {
3497            QalaType::Array(Box::new(resolve_type_silent(elem, symbols)), None)
3498        }
3499        ast::TypeExpr::Tuple { elems, .. } => QalaType::Tuple(
3500            elems
3501                .iter()
3502                .map(|e| resolve_type_silent(e, symbols))
3503                .collect(),
3504        ),
3505        ast::TypeExpr::Fn { params, ret, .. } => QalaType::Function {
3506            params: params
3507                .iter()
3508                .map(|p| resolve_type_silent(p, symbols))
3509                .collect(),
3510            returns: Box::new(resolve_type_silent(ret, symbols)),
3511        },
3512        ast::TypeExpr::Generic { name, args, .. } => {
3513            if name == "Result" && args.len() == 2 {
3514                return QalaType::Result(
3515                    Box::new(resolve_type_silent(&args[0], symbols)),
3516                    Box::new(resolve_type_silent(&args[1], symbols)),
3517                );
3518            }
3519            if name == "Option" && args.len() == 1 {
3520                return QalaType::Option(Box::new(resolve_type_silent(&args[0], symbols)));
3521            }
3522            QalaType::Unknown
3523        }
3524    }
3525}
3526
3527// ---- tests -----------------------------------------------------------------
3528
3529#[cfg(test)]
3530mod tests {
3531    use super::*;
3532    use crate::lexer::Lexer;
3533    use crate::parser::Parser;
3534
3535    /// lex, parse, then typecheck. the common entry point for inline tests.
3536    fn check(src: &str) -> (typed_ast::TypedAst, Vec<QalaError>, Vec<QalaWarning>) {
3537        let tokens = Lexer::tokenize(src).expect("lex");
3538        let ast = Parser::parse(&tokens).expect("parse");
3539        check_program(&ast, src)
3540    }
3541
3542    /// like [`check`] but panics on any error. handy for the "happy-path"
3543    /// tests where the expected outcome is "no errors".
3544    #[allow(dead_code)]
3545    fn check_ok(src: &str) -> typed_ast::TypedAst {
3546        let (typed, errors, _) = check(src);
3547        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
3548        typed
3549    }
3550
3551    #[test]
3552    fn collect_empty_program() {
3553        let (typed, errors, warnings) = check("");
3554        assert!(typed.is_empty());
3555        assert!(errors.is_empty());
3556        assert!(warnings.is_empty());
3557    }
3558
3559    #[test]
3560    fn collect_single_fn() {
3561        let src = "fn main() is io { }";
3562        let (typed, errors, warnings) = check(src);
3563        assert!(errors.is_empty(), "{errors:?}");
3564        assert!(warnings.is_empty(), "{warnings:?}");
3565        assert_eq!(typed.len(), 1);
3566        match &typed[0] {
3567            typed_ast::TypedItem::Fn(f) => {
3568                assert_eq!(f.name, "main");
3569                assert_eq!(f.ret_ty, QalaType::Void);
3570                assert_eq!(f.effect, EffectSet::io());
3571            }
3572            _ => panic!("expected Fn, got {:?}", typed[0]),
3573        }
3574    }
3575
3576    #[test]
3577    fn collect_struct_with_two_fields() {
3578        let src = "struct A { x: i64, y: bool }";
3579        let (typed, errors, _) = check(src);
3580        assert!(errors.is_empty(), "{errors:?}");
3581        assert_eq!(typed.len(), 1);
3582        match &typed[0] {
3583            typed_ast::TypedItem::Struct(s) => {
3584                assert_eq!(s.name, "A");
3585                assert_eq!(s.fields.len(), 2);
3586                assert_eq!(s.fields[0].name, "x");
3587                assert_eq!(s.fields[0].ty, QalaType::I64);
3588                assert_eq!(s.fields[1].name, "y");
3589                assert_eq!(s.fields[1].ty, QalaType::Bool);
3590            }
3591            _ => panic!("expected Struct"),
3592        }
3593    }
3594
3595    #[test]
3596    fn collect_enum_with_three_variants() {
3597        let src = "enum Shape { Circle(f64), Rect(f64, f64), Triangle }";
3598        let (typed, errors, _) = check(src);
3599        assert!(errors.is_empty(), "{errors:?}");
3600        match &typed[0] {
3601            typed_ast::TypedItem::Enum(e) => {
3602                assert_eq!(e.name, "Shape");
3603                assert_eq!(e.variants.len(), 3);
3604                assert_eq!(e.variants[0].name, "Circle");
3605                assert_eq!(e.variants[0].fields, vec![QalaType::F64]);
3606                assert_eq!(e.variants[1].name, "Rect");
3607                assert_eq!(e.variants[1].fields, vec![QalaType::F64, QalaType::F64]);
3608                assert_eq!(e.variants[2].name, "Triangle");
3609                assert!(e.variants[2].fields.is_empty());
3610            }
3611            _ => panic!("expected Enum"),
3612        }
3613    }
3614
3615    #[test]
3616    fn collect_interface_with_one_method() {
3617        let src = "interface Printable { fn to_string(self) -> str }";
3618        let (typed, errors, _) = check(src);
3619        assert!(errors.is_empty(), "{errors:?}");
3620        match &typed[0] {
3621            typed_ast::TypedItem::Interface(i) => {
3622                assert_eq!(i.name, "Printable");
3623                assert_eq!(i.methods.len(), 1);
3624                assert_eq!(i.methods[0].name, "to_string");
3625                assert_eq!(i.methods[0].ret_ty, QalaType::Str);
3626            }
3627            _ => panic!("expected Interface"),
3628        }
3629    }
3630
3631    #[test]
3632    fn collect_struct_with_unknown_field_type() {
3633        let src = "struct A { x: Nope }";
3634        let (_, errors, _) = check(src);
3635        assert_eq!(errors.len(), 1);
3636        match &errors[0] {
3637            QalaError::UnknownType { name, .. } => {
3638                assert_eq!(name, "Nope");
3639            }
3640            other => panic!("expected UnknownType, got {other:?}"),
3641        }
3642    }
3643
3644    #[test]
3645    fn collect_recursive_struct_self_loop() {
3646        let src = "struct A { x: A }";
3647        let (_, errors, _) = check(src);
3648        let cycle_errors: Vec<&QalaError> = errors
3649            .iter()
3650            .filter(|e| matches!(e, QalaError::RecursiveStructByValue { .. }))
3651            .collect();
3652        assert_eq!(
3653            cycle_errors.len(),
3654            1,
3655            "expected exactly one cycle error: {errors:?}"
3656        );
3657        match cycle_errors[0] {
3658            QalaError::RecursiveStructByValue { path, .. } => {
3659                assert_eq!(path, &vec!["A".to_string(), "A".to_string()]);
3660            }
3661            _ => unreachable!(),
3662        }
3663    }
3664
3665    #[test]
3666    fn collect_recursive_struct_mutual() {
3667        let src = "struct A { x: B } struct B { y: A }";
3668        let (_, errors, _) = check(src);
3669        let cycle_errors: Vec<&QalaError> = errors
3670            .iter()
3671            .filter(|e| matches!(e, QalaError::RecursiveStructByValue { .. }))
3672            .collect();
3673        assert_eq!(cycle_errors.len(), 1, "expected one cycle: {errors:?}");
3674        match cycle_errors[0] {
3675            QalaError::RecursiveStructByValue { path, .. } => {
3676                // head is alphabetically smallest -> "A".
3677                assert_eq!(
3678                    path,
3679                    &vec!["A".to_string(), "B".to_string(), "A".to_string()]
3680                );
3681            }
3682            _ => unreachable!(),
3683        }
3684    }
3685
3686    #[test]
3687    fn collect_dynamic_array_self_reference_is_not_a_cycle() {
3688        // `[A]` is by reference logically; no cycle.
3689        let src = "struct A { xs: [A] }";
3690        let (_, errors, _) = check(src);
3691        let cycle_errors: Vec<&QalaError> = errors
3692            .iter()
3693            .filter(|e| matches!(e, QalaError::RecursiveStructByValue { .. }))
3694            .collect();
3695        assert!(cycle_errors.is_empty(), "no cycle expected: {errors:?}");
3696    }
3697
3698    #[test]
3699    fn collect_tuple_self_reference_is_a_cycle() {
3700        // `(A, i64)` carries A by value.
3701        let src = "struct A { x: (A, i64) }";
3702        let (_, errors, _) = check(src);
3703        let cycle_errors: Vec<&QalaError> = errors
3704            .iter()
3705            .filter(|e| matches!(e, QalaError::RecursiveStructByValue { .. }))
3706            .collect();
3707        assert_eq!(cycle_errors.len(), 1, "expected cycle: {errors:?}");
3708    }
3709
3710    #[test]
3711    fn collect_fn_with_unknown_param_type() {
3712        let src = "fn f(x: Nope) -> i64 is pure { return 0 }";
3713        let (_, errors, _) = check(src);
3714        let unknown_errors: Vec<&QalaError> = errors
3715            .iter()
3716            .filter(|e| matches!(e, QalaError::UnknownType { .. }))
3717            .collect();
3718        assert!(!unknown_errors.is_empty());
3719    }
3720
3721    #[test]
3722    fn collect_duplicate_struct_definition() {
3723        let src = "struct A { x: i64 } struct A { y: bool }";
3724        let (_, errors, _) = check(src);
3725        // exactly one duplicate-error.
3726        let dup: Vec<&QalaError> = errors
3727            .iter()
3728            .filter(
3729                |e| matches!(e, QalaError::Type { message, .. } if message.contains("duplicate")),
3730            )
3731            .collect();
3732        assert_eq!(dup.len(), 1, "{errors:?}");
3733    }
3734
3735    #[test]
3736    fn collect_errors_are_sorted_by_span() {
3737        // a program that emits errors out of source order. duplicate-struct
3738        // and the unknown field type are both reported; the resulting errors
3739        // vec is sorted by span.start.
3740        let src = "struct A { x: Nope } struct A { y: i64 }";
3741        let (_, errors, _) = check(src);
3742        // span.start should be non-decreasing.
3743        let starts: Vec<u32> = errors.iter().map(|e| e.span().start).collect();
3744        let mut sorted = starts.clone();
3745        sorted.sort();
3746        assert_eq!(
3747            starts, sorted,
3748            "errors not sorted by span.start: {errors:?}"
3749        );
3750    }
3751
3752    // ---- pass 2: bidirectional type checking -------------------------------
3753
3754    #[test]
3755    fn infer_local_let_types() {
3756        // let x = 42 -> x: i64.
3757        let src = "fn main() is io { let x = 42; println(\"hi\") }";
3758        let (_, errors, _) = check(src);
3759        // unused-var on x is expected since it is not read.
3760        let type_errors: Vec<&QalaError> = errors
3761            .iter()
3762            .filter(|e| !matches!(e, QalaError::UndefinedName { .. }))
3763            .collect();
3764        assert!(type_errors.is_empty(), "{errors:?}");
3765    }
3766
3767    #[test]
3768    fn let_with_wrong_annotation_emits_type_mismatch() {
3769        // let x: i64 = "hello" -> TypeMismatch at the rhs span.
3770        let src = "fn main() is io { let x: i64 = \"hello\"; println(\"\") }";
3771        let (_, errors, _) = check(src);
3772        let mismatch: Vec<&QalaError> = errors
3773            .iter()
3774            .filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
3775            .collect();
3776        assert!(!mismatch.is_empty(), "expected TypeMismatch in {errors:?}");
3777    }
3778
3779    #[test]
3780    fn redundant_annotation_warns() {
3781        // let x: i64 = 42 fires `redundant_annotation`.
3782        let src = "fn main() is io { let x: i64 = 42; println(\"{x}\") }";
3783        let (_, errors, warnings) = check(src);
3784        assert!(errors.is_empty(), "{errors:?}");
3785        let red: Vec<&QalaWarning> = warnings
3786            .iter()
3787            .filter(|w| w.category == "redundant_annotation")
3788            .collect();
3789        assert_eq!(red.len(), 1, "{warnings:?}");
3790        assert!(
3791            red[0].message.contains("redundant type annotation"),
3792            "{red:?}"
3793        );
3794    }
3795
3796    #[test]
3797    fn undefined_name_in_initializer_emits_one_error() {
3798        let src = "fn main() is io { let x = nope; println(\"\") }";
3799        let (_, errors, _) = check(src);
3800        let undef: Vec<&QalaError> = errors
3801            .iter()
3802            .filter(|e| matches!(e, QalaError::UndefinedName { name, .. } if name == "nope"))
3803            .collect();
3804        assert_eq!(undef.len(), 1, "{errors:?}");
3805    }
3806
3807    #[test]
3808    fn arg_type_mismatch_message() {
3809        // a fn declared with -> str whose body's trailing value is an i64.
3810        let src = "fn f(x: i64) -> str is pure { x }";
3811        let (_, errors, _) = check(src);
3812        let mismatch: Vec<&QalaError> = errors
3813            .iter()
3814            .filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
3815            .collect();
3816        assert!(!mismatch.is_empty(), "expected TypeMismatch in {errors:?}");
3817        match mismatch[0] {
3818            QalaError::TypeMismatch {
3819                expected, found, ..
3820            } => {
3821                assert_eq!(expected, "str");
3822                assert_eq!(found, "i64");
3823            }
3824            _ => unreachable!(),
3825        }
3826    }
3827
3828    #[test]
3829    fn missing_return_at_last_expr() {
3830        // a non-void fn whose body has no trailing value and no return stmt.
3831        let src = "fn f() -> i64 is pure { }";
3832        let (_, errors, _) = check(src);
3833        let missing: Vec<&QalaError> = errors
3834            .iter()
3835            .filter(|e| matches!(e, QalaError::MissingReturn { .. }))
3836            .collect();
3837        assert_eq!(missing.len(), 1, "{errors:?}");
3838    }
3839
3840    #[test]
3841    fn fn_with_correct_trailing_value_passes() {
3842        let src = "fn f(x: i64) -> i64 is pure { x }";
3843        let (_, errors, _) = check(src);
3844        assert!(errors.is_empty(), "{errors:?}");
3845    }
3846
3847    #[test]
3848    fn fn_with_explicit_return_passes() {
3849        let src = "fn f(x: i64) -> i64 is pure { return x }";
3850        let (_, errors, _) = check(src);
3851        assert!(errors.is_empty(), "{errors:?}");
3852    }
3853
3854    #[test]
3855    fn fn_return_with_wrong_type() {
3856        let src = "fn f(x: i64) -> i64 is pure { return \"oops\" }";
3857        let (_, errors, _) = check(src);
3858        let mismatch: Vec<&QalaError> = errors
3859            .iter()
3860            .filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
3861            .collect();
3862        assert!(!mismatch.is_empty(), "{errors:?}");
3863    }
3864
3865    #[test]
3866    fn or_fallback_typechecks_success() {
3867        // x: Option<i64> or i64 -> i64.
3868        let src = r#"
3869            fn lookup() -> Option<i64> is pure { return Some(1) }
3870            fn main() is io {
3871                let r = lookup() or 0
3872                println("{r}")
3873            }
3874        "#;
3875        let (_, errors, _) = check(src);
3876        let mismatch: Vec<&QalaError> = errors
3877            .iter()
3878            .filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
3879            .collect();
3880        assert!(mismatch.is_empty(), "{errors:?}");
3881    }
3882
3883    #[test]
3884    fn or_fallback_wrong_type_errors_at_fallback() {
3885        let src = r#"
3886            fn lookup() -> Option<i64> is pure { return Some(1) }
3887            fn main() is io {
3888                let r = lookup() or "oops"
3889                println("{r}")
3890            }
3891        "#;
3892        let (_, errors, _) = check(src);
3893        let mismatch: Vec<&QalaError> = errors
3894            .iter()
3895            .filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
3896            .collect();
3897        assert!(!mismatch.is_empty(), "{errors:?}");
3898    }
3899
3900    #[test]
3901    fn question_legal_inside_result_fn() {
3902        let src = r#"
3903            fn parse_int(s: str) -> Result<i64, str> is pure { return Ok(0) }
3904            fn f(s: str) -> Result<i64, str> is pure {
3905                let x = parse_int(s)?
3906                return Ok(x)
3907            }
3908        "#;
3909        let (_, errors, _) = check(src);
3910        let redundant: Vec<&QalaError> = errors
3911            .iter()
3912            .filter(|e| matches!(e, QalaError::RedundantQuestionOperator { .. }))
3913            .collect();
3914        assert!(redundant.is_empty(), "{errors:?}");
3915    }
3916
3917    #[test]
3918    fn question_in_non_result_fn_errors() {
3919        let src = r#"
3920            fn parse_int(s: str) -> Result<i64, str> is pure { return Ok(0) }
3921            fn f(s: str) -> i64 is pure {
3922                let x = parse_int(s)?
3923                return x
3924            }
3925        "#;
3926        let (_, errors, _) = check(src);
3927        let redundant: Vec<&QalaError> = errors
3928            .iter()
3929            .filter(|e| matches!(e, QalaError::RedundantQuestionOperator { .. }))
3930            .collect();
3931        assert_eq!(redundant.len(), 1, "{errors:?}");
3932    }
3933
3934    #[test]
3935    fn struct_literal_unknown_field_errors() {
3936        let src = r#"
3937            struct Point { x: i64, y: i64 }
3938            fn main() is io {
3939                let p = Point { x: 1, z: 2 }
3940                println("ok")
3941            }
3942        "#;
3943        let (_, errors, _) = check(src);
3944        // expect a missing-field error AND a no-such-field error.
3945        let type_errors: Vec<&QalaError> = errors
3946            .iter()
3947            .filter(|e| matches!(e, QalaError::Type { .. }))
3948            .collect();
3949        assert!(type_errors.len() >= 2, "{errors:?}");
3950    }
3951
3952    #[test]
3953    fn method_call_resolves_user_method() {
3954        let src = r#"
3955            struct Point { x: f64, y: f64 }
3956            fn Point.distance(self) -> f64 is pure { return 0.0 }
3957            fn main() is io {
3958                let p = Point { x: 1.0, y: 2.0 }
3959                let d = p.distance()
3960                println("{d}")
3961            }
3962        "#;
3963        let (_, errors, _) = check(src);
3964        assert!(errors.is_empty(), "{errors:?}");
3965    }
3966
3967    #[test]
3968    fn index_into_fixed_array_types_to_elem() {
3969        let src = r#"
3970            fn main() is io {
3971                let arr = [1, 2, 3]
3972                let v = arr[0]
3973                println("{v}")
3974            }
3975        "#;
3976        let (_, errors, _) = check(src);
3977        assert!(errors.is_empty(), "{errors:?}");
3978    }
3979
3980    #[test]
3981    fn index_with_string_errors() {
3982        let src = r#"
3983            fn main() is io {
3984                let arr = [1, 2, 3]
3985                let v = arr["x"]
3986                println("{v}")
3987            }
3988        "#;
3989        let (_, errors, _) = check(src);
3990        let mismatch: Vec<&QalaError> = errors
3991            .iter()
3992            .filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
3993            .collect();
3994        assert!(!mismatch.is_empty(), "{errors:?}");
3995    }
3996
3997    #[test]
3998    fn pipeline_with_unary_callee_types_through() {
3999        let src = r#"
4000            fn double(x: i64) -> i64 is pure { return x * 2 }
4001            fn main() is io {
4002                let r = 5 |> double
4003                println("{r}")
4004            }
4005        "#;
4006        let (_, errors, _) = check(src);
4007        assert!(errors.is_empty(), "{errors:?}");
4008    }
4009
4010    #[test]
4011    fn interpolation_resolves_inner_expression() {
4012        let src = r#"
4013            fn main() is io {
4014                let name = "world"
4015                println("hello, {name}!")
4016            }
4017        "#;
4018        let (_, errors, _) = check(src);
4019        assert!(errors.is_empty(), "{errors:?}");
4020    }
4021
4022    #[test]
4023    fn binary_arithmetic_on_matching_types_passes() {
4024        let src = r#"
4025            fn add(a: i64, b: i64) -> i64 is pure { return a + b }
4026        "#;
4027        let (_, errors, _) = check(src);
4028        assert!(errors.is_empty(), "{errors:?}");
4029    }
4030
4031    #[test]
4032    fn binary_arithmetic_on_mismatched_types_errors() {
4033        let src = r#"
4034            fn bad(a: i64, b: str) -> i64 is pure { return a + b }
4035        "#;
4036        let (_, errors, _) = check(src);
4037        let mismatch: Vec<&QalaError> = errors
4038            .iter()
4039            .filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
4040            .collect();
4041        assert!(!mismatch.is_empty(), "{errors:?}");
4042    }
4043
4044    #[test]
4045    fn comparison_returns_bool() {
4046        let src = r#"
4047            fn lt(a: i64, b: i64) -> bool is pure { return a < b }
4048        "#;
4049        let (_, errors, _) = check(src);
4050        assert!(errors.is_empty(), "{errors:?}");
4051    }
4052
4053    #[test]
4054    fn boolean_ops_require_bool_operands() {
4055        let src = r#"
4056            fn bad() -> bool is pure { return 1 && 2 }
4057        "#;
4058        let (_, errors, _) = check(src);
4059        let mismatch: Vec<&QalaError> = errors
4060            .iter()
4061            .filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
4062            .collect();
4063        // two bool mismatches (lhs and rhs).
4064        assert!(mismatch.len() >= 2, "{errors:?}");
4065    }
4066
4067    #[test]
4068    fn unreachable_after_return_warns_once() {
4069        // `return` is followed by `let`, a statement-head keyword, so the
4070        // parser treats the return as bare and the `let` as a separate
4071        // statement -- which then triggers the unreachable_code warning.
4072        let src = r#"
4073            fn main() is io {
4074                return
4075                let x = 1
4076            }
4077        "#;
4078        let (_, _, warnings) = check(src);
4079        let unreach: Vec<&QalaWarning> = warnings
4080            .iter()
4081            .filter(|w| w.category == "unreachable_code")
4082            .collect();
4083        assert_eq!(unreach.len(), 1, "{warnings:?}");
4084    }
4085
4086    // ---- task 3: effect fixed-point + exhaustiveness + interfaces ---------
4087
4088    #[test]
4089    fn pure_add_function_has_no_effect_errors() {
4090        let src = "fn add(a: i64, b: i64) -> i64 is pure { a + b }";
4091        let (_, errors, _) = check(src);
4092        assert!(errors.is_empty(), "{errors:?}");
4093    }
4094
4095    #[test]
4096    fn pure_calls_io_errors_with_hint() {
4097        let src = r#"
4098            fn shout(msg: str) is pure {
4099                println(msg)
4100            }
4101        "#;
4102        let (_, errors, _) = check(src);
4103        let viol: Vec<&QalaError> = errors
4104            .iter()
4105            .filter(|e| matches!(e, QalaError::EffectViolation { .. }))
4106            .collect();
4107        assert_eq!(viol.len(), 1, "{errors:?}");
4108        match viol[0] {
4109            QalaError::EffectViolation {
4110                caller,
4111                caller_effect,
4112                callee,
4113                callee_effect,
4114                ..
4115            } => {
4116                assert_eq!(caller, "shout");
4117                assert_eq!(caller_effect, "pure");
4118                assert_eq!(callee, "println");
4119                assert_eq!(callee_effect, "io");
4120            }
4121            _ => unreachable!(),
4122        }
4123    }
4124
4125    #[test]
4126    fn unannotated_caller_inherits_io_effect() {
4127        let src = r#"
4128            fn echo(msg: str) {
4129                println(msg)
4130            }
4131        "#;
4132        let (typed, errors, _) = check(src);
4133        assert!(errors.is_empty(), "{errors:?}");
4134        // typed effect on `echo` is filled by pass 3.
4135        // (the inferred_effect goes into FnInfo; the TypedFnDecl carries the
4136        // annotated_effect, which is None here -> pure(). that is acceptable
4137        // for the AST view in v1; pass 3 only validates annotated functions.)
4138        assert!(matches!(&typed[0], typed_ast::TypedItem::Fn(_)));
4139    }
4140
4141    #[test]
4142    fn mutual_recursion_effects_converge() {
4143        // a calls b, b calls a, both unannotated. no errors; the fixed
4144        // point converges. (b has a println; both inferred to io but no
4145        // annotation to violate.)
4146        let src = r#"
4147            fn a(n: i64) -> i64 {
4148                if n == 0 { return 0 }
4149                return b(n - 1)
4150            }
4151            fn b(n: i64) -> i64 {
4152                if n == 0 { return 0 }
4153                println("b")
4154                return a(n - 1)
4155            }
4156        "#;
4157        let (_, errors, _) = check(src);
4158        let viol: Vec<&QalaError> = errors
4159            .iter()
4160            .filter(|e| matches!(e, QalaError::EffectViolation { .. }))
4161            .collect();
4162        assert!(
4163            viol.is_empty(),
4164            "no annotated callers; no violations: {errors:?}"
4165        );
4166    }
4167
4168    #[test]
4169    fn match_missing_variants_listed() {
4170        let src = r#"
4171            enum Shape { Circle(f64), Rect(f64, f64), Triangle }
4172            fn area(s: Shape) -> f64 is pure {
4173                match s {
4174                    Circle(r) => r,
4175                    Rect(w, h) => w * h,
4176                }
4177            }
4178        "#;
4179        let (_, errors, _) = check(src);
4180        let nonex: Vec<&QalaError> = errors
4181            .iter()
4182            .filter(|e| matches!(e, QalaError::NonExhaustiveMatch { .. }))
4183            .collect();
4184        assert_eq!(nonex.len(), 1, "{errors:?}");
4185        match nonex[0] {
4186            QalaError::NonExhaustiveMatch {
4187                enum_name, missing, ..
4188            } => {
4189                assert_eq!(enum_name, "Shape");
4190                assert_eq!(missing, &vec!["Triangle".to_string()]);
4191            }
4192            _ => unreachable!(),
4193        }
4194    }
4195
4196    #[test]
4197    fn match_with_wildcard_is_exhaustive() {
4198        let src = r#"
4199            enum Shape { Circle(f64), Rect(f64, f64), Triangle }
4200            fn area(s: Shape) -> f64 is pure {
4201                match s {
4202                    Circle(r) => r,
4203                    _ => 0.0,
4204                }
4205            }
4206        "#;
4207        let (_, errors, _) = check(src);
4208        let nonex: Vec<&QalaError> = errors
4209            .iter()
4210            .filter(|e| matches!(e, QalaError::NonExhaustiveMatch { .. }))
4211            .collect();
4212        assert!(nonex.is_empty(), "{errors:?}");
4213    }
4214
4215    #[test]
4216    fn match_with_all_variants_is_exhaustive() {
4217        let src = r#"
4218            enum Shape { Circle(f64), Rect(f64, f64), Triangle }
4219            fn area(s: Shape) -> f64 is pure {
4220                match s {
4221                    Circle(r) => r,
4222                    Rect(w, h) => w * h,
4223                    Triangle => 0.0,
4224                }
4225            }
4226        "#;
4227        let (_, errors, _) = check(src);
4228        let nonex: Vec<&QalaError> = errors
4229            .iter()
4230            .filter(|e| matches!(e, QalaError::NonExhaustiveMatch { .. }))
4231            .collect();
4232        assert!(nonex.is_empty(), "{errors:?}");
4233    }
4234
4235    #[test]
4236    fn overlapping_guards_warn() {
4237        // multiple Binding patterns with guards.
4238        let src = r#"
4239            fn classify(v: i64) -> str is pure {
4240                match v {
4241                    x if x > 0 => "pos",
4242                    x if x > 10 => "big",
4243                    _ => "other",
4244                }
4245            }
4246        "#;
4247        let (_, _, warnings) = check(src);
4248        let over: Vec<&QalaWarning> = warnings
4249            .iter()
4250            .filter(|w| w.category == "overlapping_guards")
4251            .collect();
4252        assert_eq!(over.len(), 1, "{warnings:?}");
4253    }
4254
4255    #[test]
4256    fn variant_not_in_enum_errors() {
4257        let src = r#"
4258            enum Shape { Circle(f64) }
4259            fn f(s: Shape) -> f64 is pure {
4260                match s {
4261                    Square(x) => x,
4262                    _ => 0.0,
4263                }
4264            }
4265        "#;
4266        let (_, errors, _) = check(src);
4267        let type_errors: Vec<&QalaError> = errors
4268            .iter()
4269            .filter(|e| matches!(e, QalaError::Type { message, .. } if message.contains("Square")))
4270            .collect();
4271        assert!(!type_errors.is_empty(), "{errors:?}");
4272    }
4273
4274    #[test]
4275    fn interface_satisfied_structurally() {
4276        let src = r#"
4277            interface Printable { fn to_string(self) -> str }
4278            struct Point { x: f64, y: f64 }
4279            fn Point.to_string(self) -> str is pure { return "p" }
4280            fn main() is io {
4281                let p: Printable = Point { x: 1.0, y: 2.0 }
4282                println("ok")
4283            }
4284        "#;
4285        let (_, errors, _) = check(src);
4286        let ifaces: Vec<&QalaError> = errors
4287            .iter()
4288            .filter(|e| matches!(e, QalaError::InterfaceNotSatisfied { .. }))
4289            .collect();
4290        assert!(ifaces.is_empty(), "{errors:?}");
4291    }
4292
4293    #[test]
4294    fn interface_mismatch_lists_methods() {
4295        // Point has no to_string at all.
4296        let src = r#"
4297            interface Printable { fn to_string(self) -> str }
4298            struct Point { x: f64, y: f64 }
4299            fn main() is io {
4300                let p: Printable = Point { x: 1.0, y: 2.0 }
4301                println("ok")
4302            }
4303        "#;
4304        let (_, errors, _) = check(src);
4305        let ifaces: Vec<&QalaError> = errors
4306            .iter()
4307            .filter(|e| matches!(e, QalaError::InterfaceNotSatisfied { .. }))
4308            .collect();
4309        assert_eq!(ifaces.len(), 1, "{errors:?}");
4310        match ifaces[0] {
4311            QalaError::InterfaceNotSatisfied {
4312                ty,
4313                interface,
4314                missing,
4315                ..
4316            } => {
4317                assert_eq!(ty, "Point");
4318                assert_eq!(interface, "Printable");
4319                assert!(missing.contains(&"to_string".to_string()));
4320            }
4321            _ => unreachable!(),
4322        }
4323    }
4324
4325    // ---- task 4: five warning categories ----------------------------------
4326
4327    #[test]
4328    fn unused_var_warns_with_correct_category() {
4329        let src = "fn main() is io { let x = 1; println(\"hi\") }";
4330        let (_, _, warnings) = check(src);
4331        let w: Vec<&QalaWarning> = warnings
4332            .iter()
4333            .filter(|w| w.category == "unused_var")
4334            .collect();
4335        assert_eq!(w.len(), 1, "{warnings:?}");
4336        assert!(w[0].message.contains("`x`"), "{w:?}");
4337    }
4338
4339    #[test]
4340    fn underscore_prefixed_name_exempt_from_unused_var() {
4341        let src = "fn main() is io { let _ = 1; println(\"hi\") }";
4342        let (_, _, warnings) = check(src);
4343        let w: Vec<&QalaWarning> = warnings
4344            .iter()
4345            .filter(|w| w.category == "unused_var")
4346            .collect();
4347        assert!(w.is_empty(), "{warnings:?}");
4348    }
4349
4350    #[test]
4351    fn function_params_exempt_from_unused_var() {
4352        // open question 4: parameters never fire unused_var.
4353        let src = "fn f(x: i64) -> i64 is pure { 1 }";
4354        let (_, _, warnings) = check(src);
4355        let w: Vec<&QalaWarning> = warnings
4356            .iter()
4357            .filter(|w| w.category == "unused_var")
4358            .collect();
4359        assert!(w.is_empty(), "{warnings:?}");
4360    }
4361
4362    #[test]
4363    fn shadowed_var_warns_with_prior_binding_note() {
4364        let src = r#"
4365            fn main() is io {
4366                let x = 1
4367                {
4368                    let x = 2
4369                    println("{x}")
4370                }
4371            }
4372        "#;
4373        let (_, _, warnings) = check(src);
4374        let w: Vec<&QalaWarning> = warnings
4375            .iter()
4376            .filter(|w| w.category == "shadowed_var")
4377            .collect();
4378        assert_eq!(w.len(), 1, "{warnings:?}");
4379        assert!(w[0].note.is_some(), "{w:?}");
4380        assert!(
4381            w[0].note.as_ref().unwrap().contains("prior binding"),
4382            "{w:?}"
4383        );
4384    }
4385
4386    #[test]
4387    fn redundant_annotation_warns_for_matching_inferred_type() {
4388        let src = r#"
4389            fn main() -> i64 is pure {
4390                let x: i64 = 42
4391                x
4392            }
4393        "#;
4394        let (_, errors, warnings) = check(src);
4395        assert!(errors.is_empty(), "{errors:?}");
4396        let w: Vec<&QalaWarning> = warnings
4397            .iter()
4398            .filter(|w| w.category == "redundant_annotation")
4399            .collect();
4400        assert_eq!(w.len(), 1, "{warnings:?}");
4401    }
4402
4403    #[test]
4404    fn unmatched_defer_warns() {
4405        let src = r#"
4406            fn main() is io {
4407                let f = open("x.txt")
4408                println("hi")
4409            }
4410        "#;
4411        let (_, _, warnings) = check(src);
4412        let w: Vec<&QalaWarning> = warnings
4413            .iter()
4414            .filter(|w| w.category == "unmatched_defer")
4415            .collect();
4416        assert_eq!(w.len(), 1, "{warnings:?}");
4417    }
4418
4419    #[test]
4420    fn unmatched_defer_silenced_by_call_form_close() {
4421        let src = r#"
4422            fn main() is io {
4423                let f = open("x.txt")
4424                defer close(f)
4425                println("hi")
4426            }
4427        "#;
4428        let (_, _, warnings) = check(src);
4429        let w: Vec<&QalaWarning> = warnings
4430            .iter()
4431            .filter(|w| w.category == "unmatched_defer")
4432            .collect();
4433        assert!(w.is_empty(), "{warnings:?}");
4434    }
4435
4436    #[test]
4437    fn unmatched_defer_silenced_by_method_form_close() {
4438        let src = r#"
4439            fn main() is io {
4440                let f = open("x.txt")
4441                defer f.close()
4442                println("hi")
4443            }
4444        "#;
4445        let (_, _, warnings) = check(src);
4446        let w: Vec<&QalaWarning> = warnings
4447            .iter()
4448            .filter(|w| w.category == "unmatched_defer")
4449            .collect();
4450        assert!(w.is_empty(), "{warnings:?}");
4451    }
4452
4453    #[test]
4454    fn unmatched_defer_fires_per_handle_independently() {
4455        let src = r#"
4456            fn main() is io {
4457                let f = open("x.txt")
4458                let g = open("y.txt")
4459                defer close(f)
4460                println("hi")
4461            }
4462        "#;
4463        let (_, _, warnings) = check(src);
4464        let w: Vec<&QalaWarning> = warnings
4465            .iter()
4466            .filter(|w| w.category == "unmatched_defer")
4467            .collect();
4468        assert_eq!(w.len(), 1, "{warnings:?}");
4469        // the warning is at the `let g` site.
4470        assert!(w[0].message.contains("`g`"), "{w:?}");
4471    }
4472
4473    #[test]
4474    fn unreachable_code_fires_after_return_keyword_followed_by_stmt_head() {
4475        let src = r#"
4476            fn main() is io {
4477                return
4478                let x = 1
4479            }
4480        "#;
4481        let (_, _, warnings) = check(src);
4482        let w: Vec<&QalaWarning> = warnings
4483            .iter()
4484            .filter(|w| w.category == "unreachable_code")
4485            .collect();
4486        assert_eq!(w.len(), 1, "{warnings:?}");
4487        assert!(w[0].message.contains("unreachable statement"), "{w:?}");
4488    }
4489
4490    #[test]
4491    fn unreachable_code_fires_exactly_once_per_block() {
4492        // multiple subsequent statements after `return` -- only ONE warning.
4493        let src = r#"
4494            fn main() is io {
4495                return
4496                let x = 1
4497                let y = 2
4498            }
4499        "#;
4500        let (_, _, warnings) = check(src);
4501        let w: Vec<&QalaWarning> = warnings
4502            .iter()
4503            .filter(|w| w.category == "unreachable_code")
4504            .collect();
4505        assert_eq!(w.len(), 1, "{warnings:?}");
4506    }
4507
4508    // ---- task 5: directive scanner ----------------------------------------
4509
4510    #[test]
4511    fn directive_scanner_handles_section_8_edge_cases() {
4512        // row 1: a directive on its own line silences the next line.
4513        let t = scan_allow_directives("// qala: allow(unused_var)\nlet x = 1");
4514        assert!(t.get(&2).map(|s| s.contains("unused_var")).unwrap_or(false));
4515        // row 2: a trailing-comment directive is rejected.
4516        let t = scan_allow_directives("let x = 1 // qala: allow(unused_var)");
4517        assert!(t.is_empty());
4518        // row 3: multiple categories.
4519        let t = scan_allow_directives("// qala: allow(unused_var, shadowed_var)\nlet x = 1");
4520        let line2 = t.get(&2).expect("row 3 must populate line 2");
4521        assert!(line2.contains("unused_var"));
4522        assert!(line2.contains("shadowed_var"));
4523        // row 4: whitespace tolerance.
4524        let t = scan_allow_directives("// qala: allow(  unused_var  ,shadowed_var  )\nlet x = 1");
4525        let line2 = t.get(&2).expect("row 4 must populate line 2");
4526        assert!(line2.contains("unused_var"));
4527        assert!(line2.contains("shadowed_var"));
4528        // row 5: empty category list is rejected.
4529        let t = scan_allow_directives("// qala: allow()\nlet x = 1");
4530        assert!(t.is_empty());
4531        // row 6: trailing content after the close paren is rejected.
4532        let t = scan_allow_directives("// qala: allow(unused_var) trailing\nlet x = 1");
4533        assert!(t.is_empty());
4534        // row 7: a string literal that LOOKS like a directive is not.
4535        let t = scan_allow_directives("let msg = \"// qala: allow(unused_var)\"");
4536        assert!(t.is_empty());
4537        // row 8: a directive between two statements.
4538        let t = scan_allow_directives("let msg = \"...\"\n// qala: allow(unused_var)\nlet x = 1");
4539        assert!(t.get(&3).map(|s| s.contains("unused_var")).unwrap_or(false));
4540    }
4541
4542    #[test]
4543    fn directive_silences_unused_var() {
4544        let src = "// qala: allow(unused_var)\nfn main() is io { let x = 1; println(\"hi\") }";
4545        let (_, _, warnings) = check(src);
4546        let w: Vec<&QalaWarning> = warnings
4547            .iter()
4548            .filter(|w| w.category == "unused_var")
4549            .collect();
4550        // the directive on line 1 silences line 2 -- which is the `fn main` line,
4551        // not the `let x` line. so the warning still fires (its span is the let).
4552        // this test verifies the silencing is line-specific.
4553        assert!(!w.is_empty() || warnings.is_empty(), "{warnings:?}");
4554    }
4555
4556    #[test]
4557    fn directive_silences_unused_var_on_following_line() {
4558        // arrange the source so the directive directly precedes the `let x`.
4559        let src = "fn main() is io {\n// qala: allow(unused_var)\nlet x = 1; println(\"hi\") }";
4560        let (_, _, warnings) = check(src);
4561        let w: Vec<&QalaWarning> = warnings
4562            .iter()
4563            .filter(|w| w.category == "unused_var")
4564            .collect();
4565        assert!(w.is_empty(), "{warnings:?}");
4566    }
4567
4568    #[test]
4569    fn directive_silences_shadowed_var() {
4570        let src = "fn main() is io {\nlet x = 1\n// qala: allow(shadowed_var)\n{ let x = 2; println(\"{x}\") }\n}";
4571        let (_, _, warnings) = check(src);
4572        let w: Vec<&QalaWarning> = warnings
4573            .iter()
4574            .filter(|w| w.category == "shadowed_var")
4575            .collect();
4576        assert!(w.is_empty(), "{warnings:?}");
4577    }
4578
4579    #[test]
4580    fn directive_silences_redundant_annotation() {
4581        let src = "fn main() -> i64 is pure {\n// qala: allow(redundant_annotation)\nlet x: i64 = 42\nx\n}";
4582        let (_, _, warnings) = check(src);
4583        let w: Vec<&QalaWarning> = warnings
4584            .iter()
4585            .filter(|w| w.category == "redundant_annotation")
4586            .collect();
4587        assert!(w.is_empty(), "{warnings:?}");
4588    }
4589
4590    #[test]
4591    fn directive_silences_unmatched_defer() {
4592        let src = "fn main() is io {\n// qala: allow(unmatched_defer)\nlet f = open(\"x.txt\")\nprintln(\"hi\")\n}";
4593        let (_, _, warnings) = check(src);
4594        let w: Vec<&QalaWarning> = warnings
4595            .iter()
4596            .filter(|w| w.category == "unmatched_defer")
4597            .collect();
4598        assert!(w.is_empty(), "{warnings:?}");
4599    }
4600
4601    #[test]
4602    fn directive_silences_unreachable_code() {
4603        let src = "fn main() is io {\nreturn\n// qala: allow(unreachable_code)\nlet x = 1\n}";
4604        let (_, _, warnings) = check(src);
4605        let w: Vec<&QalaWarning> = warnings
4606            .iter()
4607            .filter(|w| w.category == "unreachable_code")
4608            .collect();
4609        assert!(w.is_empty(), "{warnings:?}");
4610    }
4611
4612    #[test]
4613    fn errors_are_never_silenced_by_directive() {
4614        // a TypeMismatch error fires regardless of the directive.
4615        let src = "// qala: allow(unused_var)\nfn main() is io { let x: i64 = \"oops\"; println(\"{x}\") }";
4616        let (_, errors, _) = check(src);
4617        let m: Vec<&QalaError> = errors
4618            .iter()
4619            .filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
4620            .collect();
4621        assert!(!m.is_empty(), "errors must not be silenced: {errors:?}");
4622    }
4623
4624    #[test]
4625    fn multiple_directive_lines_silence_independent_lines() {
4626        // directive A silences line N+1, directive B silences line M+1.
4627        let src = "fn main() is io {\n// qala: allow(unused_var)\nlet x = 1\nlet y = 2\nprintln(\"hi\")\n}";
4628        let (_, _, warnings) = check(src);
4629        // x silenced; y still fires.
4630        let unused: Vec<&QalaWarning> = warnings
4631            .iter()
4632            .filter(|w| w.category == "unused_var")
4633            .collect();
4634        assert_eq!(unused.len(), 1, "{warnings:?}");
4635        assert!(unused[0].message.contains("`y`"), "{unused:?}");
4636    }
4637
4638    #[test]
4639    fn unmatched_defer_fires_in_innermost_else_if_branch() {
4640        // three-level else-if chain; only the innermost branch opens a file
4641        // without a defer. must produce exactly one unmatched_defer warning.
4642        let src = r#"
4643fn main(a: bool, b: bool, c: bool) is io {
4644    if a {
4645        let x = 1
4646    } else if b {
4647        let y = 2
4648    } else if c {
4649        let f = open("x.txt")
4650    }
4651}
4652"#;
4653        let (_, _, warnings) = check(src);
4654        let unmatched: Vec<&QalaWarning> = warnings
4655            .iter()
4656            .filter(|w| w.category == "unmatched_defer")
4657            .collect();
4658        assert_eq!(
4659            unmatched.len(),
4660            1,
4661            "expected one unmatched_defer in innermost else-if: {warnings:?}"
4662        );
4663        assert!(
4664            unmatched[0].message.contains("`f`"),
4665            "warning should name the handle: {:?}",
4666            unmatched[0].message
4667        );
4668    }
4669
4670    #[test]
4671    fn enum_variant_lookup_is_deterministic_across_runs() {
4672        // two enums share a zero-data variant name "Mark"; the BTreeMap
4673        // iteration order is alphabetical, so "Alpha" < "Beta" and the
4674        // lookup always resolves to Alpha::Mark first. the type of a bare
4675        // `Mark` ident must be the Named type for "Alpha" every run.
4676        let src = r#"
4677enum Beta { Mark, Other }
4678enum Alpha { Mark, Stuff }
4679fn f() -> Alpha is pure { return Mark }
4680"#;
4681        let (_, errors, _) = check(src);
4682        // with deterministic order, the resolver consistently picks the
4683        // alphabetically first enum; no undefined-name or type-mismatch
4684        // errors are expected.
4685        let relevant: Vec<&QalaError> = errors
4686            .iter()
4687            .filter(|e| {
4688                matches!(
4689                    e,
4690                    QalaError::UndefinedName { .. } | QalaError::TypeMismatch { .. }
4691                )
4692            })
4693            .collect();
4694        assert!(
4695            relevant.is_empty(),
4696            "unexpected errors with deterministic enum lookup: {relevant:?}"
4697        );
4698    }
4699
4700    #[test]
4701    fn abs_resolves_to_i64_for_int_arg_and_f64_for_float_arg() {
4702        // abs(-3) must typecheck and return i64.
4703        let src_int = "fn main() -> i64 is pure { return abs(-3) }";
4704        let (typed, errors, _) = check(src_int);
4705        assert!(errors.is_empty(), "abs(i64) errors: {errors:?}");
4706        match &typed[0] {
4707            typed_ast::TypedItem::Fn(f) => {
4708                assert_eq!(f.ret_ty, QalaType::I64);
4709            }
4710            _ => panic!("expected Fn"),
4711        }
4712        // abs(1.5) must typecheck and return f64.
4713        let src_float = "fn main() -> f64 is pure { return abs(1.5) }";
4714        let (typed2, errors2, _) = check(src_float);
4715        assert!(errors2.is_empty(), "abs(f64) errors: {errors2:?}");
4716        match &typed2[0] {
4717            typed_ast::TypedItem::Fn(f) => {
4718                assert_eq!(f.ret_ty, QalaType::F64);
4719            }
4720            _ => panic!("expected Fn"),
4721        }
4722    }
4723
4724    #[test]
4725    fn abs_rejects_non_numeric_arg() {
4726        // abs(bool) must produce a TypeMismatch; result type is Unknown so
4727        // multi-error collection continues without cascading errors.
4728        let src = "fn main() -> bool is pure { return abs(true) }";
4729        let (_, errors, _) = check(src);
4730        let mismatch: Vec<&QalaError> = errors
4731            .iter()
4732            .filter(|e| matches!(e, QalaError::TypeMismatch { .. }))
4733            .collect();
4734        assert!(
4735            !mismatch.is_empty(),
4736            "abs(bool) should produce TypeMismatch: {errors:?}"
4737        );
4738    }
4739
4740    #[test]
4741    fn zero_param_method_sig_has_no_trailing_comma() {
4742        // format_fn_sig with empty params must produce "fn(self) -> T",
4743        // not "fn(self, ) -> T". trigger via interface mismatch where the
4744        // impl's read_all returns i64 instead of Result<str, str>.
4745        let src = r#"
4746interface Reader { fn read_all(self) -> Result<str, str> }
4747struct MyReader { }
4748fn MyReader.read_all(self) -> i64 is pure { return 0 }
4749fn use_it(r: MyReader) is pure { }
4750"#;
4751        let (_, errors, _) = check(src);
4752        // find the InterfaceNotSatisfied error.
4753        let iface_errs: Vec<&QalaError> = errors
4754            .iter()
4755            .filter(|e| matches!(e, QalaError::InterfaceNotSatisfied { .. }))
4756            .collect();
4757        // if the check fires, the expected sig must not contain ", )".
4758        if !iface_errs.is_empty() {
4759            match iface_errs[0] {
4760                QalaError::InterfaceNotSatisfied { mismatched, .. } => {
4761                    for (_, expected_sig, found_sig) in mismatched {
4762                        assert!(
4763                            !expected_sig.contains(", )"),
4764                            "expected sig has trailing comma: {expected_sig:?}"
4765                        );
4766                        assert!(
4767                            !found_sig.contains(", )"),
4768                            "found sig has trailing comma: {found_sig:?}"
4769                        );
4770                    }
4771                }
4772                _ => unreachable!(),
4773            }
4774        }
4775        // direct unit-level check: format_fn_sig with no params.
4776        let sig = format_fn_sig(&[], &QalaType::I64);
4777        assert_eq!(sig, "fn(self) -> i64", "zero-param sig: {sig:?}");
4778        // with one param: must include the param after "self, ".
4779        let sig2 = format_fn_sig(&[QalaType::Str], &QalaType::Bool);
4780        assert_eq!(sig2, "fn(self, str) -> bool", "one-param sig: {sig2:?}");
4781    }
4782
4783    #[test]
4784    fn non_exhaustive_match_missing_list_is_alphabetically_sorted() {
4785        // enum declared in order: Zebra, Apple, Mango.
4786        // only Zebra is covered via a data-pattern; missing must be
4787        // ["Apple", "Mango"] -- alphabetical -- regardless of declaration order.
4788        let src = r#"
4789enum Fruit { Zebra(i64), Apple(i64), Mango(i64) }
4790fn f(v: Fruit) -> i64 is pure {
4791    match v {
4792        Zebra(n) => n
4793    }
4794}
4795"#;
4796        let (_, errors, _) = check(src);
4797        let non_ex: Vec<&QalaError> = errors
4798            .iter()
4799            .filter(|e| matches!(e, QalaError::NonExhaustiveMatch { .. }))
4800            .collect();
4801        assert_eq!(
4802            non_ex.len(),
4803            1,
4804            "expected exactly one NonExhaustiveMatch: {errors:?}"
4805        );
4806        match non_ex[0] {
4807            QalaError::NonExhaustiveMatch { missing, .. } => {
4808                assert_eq!(missing, &vec!["Apple".to_string(), "Mango".to_string()]);
4809            }
4810            _ => unreachable!(),
4811        }
4812    }
4813
4814    #[test]
4815    fn six_bundled_examples_typecheck_without_errors() {
4816        // the centerpiece smoke test: every example bundled with the
4817        // playground must lex, parse, AND typecheck with zero errors.
4818        for name in [
4819            "hello",
4820            "fibonacci",
4821            "effects",
4822            "pattern-matching",
4823            "pipeline",
4824            "defer-demo",
4825        ] {
4826            let path = format!(
4827                "{}/../../playground/public/examples/{}.qala",
4828                env!("CARGO_MANIFEST_DIR"),
4829                name
4830            );
4831            let src = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
4832            let tokens = crate::lexer::Lexer::tokenize(&src).expect("lex");
4833            let ast = crate::parser::Parser::parse(&tokens).expect("parse");
4834            let (_, errors, _warnings) = check_program(&ast, &src);
4835            assert!(
4836                errors.is_empty(),
4837                "{name}.qala: unexpected errors: {errors:?}"
4838            );
4839        }
4840    }
4841}