Skip to main content

datalogic_rs/
path.rs

1//! Public path-resolution surface — translates the raw `Vec<u32>` breadcrumb
2//! that [`crate::Error`] carries into structured [`PathStep`]s consumers can
3//! act on.
4
5use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9use crate::Logic;
10use crate::node::CompiledNode;
11
12/// One node along the path from the root of a compiled rule down to the
13/// failing sub-expression. Returned root-to-leaf by
14/// [`crate::Logic::resolve_node_ids`] / [`crate::Error::resolve_path`].
15///
16/// `#[non_exhaustive]` so future fields can be added in 5.x without
17/// breaking downstream — external code reads fields freely but cannot
18/// construct via struct literal. UI tooling that consumes this type
19/// over the wire can roundtrip via the derived `Serialize` /
20/// `Deserialize`.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[non_exhaustive]
23pub struct PathStep {
24    /// Compile-time node id, matching [`crate::Error::node_ids`].
25    pub node_id: u32,
26    /// Operator name at this node, when one applies. `None` for plain values
27    /// and arrays.
28    pub operator: Option<String>,
29    /// Position within the parent node's argument list. `None` for the root
30    /// step (no parent) and for non-positional contexts.
31    pub arg_index: Option<u32>,
32    /// JSONLogic-flavoured pointer from the root to this node — e.g.
33    /// `/if/0/>/0` for the `var` slot of the inner `>` inside an `if`.
34    /// Empty string for the root step. Tokens are escaped as RFC 6901
35    /// escapes them (`/` is `~1`), as in a trace's pointers; for a rule
36    /// compiled with `TracedSession::compile` it is
37    /// [`Logic::pointer`](crate::Logic::pointer), the pointer into the
38    /// rule as written.
39    pub json_pointer: String,
40}
41
42/// Internal index entry collected during the walk.
43struct NodeInfo {
44    operator: Option<String>,
45    arg_index: Option<u32>,
46    json_pointer: String,
47}
48
49impl Logic {
50    /// Translate a breadcrumb of compiled-node ids into structured
51    /// [`PathStep`]s, root-to-leaf.
52    ///
53    /// Input is the leaf-to-root breadcrumb stored on [`crate::Error::node_ids`].
54    /// Walks the compiled tree once to build an id → location index, then
55    /// resolves each input id; ids absent from the tree are skipped (defensive
56    /// against synthetic nodes from operator fast paths).
57    pub fn resolve_node_ids(&self, ids: &[u32]) -> Vec<PathStep> {
58        if ids.is_empty() {
59            return Vec::new();
60        }
61
62        let mut index: HashMap<u32, NodeInfo> = HashMap::new();
63        walk(&self.root, None, String::new(), &mut index);
64
65        let mut out = Vec::with_capacity(ids.len());
66        // Breadcrumb is leaf-to-root; reverse for natural root-to-leaf reading.
67        for &id in ids.iter().rev() {
68            if let Some(ni) = index.get(&id) {
69                out.push(PathStep {
70                    node_id: id,
71                    operator: ni.operator.clone(),
72                    arg_index: ni.arg_index,
73                    json_pointer: self
74                        .pointer(id)
75                        .map_or_else(|| ni.json_pointer.clone(), str::to_string),
76                });
77            }
78        }
79        out
80    }
81}
82
83/// Depth-first walk of a [`CompiledNode`], recording (operator, arg_index,
84/// json_pointer) for every reachable node id. `arg_index` and
85/// `json_pointer` describe how *this* node is reached from above.
86///
87/// For a rule compiled without folding, the pointers match the ones a
88/// traced compile records, except where an operator's lone argument was
89/// written without its array (`{"!": {"var": "x"}}`): the compiled tree
90/// does not keep that, so the pointer names it as item 0.
91///
92/// Recursion delegates the "what are this node's children" question to
93/// [`CompiledNode::visit_indexed_children`] so the variant match lives in
94/// exactly one place.
95fn walk(
96    node: &CompiledNode,
97    arg_index: Option<u32>,
98    json_pointer: String,
99    out: &mut HashMap<u32, NodeInfo>,
100) {
101    // CSE memo wrappers are path-transparent: delegate before the generic
102    // body so the wrapped node's operator/pointer are recorded exactly as
103    // in an unwrapped tree (no extra "/op/0" step for the wrapper).
104    if let CompiledNode::Cse(data) = node {
105        return walk(&data.inner, arg_index, json_pointer, out);
106    }
107
108    let id = node.id();
109    let operator = node.operator_name().map(|c| c.into_owned());
110
111    // Children of an `Array` form pointers like "/<idx>"; for every other
112    // variant the current node's operator name is the pointer prefix.
113    let child_parent_op = if matches!(node, CompiledNode::Array { .. }) {
114        None
115    } else {
116        operator.as_deref()
117    };
118
119    // Recurse first while borrowing `operator` / `json_pointer`, then move
120    // both owned values into the map — node ids are unique, so insertion
121    // order does not matter, and this avoids cloning them per node.
122    match node {
123        // A template field sits under its key, not its position.
124        #[cfg(feature = "templating")]
125        CompiledNode::StructuredObject(data) => {
126            for (i, (key, child)) in data.fields.iter().enumerate() {
127                let mut pointer = json_pointer.clone();
128                crate::node::push_pointer_token(&mut pointer, key);
129                walk(child, Some(i as u32), pointer, out);
130            }
131        }
132        _ => node.visit_indexed_children(&mut |i, child| {
133            let pointer = build_pointer(&json_pointer, child_parent_op, i);
134            walk(child, Some(i), pointer, out);
135        }),
136    }
137
138    out.insert(
139        id,
140        NodeInfo {
141            operator,
142            arg_index,
143            json_pointer,
144        },
145    );
146}
147
148/// The pointer of child `idx` of the node at `parent_pointer`: under the
149/// parent's operator key, or directly under the parent for an array.
150#[inline]
151fn build_pointer(parent_pointer: &str, parent_op: Option<&str>, idx: u32) -> String {
152    let mut pointer = parent_pointer.to_string();
153    if let Some(op) = parent_op {
154        crate::node::push_pointer_token(&mut pointer, op);
155    }
156    pointer.push('/');
157    pointer.push_str(itoa::Buffer::new().format(idx));
158    pointer
159}
160
161#[cfg(test)]
162mod tests {
163    fn engine() -> crate::Engine {
164        crate::Engine::new()
165    }
166
167    #[test]
168    fn resolve_root_only() {
169        // Use a rule with a `var` that survives static evaluation as the root.
170        let compiled = engine().compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
171        let root_id = compiled.root.id();
172        let steps = compiled.resolve_node_ids(&[root_id]);
173        assert_eq!(steps.len(), 1);
174        assert_eq!(steps[0].node_id, root_id);
175        assert_eq!(steps[0].operator.as_deref(), Some("=="));
176        assert_eq!(steps[0].arg_index, None);
177        assert_eq!(steps[0].json_pointer, "");
178    }
179
180    #[test]
181    fn resolve_empty_path_returns_empty() {
182        let compiled = engine().compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
183        assert!(compiled.resolve_node_ids(&[]).is_empty());
184    }
185
186    #[test]
187    fn resolve_unknown_ids_are_skipped() {
188        let compiled = engine().compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
189        // u32::MAX won't exist in the tree.
190        assert!(compiled.resolve_node_ids(&[u32::MAX]).is_empty());
191    }
192
193    #[test]
194    fn resolve_via_evaluation_error() {
195        // {"+": ["x", 1]} — the string-vs-number arithmetic raises NaN.
196        let engine = engine();
197        let compiled = engine.compile(r#"{"+": ["x", 1]}"#).unwrap();
198        let arena = bumpalo::Bump::new();
199        let data = datavalue::DataValue::from_str("null", &arena).unwrap();
200        let err = engine.evaluate(&compiled, data, &arena).unwrap_err();
201        // The merged Error should carry a non-empty path now.
202        let steps = err.resolve_path(&compiled);
203        assert!(
204            !steps.is_empty(),
205            "expected resolved path for arithmetic failure, got {:?}",
206            err
207        );
208        // First step (root-to-leaf) is the outermost operator.
209        assert_eq!(steps[0].operator.as_deref(), Some("+"));
210        assert_eq!(steps[0].json_pointer, "");
211    }
212
213    /// Error-path pointers escape tokens as trace pointers do, and a rule
214    /// compiled for tracing reports its recorded pointers.
215    #[test]
216    fn pointers_escape_and_follow_the_traced_compile() {
217        let engine = engine();
218        let rule = r#"{"/": [{"+": [{"var": "x"}, 1]}, 2]}"#;
219        let steps = |compiled: &crate::Logic| {
220            let arena = bumpalo::Bump::new();
221            let err = engine
222                .evaluate(compiled, r#"{"x": "a"}"#, &arena)
223                .unwrap_err();
224            err.resolve_path(compiled)
225        };
226        let plain = engine.compile(rule).unwrap();
227        let got: Vec<_> = steps(&plain).into_iter().map(|s| s.json_pointer).collect();
228        assert_eq!(got, ["", "/~1/0"]);
229        #[cfg(feature = "trace")]
230        {
231            let traced = engine.trace().compile(rule).unwrap();
232            let traced_steps = steps(&traced);
233            assert_eq!(traced_steps.last().unwrap().json_pointer, "/~1/0");
234            for step in &traced_steps {
235                assert_eq!(
236                    traced.pointer(step.node_id),
237                    Some(step.json_pointer.as_str())
238                );
239            }
240        }
241    }
242
243    /// Pointers of every node of a rule compiled without folding.
244    fn pointers(rule: &str, templating: bool) -> Vec<(Option<u32>, String)> {
245        let engine = crate::Engine::builder()
246            .with_constant_folding(false)
247            .with_templating(templating)
248            .build();
249        let compiled = engine.compile(rule).unwrap();
250        let mut index = std::collections::HashMap::new();
251        super::walk(&compiled.root, None, String::new(), &mut index);
252        let mut out: Vec<_> = index
253            .into_values()
254            .map(|n| (n.arg_index, n.json_pointer))
255            .collect();
256        out.sort_by(|a, b| a.1.cmp(&b.1));
257        out
258    }
259
260    /// A `var` default is item 1 of `{"var": [path, default]}`.
261    #[test]
262    fn var_default_is_item_one() {
263        let got = pointers(r#"{"var": ["x", {"+": [1, {"var": "y"}]}]}"#, false);
264        let ptrs: Vec<_> = got.iter().map(|(i, p)| (*i, p.as_str())).collect();
265        assert_eq!(
266            ptrs,
267            [
268                (None, ""),
269                (Some(1), "/var/1"),
270                (Some(0), "/var/1/+/0"),
271                (Some(1), "/var/1/+/1"),
272            ]
273        );
274    }
275
276    /// A template field sits under its key.
277    #[cfg(feature = "templating")]
278    #[test]
279    fn template_fields_are_keyed() {
280        let got = pointers(r#"{"a": 1, "b/c": {"var": "x"}}"#, true);
281        let ptrs: Vec<_> = got.iter().map(|(i, p)| (*i, p.as_str())).collect();
282        assert_eq!(ptrs, [(None, ""), (Some(0), "/a"), (Some(1), "/b~1c")]);
283    }
284}