Skip to main content

bash_strings/
codec.rs

1//! Recursive values, and the two ways to flatten one into bash words.
2//!
3//! `BashVal` is a tree of strings and `Schema` its depth. Bash arrays are
4//! flat, so nesting is encoded textually:
5//!
6//! - [`QuotedNest`] — each inner array is one bash-literal word at the outer
7//!   level: `[[a,b],[c]] → ["('a' 'b')", "('c')"]`. The receiver unquotes one
8//!   layer per level.
9//!
10//! - [`LinkedArr`] — one flat word stream, each group prefixed by its width:
11//!   `[[a,b],[c]] → [2, a, b, 1, c]`. A bash-side walker reads it by
12//!   taking a width and shifting that many words, with no parser.
13//!
14//! Emitting takes the depth from the value; parsing takes it from a `Schema`,
15//! which is what the text alone does not say. Scalar leaves are raw strings —
16//! bash quoting is applied by [`emit_array`] when a literal
17//! is built.
18//!
19//! One dimension is the ubiquitous case and has a named entry point that needs
20//! no `Schema`: [`parse_array`](super::parse_array) / [`emit_array`]. Anything
21//! deeper, or in `LinkedArr`'s encoding, goes through a codec and a schema —
22//! two dimensions through [`QuotedNest::rows`].
23
24use super::emit::emit_array;
25use super::error::ParseError;
26use super::parser::{inside, parse_q_words};
27
28#[derive(Debug, Clone, PartialEq)]
29pub enum BashVal {
30    Str(String),
31    Arr(Vec<BashVal>),
32}
33
34#[derive(Debug, Clone, PartialEq)]
35pub enum Schema {
36    Scalar,
37    Arr(Box<Schema>),
38}
39
40impl Schema {
41    pub fn n_d(n: usize) -> Self {
42        let mut schema = Schema::Scalar;
43        for _ in 0..n {
44            schema = Schema::Arr(Box::new(schema));
45        }
46        schema
47    }
48}
49
50impl BashVal {
51    /// One flat row of words.
52    pub fn row(words: impl IntoIterator<Item = impl Into<String>>) -> Self {
53        Self::Arr(
54            words
55                .into_iter()
56                .map(|word| Self::Str(word.into()))
57                .collect(),
58        )
59    }
60
61    /// The words of a one-dimensional value; `None` if it is any other shape.
62    pub fn words(self) -> Option<Vec<String>> {
63        let Self::Arr(items) = self else { return None };
64
65        items
66            .into_iter()
67            .map(|item| match item {
68                Self::Str(word) => Some(word),
69                Self::Arr(_) => None,
70            })
71            .collect()
72    }
73
74    /// The rows of a two-dimensional value; `None` if it is any other shape.
75    pub fn rows(self) -> Option<Vec<Vec<String>>> {
76        let Self::Arr(rows) = self else { return None };
77
78        rows.into_iter().map(Self::words).collect()
79    }
80}
81
82/// The depth said one word and there were others.
83fn scalar_expected(words: &[String]) -> ParseError {
84    ParseError::new(
85        &words.join(" "),
86        0,
87        format!("expected one word, got {}", words.len()),
88    )
89}
90
91pub trait BashCodec {
92    /// The value's own depth decides the encoding, so there is nothing to
93    /// disagree with and nothing to fail.
94    fn emit(&self, val: &BashVal) -> Vec<String>;
95
96    /// A `Schema` says how many layers of quoting to peel, which the text
97    /// alone does not.
98    fn parse(&self, words: &[String], schema: &Schema) -> Result<BashVal, ParseError>;
99
100    /// A complete bash array literal: `(w1 w2 …)`, each scalar single-quoted.
101    fn emit_literal(&self, val: &BashVal) -> String {
102        emit_array(&self.emit(val))
103    }
104
105    fn parse_literal(&self, input: &str, schema: &Schema) -> Result<BashVal, ParseError> {
106        self.parse(&parse_q_words(inside(input)?)?, schema)
107    }
108
109    /// A two-dimensional literal as its rows: `("'a' 'b'" "'c'")` →
110    /// `[["a", "b"], ["c"]]`. One dimension needs no codec — see
111    /// [`parse_array`](super::parse_array).
112    fn rows(&self, input: &str) -> Result<Vec<Vec<String>>, ParseError> {
113        self.parse_literal(input, &Schema::n_d(2))?
114            .rows()
115            .ok_or_else(|| ParseError::new(input, 0, "expected rows"))
116    }
117}
118
119pub struct QuotedNest;
120
121impl BashCodec for QuotedNest {
122    fn emit(&self, val: &BashVal) -> Vec<String> {
123        match val {
124            BashVal::Str(word) => vec![word.clone()],
125            BashVal::Arr(items) => items
126                .iter()
127                .map(|item| match item {
128                    BashVal::Str(word) => word.clone(),
129                    BashVal::Arr(_) => self.emit_literal(item),
130                })
131                .collect(),
132        }
133    }
134
135    fn parse(&self, words: &[String], schema: &Schema) -> Result<BashVal, ParseError> {
136        match schema {
137            Schema::Scalar => match words {
138                [only] => Ok(BashVal::Str(only.clone())),
139                _ => Err(scalar_expected(words)),
140            },
141            Schema::Arr(inner) => words
142                .iter()
143                .map(|word| match **inner {
144                    Schema::Scalar => Ok(BashVal::Str(word.clone())),
145                    Schema::Arr(_) => self.parse_literal(word, inner),
146                })
147                .collect::<Result<_, _>>()
148                .map(BashVal::Arr),
149        }
150    }
151}
152
153/// A group is prefixed by its width — the full inner word stream, nested
154/// prefixes included — exactly where its elements are themselves groups,
155/// since a scalar is already one bash word.
156///
157/// | value | words |
158/// |---|---|
159/// | `[a, b]` | `a b` |
160/// | `[[a,b],[c,d,e]]` | `2 a b 3 c d e` |
161/// | `[[[a,b],[c]]]` | `5 2 a b 1 c` |
162/// | `[[[a,b]],[[c]]]` | `3 2 a b 2 1 c` |
163///
164/// The width prefix is what lets bash walk the stream with `shift`
165/// alone, so a reader needs no quoting rules of its own.
166pub struct LinkedArr;
167
168impl BashCodec for LinkedArr {
169    fn emit(&self, val: &BashVal) -> Vec<String> {
170        match val {
171            BashVal::Str(word) => vec![word.clone()],
172            BashVal::Arr(items) => {
173                let nested = matches!(items.first(), Some(BashVal::Arr(_)));
174                let mut out = Vec::new();
175                for item in items {
176                    let body = self.emit(item);
177                    if nested {
178                        out.push(body.len().to_string());
179                    }
180                    out.extend(body);
181                }
182                out
183            }
184        }
185    }
186
187    fn parse(&self, words: &[String], schema: &Schema) -> Result<BashVal, ParseError> {
188        match schema {
189            Schema::Scalar => match words {
190                [only] => Ok(BashVal::Str(only.clone())),
191                _ => Err(scalar_expected(words)),
192            },
193            Schema::Arr(_) => {
194                let (val, consumed) = parse_body(words, schema)?;
195                if consumed != words.len() {
196                    return Err(ParseError::new(
197                        &words.join(" "),
198                        0,
199                        format!(
200                            "trailing words: consumed {consumed} of {}",
201                            words.len()
202                        ),
203                    ));
204                }
205                Ok(val)
206            }
207        }
208    }
209}
210
211fn parse_body(words: &[String], schema: &Schema) -> Result<(BashVal, usize), ParseError> {
212    let Schema::Arr(inner) = schema else {
213        return match words.first() {
214            Some(word) => Ok((BashVal::Str(word.clone()), 1)),
215            None => Err(ParseError::new(
216                "",
217                0,
218                "a scalar position with no word",
219            )),
220        };
221    };
222
223    let grouped = matches!(**inner, Schema::Arr(_));
224    let mut items = Vec::new();
225    let mut at = 0;
226
227    while at < words.len() {
228        if !grouped {
229            items.push(BashVal::Str(words[at].clone()));
230            at += 1;
231            continue;
232        }
233
234        let width: usize = words[at].parse().map_err(|_| {
235            ParseError::new(
236                &words.join(" "),
237                0,
238                format!(
239                    "length prefix not numeric at pos {at}: {:?}",
240                    words[at]
241                ),
242            )
243        })?;
244        at += 1;
245
246        let end = at + width;
247        if end > words.len() {
248            return Err(ParseError::new(
249                &words.join(" "),
250                0,
251                format!(
252                    "group claims {width} words; only {} available",
253                    words.len() - at
254                ),
255            ));
256        }
257
258        let (item, consumed) = parse_body(&words[at..end], inner)?;
259        if consumed != end - at {
260            return Err(ParseError::new(
261                &words.join(" "),
262                0,
263                format!(
264                    "nested group: consumed {consumed} of {} body words",
265                    end - at
266                ),
267            ));
268        }
269        items.push(item);
270        at = end;
271    }
272
273    Ok((BashVal::Arr(items), at))
274}
275
276#[cfg(test)]
277mod tests {
278
279    /// Two dimensions carried in one: each inner array is one word of the
280    /// outer, so a flat bash array holds a nested value.
281    #[test]
282    fn rows_round_trip_through_one_flat_array() {
283        let rows = vec![
284            vec!["AspectRequire".to_string(), "env".into(), "mod a".into()],
285            vec!["Accumulate".to_string()],
286            Vec::new(),
287        ];
288
289        let text = QuotedNest.emit_literal(&BashVal::Arr(
290            rows.iter()
291                .map(|row| BashVal::row(row.iter().cloned()))
292                .collect(),
293        ));
294        let outer = crate::parse_array(&text).unwrap();
295
296        assert_eq!(
297            outer.len(),
298            3,
299            "three words at the outer level, one per row"
300        );
301        assert_eq!(
302            outer[0], "('AspectRequire' 'env' 'mod a')",
303            "each one an array literal"
304        );
305        assert_eq!(
306            crate::parse_array(&outer[0]).unwrap(),
307            rows[0],
308            "which reads back on its own"
309        );
310
311        assert_eq!(
312            QuotedNest.rows(&text).unwrap(),
313            rows,
314            "or in one step"
315        );
316    }
317    use super::*;
318
319    fn row(words: &[&str]) -> BashVal {
320        BashVal::row(words.iter().copied())
321    }
322
323    fn arr(items: Vec<BashVal>) -> BashVal {
324        BashVal::Arr(items)
325    }
326
327    fn words(items: &[&str]) -> Vec<String> {
328        items.iter().map(|word| word.to_string()).collect()
329    }
330
331    /// Each inner array becomes one quoted word at the outer level, and comes
332    /// back through the schema that says how deep to look.
333    #[test]
334    fn quoted_nest_wraps_a_level_per_dimension() {
335        let two_d = arr(vec![
336            row(&["a", "b"]),
337            row(&["c", "d", "e"]),
338        ]);
339
340        assert_eq!(
341            QuotedNest.emit(&two_d),
342            words(&["('a' 'b')", "('c' 'd' 'e')"])
343        );
344        assert_eq!(
345            QuotedNest
346                .parse(
347                    &QuotedNest.emit(&two_d),
348                    &Schema::n_d(2)
349                )
350                .unwrap(),
351            two_d
352        );
353    }
354
355    #[test]
356    fn linked_arr_prefixes_each_group_with_its_width() {
357        assert_eq!(
358            LinkedArr.emit(&arr(vec![
359                row(&["a", "b"]),
360                row(&["c", "d", "e"])
361            ])),
362            words(&["2", "a", "b", "3", "c", "d", "e"])
363        );
364        assert_eq!(
365            LinkedArr.emit(&arr(vec![arr(vec![
366                row(&["a", "b"]),
367                row(&["c"])
368            ])])),
369            words(&["5", "2", "a", "b", "1", "c"])
370        );
371        assert_eq!(
372            LinkedArr.emit(&arr(vec![
373                arr(vec![row(&["a", "b"])]),
374                arr(vec![row(&["c"])])
375            ])),
376            words(&["3", "2", "a", "b", "2", "1", "c"])
377        );
378    }
379
380    /// Past two dimensions the named helpers stop and a `Schema` says how deep
381    /// to look — the text alone cannot, since every level is just words.
382    #[test]
383    fn quoted_nest_round_trips_at_three_dimensions() {
384        let three_d = arr(vec![
385            arr(vec![row(&["a", "b"]), row(&["c"])]),
386            arr(vec![row(&["d", "e"])]),
387        ]);
388        let text = QuotedNest.emit_literal(&three_d);
389
390        assert_eq!(
391            QuotedNest.parse_literal(&text, &Schema::n_d(3)).unwrap(),
392            three_d
393        );
394        assert_eq!(
395            QuotedNest
396                .parse_literal(&text, &Schema::n_d(2))
397                .unwrap()
398                .rows()
399                .unwrap()
400                .len(),
401            2,
402            "read one level shallower it is still two rows, of one word each"
403        );
404    }
405
406    #[test]
407    fn linked_arr_round_trips_at_three_dimensions() {
408        let three_d = arr(vec![
409            arr(vec![row(&["a", "b"]), row(&["c"])]),
410            arr(vec![row(&["d", "e"])]),
411        ]);
412
413        assert_eq!(
414            LinkedArr
415                .parse(
416                    &LinkedArr.emit(&three_d),
417                    &Schema::n_d(3)
418                )
419                .unwrap(),
420            three_d
421        );
422    }
423}