Skip to main content

aver/replay/
entry.rs

1//! Parsing and serialising of user-supplied entry-point expressions
2//! for record mode. Shared by the `aver run --expr` CLI path and the
3//! playground's custom-entry recording API.
4
5use std::sync::Arc;
6
7use crate::ast::{BinOp, Expr, Literal, Spanned};
8use crate::lexer::Lexer;
9use crate::parser::Parser;
10use crate::replay::{JsonValue, value_to_json};
11use crate::value::Value;
12
13/// Parse a CLI `--expr` / playground entry expression.
14///
15/// Accepts a single function call of the form `name(arg1, arg2, ...)` where
16/// each argument is a literal (`String` / `Int` / `Float` / `Bool` / `Unit`).
17/// Returns `(function_name, evaluated_args)`.
18///
19/// Complex argument expressions (arithmetic, record construction, nested
20/// calls) are rejected because recordings store entry args in the
21/// `input` JSON field which only round-trips values. Users needing richer
22/// inputs wrap the call in a helper function and point the entry at that.
23pub fn parse_entry_call(src: &str) -> Result<(String, Vec<Value>), String> {
24    let mut lexer = Lexer::new(src);
25    let tokens = lexer
26        .tokenize()
27        .map_err(|e| format!("lex error in entry expression: {}", e))?;
28    let mut parser = Parser::new(tokens);
29    let spanned = parser
30        .parse_expr()
31        .map_err(|e| format!("parse error in entry expression: {}", e))?;
32
33    let (target, args) = match spanned.node {
34        Expr::FnCall(target, args) => (target, args),
35        _ => {
36            return Err(
37                "entry expression must be a function call like 'loadTaxRate(\"PL\")'".to_string(),
38            );
39        }
40    };
41
42    let fn_name = match &target.node {
43        Expr::Ident(name) => name.clone(),
44        _ => {
45            return Err("entry expression target must be a bare function name \
46                 (qualified paths not supported yet)"
47                .to_string());
48        }
49    };
50
51    let mut values = Vec::with_capacity(args.len());
52    for (idx, arg) in args.iter().enumerate() {
53        let val = expr_to_value(&arg.node).map_err(|e| format!("arg #{}: {}", idx + 1, e))?;
54        values.push(val);
55    }
56
57    Ok((fn_name, values))
58}
59
60/// Convert a parsed expression to a runtime `Value` without running the VM.
61/// Supports literals, tuples, lists, and ADT constructors (both built-in
62/// Result/Option/None and user-defined variants). Rejects arbitrary
63/// expressions (function calls, arithmetic, variables, records) so entry
64/// arguments stay round-trippable through the replay JSON schema.
65fn expr_to_value(expr: &Expr) -> Result<Value, String> {
66    match expr {
67        Expr::Literal(lit) => Ok(literal_to_value(lit)),
68        // Aver parses `-N` as `BinOp(Sub, 0, N)`. Collapse that shape back
69        // into a negated literal so users can type `-300.0` in an --expr arg.
70        Expr::BinOp(BinOp::Sub, lhs, rhs) if matches!(lhs.node, Expr::Literal(Literal::Int(0))) => {
71            match &rhs.node {
72                Expr::Literal(Literal::Int(n)) => Ok(Value::Int(-*n)),
73                Expr::Literal(Literal::Float(f)) => Ok(Value::Float(-*f)),
74                _ => {
75                    Err("unary '-' must be applied to a numeric literal in entry args".to_string())
76                }
77            }
78        }
79        Expr::Ident(name) if is_upper_camel(name) => constructor_value(name, &[]),
80        Expr::Attr(_, _) if dotted_upper_path(expr).is_some() => {
81            let path = dotted_upper_path(expr).unwrap();
82            constructor_value(&path, &[])
83        }
84        Expr::Constructor(name, arg) => {
85            let fields = constructor_arg_fields(arg.as_deref())?;
86            constructor_value(name, &fields)
87        }
88        Expr::FnCall(target, args) if dotted_upper_path(&target.node).is_some() => {
89            let path = dotted_upper_path(&target.node).unwrap();
90            let mut fields = Vec::with_capacity(args.len());
91            for a in args {
92                fields.push(expr_to_value(&a.node)?);
93            }
94            constructor_value(&path, &fields)
95        }
96        Expr::List(items) => {
97            let mut out = Vec::with_capacity(items.len());
98            for e in items {
99                out.push(expr_to_value(&e.node)?);
100            }
101            Ok(Value::List(aver_rt::AverList::from_vec(out)))
102        }
103        Expr::Tuple(items) => {
104            let mut out = Vec::with_capacity(items.len());
105            for e in items {
106                out.push(expr_to_value(&e.node)?);
107            }
108            Ok(Value::Tuple(out))
109        }
110        _ => Err(
111            "unsupported expression shape (supported: literals, lists, tuples, \
112             ADT constructors like Shape.Circle(1.0) / Result.Ok(x) / Option.None)"
113                .to_string(),
114        ),
115    }
116}
117
118fn literal_to_value(lit: &Literal) -> Value {
119    match lit {
120        Literal::Int(i) => Value::Int(*i),
121        Literal::Float(f) => Value::Float(*f),
122        Literal::Str(s) => Value::Str(s.clone()),
123        Literal::Bool(b) => Value::Bool(*b),
124        Literal::Unit => Value::Unit,
125    }
126}
127
128fn is_upper_camel(name: &str) -> bool {
129    name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
130}
131
132fn dotted_upper_path(expr: &Expr) -> Option<String> {
133    match expr {
134        Expr::Ident(name) if is_upper_camel(name) => Some(name.clone()),
135        Expr::Attr(inner, field) if is_upper_camel(field) => {
136            let base = dotted_upper_path(&inner.node)?;
137            Some(format!("{}.{}", base, field))
138        }
139        _ => None,
140    }
141}
142
143fn constructor_arg_fields(arg: Option<&Spanned<Expr>>) -> Result<Vec<Value>, String> {
144    match arg {
145        None => Ok(Vec::new()),
146        Some(inner) => match &inner.node {
147            Expr::Tuple(items) => {
148                let mut out = Vec::with_capacity(items.len());
149                for e in items {
150                    out.push(expr_to_value(&e.node)?);
151                }
152                Ok(out)
153            }
154            _ => Ok(vec![expr_to_value(&inner.node)?]),
155        },
156    }
157}
158
159fn constructor_value(path: &str, fields: &[Value]) -> Result<Value, String> {
160    // Built-in wrapper constructors: accept both qualified (`Result.Ok`)
161    // and bare (`Ok`) forms mirroring what the parser produces.
162    match path {
163        "Result.Ok" | "Ok" => {
164            require_arity(path, fields, 1)?;
165            Ok(Value::Ok(Box::new(fields[0].clone())))
166        }
167        "Result.Err" | "Err" => {
168            require_arity(path, fields, 1)?;
169            Ok(Value::Err(Box::new(fields[0].clone())))
170        }
171        "Option.Some" | "Some" => {
172            require_arity(path, fields, 1)?;
173            Ok(Value::Some(Box::new(fields[0].clone())))
174        }
175        "Option.None" | "None" => {
176            require_arity(path, fields, 0)?;
177            Ok(Value::None)
178        }
179        _ => {
180            let mut parts = path.rsplitn(2, '.');
181            let variant = parts.next().ok_or("empty constructor path")?.to_string();
182            let type_name = parts
183                .next()
184                .ok_or_else(|| {
185                    format!(
186                        "constructor '{}' needs a type prefix (e.g. 'Shape.Circle')",
187                        path
188                    )
189                })?
190                .to_string();
191            Ok(Value::Variant {
192                type_name,
193                variant,
194                fields: Arc::<[Value]>::from(fields.to_vec()),
195            })
196        }
197    }
198}
199
200fn require_arity(path: &str, fields: &[Value], expected: usize) -> Result<(), String> {
201    if fields.len() != expected {
202        return Err(format!(
203            "constructor '{}' expects {} argument{}, got {}",
204            path,
205            expected,
206            if expected == 1 { "" } else { "s" },
207            fields.len()
208        ));
209    }
210    Ok(())
211}
212
213/// Serialise entry-call arguments into the replay schema's `input` field.
214///
215/// Matches `decode_entry_args` on the replay side:
216/// - empty arg list → `JsonValue::Null`
217/// - single arg → the single value directly
218/// - multiple args → a JSON array
219pub fn encode_entry_args(args: &[Value]) -> Result<JsonValue, String> {
220    match args.len() {
221        0 => Ok(JsonValue::Null),
222        1 => value_to_json(&args[0]),
223        _ => {
224            let jsons: Result<Vec<_>, _> = args.iter().map(value_to_json).collect();
225            jsons.map(JsonValue::Array)
226        }
227    }
228}
229
230/// Derive a readable filename stem from an entry call.
231/// Simple literal args produce a visible slug (`"fetchUser-alice"`);
232/// complex cases fall back to a stable hash-based stem.
233pub fn recording_stem(fn_name: &str, args: &[Value]) -> String {
234    fn value_slug(v: &Value) -> Option<String> {
235        match v {
236            Value::Str(s) if is_slug_safe(s) && s.len() <= 32 => Some(s.clone()),
237            Value::Int(i) => Some(i.to_string()),
238            Value::Float(f) if f.is_finite() => Some(format!("{}", f).replace('.', "_")),
239            Value::Bool(b) => Some(if *b { "true".into() } else { "false".into() }),
240            _ => None,
241        }
242    }
243    fn is_slug_safe(s: &str) -> bool {
244        !s.is_empty()
245            && s.chars()
246                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
247    }
248
249    let slugs: Option<Vec<String>> = args.iter().map(value_slug).collect();
250    match slugs {
251        Some(parts) if !parts.is_empty() => format!("{}-{}", fn_name, parts.join("-")),
252        Some(_) => fn_name.to_string(),
253        None => {
254            use std::collections::hash_map::DefaultHasher;
255            use std::hash::{Hash, Hasher};
256            let mut hasher = DefaultHasher::new();
257            fn_name.hash(&mut hasher);
258            for v in args {
259                format!("{:?}", v).hash(&mut hasher);
260            }
261            let h = hasher.finish();
262            format!("{}-{:08x}", fn_name, (h & 0xffff_ffff) as u32)
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn parse(src: &str) -> (String, Vec<Value>) {
272        parse_entry_call(src).expect("should parse")
273    }
274
275    fn parse_err(src: &str) -> String {
276        parse_entry_call(src)
277            .expect_err("should reject")
278            .to_string()
279    }
280
281    #[test]
282    fn literal_args() {
283        let (name, args) = parse(r#"greet("Alice", 42, 3.14, true)"#);
284        assert_eq!(name, "greet");
285        assert_eq!(args.len(), 4);
286        assert!(matches!(args[0], Value::Str(ref s) if s == "Alice"));
287        assert!(matches!(args[1], Value::Int(42)));
288        let expected = 314.0 / 100.0;
289        assert!(matches!(args[2], Value::Float(f) if (f - expected).abs() < 1e-9));
290        assert!(matches!(args[3], Value::Bool(true)));
291    }
292
293    #[test]
294    fn negative_numeric_literals() {
295        let (_, args) = parse("loadTempBounds(-300.0, -40)");
296        assert!(matches!(args[0], Value::Float(f) if (f + 300.0).abs() < 1e-9));
297        assert!(matches!(args[1], Value::Int(-40)));
298    }
299
300    #[test]
301    fn negative_on_non_literal_is_rejected() {
302        let msg = parse_err("foo(-Shape.Circle(1.0))");
303        assert!(msg.contains("numeric literal"), "got: {}", msg);
304    }
305
306    #[test]
307    fn user_variant_single_and_multi_field() {
308        let (_, args) = parse("area(Shape.Circle(1.0))");
309        let Value::Variant {
310            type_name,
311            variant,
312            fields,
313        } = &args[0]
314        else {
315            panic!("expected Variant, got {:?}", args[0]);
316        };
317        assert_eq!(type_name, "Shape");
318        assert_eq!(variant, "Circle");
319        assert_eq!(fields.len(), 1);
320        assert!(matches!(fields[0], Value::Float(f) if (f - 1.0).abs() < 1e-9));
321
322        let (_, args) = parse("area(Shape.Rectangle(3.0, 4.0))");
323        let Value::Variant { fields, .. } = &args[0] else {
324            panic!("expected Variant");
325        };
326        assert_eq!(fields.len(), 2);
327    }
328
329    #[test]
330    fn builtin_wrapper_constructors() {
331        let (_, args) = parse(r#"handle(Result.Ok(5))"#);
332        assert!(matches!(&args[0], Value::Ok(inner) if matches!(**inner, Value::Int(5))));
333
334        let (_, args) = parse(r#"handle(Result.Err("bad"))"#);
335        assert!(
336            matches!(&args[0], Value::Err(inner) if matches!(**inner, Value::Str(ref s) if s == "bad"))
337        );
338
339        let (_, args) = parse("handle(Option.Some(1))");
340        assert!(matches!(&args[0], Value::Some(inner) if matches!(**inner, Value::Int(1))));
341
342        let (_, args) = parse("handle(Option.None)");
343        assert!(matches!(&args[0], Value::None));
344    }
345
346    #[test]
347    fn nested_constructors() {
348        let (_, args) = parse("handle(Result.Ok(Shape.Circle(2.0)))");
349        let Value::Ok(inner) = &args[0] else {
350            panic!("expected Ok");
351        };
352        let Value::Variant {
353            type_name, variant, ..
354        } = &**inner
355        else {
356            panic!("expected inner Variant");
357        };
358        assert_eq!(type_name, "Shape");
359        assert_eq!(variant, "Circle");
360    }
361
362    #[test]
363    fn list_and_tuple_args() {
364        let (_, args) = parse("sumAll([1, 2, 3])");
365        assert!(matches!(args[0], Value::List(_)));
366
367        let (_, args) = parse(r#"describe((1, "x"))"#);
368        assert!(matches!(args[0], Value::Tuple(ref items) if items.len() == 2));
369    }
370
371    #[test]
372    fn arity_mismatch_on_builtin_wrapper() {
373        let msg = parse_err("handle(Result.Ok(1, 2))");
374        assert!(msg.contains("Result.Ok"), "got: {}", msg);
375    }
376
377    #[test]
378    fn zero_arg_call_is_accepted() {
379        let (name, args) = parse("tick()");
380        assert_eq!(name, "tick");
381        assert!(args.is_empty());
382    }
383
384    #[test]
385    fn top_level_must_be_a_call() {
386        let msg = parse_err("42");
387        assert!(msg.contains("function call"), "got: {}", msg);
388    }
389
390    #[test]
391    fn arithmetic_arg_rejected() {
392        let msg = parse_err("foo(1 + 2)");
393        assert!(msg.contains("arg #1"), "got: {}", msg);
394    }
395
396    #[test]
397    fn function_call_arg_rejected() {
398        // Lowercase `helper` = function ref, not constructor. Rejected.
399        let msg = parse_err("foo(helper(5))");
400        assert!(msg.contains("arg #1"), "got: {}", msg);
401    }
402
403    #[test]
404    fn variable_arg_rejected() {
405        let msg = parse_err("foo(x)");
406        assert!(msg.contains("arg #1"), "got: {}", msg);
407    }
408
409    #[test]
410    fn qualified_target_rejected() {
411        let msg = parse_err("Math.abs(-5)");
412        assert!(msg.contains("bare function name"), "got: {}", msg);
413    }
414
415    #[test]
416    fn encode_entry_args_shape() {
417        use crate::replay::JsonValue;
418
419        match encode_entry_args(&[]).unwrap() {
420            JsonValue::Null => {}
421            other => panic!("expected Null for empty, got {:?}", other),
422        }
423
424        let single = encode_entry_args(&[Value::Int(5)]).unwrap();
425        assert!(matches!(single, JsonValue::Int(5)), "got: {:?}", single);
426
427        let multi = encode_entry_args(&[Value::Int(1), Value::Str("x".into())]).unwrap();
428        assert!(
429            matches!(&multi, JsonValue::Array(v) if v.len() == 2),
430            "got: {:?}",
431            multi
432        );
433    }
434
435    #[test]
436    fn recording_stem_literal_args() {
437        assert_eq!(
438            recording_stem("loadPort", &[Value::Str("PL".into())]),
439            "loadPort-PL"
440        );
441        assert_eq!(recording_stem("fib", &[Value::Int(10)]), "fib-10");
442        assert_eq!(recording_stem("flag", &[Value::Bool(false)]), "flag-false");
443    }
444
445    #[test]
446    fn recording_stem_complex_args_fall_back_to_hash() {
447        let stem = recording_stem(
448            "area",
449            &[Value::Variant {
450                type_name: "Shape".into(),
451                variant: "Circle".into(),
452                fields: Arc::<[Value]>::from(vec![Value::Float(1.0)]),
453            }],
454        );
455        assert!(stem.starts_with("area-"), "got: {}", stem);
456        assert_eq!(stem.len(), "area-".len() + 8, "expected 8-hex suffix");
457    }
458}