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