Skip to main content

jpx_core/
value_ext.rs

1//! Extension trait for `serde_json::Value` providing JMESPath operations.
2//!
3//! This replaces the 2,719-line `variable.rs` in jmespath.rs with ~200 lines
4//! by leveraging `Value`'s built-in API.
5
6use serde_json::Value;
7
8use crate::ast::Comparator;
9
10/// JMESPath type names used in error messages and the `type()` function.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum JmespathType {
13    Null,
14    String,
15    Number,
16    Boolean,
17    Array,
18    Object,
19    Expref,
20}
21
22impl std::fmt::Display for JmespathType {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        match self {
25            JmespathType::Null => write!(f, "null"),
26            JmespathType::String => write!(f, "string"),
27            JmespathType::Number => write!(f, "number"),
28            JmespathType::Boolean => write!(f, "boolean"),
29            JmespathType::Array => write!(f, "array"),
30            JmespathType::Object => write!(f, "object"),
31            JmespathType::Expref => write!(f, "expref"),
32        }
33    }
34}
35
36/// Extension trait providing JMESPath operations on `serde_json::Value`.
37pub trait ValueExt {
38    /// Returns the JMESPath type name.
39    fn jmespath_type(&self) -> JmespathType;
40
41    /// Returns true if the value is "truthy" per the JMESPath spec.
42    ///
43    /// - `null` is falsy
44    /// - `false` is falsy
45    /// - `""` (empty string) is falsy
46    /// - `[]` (empty array) is falsy
47    /// - `{}` (empty object) is falsy
48    /// - Everything else is truthy
49    fn is_truthy(&self) -> bool;
50
51    /// Extracts a named field from an object; returns `Value::Null` if not found.
52    fn get_field(&self, name: &str) -> Value;
53
54    /// Extracts an element by positive index; returns `Value::Null` if out of range.
55    fn get_index(&self, idx: usize) -> Value;
56
57    /// Extracts an element by negative index (counting from end).
58    fn get_negative_index(&self, idx: usize) -> Value;
59
60    /// Slices an array with start/stop/step, per the JMESPath spec.
61    /// Returns `None` if the value is not an array.
62    fn slice(&self, start: Option<i32>, stop: Option<i32>, step: i32) -> Option<Vec<Value>>;
63
64    /// Compares two values using a JMESPath comparator.
65    /// Returns `None` if the comparison is not valid for these types.
66    fn compare(&self, comparator: &Comparator, other: &Value) -> Option<bool>;
67
68    /// Returns `true` if this is an expref sentinel.
69    fn is_expref(&self) -> bool;
70}
71
72impl ValueExt for Value {
73    fn jmespath_type(&self) -> JmespathType {
74        if self.is_expref() {
75            return JmespathType::Expref;
76        }
77        match self {
78            Value::Null => JmespathType::Null,
79            Value::Bool(_) => JmespathType::Boolean,
80            Value::Number(_) => JmespathType::Number,
81            Value::String(_) => JmespathType::String,
82            Value::Array(_) => JmespathType::Array,
83            Value::Object(_) => JmespathType::Object,
84        }
85    }
86
87    fn is_truthy(&self) -> bool {
88        match self {
89            Value::Null => false,
90            Value::Bool(b) => *b,
91            Value::String(s) => !s.is_empty(),
92            Value::Array(a) => !a.is_empty(),
93            Value::Object(o) => !o.is_empty(),
94            Value::Number(_) => true,
95        }
96    }
97
98    fn get_field(&self, name: &str) -> Value {
99        match self {
100            Value::Object(map) => map.get(name).cloned().unwrap_or(Value::Null),
101            _ => Value::Null,
102        }
103    }
104
105    fn get_index(&self, idx: usize) -> Value {
106        match self {
107            Value::Array(arr) => arr.get(idx).cloned().unwrap_or(Value::Null),
108            _ => Value::Null,
109        }
110    }
111
112    fn get_negative_index(&self, idx: usize) -> Value {
113        match self {
114            Value::Array(arr) => {
115                if idx > arr.len() {
116                    Value::Null
117                } else {
118                    arr.get(arr.len() - idx).cloned().unwrap_or(Value::Null)
119                }
120            }
121            _ => Value::Null,
122        }
123    }
124
125    fn slice(&self, start: Option<i32>, stop: Option<i32>, step: i32) -> Option<Vec<Value>> {
126        let arr = self.as_array()?;
127        let len = arr.len() as i32;
128        if len == 0 {
129            return Some(vec![]);
130        }
131
132        let a: i32 = match start {
133            Some(s) => adjust_slice_endpoint(len, s, step),
134            _ if step < 0 => len - 1,
135            _ => 0,
136        };
137        let b: i32 = match stop {
138            Some(s) => adjust_slice_endpoint(len, s, step),
139            _ if step < 0 => -1,
140            _ => len,
141        };
142
143        let mut result = Vec::new();
144        let mut i = a;
145        if step > 0 {
146            while i < b {
147                result.push(arr[i as usize].clone());
148                i += step;
149            }
150        } else {
151            while i > b {
152                result.push(arr[i as usize].clone());
153                i += step;
154            }
155        }
156        Some(result)
157    }
158
159    fn compare(&self, comparator: &Comparator, other: &Value) -> Option<bool> {
160        match comparator {
161            Comparator::Equal => Some(values_equal(self, other)),
162            Comparator::NotEqual => Some(!values_equal(self, other)),
163            Comparator::LessThan => compare_ordered(self, other).map(|o| o.is_lt()),
164            Comparator::LessThanEqual => compare_ordered(self, other).map(|o| o.is_le()),
165            Comparator::GreaterThan => compare_ordered(self, other).map(|o| o.is_gt()),
166            Comparator::GreaterThanEqual => compare_ordered(self, other).map(|o| o.is_ge()),
167        }
168    }
169
170    fn is_expref(&self) -> bool {
171        matches!(self, Value::Object(map) if map.contains_key("__jpx_expref__"))
172    }
173}
174
175/// Adjusts a slice endpoint per the JMESPath spec, accounting for step direction.
176fn adjust_slice_endpoint(len: i32, mut endpoint: i32, step: i32) -> i32 {
177    if endpoint < 0 {
178        endpoint += len;
179        if endpoint >= 0 {
180            endpoint
181        } else if step < 0 {
182            -1
183        } else {
184            0
185        }
186    } else if endpoint < len {
187        endpoint
188    } else if step < 0 {
189        len - 1
190    } else {
191        len
192    }
193}
194
195/// Equality comparison per JMESPath spec.
196fn values_equal(a: &Value, b: &Value) -> bool {
197    match (a, b) {
198        (Value::Null, Value::Null) => true,
199        (Value::Bool(a), Value::Bool(b)) => a == b,
200        (Value::Number(a), Value::Number(b)) => {
201            // Compare as f64 to handle integer/float comparison
202            a.as_f64().zip(b.as_f64()).is_some_and(|(af, bf)| af == bf)
203        }
204        (Value::String(a), Value::String(b)) => a == b,
205        (Value::Array(a), Value::Array(b)) => {
206            a.len() == b.len() && a.iter().zip(b.iter()).all(|(x, y)| values_equal(x, y))
207        }
208        (Value::Object(a), Value::Object(b)) => {
209            a.len() == b.len()
210                && a.iter()
211                    .all(|(k, v)| b.get(k).is_some_and(|bv| values_equal(v, bv)))
212        }
213        _ => false,
214    }
215}
216
217/// Ordering comparison per JMESPath spec - only numbers and strings.
218fn compare_ordered(a: &Value, b: &Value) -> Option<std::cmp::Ordering> {
219    match (a, b) {
220        (Value::Number(a), Value::Number(b)) => {
221            let af = a.as_f64()?;
222            let bf = b.as_f64()?;
223            af.partial_cmp(&bf)
224        }
225        (Value::String(a), Value::String(b)) => Some(a.cmp(b)),
226        _ => None,
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use serde_json::json;
234
235    #[test]
236    fn test_truthy() {
237        assert!(!Value::Null.is_truthy());
238        assert!(!Value::Bool(false).is_truthy());
239        assert!(Value::Bool(true).is_truthy());
240        assert!(!Value::String("".into()).is_truthy());
241        assert!(Value::String("hi".into()).is_truthy());
242        assert!(!json!([]).is_truthy());
243        assert!(json!([1]).is_truthy());
244        assert!(!json!({}).is_truthy());
245        assert!(json!({"a": 1}).is_truthy());
246        assert!(json!(0).is_truthy());
247        assert!(json!(42).is_truthy());
248    }
249
250    #[test]
251    fn test_get_field() {
252        let obj = json!({"foo": "bar"});
253        assert_eq!(obj.get_field("foo"), json!("bar"));
254        assert_eq!(obj.get_field("missing"), Value::Null);
255        assert_eq!(Value::Null.get_field("x"), Value::Null);
256    }
257
258    #[test]
259    fn test_get_index() {
260        let arr = json!([10, 20, 30]);
261        assert_eq!(arr.get_index(0), json!(10));
262        assert_eq!(arr.get_index(2), json!(30));
263        assert_eq!(arr.get_index(5), Value::Null);
264    }
265
266    #[test]
267    fn test_get_negative_index() {
268        let arr = json!([10, 20, 30]);
269        assert_eq!(arr.get_negative_index(1), json!(30));
270        assert_eq!(arr.get_negative_index(3), json!(10));
271        assert_eq!(arr.get_negative_index(4), Value::Null);
272    }
273
274    #[test]
275    fn test_slice() {
276        let arr = json!([0, 1, 2, 3, 4, 5]);
277        assert_eq!(
278            arr.slice(Some(1), Some(4), 1),
279            Some(vec![json!(1), json!(2), json!(3)])
280        );
281        assert_eq!(
282            arr.slice(None, None, 2),
283            Some(vec![json!(0), json!(2), json!(4)])
284        );
285        assert_eq!(
286            arr.slice(None, None, -1),
287            Some(vec![
288                json!(5),
289                json!(4),
290                json!(3),
291                json!(2),
292                json!(1),
293                json!(0)
294            ])
295        );
296    }
297
298    #[test]
299    fn test_compare() {
300        assert_eq!(json!(1).compare(&Comparator::Equal, &json!(1)), Some(true));
301        assert_eq!(
302            json!(1).compare(&Comparator::LessThan, &json!(2)),
303            Some(true)
304        );
305        assert_eq!(
306            json!("a").compare(&Comparator::GreaterThan, &json!("b")),
307            Some(false)
308        );
309        // Mixed types return None for ordering
310        assert_eq!(json!(1).compare(&Comparator::LessThan, &json!("a")), None);
311        // But equal/notequal work across types
312        assert_eq!(
313            json!(1).compare(&Comparator::Equal, &json!("a")),
314            Some(false)
315        );
316    }
317
318    #[test]
319    fn test_jmespath_type() {
320        assert_eq!(Value::Null.jmespath_type(), JmespathType::Null);
321        assert_eq!(json!(true).jmespath_type(), JmespathType::Boolean);
322        assert_eq!(json!(42).jmespath_type(), JmespathType::Number);
323        assert_eq!(json!("s").jmespath_type(), JmespathType::String);
324        assert_eq!(json!([]).jmespath_type(), JmespathType::Array);
325        assert_eq!(json!({}).jmespath_type(), JmespathType::Object);
326    }
327}