Evaluator, precedent graph, dirty-set recalc, cycle detection
Adds calc.Eval (arithmetic, comparison, concatenation, error propagation with left-most-wins), calc.CellKey/Env per the pre-flight rulings, and calc.Graph with SetFormula/Recalc/Value implementing transitive dirty-set recalculation and colour-marked cycle detection that resolves to #CIRCULAR! instead of recursing. Call dispatch is wired to an empty funcTable (unregistered calls resolve to #NAME?) for task 8's registry.go to populate.
8 files changed,
+726
−0
internal/sheets/calc/errors.go+36 −0internal/sheets/calc/eval.go+138 −0internal/sheets/calc/eval_arith.go+183 −0internal/sheets/calc/eval_call.go+47 −0internal/sheets/calc/eval_test.go+64 −0internal/sheets/calc/graph.go+122 −0internal/sheets/calc/graph_recalc.go+84 −0internal/sheets/calc/graph_test.go+52 −0
Intent
diff --git a/internal/sheets/calc/errors.go b/internal/sheets/calc/errors.go| new file mode 100644 |
| index 0000000..6b2f448 |
| --- /dev/null |
| +++ b/internal/sheets/calc/errors.go |
| @@ -0,0 +1,36 @@ |
| +package calc |
| + |
| +// ErrKind is an Excel error value. These are values, not Go errors: they |
| +// live in cells, propagate through arithmetic, and round-trip through XLSX. |
| +type ErrKind uint8 |
| + |
| +const ( |
| + ErrNone ErrKind = iota |
| + ErrDiv0 |
| + ErrValue |
| + ErrRef |
| + ErrName |
| + ErrNum |
| + ErrNA |
| + ErrCircular |
| +) |
| + |
| +func (e ErrKind) String() string { |
| + switch e { |
| + case ErrDiv0: |
| + return "#DIV/0!" |
| + case ErrValue: |
| + return "#VALUE!" |
| + case ErrRef: |
| + return "#REF!" |
| + case ErrName: |
| + return "#NAME?" |
| + case ErrNum: |
| + return "#NUM!" |
| + case ErrNA: |
| + return "#N/A" |
| + case ErrCircular: |
| + return "#CIRCULAR!" |
| + } |
| + return "" |
| +} |
diff --git a/internal/sheets/calc/eval.go b/internal/sheets/calc/eval.go| new file mode 100644 |
| index 0000000..6856457 |
| --- /dev/null |
| +++ b/internal/sheets/calc/eval.go |
| @@ -0,0 +1,138 @@ |
| +package calc |
| + |
| +import "time" |
| + |
| +// ValueKind tags which field of a Value is live. KindNum is the zero |
| +// value, so a Value nobody has set (e.g. env.CellValue's answer for a |
| +// cell that has never been written) reads as the number 0 — the same way |
| +// an empty cell behaves in arithmetic. It renders as "" only where a |
| +// caller explicitly asks for text (see toStr); the zero Value itself does |
| +// not carry a distinct "empty" tag, since nothing in this task's tests |
| +// needs one and adding a fifth kind and its coercion rules everywhere is |
| +// a decision better made against a real corpus (task 8). |
| +type ValueKind uint8 |
| + |
| +const ( |
| + KindNum ValueKind = iota |
| + KindStr |
| + KindBool |
| + KindErr |
| +) |
| + |
| +// Value is a computed cell value. Cell values are IEEE doubles end to |
| +// end — Num carries the number verbatim, with no rounding at this |
| +// boundary; currency and other display formatting are a concern outside |
| +// this package. Kind selects which field is meaningful: Num for KindNum, |
| +// Str for KindStr, Bool for KindBool, Err for KindErr. |
| +type Value struct { |
| + Kind ValueKind |
| + Num float64 |
| + Str string |
| + Bool bool |
| + Err ErrKind |
| +} |
| + |
| +// CellKey identifies a cell by its stable row and column ids. It is the |
| +// dependency graph's node key and the key Env looks values up by. |
| +type CellKey struct { |
| + RowID, ColID uint64 |
| +} |
| + |
| +// Env supplies the two things Eval cannot know by itself: the current |
| +// value of any cell a formula references, and the instant this |
| +// recalculation pass is pinned to. One pass sees one instant — Now must |
| +// return the same value on every call within a single Eval/Recalc walk, |
| +// which is what makes a volatile function like NOW() or TODAY() |
| +// deterministic within a pass (task 8 exercises this). |
| +// |
| +// Env is an interface, not a concrete type, so the dependency graph can |
| +// hand Eval a view backed by its own cache and tests can hand it a plain |
| +// map — and so that nothing in this package needs to import store or |
| +// sheets to evaluate a formula. |
| +type Env interface { |
| + CellValue(k CellKey) Value |
| + Now() time.Time |
| +} |
| + |
| +// refKey turns a parsed reference's position into the id space Env is |
| +// keyed by. Binding a reference to a stable id that survives insert and |
| +// delete is task 10's job; until it exists, position IS the id — the AST |
| +// carries 0-based row/column positions, so the id is simply position+1 |
| +// (1-based, so 0 stays free as a reserved/unbound sentinel elsewhere in |
| +// the system). Task 10's real binder can diverge ids from position after |
| +// structural edits; nothing here assumes it never will. |
| +func refKey(r *Ref) CellKey { |
| + return CellKey{RowID: uint64(r.RowPos + 1), ColID: uint64(r.ColPos + 1)} |
| +} |
| + |
| +// rangeCells expands a Range into every CellKey it covers, normalizing a |
| +// range written back-to-front (e.g. "A5:A1"). Used both to fetch a |
| +// function call argument's values and to build the dependency graph's |
| +// precedent edges for a formula that reads a range. |
| +// |
| +// This walks every cell in the range, which is fine for the small ranges |
| +// a spreadsheet mostly uses but not for "the whole column" — a formula |
| +// over A1:A1048576 would materialize a million keys. Nothing in this |
| +// task's brief exercises that shape; a sparse or interval-based |
| +// representation is a follow-up for whichever task first needs one. |
| +func rangeCells(rng *Range) []CellKey { |
| + 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 |
| + } |
| + out := make([]CellKey, 0, (r1-r0+1)*(c1-c0+1)) |
| + for row := r0; row <= r1; row++ { |
| + for col := c0; col <= c1; col++ { |
| + out = append(out, CellKey{RowID: uint64(row + 1), ColID: uint64(col + 1)}) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// Eval walks n and returns its value under env. Errors are values, not Go |
| +// errors: any error operand makes the whole expression that error, |
| +// left-most operand wins when both sides are errors (see evalBinary). |
| +func Eval(n Node, env Env) Value { |
| + switch t := n.(type) { |
| + case *Num: |
| + return Value{Kind: KindNum, Num: t.V} |
| + case *Str: |
| + return Value{Kind: KindStr, Str: t.V} |
| + case *Bool: |
| + return Value{Kind: KindBool, Bool: t.V} |
| + case *Ref: |
| + return env.CellValue(refKey(t)) |
| + case *Range: |
| + // A bare range is not a scalar. Excel accepts one outside a |
| + // function only via implicit intersection with the evaluating |
| + // cell's own row/column, which this engine does not implement; |
| + // reject the same way Excel does when intersection finds |
| + // nothing. |
| + return Value{Kind: KindErr, Err: ErrValue} |
| + case *Unary: |
| + return evalUnary(t, env) |
| + case *Binary: |
| + return evalBinary(t, env) |
| + case *Call: |
| + return evalCall(t, env) |
| + } |
| + return Value{Kind: KindErr, Err: ErrValue} |
| +} |
| + |
| +// evalUnary handles the only unary operator the parser produces: negation |
| +// (see parse.go's parseUnary and translate.go's Render doc comment). |
| +func evalUnary(t *Unary, env Env) Value { |
| + x := Eval(t.X, env) |
| + if x.Kind == KindErr { |
| + return x |
| + } |
| + num, errKind := toNumber(x) |
| + if errKind != ErrNone { |
| + return Value{Kind: KindErr, Err: errKind} |
| + } |
| + return Value{Kind: KindNum, Num: -num} |
| +} |
diff --git a/internal/sheets/calc/eval_arith.go b/internal/sheets/calc/eval_arith.go| new file mode 100644 |
| index 0000000..9c0343f |
| --- /dev/null |
| +++ b/internal/sheets/calc/eval_arith.go |
| @@ -0,0 +1,183 @@ |
| +package calc |
| + |
| +import ( |
| + "math" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// evalBinary evaluates both operands unconditionally (formulas have no |
| +// side effects to order), then propagates the left operand's error if it |
| +// is one, else the right's — "left-most wins" when both sides are errors. |
| +func evalBinary(t *Binary, env Env) Value { |
| + l := Eval(t.Left, env) |
| + r := Eval(t.Right, env) |
| + if l.Kind == KindErr { |
| + return l |
| + } |
| + if r.Kind == KindErr { |
| + return r |
| + } |
| + switch t.Op { |
| + case "+", "-", "*", "/", "^": |
| + return evalArith(t.Op, l, r) |
| + case "&": |
| + return evalConcat(l, r) |
| + default: // "=", "<>", "<", "<=", ">", ">=" |
| + return evalCompare(t.Op, l, r) |
| + } |
| +} |
| + |
| +// evalArith coerces both operands to numbers and applies op. Coercion |
| +// failure on the left operand wins over one on the right, keeping |
| +// "left-most wins" consistent even below the Value level. |
| +func evalArith(op string, l, r Value) Value { |
| + ln, errKind := toNumber(l) |
| + if errKind != ErrNone { |
| + return Value{Kind: KindErr, Err: errKind} |
| + } |
| + rn, errKind := toNumber(r) |
| + if errKind != ErrNone { |
| + return Value{Kind: KindErr, Err: errKind} |
| + } |
| + switch op { |
| + case "+": |
| + return Value{Kind: KindNum, Num: ln + rn} |
| + case "-": |
| + return Value{Kind: KindNum, Num: ln - rn} |
| + case "*": |
| + return Value{Kind: KindNum, Num: ln * rn} |
| + case "/": |
| + if rn == 0 { |
| + return Value{Kind: KindErr, Err: ErrDiv0} |
| + } |
| + return Value{Kind: KindNum, Num: ln / rn} |
| + default: // "^" |
| + return Value{Kind: KindNum, Num: math.Pow(ln, rn)} |
| + } |
| +} |
| + |
| +// evalConcat implements "&": both operands render as text (Excel's own |
| +// coercion — TRUE, FALSE, and a number's plain decimal form) and join. |
| +func evalConcat(l, r Value) Value { |
| + return Value{Kind: KindStr, Str: toStr(l) + toStr(r)} |
| +} |
| + |
| +// evalCompare implements the six comparison operators over compareValues' |
| +// ordering. |
| +func evalCompare(op string, l, r Value) Value { |
| + c := compareValues(l, r) |
| + var result bool |
| + switch op { |
| + case "=": |
| + result = c == 0 |
| + case "<>": |
| + result = c != 0 |
| + case "<": |
| + result = c < 0 |
| + case "<=": |
| + result = c <= 0 |
| + case ">": |
| + result = c > 0 |
| + default: // ">=" |
| + result = c >= 0 |
| + } |
| + return Value{Kind: KindBool, Bool: result} |
| +} |
| + |
| +// compareValues orders l against r the way Excel's comparison operators |
| +// do. Within the same kind: numbers compare numerically, text compares |
| +// case-insensitively (Excel's default — "abc" = "ABC"), and booleans |
| +// order false before true. Across kinds, every number sorts below every |
| +// piece of text, and every piece of text sorts below every boolean, |
| +// regardless of the text's literal content — the string "9" is still |
| +// greater than the number 100, because it is text, not because of what it |
| +// says. |
| +func compareValues(l, r Value) int { |
| + lr, rr := typeRank(l), typeRank(r) |
| + if lr != rr { |
| + if lr < rr { |
| + return -1 |
| + } |
| + return 1 |
| + } |
| + switch l.Kind { |
| + case KindStr: |
| + return strings.Compare(strings.ToUpper(l.Str), strings.ToUpper(r.Str)) |
| + case KindBool: |
| + switch { |
| + case l.Bool == r.Bool: |
| + return 0 |
| + case !l.Bool: |
| + return -1 |
| + default: |
| + return 1 |
| + } |
| + default: // KindNum |
| + switch { |
| + case l.Num < r.Num: |
| + return -1 |
| + case l.Num > r.Num: |
| + return 1 |
| + default: |
| + return 0 |
| + } |
| + } |
| +} |
| + |
| +// typeRank orders the value kinds for cross-kind comparison: number, |
| +// then text, then boolean. |
| +func typeRank(v Value) int { |
| + switch v.Kind { |
| + case KindStr: |
| + return 1 |
| + case KindBool: |
| + return 2 |
| + default: // KindNum |
| + return 0 |
| + } |
| +} |
| + |
| +// toNumber coerces v for an arithmetic operator: a number as itself, a |
| +// boolean as 1/0 (Excel's own arithmetic coercion), and text only if it |
| +// parses as a number outright (no currency symbols, percent signs or |
| +// thousands separators — that normalization belongs to whatever task |
| +// parses user input into a formula, not to evaluation). Never called on a |
| +// KindErr Value: callers check for an error operand before coercing. |
| +func toNumber(v Value) (float64, ErrKind) { |
| + switch v.Kind { |
| + case KindNum: |
| + return v.Num, ErrNone |
| + case KindBool: |
| + if v.Bool { |
| + return 1, ErrNone |
| + } |
| + return 0, ErrNone |
| + case KindStr: |
| + n, err := strconv.ParseFloat(strings.TrimSpace(v.Str), 64) |
| + if err != nil { |
| + return 0, ErrValue |
| + } |
| + return n, ErrNone |
| + default: |
| + return 0, ErrValue |
| + } |
| +} |
| + |
| +// toStr renders v as text for "&" concatenation: a string as itself, a |
| +// number in its plain decimal form (no thousands separator or currency — |
| +// display formatting is a concern outside this package), a boolean as |
| +// TRUE/FALSE. |
| +func toStr(v Value) string { |
| + switch v.Kind { |
| + case KindStr: |
| + return v.Str |
| + case KindBool: |
| + if v.Bool { |
| + return "TRUE" |
| + } |
| + return "FALSE" |
| + default: // KindNum |
| + return strconv.FormatFloat(v.Num, 'g', -1, 64) |
| + } |
| +} |
diff --git a/internal/sheets/calc/eval_call.go b/internal/sheets/calc/eval_call.go| new file mode 100644 |
| index 0000000..12de78a |
| --- /dev/null |
| +++ b/internal/sheets/calc/eval_call.go |
| @@ -0,0 +1,47 @@ |
| +package calc |
| + |
| +// Func is a built-in spreadsheet function: it receives every argument's |
| +// 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. |
| +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. |
| +func evalCall(t *Call, env Env) Value { |
| + fn, ok := funcTable[t.Name] |
| + if !ok { |
| + return Value{Kind: KindErr, Err: ErrName} |
| + } |
| + var args []Value |
| + for _, a := range t.Args { |
| + args = append(args, evalCallArg(a, env)...) |
| + } |
| + return fn(args) |
| +} |
| + |
| +// evalCallArg evaluates one call argument into one or more Values: a |
| +// range expands to its cells in row-major order, anything else evaluates |
| +// to exactly one Value. |
| +func evalCallArg(n Node, env Env) []Value { |
| + rng, ok := n.(*Range) |
| + if !ok { |
| + return []Value{Eval(n, env)} |
| + } |
| + cells := rangeCells(rng) |
| + out := make([]Value, len(cells)) |
| + for i, k := range cells { |
| + out[i] = env.CellValue(k) |
| + } |
| + return out |
| +} |
diff --git a/internal/sheets/calc/eval_test.go b/internal/sheets/calc/eval_test.go| new file mode 100644 |
| index 0000000..a008c7d |
| --- /dev/null |
| +++ b/internal/sheets/calc/eval_test.go |
| @@ -0,0 +1,64 @@ |
| +package calc |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +// mustParse parses src or fails the test. It never puts src into the |
| +// failure message: formula text is cell content, and this codebase's |
| +// discipline (see parse.go) is that cell content never appears in a |
| +// string a person might read off a log or a test report. |
| +func mustParse(t *testing.T, src string) Node { |
| + t.Helper() |
| + n, err := Parse(src) |
| + if err != nil { |
| + t.Fatalf("mustParse: %v", err) |
| + } |
| + return n |
| +} |
| + |
| +// emptyEnv answers every cell as an untouched, empty cell (KindNum zero) |
| +// and pins Now to the zero time. Good enough for tests that never read a |
| +// cell or a clock. |
| +type emptyEnv struct{} |
| + |
| +func (emptyEnv) CellValue(CellKey) Value { return Value{} } |
| +func (emptyEnv) Now() time.Time { return time.Time{} } |
| + |
| +// mapEnv is a plain map standing in for a real cell store: exactly the |
| +// kind of test double Env exists to make possible without calc importing |
| +// store. A missing key reads as an untouched empty cell, same as emptyEnv. |
| +type mapEnv map[CellKey]Value |
| + |
| +func (m mapEnv) CellValue(k CellKey) Value { |
| + if v, ok := m[k]; ok { |
| + return v |
| + } |
| + return Value{} |
| +} |
| +func (m mapEnv) Now() time.Time { return time.Time{} } |
| + |
| +func TestEvalArithmetic(t *testing.T) { |
| + v := Eval(mustParse(t, "1+2*3"), emptyEnv{}) |
| + if v.Kind != KindNum || v.Num != 7 { |
| + t.Fatalf("1+2*3 = %v, want 7", v) |
| + } |
| +} |
| + |
| +func TestDivideByZeroIsAnExcelError(t *testing.T) { |
| + v := Eval(mustParse(t, "1/0"), emptyEnv{}) |
| + if v.Kind != KindErr || v.Err != ErrDiv0 { |
| + t.Fatalf("1/0 = %v, want #DIV/0!", v) |
| + } |
| +} |
| + |
| +// Errors propagate rather than being swallowed: =A1+1 where A1 is #REF! |
| +// must be #REF!, not 1. XLSX round-trips these, so they are values. |
| +func TestErrorsPropagate(t *testing.T) { |
| + env := mapEnv{{1, 1}: {Kind: KindErr, Err: ErrRef}} |
| + v := Eval(mustParse(t, "A1+1"), env) |
| + if v.Kind != KindErr || v.Err != ErrRef { |
| + t.Fatalf("propagation = %v, want #REF!", v) |
| + } |
| +} |
diff --git a/internal/sheets/calc/graph.go b/internal/sheets/calc/graph.go| new file mode 100644 |
| index 0000000..c8a744e |
| --- /dev/null |
| +++ b/internal/sheets/calc/graph.go |
| @@ -0,0 +1,122 @@ |
| +package calc |
| + |
| +// maxDirtyPerPass caps how many cells a single Recalc will touch. One |
| +// edit to a cell feeding a whole-column formula can transitively dirty |
| +// thousands of dependents; past the cap Recalc stops without evaluating |
| +// anything and just reports how many cells it would have touched, so the |
| +// caller can tell the client to refetch its viewport instead of shipping |
| +// (and the client diffing) an enormous delta. |
| +const maxDirtyPerPass = 5000 |
| + |
| +// Graph holds the precedent/dependent edges between formula cells and a |
| +// cache of their last computed value. It is what turns "this cell |
| +// changed" into "recompute exactly the cells that read it, transitively, |
| +// and nothing else." A Graph is not safe for concurrent use — callers |
| +// serialize edits and recalculation passes, the same discipline a single |
| +// pass seeing a single Now() already requires. |
| +type Graph struct { |
| + formulas map[CellKey]Node |
| + precedents map[CellKey]map[CellKey]struct{} // cell -> cells its formula reads |
| + dependents map[CellKey]map[CellKey]struct{} // cell -> cells that read it |
| + cache map[CellKey]Value |
| + cached map[CellKey]bool // cache presence, since a cached zero Value is a real answer |
| + color map[CellKey]cellColor |
| +} |
| + |
| +// cellColor marks a cell's state during one evaluation walk, the standard |
| +// three-colour cycle check: white (unvisited), grey (on the current |
| +// recursion stack), black (finished). A node re-entered while grey is a |
| +// cycle. |
| +type cellColor uint8 |
| + |
| +const ( |
| + white cellColor = iota |
| + grey |
| + black |
| +) |
| + |
| +// NewGraph returns an empty graph with no formulas. |
| +func NewGraph() *Graph { |
| + return &Graph{ |
| + formulas: make(map[CellKey]Node), |
| + precedents: make(map[CellKey]map[CellKey]struct{}), |
| + dependents: make(map[CellKey]map[CellKey]struct{}), |
| + cache: make(map[CellKey]Value), |
| + cached: make(map[CellKey]bool), |
| + } |
| +} |
| + |
| +// SetFormula installs (or replaces) the formula at key, rewiring |
| +// precedent and dependent edges to match the references the new formula |
| +// actually contains. The cell's cached value is dropped; nothing |
| +// recomputes until Recalc or Value asks for it. |
| +func (g *Graph) SetFormula(key CellKey, n Node) { |
| + if old, ok := g.precedents[key]; ok { |
| + for p := range old { |
| + delete(g.dependents[p], key) |
| + } |
| + } |
| + refs := collectRefs(n) |
| + prec := make(map[CellKey]struct{}, len(refs)) |
| + for _, r := range refs { |
| + prec[r] = struct{}{} |
| + if g.dependents[r] == nil { |
| + g.dependents[r] = make(map[CellKey]struct{}) |
| + } |
| + g.dependents[r][key] = struct{}{} |
| + } |
| + g.formulas[key] = n |
| + g.precedents[key] = prec |
| + delete(g.cache, key) |
| + delete(g.cached, key) |
| +} |
| + |
| +// collectRefs walks n and returns the CellKey of every cell it reads — |
| +// every *Ref directly, every cell a *Range covers, recursively through |
| +// binary, unary and call nodes. This is what SetFormula uses to build |
| +// precedent/dependent edges. |
| +func collectRefs(n Node) []CellKey { |
| + var out []CellKey |
| + var walk func(Node) |
| + walk = func(n Node) { |
| + switch t := n.(type) { |
| + case *Ref: |
| + out = append(out, refKey(t)) |
| + case *Range: |
| + out = append(out, rangeCells(t)...) |
| + case *Binary: |
| + walk(t.Left) |
| + walk(t.Right) |
| + case *Unary: |
| + walk(t.X) |
| + case *Call: |
| + for _, a := range t.Args { |
| + walk(a) |
| + } |
| + } |
| + } |
| + walk(n) |
| + return out |
| +} |
| + |
| +// transitiveDependents returns every cell reachable from origin by |
| +// following dependent edges — the set that could change because origin |
| +// did. origin itself is never included by this walk (callers that also |
| +// need to recompute a changed formula cell's own value add it |
| +// separately; see Recalc). |
| +func (g *Graph) transitiveDependents(origin CellKey) map[CellKey]struct{} { |
| + seen := make(map[CellKey]struct{}) |
| + queue := []CellKey{origin} |
| + for len(queue) > 0 { |
| + cur := queue[0] |
| + queue = queue[1:] |
| + for dep := range g.dependents[cur] { |
| + if _, ok := seen[dep]; ok { |
| + continue |
| + } |
| + seen[dep] = struct{}{} |
| + queue = append(queue, dep) |
| + } |
| + } |
| + return seen |
| +} |
diff --git a/internal/sheets/calc/graph_recalc.go b/internal/sheets/calc/graph_recalc.go| new file mode 100644 |
| index 0000000..b7f296c |
| --- /dev/null |
| +++ b/internal/sheets/calc/graph_recalc.go |
| @@ -0,0 +1,84 @@ |
| +package calc |
| + |
| +import "time" |
| + |
| +// Recalc propagates a change at origin to every cell that transitively |
| +// depends on it, evaluates each of those exactly once, and returns how |
| +// many cells it touched. origin's own new value is expected to already |
| +// be in env (e.g. a literal the user just typed) unless origin itself is |
| +// a formula cell — an edit to a formula's own text — in which case origin |
| +// is recomputed too. |
| +// |
| +// Past maxDirtyPerPass, Recalc evaluates nothing and returns the count it |
| +// would have touched; see the constant's comment for why. |
| +func (g *Graph) Recalc(origin CellKey, env Env) int { |
| + dirty := g.transitiveDependents(origin) |
| + if _, hasFormula := g.formulas[origin]; hasFormula { |
| + dirty[origin] = struct{}{} |
| + } |
| + if len(dirty) > maxDirtyPerPass { |
| + return len(dirty) |
| + } |
| + g.color = make(map[CellKey]cellColor, len(dirty)) |
| + genv := &graphEnv{g: g, base: env, dirty: dirty} |
| + for k := range dirty { |
| + genv.CellValue(k) |
| + } |
| + return len(dirty) |
| +} |
| + |
| +// Value returns key's current value, evaluating it — and, recursively, |
| +// whatever it depends on — from scratch. Unlike Recalc, it does not trust |
| +// any existing cache: it is the ad-hoc "what is this cell right now" |
| +// query (and the one TestCycleDetection uses), not the incremental fast |
| +// path an edit takes. |
| +// |
| +// Cycle detection is the colour mark during this walk: a formula cell |
| +// re-entered while still grey — still mid-evaluation lower in the same |
| +// call stack — resolves to #CIRCULAR! instead of recursing forever. |
| +func (g *Graph) Value(key CellKey, env Env) Value { |
| + g.color = make(map[CellKey]cellColor) |
| + genv := &graphEnv{g: g, base: env} |
| + return genv.CellValue(key) |
| +} |
| + |
| +// graphEnv is the Env Eval sees while the graph drives an evaluation. It |
| +// intercepts CellValue for any cell the graph itself manages (one with a |
| +// formula) and defers to base for everything else — a raw input cell, or |
| +// any cell this graph has never heard of. |
| +type graphEnv struct { |
| + g *Graph |
| + base Env |
| + // dirty scopes a Recalc pass: a formula cell outside this set is |
| + // assumed unaffected by the change and its existing cached value is |
| + // reused as-is, so a clean precedent is never recomputed (and never |
| + // inflates Recalc's touched count). nil means no such assumption |
| + // applies — Value's every-cell-fresh walk. |
| + dirty map[CellKey]struct{} |
| +} |
| + |
| +func (e *graphEnv) Now() time.Time { return e.base.Now() } |
| + |
| +func (e *graphEnv) CellValue(k CellKey) Value { |
| + n, isFormula := e.g.formulas[k] |
| + if !isFormula { |
| + return e.base.CellValue(k) |
| + } |
| + if e.dirty != nil { |
| + if _, inPass := e.dirty[k]; !inPass && e.g.cached[k] { |
| + return e.g.cache[k] // clean precedent: unaffected, last value still holds |
| + } |
| + } |
| + switch e.g.color[k] { |
| + case black: |
| + return e.g.cache[k] |
| + case grey: |
| + return Value{Kind: KindErr, Err: ErrCircular} |
| + } |
| + e.g.color[k] = grey |
| + v := Eval(n, e) |
| + e.g.color[k] = black |
| + e.g.cache[k] = v |
| + e.g.cached[k] = true |
| + return v |
| +} |
diff --git a/internal/sheets/calc/graph_test.go b/internal/sheets/calc/graph_test.go| new file mode 100644 |
| index 0000000..8b3f95f |
| --- /dev/null |
| +++ b/internal/sheets/calc/graph_test.go |
| @@ -0,0 +1,52 @@ |
| +package calc |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The graph tests key cells by hand rather than through a real binder |
| +// (task 10 owns binding). RowID/ColID here are exactly refKey's position- |
| +// plus-one convention, so a formula's "A1" and the test's cellA1 name the |
| +// same CellKey. |
| +var ( |
| + cellA1 = CellKey{RowID: 1, ColID: 1} |
| + cellB1 = CellKey{RowID: 1, ColID: 2} |
| + cellC1 = CellKey{RowID: 1, ColID: 3} |
| + cellZ9 = CellKey{RowID: 9, ColID: 26} |
| +) |
| + |
| +// env gives A1 a raw input value so B1 (=A1*2) and C1 (=B1+1) have |
| +// something concrete to compute from; it is shared read-only by every |
| +// test in this file, none of which mutate it. |
| +var env = mapEnv{cellA1: {Kind: KindNum, Num: 10}} |
| + |
| +func TestDirtySetTouchesOnlyDependents(t *testing.T) { |
| + g := NewGraph() |
| + g.SetFormula(cellB1, mustParse(t, "A1*2")) |
| + g.SetFormula(cellC1, mustParse(t, "B1+1")) |
| + g.SetFormula(cellZ9, mustParse(t, "42")) // unrelated |
| + |
| + touched := g.Recalc(cellA1, env) |
| + if touched != 2 { |
| + t.Fatalf("touched %d cells, want 2 (B1, C1) — Z9 must not recalc", touched) |
| + } |
| +} |
| + |
| +// A cycle must return an error value, not hang. This test has a timeout |
| +// because the failure mode of getting this wrong is a wedged process. |
| +func TestCycleDetection(t *testing.T) { |
| + g := NewGraph() |
| + g.SetFormula(cellA1, mustParse(t, "B1")) |
| + g.SetFormula(cellB1, mustParse(t, "A1")) |
| + done := make(chan Value, 1) |
| + go func() { done <- g.Value(cellA1, env) }() |
| + select { |
| + case v := <-done: |
| + if v.Kind != KindErr || v.Err != ErrCircular { |
| + t.Fatalf("cycle = %v, want #CIRCULAR!", v) |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("cycle detection hung") |
| + } |
| +} |