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}