Skip to main content

formualizer_parse/
tokenizer.rs

1use std::convert::TryFrom;
2use std::error::Error;
3use std::fmt::{self, Display};
4use std::sync::Arc;
5
6#[cfg(feature = "serde")]
7use serde::{Deserialize, Serialize};
8
9use crate::types::FormulaDialect;
10
11const TOKEN_ENDERS: &str = ",;}) +-*/^&=><%@";
12
13#[expect(
14    clippy::indexing_slicing,
15    clippy::arithmetic_side_effects,
16    reason = "const fn evaluated at compile time: a u8 index into a 256-entry table and a cursor bounded by the slice length"
17)]
18const fn build_token_enders() -> [bool; 256] {
19    let mut tbl = [false; 256];
20    let bytes = TOKEN_ENDERS.as_bytes();
21    let mut i = 0;
22    while i < bytes.len() {
23        tbl[bytes[i] as usize] = true;
24        i += 1;
25    }
26    tbl
27}
28static TOKEN_ENDERS_TABLE: [bool; 256] = build_token_enders();
29
30#[inline(always)]
31#[expect(clippy::indexing_slicing, reason = "u8 index into a 256-entry table")]
32fn is_token_ender(c: u8) -> bool {
33    TOKEN_ENDERS_TABLE[c as usize]
34}
35
36// Recognised Excel error literals. The lookup matches an exact-length slice
37// using `eq_ignore_ascii_case`, so ordering only matters when one entry is a
38// prefix of another (none currently are). We keep entries grouped by era and
39// sorted longest-first defensively so future additions don't introduce
40// prefix-collision ambiguity.
41//
42// Modern (Excel 2018+) literals currently recognised here are limited to the
43// ones whose `ExcelErrorKind` already exists in `formualizer-common`:
44//   - `#SPILL!` (dynamic-array spill blocked)
45//   - `#CALC!`  (generic calc-engine error)
46// Other modern literals (`#FIELD!`, `#BLOCKED!`, `#CONNECT!`, `#UNKNOWN!`,
47// `#EXTERNAL!`, `#BUSY!`, `#PYTHON!`) are intentionally not recognised yet;
48// adding them requires a coordinated change in `ExcelErrorKind` and all
49// downstream exhaustive matches/bindings/serde/display.
50//
51// `#GETTING_DATA` is kept for OOXML/legacy compatibility.
52static ERROR_CODES: &[&str] = &[
53    "#GETTING_DATA",
54    "#DIV/0!",
55    "#VALUE!",
56    "#SPILL!",
57    "#NAME?",
58    "#NULL!",
59    "#CALC!",
60    "#NUM!",
61    "#REF!",
62    "#N/A",
63];
64
65/// Represents operator associativity.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Associativity {
68    Left,
69    Right,
70}
71
72/// A custom error type for the tokenizer.
73#[derive(Debug)]
74pub struct TokenizerError {
75    pub message: String,
76    pub pos: usize,
77}
78
79/// Recovering action taken for a malformed span.
80#[non_exhaustive]
81#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum RecoveryAction {
84    /// Tokenization stopped at a resource budget; source may have been discarded.
85    ResourceLimitExceeded,
86    /// Unmatched closer was emitted as a recovery span and scanning continued.
87    SkippedUnmatchedCloser,
88    /// Unterminated string was emitted as one recovery span.
89    UnterminatedString,
90    /// Unmatched opening '[' was emitted as a recovery span.
91    UnmatchedBracket,
92    /// Invalid # literal was emitted as a recovery span.
93    InvalidErrorLiteral,
94    /// Unmatched opener was recorded at end-of-input.
95    UnmatchedOpener,
96}
97
98/// Token-level diagnostic emitted by best-effort tokenization.
99#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct TokenDiagnostic {
102    pub span: TokenSpan,
103    pub message: String,
104    pub recovery: RecoveryAction,
105}
106
107impl TokenDiagnostic {
108    fn resource_limit(message: String) -> Self {
109        Self::new(
110            TokenSpan {
111                token_type: TokenType::Literal,
112                subtype: TokenSubType::None,
113                start: 0,
114                end: 0,
115            },
116            message,
117            RecoveryAction::ResourceLimitExceeded,
118        )
119    }
120    fn new(span: TokenSpan, message: String, recovery: RecoveryAction) -> Self {
121        Self {
122            span,
123            message,
124            recovery,
125        }
126    }
127}
128
129#[derive(Debug, Clone)]
130struct SpanTokenizerError {
131    kind: SpanTokenizerErrorKind,
132    pos: usize,
133    message: String,
134    span_start: Option<usize>,
135    span_end: Option<usize>,
136}
137
138#[derive(Debug, Clone, Copy)]
139enum SpanTokenizerErrorKind {
140    NoMatchingOpener,
141    UnmatchedOpening,
142    UnterminatedString,
143    UnmatchedBracket,
144    MismatchedPair,
145    InvalidErrorLiteral,
146}
147
148impl From<SpanTokenizerError> for TokenizerError {
149    fn from(value: SpanTokenizerError) -> Self {
150        TokenizerError {
151            message: value.message,
152            pos: value.pos,
153        }
154    }
155}
156
157impl fmt::Display for TokenizerError {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        write!(f, "TokenizerError: {}", self.message)
160    }
161}
162
163impl Error for TokenizerError {}
164
165/// The type of a token.
166#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
168pub enum TokenType {
169    Literal,
170    Operand,
171    Func,
172    Array,
173    Paren,
174    Sep,
175    OpPrefix,
176    OpInfix,
177    OpPostfix,
178    Whitespace,
179}
180impl Display for TokenType {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        write!(f, "{self:?}")
183    }
184}
185
186/// The subtype of a token.
187#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
189pub enum TokenSubType {
190    None,
191    Text,
192    Number,
193    Logical,
194    Error,
195    Range,
196    Open,
197    Close,
198    Arg,
199    Row,
200}
201impl Display for TokenSubType {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        write!(f, "{self:?}")
204    }
205}
206
207/// A token in an Excel formula.
208#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
209#[derive(Debug, Clone, PartialEq, Hash)]
210pub struct Token {
211    pub value: String, // We'll keep this for API compatibility but compute it lazily
212    pub token_type: TokenType,
213    pub subtype: TokenSubType,
214    pub start: usize,
215    pub end: usize,
216}
217
218impl Display for Token {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        write!(
221            f,
222            "<{} subtype: {:?} value: {}>",
223            self.token_type, self.subtype, self.value
224        )
225    }
226}
227
228impl Token {
229    pub fn new(value: String, token_type: TokenType, subtype: TokenSubType) -> Self {
230        Token {
231            value,
232            token_type,
233            subtype,
234            start: 0,
235            end: 0,
236        }
237    }
238
239    pub fn new_with_span(
240        value: String,
241        token_type: TokenType,
242        subtype: TokenSubType,
243        start: usize,
244        end: usize,
245    ) -> Self {
246        Token {
247            value,
248            token_type,
249            subtype,
250            start,
251            end,
252        }
253    }
254
255    pub fn is_operator(&self) -> bool {
256        matches!(
257            self.token_type,
258            TokenType::OpPrefix | TokenType::OpInfix | TokenType::OpPostfix
259        )
260    }
261
262    pub fn get_precedence(&self) -> Option<(u8, Associativity)> {
263        // For a prefix operator, use the 'u' key.
264        let op = if self.token_type == TokenType::OpPrefix {
265            "u"
266        } else {
267            self.value.as_str()
268        };
269
270        // Higher number => tighter binding.
271        // Excel precedence (high to low, simplified):
272        //   reference ops (:
273        //   postfix %
274        //   prefix unary +/- (binds tighter than ^)
275        //   exponent ^ (left-assoc, as in Excel: 2^3^2 = (2^3)^2)
276        //   */
277        //   +-
278        //   &
279        //   comparisons
280        match op {
281            "#" => Some((11, Associativity::Left)),
282            ":" => Some((10, Associativity::Left)),
283            " " => Some((9, Associativity::Left)),
284            "," => Some((8, Associativity::Left)),
285            "%" => Some((7, Associativity::Left)),
286            "u" => Some((6, Associativity::Right)),
287            "^" => Some((5, Associativity::Left)),
288            "*" | "/" => Some((4, Associativity::Left)),
289            "+" | "-" => Some((3, Associativity::Left)),
290            "&" => Some((2, Associativity::Left)),
291            "=" | "<" | ">" | "<=" | ">=" | "<>" => Some((1, Associativity::Left)),
292            _ => None,
293        }
294    }
295
296    /// Create an operand token based on the value.
297    pub fn make_operand(value: String) -> Self {
298        let subtype = if value.starts_with('"') {
299            TokenSubType::Text
300        } else if value.starts_with('#') {
301            TokenSubType::Error
302        } else if value.eq_ignore_ascii_case("TRUE") || value.eq_ignore_ascii_case("FALSE") {
303            TokenSubType::Logical
304        } else if value.parse::<f64>().is_ok() {
305            TokenSubType::Number
306        } else {
307            TokenSubType::Range
308        };
309        Token::new(value, TokenType::Operand, subtype)
310    }
311
312    /// Create an operand token with byte position span.
313    pub fn make_operand_with_span(value: String, start: usize, end: usize) -> Self {
314        let subtype = if value.starts_with('"') {
315            TokenSubType::Text
316        } else if value.starts_with('#') {
317            TokenSubType::Error
318        } else if value.eq_ignore_ascii_case("TRUE") || value.eq_ignore_ascii_case("FALSE") {
319            TokenSubType::Logical
320        } else if value.parse::<f64>().is_ok() {
321            TokenSubType::Number
322        } else {
323            TokenSubType::Range
324        };
325        Token::new_with_span(value, TokenType::Operand, subtype, start, end)
326    }
327
328    /// Given an opener token, return its corresponding closer token.
329    pub fn get_closer(&self) -> Result<Token, TokenizerError> {
330        if self.subtype != TokenSubType::Open {
331            return Err(TokenizerError {
332                message: "Token is not an opener".to_string(),
333                pos: 0,
334            });
335        }
336        let (value, token_type) = match self.token_type {
337            TokenType::Func => (")", TokenType::Func),
338            TokenType::Array => ("}", TokenType::Array),
339            _ => (")", TokenType::Paren),
340        };
341        Ok(Token::new(
342            value.to_string(),
343            token_type,
344            TokenSubType::Close,
345        ))
346    }
347}
348
349#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
351pub struct TokenSpan {
352    pub token_type: TokenType,
353    pub subtype: TokenSubType,
354    pub start: usize,
355    pub end: usize,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359pub struct TokenView<'a> {
360    pub span: &'a TokenSpan,
361    pub value: &'a str,
362}
363
364/// Source-backed token stream (span-only).
365///
366/// This is intended as a high-performance representation for callers that
367/// want to avoid allocating a `String` per token. It can materialize owned
368/// `Token`s when needed (FFI/debug).
369#[derive(Debug, Clone)]
370pub struct TokenStream {
371    pub(crate) pending_error: Option<String>,
372    source: Arc<str>,
373    pub spans: Vec<TokenSpan>,
374    dialect: FormulaDialect,
375    diagnostics: Vec<TokenDiagnostic>,
376}
377
378fn is_whitespace_intersection_span(source: &str, span: &TokenSpan) -> bool {
379    if span.token_type != TokenType::OpInfix {
380        return false;
381    }
382    let Some(value) = source.get(span.start..span.end) else {
383        return false;
384    };
385    value.as_bytes().contains(&b' ')
386        && value
387            .as_bytes()
388            .iter()
389            .all(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
390}
391
392impl TokenStream {
393    pub fn new(formula: &str) -> Result<Self, TokenizerError> {
394        Self::new_with_dialect(formula, FormulaDialect::Excel)
395    }
396
397    pub fn new_with_dialect(
398        formula: &str,
399        dialect: FormulaDialect,
400    ) -> Result<Self, TokenizerError> {
401        let spans = tokenize_spans_with_dialect(formula, dialect)?;
402        let source: Arc<str> = Arc::from(formula);
403        Ok(TokenStream {
404            pending_error: None,
405            source,
406            spans,
407            dialect,
408            diagnostics: Vec::new(),
409        })
410    }
411
412    pub fn new_best_effort(formula: &str) -> Self {
413        Self::new_best_effort_with_dialect(formula, FormulaDialect::Excel)
414    }
415
416    pub fn new_best_effort_with_dialect(formula: &str, dialect: FormulaDialect) -> Self {
417        if let Err(error) = crate::ParserLimits::default().check_source(formula) {
418            return TokenStream {
419                source: Arc::from(""),
420                spans: Vec::new(),
421                dialect,
422                diagnostics: vec![TokenDiagnostic::resource_limit(error.message.clone())],
423                pending_error: Some(error.message),
424            };
425        }
426        let source: Arc<str> = Arc::from(formula);
427        let mut tokenizer = SpanTokenizer::new(source.as_ref(), dialect);
428        let spans = tokenizer.parse_best_effort();
429        let pending_error = tokenizer.exceeded.then(|| {
430            format!(
431                "Formula token limit exceeded (max {})",
432                tokenizer.token_limit
433            )
434        });
435        let mut diagnostics = tokenizer.diagnostics;
436        if let Some(message) = &pending_error {
437            diagnostics.push(TokenDiagnostic::resource_limit(message.clone()));
438        }
439        TokenStream {
440            pending_error,
441            source,
442            spans,
443            dialect,
444            diagnostics,
445        }
446    }
447
448    /// Admission for external mutable streams, before any owned materialization.
449    pub(crate) fn admission_error(&self, limits: crate::ParserLimits) -> Option<TokenizerError> {
450        if let Some(message) = &self.pending_error {
451            return Some(TokenizerError {
452                message: message.clone(),
453                pos: 0,
454            });
455        }
456        if let Err(error) = limits.check_source(&self.source) {
457            return Some(error);
458        }
459        if self.spans.len() > limits.tokens() {
460            return Some(TokenizerError {
461                message: format!("Formula token limit exceeded (max {})", limits.tokens()),
462                pos: 0,
463            });
464        }
465        let mut previous_end = 0;
466        for span in &self.spans {
467            // Disjoint ordered spans prevent repeating a large source slice
468            // thousands of times into otherwise shallow, budget-compliant ASTs.
469            if span.start < previous_end || self.source.get(span.start..span.end).is_none() {
470                return Some(TokenizerError {
471                    message: "Invalid external token span sequence".into(),
472                    pos: span.start,
473                });
474            }
475            previous_end = span.end;
476        }
477        None
478    }
479
480    pub fn diagnostics(&self) -> Vec<TokenDiagnostic> {
481        self.diagnostics.clone()
482    }
483
484    pub fn diagnostics_ref(&self) -> &[TokenDiagnostic] {
485        &self.diagnostics
486    }
487
488    pub fn has_errors(&self) -> bool {
489        !self.diagnostics.is_empty()
490    }
491
492    pub fn invalid_spans_iter(&self) -> impl Iterator<Item = &TokenSpan> {
493        self.spans.iter().filter(|span| {
494            self.diagnostics.iter().any(|diag| {
495                diag.span.start == span.start
496                    && diag.span.end == span.end
497                    && diag.span.token_type == span.token_type
498            })
499        })
500    }
501
502    pub fn invalid_spans(&self) -> Vec<&TokenSpan> {
503        self.invalid_spans_iter().collect()
504    }
505
506    pub fn source(&self) -> &str {
507        &self.source
508    }
509
510    pub fn dialect(&self) -> FormulaDialect {
511        self.dialect
512    }
513
514    pub fn len(&self) -> usize {
515        self.spans.len()
516    }
517
518    pub fn is_empty(&self) -> bool {
519        self.spans.is_empty()
520    }
521
522    pub fn get(&self, index: usize) -> Option<TokenView<'_>> {
523        let span = self.spans.get(index)?;
524        let value = self.source.get(span.start..span.end)?;
525        Some(TokenView { span, value })
526    }
527
528    pub fn to_tokens(&self) -> Vec<Token> {
529        tokens_from_spans(&self.source, &self.spans)
530    }
531
532    /// Reconstruct the tokenized payload from spans.
533    ///
534    /// For formulas that start with '=', this intentionally omits the leading
535    /// '=' to preserve historical `TokenStream::render()` behavior.
536    pub fn render(&self) -> String {
537        let mut out = String::with_capacity(self.source.len());
538        for span in &self.spans {
539            if let Some(s) = self.source.get(span.start..span.end) {
540                out.push_str(s);
541            }
542        }
543        out
544    }
545
546    /// Reconstruct the full input formula, including a leading '=' when present.
547    pub fn render_formula(&self) -> String {
548        if self.source.as_bytes().first() == Some(&b'=') {
549            format!("={}", self.render())
550        } else {
551            self.render()
552        }
553    }
554}
555
556/// Owned tokens for `spans` over `source`. A whitespace run that is the
557/// intersection operator gets the value `" "`.
558fn tokens_from_spans(source: &str, spans: &[TokenSpan]) -> Vec<Token> {
559    spans
560        .iter()
561        .map(|s| {
562            let value = if is_whitespace_intersection_span(source, s) {
563                " ".to_string()
564            } else {
565                source.get(s.start..s.end).unwrap_or_default().to_string()
566            };
567            Token::new_with_span(value, s.token_type, s.subtype, s.start, s.end)
568        })
569        .collect()
570}
571
572pub(crate) fn tokenize_spans_with_dialect(
573    formula: &str,
574    dialect: FormulaDialect,
575) -> Result<Vec<TokenSpan>, TokenizerError> {
576    tokenize_spans_with_limits(formula, dialect, crate::ParserLimits::default())
577}
578pub(crate) fn tokenize_spans_with_limits(
579    formula: &str,
580    dialect: FormulaDialect,
581    limits: crate::ParserLimits,
582) -> Result<Vec<TokenSpan>, TokenizerError> {
583    limits.check_source(formula)?;
584    let mut tokenizer = SpanTokenizer::new_with_limit(formula, dialect, limits.tokens());
585    tokenizer.parse()?;
586    Ok(tokenizer.spans)
587}
588
589fn operand_subtype(value_str: &str) -> TokenSubType {
590    if value_str.starts_with('"') {
591        TokenSubType::Text
592    } else if value_str.starts_with('#') {
593        TokenSubType::Error
594    } else if value_str.eq_ignore_ascii_case("TRUE") || value_str.eq_ignore_ascii_case("FALSE") {
595        TokenSubType::Logical
596    } else if value_str.parse::<f64>().is_ok() {
597        TokenSubType::Number
598    } else {
599        TokenSubType::Range
600    }
601}
602
603/// `$`? letters `$`? digits, with at least one letter and one digit.
604fn is_cell_reference_like(value: &str) -> bool {
605    let col = value.strip_prefix('$').unwrap_or(value);
606    let after_letters = col.trim_start_matches(|c: char| c.is_ascii_alphabetic());
607    if after_letters.len() == col.len() {
608        return false;
609    }
610    let row = after_letters.strip_prefix('$').unwrap_or(after_letters);
611    !row.is_empty() && row.bytes().all(|b| b.is_ascii_digit())
612}
613
614fn reference_value_contains_range_colon(value: &str) -> bool {
615    let value_part = value
616        .rsplit_once('!')
617        .map_or(value, |(_, value_part)| value_part);
618    value_part.contains(':')
619}
620
621/// Whether a pending operand contains a structured-reference bracket
622/// (`Table1[Col]`, `Sheet1!Table1[Col]`). A bracket that only appears in the
623/// sheet qualifier is an external workbook (`[1]Sheet1!A1`,
624/// `'[Book.xlsx]Sheet 1'!A1`), whose `:` continues the same range reference.
625fn value_has_structured_reference_bracket(value: &str) -> bool {
626    value
627        .rsplit_once('!')
628        .map_or(value, |(_, value_part)| value_part)
629        .contains('[')
630}
631
632/// The byte offset of a range `:` inside an accumulated function-name token,
633/// as in `B10:INDEX(` or `Sheet1!B10:OFFSET(`. No function name contains a
634/// colon, so the text before it is the left end of a range and the call is
635/// the right end: `B10`, `:`, `INDEX(`.
636#[expect(
637    clippy::arithmetic_side_effects,
638    reason = "byte offset from ASCII delimiter search: bang and colon are indices of bytes inside value"
639)]
640fn range_colon_before_call(value: &str) -> Option<usize> {
641    let name_start = value.rfind('!').map_or(0, |bang| bang + 1);
642    let colon = name_start + value.get(name_start..)?.rfind(':')?;
643    (colon > 0 && colon + 1 < value.len()).then_some(colon)
644}
645
646/// Whether `piece` can be one end of an A1 range: a cell (`B5`, `$B$5`), a
647/// column (`B`, `$XFD`) or a row (`5`, `$5`) inside the grid.
648#[expect(
649    clippy::arithmetic_side_effects,
650    clippy::string_slice,
651    reason = "cursor bounded by slice length; at most 3 letters are accumulated (col <= 18_278), each checked alphabetic before its offset is subtracted; the digit run is sliced at ASCII positions"
652)]
653fn is_a1_range_end(piece: &str) -> bool {
654    let bytes = piece.as_bytes();
655    let mut i = 0;
656    if bytes.get(i) == Some(&b'$') {
657        i += 1;
658    }
659    let letters_start = i;
660    let mut col: u32 = 0;
661    while let Some(&b) = bytes.get(i)
662        && b.is_ascii_alphabetic()
663        && i - letters_start < 3
664    {
665        col = col * 26 + u32::from(b.to_ascii_uppercase() - b'A' + 1);
666        i += 1;
667    }
668    let has_letters = i > letters_start;
669    if has_letters && (col > 16_384 || bytes.get(i).is_some_and(u8::is_ascii_alphabetic)) {
670        return false;
671    }
672    if has_letters && bytes.get(i) == Some(&b'$') {
673        i += 1;
674    }
675    let digits_start = i;
676    while bytes.get(i).is_some_and(u8::is_ascii_digit) {
677        i += 1;
678    }
679    let digits = &piece[digits_start..i];
680    let row_ok = digits.is_empty()
681        || (digits.len() <= 7
682            && digits
683                .parse::<u32>()
684                .is_ok_and(|r| (1..=1_048_576).contains(&r)));
685    i == bytes.len() && (has_letters || !digits.is_empty()) && row_ok
686}
687
688/// The byte offset of a `:` inside an accumulated operand that is the range
689/// operator between two operands rather than part of one A1 range. Excel
690/// reads `A1:Finish` as the cell A1, `:`, and the name `Finish`, and
691/// `Seed_1:Seed_4` as two names, because neither end can be an A1 range end.
692#[expect(
693    clippy::arithmetic_side_effects,
694    reason = "byte offset from ASCII delimiter search: start and the colon are positions inside value"
695)]
696fn range_operator_colon(value: &str) -> Option<usize> {
697    let start = value.rfind('!').map_or(0, |bang| bang + 1);
698    let part = value.get(start..)?;
699    if part.contains(['[', '\'', '"', '#']) || part.matches(':').count() != 1 {
700        return None;
701    }
702    let (left, right) = part.split_once(':')?;
703    if left.is_empty() || right.is_empty() || (is_a1_range_end(left) && is_a1_range_end(right)) {
704        return None;
705    }
706    Some(start + left.len())
707}
708
709/// Whether a `#REF!` error literal starts at `offset`.
710fn ref_error_starts_at(formula: &str, offset: usize) -> bool {
711    formula
712        .as_bytes()
713        .get(offset..)
714        .and_then(|rest| rest.get(..5))
715        .is_some_and(|s| s.eq_ignore_ascii_case(b"#REF!"))
716}
717
718fn is_reference_operand_value(value: &str) -> bool {
719    operand_subtype(value) == TokenSubType::Range
720        && (reference_value_contains_range_colon(value)
721            || value.contains('!')
722            || value.contains('[')
723            || is_cell_reference_like(value))
724}
725
726fn next_starts_reference_expression(formula: &str, offset: usize) -> bool {
727    let Some(rest) = formula.as_bytes().get(offset..) else {
728        return false;
729    };
730    rest.iter()
731        .find(|b| !matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
732        .is_some_and(|&b| matches!(b, b'(' | b'[' | b'\'' | b'$') || b.is_ascii_alphabetic())
733}
734
735fn next_reference_has_sheet_qualifier(formula: &str, offset: usize) -> bool {
736    let Some(rest) = formula.as_bytes().get(offset..) else {
737        return false;
738    };
739    let mut bytes = rest
740        .iter()
741        .copied()
742        .skip_while(|b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
743        .peekable();
744
745    let mut in_quote = false;
746    while let Some(b) = bytes.next() {
747        match b {
748            b'\'' => {
749                if in_quote && bytes.peek() == Some(&b'\'') {
750                    bytes.next();
751                    continue;
752                }
753                in_quote = !in_quote;
754            }
755            b'!' => return true,
756            b':' if !in_quote => return false,
757            b',' | b';' | b'}' | b')' | b' ' | b'\t' | b'\r' | b'\n' | b'+' | b'-' | b'*'
758            | b'/' | b'^' | b'&' | b'=' | b'>' | b'<' | b'%' | b'@'
759                if !in_quote =>
760            {
761                return false;
762            }
763            _ => {}
764        }
765    }
766
767    false
768}
769
770/// Byte-level formula tokenizer producing [`TokenSpan`]s.
771///
772/// Invariant: `token_start <= token_end <= offset <= formula.len()` outside
773/// the body of a step, and every stored offset is 0, `formula.len()`, the
774/// index of an ASCII byte or one past one (non-ASCII bytes only advance
775/// `offset` in the accumulate arm), so every offset is a char boundary. The
776/// `expect` attributes below cite this invariant.
777struct SpanTokenizer<'a> {
778    formula: &'a str,
779    spans: Vec<TokenSpan>,
780    token_stack: Vec<TokenSpan>,
781    offset: usize,
782    token_start: usize,
783    token_end: usize,
784    dialect: FormulaDialect,
785    diagnostics: Vec<TokenDiagnostic>,
786    token_limit: usize,
787    exceeded: bool,
788}
789
790impl<'a> SpanTokenizer<'a> {
791    fn new(formula: &'a str, dialect: FormulaDialect) -> Self {
792        Self::new_with_limit(formula, dialect, crate::ParserLimits::default().tokens())
793    }
794    fn new_with_limit(formula: &'a str, dialect: FormulaDialect, token_limit: usize) -> Self {
795        SpanTokenizer {
796            formula,
797            spans: Vec::with_capacity((formula.len() / 2).min(token_limit)),
798            token_stack: Vec::with_capacity(16),
799            offset: 0,
800            token_start: 0,
801            token_end: 0,
802            dialect,
803            diagnostics: Vec::new(),
804            token_limit,
805            exceeded: false,
806        }
807    }
808
809    #[inline]
810    fn current_byte(&self) -> Option<u8> {
811        self.formula.as_bytes().get(self.offset).copied()
812    }
813
814    #[inline]
815    fn has_token(&self) -> bool {
816        self.token_end > self.token_start
817    }
818
819    #[inline]
820    fn start_token(&mut self) {
821        self.token_start = self.offset;
822        self.token_end = self.offset;
823    }
824
825    #[inline]
826    fn extend_token(&mut self) {
827        self.token_end = self.offset;
828    }
829
830    /// The accumulated token text.
831    #[inline]
832    #[expect(
833        clippy::string_slice,
834        reason = "SpanTokenizer invariant: token_start <= token_end <= formula.len(), and every stored offset is 0, the length, the index of an ASCII byte or one past one, so it is a char boundary"
835    )]
836    fn token_str(&self) -> &'a str {
837        &self.formula[self.token_start..self.token_end]
838    }
839
840    /// The source text between two offsets of this tokenizer.
841    #[inline]
842    #[expect(
843        clippy::string_slice,
844        reason = "SpanTokenizer invariant: token_start <= token_end <= formula.len(), and every stored offset is 0, the length, the index of an ASCII byte or one past one, so it is a char boundary"
845    )]
846    fn source_str(&self, start: usize, end: usize) -> &'a str {
847        &self.formula[start..end]
848    }
849
850    fn push_span(
851        &mut self,
852        token_type: TokenType,
853        subtype: TokenSubType,
854        start: usize,
855        end: usize,
856    ) {
857        if self.spans.len() >= self.token_limit {
858            self.exceeded = true;
859            return;
860        }
861        self.spans.push(TokenSpan {
862            token_type,
863            subtype,
864            start,
865            end,
866        });
867    }
868
869    #[expect(
870        clippy::arithmetic_side_effects,
871        reason = "byte offset from ASCII delimiter search: colon is the index of a `:` inside the token, so colon + 1 <= token_end"
872    )]
873    fn save_token(&mut self) {
874        if self.has_token() {
875            if let Some(colon) = range_operator_colon(self.token_str()) {
876                let colon = self.token_start + colon;
877                let left = operand_subtype(self.source_str(self.token_start, colon));
878                self.push_span(TokenType::Operand, left, self.token_start, colon);
879                self.push_span(TokenType::OpInfix, TokenSubType::None, colon, colon + 1);
880                self.token_start = colon + 1;
881            }
882            let subtype = operand_subtype(self.token_str());
883            self.push_span(
884                TokenType::Operand,
885                subtype,
886                self.token_start,
887                self.token_end,
888            );
889        }
890    }
891
892    #[expect(
893        clippy::arithmetic_side_effects,
894        reason = "cursor bounded by slice length: current_byte() returned Some, so offset < len"
895    )]
896    fn check_scientific_notation(&mut self) -> bool {
897        if let Some(curr_byte) = self.current_byte()
898            && (curr_byte == b'+' || curr_byte == b'-')
899            && self.has_token()
900            && self.is_scientific_notation_base()
901            && self
902                .formula
903                .as_bytes()
904                .get(self.offset + 1)
905                .is_some_and(|b| b.is_ascii_digit())
906        {
907            self.offset += 1;
908            self.extend_token();
909            return true;
910        }
911        false
912    }
913
914    fn is_scientific_notation_base(&self) -> bool {
915        let [first, middle @ .., last] = self.token_str().as_bytes() else {
916            return false;
917        };
918        if !(*last == b'E' || *last == b'e') {
919            return false;
920        }
921        if !first.is_ascii_digit() {
922            return false;
923        }
924
925        let mut dot_seen = false;
926        for &ch in middle {
927            match ch {
928                b'0'..=b'9' => {}
929                b'.' if !dot_seen => dot_seen = true,
930                _ => return false,
931            }
932        }
933        true
934    }
935
936    fn parse(&mut self) -> Result<(), TokenizerError> {
937        let result = self.parse_with_recovery(false).map_err(Into::into);
938        if self.exceeded {
939            return Err(TokenizerError {
940                message: format!("Formula token limit exceeded (max {})", self.token_limit),
941                pos: self.offset,
942            });
943        }
944        result
945    }
946
947    pub(crate) fn parse_best_effort(&mut self) -> Vec<TokenSpan> {
948        let _ = self.parse_with_recovery(true);
949        self.spans.clone()
950    }
951
952    #[expect(
953        clippy::arithmetic_side_effects,
954        reason = "cursor bounded by slice length: current_byte() returned Some, so offset < len"
955    )]
956    fn parse_with_recovery(&mut self, best_effort: bool) -> Result<(), SpanTokenizerError> {
957        match self.formula.as_bytes().first() {
958            None => return Ok(()),
959            Some(b'=') => {}
960            Some(_) => {
961                self.push_span(
962                    TokenType::Literal,
963                    TokenSubType::None,
964                    0,
965                    self.formula.len(),
966                );
967                return Ok(());
968            }
969        }
970
971        self.offset = 1;
972        self.start_token();
973
974        // The scientific-notation check may advance `offset`; it then
975        // `continue`s, so `curr_byte` is always the byte at `offset`.
976        while let Some(curr_byte) = self.current_byte() {
977            if self.exceeded {
978                break;
979            }
980            if self.check_scientific_notation() {
981                continue;
982            }
983
984            if is_token_ender(curr_byte) && self.has_token() {
985                self.save_token();
986                self.start_token();
987            }
988
989            let parse_result = match curr_byte {
990                b'"' | b'\'' => self.parse_string(curr_byte),
991                b'[' => self.parse_brackets(),
992                b'#' => {
993                    if self.should_emit_hash_postfix() {
994                        self.emit_hash_postfix();
995                        Ok(())
996                    } else {
997                        self.parse_error()
998                    }
999                }
1000                b' ' | b'\t' | b'\r' | b'\n' => self.parse_whitespace(),
1001                b':' => {
1002                    if self.should_emit_colon_infix() {
1003                        self.emit_infix_operator(self.offset, self.offset + 1);
1004                        Ok(())
1005                    } else {
1006                        if !self.has_token() {
1007                            self.start_token();
1008                        }
1009                        self.offset += 1;
1010                        self.extend_token();
1011                        Ok(())
1012                    }
1013                }
1014                b'+' | b'-' | b'*' | b'/' | b'^' | b'&' | b'=' | b'>' | b'<' | b'%' | b'@' => {
1015                    self.parse_operator(curr_byte)
1016                }
1017                b'{' | b'(' => self.parse_opener(curr_byte),
1018                b')' | b'}' => self.parse_closer(curr_byte),
1019                b';' | b',' => self.parse_separator(curr_byte),
1020                _ => {
1021                    if !self.has_token() {
1022                        self.start_token();
1023                    }
1024                    self.offset += 1;
1025                    self.extend_token();
1026                    Ok(())
1027                }
1028            };
1029
1030            if let Err(err) = parse_result {
1031                if best_effort {
1032                    self.recover_from_error(err);
1033                } else {
1034                    return Err(err);
1035                }
1036            }
1037        }
1038
1039        // Do not recover/sweep unmatched openers after budget exhaustion.
1040        // Admission failure is reported by parse() or the best-effort adapter.
1041        if self.exceeded {
1042            return Ok(());
1043        }
1044        if self.has_token() {
1045            self.save_token();
1046        }
1047        if self.exceeded {
1048            return Ok(());
1049        }
1050
1051        if !self.token_stack.is_empty() {
1052            if best_effort {
1053                while let Some(open_token) = self.token_stack.pop() {
1054                    if let Some(span) = self.spans.iter().find(|span| {
1055                        span.start == open_token.start
1056                            && span.end == open_token.end
1057                            && span.token_type == open_token.token_type
1058                            && span.subtype == open_token.subtype
1059                    }) {
1060                        self.diagnostics.push(TokenDiagnostic::new(
1061                            *span,
1062                            "Unmatched opening parenthesis or bracket".to_string(),
1063                            RecoveryAction::UnmatchedOpener,
1064                        ));
1065                    }
1066                }
1067            } else {
1068                return Err(SpanTokenizerError {
1069                    kind: SpanTokenizerErrorKind::UnmatchedOpening,
1070                    pos: self.offset,
1071                    message: "Unmatched opening parenthesis or bracket".to_string(),
1072                    span_start: None,
1073                    span_end: None,
1074                });
1075            }
1076        }
1077
1078        Ok(())
1079    }
1080
1081    #[expect(
1082        clippy::arithmetic_side_effects,
1083        reason = "error.pos is the offset of the ASCII byte that failed, < len, so pos + 1 <= len"
1084    )]
1085    fn recover_from_error(&mut self, error: SpanTokenizerError) {
1086        if self.spans.len() >= self.token_limit {
1087            self.exceeded = true;
1088            return;
1089        }
1090        match error.kind {
1091            SpanTokenizerErrorKind::NoMatchingOpener => {
1092                let span = TokenSpan {
1093                    token_type: TokenType::Operand,
1094                    subtype: TokenSubType::None,
1095                    start: error.pos,
1096                    end: error.pos + 1,
1097                };
1098                self.push_span(TokenType::Operand, TokenSubType::None, span.start, span.end);
1099                self.offset = span.end;
1100                self.start_token();
1101                self.diagnostics.push(TokenDiagnostic::new(
1102                    span,
1103                    format!("No matching opener for closer at position {}", error.pos),
1104                    RecoveryAction::SkippedUnmatchedCloser,
1105                ));
1106            }
1107            SpanTokenizerErrorKind::UnmatchedOpening => {
1108                debug_assert!(
1109                    false,
1110                    "UnmatchedOpening is handled at end-of-input and should not be routed through recover_from_error"
1111                );
1112            }
1113            SpanTokenizerErrorKind::UnterminatedString => {
1114                let start = error.span_start.unwrap_or(error.pos);
1115                let span = TokenSpan {
1116                    token_type: TokenType::Operand,
1117                    subtype: TokenSubType::None,
1118                    start,
1119                    end: self.formula.len(),
1120                };
1121                self.push_span(TokenType::Operand, TokenSubType::None, span.start, span.end);
1122                self.offset = span.end;
1123                self.start_token();
1124                self.diagnostics.push(TokenDiagnostic::new(
1125                    span,
1126                    "Reached end of formula while parsing string".to_string(),
1127                    RecoveryAction::UnterminatedString,
1128                ));
1129            }
1130            SpanTokenizerErrorKind::UnmatchedBracket => {
1131                let start = error.span_start.unwrap_or(error.pos);
1132                let end = error.span_end.unwrap_or(self.formula.len());
1133                let span = TokenSpan {
1134                    token_type: TokenType::Operand,
1135                    subtype: TokenSubType::None,
1136                    start,
1137                    end,
1138                };
1139                self.push_span(TokenType::Operand, TokenSubType::None, span.start, span.end);
1140                self.offset = span.end;
1141                self.start_token();
1142                self.diagnostics.push(TokenDiagnostic::new(
1143                    span,
1144                    "Encountered unmatched '['".to_string(),
1145                    RecoveryAction::UnmatchedBracket,
1146                ));
1147            }
1148            SpanTokenizerErrorKind::MismatchedPair => {
1149                let span = TokenSpan {
1150                    token_type: TokenType::Operand,
1151                    subtype: TokenSubType::None,
1152                    start: error.pos,
1153                    end: error.pos + 1,
1154                };
1155                self.push_span(TokenType::Operand, TokenSubType::None, span.start, span.end);
1156                self.offset = span.end;
1157                self.start_token();
1158                self.diagnostics.push(TokenDiagnostic::new(
1159                    span,
1160                    "Mismatched ( and { pair".to_string(),
1161                    RecoveryAction::SkippedUnmatchedCloser,
1162                ));
1163            }
1164            SpanTokenizerErrorKind::InvalidErrorLiteral => {
1165                let start = error.span_start.unwrap_or(error.pos);
1166                let end = error.span_end.unwrap_or(error.pos + 1);
1167                let span = TokenSpan {
1168                    token_type: TokenType::Operand,
1169                    subtype: TokenSubType::None,
1170                    start,
1171                    end,
1172                };
1173                self.push_span(TokenType::Operand, TokenSubType::None, span.start, span.end);
1174                self.offset = span.end;
1175                self.start_token();
1176                self.diagnostics.push(TokenDiagnostic::new(
1177                    span,
1178                    "Invalid error code".to_string(),
1179                    RecoveryAction::InvalidErrorLiteral,
1180                ));
1181            }
1182        }
1183    }
1184
1185    /// `delim` is the quote byte at `offset`.
1186    #[expect(
1187        clippy::arithmetic_side_effects,
1188        reason = "cursor bounded by slice length: offset only advances past bytes read with get"
1189    )]
1190    fn parse_string(&mut self, delim: u8) -> Result<(), SpanTokenizerError> {
1191        debug_assert!(delim == b'"' || delim == b'\'');
1192        debug_assert_eq!(self.current_byte(), Some(delim));
1193
1194        let is_dollar_ref = delim == b'\'' && self.token_str() == "$";
1195
1196        // Issue #79: only single-quoted strings (cross-sheet references like
1197        // `A1:'Other Sheet'!B2`) should glue onto a pending `:`-terminated
1198        // token. Double-quoted string literals must always flush so the
1199        // pending `A1:` prefix is not silently discarded.
1200        let glue_to_token = delim == b'\'' && self.token_str().ends_with(':');
1201
1202        if !is_dollar_ref && !glue_to_token && self.has_token() {
1203            self.save_token();
1204            self.start_token();
1205        }
1206
1207        let string_start = if is_dollar_ref {
1208            self.token_start
1209        } else {
1210            self.offset
1211        };
1212        self.offset += 1;
1213
1214        while let Some(b) = self.current_byte() {
1215            if b == delim {
1216                self.offset += 1;
1217                if self.current_byte() == Some(delim) {
1218                    self.offset += 1;
1219                } else {
1220                    if delim == b'"' {
1221                        let subtype = operand_subtype(self.source_str(string_start, self.offset));
1222                        self.push_span(TokenType::Operand, subtype, string_start, self.offset);
1223                        self.start_token();
1224                    } else {
1225                        self.token_end = self.offset;
1226                    }
1227                    return Ok(());
1228                }
1229            } else {
1230                self.offset += 1;
1231            }
1232        }
1233
1234        Err(SpanTokenizerError {
1235            kind: SpanTokenizerErrorKind::UnterminatedString,
1236            pos: self.offset,
1237            message: "Reached end of formula while parsing string".to_string(),
1238            span_start: Some(string_start),
1239            span_end: Some(self.formula.len()),
1240        })
1241    }
1242
1243    #[expect(
1244        clippy::arithmetic_side_effects,
1245        reason = "cursor bounded by slice length: offset advances by 2 only when offset + 1 < len; open_count counts unclosed `[` and is decremented only while positive"
1246    )]
1247    fn parse_brackets(&mut self) -> Result<(), SpanTokenizerError> {
1248        debug_assert_eq!(self.current_byte(), Some(b'['));
1249
1250        if !self.has_token() {
1251            self.start_token();
1252        }
1253
1254        let bracket_start = self.offset;
1255        let mut open_count: usize = 1;
1256        self.offset += 1;
1257
1258        while let Some(b) = self.current_byte() {
1259            match b {
1260                // OOXML structured-reference escape: a single apostrophe makes
1261                // the next byte literal (used to embed `[`, `]`, `'`, or `#`
1262                // inside a column name). Skip the following byte without
1263                // updating nesting depth.
1264                b'\'' => {
1265                    if self.offset + 1 < self.formula.len() {
1266                        self.offset += 2;
1267                        continue;
1268                    }
1269                    // Trailing apostrophe inside brackets is malformed; fall
1270                    // through so the loop ends with an UnmatchedBracket error.
1271                    self.offset += 1;
1272                    continue;
1273                }
1274                b'[' => open_count += 1,
1275                b']' => {
1276                    open_count -= 1;
1277                    if open_count == 0 {
1278                        self.offset += 1;
1279                        self.extend_token();
1280                        return Ok(());
1281                    }
1282                }
1283                _ => {}
1284            }
1285            self.offset += 1;
1286        }
1287
1288        Err(SpanTokenizerError {
1289            kind: SpanTokenizerErrorKind::UnmatchedBracket,
1290            pos: self.offset,
1291            message: "Encountered unmatched '['".to_string(),
1292            span_start: Some(bracket_start),
1293            span_end: Some(self.formula.len()),
1294        })
1295    }
1296
1297    #[expect(
1298        clippy::arithmetic_side_effects,
1299        reason = "cursor bounded by slice length: offset advances past an error code matched inside the formula; end scans under get"
1300    )]
1301    fn parse_error(&mut self) -> Result<(), SpanTokenizerError> {
1302        // OOXML serializes broken sheet-qualified references as `Sheet1!#REF!`.
1303        // When an accumulated token ends with `!`, treat the prefix as a sheet
1304        // qualifier and discard it: the resulting AST is identical to the bare
1305        // error literal `=#REF!`, preserving the error kind via the matched
1306        // `ERROR_CODES` entry below.
1307        let has_sheet_prefix = self.token_str().ends_with('!');
1308        if has_sheet_prefix {
1309            if self.token_str().len() <= 1 {
1310                return Err(SpanTokenizerError {
1311                    kind: SpanTokenizerErrorKind::InvalidErrorLiteral,
1312                    pos: self.offset,
1313                    message: format!(
1314                        "Empty sheet qualifier before error literal at position {}",
1315                        self.offset
1316                    ),
1317                    span_start: Some(self.token_start),
1318                    span_end: Some(self.offset),
1319                });
1320            }
1321            // Discard the sheet prefix; the error kind is what matters.
1322            self.start_token();
1323        } else if self.has_token() {
1324            self.save_token();
1325            self.start_token();
1326        }
1327
1328        // A defined name whose sheet was deleted decays to `#REF!#REF!`: a
1329        // deleted sheet qualifier followed by a deleted address. Like the
1330        // `Sheet1!#REF!` prefix above, the qualifier is discarded and the
1331        // operand is the single error literal `#REF!`.
1332        if ref_error_starts_at(self.formula, self.offset)
1333            && ref_error_starts_at(self.formula, self.offset + 5)
1334        {
1335            self.offset += 5;
1336        }
1337
1338        let error_start = self.offset;
1339
1340        for &err_code in ERROR_CODES {
1341            let err_bytes = err_code.as_bytes();
1342            if self
1343                .formula
1344                .as_bytes()
1345                .get(self.offset..)
1346                .and_then(|rest| rest.get(..err_bytes.len()))
1347                .is_some_and(|slice| slice.eq_ignore_ascii_case(err_bytes))
1348            {
1349                self.push_span(
1350                    TokenType::Operand,
1351                    TokenSubType::Error,
1352                    error_start,
1353                    self.offset + err_bytes.len(),
1354                );
1355                self.offset += err_bytes.len();
1356                self.start_token();
1357                return Ok(());
1358            }
1359        }
1360
1361        let mut end = self.offset + 1;
1362        while let Some(&ch) = self.formula.as_bytes().get(end) {
1363            if is_token_ender(ch)
1364                || ch == b' '
1365                || ch == b'\t'
1366                || ch == b'\r'
1367                || ch == b'\n'
1368                || ch == b'('
1369                || ch == b'{'
1370                || ch == b'['
1371                || ch == b'"'
1372                || ch == b'\''
1373            {
1374                break;
1375            }
1376            end += 1;
1377        }
1378
1379        Err(SpanTokenizerError {
1380            kind: SpanTokenizerErrorKind::InvalidErrorLiteral,
1381            pos: self.offset,
1382            message: format!("Invalid error code at position {}", self.offset),
1383            span_start: Some(error_start),
1384            span_end: Some(end),
1385        })
1386    }
1387
1388    #[expect(
1389        clippy::arithmetic_side_effects,
1390        reason = "cursor bounded by slice length"
1391    )]
1392    fn parse_whitespace(&mut self) -> Result<(), SpanTokenizerError> {
1393        self.save_token();
1394
1395        let ws_start = self.offset;
1396        let mut contains_intersection_space = false;
1397        while let Some(b) = self.current_byte() {
1398            match b {
1399                b' ' => {
1400                    contains_intersection_space = true;
1401                    self.offset += 1;
1402                }
1403                b'\t' | b'\r' | b'\n' => self.offset += 1,
1404                _ => break,
1405            }
1406        }
1407
1408        // Excel accepts TAB/CR/LF as lexical whitespace, but only an ASCII
1409        // space in the run can spell the range-intersection operator.
1410        let token_type = if contains_intersection_space
1411            && self.prev_is_reference_producing()
1412            && next_starts_reference_expression(self.formula, self.offset)
1413        {
1414            TokenType::OpInfix
1415        } else {
1416            TokenType::Whitespace
1417        };
1418        self.push_span(token_type, TokenSubType::None, ws_start, self.offset);
1419        self.start_token();
1420        Ok(())
1421    }
1422
1423    fn prev_is_reference_producing(&self) -> bool {
1424        match self.prev_non_whitespace() {
1425            Some(prev) => match prev.token_type {
1426                TokenType::OpPostfix => true,
1427                TokenType::Paren | TokenType::Func | TokenType::Array
1428                    if prev.subtype == TokenSubType::Close =>
1429                {
1430                    true
1431                }
1432                TokenType::Operand if prev.subtype == TokenSubType::Range => self
1433                    .formula
1434                    .get(prev.start..prev.end)
1435                    .is_some_and(is_reference_operand_value),
1436                _ => false,
1437            },
1438            None => false,
1439        }
1440    }
1441
1442    #[expect(
1443        clippy::arithmetic_side_effects,
1444        reason = "cursor bounded by slice length: offset is the index of the `:` being dispatched, < len"
1445    )]
1446    fn should_emit_colon_infix(&self) -> bool {
1447        if self.has_token() {
1448            let value = self.token_str();
1449            if value.ends_with('!') {
1450                return false;
1451            }
1452            return reference_value_contains_range_colon(value)
1453                || value_has_structured_reference_bracket(value)
1454                // A range end pointing at deleted cells is stored as `#REF!`
1455                // (`A1:#REF!`); the `:` next to it is still the range operator.
1456                || ref_error_starts_at(self.formula, self.offset + 1)
1457                || (value.contains('!')
1458                    && next_reference_has_sheet_qualifier(self.formula, self.offset + 1));
1459        }
1460        self.prev_is_reference_producing()
1461            || self.prev_non_whitespace().is_some_and(|prev| {
1462                prev.subtype == TokenSubType::Error && ref_error_starts_at(self.formula, prev.start)
1463            })
1464    }
1465
1466    fn emit_infix_operator(&mut self, start: usize, end: usize) {
1467        self.save_token();
1468        self.start_token();
1469        self.push_span(TokenType::OpInfix, TokenSubType::None, start, end);
1470        self.offset = end;
1471        self.start_token();
1472    }
1473
1474    fn prev_non_whitespace(&self) -> Option<&TokenSpan> {
1475        self.spans
1476            .iter()
1477            .rev()
1478            .find(|t| t.token_type != TokenType::Whitespace)
1479    }
1480
1481    /// Decide whether a `#` at `self.offset` should be emitted as a spill
1482    /// postfix operator (`OpPostfix`) instead of routed to the error-literal
1483    /// parser. The rule mirrors the space-operator gating: `#` is postfix when
1484    /// it follows a reference-producing token.
1485    fn should_emit_hash_postfix(&self) -> bool {
1486        if self.has_token() {
1487            let value = self.token_str();
1488            // An accumulated token whose last byte is `!` is a sheet/file
1489            // qualifier (e.g. `Sheet1!`). Defer to `parse_error` so it can
1490            // merge `Sheet1!` + `#REF!` into a single qualified-error operand.
1491            if value.ends_with('!') {
1492                return false;
1493            }
1494            return operand_subtype(value) == TokenSubType::Range;
1495        }
1496        match self.prev_non_whitespace() {
1497            Some(prev) => match prev.token_type {
1498                TokenType::OpPostfix => true,
1499                TokenType::Paren | TokenType::Func | TokenType::Array
1500                    if prev.subtype == TokenSubType::Close =>
1501                {
1502                    true
1503                }
1504                TokenType::Operand if prev.subtype == TokenSubType::Range => true,
1505                _ => false,
1506            },
1507            None => false,
1508        }
1509    }
1510
1511    #[expect(
1512        clippy::arithmetic_side_effects,
1513        reason = "cursor bounded by slice length: offset is the index of the `#` being dispatched, < len"
1514    )]
1515    fn emit_hash_postfix(&mut self) {
1516        self.save_token();
1517        self.start_token();
1518        self.push_span(
1519            TokenType::OpPostfix,
1520            TokenSubType::None,
1521            self.offset,
1522            self.offset + 1,
1523        );
1524        self.offset += 1;
1525        self.start_token();
1526    }
1527
1528    /// `curr_byte` is the operator byte at `offset`.
1529    #[expect(
1530        clippy::arithmetic_side_effects,
1531        reason = "cursor bounded by slice length: offset < len while dispatching, and the two-byte operators were read with get"
1532    )]
1533    fn parse_operator(&mut self, curr_byte: u8) -> Result<(), SpanTokenizerError> {
1534        debug_assert_eq!(self.current_byte(), Some(curr_byte));
1535        self.save_token();
1536
1537        if let Some(b">=" | b"<=" | b"<>") = self
1538            .formula
1539            .as_bytes()
1540            .get(self.offset..)
1541            .and_then(|rest| rest.get(..2))
1542        {
1543            self.push_span(
1544                TokenType::OpInfix,
1545                TokenSubType::None,
1546                self.offset,
1547                self.offset + 2,
1548            );
1549            self.offset += 2;
1550            self.start_token();
1551            return Ok(());
1552        }
1553
1554        let token_type = match curr_byte {
1555            b'@' => TokenType::OpPrefix,
1556            b'%' => TokenType::OpPostfix,
1557            b'+' | b'-' => {
1558                if self.spans.is_empty() {
1559                    TokenType::OpPrefix
1560                } else {
1561                    let prev = self.prev_non_whitespace();
1562                    if let Some(p) = prev {
1563                        if p.subtype == TokenSubType::Close
1564                            || p.token_type == TokenType::OpPostfix
1565                            || p.token_type == TokenType::Operand
1566                        {
1567                            TokenType::OpInfix
1568                        } else {
1569                            TokenType::OpPrefix
1570                        }
1571                    } else {
1572                        TokenType::OpPrefix
1573                    }
1574                }
1575            }
1576            _ => TokenType::OpInfix,
1577        };
1578
1579        self.push_span(token_type, TokenSubType::None, self.offset, self.offset + 1);
1580        self.offset += 1;
1581        self.start_token();
1582        Ok(())
1583    }
1584
1585    /// `curr_byte` is the `(` or `{` at `offset`.
1586    #[expect(
1587        clippy::arithmetic_side_effects,
1588        reason = "cursor bounded by slice length: offset < len while dispatching; colon is the index of a `:` inside the token"
1589    )]
1590    fn parse_opener(&mut self, curr_byte: u8) -> Result<(), SpanTokenizerError> {
1591        debug_assert!(curr_byte == b'(' || curr_byte == b'{');
1592        debug_assert_eq!(self.current_byte(), Some(curr_byte));
1593
1594        let token = if curr_byte == b'{' {
1595            self.save_token();
1596            TokenSpan {
1597                token_type: TokenType::Array,
1598                subtype: TokenSubType::Open,
1599                start: self.offset,
1600                end: self.offset + 1,
1601            }
1602        } else if self.has_token() {
1603            if let Some(colon) = range_colon_before_call(self.token_str()) {
1604                let colon = self.token_start + colon;
1605                let subtype = operand_subtype(self.source_str(self.token_start, colon));
1606                self.push_span(TokenType::Operand, subtype, self.token_start, colon);
1607                self.push_span(TokenType::OpInfix, TokenSubType::None, colon, colon + 1);
1608                self.token_start = colon + 1;
1609            }
1610            let token = TokenSpan {
1611                token_type: TokenType::Func,
1612                subtype: TokenSubType::Open,
1613                start: self.token_start,
1614                end: self.offset + 1,
1615            };
1616            self.token_start = self.offset + 1;
1617            self.token_end = self.offset + 1;
1618            token
1619        } else {
1620            TokenSpan {
1621                token_type: TokenType::Paren,
1622                subtype: TokenSubType::Open,
1623                start: self.offset,
1624                end: self.offset + 1,
1625            }
1626        };
1627
1628        if self.spans.len() >= self.token_limit {
1629            self.exceeded = true;
1630            return Ok(());
1631        }
1632        self.spans.push(token);
1633        self.token_stack.push(token);
1634        self.offset += 1;
1635        self.start_token();
1636        Ok(())
1637    }
1638
1639    /// `curr_byte` is the `)` or `}` at `offset`.
1640    #[expect(
1641        clippy::arithmetic_side_effects,
1642        reason = "cursor bounded by slice length: offset < len while dispatching"
1643    )]
1644    fn parse_closer(&mut self, curr_byte: u8) -> Result<(), SpanTokenizerError> {
1645        debug_assert!(curr_byte == b')' || curr_byte == b'}');
1646        debug_assert_eq!(self.current_byte(), Some(curr_byte));
1647        self.save_token();
1648
1649        if let Some(open_token) = self.token_stack.last().copied() {
1650            let expected = if open_token.token_type == TokenType::Array {
1651                b'}'
1652            } else {
1653                b')'
1654            };
1655            if curr_byte != expected {
1656                return Err(SpanTokenizerError {
1657                    kind: SpanTokenizerErrorKind::MismatchedPair,
1658                    pos: self.offset,
1659                    message: "Mismatched ( and { pair".to_string(),
1660                    span_start: Some(self.offset),
1661                    span_end: Some(self.offset + 1),
1662                });
1663            }
1664
1665            self.token_stack.pop();
1666            self.push_span(
1667                open_token.token_type,
1668                TokenSubType::Close,
1669                self.offset,
1670                self.offset + 1,
1671            );
1672        } else {
1673            return Err(SpanTokenizerError {
1674                kind: SpanTokenizerErrorKind::NoMatchingOpener,
1675                pos: self.offset,
1676                message: format!("No matching opener for closer at position {}", self.offset),
1677                span_start: Some(self.offset),
1678                span_end: Some(self.offset + 1),
1679            });
1680        }
1681
1682        self.offset += 1;
1683        self.start_token();
1684        Ok(())
1685    }
1686
1687    /// `curr_byte` is the `,` or `;` at `offset`.
1688    #[expect(
1689        clippy::arithmetic_side_effects,
1690        reason = "cursor bounded by slice length: offset < len while dispatching"
1691    )]
1692    fn parse_separator(&mut self, curr_byte: u8) -> Result<(), SpanTokenizerError> {
1693        debug_assert!(curr_byte == b';' || curr_byte == b',');
1694        debug_assert_eq!(self.current_byte(), Some(curr_byte));
1695        self.save_token();
1696
1697        let top_token = self.token_stack.last();
1698        let in_function_or_array = matches!(
1699            top_token.map(|t| t.token_type),
1700            Some(TokenType::Func | TokenType::Array)
1701        );
1702        let in_array = matches!(top_token.map(|t| t.token_type), Some(TokenType::Array));
1703
1704        let (token_type, subtype) = match curr_byte {
1705            b',' => {
1706                if in_function_or_array {
1707                    (TokenType::Sep, TokenSubType::Arg)
1708                } else {
1709                    (TokenType::OpInfix, TokenSubType::None)
1710                }
1711            }
1712            b';' => {
1713                if in_array {
1714                    (TokenType::Sep, TokenSubType::Row)
1715                } else if self.dialect == FormulaDialect::OpenFormula && in_function_or_array {
1716                    (TokenType::Sep, TokenSubType::Arg)
1717                } else if self.dialect == FormulaDialect::OpenFormula {
1718                    (TokenType::OpInfix, TokenSubType::None)
1719                } else {
1720                    (TokenType::Sep, TokenSubType::Row)
1721                }
1722            }
1723            _ => (TokenType::OpInfix, TokenSubType::None),
1724        };
1725
1726        self.push_span(token_type, subtype, self.offset, self.offset + 1);
1727        self.offset += 1;
1728        self.start_token();
1729        Ok(())
1730    }
1731}
1732
1733/// A tokenizer for Excel worksheet formulas.
1734///
1735/// An owned form of [`TokenStream`]: the same tokens, each with its text
1736/// copied into [`Token::value`], in [`Self::items`].
1737pub struct Tokenizer {
1738    pub items: Vec<Token>,
1739    admission_error: Option<TokenizerError>,
1740    dialect: FormulaDialect,
1741}
1742
1743impl Tokenizer {
1744    /// Create a new tokenizer and immediately parse the formula.
1745    pub fn new(formula: &str) -> Result<Self, TokenizerError> {
1746        Self::new_with_dialect(formula, FormulaDialect::Excel)
1747    }
1748
1749    /// Recover malformed syntax without returning a Result.
1750    /// Check [`Self::admission_error`] before using output: resource rejection
1751    /// produces empty output rather than a silently truncated formula.
1752    pub fn new_best_effort(formula: &str) -> Self {
1753        Self::new_best_effort_with_dialect(formula, FormulaDialect::Excel)
1754    }
1755
1756    /// Create a new tokenizer with best-effort parsing for the specified dialect.
1757    pub fn new_best_effort_with_dialect(formula: &str, dialect: FormulaDialect) -> Self {
1758        let stream = TokenStream::new_best_effort_with_dialect(formula, dialect);
1759        Self::from_token_stream(&stream)
1760    }
1761
1762    /// Create a new tokenizer for the specified formula dialect.
1763    pub fn new_with_dialect(
1764        formula: &str,
1765        dialect: FormulaDialect,
1766    ) -> Result<Self, TokenizerError> {
1767        let spans = tokenize_spans_with_dialect(formula, dialect)?;
1768        Ok(Tokenizer {
1769            items: tokens_from_spans(formula, &spans),
1770            admission_error: None,
1771            dialect,
1772        })
1773    }
1774
1775    pub fn from_token_stream(stream: &TokenStream) -> Self {
1776        let admission_error = stream.admission_error(crate::ParserLimits::default());
1777        let items = if admission_error.is_some() {
1778            Vec::new()
1779        } else {
1780            stream.to_tokens()
1781        };
1782        Tokenizer {
1783            items,
1784            admission_error,
1785            dialect: stream.dialect,
1786        }
1787    }
1788
1789    /// Why an infallible best-effort/stream constructor could not admit input.
1790    pub fn admission_error(&self) -> Option<&TokenizerError> {
1791        self.admission_error.as_ref()
1792    }
1793
1794    /// Reconstruct the formula from the parsed tokens.
1795    pub fn render(&self) -> String {
1796        match self.items.first() {
1797            None => String::new(),
1798            Some(first) if first.token_type == TokenType::Literal => first.value.clone(),
1799            Some(_) => {
1800                let mut out = String::from("=");
1801                for token in &self.items {
1802                    out.push_str(&token.value);
1803                }
1804                out
1805            }
1806        }
1807    }
1808
1809    /// Return the dialect used when tokenizing this formula.
1810    pub fn dialect(&self) -> FormulaDialect {
1811        self.dialect
1812    }
1813}
1814
1815impl TryFrom<&str> for Tokenizer {
1816    type Error = TokenizerError;
1817
1818    fn try_from(value: &str) -> Result<Self, Self::Error> {
1819        Tokenizer::new(value)
1820    }
1821}
1822
1823impl TryFrom<String> for Tokenizer {
1824    type Error = TokenizerError;
1825
1826    fn try_from(value: String) -> Result<Self, Self::Error> {
1827        Tokenizer::new(&value)
1828    }
1829}