Skip to main content

dekopon_shell/
value.rs

1//! The script value type and its coercion rules.
2//!
3//! Every shell variable, command result, and pipeline element is a [`serde_json::Value`]. Nothing
4//! in this interpreter is stringly typed, so capability inputs and outputs never need marshaling:
5//! the rest of the workspace already speaks `serde_json::Value` everywhere.
6
7use serde_json::{Map, Value};
8
9/// Coerces one value to its display form.
10///
11/// This is the form used by bare-word arguments, double-quoted interpolation, and emitted output:
12///
13/// - strings are reproduced verbatim, without quotes,
14/// - numbers use their JSON literal,
15/// - booleans become `true` or `false`,
16/// - null becomes the empty string,
17/// - arrays and objects become compact JSON text.
18#[must_use]
19pub fn display(value: &Value) -> String {
20    match value {
21        Value::Null => String::new(),
22        Value::Bool(flag) => flag.to_string(),
23        Value::Number(number) => number.to_string(),
24        Value::String(text) => text.clone(),
25        Value::Array(_) | Value::Object(_) => value.to_string(),
26    }
27}
28
29/// Reports whether a value is "true" for `if`, `while`, and `test`.
30///
31/// This is a value-model predicate, not bash's exit-status rule: exit status drives control flow
32/// in the evaluator, while this helper is only used by builtins that inspect a value directly.
33#[must_use]
34pub fn truthy(value: &Value) -> bool {
35    match value {
36        Value::Null => false,
37        Value::Bool(flag) => *flag,
38        Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
39        Value::String(text) => !text.is_empty(),
40        Value::Array(items) => !items.is_empty(),
41        Value::Object(fields) => !fields.is_empty(),
42    }
43}
44
45/// Converts a value into the line list consumed by text-shaped builtins.
46///
47/// A JSON array is treated as an array of lines (each element display-coerced). Every other value
48/// is display-coerced and split on newlines. A trailing empty line is dropped so that
49/// `"a\nb\n"` and `"a\nb"` behave identically.
50#[must_use]
51pub fn to_lines(value: &Value) -> Vec<String> {
52    match value {
53        Value::Array(items) => items.iter().map(display).collect(),
54        Value::Null => Vec::new(),
55        other => {
56            let text = display(other);
57            if text.is_empty() {
58                return Vec::new();
59            }
60            let mut lines = text.split('\n').map(str::to_owned).collect::<Vec<_>>();
61            if lines.last().is_some_and(String::is_empty) {
62                lines.pop();
63            }
64            lines
65        }
66    }
67}
68
69/// Converts a line list back into a value.
70///
71/// No lines becomes `null`, which emits nothing: a `grep` that matched nothing must print nothing,
72/// where an empty string would print a phantom blank line and spend a line of the output ceiling.
73/// A single line becomes a string so that `echo hi | grep hi` stays scalar; anything else becomes a
74/// JSON array of lines so that later `jq` or index expressions see real structure.
75#[must_use]
76pub fn from_lines(lines: Vec<String>) -> Value {
77    match lines.len() {
78        0 => Value::Null,
79        1 => Value::String(lines.into_iter().next().unwrap_or_default()),
80        _ => Value::Array(lines.into_iter().map(Value::String).collect()),
81    }
82}
83
84/// Converts a value into the text a text-shaped builtin operates on.
85#[must_use]
86pub fn to_text(value: &Value) -> String {
87    match value {
88        Value::Array(_) => to_lines(value).join("\n"),
89        other => display(other),
90    }
91}
92
93/// Indexes a value with one display-coerced key.
94///
95/// Arrays accept non-negative decimal indices; objects accept field names. Anything else yields
96/// `null`, matching how a missing JSON field reads.
97#[must_use]
98pub fn index(value: &Value, key: &str) -> Value {
99    match value {
100        Value::Array(items) => key
101            .parse::<usize>()
102            .ok()
103            .and_then(|offset| items.get(offset))
104            .cloned()
105            .unwrap_or(Value::Null),
106        Value::Object(fields) => fields.get(key).cloned().unwrap_or(Value::Null),
107        _ => Value::Null,
108    }
109}
110
111/// Parses one argv token into a value, keeping ambiguous text as a string.
112///
113/// Only JSON numbers, `true`, `false`, and `null` are promoted. Objects and arrays are deliberately
114/// left as strings here so a flag value such as `--message '{"a":1}'` is not silently restructured;
115/// the single-bare-argument JSON form used by `cap` is the explicit way to pass an object.
116#[must_use]
117pub fn scalar_from_token(token: &str) -> Value {
118    match token {
119        "true" => return Value::Bool(true),
120        "false" => return Value::Bool(false),
121        "null" => return Value::Null,
122        _ => {}
123    }
124    if let Ok(Value::Number(number)) = serde_json::from_str::<Value>(token) {
125        return Value::Number(number);
126    }
127    Value::String(token.to_owned())
128}
129
130/// Builds an object from ordered key/value pairs, folding repeated keys into arrays.
131#[must_use]
132pub fn object_from_pairs(pairs: Vec<(String, Value)>) -> Value {
133    let mut fields = Map::new();
134    for (key, value) in pairs {
135        match fields.remove(&key) {
136            None => {
137                fields.insert(key, value);
138            }
139            Some(Value::Array(mut existing)) => {
140                existing.push(value);
141                fields.insert(key, Value::Array(existing));
142            }
143            Some(existing) => {
144                fields.insert(key, Value::Array(vec![existing, value]));
145            }
146        }
147    }
148    Value::Object(fields)
149}
150
151#[cfg(test)]
152mod tests {
153    use serde_json::json;
154
155    use super::{
156        Value, display, from_lines, index, object_from_pairs, scalar_from_token, to_lines, truthy,
157    };
158
159    #[test]
160    fn display_uses_documented_coercions() {
161        assert_eq!(display(&json!("hi")), "hi");
162        assert_eq!(display(&json!(7)), "7");
163        assert_eq!(display(&json!(1.5)), "1.5");
164        assert_eq!(display(&json!(true)), "true");
165        assert_eq!(display(&Value::Null), "");
166        assert_eq!(display(&json!([1, 2])), "[1,2]");
167        assert_eq!(display(&json!({"a": 1})), r#"{"a":1}"#);
168    }
169
170    #[test]
171    fn truthiness_follows_the_value_model() {
172        assert!(!truthy(&Value::Null));
173        assert!(!truthy(&json!("")));
174        assert!(truthy(&json!("x")));
175        assert!(!truthy(&json!(0)));
176        assert!(truthy(&json!(3)));
177        assert!(!truthy(&json!([])));
178        assert!(truthy(&json!([1])));
179    }
180
181    #[test]
182    fn text_shaped_conversions_round_trip() {
183        assert_eq!(to_lines(&json!("a\nb")), vec!["a", "b"]);
184        assert_eq!(to_lines(&json!("a\nb\n")), vec!["a", "b"]);
185        assert_eq!(to_lines(&json!(["a", "b"])), vec!["a", "b"]);
186        assert_eq!(to_lines(&Value::Null), Vec::<String>::new());
187        assert_eq!(from_lines(vec!["only".to_owned()]), json!("only"));
188        assert_eq!(
189            from_lines(vec!["a".to_owned(), "b".to_owned()]),
190            json!(["a", "b"])
191        );
192        // Nothing selected is nothing emitted, not an empty line.
193        assert_eq!(from_lines(Vec::new()), Value::Null);
194    }
195
196    #[test]
197    fn indexing_is_backed_by_real_json() {
198        assert_eq!(index(&json!([10, 20]), "1"), json!(20));
199        assert_eq!(index(&json!([10, 20]), "9"), Value::Null);
200        assert_eq!(index(&json!({"key": "v"}), "key"), json!("v"));
201        assert_eq!(index(&json!("scalar"), "0"), Value::Null);
202    }
203
204    #[test]
205    fn argv_tokens_promote_only_unambiguous_scalars() {
206        assert_eq!(scalar_from_token("7"), json!(7));
207        assert_eq!(scalar_from_token("-1.5"), json!(-1.5));
208        assert_eq!(scalar_from_token("true"), json!(true));
209        assert_eq!(scalar_from_token("null"), Value::Null);
210        assert_eq!(scalar_from_token("hello"), json!("hello"));
211        assert_eq!(scalar_from_token(r#"{"a":1}"#), json!(r#"{"a":1}"#));
212    }
213
214    #[test]
215    fn repeated_object_keys_fold_into_arrays() {
216        let object = object_from_pairs(vec![
217            ("headerName".to_owned(), json!("a")),
218            ("headerName".to_owned(), json!("b")),
219            ("other".to_owned(), json!(1)),
220        ]);
221        assert_eq!(object, json!({"headerName": ["a", "b"], "other": 1}));
222    }
223}