Formulas: the spill range operator A1#
formula::parse("Sheet1!$D$1#") failed with "unknown error literal".
A # right after a cell reference is now the spill range operator: it
evaluates to the anchor's current spill range, or #REF! when the cell
doesn't spill (or its spill is blocked). Its users depend on the
anchor, so they recalculate when the spill grows or shrinks. Names can
use it.
Files have no # operator: like Excel, the writer stores A1# as
_xlfn.ANCHORARRAY(A1), and the parser reads that back as A1#.
This commit is contained in:
parent
13664fe66b
commit
9c29ef3fff
|
|
@ -54,6 +54,10 @@ pub trait Host {
|
|||
fn random(&mut self) -> f64;
|
||||
/// Called when a volatile or dynamic-reference function is used.
|
||||
fn note_volatile(&mut self) {}
|
||||
/// The spill range of the dynamic array anchored at `anchor` (`None`: it doesn't spill).
|
||||
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
|
||||
self.workbook().sheet(sheet)?.spill_ranges.get(&anchor).copied()
|
||||
}
|
||||
/// Values of a block, row-major. Hosts can override with a sparse fast path.
|
||||
fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec<Value> {
|
||||
range.iter().map(|c| self.cell_value(sheet, c)).collect()
|
||||
|
|
@ -322,6 +326,19 @@ impl<'h> Evaluator<'h> {
|
|||
}
|
||||
|
||||
fn unary(&mut self, op: UnOp, x: &Expr) -> Ev {
|
||||
if op == UnOp::Spill {
|
||||
return match self.eval(x) {
|
||||
Ev::R(areas) => match areas.as_slice() {
|
||||
[a] if a.range.is_single() => match self.host.spill_range(a.sheet, a.range.start) {
|
||||
Some(range) => Ev::R(vec![Area { sheet: a.sheet, range }]),
|
||||
None => err(CellError::Ref),
|
||||
},
|
||||
_ => err(CellError::Ref),
|
||||
},
|
||||
Ev::V(Value::Error(e)) => err(e),
|
||||
_ => err(CellError::Ref),
|
||||
};
|
||||
}
|
||||
if op == UnOp::At {
|
||||
let ev = self.eval(x);
|
||||
return match ev {
|
||||
|
|
@ -343,7 +360,7 @@ impl<'h> Evaluator<'h> {
|
|||
UnOp::Neg => Value::number(-n),
|
||||
UnOp::Plus => Value::number(n),
|
||||
UnOp::Percent => Value::number(n / 100.0),
|
||||
UnOp::At => Value::number(n),
|
||||
UnOp::At | UnOp::Spill => Value::number(n),
|
||||
},
|
||||
op == UnOp::Plus,
|
||||
))
|
||||
|
|
|
|||
|
|
@ -225,6 +225,26 @@ impl Host for PassHost<'_> {
|
|||
fn workbook(&self) -> &Workbook {
|
||||
self.wb
|
||||
}
|
||||
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
|
||||
let k = (sheet, anchor);
|
||||
if !(self.pending.contains(&k) || self.results.contains_key(&k)) {
|
||||
return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied();
|
||||
}
|
||||
// Recalculated in this pass: its new array, unless something blocks it (#SPILL!).
|
||||
self.compute(k);
|
||||
let arr = self.spills.get(&k)?;
|
||||
let end = CellRef::new(
|
||||
anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)),
|
||||
anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)),
|
||||
);
|
||||
let range = RangeRef::new(anchor, end);
|
||||
let sh = self.wb.sheet(sheet)?;
|
||||
let blocked = end.row >= gridcraft_core::MAX_ROWS
|
||||
|| end.col >= gridcraft_core::MAX_COLS
|
||||
|| sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty()))
|
||||
|| sh.merges.iter().any(|m| m.intersects(&range));
|
||||
(!blocked).then_some(range)
|
||||
}
|
||||
fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value {
|
||||
let k = (sheet, c);
|
||||
if self.pending.contains(&k) || self.results.contains_key(&k) {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,40 @@ impl T {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_range_operator() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "3");
|
||||
t.set("B1", "=SEQUENCE(A1)");
|
||||
t.set("C1", "=SUM(B1#)");
|
||||
t.set("C2", "=ROWS(B1#)");
|
||||
assert_eq!((t.num("C1"), t.num("C2")), (6.0, 3.0));
|
||||
// Growing and shrinking the spill recalculates its users.
|
||||
t.set("A1", "5");
|
||||
assert_eq!((t.num("C1"), t.num("C2")), (15.0, 5.0));
|
||||
t.set("A1", "2");
|
||||
assert_eq!((t.num("C1"), t.num("C2")), (3.0, 2.0));
|
||||
// A blocked spill, or a cell that doesn't spill, gives #REF!.
|
||||
t.set("B2", "x");
|
||||
assert_eq!(t.get("C1"), Value::Error(CellError::Ref));
|
||||
t.set("B2", "");
|
||||
assert_eq!(t.num("C1"), 3.0);
|
||||
t.set("D1", "=SUM(A1#)");
|
||||
assert_eq!(t.get("D1"), Value::Error(CellError::Ref));
|
||||
// Through a defined name.
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Seq".into(),
|
||||
scope: None,
|
||||
formula: "Sheet1!$B$1#".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
t.set("E1", "=SUM(Seq)*10");
|
||||
assert_eq!(t.num("E1"), 30.0);
|
||||
t.set("A1", "4");
|
||||
assert_eq!(t.num("E1"), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_dependencies() {
|
||||
let mut t = T::new();
|
||||
|
|
|
|||
|
|
@ -110,6 +110,23 @@ fn formatting_and_styles() {
|
|||
s.execute("home.cellStyle", json!({"range": "D1", "name": "Good"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_references_roundtrip_through_xlsx() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=SEQUENCE(4)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=sum(b1#)"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Seq", "refersTo": "=Sheet1!$B$1#"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=MAX(Seq)"})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "C1"})).unwrap()["formula"], "=SUM(B1#)");
|
||||
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0)));
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "C1"})).unwrap()["formula"], "=SUM(B1#)");
|
||||
assert_eq!(s.doc().unwrap().wb.names[0].formula, "Sheet1!$B$1#");
|
||||
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_and_structured_refs() {
|
||||
let mut s = s();
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ pub enum UnOp {
|
|||
Percent,
|
||||
/// Implicit intersection `@`.
|
||||
At,
|
||||
/// Spill range operator `A1#`: the whole spill range of the dynamic array anchored at the
|
||||
/// cell (`ANCHORARRAY` in files).
|
||||
Spill,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -124,6 +124,11 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
|
|||
push(&mut out, Tok::Sheet(a, b), j + 1);
|
||||
i = j + 1;
|
||||
}
|
||||
'#' if out.last().is_some_and(|t: &Token| t.end == start && matches!(t.tok, Tok::Word(_))) => {
|
||||
// Spill range operator right after a reference: `A1#`.
|
||||
push(&mut out, Tok::Op("#"), i + 1);
|
||||
i += 1;
|
||||
}
|
||||
'#' => {
|
||||
// Error literal, or #-prefixed struct specifier (only inside [] which we lex as Struct).
|
||||
let rest = src.get(start..).unwrap_or("");
|
||||
|
|
|
|||
|
|
@ -223,7 +223,14 @@ impl Parser {
|
|||
Tok::LBrace => self.array(),
|
||||
Tok::Func(name) => {
|
||||
let name = normalize_function_name(&name);
|
||||
let args = self.args()?;
|
||||
let mut args = self.args()?;
|
||||
// Files write `A1#` as `_xlfn.ANCHORARRAY(A1)`.
|
||||
if name == "ANCHORARRAY"
|
||||
&& let [Expr::Ref(Reference { kind: RefKind::Cell(_), .. })] = args.as_slice()
|
||||
&& let Some(r) = args.pop()
|
||||
{
|
||||
return Ok(Expr::Unary(UnOp::Spill, Box::new(r)));
|
||||
}
|
||||
Ok(Expr::Call(name, args))
|
||||
}
|
||||
Tok::Sheet(a, b) => {
|
||||
|
|
@ -263,7 +270,12 @@ impl Parser {
|
|||
self.next();
|
||||
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Range(a, b) }));
|
||||
}
|
||||
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }));
|
||||
let cell = Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) });
|
||||
if *self.peek() == Tok::Op("#") {
|
||||
self.next();
|
||||
return Ok(Expr::Unary(UnOp::Spill, Box::new(cell)));
|
||||
}
|
||||
return Ok(cell);
|
||||
}
|
||||
let second = match self.peek_at(1) {
|
||||
Tok::Word(w2) => Some(w2.clone()),
|
||||
|
|
|
|||
|
|
@ -186,6 +186,13 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
|
|||
write_expr(s, x, r1c1);
|
||||
s.push('%');
|
||||
}
|
||||
UnOp::Spill => {
|
||||
write_expr(s, x, r1c1);
|
||||
// A reference that became #REF! (its sheet or cell was deleted) stays `#REF!`.
|
||||
if !matches!(**x, Expr::Error(_)) {
|
||||
s.push('#');
|
||||
}
|
||||
}
|
||||
},
|
||||
Expr::Binary(op, a, b) => {
|
||||
write_expr(s, a, r1c1);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,22 @@ fn normalises() {
|
|||
assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_operator() {
|
||||
for f in ["A1#", "Sheet1!$D$1#", "SUM(B2#)", "'My Sheet'!A1#*2", "ROWS(A1#)+1", "IF(A1,#N/A,#REF!)"] {
|
||||
assert_eq!(rt(f), f);
|
||||
}
|
||||
assert!(matches!(parse("A1#").unwrap(), Expr::Unary(UnOp::Spill, _)));
|
||||
// Files spell it ANCHORARRAY.
|
||||
assert_eq!(rt("_xlfn.ANCHORARRAY(Sheet1!$D$1)"), "Sheet1!$D$1#");
|
||||
assert_eq!(rt("SUM(ANCHORARRAY(B2))"), "SUM(B2#)");
|
||||
// Only a cell can spill.
|
||||
assert!(parse("A1:B2#").is_err());
|
||||
assert!(parse("Name#").is_err());
|
||||
// Deleting the anchor's sheet leaves #REF!.
|
||||
assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)").unwrap(), "Data")), "SUM(#REF!)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence() {
|
||||
let e = parse("1+2*3").unwrap();
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ fn expr_to_file(e: Expr) -> String {
|
|||
Some(n) => Expr::Call(n, args),
|
||||
None => Expr::Call(name, args),
|
||||
},
|
||||
// Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`.
|
||||
Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]),
|
||||
other => other,
|
||||
});
|
||||
gridcraft_formula::print(&e)
|
||||
|
|
@ -45,6 +47,13 @@ pub fn from_file(text: &str) -> String {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn spill_references() {
|
||||
assert_eq!(to_file(&Formula::new("SUM(Sheet1!$D$1#)+B2#")), "SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)");
|
||||
assert_eq!(text_to_file("=Sheet1!$D$1#"), "_xlfn.ANCHORARRAY(Sheet1!$D$1)");
|
||||
assert_eq!(from_file("SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"), "SUM(Sheet1!$D$1#)+B2#");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixes() {
|
||||
assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)");
|
||||
|
|
|
|||
Loading…
Reference in New Issue