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:
@@ -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 => {
|
||||
|
||||
171
src/lexer.rs
171
src/lexer.rs
@@ -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!(
|
||||
|
||||
35
src/main.rs
35
src/main.rs
@@ -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() {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user