feat: implement string interpolation

Add support for string interpolation with {expr} syntax:
  "Hello, {name}!" becomes "Hello, " + toString(name) + "!"

Lexer changes:
- Add StringPart enum (Literal/Expr) and InterpolatedString token
- Detect {expr} in strings and capture expression text
- Support escaped braces with \{ and \}

Parser changes:
- Add desugar_interpolated_string() to convert to concatenation
- Automatically wrap expressions in toString() calls

Interpreter changes:
- Fix toString() to not add quotes around strings

Tests added:
- 4 lexer tests for interpolation tokenization
- 4 integration tests for full interpolation pipeline

Add examples/interpolation.lux demonstrating:
- Variable interpolation
- Number interpolation (auto toString)
- Expression interpolation ({a + b})
- Escaped braces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 09:30:37 -05:00
parent 3734a17e5c
commit f670bd2659
5 changed files with 305 additions and 19 deletions

View File

@@ -7,6 +7,15 @@ use std::fmt;
use std::iter::Peekable;
use std::str::Chars;
/// Part of an interpolated string
#[derive(Debug, Clone, PartialEq)]
pub enum StringPart {
/// Literal text
Literal(String),
/// Expression to be evaluated (stored as source text to be parsed later)
Expr(String),
}
/// Token types
#[derive(Debug, Clone, PartialEq)]
pub enum TokenKind {
@@ -14,6 +23,8 @@ pub enum TokenKind {
Int(i64),
Float(f64),
String(String),
/// Interpolated string with embedded expressions: "Hello, {name}!"
InterpolatedString(Vec<StringPart>),
Char(char),
Bool(bool),
@@ -104,6 +115,16 @@ impl fmt::Display for TokenKind {
TokenKind::Int(n) => write!(f, "{}", n),
TokenKind::Float(n) => write!(f, "{}", n),
TokenKind::String(s) => write!(f, "\"{}\"", s),
TokenKind::InterpolatedString(parts) => {
write!(f, "\"")?;
for part in parts {
match part {
StringPart::Literal(s) => write!(f, "{}", s)?,
StringPart::Expr(e) => write!(f, "{{{}}}", e)?,
}
}
write!(f, "\"")
}
TokenKind::Char(c) => write!(f, "'{}'", c),
TokenKind::Bool(b) => write!(f, "{}", b),
TokenKind::Ident(s) => write!(f, "{}", s),
@@ -445,28 +466,78 @@ impl<'a> Lexer<'a> {
}
fn scan_string(&mut self, _start: usize) -> Result<TokenKind, LexError> {
let mut value = String::new();
let mut parts: Vec<StringPart> = Vec::new();
let mut current_literal = String::new();
loop {
match self.advance() {
Some('"') => break,
Some('\\') => {
let escaped = match self.advance() {
Some('n') => '\n',
Some('r') => '\r',
Some('t') => '\t',
Some('\\') => '\\',
Some('"') => '"',
Some(c) => c,
None => {
return Err(LexError {
message: "Unterminated string".into(),
span: Span::new(_start, self.pos),
});
// Check for escaped brace
match self.peek() {
Some('{') => {
self.advance();
current_literal.push('{');
}
};
value.push(escaped);
Some('}') => {
self.advance();
current_literal.push('}');
}
_ => {
let escaped = match self.advance() {
Some('n') => '\n',
Some('r') => '\r',
Some('t') => '\t',
Some('\\') => '\\',
Some('"') => '"',
Some(c) => c,
None => {
return Err(LexError {
message: "Unterminated string".into(),
span: Span::new(_start, self.pos),
});
}
};
current_literal.push(escaped);
}
}
}
Some(c) => value.push(c),
Some('{') => {
// Start of interpolation
if !current_literal.is_empty() {
parts.push(StringPart::Literal(std::mem::take(&mut current_literal)));
}
// Scan the expression until matching '}'
let mut expr_text = String::new();
let mut brace_depth = 1;
loop {
match self.advance() {
Some('{') => {
brace_depth += 1;
expr_text.push('{');
}
Some('}') => {
brace_depth -= 1;
if brace_depth == 0 {
break;
}
expr_text.push('}');
}
Some(c) => expr_text.push(c),
None => {
return Err(LexError {
message: "Unterminated interpolation in string".into(),
span: Span::new(_start, self.pos),
});
}
}
}
parts.push(StringPart::Expr(expr_text));
}
Some(c) => current_literal.push(c),
None => {
return Err(LexError {
message: "Unterminated string".into(),
@@ -475,7 +546,18 @@ impl<'a> Lexer<'a> {
}
}
}
Ok(TokenKind::String(value))
// If we have no interpolations, return a simple string
if parts.is_empty() {
return Ok(TokenKind::String(current_literal));
}
// Add any remaining literal
if !current_literal.is_empty() {
parts.push(StringPart::Literal(current_literal));
}
Ok(TokenKind::InterpolatedString(parts))
}
fn scan_char(&mut self, start: usize) -> Result<TokenKind, LexError> {
@@ -672,6 +754,61 @@ mod tests {
);
}
#[test]
fn test_string_interpolation_simple() {
assert_eq!(
lex("\"Hello, {name}!\""),
vec![
TokenKind::InterpolatedString(vec![
StringPart::Literal("Hello, ".into()),
StringPart::Expr("name".into()),
StringPart::Literal("!".into()),
]),
TokenKind::Eof
]
);
}
#[test]
fn test_string_interpolation_multiple() {
assert_eq!(
lex("\"{x} + {y} = {x + y}\""),
vec![
TokenKind::InterpolatedString(vec![
StringPart::Expr("x".into()),
StringPart::Literal(" + ".into()),
StringPart::Expr("y".into()),
StringPart::Literal(" = ".into()),
StringPart::Expr("x + y".into()),
]),
TokenKind::Eof
]
);
}
#[test]
fn test_string_interpolation_escaped_braces() {
assert_eq!(
lex("\"literal \\{braces\\}\""),
vec![
TokenKind::String("literal {braces}".into()),
TokenKind::Eof
]
);
}
#[test]
fn test_string_no_interpolation() {
// Plain strings without interpolation should remain as String tokens
assert_eq!(
lex("\"no interpolation here\""),
vec![
TokenKind::String("no interpolation here".into()),
TokenKind::Eof
]
);
}
#[test]
fn test_function() {
assert_eq!(