Skip to main content

cuttlefish_core/
lex.rs

1//! Turning spec text into tokens.
2//!
3//! # Why a lexer instead of splitting on punctuation
4//!
5//! The previous parser split statements on `;` and lists on `,`. That works
6//! until a value *contains* one — and a description is prose, so it contains
7//! semicolons routinely:
8//!
9//! ```text
10//! description = "Use when summarizing; especially long files.";
11//! ```
12//!
13//! Splitting on `;` cuts that in half and reports a confusing error about the
14//! description not being a quoted string. A path containing a comma broke the
15//! capability list the same way. Both were real bugs, not hypotheticals, and
16//! neither is fixable by being cleverer about splitting: a separator inside a
17//! string is only distinguishable from a separator between values by tracking
18//! whether you are inside a string, which is what a lexer is.
19//!
20//! It also buys positions. "malformed spec" with no location is a poor error for
21//! a file someone is editing by hand; every token here carries a line and column
22//! so the parser can point at the problem.
23
24/// A token's position in the source, for error messages.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct Span {
27    /// 1-based line.
28    pub line: u32,
29    /// 1-based column.
30    pub column: u32,
31}
32
33impl std::fmt::Display for Span {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        write!(f, "line {}, column {}", self.line, self.column)
36    }
37}
38
39/// What a token is.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum Tok {
42    /// A bare word: `spec`, `description`, `Ollama`, `Local_only`.
43    Ident(String),
44    /// A quoted string, with escapes already resolved.
45    Str(String),
46    /// `=`
47    Equals,
48    /// `{`
49    OpenBrace,
50    /// `}`
51    CloseBrace,
52    /// `[`
53    OpenBracket,
54    /// `]`
55    CloseBracket,
56    /// `,`
57    Comma,
58    /// `;`
59    Semicolon,
60    /// `->`
61    Arrow,
62}
63
64impl Tok {
65    /// How to name this in an error message.
66    pub fn describe(&self) -> String {
67        match self {
68            Tok::Ident(name) => format!("`{name}`"),
69            Tok::Str(_) => "a quoted string".into(),
70            Tok::Equals => "`=`".into(),
71            Tok::OpenBrace => "`{`".into(),
72            Tok::CloseBrace => "`}`".into(),
73            Tok::OpenBracket => "`[`".into(),
74            Tok::CloseBracket => "`]`".into(),
75            Tok::Comma => "`,`".into(),
76            Tok::Semicolon => "`;`".into(),
77            Tok::Arrow => "`->`".into(),
78        }
79    }
80}
81
82/// A token and where it came from.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Token {
85    /// The token.
86    pub tok: Tok,
87    /// Where it started.
88    pub span: Span,
89}
90
91/// Why lexing stopped.
92#[derive(Debug, thiserror::Error, PartialEq, Eq)]
93pub enum LexError {
94    /// A string had no closing quote.
95    #[error("unterminated string starting at {span}")]
96    UnterminatedString {
97        /// Where the string began.
98        span: Span,
99    },
100    /// A character that cannot begin any token.
101    #[error("unexpected character `{ch}` at {span}")]
102    UnexpectedChar {
103        /// The offending character.
104        ch: char,
105        /// Where it is.
106        span: Span,
107    },
108    /// A backslash escape this format does not define.
109    #[error("unknown escape `\\{ch}` at {span}")]
110    UnknownEscape {
111        /// The character after the backslash.
112        ch: char,
113        /// Where the escape is.
114        span: Span,
115    },
116}
117
118/// Tokenize spec source.
119///
120/// Comments run from `#` or `//` to end of line, and whitespace is
121/// insignificant.
122pub fn lex(src: &str) -> Result<Vec<Token>, LexError> {
123    let mut tokens = Vec::new();
124    let mut chars = src.chars().peekable();
125    let (mut line, mut column) = (1u32, 1u32);
126
127    // Consuming through a closure keeps line and column correct in one place;
128    // tracking them at each call site is how they drift.
129    macro_rules! bump {
130        () => {{
131            let c = chars.next();
132            match c {
133                Some('\n') => {
134                    line += 1;
135                    column = 1;
136                }
137                Some(_) => column += 1,
138                None => {}
139            }
140            c
141        }};
142    }
143
144    while let Some(&c) = chars.peek() {
145        let span = Span { line, column };
146
147        macro_rules! skip_to_end_of_line {
148            () => {{
149                while let Some(&c) = chars.peek() {
150                    if c == '\n' {
151                        break;
152                    }
153                    bump!();
154                }
155            }};
156        }
157
158        match c {
159            c if c.is_whitespace() => {
160                bump!();
161            }
162            // `#` and `//` both run to end of line. `//` is not redundant:
163            // this grammar reads C-ish enough that people reach for it
164            // first, and when it was unsupported the failure was maximally
165            // confusing — the lexer skipped the slashes, then reported an
166            // "unexpected character" pointing at some punctuation *inside
167            // the comment's prose*, which names a character that is not the
168            // problem and a position that is not where the mistake is.
169            '#' => {
170                skip_to_end_of_line!();
171            }
172            '/' if chars.clone().nth(1) == Some('/') => {
173                skip_to_end_of_line!();
174            }
175            '"' => {
176                bump!();
177                let mut value = String::new();
178                loop {
179                    match bump!() {
180                        None => return Err(LexError::UnterminatedString { span }),
181                        Some('"') => break,
182                        Some('\\') => {
183                            let escape_span = Span { line, column };
184                            match bump!() {
185                                Some('"') => value.push('"'),
186                                Some('\\') => value.push('\\'),
187                                Some('n') => value.push('\n'),
188                                Some('t') => value.push('\t'),
189                                Some(other) => {
190                                    return Err(LexError::UnknownEscape {
191                                        ch: other,
192                                        span: escape_span,
193                                    })
194                                }
195                                None => return Err(LexError::UnterminatedString { span }),
196                            }
197                        }
198                        // A newline inside a string is allowed: descriptions
199                        // wrap, and requiring an escape for that would make the
200                        // common case awkward.
201                        Some(other) => value.push(other),
202                    }
203                }
204                tokens.push(Token {
205                    tok: Tok::Str(value),
206                    span,
207                });
208            }
209            '-' => {
210                let mut la = chars.clone();
211                la.next();
212                if la.peek() == Some(&'>') {
213                    bump!();
214                    bump!();
215                    tokens.push(Token {
216                        tok: Tok::Arrow,
217                        span,
218                    });
219                } else {
220                    return Err(LexError::UnexpectedChar { ch: '-', span });
221                }
222            }
223            c if c.is_alphanumeric() || c == '_' || c == '.' || c == '/' => {
224                let mut word = String::new();
225                while let Some(&c) = chars.peek() {
226                    if c.is_alphanumeric() || matches!(c, '_' | '.' | '/' | ':') {
227                        word.push(c);
228                        bump!();
229                    } else {
230                        break;
231                    }
232                }
233                tokens.push(Token {
234                    tok: Tok::Ident(word),
235                    span,
236                });
237            }
238            _ => {
239                let tok = match c {
240                    '=' => Tok::Equals,
241                    '{' => Tok::OpenBrace,
242                    '}' => Tok::CloseBrace,
243                    '[' => Tok::OpenBracket,
244                    ']' => Tok::CloseBracket,
245                    ',' => Tok::Comma,
246                    ';' => Tok::Semicolon,
247                    other => return Err(LexError::UnexpectedChar { ch: other, span }),
248                };
249                bump!();
250                tokens.push(Token { tok, span });
251            }
252        }
253    }
254
255    Ok(tokens)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn an_arrow_lexes_as_one_token() {
264        let tokens = lex(r#""pdf" -> handle_pdf"#).unwrap();
265        assert_eq!(
266            tokens.iter().map(|t| t.tok.clone()).collect::<Vec<_>>(),
267            vec![
268                Tok::Str("pdf".into()),
269                Tok::Arrow,
270                Tok::Ident("handle_pdf".into()),
271            ]
272        );
273    }
274
275    #[test]
276    fn a_lone_hyphen_is_still_an_error() {
277        // Confirms `-` alone (not followed by `>`) keeps today's behavior —
278        // this plan only special-cases the two-character `->` sequence.
279        assert!(lex("- foo").is_err());
280    }
281}
282
283#[cfg(test)]
284mod comment_tests {
285    use super::*;
286
287    /// Both comment syntaxes reach end of line and nothing else.
288    ///
289    /// `//` was added after a real spec failed to parse because its author
290    /// reached for it. The lexer skipped the slashes and then reported an
291    /// "unexpected character" pointing at a `(` inside the comment's own
292    /// prose — a character that was not the problem, at a position that was
293    /// not the mistake. That is the most confusing possible way to say
294    /// "comments start with `#`".
295    #[test]
296    fn both_comment_syntaxes_run_to_end_of_line() {
297        let hash = lex("# note\nspec").expect("`#` comments must lex");
298        let slash = lex("// note\nspec").expect("`//` comments must lex");
299        assert_eq!(
300            hash.len(),
301            slash.len(),
302            "the two syntaxes must produce identical token streams"
303        );
304
305        // Punctuation inside a comment is prose, not syntax — this is the
306        // exact input that used to fail.
307        lex("// call infer(prompt, 32) here\nspec").expect("prose in a comment must be skipped");
308        lex("# call infer(prompt, 32) here\nspec").unwrap();
309    }
310
311    #[test]
312    fn a_comment_ends_at_the_newline_and_not_before_or_after() {
313        // The token after a comment must still be seen: a comment that ate
314        // the rest of the file would turn a typo into silent truncation.
315        let tokens = lex("# gone\nkept").unwrap();
316        assert_eq!(tokens.len(), 1, "{tokens:?}");
317        // And a trailing comment with no newline must simply end.
318        assert!(lex("kept # gone").is_ok());
319    }
320
321    #[test]
322    fn a_single_slash_is_still_an_ordinary_identifier_character() {
323        // Only a *doubled* slash opens a comment. `/` is a legal identifier
324        // character here — namespaced catalog names like `team/cat-a@1` rely
325        // on it — so making `/` special would have broken them. Pinned
326        // because the comment rule is one character away from doing exactly
327        // that.
328        let tokens = lex("team/cat").expect("a slash inside an identifier must still lex");
329        assert_eq!(tokens.len(), 1, "{tokens:?}");
330        // And a slash that opens nothing still ends the identifier cleanly
331        // rather than swallowing the rest of the line.
332        assert_eq!(lex("a/b c").unwrap().len(), 2);
333    }
334
335    /// The exact input from the field log, which used to fail with
336    /// ``unexpected character `(` at line 4, column 25``.
337    ///
338    /// Column 25 was a `(` inside the *comment's prose*. The lexer had taken
339    /// `//` as an identifier (a slash is a legal identifier character), then
340    /// `call`, then `infer`, and only tripped on the parenthesis — naming a
341    /// character that was not the problem, at a position that was not the
342    /// mistake, several tokens past the real cause.
343    #[test]
344    fn the_spec_that_failed_in_the_field_now_parses() {
345        let src = r#"spec demo = {
346  description = "Use when testing.";
347  model = Stub "unused";
348  // the model is called via infer(prompt, 32) inside the block
349  data_policy = Local_only;
350  capabilities = [ ];
351  nodes = { check = { block = "./check.rhai"; }; };
352}
353"#;
354        crate::spec::parse_spec(src).expect("a spec with `//` comments must parse");
355    }
356}