diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs
index b46f4d217..de01ba043 100644
--- a/crates/calc/src/eval.rs
+++ b/crates/calc/src/eval.rs
@@ -90,6 +90,8 @@ struct FnCtx<'a, 'h> {
ev: &'a mut Evaluator<'h>,
/// The criteria argument of a database function, when it is a reference.
criteria: Option,
+ /// The LAMBDA given as the function argument of GROUPBY/PIVOTBY.
+ lambda: Option>,
}
impl Ctx for FnCtx<'_, '_> {
@@ -124,6 +126,19 @@ impl Ctx for FnCtx<'_, '_> {
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
}
+ fn has_lambda(&self) -> bool {
+ self.lambda.is_some()
+ }
+ /// A LAMBDA must give one value per group; an array result is #CALC!.
+ fn call_lambda(&mut self, arg: Value) -> Option {
+ let l = self.lambda.clone()?;
+ let [p] = l.params.as_slice() else { return Some(Value::Error(CellError::Value)) };
+ let r = self.ev.call_lambda_values(&l, vec![(p.clone(), Ev::V(arg))]);
+ Some(match self.ev.deref(r) {
+ Value::Array(a) if a.data.len() != 1 => Value::Error(CellError::Calc),
+ v => v.scalar(),
+ })
+ }
}
impl<'h> Evaluator<'h> {
@@ -618,7 +633,7 @@ impl<'h> Evaluator<'h> {
let value = self.value(a);
evaluated.push(Arg { value, from_ref: false });
}
- let mut ctx = FnCtx { ev: self, criteria: None };
+ let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V);
}
let i = match index.to_number() {
@@ -817,8 +832,21 @@ impl<'h> Evaluator<'h> {
let mut criteria_shape = None;
// Database functions: the criteria range (argument 2), whose formulas are computed criteria.
let mut criteria = None;
+ // GROUPBY/PIVOTBY: the LAMBDA given as their function.
+ let mut lambda = None;
+ let lambda_at = match name {
+ "GROUPBY" => Some(2),
+ "PIVOTBY" => Some(3),
+ _ => None,
+ };
for (i, a) in args.iter().enumerate() {
let mut ev = self.eval(a);
+ if lambda_at == Some(i)
+ && let Ev::L(l) = &ev
+ {
+ lambda = Some(l.clone());
+ ev = Ev::V(Value::Empty);
+ }
if spec.category == gridcraft_functions::Category::Database
&& i == 2
&& let Ev::R(areas) = &ev
@@ -853,7 +881,7 @@ impl<'h> Evaluator<'h> {
};
evaluated.push(Arg { value, from_ref });
}
- let mut ctx = FnCtx { ev: self, criteria };
+ let mut ctx = FnCtx { ev: self, criteria, lambda };
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
}
@@ -907,13 +935,13 @@ impl<'h> Evaluator<'h> {
};
let args = [Arg { value, from_ref }];
if kind != Agg::Count {
- match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) {
+ match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
Value::Number(n) => acc.sum += n,
other => return Err(Ev::V(other)),
}
}
if kind != Agg::Sum {
- match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) {
+ match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
Value::Number(n) => acc.count = acc.count.saturating_add(n as u64),
other => return Err(Ev::V(other)),
}
@@ -1012,15 +1040,18 @@ impl<'h> Evaluator<'h> {
let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect();
let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1);
let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1);
- if (rows * cols) as u64 > MAX_CELLS {
- return err(CellError::Num);
+ let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) };
+ // The LAMBDA takes one parameter per array.
+ if l.params.len() != arrays.len() {
+ return err(CellError::Value);
}
- let mut out = Vec::with_capacity(rows * cols);
+ let mut out = Vec::with_capacity(n);
for r in 0..rows {
for c in 0..cols {
let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect();
let v = self.call_lambda_values(&l, bound);
- out.push(self.deref(v).scalar());
+ let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
+ out.push(one);
}
}
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
@@ -1041,7 +1072,8 @@ impl<'h> Evaluator<'h> {
let r = self.call_lambda_values(&l, bound);
acc = self.deref(r);
if name == "SCAN" {
- scan.push(acc.scalar());
+ let Some(one) = single(acc.clone()) else { return err(CellError::Calc) };
+ scan.push(one);
}
}
if name == "SCAN" {
@@ -1057,6 +1089,9 @@ impl<'h> Evaluator<'h> {
let arr = as_array(self.value(&args[0]));
let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) };
let Some(p) = l.params.first().cloned() else { return err(CellError::Value) };
+ if l.params.len() != 1 {
+ return err(CellError::Value);
+ }
let by_row = name == "BYROW";
let n = if by_row { arr.rows } else { arr.cols };
let mut out = Vec::with_capacity(n);
@@ -1068,7 +1103,8 @@ impl<'h> Evaluator<'h> {
};
let a = if by_row { Array::row(slice) } else { Array::column(slice) };
let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]);
- out.push(self.deref(r).scalar());
+ let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) };
+ out.push(one);
}
let a = if by_row { Array::column(out) } else { Array::row(out) };
Ev::V(a.into())
@@ -1097,7 +1133,8 @@ impl<'h> Evaluator<'h> {
(l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))),
];
let v = self.call_lambda_values(&l, bound);
- out.push(self.deref(v).scalar());
+ let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
+ out.push(one);
}
}
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
@@ -1318,7 +1355,7 @@ impl<'h> Evaluator<'h> {
}
}
}
- let mut ctx = FnCtx { ev: self, criteria: None };
+ let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
return match gridcraft_functions::call(spec, &xargs, &mut ctx) {
Value::Number(p) if p >= 1.0 => {
let k = p as u32 - 1;
@@ -1431,43 +1468,74 @@ impl<'h> Evaluator<'h> {
if name == "AGGREGATE" && func >= 14 {
k = rest.get(1).map(|e| self.value(e));
}
+ // Every cell visited (even one skipped as hidden, nested or an error) and every value
+ // counts against one budget for the whole call. All arguments are evaluated (references
+ // stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes
+ // `A:XFD` trim to the whole sheet, ~17 billion cells (#2175).
+ enum Input {
+ Cells(usize, RangeRef),
+ Values(Value),
+ }
+ let mut inputs = Vec::new();
+ let mut budget = MAX_CELLS;
for e in refs {
match self.eval(e) {
Ev::R(areas) => {
for a in areas {
let range = self.trim(&a);
- for c in range.iter() {
- let (hidden, nested) = {
- let wb = self.host.workbook();
- let s = wb.sheet(a.sheet);
- let hidden =
- s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) });
- let nested = skip_nested
- && s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| {
- let u = f.text.to_ascii_uppercase();
- u.contains("SUBTOTAL(") || u.contains("AGGREGATE(")
- });
- (hidden, nested)
- };
- if hidden || nested {
- continue;
- }
- let v = self.host.cell_value(a.sheet, c);
- if skip_errors && v.is_error() {
- continue;
- }
- values.push(v);
- }
+ budget = match budget.checked_sub(range.count()) {
+ Some(left) => left,
+ None => return err(CellError::Num),
+ };
+ inputs.push(Input::Cells(a.sheet, range));
}
}
- Ev::V(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()),
- Ev::V(v) => values.push(v),
+ Ev::V(v) => {
+ let n = match &v {
+ Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX),
+ _ => 1,
+ };
+ budget = match budget.checked_sub(n) {
+ Some(left) => left,
+ None => return err(CellError::Num),
+ };
+ inputs.push(Input::Values(v));
+ }
Ev::L(_) => return err(CellError::Value),
}
}
+ for input in inputs {
+ match input {
+ Input::Cells(sheet, range) => {
+ for c in range.iter() {
+ let (hidden, nested) = {
+ let wb = self.host.workbook();
+ let s = wb.sheet(sheet);
+ let hidden =
+ s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) });
+ let nested = skip_nested
+ && s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| {
+ let u = f.text.to_ascii_uppercase();
+ u.contains("SUBTOTAL(") || u.contains("AGGREGATE(")
+ });
+ (hidden, nested)
+ };
+ if hidden || nested {
+ continue;
+ }
+ let v = self.host.cell_value(sheet, c);
+ if skip_errors && v.is_error() {
+ continue;
+ }
+ values.push(v);
+ }
+ }
+ Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()),
+ Input::Values(v) => values.push(v),
+ }
+ }
if let Some(k) = k {
- values.push(Value::Empty);
- return Ev::V(gridcraft_functions::aggregate_values_k(func, &values[..values.len() - 1], &k));
+ return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k));
}
Ev::V(gridcraft_functions::aggregate_values(func, &values))
}
@@ -1624,7 +1692,8 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
(Value::Array(x), Value::Array(y)) => {
let rows = broadcast_dim(x.rows, y.rows);
let cols = broadcast_dim(x.cols, y.cols);
- let mut data = Vec::with_capacity(rows * cols);
+ let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
+ let mut data = Vec::with_capacity(n);
for r in 0..rows {
for c in 0..cols {
data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c)));
@@ -1638,6 +1707,13 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
}
}
+/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
+/// Each operand passes its own size cap, but their broadcast product can be far larger
+/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
+fn broadcast_len(rows: usize, cols: usize) -> Option {
+ rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS)
+}
+
fn broadcast_dim(a: usize, b: usize) -> usize {
if a == 1 {
b
@@ -1654,8 +1730,9 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V
let (fr, fc) = dims(f);
let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr);
let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc);
+ let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() };
- let mut data = Vec::with_capacity(rows * cols);
+ let mut data = Vec::with_capacity(n);
for r in 0..rows {
for col in 0..cols {
data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col)));
@@ -1664,6 +1741,15 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V
Value::from(Array { rows, cols, data })
}
+/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays
+/// can't nest, so the whole helper gives `#CALC!`, as in Excel.
+fn single(v: Value) -> Option {
+ match v {
+ Value::Array(a) if a.rows * a.cols > 1 => None,
+ v => Some(v.scalar()),
+ }
+}
+
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
}
diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs
index 4e5486fbe..88061120c 100644
--- a/crates/calc/src/tests.rs
+++ b/crates/calc/src/tests.rs
@@ -310,6 +310,29 @@ fn date_text_coerces_in_the_workbook_date_system() {
}
}
+#[test]
+fn lambda_helpers_check_shapes_and_parameters() {
+ let mut t = T::new();
+ let calc = Value::Error(CellError::Calc);
+ // A LAMBDA returning several values can't fill one cell: the whole result is #CALC!.
+ t.set("A1", "=MAP({1,2},LAMBDA(x,{1,2}))");
+ assert_eq!((t.get("A1"), t.get("B1")), (calc.clone(), Value::Empty));
+ t.set("A2", "=BYROW({1,2;3,4},LAMBDA(r,r))");
+ assert_eq!((t.get("A2"), t.get("A3")), (calc.clone(), Value::Empty));
+ t.set("A4", "=MAKEARRAY(1,2,LAMBDA(r,c,{1,2}))");
+ assert_eq!(t.get("A4"), calc.clone());
+ t.set("D1", "=SCAN(0,{1,2},LAMBDA(a,b,{1,2}))");
+ assert_eq!((t.get("D1"), t.get("E1")), (calc, Value::Empty));
+ // One parameter per array (MAP) or exactly one (BYROW/BYCOL).
+ t.set("A5", "=MAP({1,2},LAMBDA(x,y,x))");
+ assert_eq!(t.get("A5"), Value::Error(CellError::Value));
+ t.set("A6", "=BYCOL({1,2},LAMBDA(c,d,SUM(c)))");
+ assert_eq!(t.get("A6"), Value::Error(CellError::Value));
+ // A single value in an array is fine.
+ t.set("A7", "=MAP({1,2},LAMBDA(x,SEQUENCE(1)*x))");
+ assert_eq!(t.num("B7"), 2.0);
+}
+
#[test]
fn operators() {
let mut t = T::new();
@@ -632,6 +655,36 @@ fn functions_given_by_name() {
assert_eq!(t.num("J1"), 20.0);
}
+#[test]
+fn groupby_and_pivotby_take_a_lambda() {
+ let mut t = T::new();
+ for (i, (k, c, v)) in [("a", "x", 1), ("b", "x", 2), ("a", "y", 3)].iter().enumerate() {
+ t.set(&format!("A{}", i + 1), k);
+ t.set(&format!("B{}", i + 1), c);
+ t.set(&format!("C{}", i + 1), &v.to_string());
+ }
+ t.set("E1", "=GROUPBY(A1:A3,C1:C3,LAMBDA(x,SUM(x)))");
+ assert_eq!(t.get("E1"), Value::from("a"));
+ assert_eq!(t.num("F1"), 4.0);
+ assert_eq!(t.get("E2"), Value::from("b"));
+ assert_eq!(t.num("F2"), 2.0);
+ assert_eq!(t.get("E3"), Value::from("Total"));
+ assert_eq!(t.num("F3"), 6.0);
+ // A LET-bound lambda, called once per group with that group's values.
+ t.set("H1", "=LET(f,LAMBDA(v,ROWS(v)*10),GROUPBY(A1:A3,C1:C3,f))");
+ assert_eq!(t.num("I1"), 20.0);
+ assert_eq!(t.num("I3"), 30.0);
+ // PIVOTBY: rows a, b, Total; columns x, y, Total.
+ t.set("K1", "=PIVOTBY(A1:A3,B1:B3,C1:C3,LAMBDA(x,SUM(x)))");
+ assert_eq!(t.get("L1"), Value::from("x"));
+ assert_eq!(t.num("L2"), 1.0);
+ assert_eq!(t.num("M2"), 3.0);
+ assert_eq!(t.num("N4"), 6.0);
+ // Neither a function nor a lambda.
+ t.set("P1", "=GROUPBY(A1:A3,C1:C3,TRUE)");
+ assert_eq!(t.get("P1"), Value::Error(CellError::Value));
+}
+
#[test]
fn lambda_argument_count() {
let mut t = T::new();
@@ -723,6 +776,34 @@ fn legacy_array_formulas_fill_their_range() {
assert_eq!(t.get("H1"), Value::Error(CellError::Num));
}
+#[test]
+fn array_broadcast_result_is_capped() {
+ // A full column against a full row broadcasts to 1048576 x 16384 (17 billion cells, about
+ // 400 GB): each operand is within its own cap, so only the product check stops the abort.
+ let mut t = T::new();
+ let col = "SEQUENCE(1048576)";
+ let row = "SEQUENCE(1,16384)";
+ for (at, formula) in [
+ ("A1", format!("=ROWS({col}*{row})")),
+ ("A2", format!("=ROWS({col}&{row})")),
+ ("A3", format!("=ROWS({col}>{row})")),
+ ("A4", format!("=ROWS(IF({col},{row}))")),
+ ("A5", format!("=ROWS(IF({col}>0,1,{row}))")),
+ ("A6", format!("=ROWS(TEXT({col},{row}))")),
+ ("A7", format!("=ROWS(MAP({col},{row},LAMBDA(a,b,a)))")),
+ ] {
+ t.set(at, &formula);
+ assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}");
+ }
+ // Broadcasts within the cap are unchanged.
+ t.set("C1", "=SUM(SEQUENCE(3)*SEQUENCE(1,4))");
+ assert_eq!(t.num("C1"), 60.0);
+ t.set("C2", "=SUM(IF(SEQUENCE(2)>1,SEQUENCE(1,3),0))");
+ assert_eq!(t.num("C2"), 6.0);
+ t.set("C3", "=COLUMNS(TEXT(SEQUENCE(2),SEQUENCE(1,5)))");
+ assert_eq!(t.num("C3"), 5.0);
+}
+
#[test]
fn text_function() {
let mut t = T::new();
@@ -812,6 +893,40 @@ fn subtotal_109_skips_nested_subtotal() {
assert_eq!(t.num("B2"), 3.0);
}
+/// One value stored in the sheet's last cell makes `A:XFD` trim to ~17 billion cells. SUBTOTAL
+/// and AGGREGATE used to read every one of them into a vector (an abort on open, #2175); now
+/// every argument is charged against `MAX_CELLS` before any cell is read, and `#NUM!` comes back.
+#[test]
+fn subtotal_and_aggregate_cap_cells_visited() {
+ let mut t = T::new();
+ t.set("XFD1048576", "1");
+ let formulas = [
+ "=SUBTOTAL(9,A:XFD)",
+ "=SUBTOTAL(109,1:1048576)",
+ "=AGGREGATE(9,6,A:XFD)",
+ "=AGGREGATE(14,7,A:XFD,1)",
+ // A:J and K:T are about 10.5 million cells each: under the limit alone, over it together,
+ // so the second area is refused before the first is read.
+ "=SUBTOTAL(9,A:J,K:T)",
+ "=AGGREGATE(9,4,(A:J,K:T))",
+ ];
+ for (i, f) in formulas.iter().enumerate() {
+ let at = format!("B{}", i + 1);
+ t.set(&at, f);
+ assert_eq!(t.get(&at), Value::Error(CellError::Num), "{f}");
+ }
+
+ // Whole columns and rows still clip to the used range when it is small.
+ let mut t = T::new();
+ t.set("A1", "1");
+ t.set("B2", "2");
+ t.set("C2", "#N/A");
+ t.set("E5", "=SUBTOTAL(9,A:B)");
+ t.set("E6", "=AGGREGATE(9,6,1:2)");
+ t.set("E7", "=AGGREGATE(14,6,A:C,1)");
+ assert_eq!((t.num("E5"), t.num("E6"), t.num("E7")), (3.0, 3.0, 2.0));
+}
+
/// SUBTOTAL leaves out rows a filter hides for every function number, while 1-11 keep rows
/// hidden by hand (as in ConditionalFormattingSamples.xlsx, where `SUBTOTAL(9,Table69[Sales])`
/// under a filtered table adds only the visible rows).
@@ -1405,3 +1520,20 @@ fn cell_filename_names_the_workbook_file() {
t.set("B7", "=CELL(\"filename\",A1)");
assert_eq!(t.get("B7"), Value::from("[Book.xlsx]Info"));
}
+
+#[test]
+fn n_lifts_over_a_computed_array_but_reads_the_first_cell_of_a_range() {
+ let mut t = T::new();
+ t.set("B1", "0");
+ t.set("B2", "23850");
+ t.set("D2", "10%");
+ t.set("D3", "12%");
+ t.set("F1", "50000");
+ t.set("H1", "=SUMPRODUCT(D2:D3-D1:D2,F1-B1:B2,N(F1>B1:B2))");
+ assert!((t.num("H1") - 5523.0).abs() < 1e-9, "{:?}", t.get("H1"));
+ t.set("H2", "=SUMPRODUCT(N(B1:B2>0))");
+ assert_eq!(t.num("H2"), 1.0);
+ // A spill of N(D2:D3) would also show 10% in H3; summing it tells the two apart.
+ t.set("H3", "=SUM(N(D2:D3))");
+ assert!((t.num("H3") - 0.1).abs() < 1e-12, "{:?}", t.get("H3"));
+}
diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs
index 67c56999e..05638b2b7 100644
--- a/crates/functions/src/date.rs
+++ b/crates/functions/src/date.rs
@@ -13,16 +13,16 @@ const ND: &[bool] = &[true, true, false];
const NDI: &[bool] = &[true, true, true, false];
/// A date serial argument (truncated to whole days when `whole`). Out of range → `#NUM!`.
-fn serial_arg(a: &[Arg], i: usize) -> R {
- let n = num(a, i)?;
+fn serial_arg(a: &[Arg], i: usize, sys: DateSystem) -> R {
+ let n = scalar(arg(a, i)?).to_number_in(sys)?;
if !(0.0..MAX_SERIAL + 1.0).contains(&n) {
return Err(CellError::Num);
}
Ok(n)
}
-fn day_arg(a: &[Arg], i: usize) -> R {
- Ok(serial_arg(a, i)?.floor() as i64)
+fn day_arg(a: &[Arg], i: usize, sys: DateSystem) -> R {
+ Ok(serial_arg(a, i, sys)?.floor() as i64)
}
fn dt(sys: DateSystem, serial: f64) -> R {
@@ -66,8 +66,9 @@ fn time(a: &[Arg], _c: &mut dyn Ctx) -> R {
}
fn part(a: &[Arg], c: &mut dyn Ctx, f: fn(&DateTime) -> f64) -> R {
- let s = serial_arg(a, 0)?;
- num_val(f(&dt(c.date_system(), s.floor())?))
+ let sys = c.date_system();
+ let s = serial_arg(a, 0, sys)?;
+ num_val(f(&dt(sys, s.floor())?))
}
fn year(a: &[Arg], c: &mut dyn Ctx) -> R {
@@ -81,20 +82,20 @@ fn day(a: &[Arg], c: &mut dyn Ctx) -> R {
}
/// Seconds into the day, rounded to the nearest second.
-fn day_seconds(a: &[Arg]) -> R {
- let s = serial_arg(a, 0)?;
+fn day_seconds(a: &[Arg], sys: DateSystem) -> R {
+ let s = serial_arg(a, 0, sys)?;
let secs = ((s - s.floor()) * 86400.0).round() as i64;
Ok(if secs >= 86400 { 0 } else { secs })
}
-fn hour(a: &[Arg], _c: &mut dyn Ctx) -> R {
- num_val((day_seconds(a)? / 3600) as f64)
+fn hour(a: &[Arg], c: &mut dyn Ctx) -> R {
+ num_val((day_seconds(a, c.date_system())? / 3600) as f64)
}
-fn minute(a: &[Arg], _c: &mut dyn Ctx) -> R {
- num_val((day_seconds(a)? / 60 % 60) as f64)
+fn minute(a: &[Arg], c: &mut dyn Ctx) -> R {
+ num_val((day_seconds(a, c.date_system())? / 60 % 60) as f64)
}
-fn second(a: &[Arg], _c: &mut dyn Ctx) -> R {
- num_val((day_seconds(a)? % 60) as f64)
+fn second(a: &[Arg], c: &mut dyn Ctx) -> R {
+ num_val((day_seconds(a, c.date_system())? % 60) as f64)
}
fn now(_a: &[Arg], c: &mut dyn Ctx) -> R {
@@ -105,9 +106,10 @@ fn today(_a: &[Arg], c: &mut dyn Ctx) -> R {
}
fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R {
- let s = day_arg(a, 0)?;
+ let sys = c.date_system();
+ let s = day_arg(a, 0, sys)?;
let t = to_int(opt_num(a, 1, 1.0)?)?;
- let wd = weekday0(c.date_system(), s) as i64;
+ let wd = weekday0(sys, s) as i64;
let r = match t {
1 | 17 => wd + 1,
2 | 11 => (wd + 6) % 7 + 1,
@@ -128,7 +130,7 @@ fn isoweek(sys: DateSystem, s: i64) -> R {
fn weeknum(a: &[Arg], c: &mut dyn Ctx) -> R {
let sys = c.date_system();
- let s = day_arg(a, 0)?;
+ let s = day_arg(a, 0, sys)?;
let t = to_int(opt_num(a, 1, 1.0)?)?;
let start = match t {
1 | 17 => 0,
@@ -144,7 +146,8 @@ fn weeknum(a: &[Arg], c: &mut dyn Ctx) -> R {
}
fn isoweeknum(a: &[Arg], c: &mut dyn Ctx) -> R {
- num_val(isoweek(c.date_system(), day_arg(a, 0)?)? as f64)
+ let sys = c.date_system();
+ num_val(isoweek(sys, day_arg(a, 0, sys)?)? as f64)
}
fn months_arg(a: &[Arg], i: usize) -> R {
@@ -157,7 +160,7 @@ fn months_arg(a: &[Arg], i: usize) -> R {
fn edate(a: &[Arg], c: &mut dyn Ctx) -> R {
let sys = c.date_system();
- let d = dt(sys, day_arg(a, 0)? as f64)?;
+ let d = dt(sys, day_arg(a, 0, sys)? as f64)?;
let m = months_arg(a, 1)?;
let total = d.year as i64 * 12 + d.month as i64 - 1 + m;
let (y, mo) = (total.div_euclid(12), total.rem_euclid(12) + 1);
@@ -172,7 +175,7 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R {
fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R {
let sys = c.date_system();
- let d = dt(sys, day_arg(a, 0)? as f64)?;
+ let d = dt(sys, day_arg(a, 0, sys)? as f64)?;
let m = months_arg(a, 1)?;
let total = d.year as i64 * 12 + d.month as i64 - 1 + m;
let (y, mo) = (total.div_euclid(12), total.rem_euclid(12) + 1);
@@ -185,8 +188,8 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R {
fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R {
let sys = c.date_system();
- let s = day_arg(a, 0)?;
- let e = day_arg(a, 1)?;
+ let s = day_arg(a, 0, sys)?;
+ let e = day_arg(a, 1, sys)?;
let unit = text(a, 2)?.to_ascii_uppercase();
if s > e {
return Err(CellError::Num);
@@ -247,8 +250,9 @@ fn timevalue(a: &[Arg], c: &mut dyn Ctx) -> R {
num_val(n - n.floor())
}
-fn days(a: &[Arg], _c: &mut dyn Ctx) -> R {
- num_val((day_arg(a, 0)? - day_arg(a, 1)?) as f64)
+fn days(a: &[Arg], c: &mut dyn Ctx) -> R {
+ let sys = c.date_system();
+ num_val((day_arg(a, 0, sys)? - day_arg(a, 1, sys)?) as f64)
}
fn last_of_feb(d: &DateTime) -> bool {
@@ -257,8 +261,8 @@ fn last_of_feb(d: &DateTime) -> bool {
fn days360(a: &[Arg], c: &mut dyn Ctx) -> R {
let sys = c.date_system();
- let d1 = dt(sys, day_arg(a, 0)? as f64)?;
- let d2 = dt(sys, day_arg(a, 1)? as f64)?;
+ let d1 = dt(sys, day_arg(a, 0, sys)? as f64)?;
+ let d2 = dt(sys, day_arg(a, 1, sys)? as f64)?;
let european = opt_bool(a, 2, false)?;
num_val(days360_core(&d1, &d2, european) as f64)
}
@@ -286,7 +290,10 @@ fn weekend_mask(v: Option<&Arg>) -> R<[bool; 7]> {
Value::Empty if !arg.from_ref => Ok([true, false, false, false, false, false, true]),
Value::Text(t) => {
let b = t.as_bytes();
- if b.len() != 7 || !b.iter().all(|c| *c == b'0' || *c == b'1') || b.iter().all(|c| *c == b'1') {
+ // All seven days marked as the weekend is a valid string: Microsoft documents
+ // NETWORKDAYS.INTL as "Using 1111111 will always return 0" and WORKDAY.INTL as
+ // rejecting it, which `workday_core`'s `per_week == 0` guard already implements.
+ if b.len() != 7 || !b.iter().all(|c| *c == b'0' || *c == b'1') {
return Err(CellError::Value);
}
// String order is Monday..Sunday.
@@ -313,7 +320,7 @@ fn weekend_mask(v: Option<&Arg>) -> R<[bool; 7]> {
}
}
-fn holidays(v: Option<&Arg>) -> R> {
+fn holidays(v: Option<&Arg>, sys: DateSystem) -> R> {
let mut out = Vec::new();
if let Some(a) = v {
for x in as_array(&a.value).iter() {
@@ -321,7 +328,7 @@ fn holidays(v: Option<&Arg>) -> R> {
Value::Empty => {}
Value::Error(e) => return Err(*e),
other => {
- let n = other.to_number()?;
+ let n = other.to_number_in(sys)?;
if !(0.0..MAX_SERIAL + 1.0).contains(&n) {
return Err(CellError::Num);
}
@@ -353,9 +360,9 @@ fn count_workdays(sys: DateSystem, a: i64, b: i64, mask: &[bool; 7]) -> i64 {
fn networkdays_core(a: &[Arg], c: &mut dyn Ctx, mask: [bool; 7], hol: Option<&Arg>) -> R {
let sys = c.date_system();
- let s = day_arg(a, 0)?;
- let e = day_arg(a, 1)?;
- let hs = holidays(hol)?;
+ let s = day_arg(a, 0, sys)?;
+ let e = day_arg(a, 1, sys)?;
+ let hs = holidays(hol, sys)?;
let (lo, hi, sign) = if s <= e { (s, e, 1) } else { (e, s, -1) };
let mut n = count_workdays(sys, lo, hi, &mask);
n -= hs.iter().filter(|&&h| h >= lo && h <= hi && !mask[weekday0(sys, h)]).count() as i64;
@@ -387,13 +394,13 @@ fn advance(sys: DateSystem, start: i64, n: i64, dir: i64, mask: &[bool; 7], per_
fn workday_core(a: &[Arg], c: &mut dyn Ctx, mask: [bool; 7], hol: Option<&Arg>) -> R {
let sys = c.date_system();
- let start = day_arg(a, 0)?;
+ let start = day_arg(a, 0, sys)?;
let days = num(a, 1)?.trunc();
if days.abs() > 1e7 {
return Err(CellError::Num);
}
let days = days as i64;
- let hs = holidays(hol)?;
+ let hs = holidays(hol, sys)?;
let per_week = mask.iter().filter(|w| !**w).count() as i64;
if per_week == 0 {
return Err(CellError::Value);
@@ -476,10 +483,11 @@ fn feb29_between(sys: DateSystem, s: i64, e: i64, d1: &DateTime, d2: &DateTime)
}
fn yearfrac(a: &[Arg], c: &mut dyn Ctx) -> R {
- let s = day_arg(a, 0)?;
- let e = day_arg(a, 1)?;
+ let sys = c.date_system();
+ let s = day_arg(a, 0, sys)?;
+ let e = day_arg(a, 1, sys)?;
let basis = if has(a, 2) { to_int(num(a, 2)?)? } else { 0 };
- num_val(yearfrac_core(c.date_system(), s, e, basis)?)
+ num_val(yearfrac_core(sys, s, e, basis)?)
}
pub(crate) fn specs() -> Vec {
@@ -766,7 +774,10 @@ mod tests {
close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 2.0, 1.0)), n(7.0)]), 24.0);
close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 2.0, 1.0)), t("0000110")]), 24.0);
close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 1.0, 31.0)), n(11.0)]), 26.0);
- is_err(ev("NETWORKDAYS.INTL", vec![n(1.0), n(10.0), t("1111111")]), CellError::Value);
+ // An all-weekend string is valid: no day is a workday, so the count is 0.
+ close(ev("NETWORKDAYS.INTL", vec![n(1.0), n(10.0), t("1111111")]), 0.0);
+ // WORKDAY.INTL rejects it (there is no working day to move to).
+ is_err(ev("WORKDAY.INTL", vec![n(1.0), n(10.0), t("1111111")]), CellError::Value);
is_err(ev("NETWORKDAYS.INTL", vec![n(1.0), n(10.0), n(8.0)]), CellError::Num);
// WORKDAY
close(ev("WORKDAY", vec![n(d(2008.0, 10.0, 1.0)), n(151.0)]), d(2009.0, 4.0, 30.0));
@@ -832,4 +843,15 @@ mod tests {
close(crate::call(spec, &[t("2/29/1904")], &mut ctx), 59.0);
is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value);
}
+
+ #[test]
+ fn date_text_arguments_use_the_workbook_date_system() {
+ let mut ctx = TestCtx { sys: gridcraft_core::DateSystem::D1904, ..TestCtx::default() };
+ close(crate::call(crate::lookup("YEAR").unwrap(), &[t("1/1/2020")], &mut ctx), 2020.0);
+ close(crate::call(crate::lookup("MONTH").unwrap(), &[t("2/29/1904")], &mut ctx), 2.0);
+ close(crate::call(crate::lookup("DAY").unwrap(), &[t("2/29/1904")], &mut ctx), 29.0);
+ close(crate::call(crate::lookup("DAYS").unwrap(), &[t("1/2/2020"), t("1/1/2020")], &mut ctx), 1.0);
+ let networkdays = crate::lookup("NETWORKDAYS").unwrap();
+ close(crate::call(networkdays, &[t("1/6/2020"), t("1/10/2020"), t("1/8/2020")], &mut ctx), 4.0);
+ }
}
diff --git a/crates/functions/src/dynamic.rs b/crates/functions/src/dynamic.rs
index 2e29b416c..ee3e10a3e 100644
--- a/crates/functions/src/dynamic.rs
+++ b/crates/functions/src/dynamic.rs
@@ -86,8 +86,11 @@ fn key_of(v: &Value) -> String {
fn row_key(r: &[Value]) -> String {
let mut s = String::new();
for v in r {
- s.push_str(&key_of(v));
- s.push('\u{1}');
+ let k = key_of(v);
+ // Length prefix keeps the encoding unambiguous even when a text value contains the separator.
+ s.push_str(&k.len().to_string());
+ s.push(':');
+ s.push_str(&k);
}
s
}
@@ -573,6 +576,23 @@ fn parse_agg(v: &Value) -> R {
}
}
+/// The function argument of GROUPBY/PIVOTBY: a built-in aggregation, or `None` for a LAMBDA (called
+/// through the context).
+fn agg_arg(args: &[Arg], i: usize, c: &dyn Ctx) -> R