Skip to main content

truecalc_core/parser/
mod.rs

1pub mod ast;
2pub mod refs;
3pub mod tokens;
4
5pub use ast::{Expr, Span};
6pub use refs::{CellAddr, Ref};
7use ast::{BinaryOp, UnaryOp};
8use crate::types::ParseError;
9use nom::{IResult, character::complete::multispace0};
10use tokens::{bool_literal, dollar_cell_ref, error_literal, identifier, number_literal, offset, string_literal};
11
12/// A cell-address token: `dollar_cell_ref()`'s `$`-bearing shape (`$A1`,
13/// `A$1`, `$A$1`), or — when no literal `$` is present — `identifier()`'s
14/// plain shape (`A1`). `dollar_cell_ref` must be tried first: `identifier`
15/// does not fail on input like `"A$1"`, it just stops early at `A` and
16/// succeeds, so trying it first would never give `dollar_cell_ref` a chance
17/// to claim the `$1` suffix. Used wherever a range endpoint is expected, so
18/// either corner of a range may independently carry `$` anchors (e.g.
19/// `A1:$D$4`).
20fn cell_ref_text(i: &str) -> IResult<&str, &str> {
21    dollar_cell_ref(i).or_else(|_| identifier(i))
22}
23
24struct Parser<'a> {
25    full: &'a str,
26}
27
28impl<'a> Parser<'a> {
29    fn new(full: &'a str) -> Self {
30        Self { full }
31    }
32
33    fn span(&self, before: &str, after: &str) -> Span {
34        let start = offset(self.full, before);
35        let end = offset(self.full, after);
36        Span::new(start, end - start)
37    }
38
39    // ── primary ────────────────────────────────────────────────────────────
40
41    fn parse_primary(&self, i: &'a str) -> IResult<&'a str, Expr> {
42        let i = multispace0(i)?.0;
43
44        // Number literal (must come before identifier to catch e.g. "1e3")
45        if let Ok((rest, n)) = number_literal(i) {
46            return Ok((rest, Expr::Number(n, self.span(i, rest))));
47        }
48
49        // String literal
50        if let Ok((rest, text)) = string_literal(i) {
51            return Ok((rest, Expr::Text(text, self.span(i, rest))));
52        }
53
54        // Array literal: {expr, expr, ...}
55        if let Some(inner) = i.strip_prefix('{') {
56            let (rest, elems) = self.parse_array_elements(inner)?;
57            let rest = multispace0(rest)?.0;
58            if let Some(after) = rest.strip_prefix('}') {
59                return Ok((after, Expr::Array(elems, self.span(i, after))));
60            }
61            return Err(nom::Err::Error(nom::error::Error::new(
62                rest,
63                nom::error::ErrorKind::Char,
64            )));
65        }
66
67        // Parenthesised expression
68        if let Some(inner) = i.strip_prefix('(') {
69            // Trim whitespace after '(' so a padded grouping like `( A1 + B1 )`
70            // does not leak the leading space into the inner expression's span
71            // (issue #751 — same class as #746/#748/#749, which also trim before
72            // parse_comparison; the trailing side is already trimmed below).
73            let inner = multispace0(inner)?.0;
74            let (rest, expr) = self.parse_comparison(inner)?;
75            let rest = multispace0(rest)?.0;
76            if let Some(after) = rest.strip_prefix(')') {
77                return Ok((after, expr));
78            }
79            return Err(nom::Err::Error(nom::error::Error::new(
80                rest,
81                nom::error::ErrorKind::Char,
82            )));
83        }
84
85        // Boolean (before identifier — uses word-boundary check in bool_literal)
86        if let Ok((rest, b)) = bool_literal(i) {
87            return Ok((rest, Expr::Bool(b, self.span(i, rest))));
88        }
89
90        // Error literal: #REF!, #DIV/0!, #NAME?, #VALUE!, #NUM!, #N/A, #NULL!
91        // (issue #716) — parses straight to its error value, same as a
92        // number/string/boolean literal parses straight to theirs. No other
93        // primary form starts with '#', so this can be tried unconditionally.
94        if let Ok((rest, kind)) = error_literal(i) {
95            return Ok((rest, Expr::Error(kind, self.span(i, rest))));
96        }
97
98        // Unqualified current-row table reference: [@Column]. Only the `@`
99        // form is legal unqualified — a bare `[Column]` names no table and
100        // is a parse error (Task 3 test `bracket_without_at_is_a_parse_error`).
101        if let Some(after_bracket) = i.strip_prefix('[') {
102            if after_bracket.starts_with('@') {
103                return self.parse_table_ref(i, None, after_bracket);
104            }
105        }
106
107        // Quoted-sheet reference: 'Sheet Name'!A1 / 'Sheet Name'!A1:B2
108        if i.starts_with('\'') {
109            return self.parse_quoted_sheet_ref(i);
110        }
111
112        // $-anchored cell reference (bare, no sheet): $A$1, $A1, A$1. A
113        // '$'-bearing token can only ever be a cell/range reference (never a
114        // sheet name, function call, or plain variable — none of those can
115        // contain '$'), so it short-circuits straight to Expr::Variable,
116        // mirroring how plain `A1` becomes Expr::Variable("A1", ..) below.
117        if let Ok((rest, span)) = dollar_cell_ref(i) {
118            let rest_ws = multispace0(rest)?.0;
119            if let Some(after_colon) = rest_ws.strip_prefix(':') {
120                if let Ok((rest2, end_span)) = cell_ref_text(after_colon) {
121                    if CellAddr::parse(end_span).is_some() {
122                        let range_name = format!("{}:{}", span, end_span);
123                        return Ok((rest2, Expr::Variable(range_name, self.span(i, rest2))));
124                    }
125                }
126            }
127            return Ok((rest, Expr::Variable(span.to_string(), self.span(i, rest))));
128        }
129
130        // Identifier: sheet-qualified reference, variable, or function call
131        if let Ok((rest, name)) = identifier(i) {
132            // Sheet-qualified reference: Sheet1!A1 / Sheet1!A1:B2 — `!` binds
133            // tightly to the sheet name (no whitespace on either side).
134            if let Some(after_bang) = rest.strip_prefix('!') {
135                return self.parse_ref_body(i, name.to_string(), after_bang);
136            }
137            // Table reference: Table[Column] or Table[@Column] — `[` binds
138            // tightly to the table name (no whitespace on either side).
139            if let Some(after_bracket) = rest.strip_prefix('[') {
140                return self.parse_table_ref(i, Some(name.to_string()), after_bracket);
141            }
142            let rest_ws = multispace0(rest)?.0;
143            if let Some(args_input) = rest_ws.strip_prefix('(') {
144                // Function call
145                let (rest2, args) = self.parse_arg_list(args_input)?;
146                let rest2 = multispace0(rest2)?.0;
147                if let Some(after_close) = rest2.strip_prefix(')') {
148                    let func_expr = Expr::FunctionCall {
149                        name: name.to_uppercase(),
150                        args,
151                        span: self.span(i, after_close),
152                    };
153                    // Detect immediately-invoked call: FUNC(lambda_args)(call_args)
154                    let after_ws = multispace0(after_close)?.0;
155                    if let Some(call_input) = after_ws.strip_prefix('(') {
156                        let (rest3, call_args) = self.parse_arg_list(call_input)?;
157                        let rest3 = multispace0(rest3)?.0;
158                        if let Some(after) = rest3.strip_prefix(')') {
159                            return Ok((after, Expr::Apply {
160                                func: Box::new(func_expr),
161                                call_args,
162                                span: self.span(i, after),
163                            }));
164                        }
165                        return Err(nom::Err::Error(nom::error::Error::new(
166                            rest3,
167                            nom::error::ErrorKind::Char,
168                        )));
169                    }
170                    return Ok((after_close, func_expr));
171                }
172                return Err(nom::Err::Error(nom::error::Error::new(
173                    rest2,
174                    nom::error::ErrorKind::Char,
175                )));
176            }
177            // Range reference: A1:D4 (end corner may itself be $-anchored,
178            // e.g. A1:$D$4 — validated via the same CellAddr::parse used
179            // for the dollar-led branch above, so both paths agree on shape).
180            if CellAddr::parse(name).is_some() {
181                if let Some(after_colon) = rest_ws.strip_prefix(':') {
182                    if let Ok((rest2, name2)) = cell_ref_text(after_colon) {
183                        if CellAddr::parse(name2).is_some() {
184                            let range_name = format!("{}:{}", name, name2);
185                            return Ok((rest2, Expr::Variable(range_name, self.span(i, rest2))));
186                        }
187                    }
188                }
189            }
190            return Ok((rest, Expr::Variable(name.to_string(), self.span(i, rest))));
191        }
192
193        Err(nom::Err::Error(nom::error::Error::new(i, nom::error::ErrorKind::Alt)))
194    }
195
196    // ── sheet-qualified references ──────────────────────────────────────
197
198    /// Parse the part after `!`: a cell address, optionally `:cell` for a
199    /// range. `start` is where the whole reference began (for spans); `sheet`
200    /// is the unescaped sheet name.
201    fn parse_ref_body(&self, start: &'a str, sheet: String, i: &'a str) -> IResult<&'a str, Expr> {
202        let err = || nom::Err::Error(nom::error::Error::new(i, nom::error::ErrorKind::Tag));
203        let (rest, cell_text) = cell_ref_text(i).map_err(|_| err())?;
204        let addr = CellAddr::parse(cell_text).ok_or_else(err)?;
205        // Optional range tail, mirroring the bare `A1:D4` grammar below.
206        let rest_ws = multispace0(rest)?.0;
207        if let Some(after_colon) = rest_ws.strip_prefix(':') {
208            if let Ok((rest2, end_text)) = cell_ref_text(after_colon) {
209                if let Some(end) = CellAddr::parse(end_text) {
210                    let r = Ref::Range { sheet: Some(sheet), start: addr, end };
211                    return Ok((rest2, Expr::Reference(r, self.span(start, rest2))));
212                }
213            }
214        }
215        let r = Ref::Cell { sheet: Some(sheet), addr };
216        Ok((rest, Expr::Reference(r, self.span(start, rest))))
217    }
218
219    /// Parse the part after `[` in a table reference: an optional `@`, a
220    /// column identifier, then `]`. `start` is where the whole reference
221    /// began (for spans); `table` is `None` for the unqualified `[@Column]`
222    /// form (called from `parse_primary`'s top-level `[` check, Task 3).
223    fn parse_table_ref(
224        &self,
225        start: &'a str,
226        table: Option<String>,
227        i: &'a str,
228    ) -> IResult<&'a str, Expr> {
229        let err = || nom::Err::Error(nom::error::Error::new(i, nom::error::ErrorKind::Tag));
230        let this_row = i.starts_with('@');
231        let i = if this_row { &i[1..] } else { i };
232        let (rest, column) = identifier(i).map_err(|_| err())?;
233        let after = rest.strip_prefix(']').ok_or_else(err)?;
234        let r = Ref::Table { table, column: column.to_string(), this_row };
235        Ok((after, Expr::Reference(r, self.span(start, after))))
236    }
237
238    /// Parse `'Sheet Name'!A1` / `'Sheet Name'!A1:B2`. `i` starts at the
239    /// opening quote. `''` inside the quotes is an escaped single quote.
240    fn parse_quoted_sheet_ref(&self, i: &'a str) -> IResult<&'a str, Expr> {
241        let inner = &i[1..];
242        let mut sheet = String::new();
243        let mut idx = 0;
244        loop {
245            match inner[idx..].find('\'') {
246                // Unterminated quoted sheet name
247                None => {
248                    return Err(nom::Err::Error(nom::error::Error::new(
249                        i,
250                        nom::error::ErrorKind::Char,
251                    )));
252                }
253                Some(q) => {
254                    sheet.push_str(&inner[idx..idx + q]);
255                    let after = idx + q + 1;
256                    if inner[after..].starts_with('\'') {
257                        sheet.push('\'');
258                        idx = after + 1;
259                    } else {
260                        idx = after;
261                        break;
262                    }
263                }
264            }
265        }
266        let rest = &inner[idx..];
267        if sheet.is_empty() {
268            return Err(nom::Err::Error(nom::error::Error::new(
269                i,
270                nom::error::ErrorKind::Char,
271            )));
272        }
273        match rest.strip_prefix('!') {
274            Some(after_bang) => self.parse_ref_body(i, sheet, after_bang),
275            None => Err(nom::Err::Error(nom::error::Error::new(
276                rest,
277                nom::error::ErrorKind::Char,
278            ))),
279        }
280    }
281
282    fn parse_arg_list(&self, i: &'a str) -> IResult<&'a str, Vec<Expr>> {
283        let mut args = Vec::new();
284        let mut rest = multispace0(i)?.0;
285
286        if rest.starts_with(')') {
287            return Ok((rest, args));
288        }
289
290        // Parse first argument (may be empty if it starts with comma or close paren)
291        let ws = multispace0(rest)?.0;
292        if ws.starts_with(',') || ws.starts_with(')') {
293            // Empty first argument
294            args.push(Expr::Variable(String::new(), Span::new(0, 0)));
295        } else {
296            let (r, first) = self.parse_comparison(rest)?;
297            args.push(first);
298            rest = r;
299        }
300
301        loop {
302            rest = multispace0(rest)?.0;
303            if let Some(after_comma) = rest.strip_prefix(',') {
304                let after_ws = multispace0(after_comma)?.0;
305                if after_ws.starts_with(',') || after_ws.starts_with(')') {
306                    // Empty argument
307                    args.push(Expr::Variable(String::new(), Span::new(0, 0)));
308                    rest = after_comma;
309                } else {
310                    // Parse from the first non-whitespace token, not from
311                    // just after the comma — a compound (BinaryOp) argument's
312                    // span is measured from its entry point here, so passing
313                    // the untrimmed `after_comma` would make the span start
314                    // at the separating whitespace instead of the argument's
315                    // own first token.
316                    let (r, arg) = self.parse_comparison(after_ws)?;
317                    args.push(arg);
318                    rest = r;
319                }
320            } else {
321                break;
322            }
323        }
324
325        Ok((rest, args))
326    }
327
328    fn parse_array_elements(&self, i: &'a str) -> IResult<&'a str, Vec<Expr>> {
329        let mut rows: Vec<Vec<Expr>> = Vec::new();
330        let mut current_row: Vec<Expr> = Vec::new();
331        let mut rest = multispace0(i)?.0;
332        if rest.starts_with('}') {
333            return Ok((rest, Vec::new())); // empty array {}
334        }
335        let (r, first) = self.parse_comparison(rest)?;
336        current_row.push(first);
337        rest = r;
338        loop {
339            rest = multispace0(rest)?.0;
340            if let Some(after_comma) = rest.strip_prefix(',') {
341                // Parse from the first non-whitespace token, not from just
342                // after the comma — same leading-whitespace bug as #746's
343                // function-argument fix, but here in the array-element
344                // separator: passing the untrimmed `after_comma` would let a
345                // compound (BinaryOp) element's span start at the separating
346                // whitespace instead of the element's own first token.
347                let after_ws = multispace0(after_comma)?.0;
348                let (r, elem) = self.parse_comparison(after_ws)?;
349                current_row.push(elem);
350                rest = r;
351            } else if let Some(after_semi) = rest.strip_prefix(';') {
352                rows.push(std::mem::take(&mut current_row));
353                // Same trim as the comma branch above, for the first element
354                // of the new row.
355                let after_ws = multispace0(after_semi)?.0;
356                let (r, elem) = self.parse_comparison(after_ws)?;
357                current_row.push(elem);
358                rest = r;
359            } else {
360                break;
361            }
362        }
363        rows.push(current_row);
364        // If only one row (no semicolons), return flat vec
365        if rows.len() == 1 {
366            return Ok((rest, rows.into_iter().next().unwrap()));
367        }
368        // Multiple rows → wrap each row in an Array node. Each row's span
369        // must cover only that row's own elements (its first element's start
370        // to its last element's end) — not the whole `{…}` body, which is
371        // what every row got when this span was computed once outside the
372        // loop below.
373        let row_exprs: Vec<Expr> = rows
374            .into_iter()
375            .map(|row_elems| {
376                let s = match (row_elems.first(), row_elems.last()) {
377                    (Some(first), Some(last)) => {
378                        let start = first.span().offset;
379                        let end = last.span().offset + last.span().length;
380                        Span::new(start, end - start)
381                    }
382                    // A row is never empty in practice (each row starts with
383                    // an element pushed either before the loop or right
384                    // after a `;`), but fall back to the old whole-body span
385                    // rather than panic if that ever changes.
386                    _ => self.span(i, rest),
387                };
388                Expr::Array(row_elems, s)
389            })
390            .collect();
391        Ok((rest, row_exprs))
392    }
393
394    // ── postfix % ─────────────────────────────────────────────────────────
395
396    fn parse_postfix(&self, i: &'a str) -> IResult<&'a str, Expr> {
397        let (rest, expr) = self.parse_primary(i)?;
398        let rest_ws = multispace0(rest)?.0;
399        if let Some(after) = rest_ws.strip_prefix('%') {
400            return Ok((after, Expr::UnaryOp {
401                op: UnaryOp::Percent,
402                operand: Box::new(expr),
403                span: self.span(i, after),
404            }));
405        }
406        Ok((rest, expr))
407    }
408
409    // ── unary minus ───────────────────────────────────────────────────────
410
411    fn parse_unary(&self, i: &'a str) -> IResult<&'a str, Expr> {
412        let i_ws = multispace0(i)?.0;
413        if let Some(after_minus) = i_ws.strip_prefix('-') {
414            let (rest, operand) = self.parse_unary(after_minus)?;
415            return Ok((rest, Expr::UnaryOp {
416                op: UnaryOp::Neg,
417                operand: Box::new(operand),
418                span: self.span(i_ws, rest),
419            }));
420        }
421        self.parse_postfix(i)
422    }
423
424    // ── power ^ (right-associative) ───────────────────────────────────────
425
426    fn parse_power(&self, i: &'a str) -> IResult<&'a str, Expr> {
427        let (rest, left) = self.parse_unary(i)?;
428        let rest_ws = multispace0(rest)?.0;
429        if let Some(after_op) = rest_ws.strip_prefix('^') {
430            let (rest2, right) = self.parse_power(after_op)?;
431            return Ok((rest2, Expr::BinaryOp {
432                op: BinaryOp::Pow,
433                left: Box::new(left),
434                right: Box::new(right),
435                span: self.span(i, rest2),
436            }));
437        }
438        Ok((rest, left))
439    }
440
441    // ── multiplicative * / ────────────────────────────────────────────────
442
443    fn parse_multiplicative(&self, i: &'a str) -> IResult<&'a str, Expr> {
444        let (mut rest, mut left) = self.parse_power(i)?;
445        loop {
446            let ws = multispace0(rest)?.0;
447            let op = ws.strip_prefix('*').map(|after| (BinaryOp::Mul, after))
448                .or_else(|| ws.strip_prefix('/').map(|after| (BinaryOp::Div, after)));
449            match op {
450                None => break,
451                Some((op, after)) => {
452                    let (r, right) = self.parse_power(after)?;
453                    left = Expr::BinaryOp {
454                        op,
455                        span: self.span(i, r),
456                        left: Box::new(left),
457                        right: Box::new(right),
458                    };
459                    rest = r;
460                }
461            }
462        }
463        Ok((rest, left))
464    }
465
466    // ── additive + - ──────────────────────────────────────────────────────
467
468    fn parse_additive(&self, i: &'a str) -> IResult<&'a str, Expr> {
469        let (mut rest, mut left) = self.parse_multiplicative(i)?;
470        loop {
471            let ws = multispace0(rest)?.0;
472            let op = ws.strip_prefix('+').map(|after| (BinaryOp::Add, after))
473                .or_else(|| ws.strip_prefix('-').map(|after| (BinaryOp::Sub, after)));
474            match op {
475                None => break,
476                Some((op, after)) => {
477                    let (r, right) = self.parse_multiplicative(after)?;
478                    left = Expr::BinaryOp {
479                        op,
480                        span: self.span(i, r),
481                        left: Box::new(left),
482                        right: Box::new(right),
483                    };
484                    rest = r;
485                }
486            }
487        }
488        Ok((rest, left))
489    }
490
491    // ── concat & ─────────────────────────────────────────────────────────
492
493    fn parse_concat(&self, i: &'a str) -> IResult<&'a str, Expr> {
494        let (mut rest, mut left) = self.parse_additive(i)?;
495        loop {
496            let ws = multispace0(rest)?.0;
497            if let Some(after) = ws.strip_prefix('&') {
498                let (r, right) = self.parse_additive(after)?;
499                left = Expr::BinaryOp {
500                    op: BinaryOp::Concat,
501                    span: self.span(i, r),
502                    left: Box::new(left),
503                    right: Box::new(right),
504                };
505                rest = r;
506            } else {
507                break;
508            }
509        }
510        Ok((rest, left))
511    }
512
513    // ── comparison = <> < > <= >= ─────────────────────────────────────────
514
515    fn parse_comparison(&self, i: &'a str) -> IResult<&'a str, Expr> {
516        let (rest, left) = self.parse_concat(i)?;
517        let ws = multispace0(rest)?.0;
518
519        // Longest match first
520        let op_result: Option<(BinaryOp, &'a str)> = if let Some(after) = ws.strip_prefix("<>") {
521            Some((BinaryOp::Ne, after))
522        } else if let Some(after) = ws.strip_prefix("<=") {
523            Some((BinaryOp::Le, after))
524        } else if let Some(after) = ws.strip_prefix(">=") {
525            Some((BinaryOp::Ge, after))
526        } else if let Some(after) = ws.strip_prefix('<') {
527            Some((BinaryOp::Lt, after))
528        } else if let Some(after) = ws.strip_prefix('>') {
529            Some((BinaryOp::Gt, after))
530        } else if let Some(after) = ws.strip_prefix('=') {
531            Some((BinaryOp::Eq, after))
532        } else {
533            None
534        };
535
536        if let Some((op, after)) = op_result {
537            let (r, right) = self.parse_concat(after)?;
538            return Ok((r, Expr::BinaryOp {
539                op,
540                span: self.span(i, r),
541                left: Box::new(left),
542                right: Box::new(right),
543            }));
544        }
545
546        Ok((rest, left))
547    }
548}
549
550// ── public API ──────────────────────────────────────────────────────────────
551
552/// Parse a formula string into an expression tree.
553///
554/// The formula must start with `=`. Returns a [`ParseError`] if the input
555/// is not a valid formula.
556#[deprecated(since = "0.7.0", note = "use parse_formula() instead — parsing is flavor-independent, so no Engine is required; see ADR 2026-04-27; removal target: 0.7.0 coordinated release")]
557pub fn parse(formula: &str) -> Result<Expr, ParseError> {
558    parse_formula(formula)
559}
560
561/// Parse a formula string into an expression tree, without an [`Engine`].
562///
563/// This is the parser entry point [`Engine::parse`] and [`Engine::validate`]
564/// call. It is exposed directly because parsing is **flavor-independent and
565/// registry-free**: it reads only the formula text, so a caller that needs an
566/// AST (or only a syntax check) never has to construct an [`Engine`] — and
567/// therefore never has to build the function [`Registry`], which parsing does
568/// not consult (issue #900). Building that registry costs orders of magnitude
569/// more than the parse it was being built for.
570///
571/// The leading `=` is optional.
572///
573/// This is not a reversal of the flavor-explicit direction taken for
574/// evaluation (see ADR 2026-04-27) — parsing and evaluation are different
575/// operations. Evaluation requires an engine flavor because function
576/// behavior can differ across flavors; parsing does not, and this is
577/// verified rather than assumed: the parser holds no reference to
578/// [`Registry`] or [`Engine`], and an unknown function name fails at
579/// evaluation time, not at parse time.
580///
581/// [`Engine`]: crate::Engine
582/// [`Engine::parse`]: crate::Engine::parse
583/// [`Engine::validate`]: crate::Engine::validate
584/// [`Registry`]: crate::Registry
585pub fn parse_formula(formula: &str) -> Result<Expr, ParseError> {
586    let input = formula.strip_prefix('=').unwrap_or(formula).trim();
587    let p = Parser::new(formula);
588    match p.parse_comparison(input) {
589        Ok((rest, expr)) => {
590            let rest = rest.trim();
591            if rest.is_empty() {
592                Ok(expr)
593            } else {
594                Err(ParseError {
595                    message: format!("Unexpected input '{}'", rest),
596                    position: offset(formula, rest),
597                })
598            }
599        }
600        Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => Err(ParseError {
601            message: "Parse error".into(),
602            position: offset(formula, e.input),
603        }),
604        Err(nom::Err::Incomplete(_)) => Err(ParseError {
605            message: "Incomplete input".into(),
606            position: formula.len(),
607        }),
608    }
609}
610
611/// Validate that a formula string is syntactically correct without returning the AST.
612#[deprecated(since = "0.7.0", note = "use Engine::sheets()/Engine::excel() and engine.validate() — engine flavor is required; see ADR 2026-04-27; removal target: 0.7.0 coordinated release")]
613pub fn validate(formula: &str) -> Result<(), ParseError> {
614    parse_formula(formula).map(|_| ())
615}
616
617#[cfg(test)]
618mod tests;