1use 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
15fn 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 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
46fn 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; 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 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 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 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}