use std::collections::{BTreeMap, HashSet, VecDeque};
use super::{Row, Sink, Source};
use crate::intrinsics::fold;
use crate::refers::{self, Kind, Shape};
use crate::sheet::{parse_a1_range, MAX_COLUMN, MAX_ROW};
pub const SEEN_CAP: usize = 4_000_000;
#[derive(Debug, Default)]
pub struct Index {
sheets: Vec<String>,
formulas: Vec<BTreeMap<(u32, u32), String>>,
values: Vec<BTreeMap<(u32, u32), ()>>,
}
impl Index {
pub fn new(package: &dyn Source) -> Index {
let n = package.sheets().len();
Index {
sheets: package.sheets().iter().map(|s| fold(&s.name)).collect(),
formulas: (0..n).map(|_| BTreeMap::new()).collect(),
values: (0..n).map(|_| BTreeMap::new()).collect(),
}
}
fn sheet_index(&self, name: &str) -> Option<usize> {
let f = fold(name);
self.sheets.iter().position(|s| *s == f)
}
fn holds(&self, sheet: usize, at: (u32, u32)) -> bool {
self.formulas[sheet].contains_key(&at) || self.values[sheet].contains_key(&at)
}
fn cells_in(
&self,
sheet: usize,
top: u32,
left: u32,
bottom: u32,
right: u32,
) -> Vec<(u32, u32)> {
let mut out: Vec<(u32, u32)> = Vec::new();
for map_keys in [
self.formulas[sheet]
.range((top, 1)..=(bottom, MAX_COLUMN))
.map(|(k, _)| *k)
.collect::<Vec<_>>(),
self.values[sheet]
.range((top, 1)..=(bottom, MAX_COLUMN))
.map(|(k, _)| *k)
.collect::<Vec<_>>(),
] {
for (row, col) in map_keys {
if col >= left && col <= right {
out.push((row, col));
}
}
}
out
}
}
impl Sink for Index {
fn row(&mut self, row: &Row<'_>) {
match row {
Row::Cell { sheet, addr, .. } => {
if let (Some(i), Some(a)) = (self.sheet_index(sheet), parse_a1_range(addr)) {
self.values[i].insert((a.top, a.left), ());
}
}
Row::Formula { sheet, addr, text } => {
if let (Some(i), Some(a)) = (self.sheet_index(sheet), parse_a1_range(addr)) {
self.formulas[i].insert((a.top, a.left), (*text).to_string());
}
}
_ => {}
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ConeStats {
pub cells: u64,
pub formulas: u64,
pub inputs: u64,
pub blanks: u64,
pub sheets: u64,
pub depth: u64,
pub blind: u64,
pub names: u64,
pub tables: u64,
pub external: u64,
pub broken: u64,
pub unresolved: u64,
pub truncated: bool,
}
impl ConeStats {
pub fn line(&self) -> String {
let mut s = format!(
"cells {} formulas {} inputs {} blanks {} sheets {} depth {} blind {} names {} tables {} external {} broken {} unresolved {}",
self.cells,
self.formulas,
self.inputs,
self.blanks,
self.sheets,
self.depth,
self.blind,
self.names,
self.tables,
self.external,
self.broken,
self.unresolved
);
if self.truncated {
s.push_str(" truncated");
}
s
}
}
type Pending = (usize, u32, u32, u64);
pub fn cone(index: &Index, package: &dyn Source, sheet: usize, row: u32, col: u32) -> ConeStats {
let mut stats = ConeStats::default();
let mut seen: HashSet<(usize, u32, u32)> = HashSet::new();
let mut sheets_seen: HashSet<usize> = HashSet::new();
let mut queue: VecDeque<Pending> = VecDeque::new();
let mut walk = Walk {
index,
package,
stats: &mut stats,
seen: &mut seen,
sheets_seen: &mut sheets_seen,
queue: &mut queue,
};
walk.reach(sheet, row, col, 0, true);
while let Some((s, r, c, depth)) = walk.queue.pop_front() {
if walk.stats.truncated {
break;
}
let Some(text) = walk.index.formulas[s].get(&(r, c)).cloned() else {
continue;
};
walk.follow(&text, s, r, c, depth + 1);
}
stats.sheets = sheets_seen.len() as u64;
stats
}
struct Walk<'w> {
index: &'w Index,
package: &'w dyn Source,
stats: &'w mut ConeStats,
seen: &'w mut HashSet<(usize, u32, u32)>,
sheets_seen: &'w mut HashSet<usize>,
queue: &'w mut VecDeque<Pending>,
}
impl Walk<'_> {
fn reach(&mut self, sheet: usize, row: u32, col: u32, depth: u64, root: bool) {
if self.seen.len() >= SEEN_CAP {
self.stats.truncated = true;
return;
}
if !self.seen.insert((sheet, row, col)) {
return;
}
self.sheets_seen.insert(sheet);
if depth > self.stats.depth {
self.stats.depth = depth;
}
let has_formula = self.index.formulas[sheet].contains_key(&(row, col));
if has_formula {
self.stats.cells += 1;
self.stats.formulas += 1;
self.queue.push_back((sheet, row, col, depth));
} else if self.index.holds(sheet, (row, col)) || root {
self.stats.cells += 1;
self.stats.inputs += 1;
} else {
self.stats.blanks += 1;
}
}
fn reach_rect(
&mut self,
sheet: usize,
top: u32,
left: u32,
bottom: u32,
right: u32,
depth: u64,
) {
let (top, bottom) = (top.min(bottom), top.max(bottom));
let (left, right) = (left.min(right), left.max(right));
for (row, col) in self.index.cells_in(sheet, top, left, bottom, right) {
self.reach(sheet, row, col, depth, false);
if self.stats.truncated {
return;
}
}
}
fn reach_part(&mut self, r: &refers::FormulaRef, sheet: usize, depth: u64) {
match r.shape {
Shape::Cell => self.reach(sheet, r.row1, r.col1, depth, false),
Shape::Range => self.reach_rect(sheet, r.row1, r.col1, r.row2, r.col2, depth),
Shape::Column => self.reach_rect(sheet, 1, r.col1, MAX_ROW, r.col2, depth),
Shape::Row => self.reach_rect(sheet, r.row1, 1, r.row2, MAX_COLUMN, depth),
Shape::None => {}
}
}
fn sheet_of(&mut self, r: &refers::FormulaRef, home: usize) -> Option<usize> {
if r.sheet_name.is_empty() {
return Some(home);
}
let found = self.index.sheet_index(&r.sheet_name);
if found.is_none() {
self.stats.unresolved += 1;
}
found
}
fn follow(&mut self, text: &str, home: usize, row: u32, col: u32, depth: u64) {
for r in refers::scan(text) {
if self.stats.truncated {
return;
}
match r.kind {
Kind::Cell | Kind::Range | Kind::Column | Kind::Row => {
if let Some(s) = self.sheet_of(&r, home) {
self.reach_part(&r, s, depth);
}
}
Kind::Spill => {
if r.shape == Shape::Cell {
if let Some(s) = self.sheet_of(&r, home) {
self.reach(s, r.row1, r.col1, depth, false);
}
} else {
let name = r.part.trim_end_matches('#');
self.follow_name(name, &r.sheet_name, home, depth);
}
}
Kind::Name => self.follow_name(&r.part, &r.sheet_name, home, depth),
Kind::Structured => self.follow_table(&r, home, row, col, depth),
Kind::External => self.stats.external += 1,
Kind::ThreeD => self.follow_span(&r, depth),
Kind::Unreadable => self.stats.blind += 1,
Kind::Broken => self.stats.broken += 1,
}
}
}
fn follow_name(&mut self, name: &str, scope: &str, home: usize, depth: u64) {
let f = fold(name);
let scope_index = if scope.is_empty() {
None
} else {
self.index.sheet_index(scope)
};
let names = self.package.names();
let pick = |want: Option<usize>| {
names
.iter()
.find(|n| !n.placeholder && fold(&n.name) == f && n.sheet == want)
};
let found = match scope_index {
Some(s) => pick(Some(s)),
None if scope.is_empty() => pick(Some(home)).or_else(|| pick(None)),
None => None,
};
let Some(info) = found else {
self.stats.unresolved += 1;
return;
};
self.stats.names += 1;
let text = info.refers_to.clone();
let name_home = info.sheet.unwrap_or(home);
self.follow(&text, name_home, 0, 0, depth);
}
fn follow_table(
&mut self,
r: &refers::FormulaRef,
home: usize,
row: u32,
col: u32,
depth: u64,
) {
let written = r.part.as_str();
let table_name = written.split('[').next().unwrap_or("").trim();
let tables = self.package.tables();
let found = if table_name.is_empty() {
tables.iter().find(|t| {
t.sheet == home
&& parse_a1_range(&t.range).is_some_and(|a| {
row >= a.top && row <= a.bottom && col >= a.left && col <= a.right
})
})
} else {
let f = fold(table_name);
tables.iter().find(|t| fold(&t.name) == f)
};
let Some(table) = found else {
self.stats.unresolved += 1;
return;
};
let Some(range) = parse_a1_range(&table.range) else {
self.stats.unresolved += 1;
return;
};
self.stats.tables += 1;
self.reach_rect(
table.sheet,
range.top,
range.left,
range.bottom,
range.right,
depth,
);
}
fn follow_span(&mut self, r: &refers::FormulaRef, depth: u64) {
let Some((first, last)) = r.sheet_name.split_once(':') else {
self.stats.unresolved += 1;
return;
};
let (Some(a), Some(b)) = (self.index.sheet_index(first), self.index.sheet_index(last))
else {
self.stats.unresolved += 1;
return;
};
for s in a.min(b)..=a.max(b) {
self.reach_part(r, s, depth);
if self.stats.truncated {
return;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reflect::ooxml::Package;
use crate::sheet::zip;
const NS: &str = "xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"";
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
fn package_bytes() -> Vec<u8> {
let parts: Vec<(String, Vec<u8>)> = vec![
("xl/workbook.xml".to_string(), format!("<workbook {NS}><sheets><sheet name=\"Model\" sheetId=\"1\" r:id=\"rId1\"/><sheet name=\"Data\" sheetId=\"2\" r:id=\"rId2\"/></sheets><externalReferences><externalReference r:id=\"rId3\"/></externalReferences><definedNames><definedName name=\"Rate\">Model!$B$2</definedName><definedName name=\"Local\" localSheetId=\"0\">Model!$B$1</definedName><definedName name=\"Deep\">Rate</definedName></definedNames></workbook>").into_bytes()),
("xl/_rels/workbook.xml.rels".to_string(), format!("<Relationships><Relationship Id=\"rId1\" Type=\"{REL}/worksheet\" Target=\"worksheets/sheet1.xml\"/><Relationship Id=\"rId2\" Type=\"{REL}/worksheet\" Target=\"worksheets/sheet2.xml\"/><Relationship Id=\"rId3\" Type=\"{REL}/externalLink\" Target=\"externalLinks/externalLink1.xml\"/></Relationships>").into_bytes()),
("xl/worksheets/sheet1.xml".to_string(), format!(concat!(
"<worksheet {NS}><sheetData>",
"<row r=\"1\"><c r=\"A1\"><f>B1+C1</f></c><c r=\"B1\"><v>5</v></c><c r=\"C1\"><f>D1*2</f></c><c r=\"D1\"><v>7</v></c>",
"<c r=\"E1\"><f>INDIRECT(\"B1\")</f></c><c r=\"F1\"><f>Rate+Local</f></c><c r=\"G1\"><f>SUM(Sales[Amount])</f></c>",
"<c r=\"H1\"><f>SUM(Model:Data!B1)</f></c><c r=\"I1\"><f>[1]Sheet1!A1+#REF!</f></c><c r=\"J1\"><f>SUM(B:B)+Z9</f></c>",
"<c r=\"K1\"><f>Deep</f></c><c r=\"L1\"><f>Missing!A1+NoSuchName+NoTable[Col]</f></c></row>",
"<row r=\"2\"><c r=\"B2\"><v>0.2</v></c></row>",
"</sheetData></worksheet>"), NS = NS).into_bytes()),
("xl/worksheets/sheet2.xml".to_string(), format!("<worksheet {NS}><sheetData><row r=\"1\"><c r=\"A1\" t=\"inlineStr\"><is><t>Item</t></is></c><c r=\"B1\" t=\"inlineStr\"><is><t>Amount</t></is></c></row><row r=\"2\"><c r=\"A2\" t=\"inlineStr\"><is><t>pens</t></is></c><c r=\"B2\"><v>10</v></c></row></sheetData><tableParts count=\"1\"><tablePart r:id=\"rId1\"/></tableParts></worksheet>").into_bytes()),
("xl/worksheets/_rels/sheet2.xml.rels".to_string(), format!("<Relationships><Relationship Id=\"rId1\" Type=\"{REL}/table\" Target=\"../tables/table1.xml\"/></Relationships>").into_bytes()),
("xl/tables/table1.xml".to_string(), format!("<table {NS} id=\"1\" name=\"Sales\" displayName=\"Sales\" ref=\"A1:B2\"><tableColumns count=\"2\"><tableColumn id=\"1\" name=\"Item\"/><tableColumn id=\"2\" name=\"Amount\"/></tableColumns></table>").into_bytes()),
("xl/externalLinks/externalLink1.xml".to_string(), format!("<externalLink {NS}><externalBook r:id=\"rId1\"><sheetNames><sheetName val=\"Sheet1\"/></sheetNames></externalBook></externalLink>").into_bytes()),
("xl/externalLinks/_rels/externalLink1.xml.rels".to_string(), format!("<Relationships><Relationship Id=\"rId1\" Type=\"{REL}/externalLinkPath\" Target=\"Rates.xlsx\" TargetMode=\"External\"/></Relationships>").into_bytes()),
];
zip::write_stored(&parts).unwrap()
}
fn sized(root: &str) -> ConeStats {
let bytes = package_bytes();
let package = Package::open(&bytes, "t.xlsx").unwrap();
let mut index = Index::new(&package);
for i in 0..package.sheets().len() {
package.walk_sheet(i, &mut index).unwrap();
}
let (sheet, row, col) = refers::parse_home(root).unwrap();
let s = index.sheet_index(&sheet).unwrap();
cone(&index, &package, s, row, col)
}
fn counts(c: &ConeStats) -> (u64, u64, u64, u64, u64, u64) {
(c.cells, c.formulas, c.inputs, c.blanks, c.sheets, c.depth)
}
#[test]
fn a_chain_is_sized_across_cells() {
let c = sized("Model!A1");
assert_eq!(counts(&c), (4, 2, 2, 0, 1, 2));
assert_eq!(
(
c.blind,
c.names,
c.tables,
c.external,
c.broken,
c.unresolved
),
(0, 0, 0, 0, 0, 0)
);
}
#[test]
fn an_input_is_a_cone_of_one() {
let c = sized("Model!D1");
assert_eq!(counts(&c), (1, 0, 1, 0, 1, 0));
}
#[test]
fn blind_names_tables_spans_external_and_broken_are_counted() {
let c = sized("Model!E1");
assert_eq!((counts(&c), c.blind), ((1, 1, 0, 0, 1, 0), 1));
let c = sized("Model!F1");
assert_eq!((counts(&c), c.names), ((3, 1, 2, 0, 1, 1), 2));
let c = sized("Model!G1");
assert_eq!((counts(&c), c.tables), ((5, 1, 4, 0, 2, 1), 1));
let c = sized("Model!H1");
assert_eq!(counts(&c), (3, 1, 2, 0, 2, 1));
let c = sized("Model!I1");
assert_eq!(
(counts(&c), c.external, c.broken),
((1, 1, 0, 0, 1, 0), 1, 1)
);
let c = sized("Model!J1");
assert_eq!(counts(&c), (3, 1, 2, 1, 1, 1));
let c = sized("Model!K1");
assert_eq!((counts(&c), c.names), ((2, 1, 1, 0, 1, 1), 2));
let c = sized("Model!L1");
assert_eq!((counts(&c), c.unresolved), ((1, 1, 0, 0, 1, 0), 3));
}
#[test]
fn the_line_is_counts_alone() {
let c = ConeStats {
cells: 4,
formulas: 2,
inputs: 2,
depth: 2,
sheets: 1,
..ConeStats::default()
};
assert_eq!(
c.line(),
"cells 4 formulas 2 inputs 2 blanks 0 sheets 1 depth 2 blind 0 names 0 tables 0 external 0 broken 0 unresolved 0"
);
let t = ConeStats {
truncated: true,
..ConeStats::default()
};
assert!(t.line().ends_with(" truncated"));
}
}