Skip to main content

uqa_core/
rpq.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Regular path expression syntax shared by planning and graph execution.
8//!
9//! Alternation has lower precedence than concatenation; repetition binds tightest.
10//! This module contains no graph store, traversal, or automaton implementation.
11
12/// Regular path expression.
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub enum RegularPathExpr {
15    /// A single edge label.
16    Label(String),
17    /// `lhs / rhs`.
18    Concat(Box<RegularPathExpr>, Box<RegularPathExpr>),
19    /// `lhs | rhs`.
20    Alternation(Box<RegularPathExpr>, Box<RegularPathExpr>),
21    /// `inner *`.
22    KleeneStar(Box<RegularPathExpr>),
23    /// `inner { min, max }`.
24    Bounded {
25        inner: Box<RegularPathExpr>,
26        min: u32,
27        max: u32,
28    },
29}
30
31impl RegularPathExpr {
32    pub fn label(name: impl Into<String>) -> Self {
33        Self::Label(name.into())
34    }
35    pub fn concat(left: Self, right: Self) -> Self {
36        Self::Concat(Box::new(left), Box::new(right))
37    }
38    pub fn alt(left: Self, right: Self) -> Self {
39        Self::Alternation(Box::new(left), Box::new(right))
40    }
41    pub fn star(inner: Self) -> Self {
42        Self::KleeneStar(Box::new(inner))
43    }
44    pub fn bounded(inner: Self, min: u32, max: u32) -> Self {
45        Self::Bounded {
46            inner: Box::new(inner),
47            min,
48            max,
49        }
50    }
51}
52
53#[derive(Debug, thiserror::Error, PartialEq, Eq)]
54pub enum RPQParseError {
55    #[error("unexpected token at position {position}: {token:?}")]
56    Unexpected { position: usize, token: String },
57    #[error("unexpected end of expression")]
58    Eof,
59    #[error("missing closing parenthesis")]
60    MissingParen,
61    #[error("malformed bounded repetition: {0}")]
62    MalformedBound(String),
63}
64
65pub fn parse_rpq(expr: &str) -> Result<RegularPathExpr, RPQParseError> {
66    let tokens = tokenize(expr);
67    let (result, pos) = parse_alternation(&tokens, 0)?;
68    if pos != tokens.len() {
69        return Err(RPQParseError::Unexpected {
70            position: pos,
71            token: tokens[pos].clone(),
72        });
73    }
74    Ok(result)
75}
76
77fn tokenize(expr: &str) -> Vec<String> {
78    let mut tokens = Vec::new();
79    let bytes = expr.as_bytes();
80    let mut i = 0;
81    while i < bytes.len() {
82        let ch = bytes[i] as char;
83        if ch.is_ascii_whitespace() {
84            i += 1;
85            continue;
86        }
87        if matches!(ch, '(' | ')' | '/' | '|' | '*' | '{' | '}' | ',') {
88            tokens.push(ch.to_string());
89            i += 1;
90        } else {
91            let start = i;
92            while i < bytes.len() {
93                let c = bytes[i] as char;
94                if c.is_ascii_whitespace()
95                    || matches!(c, '(' | ')' | '/' | '|' | '*' | '{' | '}' | ',')
96                {
97                    break;
98                }
99                i += 1;
100            }
101            tokens.push(expr[start..i].to_string());
102        }
103    }
104    tokens
105}
106
107fn parse_alternation(
108    tokens: &[String],
109    mut pos: usize,
110) -> Result<(RegularPathExpr, usize), RPQParseError> {
111    let (mut left, p) = parse_concat(tokens, pos)?;
112    pos = p;
113    while pos < tokens.len() && tokens[pos] == "|" {
114        pos += 1;
115        let (right, p) = parse_concat(tokens, pos)?;
116        pos = p;
117        left = RegularPathExpr::alt(left, right);
118    }
119    Ok((left, pos))
120}
121
122fn parse_concat(
123    tokens: &[String],
124    mut pos: usize,
125) -> Result<(RegularPathExpr, usize), RPQParseError> {
126    let (mut left, p) = parse_star(tokens, pos)?;
127    pos = p;
128    while pos < tokens.len() && tokens[pos] == "/" {
129        pos += 1;
130        let (right, p) = parse_star(tokens, pos)?;
131        pos = p;
132        left = RegularPathExpr::concat(left, right);
133    }
134    Ok((left, pos))
135}
136
137fn parse_star(
138    tokens: &[String],
139    mut pos: usize,
140) -> Result<(RegularPathExpr, usize), RPQParseError> {
141    let (mut expr, p) = parse_atom(tokens, pos)?;
142    pos = p;
143    while pos < tokens.len() && (tokens[pos] == "*" || tokens[pos] == "{") {
144        if tokens[pos] == "*" {
145            pos += 1;
146            expr = RegularPathExpr::star(expr);
147        } else {
148            pos += 1;
149            let min = tokens
150                .get(pos)
151                .ok_or_else(|| RPQParseError::MalformedBound("missing min".into()))?
152                .parse::<u32>()
153                .map_err(|e| RPQParseError::MalformedBound(format!("min: {e}")))?;
154            pos += 1;
155            if tokens.get(pos).map(String::as_str) != Some(",") {
156                return Err(RPQParseError::MalformedBound("expected ','".into()));
157            }
158            pos += 1;
159            let max = tokens
160                .get(pos)
161                .ok_or_else(|| RPQParseError::MalformedBound("missing max".into()))?
162                .parse::<u32>()
163                .map_err(|e| RPQParseError::MalformedBound(format!("max: {e}")))?;
164            if min > max {
165                return Err(RPQParseError::MalformedBound(format!(
166                    "min {min} exceeds max {max}"
167                )));
168            }
169            pos += 1;
170            if tokens.get(pos).map(String::as_str) != Some("}") {
171                return Err(RPQParseError::MalformedBound("expected '}'".into()));
172            }
173            pos += 1;
174            expr = RegularPathExpr::bounded(expr, min, max);
175        }
176    }
177    Ok((expr, pos))
178}
179
180fn parse_atom(
181    tokens: &[String],
182    mut pos: usize,
183) -> Result<(RegularPathExpr, usize), RPQParseError> {
184    let token = tokens.get(pos).ok_or(RPQParseError::Eof)?;
185    if token == "(" {
186        pos += 1;
187        let (inner, p) = parse_alternation(tokens, pos)?;
188        pos = p;
189        if tokens.get(pos).map(String::as_str) != Some(")") {
190            return Err(RPQParseError::MissingParen);
191        }
192        pos += 1;
193        Ok((inner, pos))
194    } else if matches!(token.as_str(), ")" | "/" | "|" | "*" | "{" | "}" | ",") {
195        Err(RPQParseError::Unexpected {
196            position: pos,
197            token: token.clone(),
198        })
199    } else {
200        pos += 1;
201        Ok((RegularPathExpr::label(token.clone()), pos))
202    }
203}
204
205#[cfg(test)]
206mod tests;