Skip to main content

bynk_check/checker/
refinements.rs

1//! Refinement, literal, and zero-value logic.
2//!
3//! Split out of `checker.rs` (v0.29.10) verbatim; the parent module
4//! re-exports these via `use refinements::*`.
5
6use super::*;
7
8pub(crate) fn check_type_decl(
9    t: &TypeDecl,
10    types: &HashMap<String, Arc<TypeDecl>>,
11    tys: &Types,
12    errors: &mut Vec<CompileError>,
13) {
14    match &t.body {
15        TypeBody::Refined {
16            base,
17            base_span,
18            refinement,
19        } => {
20            check_refinement(*base, *base_span, refinement.as_ref(), errors);
21        }
22        TypeBody::Opaque {
23            base,
24            base_span,
25            refinement,
26        } => {
27            // Opaque types share refinement-validity rules with refined types.
28            check_refinement(*base, *base_span, refinement.as_ref(), errors);
29        }
30        TypeBody::Record(r) => {
31            for f in &r.fields {
32                if let Some(ref_r) = &f.refinement {
33                    // Inline refinements on fields must apply to the field's base type.
34                    if let Some(b) = field_base_type(&f.type_ref, types) {
35                        check_refinement(b, f.type_ref.span(), Some(ref_r), errors);
36                    } else {
37                        errors.push(CompileError::new(
38                            "bynk.types.field_refinement_not_base",
39                            ref_r.span,
40                            format!(
41                                "inline refinement on field `{}` requires a base or refined type",
42                                f.name.name
43                            ),
44                        ));
45                    }
46                }
47                // Events slice 3a (#972): `= expr` on a record field is
48                // meaningful only on an `event`'s own fields (checked
49                // separately, `check_event_field_default`) — an event never
50                // reaches `check_type_decl` at all (it's a `CommonsItem::Event`,
51                // resolved through the distinct `check_type_decl_refs` path,
52                // never `CommonsItem::Type`), so any `init` seen *here* is on
53                // an ordinary record and was, until now, silently parsed and
54                // dropped (`RecordField.init` is parsed for every record body
55                // — v0.11's grammar — but nothing before this read it outside
56                // agent state's own separate `StoreField.init`).
57                if let Some(init) = &f.init {
58                    errors.push(
59                        CompileError::new(
60                            "bynk.event.default_outside_event",
61                            init.span,
62                            format!(
63                                "a field default is only meaningful on an `event`'s field, not `{}`",
64                                f.name.name
65                            ),
66                        )
67                        .with_note(
68                            "a default exists so an older wire event missing this key can still \
69                             deserialise — an ordinary record has no wire-evolution story, so a \
70                             default here would be silently ignored",
71                        ),
72                    );
73                }
74            }
75        }
76        TypeBody::Sum(s) => {
77            check_embeds(&t.name.name, s, types, errors, tys);
78        }
79    }
80}
81
82/// v0.154 (ADR 0178): validate a sum's declared error embeddings. Each
83/// `embeds E as V` requires the named variant `V` to exist in this sum and to
84/// have **exactly one payload field whose type is `E`** — that is the shape a
85/// value of `E` auto-wraps into. A source type may be embedded by at most one
86/// variant, so `?`'s conversion is unambiguous.
87pub(crate) fn check_embeds(
88    sum_name: &str,
89    s: &SumBody,
90    types: &HashMap<String, Arc<TypeDecl>>,
91    errors: &mut Vec<CompileError>,
92    tys: &Types,
93) {
94    let mut seen_sources: Vec<TyId> = Vec::new();
95    for clause in &s.embeds {
96        let Some(source_ty) = resolve_type_ref(&clause.source_type, types, tys) else {
97            // An unresolvable type ref is already reported by reference
98            // resolution; skip to avoid a duplicate error.
99            continue;
100        };
101        // The named variant must exist in this sum.
102        let Some(variant) = s
103            .variants
104            .iter()
105            .find(|v| v.name.name == clause.variant.name)
106        else {
107            errors.push(CompileError::new(
108                "bynk.types.embeds_unknown_variant",
109                clause.variant.span,
110                format!(
111                    "`embeds … as {}` names no variant of `{}`",
112                    clause.variant.name, sum_name
113                ),
114            ));
115            continue;
116        };
117        // The variant must be a single-payload wrapper of the embedded type.
118        if variant.payload.len() != 1 {
119            errors.push(
120                CompileError::new(
121                    "bynk.types.embeds_variant_shape",
122                    clause.span,
123                    format!(
124                        "`embeds … as {}` requires `{}` to have exactly one payload field, but it has {}",
125                        clause.variant.name,
126                        clause.variant.name,
127                        variant.payload.len()
128                    ),
129                )
130                .with_note("a value of the embedded type is wrapped into that single field"),
131            );
132            continue;
133        }
134        let field_ty = resolve_type_ref(&variant.payload[0].type_ref, types, tys);
135        if let Some(field_ty) = &field_ty
136            && !compatible(source_ty, *field_ty, tys)
137        {
138            errors.push(CompileError::new(
139                "bynk.types.embeds_variant_shape",
140                clause.span,
141                format!(
142                    "`embeds {} as {}` — but `{}`'s payload field has type `{}`, not `{}`",
143                    source_ty.display(tys),
144                    clause.variant.name,
145                    clause.variant.name,
146                    field_ty.display(tys),
147                    source_ty.display(tys)
148                ),
149            ));
150            continue;
151        }
152        // The same source type may be embedded once at most (unambiguous `?`).
153        if seen_sources.iter().any(|t| compatible(*t, source_ty, tys)) {
154            errors.push(CompileError::new(
155                "bynk.types.embeds_ambiguous",
156                clause.span,
157                format!(
158                    "`{}` is embedded more than once by `{}` — the conversion would be ambiguous",
159                    source_ty.display(tys),
160                    sum_name
161                ),
162            ));
163            continue;
164        }
165        seen_sources.push(source_ty);
166    }
167}
168
169/// The base type of a field's type-ref (chasing through named refined types).
170fn field_base_type(r: &TypeRef, types: &HashMap<String, Arc<TypeDecl>>) -> Option<BaseType> {
171    match r {
172        TypeRef::Base(b, _) => Some(*b),
173        TypeRef::Named(id) => match types.get(&id.name).map(|t| &t.body) {
174            Some(TypeBody::Refined { base, .. }) => Some(*base),
175            _ => None,
176        },
177        _ => None,
178    }
179}
180
181/// The implicit base type of a TypeDecl whose constructor would be `T.of`:
182/// Refined and Opaque types alike share the `of(base) -> Result[T, _]` shape.
183/// Returns None for record / sum types.
184pub(crate) fn type_decl_base(decl: &TypeDecl) -> Option<BaseType> {
185    match &decl.body {
186        TypeBody::Refined { base, .. } => Some(*base),
187        TypeBody::Opaque { base, .. } => Some(*base),
188        _ => None,
189    }
190}
191
192/// The refinement attached to a refined or opaque type declaration, if any.
193pub(crate) fn type_decl_refinement(decl: &TypeDecl) -> Option<&Refinement> {
194    match &decl.body {
195        TypeBody::Refined { refinement, .. } | TypeBody::Opaque { refinement, .. } => {
196            refinement.as_ref()
197        }
198        _ => None,
199    }
200}
201
202/// Extract a compile-time literal from an expression, if it is one v0.9.4's
203/// static refinement check accepts: an int/string/bool/unit literal, or a unary
204/// minus applied directly to an int literal. Anything else (arithmetic, idents,
205/// calls) is not statically evaluated and keeps the runtime `Result` path.
206pub(crate) fn const_literal(e: &Expr) -> Option<ConstLit> {
207    match &e.kind {
208        ExprKind::IntLit { value: n, .. } => Some(ConstLit::Int(*n)),
209        ExprKind::FloatLit { value, .. } => Some(ConstLit::Float(*value)),
210        ExprKind::StrLit(s) => Some(ConstLit::Str(s.clone())),
211        ExprKind::BoolLit(b) => Some(ConstLit::Bool(*b)),
212        ExprKind::UnitLit => Some(ConstLit::Unit),
213        ExprKind::UnaryOp(UnaryOp::Neg, inner) => match &inner.kind {
214            ExprKind::IntLit { value: n, .. } => Some(ConstLit::Int(n.checked_neg()?)),
215            ExprKind::FloatLit { value, .. } => Some(ConstLit::Float(-*value)),
216            _ => None,
217        },
218        _ => None,
219    }
220}
221
222/// Evaluate a single predicate against a constant literal. A predicate whose
223/// expected base type doesn't match the literal (e.g. a length predicate on an
224/// int) returns `true` here — the base/predicate mismatch is a declaration-time
225/// error reported by `check_refinement`, not a construction concern. String
226/// length is measured in Unicode scalar values, which agrees with JS `.length`
227/// for the BMP (the range fixtures use ASCII).
228pub(crate) fn eval_predicate(pred: &PredKind, lit: &ConstLit) -> bool {
229    match (pred, lit) {
230        (PredKind::NonNegative, ConstLit::Int(n)) => *n >= 0,
231        (PredKind::Positive, ConstLit::Int(n)) => *n > 0,
232        (PredKind::InRange(lo, hi), ConstLit::Int(n)) => lo.value <= *n && *n <= hi.value,
233        (PredKind::NonNegative, ConstLit::Float(v)) => *v >= 0.0,
234        (PredKind::Positive, ConstLit::Float(v)) => *v > 0.0,
235        (PredKind::InRangeF(lo, hi), ConstLit::Float(v)) => lo.value <= *v && *v <= hi.value,
236        (PredKind::MinLength(k), ConstLit::Str(s)) => s.chars().count() as i64 >= *k,
237        (PredKind::MaxLength(k), ConstLit::Str(s)) => (s.chars().count() as i64) <= *k,
238        (PredKind::Length(k), ConstLit::Str(s)) => s.chars().count() as i64 == *k,
239        (PredKind::NonEmpty, ConstLit::Str(s)) => !s.is_empty(),
240        (PredKind::Matches(pat), ConstLit::Str(s)) => {
241            // Evaluated with the same engine semantics the emitted
242            // `new RegExp(...)` runs under (ECMAScript, no flags).
243            regress::Regex::new(&format!("^(?:{pat})$"))
244                .map(|re| re.find(s).is_some())
245                .unwrap_or(false)
246        }
247        _ => true,
248    }
249}
250
251/// The first predicate the literal fails, or `None` if it satisfies them all.
252pub(crate) fn first_failed_predicate<'a>(
253    refinement: &'a Refinement,
254    lit: &ConstLit,
255) -> Option<&'a PredKind> {
256    for p in &refinement.predicates {
257        if !eval_predicate(&p.kind, lit) {
258            return Some(&p.kind);
259        }
260    }
261    None
262}
263
264/// `LocaleTag`'s refinement, read once from the firstparty `bynk.locale.types`
265/// source — the single source of truth the emitter also lowers to a
266/// `new RegExp(...)`. `None` only if that type ever loses its refinement or the
267/// firstparty source stops parsing (both guarded elsewhere), in which case the
268/// tag check below accepts everything rather than spuriously rejecting.
269fn locale_tag_refinement() -> Option<&'static Refinement> {
270    use std::sync::OnceLock;
271    static REFINEMENT: OnceLock<Option<Refinement>> = OnceLock::new();
272    REFINEMENT
273        .get_or_init(|| {
274            let src = crate::firstparty::BYNK_LOCALE_TYPES_SRC;
275            let tokens = bynk_syntax::lexer::tokenize(src).ok()?;
276            let unit = bynk_syntax::parser::parse_unit(&tokens, src).ok()?;
277            let bynk_syntax::ast::SourceUnit::Commons(commons) = unit else {
278                return None;
279            };
280            commons.items.iter().find_map(|item| match item {
281                CommonsItem::Type(t) if t.name.name == "LocaleTag" => {
282                    type_decl_refinement(t).cloned()
283                }
284                _ => None,
285            })
286        })
287        .as_ref()
288}
289
290/// The pattern `LocaleTag`'s refinement matches against, for a diagnostic that
291/// names it. `None` if the type carries no `Matches` predicate.
292pub fn locale_tag_pattern() -> Option<&'static str> {
293    locale_tag_refinement()?.predicates.iter().find_map(|p| {
294        if let PredKind::Matches(pat) = &p.kind {
295            Some(pat.as_str())
296        } else {
297            None
298        }
299    })
300}
301
302/// Whether `tag` satisfies `LocaleTag`'s refinement — the check behind
303/// `bynk.messages.invalid_locale_tag`. Evaluated with the same regex-engine
304/// semantics (`regress`, anchored, no flags) the emitted `new RegExp(...)`
305/// runs under, so a tag accepted here is one the runtime cast is honest about.
306pub fn locale_tag_accepts(tag: &str) -> bool {
307    match locale_tag_refinement() {
308        Some(refinement) => {
309            first_failed_predicate(refinement, &ConstLit::Str(tag.to_string())).is_none()
310        }
311        None => true,
312    }
313}
314
315pub(crate) fn literal_matches_base(lit: &ConstLit, base: BaseType) -> bool {
316    matches!(
317        (lit, base),
318        (ConstLit::Int(_), BaseType::Int)
319            | (ConstLit::Str(_), BaseType::String)
320            | (ConstLit::Bool(_), BaseType::Bool)
321            | (ConstLit::Float(_), BaseType::Float)
322    )
323}
324
325/// v0.9.4: expected-type-directed literal admission. When a position expects a
326/// **refined** type `T` and `expr` is a compile-time literal of `T`'s base, the
327/// literal takes the type `T` directly (the emitter lowers it to an inline brand
328/// cast, `(lit as T)` — ADR 0182); a literal that violates the refinement is a
329/// compile error. Returns `None` when no refined type is expected (so the caller
330/// keeps the literal's base type) — `.of` remains the only constructor for
331/// runtime values.
332/// Opaque types are intentionally excluded: their representation is hidden, so
333/// they are still built via `T.of(...)`.
334///
335/// Looks through one `Effect[_]` layer first (Locale capability track, slice 1,
336/// #844): a `stub Cap.op() returns <lit>` RHS is checked as a bare tail value
337/// against the op's full `Effect[T]` return type (§tail-position auto-lift
338/// handles the reverse direction, wrapping a matched bare value back up to
339/// `Effect[T]`), so a refined `T` reached only through that wrapper must still
340/// be visible here or the literal falls back to its unrefined base type and the
341/// auto-lift's own `compatible` check then rejects it.
342pub(crate) fn admit_refined_literal(
343    expr: &Expr,
344    expected: Option<TyId>,
345    ctx: &mut Ctx,
346) -> Option<TyId> {
347    let tys = ctx.tys;
348    let expected = match expected.map(|e| tys.get(e)).as_deref() {
349        Some(Ty::Effect(inner)) => Some(*inner),
350        _ => expected,
351    };
352    let expected_node = expected.map(|e| tys.get(e));
353    let Some(Ty::Named {
354        name,
355        kind: NamedKind::Refined(base),
356        ..
357    }) = expected_node.as_deref()
358    else {
359        return None;
360    };
361    let lit = const_literal(expr)?;
362    if !literal_matches_base(&lit, *base) {
363        return None;
364    }
365    let decl = ctx.input.types.get(name)?.clone();
366    if let Some(refinement) = type_decl_refinement(&decl)
367        && let Some(failed) = first_failed_predicate(refinement, &lit)
368    {
369        ctx.errors.push(CompileError::new(
370            "bynk.refine.literal_violates",
371            expr.span,
372            format!(
373                "literal {} does not satisfy `{}` required by type `{}`",
374                lit.display(),
375                failed.name(),
376                name
377            ),
378        ));
379    }
380    Some(named_ty(&decl, tys))
381}
382
383pub(crate) fn check_refinement(
384    base: BaseType,
385    base_span: Span,
386    refinement: Option<&Refinement>,
387    errors: &mut Vec<CompileError>,
388) {
389    let Some(refinement) = refinement else {
390        return;
391    };
392
393    for pred in &refinement.predicates {
394        if !pred_applies_to(&pred.kind, base) {
395            // v0.21: `InRange` bounds must match the numeric base type —
396            // `Float where InRange(0, 1)` is the no-coercion rule applied
397            // to refinement bounds, not a predicate/base mismatch.
398            let numeric_bound_mismatch = matches!(
399                (&pred.kind, base),
400                (PredKind::InRange(_, _), BaseType::Float)
401                    | (PredKind::InRangeF(_, _), BaseType::Int)
402            );
403            if numeric_bound_mismatch {
404                let (bounds, want) = if base == BaseType::Float {
405                    ("`Int`", "`InRange(0.0, 1.0)`")
406                } else {
407                    ("`Float`", "`InRange(0, 1)`")
408                };
409                errors.push(
410                    CompileError::new(
411                        "bynk.types.no_numeric_coercion",
412                        pred.span,
413                        format!(
414                            "`InRange` bounds are {bounds} literals, but the base type is `{}`",
415                            base.name()
416                        ),
417                    )
418                    .with_label(
419                        base_span,
420                        format!("base type `{}` declared here", base.name()),
421                    )
422                    .with_note(format!(
423                        "refinement bounds must match the base type — e.g. {want}"
424                    )),
425                );
426                continue;
427            }
428            errors.push(
429                CompileError::new(
430                    "bynk.types.predicate_base_mismatch",
431                    pred.span,
432                    format!(
433                        "predicate `{}` cannot be applied to base type `{}`",
434                        pred.kind.name(),
435                        base.name()
436                    ),
437                )
438                .with_label(
439                    base_span,
440                    format!("base type `{}` declared here", base.name()),
441                )
442                .with_note(predicate_base_help(pred.kind.name())),
443            );
444        }
445        match &pred.kind {
446            PredKind::Matches(pat) => {
447                // Validate with ECMAScript semantics (the `regress` engine):
448                // the emitted check runs the pattern under JS `RegExp`, so a
449                // pattern the Rust `regex` crate accepts but JS rejects
450                // (`(?P<name>…)`, inline flags) would otherwise compile
451                // cleanly and then throw at runtime — a 500 on the request
452                // path instead of a compile error.
453                if let Err(e) = regress::Regex::new(pat) {
454                    errors.push(
455                        CompileError::new(
456                            "bynk.types.invalid_regex",
457                            pred.span,
458                            format!("invalid regular expression in `Matches(\"{pat}\")`"),
459                        )
460                        .with_note(format!("regex parse error (JS `RegExp` semantics): {e}")),
461                    );
462                } else if has_nested_unbounded_quantifier(pat) {
463                    // The emitted boundary check runs this pattern under JS
464                    // `RegExp`, a backtracking engine. A repeated group that
465                    // itself contains an unbounded quantifier (`(a+)+`) makes
466                    // matching take exponential time on a crafted near-miss
467                    // input — a refined `String` on an HTTP boundary would let
468                    // an unauthenticated client stall the Worker (ReDoS, #724).
469                    // Reject the pattern at compile time rather than ship the
470                    // hazard.
471                    errors.push(
472                        CompileError::new(
473                            "bynk.types.catastrophic_regex",
474                            pred.span,
475                            format!(
476                                "the pattern in `Matches(\"{pat}\")` nests unbounded quantifiers, \
477                                 which can cause catastrophic backtracking (ReDoS)"
478                            ),
479                        )
480                        .with_note(
481                            "a repeated group that itself contains `*`, `+`, or `{n,}` makes \
482                             matching take exponential time on crafted input; restructure the \
483                             pattern so no unbounded quantifier is nested inside another",
484                        ),
485                    );
486                }
487            }
488            PredKind::InRange(lo, hi) => {
489                if lo.value > hi.value {
490                    errors.push(
491                        CompileError::new(
492                            "bynk.types.inverted_range",
493                            pred.span,
494                            format!(
495                                "`InRange({}, {})` has its bounds inverted (`min` must be ≤ `max`)",
496                                lo.value, hi.value
497                            ),
498                        )
499                        // v0.40 (ADR 0073): a machine-applicable swap — replace
500                        // each bound's text with the other's, in place.
501                        .with_suggestion(
502                            "swap the bounds",
503                            vec![
504                                (lo.span, hi.value.to_string()),
505                                (hi.span, lo.value.to_string()),
506                            ],
507                            Applicability::MachineApplicable,
508                        ),
509                    );
510                }
511            }
512            PredKind::InRangeF(lo, hi) => {
513                if lo.value > hi.value {
514                    errors.push(
515                        CompileError::new(
516                            "bynk.types.inverted_range",
517                            pred.span,
518                            format!(
519                                "`InRange({}, {})` has its bounds inverted (`min` must be ≤ `max`)",
520                                lo.lexeme, hi.lexeme
521                            ),
522                        )
523                        .with_suggestion(
524                            "swap the bounds",
525                            vec![(lo.span, hi.lexeme.clone()), (hi.span, lo.lexeme.clone())],
526                            Applicability::MachineApplicable,
527                        ),
528                    );
529                }
530            }
531            PredKind::MinLength(n) | PredKind::MaxLength(n) | PredKind::Length(n) => {
532                if *n < 0 {
533                    errors.push(CompileError::new(
534                        "bynk.types.negative_length",
535                        pred.span,
536                        format!("length argument must be non-negative, got {n}"),
537                    ));
538                }
539            }
540            PredKind::NonNegative | PredKind::Positive | PredKind::NonEmpty => {}
541        }
542    }
543
544    let all_compatible = refinement
545        .predicates
546        .iter()
547        .all(|p| pred_applies_to(&p.kind, base));
548    if !all_compatible {
549        return;
550    }
551    match base {
552        BaseType::Int => check_int_refinement_consistency(refinement, errors),
553        BaseType::String => check_string_refinement_consistency(refinement, errors),
554        BaseType::Bool => {}
555        BaseType::Float => check_float_refinement_consistency(refinement, errors),
556        // v0.86/v0.90/v0.110: no refinement predicate applies to `Duration`,
557        // `Instant`, or `Bytes` (none is in any `pred_applies_to` row), so a
558        // refined one is rejected upstream and there is nothing to
559        // consistency-check here.
560        BaseType::Duration | BaseType::Instant | BaseType::Bytes => {}
561    }
562}
563
564fn pred_applies_to(pred: &PredKind, base: BaseType) -> bool {
565    matches!(
566        (pred, base),
567        (PredKind::Matches(_), BaseType::String)
568            | (PredKind::InRange(_, _), BaseType::Int)
569            | (PredKind::InRangeF(_, _), BaseType::Float)
570            | (PredKind::MinLength(_), BaseType::String)
571            | (PredKind::MaxLength(_), BaseType::String)
572            | (PredKind::Length(_), BaseType::String)
573            | (PredKind::NonNegative, BaseType::Int | BaseType::Float)
574            | (PredKind::Positive, BaseType::Int | BaseType::Float)
575            | (PredKind::NonEmpty, BaseType::String)
576    )
577}
578
579fn predicate_base_help(name: &str) -> &'static str {
580    match name {
581        "Matches" | "MinLength" | "MaxLength" | "Length" | "NonEmpty" => {
582            "this predicate applies to `String` only"
583        }
584        "NonNegative" | "Positive" => "this predicate applies to `Int` and `Float` only",
585        "InRange" => {
586            "this predicate applies to `Int` and `Float` only, with bounds matching the base"
587        }
588        _ => "see the documentation for valid predicate-base combinations",
589    }
590}
591
592pub(crate) fn check_int_refinement_consistency(
593    refinement: &Refinement,
594    errors: &mut Vec<CompileError>,
595) {
596    let mut lo: i64 = i64::MIN;
597    let mut hi: i64 = i64::MAX;
598    for p in &refinement.predicates {
599        match &p.kind {
600            PredKind::Positive => lo = lo.max(1),
601            PredKind::NonNegative => lo = lo.max(0),
602            PredKind::InRange(a, b) => {
603                lo = lo.max(a.value);
604                hi = hi.min(b.value);
605            }
606            _ => {}
607        }
608    }
609    if lo > hi {
610        errors.push(
611            CompileError::new(
612                "bynk.types.empty_refinement",
613                refinement.span,
614                "this refinement has no valid values — the predicates contradict each other",
615            )
616            .with_note(format!(
617                "the effective range is `{lo}..={hi}`, which is empty"
618            )),
619        );
620    }
621}
622
623pub(crate) fn check_float_refinement_consistency(
624    refinement: &Refinement,
625    errors: &mut Vec<CompileError>,
626) {
627    let mut lo = f64::NEG_INFINITY;
628    let mut hi = f64::INFINITY;
629    // `Positive` excludes the lower endpoint (0.0 itself is not positive).
630    let mut lo_exclusive = false;
631    for p in &refinement.predicates {
632        match &p.kind {
633            PredKind::Positive if 0.0 >= lo => {
634                lo = 0.0;
635                lo_exclusive = true;
636            }
637            PredKind::NonNegative if 0.0 > lo => {
638                lo = 0.0;
639                lo_exclusive = false;
640            }
641            PredKind::InRangeF(a, b) => {
642                if a.value > lo {
643                    lo = a.value;
644                    lo_exclusive = false;
645                }
646                hi = hi.min(b.value);
647            }
648            _ => {}
649        }
650    }
651    if lo > hi || (lo == hi && lo_exclusive) {
652        errors.push(
653            CompileError::new(
654                "bynk.types.empty_refinement",
655                refinement.span,
656                "this refinement has no valid values — the predicates contradict each other",
657            )
658            .with_note(format!(
659                "the effective range is `{lo}..={hi}`{}, which is empty",
660                if lo_exclusive {
661                    " (lower bound exclusive)"
662                } else {
663                    ""
664                }
665            )),
666        );
667    }
668}
669
670pub(crate) fn check_string_refinement_consistency(
671    refinement: &Refinement,
672    errors: &mut Vec<CompileError>,
673) {
674    let mut min_len: i64 = 0;
675    let mut max_len: i64 = i64::MAX;
676    let mut exact_len: Option<i64> = None;
677    for p in &refinement.predicates {
678        match &p.kind {
679            PredKind::MinLength(n) => min_len = min_len.max(*n),
680            PredKind::MaxLength(n) => max_len = max_len.min(*n),
681            PredKind::NonEmpty => min_len = min_len.max(1),
682            PredKind::Length(n) => {
683                if let Some(prev) = exact_len {
684                    if prev != *n {
685                        errors.push(CompileError::new(
686                            "bynk.types.empty_refinement",
687                            refinement.span,
688                            format!(
689                                "conflicting exact lengths: `Length({prev})` and `Length({n})` cannot both hold"
690                            ),
691                        ));
692                    }
693                } else {
694                    exact_len = Some(*n);
695                }
696                min_len = min_len.max(*n);
697                max_len = max_len.min(*n);
698            }
699            _ => {}
700        }
701    }
702    if min_len > max_len {
703        errors.push(
704            CompileError::new(
705                "bynk.types.empty_refinement",
706                refinement.span,
707                "this refinement has no valid values — minimum length exceeds maximum length",
708            )
709            .with_note(format!(
710                "the effective length range is `{min_len}..={max_len}`, which is empty"
711            )),
712        );
713    }
714}
715
716// -- function body type checking --
717
718/// v0.9.1: `assert e` as an expression. Test-privileged. Requires `e : Bool`.
719/// Always yields type `()`.
720/// True if a refinement cannot be satisfied by a generated default value — i.e.
721/// it contains a `Matches` predicate, where bare `Val[T]` must be given an
722/// explicit pin instead.
723pub(crate) fn refinement_needs_pin(refinement: &Refinement) -> bool {
724    refinement
725        .predicates
726        .iter()
727        .any(|p| matches!(p.kind, PredKind::Matches(_)))
728}
729
730/// The TypeScript zero-value expression for `type_ref` (with an optional
731/// inline field refinement), or `None` if the type is not zeroable.
732pub fn zero_value_ts(
733    type_ref: &TypeRef,
734    inline: Option<&Refinement>,
735    types: &HashMap<String, Arc<TypeDecl>>,
736) -> Option<String> {
737    zero_value_ts_inner(type_ref, inline, types, &mut Vec::new())
738}
739
740fn zero_value_ts_inner(
741    type_ref: &TypeRef,
742    inline: Option<&Refinement>,
743    types: &HashMap<String, Arc<TypeDecl>>,
744    visiting: &mut Vec<String>,
745) -> Option<String> {
746    match type_ref {
747        TypeRef::Base(b, _) => {
748            if refinement_admits_zero(*b, inline) {
749                zero_of_base(*b)
750            } else {
751                None
752            }
753        }
754        // Option's zero is None, regardless of the inner type.
755        TypeRef::Option(_, _) => Some("None".to_string()),
756        TypeRef::Named(id) => {
757            let decl = types.get(&id.name)?;
758            match &decl.body {
759                TypeBody::Refined {
760                    base, refinement, ..
761                } => {
762                    if refinement_admits_zero(*base, refinement.as_ref()) {
763                        zero_of_base(*base)
764                    } else {
765                        None
766                    }
767                }
768                TypeBody::Record(rec) => {
769                    // A record cycle (`A = { b: B }`, `B = { a: A }`) has no
770                    // finite zero value; without this guard the recursion is
771                    // unbounded and overflows the stack. The resolver rejects
772                    // such cycles, but this walk must terminate regardless of
773                    // what reaches it.
774                    if visiting.iter().any(|n| n == &id.name) {
775                        return None;
776                    }
777                    visiting.push(id.name.clone());
778                    let z = agent_state_zero_record(&rec.fields, types, visiting);
779                    visiting.pop();
780                    z
781                }
782                // Non-Option sum types and opaque types have no defined zero.
783                TypeBody::Sum(_) | TypeBody::Opaque { .. } => None,
784            }
785        }
786        // Result / Effect / HttpResult / ValidationError / Unit are not
787        // admissible state-field types and have no zero.
788        _ => None,
789    }
790}
791
792/// The zero record `{ f₁: z₁, …, fₙ: zₙ }` for a set of fields, or `None` if
793/// any field is not zeroable.
794fn agent_state_zero_record(
795    fields: &[RecordField],
796    types: &HashMap<String, Arc<TypeDecl>>,
797    visiting: &mut Vec<String>,
798) -> Option<String> {
799    let mut parts = Vec::new();
800    for f in fields {
801        let z = zero_value_ts_inner(&f.type_ref, f.refinement.as_ref(), types, visiting)?;
802        parts.push(format!("{}: {}", f.name.name, z));
803    }
804    Some(format!("{{ {} }}", parts.join(", ")))
805}
806
807fn zero_of_base(b: BaseType) -> Option<String> {
808    Some(
809        match b {
810            BaseType::Int => "0",
811            BaseType::Bool => "false",
812            BaseType::String => "\"\"",
813            BaseType::Float => "0",
814            // v0.86/v0.90: a `Duration` is milliseconds and an `Instant` is
815            // epoch milliseconds; the zero of each is `0` (the Unix epoch).
816            BaseType::Duration | BaseType::Instant => "0",
817            // v0.110 (ADR 0142): the zero of `Bytes` is the empty octet
818            // sequence (`""` in base64), erased to an empty `Uint8Array`.
819            BaseType::Bytes => "new Uint8Array()",
820        }
821        .to_string(),
822    )
823}
824
825/// Whether the zero value of `base` satisfies every predicate in `refinement`.
826/// Conservative: any predicate we cannot prove admits the zero returns false,
827/// surfacing the `non_zeroable_state_field` diagnostic rather than risking an
828/// invalid fresh state.
829fn refinement_admits_zero(base: BaseType, refinement: Option<&Refinement>) -> bool {
830    let Some(r) = refinement else {
831        return true;
832    };
833    r.predicates.iter().all(|p| pred_admits_zero(base, &p.kind))
834}
835
836fn pred_admits_zero(base: BaseType, k: &PredKind) -> bool {
837    match base {
838        BaseType::Int => match k {
839            PredKind::NonNegative => true,
840            PredKind::Positive => false,
841            PredKind::InRange(lo, hi) => lo.value <= 0 && 0 <= hi.value,
842            // Length/Matches predicates don't apply to Int; reject conservatively.
843            _ => false,
844        },
845        BaseType::String => match k {
846            PredKind::Matches(p) => regex_matches_empty(p),
847            PredKind::MinLength(n) => *n <= 0,
848            PredKind::MaxLength(n) => *n >= 0,
849            PredKind::Length(n) => *n == 0,
850            PredKind::NonEmpty => false,
851            // Numeric predicates don't apply to String; reject conservatively.
852            _ => false,
853        },
854        // The only Bool zero is `false`; no Bool refinement predicates exist.
855        BaseType::Bool => true,
856        // No refinement predicate applies to `Duration`, `Instant`, or
857        // `Bytes`, so the question is vacuous — admit it (mirrors `Bool`).
858        BaseType::Duration | BaseType::Instant | BaseType::Bytes => true,
859        BaseType::Float => match k {
860            PredKind::NonNegative => true,
861            PredKind::Positive => false,
862            PredKind::InRangeF(lo, hi) => lo.value <= 0.0 && 0.0 <= hi.value,
863            // Other predicates don't apply to Float; reject conservatively.
864            _ => false,
865        },
866    }
867}
868
869/// Does the refinement pattern match the empty string? Anchored exactly as the
870/// emitted refined-type constructor anchors it (`^(?:pattern)$`), and
871/// evaluated with the same engine semantics (ECMAScript, no flags).
872fn regex_matches_empty(pattern: &str) -> bool {
873    match regress::Regex::new(&format!("^(?:{pattern})$")) {
874        Ok(re) => re.find("").is_some(),
875        Err(_) => false,
876    }
877}
878
879/// #724 — detect one catastrophic-backtracking (ReDoS) signature: an unbounded
880/// quantifier applied to a group that itself contains an unbounded quantifier
881/// ("star height ≥ 2", e.g. `(a+)+`, `(a*)*b`, `((ab)+)+`). Under the JS
882/// backtracking `RegExp` the emitted boundary check runs, this class takes
883/// exponential time on a crafted near-miss input; the conservative structural
884/// rule rejects it at compile time.
885///
886/// "Unbounded" means `*`, `+`, or `{n,}` (open upper bound); `?` and `{n,m}`
887/// (finite) cannot explode. The scan is purely structural — the pattern is
888/// already known valid (`regress` accepted it) — so it need not model match
889/// semantics, only quantifier nesting through groups. Inner unbounded
890/// quantifiers propagate up through *bounded* quantifiers too, so `((a+)?)+`
891/// is still caught. The check is conservative in the safe direction: it can
892/// reject a star-height-2 pattern whose sub-expressions provably never overlap,
893/// but every *nested-quantifier* blowup is flagged.
894///
895/// This does **not** cover the whole exponential class. Ambiguous alternation
896/// under a single quantifier — `(a|a)+`, `(\d|\d\d)+`, `(foo|foobar)+` — is
897/// exponential too (two distinct paths spell the same string, so a backtracker
898/// explores `2ⁿ` labelings of `aⁿ`), yet it is star height 1 and is *not*
899/// flagged here. Detecting it needs branch-overlap analysis and is a deferred
900/// follow-up (#724). Nor does this target the polynomial class (`\d*\d*`,
901/// quadratic). The guard closes the common nested-quantifier subclass, not
902/// catastrophic backtracking in general.
903fn has_nested_unbounded_quantifier(pat: &str) -> bool {
904    // Precondition: `pat` is a valid regex (`regress` accepted it), so its
905    // parentheses are balanced. The `stack.last_mut().unwrap()` arms below rely
906    // on that — a `)` never pops the root frame — which the sole caller ensures
907    // by running this only after the validity check. The `)` arm keeps a
908    // defensive `unwrap_or` regardless.
909    let chars: Vec<char> = pat.chars().collect();
910    // One boolean per open group (index 0 = top level): does this group contain
911    // an unbounded quantifier anywhere within it?
912    let mut stack: Vec<bool> = vec![false];
913    // The atom a following quantifier would apply to: `None` if none is pending,
914    // else `Some(inner_unbounded)` where `inner_unbounded` is true when that atom
915    // is a group carrying an unbounded quantifier inside it.
916    let mut pending: Option<bool> = None;
917    let mut i = 0;
918
919    // Fold a pending atom that turned out to be unquantified into the current
920    // frame: if it was a group with inner unbounded content, that content still
921    // lives in the enclosing group.
922    fn fold(pending: &mut Option<bool>, stack: &mut [bool]) {
923        if pending.take() == Some(true) {
924            *stack.last_mut().unwrap() = true;
925        }
926    }
927
928    while i < chars.len() {
929        match chars[i] {
930            // An escape is a single atom; skip the escaped char.
931            '\\' => {
932                fold(&mut pending, &mut stack);
933                i += 2;
934                pending = Some(false);
935            }
936            // A character class is one atom; `*`/`+`/`{` inside it are literal.
937            '[' => {
938                fold(&mut pending, &mut stack);
939                i += 1;
940                if i < chars.len() && chars[i] == '^' {
941                    i += 1;
942                }
943                // A leading `]` is a literal member, not the class terminator.
944                if i < chars.len() && chars[i] == ']' {
945                    i += 1;
946                }
947                while i < chars.len() && chars[i] != ']' {
948                    if chars[i] == '\\' {
949                        i += 1;
950                    }
951                    i += 1;
952                }
953                i += 1; // consume the closing `]`
954                pending = Some(false);
955            }
956            '(' => {
957                fold(&mut pending, &mut stack);
958                stack.push(false);
959                i += 1;
960                // Skip a group-type prefix so its punctuation is not mistaken for
961                // an atom: `(?:`, `(?=`, `(?!`, `(?<=`, `(?<!`, `(?<name>`.
962                if i < chars.len() && chars[i] == '?' {
963                    i += 1;
964                    if i < chars.len() && matches!(chars[i], ':' | '=' | '!') {
965                        i += 1;
966                    } else if i < chars.len() && chars[i] == '<' {
967                        i += 1;
968                        if i < chars.len() && matches!(chars[i], '=' | '!') {
969                            i += 1;
970                        } else {
971                            while i < chars.len() && chars[i] != '>' {
972                                i += 1;
973                            }
974                            if i < chars.len() {
975                                i += 1; // consume `>`
976                            }
977                        }
978                    }
979                }
980            }
981            ')' => {
982                fold(&mut pending, &mut stack);
983                let closed = stack.pop().unwrap_or(false);
984                i += 1;
985                // The whole group is now an atom in its parent frame.
986                pending = Some(closed);
987            }
988            // Alternation ends the current atom; an unbounded one folds in.
989            '|' => {
990                fold(&mut pending, &mut stack);
991                i += 1;
992            }
993            // Unbounded quantifier.
994            '*' | '+' => {
995                let atom_unbounded = pending.take().unwrap_or(false);
996                if atom_unbounded {
997                    return true; // unbounded nested inside unbounded
998                }
999                *stack.last_mut().unwrap() = true;
1000                i += 1;
1001                if i < chars.len() && chars[i] == '?' {
1002                    i += 1; // lazy marker
1003                }
1004            }
1005            // Optional: bounded, but inner unbounded content still propagates.
1006            '?' => {
1007                if let Some(atom_unbounded) = pending.take() {
1008                    if atom_unbounded {
1009                        *stack.last_mut().unwrap() = true;
1010                    }
1011                    i += 1;
1012                    if i < chars.len() && chars[i] == '?' {
1013                        i += 1; // lazy marker
1014                    }
1015                } else {
1016                    // Stray `?` (unreachable for a valid pattern) — treat as atom.
1017                    i += 1;
1018                    pending = Some(false);
1019                }
1020            }
1021            '{' => {
1022                if let Some((unbounded_q, next)) = parse_brace_quantifier(&chars, i) {
1023                    let atom_unbounded = pending.take().unwrap_or(false);
1024                    if unbounded_q && atom_unbounded {
1025                        return true;
1026                    }
1027                    if unbounded_q || atom_unbounded {
1028                        *stack.last_mut().unwrap() = true;
1029                    }
1030                    i = next;
1031                    if i < chars.len() && chars[i] == '?' {
1032                        i += 1; // lazy marker
1033                    }
1034                } else {
1035                    // A `{` that is not a quantifier is a literal atom.
1036                    fold(&mut pending, &mut stack);
1037                    i += 1;
1038                    pending = Some(false);
1039                }
1040            }
1041            // Any other char (`.`, literal, `^`, `$`) is an ordinary atom.
1042            _ => {
1043                fold(&mut pending, &mut stack);
1044                i += 1;
1045                pending = Some(false);
1046            }
1047        }
1048    }
1049    false
1050}
1051
1052/// Parse a `{m}`, `{m,}`, or `{m,n}` quantifier starting at `chars[start] == '{'`.
1053/// Returns `(is_unbounded, index_after_close)` when it is a well-formed
1054/// quantifier — `is_unbounded` is true only for `{m,}` (open upper bound) — or
1055/// `None` when the braces are not a quantifier (then they are literal text, as
1056/// JS `RegExp` treats them).
1057fn parse_brace_quantifier(chars: &[char], start: usize) -> Option<(bool, usize)> {
1058    let mut i = start + 1;
1059    let lo_start = i;
1060    while i < chars.len() && chars[i].is_ascii_digit() {
1061        i += 1;
1062    }
1063    if i == lo_start {
1064        return None; // `{m` requires at least one digit
1065    }
1066    let mut unbounded = false;
1067    if i < chars.len() && chars[i] == ',' {
1068        i += 1;
1069        let hi_start = i;
1070        while i < chars.len() && chars[i].is_ascii_digit() {
1071            i += 1;
1072        }
1073        if i == hi_start {
1074            unbounded = true; // `{m,}` — no upper bound
1075        }
1076    }
1077    if i < chars.len() && chars[i] == '}' {
1078        Some((unbounded, i + 1))
1079    } else {
1080        None
1081    }
1082}
1083
1084#[cfg(test)]
1085mod redos_tests {
1086    use super::has_nested_unbounded_quantifier as redos;
1087
1088    #[test]
1089    fn flags_nested_unbounded_quantifiers() {
1090        // The classic exponential shapes: an unbounded quantifier over a group
1091        // that itself repeats unboundedly.
1092        assert!(redos("(a+)+"));
1093        assert!(redos("(a+)+$"));
1094        assert!(redos("(a*)*"));
1095        assert!(redos("(a+)*"));
1096        assert!(redos("(a*)+"));
1097        assert!(redos("((ab)+)+"));
1098        assert!(redos("(a{1,})+")); // `{1,}` is unbounded
1099        assert!(redos("(a+){2,}")); // outer open bound over inner `+`
1100        assert!(redos("x(y(z+)+w)+")); // nested a level deep
1101        assert!(redos("(\\d+)+"));
1102        assert!(redos("(?:a+)+")); // non-capturing group
1103        assert!(redos("([a-z]+)*")); // class inside the repeated group
1104        assert!(redos("((a+)?)+")); // inner unbounded propagates through `?`
1105        assert!(redos("(a+|b)+")); // alternation does not launder the nesting
1106    }
1107
1108    #[test]
1109    fn allows_safe_patterns() {
1110        // A single quantifier level is fine, however placed.
1111        assert!(!redos("a+"));
1112        assert!(!redos("(a+)")); // repeated once, not nested
1113        assert!(!redos("(a+)(b+)")); // siblings, not nested
1114        assert!(!redos("(ab)+")); // repeated group with no inner quantifier
1115        assert!(!redos("(a+)?")); // bounded outer quantifier
1116        assert!(!redos("(a+){2,3}")); // finite outer bound
1117        assert!(!redos("(a{2,3})+")); // finite *inner* bound cannot explode
1118        assert!(!redos("[a-z]+")); // `+` binds the class, not a group
1119        assert!(!redos("a{2,}b{2,}")); // two unbounded, neither nested
1120    }
1121
1122    #[test]
1123    fn does_not_flag_known_deferred_exponential_cases() {
1124        // Ambiguous alternation under a single quantifier is exponential (EDA)
1125        // but star height 1, so the nested-quantifier detector does *not* flag
1126        // it — a knowingly-deferred follow-up (branch-overlap analysis, #724).
1127        // Pinned here so the scope boundary is explicit and nobody later assumes
1128        // these are covered.
1129        assert!(!redos("(a|a)+"));
1130        assert!(!redos("(\\d|\\d\\d)+"));
1131        assert!(!redos("(foo|foobar)+"));
1132    }
1133
1134    #[test]
1135    fn allows_every_pattern_used_in_the_repo() {
1136        // Guard against a false positive on the `Matches` patterns the fixtures,
1137        // docs, and examples ship — none nests unbounded quantifiers.
1138        for pat in [
1139            "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
1140            "[a-z][a-z0-9-]*",
1141            "[A-Z]{3}-[0-9]{4}",
1142            "[A-Z]{3}",
1143            "[a-z]+",
1144            "[A-Z]+",
1145            "[a-z]+(?<=ing)",
1146            "[a-z0-9_]+",
1147            "[a-z0-9]{1,16}",
1148            "[a-z0-9]{1,8}",
1149            "[A-Z0-9]{3,16}",
1150            "[a-z0-9]{3,8}",
1151            "[a-zA-Z0-9]{6,8}",
1152            "ab|cd",
1153            "AUTH-[0-9]{8}",
1154            "AUTH-[0-9]+",
1155            "CUST-[0-9]+",
1156            "https?://.+",
1157            "ORD-[0-9]{6}",
1158            "ORD-[0-9]+",
1159            "SHP-[0-9]{8}",
1160            "T-[0-9]+",
1161        ] {
1162            assert!(!redos(pat), "false positive on safe pattern `{pat}`");
1163        }
1164        // `LocaleTag`'s pattern is read from the firstparty source rather than
1165        // duplicated here (its own single-source-of-truth rule, ADR 0279), so
1166        // fetch it live instead of hand-copying the string.
1167        let locale_pat = super::locale_tag_pattern().expect("LocaleTag has a Matches predicate");
1168        assert!(
1169            !redos(locale_pat),
1170            "false positive on safe pattern `{locale_pat}`"
1171        );
1172    }
1173}
1174
1175#[cfg(test)]
1176mod locale_tag_tests {
1177    use super::locale_tag_accepts;
1178
1179    #[test]
1180    fn admits_the_pre_existing_shapes() {
1181        for tag in ["en", "pt-BR", "zh-Hans-CN", "es-419"] {
1182            assert!(locale_tag_accepts(tag), "expected `{tag}` to be admitted");
1183        }
1184    }
1185
1186    #[test]
1187    fn admits_variants_extensions_private_use_and_extlang() {
1188        for tag in [
1189            "de-CH-1996",          // variant (digit-led)
1190            "ca-valencia",         // variant (alpha)
1191            "sl-rozaj",            // variant, shortest admitted length
1192            "en-scotland-fonipa",  // two variants
1193            "en-US-u-ca-buddhist", // extension
1194            "de-CH-x-phonebk",     // attached private-use
1195            "x-custom",            // standalone private-use
1196            "zh-yue",              // extlang
1197        ] {
1198            assert!(locale_tag_accepts(tag), "expected `{tag}` to be admitted");
1199        }
1200    }
1201
1202    #[test]
1203    fn rejects_grandfathered_and_malformed_tags() {
1204        for tag in [
1205            "i-klingon",      // grandfathered irregular
1206            "en-GB-oed",      // grandfathered irregular
1207            "Klingon",        // not a tag at all
1208            "pt-br",          // region must be uppercase
1209            "en-SCOTLAND",    // variant must be lowercase
1210            "en-x-abcdefghi", // private-use subtag over 8 chars
1211            "x",              // private-use with no subtag
1212            "",
1213        ] {
1214            assert!(!locale_tag_accepts(tag), "expected `{tag}` to be rejected");
1215        }
1216    }
1217}