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}