Skip to main content

panproto_inst/
instance_env.rs

1//! Instance-aware expression evaluation.
2//!
3//! The standard `panproto_expr::eval` has no access to the instance graph.
4//! The graph traversal builtins (`Edge`, `Children`, `HasEdge`, `EdgeCount`,
5//! `Anchor`) require an instance context to resolve. This module provides:
6//!
7//! - [`eval_with_instance`]: evaluates against a [`WInstance`] directly
8//! - [`eval_with_element_ops`]: evaluates against any [`ElementOps`]
9//!   implementor (polymorphic over all instance shapes)
10//!
11//! Both intercept graph builtins and fall through to the standard
12//! evaluator for everything else.
13
14use std::sync::Arc;
15
16use panproto_expr::{BuiltinOp, EvalConfig, Expr, Literal};
17use panproto_gat::Name;
18
19use crate::element_ops::ElementOps;
20use crate::value::Value;
21use crate::wtype::WInstance;
22
23/// Evaluate an expression with access to an instance graph.
24///
25/// Graph traversal builtins (`Edge`, `Children`, `HasEdge`, `EdgeCount`,
26/// `Anchor`) are resolved against the provided instance. All other
27/// expressions delegate to `panproto_expr::eval`.
28///
29/// The `context_node_id` determines which node is the "current" node
30/// for graph traversal. Pass `None` to disable graph traversal.
31///
32/// # Errors
33///
34/// Returns `panproto_expr::ExprError` on evaluation failure.
35pub fn eval_with_instance(
36    expr: &Expr,
37    env: &panproto_expr::Env,
38    config: &EvalConfig,
39    instance: &WInstance,
40    context_node_id: Option<u32>,
41) -> Result<Literal, panproto_expr::ExprError> {
42    match expr {
43        Expr::Builtin(op, args) if is_graph_builtin(*op) => {
44            // Evaluate arguments first via standard eval.
45            let mut eval_args = Vec::with_capacity(args.len());
46            for arg in args {
47                eval_args.push(eval_with_instance(
48                    arg,
49                    env,
50                    config,
51                    instance,
52                    context_node_id,
53                )?);
54            }
55            apply_graph_builtin(*op, &eval_args, instance, context_node_id)
56        }
57        _ => panproto_expr::eval(expr, env, config),
58    }
59}
60
61/// Evaluate an expression with graph builtins resolved via [`ElementOps`].
62///
63/// This is the polymorphic version of [`eval_with_instance`]: it works
64/// with any instance shape that implements [`ElementOps`]. Graph traversal
65/// builtins are delegated to `T::eval_graph_builtin`; all other expressions
66/// fall through to `panproto_expr::eval`.
67///
68/// # Errors
69///
70/// Returns `panproto_expr::ExprError` on evaluation failure.
71pub fn eval_with_element_ops<T: ElementOps>(
72    expr: &Expr,
73    env: &panproto_expr::Env,
74    config: &EvalConfig,
75    instance: &T,
76    context: Option<u32>,
77) -> Result<Literal, panproto_expr::ExprError> {
78    match expr {
79        Expr::Builtin(op, args) if is_graph_builtin(*op) => {
80            let mut eval_args = Vec::with_capacity(args.len());
81            for arg in args {
82                eval_args.push(eval_with_element_ops(arg, env, config, instance, context)?);
83            }
84            instance.eval_graph_builtin(*op, &eval_args, context)
85        }
86        _ => panproto_expr::eval(expr, env, config),
87    }
88}
89
90/// Check if a builtin is a graph traversal operation.
91const fn is_graph_builtin(op: BuiltinOp) -> bool {
92    matches!(
93        op,
94        BuiltinOp::Edge
95            | BuiltinOp::Children
96            | BuiltinOp::HasEdge
97            | BuiltinOp::EdgeCount
98            | BuiltinOp::Anchor
99    )
100}
101
102/// Evaluate a graph traversal builtin against an instance.
103fn apply_graph_builtin(
104    op: BuiltinOp,
105    args: &[Literal],
106    instance: &WInstance,
107    context_node_id: Option<u32>,
108) -> Result<Literal, panproto_expr::ExprError> {
109    match op {
110        BuiltinOp::Edge => {
111            // edge(node_ref, edge_kind) → child value
112            let node_id = resolve_node_ref(&args[0], context_node_id)?;
113            let edge_kind =
114                args[1]
115                    .as_str()
116                    .ok_or_else(|| panproto_expr::ExprError::TypeError {
117                        expected: "string".into(),
118                        got: args[1].type_name().into(),
119                    })?;
120            let edge_name = Name::from(edge_kind);
121            // Find the first arc matching this node and edge kind.
122            for &(src, tgt, ref edge) in &instance.arcs {
123                if src == node_id && edge.kind == edge_name {
124                    return Ok(node_to_literal(instance, tgt));
125                }
126            }
127            Ok(Literal::Null)
128        }
129        BuiltinOp::Children => {
130            // children(node_ref) → [child values]
131            let node_id = resolve_node_ref(&args[0], context_node_id)?;
132            let mut children = Vec::new();
133            for &(src, tgt, _) in &instance.arcs {
134                if src == node_id {
135                    children.push(node_to_literal(instance, tgt));
136                }
137            }
138            Ok(Literal::List(children))
139        }
140        BuiltinOp::HasEdge => {
141            // has_edge(node_ref, edge_kind) → bool
142            let node_id = resolve_node_ref(&args[0], context_node_id)?;
143            let edge_kind =
144                args[1]
145                    .as_str()
146                    .ok_or_else(|| panproto_expr::ExprError::TypeError {
147                        expected: "string".into(),
148                        got: args[1].type_name().into(),
149                    })?;
150            let edge_name = Name::from(edge_kind);
151            let found = instance
152                .arcs
153                .iter()
154                .any(|(src, _, edge)| *src == node_id && edge.kind == edge_name);
155            Ok(Literal::Bool(found))
156        }
157        BuiltinOp::EdgeCount => {
158            // edge_count(node_ref) → int
159            let node_id = resolve_node_ref(&args[0], context_node_id)?;
160            let count = instance
161                .arcs
162                .iter()
163                .filter(|(src, _, _)| *src == node_id)
164                .count();
165            #[allow(clippy::cast_possible_wrap)]
166            Ok(Literal::Int(count as i64))
167        }
168        BuiltinOp::Anchor => {
169            // anchor(node_ref) → string
170            let node_id = resolve_node_ref(&args[0], context_node_id)?;
171            instance
172                .nodes
173                .get(&node_id)
174                .map_or(Ok(Literal::Null), |node| {
175                    Ok(Literal::Str(node.anchor.as_ref().into()))
176                })
177        }
178        _ => Ok(Literal::Null),
179    }
180}
181
182/// Resolve a node reference from a literal value.
183///
184/// Accepts either an integer (direct node ID) or the string `"self"`
185/// (resolved to `context_node_id`).
186fn resolve_node_ref(
187    lit: &Literal,
188    context_node_id: Option<u32>,
189) -> Result<u32, panproto_expr::ExprError> {
190    match lit {
191        Literal::Int(id) => u32::try_from(*id).map_err(|_| panproto_expr::ExprError::TypeError {
192            expected: "non-negative int fitting u32".into(),
193            got: format!("{id}"),
194        }),
195        Literal::Str(s) if s == "self" => context_node_id.ok_or_else(|| {
196            panproto_expr::ExprError::UnboundVariable("self (no context node)".into())
197        }),
198        _ => Err(panproto_expr::ExprError::TypeError {
199            expected: "int or \"self\"".into(),
200            got: lit.type_name().into(),
201        }),
202    }
203}
204
205/// Convert a node's data to a Literal for expression evaluation.
206///
207/// Produces a Record with the node's `extra_fields`, anchor, and id.
208fn node_to_literal(instance: &WInstance, node_id: u32) -> Literal {
209    let Some(node) = instance.nodes.get(&node_id) else {
210        return Literal::Null;
211    };
212    let mut fields: Vec<(Arc<str>, Literal)> = Vec::new();
213    fields.push((Arc::from("_id"), Literal::Int(i64::from(node.id))));
214    fields.push((
215        Arc::from("_anchor"),
216        Literal::Str(node.anchor.as_ref().into()),
217    ));
218    for (key, val) in &node.extra_fields {
219        fields.push((Arc::from(key.as_str()), value_to_literal(val)));
220    }
221    Literal::Record(fields)
222}
223
224/// Convert an instance Value to a Literal.
225fn value_to_literal(val: &Value) -> Literal {
226    crate::wtype::value_to_expr_literal(val)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::collections::HashMap;
233
234    use crate::metadata::Node;
235    use crate::value::Value;
236    use panproto_schema::Edge as SchemaEdge;
237
238    fn make_instance() -> WInstance {
239        let mut nodes = HashMap::new();
240        let mut root = Node::new(0, "document");
241        root.extra_fields
242            .insert("title".into(), Value::Str("Test".into()));
243        nodes.insert(0, root);
244
245        let mut child = Node::new(1, "paragraph");
246        child
247            .extra_fields
248            .insert("text".into(), Value::Str("Hello".into()));
249        nodes.insert(1, child);
250
251        let edge = SchemaEdge {
252            src: Name::from("document"),
253            tgt: Name::from("paragraph"),
254            kind: Name::from("body"),
255            name: None,
256        };
257
258        WInstance::new(nodes, vec![(0, 1, edge)], vec![], 0, Name::from("document"))
259    }
260
261    /// Helper to evaluate and assert success.
262    fn eval_ok(expr: &Expr, inst: &WInstance, ctx: Option<u32>) -> Literal {
263        let env = panproto_expr::Env::new();
264        let config = EvalConfig::default();
265        let result = eval_with_instance(expr, &env, &config, inst, ctx);
266        assert!(result.is_ok(), "eval failed: {result:?}");
267        result.unwrap_or(Literal::Null)
268    }
269
270    #[test]
271    fn edge_follows_arc() {
272        let inst = make_instance();
273        let expr = Expr::Builtin(
274            BuiltinOp::Edge,
275            vec![
276                Expr::Lit(Literal::Int(0)),
277                Expr::Lit(Literal::Str("body".into())),
278            ],
279        );
280        let result = eval_ok(&expr, &inst, Some(0));
281        assert!(matches!(result, Literal::Record(_)));
282    }
283
284    #[test]
285    fn children_returns_list() {
286        let inst = make_instance();
287        let expr = Expr::Builtin(BuiltinOp::Children, vec![Expr::Lit(Literal::Int(0))]);
288        let result = eval_ok(&expr, &inst, Some(0));
289        assert!(matches!(result, Literal::List(ref items) if items.len() == 1));
290    }
291
292    #[test]
293    fn has_edge_true() {
294        let inst = make_instance();
295        let expr = Expr::Builtin(
296            BuiltinOp::HasEdge,
297            vec![
298                Expr::Lit(Literal::Int(0)),
299                Expr::Lit(Literal::Str("body".into())),
300            ],
301        );
302        assert_eq!(eval_ok(&expr, &inst, Some(0)), Literal::Bool(true));
303    }
304
305    #[test]
306    fn has_edge_false() {
307        let inst = make_instance();
308        let expr = Expr::Builtin(
309            BuiltinOp::HasEdge,
310            vec![
311                Expr::Lit(Literal::Int(0)),
312                Expr::Lit(Literal::Str("nonexistent".into())),
313            ],
314        );
315        assert_eq!(eval_ok(&expr, &inst, Some(0)), Literal::Bool(false));
316    }
317
318    #[test]
319    fn edge_count_works() {
320        let inst = make_instance();
321        let expr = Expr::Builtin(BuiltinOp::EdgeCount, vec![Expr::Lit(Literal::Int(0))]);
322        assert_eq!(eval_ok(&expr, &inst, Some(0)), Literal::Int(1));
323    }
324
325    #[test]
326    fn anchor_returns_kind() {
327        let inst = make_instance();
328        let expr = Expr::Builtin(BuiltinOp::Anchor, vec![Expr::Lit(Literal::Int(1))]);
329        assert_eq!(
330            eval_ok(&expr, &inst, Some(0)),
331            Literal::Str("paragraph".into())
332        );
333    }
334}