diff --git a/src/interpreter/interpreter.rs b/src/interpreter/interpreter.rs index 5d791a0..0ca5e20 100644 --- a/src/interpreter/interpreter.rs +++ b/src/interpreter/interpreter.rs @@ -750,6 +750,30 @@ mod tests { } } + #[test] + fn eval_single_quoted_string() { + match eval_expr("'hello'") { + Value::String(s) => assert_eq!(s, "hello"), + v => panic!("Expected String, got {:?}", v), + } + } + + #[test] + fn eval_mixed_quotes_concat() { + match eval_expr("\"a\" + 'b'") { + Value::String(s) => assert_eq!(s, "ab"), + v => panic!("Expected String, got {:?}", v), + } + } + + #[test] + fn eval_single_quote_containing_double() { + match eval_expr("'he\"llo'") { + Value::String(s) => assert_eq!(s, "he\"llo"), + v => panic!("Expected String, got {:?}", v), + } + } + #[test] fn eval_string_plus_number() { match eval_expr("\"a\" + 42") { diff --git a/src/lexer/lexer.rs b/src/lexer/lexer.rs index f6a4f86..c9a8a6d 100644 --- a/src/lexer/lexer.rs +++ b/src/lexer/lexer.rs @@ -157,7 +157,7 @@ impl Lexer { } } - '"' => return self.string_literal(line, column, errors), + '"' | '\'' => return self.string_literal(line, column, errors, c), c if c.is_ascii_digit() => { return Some(self.number_literal(c, line, column)); @@ -230,10 +230,11 @@ impl Lexer { line: usize, column: usize, errors: &mut Vec, + quote: char, ) -> Option { let mut value = String::new(); - while !self.is_at_end() && self.peek() != '"' { + while !self.is_at_end() && self.peek() != quote { let c = self.advance(); if c == '\\' { // Handle escape sequences @@ -246,6 +247,7 @@ impl Lexer { 't' => value.push('\t'), 'r' => value.push('\r'), '"' => value.push('"'), + '\'' => value.push('\''), '\\' => value.push('\\'), other => { errors.push(RuntimeError::lex( @@ -254,11 +256,11 @@ impl Lexer { column, )); // Consume the rest of the string to avoid cascading errors - while !self.is_at_end() && self.peek() != '"' { + while !self.is_at_end() && self.peek() != quote { self.advance(); } if !self.is_at_end() { - self.advance(); // consume closing " + self.advance(); // consume closing quote } return None; } @@ -273,7 +275,7 @@ impl Lexer { return None; } - self.advance(); // consume closing " + self.advance(); // consume closing quote Some(Token { kind: TokenKind::String(value), @@ -522,6 +524,60 @@ mod tests { assert!(errors[0].to_string().contains("Unknown escape")); } + // ----- single-quoted strings ----- + + #[test] + fn single_quoted_string() { + let kinds = tokenize("'hello'"); + assert_eq!(kinds, vec![TokenKind::String("hello".into())]); + } + + #[test] + fn single_quoted_empty_string() { + let kinds = tokenize("''"); + assert_eq!(kinds, vec![TokenKind::String("".into())]); + } + + #[test] + fn single_quoted_with_escape() { + let kinds = tokenize("'a\\'b'"); + assert_eq!(kinds, vec![TokenKind::String("a'b".into())]); + } + + #[test] + fn single_quoted_with_newline_escape() { + let kinds = tokenize("'a\\nb'"); + assert_eq!(kinds, vec![TokenKind::String("a\nb".into())]); + } + + #[test] + fn mixed_quote_strings() { + let kinds = tokenize("\"double\" 'single'"); + assert_eq!(kinds, vec![ + TokenKind::String("double".into()), + TokenKind::String("single".into()), + ]); + } + + #[test] + fn single_quote_can_contain_double_quote() { + let kinds = tokenize("'he\"llo'"); + assert_eq!(kinds, vec![TokenKind::String("he\"llo".into())]); + } + + #[test] + fn double_quote_can_contain_single_quote() { + let kinds = tokenize("\"it's\""); + assert_eq!(kinds, vec![TokenKind::String("it's".into())]); + } + + #[test] + fn unterminated_single_quoted_string() { + let (_, errors) = tokenize_full("'unterminated"); + assert_eq!(errors.len(), 1); + assert!(errors[0].to_string().contains("Unterminated")); + } + // ----- identifiers and keywords ----- #[test]