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::Right),
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                if let ASTNodeType::BinaryOp { op: child_op, .. } = &child.node_type {
82                    if child_op != parent_op {
83                        return true;
84                    }
85
86                    // Even with same op, some operators are not associative.
87                    if parent_op == "-" || parent_op == "/" {
88                        return true;
89                    }
90                }
91                false
92            } else {
93                // Right-assoc ops: parenthesize if mixing ops at same precedence.
94                if let ASTNodeType::BinaryOp { op: child_op, .. } = &child.node_type {
95                    return child_op != parent_op;
96                }
97                false
98            }
99        }
100    }
101}
102
103fn unary_operand_needs_parens(unary_op: &str, operand: &ASTNode) -> bool {
104    match unary_op {
105        "%" | "#" => matches!(operand.node_type, ASTNodeType::BinaryOp { .. }),
106        _ => {
107            let operand_prec = node_precedence(operand);
108            operand_prec < unary_precedence(unary_op)
109                && matches!(operand.node_type, ASTNodeType::BinaryOp { .. })
110        }
111    }
112}
113
114fn pretty_child(
115    child: &ASTNode,
116    parent_op: &str,
117    parent_prec: u8,
118    parent_assoc: Associativity,
119    side: Side,
120) -> String {
121    let s = pretty_print_node(child);
122    if child_needs_parens(child, parent_op, parent_prec, parent_assoc, side) {
123        format!("({s})")
124    } else {
125        s
126    }
127}
128
129fn pretty_print_arguments(args: &[ASTNode]) -> String {
130    let mut rendered = String::new();
131    for (index, arg) in args.iter().enumerate() {
132        if index > 0 {
133            rendered.push(',');
134            if !matches!(arg.node_type, ASTNodeType::Omitted) {
135                rendered.push(' ');
136            }
137        }
138        rendered.push_str(&pretty_print_node(arg));
139    }
140    rendered
141}
142
143fn pretty_print_node(ast: &ASTNode) -> String {
144    match &ast.node_type {
145        ASTNodeType::Literal(value) => match value {
146            // Quote and escape text literals to preserve Excel semantics
147            crate::LiteralValue::Text(s) => {
148                let escaped = s.replace('"', "\"\"");
149                format!("\"{escaped}\"")
150            }
151            _ => format!("{value}"),
152        },
153        ASTNodeType::Omitted => String::new(),
154        ASTNodeType::Reference { reference, .. } => reference.normalise(),
155        ASTNodeType::UnaryOp { op, expr } => {
156            let inner = pretty_print_node(expr);
157            let inner = if unary_operand_needs_parens(op, expr) {
158                format!("({inner})")
159            } else {
160                inner
161            };
162
163            if op == "%" || op == "#" {
164                format!("{inner}{op}")
165            } else {
166                format!("{op}{inner}")
167            }
168        }
169        ASTNodeType::BinaryOp { op, left, right } => {
170            let (prec, assoc) = infix_info(op);
171            let left_s = pretty_child(left, op, prec, assoc, Side::Left);
172            let right_s = pretty_child(right, op, prec, assoc, Side::Right);
173
174            match op.as_str() {
175                // Reference range operator prints tight; intersection is a
176                // single space rather than the generic three-space infix form.
177                ":" => format!("{left_s}:{right_s}"),
178                " " => format!("{left_s} {right_s}"),
179                "," => format!("{left_s}, {right_s}"),
180                _ => format!("{left_s} {op} {right_s}"),
181            }
182        }
183        ASTNodeType::Function { name, args } => {
184            let args_str = pretty_print_arguments(args);
185            format!("{}({})", name.to_uppercase(), args_str)
186        }
187        ASTNodeType::Call { callee, args } => {
188            let callee_str = pretty_print_node(callee);
189            // Wrap the callee in parentheses if it isn't already a callable-looking
190            // primary (function call or another call expression). This keeps things
191            // like `(1 + 2)(3)` unambiguous when round-tripping unusual ASTs.
192            let callee_rendered = match &callee.node_type {
193                ASTNodeType::Function { .. } | ASTNodeType::Call { .. } => callee_str,
194                _ => format!("({callee_str})"),
195            };
196            let args_str = pretty_print_arguments(args);
197            format!("{callee_rendered}({args_str})")
198        }
199        ASTNodeType::Array(rows) => {
200            let rows_str = rows
201                .iter()
202                .map(|row| {
203                    row.iter()
204                        .map(pretty_print_node)
205                        .collect::<Vec<String>>()
206                        .join(", ")
207                })
208                .collect::<Vec<String>>()
209                .join("; ");
210
211            format!("{{{rows_str}}}")
212        }
213    }
214}
215
216/// Produce a canonical Excel formula string for an AST, prefixed with '='.
217///
218/// This is the single entry-point that UI layers should use when displaying
219/// a formula reconstructed from an AST.
220pub fn canonical_formula(ast: &ASTNode) -> String {
221    format!("={}", pretty_print(ast))
222}
223
224/// Tokenizes and parses a formula, then pretty-prints it.
225///
226/// Returns a Result with the pretty-printed formula or a parser error.
227pub fn pretty_parse_render(formula: &str) -> Result<String, ParserError> {
228    // Handle empty formula case
229    if formula.is_empty() {
230        return Ok(String::new());
231    }
232
233    // If formula doesn't start with '=', add it before parsing and remove it after
234    let needs_equals = !formula.starts_with('=');
235    let formula_to_parse = if needs_equals {
236        format!("={formula}")
237    } else {
238        formula.to_string()
239    };
240
241    // Parse and pretty-print
242    let ast = parse(&formula_to_parse)?;
243
244    // Format the result with '=' prefix
245    let pretty_printed = pretty_print(&ast);
246
247    // Return the result with appropriate '=' prefix
248    if needs_equals {
249        Ok(pretty_printed)
250    } else {
251        Ok(format!("={pretty_printed}"))
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_pretty_print_validation() {
261        let original = "= sum(  a1 ,2 ) ";
262        let pretty = pretty_parse_render(original).unwrap();
263        assert_eq!(pretty, "=SUM(A1, 2)");
264
265        let round = pretty_parse_render(&pretty).unwrap();
266        assert_eq!(pretty, round); // idempotent
267    }
268
269    #[test]
270    fn test_ast_canonicalization() {
271        // Test that our pretty printer produces canonical form
272        let formula = "=sum(  a1, b2  )";
273        let pretty = pretty_parse_render(formula).unwrap();
274
275        // Check that the pretty printed version is canonicalized
276        assert_eq!(pretty, "=SUM(A1, B2)");
277
278        // Test round-trip consistency
279        let repretty = pretty_parse_render(&pretty).unwrap();
280        assert_eq!(pretty, repretty);
281    }
282
283    #[test]
284    fn test_pretty_print_operators() {
285        let formula = "=a1+b2*3";
286        let pretty = pretty_parse_render(formula).unwrap();
287        assert_eq!(pretty, "=A1 + B2 * 3");
288
289        let formula = "=a1 + b2 *     3";
290        let pretty = pretty_parse_render(formula).unwrap();
291        assert_eq!(pretty, "=A1 + B2 * 3");
292    }
293
294    #[test]
295    fn test_pretty_print_inserts_parentheses_when_needed() {
296        let formula = "=(a1+b2)*c3";
297        let pretty = pretty_parse_render(formula).unwrap();
298        assert_eq!(pretty, "=(A1 + B2) * C3");
299    }
300
301    #[test]
302    fn test_pretty_print_function_nesting() {
303        let formula = "=if(a1>0, sum(b1:b10), average(c1:c10))";
304        let pretty = pretty_parse_render(formula).unwrap();
305        assert_eq!(pretty, "=IF(A1 > 0, SUM(B1:B10), AVERAGE(C1:C10))");
306    }
307
308    #[test]
309    fn test_pretty_print_arrays() {
310        let formula = "={1,2;3,4}";
311        let pretty = pretty_parse_render(formula).unwrap();
312        assert_eq!(pretty, "={1, 2; 3, 4}");
313
314        let formula = "={1, 2; 3, 4}";
315        let pretty = pretty_parse_render(formula).unwrap();
316        assert_eq!(pretty, "={1, 2; 3, 4}");
317    }
318
319    #[test]
320    fn test_pretty_print_references() {
321        let formula = "=Sheet1!$a$1:$b$2";
322        let pretty = pretty_parse_render(formula).unwrap();
323        assert_eq!(pretty, "=Sheet1!$A$1:$B$2");
324
325        let formula = "='My Sheet'!a1";
326        let pretty = pretty_parse_render(formula).unwrap();
327        assert_eq!(pretty, "='My Sheet'!A1");
328    }
329
330    #[test]
331    fn test_pretty_print_text_literals_in_functions() {
332        // Should preserve quotes around text literals
333        let formula = "=SUMIFS(A:A, B:B, \"*Parking*\")";
334        let pretty = pretty_parse_render(formula).unwrap();
335        assert_eq!(pretty, "=SUMIFS(A:A, B:B, \"*Parking*\")");
336    }
337
338    #[test]
339    fn test_pretty_print_text_concatenation_and_escaping() {
340        // Operators as text must stay quoted, and spacing around '&' is canonical
341        let formula = "=\">=\"&DATE(2024,1,1)";
342        let pretty = pretty_parse_render(formula).unwrap();
343        assert_eq!(pretty, "=\">=\" & DATE(2024, 1, 1)");
344
345        // Embedded quotes should be doubled
346        let formula = "=\"He said \"\"Hi\"\"\"";
347        let pretty = pretty_parse_render(formula).unwrap();
348        assert_eq!(pretty, "=\"He said \"\"Hi\"\"\"");
349    }
350
351    #[test]
352    fn test_pretty_print_text_in_arrays() {
353        let formula = "={\"A\", \"B\"; \"C\", \"D\"}";
354        let pretty = pretty_parse_render(formula).unwrap();
355        assert_eq!(pretty, "={\"A\", \"B\"; \"C\", \"D\"}");
356    }
357}