Skip to main content

frazaro_core/reflect/
mod.rs

1//! The reader (PORT.8): a workbook's file read as the relations of
2//! `REFLECT` (docs/SINGULARITY.md, Stage 0.1; the roadmap's `AXM.8`), with
3//! no host on the machine.
4//!
5//! New ground, said plainly: the reference reads a workbook through Excel's
6//! object model and never opens the file, so there is no VBA arm to port
7//! for the file formats; what the relations are, and how a formula's
8//! references are read (`AXM.7`, slice 8b), is the language's and follows
9//! the VBA as everything else does. A reader prints seven of the eight:
10//! `sheet(name, state)`, `name(name, refersto)`, `table(name, sheet,
11//! range)`, `cell(sheet, addr, value)`, `formula(sheet, addr, text)` and,
12//! from 8b, `refers(from, to)`; `changed` is `diff`'s (8c, `diff.rs`: two
13//! files read through this reader and compared) and `ran` comes from no
14//! file. `audit.rs` (8d) asks Stage 2.3's audit list of the relations:
15//! six named walks, one finding a row.
16//!
17//! Two formats, one reader (8e): an OOXML package (`ooxml.rs`) and an
18//! OpenDocument spreadsheet (`odf.rs`) each open into a [`Source`], the one
19//! shape the door, `diff`, `audit` and `--cone` are written against, and
20//! print the same rows in the same spelling, a formula from either file as
21//! the formula bar shows it. [`open`] tells them apart by the file.
22//!
23//! The reader streams. It never builds a model of the workbook: each sheet
24//! part is inflated whole (the bound every part already has, 256 MB), walked
25//! once by the cursor in `cursor.rs`, and dropped before the next is read;
26//! the walk hands each relation row to a [`Sink`] as it finds it. What is
27//! held is the shared-string table, since any cell may name any string, and
28//! the sink's own state: nothing for printing, counters for `--counts`. An
29//! OpenDocument file holds every sheet in one part, so that part is held
30//! whole and each sheet walked from its own slice of it.
31//!
32//! The fixed order, which is what makes the printed text a golden: every
33//! `sheet` row in tab order; every `name` row sorted by its name without
34//! case; every `table` row by its sheet's tab order, then its name; then
35//! sheet by sheet in tab order, cell by cell in document order, each cell's
36//! `cell` row, then its `formula` row, then its `refers` rows, one per
37//! distinct target, sorted by the target as printed (slice 8b, through
38//! `crate::refers`, the port of `VLA_Refers.bas`). `print.rs` has the
39//! spelling; `cone.rs` sizes a cell's cone through an index of the walk.
40
41pub mod audit;
42pub mod calc;
43pub mod cone;
44pub mod cursor;
45pub mod diff;
46pub mod histogram;
47pub mod odf;
48pub mod ooxml;
49pub mod print;
50
51use std::collections::HashSet;
52
53use crate::intrinsics::fold;
54use crate::messages::Refusal;
55use crate::refers::{self, Kind};
56
57pub use vla_lang::rows::{sheet_prefix, RefersTo, Row, Sink, Value, Visibility};
58
59/// The cap on the sets of distinct R1C1 formulas `--counts` keeps, a
60/// sheet's and the workbook's.
61pub const DISTINCT_CAP: usize = 1 << 20;
62
63/// A sink that keeps nothing, for `--counts`.
64pub struct Discard;
65
66impl Sink for Discard {
67    fn row(&mut self, _row: &Row<'_>) {}
68}
69
70/// What one sheet's walk counted: cells with a value or a formula, formula
71/// cells, array-formula anchors, the last row and column that hold one,
72/// the part's inflated size, and from 8b the distinct formulas in R1C1 and
73/// the `INDIRECT` and `OFFSET` calls met.
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75pub struct SheetStats {
76    pub cells: u64,
77    pub formulas: u64,
78    pub array_anchors: u64,
79    pub rows: u32,
80    pub columns: u32,
81    pub part_bytes: usize,
82    /// Distinct formulas on the sheet, compared in R1C1 relative to their
83    /// cells, so that a formula filled down counts once.
84    pub distinct_r1c1: u64,
85    /// `INDIRECT` and `OFFSET` calls on the sheet: where a cone is blind.
86    pub unreadable: u64,
87}
88
89/// One sheet of the workbook, in tab order.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct SheetInfo {
92    pub name: String,
93    pub visibility: Visibility,
94    /// The part that holds it: `xl/worksheets/sheet1.xml` in a package,
95    /// `content.xml` in an OpenDocument file.
96    pub part: String,
97}
98
99/// One defined name.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct NameInfo {
102    pub name: String,
103    /// What it refers to, as the file holds it: an A1 formula without `=`.
104    pub refers_to: String,
105    /// The sheet a sheet-scoped name belongs to, by tab index.
106    pub sheet: Option<usize>,
107    pub hidden: bool,
108    /// Excel's own `_xlfn.` placeholder for a newer function, which no one
109    /// defined: counted apart and printed in no row.
110    pub placeholder: bool,
111}
112
113/// One Table (a ListObject), by the name Excel shows; in an OpenDocument
114/// file a database range, Calc's named rectangle with a header row.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct TableInfo {
117    pub name: String,
118    /// The sheet it sits on, by tab index.
119    pub sheet: usize,
120    pub range: String,
121    pub columns: Vec<String>,
122}
123
124/// What `--counts` reports beyond the sheets.
125#[derive(Clone, Debug, PartialEq, Eq)]
126pub struct Summary {
127    pub names: usize,
128    pub placeholders: usize,
129    pub tables: usize,
130    pub external_books: usize,
131    pub strings: usize,
132    pub string_bytes: usize,
133}
134
135/// A workbook opened far enough to walk, in either format: what the door,
136/// `diff`, `audit` and `--cone` ask of a file. The sheet parts are read one
137/// at a time by [`Source::walk_sheet`] and dropped.
138pub trait Source: std::fmt::Debug {
139    /// What a refusal calls the file.
140    fn label(&self) -> &str;
141    fn sheets(&self) -> &[SheetInfo];
142    fn names(&self) -> &[NameInfo];
143    fn tables(&self) -> &[TableInfo];
144    /// The other workbooks the file links to, by file name.
145    fn external_books(&self) -> &[String];
146    /// The counts `--counts` prints for the workbook.
147    fn summary(&self) -> Summary;
148
149    /// One sheet's cells in document order, to the sink: a `cell` row for a
150    /// value the file holds, a `formula` row for a formula, then its
151    /// `refers` rows; with the workbook-wide set of distinct R1C1 formulas
152    /// carried across sheets for `--counts`. What the walk counted comes
153    /// back.
154    fn walk_sheet_with(
155        &self,
156        index: usize,
157        sink: &mut dyn Sink,
158        book_distinct: &mut HashSet<String>,
159    ) -> Result<SheetStats, Refusal>;
160
161    /// [`Source::walk_sheet_with`] with no workbook-wide set.
162    fn walk_sheet(&self, index: usize, sink: &mut dyn Sink) -> Result<SheetStats, Refusal> {
163        self.walk_sheet_with(index, sink, &mut HashSet::new())
164    }
165
166    /// A `name` row's first field: the name, a sheet-scoped one behind its
167    /// sheet as a reference quotes it (`Model!Local`). The key `diff` and
168    /// `audit` match names by.
169    fn printed_name(&self, name: &NameInfo) -> String {
170        match name.sheet.and_then(|i| self.sheets().get(i)) {
171            Some(sheet) => format!("{}!{}", sheet_prefix(&sheet.name), name.name),
172            None => name.name.clone(),
173        }
174    }
175
176    /// The rows that come before any cell, in the fixed order: every
177    /// `sheet` row in tab order, every `name` row sorted by its name without
178    /// case, every `table` row by its sheet's tab order then its name.
179    fn header_rows(&self, sink: &mut dyn Sink) {
180        for sheet in self.sheets() {
181            sink.row(&Row::Sheet {
182                name: &sheet.name,
183                visibility: sheet.visibility,
184            });
185        }
186        let mut names: Vec<(String, &str)> = self
187            .names()
188            .iter()
189            .filter(|n| !n.placeholder)
190            .map(|n| (self.printed_name(n), n.refers_to.as_str()))
191            .collect();
192        names.sort_by(|a, b| fold(&a.0).cmp(&fold(&b.0)).then_with(|| a.0.cmp(&b.0)));
193        for (name, refers_to) in &names {
194            sink.row(&Row::Name { name, refers_to });
195        }
196        let mut tables: Vec<&TableInfo> = self.tables().iter().collect();
197        tables.sort_by(|a, b| {
198            a.sheet
199                .cmp(&b.sheet)
200                .then_with(|| fold(&a.name).cmp(&fold(&b.name)))
201        });
202        let sheets = self.sheets();
203        for table in tables {
204            let Some(sheet) = sheets.get(table.sheet) else {
205                continue;
206            };
207            sink.row(&Row::Table {
208                name: &table.name,
209                sheet: &sheet.name,
210                range: &table.range,
211            });
212        }
213    }
214}
215
216/// The file opened by its format: an OpenDocument spreadsheet by its
217/// `mimetype` entry (or a `content.xml` with no workbook part), an OOXML
218/// package otherwise. `label` is what a refusal calls the file.
219pub fn open<'a>(bytes: &'a [u8], label: &str) -> Result<Box<dyn Source + 'a>, Refusal> {
220    if odf::is_opendocument(bytes) {
221        Ok(Box::new(odf::Document::open(bytes, label)?))
222    } else {
223        Ok(Box::new(ooxml::Package::open(bytes, label)?))
224    }
225}
226
227/// A formula's rows and counts, the same from either reader: the `formula`
228/// row, then its `refers` rows, one per distinct target sorted as printed;
229/// the unreadable calls counted; the R1C1 rendering added to the sheet's
230/// and the workbook's distinct sets, each capped. `text` begins with `=`.
231#[allow(clippy::too_many_arguments)]
232pub(crate) fn emit_formula(
233    sink: &mut dyn Sink,
234    sheet: &str,
235    addr: &str,
236    text: &str,
237    row: u32,
238    col: u32,
239    stats: &mut SheetStats,
240    sheet_distinct: &mut HashSet<String>,
241    book_distinct: &mut HashSet<String>,
242) {
243    stats.formulas += 1;
244    sink.row(&Row::Formula { sheet, addr, text });
245    let found = refers::scan(text);
246    stats.unreadable += found.iter().filter(|r| r.kind == Kind::Unreadable).count() as u64;
247    let mut targets: Vec<RefersTo> = found.iter().map(|r| RefersTo::of(r, sheet)).collect();
248    targets.sort_by_key(print::target);
249    targets.dedup();
250    for to in &targets {
251        sink.row(&Row::Refers { sheet, addr, to });
252    }
253    let rendered = refers::r1c1(text, row, col);
254    if sheet_distinct.len() < DISTINCT_CAP {
255        sheet_distinct.insert(rendered.clone());
256    }
257    if book_distinct.len() < DISTINCT_CAP {
258        book_distinct.insert(rendered);
259    }
260}
261
262/// The whole of a workbook as text, one relation row a line in the fixed
263/// order: the API's surface, the tests' and a door's that holds the text.
264pub fn reflect_text(bytes: &[u8], label: &str) -> Result<String, Refusal> {
265    let source = open(bytes, label)?;
266    let mut printer = print::Printer::default();
267    source.header_rows(&mut printer);
268    for i in 0..source.sheets().len() {
269        source.walk_sheet(i, &mut printer)?;
270    }
271    Ok(printer.out)
272}