Skip to main content

vla_lang/
sheet.rs

1//! The sheet model (PORT.7): what a workbook holds before it is written.
2//! The language's since PORT.12 (2026-10-08): the grid with its A1
3//! coordinates, its style table and the references a formula's text holds
4//! (`crate::refers`) are what an engine stands on; the file formats that
5//! write and read it stay in `frazaro-core`'s `sheet` module, which
6//! re-exports this one.
7//! Sheets of cells, each cell a text, a number, a truth value, an error
8//! value or a formula's text; column widths, a hidden column, a style per
9//! cell or per column from a small style table; and nothing here that
10//! computes: a formula is text the host evaluates when it opens the file,
11//! which `fullCalcOnLoad` (ooxml.rs) makes it do, and since KERNEL.7
12//! (2026-10-08) text `crate::calc` evaluates over this model in memory,
13//! behind a declared subset that refuses by name, as `web/CALLOSUM.md`
14//! section 7 decision 3 amended docs/HORIZON.md section 12.
15//!
16//! New ground, said plainly: the reference has no model of a sheet; it
17//! writes through Excel's object model. What is modelled is the little the
18//! room needs (`BuildWorkspace`, VLA_IDE.bas) and what slice 7b's static
19//! subset writes, and the model grows only as a slice needs it.
20
21use std::collections::{BTreeMap, HashMap};
22
23use crate::rows::Visibility;
24
25/// A colour, as `Interior.Color = RGB(r, g, b)` names it.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct Rgb(pub u8, pub u8, pub u8);
28
29impl Rgb {
30    /// The file's spelling: opaque alpha, then the three bytes in hex.
31    pub fn argb_hex(self) -> String {
32        format!("FF{:02X}{:02X}{:02X}", self.0, self.1, self.2)
33    }
34}
35
36/// The number formats a style names: Excel's built-in General and Text
37/// (`NumberFormat = "@"`, so a sentence is never read as a formula).
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub enum NumFmt {
40    #[default]
41    General,
42    Text,
43}
44
45impl NumFmt {
46    /// The built-in format's id in the styles part.
47    pub fn id(self) -> u32 {
48        match self {
49            NumFmt::General => 0,
50            NumFmt::Text => 49,
51        }
52    }
53}
54
55/// One cell format: a solid fill or none, a number format, wrapped text or
56/// not. Index 0 of a [`Styles`] table is this type's default.
57#[derive(Clone, Debug, Default, PartialEq, Eq)]
58pub struct Style {
59    pub fill: Option<Rgb>,
60    pub num_fmt: NumFmt,
61    pub wrap: bool,
62}
63
64/// The workbook's cell formats, each once; a cell or a column names one by
65/// its index.
66#[derive(Clone, Debug)]
67pub struct Styles {
68    xfs: Vec<Style>,
69}
70
71impl Default for Styles {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl Styles {
78    /// A table holding only the default format, at index 0.
79    pub fn new() -> Styles {
80        Styles {
81            xfs: vec![Style::default()],
82        }
83    }
84
85    /// The index of a format, added if it is new.
86    pub fn id(&mut self, style: Style) -> u32 {
87        if let Some(i) = self.xfs.iter().position(|s| *s == style) {
88            return i as u32;
89        }
90        self.xfs.push(style);
91        (self.xfs.len() - 1) as u32
92    }
93
94    /// A format put at the index a record names (KERNEL.22, the loader:
95    /// a `format` row says its own index), the table grown with default
96    /// formats up to it.
97    pub fn put(&mut self, index: u32, style: Style) {
98        let i = index as usize;
99        if self.xfs.len() <= i {
100            self.xfs.resize(i + 1, Style::default());
101        }
102        self.xfs[i] = style;
103    }
104
105    /// The format at an index, or `None` past the table.
106    pub fn get(&self, index: u32) -> Option<&Style> {
107        self.xfs.get(index as usize)
108    }
109
110    /// Every format, in index order.
111    pub fn xfs(&self) -> &[Style] {
112        &self.xfs
113    }
114
115    /// The distinct fills, in the order they were first used.
116    pub fn fills(&self) -> Vec<Rgb> {
117        let mut fills: Vec<Rgb> = Vec::new();
118        for s in &self.xfs {
119            if let Some(f) = s.fill {
120                if !fills.contains(&f) {
121                    fills.push(f);
122                }
123            }
124        }
125        fills
126    }
127}
128
129/// What a cell holds. A formula is its text without the leading `=`, as the
130/// file stores it; the host computes it. A formula written into a range of
131/// cells is a shared formula, which is what Excel itself writes when a
132/// formula is filled: the top-left cell is the master and holds the text
133/// and the range, the others point at it by its index, and the host
134/// adjusts the references for each cell as `Range.Formula2` would have.
135#[derive(Clone, Debug, PartialEq)]
136pub enum Content {
137    Text(String),
138    Number(f64),
139    Bool(bool),
140    /// An error value typed into a cell with no formula behind it (`t="e"`
141    /// in the file), by its text, `#N/A`; what a reader meets in a model
142    /// (KERNEL.7). The writer writes it as the file holds it; no sentence
143    /// puts one there.
144    Error(String),
145    Formula(String),
146    SharedMaster {
147        text: String,
148        range: String,
149        si: u32,
150    },
151    SharedChild {
152        si: u32,
153    },
154    /// A dynamic-array formula (`cm="1"`, `t="array"` over its own cell):
155    /// what Excel stores for a `Formula2` entry of a formula whose function
156    /// can return an array, and reads back without the `@` it puts on a
157    /// legacy formula of that kind.
158    DynamicFormula(String),
159}
160
161#[derive(Clone, Debug, PartialEq)]
162pub struct Cell {
163    pub content: Content,
164    pub style: u32,
165}
166
167/// A column's settings: `index` is 1-based; `width` is in characters, as
168/// `ColumnWidth` counts them; `style` is the format of its cells that have
169/// none of their own.
170#[derive(Clone, Debug, PartialEq)]
171pub struct Column {
172    pub index: u32,
173    pub width: Option<f64>,
174    pub hidden: bool,
175    pub style: Option<u32>,
176}
177
178/// A rectangle of cells, rows and columns 1-based and inclusive.
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180pub struct A1Range {
181    pub top: u32,
182    pub left: u32,
183    pub bottom: u32,
184    pub right: u32,
185}
186
187impl A1Range {
188    pub fn cells(&self) -> u64 {
189        u64::from(self.bottom - self.top + 1) * u64::from(self.right - self.left + 1)
190    }
191
192    /// `B2` for one cell, `B2:C4` for more.
193    pub fn text(&self) -> String {
194        if self.top == self.bottom && self.left == self.right {
195            cell_ref(self.top, self.left)
196        } else {
197            format!(
198                "{}:{}",
199                cell_ref(self.top, self.left),
200                cell_ref(self.bottom, self.right)
201            )
202        }
203    }
204}
205
206/// Excel's last column (XFD) and last row.
207pub const MAX_COLUMN: u32 = 16_384;
208pub const MAX_ROW: u32 = 1_048_576;
209
210fn parse_a1_cell(s: &str) -> Option<(u32, u32)> {
211    let s = s.replace('$', "");
212    let letters: String = s.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
213    let digits = &s[letters.len()..];
214    if letters.is_empty() || letters.len() > 3 || digits.is_empty() {
215        return None;
216    }
217    if !digits.chars().all(|c| c.is_ascii_digit()) {
218        return None;
219    }
220    let mut col: u32 = 0;
221    for c in letters.chars() {
222        col = col * 26 + (c.to_ascii_uppercase() as u32 - 'A' as u32 + 1);
223    }
224    let row: u32 = digits.parse().ok()?;
225    if col == 0 || col > MAX_COLUMN || row == 0 || row > MAX_ROW {
226        return None;
227    }
228    Some((row, col))
229}
230
231/// An A1 reference as a program writes it (`b2`, `A1:B5`, `$C$2`), in any
232/// case, with the corners in any order; `None` for anything else, a whole
233/// column or row included.
234pub fn parse_a1_range(s: &str) -> Option<A1Range> {
235    let s = s.trim();
236    let (a, b) = match s.split_once(':') {
237        Some((a, b)) => (a, b),
238        None => (s, s),
239    };
240    let (r1, c1) = parse_a1_cell(a)?;
241    let (r2, c2) = parse_a1_cell(b)?;
242    Some(A1Range {
243        top: r1.min(r2),
244        left: c1.min(c2),
245        bottom: r1.max(r2),
246        right: c1.max(c2),
247    })
248}
249
250/// A row's settings, the twin of a [`Column`]'s (KERNEL.22; `Alonzo/SPEC.md`
251/// decision 19): its height in points, or `None` for the default, and
252/// whether it is hidden.
253#[derive(Clone, Debug, PartialEq)]
254pub struct RowSettings {
255    pub height: Option<f64>,
256    pub hidden: bool,
257}
258
259/// One sheet: its cells by (row, column), both 1-based.
260#[derive(Clone, Debug, PartialEq)]
261pub struct Sheet {
262    pub name: String,
263    pub cells: BTreeMap<(u32, u32), Cell>,
264    pub columns: Vec<Column>,
265    /// The rows with settings of their own, by number (KERNEL.22), printed
266    /// as the view record's `row` rows; empty for a sheet the writer built.
267    pub rows: BTreeMap<u32, RowSettings>,
268    /// The look map (KERNEL.22; `Alonzo/SPEC.md` decision 19): a value as a
269    /// `cell` row spells it, and the format a cell holding that value draws
270    /// with in place of its own, in the order declared; printed as the view
271    /// record's `look` rows.
272    pub looks: Vec<(String, u32)>,
273    /// Whether the sheet has a tab (KERNEL.22): the reader's three words.
274    /// Every sheet the writer builds is visible; an engine's twins are
275    /// hidden.
276    pub visibility: Visibility,
277    pub gridlines: bool,
278    /// The selected cell when the sheet opens, (row, column).
279    pub active_cell: (u32, u32),
280    /// How many shared formulas the sheet holds; the next one's index.
281    pub shared_formulas: u32,
282    /// The row of the sentence that wrote each cell, by (row, column): the
283    /// program line, which is the row of the `Frazaro` sheet that holds the
284    /// sentence (KERNEL.4, the view record). Filled by the build's walker;
285    /// empty for a sheet nothing wrote by sentence.
286    pub sentences: BTreeMap<(u32, u32), u32>,
287}
288
289impl Sheet {
290    pub fn new(name: &str) -> Sheet {
291        Sheet {
292            name: name.to_string(),
293            cells: BTreeMap::new(),
294            columns: Vec::new(),
295            rows: BTreeMap::new(),
296            looks: Vec::new(),
297            visibility: Visibility::Visible,
298            gridlines: true,
299            active_cell: (1, 1),
300            shared_formulas: 0,
301            sentences: BTreeMap::new(),
302        }
303    }
304
305    /// Record that the sentence on program row `row_of_sentence` wrote every
306    /// cell of `range`, after the cells are set.
307    pub fn wrote(&mut self, range: A1Range, row_of_sentence: u32) {
308        if row_of_sentence == 0 {
309            return;
310        }
311        for row in range.top..=range.bottom {
312            for col in range.left..=range.right {
313                self.sentences.insert((row, col), row_of_sentence);
314            }
315        }
316    }
317
318    pub fn set(&mut self, row: u32, col: u32, cell: Cell) {
319        self.cells.insert((row, col), cell);
320    }
321
322    /// A formula's text (without its `=`) into every cell of a range: one
323    /// plain formula for one cell, a shared formula for more.
324    /// A formula whose function can return an array, into every cell of a
325    /// range as Excel stores a `Formula2` entry of it: a dynamic-array
326    /// formula per cell (a dynamic-array formula cannot be shared), its
327    /// references moved by the cell's distance from the top-left one, as
328    /// Excel moves them when a formula is filled (the mover is
329    /// `crate::refers::shift_a1_references` since PORT.8, slice 8b).
330    pub fn set_formula_dynamic(&mut self, range: A1Range, text: &str, style: u32) {
331        for row in range.top..=range.bottom {
332            for col in range.left..=range.right {
333                let moved = crate::refers::shift_a1_references(
334                    text,
335                    i64::from(row) - i64::from(range.top),
336                    i64::from(col) - i64::from(range.left),
337                );
338                self.set(
339                    row,
340                    col,
341                    Cell {
342                        content: Content::DynamicFormula(moved),
343                        style,
344                    },
345                );
346            }
347        }
348    }
349
350    pub fn set_formula(&mut self, range: A1Range, text: &str, style: u32) {
351        let si = self.next_shared(range);
352        for row in range.top..=range.bottom {
353            for col in range.left..=range.right {
354                let content = filled(range, text, si, row, col);
355                self.set(row, col, Cell { content, style });
356            }
357        }
358    }
359
360    /// The index of the shared formula a formula written over `range` makes,
361    /// taken; `None` for a range of one cell, which holds a plain formula.
362    pub(crate) fn next_shared(&mut self, range: A1Range) -> Option<u32> {
363        (range.cells() > 1).then(|| {
364            self.shared_formulas += 1;
365            self.shared_formulas - 1
366        })
367    }
368
369    /// The used range as ((first row, first column), (last row, last
370    /// column)), or `None` for a sheet with no cell.
371    pub fn extent(&self) -> Option<((u32, u32), (u32, u32))> {
372        #[cfg(test)]
373        work::extents(self.cells.len());
374        let mut it = self.cells.keys();
375        let &(r0, c0) = it.next()?;
376        let (mut top, mut left, mut bottom, mut right) = (r0, c0, r0, c0);
377        for &(r, c) in it {
378            top = top.min(r);
379            left = left.min(c);
380            bottom = bottom.max(r);
381            right = right.max(c);
382        }
383        Some(((top, left), (bottom, right)))
384    }
385}
386
387/// The shared formulas' masters of a sheet, by index: the text and the cell
388/// that holds it, so that a child's text is the master's with its
389/// references moved. The view record and the evaluator both read a child
390/// through this (KERNEL.4; KERNEL.7).
391pub fn shared_masters(sheet: &Sheet) -> HashMap<u32, (&str, u32, u32)> {
392    #[cfg(test)]
393    work::walked(sheet.cells.len());
394    let mut masters = HashMap::new();
395    for ((row, col), cell) in &sheet.cells {
396        if let Content::SharedMaster { text, si, .. } = &cell.content {
397            masters.insert(*si, (text.as_str(), *row, *col));
398        }
399    }
400    masters
401}
402
403/// A formula's text as the formula bar shows it, without its `=`, the
404/// file's prefixes dropped, as the reader spells the same cell of the
405/// written file: a shared child is its master's text moved by the cell's
406/// distance, moved first and stripped after, as the reader does it. `None`
407/// for a value, or for a child whose master the sheet has lost.
408pub fn formula_text(
409    content: &Content,
410    row: u32,
411    col: u32,
412    masters: &HashMap<u32, (&str, u32, u32)>,
413) -> Option<String> {
414    let raw = match content {
415        Content::Formula(t) | Content::DynamicFormula(t) => t.clone(),
416        Content::SharedMaster { text, .. } => text.clone(),
417        Content::SharedChild { si } => {
418            let (text, m_row, m_col) = masters.get(si)?;
419            crate::refers::shift_a1_references(
420                text,
421                i64::from(row) - i64::from(*m_row),
422                i64::from(col) - i64::from(*m_col),
423            )
424        }
425        Content::Text(_) | Content::Number(_) | Content::Bool(_) | Content::Error(_) => {
426            return None
427        }
428    };
429    Some(strip_future_prefixes(&raw))
430}
431
432/// What a formula written over `range` puts in one of its cells, the rule
433/// [`Sheet::set_formula`] and a machine's write share (KERNEL.34): the text
434/// for a range of one cell (`si` `None`); else the shared formula `si`, its
435/// text and its range at the range's first cell and its index at every
436/// other.
437pub(crate) fn filled(range: A1Range, text: &str, si: Option<u32>, row: u32, col: u32) -> Content {
438    match si {
439        None => Content::Formula(text.to_string()),
440        Some(si) if row == range.top && col == range.left => Content::SharedMaster {
441            text: text.to_string(),
442            range: range.text(),
443            si,
444        },
445        Some(si) => Content::SharedChild { si },
446    }
447}
448
449/// The work a machine's write does, counted in a test (KERNEL.34's oracle,
450/// as `crate::calc::vector::taken` notes a run's paths): the cells walked
451/// to find a sheet's shared formulas or a released fill's cells, the texts
452/// the mover moved, and the fills a write released, each whole or cell by
453/// cell. A write's cost is pinned by these counts and not by its time; an
454/// allocation count would miss the walks, which allocate once a master and
455/// not once a cell. Since KERNEL.37 a view's walks are counted too: the
456/// masters' and the extent's (`Sheet::extent`). A frame's end is counted the
457/// same way (KERNEL.36's oracle): the cells it copied into their twins, and
458/// the cells it walked. Counted on the thread that asked, between
459/// [`work::begin`] and [`work::end`], and only in a test build.
460#[cfg(test)]
461pub(crate) mod work {
462    use std::cell::Cell;
463
464    /// What the writes and views since [`begin`] did.
465    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
466    pub(crate) struct Work {
467        /// The cells walked.
468        pub(crate) walked: u64,
469        /// The texts moved.
470        pub(crate) moved: u64,
471        /// The fills released whole, their first cell left made their master.
472        pub(crate) whole: u64,
473        /// The fills released cell by cell, each cell given its own text.
474        pub(crate) split: u64,
475        /// The cells walked to find a sheet's extent.
476        pub(crate) extents: u64,
477        /// The cells a frame's end copied into their twins (KERNEL.36).
478        pub(crate) copied: u64,
479        /// The cells a frame's end walked, every cell of every sheet
480        /// (KERNEL.36).
481        pub(crate) swept: u64,
482    }
483
484    thread_local! {
485        static ON: Cell<bool> = const { Cell::new(false) };
486        static DONE: Cell<Work> = const {
487            Cell::new(Work {
488                walked: 0,
489                moved: 0,
490                whole: 0,
491                split: 0,
492                extents: 0,
493                copied: 0,
494                swept: 0,
495            })
496        };
497    }
498
499    /// Counting begins on this thread, from nothing.
500    pub(crate) fn begin() {
501        ON.with(|on| on.set(true));
502        DONE.with(|done| done.set(Work::default()));
503    }
504
505    /// What was counted since [`begin`]; counting ends.
506    pub(crate) fn end() -> Work {
507        ON.with(|on| on.set(false));
508        DONE.with(Cell::get)
509    }
510
511    fn add(f: impl FnOnce(&mut Work)) {
512        if ON.with(Cell::get) {
513            DONE.with(|done| {
514                let mut work = done.get();
515                f(&mut work);
516                done.set(work);
517            });
518        }
519    }
520
521    /// A walk over a sheet of `cells` cells.
522    pub(crate) fn walked(cells: usize) {
523        add(|w| w.walked += cells as u64);
524    }
525
526    /// A walk over a sheet of `cells` cells for its extent.
527    pub(crate) fn extents(cells: usize) {
528        add(|w| w.extents += cells as u64);
529    }
530
531    /// One text moved.
532    pub(crate) fn moved() {
533        add(|w| w.moved += 1);
534    }
535
536    /// One fill released, whole or cell by cell.
537    pub(crate) fn released(whole: bool) {
538        add(|w| {
539            if whole {
540                w.whole += 1;
541            } else {
542                w.split += 1;
543            }
544        });
545    }
546
547    /// One cell a frame's end copied into its twin.
548    pub(crate) fn copied() {
549        add(|w| w.copied += 1);
550    }
551
552    /// A frame's end's walk over a sheet of `cells` cells.
553    pub(crate) fn swept(cells: usize) {
554        add(|w| w.swept += cells as u64);
555    }
556}
557
558/// The workbook: its sheets in tab order, their formats, and which tab is
559/// open when the file opens.
560#[derive(Clone, Debug)]
561pub struct Workbook {
562    pub sheets: Vec<Sheet>,
563    pub styles: Styles,
564    pub active_sheet: usize,
565    /// The workbook's defined names: each a name and its formula's text
566    /// without the `=` (a string constant is `"text"`, inner quotes doubled).
567    pub defined_names: Vec<(String, String)>,
568}
569
570impl Default for Workbook {
571    fn default() -> Self {
572        Self::new()
573    }
574}
575
576impl Workbook {
577    pub fn new() -> Workbook {
578        Workbook {
579            sheets: Vec::new(),
580            styles: Styles::new(),
581            active_sheet: 0,
582            defined_names: Vec::new(),
583        }
584    }
585
586    /// Whether any cell holds a dynamic-array formula, which the file then
587    /// needs its metadata part for.
588    pub fn has_dynamic_formulas(&self) -> bool {
589        self.sheets.iter().any(|s| {
590            s.cells
591                .values()
592                .any(|c| matches!(c.content, Content::DynamicFormula(_)))
593        })
594    }
595
596    /// The index of the sheet of that name, compared as Excel compares sheet
597    /// names, without case: `fold`'s test, which lowers the ASCII capitals
598    /// alone, made byte by byte in place (KERNEL.25). Every reference of
599    /// every evaluation asks it, and folding each name into a new string
600    /// was a third of a frame of Life.
601    pub fn find_sheet(&self, name: &str) -> Option<usize> {
602        self.sheets
603            .iter()
604            .position(|s| s.name.eq_ignore_ascii_case(name))
605    }
606
607    /// The sheet of that name, added last with the name as given when there
608    /// is none (`VlaEnsureSheet`'s shape).
609    pub fn ensure_sheet(&mut self, name: &str) -> usize {
610        if let Some(i) = self.find_sheet(name) {
611            return i;
612        }
613        self.sheets.push(Sheet::new(name));
614        self.sheets.len() - 1
615    }
616}
617
618/// A column's letters: 1 is A, 26 is Z, 27 is AA.
619pub fn column_letters(col: u32) -> String {
620    let mut n = col;
621    let mut letters = Vec::new();
622    while n > 0 {
623        let rem = (n - 1) % 26;
624        letters.push((b'A' + rem as u8) as char);
625        n = (n - 1) / 26;
626    }
627    letters.iter().rev().collect()
628}
629
630/// A cell's reference: row 1, column 2 is `B1`.
631pub fn cell_ref(row: u32, col: u32) -> String {
632    format!("{}{}", column_letters(col), row)
633}
634
635/// `ColumnWidth` in characters to the file's `width` attribute, as Excel
636/// computes it for the default font, Calibri 11, whose widest digit is 7
637/// pixels: the characters plus 5 pixels of padding, in 1/256ths of a
638/// character. 72 gives 72.7109375, which is what Excel itself writes for a
639/// column set to 72.
640pub fn column_width_attr(chars: f64) -> f64 {
641    const MAX_DIGIT_WIDTH: f64 = 7.0;
642    ((chars * MAX_DIGIT_WIDTH + 5.0) / MAX_DIGIT_WIDTH * 256.0).trunc() / 256.0
643}
644
645/// A number as the file holds it, and as the view record spells it. Rust's
646/// shortest round-trip spelling, which never uses an exponent, is one Excel
647/// reads; a value no cell can hold (not finite) is `None`, which the writer
648/// and the view spell as Excel's `#NUM!` error.
649pub fn number_text(v: f64) -> Option<String> {
650    v.is_finite().then(|| format!("{v}"))
651}
652
653/// The first character of a name in a formula's text: a letter or `_`.
654pub fn is_name_start(c: char) -> bool {
655    c.is_ascii_alphabetic() || c == '_'
656}
657
658/// A character inside a name in a formula's text: a letter, a digit, `_`
659/// or `.`.
660pub fn is_name_char(c: char) -> bool {
661    c.is_ascii_alphanumeric() || c == '_' || c == '.'
662}
663
664/// A formula's text as the formula bar shows it: `_xlfn.`, `_xlfn._xlws.`
665/// and `_xlpm.`, the prefixes an OOXML file stores a newer function and its
666/// parameters under (the core's writer adds them, its reader drops them),
667/// dropped from every name outside a string literal. The names themselves
668/// are kept as the file spells them. Held here with the grid since the view
669/// record spells a formula this way (PORT.12); the inverse of the core's
670/// `sheet::xlfn::prefix_future_functions`.
671pub fn strip_future_prefixes(formula: &str) -> String {
672    let chars: Vec<char> = formula.chars().collect();
673    let mut out = String::with_capacity(formula.len());
674    let mut i = 0;
675    while i < chars.len() {
676        let c = chars[i];
677        if c == '"' {
678            out.push(c);
679            i += 1;
680            while i < chars.len() {
681                out.push(chars[i]);
682                if chars[i] == '"' {
683                    if i + 1 < chars.len() && chars[i + 1] == '"' {
684                        out.push('"');
685                        i += 2;
686                        continue;
687                    }
688                    i += 1;
689                    break;
690                }
691                i += 1;
692            }
693            continue;
694        }
695        if is_name_start(c) && (i == 0 || !is_name_char(chars[i - 1])) {
696            let start = i;
697            while i < chars.len() && is_name_char(chars[i]) {
698                i += 1;
699            }
700            let name: String = chars[start..i].iter().collect();
701            let mut bare = name.as_str();
702            loop {
703                let before = bare.len();
704                for prefix in ["_xlfn.", "_xlws.", "_xlpm."] {
705                    if bare.len() > prefix.len()
706                        && bare[..prefix.len()].eq_ignore_ascii_case(prefix)
707                    {
708                        bare = &bare[prefix.len()..];
709                    }
710                }
711                if bare.len() == before {
712                    break;
713                }
714            }
715            out.push_str(bare);
716            continue;
717        }
718        out.push(c);
719        i += 1;
720    }
721    out
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    #[test]
729    fn references_and_letters() {
730        assert_eq!(column_letters(1), "A");
731        assert_eq!(column_letters(26), "Z");
732        assert_eq!(column_letters(27), "AA");
733        assert_eq!(column_letters(702), "ZZ");
734        assert_eq!(column_letters(703), "AAA");
735        assert_eq!(cell_ref(1, 2), "B1");
736        assert_eq!(cell_ref(12, 3), "C12");
737    }
738
739    #[test]
740    fn a_sheet_is_found_as_the_fold_finds_it() {
741        // KERNEL.25: find_sheet compares in place what it once compared
742        // through `fold`, which stays here as the rule it must keep. Names
743        // whose folds differ only outside ASCII are different sheets.
744        use crate::intrinsics::fold;
745        let mut book = Workbook::new();
746        for name in ["É", "é", "İSTANBUL", "Straße", "Data", "Screen.last"] {
747            book.sheets.push(Sheet::new(name));
748        }
749        let by_fold = |name: &str| book.sheets.iter().position(|s| fold(&s.name) == fold(name));
750        let asked = [
751            ("É", Some(0)),
752            ("é", Some(1)),
753            ("İstanbul", Some(2)),
754            ("istanbul", None),
755            ("straße", Some(3)),
756            ("STRASSE", None),
757            ("DATA", Some(4)),
758            ("SCREEN.LAST", Some(5)),
759            ("Screen", None),
760            ("", None),
761        ];
762        for (name, want) in asked {
763            assert_eq!(book.find_sheet(name), want, "{name}");
764            assert_eq!(book.find_sheet(name), by_fold(name), "{name}");
765        }
766    }
767
768    #[test]
769    fn the_room_widths_as_excel_writes_them() {
770        assert_eq!(column_width_attr(72.0), 72.7109375);
771        assert_eq!(column_width_attr(60.0), 60.7109375);
772        assert_eq!(column_width_attr(8.43), 9.140625);
773    }
774
775    #[test]
776    fn styles_dedup_and_fills_keep_first_use_order() {
777        let mut s = Styles::new();
778        let a = s.id(Style {
779            fill: Some(Rgb(1, 2, 3)),
780            ..Style::default()
781        });
782        let b = s.id(Style {
783            fill: Some(Rgb(4, 5, 6)),
784            wrap: true,
785            ..Style::default()
786        });
787        let a2 = s.id(Style {
788            fill: Some(Rgb(1, 2, 3)),
789            ..Style::default()
790        });
791        assert_eq!((a, b, a2), (1, 2, 1));
792        assert_eq!(s.fills(), vec![Rgb(1, 2, 3), Rgb(4, 5, 6)]);
793        assert_eq!(Rgb(247, 244, 252).argb_hex(), "FFF7F4FC");
794        // A format put at the index a record names, the table grown with
795        // defaults (KERNEL.22, the loader).
796        let wrap = Style {
797            wrap: true,
798            ..Style::default()
799        };
800        s.put(5, wrap.clone());
801        assert_eq!(s.xfs().len(), 6);
802        assert_eq!(s.get(5), Some(&wrap));
803        assert_eq!(s.get(4), Some(&Style::default()));
804        assert_eq!(s.get(6), None);
805    }
806
807    #[test]
808    fn a1_references_as_a_program_writes_them() {
809        let r = parse_a1_range("b2").unwrap();
810        assert_eq!((r.top, r.left, r.bottom, r.right), (2, 2, 2, 2));
811        assert_eq!(r.text(), "B2");
812        let r = parse_a1_range("c4:A1").unwrap();
813        assert_eq!((r.top, r.left, r.bottom, r.right), (1, 1, 4, 3));
814        assert_eq!(r.text(), "A1:C4");
815        assert_eq!(r.cells(), 12);
816        assert_eq!(parse_a1_range("$B$2:$B$4").unwrap().text(), "B2:B4");
817        assert_eq!(parse_a1_range("XFD1048576").unwrap().text(), "XFD1048576");
818        for bad in [
819            "c:c", "1:3", "XFE1", "A0", "A1048577", "AAAA1", "b", "2", "", "b2:",
820        ] {
821            assert!(parse_a1_range(bad).is_none(), "{bad} should not parse");
822        }
823    }
824
825    #[test]
826    fn a_formula_over_a_range_is_shared_from_its_top_left_cell() {
827        let mut sh = Sheet::new("S");
828        sh.set_formula(parse_a1_range("c2:c3").unwrap(), "B2+B3", 0);
829        sh.set_formula(parse_a1_range("d2").unwrap(), "B2*2", 0);
830        sh.set_formula(parse_a1_range("e2:e3").unwrap(), "1", 0);
831        assert_eq!(
832            sh.cells[&(2, 3)].content,
833            Content::SharedMaster {
834                text: "B2+B3".to_string(),
835                range: "C2:C3".to_string(),
836                si: 0
837            }
838        );
839        assert_eq!(sh.cells[&(3, 3)].content, Content::SharedChild { si: 0 });
840        assert_eq!(
841            sh.cells[&(2, 4)].content,
842            Content::Formula("B2*2".to_string())
843        );
844        assert_eq!(
845            sh.cells[&(2, 5)].content,
846            Content::SharedMaster {
847                text: "1".to_string(),
848                range: "E2:E3".to_string(),
849                si: 1
850            }
851        );
852        assert_eq!(sh.shared_formulas, 2);
853    }
854
855    #[test]
856    fn a_dynamic_formula_over_a_range_is_one_per_cell_with_its_references_moved() {
857        let mut sh = Sheet::new("S");
858        sh.set_formula_dynamic(
859            parse_a1_range("c2:d3").unwrap(),
860            "IFS(B2>3,$A$1,TRUE,B$1)",
861            0,
862        );
863        assert_eq!(
864            sh.cells[&(2, 3)].content,
865            Content::DynamicFormula("IFS(B2>3,$A$1,TRUE,B$1)".to_string())
866        );
867        assert_eq!(
868            sh.cells[&(3, 3)].content,
869            Content::DynamicFormula("IFS(B3>3,$A$1,TRUE,B$1)".to_string())
870        );
871        assert_eq!(
872            sh.cells[&(2, 4)].content,
873            Content::DynamicFormula("IFS(C2>3,$A$1,TRUE,C$1)".to_string())
874        );
875        assert_eq!(
876            sh.cells[&(3, 4)].content,
877            Content::DynamicFormula("IFS(C3>3,$A$1,TRUE,C$1)".to_string())
878        );
879        let mut wb = Workbook::new();
880        assert!(!wb.has_dynamic_formulas());
881        wb.sheets.push(sh);
882        assert!(wb.has_dynamic_formulas());
883    }
884
885    #[test]
886    fn sheets_are_found_without_case_and_made_once() {
887        let mut wb = Workbook::new();
888        assert_eq!(wb.find_sheet("Output"), None);
889        let i = wb.ensure_sheet("Output");
890        assert_eq!(wb.ensure_sheet("output"), i);
891        assert_eq!(wb.find_sheet("OUTPUT"), Some(i));
892        assert_eq!(wb.sheets.len(), 1);
893        assert_eq!(wb.sheets[0].name, "Output");
894    }
895
896    #[test]
897    fn a_sheet_knows_its_extent() {
898        let mut sh = Sheet::new("S");
899        assert_eq!(sh.extent(), None);
900        sh.set(
901            3,
902            2,
903            Cell {
904                content: Content::Number(1.0),
905                style: 0,
906            },
907        );
908        sh.set(
909            1,
910            3,
911            Cell {
912                content: Content::Bool(true),
913                style: 0,
914            },
915        );
916        assert_eq!(sh.extent(), Some(((1, 2), (3, 3))));
917    }
918
919    #[test]
920    fn numbers_as_the_file_holds_them() {
921        assert_eq!(number_text(5.0).as_deref(), Some("5"));
922        assert_eq!(number_text(0.1).as_deref(), Some("0.1"));
923        assert_eq!(number_text(-2.5).as_deref(), Some("-2.5"));
924        assert_eq!(number_text(f64::NAN), None);
925        assert_eq!(number_text(f64::INFINITY), None);
926    }
927
928    #[test]
929    fn the_prefixes_come_off_a_formula_read_back() {
930        assert_eq!(
931            strip_future_prefixes("_xlfn.IFS(B2>3,\"big\",TRUE,\"small\")"),
932            "IFS(B2>3,\"big\",TRUE,\"small\")"
933        );
934        assert_eq!(
935            strip_future_prefixes("_xlfn._xlws.FILTER(A:A,B:B>1)"),
936            "FILTER(A:A,B:B>1)"
937        );
938        assert_eq!(
939            strip_future_prefixes("_xlfn.LET(_xlpm.x,1,_xlpm.x*2)"),
940            "LET(x,1,x*2)"
941        );
942        assert_eq!(
943            strip_future_prefixes("SUM(A1:A3)&\"_xlfn.\""),
944            "SUM(A1:A3)&\"_xlfn.\""
945        );
946        assert_eq!(strip_future_prefixes("Rate*B1"), "Rate*B1");
947        assert_eq!(strip_future_prefixes("_xlfn."), "_xlfn.");
948    }
949}