oficina / sheets Public

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
Paul Campbell a8a1031d66936e5fb23314840d9f42817964077b parent 3d47a58
2 files changed, +139 −23
  • internal/sheets/calc/eval_call.go +42 −23
  • internal/sheets/calc/registry.go +97 −0
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} }