use std::fmt::Write;
pub(crate) fn write_json_string(out: &mut String, s: &str) {
out.push('"');
let mut plain = 0;
for (i, b) in s.bytes().enumerate() {
let escape = match b {
b'"' => "\\\"",
b'\\' => "\\\\",
b'\n' => "\\n",
b'\r' => "\\r",
b'\t' => "\\t",
0x08 => "\\b",
0x0C => "\\f",
0x0B => "\\u000B",
0x00..=0x1F | 0x7F => "\\uFFFD",
_ => continue,
};
out.push_str(&s[plain..i]);
out.push_str(escape);
plain = i + 1;
}
out.push_str(&s[plain..]);
out.push('"');
}
pub fn key_needs_quoting(key: &str) -> bool {
key.bytes().any(|b| {
matches!(
b,
b' ' | b'\t'
| b'\n'
| b'\r'
| 0x0C
| 0x08
| 0
| b'"'
| b'+'
| b':'
| b'='
| b'['
| b'\\'
| b'{'
)
})
}
pub(crate) fn write_key(out: &mut String, key: &str, quoted: bool) {
if quoted {
write_json_string(out, key);
} else {
out.push_str(key);
}
}
pub(crate) fn write_config_string(out: &mut String, s: &str, single_quoted: bool, multiline: bool) {
let has_lf = s.contains('\n');
if has_lf && (s.len() > 80 || multiline) && !has_eod_line(s) {
out.push_str("<<EOD\n");
out.push_str(s);
out.push_str("\nEOD");
} else if single_quoted && !s.contains("\\'") {
out.push('\'');
let mut plain = 0;
for (i, b) in s.bytes().enumerate() {
if b == b'\'' {
out.push_str(&s[plain..i]);
out.push_str("\\'");
plain = i + 1;
}
}
out.push_str(&s[plain..]);
out.push('\'');
} else {
write_json_string(out, s);
}
}
pub(crate) fn write_escaped_string(out: &mut String, s: &str) {
out.push('"');
let mut plain = 0;
for (i, b) in s.bytes().enumerate() {
let escape = match b {
b'"' => "\\\"",
b'\\' => "\\\\",
b'\n' => "\\n",
b'\r' => "\\r",
b'\t' => "\\t",
0x08 => "\\b",
0x0C => "\\f",
0x00..=0x1F | 0x7F => {
out.push_str(&s[plain..i]);
let _ = write!(out, "\\u{b:04X}");
plain = i + 1;
continue;
}
_ => continue,
};
out.push_str(&s[plain..i]);
out.push_str(escape);
plain = i + 1;
}
out.push_str(&s[plain..]);
out.push('"');
}
const FILE_VARIABLES: [&str; 2] = ["FILENAME", "CURDIR"];
pub(crate) fn refers_to_file_variable(s: &str) -> bool {
FILE_VARIABLES.iter().any(|name| {
s.match_indices('$').any(|(i, _)| {
let rest = &s[i + 1..];
rest.starts_with(name)
|| rest
.strip_prefix('{')
.and_then(|r| r.strip_prefix(name))
.is_some_and(|r| r.starts_with('}'))
})
})
}
pub(crate) fn write_exact_double_quoted(out: &mut String, s: &str) -> Result<(), String> {
if refers_to_file_variable(s) {
return Err(format!(
"the string {s:?} in double quotes: it refers to FILENAME or CURDIR, which readers \
define by default and which double quotes expand when read (spec §7.2, §7.8, \
§10.8); the config format writes it in single quotes"
));
}
write_escaped_string(out, s);
Ok(())
}
pub(crate) fn is_bare_key(key: &str) -> bool {
let bytes = key.as_bytes();
let bare = |b: u8| b.is_ascii_alphanumeric() || b == b'/' || b == b'_' || b >= 0x80;
match bytes.split_first() {
Some((&first, rest)) => {
bare(first) && rest.iter().all(|&b| bare(b) || b == b'-' || b == b'.')
}
None => false,
}
}
pub(crate) fn write_exact_config_string(out: &mut String, s: &str) -> Result<(), String> {
if !s.contains('$') {
write_escaped_string(out, s);
return Ok(());
}
if !single_quotes_hold(s) {
return Err(format!(
"the string {s:?} in the config format: it contains `$`, which double quotes would \
expand when read, and a backslash before `'`, a line break or the end, which single \
quotes cannot hold (spec §6.2, §10.8); the JSON and YAML formats write it in double \
quotes"
));
}
out.push('\'');
let mut plain = 0;
for (i, b) in s.bytes().enumerate() {
if b == b'\'' {
out.push_str(&s[plain..i]);
out.push_str("\\'");
plain = i + 1;
}
}
out.push_str(&s[plain..]);
out.push('\'');
Ok(())
}
pub(crate) fn single_quotes_hold(s: &str) -> bool {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
match bytes.get(i + 1) {
None | Some(b'\'' | b'\n' | b'\r') => return false,
Some(_) => i += 2,
}
} else {
i += 1;
}
}
true
}
fn has_eod_line(s: &str) -> bool {
s.match_indices("\nEOD")
.any(|(i, _)| matches!(s.as_bytes().get(i + 4), None | Some(b'\n')))
}
#[cfg(test)]
mod tests {
use super::*;
fn json(s: &str) -> String {
let mut out = String::new();
write_json_string(&mut out, s);
out
}
fn config(s: &str, single_quoted: bool, multiline: bool) -> String {
let mut out = String::new();
write_config_string(&mut out, s, single_quoted, multiline);
out
}
#[test]
fn json_form() {
assert_eq!(json("a\"b\\c/é"), r#""a\"b\\c/é""#);
assert_eq!(json("\n\r\t\x08\x0c"), r#""\n\r\t\b\f""#);
assert_eq!(json("\x0b\0\x01\x7f"), r#""\u000B\uFFFD\uFFFD\uFFFD""#);
}
#[test]
fn config_forms() {
assert_eq!(config("a\nb", false, true), "<<EOD\na\nb\nEOD");
assert_eq!(config("a\nb", false, false), "\"a\\nb\"");
assert_eq!(config("single", false, true), "\"single\"");
let long = format!("{}\nend", "y".repeat(77));
assert_eq!(long.len(), 81);
assert!(config(&long, false, false).starts_with("<<EOD\n"));
let boundary = format!("{}\nend", "x".repeat(76));
assert!(config(&boundary, false, false).starts_with('"'));
assert!(config("x\nEOD\ny", false, true).starts_with('"'));
assert!(config("x\nEOD", false, true).starts_with('"'));
assert!(config("x\nEODx", false, true).starts_with("<<EOD"));
assert!(config("EOD\nx", false, true).starts_with("<<EOD\nEOD\n"));
assert_eq!(config("it's", true, false), r"'it\'s'");
assert_eq!(config("a\\\\'b", true, false), r#""a\\\\'b""#);
assert_eq!(config("x\ty", true, false), "'x\ty'");
}
#[test]
fn exact_forms() {
let escaped = |s: &str| {
let mut out = String::new();
write_escaped_string(&mut out, s);
out
};
assert_eq!(escaped("a\"b\\c/é"), r#""a\"b\\c/é""#);
assert_eq!(
escaped("\n\r\t\x08\x0c\x0b\0\x01\x1f\x7f"),
r#""\n\r\t\b\f\u000B\u0000\u0001\u001F\u007F""#
);
let config = |s: &str| {
let mut out = String::new();
write_exact_config_string(&mut out, s).map(|()| out)
};
assert_eq!(config("plain").unwrap(), r#""plain""#);
assert_eq!(config("$ABI it's").unwrap(), r"'$ABI it\'s'");
assert_eq!(config("$x\\y\n").unwrap(), "'$x\\y\n'");
assert_eq!(config("$x\\\\").unwrap(), r"'$x\\'");
assert_eq!(config("$x\\\\'").unwrap(), r"'$x\\\''");
for s in ["$x\\", "$x\\'", "$x\\\n", "$x\\\r", "$x\\\\\\"] {
assert!(config(s).is_err(), "{s:?}");
}
for s in [
"$CURDIR",
"x$CURDIRy",
"${FILENAME}",
"$$FILENAME",
"a\n${CURDIR}",
] {
assert!(refers_to_file_variable(s), "{s:?}");
}
for s in [
"$CURDI",
"${CURDIR",
"${FILENAMEx}",
"$ABI",
"CURDIR",
"$ {CURDIR}",
"$",
] {
assert!(!refers_to_file_variable(s), "{s:?}");
}
for k in ["a", "A1", "1", "_a", "/a", "é", "a-b.c/d", "a_"] {
assert!(is_bare_key(k), "{k}");
}
for k in [
"", "-a", ".a", "a b", "a;b", "a}", "a#", "a,b", "$A", "a=b", "a:b", "a\"", "'a",
] {
assert!(!is_bare_key(k), "{k}");
}
}
#[test]
fn keys() {
for k in [
"k y", "a\nb", "x+y", "p:q", "a=b", "a[b", "a{b", "a\"b", "a\\b",
] {
assert!(key_needs_quoting(k), "{k}");
}
for k in ["kq", "a.b", "s/t", "a;b", "a}b", "a#b", "a,b", "$ABI", ""] {
assert!(!key_needs_quoting(k), "{k}");
}
}
}