cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Read a range where one value is wanted as the cell in the formula's row or column

Dami committed f3fe273
Implicit intersection, as Sheets and Excel do: =Rent*2 in F4 with Rent =
B2:B4 is B4*2 rather than #VALUE!. The engine evaluates each formula with
its own address (functions.EvalAt); single columns intersect by row,
single rows by column, on any sheet. Arguments that take ranges read them
whole, and expressions inside them stay #VALUE! as before.

Co-Authored-By: Claude <noreply@anthropic.com>
commit
f3fe273d338d006868a825582e75f2a53da347a1
parent
dd947e9ce56da82e1e889fa67af89cb36c7a9a15
author
Dami <dami.etoile@gmail.com>
11 files changed, +296 -17
Mdocs/architecture.md5+-
Mdocs/formulas.md23++++++
Minternal/functions/args.go2+-
Minternal/functions/eval.go62++++++++++++++++-
Minternal/functions/everyday.go2+-
Minternal/functions/reader.go3+
Minternal/sheet/evaluate.go2+-
Minternal/sheet/explain.go17+++--
Ainternal/sheet/intersect_test.go88+++++++++++++++++++++++++
Minternal/sheet/link.go2+-
Aoracle/intersect_test.go107++++++++++++++++++++++++++++++
M docs/architecture.md +4 -1
M docs/formulas.md +23 -0
@@ -69,6 +69,29 @@ caret on the problem and says what's wrong on the context line, e.g.
6969 tighter than `^` as in Sheets and Excel, so `=-2^2` is 4. 1-2-3's `#AND#`,7070 `#OR#` and `#NOT#` still work; `AND()`, `OR()` and `NOT()` are the Sheets way.7171 72+## A range where one value is wanted73+74+A range used where a formula wants one value (with an operator, as an75+argument that takes a number or text, or as a cell's whole result) reads76+as one of its cells, as in Sheets and Excel (implicit intersection):77+78+- a single column gives the cell in the formula's own row, so with the79+ named range Rent = B2:B4, `=Rent*2` in F4 is B4*2 and `=B:B+1` in G5 is80+ B5+1;81+- a single row gives the cell in the formula's own column: `=B6:D6` in C882+ is C6;83+- a single cell is itself;84+- anything else is `#VALUE!`: a formula outside the range's rows (or85+ columns), and a range of several rows and columns.86+87+Ranges on other sheets work the same, by the formula's row or column:88+`='Q3 plan'!C2:C9` in A3 reads `'Q3 plan'!C3`. Functions that take ranges89+(`SUM`, `COUNTIF`, `MATCH`, `VLOOKUP`, `SUMPRODUCT` and the like) still90+read the whole range. Inside their range arguments an expression over91+ranges, such as `SUM(B2:B4*2)` or `SUMPRODUCT(A1:A3*B1:B3)`, is `#VALUE!`:92+Sheets computes those as arrays, which 012 doesn't yet, so it doesn't give93+a single cell's answer instead.94+7295 ## Values and errors7396 7497 Text in arithmetic is `#VALUE!` unless it reads as a number (`="3"*2` is 6);
M internal/functions/args.go +1 -1
M internal/functions/eval.go +58 -4
@@ -8,8 +8,23 @@ import (
88 )99 1010 // Eval computes a formula, reading the cells it refers to through get.11+// A range where one value is wanted reads as the cell in the row or12+// column last given to EvalAt (see intersect).1113 func Eval(n Node, get *Reader) Value { return eval(n, get) }1214 15+// EvalAt computes the formula in the cell at, so that a range used where16+// one value is wanted reads as the cell in at's row or column (implicit17+// intersection, as Sheets and Excel do). Evaluations nest, one cell's18+// formula reading another's through the same Reader, so the cell before19+// is given back afterwards.20+func EvalAt(n Node, get *Reader, at Addr) Value {21+ outer := get.here22+ get.here = at23+ v := eval(n, get)24+ get.here = outer25+ return v26+}27+1328 func eval(n Node, get lookup) Value {1429 switch n := n.(type) {1530 case formula.Num:
@@ -27,10 +42,8 @@ func eval(n Node, get lookup) Value {
2742 case formula.Empty:2843 return Value{}2944 case formula.Range:30- // A range outside a function: Sheets uses the top-left cell here31- // for single-cell ranges and #VALUE! otherwise.32- if n.Rect.From == n.Rect.To {33- return get.cell(n.Sheet, n.Rect.From)45+ if a, ok := get.intersect(n.Rect); ok {46+ return get.cell(n.Sheet, a)3447 }3548 return value.ErrValue3649 case formula.Unary:
@@ -43,7 +56,10 @@ func eval(n Node, get lookup) Value {
4356 return evalBinary(formula.Binary(n), get, true)4457 case formula.Call:4558 *get.depth++ // see the engine's evaluate.go; operators count in theirs59+ arrays := get.arrays60+ get.arrays = 0 // a function's own arguments take one value again4661 v := funcOf(n).call(n.Args, get)62+ get.arrays = arrays4763 *get.depth--4864 return v4965 }
@@ -152,3 +168,41 @@ func evalBinary(n formula.Binary, get lookup, dec bool) Value {
152168 }153169 return value.ErrValue154170 }171+172+// intersect is the cell a range stands for where one value is wanted173+// (Intersect), in the formula being evaluated. Inside an argument that174+// takes ranges (evalArray) only a single cell does.175+func (rd *Reader) intersect(r Rect) (Addr, bool) {176+ if rd.arrays > 0 && r.From != r.To {177+ return Addr{}, false178+ }179+ return Intersect(r, rd.here)180+}181+182+// Intersect is the cell of r a formula in the cell at reads where it183+// wants one value: a single cell is itself; a single column gives the184+// cell in at's row, a single row the cell in at's column, when that lies185+// within r (implicit intersection, on whatever sheet r is on). Any other186+// range has no such cell, which is #VALUE!.187+func Intersect(r Rect, at Addr) (Addr, bool) {188+ switch {189+ case r.From == r.To:190+ return r.From, true191+ case r.From.Col == r.To.Col && at.Row >= r.From.Row && at.Row <= r.To.Row:192+ return Addr{Col: r.From.Col, Row: at.Row}, true193+ case r.From.Row == r.To.Row && at.Col >= r.From.Col && at.Col <= r.To.Col:194+ return Addr{Col: at.Col, Row: r.From.Row}, true195+ }196+ return Addr{}, false197+}198+199+// evalArray computes an expression given to an argument that takes200+// ranges (SUM(B2:B4*2), SUMPRODUCT(A1:A3*B1:B3)). Sheets computes those201+// over whole arrays, which 012 doesn't, so ranges in them stay #VALUE!202+// rather than reading one cell each.203+func evalArray(n Node, get lookup) Value {204+ get.arrays++205+ v := eval(n, get)206+ get.arrays--207+ return v208+}
M internal/functions/everyday.go +1 -1
M internal/functions/reader.go +3 -0
M internal/sheet/evaluate.go +1 -1
M internal/sheet/explain.go +9 -8
@@ -51,7 +51,7 @@ func (s *Sheet) explain(a Addr, path []loc) string {
5151 return "Circular reference: " + strings.Join(append(names, here.label(home)), " → ")5252 }5353 path = append(path, here)54- n, from := s.errorOrigin(c.expr, v)54+ n, from := s.errorOrigin(c.expr, a, v)5555 if from != nil {5656 why := from.s.explain(from.a, path)5757 name := from.label(home)
@@ -74,14 +74,15 @@ func (s *Sheet) explain(a Addr, path []loc) string {
7474 7575 // errorOrigin finds the innermost part of n that produces the error want7676 // itself rather than passing it on. When the error comes from a77-// referenced cell, it returns that cell.78-func (s *Sheet) errorOrigin(n Node, want Value) (Node, *loc) {79- return errorSearch{s: s, want: want, get: s.wb.values(s)}.find(n)77+// referenced cell, it returns that cell. The formula is the one at here.78+func (s *Sheet) errorOrigin(n Node, here Addr, want Value) (Node, *loc) {79+ return errorSearch{s: s, here: here, want: want, get: s.wb.values(s)}.find(n)8080 }8181 8282 // errorSearch follows an error through a formula on s to where it starts.8383 type errorSearch struct {8484 s *Sheet85+ here Addr // the formula's cell, for ranges read as one of their cells8586 want Value8687 get *reader8788 }
@@ -104,8 +105,8 @@ func (e errorSearch) find(n Node) (Node, *loc) {
104105 return n, e.at(n.Sheet, n.Addr)105106 }106107 case formula.Range:107- if n.Rect.From == n.Rect.To && e.same(e.get.Cell(n.Sheet, n.Rect.From)) {108- return n, e.at(n.Sheet, n.Rect.From)108+ if a, ok := functions.Intersect(n.Rect, e.here); ok && e.same(e.get.Cell(n.Sheet, a)) {109+ return n, e.at(n.Sheet, a)109110 }110111 case formula.Unary:111112 return e.operand(n, n.X)
@@ -121,7 +122,7 @@ func (e errorSearch) find(n Node) (Node, *loc) {
121122 // at n when none has.122123 func (e errorSearch) operand(n Node, operands ...Node) (Node, *loc) {123124 for _, x := range operands {124- if e.same(functions.Eval(x, e.get.lib)) {125+ if e.same(functions.EvalAt(x, e.get.lib, e.here)) {125126 return e.find(x)126127 }127128 }
@@ -141,7 +142,7 @@ func (e errorSearch) inCall(n formula.Call) (Node, *loc) {
141142 }142143 continue143144 }144- if e.same(functions.Eval(arg, e.get.lib)) {145+ if e.same(functions.EvalAt(arg, e.get.lib, e.here)) {145146 return e.find(arg)146147 }147148 }
A internal/sheet/intersect_test.go +88 -0
@@ -0,0 +1,88 @@
1+package sheet2+3+import (4+ "strings"5+ "testing"6+)7+8+// A range where one value is wanted reads as the cell in the formula's9+// row (a column) or column (a row), as in Sheets and Excel; functions10+// that take ranges still read all of them.11+func TestImplicitIntersection(t *testing.T) {12+ for _, decimal := range []bool{false, true} {13+ s := sheetOf(t, map[string]string{14+ "A1": "Rent", "B1": "Amount",15+ "B2": "1400", "B3": "1450", "B4": "1500",16+ "B6": "10", "C6": "20", "D6": "30",17+ "F2": "=B2:B4*2", "F3": "=Rent*2", "F4": "=Rent*2", "F5": "=Rent*2",18+ "G3": "=B2:B4", "G4": "=ABS(-Rent)", "G5": "=B:B+1",19+ "H3": "=SUM(Rent)", "H4": "=SUMPRODUCT(Rent)", "H2": "=MATCH(1450,Rent,0)",20+ "H5": "=SUM(Rent*2)", "H6": "=SUMPRODUCT(Rent*2)",21+ "C7": "=B6:D6+1", "E7": "=B6:D6", "A8": "=B2:C4",22+ "J3": "=IF(TRUE,Rent)", "K3": "=Rent&\"!\"", "L3": "=COUNTIF(Rent,\">1420\")",23+ })24+ s.SetDecimal(decimal)25+ if err := s.DefineName("Rent", rect("B2:B4")); err != nil {26+ t.Fatal(err)27+ }28+ for a, want := range map[string]string{29+ "F2": "2800", "F3": "2900", "F4": "3000", "F5": "#VALUE!",30+ "G3": "1450", "G4": "1500", "G5": "1",31+ "H3": "4350", "H4": "4350", "H2": "2",32+ "H5": "#VALUE!", "H6": "#VALUE!", // array arguments: not intersected33+ "C7": "21", "E7": "#VALUE!", "A8": "#VALUE!",34+ "J3": "1450", "K3": "1450!", "L3": "2",35+ } {36+ if got := s.Value(at(a)).String(); got != want {37+ t.Errorf("decimal %v: %s = %s, want %s", decimal, a, got, want)38+ }39+ }40+ s.Set(at("B3"), "1475") // the intersected cell is a dependency41+ if got := s.Value(at("F3")).String(); got != "2950" {42+ t.Errorf("decimal %v: F3 after B3 changed = %s", decimal, got)43+ }44+ }45+}46+47+// Ranges on other sheets intersect with the formula's own row or column.48+func TestImplicitIntersectionOtherSheet(t *testing.T) {49+ s := New()50+ w := s.Book()51+ data, err := w.AddSheet("Q3 plan", 1)52+ if err != nil {53+ t.Fatal(err)54+ }55+ for a, in := range map[string]string{"C2": "5", "C3": "6", "C4": "7", "B9": "1", "C9": "2", "D9": "3"} {56+ if err := data.Set(at(a), in); err != nil {57+ t.Fatal(err)58+ }59+ }60+ if err := w.DefineName("Plan", data, rect("C2:C4")); err != nil {61+ t.Fatal(err)62+ }63+ for a, in := range map[string]string{64+ "A3": "='Q3 plan'!C2:C4*10", "A4": "=Plan+1", "A5": "=Plan",65+ "C1": "='Q3 plan'!B9:D9", "E1": "='Q3 plan'!B9:D9",66+ } {67+ if err := s.Set(at(a), in); err != nil {68+ t.Fatal(err)69+ }70+ }71+ for a, want := range map[string]string{"A3": "60", "A4": "8", "A5": "#VALUE!", "C1": "2", "E1": "#VALUE!"} {72+ if got := s.Value(at(a)).String(); got != want {73+ t.Errorf("%s = %s, want %s", a, got, want)74+ }75+ }76+ data.Set(at("C3"), "9")77+ if got := s.Value(at("A3")).String(); got != "90" {78+ t.Errorf("A3 after 'Q3 plan'!C3 changed = %s", got)79+ }80+}81+82+// An error read through an intersected range is traced to its cell.83+func TestImplicitIntersectionExplain(t *testing.T) {84+ s := sheetOf(t, map[string]string{"B2": "1", "B3": "=1/0", "B4": "3", "D3": "=B2:B4*2"})85+ if got := s.ExplainError(at("D3")); !strings.HasPrefix(got, "From B3") {86+ t.Errorf("D3 explained as %q", got)87+ }88+}
M internal/sheet/link.go +1 -1
A oracle/intersect_test.go +107 -0
@@ -0,0 +1,107 @@
1+package oracle2+3+import (4+ "testing"5+6+ "github.com/xuri/excelize/v2"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// intersections are formulas that use a range where one value is wanted,12+// each in the cell it is written in: the range reads as the cell in the13+// formula's row (a column) or column (a row), or #VALUE! when there is14+// none. Rent is the named range Sheet1!B2:B4; Other!B2:B4 and B9:D9 hold15+// other numbers. excelize reads the first cell of any range instead, so16+// it agrees only where that is the intersection, or where a function17+// takes the range; the other cases are skipped against excelize and give18+// what Excel and Sheets do (excel), which 012 is checked against.19+var intersections = []struct{ cell, formula, excel string }{20+ // The intersection is the first cell, or a function takes the range.21+ {"F2", "=B2:B4+1", ""}, {"F2", "=Rent*2", ""}, {"B8", "=B6:D6*3", ""},22+ {"F2", "=Other!B2:B4*10", ""}, {"B1", "=Other!B9:D9", ""},23+ {"H3", "=SUM(Rent)", ""}, {"H4", "=MATCH(1500,Rent,0)", ""}, {"H2", `=COUNTIF(Rent,">1420")`, ""},24+25+ // excelize reads the first cell where Excel and Sheets intersect.26+ {"F3", "=Rent*2", "2900"}, {"F4", "=Rent*2", "3000"}, {"F5", "=Rent*2", "#VALUE!"},27+ {"G3", "=B2:B4", "1450"}, {"G4", "=ABS(-Rent)", "1500"}, {"G3", `=Rent&"x"`, "1450x"},28+ {"C8", "=B6:D6*3", "60"}, {"E8", "=B6:D6*3", "#VALUE!"}, {"A9", "=B2:C4", "#VALUE!"},29+ {"F3", "=Other!B2:B4*10", "20"}, {"C1", "=Other!B9:D9", "5"}, {"F1", "=Other!B2:B4", "#VALUE!"},30+}31+32+func TestImplicitIntersection(t *testing.T) {33+ for _, decimal := range []bool{false, true} {34+ s, x := buildIntersect(t)35+ s.SetDecimal(decimal)36+ for _, tc := range intersections {37+ key := tc.cell + " " + tc.formula38+ if err := s.Set(addr(tc.cell), tc.formula); err != nil {39+ t.Fatalf("012 rejects %s: %v", key, err)40+ }41+ ours := s.Value(addr(tc.cell))42+ s.Set(addr(tc.cell), "")43+ if tc.excel != "" {44+ t.Logf("skip %s: excelize reads the range's first cell; Excel and Sheets intersect", key)45+ if !agree(ours, tc.excel) {46+ t.Errorf("decimal %v: %s: 012 %s, Excel and Sheets %s", decimal, key, show(ours), tc.excel)47+ }48+ continue49+ }50+ if theirs := calcAt(t, x, tc.cell, tc.formula); !agree(ours, theirs) {51+ t.Errorf("decimal %v: %s: 012 %s, excelize %q", decimal, key, show(ours), theirs)52+ }53+ }54+ }55+}56+57+// buildIntersect sets up the cells and the name Rent in both engines.58+func buildIntersect(t *testing.T) (*sheet.Sheet, *excelize.File) {59+ t.Helper()60+ s := sheet.New()61+ o, err := s.Book().AddSheet("Other", 1)62+ if err != nil {63+ t.Fatal(err)64+ }65+ x := excelize.NewFile()66+ if _, err := x.NewSheet("Other"); err != nil {67+ t.Fatal(err)68+ }69+ put := func(on *sheet.Sheet, name string, cells map[string]string) {70+ for a, in := range cells {71+ if err := on.Set(addr(a), in); err != nil {72+ t.Fatal(err)73+ }74+ if err := x.SetCellValue(name, a, on.Value(addr(a)).Num); err != nil {75+ t.Fatal(err)76+ }77+ }78+ }79+ put(s, "Sheet1", map[string]string{80+ "B2": "1400", "B3": "1450", "B4": "1500", "B6": "10", "C6": "20", "D6": "30", "C2": "7", "C3": "8", "C4": "9",81+ })82+ put(o, "Other", map[string]string{"B2": "1", "B3": "2", "B4": "3", "B9": "4", "C9": "5", "D9": "6"})83+ if err := s.DefineName("Rent", sheet.Rect{From: addr("B2"), To: addr("B4")}); err != nil {84+ t.Fatal(err)85+ }86+ if err := x.SetDefinedName(&excelize.DefinedName{Name: "Rent", RefersTo: "Sheet1!$B$2:$B$4"}); err != nil {87+ t.Fatal(err)88+ }89+ return s, x90+}91+92+// calcAt is excelize's result for the formula f in cell on Sheet1, which93+// is emptied again afterwards.94+func calcAt(t *testing.T, x *excelize.File, cell, f string) string {95+ t.Helper()96+ if err := x.SetCellFormula("Sheet1", cell, toExcel(f)); err != nil {97+ t.Fatal(err)98+ }99+ theirs, err := x.CalcCellValue("Sheet1", cell, excelize.Options{RawCellValue: true})100+ if err != nil && (theirs == "" || theirs[0] != '#') {101+ theirs = err.Error()102+ }103+ if err := x.SetCellValue("Sheet1", cell, nil); err != nil {104+ t.Fatal(err)105+ }106+ return theirs107+}