Skip to main content

nix_compat/
structured_attrs.rs

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