From 691883f99c65772ed3d25a6247f3e71b28c0f184 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 20:56:16 +0200 Subject: [PATCH] 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);