Skip to main content

bash_strings/
emit.rs

1//! Bash-value emitters — single canonical form (single-quoted, with
2//! `$'…'` fallback for non-printable bytes). One emission primitive
3//! ([`emit_scalar`]) underlies every typed emitter, and one
4//! [`literal`] puts the parentheses on.
5
6use indexmap::IndexMap;
7
8pub fn emit_scalar(s: &str) -> String {
9    if s.is_empty() {
10        return "''".into();
11    }
12    if needs_ansi_c(s) {
13        return ansi_c(s);
14    }
15    let mut out = String::with_capacity(s.len() + 2);
16    out.push('\'');
17    for c in s.chars() {
18        if c == '\'' {
19            out.push_str("'\\''");
20        } else {
21            out.push(c);
22        }
23    }
24    out.push('\'');
25    out
26}
27
28pub fn emit_q_words(words: &[String]) -> String {
29    words
30        .iter()
31        .map(|word| emit_scalar(word))
32        .collect::<Vec<_>>()
33        .join(" ")
34}
35
36/// One bash array literal: `["a", "b c"]` → `('a' 'b c')`. The shape a
37/// message, an answer and every captured column travel as, and the inverse of
38/// [`parse_array`](super::parse_array).
39pub fn emit_array(words: &[String]) -> String {
40    literal(words.iter().map(|word| emit_scalar(word)))
41}
42
43pub fn emit_indexed(m: &IndexMap<usize, String>) -> String {
44    literal(
45        m.iter()
46            .map(|(key, value)| format!("[{key}]={}", emit_scalar(value))),
47    )
48}
49
50pub fn emit_assoc(m: &IndexMap<String, String>) -> String {
51    literal(m.iter().map(|(key, value)| {
52        format!(
53            "[{}]={}",
54            emit_scalar(key),
55            emit_scalar(value)
56        )
57    }))
58}
59
60fn literal(pairs: impl IntoIterator<Item = String>) -> String {
61    format!(
62        "({})",
63        pairs.into_iter().collect::<Vec<_>>().join(" ")
64    )
65}
66
67fn needs_ansi_c(s: &str) -> bool {
68    s.bytes().any(|b| b < 0x20 || b == 0x7f)
69}
70
71fn ansi_c(s: &str) -> String {
72    let mut out = String::with_capacity(s.len() + 4);
73    out.push_str("$'");
74    for c in s.chars() {
75        match c {
76            '\'' => out.push_str("\\'"),
77            '\\' => out.push_str("\\\\"),
78            '\n' => out.push_str("\\n"),
79            '\r' => out.push_str("\\r"),
80            '\t' => out.push_str("\\t"),
81            '\x07' => out.push_str("\\a"),
82            '\x08' => out.push_str("\\b"),
83            '\x1B' => out.push_str("\\E"),
84            '\x0C' => out.push_str("\\f"),
85            '\x0B' => out.push_str("\\v"),
86            c if (c as u32) < 0x20 || c == '\x7f' => {
87                out.push_str(&format!("\\{:03o}", c as u32));
88            }
89            c => out.push(c),
90        }
91    }
92    out.push('\'');
93    out
94}
95
96#[cfg(test)]
97mod tests {
98    use super::super::parser::{parse_assoc, parse_indexed, parse_q_words, parse_scalar};
99    use super::*;
100
101    fn ix<I: IntoIterator<Item = (usize, &'static str)>>(it: I) -> IndexMap<usize, String> {
102        it.into_iter().map(|(k, v)| (k, v.to_string())).collect()
103    }
104    fn ax<I: IntoIterator<Item = (&'static str, &'static str)>>(it: I) -> IndexMap<String, String> {
105        it.into_iter()
106            .map(|(k, v)| (k.to_string(), v.to_string()))
107            .collect()
108    }
109
110    #[test]
111    fn scalar_forms() {
112        assert_eq!(emit_scalar(""), "''");
113        assert_eq!(emit_scalar("hello"), "'hello'");
114        assert_eq!(emit_scalar("it's"), "'it'\\''s'");
115        assert_eq!(emit_scalar("a\nb"), "$'a\\nb'");
116        assert_eq!(emit_scalar("a\tb"), "$'a\\tb'");
117    }
118
119    #[test]
120    fn q_words_emit() {
121        assert_eq!(emit_q_words(&[]), "");
122        assert_eq!(
123            emit_q_words(&["a".into(), "b c".into()]),
124            "'a' 'b c'"
125        );
126    }
127
128    #[test]
129    fn indexed_emit() {
130        assert_eq!(emit_indexed(&ix([])), "()");
131        assert_eq!(
132            emit_indexed(&ix([(0, "a"), (5, "b c")])),
133            "([0]='a' [5]='b c')"
134        );
135    }
136
137    #[test]
138    fn assoc_emit() {
139        assert_eq!(emit_assoc(&ax([])), "()");
140        assert_eq!(
141            emit_assoc(&ax([("k", "v"), ("k 2", "v 2")])),
142            "(['k']='v' ['k 2']='v 2')"
143        );
144    }
145
146    #[test]
147    fn roundtrip_scalar() {
148        for s in ["", "simple", "it's", "a\nb", "a\tb", "c\x01d", "café"] {
149            assert_eq!(
150                parse_scalar(&emit_scalar(s)).unwrap(),
151                s
152            );
153        }
154    }
155
156    #[test]
157    fn roundtrip_q_words() {
158        let v = vec![
159            "a".to_string(),
160            "b c".to_string(),
161            "d\ne".to_string(),
162            "".to_string(),
163        ];
164        assert_eq!(
165            parse_q_words(&emit_q_words(&v)).unwrap(),
166            v
167        );
168    }
169
170    #[test]
171    fn roundtrip_indexed() {
172        let m = ix([(0, "a"), (2, "d\ne"), (5, "b c")]);
173        assert_eq!(
174            parse_indexed(&emit_indexed(&m)).unwrap(),
175            m
176        );
177    }
178
179    #[test]
180    fn roundtrip_assoc() {
181        let m = ax([("foo", "1"), ("k 2", "v\n2")]);
182        assert_eq!(parse_assoc(&emit_assoc(&m)).unwrap(), m);
183    }
184}