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