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/spill-read-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@sheetwrite/wasm": patch
"@sheetwrite/formulas": patch
---

Fix a spill that reads another spill showing the previous values. When the spill it reads is placed later in the same recalculation, the reading spill is now calculated again. Spills that read each other show #CYCLE!.
8 changes: 8 additions & 0 deletions .changeset/spill-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@sheetwrite/wasm": patch
"@sheetwrite/formulas": patch
"@sheetwrite/core": patch
"@sheetwrite/xlsx": patch
---

Support spill references such as `A1#`. A spill reference refers to the current spill of its anchor cell and follows it when the spill changes size. It returns `#REF!` when the anchor holds no spill. Row and column edits move the reference with its anchor. XLSX import and export convert it to and from `_xlfn.ANCHORARRAY`.
10 changes: 9 additions & 1 deletion docs/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2116,11 +2116,19 @@ Elements can be numbers with an optional sign, quoted text, `TRUE`, `FALSE`, or

Functions that accept arrays can use these values directly, for example `=SUM({1,2,3})` returns `6` and `=INDEX({1,2;3,4},2,1)` returns `3`. Copy and structural edits preserve constants and their text elements. XLSX import and export preserve the formula source.

## Spill references

Put `#` after a cell reference to refer to the whole spill of the formula in that cell. If `A1` contains `=SEQUENCE(D1)`, then `=SUM(A1#)` adds all the values in the spill, and `=SORT(A1#,1,-1)` spills them in reverse order. When the spill changes size, formulas that use `A1#` use the new range. A sheet name is permitted: `='Report'!B2#`.

`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`.

## Operators

| Precedence, high to low | Operators | Associativity |
| --- | --- | --- |
| Reference | `:` | — |
| Reference | `:`, postfix `#` (spill) | — |
| Unary sign | unary `+`, unary `-` | right |
| Percentage | postfix `%` | left |
| Exponentiation | `^` | left |
Expand Down
10 changes: 9 additions & 1 deletion docs/src/content/docs/guides/formulas.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,19 @@ Elements can be numbers with an optional sign, quoted text, `TRUE`, `FALSE`, or

Functions that accept arrays can use these values directly, for example `=SUM({1,2,3})` returns `6` and `=INDEX({1,2;3,4},2,1)` returns `3`. Copy and structural edits preserve constants and their text elements. XLSX import and export preserve the formula source.

## Spill references

Put `#` after a cell reference to refer to the whole spill of the formula in that cell. If `A1` contains `=SEQUENCE(D1)`, then `=SUM(A1#)` adds all the values in the spill, and `=SORT(A1#,1,-1)` spills them in reverse order. When the spill changes size, formulas that use `A1#` use the new range. A sheet name is permitted: `='Report'!B2#`.

`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`.

## Operators

| Precedence, high to low | Operators | Associativity |
| --- | --- | --- |
| Reference | `:` | — |
| Reference | `:`, postfix `#` (spill) | — |
| Unary sign | unary `+`, unary `-` | right |
| Percentage | postfix `%` | left |
| Exponentiation | `^` | left |
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/a1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,14 @@ export function remapFormulaA1Refs(
remap: (index: number) => number | null,
): string {
return source.replace(
/"(?:[^"]|"")*"|'(?:[^']|'')*'|(\$?)([A-Za-z]{1,3})(\$?)([0-9]+)/g,
/"(?:[^"]|"")*"|'(?:[^']|'')*'|(\$?)([A-Za-z]{1,3})(\$?)([0-9]+)(#?)/g,
(
match: string,
colAbsolute: string,
letters: string,
rowAbsolute: string,
digits: string,
spill: string,
offset: number,
full: string,
): string => {
Expand All @@ -100,10 +101,11 @@ export function remapFormulaA1Refs(
const col = labelToCol(letters.toUpperCase());
const row = Number(digits) - 1;
const mapped = remap(axis === "row" ? row : col);
// A deleted anchor also ends its spill reference: `A1#` becomes `#REF!`.
if (mapped === null) return "#REF!";
return axis === "row"
? `${colAbsolute}${colToA1(col)}${rowAbsolute}${mapped + 1}`
: `${colAbsolute}${colToA1(mapped)}${rowAbsolute}${row + 1}`;
? `${colAbsolute}${colToA1(col)}${rowAbsolute}${mapped + 1}${spill}`
: `${colAbsolute}${colToA1(mapped)}${rowAbsolute}${row + 1}${spill}`;
},
);
}
10 changes: 10 additions & 0 deletions packages/core/test/a1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ describe("shiftA1Refs", () => {
'=SUM({1,2;3,4})+B3&{"A1","a""B2"}',
);
});

it("keeps the spill suffix of a filled reference", () => {
expect(shiftA1Refs("=SUM(A1#)+$B$2#", 1, 1)).toBe("=SUM(B2#)+$B$2#");
});
});

describe("remapFormulaA1Refs", () => {
Expand All @@ -46,6 +50,12 @@ describe("remapFormulaA1Refs", () => {
expect(remapFormulaA1Refs("=A1+B1+C1", "column", removeColumn)).toBe("=A1+#REF!+B1");
});

it("moves spill references with their anchor and drops the suffix when it is deleted", () => {
const removeRow = (row: number) => (row === 1 ? null : row > 1 ? row - 1 : row);
expect(remapFormulaA1Refs("=SUM(A3#)", "row", removeRow)).toBe("=SUM(A2#)");
expect(remapFormulaA1Refs("=SUM(A2#)", "row", removeRow)).toBe("=SUM(#REF!)");
});

it("preserves array text while remapping adjacent references", () => {
expect(remapFormulaA1Refs('=A1&{"A1","B2";"C3","D4"}', "row", (row) => row + 1)).toBe(
'=A2&{"A1","B2";"C3","D4"}',
Expand Down
52 changes: 50 additions & 2 deletions packages/wasm/src/calc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ pub enum Func {
Rate,
Ipmt,
Ppmt,
/// `A1#`: the current spill of the anchor cell. Only `#` after a cell
/// reference makes it; the name is not in the function table.
AnchorArray,
#[cfg(feature = "analysis")]
Analysis(u16),
}
Expand Down Expand Up @@ -257,6 +260,7 @@ macro_rules! define_function_registry {
fn func_name(func: Func) -> &'static str {
match func {
$(Func::$variant => $canonical,)+
Func::AnchorArray => "ANCHORARRAY",
#[cfg(feature = "analysis")]
Func::Analysis(id) => crate::eval::analysis::name(id),
}
Expand Down Expand Up @@ -760,6 +764,8 @@ enum Tok<'a> {
Bang,
Cmp(CmpOp),
Error(FormulaError),
/// The `#` after a cell reference: the whole spill of that anchor.
Hash,
}

/// Error constants a formula can contain. `#REF!` parses as an invalid
Expand Down Expand Up @@ -944,6 +950,10 @@ fn tokenize(src: &str) -> Result<Vec<Tok<'_>>, String> {
toks.push(Tok::Colon);
i += 1;
}
b'#' => {
toks.push(Tok::Hash);
i += 1;
}
_ if !c.is_ascii() => {
let ch = src[i..].chars().next().expect("valid UTF-8 source");
if ch.is_whitespace() {
Expand Down Expand Up @@ -1273,7 +1283,7 @@ impl<'a> Parser<'a> {
_ => Err("expected cell after :".into()),
}
} else {
Ok(Ast::Cell(row, col, flags))
Ok(self.spill_suffix(Ast::Cell(row, col, flags)))
}
}
}
Expand All @@ -1298,7 +1308,18 @@ impl<'a> Parser<'a> {
return Ok(Ast::SheetRange(qualifier, r0, c0, r1, c1, range_flags));
}

Ok(Ast::SheetCell(qualifier, row, col, flags))
Ok(self.spill_suffix(Ast::SheetCell(qualifier, row, col, flags)))
}

/// `A1#` refers to the whole spill of the formula in A1. It is stored as
/// `ANCHORARRAY(A1)`, the form that XLSX files use.
fn spill_suffix(&mut self, anchor: Ast) -> Ast {
if self.peek() == Some(&Tok::Hash) {
let _ = self.next();
Ast::Func(Func::AnchorArray, vec![anchor])
} else {
anchor
}
}

fn sheet_range_end(
Expand Down Expand Up @@ -2091,6 +2112,13 @@ fn write_ast(ast: &Ast, out: &mut String) {
out.push('}');
}
Ast::LetSlot { expression, .. } => write_ast(expression, out),
Ast::Func(Func::AnchorArray, args) if args.len() == 1 => {
write_ast(&args[0], out);
// A deleted anchor writes as #REF!, which has no spill suffix.
if !matches!(args[0], Ast::InvalidRef) {
out.push('#');
}
}
Ast::Func(func, args) => {
out.push_str(func_name(*func));
out.push('(');
Expand Down Expand Up @@ -2214,6 +2242,26 @@ fn write_col(mut col: u32, out: &mut String) {
mod tests {
use super::*;

#[test]
fn spill_references_parse_and_serialize_with_a_hash_suffix() {
for (source, expected) in [
("=A1#", "=A1#"),
("=sum($b$2#)", "=SUM($B$2#)"),
("=ROWS('My Sheet'!C3#)", "=ROWS('My Sheet'!C3#)"),
] {
assert_eq!(serialize(&parse(source).unwrap()), expected, "{source}");
}
assert_eq!(
parse("=A1#").unwrap(),
Ast::Func(Func::AnchorArray, vec![Ast::Cell(0, 0, RefFlags::default())])
);
// `#` only follows a cell, and the function name cannot be typed.
for source in ["=A1:B2#", "=#", "=1#"] {
assert!(parse(source).is_err(), "{source}");
}
assert!(matches!(parse("=ANCHORARRAY(A1)"), Ok(Ast::UnknownFunc(..))));
}

#[test]
fn parse_a1_accepts_absolute_markers() {
assert_eq!(parse_a1("A1"), Some((0, 0, RefFlags::default())));
Expand Down
68 changes: 67 additions & 1 deletion packages/wasm/src/eval/array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::collections::{hash_map::DefaultHasher, HashMap};
use std::hash::{Hash, Hasher};
use std::mem::size_of;

use crate::calc::{Ast, Func};
use crate::calc::{Ast, Func, RangeFlags, SheetRef};
use crate::store::CellStore;
use crate::types::{AbsCellKey, EvalResult, FormulaError, Value};

Expand All @@ -32,6 +32,7 @@ fn static_integer(ast: Option<&Ast>) -> Option<i64> {

pub(super) fn ast_produces_array(ast: &Ast) -> bool {
match ast {
Ast::Func(Func::AnchorArray, _) => true,
#[cfg(feature = "analysis")]
Ast::BoundMatrix { .. } => true,
#[cfg(feature = "analysis")]
Expand Down Expand Up @@ -105,12 +106,68 @@ fn transformed_axis_bound(size: usize, requested: i64, drop: bool) -> Result<usi
}

impl CellStore {
/// The range that the anchor of `A1#` spills into now, or `#REF!` when
/// the anchor holds no spill.
fn spill_reference(&self, args: &[Ast], formula_sheet: usize) -> Ast {
let (sheet, row, col) = match args {
[Ast::Cell(row, col, _)] => (formula_sheet, *row, *col),
[Ast::AbsCell(sheet, row, col, _)] => (sheet.handle as usize, *row, *col),
_ => return Ast::InvalidRef,
};
let Some(range) = self
.sheets
.get(sheet)
.and_then(|data| data.installed_spill((row, col)))
else {
return Ast::InvalidRef;
};
let sheet = SheetRef {
handle: sheet as u32,
name: String::new(),
quoted: false,
};
Ast::AbsRange(sheet, row, col, range.row_end, range.col_end, RangeFlags::default())
}

/// A copy of `ast` in which every `A1#` is the range that its anchor
/// spills into now. Range functions such as SUMIF, INDEX and ROWS then
/// accept spill references without special handling.
pub(super) fn resolve_spill_refs(&self, ast: &Ast, sheet: usize) -> Ast {
let mut resolved = ast.clone();
self.replace_spill_refs(&mut resolved, sheet);
resolved
}

fn replace_spill_refs(&self, ast: &mut Ast, sheet: usize) {
match ast {
Ast::Func(Func::AnchorArray, args) => *ast = self.spill_reference(args, sheet),
Ast::Func(_, args) | Ast::UnknownFunc(_, args) => {
for arg in args {
self.replace_spill_refs(arg, sheet);
}
}
Ast::Bin(_, left, right) | Ast::Cmp(_, left, right) => {
self.replace_spill_refs(left, sheet);
self.replace_spill_refs(right, sheet);
}
Ast::Neg(inner) | Ast::Pos(inner) | Ast::Percent(inner) => {
self.replace_spill_refs(inner, sheet);
}
Ast::LetSlot { expression, .. } => self.replace_spill_refs(expression, sheet),
_ => {}
}
}

pub(super) fn dynamic_array_bound(
&self,
ast: &Ast,
formula_sheet: usize,
) -> Option<Result<usize, FormulaError>> {
match ast {
Ast::Func(Func::AnchorArray, args) => {
let range = self.spill_reference(args, formula_sheet);
Some(self.matrix_shape(&range, formula_sheet).map(|(_, _, cells)| cells))
}
#[cfg(feature = "analysis")]
Ast::BoundMatrix { rows, cols, .. } => Some(EvalMatrix::validate_shape(*rows, *cols, 1, 0)),
#[cfg(feature = "analysis")]
Expand Down Expand Up @@ -220,6 +277,11 @@ impl CellStore {
depth: usize,
) -> Option<Result<EvalMatrix, FormulaError>> {
let result = match ast {
Ast::Func(Func::AnchorArray, args) => {
let range = self.spill_reference(args, sheet);
return self.eval_dynamic_array(&range, sheet, affected, memo, visiting, depth);
}
Ast::InvalidRef => Err(FormulaError::Ref),
#[cfg(feature = "analysis")]
Ast::BoundMatrix { rows, cols, values } => {
Ok(EvalMatrix::new(*rows, *cols, values.as_ref().clone()))
Expand Down Expand Up @@ -319,6 +381,10 @@ impl CellStore {
formula_sheet: usize,
) -> Result<(usize, usize, usize), FormulaError> {
match ast {
Ast::Func(Func::AnchorArray, args) => {
return self.matrix_shape(&self.spill_reference(args, formula_sheet), formula_sheet);
}
Ast::InvalidRef => return Err(FormulaError::Ref),
#[cfg(feature = "analysis")]
Ast::BoundMatrix { rows, cols, .. } => {
return Ok((*rows, *cols, EvalMatrix::validate_shape(*rows, *cols, 1, 0)?));
Expand Down
31 changes: 31 additions & 0 deletions packages/wasm/src/eval/dependency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,37 @@ pub(super) fn collect_affected_formulas(
affected
}

/// Formulas that read any of `cells` directly, by cell or by range. Unlike
/// [`collect_affected_formulas`], this does not follow dependents further.
pub(super) fn direct_readers(
index: &DepIndex,
sheet: usize,
cells: &[(u32, u32)],
) -> HashSet<AbsCellKey> {
let mut readers = HashSet::new();
let mut range_matches: Vec<usize> = Vec::new();
let mut row_matches: Vec<usize> = Vec::new();
let mut range_marks = vec![0; index.range_groups.len()];
let mut range_stamp = 1;
for &cell in cells {
let cell = AbsCellKey::from_local(sheet, cell);
if let Some(dependents) = index.exact_dependents.get(&cell) {
readers.extend(dependents.iter().copied());
}
index.collect_matching_range_groups(
cell,
&mut range_matches,
&mut row_matches,
&mut range_marks,
&mut range_stamp,
);
for &group in &range_matches {
readers.extend(index.range_groups[group].dependents.iter().copied());
}
}
readers
}

fn formula_exists(sheets: &[SheetData], key: AbsCellKey) -> bool {
sheets
.get(key.sheet as usize)
Expand Down
Loading
Loading