alba_syntax/template.rs
1//! [`StringTemplate`], [`TemplatePart`], and string interpolation parsing.
2//!
3//! ## Source-position tracking
4//!
5//! The lexer's [`crate::token::TokenKind::Str`] holds the string's content
6//! with `\"`/`\\`/`\n`/`\t` escapes already decoded, but interpolation
7//! braces left as raw text. That decoded content is *not* used here: once
8//! escapes are resolved, the content's byte offsets no longer line up
9//! one-to-one with the original source (each decoded `\n` is 1 byte where
10//! the source had 2), and every span this module produces must point into
11//! the *original* source so a diagnostic can put a caret under it.
12//!
13//! Instead, [`parse_template_at`] re-scans `full_source[span]` directly
14//! (the literal text as written, quotes included) and decodes escapes
15//! itself while walking `full_source`'s own byte offsets, so every
16//! position it computes is automatically a real source offset — no
17//! separate offset-mapping table is needed. This does mean re-implementing
18//! the same four-entry escape table the lexer already has; that
19//! duplication is small and deliberate; see [`parse_template_at`]'s doc
20//! comment for why it is safe to make the decode step of that
21//! re-implementation infallible.
22//!
23//! ## Interpolation body parsing
24//!
25//! `{expr}` is resolved by [`scan_interpolation`], which re-lexes the
26//! remaining source one token at a time (via the ordinary
27//! [`crate::lexer::Lexer`], bounded to this string literal's own content so
28//! it can never wander into unrelated later source) and stops at the first
29//! `RBrace`. That token is unambiguously the interpolation's closing brace:
30//! the expression grammar has no other use for `{`/`}`, and any brace
31//! *inside* a nested string literal (`{a + "{b}"}`) is swallowed whole by
32//! that string's own `Str` token, since the lexer never tokenizes inside a
33//! string — so it never surfaces as a stray `LBrace`/`RBrace` here. This is
34//! also what makes nested interpolation work for free: the nested string's
35//! own `{b}` is resolved later, recursively, when that `Str` token is
36//! itself turned into a `StringTemplate`.
37//!
38//! `{}` (nothing between the braces) is rejected as an "empty
39//! interpolation" error. A `{` with no matching `}` before the end of the
40//! string is rejected as an "unclosed interpolation" error, spanning just
41//! the offending `{` — but only when the body genuinely never closes. A
42//! real lex error inside the body (an unexpected character, an
43//! unterminated nested string, ...) is propagated as itself, at its own
44//! precise span, rather than swallowed and misreported as "unclosed
45//! interpolation": the interpolation *is* syntactically closed in that
46//! case, and "unclosed" would send a reader to the wrong byte.
47
48use crate::expr::Expr;
49use crate::lexer::Lexer;
50use crate::parser::{ParseError, Parser};
51use crate::token::{Span, Token, TokenKind};
52
53/// A string literal parsed into alternating literal text and interpolated
54/// expressions, e.g. `"target/{profile}/app"` becomes
55/// `[Literal("target/"), Expr(Var(profile)), Literal("/app")]`.
56#[derive(Debug, Clone, PartialEq)]
57pub struct StringTemplate {
58 pub parts: Vec<TemplatePart>,
59 pub span: Span,
60}
61
62/// One piece of a [`StringTemplate`]: either literal text or a `{expr}`
63/// interpolation.
64#[derive(Debug, Clone, PartialEq)]
65pub enum TemplatePart {
66 Literal(String),
67 Expr(Expr),
68}
69
70/// Parses a single quoted string literal (e.g. `"target/{profile}/app"`,
71/// quotes included) into a [`StringTemplate`], treating `source` as though
72/// it were the whole world: spans in the result are 0-based byte offsets
73/// into `source` itself. Use [`parse_template_at`] when the literal is
74/// embedded in a larger file and spans must land in that original source.
75///
76/// This is a real public entry point, not just a test helper, so it holds
77/// itself to a public API's input contract: `source` is lexed first (the
78/// same way `parser::parse` lexes a whole file), and anything other than
79/// exactly one string literal followed by end of input — an empty string,
80/// unquoted text, a bad escape, an unterminated literal, trailing
81/// content, and so on — is rejected with a `ParseError` rather than
82/// panicking or silently misparsing. That validation is what lets
83/// [`parse_template_at`] below assume its `span` always names a string
84/// the lexer already accepted.
85pub fn parse_template(source: &str) -> Result<StringTemplate, ParseError> {
86 let tokens = crate::parser::tokenize(source)?;
87 match &tokens[..] {
88 [
89 Token {
90 kind: TokenKind::Str(_),
91 span,
92 },
93 Token {
94 kind: TokenKind::Eof,
95 ..
96 },
97 ] => parse_template_at(source, *span),
98 _ => Err(ParseError {
99 message: "expected a single string literal".to_string(),
100 span: Span::new(0, source.len()),
101 help: None,
102 }),
103 }
104}
105
106/// Parses the string literal at `span` (including its surrounding quotes)
107/// within `full_source` into a [`StringTemplate`], with every span in the
108/// result a byte offset into `full_source`.
109///
110/// `span` must be the span of a string literal the lexer has already
111/// tokenized successfully: either the original top-level token, a literal
112/// [`parse_template`] validated by lexing `full_source` itself, or
113/// (recursively) a nested string literal found while re-lexing an
114/// interpolation body below. In every case the lexer already validated its
115/// escapes and its closing quote, so decoding them again here can never
116/// fail; the `unreachable!` in the escape match documents that invariant
117/// rather than guarding against it. Callers that cannot guarantee this —
118/// i.e. anyone outside this crate — should go through [`parse_template`]
119/// instead, which lexes first.
120pub(crate) fn parse_template_at(
121 full_source: &str,
122 span: Span,
123) -> Result<StringTemplate, ParseError> {
124 let raw = &full_source[span.start..span.end];
125 let quote_len = raw
126 .chars()
127 .next()
128 .expect("a string literal's span covers at least its quotes")
129 .len_utf8();
130 let content_end = span.end - quote_len;
131
132 let mut parts = Vec::new();
133 let mut literal = String::new();
134 let mut pos = span.start + quote_len;
135
136 while pos < content_end {
137 let c = full_source[pos..]
138 .chars()
139 .next()
140 .expect("pos is a valid char boundary within full_source");
141 match c {
142 '\\' => {
143 let escaped = full_source[pos + 1..]
144 .chars()
145 .next()
146 .expect("a trailing backslash would have failed lexing already");
147 let decoded = match escaped {
148 '"' => '"',
149 '\\' => '\\',
150 'n' => '\n',
151 't' => '\t',
152 other => unreachable!("escape '\\{other}' would have failed lexing already"),
153 };
154 literal.push(decoded);
155 pos += 1 + escaped.len_utf8();
156 }
157 '{' if full_source[pos + 1..].starts_with('{') => {
158 literal.push('{');
159 pos += 2;
160 }
161 '}' if full_source[pos + 1..].starts_with('}') => {
162 literal.push('}');
163 pos += 2;
164 }
165 '{' => {
166 if !literal.is_empty() {
167 parts.push(TemplatePart::Literal(std::mem::take(&mut literal)));
168 }
169 let (expr, after) = scan_interpolation(full_source, pos, content_end)?;
170 parts.push(TemplatePart::Expr(expr));
171 pos = after;
172 }
173 other => {
174 literal.push(other);
175 pos += other.len_utf8();
176 }
177 }
178 }
179
180 if !literal.is_empty() || parts.is_empty() {
181 parts.push(TemplatePart::Literal(literal));
182 }
183
184 Ok(StringTemplate { parts, span })
185}
186
187/// Parses the interpolation expression opened by the `{` at `brace_pos` (an
188/// absolute byte offset into `full_source`), never reading past
189/// `content_end` (the enclosing string literal's own closing quote).
190/// Returns the parsed expression and the absolute offset just past its
191/// closing `}`.
192fn scan_interpolation(
193 full_source: &str,
194 brace_pos: usize,
195 content_end: usize,
196) -> Result<(Expr, usize), ParseError> {
197 let body_start = brace_pos + 1;
198 let mut lexer = Lexer::new(&full_source[body_start..content_end]);
199 let mut tokens = Vec::new();
200 let mut close_end = None;
201
202 loop {
203 match lexer.next() {
204 Some(Ok(tok)) if tok.kind == TokenKind::RBrace => {
205 close_end = Some(body_start + tok.span.end);
206 break;
207 }
208 Some(Ok(tok)) if tok.kind == TokenKind::Eof => break,
209 // Newline tokens carry no grammatical meaning (see
210 // parser.rs); drop them and keep scanning.
211 Some(Ok(tok)) if tok.kind == TokenKind::Newline => {}
212 Some(Ok(tok)) => tokens.push(shift(tok, body_start)),
213 // A real lex error (e.g. an unexpected character, or an
214 // unterminated nested string) is the actual fault here, and a
215 // far more precise diagnosis than "unclosed interpolation" —
216 // propagate it, shifted onto `full_source`, instead of
217 // discarding it and falling through to that generic message.
218 Some(Err(e)) => {
219 let span = shift_span(e.span, body_start);
220 return Err(ParseError {
221 message: e.message,
222 help: escaped_quote_help(full_source, span),
223 span,
224 });
225 }
226 // The lexer only ever yields `None` after it has already
227 // yielded `Eof` once (see `Lexer`'s own doc comment), and the
228 // arm above already breaks on that `Eof` — so this is not
229 // reachable in practice. Treated the same as running out of
230 // input, for defense in depth rather than `unreachable!()`.
231 None => break,
232 }
233 }
234
235 let Some(close_end) = close_end else {
236 return Err(ParseError {
237 message: "unclosed interpolation".to_string(),
238 span: Span::new(brace_pos, brace_pos + 1),
239 help: None,
240 });
241 };
242
243 if tokens.is_empty() {
244 return Err(ParseError {
245 message: "empty interpolation".to_string(),
246 span: Span::new(brace_pos, close_end),
247 help: None,
248 });
249 }
250
251 // A synthetic Eof — spanning the closing `}` itself — so the ordinary
252 // Parser machinery can confirm the body is exactly one expression with
253 // nothing trailing before `}`. Should an error land exactly on this
254 // sentinel (e.g. `{b +}`, where an operand is missing right before the
255 // brace), `describe`'s generic "end of input" phrasing would be a lie —
256 // the `}` is right there — so `fixup_eof_message` corrects it.
257 let eof_span = Span::new(close_end - 1, close_end);
258 tokens.push(Token::new(TokenKind::Eof, eof_span));
259
260 let mut parser = Parser::new(tokens, full_source);
261 let expr = parser
262 .parse_expr()
263 .map_err(|err| fixup_eof_message(err, eof_span))?;
264 if !parser.is_eof() {
265 return Err(parser.unexpected("`}`"));
266 }
267 Ok((expr, close_end))
268}
269
270/// Help text for the one lex failure inside an interpolation body that a
271/// reader is likely to hit by writing perfectly reasonable-looking code:
272/// escaping a quote, as in `run "echo {env(\"VAR\")}"`. Escapes are
273/// decoded in a string's literal parts, but an interpolation body is
274/// re-lexed straight from the source, so the backslash reaches the lexer
275/// as itself and is rejected as a stray character. Single quotes need no
276/// escaping and work, which is what this points at.
277fn escaped_quote_help(full_source: &str, span: Span) -> Option<String> {
278 full_source
279 .get(span.start..span.end)?
280 .starts_with('\\')
281 .then(|| "use single quotes inside an interpolation, e.g. `{env('VAR')}`".to_string())
282}
283
284/// Rewrites an error's "found end of input" phrasing to name the
285/// interpolation's closing `}` instead, when the error's span is exactly
286/// `eof_span` (the synthetic sentinel `scan_interpolation` feeds the
287/// sub-parser). Every other error — including the `!parser.is_eof()`
288/// check above, which only ever fires on a real trailing token — is
289/// returned unchanged.
290fn fixup_eof_message(mut err: ParseError, eof_span: Span) -> ParseError {
291 if err.span == eof_span {
292 err.message = err
293 .message
294 .replacen(crate::parser::EOF_DESCRIPTION, "`}`", 1);
295 }
296 err
297}
298
299/// Shifts a token lexed from a bounded sub-slice of `full_source` so its
300/// span points at its real, absolute position in `full_source`.
301fn shift(token: Token, offset: usize) -> Token {
302 Token::new(token.kind, shift_span(token.span, offset))
303}
304
305/// Shifts a span computed against a bounded sub-slice of `full_source` (as
306/// `scan_interpolation`'s sub-lexer produces, both for tokens and for its
307/// own lex errors) onto that slice's real, absolute position in
308/// `full_source`.
309fn shift_span(span: Span, offset: usize) -> Span {
310 Span::new(span.start + offset, span.end + offset)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn parses_interpolation() {
319 let t = parse_template(r#""target/{profile}/app""#).unwrap();
320 assert!(matches!(&t.parts[0], TemplatePart::Literal(s) if s == "target/"));
321 assert!(matches!(&t.parts[1], TemplatePart::Expr(Expr::Var(v)) if v.value == "profile"));
322 }
323
324 #[test]
325 fn double_brace_escapes() {
326 let t = parse_template(r#""a {{literal}} b""#).unwrap();
327 assert!(matches!(&t.parts[0], TemplatePart::Literal(s) if s == "a {literal} b"));
328 }
329
330 #[test]
331 fn rejects_unclosed_interpolation() {
332 let err = crate::parse(r#"beam x { run "echo {oops" }"#).unwrap_err();
333 assert!(err.message.contains("unclosed interpolation"));
334 }
335
336 #[test]
337 fn unclosed_interpolation_span_points_at_the_open_brace() {
338 let err = parse_template(r#""a{oops""#).unwrap_err();
339 assert_eq!(err.span, Span::new(2, 3));
340 assert!(err.message.contains("unclosed interpolation"));
341 }
342
343 #[test]
344 fn empty_interpolation_span_covers_both_braces() {
345 let err = parse_template(r#""a{}b""#).unwrap_err();
346 assert_eq!(err.span, Span::new(2, 4));
347 assert!(err.message.contains("empty interpolation"));
348 }
349
350 /// A syntactically closed interpolation whose body contains a real lex
351 /// error (an unexpected character) must report that error, at the
352 /// character's own span — not get misdiagnosed as "unclosed
353 /// interpolation" just because the sub-lexer that's hunting for the
354 /// closing `}` happened to fail before reaching one.
355 #[test]
356 fn interpolation_reports_the_real_lex_error_not_unclosed() {
357 let err = parse_template(r#""a{b @ c}""#).unwrap_err();
358 assert_eq!(err.span, Span::new(5, 6));
359 assert!(err.message.contains('@'));
360 assert!(!err.message.contains("unclosed"));
361 }
362
363 #[test]
364 fn missing_operand_before_close_brace_names_the_brace_not_end_of_input() {
365 let err = parse_template(r#""a{b +}""#).unwrap_err();
366 assert_eq!(err.span, Span::new(6, 7));
367 assert!(err.message.contains('}'));
368 assert!(!err.message.contains("end of input"));
369 }
370
371 /// An interpolation body is re-lexed from the raw source, so a `\"`
372 /// escape that works perfectly well in the literal parts of a string
373 /// is a stray backslash inside `{...}`. The message says so, and the
374 /// help names the form that does work.
375 #[test]
376 fn an_escaped_quote_inside_an_interpolation_suggests_single_quotes() {
377 let err = parse_template(r#""echo {env(\"VAR\")}""#).unwrap_err();
378
379 assert_eq!(err.span, Span::new(11, 12));
380 assert!(err.message.contains('\\'));
381 assert_eq!(
382 err.help.as_deref(),
383 Some("use single quotes inside an interpolation, e.g. `{env('VAR')}`")
384 );
385 }
386
387 #[test]
388 fn rejects_empty_input_instead_of_panicking() {
389 assert!(parse_template("").is_err());
390 }
391
392 #[test]
393 fn rejects_invalid_escape_instead_of_panicking() {
394 let err = parse_template(r#""a\qb""#).unwrap_err();
395 assert!(err.message.contains("unknown escape"));
396 }
397
398 #[test]
399 fn rejects_unquoted_text_instead_of_silently_truncating() {
400 assert!(parse_template("hello").is_err());
401 }
402
403 #[test]
404 fn rejects_unterminated_string_instead_of_silently_truncating() {
405 let err = parse_template(r#""ab\"#).unwrap_err();
406 assert!(err.message.contains("unterminated string"));
407 }
408
409 #[test]
410 fn rejects_trailing_content_after_the_string_literal() {
411 assert!(parse_template(r#""a" "b""#).is_err());
412 }
413}