Skip to main content

formualizer_parse/
pretty.rs

1use crate::parser::{ASTNode, ASTNodeType, ParserError, parse};
2use crate::tokenizer::Associativity;
3
4/// Pretty-prints an AST node according to canonical formatting rules.
5///
6/// Rules:
7/// - All functions upper-case, no spaces before '('
8/// - Commas followed by single space; no space before ','
9/// - Binary operators surrounded by single spaces
10/// - No superfluous parentheses (keeps semantics)
11/// - References printed via .normalise()
12/// - Array literals: {1, 2; 3, 4}
13pub fn pretty_print(ast: &ASTNode) -> String {
14    pretty_print_node(ast)
15}
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18enum Side {
19    Left,
20    Right,
21}
22
23fn infix_info(op: &str) -> (u8, Associativity) {
24    match op {
25        ":" => (10, Associativity::Left),
26        " " => (9, Associativity::Left),
27        "," => (8, Associativity::Left),
28        "^" => (5, Associativity::Left),
29        "*" | "/" => (4, Associativity::Left),
30        "+" | "-" => (3, Associativity::Left),
31        "&" => (2, Associativity::Left),
32        "=" | "<" | ">" | "<=" | ">=" | "<>" => (1, Associativity::Left),
33        _ => (0, Associativity::Left),
34    }
35}
36
37fn unary_precedence(op: &str) -> u8 {
38    match op {
39        "#" => 11,
40        "%" => 7,
41        _ => 6,
42    }
43}
44
45fn node_precedence(ast: &ASTNode) -> u8 {
46    match &ast.node_type {
47        ASTNodeType::BinaryOp { op, .. } => infix_info(op).0,
48        ASTNodeType::UnaryOp { op, .. } => unary_precedence(op),
49        // Treat everything else as an atom.
50        _ => 10,
51    }
52}
53
54fn child_needs_parens(
55    child: &ASTNode,
56    parent_op: &str,
57    parent_prec: u8,
58    parent_assoc: Associativity,
59    side: Side,
60) -> bool {
61    let child_prec = node_precedence(child);
62    if child_prec < parent_prec {
63        return true;
64    }
65    if child_prec > parent_prec {
66        return false;
67    }
68
69    // Same precedence: associativity and mixed operators matter.
70    match side {
71        Side::Left => {
72            if parent_assoc == Associativity::Right {
73                // Right-assoc ops (e.g. '^'): parenthesize left child if it could re-associate.
74                matches!(child.node_type, ASTNodeType::BinaryOp { .. })
75            } else {
76                false
77            }
78        }
79        Side::Right => {
80            if parent_assoc == Associativity::Left {
81                // A same-precedence right operand of a left-associative
82                // operator only exists because the source grouped it, so the
83                // brackets are part of the tree: `A1*(B1*C1)` is not
84                // `(A1*B1)*C1`, and `1=(2=3)` is not `1=2=3`.
85                matches!(child.node_type, ASTNodeType::BinaryOp { .. })
86            } else {
87                // Right-assoc ops: parenthesize if mixing ops at same precedence.
88                if let ASTNodeType::BinaryOp { op: child_op, .. } = &child.node_type {
89                    return child_op != parent_op;
90                }
91                false
92            }
93        }
94    }
95}
96
97fn unary_operand_needs_parens(unary_op: &str, operand: &ASTNode) -> bool {
98    match unary_op {
99        "%" | "#" => matches!(operand.node_type, ASTNodeType::BinaryOp { .. }),
100        _ => {
101            let operand_prec = node_precedence(operand);
102            operand_prec < unary_precedence(unary_op)
103                && matches!(operand.node_type, ASTNodeType::BinaryOp { .. })
104        }
105    }
106}
107
108fn pretty_child(
109    child: &ASTNode,
110    parent_op: &str,
111    parent_prec: u8,
112    parent_assoc: Associativity,
113    side: Side,
114    comma_delimited: bool,
115) -> String {
116    if child_needs_parens(child, parent_op, parent_prec, parent_assoc, side) {
117        // Explicit grouping shields any union below this child from the
118        // surrounding argument/array separator grammar.
119        format!("({})", pretty_print_node(child))
120    } else {
121        pretty_print_node_in_context(child, comma_delimited)
122    }
123}
124
125fn pretty_print_arguments(args: &[ASTNode]) -> String {
126    let mut rendered = String::new();
127    for (index, arg) in args.iter().enumerate() {
128        if index > 0 {
129            rendered.push(',');
130            if !matches!(arg.node_type, ASTNodeType::Omitted) {
131                rendered.push(' ');
132            }
133        }
134        // Union operators need grouping anywhere in the unparenthesized
135        // argument expression, not only at its root: SUM((A1,B1)+1).
136        rendered.push_str(&pretty_print_node_in_context(arg, true));
137    }
138    rendered
139}
140
141fn pretty_print_node(ast: &ASTNode) -> String {
142    pretty_print_node_in_context(ast, false)
143}
144
145fn pretty_print_node_in_context(ast: &ASTNode, comma_delimited: bool) -> String {
146    if comma_delimited && matches!(&ast.node_type, ASTNodeType::BinaryOp { op, .. } if op == ",") {
147        return format!("({})", pretty_print_node(ast));
148    }
149    match &ast.node_type {
150        ASTNodeType::Literal(value) => match value {
151            // Quote and escape text literals to preserve Excel semantics
152            crate::LiteralValue::Text(s) => {
153                let escaped = s.replace('"', "\"\"");
154                format!("\"{escaped}\"")
155            }
156            _ => format!("{value}"),
157        },
158        ASTNodeType::Omitted => String::new(),
159        ASTNodeType::Reference { reference, .. } => reference.normalise(),
160        ASTNodeType::UnaryOp { op, expr } => {
161            let inner = if unary_operand_needs_parens(op, expr) {
162                format!("({})", pretty_print_node(expr))
163            } else {
164                pretty_print_node_in_context(expr, comma_delimited)
165            };
166
167            if op == "%" || op == "#" {
168                format!("{inner}{op}")
169            } else {
170                format!("{op}{inner}")
171            }
172        }
173        ASTNodeType::BinaryOp { op, left, right } => {
174            let (prec, assoc) = infix_info(op);
175            let left_s = pretty_child(left, op, prec, assoc, Side::Left, comma_delimited);
176            let right_s = pretty_child(right, op, prec, assoc, Side::Right, comma_delimited);
177
178            match op.as_str() {
179                // Reference range operator prints tight; intersection is a
180                // single space rather than the generic three-space infix form.
181                ":" => format!("{left_s}:{right_s}"),
182                " " => format!("{left_s} {right_s}"),
183                "," => format!("{left_s}, {right_s}"),
184                _ => format!("{left_s} {op} {right_s}"),
185            }
186        }
187        ASTNodeType::Function { name, args } => {
188            let args_str = pretty_print_arguments(args);
189            format!("{}({})", name.to_uppercase(), args_str)
190        }
191        ASTNodeType::Call { callee, args } => {
192            let callee_str = pretty_print_node(callee);
193            // Wrap the callee in parentheses if it isn't already a callable-looking
194            // primary (function call or another call expression). This keeps things
195            // like `(1 + 2)(3)` unambiguous when round-tripping unusual ASTs.
196            let callee_rendered = match &callee.node_type {
197                ASTNodeType::Function { .. } | ASTNodeType::Call { .. } => callee_str,
198                _ => format!("({callee_str})"),
199            };
200            let args_str = pretty_print_arguments(args);
201            format!("{callee_rendered}({args_str})")
202        }
203        ASTNodeType::Array(rows) => {
204            let rows_str = rows
205                .iter()
206                .map(|row| {
207                    row.iter()
208                        .map(|cell| pretty_print_node_in_context(cell, true))
209                        .collect::<Vec<String>>()
210                        .join(", ")
211                })
212                .collect::<Vec<String>>()
213                .join("; ");
214
215            format!("{{{rows_str}}}")
216        }
217    }
218}
219
220/// Produce a canonical Excel formula string for an AST, prefixed with '='.
221///
222/// This is the single entry-point that UI layers should use when displaying
223/// a formula reconstructed from an AST.
224pub fn canonical_formula(ast: &ASTNode) -> String {
225    format!("={}", pretty_print(ast))
226}
227
228/// Tokenizes and parses a formula, then pretty-prints it.
229///
230/// Returns a Result with the pretty-printed formula or a parser error.
231pub fn pretty_parse_render(formula: &str) -> Result<String, ParserError> {
232    // Handle empty formula case
233    if formula.is_empty() {
234        return Ok(String::new());
235    }
236
237    // If formula doesn't start with '=', add it before parsing and remove it after
238    let needs_equals = !formula.starts_with('=');
239    let formula_to_parse = if needs_equals {
240        format!("={formula}")
241    } else {
242        formula.to_string()
243    };
244
245    // Parse and pretty-print
246    let ast = parse(&formula_to_parse)?;
247
248    // Format the result with '=' prefix
249    let pretty_printed = pretty_print(&ast);
250
251    // Return the result with appropriate '=' prefix
252    if needs_equals {
253        Ok(pretty_printed)
254    } else {
255        Ok(format!("={pretty_printed}"))
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_pretty_print_validation() {
265        let original = "= sum(  a1 ,2 ) ";
266        let pretty = pretty_parse_render(original).unwrap();
267        assert_eq!(pretty, "=SUM(A1, 2)");
268
269        let round = pretty_parse_render(&pretty).unwrap();
270        assert_eq!(pretty, round); // idempotent
271    }
272
273    #[test]
274    fn test_ast_canonicalization() {
275        // Test that our pretty printer produces canonical form
276        let formula = "=sum(  a1, b2  )";
277        let pretty = pretty_parse_render(formula).unwrap();
278
279        // Check that the pretty printed version is canonicalized
280        assert_eq!(pretty, "=SUM(A1, B2)");
281
282        // Test round-trip consistency
283        let repretty = pretty_parse_render(&pretty).unwrap();
284        assert_eq!(pretty, repretty);
285    }
286
287    #[test]
288    fn test_pretty_print_operators() {
289        let formula = "=a1+b2*3";
290        let pretty = pretty_parse_render(formula).unwrap();
291        assert_eq!(pretty, "=A1 + B2 * 3");
292
293        let formula = "=a1 + b2 *     3";
294        let pretty = pretty_parse_render(formula).unwrap();
295        assert_eq!(pretty, "=A1 + B2 * 3");
296    }
297
298    #[test]
299    fn test_pretty_print_inserts_parentheses_when_needed() {
300        let formula = "=(a1+b2)*c3";
301        let pretty = pretty_parse_render(formula).unwrap();
302        assert_eq!(pretty, "=(A1 + B2) * C3");
303    }
304
305    #[test]
306    fn test_pretty_print_keeps_same_precedence_right_grouping() {
307        for (formula, expected) in [
308            ("=A1*(B1*C1)", "=A1 * (B1 * C1)"),
309            ("=A1+(B1+C1)", "=A1 + (B1 + C1)"),
310            ("=1=(2=3)", "=1 = (2 = 3)"),
311            ("=\"a\"&(\"b\"&\"c\")", "=\"a\" & (\"b\" & \"c\")"),
312            ("=A1-(B1+C1)", "=A1 - (B1 + C1)"),
313            // Left grouping is the default and needs no brackets.
314            ("=(A1*B1)*C1", "=A1 * B1 * C1"),
315            ("=(1=2)=3", "=1 = 2 = 3"),
316        ] {
317            let pretty = pretty_parse_render(formula).unwrap();
318            assert_eq!(pretty, expected, "{formula}");
319            assert_eq!(
320                parse(&pretty).unwrap().fingerprint(),
321                parse(formula).unwrap().fingerprint(),
322                "{formula} must re-parse to the same tree"
323            );
324        }
325    }
326
327    #[test]
328    fn test_pretty_print_keeps_union_argument_brackets() {
329        for (formula, expected) in [
330            ("=RANK(A1,(B1,B5))", "=RANK(A1, (B1, B5))"),
331            ("=SUM((A1,B1),C1)", "=SUM((A1, B1), C1)"),
332            ("=SUM((A1,B1,C1))", "=SUM((A1, B1, C1))"),
333        ] {
334            let pretty = pretty_parse_render(formula).unwrap();
335            assert_eq!(pretty, expected, "{formula}");
336            assert_eq!(
337                parse(&pretty).unwrap().fingerprint(),
338                parse(formula).unwrap().fingerprint(),
339                "{formula} must re-parse to the same tree"
340            );
341        }
342    }
343
344    #[test]
345    fn test_pretty_print_function_nesting() {
346        let formula = "=if(a1>0, sum(b1:b10), average(c1:c10))";
347        let pretty = pretty_parse_render(formula).unwrap();
348        assert_eq!(pretty, "=IF(A1 > 0, SUM(B1:B10), AVERAGE(C1:C10))");
349    }
350
351    #[test]
352    fn test_pretty_print_arrays() {
353        let formula = "={1,2;3,4}";
354        let pretty = pretty_parse_render(formula).unwrap();
355        assert_eq!(pretty, "={1, 2; 3, 4}");
356
357        let formula = "={1, 2; 3, 4}";
358        let pretty = pretty_parse_render(formula).unwrap();
359        assert_eq!(pretty, "={1, 2; 3, 4}");
360    }
361
362    #[test]
363    fn test_pretty_print_references() {
364        let formula = "=Sheet1!$a$1:$b$2";
365        let pretty = pretty_parse_render(formula).unwrap();
366        assert_eq!(pretty, "=Sheet1!$A$1:$B$2");
367
368        let formula = "='My Sheet'!a1";
369        let pretty = pretty_parse_render(formula).unwrap();
370        assert_eq!(pretty, "='My Sheet'!A1");
371    }
372
373    #[test]
374    fn test_pretty_print_text_literals_in_functions() {
375        // Should preserve quotes around text literals
376        let formula = "=SUMIFS(A:A, B:B, \"*Parking*\")";
377        let pretty = pretty_parse_render(formula).unwrap();
378        assert_eq!(pretty, "=SUMIFS(A:A, B:B, \"*Parking*\")");
379    }
380
381    #[test]
382    fn test_pretty_print_text_concatenation_and_escaping() {
383        // Operators as text must stay quoted, and spacing around '&' is canonical
384        let formula = "=\">=\"&DATE(2024,1,1)";
385        let pretty = pretty_parse_render(formula).unwrap();
386        assert_eq!(pretty, "=\">=\" & DATE(2024, 1, 1)");
387
388        // Embedded quotes should be doubled
389        let formula = "=\"He said \"\"Hi\"\"\"";
390        let pretty = pretty_parse_render(formula).unwrap();
391        assert_eq!(pretty, "=\"He said \"\"Hi\"\"\"");
392    }
393
394    #[test]
395    fn test_pretty_print_text_in_arrays() {
396        let formula = "={\"A\", \"B\"; \"C\", \"D\"}";
397        let pretty = pretty_parse_render(formula).unwrap();
398        assert_eq!(pretty, "={\"A\", \"B\"; \"C\", \"D\"}");
399    }
400}