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 `#` to end of line, and whitespace is insignificant.
121pub fn lex(src: &str) -> Result<Vec<Token>, LexError> {
122    let mut tokens = Vec::new();
123    let mut chars = src.chars().peekable();
124    let (mut line, mut column) = (1u32, 1u32);
125
126    // Consuming through a closure keeps line and column correct in one place;
127    // tracking them at each call site is how they drift.
128    macro_rules! bump {
129        () => {{
130            let c = chars.next();
131            match c {
132                Some('\n') => {
133                    line += 1;
134                    column = 1;
135                }
136                Some(_) => column += 1,
137                None => {}
138            }
139            c
140        }};
141    }
142
143    while let Some(&c) = chars.peek() {
144        let span = Span { line, column };
145
146        match c {
147            c if c.is_whitespace() => {
148                bump!();
149            }
150            '#' => {
151                while let Some(&c) = chars.peek() {
152                    if c == '\n' {
153                        break;
154                    }
155                    bump!();
156                }
157            }
158            '"' => {
159                bump!();
160                let mut value = String::new();
161                loop {
162                    match bump!() {
163                        None => return Err(LexError::UnterminatedString { span }),
164                        Some('"') => break,
165                        Some('\\') => {
166                            let escape_span = Span { line, column };
167                            match bump!() {
168                                Some('"') => value.push('"'),
169                                Some('\\') => value.push('\\'),
170                                Some('n') => value.push('\n'),
171                                Some('t') => value.push('\t'),
172                                Some(other) => {
173                                    return Err(LexError::UnknownEscape {
174                                        ch: other,
175                                        span: escape_span,
176                                    })
177                                }
178                                None => return Err(LexError::UnterminatedString { span }),
179                            }
180                        }
181                        // A newline inside a string is allowed: descriptions
182                        // wrap, and requiring an escape for that would make the
183                        // common case awkward.
184                        Some(other) => value.push(other),
185                    }
186                }
187                tokens.push(Token {
188                    tok: Tok::Str(value),
189                    span,
190                });
191            }
192            '-' => {
193                let mut la = chars.clone();
194                la.next();
195                if la.peek() == Some(&'>') {
196                    bump!();
197                    bump!();
198                    tokens.push(Token {
199                        tok: Tok::Arrow,
200                        span,
201                    });
202                } else {
203                    return Err(LexError::UnexpectedChar { ch: '-', span });
204                }
205            }
206            c if c.is_alphanumeric() || c == '_' || c == '.' || c == '/' => {
207                let mut word = String::new();
208                while let Some(&c) = chars.peek() {
209                    if c.is_alphanumeric() || matches!(c, '_' | '.' | '/' | ':') {
210                        word.push(c);
211                        bump!();
212                    } else {
213                        break;
214                    }
215                }
216                tokens.push(Token {
217                    tok: Tok::Ident(word),
218                    span,
219                });
220            }
221            _ => {
222                let tok = match c {
223                    '=' => Tok::Equals,
224                    '{' => Tok::OpenBrace,
225                    '}' => Tok::CloseBrace,
226                    '[' => Tok::OpenBracket,
227                    ']' => Tok::CloseBracket,
228                    ',' => Tok::Comma,
229                    ';' => Tok::Semicolon,
230                    other => return Err(LexError::UnexpectedChar { ch: other, span }),
231                };
232                bump!();
233                tokens.push(Token { tok, span });
234            }
235        }
236    }
237
238    Ok(tokens)
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn an_arrow_lexes_as_one_token() {
247        let tokens = lex(r#""pdf" -> handle_pdf"#).unwrap();
248        assert_eq!(
249            tokens.iter().map(|t| t.tok.clone()).collect::<Vec<_>>(),
250            vec![
251                Tok::Str("pdf".into()),
252                Tok::Arrow,
253                Tok::Ident("handle_pdf".into()),
254            ]
255        );
256    }
257
258    #[test]
259    fn a_lone_hyphen_is_still_an_error() {
260        // Confirms `-` alone (not followed by `>`) keeps today's behavior —
261        // this plan only special-cases the two-character `->` sequence.
262        assert!(lex("- foo").is_err());
263    }
264}