diff --git a/examples/interpolation.lux b/examples/interpolation.lux new file mode 100644 index 0000000..4dea155 --- /dev/null +++ b/examples/interpolation.lux @@ -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 {} diff --git a/src/interpreter.rs b/src/interpreter.rs index 0d26cab..28adbda 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -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 => { diff --git a/src/lexer.rs b/src/lexer.rs index 48952b2..0a7b8e4 100644 --- a/src/lexer.rs +++ b/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), 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 { - let mut value = String::new(); + let mut parts: Vec = 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 { @@ -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!( diff --git a/src/main.rs b/src/main.rs index 2e2f702..8766300 100644 --- a/src/main.rs +++ b/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() { diff --git a/src/parser.rs b/src/parser.rs index c924886..cfbe373 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -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 { + let mut exprs: Vec = 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 {