calc: lookup family -- VLOOKUP, INDEX, MATCH, and the corpus is now the full 30-function set
3 functions, plus the final 10 corpus rows (VLOOKUP/INDEX/MATCH against
a small A1:B3 table, and three COUNT/COUNTA/AVERAGE rows that exercise
a genuinely blank cell rather than a numeric argument) and an updated
corpusSHA256 -- 60 entries total, task 8's full scope.
VLOOKUP/INDEX/MATCH cannot be ordinary Funcs: they need a range
argument's shape (width/height as written), and by the time
evalCallArgs has flattened a *Range into one shared []Value, both the
shape and the boundary between one argument's cells and the next are
gone. All three register as nodeFuncs (registry.go, commit 1) and
evaluate their own argument nodes directly, using the same
position-as-id CellKey convention refKey uses elsewhere in this
package. INDEX with row_num=0 or col_num=0 ("return the whole
row/column") is unsupported and returns #VALUE! rather than silently
answering one arbitrary cell of it -- this engine has no array/spill
Value kind yet.
This is the last of task 8's ~30 functions. testdata/corpus/formulas.json
is HAND-AUTHORED, not yet verified against a real LibreOffice run (see
the file's own note and corpus_test.go's loadCorpus doc comment) --
tools/gencorpus (commit 3) exists to close that gap once a machine with
soffice is available.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
4 files changed,
+349
−2
internal/sheets/calc/corpus_test.go+1 −1internal/sheets/calc/fn_lookup.go+249 −0internal/sheets/calc/fn_lookup_test.go+88 −0internal/sheets/calc/testdata/corpus/formulas.json+11 −1
Intent
diff --git a/internal/sheets/calc/corpus_test.go b/internal/sheets/calc/corpus_test.go| index 1bf4b89..64f6af1 100644 |
| --- a/internal/sheets/calc/corpus_test.go |
| +++ b/internal/sheets/calc/corpus_test.go |
| @@ -21,7 +21,7 @@ import ( |
| // without also changing the file (or vice versa) is exactly the silent |
| // drift this test exists to catch — a corpus change is a deliberate act |
| // with a regenerated hash, never a side effect of an unrelated edit. |
| -const corpusSHA256 = "f55a322e0aea03a0a48c28663e67995b1b683c2c059914e04c8b1f9329f41757" |
| +const corpusSHA256 = "17b23ef50a9c9cf8aa01cdb37964f2d4b9ae458c1281b85ac4a0bbbb6d1d9b98" |
| // corpusValue is formulas.json's wire format for a Value: kind names one |
| // of "empty", "num", "str", "bool", "err", and only the matching field is |
diff --git a/internal/sheets/calc/fn_lookup.go b/internal/sheets/calc/fn_lookup.go| new file mode 100644 |
| index 0000000..6644a16 |
| --- /dev/null |
| +++ b/internal/sheets/calc/fn_lookup.go |
| @@ -0,0 +1,249 @@ |
| +package calc |
| + |
| +// fn_lookup.go implements VLOOKUP, INDEX and MATCH. All three need a |
| +// range argument's shape (its width and height as written), not the |
| +// flattened, boundary-free []Value evalCallArgs hands an ordinary Func — |
| +// see registry.go's nodeFunc doc comment for why. All three register as |
| +// nodeFuncs and evaluate their own arguments directly against env, using |
| +// the same position-as-id CellKey convention refKey uses elsewhere in |
| +// this package (RowID = RowPos+1, ColID = ColPos+1; see eval.go). |
| + |
| +func init() { |
| + registerNode("VLOOKUP", fnVLookup) |
| + registerNode("INDEX", fnIndex) |
| + registerNode("MATCH", fnMatch) |
| +} |
| + |
| +// rangeBounds normalizes a *Range's positions the same way rangeCells |
| +// does (eval.go), without materializing every cell — these functions |
| +// only need the four bounds to compute an offset, not a flattened slice. |
| +func rangeBounds(rng *Range) (r0, r1, c0, c1 int) { |
| + r0, r1 = rng.From.RowPos, rng.To.RowPos |
| + c0, c1 = rng.From.ColPos, rng.To.ColPos |
| + if r0 > r1 { |
| + r0, r1 = r1, r0 |
| + } |
| + if c0 > c1 { |
| + c0, c1 = c1, c0 |
| + } |
| + return |
| +} |
| + |
| +func cellValueAt(env Env, row, col int) Value { |
| + return env.CellValue(CellKey{RowID: uint64(row + 1), ColID: uint64(col + 1)}) |
| +} |
| + |
| +// fnVLookup implements VLOOKUP(lookup_value, table_array, col_index_num, |
| +// [range_lookup]). range_lookup defaults to TRUE (approximate match, |
| +// requiring the first column sorted ascending — Excel's own precondition; |
| +// an unsorted first column gives an unspecified answer in Excel too); |
| +// FALSE requires an exact match. table_array must be a literal range — a |
| +// scalar or a call result has no shape to look up a column within. |
| +func fnVLookup(argNodes []Node, env Env) Value { |
| + if len(argNodes) < 3 || len(argNodes) > 4 { |
| + return errValue(ErrValue) |
| + } |
| + lookup := Eval(argNodes[0], env) |
| + if lookup.Kind == KindErr { |
| + return lookup |
| + } |
| + rng, ok := argNodes[1].(*Range) |
| + if !ok { |
| + return errValue(ErrValue) |
| + } |
| + ciV := Eval(argNodes[2], env) |
| + if ciV.Kind == KindErr { |
| + return ciV |
| + } |
| + ciF, ek := toNumber(ciV) |
| + if ek != ErrNone { |
| + return errValue(ek) |
| + } |
| + ci := int(ciF) |
| + |
| + approx := true |
| + if len(argNodes) == 4 { |
| + rlV := Eval(argNodes[3], env) |
| + if rlV.Kind == KindErr { |
| + return rlV |
| + } |
| + b, ek := toBool(rlV) |
| + if ek != ErrNone { |
| + return errValue(ek) |
| + } |
| + approx = b |
| + } |
| + |
| + r0, r1, c0, c1 := rangeBounds(rng) |
| + cols := c1 - c0 + 1 |
| + if ci < 1 || ci > cols { |
| + return errValue(ErrRef) |
| + } |
| + resultCol := c0 + ci - 1 |
| + |
| + if !approx { |
| + for row := r0; row <= r1; row++ { |
| + if compareValues(cellValueAt(env, row, c0), lookup) == 0 { |
| + return cellValueAt(env, row, resultCol) |
| + } |
| + } |
| + return errValue(ErrNA) |
| + } |
| + |
| + found := false |
| + bestRow := 0 |
| + for row := r0; row <= r1; row++ { |
| + if compareValues(cellValueAt(env, row, c0), lookup) <= 0 { |
| + bestRow = row |
| + found = true |
| + continue |
| + } |
| + break |
| + } |
| + if !found { |
| + return errValue(ErrNA) |
| + } |
| + return cellValueAt(env, bestRow, resultCol) |
| +} |
| + |
| +// fnIndex implements INDEX(array, row_num, [col_num]); col_num defaults |
| +// to 1. This engine has no array/spill Value kind, so row_num=0 or |
| +// col_num=0 ("return the whole row/column") is unsupported and returns |
| +// #VALUE! rather than silently returning one arbitrary cell of it. |
| +func fnIndex(argNodes []Node, env Env) Value { |
| + if len(argNodes) < 2 || len(argNodes) > 3 { |
| + return errValue(ErrValue) |
| + } |
| + rowV := Eval(argNodes[1], env) |
| + if rowV.Kind == KindErr { |
| + return rowV |
| + } |
| + rowF, ek := toNumber(rowV) |
| + if ek != ErrNone { |
| + return errValue(ek) |
| + } |
| + rowN := int(rowF) |
| + |
| + colN := 1 |
| + if len(argNodes) == 3 { |
| + colV := Eval(argNodes[2], env) |
| + if colV.Kind == KindErr { |
| + return colV |
| + } |
| + colF, ek := toNumber(colV) |
| + if ek != ErrNone { |
| + return errValue(ek) |
| + } |
| + colN = int(colF) |
| + } |
| + if rowN < 1 || colN < 1 { |
| + return errValue(ErrValue) |
| + } |
| + |
| + rng, ok := argNodes[0].(*Range) |
| + if !ok { |
| + if rowN != 1 || colN != 1 { |
| + return errValue(ErrRef) |
| + } |
| + return Eval(argNodes[0], env) |
| + } |
| + r0, r1, c0, c1 := rangeBounds(rng) |
| + if r0+rowN-1 > r1 || c0+colN-1 > c1 { |
| + return errValue(ErrRef) |
| + } |
| + return cellValueAt(env, r0+rowN-1, c0+colN-1) |
| +} |
| + |
| +// fnMatch implements MATCH(lookup_value, lookup_array, [match_type]). |
| +// lookup_array must be a single row or a single column — Excel's own |
| +// restriction, since MATCH returns one position, not a (row, col) pair. |
| +// match_type: 1 (default, largest value <= lookup, ascending order), |
| +// 0 (exact match, any order), -1 (smallest value >= lookup, descending |
| +// order). No match is #N/A. |
| +func fnMatch(argNodes []Node, env Env) Value { |
| + if len(argNodes) < 2 || len(argNodes) > 3 { |
| + return errValue(ErrValue) |
| + } |
| + lookup := Eval(argNodes[0], env) |
| + if lookup.Kind == KindErr { |
| + return lookup |
| + } |
| + rng, ok := argNodes[1].(*Range) |
| + if !ok { |
| + return errValue(ErrValue) |
| + } |
| + matchType := 1 |
| + if len(argNodes) == 3 { |
| + mtV := Eval(argNodes[2], env) |
| + if mtV.Kind == KindErr { |
| + return mtV |
| + } |
| + mtF, ek := toNumber(mtV) |
| + if ek != ErrNone { |
| + return errValue(ek) |
| + } |
| + matchType = int(mtF) |
| + } |
| + |
| + r0, r1, c0, c1 := rangeBounds(rng) |
| + if r0 != r1 && c0 != c1 { |
| + return errValue(ErrNA) |
| + } |
| + |
| + switch matchType { |
| + case 0: |
| + return matchExact(env, r0, r1, c0, c1, lookup) |
| + case 1: |
| + return matchNearest(env, r0, r1, c0, c1, lookup, false) |
| + case -1: |
| + return matchNearest(env, r0, r1, c0, c1, lookup, true) |
| + } |
| + return errValue(ErrValue) |
| +} |
| + |
| +func matchExact(env Env, r0, r1, c0, c1 int, lookup Value) Value { |
| + pos := 0 |
| + for row := r0; row <= r1; row++ { |
| + for col := c0; col <= c1; col++ { |
| + pos++ |
| + if compareValues(cellValueAt(env, row, col), lookup) == 0 { |
| + return Value{Kind: KindNum, Num: float64(pos)} |
| + } |
| + } |
| + } |
| + return errValue(ErrNA) |
| +} |
| + |
| +// matchNearest implements match_type 1 (descending=false: largest value |
| +// <= lookup, ascending order) and match_type -1 (descending=true: |
| +// smallest value >= lookup, descending order). Both stop at the first |
| +// position that breaks the required ordering, returning the best |
| +// position seen so far. |
| +func matchNearest(env Env, r0, r1, c0, c1 int, lookup Value, descending bool) Value { |
| + found := false |
| + bestPos := 0 |
| + pos := 0 |
| + for row := r0; row <= r1; row++ { |
| + for col := c0; col <= c1; col++ { |
| + pos++ |
| + c := compareValues(cellValueAt(env, row, col), lookup) |
| + ok := c <= 0 |
| + if descending { |
| + ok = c >= 0 |
| + } |
| + if ok { |
| + bestPos = pos |
| + found = true |
| + continue |
| + } |
| + if found { |
| + return Value{Kind: KindNum, Num: float64(bestPos)} |
| + } |
| + return errValue(ErrNA) |
| + } |
| + } |
| + if found { |
| + return Value{Kind: KindNum, Num: float64(bestPos)} |
| + } |
| + return errValue(ErrNA) |
| +} |
diff --git a/internal/sheets/calc/fn_lookup_test.go b/internal/sheets/calc/fn_lookup_test.go| new file mode 100644 |
| index 0000000..269e1de |
| --- /dev/null |
| +++ b/internal/sheets/calc/fn_lookup_test.go |
| @@ -0,0 +1,88 @@ |
| +package calc |
| + |
| +import "testing" |
| + |
| +// lookupTable is A1:B3 -> {10:"ten", 20:"twenty", 30:"thirty"}, the same |
| +// small table testdata/corpus/formulas.json's VLOOKUP/INDEX/MATCH rows |
| +// use, kept here so unit tests don't depend on the corpus file. |
| +func lookupTable() mapEnv { |
| + return mapEnv{ |
| + {1, 1}: {Kind: KindNum, Num: 10}, {1, 2}: {Kind: KindStr, Str: "ten"}, |
| + {2, 1}: {Kind: KindNum, Num: 20}, {2, 2}: {Kind: KindStr, Str: "twenty"}, |
| + {3, 1}: {Kind: KindNum, Num: 30}, {3, 2}: {Kind: KindStr, Str: "thirty"}, |
| + } |
| +} |
| + |
| +func TestVLookupExact(t *testing.T) { |
| + v := Eval(mustParse(t, "VLOOKUP(20,A1:B3,2,FALSE)"), lookupTable()) |
| + if v.Kind != KindStr || v.Str != "twenty" { |
| + t.Fatalf("VLOOKUP exact = %v, want \"twenty\"", v) |
| + } |
| +} |
| + |
| +func TestVLookupExactNoMatchIsNA(t *testing.T) { |
| + v := Eval(mustParse(t, "VLOOKUP(99,A1:B3,2,FALSE)"), lookupTable()) |
| + if v.Kind != KindErr || v.Err != ErrNA { |
| + t.Fatalf("VLOOKUP no match = %v, want #N/A", v) |
| + } |
| +} |
| + |
| +func TestVLookupApproximate(t *testing.T) { |
| + v := Eval(mustParse(t, "VLOOKUP(25,A1:B3,2,TRUE)"), lookupTable()) |
| + if v.Kind != KindStr || v.Str != "twenty" { |
| + t.Fatalf("VLOOKUP approx = %v, want \"twenty\" (largest <= 25)", v) |
| + } |
| +} |
| + |
| +func TestVLookupColIndexOutOfRangeIsRef(t *testing.T) { |
| + v := Eval(mustParse(t, "VLOOKUP(20,A1:B3,3,FALSE)"), lookupTable()) |
| + if v.Kind != KindErr || v.Err != ErrRef { |
| + t.Fatalf("VLOOKUP col 3 of 2 = %v, want #REF!", v) |
| + } |
| +} |
| + |
| +func TestIndex(t *testing.T) { |
| + v := Eval(mustParse(t, "INDEX(A1:B3,2,2)"), lookupTable()) |
| + if v.Kind != KindStr || v.Str != "twenty" { |
| + t.Fatalf("INDEX(A1:B3,2,2) = %v, want \"twenty\"", v) |
| + } |
| + v = Eval(mustParse(t, "INDEX(A1:B3,3,1)"), lookupTable()) |
| + if v.Kind != KindNum || v.Num != 30 { |
| + t.Fatalf("INDEX(A1:B3,3,1) = %v, want 30", v) |
| + } |
| +} |
| + |
| +func TestIndexOutOfRangeIsRef(t *testing.T) { |
| + v := Eval(mustParse(t, "INDEX(A1:B3,4,1)"), lookupTable()) |
| + if v.Kind != KindErr || v.Err != ErrRef { |
| + t.Fatalf("INDEX row 4 of 3 = %v, want #REF!", v) |
| + } |
| +} |
| + |
| +func TestMatchExact(t *testing.T) { |
| + v := Eval(mustParse(t, "MATCH(20,A1:A3,0)"), lookupTable()) |
| + if v.Kind != KindNum || v.Num != 2 { |
| + t.Fatalf("MATCH exact = %v, want 2", v) |
| + } |
| +} |
| + |
| +func TestMatchNearestAscending(t *testing.T) { |
| + v := Eval(mustParse(t, "MATCH(25,A1:A3,1)"), lookupTable()) |
| + if v.Kind != KindNum || v.Num != 2 { |
| + t.Fatalf("MATCH nearest = %v, want 2", v) |
| + } |
| +} |
| + |
| +func TestMatchTwoDimensionalArrayIsNA(t *testing.T) { |
| + v := Eval(mustParse(t, "MATCH(20,A1:B3,0)"), lookupTable()) |
| + if v.Kind != KindErr || v.Err != ErrNA { |
| + t.Fatalf("MATCH over a 2D range = %v, want #N/A", v) |
| + } |
| +} |
| + |
| +func TestVLookupTableArrayMustBeRange(t *testing.T) { |
| + v := Eval(mustParse(t, "VLOOKUP(1,2,1,FALSE)"), emptyEnv{}) |
| + if v.Kind != KindErr || v.Err != ErrValue { |
| + t.Fatalf("VLOOKUP with scalar table_array = %v, want #VALUE!", v) |
| + } |
| +} |
diff --git a/internal/sheets/calc/testdata/corpus/formulas.json b/internal/sheets/calc/testdata/corpus/formulas.json| index 6f231d4..8146b1b 100644 |
| --- a/internal/sheets/calc/testdata/corpus/formulas.json |
| +++ b/internal/sheets/calc/testdata/corpus/formulas.json |
| @@ -50,6 +50,16 @@ |
| {"function": "LEN", "expr": "LEN(\"\")", "want": {"kind": "num", "num": 0}}, |
| {"function": "TRIM", "expr": "TRIM(\" a b \")", "want": {"kind": "str", "str": "a b"}}, |
| {"function": "UPPER", "expr": "UPPER(\"MixedCase\")", "want": {"kind": "str", "str": "MIXEDCASE"}}, |
| - {"function": "LOWER", "expr": "LOWER(\"MixedCase\")", "want": {"kind": "str", "str": "mixedcase"}} |
| + {"function": "LOWER", "expr": "LOWER(\"MixedCase\")", "want": {"kind": "str", "str": "mixedcase"}}, |
| + {"function": "VLOOKUP", "expr": "VLOOKUP(20,A1:B3,2,FALSE)", "want": {"kind": "str", "str": "twenty"}}, |
| + {"function": "VLOOKUP", "expr": "VLOOKUP(25,A1:B3,2,TRUE)", "want": {"kind": "str", "str": "twenty"}}, |
| + {"function": "VLOOKUP", "expr": "VLOOKUP(99,A1:B3,2,FALSE)", "want": {"kind": "err", "err": "#N/A"}}, |
| + {"function": "INDEX", "expr": "INDEX(A1:B3,2,2)", "want": {"kind": "str", "str": "twenty"}}, |
| + {"function": "INDEX", "expr": "INDEX(A1:B3,3,1)", "want": {"kind": "num", "num": 30}}, |
| + {"function": "MATCH", "expr": "MATCH(20,A1:A3,0)", "want": {"kind": "num", "num": 2}}, |
| + {"function": "MATCH", "expr": "MATCH(25,A1:A3,1)", "want": {"kind": "num", "num": 2}}, |
| + {"function": "COUNT", "expr": "COUNT(A1,C1)", "want": {"kind": "num", "num": 1}}, |
| + {"function": "COUNTA", "expr": "COUNTA(A1,C1)", "want": {"kind": "num", "num": 1}}, |
| + {"function": "AVERAGE", "expr": "AVERAGE(A1,A2,A3)", "want": {"kind": "num", "num": 20}} |
| ] |
| } |