From dec7dd65fdecb3ca114a4d48b5122ee0db6290b8 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Mon, 5 Oct 2026 15:42:35 +0700 Subject: [PATCH 1/3] refactor(wasm): share scalar operator semantics --- packages/wasm/src/eval/mod.rs | 105 +++++----------------------- packages/wasm/src/eval/operators.rs | 72 +++++++++++++++++++ 2 files changed, 89 insertions(+), 88 deletions(-) create mode 100644 packages/wasm/src/eval/operators.rs diff --git a/packages/wasm/src/eval/mod.rs b/packages/wasm/src/eval/mod.rs index 50504abc..663aef2d 100644 --- a/packages/wasm/src/eval/mod.rs +++ b/packages/wasm/src/eval/mod.rs @@ -11,6 +11,7 @@ mod functions; mod lookup; mod math; mod matrix; +mod operators; mod range_reader; mod statistics; mod text; @@ -21,7 +22,7 @@ use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::rc::Rc; -use crate::calc::{Ast, CmpOp, Func, Op}; +use crate::calc::{Ast, Func}; use crate::sheet::{spill_ownership_within_budget, SheetData, SpillRange}; use crate::store::CellStore; use crate::types::{ @@ -1033,99 +1034,27 @@ impl CellStore { | Ast::Range(..) | Ast::AbsRange(..) | Ast::SheetRange(..) => Value::Error(FormulaError::Value), - Ast::Neg(expr) => match number_from_value(&self.eval_ast( - expr, - sheet, - affected, - memo, - visiting, - depth + 1, - )) { - Ok(value) => Value::number(-value), - Err(error) => Value::Error(error), - }, - Ast::Pos(expr) => match number_from_value(&self.eval_ast( - expr, - sheet, - affected, - memo, - visiting, - depth + 1, - )) { - Ok(value) => Value::number(value), - Err(error) => Value::Error(error), - }, - Ast::Percent(expr) => match number_from_value(&self.eval_ast( - expr, - sheet, - affected, - memo, - visiting, - depth + 1, - )) { - Ok(value) => Value::number(value / 100.0), - Err(error) => Value::Error(error), - }, + Ast::Neg(expr) | Ast::Pos(expr) | Ast::Percent(expr) => { + let op = match ast { + Ast::Neg(_) => operators::UnaryOp::Neg, + Ast::Pos(_) => operators::UnaryOp::Pos, + _ => operators::UnaryOp::Percent, + }; + operators::unary( + op, + &self.eval_ast(expr, sheet, affected, memo, visiting, depth + 1), + ) + } Ast::Bin(op, left, right) => { let left = self.eval_ast(left, sheet, affected, memo, visiting, depth + 1); - if *op == Op::Concat { - let left = match text_from_value(&left) { - Ok(value) => value, - Err(error) => return Value::Error(error), - }; - let right = self.eval_ast(right, sheet, affected, memo, visiting, depth + 1); - let right = match text_from_value(&right) { - Ok(value) => value, - Err(error) => return Value::Error(error), - }; - return Value::text(left + &right); - } - let a = match number_from_value(&left) { - Ok(value) => value, - Err(error) => return Value::Error(error), - }; - let right = self.eval_ast(right, sheet, affected, memo, visiting, depth + 1); - let b = match number_from_value(&right) { - Ok(value) => value, - Err(error) => return Value::Error(error), - }; - match op { - Op::Add => Value::number(a + b), - Op::Sub => Value::number(a - b), - Op::Mul => Value::number(a * b), - Op::Div => { - if b == 0.0 { - Value::Error(FormulaError::DivZero) - } else { - Value::number(a / b) - } - } - Op::Pow => { - if a == 0.0 && b < 0.0 { - Value::Error(FormulaError::DivZero) - } else { - Value::number(a.powf(b)) - } - } - Op::Concat => unreachable!("concatenation returned before numeric coercion"), - } + operators::binary(*op, &left, || { + self.eval_ast(right, sheet, affected, memo, visiting, depth + 1) + }) } Ast::Cmp(op, left, right) => { let left = self.eval_ast(left, sheet, affected, memo, visiting, depth + 1); let right = self.eval_ast(right, sheet, affected, memo, visiting, depth + 1); - let ord = match compare_values(&left, &right) { - Ok(ord) => ord, - Err(error) => return Value::Error(error), - }; - let res = match op { - CmpOp::Eq => ord == Ordering::Equal, - CmpOp::Ne => ord != Ordering::Equal, - CmpOp::Lt => ord == Ordering::Less, - CmpOp::Gt => ord == Ordering::Greater, - CmpOp::Le => matches!(ord, Ordering::Less | Ordering::Equal), - CmpOp::Ge => matches!(ord, Ordering::Greater | Ordering::Equal), - }; - Value::Bool(res) + operators::comparison(*op, &left, &right) } Ast::Func( Func::Filter diff --git a/packages/wasm/src/eval/operators.rs b/packages/wasm/src/eval/operators.rs new file mode 100644 index 00000000..a2f09391 --- /dev/null +++ b/packages/wasm/src/eval/operators.rs @@ -0,0 +1,72 @@ +//! Shared scalar semantics for scalar and element-wise operators. + +use std::cmp::Ordering; + +use crate::calc::{CmpOp, Op}; +use crate::types::{FormulaError, Value}; + +use super::value::{compare_values, number_from_value, text_from_value}; + +#[derive(Clone, Copy)] +pub(super) enum UnaryOp { + Neg, + Pos, + Percent, +} + +pub(super) fn unary(op: UnaryOp, value: &Value) -> Value { + match number_from_value(value) { + Ok(number) => Value::number(match op { + UnaryOp::Neg => -number, + UnaryOp::Pos => number, + UnaryOp::Percent => number / 100.0, + }), + Err(error) => Value::Error(error), + } +} + +pub(super) fn binary(op: Op, left: &Value, right: impl FnOnce() -> Value) -> Value { + if op == Op::Concat { + let left = match text_from_value(left) { + Ok(text) => text, + Err(error) => return Value::Error(error), + }; + return match text_from_value(&right()) { + Ok(right) => Value::text(left + &right), + Err(error) => Value::Error(error), + }; + } + let left = match number_from_value(left) { + Ok(number) => number, + Err(error) => return Value::Error(error), + }; + let right = match number_from_value(&right()) { + Ok(number) => number, + Err(error) => return Value::Error(error), + }; + match op { + Op::Add => Value::number(left + right), + Op::Sub => Value::number(left - right), + Op::Mul => Value::number(left * right), + Op::Div if right == 0.0 => Value::Error(FormulaError::DivZero), + Op::Div => Value::number(left / right), + Op::Pow if left == 0.0 && right < 0.0 => Value::Error(FormulaError::DivZero), + Op::Pow => Value::number(left.powf(right)), + Op::Concat => unreachable!("concatenation returned before numeric coercion"), + } +} + +pub(super) fn comparison(op: CmpOp, left: &Value, right: &Value) -> Value { + let ordering = match compare_values(left, right) { + Ok(ordering) => ordering, + Err(error) => return Value::Error(error), + }; + Value::Bool(match op { + CmpOp::Eq => ordering == Ordering::Equal, + CmpOp::Ne => ordering != Ordering::Equal, + CmpOp::Lt => ordering == Ordering::Less, + CmpOp::Gt => ordering == Ordering::Greater, + CmpOp::Le => matches!(ordering, Ordering::Less | Ordering::Equal), + CmpOp::Ge => matches!(ordering, Ordering::Greater | Ordering::Equal), + }) +} From a3205107efe484324bec9650247249fdc436ed60 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Mon, 5 Oct 2026 16:08:41 +0700 Subject: [PATCH 2/3] feat(formulas): apply operators element by element over arrays --- .changeset/array-arithmetic.md | 6 + docs/public/llms-full.txt | 12 +- docs/src/content/docs/guides/formulas.md | 12 +- packages/wasm/src/eval/analysis/reshape.rs | 6 +- packages/wasm/src/eval/array.rs | 44 +++++- packages/wasm/src/eval/dependency.rs | 7 +- packages/wasm/src/eval/mod.rs | 9 +- packages/wasm/src/eval/operators.rs | 148 ++++++++++++++++++++- packages/wasm/src/tests.rs | 138 +++++++++++++++++++ packages/wasm/src/types.rs | 4 + 10 files changed, 368 insertions(+), 18 deletions(-) create mode 100644 .changeset/array-arithmetic.md diff --git a/.changeset/array-arithmetic.md b/.changeset/array-arithmetic.md new file mode 100644 index 00000000..feaca934 --- /dev/null +++ b/.changeset/array-arithmetic.md @@ -0,0 +1,6 @@ +--- +"@sheetwrite/wasm": minor +"@sheetwrite/formulas": minor +--- + +Apply formula operators to each array element. Scalars and single rows or columns expand to match the other operand. Array results spill into adjacent cells and work as arguments to functions that accept arrays. diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 821e848b..7d1c4558 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -2122,7 +2122,17 @@ Put `#` after a cell reference to refer to the whole spill of the formula in tha `A1#` returns `#REF!` when the cell does not hold a spill: for example, the cell contains a value, or a collision blocks the spill (`#SPILL!`). If you delete the row or column of the anchor cell, the reference becomes `#REF!`. Both engines support spill references. XLSX files store `A1#` as `_xlfn.ANCHORARRAY(A1)`; import and export convert between the two forms. -Arithmetic on a whole spill, such as `=A1#*10`, returns `#VALUE!`, the same as `=A1:A3*10`. Use a function that accepts a range, such as `SUM`, `INDEX`, `SORT`, or `FILTER`. +Arithmetic on a whole spill applies to each element. For example, `=A1#*10` multiplies each value in the spill by 10 and spills the result. + +## Array arithmetic + +Operators apply to each element of a range, array constant, spill reference, or dynamic-array result. This includes arithmetic (`+`, `-`, `*`, `/`, `^`), text concatenation (`&`), comparisons, unary `+` and `-`, and `%`. + +For example, `=A1:A3*10` spills three results. `=FILTER(A1:A5,B1:B5>2)` uses a comparison array to select rows. `=SUMPRODUCT((A1:A5>2)*B1:B5)` multiplies the comparison results by the values in column B. + +A scalar applies to every element. Rows and columns expand separately. A size of one expands to match the other operand. For example, `={1,2,3}+{10;20}` returns `{11,12,13;21,22,23}`. If two sizes differ and neither is one, the result uses the larger size. Positions outside the smaller operand return `#N/A`. + +Each element uses the scalar rules for values and errors. An error affects its element, not the whole array. Functions that accept arrays use the results directly. For example, `=SUM(A1:A3*B1:B3)` returns one sum. A top-level array result spills into adjacent cells. A blocked spill returns `#SPILL!`. Array size and memory limits still apply. ## Operators diff --git a/docs/src/content/docs/guides/formulas.md b/docs/src/content/docs/guides/formulas.md index 21638f80..613e7f87 100644 --- a/docs/src/content/docs/guides/formulas.md +++ b/docs/src/content/docs/guides/formulas.md @@ -124,7 +124,17 @@ Put `#` after a cell reference to refer to the whole spill of the formula in tha `A1#` returns `#REF!` when the cell does not hold a spill: for example, the cell contains a value, or a collision blocks the spill (`#SPILL!`). If you delete the row or column of the anchor cell, the reference becomes `#REF!`. Both engines support spill references. XLSX files store `A1#` as `_xlfn.ANCHORARRAY(A1)`; import and export convert between the two forms. -Arithmetic on a whole spill, such as `=A1#*10`, returns `#VALUE!`, the same as `=A1:A3*10`. Use a function that accepts a range, such as `SUM`, `INDEX`, `SORT`, or `FILTER`. +Arithmetic on a whole spill applies to each element. For example, `=A1#*10` multiplies each value in the spill by 10 and spills the result. + +## Array arithmetic + +Operators apply to each element of a range, array constant, spill reference, or dynamic-array result. This includes arithmetic (`+`, `-`, `*`, `/`, `^`), text concatenation (`&`), comparisons, unary `+` and `-`, and `%`. + +For example, `=A1:A3*10` spills three results. `=FILTER(A1:A5,B1:B5>2)` uses a comparison array to select rows. `=SUMPRODUCT((A1:A5>2)*B1:B5)` multiplies the comparison results by the values in column B. + +A scalar applies to every element. Rows and columns expand separately. A size of one expands to match the other operand. For example, `={1,2,3}+{10;20}` returns `{11,12,13;21,22,23}`. If two sizes differ and neither is one, the result uses the larger size. Positions outside the smaller operand return `#N/A`. + +Each element uses the scalar rules for values and errors. An error affects its element, not the whole array. Functions that accept arrays use the results directly. For example, `=SUM(A1:A3*B1:B3)` returns one sum. A top-level array result spills into adjacent cells. A blocked spill returns `#SPILL!`. Array size and memory limits still apply. ## Operators diff --git a/packages/wasm/src/eval/analysis/reshape.rs b/packages/wasm/src/eval/analysis/reshape.rs index 26c7a8c5..f5abd5ee 100644 --- a/packages/wasm/src/eval/analysis/reshape.rs +++ b/packages/wasm/src/eval/analysis/reshape.rs @@ -1092,12 +1092,14 @@ mod tests { ("=SUM(TOCOL(A1:B2))", "10"), ("=SUM(EXPAND(A1:B2,3,3,1))", "15"), ("=SUM(HSTACK(A1:B2,A1:B2))", "20"), - // The first value of an array in a single-value context. - ("=VSTACK(B2,A1)+0", "4"), ]; for (formula, expected) in cases { assert_eq!(evaluate(data, formula), grid(&[&[expected]]), "{formula}"); } + assert_eq!( + evaluate(data, "=VSTACK(B2,A1)+0"), + grid(&[&["4"], &["1"]]) + ); assert_eq!( evaluate(data, "=TRANSPOSE(TOCOL(A1:B2))"), grid(&[&["1", "2", "3", "4"]]) diff --git a/packages/wasm/src/eval/array.rs b/packages/wasm/src/eval/array.rs index 5f9461c9..25f8569e 100644 --- a/packages/wasm/src/eval/array.rs +++ b/packages/wasm/src/eval/array.rs @@ -30,7 +30,7 @@ fn static_integer(ast: Option<&Ast>) -> Option { } } -pub(super) fn ast_produces_array(ast: &Ast) -> bool { +pub(crate) fn ast_produces_array(ast: &Ast) -> bool { match ast { Ast::Func(Func::AnchorArray, _) => true, #[cfg(feature = "analysis")] @@ -45,6 +45,10 @@ pub(super) fn ast_produces_array(ast: &Ast) -> bool { | Ast::Structured(..) | Ast::Array { .. } => true, Ast::LetSlot { expression, .. } => ast_produces_array(expression), + Ast::Bin(_, left, right) | Ast::Cmp(_, left, right) => { + ast_produces_array(left) || ast_produces_array(right) + } + Ast::Neg(inner) | Ast::Pos(inner) | Ast::Percent(inner) => ast_produces_array(inner), #[cfg(feature = "analysis")] Ast::Func(Func::Analysis(id), args) => { let name = super::analysis::name(*id); @@ -171,6 +175,21 @@ impl CellStore { formula_sheet: usize, ) -> Option> { match ast { + Ast::Bin(_, _, _) | Ast::Cmp(_, _, _) | Ast::Neg(_) | Ast::Pos(_) | Ast::Percent(_) + if ast_produces_array(ast) => + { + Some( + self.matrix_shape(ast, formula_sheet) + .map(|shape| shape.2) + .or_else(|error| { + if error == FormulaError::Value { + Ok(SPILL_MAX_CELLS) + } else { + Err(error) + } + }), + ) + } Ast::Func(Func::AnchorArray, args) => { let range = self.spill_reference(args, formula_sheet); Some( @@ -290,6 +309,11 @@ impl CellStore { depth: usize, ) -> Option> { let result = match ast { + Ast::Bin(_, _, _) | Ast::Cmp(_, _, _) | Ast::Neg(_) | Ast::Pos(_) | Ast::Percent(_) + if ast_produces_array(ast) => + { + self.eval_operator_array(ast, sheet, affected, memo, visiting, depth + 1) + } Ast::Func(Func::AnchorArray, args) => { let range = self.spill_reference(args, sheet); return self.eval_dynamic_array(&range, sheet, affected, memo, visiting, depth); @@ -403,6 +427,24 @@ impl CellStore { formula_sheet: usize, ) -> Result<(usize, usize, usize), FormulaError> { match ast { + Ast::Bin(_, left, right) | Ast::Cmp(_, left, right) => { + let (left_rows, left_cols, _) = if ast_produces_array(left) { + self.matrix_shape(left, formula_sheet)? + } else { + (1, 1, 1) + }; + let (right_rows, right_cols, _) = if ast_produces_array(right) { + self.matrix_shape(right, formula_sheet)? + } else { + (1, 1, 1) + }; + let rows = left_rows.max(right_rows); + let cols = left_cols.max(right_cols); + return Ok((rows, cols, EvalMatrix::validate_shape(rows, cols, 1, 0)?)); + } + Ast::Neg(inner) | Ast::Pos(inner) | Ast::Percent(inner) => { + return self.matrix_shape(inner, formula_sheet); + } Ast::Func(Func::AnchorArray, args) => { return self .matrix_shape(&self.spill_reference(args, formula_sheet), formula_sheet); diff --git a/packages/wasm/src/eval/dependency.rs b/packages/wasm/src/eval/dependency.rs index e9ca7dca..8d8bf231 100644 --- a/packages/wasm/src/eval/dependency.rs +++ b/packages/wasm/src/eval/dependency.rs @@ -8,8 +8,6 @@ use crate::types::{ AbsCellKey, CellRange, EvalResult, FormulaEntry, FormulaError, Value, FORMULA_RECURSION_LIMIT, }; -use super::array::ast_produces_array; - pub(crate) struct DepIndex { exact_dependents: HashMap>, range_groups: Vec, @@ -111,8 +109,7 @@ impl DepIndex { && next.error.is_none() && previous.reads.cells == next.reads.cells && previous.reads.ranges == next.reads.ranges - && previous.ast.as_ref().is_some_and(ast_produces_array) - == next.ast.as_ref().is_some_and(ast_produces_array) + && previous.produces_array == next.produces_array } } @@ -178,7 +175,7 @@ pub(super) fn build_dep_index(sheets: &[SheetData], epoch: u64) -> DepIndex { for (sheet_index, sheet) in sheets.iter().enumerate() { for (&formula_cell, entry) in &sheet.formulas { - has_dynamic_arrays |= entry.ast.as_ref().is_some_and(ast_produces_array); + has_dynamic_arrays |= entry.produces_array; let formula_abs = AbsCellKey::from_local(sheet_index, formula_cell); for &cell in &entry.reads.cells { exact_dependents.entry(cell).or_default().push(formula_abs); diff --git a/packages/wasm/src/eval/mod.rs b/packages/wasm/src/eval/mod.rs index 663aef2d..57f7227d 100644 --- a/packages/wasm/src/eval/mod.rs +++ b/packages/wasm/src/eval/mod.rs @@ -31,7 +31,8 @@ use crate::types::{ KIND_FORMULA, KIND_NUMBER, KIND_STRING, RANGE_CELL_LIMIT, }; -use array::{ast_produces_array, dynamic_recompute_within_limit}; +pub(crate) use array::ast_produces_array; +use array::dynamic_recompute_within_limit; use criteria::{aggregate_if, extreme_if, Criterion, IfExtreme, IfSum}; pub(crate) use dependency::DepIndex; use dependency::{ @@ -47,8 +48,7 @@ pub(crate) use matrix::{matrix_resource_stats, reset_matrix_resource_stats}; use matrix::{optional_ast, range_from_ast, EvalMatrix, SPILL_MAX_BYTES}; use range_reader::RangeReader; use value::{ - aggregate_number, bool_from_value, cached_formula_value, compare_values, number_from_value, - text_from_value, + aggregate_number, bool_from_value, cached_formula_value, compare_values, text_from_value, }; const LET_BINDING_LIMIT: usize = 126; @@ -488,6 +488,9 @@ impl CellStore { .get(key.sheet as usize)? .formulas .get(&key.local())?; + if !entry.produces_array { + return None; + } let ast = entry.ast.as_ref()?; self.dynamic_array_bound(ast, key.sheet as usize) .is_some() diff --git a/packages/wasm/src/eval/operators.rs b/packages/wasm/src/eval/operators.rs index a2f09391..38b2410d 100644 --- a/packages/wasm/src/eval/operators.rs +++ b/packages/wasm/src/eval/operators.rs @@ -1,10 +1,13 @@ //! Shared scalar semantics for scalar and element-wise operators. use std::cmp::Ordering; +use std::collections::{HashMap, HashSet}; -use crate::calc::{CmpOp, Op}; -use crate::types::{FormulaError, Value}; +use crate::calc::{Ast, CmpOp, Op}; +use crate::store::CellStore; +use crate::types::{AbsCellKey, EvalResult, FormulaError, Value, FORMULA_RECURSION_LIMIT}; +use super::matrix::{EvalMatrix, SPILL_MAX_BYTES}; use super::value::{compare_values, number_from_value, text_from_value}; #[derive(Clone, Copy)] @@ -25,13 +28,17 @@ pub(super) fn unary(op: UnaryOp, value: &Value) -> Value { } } -pub(super) fn binary(op: Op, left: &Value, right: impl FnOnce() -> Value) -> Value { +pub(super) fn binary>( + op: Op, + left: &Value, + right: impl FnOnce() -> Right, +) -> Value { if op == Op::Concat { let left = match text_from_value(left) { Ok(text) => text, Err(error) => return Value::Error(error), }; - return match text_from_value(&right()) { + return match text_from_value(right().borrow()) { Ok(right) => Value::text(left + &right), Err(error) => Value::Error(error), }; @@ -40,7 +47,7 @@ pub(super) fn binary(op: Op, left: &Value, right: impl FnOnce() -> Value) -> Val Ok(number) => number, Err(error) => return Value::Error(error), }; - let right = match number_from_value(&right()) { + let right = match number_from_value(right().borrow()) { Ok(number) => number, Err(error) => return Value::Error(error), }; @@ -70,3 +77,134 @@ pub(super) fn comparison(op: CmpOp, left: &Value, right: &Value) -> Value { CmpOp::Ge => matches!(ordering, Ordering::Greater | Ordering::Equal), }) } + +enum Operand { + Scalar(Value), + Array(EvalMatrix), +} + +impl Operand { + fn bytes(&self) -> usize { + match self { + Self::Scalar(Value::Text(text)) => text.len(), + Self::Scalar(_) => 0, + Self::Array(matrix) => { + matrix.values.capacity() * std::mem::size_of::() + + matrix + .values + .iter() + .map(|value| match value { + Value::Text(text) => text.len(), + _ => 0, + }) + .sum::() + } + } + } + + fn shape(&self) -> (usize, usize) { + match self { + Self::Scalar(_) => (1, 1), + Self::Array(matrix) => (matrix.rows, matrix.cols), + } + } + + fn at(&self, row: usize, col: usize) -> Option<&Value> { + match self { + Self::Scalar(value) => Some(value), + Self::Array(matrix) => matrix.get( + if matrix.rows == 1 { 0 } else { row }, + if matrix.cols == 1 { 0 } else { col }, + ), + } + } +} + +impl CellStore { + fn operator_operand( + &self, + ast: &Ast, + sheet: usize, + affected: &HashSet, + memo: &mut HashMap, + visiting: &mut HashSet, + depth: usize, + ) -> Result { + match self.eval_dynamic_array(ast, sheet, affected, memo, visiting, depth + 1) { + Some(matrix) => matrix.map(Operand::Array), + None => Ok(Operand::Scalar(self.eval_ast( + ast, + sheet, + affected, + memo, + visiting, + depth + 1, + ))), + } + } + + pub(super) fn eval_operator_array( + &self, + ast: &Ast, + sheet: usize, + affected: &HashSet, + memo: &mut HashMap, + visiting: &mut HashSet, + depth: usize, + ) -> Result { + if depth > FORMULA_RECURSION_LIMIT { + return Err(FormulaError::Num); + } + if let Ast::Neg(inner) | Ast::Pos(inner) | Ast::Percent(inner) = ast { + let op = match ast { + Ast::Neg(_) => UnaryOp::Neg, + Ast::Pos(_) => UnaryOp::Pos, + _ => UnaryOp::Percent, + }; + let mut matrix = self + .eval_dynamic_array(inner, sheet, affected, memo, visiting, depth + 1) + .ok_or(FormulaError::Value)??; + for value in &mut matrix.values { + *value = unary(op, value); + } + return Ok(matrix); + } + let (left, right) = match ast { + Ast::Bin(_, left, right) | Ast::Cmp(_, left, right) => (left, right), + _ => return Err(FormulaError::Value), + }; + let left = self.operator_operand(left, sheet, affected, memo, visiting, depth + 1)?; + let right = self.operator_operand(right, sheet, affected, memo, visiting, depth + 1)?; + let (left_rows, left_cols) = left.shape(); + let (right_rows, right_cols) = right.shape(); + let rows = left_rows.max(right_rows); + let cols = left_cols.max(right_cols); + let operand_bytes = left + .bytes() + .checked_add(right.bytes()) + .ok_or(FormulaError::Num)?; + let cells = EvalMatrix::validate_shape(rows, cols, 1, operand_bytes)?; + let mut bytes = operand_bytes + cells * std::mem::size_of::(); + let mut values = Vec::with_capacity(cells); + for row in 0..rows { + for col in 0..cols { + let value = match (left.at(row, col), right.at(row, col)) { + (Some(left), Some(right)) => match ast { + Ast::Bin(op, _, _) => binary(*op, left, || right), + Ast::Cmp(op, _, _) => comparison(*op, left, right), + _ => unreachable!("binary operator matched above"), + }, + _ => Value::Error(FormulaError::Na), + }; + if let Value::Text(text) = &value { + bytes = bytes.checked_add(text.len()).ok_or(FormulaError::Num)?; + if bytes > SPILL_MAX_BYTES { + return Err(FormulaError::Num); + } + } + values.push(value); + } + } + Ok(EvalMatrix::new(rows, cols, values)) + } +} diff --git a/packages/wasm/src/tests.rs b/packages/wasm/src/tests.rs index 863df0f6..2e891dc9 100644 --- a/packages/wasm/src/tests.rs +++ b/packages/wasm/src/tests.rs @@ -2061,6 +2061,144 @@ fn array_constants_act_as_array_arguments() { assert_eq!(string(&store, sheet, 2, 0).as_deref(), Some("#VALUE!")); } +#[test] +fn array_operators_broadcast_each_axis_and_keep_element_errors() { + let cases: &[(&str, usize, &[&str])] = &[ + ("={1,2,3}+{10;20}", 3, &["11", "12", "13", "21", "22", "23"]), + ("={1;2}+{10;20;30}", 1, &["11", "22", "#N/A"]), + ("={1,2}+{10,20,30}", 3, &["11", "22", "#N/A"]), + ("={1,2,3}-{1;2}", 3, &["0", "1", "2", "-1", "0", "1"]), + ("={1,2,3}*10", 3, &["10", "20", "30"]), + ("={4,6,8}/{2,0,4}", 3, &["2", "#DIV/0!", "2"]), + ("={2,0,3}^{3,-1,2}", 3, &["8", "#DIV/0!", "9"]), + ("={\"a\",\"b\"}&{1;2}", 2, &["a1", "b1", "a2", "b2"]), + ("=-{1,-2,3}%", 3, &["-0.01", "0.02", "-0.03"]), + ("=+{1,\"2\",TRUE}", 3, &["1", "2", "1"]), + ("={1,#N/A,\"bad\",4}+1", 4, &["2", "#N/A", "#VALUE!", "5"]), + ("=SEQUENCE(2,2)*2", 2, &["2", "4", "6", "8"]), + ("=FILTER({1;2;3},{TRUE;FALSE;TRUE})+10", 1, &["11", "13"]), + ]; + for &(source, cols, expected) in cases { + let mut store = CellStore::new(); + let sheet = store.add_sheet(8, 8); + store.set_formula(sheet, 0, 0, source, 0); + store.recompute(sheet); + for (index, expected) in expected.iter().enumerate() { + let row = index / cols; + let col = index % cols; + if let Ok(expected) = expected.parse::() { + assert_eq!(string(&store, sheet, row, col), None, "{source}"); + assert_close(number(&store, sheet, row, col), expected); + } else { + assert_eq!( + string(&store, sheet, row, col).as_deref(), + Some(*expected), + "{source}" + ); + } + } + } +} + +#[test] +fn array_operators_compare_elements_and_feed_array_functions() { + for (source, expected) in [ + ("=SUM(A1:A3*B1:B3)", 140.0), + ("=SUMPRODUCT((A1:A5>2)*B1:B5)", 120.0), + ("=MAX(A1:A3*2)", 6.0), + ("=AVERAGE(A1:A3*2)", 4.0), + ("=COUNT(A1:A3*2)", 3.0), + ("=INDEX(A1:A3*2,2)", 4.0), + ("=ROWS(A1:A3*2)", 3.0), + ("=IF(TRUE,SUM(A1:A3*2),0)", 12.0), + ] { + let mut store = CellStore::new(); + let sheet = store.add_sheet(8, 8); + for row in 0..5 { + store.set_number(sheet, row, 0, (row + 1) as f64, 0); + store.set_number(sheet, row, 1, ((row + 1) * 10) as f64, 0); + } + store.set_formula(sheet, 0, 3, source, 0); + store.recompute(sheet); + assert_eq!(string(&store, sheet, 0, 3), None, "{source}"); + assert_close(number(&store, sheet, 0, 3), expected); + } + for (operator, expected) in [ + ("=", [false, true, false]), + ("<>", [true, false, true]), + ("<", [true, false, false]), + (">", [false, false, true]), + ("<=", [true, true, false]), + (">=", [false, true, true]), + ] { + let mut store = CellStore::new(); + let sheet = store.add_sheet(4, 4); + store.set_formula(sheet, 0, 0, &format!("={{1,2,3}}{operator}2"), 0); + store.recompute(sheet); + for (col, expected) in expected.into_iter().enumerate() { + let value = store.get_cell(sheet, 0, col); + assert_eq!( + (value.kind(), value.num()), + (KIND_BOOL, f64::from(expected)) + ); + } + } +} + +#[test] +fn array_operators_refresh_range_and_spill_dependencies_and_obstructions() { + let mut store = CellStore::new(); + let sheet = store.add_sheet(12, 12); + for row in 0..5 { + store.set_number(sheet, row, 0, (row + 1) as f64, 0); + store.set_number(sheet, row, 1, (row + 1) as f64, 0); + } + store.set_formula(sheet, 0, 3, "=A1:A3*10", 0); + store.set_formula(sheet, 0, 4, "=D1#*2", 0); + store.set_formula(sheet, 0, 5, "=FILTER(A1:A5,B1:B5>2)", 0); + store.set_formula(sheet, 0, 6, "=A1+B1", 0); + store.recompute(sheet); + for row in 0..3 { + assert_close(number(&store, sheet, row, 3), ((row + 1) * 10) as f64); + assert_close(number(&store, sheet, row, 4), ((row + 1) * 20) as f64); + assert_close(number(&store, sheet, row, 5), (row + 3) as f64); + } + assert_close(number(&store, sheet, 0, 6), 2.0); + store.set_number(sheet, 1, 0, 9.0, 0); + store.set_number(sheet, 0, 1, 4.0, 0); + store.recompute(sheet); + assert_close(number(&store, sheet, 1, 3), 90.0); + assert_close(number(&store, sheet, 1, 4), 180.0); + assert_close(number(&store, sheet, 0, 5), 1.0); + assert_close(number(&store, sheet, 3, 5), 5.0); + assert_close(number(&store, sheet, 0, 6), 5.0); + store.set_number(sheet, 6, 8, 42.0, 0); + store.set_formula(sheet, 5, 8, "={1;2}*10", 0); + store.set_formula(sheet, 5, 10, "=SEQUENCE(1001)*SEQUENCE(1,1000)", 0); + store.recompute(sheet); + assert_eq!(string(&store, sheet, 5, 8).as_deref(), Some("#SPILL!")); + assert_close(number(&store, sheet, 6, 8), 42.0); + assert_eq!(string(&store, sheet, 5, 10).as_deref(), Some("#NUM!")); +} + +#[cfg(feature = "analysis")] +#[test] +fn array_operators_feed_analysis_array_hooks() { + for (source, expected) in [ + ("=INDEX(SORTBY({1;2;3},{1;2;3}*-1),1)", 3.0), + ("=SUM(MMULT({1,2}*2,{3;4}))", 22.0), + ("=SUM(TOCOL({1,2;3,4}+10))", 50.0), + ("=SUM(MAP({1;2;3}*2,LAMBDA(x,x+1)))", 15.0), + ] { + let mut store = CellStore::new(); + let sheet = store.add_sheet(8, 8); + store.set_formula(sheet, 0, 0, source, 0); + store.recompute(sheet); + assert_eq!(string(&store, sheet, 0, 0), None, "{source}"); + assert_close(number(&store, sheet, 0, 0), expected); + } +} + #[test] fn array_constants_survive_structural_rewrites() { let mut store = CellStore::new(); diff --git a/packages/wasm/src/types.rs b/packages/wasm/src/types.rs index 2252a5af..cd3edd9b 100644 --- a/packages/wasm/src/types.rs +++ b/packages/wasm/src/types.rs @@ -330,6 +330,7 @@ pub(crate) struct FormulaEntry { /// The formula contains `A1#`. Each evaluation replaces those with the /// current spill ranges; other formulas skip that step. pub(crate) has_spill_refs: bool, + pub(crate) produces_array: bool, pub(crate) source_kind: PersistedSourceKind, } @@ -343,6 +344,7 @@ impl FormulaEntry { let (reads, volatile) = formula_metadata(&ast, sheet); Self { has_spill_refs: ast_contains_spill_ref(&ast), + produces_array: crate::eval::ast_produces_array(&ast), ast: Some(ast), source: source.to_string(), reads, @@ -372,6 +374,7 @@ impl FormulaEntry { value_kind: FormulaValueKind::Blank, volatile: false, has_spill_refs: false, + produces_array: false, source_kind: PersistedSourceKind::Reference, } } @@ -416,6 +419,7 @@ impl FormulaEntry { value_kind: FormulaValueKind::Number, volatile: false, has_spill_refs: false, + produces_array: false, source_kind: PersistedSourceKind::Formula, } } From 8773d2a8d7f9b852e475d42480fba06038d26519 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Mon, 5 Oct 2026 17:16:44 +0700 Subject: [PATCH 3/3] fix(formulas): keep scalar results and spill errors at array operands The element-wise operator path assumed every operand it reached was an array. A scalar-producing operand made the unary path report `#VALUE!` instead of the scalar result, so `=-LET(x,SEQUENCE(3),1)` lost its `-1`, and a spill reference whose anchor holds no spill replaced the scalar expression's error, so `=H1#*10` reported `#VALUE!` instead of `#REF!`. Operands that `eval_dynamic_array` answers with no matrix now evaluate as scalars, and a resolved spill reference that leaves no array keeps the scalar error. Adds the regression cases for unary `-`, `+` and `%` over a scalar LET and for a missing spill, and marks the shared scalar helpers `#[inline]`. --- packages/wasm/src/eval/mod.rs | 11 ++++++++--- packages/wasm/src/eval/operators.rs | 13 ++++++++++--- packages/wasm/src/tests.rs | 5 +++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/wasm/src/eval/mod.rs b/packages/wasm/src/eval/mod.rs index 57f7227d..07853048 100644 --- a/packages/wasm/src/eval/mod.rs +++ b/packages/wasm/src/eval/mod.rs @@ -563,9 +563,14 @@ impl CellStore { None => Err(FormulaError::Num), }, Some(Err(error)) => Err(error), - // A top-level `A1#` whose anchor holds no spill. - None if matches!(ast, Ast::InvalidRef) => Err(FormulaError::Ref), - None => Err(FormulaError::Value), + // Resolving a missing spill removes its array marker. + // Keep the scalar expression's error instead of replacing it. + None => match self.eval_ast( + &ast, output_sheet, &affected, &mut memo, &mut visiting, 0, + ) { + Value::Error(error) => Err(error), + _ => Err(FormulaError::Value), + }, }; let changed = self.install_spill_result(key, evaluated, &mut memo); // Formulas that use `A1#` read only the anchor cell. The anchor diff --git a/packages/wasm/src/eval/operators.rs b/packages/wasm/src/eval/operators.rs index 38b2410d..51814856 100644 --- a/packages/wasm/src/eval/operators.rs +++ b/packages/wasm/src/eval/operators.rs @@ -17,6 +17,7 @@ pub(super) enum UnaryOp { Percent, } +#[inline] pub(super) fn unary(op: UnaryOp, value: &Value) -> Value { match number_from_value(value) { Ok(number) => Value::number(match op { @@ -28,6 +29,7 @@ pub(super) fn unary(op: UnaryOp, value: &Value) -> Value { } } +#[inline] pub(super) fn binary>( op: Op, left: &Value, @@ -63,6 +65,7 @@ pub(super) fn binary>( } } +#[inline] pub(super) fn comparison(op: CmpOp, left: &Value, right: &Value) -> Value { let ordering = match compare_values(left, right) { Ok(ordering) => ordering, @@ -161,9 +164,13 @@ impl CellStore { Ast::Pos(_) => UnaryOp::Pos, _ => UnaryOp::Percent, }; - let mut matrix = self - .eval_dynamic_array(inner, sheet, affected, memo, visiting, depth + 1) - .ok_or(FormulaError::Value)??; + let mut matrix = + match self.operator_operand(inner, sheet, affected, memo, visiting, depth + 1)? { + Operand::Array(matrix) => matrix, + Operand::Scalar(value) => { + return Ok(EvalMatrix::new(1, 1, vec![unary(op, &value)])); + } + }; for value in &mut matrix.values { *value = unary(op, value); } diff --git a/packages/wasm/src/tests.rs b/packages/wasm/src/tests.rs index 2e891dc9..a7b9c60d 100644 --- a/packages/wasm/src/tests.rs +++ b/packages/wasm/src/tests.rs @@ -2077,6 +2077,11 @@ fn array_operators_broadcast_each_axis_and_keep_element_errors() { ("={1,#N/A,\"bad\",4}+1", 4, &["2", "#N/A", "#VALUE!", "5"]), ("=SEQUENCE(2,2)*2", 2, &["2", "4", "6", "8"]), ("=FILTER({1;2;3},{TRUE;FALSE;TRUE})+10", 1, &["11", "13"]), + ("=-LET(x,SEQUENCE(3),1)", 1, &["-1"]), + ("=+LET(x,SEQUENCE(3),2)", 1, &["2"]), + ("=LET(x,SEQUENCE(3),25)%", 1, &["0.25"]), + ("=H1#*10", 1, &["#REF!"]), + ("=-H1#", 1, &["#REF!"]), ]; for &(source, cols, expected) in cases { let mut store = CellStore::new();