Skip to main content

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}