From 9d0053f3add9fc96e543d516f5847167fabf1ca9 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:58:16 -0500 Subject: [PATCH 1/2] XLOOKUP: return a reference when the result range is one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XLOOKUP returned only values, so it couldn't end a range or feed functions that need a reference: SUM(XLOOKUP("b",A1:A4,B1:B4): XLOOKUP("d",A1:A4,B1:B4)) and SUM(B1:XLOOKUP(…)) gave #VALUE!, and so did CELL("address",XLOOKUP(…)). A match on an empty cell gave 0, so ISBLANK(XLOOKUP(…)) was FALSE and "x"&XLOOKUP(…) gave "x0" (#374). In Excel XLOOKUP returns a reference to the matching row (or column) of its result range. The evaluator now does so, finding the position with XMATCH; arrays of lookup values, array arguments and errors still go through the library function. --- crates/calc/src/eval.rs | 52 ++++++++++++++++++++++++++++++++++++++++ crates/calc/src/tests.rs | 26 ++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 3d4a743..025608c 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -681,6 +681,7 @@ impl<'h> Evaluator<'h> { "OFFSET" => self.offset(args), "INDIRECT" => self.indirect(args), "INDEX" => self.index(args), + "XLOOKUP" => self.xlookup(args), "CELL" => self.cell_info(args), "TRIMRANGE" => self.trimrange(args), "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), @@ -1137,6 +1138,57 @@ impl<'h> Evaluator<'h> { } } + /// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can + /// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of + /// lookup values, array arguments, errors) goes to the library function. + fn xlookup(&mut self, args: &[Expr]) -> Ev { + if (3..=6).contains(&args.len()) + && let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2])) + && let ([l], [r]) = (look.as_slice(), ret.as_slice()) + && let Some(spec) = gridcraft_functions::lookup("XMATCH") + { + let key = self.value(&args[0]); + let (l, r) = (*l, *r); + let vertical = l.range.width() == 1 && l.range.height() > 1; + 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 }]; + for i in [4, 5] { + match args.get(i) { + Some(Expr::Missing) | None => break, + Some(e) => { + let value = self.value(e); + xargs.push(Arg { value, from_ref: false }); + } + } + } + let mut ctx = FnCtx { ev: self }; + return match gridcraft_functions::call(spec, &xargs, &mut ctx) { + Value::Number(p) if p >= 1.0 => { + let k = p as u32 - 1; + let range = if vertical { + RangeRef::new( + CellRef::new(r.range.start.row + k, r.range.start.col), + CellRef::new(r.range.start.row + k, r.range.end.col), + ) + } else { + RangeRef::new( + CellRef::new(r.range.start.row, r.range.start.col + k), + CellRef::new(r.range.end.row, r.range.start.col + k), + ) + }; + Ev::R(vec![Area { sheet: r.sheet, range }]) + } + Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]), + Value::Error(e) => err(e), + _ => err(CellError::Value), + }; + } + } + self.builtin("XLOOKUP", args) + } + fn cell_info(&mut self, args: &[Expr]) -> Ev { let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { Some(Ok(t)) => t.to_ascii_lowercase(), diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 2205a1e..3b20027 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -94,6 +94,32 @@ fn chains_out_of_order() { assert_eq!(t.num("A5"), 5.0); } +#[test] +fn xlookup_returns_a_reference() { + let mut t = T::new(); + for (i, (k, v)) in [("a", 1), ("b", 2), ("c", 3), ("d", 4)].iter().enumerate() { + t.set(&format!("A{}", i + 1), k); + t.set(&format!("B{}", i + 1), &v.to_string()); + } + t.set("D1", "=SUM(XLOOKUP(\"b\",A1:A4,B1:B4):XLOOKUP(\"d\",A1:A4,B1:B4))"); + assert_eq!(t.num("D1"), 9.0); + t.set("D2", "=SUM(B1:XLOOKUP(\"c\",A1:A4,B1:B4))"); + assert_eq!(t.num("D2"), 6.0); + t.set("D3", "=CELL(\"address\",XLOOKUP(\"c\",A1:A4,B1:B4))"); + assert_eq!(t.get("D3"), Value::from("$B$3")); + // Not found: if_not_found, else #N/A, as before. + t.set("D4", "=XLOOKUP(\"z\",A1:A4,B1:B4,\"none\")"); + assert_eq!(t.get("D4"), Value::from("none")); + t.set("D5", "=XLOOKUP(\"z\",A1:A4,B1:B4)"); + assert_eq!(t.get("D5"), Value::Error(CellError::NA)); + // A match on an empty cell is a blank reference: ISBLANK is TRUE and & adds nothing (#374). + t.set("A5", "e"); + t.set("D6", "=ISBLANK(XLOOKUP(\"e\",A1:A5,B1:B5))"); + assert_eq!(t.get("D6"), Value::Bool(true)); + t.set("D7", "=\"x\"&XLOOKUP(\"e\",A1:A5,B1:B5)&\"y\""); + assert_eq!(t.get("D7"), Value::from("xy")); +} + #[test] fn long_chain() { let mut t = T::new(); From 6d59233e62d18123fb85a7b21c1ea49e317d8cc6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 15:49:32 -0400 Subject: [PATCH 2/2] XLOOKUP reference path: an empty match_mode keeps the search_mode after it (review fix-up for #396) Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/eval.rs | 13 +++++++++++-- crates/calc/src/tests.rs | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 8216f5d..d31a997 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1200,9 +1200,18 @@ impl<'h> Evaluator<'h> { 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 }]; - for i in [4, 5] { + // 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) { + 5 + } else if given(4) { + 4 + } else { + 3 + }; + for i in 4..=last { match args.get(i) { - Some(Expr::Missing) | None => break, + Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }), Some(e) => { let value = self.value(e); xargs.push(Arg { value, from_ref: false }); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 8b33f93..791da35 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -118,6 +118,13 @@ fn xlookup_returns_a_reference() { assert_eq!(t.get("D6"), Value::Bool(true)); t.set("D7", "=\"x\"&XLOOKUP(\"e\",A1:A5,B1:B5)&\"y\""); assert_eq!(t.get("D7"), Value::from("xy")); + // An empty match_mode keeps the search_mode after it: search last to first. + t.set("A6", "b"); + t.set("B6", "20"); + t.set("D8", "=XLOOKUP(\"b\",A1:A6,B1:B6,,,-1)"); + assert_eq!(t.num("D8"), 20.0); + t.set("D9", "=XLOOKUP(\"b\",A1:A6,B1:B6)"); + assert_eq!(t.num("D9"), 2.0); } #[test]