Skip to main content

sim_lib_pattern/
execute.rs

1//! Iterative, resource-accounted execution of regular pattern automata.
2
3use crate::{Anchor, Automaton, CaptureId, Instruction, StateId, TagBoundary, TextLimits};
4use std::collections::{BTreeMap, BTreeSet};
5
6/// One completed tagged capture, expressed in subject-symbol offsets.
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub struct CaptureSpan {
9    /// Inclusive start offset.
10    pub start: usize,
11    /// Exclusive end offset.
12    pub end: usize,
13}
14
15/// A successful regular-engine match.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct ExecutionMatch {
18    /// Inclusive start offset.
19    pub start: usize,
20    /// Exclusive end offset.
21    pub end: usize,
22    /// Captures keyed by their stable compiled identifier.
23    pub captures: BTreeMap<CaptureId, CaptureSpan>,
24}
25
26/// The resource whose configured limit stopped execution.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum ExecutionLimit {
29    /// The compiled graph contains more states than admitted.
30    States,
31    /// Transition work reached `TextLimits::max_steps`.
32    Transitions,
33    /// Capture history reached `TextLimits::max_capture_history`.
34    CaptureHistory,
35    /// The subject exceeds `TextLimits::max_subject_symbols`.
36    Subject,
37}
38
39/// Exact work consumed by an execution attempt.
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41pub struct ExecutionReceipt {
42    /// Compiled states in the input graph.
43    pub state_count: usize,
44    /// State configurations removed from the iterative worklists.
45    pub state_visits: usize,
46    /// Graph transitions considered.
47    pub transitions: usize,
48    /// Capture-boundary records created.
49    pub capture_history: usize,
50    /// Subject symbols presented to the executor.
51    pub subject_symbols: usize,
52}
53
54/// A pattern feature deliberately excluded from the regular executor.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum UnsupportedFeature {
57    /// The assertion has no statically provable fixed width.
58    VariableWidthAssertion(crate::AssertionId),
59}
60
61/// A typed execution result. Resource exhaustion is never collapsed into rejection.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub enum ExecutionOutcome {
64    /// The automaton accepted a subject prefix.
65    Match {
66        /// Match and captures.
67        matched: ExecutionMatch,
68        /// Consumed work.
69        receipt: ExecutionReceipt,
70    },
71    /// The automaton definitively rejected the subject.
72    NoMatch {
73        /// Consumed work.
74        receipt: ExecutionReceipt,
75    },
76    /// A configured resource boundary stopped execution.
77    Limit {
78        /// Exhausted resource.
79        limit: ExecutionLimit,
80        /// Work consumed before stopping.
81        receipt: ExecutionReceipt,
82    },
83    /// The requested construct belongs to the separately budgeted extension lane.
84    Unsupported {
85        /// Exact unsupported construct.
86        feature: UnsupportedFeature,
87        /// Regular work consumed before discovering it.
88        receipt: ExecutionReceipt,
89    },
90}
91
92#[derive(Clone, Debug, Default)]
93struct History {
94    open: BTreeMap<CaptureId, usize>,
95    closed: BTreeMap<CaptureId, CaptureSpan>,
96}
97
98#[derive(Clone, Debug)]
99struct Thread {
100    state: StateId,
101    repeats: BTreeMap<StateId, usize>,
102    history: History,
103}
104
105type SpanMatcher<'a, S, E> = dyn Fn(&E, &[S], usize) -> Option<usize> + 'a;
106
107/// Executes a compiled regular automaton without recursion or backtracking.
108///
109/// `extension_matches` supplies the consuming predicate for admitted extension
110/// states. Fixed-width assertions remain in this accounted regular model;
111/// variable-width assertions are returned as typed refusals.
112pub fn execute_regular<S, E>(
113    automaton: &Automaton<S, E>,
114    subject: &[S],
115    limits: TextLimits,
116    extension_matches: impl Fn(&E, &S) -> bool,
117) -> ExecutionOutcome
118where
119    S: PartialEq,
120{
121    execute_spanning(
122        automaton,
123        subject,
124        limits,
125        |extension, subject, position| {
126            subject
127                .get(position)
128                .filter(|symbol| extension_matches(extension, symbol))
129                .map(|_| position + 1)
130        },
131    )
132}
133
134/// Executes an automaton whose admitted extensions may consume any bounded
135/// subject span, including a zero-width span.
136///
137/// The callback returns the exclusive end position of a successful extension
138/// match. Returning a position before the supplied start or beyond the subject
139/// rejects that extension attempt.
140pub(crate) fn execute_spanning<S, E>(
141    automaton: &Automaton<S, E>,
142    subject: &[S],
143    limits: TextLimits,
144    extension_match: impl Fn(&E, &[S], usize) -> Option<usize>,
145) -> ExecutionOutcome
146where
147    S: PartialEq,
148{
149    execute_regular_inner(automaton, subject, limits, &extension_match)
150}
151
152fn execute_regular_inner<S, E>(
153    automaton: &Automaton<S, E>,
154    subject: &[S],
155    limits: TextLimits,
156    extension_match: &SpanMatcher<'_, S, E>,
157) -> ExecutionOutcome
158where
159    S: PartialEq,
160{
161    let mut receipt = ExecutionReceipt {
162        state_count: automaton.evidence().state_count,
163        subject_symbols: subject.len(),
164        ..ExecutionReceipt::default()
165    };
166    if receipt.state_count > limits.max_states {
167        return limited(ExecutionLimit::States, receipt);
168    }
169    if receipt.subject_symbols > limits.max_subject_symbols {
170        return limited(ExecutionLimit::Subject, receipt);
171    }
172
173    let mut current = vec![(
174        0,
175        Thread {
176            state: automaton.start(),
177            repeats: BTreeMap::new(),
178            history: History::default(),
179        },
180    )];
181    let mut seen = BTreeSet::new();
182    while let Some((position, thread)) = current.pop() {
183        receipt.state_visits += 1;
184        // Thompson state-set execution retains the first (priority-ordered)
185        // history reaching a state at a subject position.
186        if !seen.insert((position, thread.state)) {
187            continue;
188        }
189        let Some(state) = automaton.states().get(thread.state.0 as usize) else {
190            continue;
191        };
192        match &state.instruction {
193            Instruction::Accept => {
194                return ExecutionOutcome::Match {
195                    matched: ExecutionMatch {
196                        start: 0,
197                        end: position,
198                        captures: thread.history.closed,
199                    },
200                    receipt,
201                };
202            }
203            Instruction::Symbol { symbol, next } => {
204                if subject.get(position).is_some_and(|found| found == symbol) {
205                    push_at(&mut current, position + 1, thread, *next);
206                }
207            }
208            Instruction::Any { next } => {
209                if position < subject.len() {
210                    push_at(&mut current, position + 1, thread, *next);
211                }
212            }
213            Instruction::Extension { extension, next } => {
214                if let Some(end) = extension_match(extension, subject, position)
215                    && (position..=subject.len()).contains(&end)
216                {
217                    push_at(&mut current, end, thread, *next);
218                }
219            }
220            Instruction::Epsilon { next } => push_at(&mut current, position, thread, *next),
221            Instruction::Split { alternatives } => {
222                for next in alternatives.iter().rev() {
223                    push_at(&mut current, position, thread.clone(), *next);
224                }
225            }
226            Instruction::Tag {
227                capture,
228                boundary,
229                next,
230            } => {
231                if receipt.capture_history == limits.max_capture_history {
232                    return limited(ExecutionLimit::CaptureHistory, receipt);
233                }
234                receipt.capture_history += 1;
235                let mut thread = thread;
236                match boundary {
237                    TagBoundary::Start => {
238                        thread.history.open.insert(*capture, position);
239                    }
240                    TagBoundary::End => {
241                        if let Some(start) = thread.history.open.remove(capture) {
242                            thread.history.closed.insert(
243                                *capture,
244                                CaptureSpan {
245                                    start,
246                                    end: position,
247                                },
248                            );
249                        }
250                    }
251                }
252                push_at(&mut current, position, thread, *next);
253            }
254            Instruction::Anchor { anchor, next } => {
255                let holds = match anchor {
256                    Anchor::SubjectStart => position == 0,
257                    Anchor::SubjectEnd => position == subject.len(),
258                };
259                if holds {
260                    push_at(&mut current, position, thread, *next);
261                }
262            }
263            Instruction::Repeat {
264                body,
265                exit,
266                min,
267                max,
268                greedy,
269            } => {
270                let count = thread.repeats.get(&thread.state).copied().unwrap_or(0);
271                let can_repeat = max.is_none_or(|maximum| count < maximum);
272                let can_exit = count >= *min;
273                let mut body_thread = thread.clone();
274                body_thread.repeats.insert(thread.state, count + 1);
275                let choices = if *greedy {
276                    [(can_exit, *exit, thread), (can_repeat, *body, body_thread)]
277                } else {
278                    [(can_repeat, *body, body_thread), (can_exit, *exit, thread)]
279                };
280                for (enabled, next, thread) in choices {
281                    if enabled {
282                        push_at(&mut current, position, thread, next);
283                    }
284                }
285            }
286            Instruction::Assertion { assertion, next } => {
287                let Some(program) = automaton.assertion(*assertion) else {
288                    return ExecutionOutcome::Unsupported {
289                        feature: UnsupportedFeature::VariableWidthAssertion(*assertion),
290                        receipt,
291                    };
292                };
293                let end = position.saturating_add(program.width());
294                if let Some(window) = subject.get(position..end) {
295                    let remaining = TextLimits {
296                        max_steps: limits.max_steps.saturating_sub(receipt.transitions),
297                        max_states: limits.max_states,
298                        max_capture_history: limits
299                            .max_capture_history
300                            .saturating_sub(receipt.capture_history),
301                        max_subject_symbols: limits.max_subject_symbols,
302                    };
303                    match execute_regular_inner(
304                        program.automaton(),
305                        window,
306                        remaining,
307                        extension_match,
308                    ) {
309                        ExecutionOutcome::Match {
310                            matched,
311                            receipt: nested,
312                        } if matched.end == window.len() => {
313                            receipt.state_visits += nested.state_visits;
314                            receipt.transitions += nested.transitions;
315                            receipt.capture_history += nested.capture_history;
316                            push_at(&mut current, position, thread, *next);
317                        }
318                        ExecutionOutcome::Limit {
319                            limit,
320                            receipt: nested,
321                        } => {
322                            receipt.state_visits += nested.state_visits;
323                            receipt.transitions += nested.transitions;
324                            receipt.capture_history += nested.capture_history;
325                            return limited(limit, receipt);
326                        }
327                        _ => {}
328                    }
329                }
330            }
331        }
332        receipt.transitions += 1;
333        if receipt.transitions >= limits.max_steps {
334            return limited(ExecutionLimit::Transitions, receipt);
335        }
336    }
337    ExecutionOutcome::NoMatch { receipt }
338}
339
340fn push_at(stack: &mut Vec<(usize, Thread)>, position: usize, mut thread: Thread, state: StateId) {
341    thread.state = state;
342    stack.push((position, thread));
343}
344
345fn limited(limit: ExecutionLimit, receipt: ExecutionReceipt) -> ExecutionOutcome {
346    ExecutionOutcome::Limit { limit, receipt }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::{ByteDomain, EnginePolicy, IrNode, PatternIr, RepeatBounds, compile};
353
354    fn run(root: IrNode<u8, ()>, subject: &[u8], limits: TextLimits) -> ExecutionOutcome {
355        let ir = PatternIr::<ByteDomain, ()>::new(root, BTreeMap::new(), &EnginePolicy::new([]))
356            .unwrap();
357        execute_regular(&compile(&ir), subject, limits, |_, _| false)
358    }
359
360    #[test]
361    fn nested_ambiguous_repetition_has_linear_accounted_work() {
362        let repeated_a = IrNode::Repeat {
363            node: Box::new(IrNode::Alternation(vec![
364                IrNode::Symbol(b'a'),
365                IrNode::Concat(vec![IrNode::Symbol(b'a')]),
366            ])),
367            bounds: RepeatBounds::new(0, None).unwrap(),
368            greedy: true,
369        };
370        let pattern = IrNode::Concat(vec![repeated_a, IrNode::Symbol(b'b')]);
371        for length in [32, 128, 512] {
372            let outcome = run(pattern.clone(), &vec![b'a'; length], TextLimits::default());
373            let ExecutionOutcome::NoMatch { receipt } = outcome else {
374                panic!("adversarial rejection must complete normally: {outcome:?}");
375            };
376            assert!(receipt.state_visits <= (length + 1) * receipt.state_count * 2);
377        }
378    }
379
380    #[test]
381    fn long_rejection_terminates_and_limits_are_typed() {
382        let pattern = IrNode::Concat(vec![IrNode::Any, IrNode::Symbol(b'z')]);
383        let subject = vec![b'a'; 10_000];
384        assert!(matches!(
385            run(pattern.clone(), &subject, TextLimits::default()),
386            ExecutionOutcome::NoMatch { .. }
387        ));
388        let limits = TextLimits {
389            max_steps: 1,
390            ..TextLimits::default()
391        };
392        assert!(matches!(
393            run(pattern, b"az", limits),
394            ExecutionOutcome::Limit {
395                limit: ExecutionLimit::Transitions,
396                ..
397            }
398        ));
399    }
400
401    #[test]
402    fn fixed_width_assertion_runs_without_consuming_subject() {
403        let assertion = crate::AssertionId(7);
404        let ir = PatternIr::<ByteDomain, ()>::new(
405            IrNode::Concat(vec![IrNode::Assertion(assertion), IrNode::Symbol(b'a')]),
406            BTreeMap::from([(assertion, IrNode::Symbol(b'a'))]),
407            &EnginePolicy::new([]),
408        )
409        .unwrap();
410        let outcome = execute_regular(&compile(&ir), b"a", TextLimits::default(), |_, _| false);
411        assert!(matches!(
412            outcome,
413            ExecutionOutcome::Match {
414                matched: ExecutionMatch { end: 1, .. },
415                ..
416            }
417        ));
418    }
419
420    #[test]
421    fn regular_pattern_keeps_the_pre_extension_receipt() {
422        let outcome = run(IrNode::Symbol(b'a'), b"a", TextLimits::default());
423        assert_eq!(
424            outcome,
425            ExecutionOutcome::Match {
426                matched: ExecutionMatch {
427                    start: 0,
428                    end: 1,
429                    captures: BTreeMap::new(),
430                },
431                receipt: ExecutionReceipt {
432                    state_count: 2,
433                    state_visits: 2,
434                    transitions: 1,
435                    capture_history: 0,
436                    subject_symbols: 1,
437                },
438            }
439        );
440    }
441
442    #[test]
443    fn variable_width_assertion_is_a_typed_refusal() {
444        let assertion = crate::AssertionId(9);
445        let ir = PatternIr::<ByteDomain, ()>::new(
446            IrNode::Assertion(assertion),
447            BTreeMap::from([(
448                assertion,
449                IrNode::Repeat {
450                    node: Box::new(IrNode::Symbol(b'a')),
451                    bounds: RepeatBounds::new(0, None).unwrap(),
452                    greedy: true,
453                },
454            )]),
455            &EnginePolicy::new([]),
456        )
457        .unwrap();
458        assert!(matches!(
459            execute_regular(&compile(&ir), b"aaa", TextLimits::default(), |_, _| false),
460            ExecutionOutcome::Unsupported {
461                feature: UnsupportedFeature::VariableWidthAssertion(found),
462                ..
463            } if found == assertion
464        ));
465    }
466}