use tsv_lang::Error;
use tsv_lang::swar::next_byte_of;
pub(crate) fn decode_string_escapes_into(s: &str, out: &mut String) -> Result<(), Error> {
out.clear();
out.reserve(s.len());
let result = out;
let mut chars = EscapeCursor { rest: s };
loop {
let run = next_byte_of(chars.rest.as_bytes(), 0, [b'\\']);
let (plain, rest) = chars.rest.split_at(run);
result.push_str(plain);
let backslash = s.len() - rest.len();
let Some(escape) = rest.strip_prefix('\\') else {
break;
};
chars.rest = escape;
match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('r') => result.push('\r'),
Some('b') => result.push('\u{0008}'), Some('f') => result.push('\u{000C}'), Some('v') => result.push('\u{000B}'), Some('\\') => result.push('\\'),
Some('\'') => result.push('\''),
Some('"') => result.push('"'),
Some('0') if !matches!(chars.peek(), Some('0'..='9')) => {
result.push('\0');
}
Some('x') => {
let code =
read_hex_value(&mut chars, 2).map_err(|e| e.shift_position(backslash))?;
#[expect(clippy::cast_possible_truncation, reason = "two hex digits fit a byte")]
let byte = code as u8;
result.push(char::from(byte));
}
Some('u') => {
let code = read_unicode_escape_value(&mut chars)
.map_err(|e| e.shift_position(backslash))?;
if (0xD800..=0xDBFF).contains(&code)
&& let Some(trail) = take_trail_surrogate(&mut chars)
{
let paired = 0x10000 + (code - 0xD800) * 0x400 + (trail - 0xDC00);
debug_assert!(char::from_u32(paired).is_some());
result.push(char::from_u32(paired).unwrap_or(char::REPLACEMENT_CHARACTER));
} else {
result.push(char::from_u32(code).unwrap_or(char::REPLACEMENT_CHARACTER));
}
}
Some('\n' | '\u{2028}' | '\u{2029}') => {}
Some('\r') => {
if chars.peek() == Some('\n') {
chars.next();
}
}
Some(ch @ '0'..='7') => {
let mut code = ch as u32 - '0' as u32;
let max_extra = if ch <= '3' { 2 } else { 1 };
for _ in 0..max_extra {
match chars.peek() {
Some(next_ch @ '0'..='7') => {
chars.next();
code = code * 8 + (next_ch as u32 - '0' as u32);
}
_ => break,
}
}
if let Some(ch) = char::from_u32(code) {
result.push(ch);
}
}
Some(ch) => {
result.push(ch);
}
None => {
result.push('\\');
}
}
}
Ok(())
}
pub(crate) fn decode_string_escapes(s: &str) -> Result<String, Error> {
let mut out = String::new();
decode_string_escapes_into(s, &mut out)?;
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LegacyEscape {
LegacyOctal,
NonOctalDecimal,
}
pub(crate) fn find_legacy_escape(raw: &str) -> Option<(usize, LegacyEscape)> {
let bytes = raw.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'\\' {
i += 1;
continue;
}
match bytes.get(i + 1) {
Some(b'0') if bytes.get(i + 2).is_some_and(u8::is_ascii_digit) => {
return Some((i, LegacyEscape::LegacyOctal));
}
Some(b'8' | b'9') => return Some((i, LegacyEscape::NonOctalDecimal)),
Some(b'1'..=b'7') => return Some((i, LegacyEscape::LegacyOctal)),
_ => {}
}
i += 2;
}
None
}
#[derive(Clone, Copy)]
struct EscapeCursor<'a> {
rest: &'a str,
}
impl EscapeCursor<'_> {
#[inline]
fn peek(&self) -> Option<char> {
self.rest.chars().next()
}
#[inline]
fn next(&mut self) -> Option<char> {
let mut chars = self.rest.chars();
let ch = chars.next()?;
self.rest = chars.as_str();
Some(ch)
}
}
fn read_unicode_escape_value(chars: &mut EscapeCursor<'_>) -> Result<u32, Error> {
if chars.peek() != Some('{') {
return read_hex_value(chars, 4);
}
chars.next();
let mut code: u64 = 0;
let mut any_digit = false;
loop {
match chars.peek() {
Some('}') => {
chars.next(); break;
}
Some(ch) => match ch.to_digit(16) {
Some(d) => {
chars.next();
if code <= 0x10FFFF {
code = code * 16 + u64::from(d);
}
any_digit = true;
}
None => {
return Err(Error::invalid_syntax(
"Invalid unicode codepoint escape".to_string(),
0,
));
}
},
None => {
return Err(Error::invalid_syntax(
"Unterminated unicode codepoint escape".to_string(),
0,
));
}
}
}
if !any_digit {
return Err(Error::invalid_syntax(
"Invalid unicode codepoint escape length".to_string(),
0,
));
}
if code > 0x10FFFF {
return Err(Error::invalid_syntax(
format!("Invalid unicode codepoint: U+{code:X}"),
0,
));
}
Ok(code as u32)
}
fn take_trail_surrogate(chars: &mut EscapeCursor<'_>) -> Option<u32> {
if chars.peek() != Some('\\') {
return None;
}
let saved = *chars;
chars.next(); if chars.peek() == Some('u') {
chars.next(); if let Ok(trail) = read_unicode_escape_value(chars)
&& (0xDC00..=0xDFFF).contains(&trail)
{
return Some(trail);
}
}
*chars = saved;
None
}
fn read_hex_value(chars: &mut EscapeCursor<'_>, count: usize) -> Result<u32, Error> {
let mut value: u32 = 0;
for _ in 0..count {
match chars.next() {
Some(ch) => match ch.to_digit(16) {
Some(d) => value = value * 16 + d,
None => {
return Err(Error::invalid_syntax(
format!("Expected hex digit, found '{ch}'"),
0,
));
}
},
None => {
return Err(Error::invalid_syntax(
"Unexpected end of string in escape sequence".to_string(),
0,
));
}
}
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_escapes() {
assert_eq!(decode_string_escapes("test\\n").unwrap(), "test\n");
assert_eq!(decode_string_escapes("test\\t\\r").unwrap(), "test\t\r");
assert_eq!(
decode_string_escapes("test\\\\slash").unwrap(),
"test\\slash"
);
assert_eq!(decode_string_escapes("test\\'quote").unwrap(), "test'quote");
assert_eq!(
decode_string_escapes("test\\\"quote").unwrap(),
"test\"quote"
);
}
#[test]
fn test_null() {
assert_eq!(decode_string_escapes("\\0").unwrap(), "\0");
assert_eq!(decode_string_escapes("test\\0end").unwrap(), "test\0end");
}
#[test]
fn test_hex_escapes() {
assert_eq!(decode_string_escapes("\\x41\\x42").unwrap(), "AB");
assert_eq!(
decode_string_escapes("test\\x20space").unwrap(),
"test space"
);
}
#[test]
fn test_unicode_4digit() {
assert_eq!(decode_string_escapes("\\u0041").unwrap(), "A");
assert_eq!(decode_string_escapes("\\u00A9").unwrap(), "©");
}
#[test]
fn test_unicode_codepoint() {
assert_eq!(decode_string_escapes("\\u{41}").unwrap(), "A");
assert_eq!(decode_string_escapes("\\u{1F4A9}").unwrap(), "💩");
}
#[test]
fn test_surrogate_pairs() {
assert_eq!(decode_string_escapes("\\uD83D\\uDCA9").unwrap(), "💩");
}
#[test]
fn test_invalid_escape() {
assert_eq!(decode_string_escapes("\\z").unwrap(), "z");
assert_eq!(decode_string_escapes("test\\qend").unwrap(), "testqend");
}
#[test]
fn malformed_escape_errors_point_at_their_backslash() {
for (input, backslash) in [
("ab\\u{zz}", 2),
("\u{e9}\\x4", 2),
("\\n\\u12", 2),
("x\\u{12", 1),
("\\u{110000}", 0),
("\\u{}", 0),
("\\uD83D\\u{zz}", 6),
] {
let error = decode_string_escapes(input).expect_err("malformed");
assert_eq!(error.position(), Some(backslash), "{input:?}");
}
}
#[test]
fn test_line_continuation() {
assert_eq!(decode_string_escapes("test\\\nline").unwrap(), "testline");
}
#[test]
fn test_legacy_octal_escapes() {
assert_eq!(decode_string_escapes("\\101").unwrap(), "A"); assert_eq!(decode_string_escapes("\\141").unwrap(), "a"); assert_eq!(decode_string_escapes("\\012").unwrap(), "\n"); assert_eq!(decode_string_escapes("\\400").unwrap(), " 0");
assert_eq!(decode_string_escapes("\\412").unwrap(), "!2");
assert_eq!(decode_string_escapes("\\777").unwrap(), "?7");
assert_eq!(decode_string_escapes("\\377").unwrap(), "\u{ff}");
}
#[test]
fn test_find_legacy_escape() {
let octal = |i| Some((i, LegacyEscape::LegacyOctal));
let decimal = |i| Some((i, LegacyEscape::NonOctalDecimal));
assert_eq!(find_legacy_escape("\\7"), octal(0));
assert_eq!(find_legacy_escape("a\\101b"), octal(1));
assert_eq!(find_legacy_escape("\\00"), octal(0));
assert_eq!(find_legacy_escape("\\0"), None);
assert_eq!(find_legacy_escape("\\0x"), None);
assert_eq!(find_legacy_escape("\\08"), octal(0));
assert_eq!(find_legacy_escape("\\09"), octal(0));
assert_eq!(find_legacy_escape("\\8"), decimal(0));
assert_eq!(find_legacy_escape("\\9"), decimal(0));
assert_eq!(find_legacy_escape("\\\\7"), None);
assert_eq!(find_legacy_escape("\\\\\\7"), octal(2));
assert_eq!(find_legacy_escape("\\n\\t\\x41\\u0041"), None);
assert_eq!(find_legacy_escape("\\é7"), None);
assert_eq!(find_legacy_escape("no escapes at all"), None);
}
#[test]
fn test_crlf_and_unicode_line_continuations() {
assert_eq!(decode_string_escapes("a\\\r\nb").unwrap(), "ab");
assert_eq!(decode_string_escapes("a\\\u{2028}b").unwrap(), "ab");
assert_eq!(decode_string_escapes("a\\\u{2029}b").unwrap(), "ab");
}
#[test]
fn test_lone_high_surrogate_falls_back_to_replacement() {
assert_eq!(decode_string_escapes("\\uD83D").unwrap(), "\u{FFFD}");
assert_eq!(decode_string_escapes("\\uD83Dx").unwrap(), "\u{FFFD}x");
assert_eq!(
decode_string_escapes("\\uD83D\\u0041").unwrap(),
"\u{FFFD}A"
);
}
#[test]
fn test_codepoint_escape_length_and_range_errors() {
assert!(decode_string_escapes("\\u{}").is_err()); assert!(decode_string_escapes("\\u{1234567}").is_err());
assert!(decode_string_escapes("\\u{110000}").is_err()); }
#[test]
fn test_codepoint_escape_leading_zeros_unbounded() {
assert_eq!(decode_string_escapes("\\u{0041}").unwrap(), "A");
assert_eq!(decode_string_escapes("\\u{0000000000000041}").unwrap(), "A");
assert_eq!(
decode_string_escapes("\\u{00000010FFFF}").unwrap(),
"\u{10FFFF}"
);
let long = format!("\\u{{{}}}", "f".repeat(500));
assert!(decode_string_escapes(&long).is_err());
}
#[test]
fn test_surrogate_pair_joins_in_every_spelling() {
for pair in [
"\\uD83D\\uDE00",
"\\u{D83D}\\u{DE00}",
"\\uD83D\\u{DE00}",
"\\u{D83D}\\uDE00",
] {
assert_eq!(decode_string_escapes(pair).unwrap(), "\u{1F600}", "{pair}");
}
assert_eq!(
decode_string_escapes("\\u{0000D83D}\\u{0DE00}").unwrap(),
"\u{1F600}"
);
}
#[test]
fn test_unpaired_surrogate_halves_do_not_join() {
assert_eq!(
decode_string_escapes("\\u{D83D}\\u{D83D}").unwrap(),
"\u{FFFD}\u{FFFD}"
);
assert_eq!(
decode_string_escapes("\\u{DE00}\\u{D83D}").unwrap(),
"\u{FFFD}\u{FFFD}"
);
assert_eq!(
decode_string_escapes("\\uD83D\\u{41}").unwrap(),
"\u{FFFD}A"
);
assert_eq!(
decode_string_escapes("\\u{D83D}\\u0041").unwrap(),
"\u{FFFD}A"
);
assert_eq!(decode_string_escapes("\\u{D83D}x").unwrap(), "\u{FFFD}x");
}
#[test]
fn test_lead_surrogate_followed_by_malformed_escape_still_errors() {
assert!(decode_string_escapes("\\u{D83D}\\u{110000}").is_err());
assert!(decode_string_escapes("\\uD83D\\u{}").is_err());
assert!(decode_string_escapes("\\uD83D\\uZZZZ").is_err());
}
#[test]
fn test_braced_lone_surrogate_falls_back_to_replacement() {
assert_eq!(decode_string_escapes("\\u{D800}").unwrap(), "\u{FFFD}");
assert_eq!(decode_string_escapes("\\u{DFFF}").unwrap(), "\u{FFFD}");
assert_eq!(decode_string_escapes("\\u{0000D800}").unwrap(), "\u{FFFD}");
assert_eq!(decode_string_escapes("\\u{E000}").unwrap(), "\u{E000}");
}
fn model_decode_into(s: &str, out: &mut String) -> Result<(), Error> {
out.clear();
out.reserve(s.len());
let result = out;
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
let backslash = s.len() - chars.clone().map(char::len_utf8).sum::<usize>() - 1;
match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('r') => result.push('\r'),
Some('b') => result.push('\u{0008}'), Some('f') => result.push('\u{000C}'), Some('v') => result.push('\u{000B}'), Some('\\') => result.push('\\'),
Some('\'') => result.push('\''),
Some('"') => result.push('"'),
Some('0') if !matches!(chars.peek(), Some('0'..='9')) => {
result.push('\0');
}
Some('x') => {
let code = model_read_hex_value(&mut chars, 2)
.map_err(|e| e.shift_position(backslash))?;
#[expect(
clippy::cast_possible_truncation,
reason = "two hex digits fit a byte"
)]
let byte = code as u8;
result.push(char::from(byte));
}
Some('u') => {
let code = model_read_unicode_escape_value(&mut chars)
.map_err(|e| e.shift_position(backslash))?;
if (0xD800..=0xDBFF).contains(&code)
&& let Some(trail) = model_take_trail_surrogate(&mut chars)
{
let paired = 0x10000 + (code - 0xD800) * 0x400 + (trail - 0xDC00);
debug_assert!(char::from_u32(paired).is_some());
result.push(
char::from_u32(paired).unwrap_or(char::REPLACEMENT_CHARACTER),
);
} else {
result
.push(char::from_u32(code).unwrap_or(char::REPLACEMENT_CHARACTER));
}
}
Some('\n' | '\u{2028}' | '\u{2029}') => {}
Some('\r') => {
if chars.peek() == Some(&'\n') {
chars.next();
}
}
Some(ch @ '0'..='7') => {
let mut code = ch as u32 - '0' as u32;
let max_extra = if ch <= '3' { 2 } else { 1 };
for _ in 0..max_extra {
match chars.peek() {
Some(&next_ch @ '0'..='7') => {
chars.next();
code = code * 8 + (next_ch as u32 - '0' as u32);
}
_ => break,
}
}
if let Some(ch) = char::from_u32(code) {
result.push(ch);
}
}
Some(ch) => {
result.push(ch);
}
None => {
result.push('\\');
}
}
} else {
result.push(ch);
}
}
Ok(())
}
fn model_read_unicode_escape_value<I>(chars: &mut std::iter::Peekable<I>) -> Result<u32, Error>
where
I: Iterator<Item = char>,
{
if chars.peek() != Some(&'{') {
return model_read_hex_value(chars, 4);
}
chars.next();
let mut code: u64 = 0;
let mut any_digit = false;
loop {
match chars.peek() {
Some(&'}') => {
chars.next(); break;
}
Some(&ch) => match ch.to_digit(16) {
Some(d) => {
chars.next();
if code <= 0x10FFFF {
code = code * 16 + u64::from(d);
}
any_digit = true;
}
None => {
return Err(Error::invalid_syntax(
"Invalid unicode codepoint escape".to_string(),
0,
));
}
},
None => {
return Err(Error::invalid_syntax(
"Unterminated unicode codepoint escape".to_string(),
0,
));
}
}
}
if !any_digit {
return Err(Error::invalid_syntax(
"Invalid unicode codepoint escape length".to_string(),
0,
));
}
if code > 0x10FFFF {
return Err(Error::invalid_syntax(
format!("Invalid unicode codepoint: U+{code:X}"),
0,
));
}
Ok(code as u32)
}
fn model_take_trail_surrogate<I>(chars: &mut std::iter::Peekable<I>) -> Option<u32>
where
I: Iterator<Item = char> + Clone,
{
if chars.peek() != Some(&'\\') {
return None;
}
let saved = chars.clone();
chars.next(); if chars.peek() == Some(&'u') {
chars.next(); if let Ok(trail) = model_read_unicode_escape_value(chars)
&& (0xDC00..=0xDFFF).contains(&trail)
{
return Some(trail);
}
}
*chars = saved;
None
}
fn model_read_hex_value<I>(
chars: &mut std::iter::Peekable<I>,
count: usize,
) -> Result<u32, Error>
where
I: Iterator<Item = char>,
{
let mut value: u32 = 0;
for _ in 0..count {
match chars.next() {
Some(ch) => match ch.to_digit(16) {
Some(d) => value = value * 16 + d,
None => {
return Err(Error::invalid_syntax(
format!("Expected hex digit, found '{ch}'"),
0,
));
}
},
None => {
return Err(Error::invalid_syntax(
"Unexpected end of string in escape sequence".to_string(),
0,
));
}
}
}
Ok(value)
}
fn outcome(
decode: fn(&str, &mut String) -> Result<(), Error>,
input: &str,
) -> (Result<(), String>, String) {
let mut out = String::from("stale");
let verdict = decode(input, &mut out).map_err(|e| format!("{e} / {e:?}"));
(verdict, out)
}
fn assert_matches_model(input: &str) {
assert_eq!(
outcome(decode_string_escapes_into, input),
outcome(model_decode_into, input),
"decoder diverged from the model on {input:?}"
);
}
const FRAGMENTS: &[&str] = &[
"a",
"0",
"7",
"8",
"u",
"x",
"{",
"}",
"]",
"41",
"DE00",
"\u{e9}",
"\u{1F600}",
"\n",
"\r",
"\u{2028}",
"\\n",
"\\t",
"\\r",
"\\b",
"\\f",
"\\v",
"\\\\",
"\\'",
"\\\"",
"\\0",
"\\1",
"\\7",
"\\8",
"\\9",
"\\101",
"\\377",
"\\400",
"\\x41",
"\\xe9",
"\\x4",
"\\x",
"\\xZZ",
"\\x\u{e9}1",
"\\u0041",
"\\u00e9",
"\\uD83D",
"\\uDE00",
"\\u",
"\\u12",
"\\uZZZZ",
"\\u\u{e9}",
"\\u{41}",
"\\u{0000041}",
"\\u{D83D}",
"\\u{DE00}",
"\\u{1F600}",
"\\u{10FFFF}",
"\\u{110000}",
"\\u{}",
"\\u{41",
"\\u{4G}",
"\\u{\u{e9}}",
"\\z",
"\\\u{e9}",
"\\\u{1F600}",
"\\\n",
"\\\r",
"\\\r\n",
"\\\u{2028}",
"\\\u{2029}",
"\\",
];
fn fragments() -> Vec<String> {
let mut all: Vec<String> = FRAGMENTS.iter().map(|&f| f.to_owned()).collect();
all.push(char::from(0u8).to_string());
all.extend(char::from_u32(0x2029).map(String::from));
all
}
#[test]
fn decoder_matches_model_over_fragment_sequences() {
assert_matches_model("");
let fragments = fragments();
let mut input = String::new();
for a in &fragments {
assert_matches_model(a);
for b in &fragments {
input.clear();
input.push_str(a);
input.push_str(b);
assert_matches_model(&input);
let pair = input.len();
for c in &fragments {
input.truncate(pair);
input.push_str(c);
assert_matches_model(&input);
}
}
}
}
#[test]
fn decoder_matches_model_at_every_run_alignment() {
const ASCII: &str = "abcdefghijklmnopqrstuvwxyz";
const WIDE: &str = "a\u{e9}b\u{4e2d}c\u{1F600}d\u{2028}e\u{e9}\u{e9}f";
let fragments = fragments();
let mut input = String::new();
for filler in [ASCII, WIDE] {
let cuts: Vec<usize> = (0..=filler.len())
.filter(|&at| filler.is_char_boundary(at))
.collect();
for &lead in &cuts {
for &tail in &[0, 1, 7, 8, 9, cuts[cuts.len() / 2], filler.len()] {
if !filler.is_char_boundary(tail) {
continue;
}
for a in &fragments {
for b in ["", "\\uDE00", "\\u{DE00}", "\\n", "7"] {
input.clear();
input.push_str(&filler[..lead]);
input.push_str(a);
input.push_str(b);
input.push_str(&filler[..tail]);
assert_matches_model(&input);
}
}
}
}
}
}
}