snix_glue/builder/structured_attrs/
shell.rs

1//! Contains the code rendering the shell script that's used for structured attrs.
2//!
3
4use regex::Regex;
5use std::sync::LazyLock;
6
7static RE_SH_VAR_NAME: LazyLock<Regex> =
8    LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap());
9
10/// Writes an escaped version of the passed string to the writer.
11fn write_shell_escaped_single_quoted<W>(f: &mut W, s: &str) -> std::fmt::Result
12where
13    W: std::fmt::Write,
14{
15    write!(f, "'")?;
16    for c in s.chars() {
17        if c == '\'' {
18            write!(f, "\\'")?;
19        } else {
20            write!(f, "{c}")?;
21        }
22    }
23    write!(f, "'")?;
24    Ok(())
25}
26
27/// determine if the value is "good to print".
28/// We essentially want to reject floats which are not just integers.
29fn is_good_simple_value(v: &serde_json::Value) -> bool {
30    match v {
31        serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::String(_) => true,
32        serde_json::Value::Number(number) => {
33            if number.as_i64().is_some() || number.as_u64().is_some() {
34                true
35            } else if let Some(n) = number.as_f64() {
36                n.ceil() == n
37            } else if number.as_i128().is_some() {
38                true
39            } else {
40                number.as_u128().is_some()
41            }
42        }
43        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
44            unreachable!("Snix bug: called write_simple_type on complex type")
45        }
46    }
47}
48
49fn write_simple_type<W>(f: &mut W, v: serde_json::Value) -> std::fmt::Result
50where
51    W: std::fmt::Write,
52{
53    match v {
54        serde_json::Value::Null => write!(f, "''")?,
55        serde_json::Value::Bool(v) => {
56            if v {
57                write!(f, "1")?;
58            } else {
59                write!(f, "")?;
60            }
61        }
62        serde_json::Value::Number(number) => {
63            if let Some(n) = number.as_i64() {
64                write!(f, "{n}")?;
65            } else if let Some(n) = number.as_u64() {
66                write!(f, "{n}")?;
67            } else if let Some(n) = number.as_f64() {
68                debug_assert!(n.ceil() == n, "bad number value");
69                write!(f, "{}", n.ceil() as i64)?;
70            } else if let Some(n) = number.as_i128() {
71                write!(f, "{n}")?;
72            } else if let Some(n) = number.as_u128() {
73                write!(f, "{n}")?;
74            } else {
75                panic!("unable to represent number");
76            }
77        }
78        serde_json::Value::String(s) => {
79            write_shell_escaped_single_quoted(f, &s)?;
80        }
81        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
82            unreachable!("Snix bug: called write_simple_type on complex type")
83        }
84    }
85
86    Ok(())
87}
88
89/// for a given json map, write the file contents of the to-be-sourced bash script.
90/// Cf. writeStructuredAttrsShell in Cppnix.
91pub fn write_attrs_sh_file<W>(
92    f: &mut W,
93    map: serde_json::Map<String, serde_json::Value>,
94) -> std::fmt::Result
95where
96    W: std::fmt::Write,
97{
98    for (k, v) in map {
99        // keys with spaces, backslashes (and potentially everything else making
100        // that key an invalid identifier) are silently skipped from the bash
101        // file (but present in the ATerm!)
102        if !RE_SH_VAR_NAME.is_match(k.as_str()) {
103            continue;
104        }
105        match v {
106            serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::String(_) => {
107                write!(f, "declare {k}=")?;
108                write_simple_type(f, v)?;
109                writeln!(f)?;
110            }
111            serde_json::Value::Number(_) => {
112                // Nix skips over bad values (floats which can't be represented as integers)
113                if !is_good_simple_value(&v) {
114                    continue;
115                }
116                write!(f, "declare {k}=")?;
117                write_simple_type(f, v)?;
118                writeln!(f)?;
119            }
120            serde_json::Value::Array(values) => {
121                // If the array contains any invalid element, the array is skipped entirely.
122                if values.iter().any(|v| !is_good_simple_value(v)) {
123                    continue;
124                }
125                write!(f, "declare -a {k}=(")?;
126                for val in values {
127                    write_simple_type(f, val)?;
128                    write!(f, " ")?;
129                }
130                writeln!(f, ")")?;
131            }
132            serde_json::Value::Object(map) => {
133                // If the array contains any invalid element, or another
134                // non-simple type, the object is skipped entirely.
135                if map.values().any(|v| {
136                    matches!(
137                        v,
138                        serde_json::Value::Array(_) | serde_json::Value::Object(_)
139                    ) || !is_good_simple_value(v)
140                }) {
141                    continue;
142                }
143
144                write!(f, "declare -A {k}=(")?;
145                for (k, v) in map {
146                    write!(f, "['{k}']=")?;
147                    write_simple_type(f, v)?;
148                    write!(f, " ")?;
149                }
150                writeln!(f, ")")?;
151            }
152        }
153    }
154
155    Ok(())
156}
157
158#[cfg(test)]
159mod test {
160    use rstest::rstest;
161    use serde_json::json;
162
163    use super::write_attrs_sh_file;
164
165    #[rstest]
166    #[case::empty(json!({}), "")]
167    #[case::empty_key(json!({"": "value"}), "")]
168    #[case::null(json!({"k": null}), r#"declare k=''"#)]
169    #[case::string(json!({"k":"v"}), r#"declare k='v'"#)]
170    #[case::string_escaping(json!({"k":"v'"}), r#"declare k='v\''"#)]
171    #[case::bool_false(json!({"k":false}), r#"declare k="#)]
172    #[case::bool_true(json!({"k":true}), r#"declare k=1"#)]
173    #[case::number(json!({"k":1}), r#"declare k=1"#)]
174    #[case::number_float(json!({"k":1.0}), r#"declare k=1"#)]
175    #[case::number_float_invalid(json!({"k":1.1}), r#""#)]
176    #[case::array_of_strings(json!({"k": ["bar", "baz"]}), r#"declare -a k=('bar' 'baz' )"#)]
177    #[case::array_of_strings_and_bool(json!({"k": ["bar", true]}), r#"declare -a k=('bar' 1 )"#)]
178    #[case::array_of_strings_and_invalid_number(json!({"k": ["bar", 1.1]}), "")]
179    #[case::object(json!(
180        {"k": {
181            "bar": true,
182            "b": 1.0,
183            "c": false,
184            "d": true,
185        }}), r#"declare -A k=(['b']=1 ['bar']=1 ['c']= ['d']=1 )"#)]
186    #[case::object_invalid_number(json!(
187        {"k": {
188            "bar": true,
189            "b": 1.1,
190        }}), "")]
191    #[case::object_too_complex(json!(
192        {"k": {
193            "bar": true,
194            "baz": [],
195        }}), r#""#)]
196    #[case::multiple(json!(
197        {
198            "k": {
199                "bar": true,
200                "b": 1.0,
201                "c": false,
202                "d": true,
203            },
204            "l": 42,
205            "m": false,
206            "n": 1.1,
207        }),
208        r#"declare -A k=(['b']=1 ['bar']=1 ['c']= ['d']=1 )
209declare l=42
210declare m="#)]
211    fn write_attrs(#[case] val: serde_json::Value, #[case] exp_output: &str) {
212        let mut out = String::new();
213        let map = val.as_object().expect("must be map").to_owned();
214        write_attrs_sh_file(&mut out, map).expect("must succeed");
215
216        if exp_output.is_empty() {
217            assert_eq!(exp_output, out, "expected output to match");
218        } else {
219            assert_eq!(
220                {
221                    let mut exp_output = String::from(exp_output);
222                    exp_output.push('\n');
223                    exp_output
224                },
225                out,
226                "expected output to match"
227            );
228        }
229    }
230}