cat / 012
clone

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

Merge the engine refactor: formula and numfmt packages, a cell store, range reads, per-workbook JEV source

Dami committed d93c619
commit
d93c619e493451c7cccc427ef35796534f730f0b
parent
a9320d2b322f54527639c62ebc583b3d782a060b
parent
e5a0be842c52d33d3b22c4ad852a7cc764600106
author
Dami <dami.etoile@gmail.com>
74 files changed, +4404 -3364
Mcmd/012/main.go6+-
Mdocs/architecture.md88+++-
Mdocs/extending.md29+-
Mdocs/limits.md22+-
Ainternal/formula/ast.go103+++++
Ainternal/formula/formula_test.go326++++++++++++++
Ainternal/formula/lex.go244+++++++++++
Ainternal/formula/parse.go269++++++++++++
Rinternal/sheet/print.go → internal/formula/print.go92++--
Rinternal/sheet/addr.go → internal/formula/ref.go55++-
Ainternal/formula/rewrite.go207+++++++++
Ainternal/formula/sheetname.go75+++
Minternal/jev/jev_test.go3+-
Ainternal/numfmt/calendar.go107+++++
Ainternal/numfmt/date.go218++++++++++
Ainternal/numfmt/edit.go92++++
Ainternal/numfmt/number.go258+++++++++++
Ainternal/numfmt/numfmt_test.go79+++
Ainternal/numfmt/pattern.go237++++++++++
Ainternal/numfmt/round.go164+++++++
Minternal/sheet/args.go14+-
Minternal/sheet/cellformat.go48+-
Minternal/sheet/chart.go28+-
Minternal/sheet/date.go182++------
Minternal/sheet/decimal.go47+-
Minternal/sheet/eval.go135+++---
Minternal/sheet/explain.go147++++--
Minternal/sheet/file.go6+-
Minternal/sheet/filter.go2+-
Minternal/sheet/filter_test.go12+-
Minternal/sheet/find.go4+-
Minternal/sheet/format.go58+--
Minternal/sheet/format_test.go72---
Ainternal/sheet/formula.go77+++
Minternal/sheet/functions.go30+-
Minternal/sheet/functions_date.go194+++++----
Minternal/sheet/functions_jev.go26+-
Minternal/sheet/functions_jev_test.go12+-
Minternal/sheet/functions_link.go12+-
Minternal/sheet/functions_logic.go100++--
Minternal/sheet/functions_lookup.go144+++---
Minternal/sheet/functions_math.go8+-
Minternal/sheet/functions_stats.go202+++++----
Minternal/sheet/functions_text.go316+++++++-------
Minternal/sheet/history.go85++-
Minternal/sheet/history_test.go2+-
Ainternal/sheet/hotpath_stress_test.go75+++
Ainternal/sheet/move.go176++++++++
Minternal/sheet/names.go44+-
Minternal/sheet/nav.go27+-
Minternal/sheet/number.go93+----
Dinternal/sheet/numfmt.go678------------------------------
Minternal/sheet/observe.go2+-
Minternal/sheet/ops.go214++--------
Dinternal/sheet/parse.go466---------------------
Ainternal/sheet/recalc.go298+++++++++++++
Minternal/sheet/refs.go203+---------
Minternal/sheet/refs_test.go142------
Minternal/sheet/remote.go37+-
Minternal/sheet/series.go25+-
Minternal/sheet/series_test.go8+-
Minternal/sheet/sheet.go271+-----------
Minternal/sheet/sheet_test.go40--
Minternal/sheet/sort.go50+-
Minternal/sheet/sort_test.go6+-
Ainternal/sheet/store.go61+++
Minternal/sheet/trace.go10+-
Minternal/sheet/view.go10+-
Minternal/sheet/workbook.go122+----
Minternal/sheet/workbook_test.go56--
Minternal/ui/file.go3+
Minternal/ui/jev.go5+-
Minternal/ui/jev_test.go6-
Minternal/ui/stress_test.go3-
M cmd/012/main.go +3 -3
M docs/architecture.md +74 -14
@@ -5,7 +5,9 @@ Lip Gloss v2.
55 66 ```77 cmd/012 entry point: flags, JEV setup, opening or importing a file8-internal/sheet the engine: cells, parsing, evaluation, recalculation, undo, files8+internal/sheet the engine: cells, evaluation, functions, recalculation, undo, files9+internal/formula the formula language: references, lexer, parser, printer, rewriting10+internal/numfmt number formats, rounding, General, date serials911 internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk11012 internal/chart chart layout, text rendering and kitty image encoding1113 internal/jev JEV configuration, answer cache and TypeSafe client
@@ -21,16 +23,50 @@ oracle/ differential tests against excelize (separate module)
2123 2224 ## The engine2325 24-`internal/sheet` knows nothing about terminals. A `Workbook` holds ordered25-`Sheet`s, the named ranges, the undo history, the arithmetic setting and26-recalculation, as a Sheets spreadsheet does. Each sheet keeps its cells in27-a sparse map from address to cell, with its column widths, frozen panes,28-filter and charts; each cell keeps what was typed, its parsed expression,29-its computed value, and its format and style.26+`internal/sheet` knows nothing about terminals. It builds on two packages27+that know nothing about cells, and dependencies point one way:3028 31-- **Parsing.** A hand-written Pratt parser turns formulas into an AST,32- keeping absolute markers so references can be rewritten when cells move.33- A printer turns ASTs back into text in Sheets' spelling.29+```30+internal/ui, internal/fileio, internal/chart, oracle31+ |32+internal/sheet ---> internal/formula33+ |34+ +--------> internal/numfmt35+```36+37+- `internal/formula` is the language: `Addr` and `Rect`, A1 references38+ with their `$` markers, sheet names in references and their quoting,39+ the lexer and Pratt parser, the syntax tree, the printer and the40+ reference rewriting for copies, moves and inserted or deleted rows and41+ columns. The parser learns which functions exist through a small42+ `Func` interface that the engine's `FuncDef` implements.43+- `internal/numfmt` renders numbers: number format patterns (as in TEXT44+ and custom formats), Sheets' General form, rounding on 15 significant45+ digits, and the calendar of serial day numbers.46+47+`sheet` re-exports what its callers used before (`sheet.Addr`,48+`sheet.Rect`, `sheet.Parse`, `sheet.FormatPattern`, the sheet-name49+helpers) as aliases and thin wrappers, so the UI, importers and charts see50+one engine package. Typed-entry recognition (`ParseValue`, which turns51+"$1,200" or "9/26" into a number and a format) stays in `sheet`: it yields52+the engine's `Format` and reads its clock.53+54+A `Workbook` holds ordered `Sheet`s, the named ranges, the undo history,55+the arithmetic setting, the JEV source and recalculation, as a Sheets56+spreadsheet does. Each sheet keeps its cells, sparse, by address, with57+its column widths, frozen panes, filter and charts; each cell keeps what58+was typed, its parsed expression, its computed value, and its format and59+style.60+61+- **Cells.** Each sheet keeps its cells in a `cellStore` (`store.go`):62+ get, set, delete, count, iterate all or a range. Nothing else touches63+ the map beneath, so the representation can change without the rest of64+ the engine noticing.65+- **Parsing.** `internal/formula`'s hand-written Pratt parser turns66+ formulas into an AST, keeping absolute markers so references can be67+ rewritten when cells move. Its printer turns ASTs back into text in68+ Sheets' spelling. Decimal arithmetic marks the operators it computes69+ with node types of the engine's own, so trees stay as parsed.3470 - **Recalculation.** Formulas record the cells and ranges they read on3571 their own sheet; references that name a sheet (`Sheet2!A1`) are kept by3672 name and resolved when evaluated, so renaming rewrites them and a deleted
@@ -39,7 +75,9 @@ its computed value, and its format and style.
3975 sheet, then evaluates the marked cells lazily in dependency order; a cell4076 reached again while it is being evaluated is part of a cycle. Volatile4177 functions (TODAY, RAND, the JEV functions) are recomputed on every42- recalculation.78+ recalculation (`recalc.go`). Formulas read other cells through a79+ `lookup`: `cell` for one, `cells` for a whole range, which the80+ aggregates (SUM, AVERAGE, COUNT and the rest) use.4381 - **Functions.** One table (`FuncDef`) holds every function's name,4482 signature, description and arity; it drives parsing, evaluation,4583 autocomplete, in-app help and [functions.md](functions.md).
@@ -48,9 +86,31 @@ its computed value, and its format and style.
4886 on any sheet, so a step can be reversed exactly; multi-cell operations4987 are one step. A deleted sheet keeps its cells, so undo brings it back.5088 - **JEV.** The engine never touches the network. JEV functions describe a51- question and look up the answer in a pluggable source; `internal/jev`52- answers from a cache and queues new questions, and the UI sends them as53- background commands.89+ question and look up the answer in the workbook's `RemoteSource`, set90+ with `SetRemote`; `internal/jev` answers from a cache and queues new91+ questions, and the UI sends them as background commands.92+93+### Where the engine can grow94+95+The package boundaries leave the bounds in [limits.md](limits.md) room to96+move without touching callers:97+98+- **Storage.** Compact cell storage (column blocks of values, formulas and99+ formats in side tables) replaces `cellStore`'s map; its methods are the100+ whole contract.101+- **Range reads.** Shared range results, prefix sums for running totals102+ and column-block scans go behind `lookup.cells`, the one place103+ aggregates read ranges.104+- **Files.** A streaming or columnar `.012` format replaces `file.go`'s105+ whole-document JSON; the format is already separate from the store.106+- **Depth limits.** A cap on nesting goes in `internal/formula`'s parser,107+ so a pathological formula fails to parse before anything evaluates it.108+- **Functions.** The function library (values, evaluation, `FuncDef` and109+ the `functions_*.go` tables, about a third of `sheet`) is the next110+ boundary: it needs cells only through `lookup`. It stays in `sheet`111+ for now because `lookup` is a concrete type (an interface across a112+ package boundary would put every range callback on the heap) and113+ `Value` and `Format` would have to move below the engine with it.54114 55115 ## The UI56116
M docs/extending.md +14 -15
@@ -42,9 +42,10 @@ to add a command log for macros and replay later.
4242 ### 4. Side effects stay at the edges4343 4444 `internal/sheet` does no I/O, network or terminal work. Anything outside the45-process is a small interface the engine asks (`RemoteSource`) and the UI46-fulfils asynchronously with `tea.Cmd`, answering from a cache. Tests47-substitute fakes (`jev.Client`, the fake TypeSafe server, `demos/fakejev`).45+process is a small interface the engine asks (`RemoteSource`, set per46+workbook with `SetRemote`) and the UI fulfils asynchronously with47+`tea.Cmd`, answering from a cache. Tests substitute fakes (`jev.Client`,48+the fake TypeSafe server, `demos/fakejev`).4849 New remote function families (other models, web lookups, databases) plug in4950 the same way.5051
@@ -61,17 +62,19 @@ around it.
6162 6263 ### 6. Storage behind a small API6364 64-Code reads and writes cells through a narrow API (get, set, iterate a65-range, iterate a column), not the map underneath. That lets the store change66-shape (compact column blocks, side tables for formulas and formats; see67-[limits.md](limits.md)) without touching the rest of the engine.65+Code reads and writes cells through a narrow API (`cellStore` in66+`internal/sheet/store.go`: get, set, delete, count, iterate everything or67+a range, a column being a range), not the map underneath. That lets the68+store change shape (compact column blocks, side tables for formulas and69+formats; see [limits.md](limits.md)) without touching the rest of the70+engine.6871 6972 ### 7. Read ranges as ranges7073 71-Formulas read single cells through `lookup` and ranges through range72-iteration, so aggregates (`SUM(A:A)`, running totals) can be served from73-column blocks, prefix sums or cached aggregates instead of one lookup per74-cell.74+Formulas read single cells through `lookup.cell` and ranges through75+`lookup.cells`, so aggregates (`SUM(A:A)`, running totals) can be served76+from column blocks, prefix sums or cached aggregates instead of one lookup77+per cell.7578 7679 ### 8. Work proportional to what changed or what's visible7780
@@ -111,10 +114,6 @@ a golden screen reviewed in the gallery for anything visible (see
111114 112115 Where the code doesn't follow the patterns yet:113116 114-- `sheet.Remote` is a package global; it belongs to the workbook (pattern 4).115-- Cells are a bare `map[Addr]*Cell` used directly across the engine116- (pattern 6).117-- Aggregates read ranges cell by cell through `lookup` (pattern 7).118117 - Undo history is capped by step count, not bytes (pattern 8).119118 - Components in `internal/ui` are handed the whole `*Model` rather than a120119 narrower interface, so they stay in package `ui` (pattern 2).
M docs/limits.md +14 -8
@@ -207,8 +207,9 @@ would take; without them a 10 M-cell sheet would need about 3 GB of
207207 heap, take 10 s to open and 140 ms per key to extend a selection over208208 it.209209 210-1. **Compact cell storage** (L, 2 to 3 weeks). Cells live in211- `map[Addr]*Cell`, 300 B each: a 216 B struct carrying formula-only210+1. **Compact cell storage** (L, 2 to 3 weeks). Cells live in a211+ `map[Addr]*Cell` behind `cellStore` (`internal/sheet/store.go`), 300 B212+ each: a 216 B struct carrying formula-only212213 fields (expression, reference lists, inferred format) on every cell,213214 plus the map entry, the input string and a boxed literal. Storing214215 columns in row blocks (say 1024 rows) of compact values, with
@@ -217,7 +218,8 @@ it.
217218 of the value, would bring numbers to about 20 to 40 B per cell and218219 turn every cell read in recalc from two hash lookups (20 ns) into an219220 index (2 to 3 ns). Cells are already per sheet in a workbook, so the220- storage can change sheet by sheet.221+ storage can change sheet by sheet, and nothing outside `cellStore`222+ touches the map, so the change stays inside it.221223 2. **Range dependency index and shared range results** (M, 1 week).222224 Range users are now indexed by column; an interval index per column223225 (or per row block) would make finding them independent of how many
@@ -225,12 +227,14 @@ it.
225227 recalc would make 1000 identical `SUM(A1:A8192)` cost one, and226228 running totals could be computed from a prefix sum per column block:227229 the fan-in and running-total rows of the table above would drop to228- milliseconds.230+ milliseconds. Aggregates already read ranges through `lookup.cells`231+ (`internal/sheet/recalc.go`), where such results would be served.229232 3. **Streaming, compact file format** (M, 1 week). The `.012` file is230233 JSON decoded whole (1.3 GB allocated to open 47 MB). A streaming231234 decoder over the same format would roughly halve open time and cut232235 allocation tenfold; a columnar or gzip-compressed variant would cut233- the size about fourfold.236+ the size about fourfold. The format is `internal/sheet/file.go`, apart237+ from the cell store.234238 4. **Incremental selection statistics** (S, 2 days). Keep per-column235239 sums and counts, updated in `place` and after recalc, and compute a236240 selection's Sum and Count from column totals minus the rows outside
@@ -246,9 +250,10 @@ it.
246250 7. **Frame rate** (S, hours). Bubble Tea draws at most 60 frames a247251 second; asking for 120 would halve the key-to-screen floor from about248252 16 ms to 8 ms at the cost of more redraws.249-8. **Recursion limits** (S, hours). The parser and evaluator recurse250- without a limit; a depth cap (Excel allows 64 nested functions) would251- turn a pathological file into an error rather than a deep stack.253+8. **Recursion limits** (S, hours). The parser (`internal/formula`) and254+ the evaluator recurse without a limit; a depth cap (Excel allows 64255+ nested functions) would turn a pathological file into an error rather256+ than a deep stack.252257 253258 ## Measuring254259
@@ -261,6 +266,7 @@ make stress-e2e # key press to screen through libghostty
261266 262267 Benchmarks are built only with `-tags stress`, so `go test ./...` stays263268 fast. They live in `internal/sheet/stress_test.go` (engine),269+`internal/sheet/hotpath_stress_test.go` (parsing and number formats),264270 `internal/ui/stress_test.go` (View, frames, keystrokes, JEV, telemetry265271 overhead), `internal/fileio/stress_test.go` (imports and exports),266272 `internal/chart/stress_test.go` (charts as text and images) and
A internal/formula/ast.go +103 -0
@@ -0,0 +1,103 @@
1+// Package formula is the formula language: A1 references and ranges,2+// sheet names in references, the lexer and Pratt parser, the syntax tree,3+// the printer that writes trees back in Sheets' spelling, and the4+// rewriting of references when cells are copied, moved, inserted or5+// deleted. It knows nothing of cells or values: the engine evaluates the6+// trees, and tells the parser which functions exist.7+package formula8+9+// Node is a parsed formula expression: one of the types below.10+type Node any11+12+type (13+ Num struct{ V float64 }14+ Str struct{ V string }15+ Bool struct{ V bool }16+ // Ref is a cell reference. Sheet is the sheet name as written before17+ // the "!" (Sheet2!A1), or "" for the formula's own sheet.18+ Ref struct {19+ Addr Addr20+ Abs Abs21+ Sheet string22+ }23+ // Range is kept normalized (Rect.From is the top-left corner); Abs24+ // holds the absolute markers of Rect.From and Rect.To.25+ Range struct {26+ Rect Rect27+ Abs [2]Abs28+ Sheet string29+ }30+ RefErr struct{} // a reference to deleted cells: #REF!31+ Name struct{ Name string } // a named range as spelled in the formula32+ Empty struct{} // an omitted argument, as in XLOOKUP(a, b, c, , 1)33+ Unary struct {34+ Op string // "-", "+", "%" (postfix) or "#NOT#"35+ X Node36+ }37+ Binary struct {38+ Op string39+ L, R Node40+ }41+ Call struct {42+ Fn Func43+ Args []Node44+ }45+)46+47+// Func is a function a formula can call. The parser checks calls against48+// its signature; what a call computes is up to the engine, which49+// recovers its own type from Call.Fn.50+type Func interface {51+ Signature() Signature52+}53+54+// Signature is how a function is called.55+type Signature struct {56+ Name string // canonical, upper case57+ Args string // shown to users, e.g. "value1, [value2, ...]"58+ Min int59+ Max int // -1 for variadic60+ // Step > 0 means arguments after Min come in groups of Step, like61+ // SUMIFS' (range, criterion) pairs.62+ Step int63+}64+65+// Funcs finds a function by its upper-case name or alias.66+type Funcs func(name string) (Func, bool)67+68+// WalkNames calls fn for every name in n.69+func WalkNames(n Node, fn func(Name)) {70+ switch n := n.(type) {71+ case Name:72+ fn(n)73+ case Unary:74+ WalkNames(n.X, fn)75+ case Binary:76+ WalkNames(n.L, fn)77+ WalkNames(n.R, fn)78+ case Call:79+ for _, a := range n.Args {80+ WalkNames(a, fn)81+ }82+ }83+}84+85+// WalkRefs calls fn for every single-cell reference and range in n, with86+// the sheet it was qualified with ("" for the formula's own sheet).87+func WalkRefs(n Node, ref func(string, Addr), rng func(string, Rect)) {88+ switch n := n.(type) {89+ case Ref:90+ ref(n.Sheet, n.Addr)91+ case Range:92+ rng(n.Sheet, n.Rect)93+ case Unary:94+ WalkRefs(n.X, ref, rng)95+ case Binary:96+ WalkRefs(n.L, ref, rng)97+ WalkRefs(n.R, ref, rng)98+ case Call:99+ for _, a := range n.Args {100+ WalkRefs(a, ref, rng)101+ }102+ }103+}
A internal/formula/formula_test.go +326 -0
@@ -0,0 +1,326 @@
1+package formula2+3+import (4+ "reflect"5+ "testing"6+)7+8+// testFunc is a function for the parser, with its signature only.9+type testFunc Signature10+11+func (f testFunc) Signature() Signature { return Signature(f) }12+13+// testFuncs is a small function table: what the parser needs to know.14+func testFuncs(name string) (Func, bool) {15+ sigs := map[string]Signature{16+ "SUM": {Name: "SUM", Args: "value1, [value2, ...]", Min: 1, Max: -1},17+ "IF": {Name: "IF", Args: "condition, value_if_true, [value_if_false]", Min: 2, Max: 3},18+ "PI": {Name: "PI", Max: 0},19+ "ROUND": {Name: "ROUND", Args: "value, [places]", Min: 1, Max: 2},20+ "SUMIFS": {Name: "SUMIFS", Args: "sum_range, criteria_range1, criterion1, ...", Min: 3, Max: -1, Step: 2},21+ }22+ sig, ok := sigs[name]23+ return testFunc(sig), ok24+}25+26+func at(s string) Addr {27+ a, ok := ParseAddr(s)28+ if !ok {29+ panic("bad address " + s)30+ }31+ return a32+}33+34+func TestAddr(t *testing.T) {35+ tests := []struct {36+ in string37+ want Addr38+ ok bool39+ }{40+ {"A1", Addr{0, 0}, true},41+ {"z10", Addr{25, 9}, true},42+ {"AA1", Addr{26, 0}, true},43+ {"IV8192", Addr{255, 8191}, true},44+ {"$B$2", Addr{1, 1}, true},45+ {"IW1", Addr{}, false},46+ {"A0", Addr{}, false},47+ {"A8193", Addr{}, false},48+ {"ABC1", Addr{}, false},49+ {"A+1", Addr{}, false},50+ {"1A", Addr{}, false},51+ }52+ for _, tt := range tests {53+ got, ok := ParseAddr(tt.in)54+ if ok != tt.ok || (ok && got != tt.want) {55+ t.Errorf("ParseAddr(%q) = %v, %v; want %v, %v", tt.in, got, ok, tt.want, tt.ok)56+ }57+ }58+ for c := range MaxCols {59+ if got, ok := ParseCol(ColName(c)); !ok || got != c {60+ t.Fatalf("ParseCol(ColName(%d)) = %d, %v", c, got, ok)61+ }62+ }63+}64+65+func TestParseRange(t *testing.T) {66+ for _, in := range []string{"B3..A1", "a1:b3", "$A$1:$B$3"} {67+ r, ok := ParseRange(in)68+ if !ok || r.String() != "A1:B3" {69+ t.Errorf("ParseRange(%q) = %v, %v", in, r, ok)70+ }71+ }72+}73+74+func TestParseAbsoluteRefs(t *testing.T) {75+ tests := []struct {76+ in string77+ want Node78+ }{79+ {"=A1", Ref{at("A1"), 0, ""}},80+ {"=$A1", Ref{at("A1"), AbsCol, ""}},81+ {"=A$1", Ref{at("A1"), AbsRow, ""}},82+ {"=$a$1", Ref{at("A1"), AbsCol | AbsRow, ""}},83+ {"=$B$3:A1", Range{NewRect(at("A1"), at("B3")), [2]Abs{0, AbsCol | AbsRow}, ""}},84+ // Each marker stays with its column or row when corners swap.85+ {"=$B1:A$2", Range{NewRect(at("A1"), at("B2")), [2]Abs{0, AbsCol | AbsRow}, ""}},86+ {"=A1..$B2", Range{NewRect(at("A1"), at("B2")), [2]Abs{0, AbsCol}, ""}},87+ {"=A$$1", Name{"A$$1"}},88+ {"=A1$", Name{"A1$"}},89+ {"=#REF!+1", Binary{Op: "+", L: RefErr{}, R: Num{1}}},90+ }91+ for _, tt := range tests {92+ got, err := Parse(tt.in, testFuncs)93+ if err != nil || !reflect.DeepEqual(got, tt.want) {94+ t.Errorf("Parse(%q) = %#v, %v; want %#v", tt.in, got, err, tt.want)95+ }96+ }97+}98+99+func TestPrintFormula(t *testing.T) {100+ tests := []struct{ in, want string }{101+ {"=a1+$b$2*C$3", "=A1+$B$2*C$3"},102+ {"=@SUM(A1..B3; 2)", "=SUM(A1:B3,2)"},103+ {"=(1+2)*3", "=(1+2)*3"},104+ {"=1-(2-3)", "=1-(2-3)"},105+ {"=(1-2)-3", "=1-2-3"},106+ {"=2^3^2", "=2^3^2"},107+ {"=2^(3^2)", "=2^(3^2)"},108+ {"=-2^2", "=-2^2"},109+ {"=(-2)^2", "=-2^2"}, // negation binds tighter than ^110+ {"=-(2^2)", "=-(2^2)"},111+ {"=-(A1+1)", "=-(A1+1)"},112+ {"=50%", "=50%"},113+ {"=(A1+1)%", "=(A1+1)%"},114+ {"=#NOT#(A1=1) #AND# B1", "=#NOT#A1=1#AND#B1"},115+ {"=1+(#NOT#A1)", "=1+(#NOT#A1)"},116+ {`="say ""hi"""&A1`, `="say ""hi"""&A1`},117+ {"=1.5E3+1e-9+2E21", "=1500+1E-09+2E+21"},118+ {"=@PI", "=PI()"},119+ {"=IF(A1>=2,TRUE,profit)", "=IF(A1>=2,TRUE,profit)"},120+ {"=SUM(#REF!)", "=SUM(#REF!)"},121+ {"+A1", "=+A1"},122+ }123+ for _, tt := range tests {124+ n, err := Parse(tt.in, testFuncs)125+ if err != nil {126+ t.Fatalf("Parse(%q): %v", tt.in, err)127+ }128+ if got := Text(n); got != tt.want {129+ t.Errorf("print(%q) = %q, want %q", tt.in, got, tt.want)130+ }131+ }132+}133+134+func TestShiftRefs(t *testing.T) {135+ tests := []struct {136+ in string137+ dc, dr int138+ want string139+ }{140+ {"=A1+B2", 1, 2, "=B3+C4"},141+ {"=$A$1+A1", 1, 1, "=$A$1+B2"},142+ {"=$A1+A$1", 2, 3, "=$A4+C$1"},143+ {"=SUM(A1:B2)", 0, 5, "=SUM(A6:B7)"},144+ {"=SUM($A$1:B2)", 1, 1, "=SUM($A$1:C3)"},145+ {"=A2", 0, -1, "=A1"},146+ {"=A2", 0, -2, "=#REF!"},147+ {"=SUM(A1:B2)+1", -1, 0, "=SUM(#REF!)+1"},148+ {"=$A2", -1, 0, "=$A2"},149+ {"=IV1", 1, 0, "=#REF!"},150+ // A mixed range can flip when one corner is anchored.151+ {"=SUM($C1:D1)", -2, 0, "=SUM(B1:$C1)"},152+ }153+ for _, tt := range tests {154+ n, _ := Parse(tt.in, testFuncs)155+ out, _ := Rewrite(n, Shift(tt.dc, tt.dr))156+ if got := Text(out); got != tt.want {157+ t.Errorf("shift(%q, %d, %d) = %q, want %q", tt.in, tt.dc, tt.dr, got, tt.want)158+ }159+ }160+}161+162+func TestSpanInterval(t *testing.T) {163+ tests := []struct {164+ sp Span165+ lo, hi int166+ wLo, wHi int167+ wOK bool168+ name string169+ checkPoint bool170+ }{171+ {Span{2, 2, 100}, 0, 1, 0, 1, true, "insert below", false},172+ {Span{2, 2, 100}, 1, 3, 1, 5, true, "insert inside grows", false},173+ {Span{2, 2, 100}, 2, 3, 4, 5, true, "insert at start shifts", false},174+ {Span{2, 2, 100}, 50, 99, 52, 99, true, "insert clamps at the edge", false},175+ {Span{2, -2, 100}, 1, 4, 1, 2, true, "delete inside shrinks", false},176+ {Span{2, -2, 100}, 2, 4, 2, 2, true, "delete top part", false},177+ {Span{2, -2, 100}, 1, 2, 1, 1, true, "delete bottom part", false},178+ {Span{2, -2, 100}, 3, 6, 2, 4, true, "delete overlapping start", false},179+ {Span{2, -2, 100}, 5, 6, 3, 4, true, "delete above shifts", false},180+ {Span{2, -2, 100}, 2, 3, 0, 0, false, "delete all of it", false},181+ }182+ for _, tt := range tests {183+ lo, hi, ok := tt.sp.Interval(tt.lo, tt.hi)184+ if ok != tt.wOK || (ok && (lo != tt.wLo || hi != tt.wHi)) {185+ t.Errorf("%s: interval(%d, %d) = %d, %d, %v", tt.name, tt.lo, tt.hi, lo, hi, ok)186+ }187+ }188+}189+190+func TestParseSheetRefs(t *testing.T) {191+ tests := []struct{ in, out, err string }{192+ {in: "=Sheet2!A1", out: "=Sheet2!A1"},193+ {in: "=sheet2!$a$1+1", out: "=sheet2!$A$1+1"},194+ {in: "='My Sheet'!B2:C9", out: "='My Sheet'!B2:C9"},195+ {in: "=SUM('Bob''s'!A1:A3)", out: "=SUM('Bob''s'!A1:A3)"},196+ {in: "=Data!B3:Data!A1", out: "=Data!A1:B3"},197+ {in: "='2026'!A1", out: "='2026'!A1"},198+ {in: "='A1'!A1", out: "='A1'!A1"},199+ {in: "=Q3.plan!A1..B2", out: "=Q3.plan!A1:B2"},200+ {in: "=Sheet2!Sales", err: "Expected a cell after Sheet2!"},201+ {in: "=Sheet2!A1:Other!B2", err: "A range can't span sheets"},202+ {in: "='Open!A1", err: "Expected ! after a quoted sheet name"},203+ {in: "='x'A1", err: "Expected ! after a quoted sheet name"},204+ }205+ for _, tt := range tests {206+ n, err := Parse(tt.in, testFuncs)207+ switch {208+ case tt.err != "":209+ if err == nil || err.Error() != tt.err {210+ t.Errorf("%s: error %v, want %q", tt.in, err, tt.err)211+ }212+ case err != nil:213+ t.Errorf("%s: %v", tt.in, err)214+ case Text(n) != tt.out:215+ t.Errorf("%s prints as %s, want %s", tt.in, Text(n), tt.out)216+ }217+ }218+}219+220+func TestQuoteSheet(t *testing.T) {221+ for name, want := range map[string]string{222+ "Sheet1": "Sheet1", "Q3_plan": "Q3_plan", "My Sheet": "'My Sheet'", "Bob's": "'Bob''s'",223+ "2026": "'2026'", "AB12": "'AB12'", "R1C1": "'R1C1'", "Café": "'Café'",224+ } {225+ if got := QuoteSheet(name); got != want {226+ t.Errorf("QuoteSheet(%q) = %s, want %s", name, got, want)227+ }228+ }229+}230+231+func TestSplitSheet(t *testing.T) {232+ tests := []struct{ in, sheet, rest string }{233+ {"A1", "", "A1"},234+ {"Sheet2!A1:B3", "Sheet2", "A1:B3"},235+ {"'Q3 plan'!B2", "Q3 plan", "B2"},236+ {"'Bob''s'!C1", "Bob's", "C1"},237+ {" Data!Z9 ", "Data", "Z9"},238+ }239+ for _, tt := range tests {240+ if sheet, rest := SplitSheet(tt.in); sheet != tt.sheet || rest != tt.rest {241+ t.Errorf("SplitSheet(%q) = %q, %q", tt.in, sheet, rest)242+ }243+ }244+}245+246+// FuzzPrint checks that printing a parsed formula gives text that parses247+// back to the same expression.248+func FuzzPrint(f *testing.F) {249+ for _, s := range []string{250+ "=A1*2", "=$A$1+A$2-$B3", "=SUM($A1:B$3)", "=-2^-2%", "=1-(2-3)", "=#NOT#A1=1#OR#B1",251+ "=2^(3^2)", `=IF(A1>2,"x""y",#REF!)`, "=1.5E-3^2", "=@SUM(A1..B3)", "=(1+2)%*-A1",252+ } {253+ f.Add(s)254+ }255+ f.Fuzz(func(t *testing.T, in string) {256+ n, err := Parse(in, testFuncs)257+ if err != nil {258+ return259+ }260+ text := Text(n)261+ back, err := Parse(text, testFuncs)262+ if err != nil {263+ t.Fatalf("%q printed as %q, which doesn't parse: %v", in, text, err)264+ }265+ if !reflect.DeepEqual(n, back) {266+ t.Fatalf("%q printed as %q parses differently:\n%#v\n%#v", in, text, n, back)267+ }268+ })269+}270+271+func TestParseErrors(t *testing.T) {272+ tests := []struct {273+ in string274+ pos int275+ msg string276+ }{277+ {"=A1+", 4, "Formula is incomplete"},278+ {"=SUM(A1", 7, "Expected , or ) in SUM"},279+ {"=NOPE(1)", 1, "Unknown function NOPE"},280+ {"=(1", 3, "Missing )"},281+ {"=A1:", 4, "Expected a cell after :"},282+ {"=1 2", 3, "Unexpected 2"},283+ {`="abc`, 1, "Missing closing quote"},284+ {"=ROUND()", 1, "Wrong number of arguments to ROUND(value, [places])"},285+ {"=PI(1)", 1, "Wrong number of arguments to PI()"},286+ {"=SUMIFS(A1:A3,B1:B3,1,C1:C3)", 1, "Wrong number of arguments to SUMIFS(sum_range, criteria_range1, criterion1, ...)"},287+ {"=1#XOR#2", 2, "Unknown operator #XOR#"},288+ {"=1#2", 2, "Unexpected #"},289+ {"=@(1)", 1, "Missing function name after @"},290+ {"=1!", 2, `Unexpected '!'`},291+ }292+ for _, tt := range tests {293+ _, err := Parse(tt.in, testFuncs)294+ pe, ok := err.(*ParseError)295+ if !ok || pe.Msg != tt.msg || pe.Pos != tt.pos {296+ t.Errorf("Parse(%q) error = %#v, want %q at %d", tt.in, err, tt.msg, tt.pos)297+ }298+ }299+}300+301+func TestLexLiterals(t *testing.T) {302+ tests := []struct {303+ in string304+ want Node305+ }{306+ {`="say ""hi"""`, Str{`say "hi"`}},307+ {`=""""`, Str{`"`}},308+ {`="plain"`, Str{"plain"}},309+ {"=.5", Num{0.5}},310+ {"=1.5e+3", Num{1500}},311+ {"=#ref!", RefErr{}},312+ {"=SUMIFS(A1:A3,B1:B3,1)", nil},313+ }314+ for _, tt := range tests {315+ got, err := Parse(tt.in, testFuncs)316+ if tt.want == nil {317+ if err != nil {318+ t.Errorf("Parse(%q): %v", tt.in, err)319+ }320+ continue321+ }322+ if err != nil || !reflect.DeepEqual(got, tt.want) {323+ t.Errorf("Parse(%q) = %#v, %v; want %#v", tt.in, got, err, tt.want)324+ }325+ }326+}
A internal/formula/lex.go +244 -0
@@ -0,0 +1,244 @@
1+package formula2+3+import (4+ "fmt"5+ "strings"6+)7+8+type tokKind int9+10+const (11+ tokEOF tokKind = iota12+ tokNum13+ tokStr14+ tokIdent // cell reference, TRUE/FALSE or a name15+ tokFunc // identifier directly followed by "("16+ tokOp // operators and punctuation17+ tokRefErr // #REF!, left behind when a referenced cell is deleted18+ tokSheet // a sheet name and its "!", as in Sheet2!A1 or 'My Sheet'!A1; text is the name19+)20+21+type token struct {22+ kind tokKind23+ text string24+ pos int25+}26+27+// ParseError describes a formula that could not be parsed. Pos is the byte28+// offset in the entry where the problem was found, used to place the edit29+// cursor.30+type ParseError struct {31+ Pos int32+ Msg string33+}34+35+func (e *ParseError) Error() string { return e.Msg }36+37+// Operators: two-character ones are matched first, then single characters.38+// #AND#, #OR# and #NOT# are 1-2-3's logical operators.39+var (40+ twoCharOps = [...]string{"..", "<=", ">=", "<>"}41+ hashOps = [...]string{"#AND#", "#OR#", "#NOT#"}42+ oneCharOps = "+-*/^=<>&(),;:%"43+ refErrorText = "#REF!"44+)45+46+func isIdentStart(c byte) bool { return isLetter(c) || c == '_' || c == '$' }47+48+func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) || c == '.' }49+50+// lexer splits a formula (without its "=") into tokens.51+type lexer struct {52+ src string53+ i int54+ toks []token55+}56+57+func lex(src string) ([]token, error) {58+ // Most formulas have a token for every two or three bytes; growing59+ // the slice as it fills cost more than lexing in deep formulas.60+ lx := lexer{src: src, toks: make([]token, 0, len(src)/2+1)}61+ for lx.i < len(src) {62+ if err := lx.next(); err != nil {63+ return nil, err64+ }65+ }66+ return append(lx.toks, token{tokEOF, "", len(src)}), nil67+}68+69+// emit adds a token that started at start and ends at the lexer's position.70+func (lx *lexer) emit(kind tokKind, text string, start int) {71+ lx.toks = append(lx.toks, token{kind, text, start})72+}73+74+func (lx *lexer) next() error {75+ c := lx.src[lx.i]76+ switch {77+ case c == ' ':78+ lx.i++79+ case isDigit(c) || c == '.' && lx.i+1 < len(lx.src) && isDigit(lx.src[lx.i+1]):80+ lx.number()81+ case c == '"':82+ return lx.str()83+ case c == '\'':84+ return lx.quotedSheet()85+ case c == '@' || isIdentStart(c):86+ return lx.ident()87+ case c == '#':88+ return lx.hash()89+ default:90+ return lx.operator()91+ }92+ return nil93+}94+95+// number reads 12, 1.5, .5 or 1.5E-3. A ".." ends it: A1..B3's range96+// operator follows digits.97+func (lx *lexer) number() {98+ src, start := lx.src, lx.i99+ for lx.i < len(src) && (isDigit(src[lx.i]) || src[lx.i] == '.' && !strings.HasPrefix(src[lx.i:], "..")) {100+ lx.i++101+ }102+ lx.exponent()103+ lx.emit(tokNum, src[start:lx.i], start)104+}105+106+// exponent reads E3, E+3 or E-3 after a number's digits, if there is one.107+func (lx *lexer) exponent() {108+ src, j := lx.src, lx.i109+ if j >= len(src) || src[j] != 'e' && src[j] != 'E' {110+ return111+ }112+ j++113+ if j < len(src) && (src[j] == '+' || src[j] == '-') {114+ j++115+ }116+ if j >= len(src) || !isDigit(src[j]) {117+ return118+ }119+ for j < len(src) && isDigit(src[j]) {120+ j++121+ }122+ lx.i = j123+}124+125+// str reads a string literal. Strings use "" to escape a quote, as in126+// Sheets.127+func (lx *lexer) str() error {128+ src, start := lx.src, lx.i129+ var escaped strings.Builder // the text so far, once it has an escape130+ from := start + 1 // the start of the text not yet copied131+ for i := from; i < len(src); i++ {132+ if src[i] != '"' {133+ continue134+ }135+ if i+1 < len(src) && src[i+1] == '"' {136+ escaped.WriteString(src[from : i+1])137+ i++138+ from = i + 1139+ continue140+ }141+ text := src[from:i]142+ if escaped.Len() > 0 {143+ escaped.WriteString(text)144+ text = escaped.String()145+ }146+ lx.i = i + 1147+ lx.emit(tokStr, text, start)148+ return nil149+ }150+ return &ParseError{start, "Missing closing quote"}151+}152+153+// quotedSheet reads a quoted sheet name and its "!", with ” for a154+// quote: 'Q3 ”26'!A1.155+func (lx *lexer) quotedSheet() error {156+ start := lx.i157+ name, end, ok := quotedName(lx.src, start)158+ if !ok || end >= len(lx.src) || lx.src[end] != '!' {159+ return &ParseError{start, "Expected ! after a quoted sheet name"}160+ }161+ lx.i = end + 1162+ lx.emit(tokSheet, name, start)163+ return nil164+}165+166+// ident reads a reference, a name, a function name, a sheet name before167+// "!", or a 1-2-3 style @function, which may omit its parentheses (@PI).168+func (lx *lexer) ident() error {169+ src, start := lx.src, lx.i170+ at := src[start] == '@'171+ if at {172+ lx.i++173+ }174+ for lx.i < len(src) && isIdentPart(src[lx.i]) && !strings.HasPrefix(src[lx.i:], "..") {175+ lx.i++176+ }177+ if !at && lx.i < len(src) && src[lx.i] == '!' {178+ lx.emit(tokSheet, src[start:lx.i], start)179+ lx.i++180+ return nil181+ }182+ name := src[start:lx.i]183+ if at {184+ name = name[1:]185+ }186+ if name == "" {187+ return &ParseError{start, "Missing function name after @"}188+ }189+ kind := tokIdent190+ if at || lx.parenFollows() {191+ kind = tokFunc192+ }193+ lx.emit(kind, strings.ToUpper(name), start)194+ return nil195+}196+197+// parenFollows reports whether "(" comes next, after any spaces.198+func (lx *lexer) parenFollows() bool {199+ j := lx.i200+ for j < len(lx.src) && lx.src[j] == ' ' {201+ j++202+ }203+ return j < len(lx.src) && lx.src[j] == '('204+}205+206+// hash reads #REF! or one of 1-2-3's #AND#, #OR# and #NOT#.207+func (lx *lexer) hash() error {208+ start, rest := lx.i, lx.src[lx.i:]209+ if len(rest) >= len(refErrorText) && strings.EqualFold(rest[:len(refErrorText)], refErrorText) {210+ lx.i += len(refErrorText)211+ lx.emit(tokRefErr, refErrorText, start)212+ return nil213+ }214+ end := strings.IndexByte(rest[1:], '#')215+ if end < 0 {216+ return &ParseError{start, "Unexpected #"}217+ }218+ op := strings.ToUpper(rest[:end+2])219+ for _, known := range hashOps {220+ if op == known {221+ lx.i += end + 2222+ lx.emit(tokOp, known, start)223+ return nil224+ }225+ }226+ return &ParseError{start, "Unknown operator " + op}227+}228+229+func (lx *lexer) operator() error {230+ start, rest := lx.i, lx.src[lx.i:]231+ for _, op := range twoCharOps {232+ if strings.HasPrefix(rest, op) {233+ lx.i += 2234+ lx.emit(tokOp, op, start)235+ return nil236+ }237+ }238+ if strings.IndexByte(oneCharOps, rest[0]) >= 0 {239+ lx.i++240+ lx.emit(tokOp, rest[:1], start)241+ return nil242+ }243+ return &ParseError{start, fmt.Sprintf("Unexpected %q", rest[0])}244+}
A internal/formula/parse.go +269 -0
@@ -0,0 +1,269 @@
1+package formula2+3+import (4+ "fmt"5+ "strconv"6+ "strings"7+)8+9+// Binding powers, lowest to highest, following Sheets' precedence.10+var infixPower = map[string]int{11+ "#OR#": 10, "#AND#": 10,12+ "=": 30, "<>": 30, "<": 30, ">": 30, "<=": 30, ">=": 30,13+ "&": 35,14+ "+": 40, "-": 40,15+ "*": 50, "/": 50,16+ "^": 70,17+}18+19+// Negation binds tighter than ^, so -2^2 is 4 as in Sheets and Excel.20+const (21+ notPower = 2022+ unaryPower = 7523+ percentPower = 8024+)25+26+type parser struct {27+ src string // the formula without its "=", for names' original spelling28+ toks []token29+ pos int30+ funcs Funcs31+}32+33+// Parse parses a formula, finding the functions it calls with funcs. src34+// may start with "=", as typed in a cell; error positions are relative to35+// src.36+func Parse(src string, funcs Funcs) (Node, error) {37+ body := strings.TrimPrefix(src, "=")38+ offset := len(src) - len(body)39+ toks, err := lex(body)40+ if err != nil {41+ return nil, shift(err, offset)42+ }43+ p := &parser{src: body, toks: toks, funcs: funcs}44+ n, err := p.expr(0)45+ if err == nil && p.peek().kind != tokEOF {46+ err = &ParseError{p.peek().pos, "Unexpected " + p.peek().text}47+ }48+ if err != nil {49+ return nil, shift(err, offset)50+ }51+ return n, nil52+}53+54+func shift(err error, offset int) error {55+ if pe, ok := err.(*ParseError); ok {56+ pe.Pos += offset57+ }58+ return err59+}60+61+func (p *parser) peek() token { return p.toks[p.pos] }62+63+func (p *parser) next() token {64+ t := p.toks[p.pos]65+ if t.kind != tokEOF {66+ p.pos++67+ }68+ return t69+}70+71+func (p *parser) isOp(op string) bool {72+ t := p.peek()73+ return t.kind == tokOp && t.text == op74+}75+76+func (p *parser) expr(minPower int) (Node, error) {77+ left, err := p.prefix()78+ if err != nil {79+ return nil, err80+ }81+ for {82+ if p.isOp("%") && percentPower > minPower {83+ p.next()84+ left = Unary{Op: "%", X: left}85+ continue86+ }87+ t := p.peek()88+ power, ok := infixPower[t.text]89+ if t.kind != tokOp || !ok || power <= minPower {90+ return left, nil91+ }92+ p.next()93+ right, err := p.expr(power)94+ if err != nil {95+ return nil, err96+ }97+ left = Binary{Op: t.text, L: left, R: right}98+ }99+}100+101+func (p *parser) prefix() (Node, error) {102+ t := p.next()103+ switch t.kind {104+ case tokNum:105+ v, err := strconv.ParseFloat(t.text, 64)106+ if err != nil {107+ return nil, &ParseError{t.pos, "Invalid number " + t.text}108+ }109+ return Num{v}, nil110+ case tokStr:111+ return Str{t.text}, nil112+ case tokIdent:113+ return p.ident(t, "")114+ case tokSheet:115+ ref := p.next()116+ if ref.kind != tokIdent {117+ return nil, &ParseError{ref.pos, "Expected a cell after " + QuoteSheet(t.text) + "!"}118+ }119+ return p.ident(ref, t.text)120+ case tokFunc:121+ return p.call(t)122+ case tokRefErr:123+ return RefErr{}, nil124+ case tokOp:125+ return p.prefixOp(t)126+ case tokEOF:127+ return nil, &ParseError{t.pos, "Formula is incomplete"}128+ }129+ return nil, &ParseError{t.pos, "Unexpected " + t.text}130+}131+132+// prefixOp parses what starts with an operator: a parenthesized133+// expression, or a prefix + - or #NOT#.134+func (p *parser) prefixOp(t token) (Node, error) {135+ switch t.text {136+ case "(":137+ n, err := p.expr(0)138+ if err != nil {139+ return nil, err140+ }141+ if !p.isOp(")") {142+ return nil, &ParseError{p.peek().pos, "Missing )"}143+ }144+ p.next()145+ return n, nil146+ case "+", "-", "#NOT#":147+ power := unaryPower148+ if t.text == "#NOT#" {149+ power = notPower150+ }151+ x, err := p.expr(power)152+ if err != nil {153+ return nil, err154+ }155+ return Unary{Op: t.text, X: x}, nil156+ }157+ return nil, &ParseError{t.pos, "Unexpected " + t.text}158+}159+160+// ident parses a reference, a range, a boolean or a name. sheet is the161+// sheet the reference was qualified with, if any.162+func (p *parser) ident(t token, sheet string) (Node, error) {163+ a, abs, isRef := ParseRef(t.text)164+ if !isRef {165+ if sheet != "" {166+ return nil, &ParseError{t.pos, "Expected a cell after " + QuoteSheet(sheet) + "!"}167+ }168+ switch t.text {169+ case "TRUE":170+ return Bool{true}, nil171+ case "FALSE":172+ return Bool{false}, nil173+ }174+ // Names match case-insensitively but print as written.175+ return Name{p.src[t.pos : t.pos+len(t.text)]}, nil176+ }177+ if !p.isOp(":") && !p.isOp("..") {178+ return Ref{a, abs, sheet}, nil179+ }180+ sep := p.next()181+ end := p.next()182+ // The second corner may repeat the sheet: Sheet2!A1:Sheet2!B3.183+ if end.kind == tokSheet {184+ if SheetKey(end.text) != SheetKey(sheet) {185+ return nil, &ParseError{end.pos, "A range can't span sheets"}186+ }187+ end = p.next()188+ }189+ b, bAbs, ok := ParseRef(end.text)190+ if end.kind != tokIdent || !ok {191+ return nil, &ParseError{end.pos, "Expected a cell after " + sep.text}192+ }193+ r := NewRange(a, b, abs, bAbs)194+ r.Sheet = sheet195+ return r, nil196+}197+198+// NewRange builds a normalized range from two corners as written. Each199+// absolute marker stays with its column or row, so $B1:A$2 becomes A1:$B$2.200+func NewRange(a, b Addr, aAbs, bAbs Abs) Range {201+ swap := func(bit Abs) {202+ aAbs, bAbs = aAbs&^bit|bAbs&bit, bAbs&^bit|aAbs&bit203+ }204+ if a.Col > b.Col {205+ a.Col, b.Col = b.Col, a.Col206+ swap(AbsCol)207+ }208+ if a.Row > b.Row {209+ a.Row, b.Row = b.Row, a.Row210+ swap(AbsRow)211+ }212+ return Range{Rect{a, b}, [2]Abs{aAbs, bAbs}, ""}213+}214+215+// call parses a function call, with or without parentheses (@PI), and216+// checks its number of arguments.217+func (p *parser) call(t token) (Node, error) {218+ fn, ok := p.funcs(t.text)219+ if !ok {220+ return nil, &ParseError{t.pos, "Unknown function " + t.text}221+ }222+ sig := fn.Signature()223+ n := Call{Fn: fn}224+ if p.isOp("(") {225+ p.next()226+ args, err := p.args(sig.Name)227+ if err != nil {228+ return nil, err229+ }230+ n.Args = args231+ }232+ if !sig.accepts(len(n.Args)) {233+ return nil, &ParseError{t.pos, fmt.Sprintf("Wrong number of arguments to %s(%s)", sig.Name, sig.Args)}234+ }235+ return n, nil236+}237+238+// args parses a call's arguments after its "(", through the ")". An239+// argument may be left out: F(a, , c) or F(a, ).240+func (p *parser) args(name string) ([]Node, error) {241+ if p.isOp(")") {242+ p.next()243+ return nil, nil244+ }245+ var args []Node246+ for {247+ if p.isOp(",") || p.isOp(";") || p.isOp(")") {248+ args = append(args, Empty{})249+ } else {250+ arg, err := p.expr(0)251+ if err != nil {252+ return nil, err253+ }254+ args = append(args, arg)255+ }256+ sep := p.next()257+ switch {258+ case sep.kind == tokOp && sep.text == ")":259+ return args, nil260+ case sep.kind != tokOp || (sep.text != "," && sep.text != ";"):261+ return nil, &ParseError{sep.pos, "Expected , or ) in " + name}262+ }263+ }264+}265+266+// accepts reports whether a call may pass n arguments.267+func (s Signature) accepts(n int) bool {268+ return n >= s.Min && (s.Max < 0 || n <= s.Max) && (s.Step <= 0 || (n-s.Min)%s.Step == 0)269+}
R internal/sheet/print.go → internal/formula/print.go +52 -40
@@ -1,4 +1,4 @@
1-package sheet1+package formula22 33 import (44 "math"
@@ -6,16 +6,24 @@ import (
66 "strings"77 )88 9-// formulaText prints a parsed formula back to text, with a leading "=".9+// Text prints a parsed formula back to text, with a leading "=".1010 // Rewritten formulas (after a paste or an inserted row) are stored this1111 // way, in Sheets' spelling: ranges as A1:B3, functions without @.12-func formulaText(n Node) string {12+func Text(n Node) string {1313 var b strings.Builder1414 b.WriteByte('=')1515 printNode(&b, n)1616 return b.String()1717 }1818 19+// Expr prints an expression without the leading "=", e.g. to quote part20+// of a formula.21+func Expr(n Node) string {22+ var b strings.Builder23+ printNode(&b, n)24+ return b.String()25+}26+1927 // atomPower is the binding power of nodes that never need parentheses.2028 const atomPower = 1002129
@@ -23,10 +31,10 @@ const atomPower = 100
2331 // the printer adds parentheses exactly where the parser needs them.2432 func power(n Node) int {2533 switch n := n.(type) {26- case binaryNode:27- return infixPower[n.op]28- case unaryNode:29- switch n.op {34+ case Binary:35+ return infixPower[n.Op]36+ case Unary:37+ switch n.Op {3038 case "%":3139 return percentPower3240 case "#NOT#":
@@ -39,40 +47,34 @@ func power(n Node) int {
3947 4048 func printNode(b *strings.Builder, n Node) {4149 switch n := n.(type) {42- case numLit:43- b.WriteString(numText(n.v))44- case strLit:45- b.WriteString(`"` + strings.ReplaceAll(n.v, `"`, `""`) + `"`)46- case boolLit:47- b.WriteString(strings.ToUpper(strconv.FormatBool(n.v)))48- case refNode:49- writeSheet(b, n.sheet)50- b.WriteString(refString(n.a, n.abs))51- case rangeNode:52- writeSheet(b, n.sheet)53- b.WriteString(refString(n.r.From, n.abs[0]) + ":" + refString(n.r.To, n.abs[1]))54- case refErrNode:55- b.WriteString("#REF!")56- case nameNode:57- b.WriteString(n.name)58- case unaryNode:59- if n.op == "%" {60- printChild(b, n.x, power(n.x) < percentPower)61- b.WriteByte('%')62- return63- }64- b.WriteString(n.op)65- printChild(b, n.x, power(n.x) < power(n))66- case binaryNode:67- p := infixPower[n.op]50+ case Num:51+ b.WriteString(numText(n.V))52+ case Str:53+ b.WriteString(`"` + strings.ReplaceAll(n.V, `"`, `""`) + `"`)54+ case Bool:55+ b.WriteString(strings.ToUpper(strconv.FormatBool(n.V)))56+ case Ref:57+ writeSheet(b, n.Sheet)58+ b.WriteString(RefString(n.Addr, n.Abs))59+ case Range:60+ writeSheet(b, n.Sheet)61+ b.WriteString(RefString(n.Rect.From, n.Abs[0]) + ":" + RefString(n.Rect.To, n.Abs[1]))62+ case RefErr:63+ b.WriteString(refErrorText)64+ case Name:65+ b.WriteString(n.Name)66+ case Unary:67+ printUnary(b, n)68+ case Binary:69+ p := infixPower[n.Op]6870 // Operators associate to the left, so a right operand of equal6971 // power needs parentheses: 1-(2-3).70- printChild(b, n.l, power(n.l) < p)71- b.WriteString(n.op)72- printChild(b, n.r, power(n.r) <= p)73- case callNode:74- b.WriteString(n.fn.Name + "(")75- for i, a := range n.args {72+ printChild(b, n.L, power(n.L) < p)73+ b.WriteString(n.Op)74+ printChild(b, n.R, power(n.R) <= p)75+ case Call:76+ b.WriteString(n.Fn.Signature().Name + "(")77+ for i, a := range n.Args {7678 if i > 0 {7779 b.WriteByte(',')7880 }
@@ -82,10 +84,20 @@ func printNode(b *strings.Builder, n Node) {
8284 }8385 }8486 87+func printUnary(b *strings.Builder, n Unary) {88+ if n.Op == "%" {89+ printChild(b, n.X, power(n.X) < percentPower)90+ b.WriteByte('%')91+ return92+ }93+ b.WriteString(n.Op)94+ printChild(b, n.X, power(n.X) < power(n))95+}96+8597 // writeSheet writes a reference's sheet and its "!", quoted as needed.8698 func writeSheet(b *strings.Builder, sheet string) {8799 if sheet != "" {88- b.WriteString(quoteSheet(sheet) + "!")100+ b.WriteString(QuoteSheet(sheet) + "!")89101 }90102 }91103
R internal/sheet/addr.go → internal/formula/ref.go +38 -17
@@ -1,11 +1,12 @@
1-package sheet1+package formula22 33 import (44 "strconv"55 "strings"66 )77 8-// Worksheet bounds, matching Lotus 1-2-3 Release 2 (A..IV, 1..8192).8+// Worksheet bounds, matching Lotus 1-2-3 Release 2 (A..IV, 1..8192). A9+// reference past them isn't a reference: IW1 reads as a name.910 const (1011 MaxCols = 2561112 MaxRows = 8192
@@ -53,24 +54,24 @@ func ParseCol(s string) (int, bool) {
5354 // ParseAddr parses an A1-style reference. Absolute markers ($A$1) are5455 // accepted and ignored.5556 func ParseAddr(s string) (Addr, bool) {56- a, _, ok := parseRef(s)57+ a, _, ok := ParseRef(s)5758 return a, ok5859 }5960 60-// absFlags records which parts of a reference are absolute ($A$1). Copying61-// a formula shifts only the relative parts.62-type absFlags uint861+// Abs records which parts of a reference are absolute ($A$1). Copying a62+// formula shifts only the relative parts.63+type Abs uint86364 6465 const (65- absCol absFlags = 1 << iota66- absRow66+ AbsCol Abs = 1 << iota67+ AbsRow6768 )6869 69-// parseRef parses a reference written as A1, $A1, A$1 or $A$1.70-func parseRef(s string) (Addr, absFlags, bool) {71- var abs absFlags70+// ParseRef parses a reference written as A1, $A1, A$1 or $A$1.71+func ParseRef(s string) (Addr, Abs, bool) {72+ var abs Abs7273 if rest, ok := strings.CutPrefix(s, "$"); ok {73- s, abs = rest, absCol74+ s, abs = rest, AbsCol7475 }7576 i := 07677 for i < len(s) && isLetter(s[i]) {
@@ -82,7 +83,7 @@ func parseRef(s string) (Addr, absFlags, bool) {
8283 }8384 digits := s[i:]8485 if rest, ok := strings.CutPrefix(digits, "$"); ok {85- digits, abs = rest, abs|absRow86+ digits, abs = rest, abs|AbsRow8687 }8788 if digits == "" || strings.ContainsFunc(digits, func(r rune) bool { return r < '0' || r > '9' }) {8889 return Addr{}, 0, false
@@ -92,14 +93,14 @@ func parseRef(s string) (Addr, absFlags, bool) {
9293 return a, abs, err == nil && a.Valid()9394 }9495 95-// refString writes a with its absolute markers, e.g. $A1.96-func refString(a Addr, abs absFlags) string {96+// RefString writes a with its absolute markers, e.g. $A1.97+func RefString(a Addr, abs Abs) string {9798 var b strings.Builder98- if abs&absCol != 0 {99+ if abs&AbsCol != 0 {99100 b.WriteByte('$')100101 }101102 b.WriteString(ColName(a.Col))102- if abs&absRow != 0 {103+ if abs&AbsRow != 0 {103104 b.WriteByte('$')104105 }105106 b.WriteString(strconv.Itoa(a.Row + 1))
@@ -150,6 +151,26 @@ func ParseRange(s string) (Rect, bool) {
150151 return NewRect(a, b), ok1 && ok2151152 }152153 154+// LooksLikeRef reports whether an upper-case name reads as a cell in A1 or155+// R1C1 style, even beyond this sheet's edges, so names stay unambiguous in156+// other spreadsheets too.157+func LooksLikeRef(k string) bool {158+ letters := strings.TrimLeft(k, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")159+ if letters != k && letters != "" && strings.Trim(letters, "0123456789") == "" {160+ return true161+ }162+ rest, ok := strings.CutPrefix(k, "R")163+ if !ok {164+ rest, ok = k, strings.HasPrefix(k, "C")165+ }166+ if !ok {167+ return false168+ }169+ rest = strings.TrimLeft(rest, "0123456789")170+ rest, _ = strings.CutPrefix(rest, "C")171+ return strings.Trim(rest, "0123456789") == ""172+}173+153174 func isLetter(c byte) bool {154175 return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')155176 }
A internal/formula/rewrite.go +207 -0
@@ -0,0 +1,207 @@
1+package formula2+3+// Reference rewriting: copying a formula shifts its relative references,4+// and moving or inserting and deleting cells updates every reference to5+// the cells that moved, as Sheets does. A reference that can't be kept6+// becomes #REF!.7+8+// Rewriter maps the references in a formula. Any function may return9+// RefErr; a nil function leaves those nodes alone.10+type Rewriter struct {11+ Ref func(Ref) Node12+ Range func(Range) Node13+ Name func(Name) Node14+}15+16+// Rewrite returns n with its references mapped, and whether anything17+// changed. Unchanged subtrees are shared.18+func Rewrite(n Node, rw Rewriter) (Node, bool) {19+ switch n := n.(type) {20+ case Ref, Range, Name:21+ out := rw.leaf(n)22+ return out, out != n23+ case Unary:24+ x, ok := Rewrite(n.X, rw)25+ if ok {26+ n.X = x27+ }28+ return n, ok29+ case Binary:30+ l, okL := Rewrite(n.L, rw)31+ r, okR := Rewrite(n.R, rw)32+ if okL || okR {33+ n.L, n.R = l, r34+ }35+ return n, okL || okR36+ case Call:37+ return rewriteCall(n, rw)38+ }39+ return n, false40+}41+42+// leaf maps a reference, range or name.43+func (rw Rewriter) leaf(n Node) Node {44+ switch n := n.(type) {45+ case Ref:46+ if rw.Ref != nil {47+ return rw.Ref(n)48+ }49+ case Range:50+ if rw.Range != nil {51+ return rw.Range(n)52+ }53+ case Name:54+ if rw.Name != nil {55+ return rw.Name(n)56+ }57+ }58+ return n59+}60+61+// rewriteCall rewrites a call's arguments, copying them only if one62+// changed.63+func rewriteCall(n Call, rw Rewriter) (Node, bool) {64+ var args []Node65+ for i, a := range n.Args {66+ if b, ok := Rewrite(a, rw); ok {67+ if args == nil {68+ args = append([]Node(nil), n.Args...)69+ }70+ args[i] = b71+ }72+ }73+ if args == nil {74+ return n, false75+ }76+ n.Args = args77+ return n, true78+}79+80+// Shift is the rewrite for copying a formula by (dc, dr): relative parts81+// move, $absolute parts stay, and references pushed off the sheet become82+// #REF!.83+func Shift(dc, dr int) Rewriter {84+ move := func(a Addr, abs Abs) (Addr, bool) {85+ if abs&AbsCol == 0 {86+ a.Col += dc87+ }88+ if abs&AbsRow == 0 {89+ a.Row += dr90+ }91+ return a, a.Valid()92+ }93+ return Rewriter{94+ Ref: func(n Ref) Node {95+ a, ok := move(n.Addr, n.Abs)96+ if !ok {97+ return RefErr{}98+ }99+ return Ref{a, n.Abs, n.Sheet}100+ },101+ Range: func(n Range) Node {102+ from, ok1 := move(n.Rect.From, n.Abs[0])103+ to, ok2 := move(n.Rect.To, n.Abs[1])104+ if !ok1 || !ok2 {105+ return RefErr{}106+ }107+ r := NewRange(from, to, n.Abs[0], n.Abs[1])108+ r.Sheet = n.Sheet109+ return r110+ },111+ }112+}113+114+// Relocate is the rewrite for cells that move on one sheet: cell maps115+// where each cell went (false if it's gone), and rng maps whole ranges.116+// on reports whether a reference written with a sheet name ("" for none)117+// points at the sheet whose cells moved; other references stay.118+func Relocate(on func(sheet string) bool, cell func(Addr) (Addr, bool), rng func(Rect) (Rect, bool)) Rewriter {119+ return Rewriter{120+ Ref: func(n Ref) Node {121+ if !on(n.Sheet) {122+ return n123+ }124+ a, ok := cell(n.Addr)125+ if !ok {126+ return RefErr{}127+ }128+ return Ref{a, n.Abs, n.Sheet}129+ },130+ Range: func(n Range) Node {131+ if !on(n.Sheet) {132+ return n133+ }134+ r, ok := rng(n.Rect)135+ if !ok {136+ return RefErr{}137+ }138+ return Range{r, n.Abs, n.Sheet}139+ },140+ }141+}142+143+// Span inserts (N > 0) or deletes (N < 0) lines starting at index At,144+// along an axis with Size lines.145+type Span struct{ At, N, Size int }146+147+// Point maps a line index; false if the line was deleted or pushed off148+// the end.149+func (sp Span) Point(v int) (int, bool) {150+ switch {151+ case v < sp.At:152+ return v, true153+ case sp.N < 0 && v < sp.At-sp.N:154+ return 0, false155+ }156+ v += sp.N157+ return v, v < sp.Size158+}159+160+// Interval maps a range's lines lo..hi as Sheets does: inserting inside161+// the range grows it, deleting part of it shrinks it, and deleting all of162+// it leaves #REF!.163+func (sp Span) Interval(lo, hi int) (int, int, bool) {164+ if sp.N > 0 {165+ lo, okLo := sp.Point(lo)166+ if !okLo {167+ return 0, 0, false168+ }169+ hi, _ := sp.Point(hi)170+ return lo, min(hi, sp.Size-1), true171+ }172+ end := sp.At - sp.N // first line after the deletion173+ if lo >= sp.At && hi < end {174+ return 0, 0, false175+ }176+ if lo >= sp.At {177+ lo = max(lo, end) + sp.N178+ }179+ if hi >= sp.At {180+ hi = max(hi+sp.N, sp.At-1)181+ }182+ return lo, hi, true183+}184+185+// AxisMaps are the cell and range mappings for inserting or deleting186+// rows (rows true) or columns, for Relocate.187+func AxisMaps(rows bool, sp Span) (func(Addr) (Addr, bool), func(Rect) (Rect, bool)) {188+ cell := func(a Addr) (Addr, bool) {189+ var ok bool190+ if rows {191+ a.Row, ok = sp.Point(a.Row)192+ } else {193+ a.Col, ok = sp.Point(a.Col)194+ }195+ return a, ok196+ }197+ rng := func(r Rect) (Rect, bool) {198+ var ok bool199+ if rows {200+ r.From.Row, r.To.Row, ok = sp.Interval(r.From.Row, r.To.Row)201+ } else {202+ r.From.Col, r.To.Col, ok = sp.Interval(r.From.Col, r.To.Col)203+ }204+ return r, ok205+ }206+ return cell, rng207+}
A internal/formula/sheetname.go +75 -0
@@ -0,0 +1,75 @@
1+package formula2+3+import "strings"4+5+// Sheet names in references: Sheet2!A1, 'Q3 plan'!B2:C9.6+7+// SheetKey is how sheet names compare: ignoring case, as in Sheets.8+func SheetKey(name string) string { return strings.ToUpper(name) }9+10+// QuoteSheet writes a sheet name as a formula needs it: bare when it reads11+// as a plain identifier (Sheet2), otherwise in single quotes with quotes12+// doubled, e.g. 'Q3 plan'.13+func QuoteSheet(name string) string {14+ bare := name != "" && (isLetter(name[0]) || name[0] == '_') && !looksLikeCell(strings.ToUpper(name))15+ for i := 0; bare && i < len(name); i++ {16+ c := name[i]17+ bare = isLetter(c) || isDigit(c) || c == '_' || c == '.'18+ }19+ if bare {20+ return name21+ }22+ return "'" + strings.ReplaceAll(name, "'", "''") + "'"23+}24+25+// looksLikeCell reports whether an upper-case name reads as a cell in26+// Excel, A1 to XFD1048576 or R1C1, so a sheet of that name is quoted27+// everywhere its formulas may go. Sheet1 doesn't: SHEET is no column.28+func looksLikeCell(k string) bool {29+ digits := strings.TrimLeft(k, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")30+ if n := len(k) - len(digits); n >= 1 && n <= 3 && digits != "" && strings.Trim(digits, "0123456789") == "" {31+ return true32+ }33+ return (strings.HasPrefix(k, "R") || strings.HasPrefix(k, "C")) && LooksLikeRef(k)34+}35+36+// quotedName reads a quoted sheet name starting at the quote at src[i] and37+// returns it unquoted with the index just past the closing quote.38+func quotedName(src string, i int) (string, int, bool) {39+ var b strings.Builder40+ for j := i + 1; j < len(src); j++ {41+ if src[j] != '\'' {42+ b.WriteByte(src[j])43+ continue44+ }45+ if j+1 < len(src) && src[j+1] == '\'' {46+ b.WriteByte('\'')47+ j++48+ continue49+ }50+ return b.String(), j + 1, b.Len() > 051+ }52+ return "", len(src), false53+}54+55+// SplitSheet splits a reference such as "Sheet2!A1:B3" or "'Q3 plan'!B2"56+// into the sheet name, unquoted, and the rest. Without a sheet, sheet is57+// "" and rest is s.58+func SplitSheet(s string) (sheet, rest string) {59+ s = strings.TrimSpace(s)60+ if strings.HasPrefix(s, "'") {61+ if name, end, ok := quotedName(s, 0); ok && end < len(s) && s[end] == '!' {62+ return name, s[end+1:]63+ }64+ return "", s65+ }66+ if i := strings.LastIndexByte(s, '!'); i > 0 {67+ return s[:i], s[i+1:]68+ }69+ return "", s70+}71+72+// Qualified writes r on sheet as a formula would: Sheet2!A1:B3.73+func Qualified(sheet string, r Rect) string {74+ return QuoteSheet(sheet) + "!" + r.String()75+}
M internal/jev/jev_test.go +1 -2
A internal/numfmt/calendar.go +107 -0
@@ -0,0 +1,107 @@
1+package numfmt2+3+import (4+ "math"5+ "time"6+)7+8+// Dates are serial numbers as in Sheets: whole days since 1899-12-309+// (day 0), with the time of day as the fraction. Unlike Excel there is no10+// fictitious 1900-02-29, and dates before 1900 are negative.11+12+var (13+ MonthNames = [...]string{"January", "February", "March", "April", "May", "June", "July",14+ "August", "September", "October", "November", "December"}15+ DayNames = [...]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}16+)17+18+// epochDays is 1899-12-30 as days since 0000-03-01 (see daysFromCivil).19+var epochDays = daysFromCivil(1899, 12, 30)20+21+// daysFromCivil counts days since 0000-03-01 in the proleptic Gregorian22+// calendar (Howard Hinnant's algorithm). Months outside 1-12 normalize.23+func daysFromCivil(y, m, d int) int {24+ y, m = AddMonths(y, m, 0)25+ if m <= 2 {26+ y--27+ }28+ era := floorDivInt(y, 400)29+ yoe := y - era*40030+ mp := (m + 9) % 1231+ doy := (153*mp+2)/5 + d - 132+ doe := yoe*365 + yoe/4 - yoe/100 + doy33+ return era*146097 + doe34+}35+36+// Civil converts a serial day number to year, month and day.37+func Civil(serial int64) (y, m, d int) {38+ z := int(serial) + epochDays39+ era := floorDivInt(z, 146097)40+ doe := z - era*14609741+ yoe := (doe - doe/1460 + doe/36524 - doe/146096) / 36542+ y = yoe + era*40043+ doy := doe - (365*yoe + yoe/4 - yoe/100)44+ mp := (5*doy + 2) / 15345+ d = doy - (153*mp+2)/5 + 146+ m = (mp+2)%12 + 147+ if m <= 2 {48+ y++49+ }50+ return y, m, d51+}52+53+// DateSerial returns the serial of a date; month and day overflow into54+// the next month or year, as DATE() allows.55+func DateSerial(y, m, d int) float64 {56+ return float64(daysFromCivil(y, m, 1) + d - 1 - epochDays)57+}58+59+// TimeSerial is the fraction of a day for a time of day.60+func TimeSerial(h, m int, s float64) float64 {61+ return (float64(h)*3600 + float64(m)*60 + s) / 8640062+}63+64+// AddMonths adds n months to month m of year y, returning a month from65+// 1 to 12 and the year it falls in. m itself may be out of range.66+func AddMonths(y, m, n int) (int, int) {67+ m += n68+ y += floorDivInt(m-1, 12)69+ m = ((m-1)%12+12)%12 + 170+ return y, m71+}72+73+// DaysIn is the number of days in month m of year y.74+func DaysIn(y, m int) int {75+ return int(DateSerial(y, m+1, 1) - DateSerial(y, m, 1))76+}77+78+// Weekday returns 0 for Sunday through 6 for Saturday. Day 0 was a79+// Saturday.80+func Weekday(serial int64) int {81+ return int(((serial+6)%7 + 7) % 7)82+}83+84+// SerialOf converts a time to a serial in its own time zone.85+func SerialOf(t time.Time) float64 {86+ h, m, s := t.Clock()87+ return DateSerial(t.Year(), int(t.Month()), t.Day()) +88+ TimeSerial(h, m, float64(s)+float64(t.Nanosecond())/1e9)89+}90+91+// SplitSerial returns the date and time parts of a serial, rounded to92+// the second.93+func SplitSerial(v float64) (days int64, secs int) {94+ total := int64(math.Round(v * 86400))95+ days = floorDiv(total, 86400)96+ return days, int(total - days*86400)97+}98+99+func floorDiv(a, b int64) int64 {100+ q := a / b101+ if (a%b != 0) && ((a < 0) != (b < 0)) {102+ q--103+ }104+ return q105+}106+107+func floorDivInt(a, b int) int { return int(floorDiv(int64(a), int64(b))) }
A internal/numfmt/date.go +218 -0
@@ -0,0 +1,218 @@
1+package numfmt2+3+import (4+ "math"5+ "strconv"6+ "strings"7+)8+9+// clock is a serial broken into the fields date and time patterns show.10+type clock struct {11+ toks []ptok12+ year, month, day int13+ weekday int // 0 for Sunday14+ hour, minute, second int15+ ampm bool // hours count 1 to 1216+ elapsed int64 // whole seconds since day 0, for [h], [m] and [s]17+ fracDigits int // decimals of a second shown18+ sub int // the fraction of the second, in fracDigits digits19+}20+21+func newClock(v float64, toks []ptok) clock {22+ c := clock{toks: toks, ampm: hasKind(toks, ptAMPM), fracDigits: secondDecimals(toks)}23+ unit := math.Pow(10, float64(c.fracDigits))24+ ticks := int64(math.Round(v * 86400 * unit)) // in 10^-fracDigits seconds25+ perDay := int64(86400 * unit)26+ days := floorDiv(ticks, perDay)27+ inDay := ticks - days*perDay28+ secs := inDay / int64(unit)29+ c.sub = int(inDay % int64(unit))30+ c.elapsed = ticks / int64(unit)31+ c.year, c.month, c.day = Civil(days)32+ c.weekday = Weekday(days)33+ c.hour, c.minute, c.second = int(secs/3600), int(secs/60%60), int(secs%60)34+ return c35+}36+37+// secondDecimals is how many decimals of a second a pattern shows: .0,38+// .00 or .000 right after seconds.39+func secondDecimals(toks []ptok) int {40+ n := 041+ for i, t := range toks {42+ seconds := t.kind == ptSecond || t.kind == ptElapsed && t.s == "s"43+ if !seconds || i+1 >= len(toks) || toks[i+1].kind != ptDot {44+ continue45+ }46+ zeros := 047+ for j := i + 2; j < len(toks) && toks[j].kind == ptDigit && toks[j].s == "0"; j++ {48+ zeros++49+ }50+ n = max(n, min(zeros, 3))51+ }52+ return n53+}54+55+// formatDate renders serial v as a date, time or duration.56+func formatDate(v float64, toks []ptok) string {57+ var b strings.Builder58+ if v < 0 && hasKind(toks, ptElapsed) {59+ b.WriteByte('-') // a negative duration60+ v = -v61+ }62+ c := newClock(v, toks)63+ for i := range toks {64+ c.write(&b, i)65+ }66+ return b.String()67+}68+69+// write writes token i of the pattern.70+func (c *clock) write(b *strings.Builder, i int) {71+ switch t := c.toks[i]; t.kind {72+ case ptLit:73+ b.WriteString(t.s)74+ case ptYear:75+ if t.n <= 2 {76+ b.WriteString(padN(c.year%100, 2))77+ } else {78+ b.WriteString(strconv.Itoa(c.year))79+ }80+ case ptMonth:81+ b.WriteString(c.monthText(t.n, i))82+ case ptDay:83+ b.WriteString(c.dayText(t.n))84+ case ptHour:85+ b.WriteString(padN(c.hour12(), t.n))86+ case ptSecond:87+ b.WriteString(padN(c.second, t.n))88+ case ptElapsed:89+ b.WriteString(c.elapsedText(t))90+ case ptAMPM:91+ b.WriteString(c.meridiem(t.s))92+ case ptDot:93+ b.WriteByte('.')94+ if secondsFraction(c.toks, i) {95+ b.WriteString(padN(c.sub, c.fracDigits))96+ }97+ case ptDigit:98+ // Fraction-of-second digits are written with the dot.99+ if !afterSecondsDot(c.toks, i) {100+ b.WriteString(t.s)101+ }102+ case ptComma:103+ b.WriteByte(',')104+ case ptPercent:105+ b.WriteByte('%')106+ }107+}108+109+// monthText writes m to mmmmm at token i: minutes (see isMinute), the110+// month's number, or its name abbreviated to three letters, one, or111+// spelled out.112+func (c *clock) monthText(n, i int) string {113+ switch {114+ case n <= 2 && isMinute(c.toks, i):115+ return padN(c.minute, n)116+ case n <= 2:117+ return padN(c.month, n)118+ case n == 3:119+ return MonthNames[c.month-1][:3]120+ case n == 5:121+ return MonthNames[c.month-1][:1]122+ }123+ return MonthNames[c.month-1]124+}125+126+// dayText writes d to dddd: the day of the month or of the week.127+func (c *clock) dayText(n int) string {128+ switch n {129+ case 1, 2:130+ return padN(c.day, n)131+ case 3:132+ return DayNames[c.weekday][:3]133+ }134+ return DayNames[c.weekday]135+}136+137+func (c *clock) hour12() int {138+ if c.ampm {139+ return (c.hour+11)%12 + 1140+ }141+ return c.hour142+}143+144+// elapsedText writes [h], [m] or [s]: the whole duration in that unit.145+func (c *clock) elapsedText(t ptok) string {146+ switch t.s {147+ case "h":148+ return padN(int(c.elapsed/3600), t.n)149+ case "m":150+ return padN(int(c.elapsed/60), t.n)151+ }152+ return padN(int(c.elapsed), t.n)153+}154+155+// meridiem writes AM/PM or A/P.156+func (c *clock) meridiem(style string) string {157+ pm := c.hour >= 12158+ switch {159+ case style == "A/P" && pm:160+ return "P"161+ case style == "A/P":162+ return "A"163+ case pm:164+ return "PM"165+ }166+ return "AM"167+}168+169+// secondsFraction reports whether the dot at i starts fractions of a170+// second (s.00).171+func secondsFraction(toks []ptok, i int) bool {172+ return i > 0 && i+1 < len(toks) && toks[i+1].kind == ptDigit &&173+ (toks[i-1].kind == ptSecond || toks[i-1].kind == ptElapsed && toks[i-1].s == "s")174+}175+176+// afterSecondsDot reports whether the digit at i belongs to fractions of177+// a second, which are written with the dot.178+func afterSecondsDot(toks []ptok, i int) bool {179+ j := i - 1180+ for j >= 0 && toks[j].kind == ptDigit {181+ j--182+ }183+ return j >= 0 && toks[j].kind == ptDot && secondsFraction(toks, j)184+}185+186+// isMinute resolves m and mm: minutes right after hours or before187+// seconds, otherwise the month.188+func isMinute(toks []ptok, i int) bool {189+ for j := i - 1; j >= 0; j-- {190+ switch toks[j].kind {191+ case ptHour:192+ return true193+ case ptElapsed:194+ return toks[j].s == "h"195+ case ptYear, ptMonth, ptDay, ptSecond:196+ j = -1197+ }198+ }199+ for j := i + 1; j < len(toks); j++ {200+ switch toks[j].kind {201+ case ptSecond:202+ return true203+ case ptElapsed:204+ return toks[j].s == "s"205+ case ptYear, ptMonth, ptDay, ptHour:206+ return false207+ }208+ }209+ return false210+}211+212+func padN(v, n int) string {213+ s := strconv.Itoa(v)214+ if len(s) < n {215+ s = strings.Repeat("0", n-len(s)) + s216+ }217+ return s218+}
A internal/numfmt/edit.go +92 -0
@@ -0,0 +1,92 @@
1+package numfmt2+3+import "strings"4+5+// AdjustDecimals adds (delta > 0) or removes zeros after the decimal6+// point of every section of a number pattern, as Sheets' Increase and7+// Decrease decimal places do. Date and time patterns are returned8+// unchanged.9+func AdjustDecimals(pat string, delta int) string {10+ secs := splitSections(pat)11+ for i, sec := range secs {12+ if isDatePattern(lexPattern(sec)) {13+ return pat14+ }15+ secs[i] = adjustSection(sec, delta)16+ }17+ return strings.Join(secs, ";")18+}19+20+func adjustSection(sec string, delta int) string {21+ // Find the last digit placeholder outside quotes; decimals follow a '.'.22+ last, dot := -1, -123+ inQuote := false24+ for i := 0; i < len(sec); i++ {25+ switch c := sec[i]; {26+ case c == '"':27+ inQuote = !inQuote28+ case inQuote:29+ case c == '\\' || c == '_' || c == '*':30+ i++31+ case c == '.' && dot < 0:32+ dot = i33+ case c == '0' || c == '#' || c == '?':34+ last = i35+ case c == 'E' || c == 'e':36+ if last >= 0 {37+ // Stop at an exponent: its digits aren't decimals.38+ i = len(sec)39+ }40+ }41+ }42+ if last < 0 {43+ return sec44+ }45+ switch {46+ case delta > 0 && dot < 0:47+ return sec[:last+1] + "." + strings.Repeat("0", delta) + sec[last+1:]48+ case delta > 0:49+ return sec[:last+1] + strings.Repeat("0", delta) + sec[last+1:]50+ case dot < 0 || last < dot:51+ return sec52+ }53+ decimals := last - dot54+ remove := min(-delta, decimals)55+ if remove == decimals { // drop the point too56+ return sec[:dot] + sec[last+1:]57+ }58+ return sec[:last+1-remove] + sec[last+1:]59+}60+61+// Accounting lays out Sheets' Accounting format with dec decimals in62+// width columns: the $ at the left, the number right-aligned with room63+// for a closing parenthesis, negatives in parentheses and zero as a dash.64+// It reports false when the number doesn't fit.65+func Accounting(v float64, dec, width int) (string, bool) {66+ whole, frac := Fixed(v, dec, HalfUp)67+ var num string68+ switch {69+ case strings.Trim(whole+frac, "0") == "":70+ num = "-" + strings.Repeat(" ", dec) + " "71+ case v < 0:72+ num = "(" + Format(-v, "#,##0"+decimals(dec)) + ")"73+ default:74+ num = Format(v, "#,##0"+decimals(dec)) + " "75+ }76+ gap := width - 1 - len(num)77+ switch {78+ case gap < 0:79+ return "", false80+ case gap >= 1:81+ // Leave a column of padding before the $, like other cells.82+ return " $" + strings.Repeat(" ", gap-1) + num, true83+ }84+ return "$" + strings.Repeat(" ", gap) + num, true85+}86+87+func decimals(n int) string {88+ if n <= 0 {89+ return ""90+ }91+ return "." + strings.Repeat("0", n)92+}
A internal/numfmt/number.go +258 -0
@@ -0,0 +1,258 @@
1+package numfmt2+3+import (4+ "math"5+ "strconv"6+ "strings"7+)8+9+// numLayout is where a number section puts each part of the number: the10+// token indexes of its integer, decimal and exponent placeholders, its11+// decimal point and exponent, and how it scales the value.12+type numLayout struct {13+ toks []ptok14+ intPH, fracPH, expPH []int15+ dot, exp int // token index, or -1 for none16+ percents int // each % multiplies by 10017+ general bool18+ // grouped is set by commas between the integer placeholders, unless19+ // literals sit among them too (a phone number pattern).20+ grouped bool21+ scale int // commas right after the last integer placeholder divide by 1000 each22+}23+24+func layoutNumber(toks []ptok) numLayout {25+ l := numLayout{toks: toks, dot: -1, exp: -1}26+ for i, t := range toks {27+ switch t.kind {28+ case ptDigit:29+ l.addPlaceholder(i)30+ case ptDot:31+ if l.dot < 0 && l.exp < 0 {32+ l.dot = i33+ }34+ case ptExp:35+ if l.exp < 0 {36+ l.exp = i37+ }38+ case ptPercent:39+ l.percents++40+ case ptGeneral:41+ l.general = true42+ }43+ }44+ if len(l.intPH) > 0 {45+ first, last := l.intPH[0], l.intPH[len(l.intPH)-1]46+ commas, literals := false, false47+ for i := first + 1; i < last; i++ {48+ commas = commas || toks[i].kind == ptComma49+ literals = literals || toks[i].kind == ptLit50+ }51+ l.grouped = commas && !literals52+ for i := last + 1; i < len(toks) && toks[i].kind == ptComma; i++ {53+ l.scale++54+ }55+ }56+ return l57+}58+59+// addPlaceholder files the digit placeholder at token i under the part of60+// the number it falls in.61+func (l *numLayout) addPlaceholder(i int) {62+ switch {63+ case l.exp >= 0:64+ l.expPH = append(l.expPH, i)65+ case l.dot >= 0:66+ l.fracPH = append(l.fracPH, i)67+ default:68+ l.intPH = append(l.intPH, i)69+ }70+}71+72+// numDigits are the digits of a number as a section shows it.73+type numDigits struct {74+ whole string // integer digits, "" for zero75+ frac string // one digit per decimal placeholder76+ exp string // exponent digits, zero-padded to the exponent's placeholders77+ expNeg bool78+}79+80+// digits rounds v to what the layout shows.81+func (l *numLayout) digits(v float64) numDigits {82+ switch {83+ case l.general && len(l.intPH) == 0:84+ // General shows its own decimals; placeholders after it get zeros.85+ return numDigits{whole: General(v), frac: strings.Repeat("0", len(l.fracPH))}86+ case l.exp >= 0:87+ return l.scientific(v)88+ }89+ whole, frac := Fixed(v, len(l.fracPH), HalfUp)90+ return numDigits{whole: whole, frac: frac}91+}92+93+// scientific splits v into a mantissa of one integer digit and an94+// exponent.95+func (l *numLayout) scientific(v float64) numDigits {96+ e := 097+ if v != 0 {98+ e = int(math.Floor(math.Log10(v)))99+ }100+ whole, frac := Fixed(v/math.Pow(10, float64(e)), len(l.fracPH), HalfUp)101+ if whole == "10" { // rounding carried into another digit102+ e++103+ whole, frac = Fixed(v/math.Pow(10, float64(e)), len(l.fracPH), HalfUp)104+ }105+ exp := strconv.Itoa(max(e, -e))106+ if len(exp) < len(l.expPH) {107+ exp = strings.Repeat("0", len(l.expPH)-len(exp)) + exp108+ }109+ return numDigits{whole: whole, frac: frac, exp: exp, expNeg: e < 0}110+}111+112+// place spreads the digits over the integer and decimal placeholders,113+// returning each placeholder's text by token index.114+func (l *numLayout) place(d numDigits) []string {115+ out := make([]string, len(l.toks))116+ // Right to left; the leftmost placeholder takes any remaining digits.117+ rem := d.whole118+ for k := len(l.intPH) - 1; k >= 0; k-- {119+ i := l.intPH[k]120+ switch ph := l.toks[i].s; {121+ case k == 0 && rem != "":122+ out[i], rem = rem, ""123+ case rem != "":124+ out[i], rem = rem[len(rem)-1:], rem[:len(rem)-1]125+ case ph == "0":126+ out[i] = "0"127+ case ph == "?":128+ out[i] = " "129+ }130+ }131+ // Trailing zeros disappear under # and turn to spaces under ?.132+ trimming := true133+ for k := len(l.fracPH) - 1; k >= 0; k-- {134+ i := l.fracPH[k]135+ digit := d.frac[k : k+1]136+ switch ph := l.toks[i].s; {137+ case trimming && digit == "0" && ph == "#":138+ case trimming && digit == "0" && ph == "?":139+ out[i] = " "140+ default:141+ trimming = false142+ out[i] = digit143+ }144+ }145+ return out146+}147+148+// formatNumber renders |v| with a number section; neg adds a minus sign.149+func formatNumber(v float64, neg bool, toks []ptok) string {150+ l := layoutNumber(toks)151+ for range l.percents {152+ v *= 100153+ }154+ for range l.scale {155+ v /= 1000156+ }157+ d := l.digits(v)158+ if neg && strings.Trim(d.whole+d.frac, "0") == "" {159+ neg = false // don't show -0.00160+ }161+ placed := l.place(d)162+ var b strings.Builder163+ if neg {164+ b.WriteByte('-')165+ }166+ for i, t := range toks {167+ l.write(&b, i, t, d, placed)168+ }169+ return b.String()170+}171+172+// write writes token i of the section.173+func (l *numLayout) write(b *strings.Builder, i int, t ptok, d numDigits, placed []string) {174+ switch t.kind {175+ case ptLit:176+ b.WriteString(t.s)177+ case ptDigit:178+ l.writeDigit(b, i, d, placed)179+ case ptDot:180+ if i == l.dot {181+ b.WriteByte('.')182+ }183+ case ptPercent:184+ b.WriteByte('%')185+ case ptExp:186+ if i == l.exp {187+ b.WriteString(expSign(t.s, d.expNeg))188+ }189+ case ptGeneral:190+ if len(l.intPH) == 0 {191+ b.WriteString(d.whole)192+ }193+ case ptComma:194+ // Grouping and scaling commas are consumed; others are literal.195+ if len(l.intPH) == 0 || i < l.intPH[0] || (l.dot >= 0 && i > l.dot) {196+ b.WriteByte(',')197+ }198+ }199+}200+201+// writeDigit writes the digit placeholder at token i: the integer part202+// grouped in thousands at the first integer placeholder, the exponent at203+// the first exponent placeholder, otherwise the placeholder's own digit.204+func (l *numLayout) writeDigit(b *strings.Builder, i int, d numDigits, placed []string) {205+ inInt := len(l.intPH) > 0 && i >= l.intPH[0] && i <= l.intPH[len(l.intPH)-1]206+ switch {207+ case inInt && l.grouped && i == l.intPH[0]:208+ var digits strings.Builder209+ for _, p := range l.intPH {210+ digits.WriteString(placed[p])211+ }212+ b.WriteString(group(digits.String()))213+ case inInt:214+ if !l.grouped {215+ b.WriteString(placed[i])216+ }217+ case len(l.expPH) > 0 && i == l.expPH[0]:218+ b.WriteString(d.exp)219+ case len(l.expPH) > 0 && i > l.expPH[0]:220+ default:221+ b.WriteString(placed[i])222+ }223+}224+225+// expSign writes an exponent marker: E- for negative exponents, E+ when226+// the pattern asks for the sign.227+func expSign(sign string, neg bool) string {228+ switch {229+ case neg:230+ return "E-"231+ case sign == "+":232+ return "E+"233+ }234+ return "E"235+}236+237+// group inserts thousands separators into a run of digits (and leading238+// spaces from ? placeholders).239+func group(s string) string {240+ lead := len(s) - len(strings.TrimLeft(s, " "))241+ digits := s[lead:]242+ if len(digits) <= 3 {243+ return s244+ }245+ var b strings.Builder246+ b.WriteString(s[:lead])247+ first := len(digits) % 3248+ if first > 0 {249+ b.WriteString(digits[:first])250+ }251+ for i := first; i < len(digits); i += 3 {252+ if i > 0 {253+ b.WriteByte(',')254+ }255+ b.WriteString(digits[i : i+3])256+ }257+ return b.String()258+}
A internal/numfmt/numfmt_test.go +79 -0
@@ -0,0 +1,79 @@
1+package numfmt2+3+import "testing"4+5+func TestFormat(t *testing.T) {6+ tests := []struct {7+ v float648+ pat string9+ want string10+ }{11+ {1234.5, "#,##0.00", "1,234.50"},12+ {-1234.5, "#,##0.00", "-1,234.50"},13+ {0.5, "#,##0", "1"},14+ {0, "#,##0.00", "0.00"},15+ {1234567.891, "#,##0", "1,234,568"},16+ {1.005, "0.00", "1.01"}, // 15 significant digits, not binary17+ {2.5, "0", "3"},18+ {-2.5, "0", "-3"},19+ {-0.001, "0.00", "0.00"}, // no negative zero20+ {0.1234, "0%", "12%"},21+ {0.1234, "0.00%", "12.34%"},22+ {1234.5, "0.00E+00", "1.23E+03"},23+ {0.000123, "0.00E+00", "1.23E-04"},24+ {9.999, "0.00E+00", "1.00E+01"},25+ {0, "0.00E+00", "0.00E+00"},26+ {1234.5, `"$"#,##0.00`, "$1,234.50"},27+ {-1234.5, `"$"#,##0.00`, "-$1,234.50"},28+ {-1234.5, "#,##0.00;(#,##0.00)", "(1,234.50)"},29+ {0, "#,##0;(#,##0);\"zero\"", "zero"},30+ {1.5, "0.0#", "1.5"},31+ {1.25, "0.0#", "1.25"},32+ {1.5, "0.??", "1.5 "},33+ {5, "000", "005"},34+ {12345, "#,##0,", "12"},35+ {1234.5, "#,##0.00 \"kg\"", "1,234.50 kg"},36+ {0.5, "#.##", ".5"},37+ {12.5, "General", "12.5"},38+ {5551234, "000-0000", "555-1234"},39+ {1.5, "General.00", "1.5.00"}, // placeholders after General get zeros40+ // Dates and times: 46291 is 2026-09-26, a Saturday.41+ {46291, "m/d/yyyy", "9/26/2026"},42+ {46291, "yyyy-mm-dd", "2026-09-26"},43+ {46291, "mmm d, yyyy", "Sep 26, 2026"},44+ {46291, "dddd, mmmm d", "Saturday, September 26"},45+ {46291, "ddd mmmmm yy", "Sat S 26"},46+ {46291.6041666667, "h:mm am/pm", "2:30 PM"},47+ {46291.6041666667, "hh:mm:ss", "14:30:00"},48+ {0.5, "h:mm:ss AM/PM", "12:00:00 PM"},49+ {0, "h:mm a/p", "12:00 A"},50+ {1.0423611111, "[h]:mm:ss", "25:01:00"},51+ {-0.5, "[h]:mm", "-12:00"},52+ {0.000011574, "h:mm:ss.00", "0:00:01.00"},53+ {0.0000173611, "mm:ss.0", "00:01.5"},54+ {46291.99999999, "m/d/yyyy h:mm", "9/27/2026 0:00"},55+ {0, "m/d/yyyy", "12/30/1899"},56+ {-1, "yyyy-mm-dd", "1899-12-29"},57+ {61, "yyyy-mm-dd", "1900-03-01"},58+ {2958465, "yyyy-mm-dd", "9999-12-31"},59+ }60+ for _, tt := range tests {61+ if got := Format(tt.v, tt.pat); got != tt.want {62+ t.Errorf("Format(%v, %q) = %q, want %q", tt.v, tt.pat, got, tt.want)63+ }64+ }65+}66+67+func TestAdjustDecimals(t *testing.T) {68+ for _, tt := range []struct{ pat, want string }{69+ {"0.00", "0.000"}, {"#,##0", "#,##0.0"}, {`"$"#,##0.00_);("$"#,##0.00)`, `"$"#,##0.000_);("$"#,##0.000)`},70+ {"0.00E+00", "0.000E+00"}, {"0%", "0.0%"},71+ } {72+ if got := AdjustDecimals(tt.pat, 1); got != tt.want {73+ t.Errorf("AdjustDecimals(%q, 1) = %q, want %q", tt.pat, got, tt.want)74+ }75+ }76+ if got := AdjustDecimals("0.0", -1); got != "0" {77+ t.Errorf("AdjustDecimals(0.0, -1) = %q", got)78+ }79+}
A internal/numfmt/pattern.go +237 -0
@@ -0,0 +1,237 @@
1+// Package numfmt renders numbers the way spreadsheets show them: number2+// format patterns as in Sheets' custom number formats and TEXT(),3+// General's shortest form, rounding on the 15 significant digits users4+// see, and the serial day numbers dates and times are stored as. It knows5+// nothing of cells or sheets.6+package numfmt7+8+import (9+ "math"10+ "strings"11+ "unicode/utf8"12+)13+14+// Number format patterns: "#,##0.00", "0.0%", "0.00E+00",15+// "$#,##0;($#,##0)", "m/d/yyyy", "h:mm am/pm", "[h]:mm:ss". Up to four16+// ;-separated sections apply to positive, negative, zero and text values.17+18+type ptKind uint819+20+const (21+ ptLit ptKind = iota22+ ptDigit // 0, # or ?23+ ptDot // decimal point24+ ptComma // thousands separator or scaling25+ ptPercent //26+ ptExp // E+ or E-27+ ptGeneral // "General"28+ ptText // @29+ ptFill // *x: repeat x to fill the cell30+ ptYear // y, yy, yyyy31+ ptMonth // m..mmmmm: month, or minutes next to h or s32+ ptDay // d..dddd33+ ptHour // h, hh34+ ptSecond // s, ss35+ ptAMPM // AM/PM or A/P36+ ptElapsed // [h], [m], [s]37+ ptFrac // .0, .00 after seconds38+)39+40+type ptok struct {41+ kind ptKind42+ s string // literal text, digit placeholder char, or the token as written43+ n int // repeat count for date tokens44+}45+46+// Format renders v with a number format pattern, as TEXT() does.47+func Format(v float64, pat string) string {48+ secs := splitSections(pat)49+ sec, neg := secs[0], v < 050+ switch {51+ case len(secs) >= 3 && v == 0:52+ sec = secs[2]53+ case len(secs) >= 2 && v < 0 && strings.TrimSpace(secs[1]) != "":54+ sec, v, neg = secs[1], -v, false55+ }56+ toks := lexPattern(sec)57+ if isDatePattern(toks) {58+ return formatDate(v, toks)59+ }60+ return formatNumber(math.Abs(v), neg, toks)61+}62+63+// splitSections splits a pattern on ; outside quotes and escapes.64+func splitSections(pat string) []string {65+ var secs []string66+ start, inQuote := 0, false67+ for i := 0; i < len(pat); i++ {68+ switch c := pat[i]; {69+ case c == '"':70+ inQuote = !inQuote71+ case inQuote:72+ case c == '\\':73+ i++74+ case c == ';':75+ secs = append(secs, pat[start:i])76+ start = i + 177+ }78+ }79+ return append(secs, pat[start:])80+}81+82+// punctuation are the one-character tokens other than digit placeholders,83+// by character; ptLit for none. Arrays rather than maps: patterns are84+// lexed for every formatted cell drawn.85+var punctuation = [256]ptKind{'.': ptDot, ',': ptComma, '%': ptPercent, '@': ptText}86+87+// words are the tokens spelled with letters, matched ignoring case.88+var words = []struct {89+ word string90+ tok ptok91+}{92+ {"am/pm", ptok{kind: ptAMPM, s: "AM/PM"}},93+ {"a/p", ptok{kind: ptAMPM, s: "A/P"}},94+ {"general", ptok{kind: ptGeneral}},95+}96+97+// dateLetters are the letters that repeat to make date and time tokens.98+var dateLetters = [256]ptKind{'y': ptYear, 'm': ptMonth, 'd': ptDay, 'h': ptHour, 's': ptSecond}99+100+// patternLexer splits one section of a pattern into tokens.101+type patternLexer struct {102+ sec string103+ lower string // sec with ASCII letters lower-cased, at the same byte offsets104+ i int105+ toks []ptok106+}107+108+func lexPattern(sec string) []ptok {109+ lx := patternLexer{sec: sec, lower: asciiLower(sec)}110+ for lx.i < len(sec) {111+ lx.next()112+ }113+ return lx.toks114+}115+116+// emit adds t, which was written in the next width bytes.117+func (lx *patternLexer) emit(t ptok, width int) {118+ lx.toks = append(lx.toks, t)119+ lx.i += width120+}121+122+func (lx *patternLexer) lit(s string, width int) { lx.emit(ptok{kind: ptLit, s: s}, width) }123+124+func (lx *patternLexer) next() {125+ c, lc := lx.sec[lx.i], lx.lower[lx.i]126+ switch {127+ case c == '"':128+ lx.quoted()129+ case c == '[':130+ lx.bracketed()131+ case (c == '\\' || c == '_' || c == '*') && lx.i+1 < len(lx.sec):132+ lx.escaped(c)133+ case c == '0' || c == '#' || c == '?':134+ lx.emit(ptok{kind: ptDigit, s: lx.sec[lx.i : lx.i+1]}, 1)135+ case punctuation[c] != ptLit:136+ lx.emit(ptok{kind: punctuation[c]}, 1)137+ case lc == 'e' && lx.i+1 < len(lx.sec) && (lx.sec[lx.i+1] == '+' || lx.sec[lx.i+1] == '-'):138+ lx.emit(ptok{kind: ptExp, s: lx.sec[lx.i+1 : lx.i+2]}, 2)139+ case lx.word(): // emitted140+ case dateLetters[lc] != ptLit:141+ j := lx.i142+ for j < len(lx.sec) && lx.lower[j] == lc {143+ j++144+ }145+ lx.emit(ptok{kind: dateLetters[lc], s: lx.sec[lx.i:j], n: j - lx.i}, j-lx.i)146+ default:147+ _, w := utf8.DecodeRuneInString(lx.sec[lx.i:])148+ lx.lit(lx.sec[lx.i:lx.i+w], w)149+ }150+}151+152+// quoted reads "text", shown as is; an unclosed quote runs to the end.153+func (lx *patternLexer) quoted() {154+ end := strings.IndexByte(lx.sec[lx.i+1:], '"')155+ if end < 0 {156+ end = len(lx.sec) - lx.i - 1157+ }158+ lx.lit(lx.sec[lx.i+1:lx.i+1+end], end+2)159+}160+161+// escaped reads the character after \ (shown as is), _ (a space as wide162+// as it) or * (repeated to fill the cell).163+func (lx *patternLexer) escaped(c byte) {164+ _, w := utf8.DecodeRuneInString(lx.sec[lx.i+1:])165+ next := lx.sec[lx.i+1 : lx.i+1+w]166+ switch c {167+ case '\\':168+ lx.lit(next, 1+w)169+ case '_':170+ lx.lit(" ", 1+w)171+ default:172+ lx.emit(ptok{kind: ptFill, s: next}, 1+w)173+ }174+}175+176+// bracketed reads [h], [mm] and [ss] (elapsed time) and [$€-407]177+// (a currency symbol). Colors and conditions are ignored; an unclosed178+// bracket is literal text.179+func (lx *patternLexer) bracketed() {180+ end := strings.IndexByte(lx.sec[lx.i:], ']')181+ if end < 0 {182+ lx.lit(lx.sec[lx.i:], len(lx.sec)-lx.i)183+ return184+ }185+ inner := lx.lower[lx.i+1 : lx.i+end]186+ switch {187+ case inner != "" && strings.Trim(inner, inner[:1]) == "" && strings.ContainsAny(inner[:1], "hms"):188+ lx.toks = append(lx.toks, ptok{kind: ptElapsed, s: inner[:1], n: len(inner)})189+ case strings.HasPrefix(inner, "$"):190+ sym, _, _ := strings.Cut(lx.sec[lx.i+2:lx.i+end], "-")191+ lx.toks = append(lx.toks, ptok{kind: ptLit, s: sym})192+ }193+ lx.i += end + 1194+}195+196+// word reads AM/PM, A/P or General, reporting whether one was there.197+func (lx *patternLexer) word() bool {198+ for _, w := range words {199+ if strings.HasPrefix(lx.lower[lx.i:], w.word) {200+ lx.emit(w.tok, len(w.word))201+ return true202+ }203+ }204+ return false205+}206+207+// asciiLower lower-cases ASCII letters only, keeping byte offsets.208+func asciiLower(s string) string {209+ b := []byte(s)210+ for i, c := range b {211+ if c >= 'A' && c <= 'Z' {212+ b[i] = c + 'a' - 'A'213+ }214+ }215+ return string(b)216+}217+218+// isDatePattern reports whether a section formats dates and times.219+func isDatePattern(toks []ptok) bool {220+ for _, t := range toks {221+ switch t.kind {222+ case ptYear, ptMonth, ptDay, ptHour, ptSecond, ptAMPM, ptElapsed:223+ return true224+ }225+ }226+ return false227+}228+229+// hasKind reports whether any token is of kind k.230+func hasKind(toks []ptok, k ptKind) bool {231+ for _, t := range toks {232+ if t.kind == k {233+ return true234+ }235+ }236+ return false237+}
A internal/numfmt/round.go +164 -0
@@ -0,0 +1,164 @@
1+package numfmt2+3+import (4+ "math"5+ "strconv"6+ "strings"7+)8+9+// Rounding is how Fixed and Round treat the digits they drop.10+type Rounding int11+12+const (13+ HalfUp Rounding = iota // ROUND and number formats14+ Up // away from zero: ROUNDUP15+ Down // toward zero: ROUNDDOWN16+)17+18+// Fixed renders |x| rounded to n decimal places (n may be negative) as19+// its integer digits (no leading zeros, "" for zero) and exactly n20+// decimal digits. It works on the 15 significant digits spreadsheets21+// show, so 1.005 rounds to 1.01 as users expect rather than to the 1.0022+// its binary value would give.23+func Fixed(x float64, n int, mode Rounding) (whole, frac string) {24+ x = math.Abs(x)25+ nFrac := max(n, 0)26+ if x == 0 || math.IsInf(x, 0) || math.IsNaN(x) {27+ return "", strings.Repeat("0", nFrac)28+ }29+ s := strconv.FormatFloat(x, 'e', 14, 64)30+ mant, e, _ := strings.Cut(s, "e")31+ exp, _ := strconv.Atoi(e)32+ d := []byte(strings.Replace(mant, ".", "", 1))33+ point := exp + 1 // digits before the decimal point34+ if point < 0 {35+ d = append([]byte(strings.Repeat("0", -point)), d...)36+ point = 037+ }38+ keep := point + n39+ if keep < 0 {40+ // Far smaller than the rounding unit.41+ if mode == Up {42+ return "1" + strings.Repeat("0", -n), strings.Repeat("0", nFrac)43+ }44+ return "", strings.Repeat("0", nFrac)45+ }46+ if keep < len(d) {47+ if roundsUp(d[keep:], mode) {48+ d, point = increment(d[:keep], point)49+ } else {50+ d = d[:keep]51+ }52+ }53+ for len(d) < point+nFrac {54+ d = append(d, '0')55+ }56+ whole = strings.TrimLeft(string(d[:point]), "0")57+ frac = string(d[point : point+nFrac])58+ return whole, frac59+}60+61+// roundsUp reports whether dropping the digits dropped rounds the kept62+// ones up.63+func roundsUp(dropped []byte, mode Rounding) bool {64+ switch mode {65+ case HalfUp:66+ return dropped[0] >= '5'67+ case Up:68+ return strings.Trim(string(dropped), "0") != ""69+ }70+ return false71+}72+73+// increment adds one to the last of the digits d, carrying; a carry out74+// of the first digit adds a digit before the point.75+func increment(d []byte, point int) ([]byte, int) {76+ for i := len(d) - 1; i >= 0; i-- {77+ if d[i] != '9' {78+ d[i]++79+ return d, point80+ }81+ d[i] = '0'82+ }83+ return append([]byte{'1'}, d...), point + 184+}85+86+// Round rounds x to places decimal places (negative places round to87+// tens, hundreds...) with mode, on x's 15 significant digits.88+func Round(x float64, places int, mode Rounding) float64 {89+ whole, frac := Fixed(x, places, mode)90+ s := whole91+ if s == "" {92+ s = "0"93+ }94+ if frac != "" {95+ s += "." + frac96+ }97+ r, _ := strconv.ParseFloat(s, 64)98+ if x < 0 {99+ r = -r100+ }101+ return r102+}103+104+// General renders a number the way Sheets shows it in General format and105+// in text: up to 15 significant digits, no exponent for ordinary106+// magnitudes.107+func General(v float64) string {108+ if v == 0 {109+ return "0"110+ }111+ r, _ := strconv.ParseFloat(strconv.FormatFloat(v, 'g', 15, 64), 64)112+ if a := math.Abs(r); a >= 1e21 || a < 1e-9 {113+ return strconv.FormatFloat(r, 'E', -1, 64)114+ }115+ return strconv.FormatFloat(r, 'f', -1, 64)116+}117+118+// GeneralFit renders v in General format within width columns: as many119+// digits as fit, rounding decimals first, then scientific notation, then120+// #s.121+func GeneralFit(v float64, width int) string {122+ if width <= 0 {123+ return ""124+ }125+ s := General(v)126+ if strings.ContainsRune(s, 'E') {127+ s = strconv.FormatFloat(v, 'f', -1, 64)128+ }129+ if len(s) <= width {130+ return s131+ }132+ if f, ok := fewerDecimals(v, s, width); ok {133+ return f134+ }135+ for prec := width; prec >= 0; prec-- {136+ if e := strconv.FormatFloat(v, 'E', prec, 64); len(e) <= width {137+ return e138+ }139+ }140+ return strings.Repeat("#", width)141+}142+143+// fewerDecimals rounds v, written in full as s, to the decimals that fit144+// in width, but never rounds a non-zero number down to zero.145+func fewerDecimals(v float64, s string, width int) (string, bool) {146+ dot := strings.IndexByte(s, '.')147+ if dot < 0 || dot > width {148+ return "", false149+ }150+ for prec := max(width-dot-1, 0); prec >= 0; prec-- {151+ f := strconv.FormatFloat(v, 'f', prec, 64)152+ if len(f) > width {153+ continue154+ }155+ if r, _ := strconv.ParseFloat(f, 64); r == 0 && v != 0 {156+ return "", false157+ }158+ if strings.Contains(f, ".") {159+ f = strings.TrimRight(strings.TrimRight(f, "0"), ".")160+ }161+ return f, true162+ }163+ return "", false164+}
M internal/sheet/args.go +8 -6
M internal/sheet/cellformat.go +25 -23
@@ -1,5 +1,7 @@
11 package sheet22 3+import "github.com/FelineStateMachine/012/internal/formula"4+35 // Formatting changes go through these methods rather than by editing46 // cells directly, so history (undo) can hook a single place.57
@@ -12,7 +14,7 @@ const materializeLimit = 1 << 16
1214 // or for Automatic, the one inferred from its formula (=DATE() shows a1315 // date, =SUM(B2:B4) of currency shows currency).1416 func (s *Sheet) DisplayFormat(a Addr) Format {15- c := s.cells[a]17+ c := s.cells.get(a)1618 switch {1719 case c == nil:1820 return Format{}
@@ -81,7 +83,7 @@ func (s *Sheet) eachCell(r Rect, create bool, fn func(Addr, *Cell)) {
8183 addrs = s.cellsIn(r)8284 }8385 for _, a := range addrs {84- old := s.cells[a]86+ old := s.cells.get(a)8587 c := old.clone()8688 if c == nil {8789 c = &Cell{}
@@ -102,15 +104,15 @@ func (s *Sheet) eachCell(r Rect, create bool, fn func(Addr, *Cell)) {
102104 // without its inputs changing (TODAY, NOW, RAND).103105 func isVolatile(n Node) bool {104106 switch n := n.(type) {105- case unaryNode:106- return isVolatile(n.x)107- case binaryNode:108- return isVolatile(n.l) || isVolatile(n.r)109- case callNode:110- if n.fn.Volatile {107+ case formula.Unary:108+ return isVolatile(n.X)109+ case formula.Binary:110+ return isVolatile(n.L) || isVolatile(n.R)111+ case formula.Call:112+ if funcOf(n).Volatile {111113 return true112114 }113- for _, a := range n.args {115+ for _, a := range n.Args {114116 if isVolatile(a) {115117 return true116118 }
@@ -125,19 +127,19 @@ func isVolatile(n Node) bool {
125127 // currency and a date plus days shows a date.126128 func inferFormat(n Node, at func(string, Addr) Format) Format {127129 switch n := n.(type) {128- case refNode:129- return at(n.sheet, n.a)130- case rangeNode:131- return at(n.sheet, n.r.From)132- case unaryNode:133- if n.op == "-" || n.op == "+" {134- return inferFormat(n.x, at)130+ case formula.Ref:131+ return at(n.Sheet, n.Addr)132+ case formula.Range:133+ return at(n.Sheet, n.Rect.From)134+ case formula.Unary:135+ if n.Op == "-" || n.Op == "+" {136+ return inferFormat(n.X, at)135137 }136- case binaryNode:137- l, r := inferFormat(n.l, at), inferFormat(n.r, at)138- switch n.op {138+ case formula.Binary:139+ l, r := inferFormat(n.L, at), inferFormat(n.R, at)140+ switch n.Op {139141 case "+", "-":140- if n.op == "-" && l.Kind.isTime() && r.Kind.isTime() {142+ if n.Op == "-" && l.Kind.isTime() && r.Kind.isTime() {141143 return Format{} // days between two dates142144 }143145 return firstFormat(l, r)
@@ -147,9 +149,9 @@ func inferFormat(n Node, at func(string, Addr) Format) Format {
147149 }148150 return firstFormat(l, r)149151 }150- case callNode:151- if n.fn.format != nil {152- return n.fn.format(n.args, func(n Node) Format { return inferFormat(n, at) })152+ case formula.Call:153+ if funcOf(n).format != nil {154+ return funcOf(n).format(n.Args, func(n Node) Format { return inferFormat(n, at) })153155 }154156 }155157 return Format{}
M internal/sheet/chart.go +19 -9
@@ -13,7 +13,9 @@ import (
1313 // follow inserted and deleted rows and columns, and every change to them1414 // is an undo step.1515 16-// ChartType is how a chart draws its series.16+// ChartType is how a chart draws its series. Adding one takes a constant17+// here and its name in chartTypeNames; the chart editor, the file format18+// and ChartTypes follow from the table, and internal/chart draws it.1719 type ChartType int1820 1921 const (
@@ -23,10 +25,18 @@ const (
2325 ChartPie // the first series as slices of a whole2426 )2527 26-// ChartTypes lists the types in the order the chart editor offers them.27-var ChartTypes = []ChartType{ChartColumn, ChartBar, ChartLine, ChartPie}28+// chartTypeNames names every type, as files store it, in the order the29+// chart editor offers them.30+var chartTypeNames = [...]string{ChartColumn: "column", ChartBar: "bar", ChartLine: "line", ChartPie: "pie"}2831 29-var chartTypeNames = [...]string{"column", "bar", "line", "pie"}32+// ChartTypes lists the types in the order the chart editor offers them.33+var ChartTypes = func() []ChartType {34+ out := make([]ChartType, len(chartTypeNames))35+ for i := range out {36+ out[i] = ChartType(i)37+ }38+ return out39+}()3040 3141 func (t ChartType) String() string {3242 if t < 0 || int(t) >= len(chartTypeNames) {
@@ -158,7 +168,7 @@ func (s *Sheet) displayText(a Addr) string {
158168 func (s *Sheet) clipToUsed(r Rect) Rect {159169 used, ok := s.UsedRange()160170 if !ok {161- return Rect{r.From, r.From}171+ return Rect{From: r.From, To: r.From}162172 }163173 r.To.Col = max(min(r.To.Col, used.To.Col), r.From.Col)164174 r.To.Row = max(min(r.To.Row, used.To.Row), r.From.Row)
@@ -171,7 +181,7 @@ func (s *Sheet) clipToUsed(r Rect) Rect {
171181 // nothing more touches it. A blank cell with no filled neighbors is a172182 // region of its own.173183 func (s *Sheet) Region(a Addr) Rect {174- filled := func(a Addr) bool { return a.Valid() && !s.cells[a].Blank() }184+ filled := func(a Addr) bool { return a.Valid() && !s.cells.get(a).Blank() }175185 seen := map[Addr]bool{}176186 var queue []Addr177187 visit := func(p Addr) {
@@ -189,14 +199,14 @@ func (s *Sheet) Region(a Addr) Rect {
189199 }190200 around(a)191201 if len(queue) == 0 {192- return Rect{a, a}202+ return Rect{From: a, To: a}193203 }194- r := Rect{queue[0], queue[0]}204+ r := Rect{From: queue[0], To: queue[0]}195205 for len(queue) > 0 {196206 for len(queue) > 0 {197207 p := queue[0]198208 queue = queue[1:]199- r = union(r, Rect{p, p})209+ r = union(r, Rect{From: p, To: p})200210 around(p)201211 }202212 // Cells touching the bounding box join too.
M internal/sheet/date.go +53 -129
@@ -1,99 +1,19 @@
11 package sheet22 33 import (4- "math"54 "strconv"65 "strings"76 "time"8-)97 10-// Dates are serial numbers as in Sheets: whole days since 1899-12-3011-// (day 0), with the time of day as the fraction. Unlike Excel there is no12-// fictitious 1900-02-29, and dates before 1900 are negative.8+ "github.com/FelineStateMachine/012/internal/numfmt"9+)1310 14-// Now is the clock used by TODAY() and NOW(); tests replace it.11+// Now is the clock used by TODAY() and NOW(), and for the year of dates12+// typed without one; tests replace it.1513 var Now = time.Now1614 17-// epochDays is 1899-12-30 as days since 0000-03-01 (see daysFromCivil).18-var epochDays = daysFromCivil(1899, 12, 30)19-20-// daysFromCivil counts days since 0000-03-01 in the proleptic Gregorian21-// calendar (Howard Hinnant's algorithm). Months outside 1-12 normalize.22-func daysFromCivil(y, m, d int) int {23- y += floorDivInt(m-1, 12)24- m = (m-1)%12 + 125- if m <= 0 {26- m += 1227- }28- if m <= 2 {29- y--30- }31- era := floorDivInt(y, 400)32- yoe := y - era*40033- mp := (m + 9) % 1234- doy := (153*mp+2)/5 + d - 135- doe := yoe*365 + yoe/4 - yoe/100 + doy36- return era*146097 + doe37-}38-39-// civil converts a serial day number to year, month and day.40-func civil(serial int64) (y, m, d int) {41- z := int(serial) + epochDays42- era := floorDivInt(z, 146097)43- doe := z - era*14609744- yoe := (doe - doe/1460 + doe/36524 - doe/146096) / 36545- y = yoe + era*40046- doy := doe - (365*yoe + yoe/4 - yoe/100)47- mp := (5*doy + 2) / 15348- d = doy - (153*mp+2)/5 + 149- m = (mp+2)%12 + 150- if m <= 2 {51- y++52- }53- return y, m, d54-}55-56-// dateSerial returns the serial of a date; month and day overflow into57-// the next month or year, as DATE() allows.58-func dateSerial(y, m, d int) float64 {59- return float64(daysFromCivil(y, m, 1) + d - 1 - epochDays)60-}61-62-// timeSerial is the fraction of a day for a time of day.63-func timeSerial(h, m int, s float64) float64 {64- return (float64(h)*3600 + float64(m)*60 + s) / 8640065-}66-67-// weekday returns 0 for Sunday through 6 for Saturday. Day 0 was a68-// Saturday.69-func weekday(serial int64) int {70- return int(((serial+6)%7 + 7) % 7)71-}72-73-// serialOf converts a time to a serial in its own time zone.74-func serialOf(t time.Time) float64 {75- h, m, s := t.Clock()76- return dateSerial(t.Year(), int(t.Month()), t.Day()) +77- timeSerial(h, m, float64(s)+float64(t.Nanosecond())/1e9)78-}79-80-func floorDiv(a, b int64) int64 {81- q := a / b82- if (a%b != 0) && ((a < 0) != (b < 0)) {83- q--84- }85- return q86-}87-88-func floorDivInt(a, b int) int { return int(floorDiv(int64(a), int64(b))) }89-90-// splitSerial returns the date and time parts of a serial, rounded to91-// the second.92-func splitSerial(v float64) (days int64, secs int) {93- total := int64(math.Round(v * 86400))94- days = floorDiv(total, 86400)95- return days, int(total - days*86400)96-}15+// Typed dates and times become serial day numbers, as in Sheets; see16+// numfmt's calendar.9717 9818 // parseDateTime recognizes dates and times typed into a cell the way9919 // Sheets does in the en-US locale: 9/26/2026, 9/26/26, 9/26 (this year),
@@ -136,44 +56,14 @@ func parseDateTime(s string) (float64, Format, bool) {
13656 // parseTime recognizes H:MM, H:MM:SS(.fff) and H AM/PM forms. Hours of13757 // 24 or more make a duration.13858 func parseTime(s string) (float64, Format, bool) {139- up := strings.ToUpper(strings.TrimSpace(s))140- meridiem := ""141- for _, m := range []string{"AM", "PM"} {142- if strings.HasSuffix(up, m) {143- meridiem, up = m[:1], strings.TrimSpace(strings.TrimSuffix(up, m))144- break145- }146- }147- parts := strings.Split(up, ":")59+ clock, meridiem := cutMeridiem(strings.ToUpper(strings.TrimSpace(s)))60+ parts := strings.Split(clock, ":")14861 if len(parts) > 3 || (len(parts) == 1 && meridiem == "") {14962 return 0, Format{}, false15063 }151- var h, m int152- var sec float64153- for i, p := range parts {154- if p == "" || !isDigit(p[0]) {155- return 0, Format{}, false156- }157- if i == 2 {158- v, err := strconv.ParseFloat(p, 64)159- if err != nil || v >= 60 || strings.ContainsAny(p, "eE+-") {160- return 0, Format{}, false161- }162- sec = v163- continue164- }165- if len(p) > 2 && i > 0 || strings.Trim(p, "0123456789") != "" {166- return 0, Format{}, false167- }168- n, _ := strconv.Atoi(p)169- if i == 0 {170- h = n171- } else {172- if n >= 60 || len(p) != 2 {173- return 0, Format{}, false174- }175- m = n176- }64+ h, m, sec, ok := clockParts(parts)65+ if !ok {66+ return 0, Format{}, false17767 }17868 f := Format{Kind: FmtTime, Pattern: "h:mm:ss"}17969 switch {
@@ -189,7 +79,45 @@ func parseTime(s string) (float64, Format, bool) {
18979 case h >= 24:19080 f = Preset(FmtDuration)19181 }192- return timeSerial(h, m, sec), f, true82+ return numfmt.TimeSerial(h, m, sec), f, true83+}84+85+// cutMeridiem cuts a trailing AM or PM off an upper-case time, returning86+// the rest and "A", "P" or "".87+func cutMeridiem(up string) (string, string) {88+ for _, m := range [...]string{"AM", "PM"} {89+ if rest, ok := strings.CutSuffix(up, m); ok {90+ return strings.TrimSpace(rest), m[:1]91+ }92+ }93+ return up, ""94+}95+96+// clockParts reads the parts of H:MM:SS: hours of any number of digits,97+// then minutes of two digits and seconds of two, which may have decimals.98+func clockParts(parts []string) (h, m int, sec float64, ok bool) {99+ for i, p := range parts {100+ if p == "" || !isDigit(p[0]) {101+ return 0, 0, 0, false102+ }103+ whole := strings.Trim(p, "0123456789") == ""104+ switch i {105+ case 0:106+ h, _ = strconv.Atoi(p)107+ ok = whole108+ case 1:109+ m, _ = strconv.Atoi(p)110+ ok = whole && len(p) == 2 && m < 60111+ default:112+ v, err := strconv.ParseFloat(p, 64)113+ sec = v114+ ok = err == nil && v < 60 && !strings.ContainsAny(p, "eE+-")115+ }116+ if !ok {117+ return 0, 0, 0, false118+ }119+ }120+ return h, m, sec, true193121 }194122 195123 // parseDate recognizes the date forms listed at parseDateTime.
@@ -243,7 +171,7 @@ func parseDate(s string) (float64, Format, bool) {
243171 // monthName matches a month's full name or three-letter abbreviation.244172 func monthName(w string) (month int, long, ok bool) {245173 w = strings.ToLower(strings.TrimSuffix(w, "."))246- for i, n := range monthNames {174+ for i, n := range numfmt.MonthNames {247175 n = strings.ToLower(n)248176 if w == n {249177 return i + 1, len(w) > 3, true
@@ -271,12 +199,8 @@ func ymd(ys, ms, ds string, f Format) (float64, Format, bool) {
271199 y += 1900272200 }273201 }274- if m < 1 || m > 12 || d < 1 || d > daysIn(y, m) {202+ if m < 1 || m > 12 || d < 1 || d > numfmt.DaysIn(y, m) {275203 return 0, Format{}, false276204 }277- return dateSerial(y, m, d), f, true278-}279-280-func daysIn(y, m int) int {281- return int(dateSerial(y, m+1, 1) - dateSerial(y, m, 1))205+ return numfmt.DateSerial(y, m, d), f, true282206 }
M internal/sheet/decimal.go +28 -19
@@ -3,6 +3,8 @@ package sheet
33 import (44 "sync"55 6+ "github.com/FelineStateMachine/012/internal/formula"7+ "github.com/FelineStateMachine/012/internal/numfmt"68 "github.com/cockroachdb/apd/v3"79 )810
@@ -83,11 +85,11 @@ func decArith(op string, a, b float64) (Value, bool) {
8385 8486 // decRounding maps ROUND's modes to apd's: half away from zero, away8587 // from zero, toward zero.86-var decRounding = [...]apd.Rounder{roundHalfUp: apd.RoundHalfUp, roundUp: apd.RoundUp, roundDown: apd.RoundDown}88+var decRounding = [...]apd.Rounder{numfmt.HalfUp: apd.RoundHalfUp, numfmt.Up: apd.RoundUp, numfmt.Down: apd.RoundDown}8789 8890 // decRound rounds x to places decimal places (negative places round to8991 // tens, hundreds...) in decimal.90-func decRound(x float64, places int, mode roundMode) (Value, bool) {92+func decRound(x float64, places int, mode numfmt.Rounding) (Value, bool) {9193 var d, z apd.Decimal9294 if _, err := d.SetFloat64(x); err != nil {9395 return Value{}, false
@@ -158,13 +160,13 @@ var decEvals = map[string]func(args []Node, get lookup) (Value, bool){
158160 }159161 return fromDec(&q)160162 },161- "ROUND": decRounder(roundHalfUp),162- "ROUNDUP": decRounder(roundUp),163- "ROUNDDOWN": decRounder(roundDown),164- "TRUNC": decRounder(roundDown),163+ "ROUND": decRounder(numfmt.HalfUp),164+ "ROUNDUP": decRounder(numfmt.Up),165+ "ROUNDDOWN": decRounder(numfmt.Down),166+ "TRUNC": decRounder(numfmt.Down),165167 }166168 167-func decRounder(mode roundMode) func([]Node, lookup) (Value, bool) {169+func decRounder(mode numfmt.Rounding) func([]Node, lookup) (Value, bool) {168170 return func(args []Node, get lookup) (Value, bool) {169171 x, err := numArg(args[0], get)170172 if err != nil {
@@ -198,24 +200,31 @@ var decFuncs = sync.OnceValue(func() map[string]*FuncDef {
198200 return out199201 })200202 203+// Operators computed in decimal are marked by these types, which eval204+// knows; parsed formulas never contain them.205+type (206+ decUnary formula.Unary207+ decBinary formula.Binary208+)209+201210 // decimalize returns a copy of n that evaluates in decimal: operators are202211 // marked, and functions with a decimal twin call it.203212 func decimalize(n Node) Node {204213 switch n := n.(type) {205- case unaryNode:206- n.x, n.dec = decimalize(n.x), true207- return n208- case binaryNode:209- n.l, n.r, n.dec = decimalize(n.l), decimalize(n.r), true210- return n211- case callNode:212- args := make([]Node, len(n.args))213- for i, a := range n.args {214+ case formula.Unary:215+ n.X = decimalize(n.X)216+ return decUnary(n)217+ case formula.Binary:218+ n.L, n.R = decimalize(n.L), decimalize(n.R)219+ return decBinary(n)220+ case formula.Call:221+ args := make([]Node, len(n.Args))222+ for i, a := range n.Args {214223 args[i] = decimalize(a)215224 }216- n.args = args217- if twin, ok := decFuncs()[n.fn.Name]; ok {218- n.fn = twin225+ n.Args = args226+ if twin, ok := decFuncs()[funcOf(n).Name]; ok {227+ n.Fn = twin219228 }220229 return n221230 }
M internal/sheet/eval.go +76 -59
@@ -3,6 +3,9 @@ package sheet
33 import (44 "math"55 "strings"6+7+ "github.com/FelineStateMachine/012/internal/formula"8+ "github.com/FelineStateMachine/012/internal/numfmt"69 )710 811 // Kind is the type of a computed cell value.
@@ -27,7 +30,7 @@ type Value struct {
2730 func (v Value) String() string {2831 switch v.Kind {2932 case Number:30- return numString(v.Num)33+ return numfmt.General(v.Num)3134 case Bool:3235 if v.Num != 0 {3336 return "TRUE"
@@ -85,87 +88,101 @@ func toNum(v Value) (float64, *Value) {
8588 return 0, errOf(v)8689 }8790 88-// lookup resolves the current value of a referenced cell: on the89-// formula's own sheet when sheet is "", otherwise on the sheet with that90-// name.91-type lookup func(sheet string, a Addr) Value91+// lookup is how a formula reads other cells (see reader): one at a time,92+// or a whole range at once, so reading a range needn't cost a lookup per93+// cell and can later be served from column blocks or cached aggregates.94+// It is a concrete type rather than an interface so the callbacks given95+// to cells stay on the stack.96+type lookup = *reader9297 9398 func eval(n Node, get lookup) Value {9499 switch n := n.(type) {95- case numLit:96- return num(n.v)97- case strLit:98- return Value{Kind: Text, Str: n.v}99- case boolLit:100- return boolean(n.v)101- case refNode:102- return get(n.sheet, n.a)103- case nameNode:100+ case formula.Num:101+ return num(n.V)102+ case formula.Str:103+ return Value{Kind: Text, Str: n.V}104+ case formula.Bool:105+ return boolean(n.V)106+ case formula.Ref:107+ return get.cell(n.Sheet, n.Addr)108+ case formula.Name:104109 return ErrName105- case refErrNode:110+ case formula.RefErr:106111 return ErrRef107- case emptyArg:112+ case formula.Empty:108113 return Value{}109- case rangeNode:114+ case formula.Range:110115 // A range outside a function: Sheets uses the top-left cell here111116 // for single-cell ranges and #VALUE! otherwise.112- if n.r.From == n.r.To {113- return get(n.sheet, n.r.From)117+ if n.Rect.From == n.Rect.To {118+ return get.cell(n.Sheet, n.Rect.From)114119 }115120 return ErrValue116- case unaryNode:117- x := eval(n.x, get)118- if x.Kind == Error {119- return x121+ case formula.Unary:122+ return evalUnary(n, get, false)123+ case decUnary:124+ return evalUnary(formula.Unary(n), get, true)125+ case formula.Binary:126+ return evalBinary(n, get, false)127+ case decBinary:128+ return evalBinary(formula.Binary(n), get, true)129+ case formula.Call:130+ return funcOf(n).call(n.Args, get)131+ }132+ return ErrValue133+}134+135+// evalUnary computes a prefix operator or the postfix %; with dec, % is136+// decimal (decimal.go).137+func evalUnary(n formula.Unary, get lookup, dec bool) Value {138+ x := eval(n.X, get)139+ if x.Kind == Error {140+ return x141+ }142+ switch n.Op {143+ case "-":144+ f, err := toNum(x)145+ if err != nil {146+ return *err120147 }121- switch n.op {122- case "-":123- f, err := toNum(x)124- if err != nil {125- return *err126- }127- return num(-f)128- case "#NOT#":129- f, err := toNum(x)130- if err != nil {131- return *err132- }133- return boolean(f == 0)134- case "%":135- f, err := toNum(x)136- if err != nil {137- return *err138- }139- if n.dec {140- if v, ok := decArith("/", f, 100); ok {141- return v142- }148+ return num(-f)149+ case "#NOT#":150+ f, err := toNum(x)151+ if err != nil {152+ return *err153+ }154+ return boolean(f == 0)155+ case "%":156+ f, err := toNum(x)157+ if err != nil {158+ return *err159+ }160+ if dec {161+ if v, ok := decArith("/", f, 100); ok {162+ return v143163 }144- return num(f / 100)145164 }146- return x // unary + is identity147- case binaryNode:148- return evalBinary(n, get)149- case callNode:150- return n.fn.call(n.args, get)165+ return num(f / 100)151166 }152- return ErrValue167+ return x // unary + is identity153168 }154169 155-func evalBinary(n binaryNode, get lookup) Value {156- l, r := eval(n.l, get), eval(n.r, get)170+// evalBinary computes a binary operator; with dec, arithmetic is decimal171+// (decimal.go).172+func evalBinary(n formula.Binary, get lookup, dec bool) Value {173+ l, r := eval(n.L, get), eval(n.R, get)157174 if l.Kind == Error {158175 return l159176 }160177 if r.Kind == Error {161178 return r162179 }163- switch n.op {180+ switch n.Op {164181 case "&":165182 return Value{Kind: Text, Str: text(l) + text(r)}166183 case "=", "<>", "<", ">", "<=", ">=":167184 c := compare(l, r)168- switch n.op {185+ switch n.Op {169186 case "=":170187 return boolean(c == 0)171188 case "<>":
@@ -187,12 +204,12 @@ func evalBinary(n binaryNode, get lookup) Value {
187204 if err != nil {188205 return *err189206 }190- if n.dec {191- if v, ok := decArith(n.op, a, b); ok {207+ if dec {208+ if v, ok := decArith(n.Op, a, b); ok {192209 return v193210 }194211 }195- switch n.op {212+ switch n.Op {196213 case "+":197214 return num(a + b)198215 case "-":
M internal/sheet/explain.go +91 -56
@@ -3,6 +3,8 @@ package sheet
33 import (44 "slices"55 "strings"6+7+ "github.com/FelineStateMachine/012/internal/formula"68 )79 810 // ExplainError says why the cell at a shows an error, for the context
@@ -19,7 +21,7 @@ func (l loc) label(from *Sheet) string {
1921 if l.s == from {2022 return l.a.String()2123 }22- return quoteSheet(l.s.name) + "!" + l.a.String()24+ return formula.QuoteSheet(l.s.name) + "!" + l.a.String()2325 }2426 2527 // maxExplainExpr caps how much of a formula an explanation quotes.
@@ -27,7 +29,7 @@ const maxExplainExpr = 40
2729 2830 func (s *Sheet) explain(a Addr, path []loc) string {2931 here := loc{s, a}30- c := s.cells[a]32+ c := s.cells.get(a)3133 v := s.Value(a)3234 if c == nil || v.Kind != Error || !c.IsFormula() {3335 return ""
@@ -57,11 +59,11 @@ func (s *Sheet) explain(a Addr, path []loc) string {
5759 }5860 return "From " + name + ": " + strings.ToLower(why[:1]) + why[1:]5961 }60- if r, ok := n.(refNode); ok && s.wb.resolve(s, r.sheet) == nil {61- return "Unresolved sheet name " + quoteSheet(r.sheet)62+ if r, ok := n.(formula.Ref); ok && s.wb.resolve(s, r.Sheet) == nil {63+ return "Unresolved sheet name " + formula.QuoteSheet(r.Sheet)6264 }63- if r, ok := n.(rangeNode); ok && s.wb.resolve(s, r.sheet) == nil {64- return "Unresolved sheet name " + quoteSheet(r.sheet)65+ if r, ok := n.(formula.Range); ok && s.wb.resolve(s, r.Sheet) == nil {66+ return "Unresolved sheet name " + formula.QuoteSheet(r.Sheet)6567 }6668 return describeError(v, n)6769 }
@@ -70,68 +72,101 @@ func (s *Sheet) explain(a Addr, path []loc) string {
7072 // itself rather than passing it on. When the error comes from a7173 // referenced cell, it returns that cell.7274 func (s *Sheet) errorOrigin(n Node, want Value) (Node, *loc) {73- same := func(v Value) bool { return v.Kind == Error && v.Str == want.Str }74- get := s.wb.values(s)75- at := func(sheet string, a Addr) *loc {76- if t := s.wb.resolve(s, sheet); t != nil {77- return &loc{t, a}78- }79- return nil // an unresolved sheet name is the error itself75+ return errorSearch{s: s, want: want, get: s.wb.values(s)}.find(n)76+}77+78+// errorSearch follows an error through a formula on s to where it starts.79+type errorSearch struct {80+ s *Sheet81+ want Value82+ get lookup83+}84+85+func (e errorSearch) same(v Value) bool { return v.Kind == Error && v.Str == e.want.Str }86+87+// at is the cell a reference points at, or nil when its sheet name is88+// unresolved: then the reference is the error itself.89+func (e errorSearch) at(sheet string, a Addr) *loc {90+ if t := e.s.wb.resolve(e.s, sheet); t != nil {91+ return &loc{t, a}8092 }93+ return nil94+}95+96+func (e errorSearch) find(n Node) (Node, *loc) {8197 switch n := n.(type) {82- case refNode:83- if same(get(n.sheet, n.a)) {84- return n, at(n.sheet, n.a)98+ case formula.Ref:99+ if e.same(e.get.cell(n.Sheet, n.Addr)) {100+ return n, e.at(n.Sheet, n.Addr)85101 }86- case rangeNode:87- if n.r.From == n.r.To && same(get(n.sheet, n.r.From)) {88- return n, at(n.sheet, n.r.From)102+ case formula.Range:103+ if n.Rect.From == n.Rect.To && e.same(e.get.cell(n.Sheet, n.Rect.From)) {104+ return n, e.at(n.Sheet, n.Rect.From)89105 }90- case unaryNode:91- if same(eval(n.x, get)) {92- return s.errorOrigin(n.x, want)106+ case formula.Unary:107+ return e.operand(n, n.X)108+ case formula.Binary:109+ return e.operand(n, n.L, n.R)110+ case formula.Call:111+ return e.inCall(n)112+ }113+ return n, nil114+}115+116+// operand follows the first of n's operands that has the error, or stops117+// at n when none has.118+func (e errorSearch) operand(n Node, operands ...Node) (Node, *loc) {119+ for _, x := range operands {120+ if e.same(eval(x, e.get)) {121+ return e.find(x)93122 }94- case binaryNode:95- for _, x := range []Node{n.l, n.r} {96- if same(eval(x, get)) {97- return s.errorOrigin(x, want)123+ }124+ return n, nil125+}126+127+// inCall follows the first argument of a call that has the error: for a128+// range, its first cell with it.129+func (e errorSearch) inCall(n formula.Call) (Node, *loc) {130+ if funcOf(n).remote != nil {131+ return n, nil // JEV explains its own answers132+ }133+ for _, arg := range n.Args {134+ if r, ok := arg.(formula.Range); ok && r.Rect.From != r.Rect.To {135+ if l, found := e.inRange(r); found {136+ return arg, l98137 }138+ continue99139 }100- case callNode:101- if n.fn.remote != nil {102- return n, nil // JEV explains its own answers103- }104- for _, arg := range n.args {105- if r, ok := arg.(rangeNode); ok && r.r.From != r.r.To {106- t := s.wb.resolve(s, r.sheet)107- if t == nil {108- return arg, nil109- }110- cells := t.cellsIn(r.r)111- sortAddrs(cells)112- for _, a := range cells {113- if same(t.Value(a)) {114- return arg, &loc{t, a}115- }116- }117- continue118- }119- if same(eval(arg, get)) {120- return s.errorOrigin(arg, want)121- }140+ if e.same(eval(arg, e.get)) {141+ return e.find(arg)122142 }123143 }124144 return n, nil125145 }126146 147+// inRange finds the first cell of r, row by row, with the error. An148+// unresolved sheet name is found, as the error itself, with no cell.149+func (e errorSearch) inRange(r formula.Range) (*loc, bool) {150+ t := e.s.wb.resolve(e.s, r.Sheet)151+ if t == nil {152+ return nil, true153+ }154+ cells := t.cellsIn(r.Rect)155+ sortAddrs(cells)156+ for _, a := range cells {157+ if e.same(t.Value(a)) {158+ return &loc{t, a}, true159+ }160+ }161+ return nil, false162+}163+127164 // lookupFuncs report #N/A when they find no match.128165 var lookupFuncs = []string{"VLOOKUP", "HLOOKUP", "XLOOKUP", "LOOKUP", "MATCH", "XMATCH"}129166 130167 // describeError explains an error code produced by n.131168 func describeError(v Value, n Node) string {132- var b strings.Builder133- printNode(&b, n)134- expr := b.String()169+ expr := formula.Expr(n)135170 if r := []rune(expr); len(r) > maxExplainExpr {136171 expr = string(r[:maxExplainExpr-1]) + "…"137172 }
@@ -139,24 +174,24 @@ func describeError(v Value, n Node) string {
139174 case ErrDiv0.Str:140175 return "Division by zero in " + expr141176 case ErrValue.Str:142- if _, ok := n.(rangeNode); ok {177+ if _, ok := n.(formula.Range); ok {143178 return "A range where one value is expected: " + expr144179 }145180 return "Wrong type of value in " + expr146181 case ErrName.Str:147- if nm, ok := n.(nameNode); ok {148- return "Unknown name " + nm.name182+ if nm, ok := n.(formula.Name); ok {183+ return "Unknown name " + nm.Name149184 }150185 return "Unknown name in " + expr151186 case ErrNA.Str:152- if call, ok := n.(callNode); ok && slices.Contains(lookupFuncs, call.fn.Name) {187+ if call, ok := n.(formula.Call); ok && slices.Contains(lookupFuncs, funcOf(call).Name) {153188 return "No match found by " + expr154189 }155190 return "Value not available in " + expr156191 case ErrNum.Str:157192 return "Number out of range in " + expr158193 case ErrRef.Str:159- if _, ok := n.(refErrNode); ok {194+ if _, ok := n.(formula.RefErr); ok {160195 return "Reference to deleted cells"161196 }162197 return "Invalid reference in " + expr
M internal/sheet/file.go +3 -3
M internal/sheet/filter.go +1 -1
M internal/sheet/filter_test.go +6 -6
@@ -59,7 +59,7 @@ func TestFilterConditions(t *testing.T) {
5959 for _, tt := range tests {6060 t.Run(tt.name, func(t *testing.T) {6161 s := fruit(t)62- s.CreateFilter(Rect{at("A1"), at("B6")})62+ s.CreateFilter(Rect{From: at("A1"), To: at("B6")})6363 s.FilterColumn(tt.col, tt.cr)6464 if got := shownRows(s); !reflect.DeepEqual(got, tt.want) {6565 t.Errorf("shown rows %v, want %v", got, tt.want)
@@ -74,7 +74,7 @@ func TestFilterConditions(t *testing.T) {
7474 7575 func TestFilterFollowsValuesAndUndo(t *testing.T) {7676 s := fruit(t)77- s.CreateFilter(Rect{at("A1"), at("B6")})77+ s.CreateFilter(Rect{From: at("A1"), To: at("B6")})7878 s.FilterColumn(1, Criteria{Cond: Condition{CondGreater, "5"}})7979 if !s.RowHidden(2) || s.HiddenRows() != 3 {8080 t.Fatalf("row 3 shown, %d hidden", s.HiddenRows())
@@ -109,7 +109,7 @@ func TestFilterFollowsValuesAndUndo(t *testing.T) {
109109 110110 func TestFilterValues(t *testing.T) {111111 s := fruit(t)112- s.CreateFilter(Rect{at("A1"), at("B6")})112+ s.CreateFilter(Rect{From: at("A1"), To: at("B6")})113113 s.FilterColumn(1, Criteria{Hidden: []string{"3", "gone"}})114114 want := []FilterValue{{"3", 2, false}, {"10", 1, true}, {"$25", 1, true}, {"gone", 0, false}, {"", 1, true}}115115 if got := s.FilterValues(1); !reflect.DeepEqual(got, want) {
@@ -125,7 +125,7 @@ func TestFilterValues(t *testing.T) {
125125 126126 func TestFilterFollowsInsertAndDelete(t *testing.T) {127127 s := fruit(t)128- s.CreateFilter(Rect{at("A1"), at("B6")})128+ s.CreateFilter(Rect{From: at("A1"), To: at("B6")})129129 s.FilterColumn(1, Criteria{Hidden: []string{"3"}})130130 s.InsertRows(2, 2) // inside the range: it grows131131 if f := s.Filter(); f.Range.String() != "A1:B8" {
@@ -148,7 +148,7 @@ func TestFilterFollowsInsertAndDelete(t *testing.T) {
148148 149149 func TestEdgeSkipsHiddenRows(t *testing.T) {150150 s := fruit(t)151- s.CreateFilter(Rect{at("A1"), at("B6")})151+ s.CreateFilter(Rect{From: at("A1"), To: at("B6")})152152 s.FilterColumn(1, Criteria{Cond: Condition{CondEqual, "3"}}) // rows 1, 3 and 6 show153153 tests := []struct {154154 from string
@@ -200,7 +200,7 @@ func TestFreeze(t *testing.T) {
200200 func TestViewFileRoundTrip(t *testing.T) {201201 s := fruit(t)202202 s.SetFrozen(1, 2)203- s.CreateFilter(Rect{at("A1"), at("B6")})203+ s.CreateFilter(Rect{From: at("A1"), To: at("B6")})204204 s.FilterColumn(1, Criteria{Hidden: []string{"3", ""}, Cond: Condition{CondLess, "$20"}})205205 var buf bytes.Buffer206206 if err := s.Write(&buf); err != nil {
M internal/sheet/find.go +2 -2
M internal/sheet/format.go +3 -55
@@ -3,6 +3,8 @@ package sheet
33 import (44 "strconv"55 "strings"6+7+ "github.com/FelineStateMachine/012/internal/numfmt"68 )79 810 // FormatKind is a number format from Sheets' Format > Number menu.
@@ -150,7 +152,7 @@ func (f Format) WithDecimals(delta int, v float64) Format {
150152 f.Pattern += "." + strings.Repeat("0", d)151153 }152154 case f.Kind == FmtCustom:153- f.Pattern = adjustPatternDecimals(f.Pattern, delta)155+ f.Pattern = numfmt.AdjustDecimals(f.Pattern, delta)154156 }155157 return f156158 }
@@ -165,60 +167,6 @@ func visibleDecimals(v float64) int {
165167 return min(len(frac), 10)166168 }167169 168-// adjustPatternDecimals adds or removes a 0 after the decimal point of169-// every section of a number pattern.170-func adjustPatternDecimals(pat string, delta int) string {171- secs := splitSections(pat)172- for i, sec := range secs {173- if isDatePattern(lexPattern(sec)) {174- return pat175- }176- secs[i] = adjustSection(sec, delta)177- }178- return strings.Join(secs, ";")179-}180-181-func adjustSection(sec string, delta int) string {182- // Find the last digit placeholder outside quotes; decimals follow a '.'.183- last, dot := -1, -1184- inQuote := false185- for i := 0; i < len(sec); i++ {186- switch c := sec[i]; {187- case c == '"':188- inQuote = !inQuote189- case inQuote:190- case c == '\\' || c == '_' || c == '*':191- i++192- case c == '.' && dot < 0:193- dot = i194- case c == '0' || c == '#' || c == '?':195- last = i196- case c == 'E' || c == 'e':197- if last >= 0 {198- // Stop at an exponent: its digits aren't decimals.199- i = len(sec)200- }201- }202- }203- if last < 0 {204- return sec205- }206- switch {207- case delta > 0 && dot < 0:208- return sec[:last+1] + "." + strings.Repeat("0", delta) + sec[last+1:]209- case delta > 0:210- return sec[:last+1] + strings.Repeat("0", delta) + sec[last+1:]211- case dot < 0 || last < dot:212- return sec213- }214- decimals := last - dot215- remove := min(-delta, decimals)216- if remove == decimals { // drop the point too217- return sec[:dot] + sec[last+1:]218- }219- return sec[:last+1-remove] + sec[last+1:]220-}221-222170 func clampInt(v, lo, hi int) int { return max(lo, min(v, hi)) }223171 224172 // Align is a cell's horizontal alignment.
M internal/sheet/format_test.go +0 -72
@@ -15,67 +15,6 @@ func fixedNow(t *testing.T) {
1515 t.Cleanup(func() { Now = old })1616 }1717 18-func TestFormatPattern(t *testing.T) {19- tests := []struct {20- v float6421- pat string22- want string23- }{24- {1234.5, "#,##0.00", "1,234.50"},25- {-1234.5, "#,##0.00", "-1,234.50"},26- {0.5, "#,##0", "1"},27- {0, "#,##0.00", "0.00"},28- {1234567.891, "#,##0", "1,234,568"},29- {1.005, "0.00", "1.01"}, // 15 significant digits, not binary30- {2.5, "0", "3"},31- {-2.5, "0", "-3"},32- {-0.001, "0.00", "0.00"}, // no negative zero33- {0.1234, "0%", "12%"},34- {0.1234, "0.00%", "12.34%"},35- {1234.5, "0.00E+00", "1.23E+03"},36- {0.000123, "0.00E+00", "1.23E-04"},37- {9.999, "0.00E+00", "1.00E+01"},38- {0, "0.00E+00", "0.00E+00"},39- {1234.5, `"$"#,##0.00`, "$1,234.50"},40- {-1234.5, `"$"#,##0.00`, "-$1,234.50"},41- {-1234.5, "#,##0.00;(#,##0.00)", "(1,234.50)"},42- {0, "#,##0;(#,##0);\"zero\"", "zero"},43- {1.5, "0.0#", "1.5"},44- {1.25, "0.0#", "1.25"},45- {1.5, "0.??", "1.5 "},46- {5, "000", "005"},47- {12345, "#,##0,", "12"},48- {1234.5, "#,##0.00 \"kg\"", "1,234.50 kg"},49- {0.5, "#.##", ".5"},50- {12.5, "General", "12.5"},51- {5551234, "000-0000", "555-1234"},52- // Dates and times: 46291 is 2026-09-26, a Saturday.53- {46291, "m/d/yyyy", "9/26/2026"},54- {46291, "yyyy-mm-dd", "2026-09-26"},55- {46291, "mmm d, yyyy", "Sep 26, 2026"},56- {46291, "dddd, mmmm d", "Saturday, September 26"},57- {46291, "ddd mmmmm yy", "Sat S 26"},58- {46291.6041666667, "h:mm am/pm", "2:30 PM"},59- {46291.6041666667, "hh:mm:ss", "14:30:00"},60- {0.5, "h:mm:ss AM/PM", "12:00:00 PM"},61- {0, "h:mm a/p", "12:00 A"},62- {1.0423611111, "[h]:mm:ss", "25:01:00"},63- {-0.5, "[h]:mm", "-12:00"},64- {0.000011574, "h:mm:ss.00", "0:00:01.00"},65- {0.0000173611, "mm:ss.0", "00:01.5"},66- {46291.99999999, "m/d/yyyy h:mm", "9/27/2026 0:00"},67- {0, "m/d/yyyy", "12/30/1899"},68- {-1, "yyyy-mm-dd", "1899-12-29"},69- {61, "yyyy-mm-dd", "1900-03-01"},70- {2958465, "yyyy-mm-dd", "9999-12-31"},71- }72- for _, tt := range tests {73- if got := FormatPattern(tt.v, tt.pat); got != tt.want {74- t.Errorf("FormatPattern(%v, %q) = %q, want %q", tt.v, tt.pat, got, tt.want)75- }76- }77-}78-7918 func TestDisplay(t *testing.T) {8019 tests := []struct {8120 v Value
@@ -273,17 +212,6 @@ func TestAdjustDecimals(t *testing.T) {
273212 t.Errorf("%q %+v %+d = %q, want %q", tt.in, tt.f, tt.delta, got, tt.want)274213 }275214 }276- for _, tt := range []struct{ pat, want string }{277- {"0.00", "0.000"}, {"#,##0", "#,##0.0"}, {`"$"#,##0.00_);("$"#,##0.00)`, `"$"#,##0.000_);("$"#,##0.000)`},278- {"0.00E+00", "0.000E+00"}, {"0%", "0.0%"},279- } {280- if got := adjustPatternDecimals(tt.pat, 1); got != tt.want {281- t.Errorf("adjustPatternDecimals(%q, 1) = %q, want %q", tt.pat, got, tt.want)282- }283- }284- if got := adjustPatternDecimals("0.0", -1); got != "0" {285- t.Errorf("adjustPatternDecimals(0.0, -1) = %q", got)286- }287215 }288216 289217 func TestFileV2RoundTrip(t *testing.T) {
A internal/sheet/formula.go +77 -0
@@ -0,0 +1,77 @@
1+package sheet2+3+import "github.com/FelineStateMachine/012/internal/formula"4+5+// The formula language lives in internal/formula. The engine's API keeps6+// its names for addresses, ranges and parsing, so callers see one package.7+8+type (9+ // Addr identifies a cell by zero-based column and row.10+ Addr = formula.Addr11+ // Rect is an inclusive rectangular range of cells.12+ Rect = formula.Rect13+ // Node is a parsed formula expression.14+ Node = formula.Node15+ // ParseError describes a formula that could not be parsed.16+ ParseError = formula.ParseError17+)18+19+// Worksheet bounds, matching Lotus 1-2-3 Release 2 (A..IV, 1..8192).20+const (21+ MaxCols = formula.MaxCols22+ MaxRows = formula.MaxRows23+)24+25+// ColName converts a zero-based column index to letters: 0 -> A, 26 -> AA.26+func ColName(c int) string { return formula.ColName(c) }27+28+// ParseCol converts column letters (case-insensitive) to a zero-based index.29+func ParseCol(s string) (int, bool) { return formula.ParseCol(s) }30+31+// ParseAddr parses an A1-style reference, ignoring absolute markers.32+func ParseAddr(s string) (Addr, bool) { return formula.ParseAddr(s) }33+34+// NewRect returns the normalized rectangle spanning a and b.35+func NewRect(a, b Addr) Rect { return formula.NewRect(a, b) }36+37+// ParseRange parses "A1", "A1:B3" or 1-2-3 style "A1..B3".38+func ParseRange(s string) (Rect, bool) { return formula.ParseRange(s) }39+40+// QuoteSheet writes a sheet name as a formula needs it, e.g. 'Q3 plan'.41+func QuoteSheet(name string) string { return formula.QuoteSheet(name) }42+43+// SplitSheet splits a reference such as "'Q3 plan'!B2" into the sheet44+// name, unquoted, and the rest.45+func SplitSheet(s string) (sheet, rest string) { return formula.SplitSheet(s) }46+47+// Qualified writes r on sheet as a formula would: Sheet2!A1:B3.48+func Qualified(sheet string, r Rect) string { return formula.Qualified(sheet, r) }49+50+// Parse parses a formula with this engine's functions. src may start51+// with "="; error positions are relative to src.52+func Parse(src string) (Node, error) { return formula.Parse(src, parserFuncs) }53+54+// parserFuncs is the function table as the parser sees it.55+func parserFuncs(name string) (formula.Func, bool) {56+ if f, ok := LookupFunc(name); ok {57+ return f, true58+ }59+ return nil, false60+}61+62+// Signature is how the parser checks calls to f.63+func (f *FuncDef) Signature() formula.Signature {64+ return formula.Signature{Name: f.Name, Args: f.Args, Min: f.Min, Max: f.Max, Step: f.step}65+}66+67+// funcOf is the function a parsed call calls: always one of ours, since68+// the parser only finds functions through parserFuncs.69+func funcOf(c formula.Call) *FuncDef { return c.Fn.(*FuncDef) }70+71+func isLetter(c byte) bool {72+ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')73+}74+75+func isDigit(c byte) bool {76+ return c >= '0' && c <= '9'77+}
M internal/sheet/functions.go +17 -13
@@ -3,6 +3,9 @@ package sheet
33 import (44 "math"55 "slices"6+7+ "github.com/FelineStateMachine/012/internal/formula"8+ "github.com/FelineStateMachine/012/internal/numfmt"69 )710 811 // FuncDef describes a spreadsheet function. The table drives parsing
@@ -98,7 +101,7 @@ func init() {
98101 &FuncDef{Name: "INT", Args: "value", Desc: "Round down to the nearest integer", Min: 1, Max: 1, eval: math1(math.Floor), format: inherit},99102 &FuncDef{Name: "SQRT", Args: "value", Desc: "Square root", Min: 1, Max: 1, eval: math1(math.Sqrt)},100103 &FuncDef{Name: "ROUND", Args: "value, [places]", Desc: "Round to a number of decimal places, halves away from zero", Min: 1, Max: 2,101- eval: rounder(roundHalfUp), format: inheritFrom(0)},104+ eval: rounder(numfmt.HalfUp), format: inheritFrom(0)},102105 &FuncDef{Name: "MOD", Args: "dividend, divisor", Desc: "Remainder, with the sign of the divisor", Min: 2, Max: 2,103106 eval: numeric(func(x []float64) Value {104107 if x[1] == 0 {
@@ -162,22 +165,23 @@ type agg struct {
162165 // #VALUE!.163166 func each(args []Node, get lookup, fn func(v Value, direct bool) *Value) *Value {164167 for _, arg := range args {165- if ref, ok := arg.(refNode); ok {166- if e := fn(get(ref.sheet, ref.a), false); e != nil {168+ if ref, ok := arg.(formula.Ref); ok {169+ if e := fn(get.cell(ref.Sheet, ref.Addr), false); e != nil {167170 return e168171 }169172 continue170173 }171- if _, ok := arg.(emptyArg); ok {174+ if _, ok := arg.(formula.Empty); ok {172175 continue173176 }174- if rn, ok := arg.(rangeNode); ok {175- for r := rn.r.From.Row; r <= rn.r.To.Row; r++ {176- for c := rn.r.From.Col; c <= rn.r.To.Col; c++ {177- if e := fn(get(rn.sheet, Addr{Col: c, Row: r}), false); e != nil {178- return e179- }180- }177+ if rn, ok := arg.(formula.Range); ok {178+ var e *Value179+ get.cells(rn.Sheet, rn.Rect, func(v Value) bool {180+ e = fn(v, false)181+ return e == nil182+ })183+ if e != nil {184+ return e181185 }182186 continue183187 }
@@ -281,7 +285,7 @@ func constant(v Value) func([]Node, lookup) Value {
281285 282286 // rounder builds ROUND, ROUNDUP, ROUNDDOWN and TRUNC: round on the 15 digits a283287 // spreadsheet shows, so ROUNDUP(2.3, 1) stays 2.3.284-func rounder(mode roundMode) func([]Node, lookup) Value {288+func rounder(mode numfmt.Rounding) func([]Node, lookup) Value {285289 return func(args []Node, get lookup) Value {286290 x, err := numArg(args[0], get)287291 if err != nil {
@@ -291,6 +295,6 @@ func rounder(mode roundMode) func([]Node, lookup) Value {
291295 if err != nil {292296 return *err293297 }294- return num(roundTo(x, clampInt(places, -308, 308), mode))298+ return num(numfmt.Round(x, clampInt(places, -308, 308), mode))295299 }296300 }
M internal/sheet/functions_date.go +104 -90
@@ -3,6 +3,8 @@ package sheet
33 import (44 "math"55 "strings"6+7+ "github.com/FelineStateMachine/012/internal/numfmt"68 )79 810 var (
@@ -14,32 +16,13 @@ var (
1416 func init() {1517 define(1618 &FuncDef{Name: "DATE", Args: "year, month, day", Desc: "A date from its parts; months and days past the end roll over", Min: 3, Max: 3,17- eval: numeric(func(x []float64) Value {18- y, m, d := math.Trunc(x[0]), math.Trunc(x[1]), math.Trunc(x[2])19- if y >= 0 && y < 1900 {20- y += 1900 // DATE(26, 1, 1) is 1926, as in Sheets21- }22- if y < 0 || y >= 10000 || math.Abs(m) > 1e6 || math.Abs(d) > 1e8 {23- return ErrNum24- }25- v := dateSerial(int(y), int(m), int(d))26- if v < 0 {27- return ErrNum28- }29- return num(v)30- }), format: dateFormat},19+ eval: numeric(date), format: dateFormat},3120 &FuncDef{Name: "TIME", Args: "hour, minute, second", Desc: "A time of day from its parts", Min: 3, Max: 3,32- eval: numeric(func(x []float64) Value {33- secs := math.Trunc(x[0])*3600 + math.Trunc(x[1])*60 + math.Trunc(x[2])34- if secs < 0 {35- return ErrNum36- }37- return num(math.Mod(secs, 86400) / 86400)38- }), format: timeFormat},21+ eval: numeric(timeOfDay), format: timeFormat},3922 &FuncDef{Name: "TODAY", Desc: "Today's date, updated on every change", Max: 0, Volatile: true,40- eval: func([]Node, lookup) Value { return num(math.Floor(serialOf(Now()))) }, format: dateFormat},23+ eval: func([]Node, lookup) Value { return num(math.Floor(numfmt.SerialOf(Now()))) }, format: dateFormat},4124 &FuncDef{Name: "NOW", Desc: "The current date and time, updated on every change", Max: 0, Volatile: true,42- eval: func([]Node, lookup) Value { return num(serialOf(Now())) }, format: dateTimeFormat},25+ eval: func([]Node, lookup) Value { return num(numfmt.SerialOf(Now())) }, format: dateTimeFormat},4326 &FuncDef{Name: "YEAR", Args: "date", Desc: "Year of a date", Min: 1, Max: 1,4427 eval: datePart(func(y, _, _ int) int { return y })},4528 &FuncDef{Name: "MONTH", Args: "date", Desc: "Month of a date, 1 to 12", Min: 1, Max: 1,
@@ -47,27 +30,7 @@ func init() {
4730 &FuncDef{Name: "DAY", Args: "date", Desc: "Day of the month of a date", Min: 1, Max: 1,4831 eval: datePart(func(_, _, d int) int { return d })},4932 &FuncDef{Name: "WEEKDAY", Args: "date, [type]", Desc: "Day of the week: 1 is Sunday, or Monday with type 2", Min: 1, Max: 2,50- eval: func(args []Node, get lookup) Value {51- d, err := dateArg(args[0], get)52- if err != nil {53- return *err54- }55- typ, err := intArg(args, 1, 1, get)56- if err != nil {57- return *err58- }59- days, _ := splitSerial(d)60- wd := weekday(days) // 0 = Sunday61- switch typ {62- case 1:63- return num(float64(wd + 1))64- case 2:65- return num(float64((wd+6)%7 + 1))66- case 3:67- return num(float64((wd + 6) % 7))68- }69- return ErrNum70- }},33+ eval: weekday},7134 &FuncDef{Name: "HOUR", Args: "time", Desc: "Hour of a time, 0 to 23", Min: 1, Max: 1,7235 eval: timePart(func(s int) int { return s / 3600 })},7336 &FuncDef{Name: "MINUTE", Args: "time", Desc: "Minute of a time, 0 to 59", Min: 1, Max: 1,
@@ -81,45 +44,98 @@ func init() {
8144 &FuncDef{Name: "DATEDIF", Args: `start_date, end_date, unit`, Desc: `Time between dates in "Y", "M", "D", "MD", "YM" or "YD"`, Min: 3, Max: 3,8245 eval: datedif},8346 &FuncDef{Name: "DAYS", Args: "end_date, start_date", Desc: "Number of days between two dates", Min: 2, Max: 2,84- eval: func(args []Node, get lookup) Value {85- end, err := dateArg(args[0], get)86- if err != nil {87- return *err88- }89- start, err := dateArg(args[1], get)90- if err != nil {91- return *err92- }93- return num(math.Floor(end) - math.Floor(start))94- }},47+ eval: days},9548 &FuncDef{Name: "NETWORKDAYS", Args: "start_date, end_date, [holidays]", Desc: "Number of weekdays between two dates, counting both", Min: 2, Max: 3,9649 eval: networkdays},9750 &FuncDef{Name: "DATEVALUE", Args: "date_string", Desc: "The date a text such as \"2026-09-26\" stands for", Min: 1, Max: 1,98- eval: func(args []Node, get lookup) Value {99- v, ok := parsedText(args[0], get)100- if ok != nil {101- return *ok102- }103- d, f, parsed := parseDateTime(v)104- if !parsed || f.Kind == FmtTime || f.Kind == FmtDuration {105- return ErrValue106- }107- return num(math.Floor(d))108- }, format: dateFormat},51+ eval: dateValue, format: dateFormat},10952 &FuncDef{Name: "TIMEVALUE", Args: "time_string", Desc: "The time of day a text such as \"2:30 PM\" stands for", Min: 1, Max: 1,110- eval: func(args []Node, get lookup) Value {111- v, ok := parsedText(args[0], get)112- if ok != nil {113- return *ok114- }115- if d, _, parsed := parseDateTime(v); parsed {116- return num(d - math.Floor(d))117- }118- return ErrValue119- }, format: timeFormat},53+ eval: timeValue, format: timeFormat},12054 )12155 }12256 57+// Evaluators for the table above, in its order.58+59+func date(x []float64) Value {60+ y, m, d := math.Trunc(x[0]), math.Trunc(x[1]), math.Trunc(x[2])61+ if y >= 0 && y < 1900 {62+ y += 1900 // DATE(26, 1, 1) is 1926, as in Sheets63+ }64+ if y < 0 || y >= 10000 || math.Abs(m) > 1e6 || math.Abs(d) > 1e8 {65+ return ErrNum66+ }67+ v := numfmt.DateSerial(int(y), int(m), int(d))68+ if v < 0 {69+ return ErrNum70+ }71+ return num(v)72+}73+74+func timeOfDay(x []float64) Value {75+ secs := math.Trunc(x[0])*3600 + math.Trunc(x[1])*60 + math.Trunc(x[2])76+ if secs < 0 {77+ return ErrNum78+ }79+ return num(math.Mod(secs, 86400) / 86400)80+}81+82+func weekday(args []Node, get lookup) Value {83+ d, err := dateArg(args[0], get)84+ if err != nil {85+ return *err86+ }87+ typ, err := intArg(args, 1, 1, get)88+ if err != nil {89+ return *err90+ }91+ days, _ := numfmt.SplitSerial(d)92+ wd := numfmt.Weekday(days) // 0 = Sunday93+ switch typ {94+ case 1:95+ return num(float64(wd + 1))96+ case 2:97+ return num(float64((wd+6)%7 + 1))98+ case 3:99+ return num(float64((wd + 6) % 7))100+ }101+ return ErrNum102+}103+104+func days(args []Node, get lookup) Value {105+ end, err := dateArg(args[0], get)106+ if err != nil {107+ return *err108+ }109+ start, err := dateArg(args[1], get)110+ if err != nil {111+ return *err112+ }113+ return num(math.Floor(end) - math.Floor(start))114+}115+116+func dateValue(args []Node, get lookup) Value {117+ v, ok := parsedText(args[0], get)118+ if ok != nil {119+ return *ok120+ }121+ d, f, parsed := parseDateTime(v)122+ if !parsed || f.Kind == FmtTime || f.Kind == FmtDuration {123+ return ErrValue124+ }125+ return num(math.Floor(d))126+}127+128+func timeValue(args []Node, get lookup) Value {129+ v, ok := parsedText(args[0], get)130+ if ok != nil {131+ return *ok132+ }133+ if d, _, parsed := parseDateTime(v); parsed {134+ return num(d - math.Floor(d))135+ }136+ return ErrValue137+}138+123139 // parsedText requires a text argument, as DATEVALUE and TIMEVALUE do.124140 func parsedText(n Node, get lookup) (string, *Value) {125141 v := eval(n, get)
@@ -151,8 +167,8 @@ func datePart(part func(y, m, d int) int) func([]Node, lookup) Value {
151167 if err != nil {152168 return *err153169 }154- days, _ := splitSerial(d)155- return num(float64(part(civil(days))))170+ days, _ := numfmt.SplitSerial(d)171+ return num(float64(part(numfmt.Civil(days))))156172 }157173 }158174
@@ -162,7 +178,7 @@ func timePart(part func(secs int) int) func([]Node, lookup) Value {
162178 if err != nil {163179 return *err164180 }165- _, secs := splitSerial(d)181+ _, secs := numfmt.SplitSerial(d)166182 return num(float64(part(secs)))167183 }168184 }
@@ -180,18 +196,16 @@ func monthShift(endOfMonth bool) func([]Node, lookup) Value {
180196 if err != nil {181197 return *err182198 }183- y, m, day := civil(int64(math.Floor(d)))184- m += months185- y += floorDivInt(m-1, 12)186- m = ((m-1)%12+12)%12 + 1199+ y, m, day := numfmt.Civil(int64(math.Floor(d)))200+ y, m = numfmt.AddMonths(y, m, months)187201 if y < 1 || y > 9999 {188202 return ErrNum189203 }190- last := daysIn(y, m)204+ last := numfmt.DaysIn(y, m)191205 if endOfMonth || day > last {192206 day = last193207 }194- v := dateSerial(y, m, day)208+ v := numfmt.DateSerial(y, m, day)195209 if v < 0 {196210 return ErrNum197211 }
@@ -216,8 +230,8 @@ func datedif(args []Node, get lookup) Value {
216230 if s > e {217231 return ErrNum218232 }219- sy, sm, sd := civil(s)220- ey, em, ed := civil(e)233+ sy, sm, sd := numfmt.Civil(s)234+ ey, em, ed := numfmt.Civil(e)221235 months := (ey-sy)*12 + em - sm222236 if ed < sd {223237 months--
@@ -240,14 +254,14 @@ func datedif(args []Node, get lookup) Value {
240254 if pm == 0 {241255 py, pm = ey-1, 12242256 }243- return num(float64(e) - dateSerial(py, pm, sd))257+ return num(float64(e) - numfmt.DateSerial(py, pm, sd))244258 case "YD":245259 // Days since the last anniversary of the start date.246260 y := ey247261 if em < sm || em == sm && ed < sd {248262 y--249263 }250- anniv := dateSerial(y, sm, min(sd, daysIn(y, sm)))264+ anniv := numfmt.DateSerial(y, sm, min(sd, numfmt.DaysIn(y, sm)))251265 return num(float64(e) - anniv)252266 }253267 return ErrNum
@@ -282,7 +296,7 @@ func networkdays(args []Node, get lookup) Value {
282296 }283297 n := 0284298 for d := s; d <= e; d++ {285- if wd := weekday(d); wd != 0 && wd != 6 && !holidays[d] {299+ if wd := numfmt.Weekday(d); wd != 0 && wd != 6 && !holidays[d] {286300 n++287301 }288302 }
M internal/sheet/functions_jev.go +15 -11
@@ -2,6 +2,8 @@ package sheet
22 33 import (44 "strings"5+6+ "github.com/FelineStateMachine/012/internal/formula"57 )68 79 // JEV functions ask TypeSafe's hosted model (jev) typed questions about a
@@ -45,7 +47,8 @@ const (
4547 // failure; the context line says why.4648 var ErrRemote = Value{Kind: Error, Str: "#ERROR!"}4749 48-// remoteEval evaluates a JEV function through Remote, converting the50+// remoteEval evaluates a JEV function through the workbook's51+// RemoteSource, converting the4952 // answer with result. Errors in the inputs come back as-is so they can be5053 // fixed; they're never sent.5154 func remoteEval(build func([]Node, lookup) (RemoteCall, error), result func(RemoteAnswer) Value) func([]Node, lookup) Value {
@@ -54,10 +57,11 @@ func remoteEval(build func([]Node, lookup) (RemoteCall, error), result func(Remo
5457 if err != nil {5558 return err.(inputError).v5659 }57- if Remote == nil {60+ remote := get.w.remote61+ if remote == nil {5862 return ErrNoRemote5963 }60- ans, ok := Remote.Lookup(call)64+ ans, ok := remote.Lookup(call)6165 switch {6266 case !ok:6367 return Pending
@@ -76,12 +80,12 @@ func (e inputError) Error() string { return e.v.Str }
7680 // jevState turns the value argument into what the model sees: text,7781 // numbers and booleans as themselves, a range as rows of them.7882 func jevState(n Node, get lookup) (any, error) {79- if rn, ok := n.(rangeNode); ok {83+ if rn, ok := n.(formula.Range); ok {8084 var rows [][]any81- for r := rn.r.From.Row; r <= rn.r.To.Row; r++ {85+ for r := rn.Rect.From.Row; r <= rn.Rect.To.Row; r++ {8286 var row []any83- for c := rn.r.From.Col; c <= rn.r.To.Col; c++ {84- v := get(rn.sheet, Addr{Col: c, Row: r})87+ for c := rn.Rect.From.Col; c <= rn.Rect.To.Col; c++ {88+ v := get.cell(rn.Sheet, Addr{Col: c, Row: r})8589 if v.Kind == Error {8690 return nil, inputError{v}8791 }
@@ -134,10 +138,10 @@ func jevQuestion(n Node, get lookup) (string, error) {
134138 // skipped.135139 func jevList(n Node, get lookup) ([]string, error) {136140 var out []string137- if rn, ok := n.(rangeNode); ok {138- for r := rn.r.From.Row; r <= rn.r.To.Row; r++ {139- for c := rn.r.From.Col; c <= rn.r.To.Col; c++ {140- v := get(rn.sheet, Addr{Col: c, Row: r})141+ if rn, ok := n.(formula.Range); ok {142+ for r := rn.Rect.From.Row; r <= rn.Rect.To.Row; r++ {143+ for c := rn.Rect.From.Col; c <= rn.Rect.To.Col; c++ {144+ v := get.cell(rn.Sheet, Addr{Col: c, Row: r})141145 if v.Kind == Error {142146 return nil, inputError{v}143147 }
M internal/sheet/functions_jev_test.go +2 -10
M internal/sheet/functions_link.go +8 -4
M internal/sheet/functions_logic.go +55 -45
@@ -3,55 +3,13 @@ package sheet
33 func init() {44 define(55 &FuncDef{Name: "IFS", Args: "condition1, value1, [condition2, value2, ...]", Desc: "The value of the first true condition", Min: 2, Max: -1, step: 2,6- eval: func(args []Node, get lookup) Value {7- for i := 0; i+1 < len(args); i += 2 {8- c, err := numArg(args[i], get)9- if err != nil {10- return *err11- }12- if c != 0 {13- return eval(args[i+1], get)14- }15- }16- return ErrNA17- }, format: func(args []Node, infer func(Node) Format) Format {18- var vals []Node19- for i := 1; i < len(args); i += 2 {20- vals = append(vals, args[i])21- }22- return inherit(vals, infer)23- }},6+ eval: ifs, format: ifsFormat},247 &FuncDef{Name: "SWITCH", Args: "expression, case1, value1, [case2, value2, ...], [default]", Desc: "The value for the first case equal to an expression", Min: 3, Max: -1,25- eval: func(args []Node, get lookup) Value {26- x := eval(args[0], get)27- if x.Kind == Error {28- return x29- }30- i := 131- for ; i+1 < len(args); i += 2 {32- c := eval(args[i], get)33- if c.Kind == Error {34- return c35- }36- if sameKind(x, c) && compare(x, c) == 0 {37- return eval(args[i+1], get)38- }39- }40- if i < len(args) {41- return eval(args[i], get)42- }43- return ErrNA44- }},8+ eval: switchCase},459 &FuncDef{Name: "XOR", Args: "logical1, [logical2, ...]", Desc: "TRUE if an odd number of arguments are true", Min: 1, Max: -1,4610 eval: logical(func(t, _ int) bool { return t%2 == 1 })},4711 &FuncDef{Name: "IFNA", Args: "value, value_if_na", Desc: "A fallback when a value is #N/A", Min: 2, Max: 2,48- eval: func(args []Node, get lookup) Value {49- v := eval(args[0], get)50- if v == ErrNA {51- return eval(args[1], get)52- }53- return v54- }, format: inherit},12+ eval: ifNA, format: inherit},5513 &FuncDef{Name: "ISBLANK", Args: "value", Desc: "TRUE if a cell is empty", Min: 1, Max: 1,5614 eval: is(func(v Value) bool { return v.Kind == Empty })},5715 &FuncDef{Name: "ISNUMBER", Args: "value", Desc: "TRUE if a value is a number", Min: 1, Max: 1,
@@ -69,6 +27,58 @@ func init() {
6927 )7028 }7129 30+// Evaluators for the table above, in its order.31+32+func ifs(args []Node, get lookup) Value {33+ for i := 0; i+1 < len(args); i += 2 {34+ c, err := numArg(args[i], get)35+ if err != nil {36+ return *err37+ }38+ if c != 0 {39+ return eval(args[i+1], get)40+ }41+ }42+ return ErrNA43+}44+45+func ifsFormat(args []Node, infer func(Node) Format) Format {46+ var vals []Node47+ for i := 1; i < len(args); i += 2 {48+ vals = append(vals, args[i])49+ }50+ return inherit(vals, infer)51+}52+53+func switchCase(args []Node, get lookup) Value {54+ x := eval(args[0], get)55+ if x.Kind == Error {56+ return x57+ }58+ i := 159+ for ; i+1 < len(args); i += 2 {60+ c := eval(args[i], get)61+ if c.Kind == Error {62+ return c63+ }64+ if sameKind(x, c) && compare(x, c) == 0 {65+ return eval(args[i+1], get)66+ }67+ }68+ if i < len(args) {69+ return eval(args[i], get)70+ }71+ return ErrNA72+}73+74+func ifNA(args []Node, get lookup) Value {75+ v := eval(args[0], get)76+ if v == ErrNA {77+ return eval(args[1], get)78+ }79+ return v80+}81+7282 // is builds the IS functions, which never return an error themselves.7383 func is(test func(Value) bool) func([]Node, lookup) Value {7484 return func(args []Node, get lookup) Value {
M internal/sheet/functions_lookup.go +76 -68
@@ -11,78 +11,13 @@ func init() {
1111 &FuncDef{Name: "HLOOKUP", Args: "search_key, range, index, [is_sorted]", Desc: "Find a key in the first row and return a value from its column", Min: 3, Max: 4,1212 eval: func(args []Node, get lookup) Value { return tableLookup(args, get, false) }},1313 &FuncDef{Name: "MATCH", Args: "search_key, range, [search_type]", Desc: "Position of a key in a row or column", Min: 2, Max: 3,14- eval: func(args []Node, get lookup) Value {15- key := eval(args[0], get)16- if key.Kind == Error {17- return key18- }19- m := matrixArg(args[1], get)20- kind, err := intArg(args, 2, 1, get)21- switch {22- case err != nil:23- return *err24- case !m.vector():25- return ErrNA26- }27- var i int28- switch {29- case kind == 0:30- i = findExact(key, m.size(), m.at, true)31- case kind > 0:32- i = findSorted(key, m.size(), m.at, 1)33- default:34- i = findSorted(key, m.size(), m.at, -1)35- }36- if i < 0 {37- return ErrNA38- }39- return num(float64(i + 1))40- }},14+ eval: match},4115 &FuncDef{Name: "INDEX", Args: "reference, [row], [column]", Desc: "The value at a row and column of a range", Min: 1, Max: 3,42- eval: func(args []Node, get lookup) Value {43- m := matrixArg(args[0], get)44- row, err := intArg(args, 1, 0, get)45- if err != nil {46- return *err47- }48- col, err := intArg(args, 2, 0, get)49- if err != nil {50- return *err51- }52- // In a single row, a lone index counts along the row.53- if m.rows == 1 && len(args) == 2 {54- row, col = 1, row55- }56- if row < 0 || col < 0 {57- return ErrValue58- }59- if row == 0 && m.rows == 1 {60- row = 161- }62- if col == 0 && m.cols == 1 {63- col = 164- }65- switch {66- case row == 0 || col == 0:67- return ErrValue // a whole row or column: an array we can't show68- case row > m.rows || col > m.cols:69- return ErrRef70- }71- return m.cell(row-1, col-1)72- }, format: inheritFrom(0)},16+ eval: index, format: inheritFrom(0)},7317 &FuncDef{Name: "XLOOKUP", Args: "search_key, lookup_range, result_range, [missing_value], [match_mode], [search_mode]", Desc: "Find a key and return the matching entry of another range", Min: 3, Max: 6,7418 eval: xlookup, format: inheritFrom(2)},7519 &FuncDef{Name: "CHOOSE", Args: "index, choice1, [choice2, ...]", Desc: "The choice at a position", Min: 2, Max: -1,76- eval: func(args []Node, get lookup) Value {77- i, err := intArg(args, 0, 0, get)78- if err != nil {79- return *err80- }81- if i < 1 || i >= len(args) {82- return ErrValue83- }84- return eval(args[i], get)85- }},20+ eval: choose},8621 &FuncDef{Name: "ROWS", Args: "range", Desc: "Number of rows in a range", Min: 1, Max: 1,8722 eval: func(args []Node, get lookup) Value { return num(float64(matrixArg(args[0], get).rows)) }},8823 &FuncDef{Name: "COLUMNS", Args: "range", Desc: "Number of columns in a range", Min: 1, Max: 1,
@@ -90,6 +25,79 @@ func init() {
9025 )9126 }9227 28+// Evaluators for the table above, in its order.29+30+func match(args []Node, get lookup) Value {31+ key := eval(args[0], get)32+ if key.Kind == Error {33+ return key34+ }35+ m := matrixArg(args[1], get)36+ kind, err := intArg(args, 2, 1, get)37+ switch {38+ case err != nil:39+ return *err40+ case !m.vector():41+ return ErrNA42+ }43+ var i int44+ switch {45+ case kind == 0:46+ i = findExact(key, m.size(), m.at, true)47+ case kind > 0:48+ i = findSorted(key, m.size(), m.at, 1)49+ default:50+ i = findSorted(key, m.size(), m.at, -1)51+ }52+ if i < 0 {53+ return ErrNA54+ }55+ return num(float64(i + 1))56+}57+58+func index(args []Node, get lookup) Value {59+ m := matrixArg(args[0], get)60+ row, err := intArg(args, 1, 0, get)61+ if err != nil {62+ return *err63+ }64+ col, err := intArg(args, 2, 0, get)65+ if err != nil {66+ return *err67+ }68+ // In a single row, a lone index counts along the row.69+ if m.rows == 1 && len(args) == 2 {70+ row, col = 1, row71+ }72+ if row < 0 || col < 0 {73+ return ErrValue74+ }75+ if row == 0 && m.rows == 1 {76+ row = 177+ }78+ if col == 0 && m.cols == 1 {79+ col = 180+ }81+ switch {82+ case row == 0 || col == 0:83+ return ErrValue // a whole row or column: an array we can't show84+ case row > m.rows || col > m.cols:85+ return ErrRef86+ }87+ return m.cell(row-1, col-1)88+}89+90+func choose(args []Node, get lookup) Value {91+ i, err := intArg(args, 0, 0, get)92+ if err != nil {93+ return *err94+ }95+ if i < 1 || i >= len(args) {96+ return ErrValue97+ }98+ return eval(args[i], get)99+}100+93101 // tableLookup is VLOOKUP (vertical) and HLOOKUP: search the first column94102 // (or row), then return the index-th entry across.95103 func tableLookup(args []Node, get lookup, vertical bool) Value {
M internal/sheet/functions_math.go +5 -3
M internal/sheet/functions_stats.go +108 -94
@@ -8,81 +8,19 @@ import (
88 func init() {99 define(1010 &FuncDef{Name: "AVERAGEIF", Args: "criteria_range, criterion, [average_range]", Desc: "Average of the cells that meet a condition", Min: 2, Max: 3,11- eval: func(args []Node, get lookup) Value {12- rng := matrixArg(args[0], get)13- cv := eval(args[1], get)14- if cv.Kind == Error {15- return cv16- }17- c := newCriterion(cv)18- avg := rng19- if len(args) > 2 {20- avg = matrixArg(args[2], get).resized(rng.rows, rng.cols, get)21- }22- mask := make([]bool, rng.size())23- for i := range mask {24- mask[i] = c.test(rng.at(i))25- }26- return averageMasked(avg, mask)27- }, format: inheritFrom(2)},11+ eval: averageIf, format: inheritFrom(2)},2812 &FuncDef{Name: "AVERAGEIFS", Args: "average_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...]", Desc: "Average of the cells that meet every condition", Min: 3, Max: -1, step: 2,29- eval: func(args []Node, get lookup) Value {30- avg := matrixArg(args[0], get)31- rows, cols, mask, err := criteriaMask(args, 1, get)32- switch {33- case err != nil:34- return *err35- case rows != avg.rows || cols != avg.cols:36- return ErrValue37- }38- return averageMasked(avg, mask)39- }, format: inheritFrom(0)},13+ eval: averageIfs, format: inheritFrom(0)},4014 &FuncDef{Name: "COUNTIF", Args: "range, criterion", Desc: "Count of the cells that meet a condition", Min: 2, Max: 2,4115 eval: countIfs},4216 &FuncDef{Name: "COUNTIFS", Args: "criteria_range1, criterion1, [criteria_range2, criterion2, ...]", Desc: "Count of the cells that meet every condition", Min: 2, Max: -1, step: 2,4317 eval: countIfs},4418 &FuncDef{Name: "COUNTBLANK", Args: "range", Desc: "Count of empty cells, including empty text", Min: 1, Max: 1,45- eval: func(args []Node, get lookup) Value {46- m := matrixArg(args[0], get)47- n := 048- for i := range m.size() {49- if v := m.at(i); v.Kind == Empty || v.Kind == Text && v.Str == "" {50- n++51- }52- }53- return num(float64(n))54- }},19+ eval: countBlank},5520 &FuncDef{Name: "MEDIAN", Args: "value1, [value2, ...]", Desc: "Middle value of numbers", Min: 1, Max: -1,56- eval: withNums(func(x []float64) Value {57- if len(x) == 0 {58- return ErrNum59- }60- slices.Sort(x)61- n := len(x)62- if n%2 == 1 {63- return num(x[n/2])64- }65- return num((x[n/2-1] + x[n/2]) / 2)66- }), format: inherit},21+ eval: withNums(median), format: inherit},6722 &FuncDef{Name: "MODE", Args: "value1, [value2, ...]", Desc: "Most common number (the first, on a tie)", Min: 1, Max: -1,68- eval: withNums(func(x []float64) Value {69- best, bestN := 0.0, 170- for i, v := range x {71- n := 072- for _, w := range x[i:] {73- if w == v {74- n++75- }76- }77- if n > bestN {78- best, bestN = v, n79- }80- }81- if bestN < 2 {82- return ErrNA83- }84- return num(best)85- }), format: inherit},23+ eval: withNums(mode), format: inherit},8624 &FuncDef{Name: "STDEV", Args: "value1, [value2, ...]", Desc: "Standard deviation of a sample", Min: 1, Max: -1,8725 eval: withNums(func(x []float64) Value { return spread(x, 1, true) })},8826 &FuncDef{Name: "STDEVP", Args: "value1, [value2, ...]", Desc: "Standard deviation of a whole population", Min: 1, Max: -1,
@@ -96,36 +34,112 @@ func init() {
9634 &FuncDef{Name: "SMALL", Args: "data, n", Desc: "The nth smallest number", Min: 2, Max: 2,9735 eval: nth(cmpFloat), format: inheritFrom(0)},9836 &FuncDef{Name: "RANK", Args: "value, data, [is_ascending]", Desc: "Rank of a number among others, largest first by default", Min: 2, Max: 3,99- eval: func(args []Node, get lookup) Value {100- v, err := numArg(args[0], get)101- if err != nil {102- return *err103- }104- data, err := nums(args[1:2], get)105- if err != nil {106- return *err107- }108- asc, err := boolArg(args, 2, false, get)109- if err != nil {110- return *err111- }112- rank, found := 1, false113- for _, d := range data {114- switch {115- case d == v:116- found = true117- case asc && d < v, !asc && d > v:118- rank++119- }120- }121- if !found {122- return ErrNA123- }124- return num(float64(rank))125- }},37+ eval: rank},12638 )12739 }12840 41+// Evaluators for the table above, in its order.42+43+func averageIf(args []Node, get lookup) Value {44+ rng := matrixArg(args[0], get)45+ cv := eval(args[1], get)46+ if cv.Kind == Error {47+ return cv48+ }49+ c := newCriterion(cv)50+ avg := rng51+ if len(args) > 2 {52+ avg = matrixArg(args[2], get).resized(rng.rows, rng.cols, get)53+ }54+ mask := make([]bool, rng.size())55+ for i := range mask {56+ mask[i] = c.test(rng.at(i))57+ }58+ return averageMasked(avg, mask)59+}60+61+func averageIfs(args []Node, get lookup) Value {62+ avg := matrixArg(args[0], get)63+ rows, cols, mask, err := criteriaMask(args, 1, get)64+ switch {65+ case err != nil:66+ return *err67+ case rows != avg.rows || cols != avg.cols:68+ return ErrValue69+ }70+ return averageMasked(avg, mask)71+}72+73+func countBlank(args []Node, get lookup) Value {74+ m := matrixArg(args[0], get)75+ n := 076+ for i := range m.size() {77+ if v := m.at(i); v.Kind == Empty || v.Kind == Text && v.Str == "" {78+ n++79+ }80+ }81+ return num(float64(n))82+}83+84+func median(x []float64) Value {85+ if len(x) == 0 {86+ return ErrNum87+ }88+ slices.Sort(x)89+ n := len(x)90+ if n%2 == 1 {91+ return num(x[n/2])92+ }93+ return num((x[n/2-1] + x[n/2]) / 2)94+}95+96+func mode(x []float64) Value {97+ best, bestN := 0.0, 198+ for i, v := range x {99+ n := 0100+ for _, w := range x[i:] {101+ if w == v {102+ n++103+ }104+ }105+ if n > bestN {106+ best, bestN = v, n107+ }108+ }109+ if bestN < 2 {110+ return ErrNA111+ }112+ return num(best)113+}114+115+func rank(args []Node, get lookup) Value {116+ v, err := numArg(args[0], get)117+ if err != nil {118+ return *err119+ }120+ data, err := nums(args[1:2], get)121+ if err != nil {122+ return *err123+ }124+ asc, err := boolArg(args, 2, false, get)125+ if err != nil {126+ return *err127+ }128+ rank, found := 1, false129+ for _, d := range data {130+ switch {131+ case d == v:132+ found = true133+ case asc && d < v, !asc && d > v:134+ rank++135+ }136+ }137+ if !found {138+ return ErrNA139+ }140+ return num(float64(rank))141+}142+129143 func countIfs(args []Node, get lookup) Value {130144 _, _, mask, err := criteriaMask(args, 0, get)131145 if err != nil {
M internal/sheet/functions_text.go +169 -147
@@ -11,71 +11,17 @@ const maxText = 50000
1111 func init() {1212 define(1313 &FuncDef{Name: "CONCATENATE", Args: "string1, [string2, ...]", Desc: "Join text, including every cell of ranges", Min: 1, Max: -1,14- eval: func(args []Node, get lookup) Value {15- parts, err := texts(args, get)16- if err != nil {17- return *err18- }19- return str(strings.Join(parts, ""))20- }},14+ eval: concatenate},2115 &FuncDef{Name: "CONCAT", Args: "value1, value2", Desc: "Join two values as text", Min: 2, Max: 2,22- eval: func(args []Node, get lookup) Value {23- a, err := textArg(args[0], get)24- if err != nil {25- return *err26- }27- b, err := textArg(args[1], get)28- if err != nil {29- return *err30- }31- return str(a + b)32- }},16+ eval: concat},3317 &FuncDef{Name: "TEXTJOIN", Args: "delimiter, ignore_empty, text1, [text2, ...]", Desc: "Join text with a delimiter", Min: 3, Max: -1,34- eval: func(args []Node, get lookup) Value {35- delim, err := textArg(args[0], get)36- if err != nil {37- return *err38- }39- skip, err := boolArg(args, 1, true, get)40- if err != nil {41- return *err42- }43- parts, err := texts(args[2:], get)44- if err != nil {45- return *err46- }47- if skip {48- parts = slicesDeleteEmpty(parts)49- }50- return capText(strings.Join(parts, delim))51- }},18+ eval: textJoin},5219 &FuncDef{Name: "LEFT", Args: "string, [number_of_characters]", Desc: "The first characters of text", Min: 1, Max: 2,5320 eval: textSlice(func(r []rune, n int) string { return string(r[:min(n, len(r))]) })},5421 &FuncDef{Name: "RIGHT", Args: "string, [number_of_characters]", Desc: "The last characters of text", Min: 1, Max: 2,5522 eval: textSlice(func(r []rune, n int) string { return string(r[len(r)-min(n, len(r)):]) })},5623 &FuncDef{Name: "MID", Args: "string, starting_at, extract_length", Desc: "Characters from the middle of text", Min: 3, Max: 3,57- eval: func(args []Node, get lookup) Value {58- s, err := textArg(args[0], get)59- if err != nil {60- return *err61- }62- start, err := intArg(args, 1, 1, get)63- if err != nil {64- return *err65- }66- n, err := intArg(args, 2, 0, get)67- if err != nil {68- return *err69- }70- if start < 1 || n < 0 {71- return ErrValue72- }73- r := []rune(s)74- if start > len(r) {75- return str("")76- }77- return str(string(r[start-1 : min(start-1+n, len(r))]))78- }},24+ eval: mid},7925 &FuncDef{Name: "LEN", Args: "text", Desc: "Number of characters in text", Min: 1, Max: 1,8026 eval: textFn(func(s string) Value { return num(float64(runeLen(s))) })},8127 &FuncDef{Name: "UPPER", Args: "text", Desc: "Text in upper case", Min: 1, Max: 1,
@@ -85,109 +31,185 @@ func init() {
8531 &FuncDef{Name: "PROPER", Args: "text", Desc: "Text with each word capitalized", Min: 1, Max: 1,8632 eval: textFn(func(s string) Value { return str(proper(s)) })},8733 &FuncDef{Name: "TRIM", Args: "text", Desc: "Text without leading, trailing and repeated spaces", Min: 1, Max: 1,88- eval: textFn(func(s string) Value {89- return str(strings.Join(strings.FieldsFunc(s, func(r rune) bool { return r == ' ' }), " "))90- })},34+ eval: textFn(trimSpaces)},9135 &FuncDef{Name: "SUBSTITUTE", Args: "text, search_for, replace_with, [occurrence_number]", Desc: "Replace occurrences of text", Min: 3, Max: 4,9236 eval: substitute},9337 &FuncDef{Name: "REPLACE", Args: "text, position, length, new_text", Desc: "Replace characters at a position", Min: 4, Max: 4,94- eval: func(args []Node, get lookup) Value {95- s, err := textArg(args[0], get)96- if err != nil {97- return *err98- }99- pos, err := intArg(args, 1, 1, get)100- if err != nil {101- return *err102- }103- n, err := intArg(args, 2, 0, get)104- if err != nil {105- return *err106- }107- repl, err := textArg(args[3], get)108- if err != nil {109- return *err110- }111- if pos < 1 || n < 0 {112- return ErrValue113- }114- r := []rune(s)115- from := min(pos-1, len(r))116- to := min(from+n, len(r))117- return capText(string(r[:from]) + repl + string(r[to:]))118- }},38+ eval: replaceAt},11939 &FuncDef{Name: "FIND", Args: "search_for, text_to_search, [starting_at]", Desc: "Position of text, case-sensitive", Min: 2, Max: 3,12040 eval: textFinder(false)},12141 &FuncDef{Name: "SEARCH", Args: "search_for, text_to_search, [starting_at]", Desc: "Position of text, ignoring case, with * and ? wildcards", Min: 2, Max: 3,12242 eval: textFinder(true)},12343 &FuncDef{Name: "TEXT", Args: "number, format", Desc: `A number as text in a format, e.g. "$#,##0.00" or "yyyy-mm-dd"`, Min: 2, Max: 2,124- eval: func(args []Node, get lookup) Value {125- v := eval(args[0], get)126- if v.Kind == Error {127- return v128- }129- pat, err := textArg(args[1], get)130- if err != nil {131- return *err132- }133- if v.Kind == Text {134- n, _, ok := ParseValue(v.Str)135- if !ok {136- return v137- }138- v = num(n)139- }140- return str(FormatPattern(v.Num, pat))141- }},44+ eval: textFormat},14245 &FuncDef{Name: "VALUE", Args: "text", Desc: "Text as a number; dates and times too", Min: 1, Max: 1,143- eval: func(args []Node, get lookup) Value {144- v := eval(args[0], get)145- switch v.Kind {146- case Error, Number:147- return v148- case Empty:149- return num(0)150- case Bool:151- return ErrValue152- }153- if n, _, ok := ParseValue(strings.TrimSpace(v.Str)); ok {154- return num(n)155- }156- if strings.TrimSpace(v.Str) == "" {157- return num(0)158- }159- return ErrValue160- }},46+ eval: valueOf},16147 &FuncDef{Name: "REPT", Args: "text, number_of_repetitions", Desc: "Text repeated a number of times", Min: 2, Max: 2,162- eval: func(args []Node, get lookup) Value {163- s, err := textArg(args[0], get)164- if err != nil {165- return *err166- }167- n, err := intArg(args, 1, 0, get)168- if err != nil {169- return *err170- }171- if n < 0 || n*len(s) > maxText {172- return ErrValue173- }174- return str(strings.Repeat(s, n))175- }},48+ eval: rept},17649 &FuncDef{Name: "EXACT", Args: "string1, string2", Desc: "TRUE if two texts are identical, case included", Min: 2, Max: 2,177- eval: func(args []Node, get lookup) Value {178- a, err := textArg(args[0], get)179- if err != nil {180- return *err181- }182- b, err := textArg(args[1], get)183- if err != nil {184- return *err185- }186- return boolean(a == b)187- }},50+ eval: exact},18851 )18952 }19053 54+// Evaluators for the table above, in its order.55+56+func concatenate(args []Node, get lookup) Value {57+ parts, err := texts(args, get)58+ if err != nil {59+ return *err60+ }61+ return str(strings.Join(parts, ""))62+}63+64+func concat(args []Node, get lookup) Value {65+ a, err := textArg(args[0], get)66+ if err != nil {67+ return *err68+ }69+ b, err := textArg(args[1], get)70+ if err != nil {71+ return *err72+ }73+ return str(a + b)74+}75+76+func textJoin(args []Node, get lookup) Value {77+ delim, err := textArg(args[0], get)78+ if err != nil {79+ return *err80+ }81+ skip, err := boolArg(args, 1, true, get)82+ if err != nil {83+ return *err84+ }85+ parts, err := texts(args[2:], get)86+ if err != nil {87+ return *err88+ }89+ if skip {90+ parts = slicesDeleteEmpty(parts)91+ }92+ return capText(strings.Join(parts, delim))93+}94+95+func mid(args []Node, get lookup) Value {96+ s, err := textArg(args[0], get)97+ if err != nil {98+ return *err99+ }100+ start, err := intArg(args, 1, 1, get)101+ if err != nil {102+ return *err103+ }104+ n, err := intArg(args, 2, 0, get)105+ if err != nil {106+ return *err107+ }108+ if start < 1 || n < 0 {109+ return ErrValue110+ }111+ r := []rune(s)112+ if start > len(r) {113+ return str("")114+ }115+ return str(string(r[start-1 : min(start-1+n, len(r))]))116+}117+118+func trimSpaces(s string) Value {119+ return str(strings.Join(strings.FieldsFunc(s, func(r rune) bool { return r == ' ' }), " "))120+}121+122+func replaceAt(args []Node, get lookup) Value {123+ s, err := textArg(args[0], get)124+ if err != nil {125+ return *err126+ }127+ pos, err := intArg(args, 1, 1, get)128+ if err != nil {129+ return *err130+ }131+ n, err := intArg(args, 2, 0, get)132+ if err != nil {133+ return *err134+ }135+ repl, err := textArg(args[3], get)136+ if err != nil {137+ return *err138+ }139+ if pos < 1 || n < 0 {140+ return ErrValue141+ }142+ r := []rune(s)143+ from := min(pos-1, len(r))144+ to := min(from+n, len(r))145+ return capText(string(r[:from]) + repl + string(r[to:]))146+}147+148+func textFormat(args []Node, get lookup) Value {149+ v := eval(args[0], get)150+ if v.Kind == Error {151+ return v152+ }153+ pat, err := textArg(args[1], get)154+ if err != nil {155+ return *err156+ }157+ if v.Kind == Text {158+ n, _, ok := ParseValue(v.Str)159+ if !ok {160+ return v161+ }162+ v = num(n)163+ }164+ return str(FormatPattern(v.Num, pat))165+}166+167+func valueOf(args []Node, get lookup) Value {168+ v := eval(args[0], get)169+ switch v.Kind {170+ case Error, Number:171+ return v172+ case Empty:173+ return num(0)174+ case Bool:175+ return ErrValue176+ }177+ if n, _, ok := ParseValue(strings.TrimSpace(v.Str)); ok {178+ return num(n)179+ }180+ if strings.TrimSpace(v.Str) == "" {181+ return num(0)182+ }183+ return ErrValue184+}185+186+func rept(args []Node, get lookup) Value {187+ s, err := textArg(args[0], get)188+ if err != nil {189+ return *err190+ }191+ n, err := intArg(args, 1, 0, get)192+ if err != nil {193+ return *err194+ }195+ if n < 0 || n*len(s) > maxText {196+ return ErrValue197+ }198+ return str(strings.Repeat(s, n))199+}200+201+func exact(args []Node, get lookup) Value {202+ a, err := textArg(args[0], get)203+ if err != nil {204+ return *err205+ }206+ b, err := textArg(args[1], get)207+ if err != nil {208+ return *err209+ }210+ return boolean(a == b)211+}212+191213 func capText(s string) Value {192214 if len(s) > maxText {193215 return ErrValue
M internal/sheet/history.go +54 -31
@@ -3,6 +3,8 @@ package sheet
33 import (44 "maps"55 "slices"6+7+ "github.com/FelineStateMachine/012/internal/formula"68 )79 810 // MaxUndo is how many steps of undo history a workbook keeps.
@@ -143,7 +145,7 @@ func (s *Sheet) record(a Addr) {
143145 }144146 l := loc{s, a}145147 if _, seen := st.cells[l]; !seen {146- st.cells[l] = s.cells[a].clone()148+ st.cells[l] = s.cells.get(a).clone()147149 }148150 s.wb.hist.dirty = append(s.wb.hist.dirty, l)149151 }
@@ -222,7 +224,7 @@ func (w *Workbook) setSheets(l *sheetList) {
222224 for _, s := range w.sheets {223225 s.name = l.names[s]224226 if s.live {225- w.byKey[sheetKey(s.name)] = s227+ w.byKey[formula.SheetKey(s.name)] = s226228 } else {227229 w.attach(s)228230 }
@@ -237,19 +239,40 @@ func (l *sheetList) equal(m *sheetList) bool {
237239 // push adds a finished step to the undo stack and clears redo, dropping238240 // no-op changes.239241 func (w *Workbook) push(st *step) {242+ w.dropUnchanged(st)243+ if st.empty() {244+ return245+ }240246 h := &w.hist247+ h.redo = nil248+ h.lastID++249+ widthOnly := st.widthOnly()250+ if top := h.top(); widthOnly && h.mergeWidths && top != nil && top.widthOnly() {251+ h.joinWidths(top, st)252+ return253+ }254+ st.id = h.lastID255+ h.undo = append(h.undo, st)256+ if len(h.undo) > MaxUndo {257+ h.undo = h.undo[len(h.undo)-MaxUndo:]258+ }259+ h.mergeWidths = widthOnly260+}261+262+// dropUnchanged removes from st what ended the step as it began.263+func (w *Workbook) dropUnchanged(st *step) {241264 for k, width := range st.widths {242265 if k.s.widths[k.col] == width {243266 delete(st.widths, k)244267 }245268 }246269 for l, c := range st.cells {247- if c == nil && l.s.cells[l.a] == nil {270+ if c == nil && l.s.cells.get(l.a) == nil {248271 delete(st.cells, l)249272 }250273 }251274 for k, n := range st.names {252- if cur := w.namePtr(k); n == nil && cur == nil || n != nil && cur != nil && *n == *cur {275+ if sameName(n, w.namePtr(k)) {253276 delete(st.names, k)254277 }255278 }
@@ -269,35 +292,35 @@ func (w *Workbook) push(st *step) {
269292 if st.decimal != nil && *st.decimal == w.decimal {270293 st.decimal = nil271294 }272- if st.empty() {273- return295+}296+297+// sameName reports whether two named ranges (nil for undefined) are the298+// same.299+func sameName(a, b *Name) bool {300+ if a == nil || b == nil {301+ return a == b274302 }275- h.redo = nil276- h.lastID++277- widthOnly := st.widthOnly()278- if top := h.top(); widthOnly && h.mergeWidths && top != nil && top.widthOnly() {279- for k, width := range st.widths {280- if _, ok := top.widths[k]; !ok {281- top.widths[k] = width282- }283- }284- for k, width := range top.widths {285- if k.s.widths[k.col] == width {286- delete(top.widths, k)287- }303+ return *a == *b304+}305+306+// joinWidths folds the width-only step st into the width-only step on307+// top, so a live preview of a column width and its final value (or its308+// cancellation) are one step.309+func (h *history) joinWidths(top, st *step) {310+ for k, width := range st.widths {311+ if _, ok := top.widths[k]; !ok {312+ top.widths[k] = width288313 }289- top.id, top.focus = h.lastID, union(top.focus, st.focus)290- if top.empty() {291- h.undo = h.undo[:len(h.undo)-1]314+ }315+ for k, width := range top.widths {316+ if k.s.widths[k.col] == width {317+ delete(top.widths, k)292318 }293- return294319 }295- st.id = h.lastID296- h.undo = append(h.undo, st)297- if len(h.undo) > MaxUndo {298- h.undo = h.undo[len(h.undo)-MaxUndo:]320+ top.id, top.focus = h.lastID, union(top.focus, st.focus)321+ if top.empty() {322+ h.undo = h.undo[:len(h.undo)-1]299323 }300- h.mergeWidths = widthOnly301324 }302325 303326 func (h *history) top() *step {
@@ -364,7 +387,7 @@ func (w *Workbook) swap(from, to *[]*step) (Change, bool) {
364387 }365388 changed := make([]loc, 0, len(st.cells))366389 for l, c := range st.cells {367- inv.cells[l] = l.s.cells[l.a].clone()390+ inv.cells[l] = l.s.cells.get(l.a).clone()368391 l.s.place(l.a, c.clone())369392 changed = append(changed, l)370393 }
@@ -404,12 +427,12 @@ func (w *Workbook) swap(from, to *[]*step) (Change, bool) {
404427 405428 // colRect is the range covering whole columns from..to.406429 func colRect(from, to int) Rect {407- return Rect{Addr{Col: from}, Addr{Col: to, Row: MaxRows - 1}}430+ return Rect{From: Addr{Col: from}, To: Addr{Col: to, Row: MaxRows - 1}}408431 }409432 410433 // rowRect is the range covering whole rows from..to.411434 func rowRect(from, to int) Rect {412- return Rect{Addr{Row: from}, Addr{Col: MaxCols - 1, Row: to}}435+ return Rect{From: Addr{Row: from}, To: Addr{Col: MaxCols - 1, Row: to}}413436 }414437 415438 func union(a, b Rect) Rect {
M internal/sheet/history_test.go +1 -1
A internal/sheet/hotpath_stress_test.go +75 -0
@@ -0,0 +1,75 @@
1+//go:build stress2+3+// Stress benchmarks of the hot paths under every edit and every frame:4+// parsing formulas, recognizing typed values, and rendering numbers5+// through their formats.6+package sheet_test7+8+import (9+ "testing"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+var parseInputs = []string{15+ "=A1+B2*3",16+ "=SUM($A$1:A100)/COUNT(A1:A100)",17+ `=IF(AND(B2>0,C2<>""),VLOOKUP(B2,Data!A1:D500,3,FALSE),"none")`,18+ `=TEXT(DATE(2026,9,26),"yyyy-mm-dd")&" "&'Q3 plan'!B7`,19+ "=-2^2+1.5E-3*(A1..B3)%",20+}21+22+// BenchmarkParse lexes and parses a mix of formulas.23+func BenchmarkParse(b *testing.B) {24+ for b.Loop() {25+ for _, src := range parseInputs {26+ if _, err := sheet.Parse(src); err != nil {27+ b.Fatal(err)28+ }29+ }30+ }31+}32+33+// BenchmarkParseValue recognizes typed numbers, currency, dates and times.34+func BenchmarkParseValue(b *testing.B) {35+ inputs := []string{"1234.5", "$1,200.00", "12%", "9/26/2026", "2026-09-26", "Sep 26, 2026", "2:30 PM", "9/26/2026 14:30:05", "hello"}36+ for b.Loop() {37+ for _, s := range inputs {38+ sheet.ParseValue(s)39+ }40+ }41+}42+43+// BenchmarkFormatPattern renders numbers and dates through patterns, as44+// every visible formatted cell does each frame.45+func BenchmarkFormatPattern(b *testing.B) {46+ cases := []struct {47+ v float6448+ pat string49+ }{50+ {1234567.891, "#,##0.00"},51+ {-0.1234, "0.0%"},52+ {12345.678, "0.00E+00"},53+ {-1234.5, "$#,##0;($#,##0)"},54+ {46291.604, "m/d/yyyy h:mm:ss am/pm"},55+ {1.5, "[h]:mm:ss.00"},56+ {46291, "dddd, mmmm d, yyyy"},57+ }58+ for b.Loop() {59+ for _, c := range cases {60+ sheet.FormatPattern(c.v, c.pat)61+ }62+ }63+}64+65+// BenchmarkDisplay renders values the way the grid does, per visible cell.66+func BenchmarkDisplay(b *testing.B) {67+ num := sheet.Value{Kind: sheet.Number, Num: 1234.5678}68+ formats := []sheet.Format{{}, sheet.Preset(sheet.FmtNumber), sheet.Preset(sheet.FmtCurrency),69+ sheet.Preset(sheet.FmtAccounting), sheet.Preset(sheet.FmtDate), sheet.Preset(sheet.FmtPercent)}70+ for b.Loop() {71+ for _, f := range formats {72+ sheet.Display(num, f, 12)73+ }74+ }75+}
A internal/sheet/move.go +176 -0
@@ -0,0 +1,176 @@
1+package sheet2+3+import "github.com/FelineStateMachine/012/internal/formula"4+5+// Moving cells, as cut and paste does: formulas that read the moved cells6+// follow them.7+8+// Move moves the cells in src so its top-left corner lands on to, as9+// cut and paste does in Sheets. Formulas anywhere that referred to the10+// moved cells follow them; references to cells the move overwrote become11+// #REF!. It returns the destination range.12+func (s *Sheet) Move(src Rect, to Addr) (Rect, error) {13+ dc, dr := to.Col-src.From.Col, to.Row-src.From.Row14+ dst := Rect{From: to, To: Addr{Col: src.To.Col + dc, Row: src.To.Row + dr}}15+ if !dst.To.Valid() {16+ return Rect{}, ErrPasteEdge17+ }18+ shift := func(a Addr) Addr { return Addr{Col: a.Col + dc, Row: a.Row + dr} }19+ cell := func(a Addr) (Addr, bool) {20+ switch {21+ case src.Contains(a):22+ return shift(a), true23+ case dst.Contains(a):24+ return a, false25+ }26+ return a, true27+ }28+ rng := func(r Rect) (Rect, bool) {29+ if src.Contains(r.From) && src.Contains(r.To) {30+ return Rect{From: shift(r.From), To: shift(r.To)}, true31+ }32+ return r, true33+ }34+ label := "move " + src.String() + " to " + dst.String()35+ s.change(label, dst, func() {36+ s.remap(label, dst, cell, rng)37+ s.remapNames(rng) // a name for exactly the moved cells follows them38+ })39+ return dst, nil40+}41+42+// MoveTo moves the cells in src to sheet dst, src's top-left corner43+// landing on to, as cutting on one sheet and pasting on another does in44+// Sheets. Formulas anywhere that read the moved cells follow them to dst,45+// naming its sheet where they need to; the moved formulas keep reading46+// what they read, naming this sheet for cells that stayed behind.47+// References to cells the move overwrote become #REF!.48+func (s *Sheet) MoveTo(dst *Sheet, src Rect, to Addr) (Rect, error) {49+ if dst == s {50+ return s.Move(src, to)51+ }52+ dc, dr := to.Col-src.From.Col, to.Row-src.From.Row53+ d := Rect{From: to, To: Addr{Col: src.To.Col + dc, Row: src.To.Row + dr}}54+ if !d.To.Valid() {55+ return Rect{}, ErrPasteEdge56+ }57+ mv := sheetMove{from: s, to: dst, src: src, d: d}58+ moved, readers := mv.movedCells(), mv.readers()59+ label := "move " + src.String() + " to " + formula.QuoteSheet(dst.name) + "!" + d.String()60+ dst.change(label, d, func() { mv.apply(moved, readers) })61+ return d, nil62+}63+64+// sheetMove is the cells in src on one sheet moving to d on another.65+type sheetMove struct {66+ from, to *Sheet67+ src, d Rect68+}69+70+func (mv sheetMove) shift(a Addr) Addr {71+ return Addr{Col: a.Col + mv.d.From.Col - mv.src.From.Col, Row: a.Row + mv.d.From.Row - mv.src.From.Row}72+}73+74+// movedCells are the cells that move, by where they land, with their75+// references rewritten.76+func (mv sheetMove) movedCells() map[Addr]*Cell {77+ moved := map[Addr]*Cell{}78+ for _, a := range mv.from.cellsIn(mv.src) {79+ moved[mv.shift(a)] = mv.from.cells.get(a).rewritten(mv.rewrite(mv.from, mv.to))80+ }81+ return moved82+}83+84+// readers are the formulas that stay where they are, rewritten, among85+// those whose references the move changes: on either sheet, or on another86+// sheet naming one of them.87+func (mv sheetMove) readers() map[loc]*Cell {88+ out := map[loc]*Cell{}89+ add := func(l loc) {90+ if mv.src.Contains(l.a) && l.s == mv.from || mv.d.Contains(l.a) && l.s == mv.to {91+ return // moved or overwritten92+ }93+ c := l.s.cells.get(l.a)94+ if nc := c.rewritten(mv.rewrite(l.s, l.s)); nc != c {95+ out[l] = nc96+ }97+ }98+ for _, t := range []*Sheet{mv.from, mv.to} {99+ for a := range t.cells.all() {100+ add(loc{t, a})101+ }102+ }103+ for _, l := range mv.from.wb.crossList() {104+ if l.s != mv.from && l.s != mv.to {105+ add(l)106+ }107+ }108+ return out109+}110+111+// apply clears the source and destination, places the moved cells and112+// the rewritten readers, and moves the named ranges wholly inside the113+// source along with it.114+func (mv sheetMove) apply(moved map[Addr]*Cell, readers map[loc]*Cell) {115+ for _, a := range mv.from.cellsIn(mv.src) {116+ mv.from.place(a, nil)117+ }118+ for _, a := range mv.to.cellsIn(mv.d) {119+ mv.to.place(a, nil)120+ }121+ for a, c := range moved {122+ mv.to.place(a, c)123+ }124+ for l, c := range readers {125+ l.s.place(l.a, c)126+ }127+ w := mv.from.wb128+ for k, n := range w.names {129+ if n.Sheet == mv.from && !n.Lost && mv.src.Contains(n.Range.From) && mv.src.Contains(n.Range.To) {130+ n.Sheet, n.Range = mv.to, Rect{From: mv.shift(n.Range.From), To: mv.shift(n.Range.To)}131+ w.putName(k, &n)132+ }133+ }134+}135+136+// rewrite maps the references of a formula that was on orig and is on137+// home after the move. A reference names its sheet unless it points at138+// home.139+func (mv sheetMove) rewrite(orig, home *Sheet) formula.Rewriter {140+ w := mv.from.wb141+ // written is how a reference names now, having named was as written.142+ written := func(as string, was, now *Sheet) string {143+ switch {144+ case now == home:145+ return ""146+ case now == was && as != "":147+ return as148+ }149+ return now.name150+ }151+ return formula.Rewriter{152+ Ref: func(n formula.Ref) Node {153+ t := w.resolve(orig, n.Sheet)154+ switch {155+ case t == nil:156+ return n157+ case t == mv.from && mv.src.Contains(n.Addr):158+ return formula.Ref{Addr: mv.shift(n.Addr), Abs: n.Abs, Sheet: written(n.Sheet, t, mv.to)}159+ case t == mv.to && mv.d.Contains(n.Addr):160+ return formula.RefErr{}161+ }162+ return formula.Ref{Addr: n.Addr, Abs: n.Abs, Sheet: written(n.Sheet, t, t)}163+ },164+ Range: func(n formula.Range) Node {165+ t := w.resolve(orig, n.Sheet)166+ switch {167+ case t == nil:168+ return n169+ case t == mv.from && mv.src.Contains(n.Rect.From) && mv.src.Contains(n.Rect.To):170+ r := Rect{From: mv.shift(n.Rect.From), To: mv.shift(n.Rect.To)}171+ return formula.Range{Rect: r, Abs: n.Abs, Sheet: written(n.Sheet, t, mv.to)}172+ }173+ return formula.Range{Rect: n.Rect, Abs: n.Abs, Sheet: written(n.Sheet, t, t)}174+ },175+ }176+}
M internal/sheet/names.go +13 -31
@@ -6,6 +6,8 @@ import (
66 "maps"77 "slices"88 "strings"9+10+ "github.com/FelineStateMachine/012/internal/formula"911 )1012 1113 // Named ranges, as in Sheets' Data > Named ranges: a name such as Sales
@@ -66,32 +68,12 @@ func ValidName(name string) error {
6668 switch k := nameKey(name); {6769 case k == "TRUE" || k == "FALSE":6870 return errors.New("TRUE and FALSE can't be names")69- case looksLikeRef(k):71+ case formula.LooksLikeRef(k):7072 return errors.New("A name can't look like a cell reference, e.g. A1 or R1C1")7173 }7274 return nil7375 }7476 75-// looksLikeRef reports whether an upper-case name reads as a cell in A1 or76-// R1C1 style, even beyond this sheet's edges, so names stay unambiguous in77-// other spreadsheets too.78-func looksLikeRef(k string) bool {79- letters := strings.TrimLeft(k, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")80- if letters != k && letters != "" && strings.Trim(letters, "0123456789") == "" {81- return true82- }83- rest, ok := strings.CutPrefix(k, "R")84- if !ok {85- rest, ok = k, strings.HasPrefix(k, "C")86- }87- if !ok {88- return false89- }90- rest = strings.TrimLeft(rest, "0123456789")91- rest, _ = strings.CutPrefix(rest, "C")92- return strings.Trim(rest, "0123456789") == ""93-}94-9577 // Names returns the named ranges, sorted by name.9678 func (w *Workbook) Names() []Name {9779 // The keys are already upper case: sorting names by nameKey upper-cased
@@ -178,14 +160,14 @@ func (s *Sheet) EditName(old, name string, r Rect) error {
178160 // renameInFormulas rewrites the formulas that use the name with key from179161 // to spell it to instead.180162 func (w *Workbook) renameInFormulas(from, to string) {181- rw := refRewrite{name: func(n nameNode) Node {182- if nameKey(n.name) == from {183- return nameNode{to}163+ rw := formula.Rewriter{Name: func(n formula.Name) Node {164+ if nameKey(n.Name) == from {165+ return formula.Name{Name: to}184166 }185167 return n186168 }}187169 for _, l := range slices.Collect(maps.Keys(w.nameUsers[from])) {188- l.s.place(l.a, l.s.cells[l.a].rewritten(rw))170+ l.s.place(l.a, l.s.cells.get(l.a).rewritten(rw))189171 }190172 }191173
@@ -242,23 +224,23 @@ func (s *Sheet) bound(c *Cell) Node {
242224 if len(c.names) == 0 {243225 return c.expr244226 }245- const fixed = absCol | absRow246- n, _ := rewrite(c.expr, refRewrite{name: func(nn nameNode) Node {247- nm, ok := s.wb.names[nameKey(nn.name)]227+ const fixed = formula.AbsCol | formula.AbsRow228+ n, _ := formula.Rewrite(c.expr, formula.Rewriter{Name: func(nn formula.Name) Node {229+ nm, ok := s.wb.names[nameKey(nn.Name)]248230 switch {249231 case !ok:250232 return nn251233 case nm.Gone():252- return refErrNode{}234+ return formula.RefErr{}253235 }254236 sheet := ""255237 if nm.Sheet != s {256238 sheet = nm.Sheet.name257239 }258240 if nm.Range.From == nm.Range.To {259- return refNode{nm.Range.From, fixed, sheet}241+ return formula.Ref{Addr: nm.Range.From, Abs: fixed, Sheet: sheet}260242 }261- return rangeNode{nm.Range, [2]absFlags{fixed, fixed}, sheet}243+ return formula.Range{Rect: nm.Range, Abs: [2]formula.Abs{fixed, fixed}, Sheet: sheet}262244 }})263245 return n264246 }
M internal/sheet/nav.go +7 -20
@@ -5,7 +5,7 @@ package sheet
55 func (s *Sheet) UsedRange() (Rect, bool) {66 var last Addr77 found := false8- for a, c := range s.cells {8+ for a, c := range s.cells.all() {99 if c.Blank() {1010 continue1111 }
@@ -22,7 +22,7 @@ func (s *Sheet) UsedRange() (Rect, bool) {
2222 // block, it goes to the next filled cell. With nothing filled ahead it2323 // stops at the edge of the worksheet. Rows the filter hides are skipped.2424 func (s *Sheet) Edge(a Addr, dc, dr int) Addr {25- filled := func(a Addr) bool { return !s.cells[a].Blank() }25+ filled := func(a Addr) bool { return !s.cells.get(a).Blank() }2626 step := func(a Addr) Addr {2727 n := Addr{Col: a.Col + dc, Row: a.Row + dr}2828 for dr != 0 && n.Valid() && s.RowHidden(n.Row) {
@@ -72,10 +72,8 @@ type statsCache struct {
7272 ok bool7373 }7474 75-// RangeStats computes Stats over r, visiting whichever is cheaper: the76-// cells in r one lookup at a time, or every stored cell (iterating the77-// map costs about a quarter of a lookup per cell). The result is kept78-// until a cell or value changes.75+// RangeStats computes Stats over r. The result is kept until a cell or76+// value changes.7977 func (s *Sheet) RangeStats(r Rect) Stats {8078 if c := s.stats; c.ok && c.r == r && c.version == s.version {8179 return c.st
@@ -88,20 +86,9 @@ func (s *Sheet) RangeStats(r Rect) Stats {
8886 st.Sum += c.Value.Num8987 }9088 }91- area := (r.To.Col - r.From.Col + 1) * (r.To.Row - r.From.Row + 1)92- if area <= len(s.cells)/4 {93- for row := r.From.Row; row <= r.To.Row; row++ {94- for col := r.From.Col; col <= r.To.Col; col++ {95- if c := s.cells[Addr{Col: col, Row: row}]; !c.Blank() {96- add(c)97- }98- }99- }100- } else {101- for a, c := range s.cells {102- if r.Contains(a) && !c.Blank() {103- add(c)104- }89+ for _, c := range s.cells.inRange(r) {90+ if !c.Blank() {91+ add(c)10592 }10693 }10794 s.stats = statsCache{r: r, version: s.version, st: st, ok: true}
M internal/sheet/number.go +12 -81
@@ -1,10 +1,11 @@
11 package sheet22 33 import (4- "math"54 "strconv"65 "strings"76 "unicode/utf8"7+8+ "github.com/FelineStateMachine/012/internal/numfmt"89 )910 1011 // ParseNumber recognizes numbers the way Google Sheets does on entry:
@@ -119,22 +120,25 @@ func Display(v Value, f Format, width int) (string, Align) {
119120 }120121 switch f.Kind {121122 case FmtAuto:122- return formatGeneral(v.Num, inner), AlignRight123+ return numfmt.GeneralFit(v.Num, inner), AlignRight123124 case FmtText:124- return numString(v.Num), AlignLeft125+ return numfmt.General(v.Num), AlignLeft125126 case FmtAccounting:126- if s, ok := accounting(v.Num, f.Decimals, width); ok {127+ if s, ok := numfmt.Accounting(v.Num, f.Decimals, width); ok {127128 return s, AlignFill128129 }129130 return strings.Repeat("#", inner), AlignRight130131 }131- s := FormatPattern(v.Num, f.pattern())132+ s := numfmt.Format(v.Num, f.pattern())132133 if utf8.RuneCountInString(s) > inner {133134 s = strings.Repeat("#", inner)134135 }135136 return s, AlignRight136137 }137138 139+// FormatPattern renders v with a number format pattern, as TEXT() does.140+func FormatPattern(v float64, pat string) string { return numfmt.Format(v, pat) }141+138142 // FormatText renders v under f with no width limit, e.g. for TEXT() or139143 // copying out of the grid.140144 func FormatText(v Value, f Format) string {
@@ -143,41 +147,9 @@ func FormatText(v Value, f Format) string {
143147 }144148 switch f.Kind {145149 case FmtAuto, FmtText:146- return numString(v.Num)147- }148- return FormatPattern(v.Num, f.pattern())149-}150-151-// accounting lays out Sheets' Accounting format in width columns: the $152-// at the left, the number right-aligned with room for a closing153-// parenthesis, negatives in parentheses and zero as a dash.154-func accounting(v float64, dec, width int) (string, bool) {155- ip, fp := fixed(v, dec, roundHalfUp)156- var num string157- switch {158- case strings.Trim(ip+fp, "0") == "":159- num = "-" + strings.Repeat(" ", dec) + " "160- case v < 0:161- num = "(" + FormatPattern(-v, "#,##0"+decimals(dec)) + ")"162- default:163- num = FormatPattern(v, "#,##0"+decimals(dec)) + " "164- }165- gap := width - 1 - len(num)166- switch {167- case gap < 0:168- return "", false169- case gap >= 1:170- // Leave a column of padding before the $, like other cells.171- return " $" + strings.Repeat(" ", gap-1) + num, true172- }173- return "$" + strings.Repeat(" ", gap) + num, true174-}175-176-func decimals(n int) string {177- if n <= 0 {178- return ""150+ return numfmt.General(v.Num)179151 }180- return "." + strings.Repeat("0", n)152+ return numfmt.Format(v.Num, f.pattern())181153 }182154 183155 // FormatValue renders v in width columns with one column of padding, the
@@ -198,45 +170,7 @@ func FormatValue(v Value, width int) string {
198170 // FormatNumber formats a number in the General format within width199171 // columns, for use outside the grid (e.g. the status line).200172 func FormatNumber(v float64, width int) string {201- return formatGeneral(v, width)202-}203-204-// formatGeneral mimics General number format: as many digits as fit,205-// rounding decimals first, then scientific notation, then #s.206-func formatGeneral(v float64, width int) string {207- if width <= 0 {208- return ""209- }210- s := numString(v)211- if strings.ContainsRune(s, 'E') {212- s = strconv.FormatFloat(v, 'f', -1, 64)213- }214- if len(s) <= width {215- return s216- }217- // Try fewer decimals before falling back to scientific notation, but218- // never round a non-zero number down to zero.219- if dot := strings.IndexByte(s, '.'); dot >= 0 && dot <= width {220- for prec := max(width-dot-1, 0); prec >= 0; prec-- {221- f := strconv.FormatFloat(v, 'f', prec, 64)222- if len(f) > width {223- continue224- }225- if r, _ := strconv.ParseFloat(f, 64); r != 0 || v == 0 {226- if strings.Contains(f, ".") {227- f = strings.TrimRight(strings.TrimRight(f, "0"), ".")228- }229- return f230- }231- break232- }233- }234- for prec := width; prec >= 0; prec-- {235- if e := strconv.FormatFloat(v, 'E', prec, 64); len(e) <= width {236- return e237- }238- }239- return strings.Repeat("#", width)173+ return numfmt.GeneralFit(v, width)240174 }241175 242176 func padLeft(s string, width int) string {
@@ -253,6 +187,3 @@ func centerPad(s string, width int) string {
253187 pad := width - len(s)254188 return strings.Repeat(" ", pad/2) + s + strings.Repeat(" ", pad-pad/2)255189 }256-257-// isWhole reports whether v is an integer.258-func isWhole(v float64) bool { return v == math.Trunc(v) }
D internal/sheet/numfmt.go +0 -678

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/observe.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/ops.go +37 -177

Not shown: this page already shows 6000 lines of diff.

D internal/sheet/parse.go +0 -466

Not shown: this page already shows 6000 lines of diff.

A internal/sheet/recalc.go +298 -0

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/refs.go +6 -197

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/refs_test.go +0 -142

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/remote.go +23 -14

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/series.go +12 -13

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/series_test.go +4 -4

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/sheet.go +23 -248

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/sheet_test.go +0 -40

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/sort.go +28 -22

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/sort_test.go +3 -3

Not shown: this page already shows 6000 lines of diff.

A internal/sheet/store.go +61 -0

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/trace.go +6 -4

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/view.go +6 -4

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/workbook.go +26 -96

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/workbook_test.go +0 -56

Not shown: this page already shows 6000 lines of diff.

M internal/ui/file.go +3 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/jev.go +3 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/jev_test.go +0 -6

Not shown: this page already shows 6000 lines of diff.

M internal/ui/stress_test.go +0 -3

Not shown: this page already shows 6000 lines of diff.