# Conflicts:
#	ROADMAP.md
#	docs/function-parity.md
This commit is contained in:
Brandon Thomas
2026-10-24 02:10:42 -04:00
5 changed files with 209 additions and 9 deletions
+2
View File
@@ -149,6 +149,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
@@ -235,6 +236,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
| 2026-10-14 | minor | Progress log: spill anchor reading its old spill area |
+143
View File
@@ -404,6 +404,149 @@ fn date_text_coerces_in_the_workbook_date_system() {
}
}
/// Lookups compare numbers exactly while `=` and the criteria functions compare them at 15
/// significant digits. Every expectation was observed in Excel 365 with these fixtures
/// (`=0.1+0.2` is 0.30000000000000004, `=100+1E-14` is 100.00000000000001).
#[test]
fn lookups_compare_numbers_exactly() {
let mut t = T::new();
for (at, input) in [
("A1", "=0.1+0.2"),
("A2", "0.3"),
("A3", "=100+1E-14"),
("A4", "100"),
("A5", "=1E15+0.5"),
("A6", "1E15"),
// Ascending 0.1, 0.3+e, 0.5 and the same descending.
("B1", "0.1"),
("B2", "=0.1+0.2"),
("B3", "0.5"),
("C1", "0.5"),
("C2", "=0.1+0.2"),
("C3", "0.1"),
// 0.3 next to 0.3+e, ascending and descending.
("D1", "0.3"),
("D2", "=0.1+0.2"),
("D3", "0.7"),
("E1", "0.7"),
("E2", "=0.1+0.2"),
("E3", "0.3"),
("G1", "=0.1+0.2"),
("H1", "x"),
("G2", "row2"),
// The bisection probes 0.3 first and must not run on into 0.3+e.
("J1", "0.1"),
("J2", "0.2"),
("J3", "0.3"),
("J4", "=0.1+0.2"),
("J5", "0.4"),
("K1", "a"),
("K2", "b"),
("K3", "c"),
("K4", "d"),
("K5", "e"),
] {
t.set(at, input);
}
let e = 0.1 + 0.2;
let n = Value::Number;
let s = |x: &str| Value::from(x);
let na = Value::Error(CellError::NA);
let cases: Vec<(&str, Value)> = vec![
// `=` and friends stay at 15 digits.
("=A1=A2", Value::Bool(true)),
("=A3=A4", Value::Bool(true)),
("=A5=A6", Value::Bool(true)),
("=SWITCH(A1,0.3,\"hit\",\"miss\")", s("hit")),
// Exact match.
("=MATCH(A1,A2:A2,0)", na.clone()),
("=MATCH(A2,A1:A1,0)", na.clone()),
("=VLOOKUP(A1,A2:A2,1,FALSE)", na.clone()),
("=HLOOKUP(A1,A2,1,FALSE)", na.clone()),
("=HLOOKUP(0.3,G1:H2,2,FALSE)", na.clone()),
("=XLOOKUP(A1,A2:A2,A2:A2,\"nf\")", s("nf")),
("=XMATCH(A1,A2:A2)", na.clone()),
("=MATCH(0.1+0.2,{0.3},0)", na.clone()),
("=MATCH(0.3,A1:A2,0)", n(2.0)),
("=MATCH(A1,A1:A2,0)", n(1.0)),
("=MATCH(0.3,INDEX(A1:A2,0),0)", n(2.0)),
("=MATCH(100,A3:A3,0)", na.clone()),
("=MATCH(1E15,A5:A5,0)", na.clone()),
("=XLOOKUP(0.3,A1:A2,{1;2},\"nf\",0,-1)", n(2.0)),
("=XMATCH(A1,A1:A2,0,-1)", n(1.0)),
// Approximate match against a single entry.
("=MATCH(0.3,A1:A1,1)", na.clone()),
("=MATCH(0.3,A1:A1,-1)", n(1.0)),
("=LOOKUP(0.3,A1:A1)", na.clone()),
("=LOOKUP(0.3,A1:A1,G2)", na.clone()),
("=VLOOKUP(0.3,A1:A1,1,TRUE)", na.clone()),
("=HLOOKUP(0.3,G1:H2,2,TRUE)", na.clone()),
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",-1)", s("nf")),
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",1)", n(e)),
("=XMATCH(0.3,A1:A1,-1)", na.clone()),
("=XMATCH(0.3,A1:A1,1)", n(1.0)),
("=MATCH(100,A3:A3,1)", na.clone()),
("=MATCH(A3,A4:A4,1)", n(1.0)),
// Approximate match in sorted lists.
("=MATCH(0.3,B1:B3,1)", n(1.0)),
("=MATCH(A1,B1:B3,1)", n(2.0)),
("=MATCH(0.3,C1:C3,-1)", n(2.0)),
("=MATCH(0.3,D1:D3,1)", n(1.0)),
("=MATCH(A1,D1:D3,1)", n(2.0)),
("=MATCH(0.3,E1:E3,-1)", n(3.0)),
("=MATCH(A1,E1:E3,-1)", n(2.0)),
("=MATCH(0.3,J1:J5,1)", n(3.0)),
("=MATCH(A1,J1:J5,1)", n(4.0)),
("=MATCH(0.3,J1:J5,0)", n(3.0)),
("=MATCH(A1,J1:J5,0)", n(4.0)),
("=VLOOKUP(0.3,J1:K5,2,TRUE)", s("c")),
("=VLOOKUP(A1,J1:K5,2,TRUE)", s("d")),
("=LOOKUP(0.3,J1:J5,K1:K5)", s("c")),
("=LOOKUP(A1,J1:J5,K1:K5)", s("d")),
// XLOOKUP / XMATCH match and search modes.
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",0,2)", s("nf")),
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",-1,2)", n(0.1)),
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",1,2)", n(e)),
("=XMATCH(0.3,B1:B3,-1,2)", n(1.0)),
("=XMATCH(0.3,B1:B3,1,2)", n(2.0)),
("=XMATCH(0.3,C1:C3,1,-2)", n(2.0)),
("=XMATCH(0.3,C1:C3,-1,-2)", n(3.0)),
("=XMATCH(0.3,C1:C3,0,-2)", na.clone()),
("=XMATCH(0.3,J1:J5,0,2)", n(3.0)),
("=XMATCH(A1,J1:J5,0,2)", n(4.0)),
("=XMATCH(0.3,J1:J5,1,2)", n(3.0)),
("=XMATCH(0.3,J1:J5,-1)", n(3.0)),
("=XMATCH(0.3,J1:J5,1)", n(3.0)),
("=XMATCH(A1,J1:J5,-1)", n(4.0)),
("=XMATCH(0.3,B1:B3,1)", n(2.0)),
("=XMATCH(0.3,B1:B3,-1)", n(1.0)),
("=XMATCH(0.3,B1:B3,0,-1)", na.clone()),
("=XMATCH(0.3,D1:D3,1,-1)", n(1.0)),
// The criteria functions keep 15 digits.
("=COUNTIF(A1:A1,0.3)", n(1.0)),
("=COUNTIF(A1:A1,\"<0.3\")", n(0.0)),
("=COUNTIF(A1:A1,\">0.3\")", n(0.0)),
("=COUNTIF(A2:A2,A1)", n(1.0)),
("=COUNTIFS(A1:A1,0.3)", n(1.0)),
("=SUMIF(A1:A1,0.3)", n(e)),
("=SUMIFS(A1:A1,A1:A1,0.3)", n(e)),
("=AVERAGEIF(A1:A1,0.3)", n(e)),
("=MAXIFS(A1:A1,A1:A1,0.3)", n(e)),
("=COUNTIF(A3:A3,\">100\")", n(0.0)),
("=COUNTIF(J1:J5,0.3)", n(2.0)),
("=ROWS(FILTER(A1:A2,A1:A2=0.3))", n(2.0)),
("=MATCH(TRUE,A1:A2=0.3,0)", n(1.0)),
];
let mut wrong = Vec::new();
for (formula, expected) in cases {
let got = crate::evaluate(&t.wb, 0, c("Z1"), formula);
if got != expected {
wrong.push(format!("{formula}: got {got:?}, Excel {expected:?}"));
}
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}
#[test]
fn lambda_helpers_check_shapes_and_parameters() {
let mut t = T::new();
+6 -3
View File
@@ -314,7 +314,9 @@ pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option<u
}
/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards
/// matches by pattern (only when `wild`), otherwise values compare case-insensitively.
/// matches by pattern (only when `wild`), otherwise values compare case-insensitively. Numbers
/// compare exactly, not at the 15 digits of `=` and the criteria functions (Excel 365:
/// `MATCH(0.1+0.2,{0.3},0)` is #N/A while `0.1+0.2=0.3` is TRUE).
pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
match (needle, hay) {
(Value::Text(p), Value::Text(t)) => {
@@ -324,7 +326,7 @@ pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
compare_text(p, t) == Ordering::Equal
}
}
(Value::Number(a), Value::Number(b)) => compare_numbers(*a, *b) == Ordering::Equal,
(Value::Number(a), Value::Number(b)) => a == b,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Error(a), Value::Error(b)) => a == b,
(Value::Empty, Value::Empty) => true,
@@ -370,7 +372,8 @@ mod tests {
assert!(m("#N/A", Value::Error(CellError::NA)));
assert!(Criterion::parse(&Value::Number(3.0)).matches(&Value::Number(3.0)));
assert!(Criterion::parse(&Value::Number(0.3)).matches(&Value::Number(0.1 + 0.2)));
assert!(lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
assert!(!lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
assert!(lookup_equal(&Value::Number(0.0), &Value::Number(-0.0), false));
assert!(m("<>a*", Value::from("bcd")));
assert!(m(">1/1/2020", Value::Number(44000.0)));
// Text patterns never match blank cells, but an empty string is text.
+57 -6
View File
@@ -23,6 +23,19 @@ fn needle(args: &[Arg], i: usize) -> R<Value> {
}
}
/// Order of two values for the approximate and binary searches: [`compare`], except that numbers
/// compare exactly. Excel's `=` treats numbers equal to 15 significant digits as equal, but its
/// lookups don't. Observed in Excel 365 with A1 holding
/// `=0.1+0.2` (0.30000000000000004): `A1=0.3` is TRUE, yet `MATCH(0.3,A1,1)` is #N/A because
/// A1 is larger, and in {0.1;0.2;0.3;A1;0.4} `MATCH(0.3,…,1)` is 3 and `MATCH(A1,…,1)` is 4.
/// The same holds for `=100+1E-14` against 100, and in every XLOOKUP / XMATCH mode.
fn lookup_order(a: &Value, b: &Value) -> Ordering {
match (a, b) {
(Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
_ => compare(a, b),
}
}
fn same_type(a: &Value, b: &Value) -> bool {
matches!((a, b), (Value::Number(_), Value::Number(_)) | (Value::Text(_), Value::Text(_)) | (Value::Bool(_), Value::Bool(_)))
}
@@ -59,10 +72,10 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
continue;
};
let v = hay.get(j)?;
let o = compare(v, x);
let o = lookup_order(v, x);
if o == Ordering::Equal {
let mut k = j;
while hay.get(k + 1).is_some_and(|w| same_type(x, w) && compare(w, x) == Ordering::Equal) {
while hay.get(k + 1).is_some_and(|w| same_type(x, w) && lookup_order(w, x) == Ordering::Equal) {
k += 1;
}
return Some(k);
@@ -88,8 +101,8 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
fn binary_order(v: &Value, x: &Value, desc: bool) -> Ordering {
match v {
Value::Empty => Ordering::Greater,
_ if desc => compare(v, x).reverse(),
_ => compare(v, x),
_ if desc => lookup_order(v, x).reverse(),
_ => lookup_order(v, x),
}
}
@@ -262,7 +275,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
if !same_type(x, v) {
continue;
}
let o = compare(v, x);
let o = lookup_order(v, x);
let candidate = if match_mode == -1 { o == Ordering::Less } else { o == Ordering::Greater };
if !candidate {
continue;
@@ -270,7 +283,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
let better = match best.and_then(|b| hay.get(b)) {
None => true,
Some(bv) => {
let c = compare(v, bv);
let c = lookup_order(v, bv);
if match_mode == -1 { c == Ordering::Greater } else { c == Ordering::Less }
}
};
@@ -748,6 +761,44 @@ mod tests {
assert_eq!(rows_of(&r), vec![vec![nv(3.0), nv(1.0)]]);
}
/// Lookups compare numbers exactly, unlike `=` (15 significant digits). Observed in Excel 365.
#[test]
fn lookups_compare_numbers_exactly() {
let e = 0.1 + 0.2;
let one = || rf(col(&[e]));
// Exact modes miss a key one bit away.
is_err(ev("MATCH", vec![n(0.3), one(), n(0.0)]), CellError::NA);
is_err(ev("VLOOKUP", vec![n(0.3), one(), n(1.0), b(false)]), CellError::NA);
is_err(ev("XMATCH", vec![n(0.3), one()]), CellError::NA);
close(ev("MATCH", vec![n(0.3), rf(col(&[e, 0.3])), n(0.0)]), 2.0);
close(ev("MATCH", vec![n(e), rf(col(&[e, 0.3])), n(0.0)]), 1.0);
is_err(ev("MATCH", vec![n(100.0), rf(col(&[100.0 + 1e-14])), n(0.0)]), CellError::NA);
// 0 and -0 are still the same key.
close(ev("MATCH", vec![n(-0.0), rf(col(&[0.0])), n(0.0)]), 1.0);
// Approximate modes: 0.3 sorts below 0.30000000000000004.
is_err(ev("MATCH", vec![n(0.3), one(), n(1.0)]), CellError::NA);
close(ev("MATCH", vec![n(0.3), one(), n(-1.0)]), 1.0);
is_err(ev("LOOKUP", vec![n(0.3), one()]), CellError::NA);
is_err(ev("XMATCH", vec![n(0.3), one(), n(-1.0)]), CellError::NA);
close(ev("XMATCH", vec![n(0.3), one(), n(1.0)]), 1.0);
// The bisection stops on 0.3 and doesn't run on into the next entry.
let list = || rf(col(&[0.1, 0.2, 0.3, e, 0.4]));
close(ev("MATCH", vec![n(0.3), list(), n(1.0)]), 3.0);
close(ev("MATCH", vec![n(e), list(), n(1.0)]), 4.0);
close(ev("XMATCH", vec![n(0.3), list(), n(0.0), n(2.0)]), 3.0);
close(ev("XMATCH", vec![n(e), list(), n(0.0), n(2.0)]), 4.0);
let asc = || rf(col(&[0.1, e, 0.5]));
let desc = || rf(col(&[0.5, e, 0.1]));
close(ev("MATCH", vec![n(0.3), asc(), n(1.0)]), 1.0);
close(ev("MATCH", vec![n(0.3), desc(), n(-1.0)]), 2.0);
is_err(ev("XMATCH", vec![n(0.3), asc(), n(0.0), n(2.0)]), CellError::NA);
close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0), n(2.0)]), 1.0);
close(ev("XMATCH", vec![n(0.3), asc(), n(1.0), n(2.0)]), 2.0);
close(ev("XMATCH", vec![n(0.3), desc(), n(-1.0), n(-2.0)]), 3.0);
close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0)]), 1.0);
close(ev("XMATCH", vec![n(0.3), asc(), n(1.0)]), 2.0);
}
#[test]
fn index() {
let t3 = table_rows3();
+1
View File
@@ -132,6 +132,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| 2026-10-16 | minor | FORECAST.ETS gap interpolation uses a forward-only known-point scan instead of rescanning each gap for every missing value; numerical behavior and missing-point limits are preserved (#1042) |
| 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation |
| 2026-10-15 | minor | ADDRESS quotes a sheet name the way Excel does: names that read as an A1 cell (`A1`) or an R1C1 reference (`R1C1`, `RC`) and names starting with `.` get quotes; `TRUE` / `FALSE` stay bare (#1458) |
| 2026-10-14 | minor | MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compare numbers exactly in every match and search mode, as Excel 365 does: `MATCH(0.1+0.2,{0.3},0)` is `#N/A` and 0.3 sorts below 0.30000000000000004 in approximate and binary search. `=`, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS…) keep comparing at 15 significant digits, also as observed |
| 2026-10-14 | minor | Analysis ToolPak value arguments (EOMONTH, WORKDAY, YEARFRAC, COUPDAYS, DISC, BIN2DEC, IM* and the rest, observed in Excel 365) give `#VALUE!` for a multi-cell reference instead of lifting it |
| 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) |
| 2026-10-14 | minor | Bounded references up to 1,048,576 cells (`D3:AZ400`) keep their full shape instead of stopping at the used range, so `SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=x)*(D1:AZ1=y))` broadcasts like Excel and `ROWS`, `COUNTBLANK` and `--(r="")` count the blanks; references that run to the edge of the sheet (`A:A`) still stop at the used range (#1010) |