oficina / sheets Public

feat(calc): fifteen functions an imported workbook already uses

The engine registered 32 callable names against §5's "~120 to start".
That gap is not a roadmap item, it is a live failure on a shipped
feature: xlsx.Import preserves a formula's input and writes it through
our engine, so a workbook using SUMIF or YEAR imports faithfully and
then shows #NAME? in every cell that used one. The file arrives looking
whole and is not.

Fifteen more, in three families:

  SUMIF COUNTIF AVERAGEIF SUMIFS COUNTIFS AVERAGEIFS
  YEAR MONTH DAY DATE EOMONTH
  ISBLANK ISNUMBER ISTEXT ISERROR

The conditional aggregates are nodeFuncs for the SHAPE reason
registry.go documents, and it bites harder here than anywhere else:
SUMIF(A1:A9,">5",B1:B9) has to pair each criteria cell with the cell at
the same OFFSET in the sum range, and a flattened []Value has thrown
away both the argument boundary and the width that makes "same offset"
mean anything. Pairing is by offset and not by address, which is why
SUMIF(A1:A9,">5",B1) sums B1:B9 — Excel's rule, implemented on purpose
rather than inherited.

The IS* predicates are ordinary Funcs, and that is also a decision:
KindEmpty survives flattening (it is the zero ValueKind precisely so a
blank stays distinguishable from a zero) and evalCall does not
short-circuit an error argument, so an error reaches ISERROR as a value.
ISERR is deliberately absent — the pair is easy to register and easy to
get backwards, and nothing has asked for the distinction.

**Every expected value came from LibreOffice 26.8.0.3**, per design §5
and this slice's decision C: 28 new corpus cases, generated, and 28
agreed with the implementation on the first run with nothing adjusted.

**Except the wildcards, and that is the interesting part.** LibreOffice
answered 0 to all five of SUMIF(B1:B3,"t*",…), COUNTIF(B1:B3,"t?n"),
COUNTIF(B1:B3,"*y") and the two IFS forms — its default profile does not
read * and ? as Excel wildcards, so "t*" is matched as a literal string.
excelize, an independent engine, answers 60/1/2/60/2, which is Excel's
behaviour and ours. Same family as SUM(1,2,"3") — 6 in Excel and here,
#VALUE! in Calc — and already documented as such.

Pinning LibreOffice's zeros would have forced the engine to be wrong in
the exact place the corpus exists to keep it right, so those five moved
to xlsx/oracle_wildcard_test.go, checked against the oracle that can
judge them. gencorpus's own guard refused to narrow the corpus on the
way, which is the check working.

**Two of the wildcard cases exist only because the first pair proved
nothing.** COUNTIF(B1:B3,"t?n") was the original ? case, and a matcher
treating ? as * still answered 1 — "t*n" matches "ten" and neither of
the others, so the mutation was invisible and the test passed. "t??"
(1 correct, 3 if ? widens) and "t*" (3 correct, 0 if * narrows) now fail
in opposite directions. Both mutations planted and watched fail; so was
the ">=" before ">" ordering in parseCriterion, which without it parses
">=5" as ">" against the text "=5" and silently matches nothing.

The Insert > Function menu is deliberately unchanged. Its existing rule
is that a one-click insert must be able to guess the arguments, which is
why VLOOKUP is already absent — and nothing can guess a criterion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014od11UreNE4Ewf3oR9837B
Paul Campbell pushed by paul@keymail.dev 6663e21d075d25093f4ad1eac5ad69e26d8754be parent 9db075c
8 files changed, +847 −2
  • internal/sheets/calc/corpus_test.go +1 −1
  • internal/sheets/calc/fn_cond.go +214 −0
  • internal/sheets/calc/fn_cond_apply.go +214 −0
  • internal/sheets/calc/fn_date_parts.go +150 −0
  • internal/sheets/calc/fn_is.go +87 −0
  • internal/sheets/calc/testdata/corpus/formulas.json +29 −1
  • internal/sheets/xlsx/oracle_wildcard_test.go +92 −0
  • tools/gencorpus/main.go +60 −0

CI passed — run details

diff --git a/internal/sheets/calc/corpus_test.go b/internal/sheets/calc/corpus_test.go
index f1a5565..1b80866 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 = "235a35080ea75bfc15e697c60f39e4995b793f7a3a41257c3f6e5d7b94948b05"
+const corpusSHA256 = "369009599c13196caea067b7f3b503dc53a691f5c655f532bd3df59131f521a2"
// 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_cond.go b/internal/sheets/calc/fn_cond.go
new file mode 100644
index 0000000..708392d
--- /dev/null
+++ b/internal/sheets/calc/fn_cond.go
@@ -0,0 +1,214 @@
+package calc
+
+import (
+ "strconv"
+ "strings"
+)
+
+// fn_cond.go: SUMIF, COUNTIF, AVERAGEIF and their *IFS plurals.
+//
+// These register as nodeFuncs for the SHAPE reason registry.go's
+// nodeFunc comment gives, and it bites harder here than anywhere else in
+// the package. `SUMIF(A1:A9, ">5", B1:B9)` has to pair each cell of the
+// criteria range with the cell at the SAME OFFSET in the sum range, and
+// a flattened []Value has thrown away both the boundary between the two
+// arguments and the width that makes "same offset" mean anything. There
+// is no recovering it afterwards.
+//
+// The pairing is by OFFSET, not by address, which is Excel's rule and
+// the one that surprises people: if the sum range is written smaller
+// than the criteria range, Excel reads it as though it had the criteria
+// range's shape, anchored at its top-left. So SUMIF(A1:A9,">5",B1) sums
+// B1:B9. Implemented deliberately rather than inherited — see sumRange.
+
+func init() {
+ registerNode("SUMIF", fnSumIf)
+ registerNode("COUNTIF", fnCountIf)
+ registerNode("AVERAGEIF", fnAverageIf)
+ registerNode("SUMIFS", fnSumIfs)
+ registerNode("COUNTIFS", fnCountIfs)
+ registerNode("AVERAGEIFS", fnAverageIfs)
+}
+
+// rect is a range's four bounds, in positions.
+type rect struct{ r0, r1, c0, c1 int }
+
+func rectOf(n Node) (rect, bool) {
+ rng, ok := n.(*Range)
+ if !ok {
+ return rect{}, false
+ }
+ r0, r1, c0, c1, ok := boundedRangeBounds(rng)
+ return rect{r0, r1, c0, c1}, ok
+}
+
+// offsetRect reshapes `to` to have `like`'s width and height, anchored
+// at its own top-left. This is the Excel rule described in the file
+// comment: a sum range is only ever read for its ORIGIN.
+func offsetRect(to, like rect) rect {
+ return rect{
+ r0: to.r0, r1: to.r0 + (like.r1 - like.r0),
+ c0: to.c0, c1: to.c0 + (like.c1 - like.c0),
+ }
+}
+
+func (x rect) cells() int { return (x.r1 - x.r0 + 1) * (x.c1 - x.c0 + 1) }
+
+// ---- criteria -------------------------------------------------------
+
+// criterion is a parsed Excel criteria argument: an optional comparison
+// operator and the value to compare against.
+//
+// Excel writes these as text — ">5", "<>", "<=2026-01-01", "apple",
+// "app*" — with the operator inside the string rather than as syntax.
+// A bare value with no operator means equality.
+type criterion struct {
+ op string // "", "=", "<>", ">", "<", ">=", "<="
+ num float64
+ isNum bool
+ text string
+}
+
+// parseCriterion reads one criteria argument.
+//
+// The order of the operator tests matters: ">=" must be tried before
+// ">", or ">=5" parses as ">" against the text "=5" and matches
+// nothing, silently, for every row.
+func parseCriterion(v Value) criterion {
+ if v.Kind != KindStr {
+ if v.Kind == KindNum {
+ return criterion{op: "=", num: v.Num, isNum: true}
+ }
+ return criterion{op: "=", text: valueText(v)}
+ }
+ s := v.Str
+ for _, op := range []string{">=", "<=", "<>", ">", "<", "="} {
+ if strings.HasPrefix(s, op) {
+ return finishCriterion(op, strings.TrimPrefix(s, op))
+ }
+ }
+ return finishCriterion("=", s)
+}
+
+func finishCriterion(op, rest string) criterion {
+ if n, err := strconv.ParseFloat(strings.TrimSpace(rest), 64); err == nil {
+ return criterion{op: op, num: n, isNum: true}
+ }
+ return criterion{op: op, text: rest}
+}
+
+// valueText renders a value the way a criteria comparison sees it.
+func valueText(v Value) string {
+ switch v.Kind {
+ case KindStr:
+ return v.Str
+ case KindNum:
+ return strconv.FormatFloat(v.Num, 'g', -1, 64)
+ case KindBool:
+ if v.Bool {
+ return "TRUE"
+ }
+ return "FALSE"
+ }
+ return ""
+}
+
+// matches reports whether one cell satisfies the criterion.
+//
+// An error cell never matches anything, including "<>" — an error is not
+// a value that is unequal to something, it is the absence of one, and
+// counting it as "not equal to 5" would quietly inflate every COUNTIF
+// over a column with one bad formula in it.
+func (c criterion) matches(v Value) bool {
+ if v.Kind == KindErr {
+ return false
+ }
+ if c.isNum {
+ if v.Kind != KindNum {
+ // A blank is not zero, and text is not a number: only "<>"
+ // is true of a cell that cannot be compared numerically.
+ return c.op == "<>"
+ }
+ switch c.op {
+ case "=", "":
+ return v.Num == c.num
+ case "<>":
+ return v.Num != c.num
+ case ">":
+ return v.Num > c.num
+ case "<":
+ return v.Num < c.num
+ case ">=":
+ return v.Num >= c.num
+ case "<=":
+ return v.Num <= c.num
+ }
+ return false
+ }
+ // Text comparison is case-insensitive, as everywhere in Excel, and
+ // an empty criterion ("=" with nothing after it) means an empty
+ // cell rather than the empty string.
+ if c.text == "" && (c.op == "=" || c.op == "") {
+ return v.Kind == KindEmpty
+ }
+ if c.text == "" && c.op == "<>" {
+ return v.Kind != KindEmpty
+ }
+ got := strings.ToUpper(valueText(v))
+ want := strings.ToUpper(c.text)
+ switch c.op {
+ case "=", "":
+ return wildcardMatch(want, got)
+ case "<>":
+ return !wildcardMatch(want, got)
+ case ">":
+ return got > want
+ case "<":
+ return got < want
+ case ">=":
+ return got >= want
+ case "<=":
+ return got <= want
+ }
+ return false
+}
+
+// wildcardMatch implements Excel's two wildcards: * for any run and ?
+// for exactly one character. A pattern with neither is a plain equality,
+// which is the overwhelmingly common case and is checked first so the
+// matcher below never runs for it.
+func wildcardMatch(pattern, s string) bool {
+ if !strings.ContainsAny(pattern, "*?") {
+ return pattern == s
+ }
+ return globMatch([]rune(pattern), []rune(s))
+}
+
+// globMatch is the ordinary two-index backtracking matcher. Recursion on
+// '*' would be O(2^n) on a pathological pattern against a long cell, and
+// a cell is caller-supplied.
+func globMatch(p, s []rune) bool {
+ var pi, si, star, mark int
+ star = -1
+ for si < len(s) {
+ switch {
+ case pi < len(p) && (p[pi] == '?' || p[pi] == s[si]):
+ pi++
+ si++
+ case pi < len(p) && p[pi] == '*':
+ star = pi
+ mark = si
+ pi++
+ case star >= 0:
+ pi = star + 1
+ mark++
+ si = mark
+ default:
+ return false
+ }
+ }
+ for pi < len(p) && p[pi] == '*' {
+ pi++
+ }
+ return pi == len(p)
+}
diff --git a/internal/sheets/calc/fn_cond_apply.go b/internal/sheets/calc/fn_cond_apply.go
new file mode 100644
index 0000000..e6b078d
--- /dev/null
+++ b/internal/sheets/calc/fn_cond_apply.go
@@ -0,0 +1,214 @@
+package calc
+
+// fn_cond_apply.go: the six conditional aggregates themselves. The
+// criteria machinery they share is in fn_cond.go; split for the 300-line
+// cap, and along the seam that already existed — one file decides
+// whether a cell matches, the other decides what to do about it.
+
+// condPair is one criteria range and the criterion applied to it.
+type condPair struct {
+ over rect
+ crit criterion
+}
+
+// selected walks the first pair's rectangle and calls fn for every
+// offset where EVERY pair matches.
+//
+// Offsets, not addresses. Each further criteria range is reshaped to the
+// first one's dimensions (offsetRect), so ranges of different written
+// sizes still line up cell for cell — Excel's rule, and the one that
+// makes SUMIFS over ranges somebody wrote inconsistently give the answer
+// they expect rather than #VALUE!.
+func selected(pairs []condPair, env Env, fn func(dr, dc int)) {
+ base := pairs[0].over
+ for dr := 0; dr <= base.r1-base.r0; dr++ {
+ for dc := 0; dc <= base.c1-base.c0; dc++ {
+ all := true
+ for _, p := range pairs {
+ at := offsetRect(p.over, base)
+ if !p.crit.matches(cellValueAt(env, at.r0+dr, at.c0+dc)) {
+ all = false
+ break
+ }
+ }
+ if all {
+ fn(dr, dc)
+ }
+ }
+ }
+}
+
+// readPairs decodes (range, criteria) argument couples starting at
+// argNodes[from], stepping two at a time.
+//
+// ok is false for a bad count, a non-range where a range must be, or a
+// range over the cell cap — the same three refusals fn_lookup.go makes,
+// answered the same way, because a caller cannot tell them apart and
+// should not have to.
+func readPairs(argNodes []Node, from int, env Env) ([]condPair, Value, bool) {
+ rest := argNodes[from:]
+ if len(rest) == 0 || len(rest)%2 != 0 {
+ return nil, errValue(ErrValue), false
+ }
+ var pairs []condPair
+ for i := 0; i < len(rest); i += 2 {
+ box, ok := rectOf(rest[i])
+ if !ok {
+ return nil, errValue(ErrValue), false
+ }
+ c := Eval(rest[i+1], env)
+ if c.Kind == KindErr {
+ return nil, c, false
+ }
+ pairs = append(pairs, condPair{over: box, crit: parseCriterion(c)})
+ }
+ // Every criteria range must have the same cell count as the first.
+ // Excel refuses a genuinely different SHAPE rather than reshaping
+ // it, and reshaping silently would make a mistyped range read the
+ // wrong cells and still return a number.
+ for _, p := range pairs[1:] {
+ if p.over.cells() != pairs[0].over.cells() {
+ return nil, errValue(ErrValue), false
+ }
+ }
+ return pairs, Value{}, true
+}
+
+// total adds up the values at the selected offsets of `over`, and also
+// counts them, so SUM and AVERAGE share one walk.
+func total(pairs []condPair, over rect, env Env) (sum float64, n int) {
+ at := offsetRect(over, pairs[0].over)
+ selected(pairs, env, func(dr, dc int) {
+ v := cellValueAt(env, at.r0+dr, at.c0+dc)
+ if v.Kind != KindNum {
+ // Text, blanks and logicals read from a reference are
+ // skipped, exactly as SUM skips them (fn_math.go's
+ // taggedValue comment) — a conditional sum must not
+ // disagree with an unconditional one about what a number is.
+ return
+ }
+ sum += v.Num
+ n++
+ })
+ return sum, n
+}
+
+// ---- SUMIF / COUNTIF / AVERAGEIF ------------------------------------
+//
+// The singulars take (range, criteria, [apply_range]) with the applied
+// range LAST and optional. The plurals take (apply_range, range1,
+// criteria1, …) with it FIRST and required. That inconsistency is
+// Excel's, not ours, and it is the single most common way these get
+// written wrongly — so the two shapes are decoded by separate functions
+// rather than by one with a flag.
+
+func fnSumIf(argNodes []Node, env Env) Value {
+ pairs, over, bad, ok := singular(argNodes, env)
+ if !ok {
+ return bad
+ }
+ sum, _ := total(pairs, over, env)
+ return Value{Kind: KindNum, Num: sum}
+}
+
+func fnCountIf(argNodes []Node, env Env) Value {
+ if len(argNodes) != 2 {
+ return errValue(ErrValue)
+ }
+ pairs, bad, ok := readPairs(argNodes, 0, env)
+ if !ok {
+ return bad
+ }
+ n := 0
+ selected(pairs, env, func(int, int) { n++ })
+ return Value{Kind: KindNum, Num: float64(n)}
+}
+
+func fnAverageIf(argNodes []Node, env Env) Value {
+ pairs, over, bad, ok := singular(argNodes, env)
+ if !ok {
+ return bad
+ }
+ sum, n := total(pairs, over, env)
+ if n == 0 {
+ // Excel's answer for an average of nothing, and deliberately
+ // not zero: zero is a number somebody would act on.
+ return errValue(ErrDiv0)
+ }
+ return Value{Kind: KindNum, Num: sum / float64(n)}
+}
+
+// singular decodes the (range, criteria, [apply_range]) shape and
+// returns the rectangle to aggregate over — the criteria range itself
+// when no third argument was written, which is what makes
+// SUMIF(A1:A9,">5") sum the cells it tested.
+func singular(argNodes []Node, env Env) ([]condPair, rect, Value, bool) {
+ if len(argNodes) != 2 && len(argNodes) != 3 {
+ return nil, rect{}, errValue(ErrValue), false
+ }
+ pairs, bad, ok := readPairs(argNodes[:2], 0, env)
+ if !ok {
+ return nil, rect{}, bad, false
+ }
+ over := pairs[0].over
+ if len(argNodes) == 3 {
+ box, ok := rectOf(argNodes[2])
+ if !ok {
+ return nil, rect{}, errValue(ErrValue), false
+ }
+ over = box
+ }
+ return pairs, over, Value{}, true
+}
+
+// ---- SUMIFS / COUNTIFS / AVERAGEIFS ---------------------------------
+
+func fnSumIfs(argNodes []Node, env Env) Value {
+ over, pairs, bad, ok := plural(argNodes, env)
+ if !ok {
+ return bad
+ }
+ sum, _ := total(pairs, over, env)
+ return Value{Kind: KindNum, Num: sum}
+}
+
+func fnAverageIfs(argNodes []Node, env Env) Value {
+ over, pairs, bad, ok := plural(argNodes, env)
+ if !ok {
+ return bad
+ }
+ sum, n := total(pairs, over, env)
+ if n == 0 {
+ return errValue(ErrDiv0)
+ }
+ return Value{Kind: KindNum, Num: sum / float64(n)}
+}
+
+// fnCountIfs has no applied range at all — it counts matches — so it
+// decodes pairs from argument zero like COUNTIF rather than through
+// plural.
+func fnCountIfs(argNodes []Node, env Env) Value {
+ pairs, bad, ok := readPairs(argNodes, 0, env)
+ if !ok {
+ return bad
+ }
+ n := 0
+ selected(pairs, env, func(int, int) { n++ })
+ return Value{Kind: KindNum, Num: float64(n)}
+}
+
+// plural decodes the (apply_range, range1, criteria1, …) shape.
+func plural(argNodes []Node, env Env) (rect, []condPair, Value, bool) {
+ if len(argNodes) < 3 {
+ return rect{}, nil, errValue(ErrValue), false
+ }
+ over, ok := rectOf(argNodes[0])
+ if !ok {
+ return rect{}, nil, errValue(ErrValue), false
+ }
+ pairs, bad, ok := readPairs(argNodes, 1, env)
+ if !ok {
+ return rect{}, nil, bad, false
+ }
+ return over, pairs, Value{}, true
+}
diff --git a/internal/sheets/calc/fn_date_parts.go b/internal/sheets/calc/fn_date_parts.go
new file mode 100644
index 0000000..903c348
--- /dev/null
+++ b/internal/sheets/calc/fn_date_parts.go
@@ -0,0 +1,150 @@
+package calc
+
+import (
+ "math"
+ "time"
+)
+
+// fn_date_parts.go: the date functions that take a value rather than the
+// clock.
+//
+// NOW and TODAY are in fn_date.go and are clockFuncs, because they need
+// the pass's pinned instant. These need nothing but their arguments, so
+// they are ordinary Funcs — and keeping the two kinds in separate files
+// is what stops somebody adding a sixth one here and reaching for
+// env.Now() out of habit, which is the bug
+// TestClockIsPinnedWithinAPass was written for.
+//
+// A date is a number. fn_date.go's excelEpoch comment states the rule
+// and it holds here unchanged: there is no date Value kind, only a
+// serial that a number format displays as a calendar date.
+
+func init() {
+ Register("YEAR", fnYear)
+ Register("MONTH", fnMonth)
+ Register("DAY", fnDay)
+ Register("DATE", fnDate)
+ Register("EOMONTH", fnEOMonth)
+}
+
+// serialTime is dateSerial's inverse.
+//
+// Truncated toward zero before conversion, so the time of day inside a
+// serial is discarded rather than rounded: 2026-09-03 23:59 must be
+// DAY 3, and a serial rounded to the nearest whole day would make it
+// the 4th. That is the one arithmetic mistake available here and it is
+// invisible for all but the last hour of a day, which is when nobody
+// is looking.
+func serialTime(serial float64) time.Time {
+ return excelEpoch.AddDate(0, 0, int(math.Trunc(serial)))
+}
+
+// datePart is the shared body of YEAR, MONTH and DAY.
+//
+// A negative serial is #NUM!, not a date before 1899: Excel has no
+// negative dates, and AddDate would cheerfully produce one that then
+// formats as something plausible.
+func datePart(args []Value, part func(time.Time) int) Value {
+ if len(args) != 1 {
+ return errValue(ErrValue)
+ }
+ if args[0].Kind == KindErr {
+ return args[0]
+ }
+ serial, ek := toNumber(args[0])
+ if ek != ErrNone {
+ return errValue(ek)
+ }
+ if serial < 0 {
+ return errValue(ErrNum)
+ }
+ return Value{Kind: KindNum, Num: float64(part(serialTime(serial)))}
+}
+
+func fnYear(args []Value) Value {
+ return datePart(args, func(t time.Time) int { return t.Year() })
+}
+
+func fnMonth(args []Value) Value {
+ return datePart(args, func(t time.Time) int { return int(t.Month()) })
+}
+
+func fnDay(args []Value) Value {
+ return datePart(args, func(t time.Time) int { return t.Day() })
+}
+
+// fnDate implements DATE(year, month, day).
+//
+// Out-of-range months and days ROLL, and that is Excel's documented
+// behaviour rather than a shortcut: DATE(2026,13,1) is January 2027 and
+// DATE(2026,1,0) is the last day of December 2025. time.Date normalises
+// exactly this way, which is why the arguments are passed through
+// rather than validated — validating them would be a divergence dressed
+// as strictness, and `=DATE(YEAR(A1),MONTH(A1)+1,0)` is the idiom half
+// of all end-of-month formulas are written with.
+func fnDate(args []Value) Value {
+ if len(args) != 3 {
+ return errValue(ErrValue)
+ }
+ parts := make([]int, 3)
+ for i, a := range args {
+ if a.Kind == KindErr {
+ return a
+ }
+ n, ek := toNumber(a)
+ if ek != ErrNone {
+ return errValue(ek)
+ }
+ parts[i] = int(math.Trunc(n))
+ }
+ // Excel's two-digit year window: 0-1899 is read as an offset from
+ // 1900, so DATE(26,9,3) is 1926 and not the year 26. Written out
+ // rather than left to time.Date, which would happily build a
+ // first-century date that then formats as a plausible-looking
+ // serial nobody meant.
+ year := parts[0]
+ if year >= 0 && year < 1900 {
+ year += 1900
+ }
+ t := time.Date(year, time.Month(parts[1]), parts[2], 0, 0, 0, 0, time.UTC)
+ if t.Before(excelEpoch) {
+ return errValue(ErrNum)
+ }
+ return Value{Kind: KindNum, Num: dateSerial(t)}
+}
+
+// fnEOMonth implements EOMONTH(start_date, months): the last day of the
+// month `months` away from start_date.
+//
+// Computed as day zero of the FOLLOWING month rather than by asking how
+// many days the target month has. Both are correct; this one has no
+// leap-year branch to get wrong, and time.Date's normalisation is
+// already the thing being relied on in fnDate above.
+func fnEOMonth(args []Value) Value {
+ if len(args) != 2 {
+ return errValue(ErrValue)
+ }
+ for _, a := range args {
+ if a.Kind == KindErr {
+ return a
+ }
+ }
+ start, ek := toNumber(args[0])
+ if ek != ErrNone {
+ return errValue(ek)
+ }
+ months, ek := toNumber(args[1])
+ if ek != ErrNone {
+ return errValue(ek)
+ }
+ if start < 0 {
+ return errValue(ErrNum)
+ }
+ t := serialTime(start)
+ end := time.Date(t.Year(), t.Month()+time.Month(int(math.Trunc(months)))+1, 0,
+ 0, 0, 0, 0, time.UTC)
+ if end.Before(excelEpoch) {
+ return errValue(ErrNum)
+ }
+ return Value{Kind: KindNum, Num: dateSerial(end)}
+}
diff --git a/internal/sheets/calc/fn_is.go b/internal/sheets/calc/fn_is.go
new file mode 100644
index 0000000..52da8a2
--- /dev/null
+++ b/internal/sheets/calc/fn_is.go
@@ -0,0 +1,87 @@
+package calc
+
+// fn_is.go: the IS* predicates.
+//
+// These are what somebody wrapped a formula in to stop a sheet showing
+// an error, so they are the functions an imported workbook is most
+// likely to use around the ones it already used. Without them the wrap
+// itself becomes the error: `=IF(ISBLANK(A1),"",B1/A1)` imports
+// faithfully and evaluates to #NAME? in every row.
+//
+// Ordinary Funcs, not nodeFuncs, and that is a real decision rather
+// than the easy one. evalCallArgs preserves both distinctions these
+// need: KindEmpty survives flattening (it is the zero ValueKind
+// precisely so a blank cell stays distinguishable from a zero — see
+// eval.go), and evalCall does NOT short-circuit an error argument, so
+// an error reaches ISERROR as a value rather than replacing the call.
+// The one thing flattening loses is a range's shape, and none of these
+// takes a range.
+
+func init() {
+ Register("ISBLANK", fnIsBlank)
+ Register("ISNUMBER", fnIsNumber)
+ Register("ISTEXT", fnIsText)
+ Register("ISERROR", fnIsError)
+}
+
+// oneArg is the shared argument check. Exactly one value, never a
+// range: `ISBLANK(A1:A3)` flattens to three arguments here, and Excel
+// answers that with an array this engine has no representation for. One
+// honest #VALUE! is better than silently testing the first cell and
+// reporting on two the caller never mentioned.
+func oneArg(args []Value) (Value, bool) {
+ if len(args) != 1 {
+ return Value{}, false
+ }
+ return args[0], true
+}
+
+// fnIsBlank is TRUE only for a genuinely empty cell.
+//
+// Not for the empty string, and the difference is the whole point of the
+// function: `=IF(A1="","",…)` and `=IF(ISBLANK(A1),"",…)` disagree on a
+// cell holding a formula that returned "", which is exactly the case
+// somebody reached for ISBLANK to tell apart.
+func fnIsBlank(args []Value) Value {
+ v, ok := oneArg(args)
+ if !ok {
+ return errValue(ErrValue)
+ }
+ return boolValue(v.Kind == KindEmpty)
+}
+
+// fnIsNumber. A boolean is not a number here, matching Excel: TRUE
+// coerces to 1 in arithmetic but ISNUMBER(TRUE) is FALSE.
+func fnIsNumber(args []Value) Value {
+ v, ok := oneArg(args)
+ if !ok {
+ return errValue(ErrValue)
+ }
+ return boolValue(v.Kind == KindNum)
+}
+
+func fnIsText(args []Value) Value {
+ v, ok := oneArg(args)
+ if !ok {
+ return errValue(ErrValue)
+ }
+ return boolValue(v.Kind == KindStr)
+}
+
+// fnIsError is TRUE for every error kind, #N/A included.
+//
+// Excel has ISERR too, which is ISERROR minus #N/A. It is deliberately
+// not here: the pair is easy to register and easy to get backwards, and
+// nothing has asked for the distinction. Adding it later costs one line;
+// shipping it now with the wrong one of the two doing the wrong thing
+// costs somebody a wrong answer they have no reason to check.
+func fnIsError(args []Value) Value {
+ v, ok := oneArg(args)
+ if !ok {
+ return errValue(ErrValue)
+ }
+ return boolValue(v.Kind == KindErr)
+}
+
+// boolValue is the counterpart of errValue for the predicate family.
+func boolValue(b bool) Value { return Value{Kind: KindBool, Bool: b} }
diff --git a/internal/sheets/calc/testdata/corpus/formulas.json b/internal/sheets/calc/testdata/corpus/formulas.json
index 6fa1fca..6b0c270 100644
--- a/internal/sheets/calc/testdata/corpus/formulas.json
+++ b/internal/sheets/calc/testdata/corpus/formulas.json
@@ -60,6 +60,34 @@
{"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}}
+ {"function": "AVERAGE", "expr": "AVERAGE(A1,A2,A3)", "want": {"kind": "num", "num": 20}},
+ {"function": "SUMIF", "expr": "SUMIF(A1:A3,\"\u003e15\")", "want": {"kind": "num", "num": 50}},
+ {"function": "SUMIF", "expr": "SUMIF(A1:A3,\"\u003e=20\")", "want": {"kind": "num", "num": 50}},
+ {"function": "SUMIF", "expr": "SUMIF(B1:B3,\"ten\",A1:A3)", "want": {"kind": "num", "num": 10}},
+ {"function": "COUNTIF", "expr": "COUNTIF(A1:A3,\"\u003e15\")", "want": {"kind": "num", "num": 2}},
+ {"function": "COUNTIF", "expr": "COUNTIF(A1:A3,\"\u003c\u003e20\")", "want": {"kind": "num", "num": 2}},
+ {"function": "AVERAGEIF", "expr": "AVERAGEIF(A1:A3,\"\u003e15\")", "want": {"kind": "num", "num": 25}},
+ {"function": "AVERAGEIF", "expr": "AVERAGEIF(B1:B3,\"\u003c\u003eten\",A1:A3)", "want": {"kind": "num", "num": 25}},
+ {"function": "SUMIFS", "expr": "SUMIFS(A1:A3,A1:A3,\"\u003e10\",A1:A3,\"\u003c30\")", "want": {"kind": "num", "num": 20}},
+ {"function": "COUNTIFS", "expr": "COUNTIFS(A1:A3,\"\u003e100\")", "want": {"kind": "num", "num": 0}},
+ {"function": "AVERAGEIFS", "expr": "AVERAGEIFS(A1:A3,B1:B3,\"\u003c\u003eten\")", "want": {"kind": "num", "num": 25}},
+ {"function": "ISBLANK", "expr": "ISBLANK(C1)", "want": {"kind": "bool", "bool": true}},
+ {"function": "ISBLANK", "expr": "ISBLANK(A1)", "want": {"kind": "bool", "bool": false}},
+ {"function": "ISNUMBER", "expr": "ISNUMBER(A1)", "want": {"kind": "bool", "bool": true}},
+ {"function": "ISNUMBER", "expr": "ISNUMBER(B1)", "want": {"kind": "bool", "bool": false}},
+ {"function": "ISTEXT", "expr": "ISTEXT(B1)", "want": {"kind": "bool", "bool": true}},
+ {"function": "ISTEXT", "expr": "ISTEXT(A1)", "want": {"kind": "bool", "bool": false}},
+ {"function": "ISERROR", "expr": "ISERROR(1/0)", "want": {"kind": "bool", "bool": true}},
+ {"function": "ISERROR", "expr": "ISERROR(A1)", "want": {"kind": "bool", "bool": false}},
+ {"function": "YEAR", "expr": "YEAR(DATE(2026,9,3))", "want": {"kind": "num", "num": 2026}},
+ {"function": "MONTH", "expr": "MONTH(DATE(2026,9,3))", "want": {"kind": "num", "num": 9}},
+ {"function": "DAY", "expr": "DAY(DATE(2026,9,3))", "want": {"kind": "num", "num": 3}},
+ {"function": "MONTH", "expr": "MONTH(DATE(2026,13,1))", "want": {"kind": "num", "num": 1}},
+ {"function": "YEAR", "expr": "YEAR(DATE(2026,13,1))", "want": {"kind": "num", "num": 2027}},
+ {"function": "DAY", "expr": "DAY(DATE(2026,3,0))", "want": {"kind": "num", "num": 28}},
+ {"function": "MONTH", "expr": "MONTH(EOMONTH(DATE(2026,1,31),1))", "want": {"kind": "num", "num": 2}},
+ {"function": "DAY", "expr": "DAY(EOMONTH(DATE(2026,1,31),1))", "want": {"kind": "num", "num": 28}},
+ {"function": "DAY", "expr": "DAY(EOMONTH(DATE(2024,1,15),1))", "want": {"kind": "num", "num": 29}},
+ {"function": "YEAR", "expr": "YEAR(EOMONTH(DATE(2026,12,1),1))", "want": {"kind": "num", "num": 2027}}
]
}
diff --git a/internal/sheets/xlsx/oracle_wildcard_test.go b/internal/sheets/xlsx/oracle_wildcard_test.go
new file mode 100644
index 0000000..a95934d
--- /dev/null
+++ b/internal/sheets/xlsx/oracle_wildcard_test.go
@@ -0,0 +1,92 @@
+package xlsx_test
+
+import (
+ "testing"
+
+ "amadan.net/oficina/sheets/internal/sheets/calc"
+)
+
+// The wildcard cases the LibreOffice corpus cannot judge.
+//
+// SUMIF, COUNTIF and their plurals take Excel's two wildcards in a text
+// criterion: * for any run of characters and ? for exactly one. Every
+// other case for these functions lives in the generated corpus, where
+// LibreOffice is the oracle — but not these.
+//
+// Measured on 2026-09-03, LibreOffice 26.8.0.3 answered **0** to every
+// one of the five below. Its default profile does not read * and ? as
+// wildcards at all, so "t*" is matched as the literal two-character
+// string. That is a configuration difference, not a bug in either
+// engine, and it is the same family as the divergence already recorded
+// for SUM(1,2,"3") — 6 in Excel, in excelize and here; #VALUE! in Calc.
+//
+// Pinning LibreOffice's zeros into the corpus would have forced this
+// engine to be wrong in exactly the place the corpus exists to keep it
+// right, so the cases moved here instead, to the oracle that can judge
+// them. excelize is an entirely independent implementation of Excel's
+// semantics and agrees with ours on all five.
+//
+// This is deliberately NOT a table of expected values written by hand.
+// The whole rule (design §5) is that expectations come from a real
+// engine, and a hand-written 60 here would be indistinguishable from a
+// hand-written 0 — both would pass, and only one would be Excel.
+// The first two exist because the obvious pair did not discriminate.
+// `COUNTIF(B1:B3,"t?n")` was the original ? case and a matcher that
+// treated ? as * still answered 1 — "t*n" matches "ten" and neither
+// "twenty" nor "thirty", so the mutation was invisible. Caught by
+// planting it deliberately, which is the only way that is ever caught.
+//
+// - "t??" is 1 with a correct ?, and 3 if ? widens to *.
+// - "t*" is 3 with a correct *, and 0 if * narrows to ?.
+//
+// Both mutations now fail, in opposite directions.
+var wildcardCases = []string{
+ `COUNTIF(B1:B3,"t??")`,
+ `COUNTIF(B1:B3,"t*")`,
+ `SUMIF(B1:B3,"t*",A1:A3)`,
+ `COUNTIF(B1:B3,"t?n")`,
+ `COUNTIF(B1:B3,"*y")`,
+ `SUMIFS(A1:A3,B1:B3,"t*")`,
+ `COUNTIFS(A1:A3,">=20",B1:B3,"t*")`,
+}
+
+func TestWildcardCriteriaAgainstExcelize(t *testing.T) {
+ for _, expr := range wildcardCases {
+ ours := calc.Eval(mustParseOracle(t, expr), oracleCellEnv)
+ oa := computeOracleAnswer(t, expr)
+ if !oa.Available {
+ t.Errorf("%s: excelize could not answer (%s) — this test has no other "+
+ "oracle, so an unavailable one makes it vacuous rather than passing",
+ expr, oa.Reason)
+ continue
+ }
+ if !agreesWithOracle(ours, oa.Value) {
+ t.Errorf("%s: ours=%q excelize=%q — investigate before assuming we are "+
+ "wrong, but note that LibreOffice's 0 for this expression is a "+
+ "profile difference and not a third opinion",
+ expr, renderCalcValue(ours), oa.Value)
+ }
+ }
+}
+
+// TestTheWildcardOracleIsActuallyComparing is the mutation check.
+//
+// The test above passes if excelize agrees, and it would also pass with
+// an empty case list or a matcher that silently answered nothing. This
+// asserts the list is populated and that the comparison is live, by
+// running an expression whose answer differs between a working wildcard
+// matcher and a literal one.
+func TestTheWildcardOracleIsActuallyComparing(t *testing.T) {
+ if len(wildcardCases) == 0 {
+ t.Fatal("the wildcard case list is empty; the test above proves nothing")
+ }
+ oa := computeOracleAnswer(t, `COUNTIF(B1:B3,"t*")`)
+ if !oa.Available {
+ t.Fatal("excelize cannot answer a plain wildcard COUNTIF; the oracle is gone")
+ }
+ if oa.Value == "0" {
+ t.Fatal(`excelize answered 0 to COUNTIF(B1:B3,"t*") — it has stopped treating ` +
+ `* as a wildcard, so it is no longer an oracle for Excel's semantics ` +
+ `and this whole file needs re-deciding`)
+ }
+}
diff --git a/tools/gencorpus/main.go b/tools/gencorpus/main.go
index 5d45879..bb2e113 100644
--- a/tools/gencorpus/main.go
+++ b/tools/gencorpus/main.go
@@ -156,6 +156,66 @@ var cases = []corpusCase{
{"COUNT", "COUNT(A1,C1)"},
{"COUNTA", "COUNTA(A1,C1)"},
{"AVERAGE", "AVERAGE(A1,A2,A3)"},
+
+ // The conditional aggregates. A1:A3 hold 10/20/30 and B1:B3 their
+ // English names, so every case below is answered by the same small
+ // table the lookup family already uses — no new support data, and
+ // the cases stay readable next to their expected answers.
+ //
+ // WILDCARD CASES ARE DELIBERATELY ABSENT, and this is measured
+ // rather than assumed. Generated on 2026-09-03, LibreOffice 26.8.0.3
+ // answered 0 to every one of SUMIF(B1:B3,"t*",A1:A3),
+ // COUNTIF(B1:B3,"t?n"), COUNTIF(B1:B3,"*y"), SUMIFS(…,"t*") and
+ // COUNTIFS(…,"t*") — its default profile does not read * and ? as
+ // Excel wildcards, so "t*" is matched as a literal string. excelize,
+ // an independent engine, answers 60/1/2/60/2, which is Excel's
+ // behaviour and ours.
+ //
+ // So LibreOffice cannot be the oracle for these, and pinning its
+ // answers here would force the engine to be wrong in the one place
+ // the corpus exists to keep it right. They live in
+ // xlsx/oracle_wildcard_test.go instead, checked against excelize.
+ // Same family of divergence as SUM(1,2,"3"), which Calc calls
+ // #VALUE! and Excel calls 6.
+ {"SUMIF", `SUMIF(A1:A3,">15")`},
+ {"SUMIF", `SUMIF(A1:A3,">=20")`},
+ {"SUMIF", `SUMIF(B1:B3,"ten",A1:A3)`},
+ {"COUNTIF", `COUNTIF(A1:A3,">15")`},
+ {"COUNTIF", `COUNTIF(A1:A3,"<>20")`},
+ {"AVERAGEIF", `AVERAGEIF(A1:A3,">15")`},
+ {"AVERAGEIF", `AVERAGEIF(B1:B3,"<>ten",A1:A3)`},
+ {"SUMIFS", `SUMIFS(A1:A3,A1:A3,">10",A1:A3,"<30")`},
+ {"COUNTIFS", `COUNTIFS(A1:A3,">100")`},
+ {"AVERAGEIFS", `AVERAGEIFS(A1:A3,B1:B3,"<>ten")`},
+
+ // The IS* predicates. C1 is deliberately blank in the support data,
+ // which is what makes the ISBLANK pair meaningful: one over a real
+ // blank and one over a cell holding a number.
+ {"ISBLANK", "ISBLANK(C1)"},
+ {"ISBLANK", "ISBLANK(A1)"},
+ {"ISNUMBER", "ISNUMBER(A1)"},
+ {"ISNUMBER", "ISNUMBER(B1)"},
+ {"ISTEXT", "ISTEXT(B1)"},
+ {"ISTEXT", "ISTEXT(A1)"},
+ {"ISERROR", "ISERROR(1/0)"},
+ {"ISERROR", "ISERROR(A1)"},
+
+ // The date parts. Every case wraps its result in a plain arithmetic
+ // or comparison so LibreOffice exports a NUMBER rather than a
+ // formatted date string: DATE() alone comes back through
+ // `--convert-to csv` as whatever the locale's date format renders,
+ // which is a string this corpus cannot compare against a serial.
+ // YEAR/MONTH/DAY already return plain numbers.
+ {"YEAR", "YEAR(DATE(2026,9,3))"},
+ {"MONTH", "MONTH(DATE(2026,9,3))"},
+ {"DAY", "DAY(DATE(2026,9,3))"},
+ {"MONTH", "MONTH(DATE(2026,13,1))"},
+ {"YEAR", "YEAR(DATE(2026,13,1))"},
+ {"DAY", "DAY(DATE(2026,3,0))"},
+ {"MONTH", "MONTH(EOMONTH(DATE(2026,1,31),1))"},
+ {"DAY", "DAY(EOMONTH(DATE(2026,1,31),1))"},
+ {"DAY", "DAY(EOMONTH(DATE(2024,1,15),1))"},
+ {"YEAR", "YEAR(EOMONTH(DATE(2026,12,1),1))"},
}
func main() {