Compare commits

..
6 changed files with 321 additions and 142 deletions
+143 -8
View File
@@ -39,6 +39,15 @@ const MAX_DEPTH: usize = 400;
/// every legitimate call; hostile work that grows past one dimension (a LAMBDA that recurses
/// or nest `MAP` into `MAP`) then stops at the same bound instead of squaring.
const LAMBDA_FUEL: u64 = MAX_CELLS;
/// Work one cell's formula may do: one unit per expression evaluated, per cell read from a
/// reference, per array value handed to a function, per pair of areas intersected and per
/// character of a defined name's formula parsed where the name is used. Each limit
/// above bounds one kind of step, but a compact formula could still repeat bounded steps
/// exponentially often (defined names that each use the one before twice, a computed criterion
/// whose database function reruns it, an aggregate over a large array inside every LAMBDA call).
/// Past this the formula is `#CALC!`. It is far above what a formula that reads every cell of a
/// full-size array a few times needs.
const EVAL_WORK: u64 = 16 * MAX_CELLS;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Area {
@@ -139,6 +148,12 @@ pub struct Evaluator<'h> {
/// runs out of work after the same number of calls that a legitimately wide one could
/// make, instead of an amount of work that squares with array nesting (#2382).
fuel: u64,
/// Work this cell still has left (see [`EVAL_WORK`]), shared by every branch of the formula
/// and by the formulas it computes on the way (names, computed criteria).
work: u64,
/// Computed criteria being evaluated, outermost first: one that is reached again from inside
/// itself counts as not met instead of repeating its work at every level.
active_criteria: Vec<(usize, CellRef)>,
}
struct FnCtx<'a, 'h> {
@@ -168,6 +183,9 @@ impl Ctx for FnCtx<'_, '_> {
fn computed_criterion(&mut self, row: usize, col: usize, db: &Array, record: usize) -> Option<bool> {
let area = self.criteria?;
let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?;
if self.ev.active_criteria.contains(&(area.sheet, at)) {
return Some(false);
}
let expr = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?.expr_arc()?;
let shifted = gridcraft_formula::adjust::shift_relative((*expr).clone(), record as i64 - 1, 0);
let env = (0..db.cols)
@@ -187,10 +205,17 @@ impl Ctx for FnCtx<'_, '_> {
calls: self.ev.calls,
stack_top: self.ev.stack_top,
fuel: self.ev.fuel,
work: self.ev.work,
active_criteria: std::mem::take(&mut self.ev.active_criteria),
};
ev.active_criteria.push((area.sheet, at));
let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true));
// The child's spend counts against this cell's budget.
self.ev.fuel = ev.fuel;
self.ev.work = ev.work;
let mut active = std::mem::take(&mut ev.active_criteria);
active.pop();
self.ev.active_criteria = active;
Some(v)
}
fn has_lambda(&self) -> bool {
@@ -210,7 +235,40 @@ impl Ctx for FnCtx<'_, '_> {
impl<'h> Evaluator<'h> {
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0, fuel: LAMBDA_FUEL }
Evaluator {
host,
sheet,
at,
env: Vec::new(),
depth: 0,
calls: 0,
stack_top: 0,
fuel: LAMBDA_FUEL,
work: EVAL_WORK,
active_criteria: Vec::new(),
}
}
/// Spends `units` of this cell's work; `false` (and nothing left) when there isn't enough.
pub(crate) fn spend(&mut self, units: u64) -> bool {
match self.work.checked_sub(units) {
Some(w) => {
self.work = w;
true
}
None => {
self.work = 0;
false
}
}
}
/// Charges the values of an array (one unit per value; one for anything else).
pub(crate) fn spend_on(&mut self, v: &Value) -> bool {
match v {
Value::Array(a) => self.spend(a.data.len() as u64),
_ => self.spend(1),
}
}
/// Evaluates to a value (references are dereferenced; multi-cell → array).
@@ -260,18 +318,27 @@ impl<'h> Evaluator<'h> {
}
// A reference keeps its size, so the array has its shape and its blanks count
// (`=A1:A10`, `SUMPRODUCT(--(A1:A10=""))`).
let range = self.read_range(a);
let (h, w) = (range.height() as usize, range.width() as usize);
if (h as u64) * (w as u64) > MAX_CELLS {
return Value::Error(CellError::Num);
}
if !self.spend((h as u64) * (w as u64)) {
return Value::Error(CellError::Calc);
}
let data = self.host.range_values(a.sheet, range);
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
}
/// The block that reading `a` gives: `a` itself, or when it is large (or runs to the sheet's
/// edge) the part inside the sheet's used range.
pub(crate) fn read_range(&mut self, a: &Area) -> RangeRef {
let cells = u64::from(a.range.height()) * u64::from(a.range.width());
// `A:A`, `1:1` and `A2:A1048576` run to the edge of the sheet: they stand for "the rest of
// the column", not for a block of a chosen size.
let to_edge = a.range.end.row == MAX_ROWS - 1 || a.range.end.col == MAX_COLS - 1;
let full_size = cells <= FULL_SIZE_CELLS || (!to_edge && cells <= FULL_SIZE_BOUNDED_CELLS);
let range = if full_size { a.range } else { self.trim(a) };
let (h, w) = (range.height() as usize, range.width() as usize);
if (h as u64) * (w as u64) > MAX_CELLS {
return Value::Error(CellError::Num);
}
let data = self.host.range_values(a.sheet, range);
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
if full_size { a.range } else { self.trim(a) }
}
/// Trims the bottom/right of an area to the sheet's used range (keeps the top-left so
@@ -304,6 +371,9 @@ impl<'h> Evaluator<'h> {
self.host.stack_exhausted();
return err(CellError::Num);
}
if !self.spend(1) {
return err(CellError::Calc);
}
self.depth += 1;
if self.depth > MAX_DEPTH {
self.depth -= 1;
@@ -370,6 +440,11 @@ impl<'h> Evaluator<'h> {
if let Some(def) = wb.name(bare, scope_sheet) {
let text = def.formula.clone();
let scope = def.scope.unwrap_or(self.sheet);
// Each use parses and evaluates the definition again: names that each use the one
// before twice double the work per name, so it is charged.
if !self.spend(text.len() as u64) {
return err(CellError::Calc);
}
return match gridcraft_formula::parse_name(&text) {
Ok(expr) => {
// Relative references in a name are relative to A1: used here, they move by
@@ -562,6 +637,10 @@ impl<'h> Evaluator<'h> {
let (x, y) = (self.eval(a), self.eval(b));
match (x, y) {
(Ev::R(mut xa), Ev::R(ya)) => {
// A union reused through LET doubles at each step: its areas count.
if !self.spend((xa.len() as u64).saturating_add(ya.len() as u64)) {
return err(CellError::Calc);
}
xa.extend(ya);
Ev::R(xa)
}
@@ -573,6 +652,11 @@ impl<'h> Evaluator<'h> {
let (x, y) = (self.eval(a), self.eval(b));
match (x, y) {
(Ev::R(xa), Ev::R(ya)) => {
// Every pair is looked at, and each may be kept: charged before the loop,
// so unions squared through LET can't build an enormous list.
if !self.spend((xa.len() as u64).saturating_mul(ya.len() as u64)) {
return err(CellError::Calc);
}
let mut out = Vec::new();
for p in &xa {
for q in &ya {
@@ -666,6 +750,57 @@ mod fuel_tests {
ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")))
}
/// Evaluates `src` in `wb` with `work` units of work, and the work left.
fn eval_work(wb: Workbook, work: u64, src: &str) -> (Value, u64) {
let mut host = Empty { wb };
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
ev.work = work;
let v = ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")));
(v, ev.work)
}
fn is_calc(v: &Value) -> bool {
matches!(v, Value::Error(CellError::Calc))
}
#[test]
fn names_that_double_at_each_step_run_out_of_work() {
// Step1 = Step0+Step0, Step2 = Step1+Step1, ...: Step60 would evaluate Step0 2^60 times.
let mut wb = Workbook::default();
let name = |name: String, formula: String| gridcraft_model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
wb.names.push(name("Step0".into(), "1".into()));
for i in 1..=60 {
wb.names.push(name(format!("Step{i}"), format!("Step{}+Step{}", i - 1, i - 1)));
}
let (v, _) = eval_work(wb.clone(), EVAL_WORK, "=Step5");
assert_eq!(v, Value::number(32.0));
let (v, left) = eval_work(wb, 100_000, "=Step60");
assert!(is_calc(&v), "{v:?}");
assert_eq!(left, 0);
}
#[test]
fn intersections_of_unions_are_charged_per_pair() {
let wb = Workbook::default();
// 4 x 4 pairs, 4 of which meet.
let src = "=AREAS((A1,B1,C1,D1) (A1,B1,C1,D1))";
let (v, _) = eval_work(wb.clone(), EVAL_WORK, src);
assert_eq!(v, Value::number(4.0));
let (v, _) = eval_work(wb, 20, src);
assert!(is_calc(&v), "{v:?}");
}
#[test]
fn an_aggregate_inside_every_lambda_call_spends_work() {
let wb = Workbook::default();
// 100 calls, each summing the 10,000 values of a captured array: about a million units.
let src = "=LET(big,SEQUENCE(10000),SUM(MAP(SEQUENCE(100),LAMBDA(x,SUM(big)))))";
let (v, _) = eval_work(wb.clone(), 2_000_000, src);
assert_eq!(v, Value::number(100.0 * 50_005_000.0));
let (v, _) = eval_work(wb, 200_000, src);
assert!(is_calc(&v), "{v:?}");
}
#[test]
fn lambda_calls_spend_fuel_and_run_out_quietly() {
// Three applications, one charge each: 3 inside the budget. (A nested MAP wouldn't do
+15
View File
@@ -353,6 +353,12 @@ impl<'h> Evaluator<'h> {
};
evaluated.push(Arg { value, from_ref });
}
// The function may go through every value it is given (a SUM inside each call of a
// LAMBDA over a large array): the values count against the cell's work.
let values = evaluated.iter().fold(0u64, |n, a| n.saturating_add(if let Value::Array(x) = &a.value { x.data.len() as u64 } else { 1 }));
if !self.spend(values) {
return err(CellError::Calc);
}
let mut ctx = FnCtx { ev: self, criteria, lambda };
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
}
@@ -383,6 +389,12 @@ impl<'h> Evaluator<'h> {
/// Adds one area to `acc`; `Err` is the result the function ends with.
fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> {
if !a.range.is_single() {
// Folded without building an array, but still walked: the part inside the used
// range counts against the cell's work.
let r = self.read_range(a);
if !self.spend(u64::from(r.height()) * u64::from(r.width())) {
return Err(err(CellError::Calc));
}
let mut error = None;
let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) {
Ok(()) => true,
@@ -402,6 +414,9 @@ impl<'h> Evaluator<'h> {
/// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules
/// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`).
fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> {
if !from_ref && !self.spend_on(&value) {
return Err(err(CellError::Calc));
}
let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else {
return Err(err(CellError::Name));
};
+99 -3
View File
@@ -118,11 +118,29 @@ impl<'h> Evaluator<'h> {
for c in 0..w {
let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c))));
let v = match pos {
// A position that picks several cells is #CALC!. That is decided from the
// shape: reading the block first, for every position, let a short formula
// read one large range over and over only to discard it.
(Ok(row), Ok(col)) if index_picks_several_values(&src, row, col, two) => Value::Error(CellError::Calc),
(Ok(row), Ok(col)) => {
let one = self.index_one(src.clone(), row, col, area_n, two);
match self.deref(one) {
Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc),
v => v.scalar(),
let cells = match &one {
Ev::R(areas) => match areas.as_slice() {
[a] => self.area_cells(a),
_ => 0,
},
_ => 0,
};
if cells > MAX_CELLS {
// As reading it would answer.
Value::Error(CellError::Num)
} else if cells > 1 {
Value::Error(CellError::Calc)
} else {
match self.deref(one) {
Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc),
v => v.scalar(),
}
}
}
(Err(e), _) | (_, Err(e)) => Value::Error(e),
@@ -139,6 +157,16 @@ impl<'h> Evaluator<'h> {
self.index_one(src, row, col, area_n, two)
}
/// Cells that reading `a` gives: its own size, or for a large block the part inside the
/// sheet's used range (what [`Evaluator::area_value`] reads).
fn area_cells(&mut self, a: &Area) -> u64 {
if a.range.is_single() {
return 1;
}
let r = self.read_range(a);
u64::from(r.height()) * u64::from(r.width())
}
/// INDEX for one row and column position (0 = the whole column or row).
fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev {
if row < 0 || col < 0 {
@@ -407,3 +435,71 @@ impl<'h> Evaluator<'h> {
Ev::V(gridcraft_functions::aggregate_values(func, &values))
}
}
/// Whether INDEX on an array picks a whole row or column of several values at position
/// (`row`, `col`), which the array-of-positions form answers with #CALC!.
fn index_picks_several_values(src: &Ev, row: i64, col: i64, two_args: bool) -> bool {
let Ev::V(Value::Array(arr)) = src else { return false };
let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) };
if row < 0 || col < 0 || row as u64 > arr.rows as u64 || col as u64 > arr.cols as u64 {
return false;
}
(row == 0 && arr.rows > 1) || (col == 0 && arr.cols > 1)
}
#[cfg(test)]
mod tests {
use super::super::Host;
use super::*;
use gridcraft_model::Workbook;
/// A sheet of blanks that counts the blocks it is asked for.
struct Counting {
wb: Workbook,
cells_read: usize,
}
impl Host for Counting {
fn workbook(&self) -> &Workbook {
&self.wb
}
fn cell_value(&mut self, _sheet: usize, _c: CellRef) -> Value {
Value::Empty
}
fn now_serial(&self) -> f64 {
0.0
}
fn random(&mut self) -> f64 {
0.0
}
fn range_values(&mut self, _sheet: usize, range: RangeRef) -> Vec<Value> {
self.cells_read += range.count() as usize;
vec![Value::Empty; range.count() as usize]
}
}
fn eval(src: &str) -> (Value, usize) {
let mut host = Counting { wb: Workbook::default(), cells_read: 0 };
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
let v = ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")));
(v, host.cells_read)
}
fn all_calc(v: &Value, n: usize) -> bool {
matches!(v, Value::Array(a) if a.data.len() == n && a.data.iter().all(|x| *x == Value::Error(CellError::Calc)))
}
#[test]
fn index_positions_that_pick_several_cells_are_calc_without_reading_them() {
// Each zero row position picks the whole of A1:A50000: #CALC!, decided from the shape. It
// used to read the 50,000 cells once per position before discarding them.
let (v, read) = eval("=INDEX(A1:A50000,{0,0,0,0},1)");
assert!(all_calc(&v, 4), "{v:?}");
assert_eq!(read, 0);
// The same for an array source: a whole column of a 3x3 array.
let (v, _) = eval("=INDEX({1,2,3;4,5,6;7,8,9},{0,0},2)");
assert!(all_calc(&v, 2), "{v:?}");
// Single cells are still read.
let (v, _) = eval("=INDEX({1,2,3;4,5,6;7,8,9},{1,3},2)");
assert!(matches!(&v, Value::Array(a) if a.data == vec![Value::number(2.0), Value::number(8.0)]), "{v:?}");
}
}
+16
View File
@@ -2372,3 +2372,19 @@ fn formulas_read_a_dirty_python_cell_after_it_is_laid_out() {
assert_eq!((t.num("B1"), t.num("B2")), (5.0, 6.0));
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (5.0, 100.0, 111.0));
}
/// A computed criterion whose formula runs a database function over the same criteria counts as
/// not met inside itself: before, each record's check ran the function again for every record, at
/// every level, so the work grew exponentially with the nesting allowed.
#[test]
fn computed_criterion_that_reenters_itself_is_not_met() {
let mut t = T::new();
for (r, (name, age)) in [("name", "age"), ("a", "1"), ("b", "2"), ("c", "3"), ("d", "4"), ("e", "5")].iter().enumerate() {
t.set(&format!("F{}", r + 2), name);
t.set(&format!("G{}", r + 2), age);
}
t.set("N46", "Formula");
t.set("N47", "=DCOUNT($F$2:$G$7,\"age\",$N$46:$N$47)>0");
t.set("B60", "=DCOUNT($F$2:$G$7,\"age\",N46:N47)");
assert_eq!(t.num("B60"), 0.0);
}
+48 -109
View File
@@ -17,13 +17,11 @@ pub(in super::super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
}
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
let d = s.doc()?;
let mut names = Resolver::new(&d.wb);
let mut target: Option<usize> = None;
let mut areas: Vec<RangeRef> = Vec::new();
let mut named = false;
for part in reference_parts(reference) {
let mut found = Vec::new();
let Some((sheet, _, by_name)) = names.target(d.wb.active_sheet(), part, 0, Some(&mut found)) else {
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else {
return Err(EngineError::Other("Reference isn't valid.".into()));
};
// One selection holds the areas of one sheet.
@@ -79,114 +77,57 @@ pub(super) fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
})
}
/// Resolves Go To references against one workbook. Defined names are found through an index
/// built once, and whether a name refers to a reference is remembered: listing the names of a
/// workbook full of names that alias other names would otherwise scan every name for every alias
/// of every name.
struct Resolver<'a> {
wb: &'a gridcraft_model::Workbook,
/// Indexes into `wb.names` by scope and folded name, in workbook order.
by_name: std::collections::HashMap<(Option<usize>, String), Vec<usize>>,
/// [`Resolver::name_areas`] without areas, by name index, sheet and depth: the target sheet
/// and the number of areas.
memo: std::collections::HashMap<(usize, usize, usize), Option<(usize, usize)>>,
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
return None;
}
let (prefix, body) = split_sheet(part);
let on = match &prefix {
Some(n) => wb.sheet_index(n)?,
None => sheet,
};
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
return Some((on, vec![r], false));
}
let name = match prefix {
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)),
None => wb.name(body, sheet),
};
if let Some(n) = name {
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
}
if prefix.is_none()
&& let Some((si, ti)) = wb.table(body)
{
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = wb.sheet(si)?.tables.get(ti)?;
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
}
None
}
impl<'a> Resolver<'a> {
fn new(wb: &'a gridcraft_model::Workbook) -> Self {
let mut by_name: std::collections::HashMap<(Option<usize>, String), Vec<usize>> = std::collections::HashMap::new();
for (i, n) in wb.names.iter().enumerate() {
by_name.entry((n.scope, gridcraft_formula::fold_name(&n.name))).or_default().push(i);
}
Self { wb, by_name, memo: std::collections::HashMap::new() }
}
/// The first name of scope `scope` that `same` accepts, as a scan of `wb.names` finds it.
/// Names `same` accepts fold alike, so only that bucket is checked.
fn find(&self, scope: Option<usize>, body: &str, same: impl Fn(&str, &str) -> bool) -> Option<usize> {
self.by_name
.get(&(scope, gridcraft_formula::fold_name(body)))?
.iter()
.copied()
.find(|&i| self.wb.names.get(i).is_some_and(|n| same(&n.name, body)))
}
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, how many
/// areas (appended to `areas` when given), and whether a defined name or a table gave them.
/// `Sheet2!Name` is the name scoped to Sheet2. A name counts when what it refers to is a
/// reference; a constant or a formula is not one.
fn target(&mut self, sheet: usize, part: &str, depth: usize, areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize, bool)> {
let wb = self.wb;
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
/// reference.
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
let from = n.scope.unwrap_or(sheet);
let formula = n.formula.trim().trim_start_matches('=');
let mut target: Option<usize> = None;
let mut areas = Vec::new();
for p in reference_parts(formula) {
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
if target.is_some_and(|t| t != s) {
return None;
}
let (prefix, body) = split_sheet(part);
let on = match &prefix {
Some(n) => wb.sheet_index(n)?,
None => sheet,
};
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
if let Some(a) = areas {
a.push(r);
}
return Some((on, 1, false));
target = Some(s);
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return None;
}
// As `Workbook::name`: the sheet's own name, else the workbook's.
let name = match prefix {
Some(_) => self.find(Some(on), body, gridcraft_formula::same_name),
None => self.find(Some(sheet), body, str::eq_ignore_ascii_case).or_else(|| self.find(None, body, str::eq_ignore_ascii_case)),
};
if let Some(i) = name {
return self.name_areas(i, sheet, depth, areas).map(|(t, n)| (t, n, true));
}
if prefix.is_none()
&& let Some((si, ti)) = wb.table(body)
{
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = wb.sheet(si)?.tables.get(ti)?;
if let Some(a) = areas {
a.push(t.data_range().unwrap_or(t.range));
}
return Some((si, 1, true));
}
None
}
/// The sheet and number of areas defined name `wb.names[i]` refers to, seen from sheet
/// `sheet`, when that is a reference; the areas are appended to `areas` when given.
fn name_areas(&mut self, i: usize, sheet: usize, depth: usize, mut areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize)> {
let key = (i, sheet, depth);
if areas.is_none()
&& let Some(known) = self.memo.get(&key)
{
return *known;
}
let out = self.name_areas_uncached(i, sheet, depth, areas.as_deref_mut());
if areas.is_none() {
self.memo.insert(key, out);
}
out
}
fn name_areas_uncached(&mut self, i: usize, sheet: usize, depth: usize, mut areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize)> {
let n = self.wb.names.get(i)?;
let from = n.scope.unwrap_or(sheet);
let formula = n.formula.trim().trim_start_matches('=');
let mut target: Option<usize> = None;
let mut count = 0usize;
for p in reference_parts(formula) {
let (s, found, _) = self.target(from, p, depth + 1, areas.as_deref_mut())?;
if target.is_some_and(|t| t != s) {
return None;
}
target = Some(s);
count += found;
if count > GO_TO_MAX_AREAS {
return None;
}
}
target.map(|t| (t, count))
}
target.map(|t| (t, areas))
}
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
@@ -196,13 +137,11 @@ impl<'a> Resolver<'a> {
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
let own: std::collections::HashSet<String> =
wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut names = Resolver::new(wb);
let mut out: Vec<String> = wb
.names
.iter()
.enumerate()
.filter(|(i, n)| !n.hidden && names.name_areas(*i, sheet, 0, None).is_some())
.filter_map(|(_, n)| match n.scope {
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
.filter_map(|n| match n.scope {
None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None,
None => Some(n.name.clone()),
Some(s) if s == sheet => Some(n.name.clone()),
-22
View File
@@ -42,28 +42,6 @@ fn go_to_lists_the_named_ranges_of_every_sheet() {
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
}
/// Thousands of names that alias other names are listed with one lookup per alias, not a scan of
/// every name for each; aliases resolve through the chain like a single name does.
#[test]
fn go_to_lists_many_aliases_and_follows_them() {
let mut s = go_to_workbook();
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
let alias = |name: String, formula: String| gridcraft_model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
wb.names.push(alias("Chain0".into(), "Areas".into()));
for i in 1..20_000 {
wb.names.push(alias(format!("Chain{i}"), format!("Chain{},price", i - 1)));
}
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
let names: Vec<&str> = list["names"].as_array().unwrap().iter().filter_map(|n| n.as_str()).collect();
// Chain0..Chain6 are within the name depth Go To follows; longer chains are not ranges.
for i in 0..7 {
assert!(names.contains(&format!("Chain{i}").as_str()), "Chain{i} in {names:?}");
}
assert!(!names.contains(&"Chain7"), "{names:?}");
let out = s.execute("edit.goTo", json!({"reference": "chain1"})).unwrap();
assert_eq!(out["selection"], "A1,C3:C4,B2:B5", "{out}");
}
#[test]
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
let mut s = go_to_workbook();