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        if range.cells() == 1 {
352            self.set(
353                range.top,
354                range.left,
355                Cell {
356                    content: Content::Formula(text.to_string()),
357                    style,
358                },
359            );
360            return;
361        }
362        let si = self.shared_formulas;
363        self.shared_formulas += 1;
364        for row in range.top..=range.bottom {
365            for col in range.left..=range.right {
366                let content = if row == range.top && col == range.left {
367                    Content::SharedMaster {
368                        text: text.to_string(),
369                        range: range.text(),
370                        si,
371                    }
372                } else {
373                    Content::SharedChild { si }
374                };
375                self.set(row, col, Cell { content, style });
376            }
377        }
378    }
379
380    /// The used range as ((first row, first column), (last row, last
381    /// column)), or `None` for a sheet with no cell.
382    pub fn extent(&self) -> Option<((u32, u32), (u32, u32))> {
383        let mut it = self.cells.keys();
384        let &(r0, c0) = it.next()?;
385        let (mut top, mut left, mut bottom, mut right) = (r0, c0, r0, c0);
386        for &(r, c) in it {
387            top = top.min(r);
388            left = left.min(c);
389            bottom = bottom.max(r);
390            right = right.max(c);
391        }
392        Some(((top, left), (bottom, right)))
393    }
394}
395
396/// The shared formulas' masters of a sheet, by index: the text and the cell
397/// that holds it, so that a child's text is the master's with its
398/// references moved. The view record and the evaluator both read a child
399/// through this (KERNEL.4; KERNEL.7).
400pub fn shared_masters(sheet: &Sheet) -> HashMap<u32, (&str, u32, u32)> {
401    let mut masters = HashMap::new();
402    for ((row, col), cell) in &sheet.cells {
403        if let Content::SharedMaster { text, si, .. } = &cell.content {
404            masters.insert(*si, (text.as_str(), *row, *col));
405        }
406    }
407    masters
408}
409
410/// A formula's text as the formula bar shows it, without its `=`, the
411/// file's prefixes dropped, as the reader spells the same cell of the
412/// written file: a shared child is its master's text moved by the cell's
413/// distance, moved first and stripped after, as the reader does it. `None`
414/// for a value, or for a child whose master the sheet has lost.
415pub fn formula_text(
416    content: &Content,
417    row: u32,
418    col: u32,
419    masters: &HashMap<u32, (&str, u32, u32)>,
420) -> Option<String> {
421    let raw = match content {
422        Content::Formula(t) | Content::DynamicFormula(t) => t.clone(),
423        Content::SharedMaster { text, .. } => text.clone(),
424        Content::SharedChild { si } => {
425            let (text, m_row, m_col) = masters.get(si)?;
426            crate::refers::shift_a1_references(
427                text,
428                i64::from(row) - i64::from(*m_row),
429                i64::from(col) - i64::from(*m_col),
430            )
431        }
432        Content::Text(_) | Content::Number(_) | Content::Bool(_) | Content::Error(_) => {
433            return None
434        }
435    };
436    Some(strip_future_prefixes(&raw))
437}
438
439/// The workbook: its sheets in tab order, their formats, and which tab is
440/// open when the file opens.
441#[derive(Clone, Debug)]
442pub struct Workbook {
443    pub sheets: Vec<Sheet>,
444    pub styles: Styles,
445    pub active_sheet: usize,
446    /// The workbook's defined names: each a name and its formula's text
447    /// without the `=` (a string constant is `"text"`, inner quotes doubled).
448    pub defined_names: Vec<(String, String)>,
449}
450
451impl Default for Workbook {
452    fn default() -> Self {
453        Self::new()
454    }
455}
456
457impl Workbook {
458    pub fn new() -> Workbook {
459        Workbook {
460            sheets: Vec::new(),
461            styles: Styles::new(),
462            active_sheet: 0,
463            defined_names: Vec::new(),
464        }
465    }
466
467    /// Whether any cell holds a dynamic-array formula, which the file then
468    /// needs its metadata part for.
469    pub fn has_dynamic_formulas(&self) -> bool {
470        self.sheets.iter().any(|s| {
471            s.cells
472                .values()
473                .any(|c| matches!(c.content, Content::DynamicFormula(_)))
474        })
475    }
476
477    /// The index of the sheet of that name, compared as Excel compares sheet
478    /// names, without case: `fold`'s test, which lowers the ASCII capitals
479    /// alone, made byte by byte in place (KERNEL.25). Every reference of
480    /// every evaluation asks it, and folding each name into a new string
481    /// was a third of a frame of Life.
482    pub fn find_sheet(&self, name: &str) -> Option<usize> {
483        self.sheets
484            .iter()
485            .position(|s| s.name.eq_ignore_ascii_case(name))
486    }
487
488    /// The sheet of that name, added last with the name as given when there
489    /// is none (`VlaEnsureSheet`'s shape).
490    pub fn ensure_sheet(&mut self, name: &str) -> usize {
491        if let Some(i) = self.find_sheet(name) {
492            return i;
493        }
494        self.sheets.push(Sheet::new(name));
495        self.sheets.len() - 1
496    }
497}
498
499/// A column's letters: 1 is A, 26 is Z, 27 is AA.
500pub fn column_letters(col: u32) -> String {
501    let mut n = col;
502    let mut letters = Vec::new();
503    while n > 0 {
504        let rem = (n - 1) % 26;
505        letters.push((b'A' + rem as u8) as char);
506        n = (n - 1) / 26;
507    }
508    letters.iter().rev().collect()
509}
510
511/// A cell's reference: row 1, column 2 is `B1`.
512pub fn cell_ref(row: u32, col: u32) -> String {
513    format!("{}{}", column_letters(col), row)
514}
515
516/// `ColumnWidth` in characters to the file's `width` attribute, as Excel
517/// computes it for the default font, Calibri 11, whose widest digit is 7
518/// pixels: the characters plus 5 pixels of padding, in 1/256ths of a
519/// character. 72 gives 72.7109375, which is what Excel itself writes for a
520/// column set to 72.
521pub fn column_width_attr(chars: f64) -> f64 {
522    const MAX_DIGIT_WIDTH: f64 = 7.0;
523    ((chars * MAX_DIGIT_WIDTH + 5.0) / MAX_DIGIT_WIDTH * 256.0).trunc() / 256.0
524}
525
526/// A number as the file holds it, and as the view record spells it. Rust's
527/// shortest round-trip spelling, which never uses an exponent, is one Excel
528/// reads; a value no cell can hold (not finite) is `None`, which the writer
529/// and the view spell as Excel's `#NUM!` error.
530pub fn number_text(v: f64) -> Option<String> {
531    v.is_finite().then(|| format!("{v}"))
532}
533
534/// The first character of a name in a formula's text: a letter or `_`.
535pub fn is_name_start(c: char) -> bool {
536    c.is_ascii_alphabetic() || c == '_'
537}
538
539/// A character inside a name in a formula's text: a letter, a digit, `_`
540/// or `.`.
541pub fn is_name_char(c: char) -> bool {
542    c.is_ascii_alphanumeric() || c == '_' || c == '.'
543}
544
545/// A formula's text as the formula bar shows it: `_xlfn.`, `_xlfn._xlws.`
546/// and `_xlpm.`, the prefixes an OOXML file stores a newer function and its
547/// parameters under (the core's writer adds them, its reader drops them),
548/// dropped from every name outside a string literal. The names themselves
549/// are kept as the file spells them. Held here with the grid since the view
550/// record spells a formula this way (PORT.12); the inverse of the core's
551/// `sheet::xlfn::prefix_future_functions`.
552pub fn strip_future_prefixes(formula: &str) -> String {
553    let chars: Vec<char> = formula.chars().collect();
554    let mut out = String::with_capacity(formula.len());
555    let mut i = 0;
556    while i < chars.len() {
557        let c = chars[i];
558        if c == '"' {
559            out.push(c);
560            i += 1;
561            while i < chars.len() {
562                out.push(chars[i]);
563                if chars[i] == '"' {
564                    if i + 1 < chars.len() && chars[i + 1] == '"' {
565                        out.push('"');
566                        i += 2;
567                        continue;
568                    }
569                    i += 1;
570                    break;
571                }
572                i += 1;
573            }
574            continue;
575        }
576        if is_name_start(c) && (i == 0 || !is_name_char(chars[i - 1])) {
577            let start = i;
578            while i < chars.len() && is_name_char(chars[i]) {
579                i += 1;
580            }
581            let name: String = chars[start..i].iter().collect();
582            let mut bare = name.as_str();
583            loop {
584                let before = bare.len();
585                for prefix in ["_xlfn.", "_xlws.", "_xlpm."] {
586                    if bare.len() > prefix.len()
587                        && bare[..prefix.len()].eq_ignore_ascii_case(prefix)
588                    {
589                        bare = &bare[prefix.len()..];
590                    }
591                }
592                if bare.len() == before {
593                    break;
594                }
595            }
596            out.push_str(bare);
597            continue;
598        }
599        out.push(c);
600        i += 1;
601    }
602    out
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn references_and_letters() {
611        assert_eq!(column_letters(1), "A");
612        assert_eq!(column_letters(26), "Z");
613        assert_eq!(column_letters(27), "AA");
614        assert_eq!(column_letters(702), "ZZ");
615        assert_eq!(column_letters(703), "AAA");
616        assert_eq!(cell_ref(1, 2), "B1");
617        assert_eq!(cell_ref(12, 3), "C12");
618    }
619
620    #[test]
621    fn a_sheet_is_found_as_the_fold_finds_it() {
622        // KERNEL.25: find_sheet compares in place what it once compared
623        // through `fold`, which stays here as the rule it must keep. Names
624        // whose folds differ only outside ASCII are different sheets.
625        use crate::intrinsics::fold;
626        let mut book = Workbook::new();
627        for name in ["É", "é", "İSTANBUL", "Straße", "Data", "Screen.last"] {
628            book.sheets.push(Sheet::new(name));
629        }
630        let by_fold = |name: &str| book.sheets.iter().position(|s| fold(&s.name) == fold(name));
631        let asked = [
632            ("É", Some(0)),
633            ("é", Some(1)),
634            ("İstanbul", Some(2)),
635            ("istanbul", None),
636            ("straße", Some(3)),
637            ("STRASSE", None),
638            ("DATA", Some(4)),
639            ("SCREEN.LAST", Some(5)),
640            ("Screen", None),
641            ("", None),
642        ];
643        for (name, want) in asked {
644            assert_eq!(book.find_sheet(name), want, "{name}");
645            assert_eq!(book.find_sheet(name), by_fold(name), "{name}");
646        }
647    }
648
649    #[test]
650    fn the_room_widths_as_excel_writes_them() {
651        assert_eq!(column_width_attr(72.0), 72.7109375);
652        assert_eq!(column_width_attr(60.0), 60.7109375);
653        assert_eq!(column_width_attr(8.43), 9.140625);
654    }
655
656    #[test]
657    fn styles_dedup_and_fills_keep_first_use_order() {
658        let mut s = Styles::new();
659        let a = s.id(Style {
660            fill: Some(Rgb(1, 2, 3)),
661            ..Style::default()
662        });
663        let b = s.id(Style {
664            fill: Some(Rgb(4, 5, 6)),
665            wrap: true,
666            ..Style::default()
667        });
668        let a2 = s.id(Style {
669            fill: Some(Rgb(1, 2, 3)),
670            ..Style::default()
671        });
672        assert_eq!((a, b, a2), (1, 2, 1));
673        assert_eq!(s.fills(), vec![Rgb(1, 2, 3), Rgb(4, 5, 6)]);
674        assert_eq!(Rgb(247, 244, 252).argb_hex(), "FFF7F4FC");
675        // A format put at the index a record names, the table grown with
676        // defaults (KERNEL.22, the loader).
677        let wrap = Style {
678            wrap: true,
679            ..Style::default()
680        };
681        s.put(5, wrap.clone());
682        assert_eq!(s.xfs().len(), 6);
683        assert_eq!(s.get(5), Some(&wrap));
684        assert_eq!(s.get(4), Some(&Style::default()));
685        assert_eq!(s.get(6), None);
686    }
687
688    #[test]
689    fn a1_references_as_a_program_writes_them() {
690        let r = parse_a1_range("b2").unwrap();
691        assert_eq!((r.top, r.left, r.bottom, r.right), (2, 2, 2, 2));
692        assert_eq!(r.text(), "B2");
693        let r = parse_a1_range("c4:A1").unwrap();
694        assert_eq!((r.top, r.left, r.bottom, r.right), (1, 1, 4, 3));
695        assert_eq!(r.text(), "A1:C4");
696        assert_eq!(r.cells(), 12);
697        assert_eq!(parse_a1_range("$B$2:$B$4").unwrap().text(), "B2:B4");
698        assert_eq!(parse_a1_range("XFD1048576").unwrap().text(), "XFD1048576");
699        for bad in [
700            "c:c", "1:3", "XFE1", "A0", "A1048577", "AAAA1", "b", "2", "", "b2:",
701        ] {
702            assert!(parse_a1_range(bad).is_none(), "{bad} should not parse");
703        }
704    }
705
706    #[test]
707    fn a_formula_over_a_range_is_shared_from_its_top_left_cell() {
708        let mut sh = Sheet::new("S");
709        sh.set_formula(parse_a1_range("c2:c3").unwrap(), "B2+B3", 0);
710        sh.set_formula(parse_a1_range("d2").unwrap(), "B2*2", 0);
711        sh.set_formula(parse_a1_range("e2:e3").unwrap(), "1", 0);
712        assert_eq!(
713            sh.cells[&(2, 3)].content,
714            Content::SharedMaster {
715                text: "B2+B3".to_string(),
716                range: "C2:C3".to_string(),
717                si: 0
718            }
719        );
720        assert_eq!(sh.cells[&(3, 3)].content, Content::SharedChild { si: 0 });
721        assert_eq!(
722            sh.cells[&(2, 4)].content,
723            Content::Formula("B2*2".to_string())
724        );
725        assert_eq!(
726            sh.cells[&(2, 5)].content,
727            Content::SharedMaster {
728                text: "1".to_string(),
729                range: "E2:E3".to_string(),
730                si: 1
731            }
732        );
733        assert_eq!(sh.shared_formulas, 2);
734    }
735
736    #[test]
737    fn a_dynamic_formula_over_a_range_is_one_per_cell_with_its_references_moved() {
738        let mut sh = Sheet::new("S");
739        sh.set_formula_dynamic(
740            parse_a1_range("c2:d3").unwrap(),
741            "IFS(B2>3,$A$1,TRUE,B$1)",
742            0,
743        );
744        assert_eq!(
745            sh.cells[&(2, 3)].content,
746            Content::DynamicFormula("IFS(B2>3,$A$1,TRUE,B$1)".to_string())
747        );
748        assert_eq!(
749            sh.cells[&(3, 3)].content,
750            Content::DynamicFormula("IFS(B3>3,$A$1,TRUE,B$1)".to_string())
751        );
752        assert_eq!(
753            sh.cells[&(2, 4)].content,
754            Content::DynamicFormula("IFS(C2>3,$A$1,TRUE,C$1)".to_string())
755        );
756        assert_eq!(
757            sh.cells[&(3, 4)].content,
758            Content::DynamicFormula("IFS(C3>3,$A$1,TRUE,C$1)".to_string())
759        );
760        let mut wb = Workbook::new();
761        assert!(!wb.has_dynamic_formulas());
762        wb.sheets.push(sh);
763        assert!(wb.has_dynamic_formulas());
764    }
765
766    #[test]
767    fn sheets_are_found_without_case_and_made_once() {
768        let mut wb = Workbook::new();
769        assert_eq!(wb.find_sheet("Output"), None);
770        let i = wb.ensure_sheet("Output");
771        assert_eq!(wb.ensure_sheet("output"), i);
772        assert_eq!(wb.find_sheet("OUTPUT"), Some(i));
773        assert_eq!(wb.sheets.len(), 1);
774        assert_eq!(wb.sheets[0].name, "Output");
775    }
776
777    #[test]
778    fn a_sheet_knows_its_extent() {
779        let mut sh = Sheet::new("S");
780        assert_eq!(sh.extent(), None);
781        sh.set(
782            3,
783            2,
784            Cell {
785                content: Content::Number(1.0),
786                style: 0,
787            },
788        );
789        sh.set(
790            1,
791            3,
792            Cell {
793                content: Content::Bool(true),
794                style: 0,
795            },
796        );
797        assert_eq!(sh.extent(), Some(((1, 2), (3, 3))));
798    }
799
800    #[test]
801    fn numbers_as_the_file_holds_them() {
802        assert_eq!(number_text(5.0).as_deref(), Some("5"));
803        assert_eq!(number_text(0.1).as_deref(), Some("0.1"));
804        assert_eq!(number_text(-2.5).as_deref(), Some("-2.5"));
805        assert_eq!(number_text(f64::NAN), None);
806        assert_eq!(number_text(f64::INFINITY), None);
807    }
808
809    #[test]
810    fn the_prefixes_come_off_a_formula_read_back() {
811        assert_eq!(
812            strip_future_prefixes("_xlfn.IFS(B2>3,\"big\",TRUE,\"small\")"),
813            "IFS(B2>3,\"big\",TRUE,\"small\")"
814        );
815        assert_eq!(
816            strip_future_prefixes("_xlfn._xlws.FILTER(A:A,B:B>1)"),
817            "FILTER(A:A,B:B>1)"
818        );
819        assert_eq!(
820            strip_future_prefixes("_xlfn.LET(_xlpm.x,1,_xlpm.x*2)"),
821            "LET(x,1,x*2)"
822        );
823        assert_eq!(
824            strip_future_prefixes("SUM(A1:A3)&\"_xlfn.\""),
825            "SUM(A1:A3)&\"_xlfn.\""
826        );
827        assert_eq!(strip_future_prefixes("Rate*B1"), "Rate*B1");
828        assert_eq!(strip_future_prefixes("_xlfn."), "_xlfn.");
829    }
830}