metrale_grammar/support/
escape.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//
3// Grammar-literal escape handling — port of the escape half of
4// `cpp/support/encoding.h` (`EscapeString` / `ParseNextEscaped` /
5// `ParseNextUTF8OrEscaped`).
6//
7// `print_as_escaped` turns a codepoint (or string) into a printable
8// escaped form; `parse_next_escaped` reverses a single `\…` sequence;
9// `parse_next_utf8_or_escaped` dispatches between a raw UTF-8 char and
10// an escape sequence.
11
12use super::encoding::{
13    TCodepoint, char_handling_error, char_to_utf8, hex_char_to_int, parse_next_utf8, parse_utf8,
14};
15use std::collections::HashMap;
16
17/// C-style escape table: codepoint -> escaped text. Matches the C++
18/// `kCodepointToEscape` static map.
19fn codepoint_to_escape(cp: TCodepoint) -> Option<&'static str> {
20    Some(match cp {
21        0x27 => "\\'",  // '
22        0x22 => "\\\"", // "
23        0x3F => "\\?",  // ?
24        0x5C => "\\\\", // backslash
25        0x07 => "\\a",
26        0x08 => "\\b",
27        0x0C => "\\f",
28        0x0A => "\\n",
29        0x0D => "\\r",
30        0x09 => "\\t",
31        0x0B => "\\v",
32        0x00 => "\\0",
33        0x1B => "\\e",
34        _ => return None,
35    })
36}
37
38/// Reverse of [`codepoint_to_escape`]: the escape letter following a
39/// backslash -> codepoint. Matches the C++ `kEscapeToCodepoint` map.
40fn escape_to_codepoint(c: u8) -> Option<TCodepoint> {
41    Some(match c {
42        b'\'' => 0x27,
43        b'"' => 0x22,
44        b'?' => 0x3F,
45        b'\\' => 0x5C,
46        // `\/` -> `/`: keeps the EBNF lexer in sync with JSON's relaxed
47        // escape rule (upstream commit 5d65108, #626).
48        b'/' => 0x2F,
49        b'a' => 0x07,
50        b'b' => 0x08,
51        b'f' => 0x0C,
52        b'n' => 0x0A,
53        b'r' => 0x0D,
54        b't' => 0x09,
55        b'v' => 0x0B,
56        b'0' => 0x00,
57        b'e' => 0x1B,
58        _ => return None,
59    })
60}
61
62/// Render a single codepoint as a printable, escaped string.
63///
64/// `additional_escape_map` is checked first (e.g. `{'-': "\\-"}`),
65/// then the C-escape table, then a printable-ASCII passthrough for
66/// `0x20..=0x7E`; anything else becomes a `\xHH` / `\uHHHH` / `\UHHHHHHHH`
67/// hex escape sized by magnitude. Faithful to C++ `EscapeString`.
68pub fn print_as_escaped(
69    codepoint: TCodepoint,
70    additional_escape_map: &HashMap<TCodepoint, String>,
71) -> String {
72    if let Some(s) = additional_escape_map.get(&codepoint) {
73        return s.clone();
74    }
75    if let Some(s) = codepoint_to_escape(codepoint) {
76        return s.to_string();
77    }
78    if (0x20..=0x7E).contains(&codepoint) {
79        return (codepoint as u8 as char).to_string();
80    }
81    // Hex escape, width and prefix selected by magnitude.
82    let (prefix, width) = if codepoint <= 0xFF {
83        ('x', 2)
84    } else if codepoint <= 0xFFFF {
85        ('u', 4)
86    } else {
87        ('U', 8)
88    };
89    format!("\\{}{:0width$x}", prefix, codepoint, width = width)
90}
91
92/// Render a single raw byte as a printable escaped string (the
93/// `EscapeString(uint8_t)` overload).
94pub fn print_byte_as_escaped(raw_char: u8) -> String {
95    print_as_escaped(raw_char as TCodepoint, &HashMap::new())
96}
97
98/// Render an entire byte string as a printable escaped string (the
99/// `EscapeString(std::string)` overload). Invalid UTF-8 bytes are
100/// preserved and escaped individually.
101pub fn print_str_as_escaped(raw: &[u8]) -> String {
102    let mut res = String::new();
103    for cp in parse_utf8(raw, true) {
104        res.push_str(&print_as_escaped(cp, &HashMap::new()));
105    }
106    res
107}
108
109/// Parse one escape sequence at the start of `data` (which must begin
110/// with `\`).
111///
112/// Returns `(codepoint, bytes_consumed)`. Supports the C-escape table,
113/// `additional_escape_map` (checked first), arbitrary-length `\xHH…`,
114/// fixed `\uHHHH`, and fixed `\UHHHHHHHH`. On any malformed input
115/// returns `(INVALID_ESCAPE, 0)` — faithful to C++ `ParseNextEscaped`.
116pub fn parse_next_escaped(
117    data: &[u8],
118    additional_escape_map: &HashMap<u8, TCodepoint>,
119) -> (TCodepoint, i32) {
120    if data.is_empty() || data[0] != b'\\' {
121        return (char_handling_error::INVALID_ESCAPE, 0);
122    }
123    // The C++ reads data[1] unconditionally; on a bare trailing '\'
124    // the C string's NUL terminator is read. We treat a missing
125    // second byte as invalid.
126    if data.len() < 2 {
127        return (char_handling_error::INVALID_ESCAPE, 0);
128    }
129    let esc = data[1];
130    // C++ rejects escape chars whose value exceeds 128.
131    if (esc as i32) > 128 {
132        return (char_handling_error::INVALID_ESCAPE, 0);
133    }
134    if let Some(&cp) = additional_escape_map.get(&esc) {
135        return (cp, 2);
136    }
137    if let Some(cp) = escape_to_codepoint(esc) {
138        return (cp, 2);
139    }
140    match esc {
141        b'x' => {
142            // Arbitrary-length hex.
143            let mut len = 0usize;
144            let mut codepoint: TCodepoint = 0;
145            while 2 + len < data.len() {
146                let digit = hex_char_to_int(data[2 + len]);
147                if digit == -1 {
148                    break;
149                }
150                codepoint = codepoint * 16 + digit;
151                len += 1;
152            }
153            if len == 0 {
154                return (char_handling_error::INVALID_ESCAPE, 0);
155            }
156            (codepoint, (len + 2) as i32)
157        }
158        b'u' | b'U' => {
159            let len = if esc == b'u' { 4 } else { 8 };
160            let mut codepoint: TCodepoint = 0;
161            for i in 0..len {
162                if 2 + i >= data.len() {
163                    return (char_handling_error::INVALID_ESCAPE, 0);
164                }
165                let digit = hex_char_to_int(data[2 + i]);
166                if digit == -1 {
167                    return (char_handling_error::INVALID_ESCAPE, 0);
168                }
169                codepoint = codepoint * 16 + digit;
170            }
171            (codepoint, (len + 2) as i32)
172        }
173        _ => (char_handling_error::INVALID_ESCAPE, 0),
174    }
175}
176
177/// Parse the first codepoint from `utf8`, transparently decoding an
178/// escape sequence when the input starts with `\`.
179///
180/// Returns `(codepoint, bytes_consumed)`. On invalid input returns
181/// `(INVALID_UTF8, 0)` or `(INVALID_ESCAPE, 0)` per the failure mode —
182/// faithful to C++ `ParseNextUTF8OrEscaped`.
183pub fn parse_next_utf8_or_escaped(
184    utf8: &[u8],
185    additional_escape_map: &HashMap<u8, TCodepoint>,
186) -> (TCodepoint, i32) {
187    if utf8.is_empty() || utf8[0] != b'\\' {
188        return parse_next_utf8(utf8);
189    }
190    parse_next_escaped(utf8, additional_escape_map)
191}
192
193/// Decode an escaped grammar-literal byte string into its UTF-8 form.
194///
195/// Convenience helper layered on [`parse_next_utf8_or_escaped`]:
196/// walks the whole input, decoding each char or escape and re-encoding
197/// the resulting codepoints to UTF-8. Returns `Err` with the offending
198/// error sentinel on the first malformed char/escape.
199pub fn unescape_string(
200    data: &[u8],
201    additional_escape_map: &HashMap<u8, TCodepoint>,
202) -> Result<Vec<u8>, TCodepoint> {
203    let mut out = Vec::new();
204    let mut pos = 0;
205    while pos < data.len() {
206        let (cp, n) = parse_next_utf8_or_escaped(&data[pos..], additional_escape_map);
207        if n == 0 {
208            return Err(cp);
209        }
210        out.extend_from_slice(&char_to_utf8(cp));
211        pos += n as usize;
212    }
213    Ok(out)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::char_handling_error::*;
219    use super::*;
220
221    fn empty_cp_map() -> HashMap<TCodepoint, String> {
222        HashMap::new()
223    }
224    fn empty_esc_map() -> HashMap<u8, TCodepoint> {
225        HashMap::new()
226    }
227
228    #[test]
229    fn escape_c_specials() {
230        assert_eq!(print_as_escaped(0x0A, &empty_cp_map()), "\\n");
231        assert_eq!(print_as_escaped(0x09, &empty_cp_map()), "\\t");
232        assert_eq!(print_as_escaped(0x0D, &empty_cp_map()), "\\r");
233        assert_eq!(print_as_escaped(0x5C, &empty_cp_map()), "\\\\");
234        assert_eq!(print_as_escaped(0x00, &empty_cp_map()), "\\0");
235        assert_eq!(print_as_escaped(0x1B, &empty_cp_map()), "\\e");
236    }
237
238    #[test]
239    fn escape_printable_passthrough() {
240        assert_eq!(print_as_escaped(0x41, &empty_cp_map()), "A");
241        assert_eq!(print_as_escaped(0x20, &empty_cp_map()), " ");
242        assert_eq!(print_as_escaped(0x7E, &empty_cp_map()), "~");
243    }
244
245    #[test]
246    fn escape_hex_forms() {
247        // Non-printable low byte -> \xHH
248        assert_eq!(print_as_escaped(0x01, &empty_cp_map()), "\\x01");
249        assert_eq!(print_as_escaped(0xFF, &empty_cp_map()), "\\xff");
250        // BMP -> \uHHHH
251        assert_eq!(print_as_escaped(0x2603, &empty_cp_map()), "\\u2603");
252        // Astral -> \UHHHHHHHH
253        assert_eq!(print_as_escaped(0x1F600, &empty_cp_map()), "\\U0001f600");
254    }
255
256    #[test]
257    fn escape_additional_map_wins() {
258        let mut m = empty_cp_map();
259        m.insert('-' as TCodepoint, "\\-".to_string());
260        assert_eq!(print_as_escaped('-' as TCodepoint, &m), "\\-");
261    }
262
263    #[test]
264    fn escape_byte_overload() {
265        assert_eq!(print_byte_as_escaped(b'A'), "A");
266        assert_eq!(print_byte_as_escaped(0x0A), "\\n");
267        assert_eq!(print_byte_as_escaped(0xFF), "\\xff");
268    }
269
270    #[test]
271    fn escape_whole_string() {
272        assert_eq!(print_str_as_escaped(b"a\nb"), "a\\nb");
273        // Invalid byte preserved + hex-escaped.
274        assert_eq!(print_str_as_escaped(&[0x41, 0x80]), "A\\x80");
275    }
276
277    #[test]
278    fn parse_c_escapes() {
279        assert_eq!(parse_next_escaped(b"\\n", &empty_esc_map()), (0x0A, 2));
280        assert_eq!(parse_next_escaped(b"\\t", &empty_esc_map()), (0x09, 2));
281        assert_eq!(parse_next_escaped(b"\\r", &empty_esc_map()), (0x0D, 2));
282        assert_eq!(parse_next_escaped(b"\\\\", &empty_esc_map()), (0x5C, 2));
283        assert_eq!(parse_next_escaped(b"\\b", &empty_esc_map()), (0x08, 2));
284        assert_eq!(parse_next_escaped(b"\\f", &empty_esc_map()), (0x0C, 2));
285        assert_eq!(parse_next_escaped(b"\\\"", &empty_esc_map()), (0x22, 2));
286        // `\/` -> `/`: JSON's relaxed escape, accepted by the lexer
287        // (upstream commit 5d65108, #626).
288        assert_eq!(parse_next_escaped(b"\\/", &empty_esc_map()), (0x2F, 2));
289    }
290
291    #[test]
292    fn parse_hex_x_escape() {
293        assert_eq!(parse_next_escaped(b"\\x41", &empty_esc_map()), (0x41, 4));
294        // Arbitrary length.
295        assert_eq!(
296            parse_next_escaped(b"\\x1F600", &empty_esc_map()),
297            (0x1F600, 7)
298        );
299        // Stops at first non-hex char.
300        assert_eq!(parse_next_escaped(b"\\xABg", &empty_esc_map()), (0xAB, 4));
301    }
302
303    #[test]
304    fn parse_unicode_escapes() {
305        assert_eq!(parse_next_escaped(b"\\u00A9", &empty_esc_map()), (0xA9, 6));
306        assert_eq!(
307            parse_next_escaped(b"\\u2603", &empty_esc_map()),
308            (0x2603, 6)
309        );
310        assert_eq!(
311            parse_next_escaped(b"\\U0001F600", &empty_esc_map()),
312            (0x1F600, 10)
313        );
314    }
315
316    #[test]
317    fn parse_invalid_escapes() {
318        // Not a backslash.
319        assert_eq!(
320            parse_next_escaped(b"n", &empty_esc_map()),
321            (INVALID_ESCAPE, 0)
322        );
323        // Unknown escape letter.
324        assert_eq!(
325            parse_next_escaped(b"\\z", &empty_esc_map()),
326            (INVALID_ESCAPE, 0)
327        );
328        // Bare backslash.
329        assert_eq!(
330            parse_next_escaped(b"\\", &empty_esc_map()),
331            (INVALID_ESCAPE, 0)
332        );
333        // \x with no hex digits.
334        assert_eq!(
335            parse_next_escaped(b"\\xg", &empty_esc_map()),
336            (INVALID_ESCAPE, 0)
337        );
338        // \u with too few digits.
339        assert_eq!(
340            parse_next_escaped(b"\\u12", &empty_esc_map()),
341            (INVALID_ESCAPE, 0)
342        );
343        // \u with a non-hex digit.
344        assert_eq!(
345            parse_next_escaped(b"\\u12zz", &empty_esc_map()),
346            (INVALID_ESCAPE, 0)
347        );
348    }
349
350    #[test]
351    fn parse_additional_escape_map() {
352        let mut m = empty_esc_map();
353        m.insert(b'-', '-' as TCodepoint);
354        assert_eq!(parse_next_escaped(b"\\-", &m), ('-' as TCodepoint, 2));
355    }
356
357    #[test]
358    fn utf8_or_escaped_dispatch() {
359        // Raw UTF-8 char.
360        assert_eq!(
361            parse_next_utf8_or_escaped(b"A", &empty_esc_map()),
362            (0x41, 1)
363        );
364        // Escape sequence.
365        assert_eq!(
366            parse_next_utf8_or_escaped(b"\\n", &empty_esc_map()),
367            (0x0A, 2)
368        );
369        // Invalid escape.
370        assert_eq!(
371            parse_next_utf8_or_escaped(b"\\z", &empty_esc_map()),
372            (INVALID_ESCAPE, 0)
373        );
374        // Invalid UTF-8.
375        assert_eq!(
376            parse_next_utf8_or_escaped(&[0x80], &empty_esc_map()),
377            (INVALID_UTF8, 0)
378        );
379    }
380
381    #[test]
382    fn unescape_full_string() {
383        // "escaped \"quotes\"" style.
384        let out = unescape_string(b"escaped \\\"quotes\\\"", &empty_esc_map()).unwrap();
385        assert_eq!(out, b"escaped \"quotes\"");
386        // Mixed \n\r\t\\
387        let out = unescape_string(b"\\n\\r\\t\\\\", &empty_esc_map()).unwrap();
388        assert_eq!(out, b"\n\r\t\\");
389    }
390
391    #[test]
392    fn unescape_unicode_to_utf8() {
393        let out = unescape_string(b"\\u00A9 \\u2603 \\U0001F600", &empty_esc_map()).unwrap();
394        assert_eq!(out, "© ☃ 😀".as_bytes());
395    }
396
397    #[test]
398    fn unescape_propagates_error() {
399        assert_eq!(
400            unescape_string(b"\\z", &empty_esc_map()),
401            Err(INVALID_ESCAPE)
402        );
403        assert_eq!(
404            unescape_string(&[0x80], &empty_esc_map()),
405            Err(INVALID_UTF8)
406        );
407    }
408
409    #[test]
410    fn escape_unescape_round_trip() {
411        for cp in [0x0A_i32, 0x41, 0xA9, 0x2603, 0x1F600, 0x00, 0x1B] {
412            let escaped = print_as_escaped(cp, &empty_cp_map());
413            let (back, _) = parse_next_utf8_or_escaped(escaped.as_bytes(), &empty_esc_map());
414            assert_eq!(back, cp, "round trip failed for {cp:#x}");
415        }
416    }
417}