diff --git a/src/formatter.rs b/src/formatter.rs index 42e734d..dbace69 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -733,7 +733,30 @@ impl Formatter { match &lit.kind { LiteralKind::Int(n) => n.to_string(), LiteralKind::Float(f) => format!("{}", f), - LiteralKind::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"").replace('{', "\\{").replace('}', "\\}")), + LiteralKind::String(s) => { + if s.contains('\n') { + // Use triple-quoted multiline string + let tab = " ".repeat(self.config.indent_size); + let base_indent = tab.repeat(self.indent_level); + let content_indent = tab.repeat(self.indent_level + 1); + let lines: Vec<&str> = s.split('\n').collect(); + let mut result = String::from("\"\"\"\n"); + for line in &lines { + if line.is_empty() { + result.push('\n'); + } else { + result.push_str(&content_indent); + result.push_str(&line.replace('{', "\\{").replace('}', "\\}")); + result.push('\n'); + } + } + result.push_str(&base_indent); + result.push_str("\"\"\""); + result + } else { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"").replace('{', "\\{").replace('}', "\\}")) + } + }, LiteralKind::Char(c) => format!("'{}'", c), LiteralKind::Bool(b) => b.to_string(), LiteralKind::Unit => "()".to_string(), diff --git a/src/lexer.rs b/src/lexer.rs index cf93325..b6fd015 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -411,7 +411,26 @@ impl<'a> Lexer<'a> { } // String literals - '"' => self.scan_string(start)?, + '"' => { + // Check for triple-quote multiline string """ + if self.peek() == Some('"') { + // Clone to peek at the second char + let mut lookahead = self.chars.clone(); + lookahead.next(); // consume first peeked " + if lookahead.peek() == Some(&'"') { + // It's a triple-quote: consume both remaining quotes + self.advance(); // second " + self.advance(); // third " + self.scan_multiline_string(start)? + } else { + // It's an empty string "" + self.advance(); // consume closing " + TokenKind::String(String::new()) + } + } else { + self.scan_string(start)? + } + } // Char literals '\'' => self.scan_char(start)?, @@ -669,6 +688,211 @@ impl<'a> Lexer<'a> { Ok(TokenKind::InterpolatedString(parts)) } + fn scan_multiline_string(&mut self, _start: usize) -> Result { + let mut parts: Vec = Vec::new(); + let mut current_literal = String::new(); + + // Skip the first newline after opening """ if present + if self.peek() == Some('\n') { + self.advance(); + } else if self.peek() == Some('\r') { + self.advance(); + if self.peek() == Some('\n') { + self.advance(); + } + } + + loop { + match self.advance() { + Some('"') => { + // Check for closing """ + if self.peek() == Some('"') { + let mut lookahead = self.chars.clone(); + lookahead.next(); // consume first peeked " + if lookahead.peek() == Some(&'"') { + // Closing """ found + self.advance(); // second " + self.advance(); // third " + break; + } + } + // Not closing triple-quote, just a regular " in the string + current_literal.push('"'); + } + Some('\\') => { + // Handle escape sequences (same as regular strings) + match self.peek() { + Some('{') => { + self.advance(); + current_literal.push('{'); + } + Some('}') => { + self.advance(); + current_literal.push('}'); + } + _ => { + let escape_start = self.pos; + let escaped = match self.advance() { + Some('n') => '\n', + Some('r') => '\r', + Some('t') => '\t', + Some('\\') => '\\', + Some('"') => '"', + Some('0') => '\0', + Some('\'') => '\'', + Some(c) => { + return Err(LexError { + message: format!("Invalid escape sequence: \\{}", c), + span: Span::new(escape_start - 1, self.pos), + }); + } + None => { + return Err(LexError { + message: "Unterminated multiline string".into(), + span: Span::new(_start, self.pos), + }); + } + }; + current_literal.push(escaped); + } + } + } + Some('{') => { + // Interpolation (same as regular strings) + if !current_literal.is_empty() { + parts.push(StringPart::Literal(std::mem::take(&mut current_literal))); + } + + 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 multiline 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 multiline string".into(), + span: Span::new(_start, self.pos), + }); + } + } + } + + // Strip common leading whitespace from all lines + let strip_indent = |s: &str| -> String { + if s.is_empty() { + return String::new(); + } + let lines: Vec<&str> = s.split('\n').collect(); + // Find minimum indentation of non-empty lines + let min_indent = lines + .iter() + .filter(|line| !line.trim().is_empty()) + .map(|line| line.len() - line.trim_start().len()) + .min() + .unwrap_or(0); + // Strip that indentation from each line + lines + .iter() + .map(|line| { + if line.len() >= min_indent { + &line[min_indent..] + } else { + line.trim_start() + } + }) + .collect::>() + .join("\n") + }; + + // Strip trailing whitespace-only line before closing """ + let trim_trailing = |s: &mut String| { + // Remove trailing spaces/tabs (indent before closing """) + while s.ends_with(' ') || s.ends_with('\t') { + s.pop(); + } + // Remove the trailing newline + if s.ends_with('\n') { + s.pop(); + if s.ends_with('\r') { + s.pop(); + } + } + }; + + if parts.is_empty() { + trim_trailing(&mut current_literal); + let result = strip_indent(¤t_literal); + return Ok(TokenKind::String(result)); + } + + // Add remaining literal + if !current_literal.is_empty() { + trim_trailing(&mut current_literal); + parts.push(StringPart::Literal(current_literal)); + } + + // For interpolated multiline strings, strip indent from literal parts + // First, collect all literal content to find min indent + let mut all_text = String::new(); + for part in &parts { + if let StringPart::Literal(lit) = part { + all_text.push_str(lit); + } + } + let lines: Vec<&str> = all_text.split('\n').collect(); + let min_indent = lines + .iter() + .filter(|line| !line.trim().is_empty()) + .map(|line| line.len() - line.trim_start().len()) + .min() + .unwrap_or(0); + + if min_indent > 0 { + for part in &mut parts { + if let StringPart::Literal(lit) = part { + let stripped_lines: Vec<&str> = lit + .split('\n') + .map(|line| { + if line.len() >= min_indent { + &line[min_indent..] + } else { + line.trim_start() + } + }) + .collect(); + *lit = stripped_lines.join("\n"); + } + } + } + + Ok(TokenKind::InterpolatedString(parts)) + } + fn scan_char(&mut self, start: usize) -> Result { let c = match self.advance() { Some('\\') => match self.advance() { diff --git a/src/main.rs b/src/main.rs index b2fb592..67128bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3925,6 +3925,49 @@ c")"#; assert_eq!(eval(source).unwrap(), r#""literal {braces}""#); } + #[test] + fn test_multiline_string() { + let source = r#" + let s = """ + hello + world + """ + let result = String.length(s) + "#; + // "hello\nworld" = 11 chars + assert_eq!(eval(source).unwrap(), "11"); + } + + #[test] + fn test_multiline_string_with_quotes() { + // Quotes are fine in the middle of triple-quoted strings + let source = "let s = \"\"\"\n She said \"hello\" to him.\n\"\"\""; + assert_eq!(eval(source).unwrap(), r#""She said "hello" to him.""#); + } + + #[test] + fn test_multiline_string_interpolation() { + let source = r#" + let name = "Lux" + let s = """ + Hello, {name}! + """ + "#; + assert_eq!(eval(source).unwrap(), r#""Hello, Lux!""#); + } + + #[test] + fn test_multiline_string_empty() { + let source = r#"let s = """""""#; + assert_eq!(eval(source).unwrap(), r#""""#); + } + + #[test] + fn test_multiline_string_inline() { + let source = r#"let s = """hello world""""#; + assert_eq!(eval(source).unwrap(), r#""hello world""#); + } + // Option tests #[test] fn test_option_constructors() {