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