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

@@ -0,0 +1,39 @@
// Demonstrating string interpolation in Lux
//
// Expected output:
// Hello, Alice!
// The answer is 42
// 2 + 3 = 5
// Nested: Hello, Bob! You are 25 years old.
// Escaped braces: {literal} and more {text}
// Basic interpolation
let name = "Alice"
let greeting = "Hello, {name}!"
// Number interpolation (auto-converts with toString)
let answer = 42
let message = "The answer is {answer}"
// Expression interpolation
let x = 2
let y = 3
let math = "{x} + {y} = {x + y}"
// Multiple interpolations
let person = "Bob"
let age = 25
let intro = "Nested: Hello, {person}! You are {age} years old."
// Escaped braces (use \{ and \})
let escaped = "Escaped braces: \{literal\} and more \{text\}"
fn printResults(): Unit with {Console} = {
Console.print(greeting)
Console.print(message)
Console.print(math)
Console.print(intro)
Console.print(escaped)
}
let output = run printResults() with {}

View File

@@ -1798,7 +1798,13 @@ impl Interpreter {
if args.len() != 1 {
return Err(err("toString requires 1 argument"));
}
Ok(EvalResult::Value(Value::String(format!("{}", args[0]))))
// For strings, return the string itself (no quotes)
// For other values, use Display formatting
let result = match &args[0] {
Value::String(s) => s.clone(),
v => format!("{}", v),
};
Ok(EvalResult::Value(Value::String(result)))
}
BuiltinFn::TypeOf => {

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,11 +466,24 @@ 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('\\') => {
// Check for escaped brace
match self.peek() {
Some('{') => {
self.advance();
current_literal.push('{');
}
Some('}') => {
self.advance();
current_literal.push('}');
}
_ => {
let escaped = match self.advance() {
Some('n') => '\n',
Some('r') => '\r',
@@ -464,9 +498,46 @@ impl<'a> Lexer<'a> {
});
}
};
value.push(escaped);
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!(

View File

@@ -904,6 +904,41 @@ c")"#;
assert_eq!(eval(source).unwrap(), r#"["a", "b", "c"]"#);
}
// String interpolation tests
#[test]
fn test_string_interpolation_simple() {
let source = r#"
let name = "World"
let x = "Hello, {name}!"
"#;
assert_eq!(eval(source).unwrap(), r#""Hello, World!""#);
}
#[test]
fn test_string_interpolation_numbers() {
let source = r#"
let n = 42
let x = "The answer is {n}"
"#;
assert_eq!(eval(source).unwrap(), r#""The answer is 42""#);
}
#[test]
fn test_string_interpolation_expressions() {
let source = r#"
let a = 2
let b = 3
let x = "{a} + {b} = {a + b}"
"#;
assert_eq!(eval(source).unwrap(), r#""2 + 3 = 5""#);
}
#[test]
fn test_string_interpolation_escaped_braces() {
let source = r#"let x = "literal \{braces\}""#;
assert_eq!(eval(source).unwrap(), r#""literal {braces}""#);
}
// Option tests
#[test]
fn test_option_constructors() {

View File

@@ -4,7 +4,7 @@
use crate::ast::*;
use crate::diagnostics::{Diagnostic, Severity};
use crate::lexer::{LexError, Lexer, Token, TokenKind};
use crate::lexer::{LexError, Lexer, StringPart, Token, TokenKind};
use std::fmt;
/// Parser error
@@ -1603,6 +1603,12 @@ impl Parser {
span: token.span,
}))
}
TokenKind::InterpolatedString(parts) => {
let parts = parts.clone();
let span = token.span;
self.advance();
self.desugar_interpolated_string(&parts, span)
}
TokenKind::Char(c) => {
let c = *c;
self.advance();
@@ -2160,6 +2166,69 @@ impl Parser {
Ok(Expr::List { elements, span })
}
/// Desugar an interpolated string into concatenation with toString calls
/// "Hello, {name}!" becomes "Hello, " + toString(name) + "!"
fn desugar_interpolated_string(
&mut self,
parts: &[StringPart],
span: Span,
) -> Result<Expr, ParseError> {
let mut exprs: Vec<Expr> = Vec::new();
for part in parts {
match part {
StringPart::Literal(s) => {
exprs.push(Expr::Literal(Literal {
kind: LiteralKind::String(s.clone()),
span,
}));
}
StringPart::Expr(expr_text) => {
// Parse the expression text
let lexer = Lexer::new(expr_text);
let tokens = lexer.tokenize().map_err(|e| ParseError {
message: format!("Lexer error in interpolation: {}", e.message),
span,
})?;
let mut parser = Parser::new(tokens);
let inner_expr = parser.parse_expr().map_err(|e| ParseError {
message: format!("Parse error in interpolation: {}", e.message),
span,
})?;
// Wrap the expression in toString() call
let to_string_call = Expr::Call {
func: Box::new(Expr::Var(Ident::new("toString".to_string(), span))),
args: vec![inner_expr],
span,
};
exprs.push(to_string_call);
}
}
}
// Chain all expressions with + operator
if exprs.is_empty() {
return Ok(Expr::Literal(Literal {
kind: LiteralKind::String(String::new()),
span,
}));
}
let mut result = exprs.remove(0);
for expr in exprs {
result = Expr::BinaryOp {
op: BinaryOp::Add,
left: Box::new(result),
right: Box::new(expr),
span,
};
}
Ok(result)
}
// Helper methods
fn parse_ident(&mut self) -> Result<Ident, ParseError> {