Skip to main content

serpent_serializer/
quote.rs

1//! Lua string-literal escaping.
2//!
3//! Reproduces Lua 5.1 `string.format("%q", s)` plus serpent's two post-passes.
4//! The result is a double-quoted Lua string literal that `load` reads back to
5//! the exact input bytes, including NUL and other control characters. This must
6//! be byte-exact for round trips, and it runs over binary data such as dumped
7//! bytecode.
8
9/// Quote a byte string as a Lua string literal.
10///
11/// Per-byte rules from Lua 5.1 `%q`:
12/// - `"` becomes `\"`
13/// - `\` becomes `\\`
14/// - newline (10) becomes backslash then a real newline
15/// - carriage return (13) becomes `\r`
16/// - NUL (0) becomes `\0`, or `\000` when the next byte is a digit
17/// - any byte at or above 128 becomes a three-digit decimal escape `\ddd`
18/// - any other byte is copied verbatim
19///
20/// Then serpent's two passes run over the result: every raw newline byte (10)
21/// becomes the letter `n`, and every EOF byte (26) becomes the four characters
22/// `\026`. Net effect: newline turns into the two-character `\n`, and byte 26
23/// turns into `\026`.
24///
25/// The `\000` and `\ddd` forms keep the output binary-safe. A bare `\0` followed
26/// by an ASCII digit would parse as a single decimal escape and lose bytes, so
27/// NUL before a digit uses the padded form. High bytes escape to decimal so the
28/// output stays valid UTF-8 and still round-trips through [`crate::load`], which
29/// reads up to three decimal digits per escape. Bytecode dumps carry high bytes,
30/// so this path runs on ordinary input.
31#[must_use]
32pub fn quote(bytes: &[u8]) -> String {
33    let mut out = String::with_capacity(bytes.len() + 2);
34    out.push('"');
35    for (i, &c) in bytes.iter().enumerate() {
36        match c {
37            b'"' => out.push_str("\\\""),
38            b'\\' => out.push_str("\\\\"),
39            // %q emits backslash then a real newline; serpent's first gsub then
40            // rewrites the newline byte to `n`, giving `\n`.
41            10 => out.push_str("\\n"),
42            13 => out.push_str("\\r"),
43            0 => {
44                let next_is_digit = bytes.get(i + 1).is_some_and(u8::is_ascii_digit);
45                out.push_str(if next_is_digit { "\\000" } else { "\\0" });
46            }
47            // serpent's second gsub maps byte 26 to the four chars `\026`.
48            26 => out.push_str("\\026"),
49            128..=255 => out.push_str(&format!("\\{c:03}")),
50            _ => out.push(c as char),
51        }
52    }
53    out.push('"');
54    out
55}