Skip to main content

lemon/dsl/
print.rs

1//! JSON `Expr` tree → DSL text (no `let` reconstruction). A call breaks onto
2//! indented lines only when one of its arguments is itself nested (a call or
3//! binop); leaves and flat calls like `sma(close, 2)` stay on one line.
4
5use serde_json::Value;
6
7use super::ops::{self, Field};
8
9const INDENT: &str = "  ";
10
11pub fn format(expr: &Value) -> String {
12    format_at(expr, 0)
13}
14
15/// `depth` is the indentation level of the node's *own* opening line; nested
16/// calls that break indent their args at `depth + 1`.
17fn format_at(expr: &Value, depth: usize) -> String {
18    let Some(tag) = expr.get("op").and_then(Value::as_str) else {
19        return literal(expr);
20    };
21    match tag {
22        "Data" => expr
23            .get("name")
24            .and_then(Value::as_str)
25            .unwrap_or("?")
26            .to_string(),
27        "Const" => literal(expr.get("value").unwrap_or(&Value::Null)),
28        "Neg" => format!("(-{})", format_at(child(expr, "of"), depth)),
29        "Not" => format!("(not {})", format_at(child(expr, "of"), depth)),
30        "Ceil" => format!("ceil({})", format_at(child(expr, "of"), depth)),
31        _ => {
32            if let Some(sym) = ops::binop_symbol_for_tag(tag) {
33                // Binops stay inline; a nested call inside one still breaks itself.
34                return format!(
35                    "({} {} {})",
36                    format_at(child(expr, "l"), depth),
37                    sym,
38                    format_at(child(expr, "r"), depth)
39                );
40            }
41            print_call(tag, expr, depth)
42        }
43    }
44}
45
46/// A node forces its parent call to break iff it isn't a bare leaf (`Data`,
47/// `Const`, or a scalar literal) — i.e. it's a call or binop.
48fn is_leaf(v: &Value) -> bool {
49    !matches!(v.get("op").and_then(Value::as_str), Some(tag) if tag != "Data" && tag != "Const")
50}
51
52fn print_call(tag: &str, expr: &Value, depth: usize) -> String {
53    let name = ops::dsl_name_for_tag(tag);
54    let Some(sig) = ops::sig_by_tag(tag) else {
55        return format!("{name}(/* unprintable op {tag} */)");
56    };
57    let child_depth = depth + 1;
58    let mut parts: Vec<String> = Vec::new();
59    let mut brk = false; // set true once any Expr arg is nested → break this call
60                         // Once true, every subsequent field must be emitted as keyword=value so we
61                         // never produce a positional arg after a keyword arg (parser would reject it).
62    let mut keyword_mode = false;
63    for field in sig.fields {
64        let fname = ops::field_name(field);
65        let Some(val) = expr.get(fname) else {
66            // Gap in the positional prefix — everything after must be keyword.
67            keyword_mode = true;
68            continue;
69        };
70        let rendered = match field {
71            Field::ExprList(_) => {
72                let items = val.as_array().cloned().unwrap_or_default();
73                if items.iter().any(|v| !is_leaf(v)) {
74                    brk = true;
75                }
76                let rendered: Vec<String> =
77                    items.iter().map(|v| format_at(v, child_depth)).collect();
78                format!("[{}]", rendered.join(", "))
79            }
80            Field::StrListOpt(_) => {
81                let empty = Vec::new();
82                let items: Vec<String> = val
83                    .as_array()
84                    .unwrap_or(&empty)
85                    .iter()
86                    .map(literal)
87                    .collect();
88                format!("[{}]", items.join(", "))
89            }
90            Field::Expr(_) | Field::ExprOpt(_) => {
91                if !is_leaf(val) {
92                    brk = true;
93                }
94                format_at(val, child_depth)
95            }
96            _ => literal(val),
97        };
98        // A field is positional only when we haven't entered keyword mode yet AND
99        // the field is a required scalar/Expr (i.e. Expr, Num, or Str — NOT any
100        // *Opt or ExprList variant, which have no positional slot in the grammar).
101        let is_required_inline = matches!(field, Field::Expr(_) | Field::Num(_) | Field::Str(_));
102        if !keyword_mode && is_required_inline {
103            parts.push(rendered);
104        } else {
105            keyword_mode = true;
106            parts.push(format!("{fname}={rendered}"));
107        }
108    }
109    if brk {
110        let ind = INDENT.repeat(child_depth);
111        let close = INDENT.repeat(depth);
112        format!(
113            "{name}(\n{ind}{}\n{close})",
114            parts.join(&format!(",\n{ind}"))
115        )
116    } else {
117        format!("{name}({})", parts.join(", "))
118    }
119}
120
121fn child<'a>(expr: &'a Value, key: &str) -> &'a Value {
122    expr.get(key).unwrap_or(&Value::Null)
123}
124
125fn literal(v: &Value) -> String {
126    match v {
127        Value::Number(n) => n.to_string(),
128        Value::Bool(b) => b.to_string(),
129        Value::String(s) => format!("\"{s}\""),
130        Value::Null => "null".into(),
131        other => other.to_string(),
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use serde_json::json;
139
140    #[test]
141    fn prints_leaves_and_call() {
142        assert_eq!(format(&json!({"op":"Data","name":"close"})), "close");
143        assert_eq!(format(&json!({"op":"Const","value":1000000})), "1000000");
144        assert_eq!(
145            format(&json!({"op":"Average","of":{"op":"Data","name":"close"},"n":2})),
146            "sma(close, 2)"
147        );
148    }
149
150    #[test]
151    fn prints_not_and_round_trips() {
152        let v = json!({"op":"Not","of":{"op":"Gt",
153            "l":{"op":"Data","name":"a"},"r":{"op":"Const","value":1}}});
154        assert_eq!(format(&v), "(not (a > 1))");
155        assert_eq!(super::super::parse(&format(&v)).unwrap(), v);
156    }
157
158    #[test]
159    fn prints_binop_parenthesised() {
160        assert_eq!(
161            format(&json!({"op":"Gt","l":{"op":"Data","name":"close"},
162                          "r":{"op":"Const","value":2}})),
163            "(close > 2)"
164        );
165    }
166
167    #[test]
168    fn prints_keyword_fields_and_list() {
169        assert_eq!(
170            format(&json!({"op":"Rebalance","of":{"op":"Data","name":"x"},"freq":"ME"})),
171            "rebalance(x, freq=\"ME\")"
172        );
173        assert_eq!(
174            format(&json!({"op":"Neutralize","of":{"op":"Data","name":"x"},
175                          "by":[{"op":"Data","name":"pe"}]})),
176            "neutralize(x, by=[pe])"
177        );
178    }
179
180    #[test]
181    fn vwap_prints_all_positional_and_reparses() {
182        let v = serde_json::json!({"op":"Vwap","high":{"op":"Data","name":"high"},
183            "low":{"op":"Data","name":"low"},"close":{"op":"Data","name":"close"},
184            "volume":{"op":"Data","name":"volume"},"n":20});
185        assert_eq!(format(&v), "vwap(high, low, close, volume, 20)");
186        assert_eq!(super::super::parse(&format(&v)).unwrap(), v);
187    }
188
189    #[test]
190    fn rank_with_gapped_optional_uses_keyword() {
191        let v = serde_json::json!({"op":"Rank","of":{"op":"Data","name":"x"},"ascending":false});
192        assert_eq!(format(&v), "rank(x, ascending=false)");
193        assert_eq!(super::super::parse(&format(&v)).unwrap(), v);
194    }
195
196    #[test]
197    fn nested_call_breaks_with_indent_flat_stays_inline() {
198        // rebalance wraps is_largest wraps sma. sma's args are leaves → it stays
199        // flat; its two enclosing calls each have a nested arg → both break.
200        let v = json!({"op":"Rebalance","freq":"ME",
201            "of":{"op":"IsLargest","n":10,
202                  "of":{"op":"Average","of":{"op":"Data","name":"close"},"n":5}}});
203        assert_eq!(
204            format(&v),
205            "rebalance(\n  is_largest(\n    sma(close, 5),\n    10\n  ),\n  freq=\"ME\"\n)"
206        );
207        assert_eq!(super::super::parse(&format(&v)).unwrap(), v);
208    }
209
210    #[test]
211    fn binop_arg_breaks_parent_but_stays_inline_itself() {
212        let v = json!({"op":"Mask","of":{"op":"IsLargest","of":{"op":"Data","name":"x"},"n":30},
213            "by":{"op":"Gt","l":{"op":"Data","name":"market_cap"},"r":{"op":"Const","value":5}}});
214        assert_eq!(
215            format(&v),
216            "mask(\n  is_largest(x, 30),\n  (market_cap > 5)\n)"
217        );
218        assert_eq!(super::super::parse(&format(&v)).unwrap(), v);
219    }
220}