Skip to main content

uqa_graph/
rpq.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Regular path queries: expression AST, parser, algebraic simplifier,
8//! Thompson NFA, and subset-construction DFA. Together these power the
9//! `RegularPathQuery` operator (see [`crate::operators`]).
10//!
11//! Grammar:
12//! ```text
13//!     atom        := label | '(' alternation ')'
14//!     star        := atom ('*' | '{' int ',' int '}')*
15//!     concat      := star ('/' star)*
16//!     alternation := concat ('|' concat)*
17//! ```
18//! Precedence (low to high): alternation, concatenation, star.
19
20use std::collections::{BTreeMap, BTreeSet, VecDeque};
21
22/// Hard limits keep user-provided path expressions from turning NFA or DFA
23/// compilation into an unbounded memory allocation. They are deliberately
24/// independent: a compact NFA can still have an exponential DFA.
25pub const MAX_RPQ_AST_DEPTH: usize = 256;
26pub const MAX_NFA_STATES: usize = 16_384;
27pub const MAX_DFA_STATES: usize = 16_384;
28
29/// Regular path expression.
30#[derive(Debug, Clone, PartialEq, Eq, Hash)]
31pub enum RegularPathExpr {
32    /// A single edge label.
33    Label(String),
34    /// `lhs / rhs`.
35    Concat(Box<RegularPathExpr>, Box<RegularPathExpr>),
36    /// `lhs | rhs`.
37    Alternation(Box<RegularPathExpr>, Box<RegularPathExpr>),
38    /// `inner *`.
39    KleeneStar(Box<RegularPathExpr>),
40    /// `inner { min, max }`.
41    Bounded {
42        inner: Box<RegularPathExpr>,
43        min: u32,
44        max: u32,
45    },
46}
47
48impl RegularPathExpr {
49    pub fn label(name: impl Into<String>) -> Self {
50        Self::Label(name.into())
51    }
52    pub fn concat(left: Self, right: Self) -> Self {
53        Self::Concat(Box::new(left), Box::new(right))
54    }
55    pub fn alt(left: Self, right: Self) -> Self {
56        Self::Alternation(Box::new(left), Box::new(right))
57    }
58    pub fn star(inner: Self) -> Self {
59        Self::KleeneStar(Box::new(inner))
60    }
61    pub fn bounded(inner: Self, min: u32, max: u32) -> Self {
62        Self::Bounded {
63            inner: Box::new(inner),
64            min,
65            max,
66        }
67    }
68}
69
70// -------------------------------------------------------------------------
71// Parser
72// -------------------------------------------------------------------------
73
74#[derive(Debug, thiserror::Error, PartialEq, Eq)]
75pub enum RPQParseError {
76    #[error("unexpected token at position {position}: {token:?}")]
77    Unexpected { position: usize, token: String },
78    #[error("unexpected end of expression")]
79    Eof,
80    #[error("missing closing parenthesis")]
81    MissingParen,
82    #[error("malformed bounded repetition: {0}")]
83    MalformedBound(String),
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
87pub enum RPQBuildError {
88    #[error("bounded repetition minimum {min} exceeds maximum {max}")]
89    InvalidBound { min: u32, max: u32 },
90    #[error("regular path expression depth {depth} exceeds limit {limit}")]
91    ExpressionTooDeep { depth: usize, limit: usize },
92    #[error("regular path NFA requires {required} states, exceeding limit {limit}")]
93    NfaStateLimitExceeded { required: usize, limit: usize },
94    #[error("regular path DFA exceeded state limit {limit}")]
95    DfaStateLimitExceeded { limit: usize },
96    #[error("invalid NFA: {0}")]
97    InvalidNfa(String),
98    #[error("unable to reserve memory for {states} NFA states")]
99    AllocationFailed { states: usize },
100}
101
102pub fn parse_rpq(expr: &str) -> Result<RegularPathExpr, RPQParseError> {
103    let tokens = tokenize(expr);
104    let (result, pos) = parse_alternation(&tokens, 0)?;
105    if pos != tokens.len() {
106        return Err(RPQParseError::Unexpected {
107            position: pos,
108            token: tokens[pos].clone(),
109        });
110    }
111    Ok(result)
112}
113
114fn tokenize(expr: &str) -> Vec<String> {
115    let mut tokens = Vec::new();
116    let bytes = expr.as_bytes();
117    let mut i = 0;
118    while i < bytes.len() {
119        let ch = bytes[i] as char;
120        if ch.is_ascii_whitespace() {
121            i += 1;
122            continue;
123        }
124        if matches!(ch, '(' | ')' | '/' | '|' | '*' | '{' | '}' | ',') {
125            tokens.push(ch.to_string());
126            i += 1;
127        } else {
128            let start = i;
129            while i < bytes.len() {
130                let c = bytes[i] as char;
131                if c.is_ascii_whitespace()
132                    || matches!(c, '(' | ')' | '/' | '|' | '*' | '{' | '}' | ',')
133                {
134                    break;
135                }
136                i += 1;
137            }
138            tokens.push(expr[start..i].to_string());
139        }
140    }
141    tokens
142}
143
144fn parse_alternation(
145    tokens: &[String],
146    mut pos: usize,
147) -> Result<(RegularPathExpr, usize), RPQParseError> {
148    let (mut left, p) = parse_concat(tokens, pos)?;
149    pos = p;
150    while pos < tokens.len() && tokens[pos] == "|" {
151        pos += 1;
152        let (right, p) = parse_concat(tokens, pos)?;
153        pos = p;
154        left = RegularPathExpr::alt(left, right);
155    }
156    Ok((left, pos))
157}
158
159fn parse_concat(
160    tokens: &[String],
161    mut pos: usize,
162) -> Result<(RegularPathExpr, usize), RPQParseError> {
163    let (mut left, p) = parse_star(tokens, pos)?;
164    pos = p;
165    while pos < tokens.len() && tokens[pos] == "/" {
166        pos += 1;
167        let (right, p) = parse_star(tokens, pos)?;
168        pos = p;
169        left = RegularPathExpr::concat(left, right);
170    }
171    Ok((left, pos))
172}
173
174fn parse_star(
175    tokens: &[String],
176    mut pos: usize,
177) -> Result<(RegularPathExpr, usize), RPQParseError> {
178    let (mut expr, p) = parse_atom(tokens, pos)?;
179    pos = p;
180    while pos < tokens.len() && (tokens[pos] == "*" || tokens[pos] == "{") {
181        if tokens[pos] == "*" {
182            pos += 1;
183            expr = RegularPathExpr::star(expr);
184        } else {
185            pos += 1;
186            let min = tokens
187                .get(pos)
188                .ok_or_else(|| RPQParseError::MalformedBound("missing min".into()))?
189                .parse::<u32>()
190                .map_err(|e| RPQParseError::MalformedBound(format!("min: {e}")))?;
191            pos += 1;
192            if tokens.get(pos).map(String::as_str) != Some(",") {
193                return Err(RPQParseError::MalformedBound("expected ','".into()));
194            }
195            pos += 1;
196            let max = tokens
197                .get(pos)
198                .ok_or_else(|| RPQParseError::MalformedBound("missing max".into()))?
199                .parse::<u32>()
200                .map_err(|e| RPQParseError::MalformedBound(format!("max: {e}")))?;
201            if min > max {
202                return Err(RPQParseError::MalformedBound(format!(
203                    "min {min} exceeds max {max}"
204                )));
205            }
206            pos += 1;
207            if tokens.get(pos).map(String::as_str) != Some("}") {
208                return Err(RPQParseError::MalformedBound("expected '}'".into()));
209            }
210            pos += 1;
211            expr = RegularPathExpr::bounded(expr, min, max);
212        }
213    }
214    Ok((expr, pos))
215}
216
217fn parse_atom(
218    tokens: &[String],
219    mut pos: usize,
220) -> Result<(RegularPathExpr, usize), RPQParseError> {
221    let token = tokens.get(pos).ok_or(RPQParseError::Eof)?;
222    if token == "(" {
223        pos += 1;
224        let (inner, p) = parse_alternation(tokens, pos)?;
225        pos = p;
226        if tokens.get(pos).map(String::as_str) != Some(")") {
227            return Err(RPQParseError::MissingParen);
228        }
229        pos += 1;
230        Ok((inner, pos))
231    } else if matches!(token.as_str(), ")" | "/" | "|" | "*" | "{" | "}" | ",") {
232        Err(RPQParseError::Unexpected {
233            position: pos,
234            token: token.clone(),
235        })
236    } else {
237        pos += 1;
238        Ok((RegularPathExpr::label(token.clone()), pos))
239    }
240}
241
242// -------------------------------------------------------------------------
243// Simplifier
244// -------------------------------------------------------------------------
245
246/// Algebraic simplification (Section 8.2, Paper 2):
247/// `a|a -> a`, `(a*)* -> a*`, `a*|a -> a*`, `a*/a* -> a*`, plus
248/// canonicalization of alternation operand order.
249pub fn simplify(expr: &RegularPathExpr) -> Result<RegularPathExpr, RPQBuildError> {
250    // Validate iteratively before entering the recursive rewriter so a
251    // programmatically constructed, deeply nested AST cannot overflow the
252    // stack before the compiler has a chance to reject it.
253    required_nfa_states(expr)?;
254    Ok(simplify_validated(expr))
255}
256
257fn simplify_validated(expr: &RegularPathExpr) -> RegularPathExpr {
258    match expr {
259        RegularPathExpr::Label(_) => expr.clone(),
260        RegularPathExpr::Alternation(l, r) => {
261            let mut left = simplify_validated(l);
262            let mut right = simplify_validated(r);
263            if left == right {
264                return left;
265            }
266            if let RegularPathExpr::KleeneStar(inner) = &left {
267                if **inner == right {
268                    return left;
269                }
270            }
271            if let RegularPathExpr::KleeneStar(inner) = &right {
272                if **inner == left {
273                    return right;
274                }
275            }
276            // Canonical: sort by debug repr.
277            let lr = format!("{left:?}");
278            let rr = format!("{right:?}");
279            if lr > rr {
280                std::mem::swap(&mut left, &mut right);
281            }
282            RegularPathExpr::alt(left, right)
283        }
284        RegularPathExpr::Concat(l, r) => {
285            let left = simplify_validated(l);
286            let right = simplify_validated(r);
287            if let (RegularPathExpr::KleeneStar(li), RegularPathExpr::KleeneStar(ri)) =
288                (&left, &right)
289            {
290                if li == ri {
291                    return left;
292                }
293            }
294            RegularPathExpr::concat(left, right)
295        }
296        RegularPathExpr::KleeneStar(inner) => {
297            let s = simplify_validated(inner);
298            if matches!(s, RegularPathExpr::KleeneStar(_)) {
299                s
300            } else {
301                RegularPathExpr::star(s)
302            }
303        }
304        RegularPathExpr::Bounded { inner, min, max } => {
305            RegularPathExpr::bounded(simplify_validated(inner), *min, *max)
306        }
307    }
308}
309
310// -------------------------------------------------------------------------
311// NFA (Thompson's construction)
312// -------------------------------------------------------------------------
313
314pub type StateId = u32;
315
316/// NFA transition target. `Some(label)` is a labeled edge consumed by
317/// matching that label; `None` is an epsilon transition.
318#[derive(Debug, Clone)]
319pub struct NfaTransition {
320    pub label: Option<String>,
321    pub target: StateId,
322}
323
324#[derive(Debug, Default)]
325pub struct Nfa {
326    /// `transitions[state_id]` lists every outgoing transition from that
327    /// state. Indexed densely; unused state ids hold an empty vec.
328    pub transitions: Vec<Vec<NfaTransition>>,
329    pub start: StateId,
330    pub accept: StateId,
331}
332
333impl Nfa {
334    fn new() -> Self {
335        Self {
336            transitions: Vec::new(),
337            start: 0,
338            accept: 0,
339        }
340    }
341
342    fn new_state(&mut self) -> Result<StateId, RPQBuildError> {
343        if self.transitions.len() >= MAX_NFA_STATES {
344            return Err(RPQBuildError::NfaStateLimitExceeded {
345                required: self.transitions.len().saturating_add(1),
346                limit: MAX_NFA_STATES,
347            });
348        }
349        let id = StateId::try_from(self.transitions.len()).map_err(|_| {
350            RPQBuildError::NfaStateLimitExceeded {
351                required: self.transitions.len().saturating_add(1),
352                limit: MAX_NFA_STATES,
353            }
354        })?;
355        self.transitions.push(Vec::new());
356        Ok(id)
357    }
358
359    fn add_transition(
360        &mut self,
361        from: StateId,
362        label: Option<String>,
363        to: StateId,
364    ) -> Result<(), RPQBuildError> {
365        if usize::try_from(to)
366            .ok()
367            .is_none_or(|target| target >= self.transitions.len())
368        {
369            return Err(RPQBuildError::InvalidNfa(format!(
370                "transition target {to} is outside {} states",
371                self.transitions.len()
372            )));
373        }
374        let state_count = self.transitions.len();
375        let transitions = self
376            .transitions
377            .get_mut(usize::try_from(from).map_err(|_| {
378                RPQBuildError::InvalidNfa(format!("transition source {from} is not addressable"))
379            })?)
380            .ok_or_else(|| {
381                RPQBuildError::InvalidNfa(format!(
382                    "transition source {from} is outside {state_count} states"
383                ))
384            })?;
385        transitions.push(NfaTransition { label, target: to });
386        Ok(())
387    }
388
389    pub fn states(&self) -> Result<Vec<StateId>, RPQBuildError> {
390        validate_nfa(self)?;
391        let end = StateId::try_from(self.transitions.len()).map_err(|_| {
392            RPQBuildError::NfaStateLimitExceeded {
393                required: self.transitions.len(),
394                limit: MAX_NFA_STATES,
395            }
396        })?;
397        Ok((0..end).collect())
398    }
399}
400
401/// Build an NFA from a regular path expression via Thompson's
402/// construction.
403pub fn build_nfa(expr: &RegularPathExpr) -> Result<Nfa, RPQBuildError> {
404    let required = required_nfa_states(expr)?;
405    let mut nfa = Nfa::new();
406    nfa.transitions
407        .try_reserve_exact(required)
408        .map_err(|_| RPQBuildError::AllocationFailed { states: required })?;
409    let (start, accept) = build_fragment(&mut nfa, expr)?;
410    nfa.start = start;
411    nfa.accept = accept;
412    Ok(nfa)
413}
414
415fn required_nfa_states(expr: &RegularPathExpr) -> Result<usize, RPQBuildError> {
416    let mut work = vec![(expr, 1_usize, false)];
417    let mut values = Vec::<usize>::new();
418    while let Some((current, depth, visited)) = work.pop() {
419        if depth > MAX_RPQ_AST_DEPTH {
420            return Err(RPQBuildError::ExpressionTooDeep {
421                depth,
422                limit: MAX_RPQ_AST_DEPTH,
423            });
424        }
425        if !visited {
426            work.push((current, depth, true));
427            match current {
428                RegularPathExpr::Label(_) => {}
429                RegularPathExpr::Concat(left, right)
430                | RegularPathExpr::Alternation(left, right) => {
431                    work.push((right, depth.saturating_add(1), false));
432                    work.push((left, depth.saturating_add(1), false));
433                }
434                RegularPathExpr::KleeneStar(inner) | RegularPathExpr::Bounded { inner, .. } => {
435                    work.push((inner, depth.saturating_add(1), false));
436                }
437            }
438            continue;
439        }
440
441        let required = match current {
442            RegularPathExpr::Label(_) => Some(2),
443            RegularPathExpr::Concat(_, _) => {
444                let right = values.pop().ok_or_else(|| {
445                    RPQBuildError::InvalidNfa("missing concat right fragment".into())
446                })?;
447                let left = values.pop().ok_or_else(|| {
448                    RPQBuildError::InvalidNfa("missing concat left fragment".into())
449                })?;
450                left.checked_add(right)
451            }
452            RegularPathExpr::Alternation(_, _) => {
453                let right = values.pop().ok_or_else(|| {
454                    RPQBuildError::InvalidNfa("missing alternation right fragment".into())
455                })?;
456                let left = values.pop().ok_or_else(|| {
457                    RPQBuildError::InvalidNfa("missing alternation left fragment".into())
458                })?;
459                left.checked_add(right).and_then(|sum| sum.checked_add(2))
460            }
461            RegularPathExpr::KleeneStar(_) => values
462                .pop()
463                .ok_or_else(|| RPQBuildError::InvalidNfa("missing Kleene-star fragment".into()))?
464                .checked_add(2),
465            RegularPathExpr::Bounded { min, max, .. } => {
466                if min > max {
467                    return Err(RPQBuildError::InvalidBound {
468                        min: *min,
469                        max: *max,
470                    });
471                }
472                let inner = values.pop().ok_or_else(|| {
473                    RPQBuildError::InvalidNfa("missing bounded-repeat fragment".into())
474                })?;
475                usize::try_from(*max)
476                    .ok()
477                    .and_then(|copies| inner.checked_mul(copies))
478                    .and_then(|states| states.checked_add(2))
479            }
480        }
481        .ok_or(RPQBuildError::NfaStateLimitExceeded {
482            required: usize::MAX,
483            limit: MAX_NFA_STATES,
484        })?;
485        if required > MAX_NFA_STATES {
486            return Err(RPQBuildError::NfaStateLimitExceeded {
487                required,
488                limit: MAX_NFA_STATES,
489            });
490        }
491        values.push(required);
492    }
493    values
494        .pop()
495        .ok_or_else(|| RPQBuildError::InvalidNfa("regular path expression has no fragment".into()))
496}
497
498fn build_fragment(
499    nfa: &mut Nfa,
500    expr: &RegularPathExpr,
501) -> Result<(StateId, StateId), RPQBuildError> {
502    match expr {
503        RegularPathExpr::Label(name) => {
504            let s = nfa.new_state()?;
505            let a = nfa.new_state()?;
506            nfa.add_transition(s, Some(name.clone()), a)?;
507            Ok((s, a))
508        }
509        RegularPathExpr::Concat(l, r) => {
510            let (ls, la) = build_fragment(nfa, l)?;
511            let (rs, ra) = build_fragment(nfa, r)?;
512            nfa.add_transition(la, None, rs)?;
513            Ok((ls, ra))
514        }
515        RegularPathExpr::Alternation(l, r) => {
516            let s = nfa.new_state()?;
517            let a = nfa.new_state()?;
518            let (ls, la) = build_fragment(nfa, l)?;
519            let (rs, ra) = build_fragment(nfa, r)?;
520            nfa.add_transition(s, None, ls)?;
521            nfa.add_transition(s, None, rs)?;
522            nfa.add_transition(la, None, a)?;
523            nfa.add_transition(ra, None, a)?;
524            Ok((s, a))
525        }
526        RegularPathExpr::KleeneStar(inner) => {
527            let s = nfa.new_state()?;
528            let a = nfa.new_state()?;
529            let (is, ia) = build_fragment(nfa, inner)?;
530            nfa.add_transition(s, None, is)?;
531            nfa.add_transition(s, None, a)?;
532            nfa.add_transition(ia, None, is)?;
533            nfa.add_transition(ia, None, a)?;
534            Ok((s, a))
535        }
536        RegularPathExpr::Bounded { inner, min, max } => {
537            if min > max {
538                return Err(RPQBuildError::InvalidBound {
539                    min: *min,
540                    max: *max,
541                });
542            }
543            let start = nfa.new_state()?;
544            let mut current_end = start;
545            for _ in 0..*min {
546                let (is, ia) = build_fragment(nfa, inner)?;
547                nfa.add_transition(current_end, None, is)?;
548                current_end = ia;
549            }
550            let accept = nfa.new_state()?;
551            if min == max {
552                nfa.add_transition(current_end, None, accept)?;
553            } else {
554                nfa.add_transition(current_end, None, accept)?;
555                for _ in 0..(*max - *min) {
556                    let (is, ia) = build_fragment(nfa, inner)?;
557                    nfa.add_transition(current_end, None, is)?;
558                    nfa.add_transition(ia, None, accept)?;
559                    current_end = ia;
560                }
561            }
562            Ok((start, accept))
563        }
564    }
565}
566
567/// Epsilon closure of a state set: every state reachable by zero or
568/// more epsilon (`label == None`) transitions.
569pub fn epsilon_closure(
570    nfa: &Nfa,
571    states: &BTreeSet<StateId>,
572) -> Result<BTreeSet<StateId>, RPQBuildError> {
573    validate_nfa(nfa)?;
574    let mut closure = states.clone();
575    let mut stack: Vec<StateId> = states.iter().copied().collect();
576    while let Some(s) = stack.pop() {
577        let outgoing = nfa
578            .transitions
579            .get(usize::try_from(s).map_err(|_| {
580                RPQBuildError::InvalidNfa(format!("closure state {s} is not addressable"))
581            })?)
582            .ok_or_else(|| {
583                RPQBuildError::InvalidNfa(format!("closure state {s} is outside the NFA"))
584            })?;
585        for t in outgoing {
586            if t.label.is_none() && !closure.contains(&t.target) {
587                closure.insert(t.target);
588                stack.push(t.target);
589            }
590        }
591    }
592    Ok(closure)
593}
594
595fn validate_nfa(nfa: &Nfa) -> Result<(), RPQBuildError> {
596    let state_count = nfa.transitions.len();
597    if state_count == 0 {
598        return Err(RPQBuildError::InvalidNfa("NFA has no states".into()));
599    }
600    if state_count > MAX_NFA_STATES {
601        return Err(RPQBuildError::NfaStateLimitExceeded {
602            required: state_count,
603            limit: MAX_NFA_STATES,
604        });
605    }
606    for (name, state) in [("start", nfa.start), ("accept", nfa.accept)] {
607        if usize::try_from(state)
608            .ok()
609            .is_none_or(|index| index >= state_count)
610        {
611            return Err(RPQBuildError::InvalidNfa(format!(
612                "{name} state {state} is outside {state_count} states"
613            )));
614        }
615    }
616    for (source, transitions) in nfa.transitions.iter().enumerate() {
617        for transition in transitions {
618            if usize::try_from(transition.target)
619                .ok()
620                .is_none_or(|target| target >= state_count)
621            {
622                return Err(RPQBuildError::InvalidNfa(format!(
623                    "transition from state {source} targets missing state {}",
624                    transition.target
625                )));
626            }
627        }
628    }
629    Ok(())
630}
631
632// -------------------------------------------------------------------------
633// DFA (subset construction)
634// -------------------------------------------------------------------------
635
636pub type DfaState = BTreeSet<StateId>;
637
638#[derive(Debug)]
639pub struct Dfa {
640    pub start: DfaState,
641    pub accepts: BTreeSet<DfaState>,
642    pub transitions: BTreeMap<DfaState, BTreeMap<String, DfaState>>,
643}
644
645/// Convert an NFA to a DFA via the standard subset construction.
646pub fn subset_construction(nfa: &Nfa) -> Result<Dfa, RPQBuildError> {
647    validate_nfa(nfa)?;
648    // Collect alphabet (non-epsilon transition labels).
649    let mut alphabet: BTreeSet<String> = BTreeSet::new();
650    for transitions in &nfa.transitions {
651        for t in transitions {
652            if let Some(label) = &t.label {
653                alphabet.insert(label.clone());
654            }
655        }
656    }
657
658    let initial = epsilon_closure(nfa, &BTreeSet::from([nfa.start]))?;
659    let mut transitions: BTreeMap<DfaState, BTreeMap<String, DfaState>> = BTreeMap::new();
660    let mut accepts: BTreeSet<DfaState> = BTreeSet::new();
661    let mut seen: BTreeSet<DfaState> = BTreeSet::from([initial.clone()]);
662    let mut work: VecDeque<DfaState> = VecDeque::from([initial.clone()]);
663
664    if initial.contains(&nfa.accept) {
665        accepts.insert(initial.clone());
666    }
667
668    while let Some(current) = work.pop_front() {
669        let mut step: BTreeMap<String, DfaState> = BTreeMap::new();
670        for label in &alphabet {
671            let mut next_nfa: BTreeSet<StateId> = BTreeSet::new();
672            for sid in &current {
673                let outgoing = nfa
674                    .transitions
675                    .get(usize::try_from(*sid).map_err(|_| {
676                        RPQBuildError::InvalidNfa(format!("DFA state {sid} is not addressable"))
677                    })?)
678                    .ok_or_else(|| {
679                        RPQBuildError::InvalidNfa(format!("DFA state {sid} is outside the NFA"))
680                    })?;
681                for t in outgoing {
682                    if t.label.as_deref() == Some(label.as_str()) {
683                        next_nfa.insert(t.target);
684                    }
685                }
686            }
687            if next_nfa.is_empty() {
688                continue;
689            }
690            let closed = epsilon_closure(nfa, &next_nfa)?;
691            step.insert(label.clone(), closed.clone());
692            if !seen.contains(&closed) {
693                if seen.len() >= MAX_DFA_STATES {
694                    return Err(RPQBuildError::DfaStateLimitExceeded {
695                        limit: MAX_DFA_STATES,
696                    });
697                }
698                seen.insert(closed.clone());
699                work.push_back(closed.clone());
700                if closed.contains(&nfa.accept) {
701                    accepts.insert(closed);
702                }
703            }
704        }
705        transitions.insert(current, step);
706    }
707
708    Ok(Dfa {
709        start: initial,
710        accepts,
711        transitions,
712    })
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718
719    #[test]
720    fn parse_single_label() {
721        let e = parse_rpq("knows").unwrap();
722        assert_eq!(e, RegularPathExpr::label("knows"));
723    }
724
725    #[test]
726    fn parse_concat() {
727        let e = parse_rpq("knows/likes").unwrap();
728        assert_eq!(
729            e,
730            RegularPathExpr::concat(
731                RegularPathExpr::label("knows"),
732                RegularPathExpr::label("likes")
733            )
734        );
735    }
736
737    #[test]
738    fn parse_alternation_lower_prec_than_concat() {
739        let e = parse_rpq("a/b|c").unwrap();
740        // a/b first, then alternated with c.
741        assert_eq!(
742            e,
743            RegularPathExpr::alt(
744                RegularPathExpr::concat(RegularPathExpr::label("a"), RegularPathExpr::label("b")),
745                RegularPathExpr::label("c")
746            )
747        );
748    }
749
750    #[test]
751    fn parse_star_binds_tightest() {
752        let e = parse_rpq("a*").unwrap();
753        assert_eq!(e, RegularPathExpr::star(RegularPathExpr::label("a")));
754    }
755
756    #[test]
757    fn parse_bounded() {
758        let e = parse_rpq("a{2,5}").unwrap();
759        assert_eq!(
760            e,
761            RegularPathExpr::bounded(RegularPathExpr::label("a"), 2, 5)
762        );
763    }
764
765    #[test]
766    fn parse_rejects_reversed_bound() {
767        assert!(matches!(
768            parse_rpq("a{5,2}"),
769            Err(RPQParseError::MalformedBound(message)) if message.contains("exceeds")
770        ));
771    }
772
773    #[test]
774    fn build_rejects_unbounded_state_allocation_before_expansion() {
775        let expr = RegularPathExpr::bounded(RegularPathExpr::label("a"), 0, u32::MAX);
776        assert!(matches!(
777            build_nfa(&expr),
778            Err(RPQBuildError::NfaStateLimitExceeded { .. })
779        ));
780    }
781
782    #[test]
783    fn build_rejects_programmatically_reversed_bound() {
784        let expr = RegularPathExpr::bounded(RegularPathExpr::label("a"), 5, 2);
785        assert_eq!(
786            build_nfa(&expr).unwrap_err(),
787            RPQBuildError::InvalidBound { min: 5, max: 2 }
788        );
789    }
790
791    #[test]
792    fn subset_construction_rejects_missing_transition_target() {
793        let malformed = Nfa {
794            transitions: vec![vec![NfaTransition {
795                label: None,
796                target: 1,
797            }]],
798            start: 0,
799            accept: 0,
800        };
801        assert!(matches!(
802            subset_construction(&malformed),
803            Err(RPQBuildError::InvalidNfa(message)) if message.contains("missing state")
804        ));
805    }
806
807    #[test]
808    fn parse_grouping() {
809        let e = parse_rpq("(a|b)*").unwrap();
810        assert_eq!(
811            e,
812            RegularPathExpr::star(RegularPathExpr::alt(
813                RegularPathExpr::label("a"),
814                RegularPathExpr::label("b")
815            ))
816        );
817    }
818
819    #[test]
820    fn simplify_idempotent_alternation() {
821        let e = RegularPathExpr::alt(RegularPathExpr::label("a"), RegularPathExpr::label("a"));
822        assert_eq!(simplify(&e).unwrap(), RegularPathExpr::label("a"));
823    }
824
825    #[test]
826    fn simplify_nested_kleene() {
827        let e = RegularPathExpr::star(RegularPathExpr::star(RegularPathExpr::label("a")));
828        assert_eq!(
829            simplify(&e).unwrap(),
830            RegularPathExpr::star(RegularPathExpr::label("a"))
831        );
832    }
833
834    #[test]
835    fn simplify_star_subsumes_label() {
836        let e = RegularPathExpr::alt(
837            RegularPathExpr::star(RegularPathExpr::label("a")),
838            RegularPathExpr::label("a"),
839        );
840        assert_eq!(
841            simplify(&e).unwrap(),
842            RegularPathExpr::star(RegularPathExpr::label("a"))
843        );
844    }
845
846    #[test]
847    fn nfa_label_two_states() {
848        let nfa = build_nfa(&RegularPathExpr::label("a")).unwrap();
849        assert_eq!(nfa.transitions.len(), 2);
850        assert_ne!(nfa.start, nfa.accept);
851    }
852
853    #[test]
854    fn dfa_recognizes_a_or_b() {
855        let nfa = build_nfa(&RegularPathExpr::alt(
856            RegularPathExpr::label("a"),
857            RegularPathExpr::label("b"),
858        ))
859        .unwrap();
860        let dfa = subset_construction(&nfa).unwrap();
861        // After reading 'a' from start, the DFA should reach an accept.
862        let after_a = dfa
863            .transitions
864            .get(&dfa.start)
865            .and_then(|m| m.get("a"))
866            .expect("no `a` transition");
867        assert!(dfa.accepts.contains(after_a));
868        let after_b = dfa
869            .transitions
870            .get(&dfa.start)
871            .and_then(|m| m.get("b"))
872            .expect("no `b` transition");
873        assert!(dfa.accepts.contains(after_b));
874        // And no `c`.
875        assert!(dfa
876            .transitions
877            .get(&dfa.start)
878            .and_then(|m| m.get("c"))
879            .is_none());
880    }
881
882    #[test]
883    fn dfa_recognizes_kleene_star() {
884        let nfa = build_nfa(&RegularPathExpr::star(RegularPathExpr::label("a"))).unwrap();
885        let dfa = subset_construction(&nfa).unwrap();
886        // Empty string accepted (start is in accepts).
887        assert!(dfa.accepts.contains(&dfa.start));
888    }
889}