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}