Skip to main content

jia_parse/
lexer.rs

1//! Tokenizer for PDDL S-expressions.
2//!
3//! Converts raw PDDL source text into a flat vector of [`Token`]s, each carrying a
4//! [`TokenKind`] and a [`Span`] for error reporting.
5//!
6//! Token conventions:
7//! - `:` prefix → [`TokenKind::Keyword`] (e.g. `:requirements`, `:typing`)
8//! - `?` prefix → [`TokenKind::Variable`] (e.g. `?x`, `?duration`)
9//! - `;` → line comment (skipped entirely)
10//! - All symbols and keywords are lowercased during tokenization.
11
12use crate::error::{ParseError, Span};
13
14/// The kind of a lexed PDDL token.
15#[derive(Debug, Clone, PartialEq)]
16pub enum TokenKind {
17    /// Opening parenthesis `(`.
18    LParen,
19    /// Closing parenthesis `)`.
20    RParen,
21    /// An identifier: predicate names, type names, operators (`+`, `-`, …), etc.
22    /// Stored in lowercase.
23    Symbol(String),
24    /// A PDDL keyword starting with `:` (e.g. `:requirements`, `:typing`).
25    /// The leading `:` is stripped; stored in lowercase.
26    Keyword(String),
27    /// A PDDL variable starting with `?` (e.g. `?x`, `?duration`).
28    /// The leading `?` is kept; stored in lowercase.
29    Variable(String),
30    /// An integer or floating-point numeric literal.
31    Number(f64),
32}
33
34/// A single token produced by the lexer, pairing a [`TokenKind`] with its source [`Span`].
35#[derive(Debug, Clone, PartialEq)]
36pub struct Token {
37    /// What kind of token this is.
38    pub kind: TokenKind,
39    /// Where in the source text this token appears.
40    pub span: Span,
41}
42
43impl Token {
44    /// Return `true` if this token is a [`TokenKind::Symbol`] matching `s` (case-insensitive).
45    ///
46    /// # Arguments
47    ///
48    /// * `s` - The symbol string to compare against (e.g. `"define"`, `"and"`)
49    pub fn symbol_eq(&self, s: &str) -> bool {
50        matches!(&self.kind, TokenKind::Symbol(sym) if sym.eq_ignore_ascii_case(s))
51    }
52
53    /// Return `true` if this token is a [`TokenKind::Keyword`] matching `s` (case-insensitive).
54    ///
55    /// # Arguments
56    ///
57    /// * `s` - The keyword to compare against, *without* the leading `:` (e.g. `"requirements"`)
58    pub fn keyword_eq(&self, s: &str) -> bool {
59        matches!(&self.kind, TokenKind::Keyword(kw) if kw.eq_ignore_ascii_case(s))
60    }
61
62    /// Return `true` if this token is a [`TokenKind::Variable`] matching `s` (case-insensitive).
63    ///
64    /// # Arguments
65    ///
66    /// * `s` - The variable to compare against, *with* the leading `?` (e.g. `"?x"`)
67    pub fn variable_eq(&self, s: &str) -> bool {
68        matches!(&self.kind, TokenKind::Variable(v) if v.eq_ignore_ascii_case(s))
69    }
70}
71
72/// Tokenize a PDDL source string into a flat vector of [`Token`]s.
73///
74/// Line comments (starting with `;`) are stripped. All symbols, keywords, and
75/// variables are lowercased so that later comparisons are case-insensitive.
76///
77/// # Arguments
78///
79/// * `input` - The raw PDDL source text to tokenize
80///
81/// # Returns
82///
83/// A vector of tokens in order of appearance.
84///
85/// # Errors
86///
87/// Returns [`ParseError`] if the input contains an unexpected character or
88/// a malformed numeric literal.
89pub fn tokenize(input: &str) -> Result<Vec<Token>, ParseError> {
90    let mut tokens = Vec::new();
91    let bytes = input.as_bytes();
92    let mut pos = 0;
93    let mut line = 1usize;
94    let mut col = 1usize;
95
96    while pos < bytes.len() {
97        let b = bytes[pos];
98
99        // Whitespace
100        if b.is_ascii_whitespace() {
101            if b == b'\n' {
102                line += 1;
103                col = 1;
104            } else {
105                col += 1;
106            }
107            pos += 1;
108            continue;
109        }
110
111        // Comment: skip to end of line
112        if b == b';' {
113            while pos < bytes.len() && bytes[pos] != b'\n' {
114                pos += 1;
115            }
116            continue;
117        }
118
119        let span = Span::new(pos, line, col);
120
121        if b == b'(' {
122            tokens.push(Token {
123                kind: TokenKind::LParen,
124                span,
125            });
126            pos += 1;
127            col += 1;
128            continue;
129        }
130
131        if b == b')' {
132            tokens.push(Token {
133                kind: TokenKind::RParen,
134                span,
135            });
136            pos += 1;
137            col += 1;
138            continue;
139        }
140
141        // Keyword `:something`
142        if b == b':' {
143            pos += 1;
144            col += 1;
145            let start = pos;
146            while pos < bytes.len() && is_symbol_char(bytes[pos]) {
147                pos += 1;
148                col += 1;
149            }
150            let kw = std::str::from_utf8(&bytes[start..pos])
151                .unwrap()
152                .to_ascii_lowercase();
153            tokens.push(Token {
154                kind: TokenKind::Keyword(kw),
155                span,
156            });
157            continue;
158        }
159
160        // Variable `?something`
161        if b == b'?' {
162            let start = pos;
163            pos += 1;
164            col += 1;
165            while pos < bytes.len() && is_symbol_char(bytes[pos]) {
166                pos += 1;
167                col += 1;
168            }
169            let var = std::str::from_utf8(&bytes[start..pos])
170                .unwrap()
171                .to_lowercase();
172            tokens.push(Token {
173                kind: TokenKind::Variable(var),
174                span,
175            });
176            continue;
177        }
178
179        // Number (possibly negative when preceded by a context that makes it unambiguous,
180        // but we handle unary minus in the parser; here we lex positive numbers and
181        // the `-` as a symbol)
182        if b.is_ascii_digit()
183            || (b == b'.' && pos + 1 < bytes.len() && bytes[pos + 1].is_ascii_digit())
184        {
185            let start = pos;
186            while pos < bytes.len() && (bytes[pos].is_ascii_digit() || bytes[pos] == b'.') {
187                pos += 1;
188                col += 1;
189            }
190            let num_str = std::str::from_utf8(&bytes[start..pos]).unwrap();
191            let num: f64 = num_str
192                .parse()
193                .map_err(|_| ParseError::new(format!("invalid number: {num_str}"), span))?;
194            tokens.push(Token {
195                kind: TokenKind::Number(num),
196                span,
197            });
198            continue;
199        }
200
201        // String literal
202        if b == b'"' {
203            pos += 1;
204            col += 1;
205            let start = pos;
206            while pos < bytes.len() && bytes[pos] != b'"' {
207                if bytes[pos] == b'\n' {
208                    line += 1;
209                    col = 1;
210                } else {
211                    col += 1;
212                }
213                pos += 1;
214            }
215            let s = std::str::from_utf8(&bytes[start..pos]).unwrap().to_string();
216            if pos < bytes.len() {
217                pos += 1; // skip closing "
218                col += 1;
219            }
220            tokens.push(Token {
221                kind: TokenKind::Symbol(s),
222                span,
223            });
224            continue;
225        }
226
227        // Symbol (identifier, operator like `+`, `-`, `*`, `/`, `=`, `<`, `>`, `<=`, `>=`)
228        if is_symbol_start(b) {
229            let start = pos;
230            while pos < bytes.len() && is_symbol_char(bytes[pos]) {
231                pos += 1;
232                col += 1;
233            }
234            let sym = std::str::from_utf8(&bytes[start..pos])
235                .unwrap()
236                .to_lowercase();
237            tokens.push(Token {
238                kind: TokenKind::Symbol(sym),
239                span,
240            });
241            continue;
242        }
243
244        return Err(ParseError::new(
245            format!("unexpected character: '{}'", b as char),
246            span,
247        ));
248    }
249
250    Ok(tokens)
251}
252
253/// Return `true` if `b` can begin a PDDL symbol (letters, `_`, arithmetic operators, comparison
254/// operators, `#`).
255fn is_symbol_start(b: u8) -> bool {
256    b.is_ascii_alphabetic()
257        || b == b'_'
258        || b == b'-'
259        || b == b'+'
260        || b == b'*'
261        || b == b'/'
262        || b == b'='
263        || b == b'<'
264        || b == b'>'
265        || b == b'#'
266}
267
268/// Return `true` if `b` can appear within (i.e. continue) a PDDL symbol. Superset of
269/// [`is_symbol_start`] plus digits and `.`.
270fn is_symbol_char(b: u8) -> bool {
271    b.is_ascii_alphanumeric()
272        || b == b'_'
273        || b == b'-'
274        || b == b'+'
275        || b == b'*'
276        || b == b'/'
277        || b == b'='
278        || b == b'<'
279        || b == b'>'
280        || b == b'.'
281        || b == b'#'
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_basic_tokens() {
290        let tokens = tokenize("(define (domain test))").unwrap();
291        assert_eq!(tokens.len(), 7);
292        assert_eq!(tokens[0].kind, TokenKind::LParen);
293        assert!(tokens[1].symbol_eq("define"));
294        assert_eq!(tokens[2].kind, TokenKind::LParen);
295        assert!(tokens[3].symbol_eq("domain"));
296        assert!(tokens[4].symbol_eq("test"));
297        assert_eq!(tokens[5].kind, TokenKind::RParen);
298        assert_eq!(tokens[6].kind, TokenKind::RParen);
299    }
300
301    #[test]
302    fn test_keywords_and_variables() {
303        let tokens = tokenize("(:requirements :typing) (?x - type1)").unwrap();
304        assert!(tokens[1].keyword_eq("requirements"));
305        assert!(!tokens[1].keyword_eq("strips"));
306        assert!(tokens[2].keyword_eq("typing"));
307        assert!(tokens[5].variable_eq("?x"));
308        assert!(!tokens[5].variable_eq("?y"));
309    }
310
311    #[test]
312    fn test_numbers() {
313        // (  =  (  f  )  3.14  ) => 7 tokens
314        let tokens = tokenize("(= (f) 3.57)").unwrap();
315        assert_eq!(tokens.len(), 7);
316        assert_eq!(tokens[5].kind, TokenKind::Number(3.57));
317    }
318
319    #[test]
320    fn test_comments_skipped() {
321        let tokens = tokenize("; comment\n(define)").unwrap();
322        assert_eq!(tokens.len(), 3);
323        assert_eq!(tokens[0].kind, TokenKind::LParen);
324    }
325
326    #[test]
327    fn test_negative_symbol() {
328        let tokens = tokenize("(- 3 2)").unwrap();
329        assert!(tokens[1].symbol_eq("-"));
330        assert_eq!(tokens[2].kind, TokenKind::Number(3.0));
331    }
332
333    #[test]
334    fn test_strings_with_newlines_symbols_hash_and_unexpected_char() {
335        let tokens = tokenize("\"line one\nline two\" #tag").unwrap();
336        assert_eq!(
337            tokens[0].kind,
338            TokenKind::Symbol("line one\nline two".to_string())
339        );
340        assert!(tokens[1].symbol_eq("#tag"));
341
342        let err = tokenize("@").unwrap_err();
343        assert!(err.message.contains("unexpected character"));
344
345        let err = tokenize("1.2.3").unwrap_err();
346        assert!(err.message.contains("invalid number"));
347    }
348}