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