calc: logic family -- IF, IFS, AND, OR, NOT, IFERROR
6 functions, plus 12 corpus rows and an updated corpusSHA256. evalCall evaluates every argument eagerly (Func receives finished Values, not lazy thunks -- see eval_call.go), so IF's untaken branch is computed too. That costs nothing observable: fnIf only returns the branch it selects and formulas have no side effects, so IF(FALSE,1/0,5) still returns 5 even though 1/0 was computed as #DIV/0! and discarded -- pinned by both a unit test and a corpus row specifically because it looks like it should be wrong. IFERROR is the one function that inspects an error argument instead of becoming it, which is exactly why evalCall does not short-circuit on KindErr arguments (task 7's documented decision) -- IFERROR is the concrete case that decision was made for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
4 files changed,
+254
−2
internal/sheets/calc/corpus_test.go+1 −1internal/sheets/calc/fn_logic.go+160 −0internal/sheets/calc/fn_logic_test.go+80 −0internal/sheets/calc/testdata/corpus/formulas.json+13 −1
Intent
diff --git a/internal/sheets/calc/corpus_test.go b/internal/sheets/calc/corpus_test.go| index 426e76a..6f9b440 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 = "ee3efa16a4cc76c3f389a7932877799113ff3ea13e8db9fa817c2086c2153699" |
| +const corpusSHA256 = "617ece1e4b0fc83dd9934d572b4b961fa650053d78ac1cf7ba88e1c109ecc1b5" |
| // 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_logic.go b/internal/sheets/calc/fn_logic.go| new file mode 100644 |
| index 0000000..963687b |
| --- /dev/null |
| +++ b/internal/sheets/calc/fn_logic.go |
| @@ -0,0 +1,160 @@ |
| +package calc |
| + |
| +import "strings" |
| + |
| +func init() { |
| + Register("IF", fnIf) |
| + Register("IFS", fnIfs) |
| + Register("AND", fnAnd) |
| + Register("OR", fnOr) |
| + Register("NOT", fnNot) |
| + Register("IFERROR", fnIfError) |
| +} |
| + |
| +// toBool coerces v the way IF's condition argument (and AND/OR/IFS's |
| +// operands) do: a boolean as itself, a number as nonzero, an empty cell |
| +// as false (Excel's own coercion for a blank condition), and text only |
| +// if it is literally "TRUE" or "FALSE" (case-insensitive) — Excel does |
| +// not treat an arbitrary string as truthy/falsy in a logical context. |
| +func toBool(v Value) (bool, ErrKind) { |
| + switch v.Kind { |
| + case KindBool: |
| + return v.Bool, ErrNone |
| + case KindNum: |
| + return v.Num != 0, ErrNone |
| + case KindEmpty: |
| + return false, ErrNone |
| + case KindStr: |
| + switch strings.ToUpper(v.Str) { |
| + case "TRUE": |
| + return true, ErrNone |
| + case "FALSE": |
| + return false, ErrNone |
| + } |
| + return false, ErrValue |
| + default: |
| + return false, ErrValue |
| + } |
| +} |
| + |
| +// fnIf implements IF(condition, value_if_true, [value_if_false]). |
| +// |
| +// evalCall has already evaluated every argument by the time this runs — |
| +// Func receives finished Values, not lazy thunks (see eval_call.go's doc |
| +// comment on evalCall) — so the branch NOT selected has already been |
| +// computed too. That costs nothing observable here: fnIf only ever |
| +// returns the ALREADY-COMPUTED value of the branch it selects, never |
| +// re-evaluates anything, and formulas have no side effects |
| +// (eval_arith.go's evalBinary), so IF(FALSE, 1/0, 5) still correctly |
| +// returns 5 even though 1/0 was computed as #DIV/0! along the way and |
| +// simply discarded — pinned by a corpus entry precisely because it looks |
| +// like it should be wrong. |
| +func fnIf(args []Value) Value { |
| + if len(args) != 2 && len(args) != 3 { |
| + return errValue(ErrValue) |
| + } |
| + if args[0].Kind == KindErr { |
| + return args[0] |
| + } |
| + cond, ek := toBool(args[0]) |
| + if ek != ErrNone { |
| + return errValue(ek) |
| + } |
| + if cond { |
| + return args[1] |
| + } |
| + if len(args) == 3 { |
| + return args[2] |
| + } |
| + return Value{Kind: KindBool, Bool: false} |
| +} |
| + |
| +// fnIfs implements IFS(cond1, value1, cond2, value2, ...): the value |
| +// paired with the first true condition, scanned left to right. Only the |
| +// conditions actually inspected before the first true one can fail this |
| +// call — a later pair's condition (already evaluated by evalCall, per |
| +// fnIf's doc comment) is never looked at once an earlier one matches. No |
| +// condition true at all is #N/A, matching Excel. |
| +func fnIfs(args []Value) Value { |
| + if len(args) < 2 || len(args)%2 != 0 { |
| + return errValue(ErrValue) |
| + } |
| + for i := 0; i < len(args); i += 2 { |
| + c := args[i] |
| + if c.Kind == KindErr { |
| + return c |
| + } |
| + b, ek := toBool(c) |
| + if ek != ErrNone { |
| + return errValue(ek) |
| + } |
| + if b { |
| + return args[i+1] |
| + } |
| + } |
| + return errValue(ErrNA) |
| +} |
| + |
| +func fnAnd(args []Value) Value { |
| + if len(args) == 0 { |
| + return errValue(ErrValue) |
| + } |
| + result := true |
| + for _, a := range args { |
| + if a.Kind == KindErr { |
| + return a |
| + } |
| + b, ek := toBool(a) |
| + if ek != ErrNone { |
| + return errValue(ek) |
| + } |
| + result = result && b |
| + } |
| + return Value{Kind: KindBool, Bool: result} |
| +} |
| + |
| +func fnOr(args []Value) Value { |
| + if len(args) == 0 { |
| + return errValue(ErrValue) |
| + } |
| + result := false |
| + for _, a := range args { |
| + if a.Kind == KindErr { |
| + return a |
| + } |
| + b, ek := toBool(a) |
| + if ek != ErrNone { |
| + return errValue(ek) |
| + } |
| + result = result || b |
| + } |
| + return Value{Kind: KindBool, Bool: result} |
| +} |
| + |
| +func fnNot(args []Value) Value { |
| + if len(args) != 1 { |
| + return errValue(ErrValue) |
| + } |
| + if args[0].Kind == KindErr { |
| + return args[0] |
| + } |
| + b, ek := toBool(args[0]) |
| + if ek != ErrNone { |
| + return errValue(ek) |
| + } |
| + return Value{Kind: KindBool, Bool: !b} |
| +} |
| + |
| +// fnIfError implements IFERROR(value, value_if_error): the one place a |
| +// formula is allowed to look at an error argument instead of becoming |
| +// it, which is exactly why evalCall does not short-circuit on an error |
| +// argument (see eval_call.go's doc comment on evalCall). |
| +func fnIfError(args []Value) Value { |
| + if len(args) != 2 { |
| + return errValue(ErrValue) |
| + } |
| + if args[0].Kind == KindErr { |
| + return args[1] |
| + } |
| + return args[0] |
| +} |
diff --git a/internal/sheets/calc/fn_logic_test.go b/internal/sheets/calc/fn_logic_test.go| new file mode 100644 |
| index 0000000..008a9d4 |
| --- /dev/null |
| +++ b/internal/sheets/calc/fn_logic_test.go |
| @@ -0,0 +1,80 @@ |
| +package calc |
| + |
| +import "testing" |
| + |
| +func TestIfBranches(t *testing.T) { |
| + v := Eval(mustParse(t, `IF(TRUE,"yes","no")`), emptyEnv{}) |
| + if v.Kind != KindStr || v.Str != "yes" { |
| + t.Fatalf("IF(TRUE,...) = %v, want \"yes\"", v) |
| + } |
| + v = Eval(mustParse(t, `IF(FALSE,"yes","no")`), emptyEnv{}) |
| + if v.Kind != KindStr || v.Str != "no" { |
| + t.Fatalf("IF(FALSE,...) = %v, want \"no\"", v) |
| + } |
| +} |
| + |
| +// The untaken branch is still evaluated by evalCall (Func receives |
| +// finished Values, not thunks), but fnIf only ever returns the |
| +// already-computed value it selects — so the discarded branch's own |
| +// error never surfaces in the result. |
| +func TestIfDiscardsErrorInUntakenBranch(t *testing.T) { |
| + v := Eval(mustParse(t, "IF(FALSE,1/0,5)"), emptyEnv{}) |
| + if v.Kind != KindNum || v.Num != 5 { |
| + t.Fatalf("IF(FALSE,1/0,5) = %v, want 5", v) |
| + } |
| +} |
| + |
| +func TestIfsFirstTrueWins(t *testing.T) { |
| + v := Eval(mustParse(t, "IFS(FALSE,1,TRUE,2,TRUE,3)"), emptyEnv{}) |
| + if v.Kind != KindNum || v.Num != 2 { |
| + t.Fatalf("IFS(...) = %v, want 2", v) |
| + } |
| +} |
| + |
| +func TestIfsNoTrueIsNA(t *testing.T) { |
| + v := Eval(mustParse(t, "IFS(FALSE,1,FALSE,2)"), emptyEnv{}) |
| + if v.Kind != KindErr || v.Err != ErrNA { |
| + t.Fatalf("IFS(all false) = %v, want #N/A", v) |
| + } |
| +} |
| + |
| +func TestAndOrNot(t *testing.T) { |
| + v := Eval(mustParse(t, "AND(TRUE,TRUE,TRUE)"), emptyEnv{}) |
| + if v.Kind != KindBool || !v.Bool { |
| + t.Fatalf("AND(T,T,T) = %v, want TRUE", v) |
| + } |
| + v = Eval(mustParse(t, "AND(TRUE,FALSE)"), emptyEnv{}) |
| + if v.Kind != KindBool || v.Bool { |
| + t.Fatalf("AND(T,F) = %v, want FALSE", v) |
| + } |
| + v = Eval(mustParse(t, "OR(FALSE,FALSE,TRUE)"), emptyEnv{}) |
| + if v.Kind != KindBool || !v.Bool { |
| + t.Fatalf("OR(F,F,T) = %v, want TRUE", v) |
| + } |
| + v = Eval(mustParse(t, "NOT(TRUE)"), emptyEnv{}) |
| + if v.Kind != KindBool || v.Bool { |
| + t.Fatalf("NOT(TRUE) = %v, want FALSE", v) |
| + } |
| +} |
| + |
| +func TestAndPropagatesError(t *testing.T) { |
| + env := mapEnv{{1, 1}: {Kind: KindErr, Err: ErrRef}} |
| + v := Eval(mustParse(t, "AND(A1,TRUE)"), env) |
| + if v.Kind != KindErr || v.Err != ErrRef { |
| + t.Fatalf("AND(A1,TRUE) = %v, want #REF!", v) |
| + } |
| +} |
| + |
| +// IFERROR is the one place a formula is allowed to inspect an error |
| +// argument instead of becoming it (evalCall does not short-circuit on an |
| +// error argument for exactly this reason). |
| +func TestIfError(t *testing.T) { |
| + v := Eval(mustParse(t, `IFERROR(1/0,"fallback")`), emptyEnv{}) |
| + if v.Kind != KindStr || v.Str != "fallback" { |
| + t.Fatalf(`IFERROR(1/0,"fallback") = %v, want "fallback"`, v) |
| + } |
| + v = Eval(mustParse(t, `IFERROR(5,"fallback")`), emptyEnv{}) |
| + if v.Kind != KindNum || v.Num != 5 { |
| + t.Fatalf(`IFERROR(5,"fallback") = %v, want 5`, v) |
| + } |
| +} |
diff --git a/internal/sheets/calc/testdata/corpus/formulas.json b/internal/sheets/calc/testdata/corpus/formulas.json| index ec13d64..be07186 100644 |
| --- a/internal/sheets/calc/testdata/corpus/formulas.json |
| +++ b/internal/sheets/calc/testdata/corpus/formulas.json |
| @@ -26,6 +26,18 @@ |
| {"function": "POWER", "expr": "POWER(9,0.5)", "want": {"kind": "num", "num": 3}}, |
| {"function": "MOD", "expr": "MOD(10,3)", "want": {"kind": "num", "num": 1}}, |
| {"function": "MOD", "expr": "MOD(-10,3)", "want": {"kind": "num", "num": 2}}, |
| - {"function": "MOD", "expr": "MOD(10,-3)", "want": {"kind": "num", "num": -2}} |
| + {"function": "MOD", "expr": "MOD(10,-3)", "want": {"kind": "num", "num": -2}}, |
| + {"function": "IF", "expr": "IF(TRUE,\"yes\",\"no\")", "want": {"kind": "str", "str": "yes"}}, |
| + {"function": "IF", "expr": "IF(FALSE,\"yes\",\"no\")", "want": {"kind": "str", "str": "no"}}, |
| + {"function": "IF", "expr": "IF(FALSE,1/0,5)", "want": {"kind": "num", "num": 5}}, |
| + {"function": "IFS", "expr": "IFS(FALSE,1,TRUE,2,TRUE,3)", "want": {"kind": "num", "num": 2}}, |
| + {"function": "AND", "expr": "AND(TRUE,TRUE,TRUE)", "want": {"kind": "bool", "bool": true}}, |
| + {"function": "AND", "expr": "AND(TRUE,FALSE)", "want": {"kind": "bool", "bool": false}}, |
| + {"function": "OR", "expr": "OR(FALSE,FALSE,TRUE)", "want": {"kind": "bool", "bool": true}}, |
| + {"function": "OR", "expr": "OR(FALSE,FALSE)", "want": {"kind": "bool", "bool": false}}, |
| + {"function": "NOT", "expr": "NOT(TRUE)", "want": {"kind": "bool", "bool": false}}, |
| + {"function": "NOT", "expr": "NOT(FALSE)", "want": {"kind": "bool", "bool": true}}, |
| + {"function": "IFERROR", "expr": "IFERROR(1/0,\"fallback\")", "want": {"kind": "str", "str": "fallback"}}, |
| + {"function": "IFERROR", "expr": "IFERROR(5,\"fallback\")", "want": {"kind": "num", "num": 5}} |
| ] |
| } |