Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/array-arithmetic.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 11 additions & 1 deletion docs/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion docs/src/content/docs/guides/formulas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions packages/wasm/src/eval/analysis/reshape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]])
Expand Down
44 changes: 43 additions & 1 deletion packages/wasm/src/eval/array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ fn static_integer(ast: Option<&Ast>) -> Option<i64> {
}
}

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")]
Expand All @@ -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);
Expand Down Expand Up @@ -171,6 +175,21 @@ impl CellStore {
formula_sheet: usize,
) -> Option<Result<usize, FormulaError>> {
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(
Expand Down Expand Up @@ -290,6 +309,11 @@ impl CellStore {
depth: usize,
) -> Option<Result<EvalMatrix, FormulaError>> {
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);
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 2 additions & 5 deletions packages/wasm/src/eval/dependency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AbsCellKey, Vec<AbsCellKey>>,
range_groups: Vec<RangeGroup>,
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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);
Expand Down
125 changes: 31 additions & 94 deletions packages/wasm/src/eval/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod functions;
mod lookup;
mod math;
mod matrix;
mod operators;
mod range_reader;
mod statistics;
mod text;
Expand All @@ -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::{
Expand All @@ -30,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::{
Expand All @@ -46,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;
Expand Down Expand Up @@ -487,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()
Expand Down Expand Up @@ -559,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
Expand Down Expand Up @@ -1033,99 +1042,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
Expand Down
Loading
Loading