Skip to main content

adam_lang/
typecheck.rs

1//! A best-effort static type checker over [`crate::ast::Sheet`] trees, built on
2//! [`cel_parser::ty::check_expr_with_type_resolver`]. Checks each `cell`'s literal or
3//! tuple-shaped initializer against its `: type_name` annotation (a `source`'s initializer is
4//! checked identically — a `source` shares
5//! `cell`'s exact shape, including its optional `filter` clause), each `relationship`/`conditional`
6//! binding's body against its declared outputs (arity: does the body actually produce as many
7//! values as declared; and per-output type), and each `out`'s initializer body against its optional
8//! `: type_name` annotation. Any `cell`, `source`, or `out`'s optional `require { ... }` block has
9//! each of its `requirement` bodies checked to produce `bool` type. Adam cell annotations that
10//! name a type [`crate::TypeRegistry`] doesn't recognize still resolve to [`cel_parser::Ty::Any`]
11//! and are never flagged, while CEL array annotations inside expression bodies resolve named
12//! leaves through the same registry-backed CEL resolver [`check_sheet`] builds per pass. An absent
13//! annotation or a call without metadata resolves to
14//! [`cel_parser::Ty::Any`] and are never flagged — matching adam-lang/CEL's extensible type
15//! system. Not a complete type system; see the design doc's "Type checking (v1)" section.
16
17use cel_parser::{Expr, ExprSpan, Literal, ParseError, Ty, ty::check_expr_with_type_resolver};
18
19use crate::TypeRegistry;
20use crate::ast::{BindingDecl, CellFilter, OutDecl, RequireBlock, Sheet, SheetItem, TypeExpr};
21use crate::type_registry::TypeShape;
22
23/// Checks `sheet` against registered types and operation/record metadata, returning diagnostics.
24/// Never fails — an absent Adam annotation, an unresolved identifier, an Adam `cell`/`source`/`out`
25/// annotation `registry` doesn't resolve, or a custom call without metadata falls back to
26/// [`cel_parser::Ty::Any`] and are silently skipped, not reported. CEL array annotations inside
27/// expressions use the same registry-backed CEL resolver, so unknown leaf names such as
28/// `[]: [Missing]` are still reported. Known custom annotations retain their complete leaf
29/// descriptors. Constructors and members use `lookup`'s schemas/signatures, not value names;
30/// unknown constructors and incompatible known fields/operands produce diagnostics.
31///
32/// - Complexity: proportional to checking every expression with
33///   [`check_expr_with_type_resolver`], plus O(n) sheet traversal and registry snapshot costs.
34///
35/// # Examples
36///
37/// ```rust
38/// use adam_lang::{AdamAstParser, TypeRegistry, check_sheet};
39///
40/// let sheet = AdamAstParser::new()
41///     .parse_str("sheet s { cell x: i32 = 1.0; }")
42///     .unwrap();
43/// let diagnostics = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
44/// assert_eq!(diagnostics.len(), 1, "1.0 defaults to f64, mismatching the i32 annotation");
45/// ```
46///
47/// ```rust
48/// use adam_lang::{AdamAstParser, TypeRegistry, check_sheet};
49///
50/// let sheet = AdamAstParser::new()
51///     .parse_str("sheet s { out values := [0, 1]: [f64]; }")
52///     .unwrap();
53/// let diagnostics = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
54/// assert_eq!(diagnostics.len(), 1);
55/// assert_eq!(
56///     diagnostics[0].message(),
57///     "array elements must match the annotation exactly: expected `f64`, found `i32`"
58/// );
59/// ```
60///
61/// ```rust
62/// use adam_lang::{AdamAstParser, TypeRegistry, check_sheet};
63///
64/// let sheet = AdamAstParser::new()
65///     .parse_str("sheet s { out values := []: [Missing]; }")
66///     .unwrap();
67/// let diagnostics = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
68/// assert_eq!(diagnostics.len(), 1);
69/// assert_eq!(diagnostics[0].message(), "unknown type `Missing`");
70/// ```
71pub fn check_sheet(
72    sheet: &Sheet,
73    registry: &TypeRegistry,
74    lookup: &cel_parser::OpLookup,
75) -> Vec<ParseError> {
76    let mut diagnostics = Vec::new();
77    let declared = declared_cell_types(sheet, registry, lookup);
78    let shapes = &declared.shapes;
79    let resolve = |name: &str| -> Ty { declared.values.get(name).cloned().unwrap_or(Ty::Any) };
80    let type_resolver = registry.cel_type_resolver();
81    for item in &sheet.items {
82        match item {
83            SheetItem::Cell(cell) => {
84                check_cell_initializer(
85                    cell.type_name.as_ref(),
86                    cell.initializer.as_ref(),
87                    registry,
88                    &type_resolver,
89                    lookup,
90                    &mut diagnostics,
91                );
92                check_filter(
93                    &cell.name,
94                    cell.filter.as_ref(),
95                    registry,
96                    &resolve,
97                    &type_resolver,
98                    lookup,
99                    &mut diagnostics,
100                );
101                check_requirements(
102                    cell.require.as_ref(),
103                    &resolve,
104                    &type_resolver,
105                    lookup,
106                    &mut diagnostics,
107                );
108            }
109            SheetItem::Source(source) => {
110                check_cell_initializer(
111                    source.type_name.as_ref(),
112                    source.initializer.as_ref(),
113                    registry,
114                    &type_resolver,
115                    lookup,
116                    &mut diagnostics,
117                );
118                check_filter(
119                    &source.name,
120                    source.filter.as_ref(),
121                    registry,
122                    &resolve,
123                    &type_resolver,
124                    lookup,
125                    &mut diagnostics,
126                );
127                check_requirements(
128                    source.require.as_ref(),
129                    &resolve,
130                    &type_resolver,
131                    lookup,
132                    &mut diagnostics,
133                );
134            }
135            SheetItem::Relationship(rel) => {
136                for binding in &rel.bindings {
137                    check_binding(
138                        binding,
139                        registry,
140                        shapes,
141                        &resolve,
142                        &type_resolver,
143                        lookup,
144                        &mut diagnostics,
145                    );
146                }
147            }
148            SheetItem::Conditional(cond) => {
149                diagnostics.extend(
150                    check_expr_with_type_resolver(
151                        &cond.match_expr,
152                        &resolve,
153                        &type_resolver,
154                        lookup,
155                    )
156                    .1,
157                );
158                for branch in &cond.branches {
159                    for rel in &branch.relationships {
160                        for binding in &rel.bindings {
161                            check_binding(
162                                binding,
163                                registry,
164                                shapes,
165                                &resolve,
166                                &type_resolver,
167                                lookup,
168                                &mut diagnostics,
169                            );
170                        }
171                    }
172                }
173                if let Some(default) = &cond.default {
174                    for rel in &default.relationships {
175                        for binding in &rel.bindings {
176                            check_binding(
177                                binding,
178                                registry,
179                                shapes,
180                                &resolve,
181                                &type_resolver,
182                                lookup,
183                                &mut diagnostics,
184                            );
185                        }
186                    }
187                }
188            }
189            SheetItem::Out(out_decl) => check_out(
190                out_decl,
191                registry,
192                &declared,
193                &resolve,
194                &type_resolver,
195                lookup,
196                &mut diagnostics,
197            ),
198            SheetItem::Error { .. } => {} // already reported as a syntax error; nothing to type-check
199        }
200    }
201    diagnostics
202}
203
204/// Converts a recursive [`TypeShape`] to the CEL expression checker's complete static type.
205///
206/// - Complexity: O(n + l) for n recursive nodes and l total registered leaf-name bytes.
207fn shape_to_ty(shape: &TypeShape, registry: &TypeRegistry) -> Ty {
208    match shape {
209        TypeShape::Named(type_id) => registry
210            .resolved_leaf_type(*type_id)
211            .map(|leaf| Ty::from_leaf(&leaf))
212            .unwrap_or(Ty::Any),
213        TypeShape::Tuple(elements) => Ty::Tuple(
214            elements
215                .iter()
216                .map(|element| shape_to_ty(element, registry))
217                .collect(),
218        ),
219        TypeShape::Array(element) => Ty::Array(Box::new(shape_to_ty(element, registry))),
220        TypeShape::Callable(parameters, result) => Ty::Callable(
221            parameters
222                .iter()
223                .map(|p| shape_to_ty(p, registry))
224                .collect(),
225            Box::new(shape_to_ty(result, registry)),
226        ),
227    }
228}
229
230/// Returns whether `lit` matches `declared` exactly, preserving adam-lang's no-coercion literal
231/// initializer rules.
232fn literal_matches_declared_ty(lit: &Literal, declared: &Ty) -> bool {
233    Ty::from_literal(lit).unifies_with(declared)
234}
235
236/// Keeps scalar approximations and exact annotation shapes for the same sheet declarations.
237///
238/// `values` includes all cells, sources, and outputs; `shapes` includes only resolved annotations.
239struct DeclaredTypes {
240    /// Inferred or annotated value types, using `Any` where unresolved.
241    values: std::collections::HashMap<String, Ty>,
242    /// Resolved recursive annotation shapes, including tuple structure.
243    shapes: std::collections::HashMap<String, TypeShape>,
244}
245
246/// Maps every declared cell name — from a `cell`, a `source`, or an `out` — to both its recursive
247/// `Ty` (for use as the identifier resolver method/condition bodies
248/// are checked against) and its full recursive `TypeShape` (for `expr_matches_shape`'s tuple-aware
249/// checks). A `source` is resolved exactly like a `cell` (same shape) so its name is referenceable
250/// by other cells'/sources'/outs' expressions exactly like a `cell`'s.
251/// A `cell`/`source`/`out` with no annotation, or one naming a type `registry` doesn't resolve, is
252/// absent from the `TypeShape` map and maps to `Ty::Any` in the `Ty` map. An `out` with an
253/// annotation resolves the same way as a `cell`; one without is
254/// inferred from its initializer body's checked type, using only `cell`-declared types as context (not
255/// other `out`s' inferred types — see this function's own note above), and is never present in
256/// the `TypeShape` map (only annotated cells/outs are).
257///
258/// - Complexity: O(n) sheet traversal plus annotation resolution and output-body checking.
259fn declared_cell_types(
260    sheet: &Sheet,
261    registry: &TypeRegistry,
262    lookup: &cel_parser::OpLookup,
263) -> DeclaredTypes {
264    /// Resolves `type_expr` against `registry`, if present and recognized; `None` covers both an
265    /// absent annotation and one naming a type `registry` doesn't recognize — the two cases
266    /// `declared_cell_types`'s callers already treat identically (fall back to `Ty::Any`, or, for
267    /// an `out`, infer from its writer body).
268    fn resolve_annotation_shape(
269        type_expr: Option<&crate::ast::TypeExpr>,
270        registry: &TypeRegistry,
271    ) -> Option<TypeShape> {
272        type_expr.and_then(|type_expr| registry.resolve(type_expr).ok())
273    }
274
275    let mut map = std::collections::HashMap::new();
276    let mut shapes = std::collections::HashMap::new();
277    for item in &sheet.items {
278        match item {
279            SheetItem::Cell(cell) => {
280                let shape = resolve_annotation_shape(cell.type_name.as_ref(), registry);
281                let ty = shape
282                    .as_ref()
283                    .map(|shape| shape_to_ty(shape, registry))
284                    .unwrap_or(Ty::Any);
285                if let Some(shape) = shape {
286                    shapes.insert(cell.name.clone(), shape);
287                }
288                map.insert(cell.name.clone(), ty);
289            }
290            SheetItem::Source(source) => {
291                let shape = resolve_annotation_shape(source.type_name.as_ref(), registry);
292                let ty = shape
293                    .as_ref()
294                    .map(|shape| shape_to_ty(shape, registry))
295                    .unwrap_or(Ty::Any);
296                if let Some(shape) = shape {
297                    shapes.insert(source.name.clone(), shape);
298                }
299                map.insert(source.name.clone(), ty);
300            }
301            _ => {}
302        }
303    }
304    let resolve_cells = |name: &str| -> Ty { map.get(name).cloned().unwrap_or(Ty::Any) };
305    let type_resolver = registry.cel_type_resolver();
306    let mut out_types = std::collections::HashMap::new();
307    for item in &sheet.items {
308        if let SheetItem::Out(out_decl) = item {
309            let shape = resolve_annotation_shape(out_decl.type_name.as_ref(), registry);
310            let ty = shape
311                .as_ref()
312                .map(|shape| shape_to_ty(shape, registry))
313                .unwrap_or_else(|| {
314                    check_expr_with_type_resolver(
315                        &out_decl.initializer,
316                        &resolve_cells,
317                        &type_resolver,
318                        lookup,
319                    )
320                    .0
321                });
322            if let Some(shape) = shape {
323                shapes.insert(out_decl.name.clone(), shape);
324            }
325            out_types.insert(out_decl.name.clone(), ty);
326        }
327    }
328    map.extend(out_types);
329    DeclaredTypes {
330        values: map,
331        shapes,
332    }
333}
334
335/// Checks whether `expr` structurally matches `shape`, recursively: a `TypeShape::Named` leaf
336/// must be a non-tuple `Expr` whose checked `Ty` unifies with that leaf (mirroring
337/// adam-lang's exact-type initializer matching, generalized past bare literals now that
338/// initializers are full `expression`s); a `TypeShape::Tuple` must be an `Expr::Tuple` of matching arity,
339/// checked element-wise, or an `Expr::If` whose `then_branch` (and `else_branch`, if present —
340/// itself possibly another `Expr::If`, covering `else if` chains) each recursively match the same
341/// `shape`, since every branch that can be taken must produce a value of that shape. An `if` with
342/// no `else` is checked only against `then_branch`, matching `check_expr`'s existing leniency
343/// toward a missing else. `TypeShape::Named(TypeId)` with no registered entry (an unrecognized
344/// custom type) always matches — not statically checked, mirroring `Ty::Any`'s existing
345/// leniency.
346///
347/// - Complexity: O(n) in the number of (nested) tuple elements and `if`/`else if` branches.
348fn expr_matches_shape(
349    expr: &Expr,
350    shape: &TypeShape,
351    registry: &TypeRegistry,
352    resolve: &impl Fn(&str) -> Ty,
353    resolve_type: &impl cel_parser::TypeResolver,
354    lookup: &cel_parser::OpLookup,
355    diagnostics: &mut Vec<ParseError>,
356) {
357    match (expr, shape) {
358        (Expr::Tuple { elements, .. }, TypeShape::Tuple(expected)) => {
359            if elements.len() != expected.len() {
360                diagnostics
361                    .extend(check_expr_with_type_resolver(expr, resolve, resolve_type, lookup).1);
362                diagnostics.push(ParseError::new_range(
363                    format!(
364                        "expected a {}-element tuple `{}`, got {}",
365                        expected.len(),
366                        registry.display_name(shape),
367                        elements.len()
368                    ),
369                    expr.span().start,
370                    expr.span().end,
371                ));
372                return;
373            }
374            for (element, element_shape) in elements.iter().zip(expected) {
375                expr_matches_shape(
376                    element,
377                    element_shape,
378                    registry,
379                    resolve,
380                    resolve_type,
381                    lookup,
382                    diagnostics,
383                );
384            }
385        }
386        (
387            Expr::If {
388                cond,
389                then_branch,
390                else_branch,
391                ..
392            },
393            TypeShape::Tuple(_),
394        ) => {
395            let (_, cond_diags) =
396                check_expr_with_type_resolver(cond, resolve, resolve_type, lookup);
397            diagnostics.extend(cond_diags);
398            expr_matches_shape(
399                then_branch,
400                shape,
401                registry,
402                resolve,
403                resolve_type,
404                lookup,
405                diagnostics,
406            );
407            if let Some(else_branch) = else_branch {
408                expr_matches_shape(
409                    else_branch,
410                    shape,
411                    registry,
412                    resolve,
413                    resolve_type,
414                    lookup,
415                    diagnostics,
416                );
417            }
418        }
419        (_, TypeShape::Tuple(_)) => {
420            let (actual, nested) =
421                check_expr_with_type_resolver(expr, resolve, resolve_type, lookup);
422            if !nested.is_empty() {
423                diagnostics.extend(nested);
424                return;
425            }
426            if actual != Ty::Any && shape_to_ty(shape, registry).unifies_with(&actual) {
427                return;
428            }
429            diagnostics.push(ParseError::new_range(
430                format!("expected tuple `{}`", registry.display_name(shape)),
431                expr.span().start,
432                expr.span().end,
433            ));
434        }
435        (Expr::Tuple { .. }, TypeShape::Named(_)) => {
436            diagnostics
437                .extend(check_expr_with_type_resolver(expr, resolve, resolve_type, lookup).1);
438            diagnostics.push(ParseError::new_range(
439                format!("expected `{}`, got a tuple", registry.display_name(shape)),
440                expr.span().start,
441                expr.span().end,
442            ));
443        }
444        (_, TypeShape::Named(_) | TypeShape::Array(_) | TypeShape::Callable(_, _)) => {
445            let declared = shape_to_ty(shape, registry);
446            let (actual, body_diags) =
447                check_expr_with_type_resolver(expr, resolve, resolve_type, lookup);
448            let valid = body_diags.is_empty();
449            diagnostics.extend(body_diags);
450            if valid && !declared.unifies_with(&actual) {
451                diagnostics.push(ParseError::new_range(
452                    format!(
453                        "expression produces `{}`, but `{}` was expected",
454                        actual.name(),
455                        declared.name()
456                    ),
457                    expr.span().start,
458                    expr.span().end,
459                ));
460            }
461        }
462    }
463}
464
465/// Checks one `cell`'s or `source`'s initializer against its `: type_expr` annotation. A no-op if
466/// the initializer is absent. Every initializer is recursively checked, including when its
467/// annotation is absent or unresolved. Known annotations check the initializer's inferred
468/// type exactly; tuple annotations check recursively element-wise. An invalid body produces
469/// its own diagnostics without an additional annotation mismatch.
470///
471/// Takes `type_name`/`initializer` directly (rather than a whole `&CellDecl`) so both
472/// `SheetItem::Cell` and `SheetItem::Source` — which share this same shape but aren't the same
473/// Rust type — can share one check.
474///
475/// - Complexity: the cost of recursively checking the initializer and resolving its annotation.
476fn check_cell_initializer(
477    type_name: Option<&TypeExpr>,
478    initializer: Option<&Expr>,
479    registry: &TypeRegistry,
480    resolve_type: &impl cel_parser::TypeResolver,
481    lookup: &cel_parser::OpLookup,
482    diagnostics: &mut Vec<ParseError>,
483) {
484    let Some(expr) = initializer else {
485        return;
486    };
487    let resolve = |_: &str| Ty::Any;
488    let Some(shape) = type_name.and_then(|type_expr| registry.resolve(type_expr).ok()) else {
489        diagnostics.extend(check_expr_with_type_resolver(expr, &resolve, resolve_type, lookup).1);
490        return;
491    };
492    if let TypeShape::Tuple(_) = shape {
493        expr_matches_shape(
494            expr,
495            &shape,
496            registry,
497            &resolve,
498            resolve_type,
499            lookup,
500            diagnostics,
501        );
502        return;
503    }
504    if let Expr::Literal {
505        value: literal,
506        span: lit_span,
507    } = expr
508    {
509        let declared = shape_to_ty(&shape, registry);
510        if !literal_matches_declared_ty(literal, &declared) {
511            diagnostics.push(ParseError::new_range(
512                format!("literal cannot be used as type `{}`", declared.name()),
513                lit_span.start,
514                lit_span.end,
515            ));
516        }
517        return;
518    }
519    expr_matches_shape(
520        expr,
521        &shape,
522        registry,
523        &resolve,
524        resolve_type,
525        lookup,
526        diagnostics,
527    );
528}
529
530/// Returns whether `expr` contains a reference to the identifier `name` anywhere in its tree.
531/// Used by `check_filter` to check whether a filter's body references `_` — deliberately a plain
532/// structural walk, not built on `check_expr`'s identifier resolution, so checking for `_`'s
533/// presence never runs type-checking a second time over any part of `expr`.
534///
535/// - Complexity: O(n) in the number of sub-expressions in `expr`.
536fn expr_references_ident(expr: &Expr, name: &str) -> bool {
537    match expr {
538        Expr::Literal { .. } => false,
539        Expr::Ident { name: ident, .. } => ident == name,
540        Expr::Struct { fields, .. } => fields
541            .iter()
542            .any(|field| expr_references_ident(&field.value, name)),
543        Expr::Op { operands, .. } => operands.iter().any(|e| expr_references_ident(e, name)),
544        Expr::Apply { callee, args, .. } => {
545            expr_references_ident(callee, name)
546                || args.iter().any(|e| expr_references_ident(e, name))
547        }
548        Expr::Member { receiver, args, .. } => {
549            expr_references_ident(receiver, name)
550                || args
551                    .iter()
552                    .flatten()
553                    .any(|e| expr_references_ident(e, name))
554        }
555        Expr::Tuple { elements, .. } => elements.iter().any(|e| expr_references_ident(e, name)),
556        Expr::Array { elements, .. } => elements.iter().any(|e| expr_references_ident(e, name)),
557        Expr::TupleIndex { base, .. } => expr_references_ident(base, name),
558        Expr::If {
559            cond,
560            then_branch,
561            else_branch,
562            ..
563        } => {
564            expr_references_ident(cond, name)
565                || expr_references_ident(then_branch, name)
566                || else_branch
567                    .as_deref()
568                    .is_some_and(|e| expr_references_ident(e, name))
569        }
570        Expr::Logical { lhs, rhs, .. } => {
571            expr_references_ident(lhs, name) || expr_references_ident(rhs, name)
572        }
573        Expr::Cast { expr, .. } => expr_references_ident(expr, name),
574        Expr::Closure { params, body, .. } => {
575            !params.iter().any(|param| param.name == name) && expr_references_ident(body, name)
576        }
577    }
578}
579
580/// Checks one filtered cell's `filter` clause, if present: the body's inferred type must
581/// unify with this cell's own declared/inferred
582/// shape (`_`'s type, via `body_resolve`'s special case below), and the body must reference `_`
583/// — the value being filtered — at least once. A registered inclusive range result instead
584/// clamps the candidate: its endpoint type must match the cell and it need not reference `_`,
585/// matching executable filter dispatch regardless of the expression's syntax.
586/// Every other identifier is resolved exactly as any
587/// other deduced expression in this file (a `relationship` binding, an `out` initializer): via
588/// `resolve`, which leaves an unrecognized name as `Ty::Any` rather than raising a diagnostic —
589/// the runtime `Sheet`-building parser (`adam_lang::parser::AdamParser::parse_cell_filter`) is
590/// what raises "undeclared cell" for a name that isn't actually a declared cell, mirroring how it
591/// (not this file) is the one that raises that error for bindings' deduced expressions too.
592///
593/// Takes `name`/`filter` directly (rather than a whole `&CellDecl`) so a `cell`'s
594/// [`SheetItem::Cell`] arm, a `source`'s [`SheetItem::Source`] arm, and an `out`'s [`check_out`]
595/// can all share this one check — `filter` is `Option<&CellFilter>` (not an owned/whole-decl
596/// reference) since `OutDecl`, `CellDecl`, and `SourceDecl` each declare their own `filter:
597/// Option<CellFilter>` field independently.
598///
599/// - Complexity: body type checking plus O(n) reference traversal over the filter expression.
600fn check_filter(
601    name: &str,
602    filter: Option<&CellFilter>,
603    registry: &TypeRegistry,
604    resolve: &impl Fn(&str) -> Ty,
605    resolve_type: &impl cel_parser::TypeResolver,
606    lookup: &cel_parser::OpLookup,
607    diagnostics: &mut Vec<ParseError>,
608) {
609    let Some(filter) = filter else {
610        return;
611    };
612
613    let own_ty = resolve(name);
614    let body_resolve = |ident: &str| -> Ty {
615        if ident == "_" {
616            own_ty.clone()
617        } else {
618            resolve(ident)
619        }
620    };
621
622    let (body_ty, body_diags) =
623        check_expr_with_type_resolver(&filter.body, &body_resolve, resolve_type, lookup);
624    diagnostics.extend(body_diags);
625    let range = body_ty.type_id().and_then(|id| registry.range_entry(id));
626    let mismatch = if let Some(range) = range {
627        own_ty != Ty::Any && own_ty.type_id() != Some(range.element_type_id)
628    } else {
629        !own_ty.unifies_with(&body_ty)
630    };
631    if mismatch {
632        let requirement = if range.is_some() {
633            "range bounds must be"
634        } else {
635            "must produce"
636        };
637        diagnostics.push(ParseError::new_range(
638            format!("cell `{name}`: filter {requirement} `{}`", own_ty.name()),
639            filter.body.span().start,
640            filter.body.span().end,
641        ));
642    }
643
644    if range.is_none() && !expr_references_ident(&filter.body, "_") {
645        diagnostics.push(ParseError::new_range(
646            "filter must reference `_` (the value being filtered)".to_string(),
647            filter.span.start,
648            filter.span.end,
649        ));
650    }
651}
652
653/// Checks `body`'s multi-output shape against `outputs`, recursively: an `Expr::Tuple` of matching
654/// arity is checked element-wise, each element against its corresponding output cell's declared
655/// type; an `Expr::If` recurses into `then_branch` (and `else_branch`, if present — itself possibly
656/// another `Expr::If`, covering `else if` chains) against the same `outputs`, since every branch
657/// that can be taken must produce a value of that shape (mirroring [`expr_matches_shape`]'s
658/// `Expr::If` handling); a known scalar or wrong-size literal tuple is a diagnostic.
659/// An `if` with no `else` is checked
660/// only against `then_branch`, matching `check_expr`'s existing leniency toward a missing else.
661/// Unresolved nonliteral bodies retain child diagnostics but defer tuple arity to runtime.
662///
663/// - Complexity: O(n) in the number of declared outputs, times the number of (nested) `if`/`else
664///   if` branches.
665fn check_tuple_output_body(
666    body: &Expr,
667    outputs: &[(String, ExprSpan)],
668    resolve: &impl Fn(&str) -> Ty,
669    resolve_type: &impl cel_parser::TypeResolver,
670    lookup: &cel_parser::OpLookup,
671    diagnostics: &mut Vec<ParseError>,
672) {
673    match body {
674        Expr::Tuple { elements, .. } if elements.len() == outputs.len() => {
675            for (element, (name, _)) in elements.iter().zip(outputs) {
676                let (element_ty, element_diags) =
677                    check_expr_with_type_resolver(element, resolve, resolve_type, lookup);
678                diagnostics.extend(element_diags);
679                let declared = resolve(name);
680                if !declared.unifies_with(&element_ty) {
681                    diagnostics.push(ParseError::new_range(
682                        format!(
683                            "binding output `{name}` produces `{}`, but is declared `{}`",
684                            element_ty.name(),
685                            declared.name()
686                        ),
687                        element.span().start,
688                        element.span().end,
689                    ));
690                }
691            }
692        }
693        Expr::If {
694            cond,
695            then_branch,
696            else_branch,
697            ..
698        } => {
699            let (_, cond_diags) =
700                check_expr_with_type_resolver(cond, resolve, resolve_type, lookup);
701            diagnostics.extend(cond_diags);
702            check_tuple_output_body(
703                then_branch,
704                outputs,
705                resolve,
706                resolve_type,
707                lookup,
708                diagnostics,
709            );
710            if let Some(else_branch) = else_branch {
711                check_tuple_output_body(
712                    else_branch,
713                    outputs,
714                    resolve,
715                    resolve_type,
716                    lookup,
717                    diagnostics,
718                );
719            }
720        }
721        other => {
722            let (body_ty, body_diags) =
723                check_expr_with_type_resolver(other, resolve, resolve_type, lookup);
724            let valid = body_diags.is_empty();
725            diagnostics.extend(body_diags);
726            if !valid {
727                return;
728            }
729            if let Ty::Tuple(elements) = &body_ty
730                && elements.len() == outputs.len()
731            {
732                for (element_ty, (name, _)) in elements.iter().zip(outputs) {
733                    let declared = resolve(name);
734                    if !declared.unifies_with(element_ty) {
735                        diagnostics.push(ParseError::new_range(
736                            format!(
737                                "binding output `{name}` produces `{}`, but is declared `{}`",
738                                element_ty.name(),
739                                declared.name()
740                            ),
741                            other.span().start,
742                            other.span().end,
743                        ));
744                    }
745                }
746                return;
747            }
748            if body_ty == Ty::Any && !matches!(other, Expr::Tuple { .. }) {
749                return;
750            }
751            let n = outputs.len();
752            diagnostics.push(ParseError::new_range(
753                format!("binding declares {n} outputs but its body is not a {n}-tuple"),
754                other.span().start,
755                other.span().end,
756            ));
757        }
758    }
759}
760
761/// Checks one `binding`'s body against its declared outputs, dispatching on
762/// [`BindingDecl::destructure`]: a destructuring binding (`(a, b) := ...` or the single-element
763/// `(a,) := ...`) is checked recursively via [`check_tuple_output_body`] against each output
764/// cell; a direct-bind single output's inferred type must instead unify with that output cell's
765/// declared type (or, when that output's declared type is itself a tuple — per `shapes` — the
766/// body is checked recursively via [`expr_matches_shape`] instead). Operator-level diagnostics
767/// from inside the body (via [`check_expr`]) are always included exactly once, regardless of
768/// which branch below runs.
769///
770/// - Complexity: body type checking plus recursive output-shape validation.
771fn check_binding(
772    binding: &BindingDecl,
773    registry: &TypeRegistry,
774    shapes: &std::collections::HashMap<String, TypeShape>,
775    resolve: &impl Fn(&str) -> Ty,
776    resolve_type: &impl cel_parser::TypeResolver,
777    lookup: &cel_parser::OpLookup,
778    diagnostics: &mut Vec<ParseError>,
779) {
780    if binding.destructure {
781        check_tuple_output_body(
782            &binding.body,
783            &binding.outputs,
784            resolve,
785            resolve_type,
786            lookup,
787            diagnostics,
788        );
789        return;
790    }
791    let Some((name, _)) = binding.outputs.first() else {
792        let (_, body_diags) =
793            check_expr_with_type_resolver(&binding.body, resolve, resolve_type, lookup);
794        diagnostics.extend(body_diags);
795        return;
796    };
797    if let Some(shape @ TypeShape::Tuple(_)) = shapes.get(name) {
798        expr_matches_shape(
799            &binding.body,
800            shape,
801            registry,
802            resolve,
803            resolve_type,
804            lookup,
805            diagnostics,
806        );
807        return;
808    }
809    let (body_ty, body_diags) =
810        check_expr_with_type_resolver(&binding.body, resolve, resolve_type, lookup);
811    diagnostics.extend(body_diags);
812    if let Expr::Tuple { elements, .. } = &binding.body {
813        let n = elements.len();
814        diagnostics.push(ParseError::new_range(
815            format!("binding declares 1 output but its body is a {n}-tuple"),
816            binding.body.span().start,
817            binding.body.span().end,
818        ));
819        return;
820    }
821    let declared = resolve(name);
822    if !declared.unifies_with(&body_ty) {
823        diagnostics.push(ParseError::new_range(
824            format!(
825                "binding body produces `{}`, but `{name}` is declared `{}`",
826                body_ty.name(),
827                declared.name()
828            ),
829            binding.body.span().start,
830            binding.body.span().end,
831        ));
832    }
833}
834
835/// Checks one `out`'s initializer body against its optional `: type_expr` annotation — mirroring
836/// `check_binding`'s single-output branch, since an out's initializer is structurally a binding
837/// with one implicit output (the out cell itself), including the same tuple-shaped dispatch to
838/// [`expr_matches_shape`] via `shapes` — its optional `filter` clause, via the same [`check_filter`]
839/// shared with a `cell`'s [`SheetItem::Cell`] arm and a `source`'s [`SheetItem::Source`] arm — and,
840/// if a `require { ... }` block is present, each of its requirements' bodies against `Ty::Bool`.
841/// Operator-level diagnostics from inside any body (via `check_expr`) are always included,
842/// regardless of whether a mismatch diagnostic is also added.
843///
844/// - Complexity: body, filter, and requirement checking plus annotation-shape validation.
845fn check_out(
846    out_decl: &OutDecl,
847    registry: &TypeRegistry,
848    declared: &DeclaredTypes,
849    resolve: &impl Fn(&str) -> Ty,
850    resolve_type: &impl cel_parser::TypeResolver,
851    lookup: &cel_parser::OpLookup,
852    diagnostics: &mut Vec<ParseError>,
853) {
854    if let Some(shape @ TypeShape::Tuple(_)) = declared.shapes.get(&out_decl.name) {
855        expr_matches_shape(
856            &out_decl.initializer,
857            shape,
858            registry,
859            resolve,
860            resolve_type,
861            lookup,
862            diagnostics,
863        );
864    } else {
865        let (body_ty, body_diags) =
866            check_expr_with_type_resolver(&out_decl.initializer, resolve, resolve_type, lookup);
867        diagnostics.extend(body_diags);
868        if out_decl.type_name.is_some() {
869            let declared = resolve(&out_decl.name);
870            if !declared.unifies_with(&body_ty) {
871                diagnostics.push(ParseError::new_range(
872                    format!(
873                        "out `{}` body produces `{}`, but is declared `{}`",
874                        out_decl.name,
875                        body_ty.name(),
876                        declared.name()
877                    ),
878                    out_decl.initializer.span().start,
879                    out_decl.initializer.span().end,
880                ));
881            }
882        }
883    }
884    check_filter(
885        &out_decl.name,
886        out_decl.filter.as_ref(),
887        registry,
888        resolve,
889        resolve_type,
890        lookup,
891        diagnostics,
892    );
893    check_requirements(
894        out_decl.require.as_ref(),
895        resolve,
896        resolve_type,
897        lookup,
898        diagnostics,
899    );
900}
901
902/// Checks every requirement in `require`'s body against `resolve`, appending a diagnostic for
903/// each one that doesn't type-check as `bool`. A no-op if `require` is absent (a plain `cell`,
904/// `source`, or `out` with no `require { ... }` block). Shared by [`check_out`] and by the
905/// `SheetItem::Cell`/`SheetItem::Source` arms of [`check_sheet`]'s match, since `require` has the
906/// same shape and rules regardless of which cell kind it's attached to.
907///
908/// - Complexity: the sum of type-checking costs for every requirement body.
909fn check_requirements(
910    require: Option<&RequireBlock>,
911    resolve: &impl Fn(&str) -> Ty,
912    resolve_type: &impl cel_parser::TypeResolver,
913    lookup: &cel_parser::OpLookup,
914    diagnostics: &mut Vec<ParseError>,
915) {
916    let Some(require) = require else {
917        return;
918    };
919    for requirement in &require.requirements {
920        let (req_ty, req_diags) =
921            check_expr_with_type_resolver(&requirement.body, resolve, resolve_type, lookup);
922        diagnostics.extend(req_diags);
923        if !req_ty.unifies_with(&Ty::Bool) {
924            diagnostics.push(ParseError::new_range(
925                match &requirement.name {
926                    Some(name) => format!(
927                        "requirement `{name}` produces `{}`, but requirements must be `bool`",
928                        req_ty.name()
929                    ),
930                    None => format!(
931                        "requirement produces `{}`, but requirements must be `bool`",
932                        req_ty.name()
933                    ),
934                },
935                requirement.body.span().start,
936                requirement.body.span().end,
937            ));
938        }
939    }
940}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945    use crate::AdamAstParser;
946
947    fn parse(source: &str) -> Sheet {
948        AdamAstParser::new().parse_str(source).unwrap()
949    }
950
951    #[derive(Clone, Debug, PartialEq)]
952    struct Custom(i32);
953
954    #[test]
955    fn cell_initializer_matching_its_annotation_has_no_diagnostic() {
956        let sheet = parse("sheet s { cell x: i32 = 1; }");
957        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
958        assert!(diags.is_empty());
959    }
960
961    #[test]
962    fn cell_initializer_mismatched_with_its_annotation_is_a_diagnostic() {
963        // Unsuffixed float literal defaults to f64, not i32.
964        let sheet = parse("sheet s { cell x: i32 = 1.0; }");
965        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
966        assert_eq!(diags.len(), 1);
967    }
968
969    #[test]
970    fn source_initializer_mismatched_with_its_annotation_is_a_diagnostic() {
971        // Unsuffixed float literal defaults to f64, not i32.
972        let sheet = parse("sheet s { source x: i32 = 1.0; }");
973        let diagnostics = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
974        assert_eq!(diagnostics.len(), 1);
975    }
976
977    #[test]
978    fn custom_array_annotations_use_the_registry_type_resolver() {
979        let sheet = parse(
980            "sheet s { \
981                out values := []: [Custom]; \
982                out nested := [[]: [Custom]]: [[Custom]]; \
983            }",
984        );
985        let mut registry = TypeRegistry::new();
986        registry.register_no_default::<Custom>("Custom");
987
988        let diagnostics = check_sheet(&sheet, &registry, &cel_parser::OpLookup::new());
989        assert!(diagnostics.is_empty(), "{diagnostics:?}");
990    }
991
992    #[test]
993    fn custom_array_annotations_reject_exact_scalar_type_mismatches() {
994        let sheet = parse("sheet s { out values := [1]: [Custom]; }");
995        let mut registry = TypeRegistry::new();
996        registry.register_no_default::<Custom>("Custom");
997
998        let diagnostics = check_sheet(&sheet, &registry, &cel_parser::OpLookup::new());
999        assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
1000        assert!(
1001            diagnostics[0]
1002                .message()
1003                .contains("expected `Custom`, found `i32`"),
1004            "got: {}",
1005            diagnostics[0].message()
1006        );
1007    }
1008
1009    #[test]
1010    fn unknown_custom_array_annotation_type_is_reported() {
1011        let sheet = parse("sheet s { out values := []: [Missing]; }");
1012        let diagnostics = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1013        assert_eq!(diagnostics.len(), 1);
1014        assert!(
1015            diagnostics[0].message().contains("unknown type `Missing`"),
1016            "got: {}",
1017            diagnostics[0].message()
1018        );
1019    }
1020
1021    #[test]
1022    fn tuple_typed_array_annotations_are_accepted_by_cel_checking() {
1023        let sheet = parse("sheet s { out values := []: [(i32, f64)]; }");
1024        let diagnostics = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1025        assert!(diagnostics.is_empty(), "{diagnostics:?}");
1026    }
1027
1028    #[test]
1029    fn cell_requirement_non_bool_body_is_a_diagnostic() {
1030        let sheet = parse("sheet s { cell x: i32 = 5 require { @positive x; }; }");
1031        let diagnostics = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1032        assert_eq!(diagnostics.len(), 1);
1033    }
1034
1035    #[test]
1036    fn cell_initializer_non_literal_expression_is_checked() {
1037        let sheet = parse("sheet s { cell x: f64 = 1 + 2; }");
1038        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1039        let messages: Vec<&str> = diags.iter().map(cel_parser::ParseError::message).collect();
1040        assert_eq!(
1041            messages,
1042            ["expression produces `i32`, but `f64` was expected"]
1043        );
1044    }
1045
1046    #[test]
1047    fn cell_initializer_array_annotation_diagnostics_still_surface() {
1048        // The initializer's own body diagnostics (here, a CEL typed-array annotation mismatch)
1049        // must still be reported even though the initializer isn't a bare literal.
1050        let sheet = parse("sheet s { cell x: f64 = [0, 1]: [f64]; }");
1051        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1052        let messages: Vec<&str> = diags.iter().map(cel_parser::ParseError::message).collect();
1053        assert_eq!(
1054            messages,
1055            vec!["array elements must match the annotation exactly: expected `f64`, found `i32`"]
1056        );
1057    }
1058
1059    #[test]
1060    fn cell_with_only_an_annotation_has_nothing_to_cross_check() {
1061        let sheet = parse("sheet s { cell x: i32; }");
1062        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1063        assert!(diags.is_empty());
1064    }
1065
1066    #[test]
1067    fn cell_initializer_unsuffixed_int_literal_mismatched_with_a_declared_unsigned_type_is_a_diagnostic()
1068     {
1069        // adam_lang::parser's real cell_decl grammar requires the initializer's inferred type to
1070        // equal the declared type exactly, no int-width coercion. An unsuffixed integer literal
1071        // always infers as `i32`, so it mismatches a `u32` annotation; `cell x: u32 = 1;` is
1072        // rejected by the real parser, and the checker must flag it too.
1073        let sheet = parse("sheet s { cell x: u32 = 1; }");
1074        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1075        assert_eq!(diags.len(), 1);
1076    }
1077
1078    #[test]
1079    fn cell_initializer_unsuffixed_int_literal_mismatched_with_a_declared_float_type_is_a_diagnostic()
1080     {
1081        // Same exact-type rule as above, but across the int/float boundary: an unsuffixed integer
1082        // literal infers as `i32`, never as `f64`, so no int-to-float coercion is allowed either.
1083        let sheet = parse("sheet s { cell x: f64 = 1; }");
1084        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1085        assert_eq!(diags.len(), 1);
1086    }
1087
1088    #[test]
1089    fn cell_initializer_char_literal_against_any_registered_type_is_a_diagnostic() {
1090        // adam-lang's real parser has no rule accepting a char literal against any registered
1091        // type, so the checker must flag it too.
1092        let sheet = parse("sheet s { cell x: i32 = 'a'; }");
1093        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1094        assert_eq!(diags.len(), 1);
1095    }
1096
1097    #[test]
1098    fn cell_annotated_with_an_unregistered_type_name_is_never_flagged() {
1099        let sheet = parse("sheet s { cell x: WidgetHandle = 1; }");
1100        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1101        assert!(diags.is_empty());
1102    }
1103
1104    #[test]
1105    fn binding_single_output_matching_declared_type_has_no_diagnostic() {
1106        let sheet = parse(
1107            "sheet s { cell width: f64; cell height: f64; cell area: f64; \
1108             relationship { area := width * height; } }",
1109        );
1110        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1111        assert!(diags.is_empty());
1112    }
1113
1114    #[test]
1115    fn binding_single_output_mismatched_with_declared_type_is_a_diagnostic() {
1116        let sheet = parse(
1117            "sheet s { cell width: f64; cell height: f64; cell area: i32; \
1118             relationship { area := width * height; } }",
1119        );
1120        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1121        assert_eq!(diags.len(), 1);
1122    }
1123
1124    #[test]
1125    fn binding_multi_output_matching_tuple_has_no_diagnostic() {
1126        let sheet = parse(
1127            "sheet s { cell a: i32; cell b: i32; cell sum: i32; cell diff: i32; \
1128             relationship { (sum, diff) := (a + b, a - b); } }",
1129        );
1130        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1131        assert!(diags.is_empty());
1132    }
1133
1134    /// Complete call-result shapes check arity and leaves without suppressing body errors.
1135    #[test]
1136    fn binding_multi_output_iife_defers_unknown_shape_but_checks_children() {
1137        for (body, expected) in [
1138            ("(|x: i32| (x, x))(1)", 0),
1139            ("(|x: i32| (x, true + x))(1)", 1),
1140            ("(|x: i32| (x, true))(1)", 1),
1141            ("(|x: i32| (x,))(1)", 1),
1142            ("1", 1),
1143            ("(1, 2, 3)", 1),
1144        ] {
1145            let sheet = parse(&format!(
1146                "sheet s {{ cell a: i32; cell b: i32; relationship {{ (a,b) := {body}; }} }}"
1147            ));
1148            let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1149            assert_eq!(diags.len(), expected, "{body}: {diags:?}");
1150        }
1151    }
1152
1153    /// Inferred tuple call results satisfy matching annotations and reject mismatched leaves.
1154    #[test]
1155    fn tuple_call_results_match_declared_shapes() {
1156        for (annotation, expected) in [("(i32, i32)", 0), ("(i32, bool)", 1)] {
1157            let sheet = parse(&format!(
1158                "sheet s {{ cell pair: {annotation} = (|x: i32| (x,x))(1); }}"
1159            ));
1160            let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1161            assert_eq!(diags.len(), expected, "{annotation}: {diags:?}");
1162        }
1163    }
1164
1165    #[test]
1166    fn binding_multi_output_arity_mismatch_is_a_diagnostic() {
1167        let sheet = parse(
1168            "sheet s { cell a: i32; cell b: i32; cell sum: i32; cell diff: i32; \
1169             relationship { (sum, diff) := a + b; } }",
1170        );
1171        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1172        assert_eq!(diags.len(), 1);
1173    }
1174
1175    #[test]
1176    fn binding_single_output_with_a_tuple_shaped_body_is_a_diagnostic() {
1177        // Body is a 2-tuple but only 1 output is declared: `check_expr` would otherwise infer
1178        // `Ty::Any` for the tuple and let this slip through with no diagnostic at all.
1179        let sheet = parse(
1180            "sheet s { cell a: i32; cell b: i32; cell out: i32; \
1181             relationship { out := (a, b); } }",
1182        );
1183        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1184        assert_eq!(diags.len(), 1);
1185    }
1186
1187    #[test]
1188    fn binding_multi_output_per_element_type_mismatch_is_a_diagnostic() {
1189        let sheet = parse(
1190            "sheet s { cell a: i32; cell b: i32; cell sum: i32; cell diff: f64; \
1191             relationship { (sum, diff) := (a + b, a - b); } }",
1192        );
1193        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1194        assert_eq!(diags.len(), 1);
1195    }
1196
1197    #[test]
1198    fn an_operator_error_inside_a_binding_body_surfaces() {
1199        let sheet = parse(
1200            "sheet s { cell name: String; cell count: i32; cell out: i32; \
1201             relationship { out := name + count; } }",
1202        );
1203        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1204        assert_eq!(diags.len(), 1);
1205    }
1206
1207    #[test]
1208    fn conditional_branch_and_default_bindings_are_both_checked() {
1209        let sheet = parse(
1210            "sheet s { cell mode: i32; cell a: i32; cell b: i32; cell out: i32; \
1211             conditional mode { \
1212                 0i32 => { relationship { out := a; } }, \
1213                 _ => { relationship { out := b; } }, \
1214             } }",
1215        );
1216        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1217        assert!(diags.is_empty());
1218    }
1219
1220    #[test]
1221    fn a_cell_with_no_type_annotation_unifies_with_anything_used_in_a_binding() {
1222        // `cell a = 1;` has an initializer but no `: type_name` — declared_cell_types maps it to
1223        // Ty::Any, which must unify silently with `out`'s declared `i32`.
1224        let sheet = parse(
1225            "sheet s { cell a = 1; cell out: i32; \
1226             relationship { out := a; } }",
1227        );
1228        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1229        assert!(diags.is_empty());
1230    }
1231
1232    #[test]
1233    fn recovered_error_items_are_skipped_without_panicking() {
1234        let sheet =
1235            parse("sheet s { cell good: i32 = 1; cell bad unknown_syntax cell after: i32 = 2; }");
1236        assert!(
1237            !sheet.errors.is_empty(),
1238            "fixture must actually recover an error item"
1239        );
1240        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1241        assert!(diags.is_empty());
1242    }
1243
1244    #[test]
1245    fn out_body_matching_its_annotation_has_no_diagnostic() {
1246        let sheet = parse(
1247            "sheet s { cell width: f64; cell height: f64; \
1248             out area: f64 := width * height; }",
1249        );
1250        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1251        assert!(diags.is_empty());
1252    }
1253
1254    #[test]
1255    fn out_body_mismatched_with_its_annotation_is_a_diagnostic() {
1256        let sheet = parse(
1257            "sheet s { cell width: f64; cell height: f64; \
1258             out area: i32 := width * height; }",
1259        );
1260        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1261        assert_eq!(diags.len(), 1);
1262    }
1263
1264    #[test]
1265    fn out_with_no_annotation_infers_its_type_and_has_no_diagnostic() {
1266        // No `: type_name` to cross-check against — nothing to flag, and a later reference to
1267        // `area`'s name (were one added) would resolve through the inferred f64, not Ty::Any.
1268        let sheet = parse(
1269            "sheet s { cell width: f64; cell height: f64; \
1270             out area := width * height; }",
1271        );
1272        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1273        assert!(diags.is_empty());
1274    }
1275
1276    #[test]
1277    fn requirement_with_bool_body_has_no_diagnostic() {
1278        let sheet = parse(
1279            "sheet s { cell width: f64; cell max_width: f64; \
1280             out area: f64 := width require { \
1281                 @max_width width <= max_width; \
1282             }; }",
1283        );
1284        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1285        assert!(diags.is_empty());
1286    }
1287
1288    #[test]
1289    fn requirement_with_non_bool_body_is_a_diagnostic() {
1290        let sheet = parse(
1291            "sheet s { cell width: f64; \
1292             out area: f64 := width require { \
1293                 @bogus width; \
1294             }; }",
1295        );
1296        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1297        assert_eq!(diags.len(), 1);
1298    }
1299
1300    #[test]
1301    fn unlabeled_requirement_with_non_bool_body_is_a_diagnostic() {
1302        let sheet = parse(
1303            "sheet s { cell width: f64; \
1304             out area: f64 := width require { \
1305                 width; \
1306             }; }",
1307        );
1308        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1309        assert_eq!(diags.len(), 1);
1310    }
1311
1312    #[test]
1313    fn cell_tuple_initializer_matching_its_annotation_has_no_diagnostic() {
1314        let sheet = parse("sheet s { cell a: (i32, f64) = (1, 2.5); }");
1315        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1316        assert!(diags.is_empty());
1317    }
1318
1319    #[test]
1320    fn cell_tuple_initializer_arity_mismatch_is_a_diagnostic() {
1321        let sheet = parse("sheet s { cell a: (i32, f64, i32) = (1, 2.5); }");
1322        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1323        assert_eq!(diags.len(), 1);
1324    }
1325
1326    #[test]
1327    fn cell_tuple_initializer_element_type_mismatch_is_a_diagnostic() {
1328        let sheet = parse("sheet s { cell a: (i32, i32) = (1, 2.5); }");
1329        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1330        assert_eq!(diags.len(), 1);
1331    }
1332
1333    #[test]
1334    fn cell_nested_tuple_initializer_matching_its_annotation_has_no_diagnostic() {
1335        let sheet = parse("sheet s { cell a: (i32, (f64, String)) = (1, (2.5, \"x\")); }");
1336        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1337        assert!(diags.is_empty());
1338    }
1339
1340    #[test]
1341    fn filter_with_matching_types_has_no_diagnostic() {
1342        let sheet = parse("sheet s { cell a: i32 = 1 filter _; }");
1343        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1344        assert!(diags.is_empty());
1345    }
1346
1347    #[test]
1348    fn filter_with_matching_types_on_a_source_has_no_diagnostic() {
1349        let sheet = parse("sheet s { source a: i32 = 1 filter _; }");
1350        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1351        assert!(diags.is_empty());
1352    }
1353
1354    #[test]
1355    fn filter_referencing_a_cell_has_no_diagnostic() {
1356        let sheet = parse(
1357            "sheet s { cell hi: i32 = 100; cell a: i32 = 1 filter if _ > hi { hi } else { _ }; }",
1358        );
1359        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1360        assert!(diags.is_empty());
1361    }
1362
1363    #[test]
1364    fn filter_body_type_mismatch_is_a_diagnostic() {
1365        // Body is `bool`-typed (a comparison), but `a` is declared `i32`.
1366        let sheet = parse("sheet s { cell a: i32 = 1 filter _ > 0; }");
1367        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1368        assert_eq!(diags.len(), 1);
1369    }
1370
1371    #[test]
1372    fn filter_without_underscore_is_a_diagnostic() {
1373        let sheet = parse("sheet s { cell a: i32 = 1 filter 1; }");
1374        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1375        assert_eq!(diags.len(), 1);
1376    }
1377
1378    /// A closure parameter hides value references, not labels or type names.
1379    #[test]
1380    fn closure_parameter_shadows_only_value_references() {
1381        for (source, references) in [
1382            ("|_: i32| _", false),
1383            ("|x: i32| _", true),
1384            ("|_: i32| value._", false),
1385            ("|_: i32| _ { x: value }", false),
1386            ("|x: i32| Record { x: _ }.x", true),
1387        ] {
1388            let expr =
1389                cel_parser::Parser::<cel_parser::AstContext>::new(cel_parser::OpLookup::new())
1390                    .parse_str_ast(source)
1391                    .unwrap();
1392            assert_eq!(expr_references_ident(&expr, "_"), references, "{source}");
1393        }
1394    }
1395
1396    /// Verifies record values contribute dependencies, while constructor and field labels do not.
1397    #[test]
1398    fn record_filter_dependencies_only_visit_field_values() {
1399        let sheet = parse("sheet s { cell a: i32 = 1 filter Record { value: _ }; }");
1400        let diagnostics = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1401        assert_eq!(diagnostics.len(), 1);
1402        assert!(diagnostics[0].message().contains("unknown record type"));
1403        for source in [
1404            "sheet s { cell a: i32 = 1 filter Record { _: 1 }; }",
1405            "sheet s { cell a: i32 = 1 filter _ { value: 1 }; }",
1406        ] {
1407            let sheet = parse(source);
1408            let diagnostics =
1409                check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1410            assert_eq!(diagnostics.len(), 2);
1411            assert!(
1412                diagnostics
1413                    .iter()
1414                    .any(|d| d.message().contains("must reference `_`"))
1415            );
1416        }
1417    }
1418
1419    #[test]
1420    fn filter_body_type_mismatch_on_an_out_is_a_diagnostic() {
1421        // Mirrors `filter_body_type_mismatch_is_a_diagnostic`, but for an `out`'s filter clause:
1422        // body is `bool`-typed (a comparison), but `area` is declared `i32`.
1423        let sheet = parse("sheet s { cell width: i32 = 1; out area: i32 := width filter _ > 0; }");
1424        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1425        assert_eq!(diags.len(), 1);
1426    }
1427
1428    #[test]
1429    fn filter_without_underscore_on_an_out_is_a_diagnostic() {
1430        // Mirrors `filter_without_underscore_is_a_diagnostic`, but for an `out`'s filter clause.
1431        let sheet = parse("sheet s { cell width: i32 = 1; out area: i32 := width filter 1; }");
1432        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1433        assert_eq!(diags.len(), 1);
1434    }
1435
1436    #[test]
1437    fn filter_body_type_mismatch_on_a_source_is_a_diagnostic() {
1438        // Mirrors `filter_body_type_mismatch_is_a_diagnostic`, but for a `source`'s filter
1439        // clause: body is `bool`-typed (a comparison), but `a` is declared `i32`.
1440        let sheet = parse("sheet s { source a: i32 = 1 filter _ > 0; }");
1441        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1442        assert_eq!(diags.len(), 1);
1443    }
1444
1445    #[test]
1446    fn filter_without_underscore_on_a_source_is_a_diagnostic() {
1447        // Mirrors `filter_without_underscore_is_a_diagnostic`, but for a `source`'s filter
1448        // clause.
1449        let sheet = parse("sheet s { source a: i32 = 1 filter 1; }");
1450        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1451        assert_eq!(diags.len(), 1);
1452    }
1453
1454    #[test]
1455    fn filter_on_a_tuple_typed_cell_is_supported() {
1456        let sheet = parse("sheet s { cell a: (i32, f64) = (1, 2.5) filter (_.0, _.1); }");
1457        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1458        assert!(diags.is_empty());
1459    }
1460
1461    #[test]
1462    fn filter_references_underscore_nested_inside_a_call_has_no_missing_underscore_diagnostic() {
1463        // `_` appears only inside an `if`'s then-branch, not as the whole body or a bare
1464        // operand — exercises `expr_references_ident`'s `Expr::If` arm specifically.
1465        let sheet = parse("sheet s { cell a: i32 = 1 filter if true { _ } else { 1 }; }");
1466        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1467        assert!(diags.is_empty());
1468    }
1469
1470    /// Verifies filter identifier walks visit receivers and arguments, but not member labels.
1471    #[test]
1472    fn member_filter_references_are_operands_not_labels() {
1473        for body in ["_.accessor", "value.accessor(_)", "_.accessor()"] {
1474            let sheet = parse(&format!("sheet s {{ cell value: i32 = 1 filter {body}; }}"));
1475            assert!(
1476                check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new()).is_empty(),
1477                "{body}"
1478            );
1479        }
1480        let sheet = parse("sheet s { cell value: i32 = 1 filter value._; }");
1481        assert_eq!(
1482            check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new()).len(),
1483            1
1484        );
1485    }
1486
1487    #[test]
1488    fn binding_single_tuple_typed_output_matching_body_has_no_diagnostic() {
1489        let sheet = parse(
1490            "sheet s { cell a: i32; cell b: i32; cell pair: (i32, i32); \
1491             relationship { pair := (a, b); } }",
1492        );
1493        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1494        assert!(diags.is_empty());
1495    }
1496
1497    #[test]
1498    fn binding_single_tuple_typed_output_element_type_mismatch_is_a_diagnostic() {
1499        let sheet = parse(
1500            "sheet s { cell a: i32; cell b: f64; cell pair: (i32, i32); \
1501             relationship { pair := (a, b); } }",
1502        );
1503        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1504        assert_eq!(diags.len(), 1);
1505    }
1506
1507    #[test]
1508    fn binding_single_tuple_typed_output_if_else_body_matching_both_branches_has_no_diagnostic() {
1509        let sheet = parse(
1510            "sheet s { cell cond: bool; cell a: i32; cell b: i32; cell pair: (i32, i32); \
1511             relationship { pair := \
1512                 if cond { (a, b) } else { (b, a) }; \
1513             } }",
1514        );
1515        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1516        assert!(diags.is_empty());
1517    }
1518
1519    #[test]
1520    fn binding_single_tuple_typed_output_if_without_else_matching_then_branch_has_no_diagnostic() {
1521        let sheet = parse(
1522            "sheet s { cell cond: bool; cell a: i32; cell b: i32; cell pair: (i32, i32); \
1523             relationship { pair := \
1524                 if cond { (a, b) }; \
1525             } }",
1526        );
1527        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1528        assert!(diags.is_empty());
1529    }
1530
1531    #[test]
1532    fn binding_single_tuple_typed_output_if_else_if_chain_matching_all_branches_has_no_diagnostic()
1533    {
1534        let sheet = parse(
1535            "sheet s { cell mode: i32; cell a: i32; cell b: i32; cell pair: (i32, i32); \
1536             relationship { pair := \
1537                 if mode == 0 { (a, b) } else if mode == 1 { (b, a) } else { (0, 0) }; \
1538             } }",
1539        );
1540        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1541        assert!(diags.is_empty());
1542    }
1543
1544    #[test]
1545    fn binding_single_tuple_typed_output_if_else_body_element_mismatch_in_each_branch_is_two_diagnostics()
1546     {
1547        // Both branches use the same mismatched `c`, so a correctly recursing checker reports one
1548        // diagnostic per branch (2 total). The old catch-all — which never recursed into an
1549        // `Expr::If` at all — could only ever report exactly 1 diagnostic for the whole node
1550        // regardless of how many branches were wrong, so `diags.len() == 2` distinguishes the fix
1551        // from the bug (unlike asserting `== 1`, which both the buggy and fixed behavior satisfy
1552        // when only one branch mismatches).
1553        let sheet = parse(
1554            "sheet s { cell cond: bool; cell a: i32; cell c: f64; cell pair: (i32, i32); \
1555             relationship { pair := \
1556                 if cond { (a, c) } else { (a, c) }; \
1557             } }",
1558        );
1559        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1560        assert_eq!(diags.len(), 2);
1561    }
1562
1563    #[test]
1564    fn binding_multi_output_if_else_body_matching_both_branches_has_no_diagnostic() {
1565        let sheet = parse(
1566            "sheet s { cell cond: bool; cell a: i32; cell b: i32; cell sum: i32; cell diff: i32; \
1567             relationship { (sum, diff) := \
1568                 if cond { (a + b, a - b) } else { (a - b, a + b) }; \
1569             } }",
1570        );
1571        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1572        assert!(diags.is_empty());
1573    }
1574
1575    #[test]
1576    fn binding_multi_output_if_else_body_element_mismatch_in_a_branch_is_a_diagnostic() {
1577        let sheet = parse(
1578            "sheet s { cell cond: bool; cell a: i32; cell b: i32; cell c: f64; \
1579             cell sum: i32; cell diff: i32; \
1580             relationship { (sum, diff) := \
1581                 if cond { (a, b) } else { (a, c) }; \
1582             } }",
1583        );
1584        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1585        assert_eq!(diags.len(), 1);
1586    }
1587
1588    #[test]
1589    fn binding_single_element_tuple_destructure_has_no_diagnostic() {
1590        let sheet =
1591            parse("sheet s { cell a: i32; cell out: i32; relationship { (out,) := (a,); } }");
1592        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1593        assert!(diags.is_empty());
1594    }
1595
1596    #[test]
1597    fn binding_single_element_tuple_destructure_type_mismatch_is_a_diagnostic() {
1598        let sheet =
1599            parse("sheet s { cell a: f64; cell out: i32; relationship { (out,) := (a,); } }");
1600        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1601        assert_eq!(diags.len(), 1);
1602    }
1603
1604    #[test]
1605    fn binding_single_element_tuple_destructure_arity_mismatch_is_a_diagnostic() {
1606        let sheet = parse("sheet s { cell a: i32; cell out: i32; relationship { (out,) := a; } }");
1607        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1608        assert_eq!(diags.len(), 1);
1609    }
1610
1611    #[test]
1612    fn filter_range_inclusive_body_does_not_require_underscore() {
1613        let sheet = parse("sheet s { cell a: i32 filter 0..=100; }");
1614        let diags = check_sheet(&sheet, &TypeRegistry::new(), &cel_parser::OpLookup::new());
1615        assert!(diags.is_empty());
1616    }
1617
1618    /// Range filters use their inferred endpoint identity, including callable-produced bounds.
1619    #[test]
1620    fn inferred_range_filters_agree_with_executable() {
1621        for source in [
1622            "sheet s { cell a: i32 filter (|x: i32| 0..=x)(100); }",
1623            "sheet s { source a: f64 = 1.0 filter 0.0..=100.0; }",
1624            "sheet s { out a: i32 := 1 filter (|x: i32| 0..=x)(100); }",
1625        ] {
1626            assert!(
1627                crate::AdamParser::new(TypeRegistry::new(), cel_parser::OpLookup::new())
1628                    .parse_str(source)
1629                    .is_ok(),
1630                "{source}"
1631            );
1632            let diags = check_sheet(
1633                &parse(source),
1634                &TypeRegistry::new(),
1635                &cel_parser::OpLookup::new(),
1636            );
1637            assert!(diags.is_empty(), "{source}: {diags:?}");
1638        }
1639        for source in [
1640            "sheet s { cell a: i32 filter 0.0..=100.0; }",
1641            "sheet s { source a: i32 = 1 filter (|x: f64| 0.0..=x)(100.0); }",
1642            "sheet s { out a: i32 := 1 filter 0.0..=100.0; }",
1643            "sheet s { cell a: i32 filter 0..100; }",
1644        ] {
1645            assert!(
1646                crate::AdamParser::new(TypeRegistry::new(), cel_parser::OpLookup::new())
1647                    .parse_str(source)
1648                    .is_err(),
1649                "{source}"
1650            );
1651            let diags = check_sheet(
1652                &parse(source),
1653                &TypeRegistry::new(),
1654                &cel_parser::OpLookup::new(),
1655            );
1656            assert!(!diags.is_empty(), "{source}");
1657        }
1658    }
1659}