Skip to main content

maskforge_core/
compile.rs

1//! The production compiler: a validated arena `SchemaIR` is lowered directly to a byte-level
2//! pattern and handed to the shared anchored-DFA builder. It consumes the typed nodes; it never
3//! re-parses JSON and never transports a regex string across the FFI.
4
5use crate::automaton::{
6    build_from_regex, concatenate, multiple_of_engine, repeat, string_set, union, Combinator,
7    ProductAutomaton, RefEngine, ShuffleAutomaton,
8};
9#[cfg(test)]
10use crate::automaton::{decimal_multiple_of_regex, multiple_of_regex};
11use crate::diagnostics::{Diagnostic, UnsupportedReason};
12use crate::error::{CompileError, ErrorCode, Stage};
13use crate::ir::{AdditionalPolicy, Charset, ItemsPolicy, Node, ScalarLit, SchemaIR};
14use crate::primitives::NodeId;
15
16/// Named compilation phases reported by the feature-gated profiling API.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18#[repr(u8)]
19pub enum CompilePhase {
20    /// Read schema and resource bytes.
21    InputLoad,
22    /// Parse JSON text.
23    JsonParse,
24    /// Index registered resources.
25    ResourceIndex,
26    /// Index static and dynamic anchors.
27    AnchorIndex,
28    /// Resolve references.
29    ReferenceResolution,
30    /// Lower the frontend representation to IR.
31    IrLowering,
32    /// Compute the shared route table.
33    RouteAnalysis,
34    /// Parse regular-expression syntax.
35    RegexParse,
36    /// Build Thompson NFAs.
37    ThompsonNfa,
38    /// Determinize NFAs.
39    Determinization,
40    /// Materialize the MaskForge DFA representation.
41    DfaMaterialization,
42    /// Construct byte equivalence classes.
43    ByteClassConstruction,
44    /// Analyze structured semantics and annotations.
45    StructuredAnalysis,
46    /// Build structured plan nodes.
47    StructuredNodeBuild,
48    /// Resolve plan SCCs.
49    SccResolution,
50    /// Freeze the immutable plan.
51    PlanFreeze,
52    /// Look up a vocabulary-neutral executable.
53    SchemaCacheLookup,
54    /// Look up or build a vocabulary trie.
55    TrieLookup,
56    /// Bind an executable to a vocabulary.
57    VocabularyBind,
58    /// Fill packed mask rows.
59    PackedMaskFill,
60}
61
62impl CompilePhase {
63    /// Number of named phases.
64    pub const COUNT: usize = 20;
65}
66
67/// Nanoseconds indexed by [`CompilePhase`]. Unobserved phases remain zero.
68#[derive(Clone, Debug, Default, Eq, PartialEq)]
69pub struct PhaseDurations {
70    values: [u64; CompilePhase::COUNT],
71}
72
73impl PhaseDurations {
74    /// Duration recorded for `phase`.
75    #[must_use]
76    pub fn get(&self, phase: CompilePhase) -> u64 {
77        self.values[phase as usize]
78    }
79
80    #[cfg(feature = "bench-internals")]
81    fn set(&mut self, phase: CompilePhase, value: u64) {
82        self.values[phase as usize] = value;
83    }
84}
85
86/// Aggregate compiler work counts. Counters that a backend cannot expose remain zero.
87#[derive(Clone, Debug, Default, Eq, PartialEq)]
88pub struct CompileCounters {
89    /// Input schema bytes.
90    pub schema_bytes: u64,
91    /// Registered resource bytes.
92    pub resource_bytes: u64,
93    /// Frozen IR nodes.
94    pub ir_nodes: u64,
95    /// Frozen IR child edges.
96    pub ir_edges: u64,
97    /// Nodes routed through regular execution.
98    pub regular_nodes: u64,
99    /// Nodes routed through structured or hybrid execution.
100    pub structured_nodes: u64,
101    /// Materialized DFA states.
102    pub dfa_states: u64,
103    /// Materialized byte classes.
104    pub byte_classes: u64,
105    /// Stored non-dead DFA edges.
106    pub graph_edges: u64,
107    /// Theoretical DFA transition cells.
108    pub transition_cells: u64,
109    /// Structured plan nodes.
110    pub structured_plan_nodes: u64,
111    /// SCC nodes examined.
112    pub scc_nodes: u64,
113}
114
115/// Retained and temporary byte accounting for one compile.
116#[derive(Clone, Debug, Default, Eq, PartialEq)]
117pub struct RetainedBytes {
118    /// Frozen IR bytes when supplied by the caller, if measured.
119    pub ir: u64,
120    /// Regular automaton bytes.
121    pub automaton: u64,
122    /// Structured plan bytes.
123    pub plan: u64,
124    /// Conservative peak temporary bytes.
125    pub estimated_peak_temporary: u64,
126}
127
128/// Named summary profile for regular IR compilation.
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct CompileProfile {
131    /// Per-phase wall durations.
132    pub durations_ns: PhaseDurations,
133    /// Backend work counters.
134    pub counters: CompileCounters,
135    /// Retained and temporary byte estimates.
136    pub retained: RetainedBytes,
137    /// Selected root route.
138    pub route: crate::routing::ExecutionKind,
139}
140
141// Array/tuple wrapping duplicates its element text; an already-large child (e.g. a oneOf's own
142// eliminated regex) can double past a safe re-compile size without either side alone looking big.
143const MAX_ARRAY_BODY_BYTES: usize = 1 << 16;
144
145fn require_regex_fits(len: usize) -> Result<(), CompileError> {
146    if len > MAX_ARRAY_BODY_BYTES {
147        return Err(CompileError::new(
148            ErrorCode::Unsupported,
149            Stage::L3,
150            UnsupportedReason::StructuredBackendRequired.advisory(),
151        ));
152    }
153    Ok(())
154}
155
156/// Compiles an IR into the anchored byte engine. An `Unsupported` occurrence fails the compile
157/// with its diagnostic; a structured-backend schema fails fast with an actionable message instead of
158/// a slow, cryptic elimination blow-up; otherwise the arena is lowered and built into a DFA.
159pub fn compile_ir(ir: &SchemaIR) -> Result<RefEngine, CompileError> {
160    if let Some(d) = ir.diagnostics().next() {
161        return Err(diagnostic_error(ir, d));
162    }
163    if ir.requires_structured_backend() {
164        return Err(CompileError::new(
165            ErrorCode::Unsupported,
166            Stage::L3,
167            UnsupportedReason::StructuredBackendRequired.advisory(),
168        ));
169    }
170    compile_node(ir, ir.root())
171}
172
173/// Compiles a regular IR and returns named phase, work, route, and memory measurements.
174#[cfg(feature = "bench-internals")]
175pub fn compile_ir_profiled(ir: &SchemaIR) -> Result<(RefEngine, CompileProfile), CompileError> {
176    if let Some(d) = ir.diagnostics().next() {
177        return Err(diagnostic_error(ir, d));
178    }
179    let mut durations_ns = PhaseDurations::default();
180    let route_started = std::time::Instant::now();
181    let route_table = ir.route_table();
182    let route = route_table
183        .get(ir.root())
184        .map_or(crate::routing::ExecutionKind::Structured, |route| {
185            route.kind
186        });
187    durations_ns.set(
188        CompilePhase::RouteAnalysis,
189        u64::try_from(route_started.elapsed().as_nanos()).unwrap_or(u64::MAX),
190    );
191    if route != crate::routing::ExecutionKind::Regular {
192        return Err(CompileError::new(
193            ErrorCode::Unsupported,
194            Stage::L3,
195            UnsupportedReason::StructuredBackendRequired.advisory(),
196        ));
197    }
198    let started = std::time::Instant::now();
199    let engine = compile_node(ir, ir.root())?;
200    durations_ns.set(
201        CompilePhase::DfaMaterialization,
202        u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX),
203    );
204    let regular_nodes = route_table
205        .routes()
206        .iter()
207        .filter(|node| node.kind == crate::routing::ExecutionKind::Regular)
208        .count();
209    let counters = CompileCounters {
210        ir_nodes: u64::try_from(ir.node_count()).unwrap_or(u64::MAX),
211        regular_nodes: u64::try_from(regular_nodes).unwrap_or(u64::MAX),
212        structured_nodes: u64::try_from(ir.node_count().saturating_sub(regular_nodes))
213            .unwrap_or(u64::MAX),
214        dfa_states: u64::try_from(engine.state_count()).unwrap_or(u64::MAX),
215        byte_classes: u64::try_from(engine.byte_class_count()).unwrap_or(u64::MAX),
216        graph_edges: u64::try_from(engine.live_transition_count()).unwrap_or(u64::MAX),
217        transition_cells: u64::try_from(engine.transition_cells()).unwrap_or(u64::MAX),
218        ..CompileCounters::default()
219    };
220    let retained = RetainedBytes {
221        automaton: u64::try_from(engine.heap_bytes()).unwrap_or(u64::MAX),
222        ..RetainedBytes::default()
223    };
224    Ok((
225        engine,
226        CompileProfile {
227            durations_ns,
228            counters,
229            retained,
230            route,
231        },
232    ))
233}
234
235/// Compatibility timing wrapper returning `(route_analysis_ns, materialization_ns)`.
236pub fn compile_ir_timed(ir: &SchemaIR) -> Result<(RefEngine, u64, u64), CompileError> {
237    if let Some(d) = ir.diagnostics().next() {
238        return Err(diagnostic_error(ir, d));
239    }
240    let route_started = std::time::Instant::now();
241    let route = ir.route_table().get(ir.root()).map(|route| route.kind);
242    let route_analysis_ns = u64::try_from(route_started.elapsed().as_nanos()).unwrap_or(u64::MAX);
243    if route != Some(crate::routing::ExecutionKind::Regular) {
244        return Err(CompileError::new(
245            ErrorCode::Unsupported,
246            Stage::L3,
247            UnsupportedReason::StructuredBackendRequired.advisory(),
248        ));
249    }
250    let started = std::time::Instant::now();
251    let engine = compile_node(ir, ir.root())?;
252    let materialization_ns = u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX);
253    Ok((engine, route_analysis_ns, materialization_ns))
254}
255
256/// Compiles the subtree rooted at `id` alone into its own anchored byte engine; used by the
257/// structured backend to fast-path a sub-schema that has no `OpenObject` anywhere beneath it.
258pub(crate) fn compile_node(ir: &SchemaIR, id: NodeId) -> Result<RefEngine, CompileError> {
259    let node = ir
260        .node(id)
261        .ok_or_else(|| internal("node id out of range"))?;
262    if !uses_direct_compilation(ir, node) {
263        let body = lower_reachable(ir, id)?;
264        return build_from_regex(&wrapped_regex(&body)?);
265    }
266    let body = compile_unwrapped(ir, id)?;
267    surround_engine(&body, JSON_WS_RE, JSON_WS_RE)
268}
269
270fn uses_direct_compilation(ir: &SchemaIR, node: &Node) -> bool {
271    match node {
272        Node::Integer {
273            multiple_of: Some(_),
274            ..
275        }
276        | Node::Intersection { .. }
277        | Node::ExactlyOne { .. }
278        | Node::Union { .. } => true,
279        Node::Enum { values } => ir
280            .lits_at(*values)
281            .is_some_and(|items| items.iter().all(|item| matches!(item, ScalarLit::Str(_)))),
282        Node::Object {
283            dependent,
284            dependent_required,
285            ..
286        } => {
287            ir.props_at(*dependent).is_some_and(<[_]>::is_empty)
288                && ir
289                    .dependent_required_at(*dependent_required)
290                    .is_some_and(<[_]>::is_empty)
291        }
292        Node::Array {
293            items: ItemsPolicy::Schema(_),
294            unique_items: false,
295            contains: None,
296            ..
297        }
298        | Node::Tuple {
299            unique_items: false,
300            contains: None,
301            ..
302        } => true,
303        _ => false,
304    }
305}
306
307fn wrapped_regex(body: &str) -> Result<String, CompileError> {
308    let capacity = JSON_WS_RE
309        .len()
310        .checked_mul(2)
311        .and_then(|bytes| bytes.checked_add(body.len()))
312        .ok_or_else(|| compile_allocation("wrapped regex length"))?;
313    let mut pattern = String::new();
314    pattern
315        .try_reserve_exact(capacity)
316        .map_err(|_| compile_allocation("wrapped regex allocation"))?;
317    pattern.push_str(JSON_WS_RE);
318    pattern.push_str(body);
319    pattern.push_str(JSON_WS_RE);
320    Ok(pattern)
321}
322
323fn compile_unwrapped(ir: &SchemaIR, id: NodeId) -> Result<RefEngine, CompileError> {
324    let node = ir
325        .node(id)
326        .ok_or_else(|| internal("node id out of range"))?;
327    match node {
328        Node::Integer {
329            minimum,
330            maximum,
331            multiple_of: Some(modulus),
332        } => compile_integer_multiple(*minimum, *maximum, *modulus),
333        Node::Intersection { branches } => compile_product_node(ir, *branches, Combinator::All),
334        Node::ExactlyOne { branches } => {
335            compile_product_node(ir, *branches, Combinator::ExactlyOne)
336        }
337        Node::Union { branches } => compile_union_node(ir, *branches),
338        Node::Enum { values }
339            if ir.lits_at(*values).is_some_and(|literals| {
340                literals
341                    .iter()
342                    .all(|item| matches!(item, ScalarLit::Str(_)))
343            }) =>
344        {
345            compile_string_enum(ir, *values)
346        }
347        Node::Object {
348            fields,
349            required,
350            dependent,
351            dependent_required,
352            ..
353        } if ir
354            .props_at(*dependent)
355            .is_some_and(|items| items.is_empty())
356            && ir
357                .dependent_required_at(*dependent_required)
358                .is_some_and(|items| items.is_empty()) =>
359        {
360            compile_object_node(ir, *fields, *required)
361        }
362        Node::Array {
363            items: ItemsPolicy::Schema(item),
364            min_items,
365            max_items,
366            unique_items: false,
367            contains: None,
368            ..
369        } => compile_array_node(ir, *item, *min_items, *max_items),
370        Node::Tuple {
371            prefix,
372            tail,
373            min_items,
374            max_items,
375            unique_items: false,
376            contains: None,
377            ..
378        } => compile_tuple_node(ir, *prefix, *tail, *min_items, *max_items),
379        _ => build_from_regex(&lower_reachable(ir, id)?),
380    }
381}
382
383fn compile_integer_multiple(
384    minimum: Option<i64>,
385    maximum: Option<i64>,
386    modulus: u64,
387) -> Result<RefEngine, CompileError> {
388    let range = build_from_regex(&integer_regex(minimum, maximum))?;
389    let multiple = multiple_of_engine(modulus)?;
390    ProductAutomaton::build(&[&range, &multiple], Combinator::All)?.into_engine()
391}
392
393fn compile_string_enum(
394    ir: &SchemaIR,
395    values: crate::ir::LitSlice,
396) -> Result<RefEngine, CompileError> {
397    let literals = ir.lits_at(values).ok_or_else(|| internal("enum ref"))?;
398    let mut strings = Vec::new();
399    strings
400        .try_reserve_exact(literals.len())
401        .map_err(|_| compile_allocation("enum string reference allocation"))?;
402    for literal in literals {
403        if let ScalarLit::Str(value) = literal {
404            strings.push(value.as_str());
405        }
406    }
407    RefEngine::from_graph(&string_set(&strings)?)
408}
409
410fn compile_product_node(
411    ir: &SchemaIR,
412    branches: crate::ir::RefSlice,
413    combinator: Combinator,
414) -> Result<RefEngine, CompileError> {
415    let ids = ir.refs_at(branches).ok_or_else(|| internal("branch ref"))?;
416    let mut engines = Vec::new();
417    engines
418        .try_reserve_exact(ids.len())
419        .map_err(|_| compile_allocation("product engine allocation"))?;
420    for &id in ids {
421        engines.push(compile_unwrapped(ir, id)?);
422    }
423    let refs = engine_refs(&engines)?;
424    ProductAutomaton::build(&refs, combinator)?.into_engine()
425}
426
427fn compile_union_node(
428    ir: &SchemaIR,
429    branches: crate::ir::RefSlice,
430) -> Result<RefEngine, CompileError> {
431    let ids = ir.refs_at(branches).ok_or_else(|| internal("branch ref"))?;
432    let mut engines = Vec::new();
433    engines
434        .try_reserve_exact(ids.len())
435        .map_err(|_| compile_allocation("union engine allocation"))?;
436    for &id in ids {
437        engines.push(compile_unwrapped(ir, id)?);
438    }
439    let refs = engine_refs(&engines)?;
440    RefEngine::from_graph(&union(&refs)?)
441}
442
443fn compile_object_node(
444    ir: &SchemaIR,
445    fields: crate::ir::PropSlice,
446    required: crate::ir::BitSlice,
447) -> Result<RefEngine, CompileError> {
448    let properties = ir.props_at(fields).ok_or_else(|| internal("object ref"))?;
449    let mut field_engines = Vec::new();
450    let mut required_flags = Vec::new();
451    field_engines
452        .try_reserve_exact(properties.len())
453        .map_err(|_| compile_allocation("field engine allocation"))?;
454    required_flags
455        .try_reserve_exact(properties.len())
456        .map_err(|_| compile_allocation("field flag allocation"))?;
457    for (index, (name, child_id)) in properties.iter().enumerate() {
458        let name = ir.str_at(*name).ok_or_else(|| internal("field name"))?;
459        let prefix = build_from_regex(&format!(
460            "{}{}",
461            escape_regex(&json_encode(name)),
462            colon_sep()
463        ))?;
464        let child = compile_unwrapped(ir, *child_id)?;
465        field_engines.push(concat_engine(&[&prefix, &child])?);
466        let index = u32::try_from(index).map_err(|_| internal("field index overflow"))?;
467        required_flags.push(ir.is_required(required, index));
468    }
469    let refs = engine_refs(&field_engines)?;
470    let separator = build_from_regex(&comma_sep())?;
471    let body = ShuffleAutomaton::build(&refs, &required_flags, &separator)?.into_engine()?;
472    surround_engine(
473        &body,
474        &format!(r"\{{{JSON_WS_RE}"),
475        &format!(r"{JSON_WS_RE}\}}"),
476    )
477}
478
479fn compile_array_node(
480    ir: &SchemaIR,
481    item: NodeId,
482    min_items: u32,
483    max_items: Option<u32>,
484) -> Result<RefEngine, CompileError> {
485    let item = compile_unwrapped(ir, item)?;
486    let comma = build_from_regex(&comma_sep())?;
487    let comma_item = concat_engine(&[&comma, &item])?;
488    let body = if max_items == Some(0) {
489        build_from_regex("")?
490    } else if min_items == 0 {
491        let tail_max = max_items.map(|max| max.saturating_sub(1));
492        let tail = RefEngine::from_graph(&repeat(&comma_item, 0, tail_max)?)?;
493        let nonempty = concat_engine(&[&item, &tail])?;
494        let empty = build_from_regex("")?;
495        RefEngine::from_graph(&union(&[&empty, &nonempty])?)?
496    } else {
497        let tail = RefEngine::from_graph(&repeat(
498            &comma_item,
499            min_items - 1,
500            max_items.map(|max| max - 1),
501        )?)?;
502        concat_engine(&[&item, &tail])?
503    };
504    surround_engine(
505        &body,
506        &format!(r"\[{JSON_WS_RE}"),
507        &format!(r"{JSON_WS_RE}\]"),
508    )
509}
510
511fn compile_tuple_node(
512    ir: &SchemaIR,
513    prefix: crate::ir::RefSlice,
514    tail: Option<NodeId>,
515    min_items: u32,
516    max_items: Option<u32>,
517) -> Result<RefEngine, CompileError> {
518    let ids = ir.refs_at(prefix).ok_or_else(|| internal("tuple ref"))?;
519    let mut prefix_engines = Vec::new();
520    prefix_engines
521        .try_reserve_exact(ids.len())
522        .map_err(|_| compile_allocation("tuple engine allocation"))?;
523    for &id in ids {
524        prefix_engines.push(compile_unwrapped(ir, id)?);
525    }
526    let tail_engine = tail.map(|id| compile_unwrapped(ir, id)).transpose()?;
527    let body = tuple_body_engine(&prefix_engines, tail_engine.as_ref(), min_items, max_items)?;
528    surround_engine(
529        &body,
530        &format!(r"\[{JSON_WS_RE}"),
531        &format!(r"{JSON_WS_RE}\]"),
532    )
533}
534
535fn tuple_body_engine(
536    prefix: &[RefEngine],
537    tail: Option<&RefEngine>,
538    min_items: u32,
539    max_items: Option<u32>,
540) -> Result<RefEngine, CompileError> {
541    let prefix_len = u32::try_from(prefix.len()).map_err(|_| internal("tuple length"))?;
542    let upper = max_items.unwrap_or(prefix_len);
543    let finite_upper = if tail.is_some() {
544        upper
545    } else {
546        upper.min(prefix_len)
547    };
548    let mut alternatives = Vec::new();
549    let finite_count = finite_upper
550        .checked_sub(min_items)
551        .and_then(|count| count.checked_add(1))
552        .unwrap_or(0);
553    alternatives
554        .try_reserve_exact(
555            usize::try_from(finite_count)
556                .map_err(|_| compile_allocation("tuple alternative count"))?
557                .saturating_add(usize::from(max_items.is_none() && tail.is_some())),
558        )
559        .map_err(|_| compile_allocation("tuple alternative allocation"))?;
560    for count in min_items..=finite_upper {
561        alternatives.push(tuple_exact_engine(prefix, tail, count)?);
562    }
563    if max_items.is_none() {
564        if let Some(tail) = tail {
565            let base_count = min_items.max(prefix_len);
566            let base = tuple_exact_engine(prefix, Some(tail), base_count)?;
567            let comma = build_from_regex(&comma_sep())?;
568            let comma_tail = concat_engine(&[&comma, tail])?;
569            let rest = RefEngine::from_graph(&repeat(&comma_tail, 0, None)?)?;
570            alternatives.push(concat_engine(&[&base, &rest])?);
571        }
572    }
573    let refs = engine_refs(&alternatives)?;
574    RefEngine::from_graph(&union(&refs)?)
575}
576
577fn tuple_exact_engine(
578    prefix: &[RefEngine],
579    tail: Option<&RefEngine>,
580    count: u32,
581) -> Result<RefEngine, CompileError> {
582    if count == 0 {
583        return build_from_regex("");
584    }
585    let comma = build_from_regex(&comma_sep())?;
586    let count = usize::try_from(count).map_err(|_| internal("tuple count"))?;
587    let mut sequence: Vec<&RefEngine> = Vec::new();
588    sequence
589        .try_reserve_exact(count.saturating_mul(2).saturating_sub(1))
590        .map_err(|_| compile_allocation("tuple sequence allocation"))?;
591    for index in 0..count {
592        if index > 0 {
593            sequence.push(&comma);
594        }
595        let item = prefix.get(index).or(tail).ok_or_else(|| {
596            CompileError::new(
597                ErrorCode::Malformed,
598                Stage::L3,
599                "tuple count exceeds closed tail",
600            )
601        })?;
602        sequence.push(item);
603    }
604    concat_engine(&sequence)
605}
606
607fn concat_engine(engines: &[&RefEngine]) -> Result<RefEngine, CompileError> {
608    RefEngine::from_graph(&concatenate(engines)?)
609}
610
611fn engine_refs(engines: &[RefEngine]) -> Result<Vec<&RefEngine>, CompileError> {
612    let mut refs = Vec::new();
613    refs.try_reserve_exact(engines.len())
614        .map_err(|_| compile_allocation("engine reference allocation"))?;
615    refs.extend(engines.iter());
616    Ok(refs)
617}
618
619fn compile_allocation(what: &'static str) -> CompileError {
620    CompileError::new(
621        ErrorCode::InternalLimitExceeded,
622        Stage::L3,
623        "compiler allocation limit",
624    )
625    .with_observed(what)
626}
627
628fn surround_engine(
629    engine: &RefEngine,
630    prefix: &str,
631    suffix: &str,
632) -> Result<RefEngine, CompileError> {
633    let prefix = build_from_regex(prefix)?;
634    let suffix = build_from_regex(suffix)?;
635    concat_engine(&[&prefix, engine, &suffix])
636}
637
638pub(crate) fn number_syntax_engine() -> Result<RefEngine, CompileError> {
639    build_from_regex(&format!("{JSON_WS_RE}{JSON_NUMBER_RE}{JSON_WS_RE}"))
640}
641
642/// Lowers only the nodes reachable from `root`, bottom-up (children precede parents): an
643/// `OpenObject` elsewhere in the arena, unreachable from `root`, must not fail this compile.
644fn lower_reachable(ir: &SchemaIR, root: NodeId) -> Result<String, CompileError> {
645    let reachable = reachable_from(ir, root)?;
646    let mut frag: Vec<String> = vec![String::new(); ir.node_count()];
647    for (i, node) in ir.nodes().enumerate() {
648        if reachable[i] {
649            frag[i] = lower_node(ir, node, &frag)?;
650        }
651    }
652    frag.into_iter()
653        .nth(root.get() as usize)
654        .ok_or_else(|| internal("root fragment missing"))
655}
656
657/// Every node id reachable from `root` via child edges (the same edges `lower_node` follows).
658fn reachable_from(ir: &SchemaIR, root: NodeId) -> Result<Vec<bool>, CompileError> {
659    let mut reached = vec![false; ir.node_count()];
660    let mut stack = vec![root];
661    while let Some(id) = stack.pop() {
662        let idx = id.get() as usize;
663        if reached.get(idx).copied().unwrap_or(true) {
664            continue;
665        }
666        reached[idx] = true;
667        let node = ir
668            .node(id)
669            .ok_or_else(|| internal("node id out of range"))?;
670        stack.extend(node_children(ir, node)?);
671    }
672    Ok(reached)
673}
674
675/// The immediate child node ids of `node`, per its own variant's back-edges.
676pub(crate) fn node_children(ir: &SchemaIR, node: &Node) -> Result<Vec<NodeId>, CompileError> {
677    Ok(match node {
678        Node::Array {
679            items: ItemsPolicy::Schema(id),
680            ..
681        } => vec![*id],
682        Node::Array {
683            items: ItemsPolicy::AllowAny,
684            ..
685        } => Vec::new(),
686        Node::Tuple { prefix, tail, .. } => {
687            let mut ids = ir
688                .refs_at(*prefix)
689                .ok_or_else(|| internal("tuple ref"))?
690                .to_vec();
691            ids.extend(*tail);
692            ids
693        }
694        Node::Object { fields, .. } => ir
695            .props_at(*fields)
696            .ok_or_else(|| internal("object ref"))?
697            .iter()
698            .map(|(_, c)| *c)
699            .collect(),
700        Node::OpenObject {
701            known,
702            patterns,
703            additional,
704            property_names,
705            ..
706        } => {
707            let mut ids: Vec<NodeId> = ir
708                .props_at(*known)
709                .ok_or_else(|| internal("open object known ref"))?
710                .iter()
711                .map(|(_, c)| *c)
712                .collect();
713            ids.extend(
714                ir.props_at(*patterns)
715                    .ok_or_else(|| internal("open object pattern ref"))?
716                    .iter()
717                    .map(|(_, c)| *c),
718            );
719            if let AdditionalPolicy::Schema(id) = additional {
720                ids.push(*id);
721            }
722            ids.extend(*property_names);
723            ids
724        }
725        Node::Union { branches }
726        | Node::Intersection { branches }
727        | Node::ExactlyOne { branches } => ir
728            .refs_at(*branches)
729            .ok_or_else(|| internal("branch ref"))?
730            .to_vec(),
731        Node::Not { inner } => vec![*inner],
732        Node::DynamicRef { initial_target, .. } => vec![*initial_target],
733        Node::Unevaluated {
734            scope, unevaluated, ..
735        } => vec![*scope, *unevaluated],
736        Node::Null
737        | Node::Boolean
738        | Node::Never
739        | Node::StringConst { .. }
740        | Node::StringPattern { .. }
741        | Node::Integer { .. }
742        | Node::Number { .. }
743        | Node::LexicalNumber { .. }
744        | Node::Enum { .. }
745        | Node::Ref { .. }
746        | Node::Unsupported { .. } => Vec::new(),
747    })
748}
749
750/// The full JSON number grammar: optional sign, an integer part with no leading zeros, an optional
751/// fractional part, and an optional exponent. A regular language, unlike a bounded real range.
752const JSON_NUMBER_RE: &str = r"-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?";
753
754/// RFC 8259 insignificant whitespace, zero or more. The only whitespace fragment in this file.
755const JSON_WS_RE: &str = r"[\x20\x09\x0A\x0D]*";
756
757/// A JSON `:` with optional whitespace on both sides.
758fn colon_sep() -> String {
759    format!("{JSON_WS_RE}:{JSON_WS_RE}")
760}
761
762/// A JSON `,` with optional whitespace on both sides.
763fn comma_sep() -> String {
764    format!("{JSON_WS_RE},{JSON_WS_RE}")
765}
766
767fn lower_node(ir: &SchemaIR, node: &Node, frag: &[String]) -> Result<String, CompileError> {
768    Ok(match node {
769        Node::Null => "null".to_string(),
770        Node::Boolean => "(?:true|false)".to_string(),
771        Node::Never => r"[^\x00-\xff]".to_string(),
772        Node::StringConst { value } => {
773            let s = ir.str_at(*value).ok_or_else(|| internal("string ref"))?;
774            escape_regex(&json_encode(s))
775        }
776        Node::StringPattern {
777            regex,
778            min_len,
779            max_len,
780            charset,
781        } => {
782            let base = ir.str_at(*regex).ok_or_else(|| internal("pattern ref"))?;
783            let body = match charset {
784                Charset::AsciiPrintableNoQuoteBackslash | Charset::Utf8CountedCodepoints => {
785                    format!("(?:{base}){}", quantifier(*min_len, *max_len))
786                }
787                Charset::Utf8Any => format!("(?:{base})"),
788            };
789            format!("\"{body}\"")
790        }
791        Node::Integer {
792            minimum,
793            maximum,
794            multiple_of,
795        } => {
796            if multiple_of.is_some() {
797                return Err(internal("multipleOf must use direct graph compilation"));
798            }
799            integer_regex(*minimum, *maximum)
800        }
801        Node::Number {
802            integer_only,
803            minimum,
804            maximum,
805            multiple_of,
806        } => {
807            if *integer_only || minimum.is_some() || maximum.is_some() || multiple_of.is_some() {
808                return Err(CompileError::new(
809                    ErrorCode::Unsupported,
810                    Stage::L3,
811                    UnsupportedReason::StructuredBackendRequired.advisory(),
812                ));
813            }
814            JSON_NUMBER_RE.to_string()
815        }
816        Node::LexicalNumber { regex } => ir
817            .str_at(*regex)
818            .ok_or_else(|| internal("lexical number pattern"))?
819            .to_owned(),
820        Node::Enum { values } => {
821            let lits = ir.lits_at(*values).ok_or_else(|| internal("enum ref"))?;
822            let mut alts: Vec<String> = lits.iter().map(scalar_regex).collect();
823            // Longest-first so a shorter byte-prefix alternative cannot pre-empt a longer one.
824            alts.sort_by_key(|a| std::cmp::Reverse(a.len()));
825            format!("(?:{})", alts.join("|"))
826        }
827        Node::Array {
828            unique_items: true, ..
829        }
830        | Node::Array {
831            items: ItemsPolicy::AllowAny,
832            ..
833        }
834        | Node::Array {
835            contains: Some(_), ..
836        } => {
837            return Err(CompileError::new(
838                ErrorCode::Unsupported,
839                Stage::L3,
840                UnsupportedReason::StructuredBackendRequired.advisory(),
841            ));
842        }
843        Node::Array {
844            items: ItemsPolicy::Schema(id),
845            min_items,
846            max_items,
847            ..
848        } => {
849            let elem = child(frag, *id)?;
850            let body = array_body(elem, *min_items, *max_items);
851            require_regex_fits(body.len())?;
852            format!("\\[{JSON_WS_RE}{body}{JSON_WS_RE}\\]")
853        }
854        Node::Tuple {
855            unique_items: true, ..
856        }
857        | Node::Tuple {
858            contains: Some(_), ..
859        } => {
860            return Err(CompileError::new(
861                ErrorCode::Unsupported,
862                Stage::L3,
863                UnsupportedReason::StructuredBackendRequired.advisory(),
864            ));
865        }
866        Node::Tuple {
867            prefix,
868            tail,
869            min_items,
870            max_items,
871            ..
872        } => {
873            let ids = ir.refs_at(*prefix).ok_or_else(|| internal("tuple ref"))?;
874            let mut elems = Vec::with_capacity(ids.len());
875            for id in ids {
876                elems.push(child(frag, *id)?);
877            }
878            let tail = match tail {
879                Some(t) => Some(child(frag, *t)?),
880                None => None,
881            };
882            let body = tuple_body(&elems, tail, *min_items, *max_items);
883            require_regex_fits(body.len())?;
884            format!("\\[{JSON_WS_RE}{body}{JSON_WS_RE}\\]")
885        }
886        Node::Object { dependent: dep, .. } if ir.props_at(*dep).is_some_and(|d| !d.is_empty()) => {
887            return Err(CompileError::new(
888                ErrorCode::Unsupported,
889                Stage::L3,
890                UnsupportedReason::StructuredBackendRequired.advisory(),
891            ));
892        }
893        Node::Object {
894            dependent_required, ..
895        } if ir
896            .dependent_required_at(*dependent_required)
897            .is_some_and(|pairs| !pairs.is_empty()) =>
898        {
899            return Err(CompileError::new(
900                ErrorCode::Unsupported,
901                Stage::L3,
902                UnsupportedReason::StructuredBackendRequired.advisory(),
903            ));
904        }
905        Node::Object {
906            fields, required, ..
907        } => {
908            let _ = (fields, required, frag);
909            return Err(internal("object must use direct graph compilation"));
910        }
911        Node::OpenObject { .. } => {
912            return Err(CompileError::new(
913                ErrorCode::Unsupported,
914                Stage::L3,
915                UnsupportedReason::StructuredBackendRequired.advisory(),
916            ));
917        }
918        Node::Union { branches } => {
919            let _ = branches;
920            return Err(internal("union must use direct graph compilation"));
921        }
922        Node::Intersection { branches } => {
923            let _ = branches;
924            return Err(internal("intersection must use direct graph compilation"));
925        }
926        Node::ExactlyOne { branches } => {
927            let _ = branches;
928            return Err(internal("oneOf must use direct graph compilation"));
929        }
930        Node::Not { .. }
931        | Node::Ref { .. }
932        | Node::DynamicRef { .. }
933        | Node::Unevaluated { .. } => {
934            return Err(CompileError::new(
935                ErrorCode::Unsupported,
936                Stage::L3,
937                UnsupportedReason::StructuredBackendRequired.advisory(),
938            ));
939        }
940        Node::Unsupported { .. } => return Err(internal("unsupported node reached the compiler")),
941    })
942}
943
944/// Intersects two independent regex fragments via the product automaton (language intersection).
945#[cfg(test)]
946fn intersect_regex(a: &str, b: &str) -> Result<String, CompileError> {
947    let engines = [build_from_regex(a)?, build_from_regex(b)?];
948    let refs: Vec<&RefEngine> = engines.iter().collect();
949    ProductAutomaton::build(&refs, Combinator::All)?.to_regex()
950}
951
952fn child(frag: &[String], id: NodeId) -> Result<&str, CompileError> {
953    frag.get(id.get() as usize)
954        .map(String::as_str)
955        .ok_or_else(|| internal("child fragment missing"))
956}
957
958fn scalar_regex(lit: &ScalarLit) -> String {
959    match lit {
960        ScalarLit::Null => "null".to_string(),
961        ScalarLit::Bool(true) => "true".to_string(),
962        ScalarLit::Bool(false) => "false".to_string(),
963        ScalarLit::Int(i) => i.to_string(),
964        ScalarLit::Str(s) => escape_regex(&json_encode(s)),
965        // A composite literal is already its canonical JSON bytes; match them structurally (own
966        // brackets/colons/commas get whitespace, string content passes through untouched).
967        ScalarLit::Json(s) => json_literal_regex(s),
968    }
969}
970
971/// Rebuilds a canonical-JSON literal with `JSON_WS_RE` around structural bytes outside strings;
972/// tracks string/escape state so a `:` or `,` inside a string is left alone.
973fn json_literal_regex(s: &str) -> String {
974    let mut out = String::with_capacity(s.len() * 2);
975    let mut in_string = false;
976    let mut escaped = false;
977    for c in s.chars() {
978        if in_string {
979            push_escaped(&mut out, c);
980            if escaped {
981                escaped = false;
982            } else if c == '\\' {
983                escaped = true;
984            } else if c == '"' {
985                in_string = false;
986            }
987            continue;
988        }
989        match c {
990            '"' => {
991                in_string = true;
992                push_escaped(&mut out, c);
993            }
994            '{' | '}' | '[' | ']' | ':' | ',' => {
995                out.push_str(JSON_WS_RE);
996                push_escaped(&mut out, c);
997                out.push_str(JSON_WS_RE);
998            }
999            c => push_escaped(&mut out, c),
1000        }
1001    }
1002    out
1003}
1004
1005/// The comma-separated repetition body for `[min,max]` array cardinality.
1006fn array_body(element: &str, min: u32, max: Option<u32>) -> String {
1007    let e = format!("(?:{element})");
1008    let c = comma_sep();
1009    match max {
1010        None if min == 0 => format!("(?:{e}(?:{c}{e})*)?"),
1011        None => format!("{e}(?:{c}{e}){{{},}}", min - 1),
1012        Some(0) => String::new(),
1013        Some(max) if min == 0 => format!("(?:{e}(?:{c}{e}){{0,{}}})?", max - 1),
1014        Some(max) => format!("{e}(?:{c}{e}){{{},{}}}", min - 1, max - 1),
1015    }
1016}
1017
1018/// The comma-joined tuple body: `elems` match the first positions, `tail` the rest (none = closed);
1019/// present-item counts down to `min` are branches, the unbounded tail folds into one repetition.
1020fn tuple_body(elems: &[&str], tail: Option<&str>, min: u32, max: Option<u32>) -> String {
1021    // Prefix length is bounded by the IR ref-slice caps enforced at build/decode, so it fits u32.
1022    let n = u32::try_from(elems.len()).expect("tuple prefix length fits u32");
1023    let at = |i: u32| -> String {
1024        let e = if i < n {
1025            elems[i as usize]
1026        } else {
1027            tail.unwrap_or("")
1028        };
1029        format!("(?:{e})")
1030    };
1031    let c = comma_sep();
1032    // Comma-joins positions [0, k), which is an array body of exactly k items.
1033    let join = |k: u32| -> String { (0..k).map(&at).collect::<Vec<_>>().join(&c) };
1034
1035    let mut alts: Vec<String> = Vec::new();
1036    match (tail, max) {
1037        // Closed tuple: at most `n` items; enumerate every valid length in [min, n].
1038        (None, _) => {
1039            let hi = max.map_or(n, |m| m.min(n));
1040            for k in min..=hi {
1041                alts.push(join(k));
1042            }
1043        }
1044        // Open tail, bounded max: exact lengths below the prefix boundary, then the full prefix
1045        // followed by the tail repeated a bounded number of times. O(prefix length) branches, never
1046        // proportional to `max`, so a large `maxItems` cannot inflate the pattern string.
1047        (Some(_), Some(hi)) if hi < n => {
1048            // max sits below the prefix: every valid length stays within the prefix positions.
1049            for k in min..=hi {
1050                alts.push(join(k));
1051            }
1052        }
1053        (Some(t), Some(hi)) => {
1054            for k in min..n {
1055                alts.push(join(k));
1056            }
1057            let base = join(n);
1058            let tail_lo = min.saturating_sub(n);
1059            let tail_hi = hi - n;
1060            let rep = if tail_hi == 0 {
1061                String::new()
1062            } else {
1063                format!("(?:{c}(?:{t})){{{tail_lo},{tail_hi}}}")
1064            };
1065            alts.push(format!("{base}{rep}"));
1066        }
1067        // Open tail, unbounded max: enumerate lengths up to the prefix boundary, then one branch
1068        // holding the full prefix followed by a repeated tail for every longer length.
1069        (Some(t), None) => {
1070            let hi = min.max(n);
1071            for k in min..hi {
1072                alts.push(join(k));
1073            }
1074            let base = join(n);
1075            let tail_lo = min.saturating_sub(n);
1076            let rep = if tail_lo == 0 {
1077                format!("(?:{c}(?:{t}))*")
1078            } else {
1079                format!("(?:{c}(?:{t})){{{tail_lo},}}")
1080            };
1081            alts.push(format!("{base}{rep}"));
1082        }
1083    }
1084    // Longest branch first so a shorter length cannot pre-empt a longer one under leftmost-first.
1085    alts.sort_by_key(|a| std::cmp::Reverse(a.len()));
1086    format!("(?:{})", alts.join("|"))
1087}
1088
1089fn quantifier(min: Option<u32>, max: Option<u32>) -> String {
1090    match (min, max) {
1091        (Some(a), Some(b)) => format!("{{{a},{b}}}"),
1092        (Some(a), None) => format!("{{{a},}}"),
1093        (None, Some(b)) => format!("{{0,{b}}}"),
1094        (None, None) => "*".to_string(),
1095    }
1096}
1097
1098/// Encodes a string as a JSON string body with RFC 8259 escapes (no surrounding quotes).
1099fn json_encode(s: &str) -> String {
1100    let mut out = String::with_capacity(s.len());
1101    for c in s.chars() {
1102        match c {
1103            '"' => out.push_str("\\\""),
1104            '\\' => out.push_str("\\\\"),
1105            '\n' => out.push_str("\\n"),
1106            '\r' => out.push_str("\\r"),
1107            '\t' => out.push_str("\\t"),
1108            '\u{08}' => out.push_str("\\b"),
1109            '\u{0c}' => out.push_str("\\f"),
1110            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1111            c => out.push(c),
1112        }
1113    }
1114    out
1115}
1116
1117const REGEX_META: &[char] = &[
1118    '\\', '.', '+', '*', '?', '(', ')', '[', ']', '{', '}', '^', '$', '|',
1119];
1120
1121/// Pushes `c` onto `out`, backslash-escaping it first if it is a regex metacharacter.
1122fn push_escaped(out: &mut String, c: char) {
1123    if REGEX_META.contains(&c) {
1124        out.push('\\');
1125    }
1126    out.push(c);
1127}
1128
1129/// Escapes regex metacharacters so a literal is matched byte-for-byte.
1130fn escape_regex(s: &str) -> String {
1131    let mut out = String::with_capacity(s.len() + 2);
1132    out.push('"');
1133    for c in s.chars() {
1134        push_escaped(&mut out, c);
1135    }
1136    out.push('"');
1137    out
1138}
1139
1140fn diagnostic_error(ir: &SchemaIR, d: &Diagnostic) -> CompileError {
1141    let mut e = CompileError::new(ErrorCode::Unsupported, Stage::L2, d.reason.advisory());
1142    if let Some(k) = ir.str_at(d.keyword) {
1143        e = e.with_keyword(k.to_string());
1144    }
1145    if let Some(p) = ir.str_at(d.json_pointer) {
1146        e = e.with_pointer(p.to_string());
1147    }
1148    e
1149}
1150
1151fn internal(what: &'static str) -> CompileError {
1152    CompileError::new(ErrorCode::InternalLimitExceeded, Stage::L2, what)
1153}
1154
1155// --- exact decimal-range regex for signed integers ---------------------------------------------
1156
1157/// A byte regex matching exactly the JSON integers `n` with `lo <= n <= hi` (a `None` bound is
1158/// unbounded). Never enumerates: it emits digit-range patterns whose size is proportional to the
1159/// number of digits, not the range width. Brute-force verified; shared with the reference lowering.
1160pub(crate) fn integer_regex(lo: Option<i64>, hi: Option<i64>) -> String {
1161    let lo = lo.map(i128::from);
1162    let hi = hi.map(i128::from);
1163    let mut alts: Vec<String> = Vec::new();
1164
1165    // Negative side: n in [lo, min(hi, -1)], mapped to m = -n which is positive.
1166    let neg_hi = hi.map_or(-1, |h| h.min(-1));
1167    let neg_present = neg_hi <= -1 && lo.is_none_or(|l| l <= neg_hi);
1168    if neg_present {
1169        let a = to_u64(-neg_hi);
1170        match lo {
1171            Some(l) => alts.push(format!("-{}", pos_range(a, to_u64(-l)))),
1172            None => alts.push(format!("-{}", pos_ge(a))),
1173        }
1174    }
1175
1176    if lo.is_none_or(|l| l <= 0) && hi.is_none_or(|h| h >= 0) {
1177        alts.push("0".to_string());
1178    }
1179
1180    // Positive side: n in [max(lo, 1), hi].
1181    let pos_lo = lo.map_or(1, |l| l.max(1));
1182    if hi.is_none_or(|h| pos_lo <= h) {
1183        match hi {
1184            Some(h) => alts.push(pos_range(to_u64(pos_lo), to_u64(h))),
1185            None => alts.push(pos_ge(to_u64(pos_lo))),
1186        }
1187    }
1188
1189    format!("(?:{})", alts.join("|"))
1190}
1191
1192/// Whether the fractional digits after the integer part are unconstrained, forced to all-zero
1193/// (an exact integer value), or forced to contain a nonzero digit (strictly past the integer).
1194#[derive(Copy, Clone, PartialEq, Eq)]
1195#[cfg(test)]
1196enum FracRule {
1197    Any,
1198    Zero,
1199    NonZero,
1200}
1201
1202/// The fraction rule that keeps a value at exactly the boundary magnitude `v` satisfying `>= v`
1203/// (inclusive) or `> v` (exclusive); `None` means no fraction can satisfy it (the whole magnitude
1204/// is excluded). Increasing digits after a negative integer part decreases the value, so the rule
1205/// flips by sign.
1206#[cfg(test)]
1207fn frac_rule_for_min(v: i64, exclusive: bool) -> Option<FracRule> {
1208    if v < 0 {
1209        if exclusive {
1210            None
1211        } else {
1212            Some(FracRule::Zero)
1213        }
1214    } else if exclusive {
1215        Some(FracRule::NonZero)
1216    } else {
1217        Some(FracRule::Any)
1218    }
1219}
1220
1221/// Mirror of `frac_rule_for_min` for a `<= v` (inclusive) or `< v` (exclusive) upper bound.
1222#[cfg(test)]
1223fn frac_rule_for_max(v: i64, exclusive: bool) -> Option<FracRule> {
1224    if v >= 0 {
1225        if exclusive {
1226            None
1227        } else {
1228            Some(FracRule::Zero)
1229        }
1230    } else if exclusive {
1231        Some(FracRule::NonZero)
1232    } else {
1233        Some(FracRule::Any)
1234    }
1235}
1236
1237/// Intersects two fraction rules for a magnitude that is both the minimum and the maximum bound.
1238#[cfg(test)]
1239fn combine_frac_rules(a: FracRule, b: FracRule) -> Option<FracRule> {
1240    match (a, b) {
1241        (FracRule::Zero, FracRule::NonZero) | (FracRule::NonZero, FracRule::Zero) => None,
1242        (FracRule::Zero, _) | (_, FracRule::Zero) => Some(FracRule::Zero),
1243        (FracRule::NonZero, _) | (_, FracRule::NonZero) => Some(FracRule::NonZero),
1244        (FracRule::Any, FracRule::Any) => Some(FracRule::Any),
1245    }
1246}
1247
1248#[cfg(test)]
1249fn frac_pattern(rule: FracRule) -> &'static str {
1250    match rule {
1251        FracRule::Any => r"(?:\.[0-9]+)?",
1252        FracRule::Zero => r"(?:\.0+)?",
1253        FracRule::NonZero => r"\.[0-9]*[1-9][0-9]*",
1254    }
1255}
1256
1257/// Converts integer-valued number bounds to inclusive integer bounds (for an integer `multipleOf`,
1258/// whose multiples are integers): an exclusive edge shifts inward by one.
1259#[cfg(test)]
1260pub(crate) fn number_bounds_to_integer(
1261    minimum: Option<(i64, bool)>,
1262    maximum: Option<(i64, bool)>,
1263) -> (Option<i64>, Option<i64>) {
1264    let lo = minimum.map(|(v, excl)| if excl { v.saturating_add(1) } else { v });
1265    let hi = maximum.map(|(v, excl)| if excl { v.saturating_sub(1) } else { v });
1266    (lo, hi)
1267}
1268
1269/// The full `type:number` + `multipleOf` regex (bounds intersected with divisibility). An integer
1270/// divisor reuses the well-formed integer path with a `.0*` tail; a fractional divisor intersects the
1271/// numeric range with the decimal-multiple DFA. Errs (limit) when a divisor's DFA is too large to
1272/// combine, so lowering can validate buildability up front rather than fail late.
1273#[cfg(test)]
1274pub(crate) fn number_multiple_of_regex(
1275    minimum: Option<(i64, bool)>,
1276    maximum: Option<(i64, bool)>,
1277    coef: u64,
1278    exp: u32,
1279) -> Result<String, CompileError> {
1280    if exp == 0 {
1281        let (lo, hi) = number_bounds_to_integer(minimum, maximum);
1282        let ints = intersect_regex(&integer_regex(lo, hi), &multiple_of_regex(coef)?)?;
1283        return Ok(format!("(?:{ints})(?:\\.0+)?"));
1284    }
1285    let range = number_range_regex(minimum, maximum);
1286    intersect_regex(&range, &decimal_multiple_of_regex(coef, exp)?)
1287}
1288
1289/// A byte regex matching JSON numbers (sign, integer part, optional fraction, no exponent) with an
1290/// integer-valued inclusive/exclusive minimum and/or maximum. Every value satisfying such a bound
1291/// has a non-exponent spelling, so dropping exponent forms loses no language a generator needs.
1292/// `None`/`None` (fully unbounded) returns the full numeric grammar, exponent included.
1293#[cfg(test)]
1294pub(crate) fn number_range_regex(
1295    minimum: Option<(i64, bool)>,
1296    maximum: Option<(i64, bool)>,
1297) -> String {
1298    if minimum.is_none() && maximum.is_none() {
1299        return JSON_NUMBER_RE.to_string();
1300    }
1301    let mut alts: Vec<String> = Vec::new();
1302    let zero_reachable = minimum
1303        .is_none_or(|(value, exclusive)| value < 0 || value == 0 && !exclusive)
1304        && maximum.is_none_or(|(value, exclusive)| value > 0 || value == 0 && !exclusive);
1305    if zero_reachable {
1306        alts.push(r"-0(?:\.0+)?".to_string());
1307    }
1308    let interior_lo = minimum.map(|(v, _)| v.checked_add(1));
1309    let interior_hi = maximum.map(|(v, _)| v.checked_sub(1));
1310    let interior_ok = match (interior_lo, interior_hi) {
1311        (Some(None), _) | (_, Some(None)) => false,
1312        (Some(Some(lo)), Some(Some(hi))) => lo <= hi,
1313        _ => true,
1314    };
1315    if interior_ok {
1316        let lo = interior_lo.flatten();
1317        let hi = interior_hi.flatten();
1318        alts.push(format!(
1319            "{}{}",
1320            integer_regex(lo, hi),
1321            frac_pattern(FracRule::Any)
1322        ));
1323    }
1324    let mut push_boundary = |v: i64, rule: Option<FracRule>| {
1325        if let Some(rule) = rule {
1326            alts.push(format!(
1327                "{}{}",
1328                integer_regex(Some(v), Some(v)),
1329                frac_pattern(rule)
1330            ));
1331        }
1332    };
1333    match (minimum, maximum) {
1334        (Some((mv, mex)), Some((xv, xex))) if mv == xv => {
1335            let rule = frac_rule_for_min(mv, mex).zip(frac_rule_for_max(xv, xex));
1336            if let Some((a, b)) = rule {
1337                push_boundary(mv, combine_frac_rules(a, b));
1338            }
1339        }
1340        _ => {
1341            if let Some((mv, mex)) = minimum {
1342                push_boundary(mv, frac_rule_for_min(mv, mex));
1343            }
1344            if let Some((xv, xex)) = maximum {
1345                push_boundary(xv, frac_rule_for_max(xv, xex));
1346            }
1347        }
1348    }
1349    // `integer_regex` never emits "-0": its negative side starts at magnitude 1, and its zero
1350    // alternative is unsigned. Values in (-1, 0), e.g. -0.5, need this hand-added slot.
1351    let neg_zero_reachable =
1352        minimum.is_none_or(|(v, _)| v <= -1) && maximum.is_none_or(|(v, _)| v >= 0);
1353    if neg_zero_reachable {
1354        alts.push(format!("-0{}", frac_pattern(FracRule::NonZero)));
1355    }
1356    format!("(?:{})", alts.join("|"))
1357}
1358
1359/// A bound's magnitude is at most `i64::MIN` negated, which fits `u64`.
1360fn to_u64(v: i128) -> u64 {
1361    u64::try_from(v).expect("integer bound magnitude fits u64")
1362}
1363
1364fn pow10(k: u32) -> u64 {
1365    let mut v = 1u64;
1366    for _ in 0..k {
1367        v *= 10;
1368    }
1369    v
1370}
1371
1372fn num_digits(n: u64) -> u32 {
1373    let mut d = 1;
1374    let mut v = n;
1375    while v >= 10 {
1376        v /= 10;
1377        d += 1;
1378    }
1379    d
1380}
1381
1382/// Renders fractional digits (values `0..9`) as a literal string.
1383#[cfg(test)]
1384fn frac_str(a: &[u8]) -> String {
1385    a.iter().map(|d| (b'0' + d) as char).collect()
1386}
1387
1388/// Drops trailing zeros: `0.a` equals `0.<stripped>` numerically.
1389#[cfg(test)]
1390fn strip_trailing_zeros(a: &[u8]) -> &[u8] {
1391    let mut end = a.len();
1392    while end > 0 && a[end - 1] == 0 {
1393        end -= 1;
1394    }
1395    &a[..end]
1396}
1397
1398/// Regex for a non-empty fractional digit run `F` with `0.F >= 0.a`. Empty `F` (value 0) satisfies
1399/// this only when `a` is all zeros; the caller decides whether to make the fraction optional.
1400#[cfg(test)]
1401fn frac_ge_body(a: &[u8]) -> String {
1402    let a = strip_trailing_zeros(a);
1403    if a.is_empty() {
1404        return "[0-9]+".to_string();
1405    }
1406    let mut alts: Vec<String> = Vec::new();
1407    for i in 0..a.len() {
1408        if a[i] < 9 {
1409            alts.push(format!("{}[{}-9][0-9]*", frac_str(&a[..i]), a[i] + 1));
1410        }
1411    }
1412    alts.push(format!("{}[0-9]*", frac_str(a)));
1413    format!("(?:{})", alts.join("|"))
1414}
1415
1416/// Regex for a non-empty fractional digit run `F` with `0.F <= 0.a`.
1417#[cfg(test)]
1418fn frac_le_body(a: &[u8]) -> String {
1419    let a = strip_trailing_zeros(a);
1420    if a.is_empty() {
1421        return "0+".to_string();
1422    }
1423    let mut alts: Vec<String> = Vec::new();
1424    for i in 0..a.len() {
1425        if a[i] > 0 {
1426            alts.push(format!("{}[0-{}][0-9]*", frac_str(&a[..i]), a[i] - 1));
1427        }
1428    }
1429    for i in 1..a.len() {
1430        alts.push(frac_str(&a[..i]));
1431    }
1432    alts.push(format!("{}0*", frac_str(a)));
1433    format!("(?:{})", alts.join("|"))
1434}
1435
1436/// The fractional suffix (including the leading `.`, or empty) for `x`'s integer part equal to a
1437/// lower bound whose fractional digits are `lf`: `>= lf`, with the fraction optional iff `lf` is 0.
1438#[cfg(test)]
1439fn lower_frac_suffix(lf: &[u8]) -> String {
1440    if strip_trailing_zeros(lf).is_empty() {
1441        return format!("(?:\\.{})?", frac_ge_body(lf));
1442    }
1443    format!("\\.{}", frac_ge_body(lf))
1444}
1445
1446/// The fractional suffix for `x`'s integer part equal to an upper bound whose fractional digits are
1447/// `hf`: `<= hf`, always optional (no fraction means `.0`, which is `<=` any bound).
1448#[cfg(test)]
1449fn upper_frac_suffix(hf: &[u8]) -> String {
1450    format!("(?:\\.{})?", frac_le_body(hf))
1451}
1452
1453/// Non-negative decimals `x` with `lo <= x <= hi` (`hi = None` is `+inf`); `lo`/`hi` are
1454/// `(integer_part, fractional_digits)`. Interior integer parts take any fraction; the two boundary
1455/// integer parts constrain the fraction via `frac_ge`/`frac_le`, intersected when they coincide.
1456#[cfg(test)]
1457fn nonneg_decimal_range(
1458    lo: (u64, &[u8]),
1459    hi: Option<(u64, &[u8])>,
1460) -> Result<String, CompileError> {
1461    let (li, lf) = lo;
1462    let li = li as i64;
1463    let mut alts: Vec<String> = Vec::new();
1464    match hi {
1465        None => {
1466            alts.push(format!(
1467                "{}{}",
1468                integer_regex(Some(li), None),
1469                lower_frac_suffix(lf)
1470            ));
1471            alts.push(format!(
1472                "{}(?:\\.[0-9]+)?",
1473                integer_regex(Some(li + 1), None)
1474            ));
1475        }
1476        Some((hi_i, hf)) => {
1477            let hi_i = hi_i as i64;
1478            if li == hi_i {
1479                // `intersect_regex` returns an ungrouped alternation, so wrap it before composing.
1480                let both = intersect_regex(&frac_ge_body(lf), &frac_le_body(hf))?;
1481                let frac = if strip_trailing_zeros(lf).is_empty() {
1482                    format!("(?:\\.(?:{both}))?")
1483                } else {
1484                    format!("\\.(?:{both})")
1485                };
1486                alts.push(format!("{}{}", integer_regex(Some(li), Some(li)), frac));
1487            } else {
1488                alts.push(format!(
1489                    "{}{}",
1490                    integer_regex(Some(li), Some(li)),
1491                    lower_frac_suffix(lf)
1492                ));
1493                alts.push(format!(
1494                    "{}{}",
1495                    integer_regex(Some(hi_i), Some(hi_i)),
1496                    upper_frac_suffix(hf)
1497                ));
1498                if hi_i - li >= 2 {
1499                    alts.push(format!(
1500                        "{}(?:\\.[0-9]+)?",
1501                        integer_regex(Some(li + 1), Some(hi_i - 1))
1502                    ));
1503                }
1504            }
1505        }
1506    }
1507    Ok(format!("(?:{})", alts.join("|")))
1508}
1509
1510/// Splits a decimal bound into `(negative, integer_part, fractional_digits)`.
1511#[cfg(test)]
1512fn to_decimal(bound: (i64, u32, bool)) -> (bool, u64, Vec<u8>) {
1513    let (value, scale, _) = bound;
1514    let mag = value.unsigned_abs();
1515    let div = 10u64.pow(scale);
1516    let frac_val = mag % div;
1517    let frac = format!("{frac_val:0width$}", width = scale as usize)
1518        .bytes()
1519        .map(|b| b - b'0')
1520        .collect();
1521    (value < 0, mag / div, frac)
1522}
1523
1524/// Every negative number (well-formed): `-` followed by a positive magnitude.
1525#[cfg(test)]
1526fn all_negatives() -> &'static str {
1527    r"-(?:[1-9][0-9]*(?:\.[0-9]+)?|0\.[0-9]*[1-9][0-9]*)"
1528}
1529
1530#[cfg(test)]
1531fn unsupported_decimal() -> CompileError {
1532    CompileError::new(
1533        ErrorCode::Unsupported,
1534        Stage::L3,
1535        "this decimal bound shape is not yet supported",
1536    )
1537}
1538
1539/// Inclusive decimal range as a regex, for the supported sign cases: a non-negative lower bound
1540/// (upper `>= 0` or none), or an upper bound `>= 0` with no lower bound (all negatives plus
1541/// `[0, max]`). Negative bounds, ranges crossing zero, and exclusive bounds return an error.
1542#[cfg(test)]
1543fn decimal_range_regex(
1544    minimum: Option<(i64, u32, bool)>,
1545    maximum: Option<(i64, u32, bool)>,
1546) -> Result<String, CompileError> {
1547    if minimum.is_some_and(|(_, _, e)| e) || maximum.is_some_and(|(_, _, e)| e) {
1548        return Err(unsupported_decimal());
1549    }
1550    let lo = minimum.map(to_decimal);
1551    let hi = maximum.map(to_decimal);
1552    match (&lo, &hi) {
1553        (Some((false, li, lf)), _) if hi.as_ref().is_none_or(|(hn, ..)| !hn) => {
1554            let hi_ref = hi.as_ref().map(|(_, hi_i, hf)| (*hi_i, hf.as_slice()));
1555            nonneg_decimal_range((*li, lf), hi_ref)
1556        }
1557        (None, Some((false, hi_i, hf))) => {
1558            let pos = nonneg_decimal_range((0, &[]), Some((*hi_i, hf.as_slice())))?;
1559            Ok(format!("(?:{}|{})", all_negatives(), pos))
1560        }
1561        _ => Err(unsupported_decimal()),
1562    }
1563}
1564
1565/// The `type:number` regex: integer-valued bounds (all `scale == 0`) reuse the integer path and
1566/// permit `multipleOf`; a fractional bound routes to the decimal range and forbids `multipleOf`.
1567#[cfg(test)]
1568pub(crate) fn number_node_regex(
1569    minimum: Option<(i64, u32, bool)>,
1570    maximum: Option<(i64, u32, bool)>,
1571    multiple_of: Option<(u64, u32)>,
1572) -> Result<String, CompileError> {
1573    let as_int = |b: Option<(i64, u32, bool)>| match b {
1574        None => Some(None),
1575        Some((v, 0, e)) => Some(Some((v, e))),
1576        Some(_) => None,
1577    };
1578    if let (Some(lo), Some(hi)) = (as_int(minimum), as_int(maximum)) {
1579        return match multiple_of {
1580            None => Ok(number_range_regex(lo, hi)),
1581            Some((coef, exp)) => number_multiple_of_regex(lo, hi, coef, exp),
1582        };
1583    }
1584    if multiple_of.is_some() {
1585        return Err(unsupported_decimal());
1586    }
1587    decimal_range_regex(minimum, maximum)
1588}
1589
1590/// Positive integers `>= a` (no leading zeros). Longer (more-digit) alternatives come first, so a
1591/// shorter prefix cannot pre-empt a longer match under leftmost-first alternation.
1592fn pos_ge(a: u64) -> String {
1593    let d = num_digits(a);
1594    let same = flrange(a, pow10(d) - 1, d);
1595    let more = if d == 1 {
1596        "[1-9][0-9]+".to_string()
1597    } else {
1598        format!("[1-9][0-9]{{{d},}}")
1599    };
1600    format!("(?:{more}|{same})")
1601}
1602
1603/// Positive integers in `[a, b]` (no leading zeros), `1 <= a <= b`. Digit-length bands are emitted
1604/// longest-first to avoid leftmost-first prefix pre-emption.
1605fn pos_range(a: u64, b: u64) -> String {
1606    let (da, db) = (num_digits(a), num_digits(b));
1607    if da == db {
1608        return flrange(a, b, da);
1609    }
1610    let mut parts = vec![flrange(a, pow10(da) - 1, da)];
1611    for d in (da + 1)..db {
1612        parts.push(all_digits(d));
1613    }
1614    parts.push(flrange(pow10(db - 1), b, db));
1615    parts.reverse(); // longest digit-length band first
1616    alt(parts)
1617}
1618
1619/// Every `d`-digit positive integer (no leading zeros).
1620fn all_digits(d: u32) -> String {
1621    if d == 1 {
1622        "[1-9]".to_string()
1623    } else {
1624        format!("[1-9][0-9]{{{}}}", d - 1)
1625    }
1626}
1627
1628/// Fixed-length decimal strings (leading zeros allowed) in `[lo, hi]`, `len` digits.
1629fn flrange(lo: u64, hi: u64, len: u32) -> String {
1630    // A full band collapses to `[0-9]{len}`; without this, full sub-ranges expand digit by digit
1631    // and the pattern blows up to megabytes for wide bounds.
1632    if lo == 0 && hi == pow10(len) - 1 {
1633        return any_digits(len);
1634    }
1635    if len == 1 {
1636        return digit_class(lo, hi);
1637    }
1638    let p = pow10(len - 1);
1639    let (ld, hd) = (lo / p, hi / p);
1640    let (lr, hr) = (lo % p, hi % p);
1641    if ld == hd {
1642        return format!("{}{}", digit(ld), flrange(lr, hr, len - 1));
1643    }
1644    let mut parts = vec![format!("{}{}", digit(ld), flrange(lr, p - 1, len - 1))];
1645    if hd >= ld + 2 {
1646        parts.push(format!(
1647            "{}{}",
1648            digit_class(ld + 1, hd - 1),
1649            any_digits(len - 1)
1650        ));
1651    }
1652    parts.push(format!("{}{}", digit(hd), flrange(0, hr, len - 1)));
1653    alt(parts)
1654}
1655
1656fn digit(d: u64) -> char {
1657    // Every caller passes a single decimal digit, so this conversion cannot fail.
1658    char::from(b'0' + u8::try_from(d).expect("a decimal digit is < 10"))
1659}
1660
1661fn digit_class(lo: u64, hi: u64) -> String {
1662    if lo == hi {
1663        digit(lo).to_string()
1664    } else {
1665        format!("[{}-{}]", digit(lo), digit(hi))
1666    }
1667}
1668
1669fn any_digits(k: u32) -> String {
1670    match k {
1671        0 => String::new(),
1672        1 => "[0-9]".to_string(),
1673        _ => format!("[0-9]{{{k}}}"),
1674    }
1675}
1676
1677fn alt(parts: Vec<String>) -> String {
1678    if parts.len() == 1 {
1679        parts.into_iter().next().unwrap_or_default()
1680    } else {
1681        format!("(?:{})", parts.join("|"))
1682    }
1683}
1684
1685#[cfg(test)]
1686mod tests {
1687    use super::*;
1688    use crate::automaton::build_from_regex;
1689    use crate::ir::{Builder, CompileOptions};
1690
1691    #[test]
1692    fn compile_ir_timed_fails_fast_on_a_structured_backend_schema_like_compile_ir() {
1693        let ir = crate::frontend::schema_to_ir(
1694            r#"{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":true}"#,
1695            CompileOptions::default(),
1696        )
1697        .unwrap();
1698        assert!(ir.requires_structured_backend());
1699        assert!(compile_ir(&ir).is_err());
1700        assert!(compile_ir_timed(&ir).is_err());
1701    }
1702
1703    // 0.f vs 0.a as rationals: pad both to equal length with trailing zeros, compare as integers.
1704    fn frac_cmp(f: &[u8], a: &[u8]) -> std::cmp::Ordering {
1705        let n = f.len().max(a.len());
1706        let val = |d: &[u8]| -> u128 {
1707            let mut v = 0u128;
1708            for i in 0..n {
1709                v = v * 10 + u128::from(*d.get(i).unwrap_or(&0));
1710            }
1711            v
1712        };
1713        val(f).cmp(&val(a))
1714    }
1715
1716    fn digits(s: &str) -> Vec<u8> {
1717        s.bytes().map(|b| b - b'0').collect()
1718    }
1719
1720    #[test]
1721    fn frac_ge_le_bodies_agree_with_the_exact_rational_oracle() {
1722        let anchored = |body: &str| build_from_regex(&format!("^{body}$")).unwrap();
1723        for a_len in 1..=3usize {
1724            for a_code in 0..10u32.pow(a_len as u32) {
1725                let a = digits(&format!("{a_code:0a_len$}"));
1726                let ge = anchored(&frac_ge_body(&a));
1727                let le = anchored(&frac_le_body(&a));
1728                for f_len in 1..=4usize {
1729                    for f_code in 0..10u32.pow(f_len as u32) {
1730                        let f_str = format!("{f_code:0f_len$}");
1731                        let f = digits(&f_str);
1732                        let ord = frac_cmp(&f, &a);
1733                        assert_eq!(
1734                            ge.accepts(f_str.as_bytes()),
1735                            ord != std::cmp::Ordering::Less,
1736                            "ge a=0.{} f=0.{f_str}",
1737                            frac_str(&a)
1738                        );
1739                        assert_eq!(
1740                            le.accepts(f_str.as_bytes()),
1741                            ord != std::cmp::Ordering::Greater,
1742                            "le a=0.{} f=0.{f_str}",
1743                            frac_str(&a)
1744                        );
1745                    }
1746                }
1747            }
1748        }
1749    }
1750
1751    // Exact value of a decimal string scaled to `s` fractional places (assumes <= s frac digits).
1752    fn scaled(x: &str, s: u32) -> i128 {
1753        let (i, f) = x.split_once('.').unwrap_or((x, ""));
1754        let mut digits = i.to_string();
1755        for _ in 0..s {
1756            digits.push('0');
1757        }
1758        let base: i128 = digits.parse().unwrap();
1759        let frac_shift: i128 = f
1760            .chars()
1761            .fold(0i128, |a, c| a * 10 + (c as u8 - b'0') as i128)
1762            * 10i128.pow(s - f.len() as u32);
1763        base + frac_shift
1764    }
1765
1766    #[test]
1767    fn nonneg_decimal_range_agrees_with_the_exact_rational_oracle() {
1768        let cands = [
1769            "0", "0.0", "0.5", "1", "1.0", "1.5", "1.50", "1.499", "1.7", "2", "2.5", "3", "3.7",
1770            "3.70", "3.700", "3.71", "0.25", "0.3", "2.999", "10", "10.4", "9.99", "0.1", "0.01",
1771            "1.25", "2.3", "2.7", "2.35",
1772        ];
1773        #[allow(clippy::type_complexity)]
1774        let bounds: &[((u64, &str), Option<(u64, &str)>)] = &[
1775            ((1, "5"), Some((3, "7"))),
1776            ((0, "0"), Some((1, "5"))),
1777            ((2, "3"), Some((2, "7"))),
1778            ((1, "5"), None),
1779            ((0, "25"), Some((10, "4"))),
1780            ((3, "0"), Some((3, "0"))),
1781        ];
1782        for (lo, hi) in bounds {
1783            let (li, lf) = (lo.0, digits(lo.1));
1784            let hi_pair = hi.map(|(i, f)| (i, digits(f)));
1785            let regex =
1786                nonneg_decimal_range((li, &lf), hi_pair.as_ref().map(|(i, f)| (*i, f.as_slice())))
1787                    .unwrap();
1788            let e = build_from_regex(&format!("^{regex}$")).unwrap();
1789            let s = 6u32;
1790            let lo_v = scaled(&format!("{}.{}", lo.0, lo.1), s);
1791            let hi_v = hi.map(|(i, f)| scaled(&format!("{i}.{f}"), s));
1792            for c in cands {
1793                let v = scaled(c, s);
1794                let want = v >= lo_v && hi_v.is_none_or(|h| v <= h);
1795                assert_eq!(e.accepts(c.as_bytes()), want, "range={lo:?}..{hi:?} c={c}");
1796            }
1797        }
1798    }
1799
1800    #[test]
1801    fn never_is_dead_from_the_very_first_byte() {
1802        let mut b = Builder::new(CompileOptions::default());
1803        let n = b.never().unwrap();
1804        let ir = b.finish(n).unwrap();
1805        let e = compile_ir(&ir).unwrap();
1806        assert!(
1807            e.is_dead(e.start()),
1808            "never must be dead before consuming any byte"
1809        );
1810        assert!(!e.accepts(b"null"));
1811        assert!(!e.accepts(b"1"));
1812        assert!(!e.accepts(b""));
1813    }
1814
1815    /// A single-field object has one possible key order: no shuffle automaton is built for it.
1816    #[test]
1817    fn single_required_field_object_skips_the_shuffle_round_trip() {
1818        let schema = r#"{"type":"object","additionalProperties":false,"required":["a"],
1819            "properties":{"a":{"type":"string"}}}"#;
1820        let ir = crate::frontend::schema_to_ir(schema, CompileOptions::default()).unwrap();
1821        let e = compile_ir(&ir).unwrap();
1822        assert!(e.accepts(br#"{"a":"x"}"#));
1823        assert!(!e.accepts(b"{}"), "the required field is missing");
1824        assert!(!e.accepts(br#"{"a":1}"#), "a is a string, not a number");
1825    }
1826
1827    #[test]
1828    fn single_optional_field_object_accepts_empty_or_present() {
1829        let schema = r#"{"type":"object","additionalProperties":false,
1830            "properties":{"a":{"type":"string"}}}"#;
1831        let ir = crate::frontend::schema_to_ir(schema, CompileOptions::default()).unwrap();
1832        let e = compile_ir(&ir).unwrap();
1833        assert!(e.accepts(br#"{"a":"x"}"#));
1834        assert!(e.accepts(b"{}"), "an optional field may be absent");
1835    }
1836
1837    // Regression for a real jsonschemabench schema that used to take 30+ seconds: an array of a
1838    // tiny 2-field object whose string-pattern child, once eliminated back to regex text by the
1839    // parent shuffle, blew up to 163KB - then got re-embedded and re-compiled twice more.
1840    #[test]
1841    fn a_real_world_nested_array_of_objects_schema_compiles_or_falls_back_in_under_a_second() {
1842        let schema = r#"{
1843            "properties": {
1844                "builders": {
1845                    "items": {
1846                        "properties": {
1847                            "builder": {"pattern": ".*:.*", "type": "string"},
1848                            "options": {"type": "object", "additionalProperties": true}
1849                        },
1850                        "required": ["builder"],
1851                        "type": "object",
1852                        "additionalProperties": true
1853                    },
1854                    "minItems": 1,
1855                    "type": "array"
1856                }
1857            },
1858            "type": "object",
1859            "additionalProperties": true
1860        }"#;
1861        let opts = CompileOptions {
1862            object_closure: crate::ir::ObjectClosure::AssumeClosedProfile,
1863            ..CompileOptions::default()
1864        };
1865        let ir = crate::frontend::schema_to_ir(schema, opts).unwrap();
1866        assert_eq!(ir.diagnostics().next(), None);
1867        let start = std::time::Instant::now();
1868        let result = compile_ir(&ir);
1869        let elapsed = start.elapsed();
1870        assert!(
1871            elapsed < std::time::Duration::from_secs(1),
1872            "compile_ir must fail fast (or succeed fast), took {elapsed:?}"
1873        );
1874        // Either outcome is fine: the point is speed, not which backend wins this schema.
1875        if let Ok(engine) = result {
1876            assert!(engine.accepts(br#"{"builders":[{"builder":"a:b"}]}"#));
1877        } else {
1878            assert!(crate::structured::try_accepts(
1879                std::sync::Arc::new(ir),
1880                br#"{"builders":[{"builder":"a:b"}]}"#,
1881            )
1882            .expect("structured program"));
1883        }
1884    }
1885
1886    // Regression for a real jsonschemabench schema (Github_easy/o67463.json) that used to take
1887    // 20+ seconds: array-of-array-of-oneOf, whose eliminated regex text doubled per array wrap.
1888    #[test]
1889    fn a_real_world_nested_array_of_array_of_one_of_schema_compiles_or_falls_back_fast() {
1890        // The jsonschemabench corpus is an optional local checkout, not part of this repository.
1891        let path = format!(
1892            "{}/../../integration/jsonschemabench/data/Github_easy/o67463.json",
1893            env!("CARGO_MANIFEST_DIR")
1894        );
1895        let Ok(schema) = std::fs::read_to_string(&path) else {
1896            eprintln!("skipping: corpus not present at {path}");
1897            return;
1898        };
1899        let opts = CompileOptions {
1900            object_closure: crate::ir::ObjectClosure::AssumeClosedProfile,
1901            ..CompileOptions::default()
1902        };
1903        let ir = crate::frontend::schema_to_ir(&schema, opts).unwrap();
1904        assert_eq!(ir.diagnostics().next(), None);
1905        let start = std::time::Instant::now();
1906        let result = compile_ir(&ir);
1907        let elapsed = start.elapsed();
1908        assert!(
1909            elapsed < std::time::Duration::from_secs(5),
1910            "compile_ir must fail fast (or succeed fast), took {elapsed:?}"
1911        );
1912        let data = br#"[[{"field":"year"}]]"#;
1913        if let Ok(engine) = result {
1914            assert!(engine.accepts(data));
1915        } else {
1916            assert!(
1917                crate::structured::try_accepts(std::sync::Arc::new(ir), data)
1918                    .expect("structured program")
1919            );
1920        }
1921    }
1922
1923    fn engine_for_integer(lo: Option<i64>, hi: Option<i64>) -> RefEngine {
1924        let mut b = Builder::new(CompileOptions::default());
1925        let n = b.integer(lo, hi).unwrap();
1926        let ir = b.finish(n).unwrap();
1927        compile_ir(&ir).unwrap()
1928    }
1929
1930    #[test]
1931    fn integer_range_matches_membership_by_brute_force() {
1932        let bounds = [
1933            (Some(0), Some(0)),
1934            (Some(0), Some(9)),
1935            (Some(1), Some(255)),
1936            (Some(-5), Some(5)),
1937            (Some(-128), Some(127)),
1938            (Some(10), Some(1000)),
1939            (Some(-1000), Some(-7)),
1940            (Some(-50), Some(50)),
1941            (Some(99), Some(101)),
1942            (Some(7), Some(7)),
1943            (None, Some(3)),
1944            (Some(-3), None),
1945            (None, None),
1946        ];
1947        for (lo, hi) in bounds {
1948            let e = engine_for_integer(lo, hi);
1949            for n in -2000i64..=2000 {
1950                let want = lo.is_none_or(|l| n >= l) && hi.is_none_or(|h| n <= h);
1951                let got = e.accepts(n.to_string().as_bytes());
1952                assert_eq!(got, want, "n={n} bounds=({lo:?},{hi:?})");
1953            }
1954        }
1955    }
1956
1957    fn engine_for_tuple(
1958        prefix: Vec<(Option<i64>, Option<i64>)>,
1959        tail: Option<(Option<i64>, Option<i64>)>,
1960        min: u32,
1961        max: Option<u32>,
1962    ) -> RefEngine {
1963        let mut b = Builder::new(CompileOptions::default());
1964        let prefix_ids = prefix
1965            .iter()
1966            .map(|&(lo, hi)| b.integer(lo, hi).unwrap())
1967            .collect();
1968        let tail_id = tail.map(|(lo, hi)| b.integer(lo, hi).unwrap());
1969        let node = b.tuple(prefix_ids, tail_id, min, max).unwrap();
1970        let ir = b.finish(node).unwrap();
1971        compile_ir(&ir).unwrap()
1972    }
1973
1974    #[test]
1975    fn tuple_bounded_open_tail_accepts_exactly_the_valid_lengths() {
1976        // prefix [int], uniform int tail, minItems 2, maxItems 4: arrays of 2..=4 single-digit ints.
1977        let e = engine_for_tuple(
1978            vec![(Some(0), Some(9))],
1979            Some((Some(0), Some(9))),
1980            2,
1981            Some(4),
1982        );
1983        assert!(!e.accepts(b"[1]"), "below minItems");
1984        assert!(e.accepts(b"[1,2]"));
1985        assert!(e.accepts(b"[1,2,3]"));
1986        assert!(e.accepts(b"[1,2,3,4]"));
1987        assert!(!e.accepts(b"[1,2,3,4,5]"), "above maxItems");
1988    }
1989
1990    #[test]
1991    fn tuple_max_items_below_the_prefix_length_caps_within_the_prefix() {
1992        // prefix of 3 positions, uniform tail, but maxItems 2 forces every valid length <= 2.
1993        let e = engine_for_tuple(
1994            vec![(Some(0), Some(9)), (Some(0), Some(9)), (Some(0), Some(9))],
1995            Some((Some(0), Some(9))),
1996            1,
1997            Some(2),
1998        );
1999        assert!(e.accepts(b"[1]"));
2000        assert!(e.accepts(b"[1,2]"));
2001        assert!(
2002            !e.accepts(b"[1,2,3]"),
2003            "maxItems 2 is below the 3-position prefix"
2004        );
2005    }
2006
2007    #[test]
2008    fn tuple_moderate_max_items_compiles_without_a_quadratic_alternation() {
2009        // A moderate maxItems that would be O(max^2) as an enumerated alternation compiles fine as a
2010        // bounded repetition; a length in range accepts and a length past it rejects.
2011        let e = engine_for_tuple(
2012            vec![(Some(0), Some(9))],
2013            Some((Some(0), Some(9))),
2014            1,
2015            Some(64),
2016        );
2017        assert!(e.accepts(b"[1,2,3,4,5]"));
2018        assert!(e.accepts(b"[0]"));
2019    }
2020
2021    #[test]
2022    fn integer_extremes_compile_and_accept_the_boundary() {
2023        let e = engine_for_integer(Some(i64::MIN), Some(i64::MAX));
2024        assert!(e.accepts(i64::MIN.to_string().as_bytes()));
2025        assert!(e.accepts(i64::MAX.to_string().as_bytes()));
2026        assert!(e.accepts(b"0"));
2027        assert!(!e.accepts(b"-0"));
2028        assert!(!e.accepts(b"007"));
2029    }
2030
2031    #[test]
2032    fn rejects_leading_zero_and_plus() {
2033        let e = engine_for_integer(Some(0), Some(100));
2034        assert!(e.accepts(b"7"));
2035        assert!(!e.accepts(b"07"));
2036        assert!(!e.accepts(b"+7"));
2037    }
2038
2039    #[test]
2040    fn compiling_an_unsupported_ir_fails_with_the_diagnostic() {
2041        use crate::diagnostics::UnsupportedReason;
2042        let mut b = Builder::new(CompileOptions::default());
2043        let u = b
2044            .unsupported("anyOf", UnsupportedReason::UnionType, "/anyOf")
2045            .unwrap();
2046        let ir = b.finish(u).unwrap();
2047        let e = compile_ir(&ir).unwrap_err();
2048        assert_eq!(e.code, ErrorCode::Unsupported);
2049        assert_eq!(e.json_pointer_path.as_deref(), Some("/anyOf"));
2050    }
2051
2052    #[test]
2053    fn boolean_and_null_and_enum_compile() {
2054        let mut b = Builder::new(CompileOptions::default());
2055        let n = b.boolean().unwrap();
2056        let ir = b.finish(n).unwrap();
2057        let e = compile_ir(&ir).unwrap();
2058        assert!(e.accepts(b"true") && e.accepts(b"false") && !e.accepts(b"null"));
2059    }
2060
2061    #[test]
2062    fn large_string_enum_compiles_as_a_decoded_value_trie() {
2063        let mut values = (0..=crate::ir::MAX_UNROLLED_ENUM)
2064            .map(|index| ScalarLit::Str(format!("member-{index:04}")))
2065            .collect::<Vec<_>>();
2066        values.extend([
2067            ScalarLit::Str("a".to_owned()),
2068            ScalarLit::Str("😀".to_owned()),
2069        ]);
2070        let mut builder = Builder::new(CompileOptions::default());
2071        let root = builder.enum_values(values).unwrap();
2072        let ir = builder.finish(root).unwrap();
2073        assert!(!ir.requires_structured_backend());
2074        let engine = compile_ir(&ir).unwrap();
2075        assert!(engine.accepts(br#""member-0042""#));
2076        assert!(engine.accepts(br#""\u0061""#));
2077        assert!(engine.accepts(br#""\uD83D\uDE00""#));
2078        assert!(!engine.accepts(br#""member-9999""#));
2079    }
2080
2081    #[test]
2082    fn small_string_enum_uses_decoded_json_string_semantics() {
2083        let mut builder = Builder::new(CompileOptions::default());
2084        let root = builder
2085            .enum_values(vec![
2086                ScalarLit::Str("a".to_owned()),
2087                ScalarLit::Str("😀".to_owned()),
2088            ])
2089            .unwrap();
2090        let engine = compile_ir(&builder.finish(root).unwrap()).unwrap();
2091        for accepted in [br#""a""#.as_slice(), br#""\u0061""#, br#""\ud83d\ude00""#] {
2092            assert!(engine.accepts(accepted), "rejected {accepted:?}");
2093        }
2094    }
2095
2096    fn engine_for_union(ranges: &[(Option<i64>, Option<i64>)]) -> RefEngine {
2097        let mut b = Builder::new(CompileOptions::default());
2098        let branches = ranges
2099            .iter()
2100            .map(|&(lo, hi)| b.integer(lo, hi).unwrap())
2101            .collect();
2102        let u = b.union_of(branches).unwrap();
2103        let ir = b.finish(u).unwrap();
2104        compile_ir(&ir).unwrap()
2105    }
2106
2107    /// A byte-prefix collision (one branch's shorter accepted string is a textual prefix of
2108    /// another branch's longer one, e.g. "1" from `[0,5]` prefixing "10" from `[3,10]`) must not
2109    /// cause the union to drop the longer match: see `automaton::byte_dfa::build_from_regex` for
2110    /// why `MatchKind::All` is required for a union DFA.
2111    #[test]
2112    fn union_of_colliding_integer_ranges_matches_membership_by_brute_force() {
2113        let cases: &[&[(Option<i64>, Option<i64>)]] = &[
2114            &[(Some(0), Some(5)), (Some(3), Some(10))],
2115            &[(Some(0), Some(10)), (Some(5), Some(15))],
2116            &[(Some(-5), Some(5)), (Some(0), Some(10))],
2117            &[(Some(-100), Some(-7)), (Some(-20), Some(20))],
2118        ];
2119        for ranges in cases {
2120            let e = engine_for_union(ranges);
2121            for n in -200i64..=200 {
2122                let want = ranges
2123                    .iter()
2124                    .any(|&(lo, hi)| lo.is_none_or(|l| n >= l) && hi.is_none_or(|h| n <= h));
2125                let got = e.accepts(n.to_string().as_bytes());
2126                assert_eq!(got, want, "n={n} ranges={ranges:?}");
2127            }
2128        }
2129    }
2130
2131    #[test]
2132    fn union_of_0_5_and_3_10_accepts_10() {
2133        let e = engine_for_union(&[(Some(0), Some(5)), (Some(3), Some(10))]);
2134        assert!(
2135            e.accepts(b"10"),
2136            "10 is in [3,10] so the union must accept it"
2137        );
2138        assert!(e.accepts(b"0"));
2139        assert!(e.accepts(b"5"));
2140        assert!(!e.accepts(b"11"));
2141    }
2142
2143    #[test]
2144    fn direct_intersection_preserves_acceptance_and_outer_whitespace() {
2145        let schema = r#"{"allOf":[{"type":"integer","minimum":0,"maximum":10},{"type":"integer","minimum":5,"maximum":20}]}"#;
2146        let ir = crate::frontend::schema_to_ir(schema, CompileOptions::default()).unwrap();
2147        let engine = compile_ir(&ir).unwrap();
2148        for accepted in [b"5".as_slice(), b"10", b" \t5\r\n"] {
2149            assert!(engine.accepts(accepted), "rejected {accepted:?}");
2150        }
2151        for rejected in [b"4".as_slice(), b"11"] {
2152            assert!(!engine.accepts(rejected), "accepted {rejected:?}");
2153        }
2154    }
2155
2156    /// Not a correctness check: prints where compile time actually goes (frontend, IR-to-regex
2157    /// lowering, regex-to-engine) on the real production-corpus schemas. `cargo test --release
2158    /// -p maskforge-core --lib compile::tests::stage_breakdown -- --ignored --nocapture`.
2159    #[test]
2160    #[ignore]
2161    fn stage_breakdown_on_production_corpus_schemas() {
2162        use crate::frontend::schema_to_ir;
2163        use std::time::Instant;
2164
2165        let schemas: [(&str, &str); 6] = [
2166            ("bool", r#"{"type":"boolean"}"#),
2167            (
2168                "flat_object",
2169                r#"{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"],"additionalProperties":false}"#,
2170            ),
2171            (
2172                "nested_object",
2173                r#"{"type":"object","properties":{"user":{"type":"object","properties":{"id":{"type":"integer"},"active":{"type":"boolean"}},"required":["id","active"],"additionalProperties":false}},"required":["user"],"additionalProperties":false}"#,
2174            ),
2175            (
2176                "bounded_array_of_objects",
2177                r#"{"type":"array","items":{"type":"object","properties":{"x":{"type":"number"}},"required":["x"],"additionalProperties":false},"minItems":1,"maxItems":5}"#,
2178            ),
2179            (
2180                "deeply_nested",
2181                r#"{"type":"object","properties":{"l1":{"type":"object","properties":{"l2":{"type":"object","properties":{"l3":{"type":"boolean"}},"required":["l3"],"additionalProperties":false}},"required":["l2"],"additionalProperties":false}},"required":["l1"],"additionalProperties":false}"#,
2182            ),
2183            (
2184                "mixed_object_array",
2185                r#"{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"},"minItems":0,"maxItems":3},"count":{"type":"integer"}},"required":["tags","count"],"additionalProperties":false}"#,
2186            ),
2187        ];
2188
2189        for (name, text) in schemas {
2190            let n = 200;
2191            let mut frontend_ns = 0u128;
2192            let mut lower_ns = 0u128;
2193            let mut engine_ns = 0u128;
2194            for _ in 0..n {
2195                let t0 = Instant::now();
2196                let ir = schema_to_ir(text, crate::ir::CompileOptions::default()).unwrap();
2197                frontend_ns += t0.elapsed().as_nanos();
2198
2199                let t0 = Instant::now();
2200                let pattern = lower_reachable(&ir, ir.root()).unwrap();
2201                lower_ns += t0.elapsed().as_nanos();
2202
2203                let t0 = Instant::now();
2204                build_from_regex(&pattern).unwrap();
2205                engine_ns += t0.elapsed().as_nanos();
2206            }
2207            println!(
2208                "{name:28} frontend={:>8.1}us  lower={:>8.1}us  regex_to_engine={:>8.1}us",
2209                frontend_ns as f64 / n as f64 / 1000.0,
2210                lower_ns as f64 / n as f64 / 1000.0,
2211                engine_ns as f64 / n as f64 / 1000.0,
2212            );
2213        }
2214    }
2215
2216    /// Exhaustive sweep: every pair of range endpoints within 0..8, checked against integer
2217    /// membership by brute force. Building one DFA per pair dominates the cost, so the sweep is
2218    /// kept small (9*10/2 = 45 ranges, 45*45 = 2025 pairs) to stay in the low seconds.
2219    #[test]
2220    fn exhaustive_small_range_pair_sweep_agrees_with_brute_force_membership() {
2221        let ranges: Vec<(i64, i64)> = (0i64..=8)
2222            .flat_map(|lo| (lo..=8).map(move |hi| (lo, hi)))
2223            .collect();
2224        for &(a_lo, a_hi) in &ranges {
2225            for &(b_lo, b_hi) in &ranges {
2226                let branches = [(Some(a_lo), Some(a_hi)), (Some(b_lo), Some(b_hi))];
2227                let e = engine_for_union(&branches);
2228                for n in 0i64..=15 {
2229                    let want = (n >= a_lo && n <= a_hi) || (n >= b_lo && n <= b_hi);
2230                    let got = e.accepts(n.to_string().as_bytes());
2231                    assert_eq!(got, want, "n={n} a=[{a_lo},{a_hi}] b=[{b_lo},{b_hi}]");
2232                }
2233            }
2234        }
2235    }
2236
2237    /// Sweeps every min/max bound pair (with inclusive/exclusive) in -3..=3 against candidates
2238    /// covering both signs, magnitudes 0..=5, and with/without a fraction, checked against an
2239    /// independent f64 comparison - not the regex construction under test.
2240    #[test]
2241    fn number_range_brute_force_matches_independent_decimal_comparison() {
2242        for min_v in -3i64..=3 {
2243            for min_excl in [false, true] {
2244                for max_v in -3i64..=3 {
2245                    for max_excl in [false, true] {
2246                        if max_v < min_v || (max_v == min_v && (min_excl || max_excl)) {
2247                            continue;
2248                        }
2249                        let pattern =
2250                            number_range_regex(Some((min_v, min_excl)), Some((max_v, max_excl)));
2251                        let e = build_from_regex(&pattern).unwrap();
2252                        for sign in [1i64, -1i64] {
2253                            for int_mag in 0i64..=5 {
2254                                for has_frac in [false, true] {
2255                                    if sign < 0 && int_mag == 0 && !has_frac {
2256                                        continue;
2257                                    }
2258                                    let text = format!(
2259                                        "{}{int_mag}{}",
2260                                        if sign < 0 { "-" } else { "" },
2261                                        if has_frac { ".1" } else { "" }
2262                                    );
2263                                    let value = sign as f64
2264                                        * (int_mag as f64 + if has_frac { 0.1 } else { 0.0 });
2265                                    let ge_min = if min_excl {
2266                                        value > min_v as f64
2267                                    } else {
2268                                        value >= min_v as f64
2269                                    };
2270                                    let le_max = if max_excl {
2271                                        value < max_v as f64
2272                                    } else {
2273                                        value <= max_v as f64
2274                                    };
2275                                    let want = ge_min && le_max;
2276                                    let got = e.accepts(text.as_bytes());
2277                                    assert_eq!(
2278                                        got, want,
2279                                        "text={text} min=({min_v},{min_excl}) max=({max_v},{max_excl})"
2280                                    );
2281                                }
2282                            }
2283                        }
2284                    }
2285                }
2286            }
2287        }
2288    }
2289
2290    fn engine_from_schema(schema: &str) -> RefEngine {
2291        let ir = crate::frontend::schema_to_ir(schema, CompileOptions::default()).unwrap();
2292        compile_ir(&ir).unwrap()
2293    }
2294
2295    #[test]
2296    fn object_whitespace_variants_are_all_accepted() {
2297        let e = engine_from_schema(
2298            r#"{"type":"object","properties":{"a":{"type":"integer"}},"required":["a"],"additionalProperties":false}"#,
2299        );
2300        assert!(e.accepts(br#"{"a":1}"#));
2301        assert!(e.accepts(br#"{"a" : 1}"#));
2302        assert!(e.accepts(br#"{ "a" : 1 }"#));
2303        assert!(e.accepts(b"{\n\"a\"\t:\r1\n}"));
2304    }
2305
2306    #[test]
2307    fn array_whitespace_variants_are_all_accepted() {
2308        let e = engine_from_schema(r#"{"type":"array","items":{"type":"integer"}}"#);
2309        assert!(e.accepts(b"[1,2]"));
2310        assert!(e.accepts(b"[ 1 , 2 ]"));
2311    }
2312
2313    #[test]
2314    fn root_leading_and_trailing_whitespace_is_accepted() {
2315        let e = engine_from_schema(r#"{"type":"boolean"}"#);
2316        assert!(e.accepts(b" true"));
2317        assert!(e.accepts(b"true "));
2318        assert!(e.accepts(b" \t\ntrue\r\n "));
2319    }
2320
2321    #[test]
2322    fn nested_object_and_array_whitespace_is_accepted() {
2323        let e = engine_from_schema(
2324            r#"{"type":"object","properties":{"xs":{"type":"array","items":{"type":"boolean"}}},"required":["xs"],"additionalProperties":false}"#,
2325        );
2326        assert!(e.accepts(br#"{ "xs" : [ true , false ] }"#));
2327    }
2328
2329    #[test]
2330    fn punctuation_inside_a_string_const_is_never_treated_as_structure() {
2331        let e = engine_from_schema(r#"{"const":"a:b,c"}"#);
2332        assert!(e.accepts(br#""a:b,c""#));
2333        assert!(
2334            !e.accepts(br#""a: b, c""#),
2335            "string content is literal, not JSON structure"
2336        );
2337    }
2338
2339    #[test]
2340    fn composite_object_const_keeps_string_content_literal_but_own_structure_spaced() {
2341        let e = engine_from_schema(r#"{"const":{"a":"x:y,z"}}"#);
2342        assert!(e.accepts(br#"{"a":"x:y,z"}"#));
2343        assert!(e.accepts(br#"{ "a" : "x:y,z" }"#));
2344        assert!(
2345            !e.accepts(br#"{"a":"x: y, z"}"#),
2346            "the : and , inside the string are literal"
2347        );
2348    }
2349
2350    #[test]
2351    fn composite_literal_handles_escaped_quotes_and_backslashes() {
2352        let e = engine_from_schema(r#"{"const":{"k":"a\"b\\c"}}"#);
2353        assert!(e.accepts(br#"{"k":"a\"b\\c"}"#));
2354        assert!(e.accepts(br#"{ "k" : "a\"b\\c" }"#));
2355    }
2356
2357    #[test]
2358    fn composite_array_and_enum_const_accept_whitespace_around_their_own_structure() {
2359        let arr = engine_from_schema(r#"{"const":[1,{"a":true},[2,3]]}"#);
2360        assert!(arr.accepts(br#"[1,{"a":true},[2,3]]"#));
2361        assert!(arr.accepts(br#"[ 1 , { "a" : true } , [ 2 , 3 ] ]"#));
2362
2363        let en = engine_from_schema(r#"{"enum":[{"x":1},[2,3]]}"#);
2364        assert!(en.accepts(br#"{ "x" : 1 }"#));
2365        assert!(en.accepts(b"[ 2 , 3 ]"));
2366    }
2367}