Skip to content

Commit f3c89c9

Browse files
{"schema":"decodex/commit/1","summary":"add lossy unescape API","authority":"manual"}
1 parent 6697e6c commit f3c89c9

3 files changed

Lines changed: 197 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ fn main() {
2020
assert_eq!(unescaper::unescape(r"\u{a}").unwrap(), "\n");
2121
assert_eq!(unescaper::unescape(r"\x0a").unwrap(), "\n");
2222
assert_eq!(unescaper::unescape(r"\12").unwrap(), "\n");
23+
assert_eq!(unescaper::unescape_lossy(r"\q\n"), "\\q\n");
2324
}
2425
```
2526

src/lib.rs

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ pub enum Error {
2525
}
2626
use Error::*;
2727

28+
enum LossyEscape {
29+
Escaped { char: char, len: usize },
30+
Literal(usize),
31+
}
32+
2833
/// Unescaper struct which holding the chars cache for unescaping.
2934
#[derive(Debug)]
3035
pub struct Unescaper {
@@ -167,3 +172,179 @@ impl Unescaper {
167172
pub fn unescape(s: &str) -> Result<String> {
168173
Unescaper::new(s).unescape()
169174
}
175+
176+
/// Unescape the given [`str`], preserving malformed escape sequences as written.
177+
///
178+
/// This is useful for user-provided text where valid escape sequences should be interpreted, but
179+
/// malformed escape sequences should not make the whole string fail.
180+
pub fn unescape_lossy(s: &str) -> String {
181+
let mut unescaped = String::new();
182+
let mut cursor = 0;
183+
184+
while cursor < s.len() {
185+
let remaining = &s[cursor..];
186+
let c = remaining.chars().next().expect("cursor is in bounds; qed");
187+
188+
if c != '\\' {
189+
unescaped.push(c);
190+
cursor += c.len_utf8();
191+
192+
continue;
193+
}
194+
195+
match parse_lossy_escape(remaining) {
196+
LossyEscape::Escaped { char, len } => {
197+
unescaped.push(char);
198+
cursor += len;
199+
},
200+
LossyEscape::Literal(len) => {
201+
unescaped.push_str(&remaining[..len]);
202+
cursor += len;
203+
},
204+
}
205+
}
206+
207+
unescaped
208+
}
209+
210+
fn parse_lossy_escape(s: &str) -> LossyEscape {
211+
let mut chars = s.char_indices();
212+
let Some((_, '\\')) = chars.next() else {
213+
unreachable!("lossy escape parser is only called at escapes")
214+
};
215+
let Some((marker_pos, marker)) = chars.next() else {
216+
return LossyEscape::Literal('\\'.len_utf8());
217+
};
218+
219+
match marker {
220+
'b' => LossyEscape::Escaped { char: '\u{0008}', len: marker_pos + marker.len_utf8() },
221+
'f' => LossyEscape::Escaped { char: '\u{000c}', len: marker_pos + marker.len_utf8() },
222+
'n' => LossyEscape::Escaped { char: '\n', len: marker_pos + marker.len_utf8() },
223+
'r' => LossyEscape::Escaped { char: '\r', len: marker_pos + marker.len_utf8() },
224+
't' => LossyEscape::Escaped { char: '\t', len: marker_pos + marker.len_utf8() },
225+
'v' => LossyEscape::Escaped { char: '\u{000b}', len: marker_pos + marker.len_utf8() },
226+
'\'' | '\"' | '\\' | '/' =>
227+
LossyEscape::Escaped { char: marker, len: marker_pos + marker.len_utf8() },
228+
'u' => parse_lossy_unicode_escape(s, marker_pos + marker.len_utf8()),
229+
'x' => parse_lossy_byte_escape(s, marker_pos + marker.len_utf8()),
230+
'0'..='7' => parse_lossy_octal_escape(s, marker_pos, marker),
231+
_ => LossyEscape::Literal(marker_pos + marker.len_utf8()),
232+
}
233+
}
234+
235+
fn parse_lossy_unicode_escape(s: &str, after_marker: usize) -> LossyEscape {
236+
if s[after_marker..].starts_with('{') {
237+
let digits_start = after_marker + '{'.len_utf8();
238+
239+
if let Some(closing_offset) = s[digits_start..].find('}') {
240+
let closing = digits_start + closing_offset;
241+
let digits = &s[digits_start..closing];
242+
let len = closing + '}'.len_utf8();
243+
244+
return u32::from_str_radix(digits, 16)
245+
.ok()
246+
.and_then(char::from_u32)
247+
.map_or(LossyEscape::Literal(len), |char| LossyEscape::Escaped { char, len });
248+
}
249+
250+
let len = incomplete_braced_unicode_escape_len(s, digits_start);
251+
252+
if len == s.len() {
253+
let digits = &s[digits_start..];
254+
255+
return u32::from_str_radix(digits, 16)
256+
.ok()
257+
.and_then(char::from_u32)
258+
.map_or(LossyEscape::Literal(len), |char| LossyEscape::Escaped { char, len });
259+
}
260+
261+
return LossyEscape::Literal(len);
262+
}
263+
264+
let Some((digits, len)) = fixed_width_escape_digits(&s[after_marker..], 4) else {
265+
return LossyEscape::Literal(incomplete_fixed_width_escape_len(s, after_marker, 4));
266+
};
267+
268+
u32::from_str_radix(digits, 16).ok().and_then(char::from_u32).map_or(
269+
LossyEscape::Literal(after_marker + len),
270+
|char| LossyEscape::Escaped { char, len: after_marker + len },
271+
)
272+
}
273+
274+
fn parse_lossy_byte_escape(s: &str, after_marker: usize) -> LossyEscape {
275+
let Some((digits, len)) = fixed_width_escape_digits(&s[after_marker..], 2) else {
276+
return LossyEscape::Literal(incomplete_fixed_width_escape_len(s, after_marker, 2));
277+
};
278+
279+
u8::from_str_radix(digits, 16).map_or(LossyEscape::Literal(after_marker + len), |byte| {
280+
LossyEscape::Escaped { char: byte as _, len: after_marker + len }
281+
})
282+
}
283+
284+
fn fixed_width_escape_digits(s: &str, width: usize) -> Option<(&str, usize)> {
285+
let mut count = 0;
286+
287+
for (pos, c) in s.char_indices() {
288+
if c == '\\' {
289+
break;
290+
}
291+
292+
count += 1;
293+
294+
if count == width {
295+
let end = pos + c.len_utf8();
296+
297+
return Some((&s[..end], end));
298+
}
299+
}
300+
301+
None
302+
}
303+
304+
fn incomplete_fixed_width_escape_len(s: &str, after_marker: usize, width: usize) -> usize {
305+
let mut end = after_marker;
306+
307+
for (count, (pos, c)) in s[after_marker..].char_indices().enumerate() {
308+
if c == '\\' || count == width {
309+
break;
310+
}
311+
312+
end = after_marker + pos + c.len_utf8();
313+
}
314+
315+
end
316+
}
317+
318+
fn incomplete_braced_unicode_escape_len(s: &str, digits_start: usize) -> usize {
319+
let mut end = digits_start;
320+
321+
for (pos, c) in s[digits_start..].char_indices() {
322+
if c == '\\' {
323+
break;
324+
}
325+
326+
end = digits_start + pos + c.len_utf8();
327+
}
328+
329+
end
330+
}
331+
332+
fn parse_lossy_octal_escape(s: &str, marker_pos: usize, marker: char) -> LossyEscape {
333+
let max_extra_digits = if matches!(marker, '0'..='3') { 2 } else { 1 };
334+
let mut octal = String::from(marker);
335+
let mut len = marker_pos + marker.len_utf8();
336+
let mut end = len;
337+
338+
for (pos, c) in s[len..].char_indices().take(max_extra_digits) {
339+
if !c.is_digit(8) {
340+
break;
341+
}
342+
343+
octal.push(c);
344+
end = len + pos + c.len_utf8();
345+
}
346+
len = end;
347+
348+
u8::from_str_radix(&octal, 8)
349+
.map_or(LossyEscape::Literal(len), |byte| LossyEscape::Escaped { char: byte as _, len })
350+
}

src/test.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,18 @@ fn unescape_special_symbols() {
9292
unescape_assert_eq!(r"\\", "\\");
9393
unescape_assert_eq!(r"//", "//");
9494
}
95+
96+
#[test]
97+
fn unescape_lossy() {
98+
assert_eq!(crate::unescape_lossy(r"a\nb"), "a\nb");
99+
assert_eq!(crate::unescape_lossy(r"a\qb\nc"), "a\\qb\nc");
100+
assert_eq!(crate::unescape_lossy(r"\u{g}\n"), "\\u{g}\n");
101+
assert_eq!(crate::unescape_lossy(r"\u{110000}\n"), "\\u{110000}\n");
102+
assert_eq!(crate::unescape_lossy(r"\u{a"), "\n");
103+
assert_eq!(crate::unescape_lossy(r"\u{1F600"), "😀");
104+
assert_eq!(crate::unescape_lossy(r"\u{12\n"), "\\u{12\n");
105+
assert_eq!(crate::unescape_lossy(r"\u12\n"), "\\u12\n");
106+
assert_eq!(crate::unescape_lossy(r"\x0a\xzz"), "\n\\xzz");
107+
assert_eq!(crate::unescape_lossy(r"abc\"), "abc\\");
108+
assert_eq!(crate::unescape_lossy(r"\377"), "\u{00ff}");
109+
}

0 commit comments

Comments
 (0)