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    // #1651: the runtime checks' order, so a literal rejected here names the
257    // predicate the boundary would.
258    bynk_syntax::ast::in_check_order(&refinement.predicates, |p| &p.kind)
259        .into_iter()
260        .find(|p| !eval_predicate(&p.kind, lit))
261        .map(|p| &p.kind)
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. This cheap first pass keeps its own, clearer
471                    // message; the ambiguity analysis below covers the rest.
472                    errors.push(
473                        CompileError::new(
474                            "bynk.types.catastrophic_regex",
475                            pred.span,
476                            format!(
477                                "the pattern in `Matches(\"{pat}\")` nests unbounded quantifiers, \
478                                 which can cause catastrophic backtracking (ReDoS)"
479                            ),
480                        )
481                        .with_note(
482                            "a repeated group that itself contains `*`, `+`, or `{n,}` makes \
483                             matching take exponential time on crafted input; restructure the \
484                             pattern so no unbounded quantifier is nested inside another",
485                        ),
486                    );
487                } else if let Some(e) = ambiguity_diagnostic(pat, pred.span, refinement) {
488                    errors.push(e);
489                }
490            }
491            PredKind::InRange(lo, hi) => {
492                if lo.value > hi.value {
493                    errors.push(
494                        CompileError::new(
495                            "bynk.types.inverted_range",
496                            pred.span,
497                            format!(
498                                "`InRange({}, {})` has its bounds inverted (`min` must be ≤ `max`)",
499                                lo.value, hi.value
500                            ),
501                        )
502                        // v0.40 (ADR 0073): a machine-applicable swap — replace
503                        // each bound's text with the other's, in place.
504                        .with_suggestion(
505                            "swap the bounds",
506                            vec![
507                                (lo.span, hi.value.to_string()),
508                                (hi.span, lo.value.to_string()),
509                            ],
510                            Applicability::MachineApplicable,
511                        ),
512                    );
513                }
514            }
515            PredKind::InRangeF(lo, hi) => {
516                if lo.value > hi.value {
517                    errors.push(
518                        CompileError::new(
519                            "bynk.types.inverted_range",
520                            pred.span,
521                            format!(
522                                "`InRange({}, {})` has its bounds inverted (`min` must be ≤ `max`)",
523                                lo.lexeme, hi.lexeme
524                            ),
525                        )
526                        .with_suggestion(
527                            "swap the bounds",
528                            vec![(lo.span, hi.lexeme.clone()), (hi.span, lo.lexeme.clone())],
529                            Applicability::MachineApplicable,
530                        ),
531                    );
532                }
533            }
534            PredKind::MinLength(n) | PredKind::MaxLength(n) | PredKind::Length(n) => {
535                if *n < 0 {
536                    errors.push(CompileError::new(
537                        "bynk.types.negative_length",
538                        pred.span,
539                        format!("length argument must be non-negative, got {n}"),
540                    ));
541                }
542            }
543            PredKind::NonNegative | PredKind::Positive | PredKind::NonEmpty => {}
544        }
545    }
546
547    let all_compatible = refinement
548        .predicates
549        .iter()
550        .all(|p| pred_applies_to(&p.kind, base));
551    if !all_compatible {
552        return;
553    }
554    match base {
555        BaseType::Int => check_int_refinement_consistency(refinement, errors),
556        BaseType::String => check_string_refinement_consistency(refinement, errors),
557        BaseType::Bool => {}
558        BaseType::Float => check_float_refinement_consistency(refinement, errors),
559        // v0.86/v0.90/v0.110: no refinement predicate applies to `Duration`,
560        // `Instant`, or `Bytes` (none is in any `pred_applies_to` row), so a
561        // refined one is rejected upstream and there is nothing to
562        // consistency-check here.
563        BaseType::Duration | BaseType::Instant | BaseType::Bytes => {}
564    }
565}
566
567fn pred_applies_to(pred: &PredKind, base: BaseType) -> bool {
568    matches!(
569        (pred, base),
570        (PredKind::Matches(_), BaseType::String)
571            | (PredKind::InRange(_, _), BaseType::Int)
572            | (PredKind::InRangeF(_, _), BaseType::Float)
573            | (PredKind::MinLength(_), BaseType::String)
574            | (PredKind::MaxLength(_), BaseType::String)
575            | (PredKind::Length(_), BaseType::String)
576            | (PredKind::NonNegative, BaseType::Int | BaseType::Float)
577            | (PredKind::Positive, BaseType::Int | BaseType::Float)
578            | (PredKind::NonEmpty, BaseType::String)
579    )
580}
581
582fn predicate_base_help(name: &str) -> &'static str {
583    match name {
584        "Matches" | "MinLength" | "MaxLength" | "Length" | "NonEmpty" => {
585            "this predicate applies to `String` only"
586        }
587        "NonNegative" | "Positive" => "this predicate applies to `Int` and `Float` only",
588        "InRange" => {
589            "this predicate applies to `Int` and `Float` only, with bounds matching the base"
590        }
591        _ => "see the documentation for valid predicate-base combinations",
592    }
593}
594
595pub(crate) fn check_int_refinement_consistency(
596    refinement: &Refinement,
597    errors: &mut Vec<CompileError>,
598) {
599    let mut lo: i64 = i64::MIN;
600    let mut hi: i64 = i64::MAX;
601    for p in &refinement.predicates {
602        match &p.kind {
603            PredKind::Positive => lo = lo.max(1),
604            PredKind::NonNegative => lo = lo.max(0),
605            PredKind::InRange(a, b) => {
606                lo = lo.max(a.value);
607                hi = hi.min(b.value);
608            }
609            _ => {}
610        }
611    }
612    if lo > hi {
613        errors.push(
614            CompileError::new(
615                "bynk.types.empty_refinement",
616                refinement.span,
617                "this refinement has no valid values — the predicates contradict each other",
618            )
619            .with_note(format!(
620                "the effective range is `{lo}..={hi}`, which is empty"
621            )),
622        );
623    }
624}
625
626pub(crate) fn check_float_refinement_consistency(
627    refinement: &Refinement,
628    errors: &mut Vec<CompileError>,
629) {
630    let mut lo = f64::NEG_INFINITY;
631    let mut hi = f64::INFINITY;
632    // `Positive` excludes the lower endpoint (0.0 itself is not positive).
633    let mut lo_exclusive = false;
634    for p in &refinement.predicates {
635        match &p.kind {
636            PredKind::Positive if 0.0 >= lo => {
637                lo = 0.0;
638                lo_exclusive = true;
639            }
640            PredKind::NonNegative if 0.0 > lo => {
641                lo = 0.0;
642                lo_exclusive = false;
643            }
644            PredKind::InRangeF(a, b) => {
645                if a.value > lo {
646                    lo = a.value;
647                    lo_exclusive = false;
648                }
649                hi = hi.min(b.value);
650            }
651            _ => {}
652        }
653    }
654    if lo > hi || (lo == hi && lo_exclusive) {
655        errors.push(
656            CompileError::new(
657                "bynk.types.empty_refinement",
658                refinement.span,
659                "this refinement has no valid values — the predicates contradict each other",
660            )
661            .with_note(format!(
662                "the effective range is `{lo}..={hi}`{}, which is empty",
663                if lo_exclusive {
664                    " (lower bound exclusive)"
665                } else {
666                    ""
667                }
668            )),
669        );
670    }
671}
672
673pub(crate) fn check_string_refinement_consistency(
674    refinement: &Refinement,
675    errors: &mut Vec<CompileError>,
676) {
677    let mut min_len: i64 = 0;
678    let mut max_len: i64 = i64::MAX;
679    let mut exact_len: Option<i64> = None;
680    for p in &refinement.predicates {
681        match &p.kind {
682            PredKind::MinLength(n) => min_len = min_len.max(*n),
683            PredKind::MaxLength(n) => max_len = max_len.min(*n),
684            PredKind::NonEmpty => min_len = min_len.max(1),
685            PredKind::Length(n) => {
686                if let Some(prev) = exact_len {
687                    if prev != *n {
688                        errors.push(CompileError::new(
689                            "bynk.types.empty_refinement",
690                            refinement.span,
691                            format!(
692                                "conflicting exact lengths: `Length({prev})` and `Length({n})` cannot both hold"
693                            ),
694                        ));
695                    }
696                } else {
697                    exact_len = Some(*n);
698                }
699                min_len = min_len.max(*n);
700                max_len = max_len.min(*n);
701            }
702            _ => {}
703        }
704    }
705    if min_len > max_len {
706        errors.push(
707            CompileError::new(
708                "bynk.types.empty_refinement",
709                refinement.span,
710                "this refinement has no valid values — minimum length exceeds maximum length",
711            )
712            .with_note(format!(
713                "the effective length range is `{min_len}..={max_len}`, which is empty"
714            )),
715        );
716    }
717}
718
719// -- function body type checking --
720
721/// v0.9.1: `assert e` as an expression. Test-privileged. Requires `e : Bool`.
722/// Always yields type `()`.
723/// True if a refinement cannot be satisfied by a generated default value — i.e.
724/// it contains a `Matches` predicate, where bare `Val[T]` must be given an
725/// explicit pin instead.
726pub(crate) fn refinement_needs_pin(refinement: &Refinement) -> bool {
727    refinement
728        .predicates
729        .iter()
730        .any(|p| matches!(p.kind, PredKind::Matches(_)))
731}
732
733/// The TypeScript zero-value expression for `type_ref` (with an optional
734/// inline field refinement), or `None` if the type is not zeroable.
735pub fn zero_value_ts(
736    type_ref: &TypeRef,
737    inline: Option<&Refinement>,
738    types: &HashMap<String, Arc<TypeDecl>>,
739) -> Option<String> {
740    zero_value_ts_inner(type_ref, inline, types, &mut Vec::new())
741}
742
743fn zero_value_ts_inner(
744    type_ref: &TypeRef,
745    inline: Option<&Refinement>,
746    types: &HashMap<String, Arc<TypeDecl>>,
747    visiting: &mut Vec<String>,
748) -> Option<String> {
749    match type_ref {
750        TypeRef::Base(b, _) => {
751            if refinement_admits_zero(*b, inline) {
752                zero_of_base(*b)
753            } else {
754                None
755            }
756        }
757        // Option's zero is None, regardless of the inner type.
758        TypeRef::Option(_, _) => Some("None".to_string()),
759        TypeRef::Named(id) => {
760            let decl = types.get(&id.name)?;
761            match &decl.body {
762                TypeBody::Refined {
763                    base, refinement, ..
764                } => {
765                    if refinement_admits_zero(*base, refinement.as_ref()) {
766                        zero_of_base(*base)
767                    } else {
768                        None
769                    }
770                }
771                TypeBody::Record(rec) => {
772                    // A record cycle (`A = { b: B }`, `B = { a: A }`) has no
773                    // finite zero value; without this guard the recursion is
774                    // unbounded and overflows the stack. The resolver rejects
775                    // such cycles, but this walk must terminate regardless of
776                    // what reaches it.
777                    if visiting.iter().any(|n| n == &id.name) {
778                        return None;
779                    }
780                    visiting.push(id.name.clone());
781                    let z = agent_state_zero_record(&rec.fields, types, visiting);
782                    visiting.pop();
783                    z
784                }
785                // Non-Option sum types and opaque types have no defined zero.
786                TypeBody::Sum(_) | TypeBody::Opaque { .. } => None,
787            }
788        }
789        // Result / Effect / HttpResult / ValidationError / Unit are not
790        // admissible state-field types and have no zero.
791        _ => None,
792    }
793}
794
795/// The zero record `{ f₁: z₁, …, fₙ: zₙ }` for a set of fields, or `None` if
796/// any field is not zeroable.
797fn agent_state_zero_record(
798    fields: &[RecordField],
799    types: &HashMap<String, Arc<TypeDecl>>,
800    visiting: &mut Vec<String>,
801) -> Option<String> {
802    let mut parts = Vec::new();
803    for f in fields {
804        let z = zero_value_ts_inner(&f.type_ref, f.refinement.as_ref(), types, visiting)?;
805        parts.push(format!("{}: {}", f.name.name, z));
806    }
807    Some(format!("{{ {} }}", parts.join(", ")))
808}
809
810fn zero_of_base(b: BaseType) -> Option<String> {
811    Some(
812        match b {
813            BaseType::Int => "0",
814            BaseType::Bool => "false",
815            BaseType::String => "\"\"",
816            BaseType::Float => "0",
817            // v0.86/v0.90: a `Duration` is milliseconds and an `Instant` is
818            // epoch milliseconds; the zero of each is `0` (the Unix epoch).
819            BaseType::Duration | BaseType::Instant => "0",
820            // v0.110 (ADR 0142): the zero of `Bytes` is the empty octet
821            // sequence (`""` in base64), erased to an empty `Uint8Array`.
822            BaseType::Bytes => "new globalThis.Uint8Array()",
823        }
824        .to_string(),
825    )
826}
827
828/// Whether the zero value of `base` satisfies every predicate in `refinement`.
829/// Conservative: any predicate we cannot prove admits the zero returns false,
830/// surfacing the `non_zeroable_state_field` diagnostic rather than risking an
831/// invalid fresh state.
832fn refinement_admits_zero(base: BaseType, refinement: Option<&Refinement>) -> bool {
833    let Some(r) = refinement else {
834        return true;
835    };
836    r.predicates.iter().all(|p| pred_admits_zero(base, &p.kind))
837}
838
839fn pred_admits_zero(base: BaseType, k: &PredKind) -> bool {
840    match base {
841        BaseType::Int => match k {
842            PredKind::NonNegative => true,
843            PredKind::Positive => false,
844            PredKind::InRange(lo, hi) => lo.value <= 0 && 0 <= hi.value,
845            // Length/Matches predicates don't apply to Int; reject conservatively.
846            _ => false,
847        },
848        BaseType::String => match k {
849            PredKind::Matches(p) => regex_matches_empty(p),
850            PredKind::MinLength(n) => *n <= 0,
851            PredKind::MaxLength(n) => *n >= 0,
852            PredKind::Length(n) => *n == 0,
853            PredKind::NonEmpty => false,
854            // Numeric predicates don't apply to String; reject conservatively.
855            _ => false,
856        },
857        // The only Bool zero is `false`; no Bool refinement predicates exist.
858        BaseType::Bool => true,
859        // No refinement predicate applies to `Duration`, `Instant`, or
860        // `Bytes`, so the question is vacuous — admit it (mirrors `Bool`).
861        BaseType::Duration | BaseType::Instant | BaseType::Bytes => true,
862        BaseType::Float => match k {
863            PredKind::NonNegative => true,
864            PredKind::Positive => false,
865            PredKind::InRangeF(lo, hi) => lo.value <= 0.0 && 0.0 <= hi.value,
866            // Other predicates don't apply to Float; reject conservatively.
867            _ => false,
868        },
869    }
870}
871
872/// Does the refinement pattern match the empty string? Anchored exactly as the
873/// emitted refined-type constructor anchors it (`^(?:pattern)$`), and
874/// evaluated with the same engine semantics (ECMAScript, no flags).
875fn regex_matches_empty(pattern: &str) -> bool {
876    match regress::Regex::new(&format!("^(?:{pattern})$")) {
877        Ok(re) => re.find("").is_some(),
878        Err(_) => false,
879    }
880}
881
882/// #1651: the diagnostic for a `Matches` pattern the automaton ambiguity
883/// analysis (`regex_ambiguity`) does not prove linear, or `None` when it does.
884///
885/// Exponential ambiguity, and a pattern the analysis cannot bound, are errors.
886/// Polynomial ambiguity is an error unless the refinement also bounds the
887/// input's length (`MaxLength` or `Length`) tightly enough for the
888/// polynomial's degree (`bound^degree` within [`POLYNOMIAL_STEP_CAP`]): the
889/// emitter checks length predicates before `Matches`
890/// (`bynk_syntax::ast::in_check_order`), so such a bound caps the cost, and it
891/// is a warning.
892fn ambiguity_diagnostic(pat: &str, span: Span, refinement: &Refinement) -> Option<CompileError> {
893    use super::regex_ambiguity::{Verdict, analyse};
894    Some(match analyse(pat) {
895        Verdict::Linear => return None,
896        Verdict::Exponential(a, b) => CompileError::new(
897            "bynk.types.catastrophic_regex",
898            span,
899            format!(
900                "the pattern in `Matches(\"{pat}\")` can match the same text in \
901                 exponentially many ways, which can cause catastrophic backtracking (ReDoS)"
902            ),
903        )
904        .with_note(format!(
905            "inside a repetition, `{a}` and `{b}` can match the same character on \
906             different paths, so each repetition doubles the ways a near-miss input \
907             is tried; make the alternatives (or repeat counts) inside the loop \
908             unambiguous, e.g. `(a|aa)+` as `a+`"
909        )),
910        Verdict::Unanalysable(why) => CompileError::new(
911            "bynk.types.catastrophic_regex",
912            span,
913            format!(
914                "the pattern in `Matches(\"{pat}\")` cannot be shown free of catastrophic \
915                 backtracking (ReDoS): {why}"
916            ),
917        )
918        .with_note(
919            "the boundary check runs this pattern on request input under a backtracking \
920             engine, so a pattern that cannot be analysed is rejected; simplify it",
921        ),
922        Verdict::Polynomial(degree, a, b) => {
923            // The tightest length bound the refinement states. Length
924            // predicates run before `Matches` (`in_check_order`), so it caps
925            // the input the pattern ever sees.
926            let bound = refinement
927                .predicates
928                .iter()
929                .filter_map(|p| match p.kind {
930                    PredKind::MaxLength(n) | PredKind::Length(n) => Some(n),
931                    _ => None,
932                })
933                .min();
934            let largest = largest_safe_bound(degree);
935            let (code, advice) = match bound {
936                Some(n) if n <= largest => (
937                    "bynk.types.polynomial_regex_capped",
938                    format!(
939                        "the refinement bounds the input at {n} characters, which keeps \
940                         the cost small, so this is a warning; rewrite the pattern so the \
941                         repetitions cannot overlap to silence it"
942                    ),
943                ),
944                Some(n) => (
945                    "bynk.types.polynomial_regex",
946                    format!(
947                        "the refinement bounds the input at {n} characters, but at degree \
948                         {degree} that still allows about {n}^{degree} steps; lower the \
949                         bound to at most {largest}, or rewrite the pattern so the \
950                         repetitions cannot overlap"
951                    ),
952                ),
953                None => (
954                    "bynk.types.polynomial_regex",
955                    format!(
956                        "add a `MaxLength(n)` predicate with `n` at most {largest} to bound \
957                         the input, or rewrite the pattern so the repetitions cannot overlap"
958                    ),
959                ),
960            };
961            CompileError::new(
962                code,
963                span,
964                format!(
965                    "the pattern in `Matches(\"{pat}\")` can split the same text between \
966                     repetitions in many ways, so matching time grows with the input's \
967                     length to the power {degree} (ReDoS)"
968                ),
969            )
970            .with_note(format!(
971                "`{a}` and `{b}` repeat in sequence and can match the same characters, as \
972                 in `\\d*\\d*`; {advice}"
973            ))
974        }
975    })
976}
977
978/// #1651: how many steps a polynomially ambiguous pattern may take on its
979/// longest admitted input and still only warn. At `10⁷` V8 matches in tens of
980/// milliseconds: `\d*\d*\d*` takes about 23 ms at 256 characters.
981const POLYNOMIAL_STEP_CAP: u128 = 10_000_000;
982
983/// The largest length bound `n` with `n^degree` within [`POLYNOMIAL_STEP_CAP`]:
984/// 3162 at degree 2, 215 at degree 3, 56 at degree 4.
985fn largest_safe_bound(degree: u32) -> i64 {
986    let mut n: i64 = 1;
987    while (n as u128 + 1)
988        .checked_pow(degree)
989        .is_some_and(|s| s <= POLYNOMIAL_STEP_CAP)
990    {
991        n += 1;
992    }
993    n
994}
995
996/// #724 — detect one catastrophic-backtracking (ReDoS) signature: an unbounded
997/// quantifier applied to a group that itself contains an unbounded quantifier
998/// ("star height ≥ 2", e.g. `(a+)+`, `(a*)*b`, `((ab)+)+`). Under the JS
999/// backtracking `RegExp` the emitted boundary check runs, this class takes
1000/// exponential time on a crafted near-miss input; the conservative structural
1001/// rule rejects it at compile time.
1002///
1003/// "Unbounded" means `*`, `+`, or `{n,}` (open upper bound). A bounded
1004/// quantifier is not flagged here, though one inside a loop can explode too
1005/// (`(a{2,3})+`); that is the ambiguity analysis's case. The scan is purely structural — the pattern is
1006/// already known valid (`regress` accepted it) — so it need not model match
1007/// semantics, only quantifier nesting through groups. Inner unbounded
1008/// quantifiers propagate up through *bounded* quantifiers too, so `((a+)?)+`
1009/// is still caught. The check is conservative in the safe direction: it can
1010/// reject a star-height-2 pattern whose sub-expressions provably never overlap,
1011/// but every *nested-quantifier* blowup is flagged.
1012///
1013/// This is a cheap first pass, not the whole exponential class. Ambiguous
1014/// alternation under a single quantifier (`(a|a)+`, `(\d|\d\d)+`), a bounded
1015/// quantifier inside a loop (`(a{2,3})+`) and the polynomial class
1016/// (`\d*\d*`) are star height 1 and pass here. `regex_ambiguity` (#1651)
1017/// decides them on the pattern's automaton, and runs only on patterns this
1018/// pass accepts, so this pass's clearer message wins where both apply.
1019fn has_nested_unbounded_quantifier(pat: &str) -> bool {
1020    // Precondition: `pat` is a valid regex (`regress` accepted it), so its
1021    // parentheses are balanced. The `stack.last_mut().unwrap()` arms below rely
1022    // on that — a `)` never pops the root frame — which the sole caller ensures
1023    // by running this only after the validity check. The `)` arm keeps a
1024    // defensive `unwrap_or` regardless.
1025    let chars: Vec<char> = pat.chars().collect();
1026    // One boolean per open group (index 0 = top level): does this group contain
1027    // an unbounded quantifier anywhere within it?
1028    let mut stack: Vec<bool> = vec![false];
1029    // The atom a following quantifier would apply to: `None` if none is pending,
1030    // else `Some(inner_unbounded)` where `inner_unbounded` is true when that atom
1031    // is a group carrying an unbounded quantifier inside it.
1032    let mut pending: Option<bool> = None;
1033    let mut i = 0;
1034
1035    // Fold a pending atom that turned out to be unquantified into the current
1036    // frame: if it was a group with inner unbounded content, that content still
1037    // lives in the enclosing group.
1038    fn fold(pending: &mut Option<bool>, stack: &mut [bool]) {
1039        if pending.take() == Some(true) {
1040            *stack.last_mut().unwrap() = true;
1041        }
1042    }
1043
1044    while i < chars.len() {
1045        match chars[i] {
1046            // An escape is a single atom; skip the escaped char.
1047            '\\' => {
1048                fold(&mut pending, &mut stack);
1049                i += 2;
1050                pending = Some(false);
1051            }
1052            // A character class is one atom; `*`/`+`/`{` inside it are literal.
1053            '[' => {
1054                fold(&mut pending, &mut stack);
1055                i += 1;
1056                if i < chars.len() && chars[i] == '^' {
1057                    i += 1;
1058                }
1059                // A leading `]` is a literal member, not the class terminator.
1060                if i < chars.len() && chars[i] == ']' {
1061                    i += 1;
1062                }
1063                while i < chars.len() && chars[i] != ']' {
1064                    if chars[i] == '\\' {
1065                        i += 1;
1066                    }
1067                    i += 1;
1068                }
1069                i += 1; // consume the closing `]`
1070                pending = Some(false);
1071            }
1072            '(' => {
1073                fold(&mut pending, &mut stack);
1074                stack.push(false);
1075                i += 1;
1076                // Skip a group-type prefix so its punctuation is not mistaken for
1077                // an atom: `(?:`, `(?=`, `(?!`, `(?<=`, `(?<!`, `(?<name>`.
1078                if i < chars.len() && chars[i] == '?' {
1079                    i += 1;
1080                    if i < chars.len() && matches!(chars[i], ':' | '=' | '!') {
1081                        i += 1;
1082                    } else if i < chars.len() && chars[i] == '<' {
1083                        i += 1;
1084                        if i < chars.len() && matches!(chars[i], '=' | '!') {
1085                            i += 1;
1086                        } else {
1087                            while i < chars.len() && chars[i] != '>' {
1088                                i += 1;
1089                            }
1090                            if i < chars.len() {
1091                                i += 1; // consume `>`
1092                            }
1093                        }
1094                    }
1095                }
1096            }
1097            ')' => {
1098                fold(&mut pending, &mut stack);
1099                let closed = stack.pop().unwrap_or(false);
1100                i += 1;
1101                // The whole group is now an atom in its parent frame.
1102                pending = Some(closed);
1103            }
1104            // Alternation ends the current atom; an unbounded one folds in.
1105            '|' => {
1106                fold(&mut pending, &mut stack);
1107                i += 1;
1108            }
1109            // Unbounded quantifier.
1110            '*' | '+' => {
1111                let atom_unbounded = pending.take().unwrap_or(false);
1112                if atom_unbounded {
1113                    return true; // unbounded nested inside unbounded
1114                }
1115                *stack.last_mut().unwrap() = true;
1116                i += 1;
1117                if i < chars.len() && chars[i] == '?' {
1118                    i += 1; // lazy marker
1119                }
1120            }
1121            // Optional: bounded, but inner unbounded content still propagates.
1122            '?' => {
1123                if let Some(atom_unbounded) = pending.take() {
1124                    if atom_unbounded {
1125                        *stack.last_mut().unwrap() = true;
1126                    }
1127                    i += 1;
1128                    if i < chars.len() && chars[i] == '?' {
1129                        i += 1; // lazy marker
1130                    }
1131                } else {
1132                    // Stray `?` (unreachable for a valid pattern) — treat as atom.
1133                    i += 1;
1134                    pending = Some(false);
1135                }
1136            }
1137            '{' => {
1138                if let Some((unbounded_q, next)) = parse_brace_quantifier(&chars, i) {
1139                    let atom_unbounded = pending.take().unwrap_or(false);
1140                    if unbounded_q && atom_unbounded {
1141                        return true;
1142                    }
1143                    if unbounded_q || atom_unbounded {
1144                        *stack.last_mut().unwrap() = true;
1145                    }
1146                    i = next;
1147                    if i < chars.len() && chars[i] == '?' {
1148                        i += 1; // lazy marker
1149                    }
1150                } else {
1151                    // A `{` that is not a quantifier is a literal atom.
1152                    fold(&mut pending, &mut stack);
1153                    i += 1;
1154                    pending = Some(false);
1155                }
1156            }
1157            // Any other char (`.`, literal, `^`, `$`) is an ordinary atom.
1158            _ => {
1159                fold(&mut pending, &mut stack);
1160                i += 1;
1161                pending = Some(false);
1162            }
1163        }
1164    }
1165    false
1166}
1167
1168/// Parse a `{m}`, `{m,}`, or `{m,n}` quantifier starting at `chars[start] == '{'`.
1169/// Returns `(is_unbounded, index_after_close)` when it is a well-formed
1170/// quantifier — `is_unbounded` is true only for `{m,}` (open upper bound) — or
1171/// `None` when the braces are not a quantifier (then they are literal text, as
1172/// JS `RegExp` treats them).
1173fn parse_brace_quantifier(chars: &[char], start: usize) -> Option<(bool, usize)> {
1174    let mut i = start + 1;
1175    let lo_start = i;
1176    while i < chars.len() && chars[i].is_ascii_digit() {
1177        i += 1;
1178    }
1179    if i == lo_start {
1180        return None; // `{m` requires at least one digit
1181    }
1182    let mut unbounded = false;
1183    if i < chars.len() && chars[i] == ',' {
1184        i += 1;
1185        let hi_start = i;
1186        while i < chars.len() && chars[i].is_ascii_digit() {
1187            i += 1;
1188        }
1189        if i == hi_start {
1190            unbounded = true; // `{m,}` — no upper bound
1191        }
1192    }
1193    if i < chars.len() && chars[i] == '}' {
1194        Some((unbounded, i + 1))
1195    } else {
1196        None
1197    }
1198}
1199
1200#[cfg(test)]
1201mod redos_tests {
1202    use super::has_nested_unbounded_quantifier as redos;
1203
1204    #[test]
1205    fn flags_nested_unbounded_quantifiers() {
1206        // The classic exponential shapes: an unbounded quantifier over a group
1207        // that itself repeats unboundedly.
1208        assert!(redos("(a+)+"));
1209        assert!(redos("(a+)+$"));
1210        assert!(redos("(a*)*"));
1211        assert!(redos("(a+)*"));
1212        assert!(redos("(a*)+"));
1213        assert!(redos("((ab)+)+"));
1214        assert!(redos("(a{1,})+")); // `{1,}` is unbounded
1215        assert!(redos("(a+){2,}")); // outer open bound over inner `+`
1216        assert!(redos("x(y(z+)+w)+")); // nested a level deep
1217        assert!(redos("(\\d+)+"));
1218        assert!(redos("(?:a+)+")); // non-capturing group
1219        assert!(redos("([a-z]+)*")); // class inside the repeated group
1220        assert!(redos("((a+)?)+")); // inner unbounded propagates through `?`
1221        assert!(redos("(a+|b)+")); // alternation does not launder the nesting
1222    }
1223
1224    #[test]
1225    fn allows_safe_patterns() {
1226        // A single quantifier level is fine, however placed.
1227        assert!(!redos("a+"));
1228        assert!(!redos("(a+)")); // repeated once, not nested
1229        assert!(!redos("(a+)(b+)")); // siblings, not nested
1230        assert!(!redos("(ab)+")); // repeated group with no inner quantifier
1231        assert!(!redos("(a+)?")); // bounded outer quantifier
1232        assert!(!redos("(a+){2,3}")); // finite outer bound
1233        // Not flagged *here*, though exponential: a bounded quantifier inside a
1234        // loop is the ambiguity analysis's case (#1651), pinned below.
1235        assert!(!redos("(a{2,3})+"));
1236        assert!(!redos("[a-z]+")); // `+` binds the class, not a group
1237        assert!(!redos("a{2,}b{2,}")); // two unbounded, neither nested
1238    }
1239
1240    #[test]
1241    fn the_ambiguity_analysis_covers_what_the_first_pass_does_not() {
1242        // #1651: star-height-1 shapes the first pass passes, and the ambiguity
1243        // analysis decides. `(foo|foobar)+` is unambiguous ({foo, foobar} is
1244        // uniquely decodable), so it is linear and accepted.
1245        use super::super::regex_ambiguity::{Verdict, analyse};
1246        for pat in [
1247            "(a|a)+",
1248            "(\\d|\\d\\d)+",
1249            "(a|aa)*",
1250            "(a{1,2})+",
1251            "(a{2,3})+",
1252        ] {
1253            assert!(!redos(pat), "the first pass is not meant to flag `{pat}`");
1254            assert!(
1255                matches!(analyse(pat), Verdict::Exponential(..)),
1256                "`{pat}` is exponential"
1257            );
1258        }
1259        assert!(matches!(analyse("\\d*\\d*"), Verdict::Polynomial(..)));
1260        assert_eq!(analyse("(foo|foobar)+"), Verdict::Linear);
1261    }
1262
1263    #[test]
1264    fn allows_every_pattern_used_in_the_repo() {
1265        // Guard against a false positive on the `Matches` patterns the fixtures,
1266        // docs, and examples ship — none nests unbounded quantifiers.
1267        for pat in [
1268            "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
1269            "[a-z][a-z0-9-]*",
1270            "[A-Z]{3}-[0-9]{4}",
1271            "[A-Z]{3}",
1272            "[a-z]+",
1273            "[A-Z]+",
1274            "[a-z]+(?<=ing)",
1275            "[a-z0-9_]+",
1276            "[a-z0-9]{1,16}",
1277            "[a-z0-9]{1,8}",
1278            "[A-Z0-9]{3,16}",
1279            "[a-z0-9]{3,8}",
1280            "[a-zA-Z0-9]{6,8}",
1281            "ab|cd",
1282            "AUTH-[0-9]{8}",
1283            "AUTH-[0-9]+",
1284            "CUST-[0-9]+",
1285            "https?://.+",
1286            "ORD-[0-9]{6}",
1287            "ORD-[0-9]+",
1288            "SHP-[0-9]{8}",
1289            "T-[0-9]+",
1290            "[A-Z0-9]{8}",
1291            "[A-Z]{2}[0-9]{2}",
1292            "^[a-z]+$",
1293            "\\d{4}",
1294            "\\d+",
1295        ] {
1296            assert!(!redos(pat), "false positive on safe pattern `{pat}`");
1297            assert_eq!(
1298                super::super::regex_ambiguity::analyse(pat),
1299                super::super::regex_ambiguity::Verdict::Linear,
1300                "the ambiguity analysis rejects safe pattern `{pat}`"
1301            );
1302        }
1303        // `LocaleTag`'s pattern is read from the firstparty source rather than
1304        // duplicated here (its own single-source-of-truth rule, ADR 0279), so
1305        // fetch it live instead of hand-copying the string.
1306        let locale_pat = super::locale_tag_pattern().expect("LocaleTag has a Matches predicate");
1307        assert!(
1308            !redos(locale_pat),
1309            "false positive on safe pattern `{locale_pat}`"
1310        );
1311        assert_eq!(
1312            super::super::regex_ambiguity::analyse(locale_pat),
1313            super::super::regex_ambiguity::Verdict::Linear,
1314            "the ambiguity analysis rejects `LocaleTag`"
1315        );
1316    }
1317}
1318
1319#[cfg(test)]
1320mod locale_tag_tests {
1321    use super::locale_tag_accepts;
1322
1323    #[test]
1324    fn admits_the_pre_existing_shapes() {
1325        for tag in ["en", "pt-BR", "zh-Hans-CN", "es-419"] {
1326            assert!(locale_tag_accepts(tag), "expected `{tag}` to be admitted");
1327        }
1328    }
1329
1330    #[test]
1331    fn admits_variants_extensions_private_use_and_extlang() {
1332        for tag in [
1333            "de-CH-1996",          // variant (digit-led)
1334            "ca-valencia",         // variant (alpha)
1335            "sl-rozaj",            // variant, shortest admitted length
1336            "en-scotland-fonipa",  // two variants
1337            "en-US-u-ca-buddhist", // extension
1338            "de-CH-x-phonebk",     // attached private-use
1339            "x-custom",            // standalone private-use
1340            "zh-yue",              // extlang
1341        ] {
1342            assert!(locale_tag_accepts(tag), "expected `{tag}` to be admitted");
1343        }
1344    }
1345
1346    #[test]
1347    fn rejects_grandfathered_and_malformed_tags() {
1348        for tag in [
1349            "i-klingon",      // grandfathered irregular
1350            "en-GB-oed",      // grandfathered irregular
1351            "Klingon",        // not a tag at all
1352            "pt-br",          // region must be uppercase
1353            "en-SCOTLAND",    // variant must be lowercase
1354            "en-x-abcdefghi", // private-use subtag over 8 chars
1355            "x",              // private-use with no subtag
1356            "",
1357        ] {
1358            assert!(!locale_tag_accepts(tag), "expected `{tag}` to be rejected");
1359        }
1360    }
1361}