Skip to main content

lemon/dsl/
mod.rs

1//! Human-writable DSL ⇄ JSON `Expr` tree. `parse` lowers DSL text to the same
2//! `serde_json::Value` the engine deserializes into [`crate::spec::Expr`];
3//! `print` renders a tree back to DSL text (flat — no `let` reconstruction).
4
5mod lex;
6mod ops;
7mod parse;
8mod print;
9
10/// A parse failure with a 1-based source position.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ParseError {
13    pub line: usize,
14    pub col: usize,
15    pub message: String,
16}
17
18impl std::fmt::Display for ParseError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        write!(f, "{}:{}: {}", self.line, self.col, self.message)
21    }
22}
23
24impl std::error::Error for ParseError {}
25
26pub use parse::parse;
27pub use print::format;
28
29#[cfg(test)]
30mod tests {
31    use super::*;
32
33    #[test]
34    fn parse_error_displays_with_position() {
35        let e = ParseError {
36            line: 3,
37            col: 7,
38            message: "bad token".into(),
39        };
40        assert_eq!(e.to_string(), "3:7: bad token");
41    }
42}
43
44#[cfg(test)]
45mod roundtrip {
46    use super::{format, parse};
47    use crate::spec::Expr;
48    use serde_json::{json, Value};
49
50    fn corpus() -> Vec<Value> {
51        vec![
52            json!({"op":"Gt","l":{"op":"Data","name":"close"},
53                   "r":{"op":"Average","of":{"op":"Data","name":"close"},"n":2}}),
54            json!({"op":"Rank","of":{"op":"Neg","of":{"op":"Data","name":"pe"}}}),
55            json!({"op":"Vwap","high":{"op":"Data","name":"high"},"low":{"op":"Data","name":"low"},
56                   "close":{"op":"Data","name":"close"},"volume":{"op":"Data","name":"volume"},"n":20}),
57            json!({"op":"Mask","of":{"op":"IsLargest","of":{"op":"Data","name":"x"},"n":30},
58                   "by":{"op":"Gt","l":{"op":"Data","name":"market_cap"},"r":{"op":"Const","value":500000000}}}),
59            json!({"op":"Sustain","of":{"op":"Data","name":"x"},"nwindow":3,"nsatisfy":2}),
60            json!({"op":"Rebalance","of":{"op":"Data","name":"x"},"freq":"ME"}),
61            json!({"op":"Neutralize","of":{"op":"Data","name":"x"},
62                   "by":[{"op":"Data","name":"pe"},{"op":"Data","name":"market_cap"}]}),
63            json!({"op":"NeutralizeIndustry","of":{"op":"Data","name":"x"}}),
64            json!({"op":"HoldUntil",
65                   "entry":{"op":"Gt","l":{"op":"Data","name":"close"},"r":{"op":"Const","value":1}},
66                   "exit":{"op":"Lt","l":{"op":"Data","name":"close"},"r":{"op":"Const","value":1}},
67                   "nstocks_limit":1}),
68            json!({"op":"Add",
69                   "l":{"op":"Mul","l":{"op":"Const","value":2},"r":{"op":"Data","name":"x"}},
70                   "r":{"op":"Data","name":"y"}}),
71            json!({"op":"IndustryRank","of":{"op":"Data","name":"x"},
72                "categories":["tech","fin"]}),
73        ]
74    }
75
76    #[test]
77    fn corpus_is_valid_expr() {
78        for tree in corpus() {
79            let r: Result<Expr, _> = serde_json::from_value(tree.clone());
80            assert!(r.is_ok(), "not a valid Expr: {tree}");
81        }
82    }
83
84    #[test]
85    fn parse_of_print_is_identity() {
86        for tree in corpus() {
87            let text = format(&tree);
88            let back = parse(&text).unwrap_or_else(|e| panic!("reparse failed for `{text}`: {e}"));
89            assert_eq!(back, tree, "round-trip mismatch via `{text}`");
90        }
91    }
92
93    /// Formatting is a published contract: re-formatting already-formatted source
94    /// must be a no-op, or every consumer's diffs churn. Guards against a future
95    /// printer change that is stable on raw trees but not on its own output.
96    #[test]
97    fn print_is_idempotent() {
98        for tree in corpus() {
99            let once = format(&tree);
100            let reparsed = parse(&once).unwrap();
101            assert_eq!(
102                format(&reparsed),
103                once,
104                "format not idempotent for `{once}`"
105            );
106        }
107    }
108}