Skip to main content

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}