Skip to main content

acts_expr/
lib.rs

1//! A small expression evaluator for the expressions a workflow carries.
2//!
3//! `acts-expr` compiles a source string once into an [`Expr`] and evaluates it
4//! against a [`Context`] as often as needed — the same compiled expression can
5//! be evaluated from any number of threads with different contexts. It has no
6//! dependencies, allocates one `Vec` per compiled expression (nodes reference
7//! their children by index, not by pointer) and never touches a global.
8//!
9//! # The expression surface
10//!
11//! - literals: `12`, `1.5`, `1e3`, `'text'`, `"text"`, `true`, `false`, `null`
12//! - arithmetic: `+ - * / %`, unary `-`, with `( )` to group
13//! - comparison: `== != < <= > >=` (which do not chain, so `1 < 2 < 3` is a
14//!   parse error rather than a type error)
15//! - logic: `&&`, `||`, unary `!`, both short-circuiting
16//! - access: `a.b`, `a['b']`, `list[0]`, and calls `f(x)`, `a.f(x)`
17//!
18//! `==` compares numbers across `int`, `uint` and `float`, lists and objects
19//! element-wise, and values of different kinds are not equal (so a workflow
20//! that compares a missing value with a string is `false`, not an error).
21//! Arithmetic requires numbers, `+` also concatenates two strings, and
22//! `&& || !` require bools. Two ints stay an int (so `/` truncates and `%` is
23//! the remainder) and an overflow is an error; a `uint` — what JSON gives a
24//! host for a value above `i64::MAX` — stays exact against an int, and any
25//! float either side makes the result a float.
26//!
27//! # Built-in methods
28//!
29//! A value carries a few methods of its own, so the common test needs nothing
30//! injected:
31//!
32//! - a string: `length()`, `startsWith(p)`, `endsWith(p)`, `indexOf(p)`,
33//!   `contains(p)`, and `is_match(pattern)` — a regular expression, behind the
34//!   `regex` feature
35//! - a list: `length()`, `contains(value)` (an element, compared by value)
36//! - a map: `length()` (its entries)
37//!
38//! `length()` and `indexOf()` count characters, not bytes. These are found
39//! last: the object's own method, then a function of that name in the context,
40//! then the built-in — so a host that injects `length` defines what it means.
41//!
42//! # Injection
43//!
44//! Everything an expression reads is in the [`Context`]: scalars, lists and
45//! objects ([`Value::Map`]) as values, and methods as [`Value::Function`]s.
46//! A function is called as `f(x)`, or as a method with its receiver first —
47//! `x.f()` — and a function stored *inside* an object is called that way
48//! through the object, so an injected object carries its own methods.
49//!
50//! ```rust
51//! use acts_expr::{Context, Expr, Value};
52//!
53//! let mut context = Context::new();
54//! context
55//!     .set("count", 3)
56//!     .set("status", "ok")
57//!     .set("step1", Value::map([
58//!         ("value", Value::from(7)),
59//!         ("id", Value::from("step1")),
60//!     ]))
61//!     .set(
62//!         "upper",
63//!         Value::function(|args| match args {
64//!             [Value::Str(text)] => Ok(Value::from(text.to_uppercase())),
65//!             _ => Err(acts_expr::Error::Type { operation: "upper", got: "other" }),
66//!         }),
67//!     );
68//!
69//! let expr = Expr::compile("step1.value + count * 2 >= 13 && status != 'bad'")?;
70//! assert_eq!(expr.eval(&context)?, Value::Bool(true));
71//!
72//! // A method: `upper` from the context, called with `status` as its receiver.
73//! let name = Expr::compile("status.upper()")?;
74//! assert_eq!(name.eval(&context)?, Value::from("OK"));
75//! # Ok::<_, acts_expr::Error>(())
76//! ```
77//!
78//! # Deliberately not in the surface
79//!
80//! The evaluator answers the expressions a workflow writes — conditions,
81//! arithmetic on task data, string and object access — and nothing more. There
82//! is no assignment, no statement, no ternary (`?:`), no collection literal
83//! (`[...]`, `{...}`), no `in`, no comprehension, and no method beyond the
84//! built-ins above — every other method is one the host injected. Each of the
85//! absent forms is rejected with a message that says so.
86
87mod error;
88mod eval;
89mod lexer;
90mod methods;
91mod parser;
92mod value;
93
94#[cfg(test)]
95mod tests;
96
97pub use error::{Error, Result};
98pub use eval::Context;
99pub use value::{Map, NativeFunction, Value};
100
101use parser::Node;
102
103/// A compiled expression.
104///
105/// Compiling is the only step that reads the source; evaluation is a walk over
106/// the compiled nodes, so a host that evaluates the same expression per task —
107/// a step condition, a parameter filled per process — compiles it once and
108/// keeps the [`Expr`].
109#[derive(Debug, Clone)]
110pub struct Expr {
111    nodes: Vec<Node>,
112    root: u32,
113}
114
115impl Expr {
116    /// Compile `source`, or return the byte offset the parse stopped at.
117    pub fn compile(source: &str) -> Result<Expr> {
118        let (nodes, root) = parser::Parser::new(source)?.parse()?;
119
120        Ok(Expr { nodes, root })
121    }
122
123    /// Evaluate against `context`.
124    pub fn eval(&self, context: &Context) -> Result<Value> {
125        eval::eval(&self.nodes, self.root, context)
126    }
127
128    /// The names this expression reads, deduplicated and sorted: variables,
129    /// called functions, and the names of called methods (which a receiver may
130    /// answer itself before the context is consulted).
131    ///
132    /// A host that injects on demand — rather than filling a context with
133    /// everything an expression *might* read — injects exactly these.
134    pub fn references(&self) -> Vec<&str> {
135        let mut names = Vec::new();
136
137        for node in &self.nodes {
138            match node {
139                Node::Ident(name) => names.push(name.as_ref()),
140                Node::Call(name, _) | Node::Method(_, name, _) => names.push(name.as_ref()),
141                _ => {}
142            }
143        }
144
145        names.sort_unstable();
146        names.dedup();
147        names
148    }
149
150    /// How many nodes the compiled expression holds, for a host that bounds
151    /// what it accepts.
152    pub fn len(&self) -> usize {
153        self.nodes.len()
154    }
155
156    pub fn is_empty(&self) -> bool {
157        self.nodes.is_empty()
158    }
159}
160
161/// Compile and evaluate in one step, for a caller that has no reason to keep
162/// the compiled expression.
163pub fn eval(source: &str, context: &Context) -> Result<Value> {
164    Expr::compile(source)?.eval(context)
165}
166
167#[cfg(test)]
168mod unit_tests {
169    use super::*;
170
171    #[test]
172    fn a_compiled_expression_reports_its_size() {
173        let expr = Expr::compile("1 + 2").unwrap();
174        assert_eq!(expr.len(), 3);
175        assert!(!expr.is_empty());
176
177        let clone = expr.clone();
178        assert_eq!(clone.eval(&Context::new()).unwrap(), Value::Int(3));
179        assert!(format!("{expr:?}").starts_with("Expr {"));
180    }
181
182    #[test]
183    fn the_one_step_eval_compiles_first() {
184        let mut context = Context::new();
185        context.set("a", 1);
186        assert_eq!(eval("a + 1", &context).unwrap(), Value::Int(2));
187        assert_eq!(eval("a +", &context).unwrap_err().position(), Some(3));
188    }
189}