Skip to main content

brep_kernel/feature_pipeline/
expression.rs

1//! A deterministic recursive-descent evaluator for the practical subset of the
2//! app's expression DSL (user directive: move expressions to Rust).
3//!
4//! The previous app evaluated expressions with a JavaScript `Function()` reading a
5//! `configurator` object and a `resolution` default (see
6//! `PartHistory.buildExpressionSource`). The REAL expressions are arithmetic, so
7//! this ports exactly that arithmetic surface — no arbitrary code eval. Anything outside
8//! the supported grammar is a clear `Err`, never a silent wrong answer.
9//!
10//! Grammar (recursive descent, standard precedence):
11//! ```text
12//!   source      := statement*
13//!   statement   := IDENT '=' expr ';'
14//!   expr        := term (('+' | '-') term)*
15//!   term        := unary (('*' | '/' | '%') unary)*
16//!   unary       := ('-')? primary
17//!   primary     := NUMBER
18//!                | '(' expr ')'
19//!                | dotted ('(' args ')')?
20//!   dotted      := IDENT ('.' IDENT)*
21//!   args        := (expr (',' expr)*)?
22//! ```
23//! Supported: f64 literals (incl. scientific notation), identifiers, `+ - * / %`,
24//! unary `-`, parentheses, member access `configurator.field`, and a `Math.*`
25//! subset: constants `PI`, `E`; unary `sin cos tan sqrt abs floor ceil round`;
26//! `pow(a,b)`; variadic `min`/`max`. `//` line comments are skipped.
27
28use std::collections::HashMap;
29
30/// A value in the expression environment: a scalar, or the injected
31/// `configurator` object (a flat `field -> f64` map, matching what the
32/// `configurator = <values>` prelude exposes to expressions).
33#[derive(Debug, Clone)]
34enum EvalValue {
35    Scalar(f64),
36    Object(HashMap<String, f64>),
37}
38
39/// The expression environment: the variable bindings produced by evaluating the
40/// prelude (`resolution = 32`), the injected `configurator` object, and the
41/// user's `expressions` statements in order.
42#[derive(Debug, Clone, Default)]
43pub struct Env {
44    vars: HashMap<String, EvalValue>,
45    /// If the env failed to build, every `eval()` returns this error. Numeric
46    /// literal params still pass through their own `param_f64` path without ever
47    /// calling `eval`, so an expression-free history is unaffected by a poison.
48    poison: Option<String>,
49}
50
51impl Env {
52    /// Build the environment: prelude `resolution = 32`, then the injected
53    /// `configurator` object, then the user's `expressions` statements in order
54    /// (users may overwrite `resolution`). `configurator_json` may be the full
55    /// configurator state (`{ values: {...}, ... }`) or a bare values map — the
56    /// `.values` sub-object is used when present, matching the prelude.
57    pub fn build(expressions: &str, configurator_json: &serde_json::Value) -> Result<Env, String> {
58        let mut env = Env::default();
59        env.vars
60            .insert("resolution".to_string(), EvalValue::Scalar(32.0));
61        env.vars.insert(
62            "configurator".to_string(),
63            EvalValue::Object(configurator_values(configurator_json)),
64        );
65        env.run_statements(expressions)?;
66        Ok(env)
67    }
68
69    /// A poisoned environment: `eval()` reports `msg`; numeric params still pass
70    /// through. Lets `execute_history` keep the `-> HistoryResult` signature and
71    /// surface an expression-block error per-feature instead of globally.
72    pub fn poisoned(msg: String) -> Env {
73        Env {
74            poison: Some(msg),
75            ..Env::default()
76        }
77    }
78
79    /// Evaluate a single expression string against the environment.
80    pub fn eval(&self, source: &str) -> Result<f64, String> {
81        if let Some(poison) = &self.poison {
82            return Err(format!("expression environment failed to build: {poison}"));
83        }
84        let tokens = tokenize(source)?;
85        let mut parser = Parser::new(&tokens, self);
86        let value = parser.parse_expr()?;
87        parser.expect_eof()?;
88        Ok(value)
89    }
90
91    /// Lookup a bound scalar variable (test/introspection helper).
92    pub fn get(&self, name: &str) -> Option<f64> {
93        match self.vars.get(name) {
94            Some(EvalValue::Scalar(value)) => Some(*value),
95            _ => None,
96        }
97    }
98
99    fn run_statements(&mut self, source: &str) -> Result<(), String> {
100        let tokens = tokenize(source)?;
101        let mut index = 0;
102        while index < tokens.len() && tokens[index] != Token::Eof {
103            // statement := IDENT '=' expr ';'
104            let name = match &tokens[index] {
105                Token::Ident(name) => name.clone(),
106                other => {
107                    return Err(format!(
108                        "expected an identifier at the start of a statement, found {other:?}"
109                    ))
110                }
111            };
112            index += 1;
113            if tokens.get(index) != Some(&Token::Assign) {
114                return Err(format!("expected '=' after `{name}` in expression statement"));
115            }
116            index += 1;
117            // Collect the RHS tokens up to the next ';'.
118            let start = index;
119            while index < tokens.len()
120                && tokens[index] != Token::Semi
121                && tokens[index] != Token::Eof
122            {
123                index += 1;
124            }
125            let rhs = &tokens[start..index];
126            let mut parser = Parser::new(rhs, self);
127            let value = parser.parse_expr()?;
128            parser.expect_eof()?;
129            self.vars.insert(name, EvalValue::Scalar(value));
130            // Consume the terminating ';' if present.
131            if tokens.get(index) == Some(&Token::Semi) {
132                index += 1;
133            }
134        }
135        Ok(())
136    }
137}
138
139/// Extract the flat `field -> f64` values map from a configurator JSON value,
140/// preferring a `.values` sub-object (the normalized configurator state shape).
141/// Non-numeric fields are dropped (an expression referencing one errors clearly
142/// at member-access time).
143fn configurator_values(configurator: &serde_json::Value) -> HashMap<String, f64> {
144    let source = configurator
145        .get("values")
146        .filter(|value| value.is_object())
147        .unwrap_or(configurator);
148    let mut map = HashMap::new();
149    if let Some(object) = source.as_object() {
150        for (key, value) in object {
151            if let Some(number) = value_as_f64(value) {
152                map.insert(key.clone(), number);
153            }
154        }
155    }
156    map
157}
158
159fn value_as_f64(value: &serde_json::Value) -> Option<f64> {
160    match value {
161        serde_json::Value::Number(number) => number.as_f64(),
162        serde_json::Value::String(text) => text.trim().parse::<f64>().ok(),
163        serde_json::Value::Bool(flag) => Some(if *flag { 1.0 } else { 0.0 }),
164        _ => None,
165    }
166}
167
168// ---------------------------------------------------------------------------
169// Tokenizer
170// ---------------------------------------------------------------------------
171
172#[derive(Debug, Clone, PartialEq)]
173enum Token {
174    Number(f64),
175    Ident(String),
176    Plus,
177    Minus,
178    Star,
179    Slash,
180    Percent,
181    LParen,
182    RParen,
183    Dot,
184    Comma,
185    Assign,
186    Semi,
187    Eof,
188}
189
190fn tokenize(source: &str) -> Result<Vec<Token>, String> {
191    let bytes = source.as_bytes();
192    let mut tokens = Vec::new();
193    let mut i = 0;
194    while i < bytes.len() {
195        let c = bytes[i] as char;
196        if c.is_whitespace() {
197            i += 1;
198            continue;
199        }
200        // `//` line comment.
201        if c == '/' && i + 1 < bytes.len() && bytes[i + 1] as char == '/' {
202            while i < bytes.len() && bytes[i] as char != '\n' {
203                i += 1;
204            }
205            continue;
206        }
207        match c {
208            '+' => {
209                tokens.push(Token::Plus);
210                i += 1;
211            }
212            '-' => {
213                tokens.push(Token::Minus);
214                i += 1;
215            }
216            '*' => {
217                tokens.push(Token::Star);
218                i += 1;
219            }
220            '/' => {
221                tokens.push(Token::Slash);
222                i += 1;
223            }
224            '%' => {
225                tokens.push(Token::Percent);
226                i += 1;
227            }
228            '(' => {
229                tokens.push(Token::LParen);
230                i += 1;
231            }
232            ')' => {
233                tokens.push(Token::RParen);
234                i += 1;
235            }
236            '.' if !next_is_digit(bytes, i + 1) => {
237                tokens.push(Token::Dot);
238                i += 1;
239            }
240            ',' => {
241                tokens.push(Token::Comma);
242                i += 1;
243            }
244            '=' => {
245                tokens.push(Token::Assign);
246                i += 1;
247            }
248            ';' => {
249                tokens.push(Token::Semi);
250                i += 1;
251            }
252            _ if c.is_ascii_digit() || c == '.' => {
253                let (number, next) = scan_number(bytes, i)?;
254                tokens.push(Token::Number(number));
255                i = next;
256            }
257            _ if c.is_ascii_alphabetic() || c == '_' || c == '$' => {
258                let start = i;
259                while i < bytes.len() {
260                    let ch = bytes[i] as char;
261                    if ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' {
262                        i += 1;
263                    } else {
264                        break;
265                    }
266                }
267                tokens.push(Token::Ident(source[start..i].to_string()));
268            }
269            _ => return Err(format!("unexpected character `{c}` in expression")),
270        }
271    }
272    tokens.push(Token::Eof);
273    Ok(tokens)
274}
275
276fn next_is_digit(bytes: &[u8], i: usize) -> bool {
277    i < bytes.len() && (bytes[i] as char).is_ascii_digit()
278}
279
280/// Scan a decimal number with optional fraction and scientific exponent.
281fn scan_number(bytes: &[u8], start: usize) -> Result<(f64, usize), String> {
282    let mut i = start;
283    while next_is_digit(bytes, i) {
284        i += 1;
285    }
286    if i < bytes.len() && bytes[i] as char == '.' {
287        i += 1;
288        while next_is_digit(bytes, i) {
289            i += 1;
290        }
291    }
292    if i < bytes.len() && (bytes[i] as char == 'e' || bytes[i] as char == 'E') {
293        let mut j = i + 1;
294        if j < bytes.len() && (bytes[j] as char == '+' || bytes[j] as char == '-') {
295            j += 1;
296        }
297        if next_is_digit(bytes, j) {
298            i = j;
299            while next_is_digit(bytes, i) {
300                i += 1;
301            }
302        }
303    }
304    let text = std::str::from_utf8(&bytes[start..i]).map_err(|error| error.to_string())?;
305    let value = text
306        .parse::<f64>()
307        .map_err(|_| format!("invalid number literal `{text}`"))?;
308    Ok((value, i))
309}
310
311// ---------------------------------------------------------------------------
312// Parser + evaluator (evaluated directly against a fixed env)
313// ---------------------------------------------------------------------------
314
315struct Parser<'a> {
316    tokens: &'a [Token],
317    pos: usize,
318    env: &'a Env,
319}
320
321impl<'a> Parser<'a> {
322    fn new(tokens: &'a [Token], env: &'a Env) -> Self {
323        Self {
324            tokens,
325            pos: 0,
326            env,
327        }
328    }
329
330    fn peek(&self) -> &Token {
331        self.tokens.get(self.pos).unwrap_or(&Token::Eof)
332    }
333
334    fn advance(&mut self) -> Token {
335        let token = self.tokens.get(self.pos).cloned().unwrap_or(Token::Eof);
336        self.pos += 1;
337        token
338    }
339
340    fn expect_eof(&self) -> Result<(), String> {
341        match self.peek() {
342            Token::Eof => Ok(()),
343            other => Err(format!("unexpected trailing token {other:?} in expression")),
344        }
345    }
346
347    fn parse_expr(&mut self) -> Result<f64, String> {
348        let mut value = self.parse_term()?;
349        loop {
350            match self.peek() {
351                Token::Plus => {
352                    self.advance();
353                    value += self.parse_term()?;
354                }
355                Token::Minus => {
356                    self.advance();
357                    value -= self.parse_term()?;
358                }
359                _ => break,
360            }
361        }
362        Ok(value)
363    }
364
365    fn parse_term(&mut self) -> Result<f64, String> {
366        let mut value = self.parse_unary()?;
367        loop {
368            match self.peek() {
369                Token::Star => {
370                    self.advance();
371                    value *= self.parse_unary()?;
372                }
373                Token::Slash => {
374                    self.advance();
375                    value /= self.parse_unary()?;
376                }
377                Token::Percent => {
378                    self.advance();
379                    value %= self.parse_unary()?;
380                }
381                _ => break,
382            }
383        }
384        Ok(value)
385    }
386
387    fn parse_unary(&mut self) -> Result<f64, String> {
388        if self.peek() == &Token::Minus {
389            self.advance();
390            return Ok(-self.parse_unary()?);
391        }
392        if self.peek() == &Token::Plus {
393            self.advance();
394            return self.parse_unary();
395        }
396        self.parse_primary()
397    }
398
399    fn parse_primary(&mut self) -> Result<f64, String> {
400        match self.advance() {
401            Token::Number(value) => Ok(value),
402            Token::LParen => {
403                let value = self.parse_expr()?;
404                if self.advance() != Token::RParen {
405                    return Err("expected ')'".to_string());
406                }
407                Ok(value)
408            }
409            Token::Ident(first) => {
410                let mut parts = vec![first];
411                while self.peek() == &Token::Dot {
412                    self.advance();
413                    match self.advance() {
414                        Token::Ident(name) => parts.push(name),
415                        other => {
416                            return Err(format!("expected identifier after '.', found {other:?}"))
417                        }
418                    }
419                }
420                if self.peek() == &Token::LParen {
421                    self.advance();
422                    let args = self.parse_args()?;
423                    self.eval_call(&parts.join("."), &args)
424                } else {
425                    self.eval_reference(&parts)
426                }
427            }
428            other => Err(format!("unexpected token {other:?} in expression")),
429        }
430    }
431
432    fn parse_args(&mut self) -> Result<Vec<f64>, String> {
433        let mut args = Vec::new();
434        if self.peek() == &Token::RParen {
435            self.advance();
436            return Ok(args);
437        }
438        loop {
439            args.push(self.parse_expr()?);
440            match self.advance() {
441                Token::Comma => continue,
442                Token::RParen => break,
443                other => return Err(format!("expected ',' or ')' in argument list, found {other:?}")),
444            }
445        }
446        Ok(args)
447    }
448
449    fn eval_reference(&self, parts: &[String]) -> Result<f64, String> {
450        match parts.len() {
451            1 => match self.env.vars.get(&parts[0]) {
452                Some(EvalValue::Scalar(value)) => Ok(*value),
453                Some(EvalValue::Object(_)) => {
454                    Err(format!("`{}` is an object, not a number", parts[0]))
455                }
456                None => Err(format!("unknown identifier `{}`", parts[0])),
457            },
458            2 => {
459                if parts[0] == "Math" {
460                    return match parts[1].as_str() {
461                        "PI" => Ok(std::f64::consts::PI),
462                        "E" => Ok(std::f64::consts::E),
463                        other => Err(format!("unsupported Math constant `Math.{other}`")),
464                    };
465                }
466                match self.env.vars.get(&parts[0]) {
467                    Some(EvalValue::Object(map)) => map
468                        .get(&parts[1])
469                        .copied()
470                        .ok_or_else(|| format!("`{}.{}` is not a number", parts[0], parts[1])),
471                    Some(EvalValue::Scalar(_)) => {
472                        Err(format!("`{}` is a number, not an object", parts[0]))
473                    }
474                    None => Err(format!("unknown identifier `{}`", parts[0])),
475                }
476            }
477            _ => Err(format!("unsupported member access `{}`", parts.join("."))),
478        }
479    }
480
481    fn eval_call(&self, name: &str, args: &[f64]) -> Result<f64, String> {
482        let expect = |arity: usize| -> Result<(), String> {
483            if args.len() == arity {
484                Ok(())
485            } else {
486                Err(format!(
487                    "`{name}` expects {arity} argument(s), got {}",
488                    args.len()
489                ))
490            }
491        };
492        match name {
493            "Math.sin" => {
494                expect(1)?;
495                Ok(args[0].sin())
496            }
497            "Math.cos" => {
498                expect(1)?;
499                Ok(args[0].cos())
500            }
501            "Math.tan" => {
502                expect(1)?;
503                Ok(args[0].tan())
504            }
505            "Math.sqrt" => {
506                expect(1)?;
507                Ok(args[0].sqrt())
508            }
509            "Math.abs" => {
510                expect(1)?;
511                Ok(args[0].abs())
512            }
513            "Math.floor" => {
514                expect(1)?;
515                Ok(args[0].floor())
516            }
517            "Math.ceil" => {
518                expect(1)?;
519                Ok(args[0].ceil())
520            }
521            "Math.round" => {
522                expect(1)?;
523                // JavaScript Math.round rounds half up (toward +inf); Rust round() is
524                // half-away-from-zero. Match JavaScript.
525                Ok((args[0] + 0.5).floor())
526            }
527            "Math.pow" => {
528                expect(2)?;
529                Ok(args[0].powf(args[1]))
530            }
531            "Math.min" => {
532                if args.is_empty() {
533                    return Ok(f64::INFINITY);
534                }
535                Ok(args.iter().copied().fold(f64::INFINITY, f64::min))
536            }
537            "Math.max" => {
538                if args.is_empty() {
539                    return Ok(f64::NEG_INFINITY);
540                }
541                Ok(args.iter().copied().fold(f64::NEG_INFINITY, f64::max))
542            }
543            other => Err(format!("unsupported function `{other}`")),
544        }
545    }
546}
547
548// BREP private tests: 6c9ff62a18f24739