Skip to main content

agentic_graph_spec/
agx.rs

1use thiserror::Error;
2
3/// A function call discovered while parsing an AGX expression.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct AgxCall {
6    /// Function name.
7    pub name: String,
8    /// Number of supplied arguments.
9    pub arity: usize,
10}
11
12/// Structural information collected from a valid AGX expression.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ParsedExpression {
15    /// Function calls in encounter order.
16    pub calls: Vec<AgxCall>,
17    /// Dotted reference paths in encounter order.
18    pub references: Vec<Vec<String>>,
19}
20
21/// Syntax error returned by the AGX parser.
22#[derive(Debug, Error)]
23#[error("{0}")]
24pub struct AgxError(pub String);
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27enum Token {
28    Name(String),
29    String,
30    Number,
31    True,
32    False,
33    Null,
34    Op(String),
35    LParen,
36    RParen,
37    LBracket,
38    RBracket,
39    Dot,
40    Comma,
41}
42
43fn tokenize(input: &str) -> Result<Vec<Token>, AgxError> {
44    let chars: Vec<char> = input.chars().collect();
45    let mut tokens = vec![];
46    let mut i = 0;
47    while i < chars.len() {
48        match chars[i] {
49            c if c.is_whitespace() => i += 1,
50            '(' => {
51                tokens.push(Token::LParen);
52                i += 1;
53            }
54            ')' => {
55                tokens.push(Token::RParen);
56                i += 1;
57            }
58            '[' => {
59                tokens.push(Token::LBracket);
60                i += 1;
61            }
62            ']' => {
63                tokens.push(Token::RBracket);
64                i += 1;
65            }
66            '.' => {
67                tokens.push(Token::Dot);
68                i += 1;
69            }
70            ',' => {
71                tokens.push(Token::Comma);
72                i += 1;
73            }
74            '\'' | '"' => {
75                let quote = chars[i];
76                i += 1;
77                let mut closed = false;
78                while i < chars.len() {
79                    if chars[i] == '\\' {
80                        i += 2;
81                        continue;
82                    }
83                    if chars[i] == quote {
84                        i += 1;
85                        closed = true;
86                        break;
87                    }
88                    i += 1;
89                }
90                if !closed {
91                    return Err(AgxError("unterminated string".into()));
92                }
93                tokens.push(Token::String);
94            }
95            c if c.is_ascii_digit() => {
96                i += 1;
97                while i < chars.len()
98                    && (chars[i].is_ascii_digit()
99                        || matches!(chars[i], '.' | 'e' | 'E' | '+' | '-'))
100                {
101                    i += 1;
102                }
103                tokens.push(Token::Number);
104            }
105            c if c.is_ascii_alphabetic() || c == '_' => {
106                let start = i;
107                i += 1;
108                while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
109                    i += 1;
110                }
111                let name: String = chars[start..i].iter().collect();
112                tokens.push(match name.as_str() {
113                    "true" => Token::True,
114                    "false" => Token::False,
115                    "null" => Token::Null,
116                    "in" => Token::Op(name),
117                    _ => Token::Name(name),
118                });
119            }
120            _ => {
121                let remaining: String = chars[i..].iter().collect();
122                let op = [
123                    "&&", "||", "==", "!=", "<=", ">=", "+", "-", "*", "/", "%", "!", "<", ">",
124                ]
125                .into_iter()
126                .find(|candidate| remaining.starts_with(candidate))
127                .ok_or_else(|| AgxError(format!("unexpected character {:?}", chars[i])))?;
128                tokens.push(Token::Op(op.into()));
129                i += op.len();
130            }
131        }
132    }
133    Ok(tokens)
134}
135
136struct Parser {
137    tokens: Vec<Token>,
138    at: usize,
139    calls: Vec<AgxCall>,
140    references: Vec<Vec<String>>,
141}
142
143impl Parser {
144    fn peek(&self) -> Option<&Token> {
145        self.tokens.get(self.at)
146    }
147    fn take(&mut self) -> Result<Token, AgxError> {
148        let token = self
149            .peek()
150            .cloned()
151            .ok_or_else(|| AgxError("unexpected end of expression".into()))?;
152        self.at += 1;
153        Ok(token)
154    }
155    fn op(&mut self, wanted: &str) -> bool {
156        if matches!(self.peek(), Some(Token::Op(op)) if op == wanted) {
157            self.at += 1;
158            true
159        } else {
160            false
161        }
162    }
163    fn parse(&mut self) -> Result<(), AgxError> {
164        self.or()?;
165        if self.peek().is_some() {
166            return Err(AgxError("unexpected trailing token".into()));
167        }
168        Ok(())
169    }
170    fn or(&mut self) -> Result<(), AgxError> {
171        self.and()?;
172        while self.op("||") {
173            self.and()?;
174        }
175        Ok(())
176    }
177    fn and(&mut self) -> Result<(), AgxError> {
178        self.equality()?;
179        while self.op("&&") {
180            self.equality()?;
181        }
182        Ok(())
183    }
184    fn equality(&mut self) -> Result<(), AgxError> {
185        self.comparison()?;
186        loop {
187            if self.op("==") || self.op("!=") || self.op("in") {
188                self.comparison()?;
189            } else {
190                break;
191            }
192        }
193        Ok(())
194    }
195    fn comparison(&mut self) -> Result<(), AgxError> {
196        self.additive()?;
197        loop {
198            if self.op("<") || self.op("<=") || self.op(">") || self.op(">=") {
199                self.additive()?;
200            } else {
201                break;
202            }
203        }
204        Ok(())
205    }
206    fn additive(&mut self) -> Result<(), AgxError> {
207        self.product()?;
208        loop {
209            if self.op("+") || self.op("-") {
210                self.product()?;
211            } else {
212                break;
213            }
214        }
215        Ok(())
216    }
217    fn product(&mut self) -> Result<(), AgxError> {
218        self.unary()?;
219        loop {
220            if self.op("*") || self.op("/") || self.op("%") {
221                self.unary()?;
222            } else {
223                break;
224            }
225        }
226        Ok(())
227    }
228    fn unary(&mut self) -> Result<(), AgxError> {
229        if self.op("!") || self.op("-") {
230            self.unary()
231        } else {
232            self.primary()
233        }
234    }
235    fn primary(&mut self) -> Result<(), AgxError> {
236        match self.take()? {
237            Token::String | Token::Number | Token::True | Token::False | Token::Null => Ok(()),
238            Token::LParen => {
239                self.or()?;
240                if self.take()? != Token::RParen {
241                    return Err(AgxError("expected ')'".into()));
242                }
243                Ok(())
244            }
245            Token::LBracket => {
246                if matches!(self.peek(), Some(Token::RBracket)) {
247                    self.at += 1;
248                    return Ok(());
249                }
250                loop {
251                    self.or()?;
252                    match self.take()? {
253                        Token::Comma => continue,
254                        Token::RBracket => break,
255                        _ => return Err(AgxError("expected ',' or ']'".into())),
256                    }
257                }
258                Ok(())
259            }
260            Token::Name(name) => {
261                if matches!(self.peek(), Some(Token::LParen)) {
262                    self.at += 1;
263                    let mut arity = 0;
264                    if !matches!(self.peek(), Some(Token::RParen)) {
265                        loop {
266                            self.or()?;
267                            arity += 1;
268                            if matches!(self.peek(), Some(Token::Comma)) {
269                                self.at += 1;
270                            } else {
271                                break;
272                            }
273                        }
274                    }
275                    if self.take()? != Token::RParen {
276                        return Err(AgxError("expected ')'".into()));
277                    }
278                    self.calls.push(AgxCall { name, arity });
279                } else {
280                    let mut reference = vec![name];
281                    while matches!(self.peek(), Some(Token::Dot)) {
282                        self.at += 1;
283                        match self.take()? {
284                            Token::Name(part) => reference.push(part),
285                            _ => return Err(AgxError("expected name after '.'".into())),
286                        }
287                    }
288                    self.references.push(reference);
289                }
290                Ok(())
291            }
292            _ => Err(AgxError("expected expression".into())),
293        }
294    }
295}
296
297/// Parses and validates AGX syntax while collecting calls and references.
298pub fn parse_expression(input: &str) -> Result<ParsedExpression, AgxError> {
299    let mut parser = Parser {
300        tokens: tokenize(input)?,
301        at: 0,
302        calls: vec![],
303        references: vec![],
304    };
305    parser.parse()?;
306    Ok(ParsedExpression {
307        calls: parser.calls,
308        references: parser.references,
309    })
310}