feat: add triple-quoted multiline string literals (issue 12)
Support """...""" syntax for multiline strings with:
- Automatic indent stripping (based on minimum indentation)
- Leading newline after opening """ is skipped
- Trailing whitespace-only line before closing """ is stripped
- String interpolation ({expr}) support
- All escape sequences supported
- Formatter outputs multiline strings for strings containing newlines
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -733,7 +733,30 @@ impl Formatter {
|
|||||||
match &lit.kind {
|
match &lit.kind {
|
||||||
LiteralKind::Int(n) => n.to_string(),
|
LiteralKind::Int(n) => n.to_string(),
|
||||||
LiteralKind::Float(f) => format!("{}", f),
|
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::Char(c) => format!("'{}'", c),
|
||||||
LiteralKind::Bool(b) => b.to_string(),
|
LiteralKind::Bool(b) => b.to_string(),
|
||||||
LiteralKind::Unit => "()".to_string(),
|
LiteralKind::Unit => "()".to_string(),
|
||||||
|
|||||||
226
src/lexer.rs
226
src/lexer.rs
@@ -411,7 +411,26 @@ impl<'a> Lexer<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// String literals
|
// 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
|
// Char literals
|
||||||
'\'' => self.scan_char(start)?,
|
'\'' => self.scan_char(start)?,
|
||||||
@@ -669,6 +688,211 @@ impl<'a> Lexer<'a> {
|
|||||||
Ok(TokenKind::InterpolatedString(parts))
|
Ok(TokenKind::InterpolatedString(parts))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scan_multiline_string(&mut self, _start: usize) -> Result<TokenKind, LexError> {
|
||||||
|
let mut parts: Vec<StringPart> = 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::<Vec<_>>()
|
||||||
|
.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<TokenKind, LexError> {
|
fn scan_char(&mut self, start: usize) -> Result<TokenKind, LexError> {
|
||||||
let c = match self.advance() {
|
let c = match self.advance() {
|
||||||
Some('\\') => match self.advance() {
|
Some('\\') => match self.advance() {
|
||||||
|
|||||||
43
src/main.rs
43
src/main.rs
@@ -3925,6 +3925,49 @@ c")"#;
|
|||||||
assert_eq!(eval(source).unwrap(), r#""literal {braces}""#);
|
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
|
// Option tests
|
||||||
#[test]
|
#[test]
|
||||||
fn test_option_constructors() {
|
fn test_option_constructors() {
|
||||||
|
|||||||
Reference in New Issue
Block a user