Skip to main content

appcore_filemaker/
expression.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: expression.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded expression contracts and behavior for this crate.
12
13use crate::{DataValue, ErrorCode, FileMakerError, Result};
14
15/// A parsed bounded expression without IO or arbitrary evaluation.
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct Expression {
18    source: String,
19}
20
21impl Expression {
22    /// Parses a supported deterministic expression.
23    pub fn parse(source: impl Into<String>) -> Result<Self> {
24        let source = source.into();
25        if source.is_empty() || source.len() > 4_096 {
26            return Err(expression_error("expression is empty or too long"));
27        }
28        if source.contains(['(', ')', ';', '`']) {
29            return Err(expression_error(
30                "functions and arbitrary evaluation are not supported",
31            ));
32        }
33        Ok(Self { source })
34    }
35
36    /// Returns the original normalized expression.
37    #[must_use]
38    pub fn source(&self) -> &str {
39        &self.source
40    }
41
42    /// Returns root data fields referenced by the expression in lexical order.
43    ///
44    /// This is used to build the computed-field dependency graph without
45    /// evaluating the expression or consulting external state.
46    pub fn dependencies(&self) -> Vec<String> {
47        let mut dependencies = std::collections::BTreeSet::new();
48        collect_dependencies(self.source.trim(), &mut dependencies);
49        dependencies.into_iter().collect()
50    }
51
52    /// Evaluates path lookup, literals, comparisons, boolean operators, and concatenation.
53    pub fn evaluate(&self, root: &DataValue, budget: &mut ExpressionBudget) -> Result<DataValue> {
54        evaluate_expression(self.source.trim(), root, budget)
55    }
56}
57
58fn collect_dependencies(source: &str, dependencies: &mut std::collections::BTreeSet<String>) {
59    for operator in ["||", "&&", "==", "!=", "+"] {
60        if let Some((left, right)) = split_operator(source, operator) {
61            collect_dependencies(left.trim(), dependencies);
62            collect_dependencies(right.trim(), dependencies);
63            return;
64        }
65    }
66    let atom = source.trim();
67    if atom.is_empty()
68        || atom.starts_with('"')
69        || matches!(atom, "true" | "false" | "null")
70        || atom.parse::<i64>().is_ok()
71    {
72        return;
73    }
74    let path = atom.strip_prefix("data.").unwrap_or(atom);
75    if let Some(root) = path.split('.').next().filter(|root| !root.is_empty()) {
76        dependencies.insert(root.to_owned());
77    }
78}
79
80/// Per-expression operation budget.
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct ExpressionBudget {
83    remaining: usize,
84}
85
86impl ExpressionBudget {
87    /// Creates a non-zero operation budget.
88    pub fn new(max_steps: usize) -> Result<Self> {
89        if max_steps == 0 {
90            return Err(FileMakerError::new(
91                ErrorCode::LimitExceeded,
92                "expression step budget must be non-zero",
93            ));
94        }
95        Ok(Self {
96            remaining: max_steps,
97        })
98    }
99
100    fn step(&mut self) -> Result<()> {
101        self.remaining = self.remaining.checked_sub(1).ok_or_else(|| {
102            FileMakerError::new(ErrorCode::LimitExceeded, "expression step budget exceeded")
103        })?;
104        Ok(())
105    }
106}
107
108fn evaluate_expression(
109    source: &str,
110    root: &DataValue,
111    budget: &mut ExpressionBudget,
112) -> Result<DataValue> {
113    budget.step()?;
114    if let Some((left, right)) = split_operator(source, "||") {
115        return Ok(DataValue::Boolean(
116            evaluate_expression(left, root, budget)?.is_truthy()
117                || evaluate_expression(right, root, budget)?.is_truthy(),
118        ));
119    }
120    if let Some((left, right)) = split_operator(source, "&&") {
121        return Ok(DataValue::Boolean(
122            evaluate_expression(left, root, budget)?.is_truthy()
123                && evaluate_expression(right, root, budget)?.is_truthy(),
124        ));
125    }
126    if let Some((left, right)) = split_operator(source, "==") {
127        return Ok(DataValue::Boolean(
128            evaluate_expression(left, root, budget)? == evaluate_expression(right, root, budget)?,
129        ));
130    }
131    if let Some((left, right)) = split_operator(source, "!=") {
132        return Ok(DataValue::Boolean(
133            evaluate_expression(left, root, budget)? != evaluate_expression(right, root, budget)?,
134        ));
135    }
136    if let Some((left, right)) = split_operator(source, "+") {
137        let left = evaluate_expression(left, root, budget)?;
138        let right = evaluate_expression(right, root, budget)?;
139        return add(left, right);
140    }
141    atom(source, root)
142}
143
144fn atom(source: &str, root: &DataValue) -> Result<DataValue> {
145    let source = source.trim();
146    if let Some(value) = source
147        .strip_prefix('"')
148        .and_then(|rest| rest.strip_suffix('"'))
149    {
150        return Ok(DataValue::String(value.to_owned()));
151    }
152    match source {
153        "true" => return Ok(DataValue::Boolean(true)),
154        "false" => return Ok(DataValue::Boolean(false)),
155        "null" => return Ok(DataValue::Null),
156        _ => {}
157    }
158    if let Ok(integer) = source.parse::<i64>() {
159        return Ok(DataValue::Integer(integer));
160    }
161    let path = source.strip_prefix("data.").unwrap_or(source);
162    root.get_path(path)
163        .cloned()
164        .ok_or_else(|| expression_error(format!("binding path `{path}` was not found")))
165}
166
167fn add(left: DataValue, right: DataValue) -> Result<DataValue> {
168    match (left, right) {
169        (DataValue::Integer(left), DataValue::Integer(right)) => left
170            .checked_add(right)
171            .map(DataValue::Integer)
172            .ok_or_else(|| expression_error("integer expression overflow")),
173        (left, right) => Ok(DataValue::String(format!(
174            "{}{}",
175            left.display(),
176            right.display()
177        ))),
178    }
179}
180
181fn split_operator<'a>(source: &'a str, operator: &str) -> Option<(&'a str, &'a str)> {
182    let mut quoted = false;
183    let bytes = source.as_bytes();
184    let operator = operator.as_bytes();
185    let mut index = 0;
186    while index + operator.len() <= bytes.len() {
187        if bytes[index] == b'"' {
188            quoted = !quoted;
189        }
190        if !quoted && &bytes[index..index + operator.len()] == operator {
191            return Some((&source[..index], &source[index + operator.len()..]));
192        }
193        index += 1;
194    }
195    None
196}
197
198fn expression_error(message: impl Into<String>) -> FileMakerError {
199    FileMakerError::new(ErrorCode::DataType, message)
200}
201
202#[cfg(test)]
203mod tests {
204    use std::collections::BTreeMap;
205
206    use super::*;
207
208    #[test]
209    fn evaluates_paths_and_bounded_operators() {
210        let root = DataValue::Object(BTreeMap::from([
211            ("name".to_owned(), DataValue::String("Ada".to_owned())),
212            ("active".to_owned(), DataValue::Boolean(true)),
213        ]));
214        let expression = Expression::parse("data.name + \"!\"").unwrap();
215        assert_eq!(
216            expression
217                .evaluate(&root, &mut ExpressionBudget::new(8).unwrap())
218                .unwrap(),
219            DataValue::String("Ada!".to_owned())
220        );
221        let condition = Expression::parse("active == true").unwrap();
222        assert!(condition
223            .evaluate(&root, &mut ExpressionBudget::new(8).unwrap())
224            .unwrap()
225            .is_truthy());
226        assert_eq!(
227            Expression::parse("data.name + active")
228                .unwrap()
229                .dependencies(),
230            vec!["active".to_owned(), "name".to_owned()]
231        );
232    }
233}