From 691883f99c65772ed3d25a6247f3e71b28c0f184 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 20:56:16 +0200 Subject: [PATCH 1/6] Functions: VLOOKUP/HLOOKUP/LOOKUP/XLOOKUP keep an empty target blank (#374) A looked-up empty cell was turned into the number 0 inside the function, so ISBLANK saw FALSE and & / CONCATENATE inserted "0". The value now stays blank, like INDEX; a bare lookup still displays 0 as a formula's final result. Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 23 +++++++++++++++++++++++ crates/functions/src/lookup.rs | 22 ++++++++++------------ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 513340983..ac58b3d31 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -968,3 +968,26 @@ fn trim_references() { t.set("K20", "=SUM(Q1:.Q9)"); assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); } + +#[test] +fn lookup_of_an_empty_cell_stays_blank() { + // #374: B1 (the value for key "k") is empty. + let mut t = T::new(); + t.set("A1", "k"); + t.set("A2", "j"); + t.set("B2", "v"); + for (at, f, want) in [ + ("D1", "=ISBLANK(VLOOKUP(\"k\",A1:B2,2,0))", Value::Bool(true)), + ("D2", "=\"x\"&VLOOKUP(\"k\",A1:B2,2,0)&\"y\"", Value::from("xy")), + ("D3", "=CONCATENATE(\"x\",VLOOKUP(\"k\",A1:B2,2,0),\"y\")", Value::from("xy")), + ("D4", "=ISBLANK(XLOOKUP(\"k\",A1:A2,B1:B2))", Value::Bool(true)), + ("D5", "=\"x\"&XLOOKUP(\"k\",A1:A2,B1:B2)&\"y\"", Value::from("xy")), + ("D6", "=ISBLANK(LOOKUP(\"k\",A1:A1,B1:B1))", Value::Bool(true)), + ("D7", "=ISBLANK(HLOOKUP(\"j\",A2:B3,2,0))", Value::Bool(true)), + // A bare lookup still shows 0, like a reference to an empty cell. + ("D8", "=VLOOKUP(\"k\",A1:B2,2,0)", Value::Number(0.0)), + ] { + t.set(at, f); + assert_eq!(t.get(at), want, "{f}"); + } +} diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index 56739c933..a04ce9960 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -119,11 +119,6 @@ fn row(a: &Array, r: usize) -> Vec { (0..a.cols).map(|c| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect() } -/// A looked-up cell value: blanks read as 0 like a reference to an empty cell. -fn cell_result(v: Value) -> Value { - if matches!(v, Value::Empty) { Value::Number(0.0) } else { v } -} - // --------------------------------------------------------------------------------------------- // VLOOKUP / HLOOKUP / LOOKUP / MATCH @@ -146,7 +141,9 @@ fn vh_lookup(args: &[Arg], vertical: bool) -> R { let pos = if approx { bsearch_last(&x, &keys, false) } else { find_exact(&x, &keys, true, false) }; let pos = pos.ok_or(CellError::NA)?; let v = if vertical { table.get(pos, idx) } else { table.get(idx, pos) }; - Ok(cell_result(v.cloned().unwrap_or(Value::Empty))) + // An empty target stays blank, as in Excel: ISBLANK sees it as blank and `&` / + // CONCATENATE treat it as ""; only a formula's final result displays 0 (#374). + Ok(v.cloned().unwrap_or(Value::Empty)) } fn vlookup(a: &[Arg], _c: &mut dyn Ctx) -> R { @@ -179,18 +176,18 @@ fn lookup(args: &[Arg], _c: &mut dyn Ctx) -> R { } else { None }; - return v.map(cell_result).ok_or(CellError::NA); + return v.ok_or(CellError::NA); } // Array form: search the first row of a wide array, else the first column; return the // matching entry from the last row / column. if la.cols > la.rows { let keys = row(&la, 0); let pos = bsearch_last(&x, &keys, false).ok_or(CellError::NA)?; - Ok(cell_result(la.get(la.rows - 1, pos).cloned().unwrap_or(Value::Empty))) + Ok(la.get(la.rows - 1, pos).cloned().unwrap_or(Value::Empty)) } else { let keys = column(&la, 0); let pos = bsearch_last(&x, &keys, false).ok_or(CellError::NA)?; - Ok(cell_result(la.get(pos, la.cols - 1).cloned().unwrap_or(Value::Empty))) + Ok(la.get(pos, la.cols - 1).cloned().unwrap_or(Value::Empty)) } } @@ -311,13 +308,13 @@ fn xlookup(args: &[Arg], _c: &mut dyn Ctx) -> R { if vertical { let r = row(&ra, p); if r.len() == 1 { - return Ok(cell_result(r.into_iter().next().unwrap_or(Value::Empty))); + return Ok(r.into_iter().next().unwrap_or(Value::Empty)); } array_val(1, r.len(), r) } else { let c = column(&ra, p); if c.len() == 1 { - return Ok(cell_result(c.into_iter().next().unwrap_or(Value::Empty))); + return Ok(c.into_iter().next().unwrap_or(Value::Empty)); } array_val(c.len(), 1, c) } @@ -581,7 +578,8 @@ mod tests { is_err(ev("VLOOKUP", vec![n(4.0), rf(table()), n(2.0), b(false)]), CellError::NA); is_err(ev("VLOOKUP", vec![n(4.0), rf(table()), n(2.0), empty()]), CellError::NA); close(ev("VLOOKUP", vec![n(3.0), rf(table()), n(3.0), b(false)]), 30.0); - close(ev("VLOOKUP", vec![n(5.0), rf(table()), n(3.0), b(false)]), 0.0); + // An empty target stays blank (the cell displays 0 only as a final result) (#374). + assert_eq!(ev("VLOOKUP", vec![n(5.0), rf(table()), n(3.0), b(false)]), Value::Empty); is_err(ev("VLOOKUP", vec![n(3.0), rf(table()), n(4.0)]), CellError::Ref); is_err(ev("VLOOKUP", vec![n(3.0), rf(table()), n(0.0)]), CellError::Value); close(ev("VLOOKUP", vec![t("b*"), rf(names()), n(2.0), b(false)]), 2.0); From d2b9affbc519c54d438e3b6b74f930a0c3de6c8f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 20:59:09 +0200 Subject: [PATCH 2/6] Functions: test that arithmetic reads a blank lookup result as 0 (#374) Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index ac58b3d31..fe55dc3e1 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -986,6 +986,8 @@ fn lookup_of_an_empty_cell_stays_blank() { ("D7", "=ISBLANK(HLOOKUP(\"j\",A2:B3,2,0))", Value::Bool(true)), // A bare lookup still shows 0, like a reference to an empty cell. ("D8", "=VLOOKUP(\"k\",A1:B2,2,0)", Value::Number(0.0)), + // Arithmetic still reads the blank as 0. + ("D9", "=VLOOKUP(\"k\",A1:B2,2,0)+1", Value::Number(1.0)), ] { t.set(at, f); assert_eq!(t.get(at), want, "{f}"); From 423810750d8d43f732163d57267905a0110afe83 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 19:18:01 +0200 Subject: [PATCH 3/6] Functions: don't assert ISBLANK(VLOOKUP/HLOOKUP/LOOKUP), unverified against Excel (#374) Co-Authored-By: Claude Sonnet 5.5 --- crates/calc/src/tests.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index fe55dc3e1..cfce701d1 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -977,13 +977,10 @@ fn lookup_of_an_empty_cell_stays_blank() { t.set("A2", "j"); t.set("B2", "v"); for (at, f, want) in [ - ("D1", "=ISBLANK(VLOOKUP(\"k\",A1:B2,2,0))", Value::Bool(true)), ("D2", "=\"x\"&VLOOKUP(\"k\",A1:B2,2,0)&\"y\"", Value::from("xy")), ("D3", "=CONCATENATE(\"x\",VLOOKUP(\"k\",A1:B2,2,0),\"y\")", Value::from("xy")), ("D4", "=ISBLANK(XLOOKUP(\"k\",A1:A2,B1:B2))", Value::Bool(true)), ("D5", "=\"x\"&XLOOKUP(\"k\",A1:A2,B1:B2)&\"y\"", Value::from("xy")), - ("D6", "=ISBLANK(LOOKUP(\"k\",A1:A1,B1:B1))", Value::Bool(true)), - ("D7", "=ISBLANK(HLOOKUP(\"j\",A2:B3,2,0))", Value::Bool(true)), // A bare lookup still shows 0, like a reference to an empty cell. ("D8", "=VLOOKUP(\"k\",A1:B2,2,0)", Value::Number(0.0)), // Arithmetic still reads the blank as 0. From 9977d850a4db34ebe2028ebe0320e027e43eb3af Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 17 Oct 2026 15:23:42 +0200 Subject: [PATCH 4/6] test(calc): Excel-verified blank results for VLOOKUP, HLOOKUP and LOOKUP (#374) Excel 365 for Windows (2609, build 16.0.20430.20032) keeps a lookup of an empty cell blank inside ISBLANK and concatenation for VLOOKUP, HLOOKUP and LOOKUP. The cases were checked in Excel by scottcampbelldata in vangel76/gridcraft#1. Co-Authored-By: scottcampbelldata <251750033+scottcampbelldata@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 4827b1bcc..357249f56 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1193,6 +1193,24 @@ fn lookup_of_an_empty_cell_stays_blank() { } } +#[test] +fn blank_lookup_values_match_excel_windows_observation() { + // Excel 365 Windows 2609, build 16.0.20430.20032, black-box check 2026-10-13: + // A1="k", B1 and A2 genuinely empty. + // All three value lookups preserve blankness inside ISBLANK/concatenation. + let mut t = T::new(); + t.set("A1", "k"); + for (at, f, want) in [ + ("D1", "=ISBLANK(VLOOKUP(\"k\",A1:B1,2,FALSE))", Value::Bool(true)), + ("D2", "=ISBLANK(HLOOKUP(\"k\",A1:A2,2,FALSE))", Value::Bool(true)), + ("D3", "=ISBLANK(LOOKUP(\"k\",A1:A1,B1:B1))", Value::Bool(true)), + ("D4", "=\"x\"&HLOOKUP(\"k\",A1:A2,2,FALSE)&\"y\"", Value::from("xy")), + ] { + t.set(at, f); + assert_eq!(t.get(at), want, "{f}"); + } +} + #[test] fn cell_width_is_the_column_width_dialog_value_rounded() { // #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them From 63150810b35539a05492c1269bb438ff4a2f7766 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 13:21:44 +0100 Subject: [PATCH 5/6] fix(functions): a blank lookup given as an argument is not an omitted one FILTER(1,FALSE,VLOOKUP(...)) with a blank lookup result returned #CALC! because util::has read the blank value as a left-out argument. Arguments written in the formula that compute a blank are now marked as given. Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/eval/call.rs | 10 ++++++---- crates/calc/src/eval/reference_fns.rs | 7 ++++--- crates/calc/src/tests.rs | 3 +++ crates/functions/src/info.rs | 2 +- crates/functions/src/lib.rs | 7 +++++-- crates/functions/src/lift.rs | 7 +------ crates/functions/src/util.rs | 2 +- 7 files changed, 21 insertions(+), 17 deletions(-) diff --git a/crates/calc/src/eval/call.rs b/crates/calc/src/eval/call.rs index 8ac58c8d1..848762ab9 100644 --- a/crates/calc/src/eval/call.rs +++ b/crates/calc/src/eval/call.rs @@ -203,9 +203,9 @@ impl<'h> Evaluator<'h> { let index = self.value(&args[0]); if let Value::Array(_) = index { let Some(spec) = gridcraft_functions::lookup("CHOOSE") else { return err(CellError::Name) }; - let mut evaluated = vec![Arg { value: index, from_ref: false }]; + let mut evaluated = vec![Arg::val(index)]; for argument in &args[1..] { - evaluated.push(Arg { value: self.value(argument), from_ref: false }); + evaluated.push(Arg::val(self.value(argument))); } let mut context = FnCtx { ev: self, criteria: None, lambda: None }; return gridcraft_functions::call(spec, &evaluated, &mut context).pipe(Ev::V); @@ -351,7 +351,9 @@ impl<'h> Evaluator<'h> { } ev => self.deref(ev), }; - evaluated.push(Arg { value, from_ref }); + // Only an argument left out (`f(a,,b)`) is omitted; one that computes a blank is given. + let blank_value = !from_ref && value == Value::Empty && lambda_at != Some(i) && !matches!(a, Expr::Missing); + evaluated.push(Arg { value, from_ref, blank_value }); } let mut ctx = FnCtx { ev: self, criteria, lambda }; gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) @@ -414,7 +416,7 @@ impl<'h> Evaluator<'h> { let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else { return Err(err(CellError::Name)); }; - let args = [Arg { value, from_ref }]; + let args = [Arg { value, from_ref, blank_value: false }]; if kind != Agg::Count { match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { Value::Number(n) => acc.sum += n, diff --git a/crates/calc/src/eval/reference_fns.rs b/crates/calc/src/eval/reference_fns.rs index 4f5154c2d..6bef99732 100644 --- a/crates/calc/src/eval/reference_fns.rs +++ b/crates/calc/src/eval/reference_fns.rs @@ -203,7 +203,7 @@ impl<'h> Evaluator<'h> { let horizontal = l.range.height() == 1 && l.range.width() > 1; let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() }; if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len { - let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }]; + let mut xargs = vec![Arg::val(key), Arg::reference(self.area_value(&l))]; // An empty match_mode before a given search_mode is the default (0), not the end. let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing)); let last = if given(5) { @@ -215,10 +215,11 @@ impl<'h> Evaluator<'h> { }; for i in 4..=last { match args.get(i) { - Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }), + Some(Expr::Missing) | None => xargs.push(Arg::val(Value::Number(0.0))), Some(e) => { let value = self.value(e); - xargs.push(Arg { value, from_ref: false }); + let blank_value = value == Value::Empty; + xargs.push(Arg { value, from_ref: false, blank_value }); } } } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index fffbc59a1..3c1330dbe 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1842,6 +1842,9 @@ fn lookup_of_an_empty_cell_stays_blank() { ("D8", "=VLOOKUP(\"k\",A1:B2,2,0)", Value::Number(0.0)), // Arithmetic still reads the blank as 0. ("D9", "=VLOOKUP(\"k\",A1:B2,2,0)+1", Value::Number(1.0)), + // A blank lookup given as an argument is still given, not omitted. + ("D10", "=FILTER(1,FALSE,VLOOKUP(\"k\",A1:B2,2,FALSE))", Value::Number(0.0)), + ("D11", "=XLOOKUP(\"z\",A1:A2,B1:B2,VLOOKUP(\"k\",A1:B2,2,0))", Value::Number(0.0)), ] { t.set(at, f); assert_eq!(t.get(at), want, "{f}"); diff --git a/crates/functions/src/info.rs b/crates/functions/src/info.rs index 309f0323e..82eda5828 100644 --- a/crates/functions/src/info.rs +++ b/crates/functions/src/info.rs @@ -91,7 +91,7 @@ fn na(_a: &[Arg], _c: &mut dyn Ctx) -> R { fn n_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { // N(range) reads the range's first cell, but a computed array (N(A1:A3>0), N({...})) is // converted element by element, so N can stand in for `--` inside SUMPRODUCT. - if let Some(Arg { value: Value::Array(arr), from_ref: false }) = a.first() { + if let Some(Arg { value: Value::Array(arr), from_ref: false, .. }) = a.first() { let data = arr.data.iter().map(|v| n_of(v.clone()).unwrap_or_else(Value::Error)).collect(); return Array::new(arr.rows, arr.cols, data).map(Value::from).ok_or(CellError::Calc); } diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index ccc61b026..6cb035b28 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -87,16 +87,19 @@ pub use stat::{aggregate_values, aggregate_values_k}; pub struct Arg { pub value: Value, pub from_ref: bool, + /// A written argument whose value came out blank (a lookup of an empty cell): given, not + /// omitted, although its value is `Value::Empty`. + pub blank_value: bool, } impl Arg { /// A literal (non-reference) argument. pub fn val(value: impl Into) -> Arg { - Arg { value: value.into(), from_ref: false } + Arg { value: value.into(), from_ref: false, blank_value: false } } /// An argument that came from a cell or range reference. pub fn reference(value: impl Into) -> Arg { - Arg { value: value.into(), from_ref: true } + Arg { value: value.into(), from_ref: true, blank_value: false } } } diff --git a/crates/functions/src/lift.rs b/crates/functions/src/lift.rs index ea7f07fd9..6d2e3fbc4 100644 --- a/crates/functions/src/lift.rs +++ b/crates/functions/src/lift.rs @@ -50,12 +50,7 @@ fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option> { if !args.iter().enumerate().any(|(i, a)| multi(i, a)) { return None; } - Some( - args.iter() - .enumerate() - .map(|(i, a)| if multi(i, a) { Arg { value: Value::Error(CellError::Value), from_ref: false } } else { a.clone() }) - .collect(), - ) + Some(args.iter().enumerate().map(|(i, a)| if multi(i, a) { Arg::val(Value::Error(CellError::Value)) } else { a.clone() }).collect()) } /// Lookups whose every call searches a lookup vector from its start, so a call lifted over `n` diff --git a/crates/functions/src/util.rs b/crates/functions/src/util.rs index 14c627937..4e7853d2c 100644 --- a/crates/functions/src/util.rs +++ b/crates/functions/src/util.rs @@ -51,7 +51,7 @@ pub(crate) fn arg(args: &[Arg], i: usize) -> R<&Arg> { /// Whether argument `i` was supplied (present and not an empty literal like `F(1,)`). pub(crate) fn has(args: &[Arg], i: usize) -> bool { - matches!(args.get(i), Some(a) if !matches!(a.value, Value::Empty) || a.from_ref) + matches!(args.get(i), Some(a) if !matches!(a.value, Value::Empty) || a.from_ref || a.blank_value) } /// Number coercion for a scalar parameter: empty = 0, TRUE = 1, numeric text parses. From 642fa97fa5360ff54a4cff82d5d415dbadcc2089 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 14:19:05 +0100 Subject: [PATCH 6/6] fix(functions): a blank element of a lifted argument is given, not omitted CEILING.MATH(6.7,VLOOKUP({"k","k"},A1:B1,2,FALSE)) with B1 empty now gives 0 per element, as the scalar call does, instead of treating the significance as left out. Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 5 +++++ crates/functions/src/lift.rs | 2 ++ 2 files changed, 7 insertions(+) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 3c1330dbe..d29dc9653 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1845,6 +1845,11 @@ fn lookup_of_an_empty_cell_stays_blank() { // A blank lookup given as an argument is still given, not omitted. ("D10", "=FILTER(1,FALSE,VLOOKUP(\"k\",A1:B2,2,FALSE))", Value::Number(0.0)), ("D11", "=XLOOKUP(\"z\",A1:A2,B1:B2,VLOOKUP(\"k\",A1:B2,2,0))", Value::Number(0.0)), + // The same holds per element when the call is lifted over an array of blank lookups. + ("D12", "=CEILING.MATH(6.7,VLOOKUP(\"k\",A1:B2,2,0))", Value::Number(0.0)), + ("D13", "=SUM(CEILING.MATH(6.7,VLOOKUP({\"k\",\"k\"},A1:B2,2,0)))", Value::Number(0.0)), + ("D14", "=FLOOR.MATH(6.7,VLOOKUP(\"k\",A1:B2,2,0))", Value::Number(0.0)), + ("D15", "=SUM(FLOOR.MATH(6.7,VLOOKUP({\"k\",\"k\"},A1:B2,2,0)))", Value::Number(0.0)), ] { t.set(at, f); assert_eq!(t.get(at), want, "{f}"); diff --git a/crates/functions/src/lift.rs b/crates/functions/src/lift.rs index 6d2e3fbc4..d474bf12d 100644 --- a/crates/functions/src/lift.rs +++ b/crates/functions/src/lift.rs @@ -115,6 +115,8 @@ pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Val { let v = arr.get_broadcast(r, c); slot.value = if let Value::Array(inner) = &v { inner.data.first().cloned().unwrap_or(Value::Empty) } else { v }; + // A blank element of a written array argument is given, as a scalar blank is. + slot.blank_value = !slot.from_ref && matches!(slot.value, Value::Empty); } } let out = (spec.imp)(&cell_args, ctx);