Skip to main content

formualizer_eval/engine/graph/
range_deps.rs

1use super::*;
2use crate::engine::used_extent::{ExtentPolicy, OpenRangeBounds, resolve_used_extent};
3use formualizer_common::LiteralValue;
4use formualizer_parse::parser::{ASTNode, ASTNodeType, ReferenceType};
5
6#[derive(Clone, Copy, PartialEq, Eq)]
7enum RangeSelfUse {
8    NoMatch,
9    Excluded,
10    IncludedOrUnknown,
11}
12
13impl RangeSelfUse {
14    fn merge(self, other: Self) -> Self {
15        match (self, other) {
16            (Self::IncludedOrUnknown, _) | (_, Self::IncludedOrUnknown) => Self::IncludedOrUnknown,
17            (Self::Excluded, _) | (_, Self::Excluded) => Self::Excluded,
18            _ => Self::NoMatch,
19        }
20    }
21}
22
23impl DependencyGraph {
24    pub(crate) fn compressed_range_dependents_intersecting_deleted_rows(
25        &self,
26        sheet_id: SheetId,
27        start_row: u32,
28        end_row: u32,
29    ) -> Vec<VertexId> {
30        self.formula_to_range_deps
31            .iter()
32            .filter_map(|(&dependent, ranges)| {
33                ranges
34                    .iter()
35                    .any(|range| {
36                        // `Current` is the dependent formula's own sheet. An
37                        // unresolvable sheet name keeps the dependent in the
38                        // candidate set (over-approximating is safe here;
39                        // dropping it would silently lose a dependent).
40                        let range_sheet_id = self
41                            .sheet_reg
42                            .resolve_locator(&range.sheet, self.get_vertex_sheet_id(dependent))
43                            .unwrap_or(sheet_id);
44                        let range_start = range.start_row.map(|bound| bound.index).unwrap_or(0);
45                        let range_end = range.end_row.map(|bound| bound.index).unwrap_or(u32::MAX);
46                        range_sheet_id == sheet_id
47                            && range_start <= end_row
48                            && range_end >= start_row
49                    })
50                    .then_some(dependent)
51            })
52            .collect()
53    }
54
55    pub(crate) fn compressed_range_dependents_intersecting_deleted_columns(
56        &self,
57        sheet_id: SheetId,
58        start_col: u32,
59        end_col: u32,
60    ) -> Vec<VertexId> {
61        self.formula_to_range_deps
62            .iter()
63            .filter_map(|(&dependent, ranges)| {
64                ranges
65                    .iter()
66                    .any(|range| {
67                        // See the row query above.
68                        let range_sheet_id = self
69                            .sheet_reg
70                            .resolve_locator(&range.sheet, self.get_vertex_sheet_id(dependent))
71                            .unwrap_or(sheet_id);
72                        let range_start = range.start_col.map(|bound| bound.index).unwrap_or(0);
73                        let range_end = range.end_col.map(|bound| bound.index).unwrap_or(u32::MAX);
74                        range_sheet_id == sheet_id
75                            && range_start <= end_col
76                            && range_end >= start_col
77                    })
78                    .then_some(dependent)
79            })
80            .collect()
81    }
82
83    /// Visit compressed-range formula dependents covering one cell without
84    /// materializing the stripe union used by dirty propagation.
85    ///
86    /// This path is intentionally parallel to
87    /// `collect_range_dependents_for_rect`: scheduling keeps its existing
88    /// behavior, while inspection can stop before a pathological stripe has
89    /// been copied into an unbounded candidate set. Work is charged for every
90    /// stripe candidate and every compressed range exact-check.
91    pub(crate) fn visit_range_dependents_covering_bounded(
92        &self,
93        sheet_id: SheetId,
94        row0: u32,
95        col0: u32,
96        remaining_work: &mut u64,
97        visitor: &mut dyn FnMut(VertexId) -> bool,
98    ) -> bool {
99        if self.stripe_to_dependents.is_empty() {
100            return true;
101        }
102
103        let mut seen = FxHashSet::default();
104        let keys = [
105            StripeKey {
106                sheet_id,
107                stripe_type: StripeType::Column,
108                index: col0,
109            },
110            StripeKey {
111                sheet_id,
112                stripe_type: StripeType::Row,
113                index: row0,
114            },
115            StripeKey {
116                sheet_id,
117                stripe_type: StripeType::Block,
118                index: block_index(row0, col0),
119            },
120        ];
121
122        for key in keys {
123            if key.stripe_type == StripeType::Block && !self.config.enable_block_stripes {
124                continue;
125            }
126            let Some(candidates) = self.stripe_to_dependents.get(&key) else {
127                continue;
128            };
129            for &dependent in candidates {
130                if *remaining_work == 0 {
131                    return false;
132                }
133                *remaining_work -= 1;
134                if !seen.insert(dependent) {
135                    continue;
136                }
137                let Some(ranges) = self.formula_to_range_deps.get(&dependent) else {
138                    continue;
139                };
140                let mut covered = false;
141                for range in ranges {
142                    if *remaining_work == 0 {
143                        return false;
144                    }
145                    *remaining_work -= 1;
146                    // `Current` is the dependent formula's own sheet; an
147                    // unresolvable sheet name is interpreted on the query sheet
148                    // so the dependent is not silently dropped.
149                    let range_sheet = self
150                        .sheet_reg
151                        .resolve_locator(&range.sheet, self.get_vertex_sheet_id(dependent))
152                        .unwrap_or(sheet_id);
153                    if range_sheet != sheet_id {
154                        continue;
155                    }
156                    let start_row = range.start_row.map(|bound| bound.index).unwrap_or(0);
157                    let end_row = range.end_row.map(|bound| bound.index).unwrap_or(u32::MAX);
158                    let start_col = range.start_col.map(|bound| bound.index).unwrap_or(0);
159                    let end_col = range.end_col.map(|bound| bound.index).unwrap_or(u32::MAX);
160                    if start_row <= row0 && row0 <= end_row && start_col <= col0 && col0 <= end_col
161                    {
162                        covered = true;
163                        break;
164                    }
165                }
166                if covered && !visitor(dependent) {
167                    return false;
168                }
169            }
170        }
171        true
172    }
173
174    /// Public wrapper to add range-dependent edges.
175    pub fn add_range_edges(
176        &mut self,
177        dependent: VertexId,
178        ranges: &[SharedRangeRef<'static>],
179        current_sheet_id: SheetId,
180    ) {
181        self.add_range_dependent_edges(dependent, ranges, current_sheet_id);
182    }
183
184    /// Return the compressed range dependencies recorded for a formula vertex, if any.
185    /// These are `SharedRangeRef` entries that were not expanded into explicit
186    /// cell edges due to `range_expansion_limit` or due to infinite/partial bounds.
187    pub fn get_range_dependencies(
188        &self,
189        vertex: VertexId,
190    ) -> Option<&Vec<SharedRangeRef<'static>>> {
191        self.formula_to_range_deps.get(&vertex)
192    }
193
194    #[cfg(test)]
195    pub(crate) fn formula_to_range_deps(
196        &self,
197    ) -> &FxHashMap<VertexId, Vec<SharedRangeRef<'static>>> {
198        &self.formula_to_range_deps
199    }
200
201    #[cfg(test)]
202    pub(crate) fn stripe_to_dependents(&self) -> &FxHashMap<StripeKey, FxHashSet<VertexId>> {
203        &self.stripe_to_dependents
204    }
205
206    /// True when a (possibly open-ended) range region on `sheet_id` covers
207    /// the formula vertex's own cell. Used to record a self-loop for
208    /// stripe-compressed / whole-axis self-inclusion (#120): such references
209    /// never produce explicit cell edges, so the ingest self-reference check
210    /// (which scans expanded cell deps) misses them. `None` bounds mean the
211    /// axis is unbounded (whole column/row), which always covers the cell.
212    fn range_region_contains_self(
213        &self,
214        dependent: VertexId,
215        sheet_id: SheetId,
216        s_row: Option<u32>,
217        e_row: Option<u32>,
218        s_col: Option<u32>,
219        e_col: Option<u32>,
220    ) -> bool {
221        if self.store.sheet_id(dependent) != sheet_id {
222            return false;
223        }
224        let coord = self.store.coord(dependent);
225        let r0 = coord.row();
226        let c0 = coord.col();
227        s_row.is_none_or(|s| r0 >= s)
228            && e_row.is_none_or(|e| r0 <= e)
229            && s_col.is_none_or(|s| c0 >= s)
230            && e_col.is_none_or(|e| c0 <= e)
231    }
232
233    /// Record a self-loop edge (vertex → itself). The edge store and Tarjan
234    /// both treat self-loops as cycles (`separate_cycles` via `has_self_loop`).
235    fn record_self_loop(&mut self, vertex: VertexId) {
236        if !self.has_self_loop(vertex) {
237            self.edges.add_edge(vertex, vertex);
238        }
239    }
240
241    pub(crate) fn compressed_range_resolved_bounds(
242        &self,
243        sheet: SheetId,
244        range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
245    ) -> Option<(u32, u32, u32, u32)> {
246        let (start_row, end_row, start_col, end_col) = range;
247        let extent = resolve_used_extent(
248            OpenRangeBounds {
249                start_row,
250                start_column: start_col,
251                end_row,
252                end_column: end_col,
253            },
254            ExtentPolicy::GraphCompat {
255                fallback_row: self.config.max_open_ended_rows.saturating_sub(1),
256                fallback_column: self.config.max_open_ended_cols.saturating_sub(1),
257            },
258            |first, last| self.used_row_bounds_for_columns(sheet, first, last),
259            |first, last| self.used_col_bounds_for_rows(sheet, first, last),
260        )?;
261        Some((
262            extent.start_row,
263            extent.end_row,
264            extent.start_column,
265            extent.end_column,
266        ))
267    }
268
269    /// Classify whether every occurrence of one compressed range that covers
270    /// the formula cell is narrowed away from that cell by a statically
271    /// resolvable `INDEX`. The range dependency itself remains conservative so
272    /// used-bound growth still invalidates the formula; only the synthetic #120
273    /// self-loop is omitted when the selected reference cannot contain the
274    /// formula cell.
275    fn compressed_range_self_use(
276        &self,
277        dependent: VertexId,
278        range_sheet: SheetId,
279        range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
280    ) -> RangeSelfUse {
281        let Some(ast) = self.get_formula(dependent) else {
282            return RangeSelfUse::IncludedOrUnknown;
283        };
284
285        fn static_index(node: &ASTNode) -> Option<i64> {
286            match &node.node_type {
287                ASTNodeType::Literal(LiteralValue::Int(value)) => Some(*value),
288                ASTNodeType::Literal(LiteralValue::Number(value)) if value.is_finite() => {
289                    Some(*value as i64)
290                }
291                ASTNodeType::UnaryOp { op, expr } if op == "+" => static_index(expr),
292                ASTNodeType::UnaryOp { op, expr } if op == "-" => static_index(expr)?.checked_neg(),
293                _ => None,
294            }
295        }
296
297        fn matching_range(
298            graph: &DependencyGraph,
299            node: &ASTNode,
300            dependent: VertexId,
301            range_sheet: SheetId,
302            range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
303        ) -> bool {
304            let ASTNodeType::Reference {
305                reference:
306                    ReferenceType::Range {
307                        sheet,
308                        start_row,
309                        start_col,
310                        end_row,
311                        end_col,
312                        ..
313                    },
314                ..
315            } = &node.node_type
316            else {
317                return false;
318            };
319            let sheet_id = match sheet.as_deref() {
320                Some(name) => match graph.sheet_id(name) {
321                    Some(id) => id,
322                    None => return false,
323                },
324                None => graph.get_vertex_sheet_id(dependent),
325            };
326            sheet_id == range_sheet
327                && start_row.map(|index| index.saturating_sub(1)) == range.0
328                && end_row.map(|index| index.saturating_sub(1)) == range.1
329                && start_col.map(|index| index.saturating_sub(1)) == range.2
330                && end_col.map(|index| index.saturating_sub(1)) == range.3
331        }
332
333        fn selected_region_contains_self(
334            graph: &DependencyGraph,
335            dependent: VertexId,
336            range_sheet: SheetId,
337            range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
338            position: i64,
339            explicit_col: Option<i64>,
340        ) -> Option<bool> {
341            let (sr, er, sc, ec) = graph.compressed_range_resolved_bounds(range_sheet, range)?;
342            let (row, col) = match explicit_col {
343                Some(col) => (position, col),
344                None if sr == er => (1, position),
345                None => (position, 1),
346            };
347            if row < 0 || col < 0 {
348                return Some(false);
349            }
350            let coord = graph.store.coord(dependent);
351            let contains = if row == 0 && col == 0 {
352                coord.row() >= sr && coord.row() <= er && coord.col() >= sc && coord.col() <= ec
353            } else if col == 0 {
354                let selected_row = sr.checked_add(u32::try_from(row).ok()?.saturating_sub(1))?;
355                selected_row <= er
356                    && coord.row() == selected_row
357                    && coord.col() >= sc
358                    && coord.col() <= ec
359            } else if row == 0 {
360                let selected_col = sc.checked_add(u32::try_from(col).ok()?.saturating_sub(1))?;
361                selected_col <= ec
362                    && coord.col() == selected_col
363                    && coord.row() >= sr
364                    && coord.row() <= er
365            } else {
366                let selected_row = sr.checked_add(u32::try_from(row).ok()?.saturating_sub(1))?;
367                let selected_col = sc.checked_add(u32::try_from(col).ok()?.saturating_sub(1))?;
368                selected_row <= er
369                    && selected_col <= ec
370                    && coord.row() == selected_row
371                    && coord.col() == selected_col
372            };
373            Some(contains)
374        }
375
376        fn visit(
377            graph: &DependencyGraph,
378            node: &ASTNode,
379            dependent: VertexId,
380            range_sheet: SheetId,
381            range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
382            index: Option<(i64, Option<i64>)>,
383        ) -> RangeSelfUse {
384            if matching_range(graph, node, dependent, range_sheet, range) {
385                return match index.and_then(|(row, col)| {
386                    selected_region_contains_self(graph, dependent, range_sheet, range, row, col)
387                }) {
388                    Some(false) => RangeSelfUse::Excluded,
389                    Some(true) | None => RangeSelfUse::IncludedOrUnknown,
390                };
391            }
392            match &node.node_type {
393                ASTNodeType::Function { name, args }
394                    if name.eq_ignore_ascii_case("INDEX") && (2..=3).contains(&args.len()) =>
395                {
396                    let row = static_index(&args[1]);
397                    let col = args.get(2).and_then(static_index);
398                    let selection = row.and_then(|row| {
399                        if args.len() == 2 || col.is_some() {
400                            Some((row, col))
401                        } else {
402                            None
403                        }
404                    });
405                    let mut use_kind =
406                        visit(graph, &args[0], dependent, range_sheet, range, selection);
407                    for arg in &args[1..] {
408                        use_kind =
409                            use_kind.merge(visit(graph, arg, dependent, range_sheet, range, None));
410                    }
411                    use_kind
412                }
413                ASTNodeType::Function { args, .. } => {
414                    args.iter().fold(RangeSelfUse::NoMatch, |kind, arg| {
415                        kind.merge(visit(graph, arg, dependent, range_sheet, range, None))
416                    })
417                }
418                ASTNodeType::UnaryOp { expr, .. } => {
419                    visit(graph, expr, dependent, range_sheet, range, None)
420                }
421                ASTNodeType::BinaryOp { left, right, .. } => visit(
422                    graph,
423                    left,
424                    dependent,
425                    range_sheet,
426                    range,
427                    None,
428                )
429                .merge(visit(graph, right, dependent, range_sheet, range, None)),
430                ASTNodeType::Call { callee, args } => {
431                    let mut kind = visit(graph, callee, dependent, range_sheet, range, None);
432                    for arg in args {
433                        kind = kind.merge(visit(graph, arg, dependent, range_sheet, range, None));
434                    }
435                    kind
436                }
437                ASTNodeType::Array(rows) => {
438                    rows.iter()
439                        .flatten()
440                        .fold(RangeSelfUse::NoMatch, |kind, item| {
441                            kind.merge(visit(graph, item, dependent, range_sheet, range, None))
442                        })
443                }
444                ASTNodeType::Literal(_) | ASTNodeType::Omitted | ASTNodeType::Reference { .. } => {
445                    RangeSelfUse::NoMatch
446                }
447            }
448        }
449
450        visit(self, &ast, dependent, range_sheet, range, None)
451    }
452
453    pub(super) fn add_range_dependent_edges(
454        &mut self,
455        dependent: VertexId,
456        ranges: &[SharedRangeRef<'static>],
457        current_sheet_id: SheetId,
458    ) {
459        if ranges.is_empty() {
460            return;
461        }
462
463        self.formula_to_range_deps
464            .insert(dependent, ranges.to_vec());
465
466        for range in ranges {
467            // `current_sheet_id` is the dependent formula's sheet, which is what
468            // `Current` means. An unresolvable sheet name falls back to it so a
469            // stripe is still registered rather than the edge being dropped.
470            let sheet_id = self
471                .sheet_reg
472                .resolve_locator(&range.sheet, current_sheet_id)
473                .unwrap_or(current_sheet_id);
474
475            let s_row = range.start_row.map(|b| b.index);
476            let e_row = range.end_row.map(|b| b.index);
477            let s_col = range.start_col.map(|b| b.index);
478            let e_col = range.end_col.map(|b| b.index);
479
480            // #120: a compressed range whose region covers this formula's own
481            // cell is a self-reference. Record a self-loop so SCC detection
482            // flags the cycle (the ingest self-ref check only sees expanded
483            // cell edges, which compressed ranges do not produce).
484            if self.range_region_contains_self(dependent, sheet_id, s_row, e_row, s_col, e_col)
485                && self.compressed_range_self_use(dependent, sheet_id, (s_row, e_row, s_col, e_col))
486                    != RangeSelfUse::Excluded
487            {
488                self.record_self_loop(dependent);
489            }
490
491            let col_stripes = (s_row.is_none() && e_row.is_none())
492                || (s_col.is_some() && e_col.is_some() && (s_row.is_none() || e_row.is_none()));
493            let row_stripes = (s_col.is_none() && e_col.is_none())
494                || (s_row.is_some() && e_row.is_some() && (s_col.is_none() || e_col.is_none()));
495
496            if col_stripes && !row_stripes {
497                let sc = s_col.unwrap_or(0);
498                let ec = e_col.unwrap_or(sc);
499                for col in sc..=ec {
500                    let key = StripeKey {
501                        sheet_id,
502                        stripe_type: StripeType::Column,
503                        index: col,
504                    };
505                    self.stripe_to_dependents
506                        .entry(key.clone())
507                        .or_default()
508                        .insert(dependent);
509                    #[cfg(test)]
510                    {
511                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
512                            && let Ok(mut g) = self.instr.lock()
513                        {
514                            g.stripe_inserts += 1;
515                        }
516                    }
517                }
518                continue;
519            }
520
521            if row_stripes && !col_stripes {
522                let sr = s_row.unwrap_or(0);
523                let er = e_row.unwrap_or(sr);
524                for row in sr..=er {
525                    let key = StripeKey {
526                        sheet_id,
527                        stripe_type: StripeType::Row,
528                        index: row,
529                    };
530                    self.stripe_to_dependents
531                        .entry(key.clone())
532                        .or_default()
533                        .insert(dependent);
534                    #[cfg(test)]
535                    {
536                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
537                            && let Ok(mut g) = self.instr.lock()
538                        {
539                            g.stripe_inserts += 1;
540                        }
541                    }
542                }
543                continue;
544            }
545
546            let start_row = s_row.unwrap_or(0);
547            let start_col = s_col.unwrap_or(0);
548            let end_row = e_row.unwrap_or(start_row);
549            let end_col = e_col.unwrap_or(start_col);
550
551            let height = end_row.saturating_sub(start_row) + 1;
552            let width = end_col.saturating_sub(start_col) + 1;
553
554            if self.config.enable_block_stripes && height > 1 && width > 1 {
555                let start_block_row = start_row / BLOCK_H;
556                let end_block_row = end_row / BLOCK_H;
557                let start_block_col = start_col / BLOCK_W;
558                let end_block_col = end_col / BLOCK_W;
559
560                for block_row in start_block_row..=end_block_row {
561                    for block_col in start_block_col..=end_block_col {
562                        let key = StripeKey {
563                            sheet_id,
564                            stripe_type: StripeType::Block,
565                            index: block_index(block_row * BLOCK_H, block_col * BLOCK_W),
566                        };
567                        self.stripe_to_dependents
568                            .entry(key.clone())
569                            .or_default()
570                            .insert(dependent);
571                        #[cfg(test)]
572                        {
573                            if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
574                                && let Ok(mut g) = self.instr.lock()
575                            {
576                                g.stripe_inserts += 1;
577                            }
578                        }
579                    }
580                }
581            } else if height > width {
582                for col in start_col..=end_col {
583                    let key = StripeKey {
584                        sheet_id,
585                        stripe_type: StripeType::Column,
586                        index: col,
587                    };
588                    self.stripe_to_dependents
589                        .entry(key.clone())
590                        .or_default()
591                        .insert(dependent);
592                    #[cfg(test)]
593                    {
594                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
595                            && let Ok(mut g) = self.instr.lock()
596                        {
597                            g.stripe_inserts += 1;
598                        }
599                    }
600                }
601            } else {
602                for row in start_row..=end_row {
603                    let key = StripeKey {
604                        sheet_id,
605                        stripe_type: StripeType::Row,
606                        index: row,
607                    };
608                    self.stripe_to_dependents
609                        .entry(key.clone())
610                        .or_default()
611                        .insert(dependent);
612                    #[cfg(test)]
613                    {
614                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
615                            && let Ok(mut g) = self.instr.lock()
616                        {
617                            g.stripe_inserts += 1;
618                        }
619                    }
620                }
621            }
622        }
623    }
624
625    /// Fast-path: add range dependencies using compact RangeKey.
626    pub fn add_range_deps_from_keys(
627        &mut self,
628        dependent: VertexId,
629        keys: &[crate::engine::plan::RangeKey],
630        current_sheet_id: SheetId,
631    ) {
632        use crate::engine::plan::RangeKey as RK;
633        if keys.is_empty() {
634            return;
635        }
636
637        let mut shared_ranges: Vec<SharedRangeRef<'static>> = Vec::with_capacity(keys.len());
638        for k in keys {
639            let sheet_loc = SharedSheetLocator::Id(match k {
640                RK::Rect { sheet, .. }
641                | RK::WholeRow { sheet, .. }
642                | RK::WholeCol { sheet, .. }
643                | RK::OpenRect { sheet, .. } => *sheet,
644            });
645
646            let mk_axis = |idx0: u32| formualizer_common::AxisBound::new(idx0, false);
647
648            let built = match k {
649                RK::Rect { start, end, .. } => {
650                    let sr = mk_axis(start.row());
651                    let sc = mk_axis(start.col());
652                    let er = mk_axis(end.row());
653                    let ec = mk_axis(end.col());
654                    SharedRangeRef::from_parts(sheet_loc, Some(sr), Some(sc), Some(er), Some(ec))
655                        .ok()
656                }
657                RK::WholeRow { row, .. } => {
658                    let r0 = row.saturating_sub(1);
659                    let b = mk_axis(r0);
660                    SharedRangeRef::from_parts(sheet_loc, Some(b), None, Some(b), None).ok()
661                }
662                RK::WholeCol { col, .. } => {
663                    let c0 = col.saturating_sub(1);
664                    let b = mk_axis(c0);
665                    SharedRangeRef::from_parts(sheet_loc, None, Some(b), None, Some(b)).ok()
666                }
667                RK::OpenRect { start, end, .. } => {
668                    let (sr, sc) = match start {
669                        Some(p) => (Some(mk_axis(p.row())), Some(mk_axis(p.col()))),
670                        None => (None, None),
671                    };
672                    let (er, ec) = match end {
673                        Some(p) => (Some(mk_axis(p.row())), Some(mk_axis(p.col()))),
674                        None => (None, None),
675                    };
676                    SharedRangeRef::from_parts(sheet_loc, sr, sc, er, ec).ok()
677                }
678            };
679
680            if let Some(r) = built {
681                shared_ranges.push(r.into_owned());
682            }
683        }
684
685        if shared_ranges.is_empty() {
686            return;
687        }
688
689        self.formula_to_range_deps
690            .insert(dependent, shared_ranges.clone());
691
692        for range in &shared_ranges {
693            // See add_range_dependent_edges.
694            let sheet_id = self
695                .sheet_reg
696                .resolve_locator(&range.sheet, current_sheet_id)
697                .unwrap_or(current_sheet_id);
698
699            let s_row = range.start_row.map(|b| b.index);
700            let e_row = range.end_row.map(|b| b.index);
701            let s_col = range.start_col.map(|b| b.index);
702            let e_col = range.end_col.map(|b| b.index);
703
704            // #120: see add_range_dependent_edges — compressed range covering
705            // the formula's own cell records a self-loop for SCC detection.
706            if self.range_region_contains_self(dependent, sheet_id, s_row, e_row, s_col, e_col)
707                && self.compressed_range_self_use(dependent, sheet_id, (s_row, e_row, s_col, e_col))
708                    != RangeSelfUse::Excluded
709            {
710                self.record_self_loop(dependent);
711            }
712
713            let col_stripes = (s_row.is_none() && e_row.is_none())
714                || (s_col.is_some() && e_col.is_some() && (s_row.is_none() || e_row.is_none()));
715            let row_stripes = (s_col.is_none() && e_col.is_none())
716                || (s_row.is_some() && e_row.is_some() && (s_col.is_none() || e_col.is_none()));
717
718            if col_stripes && !row_stripes {
719                let sc = s_col.unwrap_or(0);
720                let ec = e_col.unwrap_or(sc);
721                for col in sc..=ec {
722                    let key = StripeKey {
723                        sheet_id,
724                        stripe_type: StripeType::Column,
725                        index: col,
726                    };
727                    self.stripe_to_dependents
728                        .entry(key)
729                        .or_default()
730                        .insert(dependent);
731                }
732                continue;
733            }
734
735            if row_stripes && !col_stripes {
736                let sr = s_row.unwrap_or(0);
737                let er = e_row.unwrap_or(sr);
738                for row in sr..=er {
739                    let key = StripeKey {
740                        sheet_id,
741                        stripe_type: StripeType::Row,
742                        index: row,
743                    };
744                    self.stripe_to_dependents
745                        .entry(key)
746                        .or_default()
747                        .insert(dependent);
748                }
749                continue;
750            }
751
752            let start_row = s_row.unwrap_or(0);
753            let start_col = s_col.unwrap_or(0);
754            let end_row = e_row.unwrap_or(start_row);
755            let end_col = e_col.unwrap_or(start_col);
756
757            let height = end_row.saturating_sub(start_row) + 1;
758            let width = end_col.saturating_sub(start_col) + 1;
759
760            if self.config.enable_block_stripes && height > 1 && width > 1 {
761                let start_block_row = start_row / BLOCK_H;
762                let end_block_row = end_row / BLOCK_H;
763                let start_block_col = start_col / BLOCK_W;
764                let end_block_col = end_col / BLOCK_W;
765
766                for block_row in start_block_row..=end_block_row {
767                    for block_col in start_block_col..=end_block_col {
768                        let key = StripeKey {
769                            sheet_id,
770                            stripe_type: StripeType::Block,
771                            index: block_index(block_row * BLOCK_H, block_col * BLOCK_W),
772                        };
773                        self.stripe_to_dependents
774                            .entry(key)
775                            .or_default()
776                            .insert(dependent);
777                    }
778                }
779            } else if height > width {
780                for col in start_col..=end_col {
781                    let key = StripeKey {
782                        sheet_id,
783                        stripe_type: StripeType::Column,
784                        index: col,
785                    };
786                    self.stripe_to_dependents
787                        .entry(key)
788                        .or_default()
789                        .insert(dependent);
790                }
791            } else {
792                for row in start_row..=end_row {
793                    let key = StripeKey {
794                        sheet_id,
795                        stripe_type: StripeType::Row,
796                        index: row,
797                    };
798                    self.stripe_to_dependents
799                        .entry(key)
800                        .or_default()
801                        .insert(dependent);
802                }
803            }
804        }
805    }
806}