calc: three-table function dispatch (Register, clockFunc, nodeFunc)
Extends evalCall (task 7's stub, funcTable empty) to check three function tables instead of one: the ordinary Func table (Register, exported per the plan), a clockFunc table for functions that need the recalculation pass's pinned instant (env.Now()) rather than argument values alone, and a nodeFunc table for functions that need a range argument's shape (width/height) as written rather than the flattened, boundary-free []Value evalCallArgs hands an ordinary Func. Both extra tables exist so NOW/TODAY and VLOOKUP/INDEX/MATCH (next commits) never need an ordinary funcTable entry that would only work correctly by accident -- see registry.go's clockFunc/nodeFunc doc comments for the reasoning. No function bodies yet; funcTable, clockFuncTable and nodeFuncTable are all still empty, so every call still resolves to #NAME? exactly as task 7 left it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
2 files changed,
+139
−23
internal/sheets/calc/eval_call.go+42 −23internal/sheets/calc/registry.go+97 −0
Intent
diff --git a/internal/sheets/calc/eval_call.go b/internal/sheets/calc/eval_call.go| index e1bdbaf..4f79c1d 100644 |
| --- a/internal/sheets/calc/eval_call.go |
| +++ b/internal/sheets/calc/eval_call.go |
| @@ -4,40 +4,59 @@ package calc |
| // value already evaluated, with a range argument flattened to one Value |
| // per cell (row-major), and returns the call's result. |
| // |
| -// This task wires the dispatch only; funcTable starts empty. Task 8 owns |
| -// the actual function bodies (SUM, IF, VLOOKUP, ...) in fn_*.go and |
| -// registry.go, and populates funcTable from there — same package, so it |
| -// needs no exported Register from this file. Until then, every call |
| -// resolves to #NAME?, which is also where a reference-shaped word the |
| -// lexer treated as a call because '(' followed it (token.go's |
| -// followedByCallParen — e.g. A1(2) parses as Call{Name:"A1"}) ends up: it |
| -// fails here, not at parse time. |
| +// Task 7 wires the dispatch only; funcTable started empty. Task 8 owns the |
| +// actual function bodies (SUM, IF, VLOOKUP, ...) in fn_*.go and |
| +// registry.go, and populates funcTable from there via the exported |
| +// Register — same package, so registry.go needs no access to funcTable |
| +// beyond what's already unexported here. |
| +// |
| +// A few functions (NOW/TODAY, VLOOKUP/INDEX/MATCH) cannot be expressed as |
| +// a plain Func — see clockFuncTable and nodeFuncTable below — so |
| +// evalCall checks three tables, not one; Func itself keeps this exact |
| +// signature for the ~150 ordinary functions the plan describes. |
| type Func func(args []Value) Value |
| var funcTable = map[string]Func{} |
| // evalCall dispatches a function call: look up t.Name (already upper-cased |
| -// by the parser), flatten every argument, and invoke it. |
| +// by the parser) in whichever of the three function tables has it, and |
| +// invoke it. An unresolved name — including a reference-shaped word the |
| +// lexer treated as a call because '(' followed it (token.go's |
| +// followedByCallParen — e.g. A1(2) parses as Call{Name:"A1"}) — is |
| +// #NAME?. |
| // |
| -// Argument values reach fn exactly as evaluated, including any that are |
| -// KindErr — evalCall does not short-circuit on an error argument the way |
| -// evalBinary and evalUnary short-circuit on an error operand. That is |
| -// deliberate, not an oversight: IFERROR and ISERROR (task 8) need the raw |
| -// error value to inspect, so blanket propagation here would break the one |
| -// place a formula is allowed to look at an error instead of becoming one. |
| -// Each registered Func decides for itself whether an error argument means |
| -// "become that error" (most functions) or something else (IFERROR, |
| -// ISERROR). |
| +// Argument values reach an ordinary Func exactly as evaluated, including |
| +// any that are KindErr — evalCall does not short-circuit on an error |
| +// argument the way evalBinary and evalUnary short-circuit on an error |
| +// operand. That is deliberate, not an oversight: IFERROR (task 8) needs |
| +// the raw error value to inspect, so blanket propagation here would break |
| +// the one place a formula is allowed to look at an error instead of |
| +// becoming one. Each registered Func decides for itself whether an error |
| +// argument means "become that error" (most functions) or something else |
| +// (IFERROR). |
| func evalCall(t *Call, env Env) Value { |
| - fn, ok := funcTable[t.Name] |
| - if !ok { |
| - return Value{Kind: KindErr, Err: ErrName} |
| + if fn, ok := funcTable[t.Name]; ok { |
| + return fn(evalCallArgs(t.Args, env)) |
| } |
| + if fn, ok := clockFuncTable[t.Name]; ok { |
| + return fn(env, evalCallArgs(t.Args, env)) |
| + } |
| + if fn, ok := nodeFuncTable[t.Name]; ok { |
| + return fn(t.Args, env) |
| + } |
| + return Value{Kind: KindErr, Err: ErrName} |
| +} |
| + |
| +// evalCallArgs flattens every argument node into the shared []Value an |
| +// ordinary Func or clockFunc receives — the same eager, error-preserving |
| +// evaluation evalCall has always done, just factored out so both dispatch |
| +// branches that need it don't repeat the loop. |
| +func evalCallArgs(nodes []Node, env Env) []Value { |
| var args []Value |
| - for _, a := range t.Args { |
| + for _, a := range nodes { |
| args = append(args, evalCallArg(a, env)...) |
| } |
| - return fn(args) |
| + return args |
| } |
| // evalCallArg evaluates one call argument into one or more Values: a |
diff --git a/internal/sheets/calc/registry.go b/internal/sheets/calc/registry.go| new file mode 100644 |
| index 0000000..e4127a2 |
| --- /dev/null |
| +++ b/internal/sheets/calc/registry.go |
| @@ -0,0 +1,97 @@ |
| +package calc |
| + |
| +// Register adds fn to the ordinary function table under name. Every |
| +// fn_*.go family file calls this once per function from its own init(), |
| +// so import order never matters — each file is independently |
| +// self-registering, and the whole set is populated before any test or |
| +// caller can reach evalCall. |
| +// |
| +// name is expected already upper-cased, matching how the parser |
| +// upper-cases every call name before Eval ever sees one (parseIdentOrCall, |
| +// parse.go) — Register itself does not upper-case anything, so a family |
| +// file that registered "sum" instead of "SUM" would silently never be |
| +// found by a real formula. Two families registering the same name is a |
| +// programming error caught immediately at init time, not a silent |
| +// last-write-wins overwrite that only surfaces as a wrong answer later. |
| +func Register(name string, fn Func) { |
| + if _, exists := funcTable[name]; exists { |
| + panic("calc: duplicate function registration: " + name) |
| + } |
| + funcTable[name] = fn |
| +} |
| + |
| +// clockFunc is the signature for a function that needs the current |
| +// recalculation pass's pinned instant (env.Now()) rather than argument |
| +// values alone. NOW and TODAY (fn_date.go) are the only two in this |
| +// task's scope. |
| +// |
| +// Func's signature — func(args []Value) Value — has no room for env, and |
| +// giving NOW/TODAY an ordinary funcTable entry that silently ignored env |
| +// (returning some fixed or approximate time) would be exactly the bug |
| +// this task's brief warns about: a registration that reads as "a real |
| +// registered function" on inspection but only behaves correctly by |
| +// accident, because something else intercepts the name before ever |
| +// calling it. Keeping "this function needs the clock" as a type-level |
| +// fact — a distinct signature, a distinct table evalCall checks |
| +// explicitly — means there is no ordinary Func entry for NOW/TODAY that |
| +// could be invoked without one. |
| +type clockFunc func(env Env, args []Value) Value |
| + |
| +var clockFuncTable = map[string]clockFunc{} |
| + |
| +// registerClock is Register's counterpart for clockFunc. Unexported: only |
| +// fn_date.go's two functions need it, so it is not part of the package's |
| +// public registration contract. |
| +func registerClock(name string, fn clockFunc) { |
| + if _, exists := clockFuncTable[name]; exists { |
| + panic("calc: duplicate clock function registration: " + name) |
| + } |
| + clockFuncTable[name] = fn |
| +} |
| + |
| +// nodeFunc is the signature for a function that needs a range argument's |
| +// shape as written — its width and height — rather than only the |
| +// flattened, boundary-free []Value evalCallArg already produces for an |
| +// ordinary Func. VLOOKUP, INDEX and MATCH (fn_lookup.go) all turn a |
| +// row/column offset into a position within a table/array argument, and |
| +// by the time evalCallArgs has flattened every argument into one shared |
| +// slice, both the shape AND the boundary between one argument's cells and |
| +// the next are gone — unrecoverable, not merely inconvenient. These three |
| +// see the raw argument AST nodes instead and evaluate what they need |
| +// themselves, the same way evalCall would. |
| +type nodeFunc func(argNodes []Node, env Env) Value |
| + |
| +var nodeFuncTable = map[string]nodeFunc{} |
| + |
| +// registerNode is Register's counterpart for nodeFunc. Unexported for the |
| +// same reason as registerClock. |
| +func registerNode(name string, fn nodeFunc) { |
| + if _, exists := nodeFuncTable[name]; exists { |
| + panic("calc: duplicate node function registration: " + name) |
| + } |
| + nodeFuncTable[name] = fn |
| +} |
| + |
| +// isRegisteredFunc reports whether name resolves in any of the three |
| +// dispatch tables evalCall checks — ordinary, clock, or node — so |
| +// corpus_test.go's TestExclusionsAreRealFunctions can check "is this |
| +// really a function" without needing to know which table a given name |
| +// happens to live in. |
| +func isRegisteredFunc(name string) bool { |
| + if _, ok := funcTable[name]; ok { |
| + return true |
| + } |
| + if _, ok := clockFuncTable[name]; ok { |
| + return true |
| + } |
| + if _, ok := nodeFuncTable[name]; ok { |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// errValue is shorthand for the error Value most functions return on a |
| +// bad argument count or a coercion failure — argument-count and type |
| +// errors are #VALUE! throughout this package's function library, per the |
| +// plan's own convention. |
| +func errValue(k ErrKind) Value { return Value{Kind: KindErr, Err: k} } |