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