Skip to main content

bash_strings/
parser.rs

1//! The forms bash prints a value in, and nothing else.
2//!
3//! Each is strict: it accepts what bash itself writes and refuses the rest.
4//! Where a word begins and ends is [`quoting`](super::quoting)'s; this is what
5//! surrounds one.
6//!
7//! | | |
8//! |---|---|
9//! | scalar | one word |
10//! | q_words | words separated by exactly one space |
11//! | array | `('a' 'b c')` — q_words in parentheses |
12//! | rows | `("'a' 'b'" "'c'")` — an array of arrays, one level of nesting |
13//! | indexed | `([0]=… [5]=…)` — subscripts are data, and sparse |
14//! | assoc | `([k]=… )` — the key is a word too |
15
16use indexmap::IndexMap;
17
18use super::error::ParseError;
19use super::quoting::{Cursor, parse_with};
20
21/// Where a value ends. `)` closes a compound, so it stops a word inside one.
22const VALUE_STOPS: &[char] = &[' ', '\t', '\n', ')'];
23
24/// Where a subscript ends, inside its brackets.
25const KEY_STOPS: &[char] = &[']'];
26
27pub fn parse_scalar(text: &str) -> Result<String, ParseError> {
28    parse_with(trimmed(text), |c| c.word(VALUE_STOPS))
29}
30
31pub fn parse_q_words(text: &str) -> Result<Vec<String>, ParseError> {
32    parse_with(trimmed(text), q_words)
33}
34
35/// One bash array literal as its words: `('a' 'b c')` → `["a", "b c"]`.
36///
37/// Codec-independent — at one dimension [`QuotedNest`](super::QuotedNest) and
38/// [`LinkedArr`](super::LinkedArr) write the same text, so there is nothing to
39/// choose. Deeper values go through [`BashCodec`](super::BashCodec), where the choice is real.
40pub fn parse_array(text: &str) -> Result<Vec<String>, ParseError> {
41    parse_q_words(inside(text)?)
42}
43
44pub fn parse_indexed(text: &str) -> Result<IndexMap<usize, String>, ParseError> {
45    parse_with(trimmed(text), indexed_compound)
46}
47
48pub fn parse_assoc(text: &str) -> Result<IndexMap<String, String>, ParseError> {
49    parse_with(trimmed(text), assoc_compound)
50}
51
52/// The body of a `(…)` array literal, which is the one shape that surrounds
53/// every other. Spelled here and nowhere else on the reading side;
54/// [`emit_array`](super::emit_array) is its inverse.
55pub(super) fn inside(text: &str) -> Result<&str, ParseError> {
56    let trimmed = text.trim();
57
58    trimmed
59        .strip_prefix('(')
60        .and_then(|rest| rest.strip_suffix(')'))
61        .ok_or_else(|| {
62            ParseError::new(
63                trimmed,
64                0,
65                "expected a (…) array literal",
66            )
67        })
68}
69
70/// Bash writes no trailing newline inside a value; one appended by a `$( )`
71/// or a file read is not part of it.
72fn trimmed(text: &str) -> &str {
73    text.trim_end_matches('\n')
74}
75
76/// Exactly one space between words: bash writes no more, so more is not its
77/// output.
78fn q_words(c: &mut Cursor<'_>) -> Result<Vec<String>, ParseError> {
79    let mut out = Vec::new();
80    if c.at_end() {
81        return Ok(out);
82    }
83
84    out.push(c.word(VALUE_STOPS)?);
85    while !c.at_end() {
86        c.lit(" ")?;
87        out.push(c.word(VALUE_STOPS)?);
88    }
89    Ok(out)
90}
91
92fn indexed_compound(c: &mut Cursor<'_>) -> Result<IndexMap<usize, String>, ParseError> {
93    c.lit("(")?;
94    c.ws0();
95
96    let mut out = IndexMap::new();
97    while !c.starts_with(")") {
98        let index = bracket_index(c)?;
99        c.lit("=")?;
100        out.insert(index, c.word(VALUE_STOPS)?);
101        c.ws0();
102    }
103    c.lit(")")?;
104
105    Ok(out)
106}
107
108fn assoc_compound(c: &mut Cursor<'_>) -> Result<IndexMap<String, String>, ParseError> {
109    c.lit("(")?;
110    c.ws0();
111
112    let mut out = IndexMap::new();
113    while !c.starts_with(")") {
114        c.lit("[")?;
115        let key = c.word(KEY_STOPS)?;
116        c.lit("]")?;
117        c.lit("=")?;
118        out.insert(key, c.word(VALUE_STOPS)?);
119        c.ws0();
120    }
121    c.lit(")")?;
122
123    Ok(out)
124}
125
126fn bracket_index(c: &mut Cursor<'_>) -> Result<usize, ParseError> {
127    c.lit("[")?;
128
129    let digits = c.take_while(|d| d.is_ascii_digit());
130    if digits.is_empty() {
131        return Err(c.fail("expected a subscript"));
132    }
133
134    // A bash subscript is a machine integer. One too wide to be one was not
135    // printed by bash, so it is rejected rather than wrapped or truncated.
136    let index = digits.parse().map_err(|_| {
137        c.fail(format!(
138            "subscript {digits:?} is not an index"
139        ))
140    })?;
141    c.lit("]")?;
142
143    Ok(index)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::super::codec::{BashCodec, QuotedNest};
149    use super::*;
150    use crate::{BashVal, LinkedArr, emit_array};
151
152    fn ix<I: IntoIterator<Item = (usize, &'static str)>>(it: I) -> IndexMap<usize, String> {
153        it.into_iter().map(|(k, v)| (k, v.to_string())).collect()
154    }
155    fn ax<I: IntoIterator<Item = (&'static str, &'static str)>>(it: I) -> IndexMap<String, String> {
156        it.into_iter()
157            .map(|(k, v)| (k.to_string(), v.to_string()))
158            .collect()
159    }
160
161    #[test]
162    fn scalar_canonical_forms() {
163        assert_eq!(
164            parse_scalar("'hello world'").unwrap(),
165            "hello world"
166        );
167        assert_eq!(
168            parse_scalar(r#""hello \$VAR""#).unwrap(),
169            "hello $VAR"
170        );
171        assert_eq!(
172            parse_scalar(r"$'a\nb'").unwrap(),
173            "a\nb"
174        );
175        assert_eq!(parse_scalar("''").unwrap(), "");
176    }
177
178    #[test]
179    fn scalar_concat() {
180        assert_eq!(parse_scalar("'a''b'").unwrap(), "ab");
181    }
182
183    #[test]
184    fn scalar_rejects_non_canonical() {
185        assert!(parse_scalar("").is_err());
186        assert!(parse_scalar("'a' 'b'").is_err());
187        assert!(parse_scalar(" 'a'").is_err());
188    }
189
190    #[test]
191    fn q_words_canonical() {
192        assert_eq!(
193            parse_q_words("'a' 'b'").unwrap(),
194            vec!["a", "b"]
195        );
196        assert_eq!(
197            parse_q_words("'a b' $'c\\nd'").unwrap(),
198            vec!["a b", "c\nd"]
199        );
200        assert_eq!(
201            parse_q_words("").unwrap(),
202            Vec::<String>::new()
203        );
204    }
205
206    #[test]
207    fn q_words_rejects_non_canonical_spacing() {
208        assert!(parse_q_words("'a'  'b'").is_err());
209        assert!(parse_q_words(" 'a' 'b'").is_err());
210        assert!(parse_q_words("'a' 'b' ").is_err());
211    }
212
213    #[test]
214    fn ansi_c_escapes() {
215        assert_eq!(
216            parse_q_words(r"$'\t\r\\\''").unwrap(),
217            vec!["\t\r\\'"]
218        );
219        assert_eq!(
220            parse_q_words(r"$'\x41' $'\101'").unwrap(),
221            vec!["A", "A"]
222        );
223    }
224
225    #[test]
226    fn array_round_trips_and_needs_its_parentheses() {
227        let words = vec!["a".to_string(), "b c".into(), "d\ne".into(), String::new()];
228
229        assert_eq!(
230            emit_array(&words),
231            "('a' 'b c' $'d\\ne' '')"
232        );
233        assert_eq!(
234            parse_array(&emit_array(&words)).unwrap(),
235            words
236        );
237        assert_eq!(
238            parse_array("()").unwrap(),
239            Vec::<String>::new()
240        );
241
242        let bare = parse_array("'a' 'b'").expect_err("no parentheses");
243        assert!(
244            bare.message.contains("array literal"),
245            "{bare}"
246        );
247    }
248
249    /// At one dimension the two codecs write the same text, which is what
250    /// lets `parse_array` take no codec.
251    #[test]
252    fn one_dimension_is_the_same_under_either_codec() {
253        let words = vec!["a".to_string(), "b c".into(), "2".into()];
254        let value = BashVal::row(words.clone());
255
256        assert_eq!(
257            QuotedNest.emit_literal(&value),
258            LinkedArr.emit_literal(&value)
259        );
260        assert_eq!(
261            QuotedNest.emit_literal(&value),
262            emit_array(&words)
263        );
264        assert_eq!(
265            parse_array(&emit_array(&words)).unwrap(),
266            words
267        );
268    }
269
270    #[test]
271    fn indexed_canonical() {
272        assert_eq!(
273            parse_indexed("([0]='a' [1]='b')").unwrap(),
274            ix([(0, "a"), (1, "b")])
275        );
276        assert_eq!(parse_indexed("()").unwrap(), ix([]));
277        assert_eq!(
278            parse_indexed(r#"([0]="a" [1]="b c" [2]=$'d\ne')"#).unwrap(),
279            ix([(0, "a"), (1, "b c"), (2, "d\ne")])
280        );
281    }
282
283    #[test]
284    fn indexed_sparse_ascending() {
285        assert_eq!(
286            parse_indexed(r#"([0]="zero" [2]="two" [5]="five")"#).unwrap(),
287            ix([(0, "zero"), (2, "two"), (5, "five")])
288        );
289    }
290
291    #[test]
292    fn indexed_rejects_non_canonical() {
293        assert!(parse_indexed("").is_err());
294        assert!(parse_indexed("[0]='a' [1]='b'").is_err());
295        assert!(parse_indexed("([0]=)").is_err());
296        assert!(parse_indexed("([0]='a'").is_err());
297    }
298
299    #[test]
300    fn assoc_canonical() {
301        assert_eq!(
302            parse_assoc(r#"([k]="v")"#).unwrap(),
303            ax([("k", "v")])
304        );
305        assert_eq!(parse_assoc("()").unwrap(), ax([]));
306        assert_eq!(
307            parse_assoc(r#"([foo]="1" [c]="3" )"#).unwrap(),
308            ax([("foo", "1"), ("c", "3")])
309        );
310    }
311
312    #[test]
313    fn assoc_quoted_key_ansi_value() {
314        assert_eq!(
315            parse_assoc(r#"([foo]="1" ["k 2"]=$'v\n2' [c]="3" )"#).unwrap(),
316            ax([("foo", "1"), ("k 2", "v\n2"), ("c", "3")])
317        );
318    }
319
320    #[test]
321    fn assoc_rejects_non_canonical() {
322        assert!(parse_assoc("").is_err());
323        assert!(parse_assoc("[k]='v'").is_err());
324        assert!(parse_assoc("([k]=)").is_err());
325    }
326
327    /// Bash prints a byte it cannot show as up to three octal digits, and the
328    /// widest it ever prints is `\377`. Anything above that is not its output
329    /// and is refused rather than wrapped — three digits reach 511, which is
330    /// where an unchecked `u8` conversion would have given up.
331    #[test]
332    fn an_octal_escape_stops_at_a_byte() {
333        assert_eq!(
334            parse_q_words(r"$'\377'").unwrap(),
335            vec!["\u{ff}"]
336        );
337        assert_eq!(
338            parse_q_words(r"$'\0'").unwrap(),
339            vec!["\0"]
340        );
341        assert!(parse_q_words(r"$'\400'").is_err());
342        assert!(parse_q_words(r"$'\777'").is_err());
343    }
344
345    /// A subscript is a machine integer. One too wide to be one was never
346    /// printed by bash, and is an error rather than a panic.
347    #[test]
348    fn a_subscript_too_wide_to_be_one_is_refused() {
349        assert!(parse_indexed("([99999999999999999999999]='a')").is_err());
350        assert_eq!(
351            parse_indexed(&format!("([{}]='a')", usize::MAX)).unwrap(),
352            ix([(usize::MAX, "a")]),
353            "the widest one that is still an index"
354        );
355    }
356
357    /// The snippet in an error is cut to character boundaries, so an input
358    /// with multi-byte characters still reports one.
359    #[test]
360    fn an_error_reports_the_text_around_it() {
361        let long = format!("'{}' trailing", "é".repeat(30));
362        let failed = parse_scalar(&long).expect_err("trailing input");
363
364        assert!(!failed.snippet.is_empty(), "{failed}");
365        assert!(
366            long.contains(&failed.snippet),
367            "{failed}"
368        );
369    }
370}