Skip to main content

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                    // Nix shell-escapes the key (parsed-derivations.cc,
147                    // writeStructuredAttrsShell); unlike the outer var name,
148                    // inner keys are not filtered and may contain quotes.
149                    write!(f, "[")?;
150                    write_shell_escaped_single_quoted(f, &k)?;
151                    write!(f, "]=")?;
152                    write_simple_type(f, v)?;
153                    write!(f, " ")?;
154                }
155                writeln!(f, ")")?;
156            }
157        }
158    }
159
160    Ok(())
161}
162
163#[cfg(test)]
164mod test {
165    use rstest::rstest;
166    use serde_json::json;
167
168    use super::write_attrs_sh_file;
169
170    #[rstest]
171    #[case::empty(json!({}), "")]
172    #[case::empty_key(json!({"": "value"}), "")]
173    #[case::null(json!({"k": null}), r#"declare k=''"#)]
174    #[case::string(json!({"k":"v"}), r#"declare k='v'"#)]
175    #[case::string_escaping(json!({"k":"v'w"}), r#"declare k='v'\''w'"#)]
176    #[case::bool_false(json!({"k":false}), r#"declare k="#)]
177    #[case::bool_true(json!({"k":true}), r#"declare k=1"#)]
178    #[case::number(json!({"k":1}), r#"declare k=1"#)]
179    #[case::number_float(json!({"k":1.0}), r#"declare k=1"#)]
180    #[case::number_float_invalid(json!({"k":1.1}), r#""#)]
181    #[case::array_of_strings(json!({"k": ["bar", "baz"]}), r#"declare -a k=('bar' 'baz' )"#)]
182    #[case::array_of_strings_and_bool(json!({"k": ["bar", true]}), r#"declare -a k=('bar' 1 )"#)]
183    #[case::array_of_strings_and_invalid_number(json!({"k": ["bar", 1.1]}), "")]
184    #[case::object_key_escaping(json!(
185        {"k": {"it's": "v"}}), r#"declare -A k=(['it'\''s']='v' )"#)]
186    #[case::object(json!(
187        {"k": {
188            "bar": true,
189            "b": 1.0,
190            "c": false,
191            "d": true,
192        }}), r#"declare -A k=(['b']=1 ['bar']=1 ['c']= ['d']=1 )"#)]
193    #[case::object_invalid_number(json!(
194        {"k": {
195            "bar": true,
196            "b": 1.1,
197        }}), "")]
198    #[case::object_too_complex(json!(
199        {"k": {
200            "bar": true,
201            "baz": [],
202        }}), r#""#)]
203    #[case::multiple(json!(
204        {
205            "k": {
206                "bar": true,
207                "b": 1.0,
208                "c": false,
209                "d": true,
210            },
211            "l": 42,
212            "m": false,
213            "n": 1.1,
214        }),
215        r#"declare -A k=(['b']=1 ['bar']=1 ['c']= ['d']=1 )
216declare l=42
217declare m="#)]
218    fn write_attrs(#[case] val: serde_json::Value, #[case] exp_output: &str) {
219        let mut out = String::new();
220        let map = val.as_object().expect("must be map").to_owned();
221        write_attrs_sh_file(&mut out, map).expect("must succeed");
222
223        if exp_output.is_empty() {
224            assert_eq!(exp_output, out, "expected output to match");
225        } else {
226            assert_eq!(
227                {
228                    let mut exp_output = String::from(exp_output);
229                    exp_output.push('\n');
230                    exp_output
231                },
232                out,
233                "expected output to match"
234            );
235        }
236    }
237}