Merge pull request #396 from nihilist-narwhal/fix-xlookup-reference

XLOOKUP: return a reference when the result range is one
This commit is contained in:
Brandon Thomas 2026-10-11 15:49:34 -04:00
commit baaa606036
2 changed files with 94 additions and 0 deletions

View File

@ -688,6 +688,7 @@ impl<'h> Evaluator<'h> {
"OFFSET" => self.offset(args), "OFFSET" => self.offset(args),
"INDIRECT" => self.indirect(args), "INDIRECT" => self.indirect(args),
"INDEX" => self.index(args), "INDEX" => self.index(args),
"XLOOKUP" => self.xlookup(args),
"CELL" => self.cell_info(args), "CELL" => self.cell_info(args),
"TRIMRANGE" => self.trimrange(args), "TRIMRANGE" => self.trimrange(args),
"SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args),
@ -1183,6 +1184,66 @@ 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 }];
// 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 => 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 });
}
}
}
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 { fn cell_info(&mut self, args: &[Expr]) -> Ev {
let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) {
Some(Ok(t)) => t.to_ascii_lowercase(), Some(Ok(t)) => t.to_ascii_lowercase(),

View File

@ -94,6 +94,39 @@ fn chains_out_of_order() {
assert_eq!(t.num("A5"), 5.0); 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"));
// 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] #[test]
fn long_chain() { fn long_chain() {
let mut t = T::new(); let mut t = T::new();