use crate::expr::Expr;
use crate::lexer::Lexer;
use crate::parser::{ParseError, Parser};
use crate::token::{Span, Token, TokenKind};
#[derive(Debug, Clone, PartialEq)]
pub struct StringTemplate {
pub parts: Vec<TemplatePart>,
pub span: Span,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TemplatePart {
Literal(String),
Expr(Expr),
}
pub fn parse_template(source: &str) -> Result<StringTemplate, ParseError> {
let tokens = crate::parser::tokenize(source)?;
match &tokens[..] {
[
Token {
kind: TokenKind::Str(_),
span,
},
Token {
kind: TokenKind::Eof,
..
},
] => parse_template_at(source, *span),
_ => Err(ParseError {
message: "expected a single string literal".to_string(),
span: Span::new(0, source.len()),
help: None,
}),
}
}
pub(crate) fn parse_template_at(
full_source: &str,
span: Span,
) -> Result<StringTemplate, ParseError> {
let raw = &full_source[span.start..span.end];
let quote_len = raw
.chars()
.next()
.expect("a string literal's span covers at least its quotes")
.len_utf8();
let content_end = span.end - quote_len;
let mut parts = Vec::new();
let mut literal = String::new();
let mut pos = span.start + quote_len;
while pos < content_end {
let c = full_source[pos..]
.chars()
.next()
.expect("pos is a valid char boundary within full_source");
match c {
'\\' => {
let escaped = full_source[pos + 1..]
.chars()
.next()
.expect("a trailing backslash would have failed lexing already");
let decoded = match escaped {
'"' => '"',
'\\' => '\\',
'n' => '\n',
't' => '\t',
other => unreachable!("escape '\\{other}' would have failed lexing already"),
};
literal.push(decoded);
pos += 1 + escaped.len_utf8();
}
'{' if full_source[pos + 1..].starts_with('{') => {
literal.push('{');
pos += 2;
}
'}' if full_source[pos + 1..].starts_with('}') => {
literal.push('}');
pos += 2;
}
'{' => {
if !literal.is_empty() {
parts.push(TemplatePart::Literal(std::mem::take(&mut literal)));
}
let (expr, after) = scan_interpolation(full_source, pos, content_end)?;
parts.push(TemplatePart::Expr(expr));
pos = after;
}
other => {
literal.push(other);
pos += other.len_utf8();
}
}
}
if !literal.is_empty() || parts.is_empty() {
parts.push(TemplatePart::Literal(literal));
}
Ok(StringTemplate { parts, span })
}
fn scan_interpolation(
full_source: &str,
brace_pos: usize,
content_end: usize,
) -> Result<(Expr, usize), ParseError> {
let body_start = brace_pos + 1;
let mut lexer = Lexer::new(&full_source[body_start..content_end]);
let mut tokens = Vec::new();
let mut close_end = None;
loop {
match lexer.next() {
Some(Ok(tok)) if tok.kind == TokenKind::RBrace => {
close_end = Some(body_start + tok.span.end);
break;
}
Some(Ok(tok)) if tok.kind == TokenKind::Eof => break,
Some(Ok(tok)) if tok.kind == TokenKind::Newline => {}
Some(Ok(tok)) => tokens.push(shift(tok, body_start)),
Some(Err(e)) => {
let span = shift_span(e.span, body_start);
return Err(ParseError {
message: e.message,
help: escaped_quote_help(full_source, span),
span,
});
}
None => break,
}
}
let Some(close_end) = close_end else {
return Err(ParseError {
message: "unclosed interpolation".to_string(),
span: Span::new(brace_pos, brace_pos + 1),
help: None,
});
};
if tokens.is_empty() {
return Err(ParseError {
message: "empty interpolation".to_string(),
span: Span::new(brace_pos, close_end),
help: None,
});
}
let eof_span = Span::new(close_end - 1, close_end);
tokens.push(Token::new(TokenKind::Eof, eof_span));
let mut parser = Parser::new(tokens, full_source);
let expr = parser
.parse_expr()
.map_err(|err| fixup_eof_message(err, eof_span))?;
if !parser.is_eof() {
return Err(parser.unexpected("`}`"));
}
Ok((expr, close_end))
}
fn escaped_quote_help(full_source: &str, span: Span) -> Option<String> {
full_source
.get(span.start..span.end)?
.starts_with('\\')
.then(|| "use single quotes inside an interpolation, e.g. `{env('VAR')}`".to_string())
}
fn fixup_eof_message(mut err: ParseError, eof_span: Span) -> ParseError {
if err.span == eof_span {
err.message = err
.message
.replacen(crate::parser::EOF_DESCRIPTION, "`}`", 1);
}
err
}
fn shift(token: Token, offset: usize) -> Token {
Token::new(token.kind, shift_span(token.span, offset))
}
fn shift_span(span: Span, offset: usize) -> Span {
Span::new(span.start + offset, span.end + offset)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_interpolation() {
let t = parse_template(r#""target/{profile}/app""#).unwrap();
assert!(matches!(&t.parts[0], TemplatePart::Literal(s) if s == "target/"));
assert!(matches!(&t.parts[1], TemplatePart::Expr(Expr::Var(v)) if v.value == "profile"));
}
#[test]
fn double_brace_escapes() {
let t = parse_template(r#""a {{literal}} b""#).unwrap();
assert!(matches!(&t.parts[0], TemplatePart::Literal(s) if s == "a {literal} b"));
}
#[test]
fn rejects_unclosed_interpolation() {
let err = crate::parse(r#"beam x { run "echo {oops" }"#).unwrap_err();
assert!(err.message.contains("unclosed interpolation"));
}
#[test]
fn unclosed_interpolation_span_points_at_the_open_brace() {
let err = parse_template(r#""a{oops""#).unwrap_err();
assert_eq!(err.span, Span::new(2, 3));
assert!(err.message.contains("unclosed interpolation"));
}
#[test]
fn empty_interpolation_span_covers_both_braces() {
let err = parse_template(r#""a{}b""#).unwrap_err();
assert_eq!(err.span, Span::new(2, 4));
assert!(err.message.contains("empty interpolation"));
}
#[test]
fn interpolation_reports_the_real_lex_error_not_unclosed() {
let err = parse_template(r#""a{b @ c}""#).unwrap_err();
assert_eq!(err.span, Span::new(5, 6));
assert!(err.message.contains('@'));
assert!(!err.message.contains("unclosed"));
}
#[test]
fn missing_operand_before_close_brace_names_the_brace_not_end_of_input() {
let err = parse_template(r#""a{b +}""#).unwrap_err();
assert_eq!(err.span, Span::new(6, 7));
assert!(err.message.contains('}'));
assert!(!err.message.contains("end of input"));
}
#[test]
fn an_escaped_quote_inside_an_interpolation_suggests_single_quotes() {
let err = parse_template(r#""echo {env(\"VAR\")}""#).unwrap_err();
assert_eq!(err.span, Span::new(11, 12));
assert!(err.message.contains('\\'));
assert_eq!(
err.help.as_deref(),
Some("use single quotes inside an interpolation, e.g. `{env('VAR')}`")
);
}
#[test]
fn rejects_empty_input_instead_of_panicking() {
assert!(parse_template("").is_err());
}
#[test]
fn rejects_invalid_escape_instead_of_panicking() {
let err = parse_template(r#""a\qb""#).unwrap_err();
assert!(err.message.contains("unknown escape"));
}
#[test]
fn rejects_unquoted_text_instead_of_silently_truncating() {
assert!(parse_template("hello").is_err());
}
#[test]
fn rejects_unterminated_string_instead_of_silently_truncating() {
let err = parse_template(r#""ab\"#).unwrap_err();
assert!(err.message.contains("unterminated string"));
}
#[test]
fn rejects_trailing_content_after_the_string_literal() {
assert!(parse_template(r#""a" "b""#).is_err());
}
}