truecalc_workbook/depgraph.rs
1//! Dependency graph for the workbook runtime (plan item 3.2, issue #534).
2//!
3//! The graph records, for every formula cell, *which cells, ranges, and named
4//! ranges it reads* — its **precedents** — derived once from the parsed
5//! formula via [`truecalc_core::extract_refs`] (P1.3). The reverse edges (a
6//! cell's **dependents**: the formula cells that must recalculate when it
7//! changes) are what the recalc engine (P3.3, #535) walks to propagate a dirty
8//! set, and what topological ordering and cycle detection run over.
9//!
10//! # What this layer is (and is not)
11//!
12//! This is the dependency *graph only*. It owns no values and performs no
13//! evaluation: [`DependencyGraph::build`] reads a [`Workbook`] and produces the
14//! edges; recalculation is P3.3. It exposes a [topological order /
15//! cycle-detection primitive](DependencyGraph::topological_order) because P3.3
16//! needs it, but it never evaluates a formula.
17//!
18//! # How edges are derived ([`extract_refs`])
19//!
20//! For each formula cell the graph parses the verbatim formula with the
21//! workbook's locked engine, calls [`extract_refs`] on the AST, and resolves
22//! each [`Ref`] to a concrete graph node:
23//!
24//! - [`Ref::Cell`] → a single-cell precedent; a bare `A1` resolves against the
25//! formula cell's *own* sheet, a qualified `Sheet1!A1` against the named
26//! sheet.
27//! - [`Ref::Range`] → a **range node** (range-node compression): `A1:A100000`
28//! is one node, not 100 000 edges. A changed cell finds its range-dependents
29//! by testing membership in each live range node, so the graph stays linear
30//! in the number of *distinct ranges*, not their area.
31//! - [`Ref::Name`] → a **name node** (name → target indirection): the formula
32//! depends on the name, the name depends on its current target cell/range.
33//! Retargeting a name (P3.4) therefore dirties the name's dependents without
34//! rebuilding their edges, and a write inside a name's target range dirties
35//! the name's dependents transitively.
36//!
37//! A reference that cannot be resolved (an unknown sheet, an unknown name, a
38//! malformed or unparseable formula) is recorded as an [`Unresolved`]
39//! precedent rather than dropped: it carries no edge (nothing can dirty it),
40//! but it is preserved so the recalc engine can surface the Sheets error the
41//! formula will ultimately produce (`#REF!` / `#NAME?`), fixture-verified in
42//! P3.3 rather than assumed here.
43//!
44//! [`extract_refs`]: truecalc_core::extract_refs
45//! [`Ref`]: truecalc_core::Ref
46//! [`Ref::Cell`]: truecalc_core::Ref::Cell
47//! [`Ref::Range`]: truecalc_core::Ref::Range
48//! [`Ref::Name`]: truecalc_core::Ref::Name
49//! [`Unresolved`]: Precedent::Unresolved
50
51use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
52
53use icu_casemap::CaseMapperBorrowed;
54use truecalc_core::{CellAddr, Engine, EngineFlavor, Ref};
55
56use crate::address::Address;
57use crate::casefold::simple_fold;
58use crate::named_ref;
59use crate::workbook::Workbook;
60
61/// A fully resolved cell coordinate: a sheet (by its position-independent,
62/// case-folded name) and an in-bounds [`Address`].
63///
64/// Sheets are keyed by **folded name**, not tab index, so the key survives a
65/// sheet move (P3.1 `move_sheet`) and matches the case-insensitive sheet
66/// lookup of [`Workbook::sheet`](crate::Workbook::sheet). A rename changes the
67/// key, which is why a rename forces a graph rebuild (see the module docs and
68/// [`DependencyGraph::build`]).
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
70pub struct CellRef {
71 /// The target sheet's name, simple-case-folded (schema spec §2).
72 pub sheet: String,
73 /// The in-bounds A1 address within that sheet.
74 pub addr: Address,
75}
76
77impl CellRef {
78 fn new(sheet: String, addr: Address) -> Self {
79 Self { sheet, addr }
80 }
81}
82
83/// A resolved rectangular range: a sheet (folded name) and an inclusive,
84/// top-left-first corner pair.
85///
86/// Range-node compression hinges on this being a single value regardless of
87/// area: membership of a [`CellRef`] is an `O(1)` rectangle test
88/// ([`RangeRef::contains`]), so finding the formula cells that read a changed
89/// cell through a range costs one test per *distinct range*, never one per cell
90/// in the range.
91#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
92pub struct RangeRef {
93 /// The target sheet's name, simple-case-folded.
94 pub sheet: String,
95 /// Top-left corner (minimum row, minimum column).
96 pub start: Address,
97 /// Bottom-right corner (maximum row, maximum column).
98 pub end: Address,
99}
100
101impl RangeRef {
102 /// Whether `cell` lies inside this range (same sheet, within the inclusive
103 /// rectangle). The membership test behind range-node compression.
104 pub fn contains(&self, cell: &CellRef) -> bool {
105 cell.sheet == self.sheet
106 && cell.addr.row >= self.start.row
107 && cell.addr.row <= self.end.row
108 && cell.addr.column >= self.start.column
109 && cell.addr.column <= self.end.column
110 }
111}
112
113/// One resolved precedent of a formula cell: what a single [`Ref`] in the
114/// formula points at, after sheet/name resolution.
115///
116/// [`extract_refs`](truecalc_core::extract_refs) yields one [`Ref`] per
117/// reference occurrence (duplicates preserved); the graph maps each to one of
118/// these. [`Unresolved`](Precedent::Unresolved) keeps a reference that has no
119/// concrete target (unknown sheet/name, unparseable formula) so the recalc
120/// engine can still emit the right Sheets error.
121#[derive(Debug, Clone, PartialEq, Eq, Hash)]
122pub enum Precedent {
123 /// A single cell (`A1` on the formula's own sheet, or `Sheet1!A1`).
124 Cell(CellRef),
125 /// A rectangular range (`A1:D4`, `Sheet1!A1:B2`) — a compressed range node.
126 Range(RangeRef),
127 /// A workbook-scoped named range, by its case-folded name. The name's
128 /// current target (cell or range) supplies the transitive edges.
129 Name(String),
130 /// A reference that did not resolve to a concrete target: an unknown sheet
131 /// or name, or a formula that failed to parse. Carries the canonical
132 /// reference text for diagnostics; it produces no dirty-propagation edge.
133 Unresolved(String),
134}
135
136/// The dependency graph of a [`Workbook`]: precedents and reverse-edge indexes
137/// derived from every formula cell via [`extract_refs`](truecalc_core::extract_refs).
138///
139/// Built with [`DependencyGraph::build`]; queried with
140/// [`precedents_of`](Self::precedents_of),
141/// [`direct_dependents_of`](Self::direct_dependents_of),
142/// [`topological_order`](Self::topological_order), and
143/// [`cycle_cells`](Self::cycle_cells). It is a pure derived view — it borrows
144/// nothing from the workbook after `build` returns and holds no values.
145///
146/// Rebuild rules (issue #534, "Rebuild rules on set/clear/rename"): the graph
147/// is a function of the workbook's formulas, sheet names, and named-range
148/// targets, so any edit that changes those — `set`/`clear` of a formula cell,
149/// a sheet rename, a named-range retarget — invalidates it. The P3.4 mutation
150/// API rebuilds (or incrementally updates) the graph after such edits; the
151/// graph-rebuild equivalence tests assert that a from-scratch
152/// [`build`](Self::build) after an arbitrary edit sequence equals the
153/// maintained graph.
154#[derive(Debug, Clone, PartialEq)]
155pub struct DependencyGraph {
156 /// Every formula cell, with its resolved precedents in formula order
157 /// (duplicates from `extract_refs` deduplicated per cell). The key set is
158 /// exactly the set of graph nodes that carry a formula.
159 precedents: BTreeMap<CellRef, Vec<Precedent>>,
160 /// Reverse cell→formula edges: for a precedent *cell*, the formula cells
161 /// that read it directly. The `O(1)` half of dependent lookup.
162 cell_dependents: HashMap<CellRef, BTreeSet<CellRef>>,
163 /// Reverse range edges: each distinct range node and the formula cells that
164 /// read it. Range-node compression — one entry per range, tested by
165 /// rectangle membership at query time.
166 range_dependents: Vec<(RangeRef, BTreeSet<CellRef>)>,
167 /// Name → its dependent formula cells (formulas that reference the name).
168 name_dependents: HashMap<String, BTreeSet<CellRef>>,
169 /// Name → its resolved current target (the indirection layer). Absent if
170 /// the name is undefined or dangles; retargeting updates this entry.
171 name_targets: HashMap<String, NameTarget>,
172}
173
174/// What a named range currently resolves to (the name→target indirection).
175#[derive(Debug, Clone, PartialEq, Eq)]
176enum NameTarget {
177 Cell(CellRef),
178 Range(RangeRef),
179}
180
181impl DependencyGraph {
182 /// Builds the dependency graph from `workbook`.
183 ///
184 /// Walks every populated cell on every sheet; for each *formula* cell,
185 /// parses the formula with the workbook's locked engine, extracts its refs
186 /// ([`extract_refs`](truecalc_core::extract_refs)), resolves each to a
187 /// concrete node, and records both the forward precedent list and the
188 /// reverse edges. Named-range targets are resolved up front so name
189 /// indirection edges are available.
190 ///
191 /// Resolution is total: an unresolvable reference becomes a
192 /// [`Precedent::Unresolved`] rather than an error, so a workbook with a
193 /// dangling `Sheet9!A1` or an unknown name still builds (the recalc engine
194 /// turns those into Sheets errors, fixture-verified in P3.3). Building
195 /// therefore never fails.
196 pub fn build(workbook: &Workbook) -> Self {
197 let folder = CaseMapperBorrowed::new();
198 let engine = match workbook.engine() {
199 EngineFlavor::Sheets => Engine::sheets(),
200 EngineFlavor::Excel => Engine::excel(),
201 };
202
203 // Resolve named-range targets first (the name → target indirection
204 // layer). A name whose ref names a missing sheet, or is itself
205 // malformed, simply has no target and contributes no transitive edge.
206 let mut name_targets: HashMap<String, NameTarget> = HashMap::new();
207 for nr in workbook.names() {
208 let folded = simple_fold(&folder, &nr.name);
209 if let Some(target) = resolve_name_ref(&nr.r#ref, &folder, workbook) {
210 name_targets.insert(folded, target);
211 }
212 }
213
214 let mut graph = DependencyGraph {
215 precedents: BTreeMap::new(),
216 cell_dependents: HashMap::new(),
217 range_dependents: Vec::new(),
218 name_dependents: HashMap::new(),
219 name_targets,
220 };
221 // Stable index from a range node to its slot in `range_dependents`, so
222 // repeated references to the same range share one compressed node.
223 let mut range_slots: HashMap<RangeRef, usize> = HashMap::new();
224
225 for sheet in workbook.sheets() {
226 let sheet_folded = simple_fold(&folder, sheet.name());
227 for (addr, cell) in sheet.iter() {
228 let Some(formula) = cell.formula() else {
229 continue;
230 };
231 let from = CellRef::new(sheet_folded.clone(), addr);
232
233 let refs = match engine.parse(formula) {
234 Ok(expr) => truecalc_core::extract_refs(&expr),
235 // An unparseable formula has one self-describing precedent
236 // and no edges — the recalc engine reports the parse error.
237 Err(_) => {
238 graph.precedents.insert(
239 from.clone(),
240 vec![Precedent::Unresolved(formula.to_owned())],
241 );
242 continue;
243 }
244 };
245
246 let mut seen: HashSet<Precedent> = HashSet::new();
247 let mut resolved: Vec<Precedent> = Vec::new();
248 for r in &refs {
249 let prec = resolve_ref(r, &from.sheet, &folder, workbook);
250 if seen.insert(prec.clone()) {
251 resolved.push(prec);
252 }
253 }
254
255 // Record reverse edges for each resolved precedent.
256 for prec in &resolved {
257 match prec {
258 Precedent::Cell(target) => {
259 graph
260 .cell_dependents
261 .entry(target.clone())
262 .or_default()
263 .insert(from.clone());
264 }
265 Precedent::Range(range) => {
266 let slot = *range_slots.entry(range.clone()).or_insert_with(|| {
267 graph
268 .range_dependents
269 .push((range.clone(), BTreeSet::new()));
270 graph.range_dependents.len() - 1
271 });
272 graph.range_dependents[slot].1.insert(from.clone());
273 }
274 Precedent::Name(name) => {
275 graph
276 .name_dependents
277 .entry(name.clone())
278 .or_default()
279 .insert(from.clone());
280 }
281 Precedent::Unresolved(_) => {}
282 }
283 }
284
285 graph.precedents.insert(from, resolved);
286 }
287 }
288
289 graph
290 }
291
292 /// The resolved precedents of `cell` in formula order, or `None` if `cell`
293 /// is not a formula cell (a literal or an empty cell has no precedents).
294 pub fn precedents_of(&self, cell: &CellRef) -> Option<&[Precedent]> {
295 self.precedents.get(cell).map(Vec::as_slice)
296 }
297
298 /// Whether `cell` is a formula cell tracked by the graph.
299 pub fn is_formula(&self, cell: &CellRef) -> bool {
300 self.precedents.contains_key(cell)
301 }
302
303 /// Every formula cell in the graph, in canonical (sheet, address) order.
304 pub fn formula_cells(&self) -> impl Iterator<Item = &CellRef> {
305 self.precedents.keys()
306 }
307
308 /// The formula cells that read `cell` **directly** — through a single-cell
309 /// reference, through a range that contains `cell`, or through a named
310 /// range whose target contains `cell`.
311 ///
312 /// This is the dirty-propagation primitive the incremental recalc engine
313 /// (P3.3) walks transitively: when `cell` changes, every cell returned here
314 /// is dirty, and the walk repeats from each of them. It deliberately
315 /// composes all three edge kinds so callers never reason about
316 /// range-node compression or name indirection themselves.
317 ///
318 /// Returned in canonical (sheet, address) order; the set is deduplicated
319 /// even when a formula reaches `cell` by more than one path.
320 pub fn direct_dependents_of(&self, cell: &CellRef) -> BTreeSet<CellRef> {
321 let mut out = BTreeSet::new();
322 if let Some(direct) = self.cell_dependents.get(cell) {
323 out.extend(direct.iter().cloned());
324 }
325 for (range, deps) in &self.range_dependents {
326 if range.contains(cell) {
327 out.extend(deps.iter().cloned());
328 }
329 }
330 // Name indirection: a write inside a name's target dirties the name's
331 // dependents.
332 for (name, target) in &self.name_targets {
333 let hit = match target {
334 NameTarget::Cell(c) => c == cell,
335 NameTarget::Range(r) => r.contains(cell),
336 };
337 if hit {
338 if let Some(deps) = self.name_dependents.get(name) {
339 out.extend(deps.iter().cloned());
340 }
341 }
342 }
343 out
344 }
345
346 /// The formula cells that depend on the named range `name` (any case),
347 /// i.e. would be dirtied by retargeting it (P3.4).
348 ///
349 /// Retargeting a name changes what its dependents read without changing
350 /// their formulas, so the recalc engine dirties exactly this set (the
351 /// name → target indirection promised by issue #534).
352 pub fn name_dependents_of(&self, name: &str) -> BTreeSet<CellRef> {
353 let folder = CaseMapperBorrowed::new();
354 let folded = simple_fold(&folder, name);
355 self.name_dependents
356 .get(&folded)
357 .cloned()
358 .unwrap_or_default()
359 }
360
361 /// A topological order of the formula cells: every cell appears after all
362 /// the formula cells it (transitively) reads, so evaluating in this order
363 /// visits each cell only once with its precedents already current.
364 ///
365 /// Returns `Ok(order)` when the formula-cell subgraph is acyclic, or
366 /// `Err(cycle_cells)` listing every formula cell that lies on a cycle (the
367 /// set [`cycle_cells`](Self::cycle_cells) returns). Only edges *between
368 /// formula cells* participate: a formula that reads a literal cell has
369 /// nothing to wait for. This is the ordering primitive P3.3 evaluates in;
370 /// the Sheets circular-dependency error semantics for the cells on a cycle
371 /// are applied by the recalc engine (fixture-verified there), not here.
372 pub fn topological_order(&self) -> Result<Vec<CellRef>, BTreeSet<CellRef>> {
373 // Index the formula cells in canonical order so Kahn's algorithm is
374 // O(V + E) and its tie-breaking is deterministic across surfaces.
375 let nodes: Vec<&CellRef> = self.precedents.keys().collect();
376 let index_of: HashMap<&CellRef, usize> =
377 nodes.iter().enumerate().map(|(i, n)| (*n, i)).collect();
378
379 // Formula-cell-only adjacency: precedent formula cell → dependent
380 // formula cell, with in-degrees for Kahn's algorithm. `BTreeSet` keeps
381 // each node's successors in canonical order and dedups parallel edges.
382 let mut succ: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); nodes.len()];
383 let mut indeg: Vec<usize> = vec![0; nodes.len()];
384 for (i, cell) in nodes.iter().enumerate() {
385 for prec in &self.precedents[*cell] {
386 for fp in self.formula_precedent_cells(prec) {
387 // Edge fp(j) → cell(i): cell i depends on fp.
388 if let Some(&j) = index_of.get(&fp) {
389 if succ[j].insert(i) {
390 indeg[i] += 1;
391 }
392 }
393 }
394 }
395 }
396
397 // Kahn's algorithm; the ready set is a BTreeSet of indices, which —
398 // because `nodes` is in canonical order — pops in canonical order, so
399 // the topological order is itself deterministic.
400 let mut ready: BTreeSet<usize> = (0..nodes.len()).filter(|&i| indeg[i] == 0).collect();
401 let mut order: Vec<CellRef> = Vec::with_capacity(nodes.len());
402 while let Some(&node) = ready.iter().next() {
403 ready.remove(&node);
404 order.push(nodes[node].clone());
405 for &dep in &succ[node] {
406 indeg[dep] -= 1;
407 if indeg[dep] == 0 {
408 ready.insert(dep);
409 }
410 }
411 }
412
413 if order.len() == nodes.len() {
414 Ok(order)
415 } else {
416 // Some cells never reached in-degree 0: they are on or downstream
417 // of a cycle. Report exactly the cells *on* a cycle.
418 Err(self.cycle_cells())
419 }
420 }
421
422 /// A topological order over the formula cells **not** on a cycle, for the
423 /// cyclic-graph case (P3.3): cells that do not transitively read the cycle
424 /// still evaluate in dependency order; cells on or downstream of the cycle
425 /// are omitted (the recalc engine gives them the circular error). When the
426 /// graph is acyclic this equals [`topological_order`](Self::topological_order).
427 ///
428 /// `cycle` must be the cycle set returned by
429 /// [`cycle_cells`](Self::cycle_cells) (passed in so the caller computes it
430 /// once). The order is deterministic (canonical tie-breaking), matching
431 /// `topological_order`'s discipline.
432 pub fn acyclic_order_excluding(&self, cycle: &BTreeSet<CellRef>) -> Vec<CellRef> {
433 let nodes: Vec<&CellRef> = self
434 .precedents
435 .keys()
436 .filter(|c| !cycle.contains(*c))
437 .collect();
438 let index_of: HashMap<&CellRef, usize> =
439 nodes.iter().enumerate().map(|(i, n)| (*n, i)).collect();
440
441 // Edges among non-cycle formula cells only (a precedent that is a cycle
442 // cell, a literal, or empty contributes no edge — a cell downstream of
443 // the cycle is then simply never reached and stays omitted).
444 let mut succ: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); nodes.len()];
445 let mut indeg: Vec<usize> = vec![0; nodes.len()];
446 for (i, cell) in nodes.iter().enumerate() {
447 for prec in &self.precedents[*cell] {
448 for fp in self.formula_precedent_cells(prec) {
449 if cycle.contains(&fp) {
450 // Downstream of the cycle: drop this node entirely so it
451 // is never placed (it takes the circular error).
452 continue;
453 }
454 if let Some(&j) = index_of.get(&fp) {
455 if succ[j].insert(i) {
456 indeg[i] += 1;
457 }
458 }
459 }
460 }
461 }
462 // A node that reads the cycle must be excluded from the order even
463 // though it has in-degree 0 over the surviving edges. Mark such nodes.
464 let mut tainted = vec![false; nodes.len()];
465 for (i, cell) in nodes.iter().enumerate() {
466 for prec in &self.precedents[*cell] {
467 for fp in self.formula_precedent_cells(prec) {
468 if cycle.contains(&fp) {
469 tainted[i] = true;
470 }
471 }
472 }
473 }
474
475 let mut ready: BTreeSet<usize> = (0..nodes.len())
476 .filter(|&i| indeg[i] == 0 && !tainted[i])
477 .collect();
478 let mut order: Vec<CellRef> = Vec::new();
479 while let Some(&node) = ready.iter().next() {
480 ready.remove(&node);
481 order.push(nodes[node].clone());
482 for &dep in &succ[node] {
483 indeg[dep] -= 1;
484 if indeg[dep] == 0 && !tainted[dep] {
485 ready.insert(dep);
486 }
487 }
488 }
489 order
490 }
491
492 /// Every formula cell that lies on a dependency cycle (a strongly connected
493 /// component of size > 1, or a self-referential cell).
494 ///
495 /// This is the set P3.3 marks with the Sheets circular-dependency error.
496 /// Empty iff the formula-cell subgraph is acyclic. Computed independently
497 /// of [`topological_order`](Self::topological_order) so it can be queried
498 /// directly.
499 pub fn cycle_cells(&self) -> BTreeSet<CellRef> {
500 // Tarjan's SCC over the formula-cell-only graph.
501 let nodes: Vec<CellRef> = self.precedents.keys().cloned().collect();
502 let index_of: HashMap<&CellRef, usize> =
503 nodes.iter().enumerate().map(|(i, n)| (n, i)).collect();
504
505 // Successor list (precedent formula cell → dependent formula cell),
506 // matching topological_order's edge direction. Self loops are kept so a
507 // self-referential formula registers as its own cycle.
508 let mut adj: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); nodes.len()];
509 for (i, cell) in nodes.iter().enumerate() {
510 for prec in &self.precedents[cell] {
511 for fp in self.formula_precedent_cells(prec) {
512 if let Some(&j) = index_of.get(&fp) {
513 // Edge fp(j) → cell(i).
514 adj[j].insert(i);
515 }
516 }
517 }
518 }
519
520 TarjanScc::new(&adj).cycle_members(&nodes)
521 }
522
523 /// Maps a precedent to the *formula* cells it covers (its intersection with
524 /// the graph's formula-cell set), following name indirection. Literal and
525 /// empty cells are not yielded — only edges between formula cells matter for
526 /// ordering and cycles.
527 fn formula_precedent_cells(&self, prec: &Precedent) -> Vec<CellRef> {
528 match prec {
529 Precedent::Cell(c) => {
530 if self.precedents.contains_key(c) {
531 vec![c.clone()]
532 } else {
533 Vec::new()
534 }
535 }
536 Precedent::Range(r) => self
537 .precedents
538 .keys()
539 .filter(|c| r.contains(c))
540 .cloned()
541 .collect(),
542 Precedent::Name(name) => match self.name_targets.get(name) {
543 Some(NameTarget::Cell(c)) if self.precedents.contains_key(c) => vec![c.clone()],
544 Some(NameTarget::Cell(_)) | None => Vec::new(),
545 Some(NameTarget::Range(r)) => self
546 .precedents
547 .keys()
548 .filter(|c| r.contains(c))
549 .cloned()
550 .collect(),
551 },
552 Precedent::Unresolved(_) => Vec::new(),
553 }
554 }
555}
556
557/// Resolves a single parsed [`Ref`] against the workbook, relative to the
558/// formula's own (folded) sheet for bare references.
559fn resolve_ref(
560 r: &Ref,
561 own_sheet: &str,
562 folder: &CaseMapperBorrowed<'static>,
563 workbook: &Workbook,
564) -> Precedent {
565 match r {
566 Ref::Cell { sheet, addr } => {
567 let sheet_folded = match sheet {
568 None => own_sheet.to_owned(),
569 Some(name) => match workbook.sheet(name) {
570 Some(_) => simple_fold(folder, name),
571 // `relative_display` (not `to_string`) so a missing-sheet
572 // reference reached via `$A$1` dedupes with one reached
573 // via `A1` — `$` anchors don't change what's unresolved.
574 None => return Precedent::Unresolved(r.relative_display()),
575 },
576 };
577 match to_address(addr) {
578 Some(a) => Precedent::Cell(CellRef::new(sheet_folded, a)),
579 None => Precedent::Unresolved(r.relative_display()),
580 }
581 }
582 Ref::Range { sheet, start, end } => {
583 let sheet_folded = match sheet {
584 None => own_sheet.to_owned(),
585 Some(name) => match workbook.sheet(name) {
586 Some(_) => simple_fold(folder, name),
587 None => return Precedent::Unresolved(r.relative_display()),
588 },
589 };
590 match normalize_range(start, end) {
591 Some((s, e)) => Precedent::Range(RangeRef {
592 sheet: sheet_folded,
593 start: s,
594 end: e,
595 }),
596 None => Precedent::Unresolved(r.relative_display()),
597 }
598 }
599 Ref::Name(name) => {
600 let folded = simple_fold(folder, name);
601 // A name is a precedent only if the workbook actually defines it;
602 // an unknown bare identifier is an unresolved reference (a `#NAME?`
603 // in Sheets), not a phantom name node.
604 if workbook
605 .names()
606 .iter()
607 .any(|nr| simple_fold(folder, &nr.name) == folded)
608 {
609 Precedent::Name(folded)
610 } else {
611 Precedent::Unresolved(name.clone())
612 }
613 }
614 }
615}
616
617/// Resolves a named range's canonical `ref` string to its concrete target,
618/// requiring the target sheet to exist. Returns `None` when the ref is
619/// malformed or names a missing sheet (a dangling name has no target).
620fn resolve_name_ref(
621 r: &str,
622 folder: &CaseMapperBorrowed<'static>,
623 workbook: &Workbook,
624) -> Option<NameTarget> {
625 let parsed = named_ref::parse_canonical_ref(r).ok()?;
626 // The ref's sheet must exist; key the target by its folded name.
627 let sheet = workbook.sheet(&parsed.sheet)?;
628 let sheet_folded = simple_fold(folder, sheet.name());
629
630 // Recover the A1 part (parse_canonical_ref already validated it).
631 let a1_part = r.rsplit_once('!').map(|(_, a)| a).unwrap_or(r);
632 match a1_part.split_once(':') {
633 None => {
634 let addr = Address::from_a1(a1_part)?;
635 Some(NameTarget::Cell(CellRef::new(sheet_folded, addr)))
636 }
637 Some((s, e)) => {
638 let start = Address::from_a1(s)?;
639 let end = Address::from_a1(e)?;
640 Some(NameTarget::Range(RangeRef {
641 sheet: sheet_folded,
642 start,
643 end,
644 }))
645 }
646 }
647}
648
649/// Converts a core [`CellAddr`] (no upper bound of its own) to a workbook
650/// [`Address`], enforcing the workbook's grid bounds. An out-of-bounds ref
651/// (legal to *parse*, but off the grid) resolves to `None` → `Unresolved`.
652fn to_address(addr: &CellAddr) -> Option<Address> {
653 Address::new(addr.row, addr.col)
654}
655
656/// Normalizes a parsed range to top-left-first, in-bounds corners. Returns
657/// `None` if either corner is off-grid.
658fn normalize_range(start: &CellAddr, end: &CellAddr) -> Option<(Address, Address)> {
659 let top = Address::new(start.row.min(end.row), start.col.min(end.col))?;
660 let bottom = Address::new(start.row.max(end.row), start.col.max(end.col))?;
661 Some((top, bottom))
662}
663
664/// Tarjan's strongly-connected-components, specialized to return the set of
665/// nodes that lie on a cycle (SCCs of size > 1, plus self loops). Iterative to
666/// avoid recursion blowup on deep dependency chains (the plan's ≥10k-deep
667/// benchmark).
668struct TarjanScc<'a> {
669 adj: &'a [BTreeSet<usize>],
670 index: Vec<Option<usize>>,
671 lowlink: Vec<usize>,
672 on_stack: Vec<bool>,
673 stack: Vec<usize>,
674 next_index: usize,
675 components: Vec<Vec<usize>>,
676}
677
678impl<'a> TarjanScc<'a> {
679 fn new(adj: &'a [BTreeSet<usize>]) -> Self {
680 let n = adj.len();
681 Self {
682 adj,
683 index: vec![None; n],
684 lowlink: vec![0; n],
685 on_stack: vec![false; n],
686 stack: Vec::new(),
687 next_index: 0,
688 components: Vec::new(),
689 }
690 }
691
692 fn cycle_members(mut self, nodes: &[CellRef]) -> BTreeSet<CellRef> {
693 for v in 0..self.adj.len() {
694 if self.index[v].is_none() {
695 self.strongconnect(v);
696 }
697 }
698 let mut out = BTreeSet::new();
699 for comp in &self.components {
700 let on_cycle = comp.len() > 1
701 // A singleton SCC is on a cycle only via a self loop.
702 || (comp.len() == 1 && self.adj[comp[0]].contains(&comp[0]));
703 if on_cycle {
704 for &i in comp {
705 out.insert(nodes[i].clone());
706 }
707 }
708 }
709 out
710 }
711
712 fn strongconnect(&mut self, v: usize) {
713 let mut call_stack: Vec<(usize, Vec<usize>)> =
714 vec![(v, self.adj[v].iter().copied().collect())];
715 self.index[v] = Some(self.next_index);
716 self.lowlink[v] = self.next_index;
717 self.next_index += 1;
718 self.stack.push(v);
719 self.on_stack[v] = true;
720
721 while let Some((node, successors)) = call_stack.last_mut() {
722 let node = *node;
723 if let Some(w) = successors.pop() {
724 if self.index[w].is_none() {
725 self.index[w] = Some(self.next_index);
726 self.lowlink[w] = self.next_index;
727 self.next_index += 1;
728 self.stack.push(w);
729 self.on_stack[w] = true;
730 call_stack.push((w, self.adj[w].iter().copied().collect()));
731 } else if self.on_stack[w] {
732 self.lowlink[node] = self.lowlink[node].min(self.index[w].unwrap());
733 }
734 } else {
735 // All successors processed: finalize this node.
736 if self.lowlink[node] == self.index[node].unwrap() {
737 let mut component = Vec::new();
738 loop {
739 let w = self.stack.pop().unwrap();
740 self.on_stack[w] = false;
741 component.push(w);
742 if w == node {
743 break;
744 }
745 }
746 self.components.push(component);
747 }
748 call_stack.pop();
749 if let Some((parent, _)) = call_stack.last() {
750 let parent = *parent;
751 self.lowlink[parent] = self.lowlink[parent].min(self.lowlink[node]);
752 }
753 }
754 }
755 }
756}