Merge pull request #115 from nihilist-narwhal/feat-spill-range-operator
Formulas: the spill range operator (A1#)
This commit is contained in:
commit
54df7e1fc5
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -272,6 +272,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