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