- commit
- c45e7e8cb7f485f18d6cb54635c7a70940729842
- parent
- a68f1ee41377c09c2869df754fe9c21a11e2ab5e
- parent
- fd7abaa18a86132118ab63b9acc1bb5d158ea94d
- author
- Dami <dami.etoile@gmail.com>
@@ -7,7 +7,7 @@ GHOSTTY_SRC := $(DEPS)/ghostty-src
77 GHOSTTY_OUT := $(DEPS)/ghostty88 GHOSTTY_STAMP := $(GHOSTTY_OUT)/.built-$(GHOSTTY_COMMIT)99 10-10+1111 1212 build:1313 CGO_ENABLED=0 go build -o bin/012 ./cmd/012@@ -25,13 +25,14 @@ test:
2525 STRESS_DIR=$(DEPS)/stress go test ./...2626 2727 # Everything that must pass before a push: formatting, vet (also with the28-# stress benchmarks), shape limits, unit tests, the excelize oracle and the29-# end-to-end tests in libghostty.28+# stress benchmarks), shape limits, unit tests, the speed gate, the29+# excelize oracle and the end-to-end tests in libghostty.3030 check:3131 @test -z "$$(gofmt -l cmd internal demos e2e oracle)" || { gofmt -l cmd internal demos e2e oracle; echo "gofmt: files above need formatting"; exit 1; }3232 go vet -tags stress ./...3333 $(MAKE) lint3434 STRESS_DIR=$(DEPS)/stress go test ./...35+ $(MAKE) speed3536 $(MAKE) oracle3637 $(MAKE) e2e3738 @@ -40,9 +41,20 @@ lint:
4041 go vet ./...4142 scripts/lint.sh4243 44+# The speed gate: frames and recalculation on mid-sized sheets against45+# internal/ui/testdata/speed.json, in about two seconds, alone so that46+# no other package's tests share the CPU. speed-update rewrites the47+# baseline. See docs/contributing/limits.md#the-speed-gate.48+speed:49+ go test ./internal/ui -run '^TestSpeed$$' -count=1 -speed50+51+speed-update:52+ go test ./internal/ui -run '^TestSpeed$$' -count=1 -speed-update -v53+4354 fuzz:4455 go test ./internal/sheet -run '^$$' -fuzz FuzzParse -fuzztime 60s4556 go test ./internal/sheet -run '^$$' -fuzz FuzzRead -fuzztime 60s57+ go test ./internal/sheet -run '^$$' -fuzz FuzzRandomEdits -fuzztime 60s4658 go test ./internal/fileio -run '^$$' -fuzz FuzzReadDelimited -fuzztime 60s4759 go test ./internal/fileio -run '^$$' -fuzz FuzzReadWK1 -fuzztime 60s4860 go test ./internal/fileio -run '^$$' -fuzz 'FuzzReadXLSX$$' -fuzztime 60s@@ -57,11 +57,8 @@ shared editing in section 3.
5757 5858 | Item | Result | Size |5959 |---|---|---|60-| Files saved by each release kept as fixtures that every later build opens and saves unchanged; the `.012` format written down with a version | Old files keep opening | S |61-| Random sequences of edits, sorts, fills and region runs checked for invariants: undo all restores the start, save and reopen match, a full recalc agrees with the incremental one | Bugs between features found before users hit them | M |6260 | A workbook of formulas where 012 matches Sheets on purpose (dates, text, rounding, errors, spills), results checked once in Sheets and asserted after; differences chosen on purpose listed in the docs | Formula results people rely on stay put | S |63-| Frame time and recalculation checked in `make check` against a baseline with a noise margin | Slowdowns fail the check, not a later benchmark | S |64-| A panic in any session saves the workbook for recovery and writes a short report, tested end to end | A crash loses nothing | S |61+| Cycles found from the formulas as written rather than as evaluation walks them: a cycle behind an error that ends a range read or an IF branch not taken, and arrays and notebook outputs in each other's way, settle the same whatever order they're computed in (`-randedit.seeds=20000` still finds about one sequence in 2,500 that reopens differently, and `randKnown` skips one of the default seeds) | Every workbook reads the same when reopened | M |6562 | A week of real use by the owner, problems triaged into this page | Rough edges found by use | S |6663 6764 ### 5. Around the grid@@ -147,6 +144,8 @@ stream above rather than a design of their own.
147144 - Commands for scripts without the screen: `012 get` (text, CSV, TSV, JSON or NUON), `012 set`, `012 recalc` and `012 export`, running notebooks and JEV only behind flags: [Scripts](docs/files/scripts.md)148145 - `012 diff` cell by cell, as git's diff command or textconv, and `012 merge-driver` merging cell by cell with conflicts noted on the cells: [Diff and merge in git](docs/files/git.md)149146 - Following files: a linked region follows a CSV, TSV, JSON lines or NUON file as it grows and any importable file as it's rewritten, keeping every row or the last ones, its rows arriving as the change stream: [Following files](docs/files/following.md)147+- The `.012` format written down with a version, and each release's workbooks kept as fixtures that every later build opens and saves unchanged: [The .012 format](docs/files/format.md#versions), [Releasing](docs/contributing/releasing.md#file-fixtures)148+- A crash keeps unsaved work for recovery, restores the terminal and writes a report, locally and in `012 serve`: [Saving](docs/files/saving.md#if-012-crashes)150149 151150 **Upkeep**152151 @@ -155,3 +154,5 @@ stream above rather than a design of their own.
155154 - `make stress-report` flags regressions against the last release's run, allowing for noise: [Observability](docs/contributing/observability.md#regressions-against-the-last-release)156155 - Grafana: recent traces and a trace view of the nested spans: [Observability](docs/contributing/observability.md#the-stack)157156 - Every overlay, prompts and formula suggestions behind narrow hosts, most in packages of their own with fake-host tests: [Architecture](docs/contributing/architecture.md#the-ui)157+- Random sequences of edits, pastes, fills, sorts, inserts, formats and region runs checked for undo, reopening and full recalculation agreeing: [Testing](docs/contributing/testing.md#unit-tests)158+- Frame time and recalculation held to a checked-in baseline in `make check`: [Bounds of support](docs/contributing/limits.md#the-speed-gate)@@ -0,0 +1,78 @@
1+package main2+3+import (4+ "errors"5+ "fmt"6+ "path/filepath"7+ "strings"8+ "time"9+10+ "github.com/FelineStateMachine/012/internal/config"11+ "github.com/FelineStateMachine/012/internal/fileio"12+ "github.com/FelineStateMachine/012/internal/ui"13+)14+15+// When the UI stops on an internal error (a panic, which ui.Guard16+// catches), the terminal is already restored; what's left is to keep17+// the unsaved work where the next start on that file offers it back,18+// write a report, and say where both are. See docs/files/saving.md.19+20+// recoveryDir is where local sessions keep unsaved work after a crash,21+// <config.Dir>/recovery; "" when there's no config directory.22+func recoveryDir() string {23+ d, err := config.Dir()24+ if err != nil {25+ return ""26+ }27+ return filepath.Join(d, "recovery")28+}29+30+// crashDir is where crash reports go, <config.Dir>/crashes.31+func crashDir() string {32+ d, err := config.Dir()33+ if err != nil {34+ return ""35+ }36+ return filepath.Join(d, "crashes")37+}38+39+// offersKept reports whether a session started on args, reading40+// standard input or not, offers the changes a crash kept: when it opens41+// a .012 file or nothing, not when it imports a file or reads a table.42+func offersKept(args []string, stdin bool) bool {43+ if stdin {44+ return false45+ }46+ if len(args) == 1 {47+ if _, imports := fileio.KindOf(args[0]); imports {48+ return false49+ }50+ }51+ return true52+}53+54+// crashed keeps the unsaved work of the program g ran, writes the55+// report, and returns the error that tells the user where both are.56+func crashed(g *ui.Guarded, c *ui.Crash) error {57+ now := time.Now()58+ m := g.Model()59+ kept, keepErr := g.Keep(now)60+ lines := []string{fmt.Sprintf("stopped on an internal error (a panic in %s: %v)", c.Where, c.Value)}61+ switch {62+ case keepErr != nil:63+ lines = append(lines, "couldn't keep the unsaved changes: "+keepErr.Error())64+ case kept != "" && m.Filename() == "":65+ lines = append(lines, "unsaved changes kept in "+kept+"; start 012 again to restore them")66+ case kept != "":67+ lines = append(lines, "unsaved changes kept in "+kept+"; open "+m.Filename()+" again to restore them")68+ }69+ if dir := crashDir(); dir != "" {70+ report, err := c.Report(dir, buildVersion(), m.Filename(), kept, keepErr, now)71+ if err != nil {72+ lines = append(lines, "couldn't write a report: "+err.Error())73+ } else {74+ lines = append(lines, "a report is in "+report+"; include it when reporting the problem")75+ }76+ }77+ return errors.New(strings.Join(lines, "\n012: "))78+}@@ -144,6 +144,8 @@ func run(args []string, e env) error {
144144 Keys: keyStore(cfg, e),145145 Connect: func(key string) (jev.Client, error) { return jev.NewClient(jevConfig(cfg, key)) },146146 Notes: configNotes(cfg),147+ // Where a crash keeps unsaved work: crash.go.148+ RecoveryDir: recoveryDir(),147149 }148150 client, notes := startJEV(cfg, e, settings.Keys)149151 settings.Notes = append(settings.Notes, notes...)@@ -154,13 +156,20 @@ func run(args []string, e env) error {
154156 if notebook {155157 m.OpenNotebook()156158 }159+ if offersKept(args, pipe.stdin) {160+ m.OfferKept()161+ }157162 setPipe(m, pipe, e.stdin)158163 opts, closeTTY, err := tuiOptions(pipe, e)159164 if err != nil {160165 return err161166 }162- err = e.runTUI(m, opts...)167+ g := ui.Guard(m)168+ err = e.runTUI(g, opts...)163169 closeTTY()170+ if c := g.Finish(err); c != nil {171+ return crashed(g, c)172+ }164173 if err != nil {165174 return err166175 }@@ -625,3 +625,48 @@ overhead), `internal/fileio/stress_test.go` (imports and exports),
625625 `internal/stress`, up to `dense-1Mx10`, ten million numbers, the626626 default `max-cells`. Results accumulate in `.deps/stress/results` and, with627627 the observability stack up, in ClickHouse.628+629+### The speed gate630+631+`make speed`, part of `make check`, catches a clear slowdown before a632+push rather than at the next stress run. `TestSpeed`633+(`internal/ui/speed_test.go`) builds mid-sized sheets with634+`internal/stress` and draws frames through the stress benchmarks' fake635+terminal, so it measures what `BenchmarkKeystroke` and `BenchmarkEdit`636+do, on fewer cases and briefly: an arrow key through to its frame at637+200 x 60 (dense, laid out, color scale and chart sheets of 8192 rows),638+single edits (fan-in, a chain, criteria functions, lookups) and full639+recalculations (running totals, arrays). It takes about two seconds.640+641+Each case is held to `internal/ui/testdata/speed.json` two ways:642+643+- **Allocations** an operation, counted with `testing.AllocsPerRun`,644+ so the same code gives the same count on any machine and under any645+ load. A case fails past 1.25 times its baseline plus 16.646+- **Time** relative to a calibration workload (formatting numbers,647+ string map lookups, sorting) timed beside it in the same process,648+ the fastest of seven timings of each, so a slower or busier machine649+ slows both. A case fails past twice its baseline's ratio. Ratios650+ still differ between CPUs, so they are compared only on the651+ baseline's OS and architecture (the file records them); elsewhere652+ only allocations are.653+654+A case over its margin is measured once more before it fails. Runs on655+one machine vary by up to about a third, under load from other tests656+too, well inside the margin; the margins are for regressions of a657+multiple, and finer ones are what `make stress-report` is for.658+659+The gate runs alone (`go test ./...` skips `TestSpeed` without660+`-speed`), and while it times it holds `/tmp/012-bench.lock`, the lock661+other timed runs on the machine take: with flock(2) when the path is a662+file (`flock /tmp/012-bench.lock make stress` where the `flock` command663+exists), and otherwise as a directory it makes and removes, which needs664+no `flock` command. It waits up to ten minutes for a directory someone665+else holds.666+667+When it fails, run `make speed` again on a quiet machine, then look at668+the case with the stress benchmarks (`BENCH='Keystroke|Edit' make669+stress`). A slowdown that is intended, or a new case, is taken into the670+baseline with `make speed-update`, which measures every case and671+rewrites `speed.json` for the machine it runs on; commit it with the672+change that explains it.@@ -21,7 +21,8 @@ flowchart TD
2121 small -->|no| stress[make stress, make stress-report]2222 small -->|yes| xlsx2323 stress -->|the run recorded on the commit| xlsx[XLSX opened in Excel, LibreOffice, Sheets]24- xlsx --> tag[git tag -a, git push]24+ xlsx --> fixtures[this release's file fixtures committed]25+ fixtures --> tag[git tag -a, git push]2526 tag -->|the module proxy| install[go install ...@v1.2.3]2627 tag --> dist[make dist]2728 dist --> archives[dist/: archives, SHA256SUMS]@@ -42,7 +43,9 @@ flowchart TD
4243 Excel, LibreOffice Calc and Google Sheets: it opens without a repair4344 prompt, and values, formulas and formats read as they did in 012. The4445 automated checks read XLSX with excelize and 012's own reader only.45-4. **Tag** with the release notes as the message, what changed since the46+4. **File fixtures**: the workbooks this release saves, committed (see47+ [File fixtures](#file-fixtures)).48+5. **Tag** with the release notes as the message, what changed since the4649 previous tag for a user, with the install line last:4750 4851 ```sh@@ -52,7 +55,36 @@ flowchart TD
5255 5356 The Go module proxy picks the tag up, so `go install5457 github.com/FelineStateMachine/012/cmd/012@v1.2.3` works from then on.55-5. **Binaries**: `make dist VERSION=v1.2.3` on the tagged commit.58+6. **Binaries**: `make dist VERSION=v1.2.3` on the tagged commit.59+60+## File fixtures61+62+`internal/sheet/testdata/fixtures` holds workbooks as each release saved63+them, a folder per release (`v0.2.0/`), and64+`TestFixturesOpenAndSaveUnchanged` opens every one and saves it again,65+failing unless the bytes come back the same. A later build that can't66+open a release's file, or saves it differently, fails `make check`67+([The .012 format](../files/format.md#versions) says what that68+promises).69+70+The workbooks a release saves are the ones in `fixtures/new`, written by71+hand to use everything the format stores: formats, entries that read72+against their format, column and row formats, widths, heights, merges,73+borders, freeze, filters, names, notes, protection, rules and74+validation, charts, pivot tables, a notebook tab's cells and outputs,75+outputs sent to a sheet, linked files, macros, the locale and decimal76+arithmetic. A release that adds to the format adds what it adds there.77+Then, on the commit to be tagged:78+79+```sh80+go test ./internal/sheet -run TestFixtures -fixtures v1.2.381+```82+83+saves them with that build into `fixtures/v1.2.3`, to commit with the84+release. The test refuses a folder that exists. A released folder is85+never edited or replaced: when a later build saves one differently, the86+build is what changes. The folder of a release not yet tagged may be87+deleted and saved again.5688 5789 ## make dist5890 @@ -6,10 +6,11 @@ sidebar_position: 5
66 # Testing77 88 ```sh9-make check # all of the below that must pass before a push: gofmt, vet, lint, test, oracle, e2e9+make check # all of the below that must pass before a push: gofmt, vet, lint, test, speed, oracle, e2e1010 make lint # go vet, staticcheck, cognitive complexity at most 25, Go files at most 500 lines, doclint and doccheck (see Docs below)1111 make test # engine, file formats and UI unit tests12-make fuzz # fuzz the formula parser and the .012, CSV, .wk1, XLSX and NUON readers12+make fuzz # fuzz the formula parser, random edits, and the .012, CSV, .wk1, XLSX and NUON readers13+make speed # frames and recalculation against a checked-in baseline (see limits.md)1314 make oracle # compare formulas and number formats with excelize1415 make e2e # run the real binary in a terminal emulator (needs Zig and pkg-config)1516 make screens # rewrite the golden screens and build the review gallery@@ -22,7 +23,31 @@ make site # build the docs site, failing on broken links (needs Node; see si
2223 The engine is tested directly: parsing, every function, recalculation,2324 undo, reference rewriting, file round trips. `FuzzRead` holds the2425 streaming `.012` reader to encoding/json decoding the whole file: both25-refuse a file or both read it into workbooks that save the same bytes. The UI is tested by sending26+refuse a file or both read it into workbooks that save the same bytes.27+`TestFixturesOpenAndSaveUnchanged` opens the workbooks every release28+saved and saves them again, byte for byte29+([File fixtures](releasing.md#file-fixtures)).30+31+`TestRandomEdits` (`internal/sheet/randedit_test.go`) checks what holds32+between features: from each of a thousand seeds it builds a workbook33+of two sheets and a notebook tab, makes up to 40 edits drawn from the34+seed (entries, pastes, fills, series, sorts, inserted and deleted rows35+and columns, formats, borders, merges, moves, notes, rules, names,36+sheets, frozen panes, filters, notebook cells, and outputs sent to37+sheets, frozen, deleted and sent rows, with tables standing in for38+nushell's), and checks that undoing every step gives back the file and39+the values it started with, redoing gives back the end, saving and40+reopening gives the same file and values (a recalculation from41+scratch), and recalculating everything in place changes no value.42+Outputs' rows come from outside the undo history, so the test sends43+them again after each edit, undo and reopening, as the UI does. A44+failure names the fewest of the seed's edits that still fail. It takes45+about a second; `-randedit.seeds=20000` runs more, and46+`FuzzRandomEdits` (in `make fuzz`) lets the fuzzer choose the edits.47+Seeds that fail for a reason on the roadmap are listed in `randKnown`,48+with it.49+50+The UI is tested by sending2651 Bubble Tea messages (keys, mouse, paste, window size) to the model and2752 reading what `View` renders, without a terminal.2853 @@ -42,7 +67,10 @@ that macros' trust uses are the test's own: sessions that share a
4267 directory are one computer. The binary is built with `-tags4368 fakekeyring`, whose credential store is a file there, so tests never4469 read your config or touch your keychain. Unit tests do the same with45-`ui.Settings` and `keyring.Memory`.70+`ui.Settings` and `keyring.Memory`. The binary is also built with `-tags71+crashtest`, where F12 panics in the update, the view or a command, as72+the session's environment says (`internal/ui/crashtest.go`), so `e2e/crash_test.go` can check that a crash73+restores the terminal and keeps the work; release builds have no such key.4674 4775 `e2e/ssh_test.go` runs `012 serve` and reaches it with the system's4876 `ssh` client inside the same libghostty terminal, so the server path@@ -44,7 +44,9 @@ file's rows, typed as an import types them. The region is the file's:
4444 - it grows to the right and down as the file does. Where it would write4545 over a cell holding something, its rows aren't shown, and its first4646 cell (`#REF!`) and the context line say which cell is in the way;47- clearing that cell reads the file again;47+ clearing that cell reads the file again. An [array](../formulas/arrays.md)'s48+ cells, blank ones too, are in its way, and an array that needs its49+ cells takes them, whichever came first, as when the file opens;4850 - inserting or deleting rows or columns before it moves it, and it reads4951 the file again there; deleting its first cell's row or column unlinks5052 it.@@ -6,7 +6,9 @@ sidebar_position: 4
66 # The .012 format77 88 `.012` files are JSON with one line per cell, keyed by address, so diffs9-read naturally and files merge reasonably in version control. A cell9+read naturally and files merge reasonably in version control. This page10+is the format at version 5, the newest; [Versions](#versions) says what11+each version adds and what 012 promises about old files. A cell1012 without formatting is just what was typed; a formatted cell is a small1113 object:1214 @@ -48,6 +50,13 @@ spacing, and the case of field names don't matter. A cell given twice
4850 takes its last entry, though an invalid earlier one is refused; the4951 `version` or the `sheets` list given twice around cells is refused.5052 53+012 writes one form of each workbook: the fields in the order this page54+shows them, cells by row and then column, widths and heights by55+position, column and row formats with the whole sheet's first, then56+columns', then rows', names by name, and charts, rules, regions and57+macros in the order they were made. Saving a file 012 wrote, without58+changing it, gives back the same bytes.59+5160 012 reads and writes the file as a stream, each cell straight into or5261 out of its sheet, so opening a file takes about the memory of the5362 workbook however large the file; what that costs, and why there is no@@ -69,6 +78,21 @@ still load.
6978 | 5 | A pivot table | a sheet's `pivot`; see [Pivot tables](#pivot-tables) |7079 | 6 | A table | a sheet's `tables`; see [Tables](#tables) |7180 81+What stays the same from release to release:82+83+- Every file a release saved opens in every later build, and saving it84+ unchanged gives back its bytes. Each release keeps workbooks it saved85+ as fixtures (`internal/sheet/testdata/fixtures/<release>`), which every86+ build's tests open and save again87+ ([Releasing](../contributing/releasing.md#file-fixtures)).88+- Fields are only added, never renamed or given another meaning; the89+ version rises only for a field older builds can't do without, and the90+ rest are listed under [Fields that need no version](#fields-that-need-no-version).91+- An older build ignores a field it doesn't know, but refuses a value it92+ doesn't know in a field it does (a chart type, a rule's condition such93+ as a date period), rather than open the file with the rule or chart94+ quietly changed.95+7296 ## Fields that need no version7397 7498 Builds that don't know these fields ignore them (and drop them if they@@ -78,6 +102,7 @@ save), so they raise no version:
78102 |---|---|---|79103 | `note` | a cell | Its [note](../sheets/notes-protection.md#notes) |80104 | `own` | a cell | `true` when its formatting is its own, not its column's or row's (Automatic in a currency column) |105+| `text` | a cell | How its `input` reads when its `format` would say otherwise: `true` for text typed into a Plain text cell whose format changed since (`{"input":"007","text":true}`), `false` for a number or formula typed before the cell was formatted as Plain text; either stays as typed, as in Sheets. Older builds read the input as the format says |81106 | `wrap`, `borders` | a cell or a line | How its text [wraps](../sheets/formatting.md#wrapping) and its [borders](../sheets/formatting.md#borders); older builds show the text overflowing, without lines |82107 | `valign`, and colors in `borders` | a cell or a line | Where its text sits in a [tall row](../sheets/formatting.md#vertical-alignment), and its borders' colors; older builds show the text at the bottom and the lines in the text's ink |83108 | `lines` | a sheet | [Column and row formats](#column-and-row-formats) |@@ -17,3 +17,35 @@ Under `012 serve`, a session that idles out or is ended by the server
1717 stopping keeps its unsaved changes in `.012-recovery/` in the served1818 directory, and the next session opening that file offers them back: see1919 [Serving over SSH](../terminal/ssh.md#unsaved-work).20+21+## If 012 crashes22+23+An internal error (a bug, a Go panic) stops 012 rather than leaving24+the terminal broken: the screen goes back to what the shell showed,25+and 012 says what happened and where it put things:26+27+```28+012: stopped on an internal error (a panic in update: ...)29+012: unsaved changes kept in ~/.config/012/recovery/%2FUsers%2Fann%2Fbudget-20260929-101112.012; open budget.012 again to restore them30+012: a report is in ~/.config/012/crashes/crash-20260929-101112.txt; include it when reporting the problem31+```32+33+- **Unsaved changes** are written, the whole workbook, to `recovery/`34+ in 012's config directory (the one `012 config path` prints the35+ [config file](../reference/config.md) of, `~/.config/012` on Linux),36+ named after the file's full path. Opening that37+ file again, or starting `012` on nothing for a sheet never saved,38+ asks on the context line: Enter restores them as unsaved changes to39+ the file, D deletes them, Esc leaves them for next time. Saving a40+ restored workbook removes its recovery file. Nothing is kept without41+ unsaved changes, and only the newest three files per name.42+- **The report** goes to `crashes/` beside it: where the error43+ happened, the error, its stack of calls, 012's version, the system,44+ the file's name and where its changes went, never what the workbook45+ holds. The newest ten are kept.46+47+If the error happened while drawing the screen, the terminal also48+shows the stack of calls above those lines. Under `012 serve`, the49+session's changes go to `.012-recovery/` as above, the client is told50+that 012 stopped on an internal error, and the report goes to the51+server's log.@@ -47,7 +47,7 @@ text. Errors use Sheets' codes:
4747 | `#DIV/0!` | division by zero |4848 | `#VALUE!` | the wrong kind of value, e.g. text in arithmetic |4949 | `#NAME?` | an unknown name |50-| `#REF!` | a reference to deleted cells, or a circular reference |50+| `#REF!` | a reference to deleted cells, or a circular reference: every cell of the cycle, even one whose formula catches errors (`IFERROR`) or skips them (`COUNTIF`), as in Sheets |5151 | `#N/A` | not available, e.g. a lookup found nothing |5252 | `#NUM!` | a number out of range, e.g. `SQRT(-1)` |5353 @@ -61,7 +61,12 @@ When a cell in the way of an array isn't empty, the formula shows
6161 `#REF!` and says why, as Sheets does: "Array result was not expanded6262 because it would overwrite data in C3". Clearing that cell lets the6363 array spill. The same goes for an array that would pass the sheet's64-edge, or write more cells than `max-cells` allows.64+edge, or write more cells than `max-cells` allows. When two arrays need65+the same cells, blank ones of theirs included, the one whose formula66+comes first, row by row, spills and the other shows `#REF!`, whichever67+was typed first, so a sheet looks the same when opened again. An array that would68+spill into cells its formula reads, `=SORT(B8:D9)` in A9, is a circular69+dependency and shows `#REF!` too.6570 6671 ## Names in a formula: LET and LAMBDA6772 @@ -165,8 +165,9 @@ the cell being written; without it, 012's own highlighting answers.
165165 ## Unsaved work166166 167167 A session that ends without the user quitting, because it was idle for168-`--idle-timeout` or because the server is stopping (Ctrl+C, SIGTERM),169-keeps its unsaved changes: the whole workbook is written to168+`--idle-timeout`, because the server is stopping (Ctrl+C, SIGTERM), or169+because 012 [stopped on an internal error](../files/saving.md#if-012-crashes)170+(whose report goes to the server's log), keeps its unsaved changes: the whole workbook is written to170171 `.012-recovery/<name>-<time>.012` in the served directory, and the171172 client's terminal is told where before the connection closes:172173 @@ -0,0 +1,61 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ ghostty "go.mitchellh.com/libghostty"10+)11+12+// A panic stops 012 with the terminal restored (the primary screen, the13+// cursor shown), the unsaved work kept, and a report written, their14+// paths printed. Opening the file again offers the work back. The15+// binary is built with -tags crashtest, where F12 panics where16+// O12_CRASH_TEST says (internal/ui/crashtest.go).17+func TestPanicKeepsWork(t *testing.T) {18+ for _, where := range []string{"update", "command", "view"} {19+ t.Run(where, func(t *testing.T) {20+ dir := t.TempDir()21+ o := options{dir: dir, env: []string{"O12_CRASH_TEST=" + where}}22+ s := startWith(t, o, "budget.012")23+ s.keys("kept", "<enter>")24+ s.waitFor("kept")25+ s.keys("<f12>")26+ s.waitExit()27+ if scr := s.activeScreen(); scr != ghostty.ScreenPrimary {28+ t.Errorf("active screen after the crash = %v, want primary", scr)29+ }30+ s.mu.Lock()31+ visible, _ := s.vt.CursorVisible()32+ s.mu.Unlock()33+ if !visible {34+ t.Error("the cursor is hidden after the crash")35+ }36+ text := strings.ReplaceAll(s.screen(), "\n", "")37+ recovery, _ := filepath.Glob(filepath.Join(dir, ".config", "012", "recovery", "*budget-*.012"))38+ reports, _ := filepath.Glob(filepath.Join(dir, ".config", "012", "crashes", "crash-*.txt"))39+ if len(recovery) != 1 || len(reports) != 1 {40+ t.Fatalf("recovery files %v, reports %v; screen:\n%s", recovery, reports, s.screen())41+ }42+ for _, want := range []string{"012: stopped on an internal error (a panic in " + strings.Replace(where, "command", "a command", 1), "unsaved changes kept in " + recovery[0] + "; open budget.012 again to restore them",43+ "a report is in " + reports[0]} {44+ if !strings.Contains(text, want) {45+ t.Errorf("the screen lacks %q:\n%s", want, s.screen())46+ }47+ }48+ report, _ := os.ReadFile(reports[0])49+ if !strings.Contains(string(report), "Panic: crash test in ") {50+ t.Errorf("report:\n%s", report)51+ }52+53+ r := startWith(t, options{dir: dir, startsOn: "Unsaved changes to budget.012 were kept."}, "budget.012")54+ r.keys("<enter>")55+ r.waitFor("Restored the kept changes")56+ if !strings.Contains(r.line(gridRow1), "kept") {57+ t.Errorf("A1 after restoring: %q", r.line(gridRow1))58+ }59+ })60+ }61+}@@ -28,8 +28,9 @@ func TestMain(m *testing.M) {
2828 }2929 binPath = filepath.Join(dir, "012")3030 // fakekeyring swaps the OS credential store for a file under31- // XDG_CONFIG_HOME, which every session points at a temporary directory.32- build := exec.Command("go", "build", "-tags", "fakekeyring", "-o", binPath, "./cmd/012")31+ // XDG_CONFIG_HOME, which every session points at a temporary32+ // directory; crashtest makes F12 panic (crash_test.go).33+ build := exec.Command("go", "build", "-tags", "fakekeyring,crashtest", "-o", binPath, "./cmd/012")3334 build.Dir = ".."3435 build.Env = append(os.Environ(), "CGO_ENABLED=0")3536 if out, err := build.CombinedOutput(); err != nil {@@ -124,7 +124,7 @@ var namedKeys = map[string]ghostty.Key{
124124 "up": ghostty.KeyArrowUp, "down": ghostty.KeyArrowDown,125125 "left": ghostty.KeyArrowLeft, "right": ghostty.KeyArrowRight,126126 "pgup": ghostty.KeyPageUp, "pgdown": ghostty.KeyPageDown,127- "f1": ghostty.KeyF1, "f2": ghostty.KeyF2, "f5": ghostty.KeyF5, "f9": ghostty.KeyF9, "f10": ghostty.KeyF10, "f11": ghostty.KeyF11,127+ "f1": ghostty.KeyF1, "f2": ghostty.KeyF2, "f5": ghostty.KeyF5, "f9": ghostty.KeyF9, "f10": ghostty.KeyF10, "f11": ghostty.KeyF11, "f12": ghostty.KeyF12,128128 "space": ghostty.KeySpace,129129 }130130 @@ -49,21 +49,23 @@ func (s *Server) fileArg(sess ssh.Session) (string, error) {
4949 5050 // ending is how a session ended, and where its unsaved work was kept.5151 type ending struct {52- idle, stopped bool // it idled out; the server stopped53- kept string // the recovery file, relative to the served directory54- keptFor string // the file name it was kept for, "" for a book never saved55- err error // keeping it failed52+ idle, stopped bool // it idled out; the server stopped53+ crash *ui.Crash // it stopped on an internal error54+ kept string // the recovery file, relative to the served directory55+ keptFor string // the file name it was kept for, "" for a book never saved56+ err error // keeping it failed5657 }5758 58-// keep writes m's unsaved work to a recovery file when the session ended59-// by idling out or by the server stopping; when the user quits, the app60-// has already asked about unsaved changes.61-func (e *ending) keep(m *ui.Model) {62- if !e.idle && !e.stopped || !m.Unsaved() {59+// keep writes the unsaved work of the program g ran to a recovery file60+// when the session ended by idling out, by the server stopping or on an61+// internal error; when the user quits, the app has already asked about62+// unsaved changes.63+func (e *ending) keep(g *ui.Guarded) {64+ if !e.idle && !e.stopped && e.crash == nil {6365 return6466 }65- e.keptFor = m.Filename()66- e.kept, e.err = m.Recover(time.Now())67+ e.keptFor = g.Model().Filename()68+ e.kept, e.err = g.Keep(time.Now())6769 }6870 6971 // tell says on the client's terminal why the session ended and where its@@ -74,6 +76,8 @@ func (e ending) tell(w io.Writer, idle time.Duration, root confine.Root) {
7476 fmt.Fprint(w, "012: the server is stopping\r\n")7577 case e.idle:7678 fmt.Fprintf(w, "012: closed after %v without input\r\n", idle)79+ case e.crash != nil:80+ fmt.Fprint(w, "012: stopped on an internal error; the server's log has a report\r\n")7781 }7882 switch {7983 case e.err != nil:@@ -11,9 +11,12 @@ import (
1111 "testing"1212 "time"1313 14+ tea "charm.land/bubbletea/v2"1415 gossh "golang.org/x/crypto/ssh"1516 17+ "github.com/FelineStateMachine/012/internal/confine"1618 "github.com/FelineStateMachine/012/internal/sheet"19+ "github.com/FelineStateMachine/012/internal/ui"1720 )1821 1922 // openExec starts a session with a terminal and an exec request for@@ -257,3 +260,28 @@ func TestRecoveryOnShutdown(t *testing.T) {
257260 io.WriteString(tm.stdin, "\x11")258261 tm.waitClosed(t)259262 }263+264+// A session that stopped on an internal error keeps its unsaved work,265+// however it ended, and tells the client the report is in the log.266+func TestRecoveryOnCrash(t *testing.T) {267+ dir := t.TempDir()268+ root, err := confine.New(dir)269+ if err != nil {270+ t.Fatal(err)271+ }272+ m := ui.New(sheet.New(), "book.012")273+ m.Serve(root, nil)274+ m.Update(tea.KeyPressMsg{Code: '7', Text: "7"})275+ m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})276+ e := ending{crash: &ui.Crash{Where: "update", Value: "boom"}}277+ e.keep(ui.Guard(m))278+ var out strings.Builder279+ e.tell(&out, time.Minute, root)280+ if e.err != nil || !strings.HasPrefix(e.kept, filepath.Join(".012-recovery", "book-")) {281+ t.Fatalf("kept %q, %v", e.kept, e.err)282+ }283+ if !strings.Contains(out.String(), "stopped on an internal error; the server's log has a report") ||284+ !strings.Contains(out.String(), "open book.012 again to restore them") {285+ t.Errorf("told %q", out.String())286+ }287+}@@ -310,7 +310,8 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, nam
310310 m.EnableJEV(s.jev, jev.NewCache())311311 }312312 in := newActivity(sess)313- p := tea.NewProgram(m, append(bm.MakeOptions(sess), tea.WithInput(in), tea.WithFPS(ui.FrameRate))...)313+ g := ui.Guard(m)314+ p := tea.NewProgram(g, append(bm.MakeOptions(sess), tea.WithInput(in), tea.WithFPS(ui.FrameRate))...)314315 315316 // The connection's context ends when the client goes away; cancel316317 // stops the goroutines below when the program quits first.@@ -333,12 +334,17 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, nam
333334 }334335 }()335336 }336- if _, err := p.Run(); err != nil && !errors.Is(err, tea.ErrProgramKilled) {337+ _, err := p.Run()338+ if err != nil && !errors.Is(err, tea.ErrProgramKilled) {337339 s.log.Error("session", "error", err.Error())338340 }339341 p.Kill()340- end := ending{idle: idled.Load(), stopped: s.stopping()}341- end.keep(m)342+ end := ending{idle: idled.Load(), stopped: s.stopping(), crash: g.Finish(err)}343+ if c := end.crash; c != nil {344+ // The report is the server's log: the operator's to read.345+ s.log.Error("session panic", "where", c.Where, "panic", fmt.Sprint(c.Value), "stack", string(c.Stack))346+ }347+ end.keep(g)342348 return end343349 }344350 @@ -9,7 +9,13 @@ import (
99 // fileCell is a cell with formatting or a note as the file writes it; a1010 // cell with neither is just its input (see fileFormat).1111 type fileCell struct {12- Input string `json:"input,omitempty"`12+ Input string `json:"input,omitempty"`13+ // Text says how the input reads when that isn't what the format14+ // says: true for text typed into a Plain text cell whose format15+ // changed since, false for a number or formula typed before the cell16+ // was formatted as Plain text. It needs no version bump: earlier17+ // builds read the input as the format says.18+ Text *bool `json:"text,omitempty"`1319 Format string `json:"format,omitempty"`1420 Decimals *int `json:"decimals,omitempty"`1521 Pattern string `json:"pattern,omitempty"`@@ -32,11 +38,13 @@ type fileCell struct {
3238 }3339 3440 func encodeCell(c *Cell) (json.RawMessage, error) {35- if c.Format.IsZero() && c.Style.IsZero() && c.Note == "" {41+ text := c.readsAgainstFormat()42+ if c.Format.IsZero() && c.Style.IsZero() && c.Note == "" && text == nil {3643 return json.Marshal(c.Input)3744 }3845 fc := fileCell{3946 Input: c.Input,47+ Text: text,4048 Pattern: c.Format.Pattern,4149 Bold: c.Style.Bold,4250 Italic: c.Style.Italic,@@ -59,25 +67,57 @@ func encodeCell(c *Cell) (json.RawMessage, error) {
5967 return json.Marshal(fc)6068 }6169 70+// readsAgainstFormat is the file's text field for c: whether its entry71+// is text, when its format says otherwise (see fileCell), else nil.72+func (c *Cell) readsAgainstFormat() *bool {73+ text := c.Format.Kind == FmtText74+ switch {75+ case text && c.expr != nil, !text && c.typedText:76+ v := c.typedText77+ return &v78+ }79+ return nil80+}81+6282 // decodeCell reads a cell's entry and formatting, as line formats are6383 // stored too.6484 func decodeCell(raw json.RawMessage) (string, Format, Style, error) {65- input, f, st, _, err := decodeNoted(raw)66- return input, f, st, err85+ e, err := decodeNoted(raw)86+ return e.input, e.f, e.st, err87+}88+89+// fileEntry is a cell as the file has it.90+type fileEntry struct {91+ input string92+ f Format93+ st Style94+ note string95+ text *bool // see fileCell.Text96+}97+98+// cell builds the cell, as newCell does, reading its entry as text or99+// not as the file says.100+func (e fileEntry) cell(implied bool) (*Cell, error) {101+ asText := e.f.Kind == FmtText102+ if e.text != nil {103+ asText = *e.text104+ }105+ c, err := readEntry(e.input, e.f, e.st, implied, asText)106+ return c.withNote(CleanNote(e.note)), err67107 }68108 69-// decodeNoted is decodeCell with the cell's note.70-func decodeNoted(raw json.RawMessage) (string, Format, Style, string, error) {109+// decodeNoted is decodeCell with the cell's note and how it reads.110+func decodeNoted(raw json.RawMessage) (fileEntry, error) {71111 var input string72112 if err := json.Unmarshal(raw, &input); err == nil {73- return input, Format{}, Style{}, "", nil113+ return fileEntry{input: input}, nil74114 }75115 var fc fileCell76116 if err := json.Unmarshal(raw, &fc); err != nil {77- return "", Format{}, Style{}, "", err117+ return fileEntry{}, err78118 }79119 input, f, st, err := decodeFormatted(fc)80- return input, f, st, fc.Note, err120+ return fileEntry{input, f, st, fc.Note, fc.Text}, err81121 }82122 83123 // decodeFormatted reads the entry and formatting of a cell written as an@@ -62,3 +62,30 @@ func TestBatchJoinsFormattingSteps(t *testing.T) {
6262 t.Errorf("C1 format %+v", f)6363 }6464 }65+66+// An entry reads as it was typed until it's typed again: a number or67+// formula formatted as Plain text afterwards keeps computing, and text68+// typed into a Plain text cell stays text in another format or none,69+// in the file too, and a formula that doesn't parse stays text without70+// making the file unreadable.71+func TestPlainTextFormatKeepsEntries(t *testing.T) {72+ s := New()73+ s.Set(at("A1"), "=1+1")74+ s.Set(at("A2"), "5")75+ s.SetFormat(rect("A1:A2"), Format{Kind: FmtText})76+ s.SetFormat(rect("B1:B3"), Format{Kind: FmtText})77+ s.Set(at("B1"), "=1+1")78+ s.Set(at("B2"), "5")79+ s.Set(at("B3"), "=SUM(")80+ s.SetFormat(rect("B1:B1"), Format{Kind: FmtNumber, Decimals: 1})81+ s.ClearFormatting(rect("B2:B3"))82+ want := map[string]Value{"A1": num(2), "A2": num(5),83+ "B1": {Kind: Text, Str: "=1+1"}, "B2": {Kind: Text, Str: "5"}, "B3": {Kind: Text, Str: "=SUM("}}84+ for name, sh := range map[string]*Sheet{"edited": s, "reopened": roundTrip(t, s)} {85+ for a, v := range want {86+ if got := sh.Value(at(a)); got != v {87+ t.Errorf("%s: %s = %+v, want %+v", name, a, got, v)88+ }89+ }90+ }91+}@@ -34,6 +34,14 @@ type tooDeep struct{}
3434 type evaluator struct {3535 w *Workbook3636 pending []loc // cells put off, each needed by the one before37+ // stack holds the formulas being evaluated, outermost first, and38+ // cycle the cells found to be part of a cycle: every formula on the39+ // stack from the cell reached again up. Each of them is #REF!,40+ // whatever its formula makes of the #REF! it read (IFERROR, COUNTIF),41+ // as in Sheets, so a cycle's values don't depend on the order its42+ // cells were evaluated in.43+ stack []loc44+ cycle map[loc]bool3745 }3846 3947 // evaluate computes the cells marked dirty in each sheet's calc. Cells@@ -79,7 +87,10 @@ func (e *evaluator) compute(s *Sheet, a Addr) Value {
7987 return v8088 }8189 switch st := s.calc[a]; {82- case st == visiting, st == deferred:90+ case st == visiting:91+ e.inCycle(loc{s, a})92+ return ErrRef93+ case st == deferred:8394 w.Circular = true8495 return ErrRef8596 case st != dirty:@@ -119,17 +130,38 @@ func (e *evaluator) formula(s *Sheet, a Addr, c *Cell) {
119130 s.calc[a] = visiting120131 w.depth++121132 expr := s.bound(a, c)122- outer := w.evaluating123- w.evaluating = loc{s, a}133+ outer, l := w.evaluating, loc{s, a}134+ w.evaluating = l135+ e.stack = append(e.stack, l)124136 c.Value = functions.EvalCell(w.arith(expr), s.calcGet.lib, a)125- if s.calcGet.lib.Spilled() != nil || s.spills != nil {126- w.noteSpill(s, a, c)137+ e.stack = e.stack[:len(e.stack)-1]138+ arr := s.calcGet.lib.Spilled()139+ if e.cycle[l] {140+ c.Value, arr = ErrRef, nil141+ }142+ if arr != nil || s.spills != nil {143+ w.noteSpill(s, a, c, arr)127144 }128145 w.evaluating = outer129146 c.auto = functions.InferFormat(expr, s.calcFmt)130147 w.depth--131148 }132149 150+// inCycle notes that the formula at l, being evaluated, was reached151+// again: it and the formulas it led to, up the stack, form a cycle.152+func (e *evaluator) inCycle(l loc) {153+ e.w.Circular = true154+ if e.cycle == nil {155+ e.cycle = map[loc]bool{}156+ }157+ for i := len(e.stack) - 1; i >= 0; i-- {158+ e.cycle[e.stack[i]] = true159+ if e.stack[i] == l {160+ return161+ }162+ }163+}164+133165 // sweep evaluates s's dirty cells, reporting false if it went too deep134166 // and put off a cell.135167 func (e *evaluator) sweep(s *Sheet) (finished bool) {@@ -188,5 +220,6 @@ func (e *evaluator) abandon(finished *bool) {
188220 }189221 e.w.depth = 0190222 e.w.evaluating = loc{}223+ e.stack = e.stack[:0]191224 *finished = false192225 }@@ -101,3 +101,28 @@ func TestFullSheetChain(t *testing.T) {
101101 t.Errorf("IV8192 = %+v, want %v", got, fmt.Sprint(want.Num))102102 }103103 }104+105+// Every cell of a cycle is #REF!, even one whose formula would make106+// something else of the #REF! it reads (COUNTIF skips errors, IFERROR107+// catches them), whichever of its cells is evaluated first: typed in108+// either order, or all at once on opening.109+func TestCycleIsRefWhateverReadsIt(t *testing.T) {110+ for _, reader := range []string{`=COUNTIF(F1:F20,">2")`, "=IFERROR(F13,0)"} {111+ for _, order := range [][2]string{{"C5", "F13"}, {"F13", "C5"}} {112+ s := New()113+ in := map[string]string{"C5": reader, "F13": "=C5+1"}114+ for _, a := range order {115+ s.Set(at(a), in[a])116+ }117+ reopened := roundTrip(t, s)118+ for _, a := range order {119+ if v := s.Value(at(a)); v != ErrRef {120+ t.Errorf("%s typed %v: %s = %v", reader, order, a, v)121+ }122+ if v := reopened.Value(at(a)); v != ErrRef {123+ t.Errorf("%s reopened: %s = %v", reader, a, v)124+ }125+ }126+ }127+ }128+}@@ -332,9 +332,7 @@ func (s *Sheet) readCell(sc *scanner, a Addr, implied bool) error {
332332 if err != nil {333333 return err334334 }335- var input, note string336- var fm Format337- var st Style335+ var e fileEntry338336 if c == '"' {339337 body, plain, err := sc.text()340338 if err != nil {@@ -343,7 +341,7 @@ func (s *Sheet) readCell(sc *scanner, a Addr, implied bool) error {
343341 if plain && s.loadNum(a, body, Format{}, Style{}) {344342 return nil345343 }346- if input, err = unquote(body, plain); err != nil {344+ if e.input, err = unquote(body, plain); err != nil {347345 return err348346 }349347 } else {@@ -351,19 +349,19 @@ func (s *Sheet) readCell(sc *scanner, a Addr, implied bool) error {
351349 if err != nil {352350 return err353351 }354- if input, fm, st, note, err = decodeNoted(raw); err != nil {352+ if e, err = decodeNoted(raw); err != nil {355353 return err356354 }357- if note == "" && s.loadNum(a, []byte(input), fm, st) {355+ if e.note == "" && e.text == nil && s.loadNum(a, []byte(e.input), e.f, e.st) {358356 return nil359357 }360358 }361- cell, err := newCell(input, fm, st, implied)359+ cell, err := e.cell(implied)362360 if err != nil {363361 return err364362 }365363 // An empty entry leaves no cell, even where the key came before.366- if cell = cell.withNote(CleanNote(note)); cell != nil || s.cells.has(a) {364+ if cell != nil || s.cells.has(a) {367365 s.place(a, cell)368366 }369367 return nil@@ -82,15 +82,15 @@ func placeWhole(s *Sheet, cells map[string]json.RawMessage, version int) (ambigu
8282 }8383 ambiguous = ambiguous || seen[a]8484 seen[a] = true85- input, fm, st, note, err := decodeNoted(raw)85+ e, err := decodeNoted(raw)8686 if err != nil {8787 return false, err8888 }89- c, err := newCell(input, fm, st, version < 2)89+ c, err := e.cell(version < 2)9090 if err != nil {9191 return false, err9292 }93- if c = c.withNote(CleanNote(note)); c != nil {93+ if c != nil {9494 s.place(a, c)9595 }9696 }@@ -0,0 +1,140 @@
1+package sheet2+3+import (4+ "bytes"5+ "flag"6+ "fmt"7+ "os"8+ "path/filepath"9+ "regexp"10+ "strings"11+ "testing"12+)13+14+// saveFixtures names a release: go test -run TestFixtures -fixtures15+// v1.2.3 saves the workbooks in testdata/fixtures/new with this build16+// into testdata/fixtures/v1.2.3. See docs/contributing/releasing.md.17+var saveFixtures = flag.String("fixtures", "", "save testdata/fixtures/new into testdata/fixtures/<release>")18+19+var releaseDir = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)20+21+// TestFixturesOpenAndSaveUnchanged opens every workbook a release saved22+// (testdata/fixtures/<release>) and saves it again: the bytes must be23+// the file's own. The writer puts every map in a fixed order (cells by24+// row then column, widths and lines by column, names by name), so byte25+// order never legitimately varies and the comparison is exact.26+// v0.2.0's fixtures were saved by that tag from fixtures/new, less the27+// rule and validation options it refuses (date periods, top values,28+// duplicates, averages, data bars, icon sets, dropdown display and29+// checkbox values).30+func TestFixturesOpenAndSaveUnchanged(t *testing.T) {31+ if *saveFixtures != "" {32+ writeFixtures(t, *saveFixtures)33+ }34+ dirs, err := os.ReadDir("testdata/fixtures")35+ if err != nil {36+ t.Fatal(err)37+ }38+ n := 039+ for _, d := range dirs {40+ if !d.IsDir() || !releaseDir.MatchString(d.Name()) {41+ continue42+ }43+ files, _ := filepath.Glob(filepath.Join("testdata/fixtures", d.Name(), "*.012"))44+ for _, path := range files {45+ n++46+ t.Run(d.Name()+"/"+filepath.Base(path), func(t *testing.T) {47+ want, err := os.ReadFile(path)48+ if err != nil {49+ t.Fatal(err)50+ }51+ if got := resave(t, want); !bytes.Equal(got, want) {52+ t.Errorf("saved differently:\n%s", lineDiff(string(want), string(got)))53+ }54+ })55+ }56+ }57+ if n == 0 {58+ t.Fatal("no fixtures in testdata/fixtures/v*")59+ }60+}61+62+// TestFixtureSourcesStable checks that the workbooks the next release's63+// fixtures are made from open, and that what this build saves of them64+// saves again unchanged, so -fixtures always makes fixtures that pass.65+func TestFixtureSourcesStable(t *testing.T) {66+ files, _ := filepath.Glob("testdata/fixtures/new/*.012")67+ if len(files) == 0 {68+ t.Fatal("no workbooks in testdata/fixtures/new")69+ }70+ for _, path := range files {71+ t.Run(filepath.Base(path), func(t *testing.T) {72+ src, err := os.ReadFile(path)73+ if err != nil {74+ t.Fatal(err)75+ }76+ once := resave(t, src)77+ if twice := resave(t, once); !bytes.Equal(once, twice) {78+ t.Errorf("saved differently the second time:\n%s", lineDiff(string(once), string(twice)))79+ }80+ })81+ }82+}83+84+// resave opens a file and returns what this build saves of it.85+func resave(t *testing.T, data []byte) []byte {86+ t.Helper()87+ wb, err := ReadBook(bytes.NewReader(data))88+ if err != nil {89+ t.Fatalf("open: %v", err)90+ }91+ var out bytes.Buffer92+ if err := wb.Write(&out); err != nil {93+ t.Fatalf("save: %v", err)94+ }95+ return out.Bytes()96+}97+98+// writeFixtures saves testdata/fixtures/new with this build as the99+// fixtures of release, refusing to replace a release's fixtures.100+func writeFixtures(t *testing.T, release string) {101+ if !releaseDir.MatchString(release) {102+ t.Fatalf("-fixtures %q: want a release such as v1.2.3", release)103+ }104+ dir := filepath.Join("testdata/fixtures", release)105+ if _, err := os.Stat(dir); err == nil {106+ t.Fatalf("%s exists: a release's fixtures are never replaced", dir)107+ }108+ if err := os.MkdirAll(dir, 0o755); err != nil {109+ t.Fatal(err)110+ }111+ files, _ := filepath.Glob("testdata/fixtures/new/*.012")112+ for _, path := range files {113+ src, err := os.ReadFile(path)114+ if err != nil {115+ t.Fatal(err)116+ }117+ if err := os.WriteFile(filepath.Join(dir, filepath.Base(path)), resave(t, src), 0o644); err != nil {118+ t.Fatal(err)119+ }120+ }121+}122+123+// lineDiff lists the lines of want and got that differ, by line number.124+func lineDiff(want, got string) string {125+ w, g := strings.Split(want, "\n"), strings.Split(got, "\n")126+ var b strings.Builder127+ for i := range max(len(w), len(g)) {128+ var wl, gl string129+ if i < len(w) {130+ wl = w[i]131+ }132+ if i < len(g) {133+ gl = g[i]134+ }135+ if wl != gl {136+ fmt.Fprintf(&b, "%d:\n want %s\n got %s\n", i+1, wl, gl)137+ }138+ }139+ return b.String()140+}@@ -15,6 +15,12 @@ func (w *Workbook) restore(st *step) (inv *step, changed []loc) {
1515 inv.sheets = w.sheetList()1616 w.setSheets(st.sheets)1717 }18+ for s, r := range st.regions {19+ changed = append(changed, s.emptyMoved(r)...)20+ }21+ for s, img := range st.cells {22+ changed = append(changed, s.emptyUnder(img)...)23+ }1824 for s, img := range st.cells {1925 img.each(func(a Addr, c *Cell) {2026 inv.keep(s, a)@@ -38,3 +38,17 @@ func TestMergeFormattingUndo(t *testing.T) {
3838 t.Errorf("pasted C1 %+v", c)3939 }4040 }41+42+// A merge that deleting rows takes away frees the array it blocked.43+func TestDeletedMergeFreesArray(t *testing.T) {44+ s := New()45+ s.Merge(NewRect(at("B18"), at("C20")), MergeVertically)46+ s.Set(at("B17"), "=SEQUENCE(2)")47+ if v := s.Value(at("B17")); v != ErrRef {48+ t.Fatalf("B17 over a merge = %v", v)49+ }50+ s.DeleteRows(18, 3)51+ if v := s.Value(at("B18")); v != num(2) {52+ t.Errorf("B18 once the merge went = %v", v)53+ }54+}@@ -0,0 +1,322 @@
1+package sheet2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/notebook"8+)9+10+// The edits TestRandomEdits draws from, each one undo step (or none,11+// when it's refused), described for the log.12+var randEdits = []func(r *randBook, e *edits) string{13+ (*randBook).editSet, (*randBook).editSet, (*randBook).editSet,14+ (*randBook).editErase, (*randBook).editPaste, (*randBook).editFill,15+ (*randBook).editSeries, (*randBook).editSort, (*randBook).editInsert,16+ (*randBook).editDelete, (*randBook).editFormat, (*randBook).editStyle,17+ (*randBook).editMerge, (*randBook).editMove, (*randBook).editLayout,18+ (*randBook).editNote, (*randBook).editRule, (*randBook).editRegion,19+ (*randBook).editSheets, (*randBook).editView,20+}21+22+func (r *randBook) edit(e *edits) string {23+ return randEdits[e.n(len(randEdits))](r, e)24+}25+26+// sheet picks one of the grid sheets (not the notebook) to edit.27+func (r *randBook) sheet(e *edits) *Sheet {28+ var grids []*Sheet29+ for _, s := range r.wb.sheets {30+ if !s.IsNotebook() {31+ grids = append(grids, s)32+ }33+ }34+ return grids[e.n(len(grids))]35+}36+37+// input is something to type into a cell: numbers, text, dates and38+// formulas that reach across ranges, sheets, names, regions and spills.39+func (r *randBook) input(e *edits) string {40+ ref := func() string {41+ a := e.addr()42+ if e.n(4) == 0 {43+ return "$" + ColName(a.Col) + "$" + fmt.Sprint(a.Row+1)44+ }45+ return a.String()46+ }47+ rng := func() string { return e.rect().String() }48+ other := []string{"Sheet1", "S2"}[e.n(2)]49+ forms := []func() string{50+ func() string { return fmt.Sprint(e.n(20) - 5) },51+ func() string { return fmt.Sprintf("%d.5", e.n(10)) },52+ func() string { return []string{"a", "b", "x y", "TRUE", "2026-01-05", "10%"}[e.n(6)] },53+ func() string { return "=" + ref() + "+" + ref() },54+ func() string { return "=SUM(" + rng() + ")" },55+ func() string { return "=" + other + "!" + ref() + "*2" },56+ func() string { return "=SUM(" + other + "!" + rng() + ")" },57+ func() string { return "=IFERROR(" + ref() + "/" + ref() + ",0)" },58+ func() string { return `=COUNTIF(` + rng() + `,">2")` },59+ func() string { return "=SEQUENCE(" + fmt.Sprint(1+e.n(3)) + ")" },60+ func() string { return "=SORT(" + rng() + ")" },61+ func() string { return "=VLOOKUP(" + ref() + "," + rng() + ",1,FALSE)" },62+ func() string { return "=SUM(nu.r1)" },63+ func() string { return "=SUM(Total)*2" },64+ func() string { return "=INDEX(" + rng() + ",1,1)" },65+ func() string { return "=MAX(" + ColName(e.n(randCols)) + ":" + ColName(e.n(randCols)) + ")" },66+ func() string { return "=CONCAT(" + ref() + "," + ref() + ")" },67+ func() string { return "=ABS(" + ref() + ")+1" },68+ func() string { return "=" + ref() + "&\"\"" },69+ func() string { return "" },70+ }71+ return forms[e.n(len(forms))]()72+}73+74+func (r *randBook) editSet(e *edits) string {75+ s, a, in := r.sheet(e), e.addr(), r.input(e)76+ err := s.Set(a, in)77+ return fmt.Sprintf("%s!%s = %q (%v)", s.name, a, in, err)78+}79+80+func (r *randBook) editErase(e *edits) string {81+ s, rg := r.sheet(e), e.rect()82+ s.EraseRange(rg)83+ return fmt.Sprintf("erase %s!%s", s.name, rg)84+}85+86+func (r *randBook) editPaste(e *edits) string {87+ src, dst := r.sheet(e), r.sheet(e)88+ from, to := e.rect(), e.rect()89+ values := e.n(3) == 090+ _, err := dst.Paste(src.Copy(from), to, values)91+ return fmt.Sprintf("copy %s!%s, paste (values %v) %s!%s (%v)", src.name, from, values, dst.name, to, err)92+}93+94+func (r *randBook) editFill(e *edits) string {95+ s, rg := r.sheet(e), e.rect()96+ var err error97+ if e.n(2) == 0 {98+ _, err = s.FillDown(rg)99+ return fmt.Sprintf("fill down %s!%s (%v)", s.name, rg, err)100+ }101+ _, err = s.FillRight(rg)102+ return fmt.Sprintf("fill right %s!%s (%v)", s.name, rg, err)103+}104+105+func (r *randBook) editSeries(e *edits) string {106+ s, src := r.sheet(e), e.rect()107+ dst := src108+ if e.n(2) == 0 {109+ dst.To.Row = min(dst.To.Row+1+e.n(6), randRows-1)110+ } else {111+ dst.To.Col = min(dst.To.Col+1+e.n(3), randCols-1)112+ }113+ _, err := s.FillSeries(src, dst)114+ return fmt.Sprintf("series %s!%s to %s (%v)", s.name, src, dst, err)115+}116+117+func (r *randBook) editSort(e *edits) string {118+ s, rg := r.sheet(e), e.rect()119+ keys := []SortKey{{Col: rg.From.Col + e.n(rg.To.Col-rg.From.Col+1), Desc: e.n(2) == 0}}120+ if e.n(2) == 0 {121+ keys = append(keys, SortKey{Col: rg.From.Col + e.n(rg.To.Col-rg.From.Col+1)})122+ }123+ s.SortRange(rg, keys)124+ return fmt.Sprintf("sort %s!%s by %v", s.name, rg, keys)125+}126+127+func (r *randBook) editInsert(e *edits) string {128+ s, at, n := r.sheet(e), e.n(randRows), 1+e.n(3)129+ if e.n(2) == 0 {130+ err := s.InsertRows(at, n)131+ return fmt.Sprintf("insert %d rows at %s!%d (%v)", n, s.name, at+1, err)132+ }133+ at %= randCols134+ err := s.InsertCols(at, n)135+ return fmt.Sprintf("insert %d cols at %s!%s (%v)", n, s.name, ColName(at), err)136+}137+138+func (r *randBook) editDelete(e *edits) string {139+ s, at, n := r.sheet(e), e.n(randRows), 1+e.n(3)140+ if e.n(2) == 0 {141+ s.DeleteRows(at, n)142+ return fmt.Sprintf("delete %d rows at %s!%d", n, s.name, at+1)143+ }144+ at %= randCols145+ s.DeleteCols(at, n)146+ return fmt.Sprintf("delete %d cols at %s!%s", n, s.name, ColName(at))147+}148+149+func (r *randBook) editFormat(e *edits) string {150+ s, rg := r.sheet(e), e.rect()151+ f := Format{Kind: FormatKind(e.n(int(FmtSize) + 1))}152+ if f.Kind.HasDecimals() {153+ f.Decimals = e.n(4)154+ }155+ switch e.n(3) {156+ case 0:157+ rg = colRect(rg.From.Col, rg.To.Col)158+ case 1:159+ rg = rowRect(rg.From.Row, rg.To.Row)160+ }161+ s.SetFormat(rg, f)162+ return fmt.Sprintf("format %s!%s %s", s.name, rg, f.Kind)163+}164+165+func (r *randBook) editStyle(e *edits) string {166+ s, rg := r.sheet(e), e.rect()167+ switch e.n(4) {168+ case 0:169+ s.SetStyle(rg, func(st *Style) { st.Bold = !st.Bold })170+ return fmt.Sprintf("bold %s!%s", s.name, rg)171+ case 1:172+ k, l := BorderKind(e.n(int(BorderNone)+1)), Line(1+e.n(3))173+ s.SetBorders(rg, k, l)174+ return fmt.Sprintf("borders %d %d %s!%s", k, l, s.name, rg)175+ case 2:176+ s.SetStyle(rg, func(st *Style) { st.Wrap = WrapOn })177+ return fmt.Sprintf("wrap %s!%s", s.name, rg)178+ }179+ s.ClearFormatting(rg)180+ return fmt.Sprintf("clear formatting %s!%s", s.name, rg)181+}182+183+func (r *randBook) editMerge(e *edits) string {184+ s, rg := r.sheet(e), e.rect()185+ if e.n(3) == 0 {186+ n := s.Unmerge(rg)187+ return fmt.Sprintf("unmerge %s!%s (%d)", s.name, rg, n)188+ }189+ k := MergeKind(e.n(3))190+ err := s.Merge(rg, k)191+ return fmt.Sprintf("merge %d %s!%s (%v)", k, s.name, rg, err)192+}193+194+func (r *randBook) editMove(e *edits) string {195+ src, dst := r.sheet(e), r.sheet(e)196+ rg, to := e.rect(), e.addr()197+ _, err := src.MoveTo(dst, rg, to)198+ return fmt.Sprintf("move %s!%s to %s!%s (%v)", src.name, rg, dst.name, to, err)199+}200+201+func (r *randBook) editLayout(e *edits) string {202+ s := r.sheet(e)203+ if e.n(2) == 0 {204+ c, w := e.n(randCols), 3+e.n(20)205+ s.SetColWidth(c, w)206+ return fmt.Sprintf("width %s!%s %d", s.name, ColName(c), w)207+ }208+ row, h := e.n(randRows), e.n(4)209+ s.SetRowHeight(row, row, h)210+ return fmt.Sprintf("height %s!%d %d", s.name, row+1, h)211+}212+213+func (r *randBook) editNote(e *edits) string {214+ s, a := r.sheet(e), e.addr()215+ text := []string{"", "note", "another"}[e.n(3)]216+ err := s.SetNote(a, text)217+ return fmt.Sprintf("note %s!%s %q (%v)", s.name, a, text, err)218+}219+220+func (r *randBook) editRule(e *edits) string {221+ s, rg := r.sheet(e), e.rect()222+ switch e.n(3) {223+ case 0:224+ err := s.AddCondFormat(CondFormat{Ranges: []Rect{rg}, Op: RuleGreater, Args: [2]string{fmt.Sprint(e.n(9))}, Style: RuleStyle{Bold: true}})225+ return fmt.Sprintf("conditional format %s!%s (%v)", s.name, rg, err)226+ case 1:227+ err := s.AddValidation(Validation{Ranges: []Rect{rg}, Kind: ValidList, Items: []string{"a", "b"}})228+ return fmt.Sprintf("validation %s!%s (%v)", s.name, rg, err)229+ }230+ s.ClearCondFormats(rg)231+ s.ClearValidations(rg)232+ return fmt.Sprintf("clear rules %s!%s", s.name, rg)233+}234+235+func (r *randBook) editRegion(e *edits) string {236+ _, _, has2 := r.wb.Region("r2")237+ switch e.n(5) {238+ case 0:239+ cells := r.nb.NotebookCells()240+ if len(cells) > 2 {241+ cells = cells[:2]242+ } else {243+ cells = append(cells, notebook.Cell{Source: "r2 = $r1 | first 2"})244+ }245+ r.nb.SetNotebookCells("edit cells", cells)246+ return fmt.Sprintf("notebook cells %d", len(cells))247+ case 1:248+ if has2 {249+ err := r.wb.deleteRegion("r2")250+ return fmt.Sprintf("delete r2 (%v)", err)251+ }252+ s := r.sheet(e)253+ err := r.addOutput(s, "r2", e)254+ return fmt.Sprintf("send r2 to %s (%v)", s.name, err)255+ case 2:256+ if has2 {257+ s, _, _ := r.wb.Region("r2")258+ err := s.FreezeRegion("r2")259+ return fmt.Sprintf("freeze r2 (%v)", err)260+ }261+ }262+ name := "r1"263+ if has2 && e.n(2) == 0 {264+ name = "r2"265+ }266+ r.feed(name, e)267+ return "feed " + name268+}269+270+// deleteRegion deletes the region name, wherever it is.271+func (w *Workbook) deleteRegion(name string) error {272+ s, _, ok := w.Region(name)273+ if !ok {274+ return ErrNoRegion275+ }276+ return s.DeleteRegion(name)277+}278+279+func (r *randBook) editSheets(e *edits) string {280+ s := r.sheet(e)281+ switch e.n(4) {282+ case 0:283+ n, err := r.wb.AddSheet("", e.n(len(r.wb.sheets)+1))284+ if err != nil {285+ return fmt.Sprintf("add sheet (%v)", err)286+ }287+ return "add sheet " + n.name288+ case 1:289+ if s.name == "Sheet1" || s.name == "S2" {290+ return "keep " + s.name291+ }292+ err := r.wb.DeleteSheet(s)293+ return fmt.Sprintf("delete sheet %s (%v)", s.name, err)294+ case 2:295+ name := s.name + "x"296+ if strings.HasPrefix(s.name, "Sheet1") || s.name == "S2" {297+ return "keep the name " + s.name298+ }299+ err := r.wb.RenameSheet(s, name)300+ return fmt.Sprintf("rename %s (%v)", s.name, err)301+ }302+ rg := e.rect()303+ r.wb.DeleteName("Total")304+ err := r.wb.DefineName("Total", s, rg)305+ return fmt.Sprintf("Total = %s!%s (%v)", s.name, rg, err)306+}307+308+func (r *randBook) editView(e *edits) string {309+ s := r.sheet(e)310+ switch e.n(3) {311+ case 0:312+ rows, cols := e.n(3), e.n(3)313+ s.SetFrozen(rows, cols)314+ return fmt.Sprintf("freeze %s %d %d", s.name, rows, cols)315+ case 1:316+ rg := e.rect()317+ s.CreateFilter(rg)318+ return fmt.Sprintf("filter %s!%s", s.name, rg)319+ }320+ s.RemoveFilter()321+ return "remove filter " + s.name322+}@@ -0,0 +1,322 @@
1+package sheet2+3+import (4+ "bytes"5+ "flag"6+ "fmt"7+ "maps"8+ "math"9+ "math/rand/v2"10+ "slices"11+ "strings"12+ "testing"13+14+ "github.com/FelineStateMachine/012/internal/notebook"15+)16+17+// Random edits check invariants that hold between features rather than18+// within one: a sequence of edits, pastes, fills, sorts, inserts and19+// deletes, formats, merges, moves, notes, rules, names, sheets and20+// region runs, drawn from a seed, must leave a workbook that21+//22+// - undoes, step by step, to exactly where it started (the file it23+// saves and every value), and redoes to where it ended;24+// - saves and reopens to the same file and the same values (reopening25+// recalculates everything from scratch, so this is also a full26+// recalculation agreeing with the incremental one);27+// - recalculates in place, everything at once, to the same values.28+//29+// go test runs a fixed set of seeds in a second or two; -randedit.seeds30+// runs more, and FuzzRandomEdits lets the fuzzer choose the edits.31+var (32+ randSeeds = flag.Int("randedit.seeds", 1000, "seeds TestRandomEdits runs")33+ randSteps = flag.Int("randedit.steps", 40, "edits per seed")34+)35+36+func TestRandomEdits(t *testing.T) {37+ seeds := *randSeeds38+ if testing.Short() {39+ seeds = min(seeds, 50)40+ }41+ for seed := range seeds {42+ rng := rand.New(rand.NewPCG(uint64(seed), 12))43+ data := make([]byte, 64+(*randSteps)*8)44+ for i := range data {45+ data[i] = byte(rng.Uint32())46+ }47+ t.Run(fmt.Sprint(seed), func(t *testing.T) {48+ if why, known := randKnown[seed]; known && *randSteps == 40 {49+ t.Skip(why)50+ }51+ checkRandomEdits(t, data, *randSteps)52+ })53+ }54+}55+56+// randKnown are the seeds, of the default 40 edits, whose failures are57+// known and on the roadmap, with why. Dynamic cycle detection finds a58+// cycle only when evaluation walks it: a range read that stops at an59+// error, or an IF branch not taken, hides one that reopening, computing60+// in another order, finds.61+var randKnown = map[int]string{62+ 124: "a cycle hidden behind a range read that stops at an error (roadmap: cycles found from the formulas as written)",63+}64+65+// FuzzRandomEdits reads the edits from the fuzzer's bytes: go test66+// -run '^$' -fuzz FuzzRandomEdits ./internal/sheet.67+func FuzzRandomEdits(f *testing.F) {68+ f.Add([]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20})69+ f.Fuzz(func(t *testing.T, data []byte) { checkRandomEdits(t, data, 60) })70+}71+72+// edits draws choices from bytes; past their end every choice is 0.73+type edits struct {74+ data []byte75+ i int76+}77+78+func (e *edits) n(k int) int {79+ if k <= 1 || e.i >= len(e.data) {80+ return 081+ }82+ b := int(e.data[e.i])83+ e.i++84+ return b % k85+}86+87+func (e *edits) done() bool { return e.i >= len(e.data) }88+89+// Edits stay in a small corner, so they meet: columns A to H, rows 1 to90+// 20.91+const randCols, randRows = 8, 2092+93+func (e *edits) addr() Addr { return Addr{Col: e.n(randCols), Row: e.n(randRows)} }94+95+func (e *edits) rect() Rect {96+ a := e.addr()97+ return NewRect(a, Addr{Col: min(a.Col+e.n(3), randCols-1), Row: min(a.Row+e.n(5), randRows-1)})98+}99+100+// checkRandomEdits applies up to steps edits from data and checks the101+// invariants. When one fails, it reports the fewest of the edits that102+// still fail, with the log of them.103+func checkRandomEdits(t *testing.T, data []byte, steps int) {104+ t.Helper()105+ msg, log := randomEdits(data, steps)106+ if msg == "" {107+ return108+ }109+ for n := 1; n < len(log); n++ {110+ if m, l := randomEdits(data, n); m != "" {111+ msg, log = m, l112+ break113+ }114+ }115+ t.Fatalf("%s\nafter %d edits:\n %s", msg, len(log), strings.Join(log, "\n "))116+}117+118+// randomEdits builds a workbook, applies up to steps edits from data,119+// and says which invariant fails, if one does, and the edits made.120+//121+// A region's rows come from outside the undo history, as a notebook122+// cell's output or a linked file does: the edits send a region its123+// rows (feed), and after undoing, redoing or reopening, each region is124+// sent the rows its source gave at that point, as the UI would.125+func randomEdits(data []byte, steps int) (string, []string) {126+ e := &edits{data: data}127+ r := newRandBook(e)128+ start, startVals, startFeeds := r.save(), bookValues(r.wb), maps.Clone(r.feeds)129+ var log []string130+ for range steps {131+ if e.done() {132+ break133+ }134+ log = append(log, r.edit(e))135+ r.feedStale()136+ }137+ end, endVals := r.save(), bookValues(r.wb)138+ if d := r.reopenDiff(end, endVals); d != "" {139+ return "reopening differs: " + d, log140+ }141+ undone := 0142+ for r.wb.CanUndo() {143+ r.wb.Undo()144+ undone++145+ }146+ feedAll(r.wb, startFeeds)147+ if got := r.save(); !bytes.Equal(got, start) {148+ return fmt.Sprintf("undoing %d steps saves differently:\n%s", undone, lineDiff(string(start), string(got))), log149+ }150+ if d := diffValues(startVals, bookValues(r.wb)); d != "" {151+ return fmt.Sprintf("undoing %d steps: %s", undone, d), log152+ }153+ for r.wb.CanRedo() {154+ r.wb.Redo()155+ }156+ feedAll(r.wb, r.feeds)157+ if got := r.save(); !bytes.Equal(got, end) {158+ return "redoing saves differently:\n" + lineDiff(string(end), string(got)), log159+ }160+ if d := diffValues(endVals, bookValues(r.wb)); d != "" {161+ return "redoing: " + d, log162+ }163+ r.wb.RecalcAll()164+ r.feedStale()165+ if d := diffValues(endVals, bookValues(r.wb)); d != "" {166+ return "recalculating everything: " + d, log167+ }168+ return "", log169+}170+171+// randBook is the workbook edited, its notebook tab, and the rows each172+// region's source gives now, by region.173+type randBook struct {174+ wb *Workbook175+ nb *Sheet176+ feeds map[string]LiveOp177+}178+179+// newRandBook makes two sheets of values and formulas drawn from e, a180+// named range, a notebook tab of a note and a code cell, the code181+// cell's output sent to S2, then forgets the history, so undoing182+// everything comes back here.183+func newRandBook(e *edits) *randBook {184+ s := New()185+ wb := s.Book()186+ s2, _ := wb.AddSheet("S2", 1)187+ nb, _ := wb.AddNotebook("N", s2)188+ r := &randBook{wb: wb, nb: nb, feeds: map[string]LiveOp{}}189+ for _, sh := range []*Sheet{s, s2} {190+ for range 14 {191+ sh.Set(e.addr(), r.input(e))192+ }193+ }194+ wb.DefineName("Total", s, NewRect(Addr{}, Addr{Col: 1, Row: 9}))195+ nb.SetNotebookCells("add cells", []notebook.Cell{{Kind: notebook.Note, Source: "# Files"}, {Source: "r1 = ls"}})196+ r.addOutput(s2, "r1", e)197+ wb.ClearHistory()198+ return r199+}200+201+// addOutput sends the output of the notebook cell name to s at an empty202+// cell, and gives it rows.203+func (r *randBook) addOutput(s *Sheet, name string, e *edits) error {204+ a := e.addr()205+ for tries := 0; s.Filled(a) && tries < 20; tries++ {206+ a = e.addr()207+ }208+ if err := s.AddRegion(Region{Name: name, At: a, Output: true}); err != nil {209+ return err210+ }211+ r.feed(name, e)212+ return nil213+}214+215+// feed has a region's source give it a random table of numbers under a216+// header, as running its cell would.217+func (r *randBook) feed(name string, e *edits) {218+ rows, cols := e.n(5), 1+e.n(3)219+ op := LiveOp{Region: name, Reset: true}220+ for c := range cols {221+ op.Header = append(op.Header, LiveCell{V: Value{Kind: Text, Str: fmt.Sprintf("c%d", c)}})222+ }223+ for range rows {224+ row := make(LiveRow, cols)225+ for c := range row {226+ row[c] = LiveCell{V: Value{Kind: Number, Num: float64(e.n(20))}}227+ }228+ op.Rows = append(op.Rows, row)229+ }230+ r.feeds[name] = op231+ sendRows(r.wb, op)232+}233+234+// feedStale sends the regions to be sent again (moved, put back by235+// undo) their source's rows, as the UI does after every change.236+func (r *randBook) feedStale() {237+ for _, name := range r.wb.StaleOutputs() {238+ if op, ok := r.feeds[name]; ok {239+ sendRows(r.wb, op)240+ }241+ }242+}243+244+// feedAll sends each region wb holds its source's rows.245+func feedAll(wb *Workbook, feeds map[string]LiveOp) {246+ for _, name := range slices.Sorted(maps.Keys(feeds)) {247+ sendRows(wb, feeds[name])248+ }249+}250+251+// sendRows sends a region its rows. A table widens its columns to its252+// text the first time it shows, outside the undo history as its rows253+// are, so undoing the region leaves them wide; the edits change widths254+// themselves instead.255+func sendRows(wb *Workbook, op LiveOp) {256+ if s, r, ok := wb.Region(op.Region); ok {257+ s.meta(nameKey(r.Name)).fitted = true258+ }259+ wb.ApplyLive(op)260+}261+262+func (r *randBook) save() []byte { return saveBook(r.wb) }263+264+// saveBook is the file wb saves. Saving into memory fails only on a265+// bug, which the test then panics with.266+func saveBook(wb *Workbook) []byte {267+ var b bytes.Buffer268+ if err := wb.Write(&b); err != nil {269+ panic(err)270+ }271+ return b.Bytes()272+}273+274+// reopenDiff reads the saved file, sends its regions their rows again,275+// and says how it differs from the workbook saved.276+func (r *randBook) reopenDiff(file []byte, vals map[string]cellValue) string {277+ wb, err := ReadBook(bytes.NewReader(file))278+ if err != nil {279+ return "reopening: " + err.Error()280+ }281+ if again := saveBook(wb); !bytes.Equal(again, file) {282+ return "saves differently:\n" + lineDiff(string(file), string(again))283+ }284+ feedAll(wb, r.feeds)285+ return diffValues(vals, bookValues(wb))286+}287+288+// cellValue is a cell's value, and what was typed in it to say which.289+type cellValue struct {290+ v Value291+ input string292+}293+294+// bookValues are every filled cell's value, by sheet and address.295+func bookValues(wb *Workbook) map[string]cellValue {296+ out := map[string]cellValue{}297+ for _, s := range wb.sheets {298+ for _, a := range s.Addrs() {299+ out[s.name+"!"+a.String()] = cellValue{s.Value(a), s.Cell(a).Input}300+ }301+ }302+ return out303+}304+305+func diffValues(want, got map[string]cellValue) string {306+ var b strings.Builder307+ for k, w := range want {308+ if g, ok := got[k]; !ok || !sameValue(w.v, g.v) {309+ fmt.Fprintf(&b, "\n %s: want %v (%q), got %v (%q)", k, w.v, w.input, g.v, g.input)310+ }311+ }312+ for k, g := range got {313+ if _, ok := want[k]; !ok {314+ fmt.Fprintf(&b, "\n %s: want nothing, got %v (%q)", k, g.v, g.input)315+ }316+ }317+ return b.String()318+}319+320+func sameValue(a, b Value) bool {321+ return a.Kind == b.Kind && a.Str == b.Str && (a.Num == b.Num || math.IsNaN(a.Num) && math.IsNaN(b.Num))322+}@@ -81,10 +81,9 @@ func (w *Workbook) affected(changed []loc, volatiles bool) int {
8181 // readers marked too, but not those cells: spilling changed what an8282 // anchor shows (spill.go), not its formula.8383 func (w *Workbook) affectedBy(changed, readers []loc, volatiles bool) int {84- m := marking{w: w, queue: append([]loc(nil), changed...), named: w.namedInUse(), byName: map[*Sheet]bool{}}84+ m := marking{readerLookup: w.readerLookup(), queue: append([]loc(nil), changed...)}8585 for _, s := range w.sheets {8686 s.calc = make(map[Addr]int)87- m.byName[s] = w.crossKeys[formula.SheetKey(s.name)] > 08887 if !volatiles {8988 continue9089 }@@ -109,15 +108,28 @@ func (w *Workbook) affectedBy(changed, readers []loc, volatiles bool) int {
109108 return n110109 }111110 112-// marking is the state of affected: the cells still to mark, and what it113-// looks up once rather than for every cell.111+// marking is the state of affected: the cells still to mark.114112 type marking struct {113+ readerLookup114+ queue []loc115+}116+117+// readerLookup finds the formulas that read a cell, with what it looks118+// up once rather than for every cell.119+type readerLookup struct {115120 w *Workbook116- queue []loc117121 named []namedUsers118122 byName map[*Sheet]bool // whether some formula names the sheet119123 }120124 125+func (w *Workbook) readerLookup() readerLookup {126+ r := readerLookup{w: w, named: w.namedInUse(), byName: make(map[*Sheet]bool, len(w.sheets))}127+ for _, s := range w.sheets {128+ r.byName[s] = w.crossKeys[formula.SheetKey(s.name)] > 0129+ }130+ return r131+}132+121133 // namedUsers are the formulas that use a named range, with its cells.122134 type namedUsers struct {123135 s *Sheet@@ -146,27 +158,30 @@ func (m *marking) push(u loc) {
146158 }147159 }148160 149-// readersOf queues the formulas that read the cell l: by reference, by a150-// range over it, through a named range, or by naming its sheet.151-func (m *marking) readersOf(l loc) {161+// readersOf queues the formulas that read the cell l.162+func (m *marking) readersOf(l loc) { m.each(l, m.push) }163+164+// each calls fn with the formulas that read the cell l: by reference, by165+// a range over it, through a named range, or by naming its sheet.166+func (r readerLookup) each(l loc, fn func(loc)) {152167 s, a := l.s, l.a153168 for d := range s.dependents[a] {154- m.push(loc{s, d})169+ fn(loc{s, d})155170 }156- s.rangeUsers.readers(a, func(u Addr) { m.push(loc{s, u}) })157- for _, nu := range m.named {171+ s.rangeUsers.readers(a, func(u Addr) { fn(loc{s, u}) })172+ for _, nu := range r.named {158173 if nu.s == s && nu.r.Contains(a) {159174 for u := range nu.users {160- m.push(u)175+ fn(u)161176 }162177 }163178 }164- if !m.byName[s] {179+ if !r.byName[s] {165180 return // no formula names this sheet166181 }167- for u := range m.w.crossUsers {168- if m.w.crossReads(u, l) {169- m.push(u)182+ for u := range r.w.crossUsers {183+ if r.w.crossReads(u, l) {184+ fn(u)170185 }171186 }172187 }@@ -267,3 +267,38 @@ func (s *Sheet) putRegionsBack(st regionState) {
267267 }268268 s.regions, s.regionsStale = st, true269269 }270+271+// emptyUnder empties the regions holding cells that undo puts contents272+// back in (what a region's table grew over once they left), so the273+// contents come back and the region, sent again, finds them in its way,274+// as before they left.275+func (s *Sheet) emptyUnder(img *image) []loc {276+ if len(s.regions.list) == 0 {277+ return nil278+ }279+ var changed []loc280+ img.each(func(a Addr, c *Cell) {281+ if c == nil || c.Blank() || c.Spilled() {282+ return283+ }284+ if _, me, ok := s.ownerOf(a); ok {285+ changed = append(changed, s.emptyRegion(me)...)286+ }287+ })288+ return changed289+}290+291+// emptyMoved empties the regions that putting st back moves or takes292+// away, outside the undo history, as moving them does, before undo puts293+// cells back: a cell a region holds keeps its value when placed, so294+// what undo brings back where a region moved to would be lost.295+func (s *Sheet) emptyMoved(st regionState) []loc {296+ var changed []loc297+ for _, r := range s.regions.list {298+ i := slices.IndexFunc(st.list, func(x Region) bool { return nameKey(x.Name) == nameKey(r.Name) })299+ if me := s.regionMeta[nameKey(r.Name)]; me != nil && (i < 0 || st.list[i] != r) {300+ changed = append(changed, s.emptyRegion(me)...)301+ }302+ }303+ return changed304+}@@ -49,6 +49,72 @@ func TestSentOutputFollowsRuns(t *testing.T) {
4949 }5050 }5151 52+// Undoing a deleted column puts back the cells a region moved over and53+// was sent again at, rather than keep the region's values there.54+func TestUndoPutsBackCellsARegionMovedOver(t *testing.T) {55+ s := New()56+ w := s.Book()57+ s.Set(at("D19"), "=SEQUENCE(2)")58+ sent(t, s, "r1", "E19")59+ op := LiveOp{Region: "r1", Reset: true, Header: liveRow("n"), Rows: []LiveRow{liveRow("3")}}60+ apply(t, s, op)61+ s.DeleteCols(1, 1)62+ apply(t, s, op) // sent again where it moved, as the UI does63+ wantShown(t, s, map[string]string{"C19": "1", "D19": "n"})64+ w.Undo()65+ apply(t, s, op)66+ wantShown(t, s, map[string]string{"D19": "1", "D20": "2", "E19": "n", "E20": "3"})67+}68+69+// A formula naming a region whose table is blocked reads #REF!, even70+// when the table it showed was only its header, where the region's71+// first cell stays.72+func TestFormulaNamingABlockedRegion(t *testing.T) {73+ s := New()74+ s.Set(at("D1"), "=SUM(nu.r1)")75+ s.Set(at("F12"), "5")76+ sent(t, s, "r1", "E12")77+ apply(t, s, LiveOp{Region: "r1", Reset: true, Header: liveRow("n")})78+ wantShown(t, s, map[string]string{"D1": "0"})79+ apply(t, s, LiveOp{Region: "r1", Reset: true, Header: liveRow("n", "m"), Rows: []LiveRow{liveRow("1", "2")}})80+ wantShown(t, s, map[string]string{"E12": "#REF!", "D1": "#REF!"})81+}82+83+// Undoing a move puts back the cell a region's table grew over once the84+// cell left, and the region, sent again, is blocked by it as before.85+func TestUndoPutsBackCellsARegionGrewOver(t *testing.T) {86+ s := New()87+ w := s.Book()88+ s.Set(at("F14"), "=1+1")89+ sent(t, s, "r1", "E13")90+ op := LiveOp{Region: "r1", Reset: true, Header: liveRow("n", "m"), Rows: []LiveRow{liveRow("1", "2")}}91+ apply(t, s, op)92+ wantShown(t, s, map[string]string{"E13": "#REF!", "F14": "2"})93+ if _, err := s.Move(rect("F14"), at("A1")); err != nil {94+ t.Fatal(err)95+ }96+ apply(t, s, op)97+ wantShown(t, s, map[string]string{"E13": "n", "F14": "2", "A1": "2"})98+ w.Undo()99+ apply(t, s, op)100+ wantShown(t, s, map[string]string{"E13": "#REF!", "F14": "2", "A1": ""})101+ if s.Cell(at("F14")).Input != "=1+1" {102+ t.Errorf("F14 = %q", s.Cell(at("F14")).Input)103+ }104+}105+106+// A region whose column is deleted is gone, and formulas naming it say107+// so, as after reopening.108+func TestFormulaNamingARegionDeleted(t *testing.T) {109+ s := New()110+ s.Set(at("D1"), "=SUM(nu.r1)")111+ sent(t, s, "r1", "H3")112+ apply(t, s, LiveOp{Region: "r1", Reset: true, Header: liveRow("n"), Rows: []LiveRow{liveRow("56")}})113+ wantShown(t, s, map[string]string{"D1": "56"})114+ s.DeleteCols(7, 2)115+ wantShown(t, s, map[string]string{"D1": "#NAME?"})116+}117+52118 func TestSentOutputNames(t *testing.T) {53119 s := New()54120 w := s.Book()@@ -62,13 +62,17 @@ func (w *Workbook) settleRegions() {
6262 }6363 6464 // writeRegions empties the regions that are to be fed again and clears65-// the cells of regions gone, returning the cells that changed.65+// the cells of regions gone, returning the cells that changed and the66+// formulas naming regions gone.6667 func (s *Sheet) writeRegions() []loc {6768 var changed []loc6869 for k, me := range s.regionMeta {6970 if s.regionIndex(k) < 0 {7071 changed = append(changed, s.clearOwned(me, noRect)...)7172 delete(s.regionMeta, k)73+ for u := range s.wb.nameUsers[nameKey(regionPrefix+k)] {74+ changed = append(changed, u) // the name reads nothing now75+ }7276 }7377 }7478 for _, r := range s.regions.list {@@ -94,10 +98,11 @@ func (s *Sheet) emptyRegion(me *regionMeta) []loc {
9498 // nil rows show already. It writes only the cells that differ and9599 // clears the region's cells it no longer needs; when a cell it needs96100 // holds something else, it shows only why. It returns the cells that97-// changed, with the formulas naming the region when its table moved.101+// changed, with the formulas naming the region when its table moved or102+// changed shape.98103 func (s *Sheet) writeTable(r Region, me *regionMeta, header LiveRow, rows []LiveRow, reset bool) []loc {99104 cols, nrows := tableSize(me, header, rows, reset)100- was, had := me.written, me.has105+ was, had, shape := me.written, me.has, [2]int{me.rows, me.cols}101106 need := s.tableArea(r, cols, nrows)102107 me.why = ""103108 switch {@@ -137,7 +142,7 @@ func (s *Sheet) writeTable(r Region, me *regionMeta, header LiveRow, rows []Live
137142 for _, c := range changed {138143 s.freedFor(c.a)139144 }140- if !had || was != me.written {145+ if !had || was != me.written || shape != [2]int{me.rows, me.cols} { // moved, or its table changed shape141146 for _, k := range []string{nameKey(r.FormulaName()), nameKey(r.Name)} {142147 for u := range s.wb.nameUsers[k] {143148 changed = append(changed, u)@@ -204,6 +209,16 @@ func (s *Sheet) writeRegionCell(a Addr, c LiveCell, me *regionMeta, changed []lo
204209 // meta me didn't write. Only what the region didn't hold is looked at:205210 // nothing else can be placed in a region's cells.206211 func (s *Sheet) inTheWay(need Rect, me *regionMeta) (Addr, bool) {212+ // An array's cells are in the way even where it spilled blanks, as213+ // another array's are (spill.go).214+ for a, sp := range s.spills {215+ if x, ok := intersectRect(sp.area, need); ok && sp.why == "" {216+ if x.From != a {217+ return x.From, true218+ }219+ return a, true220+ }221+ }207222 for _, part := range fresh(need, me) {208223 for a := range s.cells.anyKeysIn(part) {209224 if s.cells.filledAt(a) && !s.regionOwns(a, me) {@@ -214,6 +229,15 @@ func (s *Sheet) inTheWay(need Rect, me *regionMeta) (Addr, bool) {
214229 return Addr{}, false215230 }216231 232+// regionCell reports whether a region holds the cell at a.233+func (s *Sheet) regionCell(a Addr) bool {234+ if len(s.regions.list) == 0 {235+ return false236+ }237+ _, _, ok := s.ownerOf(a)238+ return ok239+}240+217241 // fresh is the parts of need the region with meta me doesn't hold: all218242 // of it, or when need grows from what it holds, the strips to its right219243 // and below, so rows arriving cost what they add.@@ -235,7 +259,25 @@ func fresh(need Rect, me *regionMeta) []Rect {
235259 // writable reports whether the cell at a is free for the region with236260 // meta me to write.237261 func (s *Sheet) writable(a Addr, me *regionMeta) bool {238- return !s.cells.filledAt(a) || s.regionOwns(a, me)262+ if s.regionOwns(a, me) {263+ return true264+ }265+ _, spilled := s.SpillAnchor(a) // an array's blank cell is its own266+ return !spilled && !s.cells.filledAt(a)267+}268+269+// regionsGiveWay empties the regions whose tables are in area, where an270+// array spills: an array spills over a region's table, whichever came271+// first, as it does when the file opens and arrays spill before regions272+// are sent their rows. Sent again, a region finds the array in its way.273+func (s *Sheet) regionsGiveWay(area Rect) []loc {274+ var changed []loc275+ for _, r := range s.regions.list {276+ if me := s.regionMeta[nameKey(r.Name)]; me != nil && me.has && overlaps(me.written, area) {277+ changed = append(changed, s.emptyRegion(me)...)278+ }279+ }280+ return changed239281 }240282 241283 // regionOwns reports whether the cell at a is one the region with meta@@ -47,6 +47,11 @@ type Cell struct {
4747 volatile bool // expr calls TODAY, NOW, RAND...4848 derived bool // a pivot table's result, owned by the engine: see pivot.go4949 spilled bool // part of an array a formula spills, owned by it: see spill.go50+ // typedText marks text typed into a Plain text cell that would read51+ // as a number or formula, or not at all, typed into a cell of another52+ // format: it stays text when the cell's format changes, as in Sheets,53+ // until it's typed again.54+ typedText bool5055 }5156 5257 // Spilled reports whether the cell shows part of an array another@@ -275,6 +280,13 @@ func (s *Sheet) put(a Addr, input string) error {
275280 // is text. Control characters are dropped so a worksheet file can't276281 // smuggle escape sequences to the terminal.277282 func newCell(input string, f Format, st Style, implied bool) (*Cell, error) {283+ return readEntry(input, f, st, implied, f.Kind == FmtText)284+}285+286+// readEntry is newCell reading the entry as text when asText, and as287+// typed into a cell of another format than Plain text otherwise, as a288+// file says to of an entry typed before its cell's format changed.289+func readEntry(input string, f Format, st Style, implied, asText bool) (*Cell, error) {278290 input = strings.Map(func(r rune) rune {279291 if unicode.IsControl(r) {280292 return -1@@ -285,21 +297,28 @@ func newCell(input string, f Format, st Style, implied bool) (*Cell, error) {
285297 return formattingOnly(f, st), nil286298 }287299 var n Node288- if f.Kind != FmtText {300+ if !asText {289301 var fi Format290302 var err error291303 if n, fi, err = classify(input); err != nil {292304 return nil, err293305 }294- if implied && !fi.IsZero() {306+ if implied && !fi.IsZero() && f.Kind != FmtText {295307 f = fi296308 }297309 }298- c := &Cell{Input: input, Format: f, Style: st}310+ c := &Cell{Input: input, Format: f, Style: st, typedText: asText && readsOtherwise(input)}299311 c.setExpr(n)300312 return c, nil301313 }302314 315+// readsOtherwise reports whether an entry typed as text reads as316+// something else, or not at all, typed into a cell of another format.317+func readsOtherwise(input string) bool {318+ n, _, err := classify(input)319+ return n != nil || err != nil320+}321+303322 // formattingOnly is a blank cell holding formatting, or nil when there is304323 // none.305324 func formattingOnly(f Format, st Style) *Cell {@@ -150,7 +150,7 @@ var noLook look
150150 // strings it uses to the table.151151 func (st *cellStore) plainSlot(c *Cell) (slot, bool) {152152 switch {153- case c.Note != "":153+ case c.Note != "" || c.typedText:154154 return slot{}, false155155 case c.derived || c.spilled:156156 return st.derivedSlot(c)@@ -33,6 +33,9 @@ import (
3333 type spill struct {3434 area Rect // the cells it covers, the anchor first; when blocked, those it needs3535 why string // why the anchor shows #REF! instead, or ""36+ // circular is set when it's blocked by a cycle through its cells37+ // (spillblock.go).38+ circular bool3639 // arr and auto are the array written and its format, so the same3740 // array computed again isn't written again, unless stale says a cell3841 // of area changed since.@@ -55,22 +58,29 @@ type spillWork map[loc]pendingSpill
5558 5659 type pendingSpill struct {5760 arr *functions.Array // nil when the formula computed one value58- v Value // what the formula computed, before spilling61+ v Value // what the anchor showed formulas read meanwhile62+ top Value // what the formula computed, the array's first value5963 }6064 61-// noteSpill records what the formula of c, at a on s, computed, when it62-// spills or did.65+// noteSpill records what the formula of c, at a on s, computed (top,66+// and arr, nil for one value), when it spills or did. An array blocked67+// before keeps showing #REF! until spilling finds it free, so formulas68+// reading it don't see it come and go while it stays blocked.6369 //6470 //go:noinline65-func (w *Workbook) noteSpill(s *Sheet, a Addr, c *Cell) {66- arr, v, l := s.calcGet.lib.Spilled(), c.Value, loc{s, a}67- if arr == nil && s.spills[a] == nil {71+func (w *Workbook) noteSpill(s *Sheet, a Addr, c *Cell, arr *functions.Array) {72+ l, top := loc{s, a}, c.Value73+ sp := s.spills[a]74+ if arr == nil && sp == nil {6875 return6976 }77+ if arr != nil && sp != nil && sp.why != "" {78+ c.Value = ErrRef79+ }7080 if w.spillWork == nil {7181 w.spillWork = spillWork{}7282 }73- w.spillWork[l] = pendingSpill{arr, v}83+ w.spillWork[l] = pendingSpill{arr, c.Value, top}7484 }7585 7686 // settleSpills writes the arrays evaluated, then recalculates what reads@@ -125,22 +135,40 @@ func (s *Sheet) applySpill(a Addr, p pendingSpill) ([]loc, bool) {
125135 if c == nil || !c.IsFormula() || p.arr == nil {126136 return s.dropSpill(a), false127137 }138+ area, why, later := s.spillArea(a, p.arr)139+ var cycle []loc140+ circ := false141+ if why == "" {142+ // Checked each time the anchor is computed: a formula typed since143+ // it spilled may lead from its cells back to it.144+ why, cycle = s.spillsIntoItself(a, area)145+ circ = why != ""146+ }128147 old := s.spills[a]129- if old != nil && old.why == "" && !old.stale && old.auto == c.auto && sameArray(old.arr, p.arr) {148+ if why == "" && len(later) == 0 && old != nil && old.why == "" && !old.stale && old.auto == c.auto && sameArray(old.arr, p.arr) {130149 return nil, false // written already, and nothing has changed in its way131150 }132- area, why := s.spillArea(a, p.arr)151+ var changed []loc152+ if circ != (old != nil && old.circular) {153+ changed = s.wb.recheckArrays(loc{s, a}) // a cycle through arrays began or ended here154+ }133155 if why != "" {134- changed := s.dropSpill(a)135- s.setSpill(a, &spill{area: area, why: why})156+ changed = append(changed, s.dropSpill(a)...)157+ s.setSpill(a, &spill{area: area, why: why, circular: circ})136158 c.Value = ErrRef137- return changed, p.v != ErrRef159+ return append(changed, blockCircular(cycle)...), p.v != ErrRef138160 }139- var changed []loc161+ for _, b := range later {162+ // b's array gives way, and b is computed again to find itself163+ // blocked by this one.164+ changed = append(append(changed, s.dropSpill(b)...), loc{s, b})165+ }166+ changed = append(changed, s.regionsGiveWay(area)...)140167 if old != nil && old.why == "" {141- changed = s.clearSpilled(old.area, area)168+ changed = append(changed, s.clearSpilled(old.area, area)...)142169 }143170 s.setSpill(a, &spill{area: area, arr: p.arr, auto: c.auto})171+ c.Value = p.top144172 for r := range area.To.Row - a.Row + 1 {145173 for col := range area.To.Col - a.Col + 1 {146174 if r == 0 && col == 0 {@@ -152,40 +180,82 @@ func (s *Sheet) applySpill(a Addr, p pendingSpill) ([]loc, bool) {
152180 }153181 }154182 }155- return changed, false183+ return changed, p.v != p.top156184 }157185 158186 // spillArea is where the array computed at a goes, or why it can't: past159187 // the sheet's edge, past max-cells, or over a cell that isn't empty.160188 // Blank entries past the array's data aren't spilled, so a whole column161189 // read as an array spills what it holds.162-func (s *Sheet) spillArea(a Addr, arr *functions.Array) (Rect, string) {190+//191+// When two arrays need the same cells, the one whose anchor comes first,192+// row by row, gets them, whichever spilled first: later lists the193+// anchors whose arrays give way to this one. So what spills doesn't194+// depend on the order formulas were typed or computed in, and a file195+// opens as it was saved.196+func (s *Sheet) spillArea(a Addr, arr *functions.Array) (area Rect, why string, later []Addr) {163197 rows, cols := arr.Rows, arr.Cols164198 if arr.Fill.Kind == Empty {165199 rows, cols = min(rows, max(arr.DRows, 1)), min(cols, max(arr.DCols, 1))166200 }167- area := Rect{From: a, To: Addr{Col: a.Col + cols - 1, Row: a.Row + rows - 1}}201+ area = Rect{From: a, To: Addr{Col: a.Col + cols - 1, Row: a.Row + rows - 1}}168202 switch {169203 case area.To.Row >= MaxRows || area.To.Col >= MaxCols:170204 area.To = Addr{Col: min(area.To.Col, MaxCols-1), Row: min(area.To.Row, MaxRows-1)}171- return area, "Array result was not expanded because it would go past the edge of the sheet"205+ return area, "Array result was not expanded because it would go past the edge of the sheet", nil172206 case rows*cols > MaxCells():173- return area, fmt.Sprintf("Array result was not expanded because it would write more cells than max-cells allows (%d)", MaxCells())207+ return area, fmt.Sprintf("Array result was not expanded because it would write more cells than max-cells allows (%d)", MaxCells()), nil174208 }175209 if ms := s.MergesIn(area); len(ms) > 0 {176- return area, "Array result was not expanded because it would overwrite merged cells in " + ms[0].String()210+ return area, "Array result was not expanded because it would overwrite merged cells in " + ms[0].String(), nil211+ }212+ if at, blocked := s.arraysInTheWay(a, area, &later); blocked {213+ return area, "Array result was not expanded because it would overwrite data in " + at.String(), nil177214 }178215 old := s.spills[a]179216 for at := range s.cells.anyKeysIn(area) {180217 if at == a || !s.cells.filledAt(at) {181218 continue182219 }183- if s.cells.derivedAt(at) == slotSpill && old != nil && old.why == "" && old.area.Contains(at) {184- continue220+ if s.cells.derivedAt(at) == slotSpill {221+ if old != nil && old.why == "" && old.area.Contains(at) {222+ continue223+ }224+ if _, ok := s.SpillAnchor(at); ok || s.regionCell(at) {225+ continue // an array's, found above, or a region's, which gives way226+ }227+ }228+ return area, "Array result was not expanded because it would overwrite data in " + at.String(), nil229+ }230+ return area, "", later231+}232+233+// arraysInTheWay finds the other arrays spilled over area, where the234+// array at a would go, blank cells of theirs included, as Sheets keeps235+// an array's whole range: it reports a cell of one whose anchor comes236+// before a, which blocks it, and adds to later those whose anchors come237+// after.238+func (s *Sheet) arraysInTheWay(a Addr, area Rect, later *[]Addr) (Addr, bool) {239+ if n := len(s.spills); n == 0 || n == 1 && s.spills[a] != nil {240+ return Addr{}, false241+ }242+ var at Addr243+ blocked := false244+ for row := area.From.Row; row <= area.To.Row && !blocked; row++ {245+ for col := area.From.Col; col <= area.To.Col && !blocked; col++ {246+ cell := Addr{Col: col, Row: row}247+ s.spillAt.readers(cell, func(b Addr) {248+ switch sp := s.spills[b]; {249+ case b == a || sp == nil || sp.why != "" || blocked:250+ case b.Row < a.Row || b.Row == a.Row && b.Col < a.Col:251+ at, blocked = cell, true252+ case !slices.Contains(*later, b):253+ *later = append(*later, b)254+ }255+ })185256 }186- return area, "Array result was not expanded because it would overwrite data in " + at.String()187257 }188- return area, ""258+ return at, blocked189259 }190260 191261 // writeSpilled makes the cell at a show v as a spilled cell, keeping its@@ -221,7 +291,7 @@ func (s *Sheet) clearSpilled(area, keep Rect) []loc {
221291 var changed []loc222292 var gone []Addr223293 for at := range s.cells.anyKeysIn(area) {224- if s.cells.derivedAt(at) == slotSpill && !keep.Contains(at) {294+ if s.cells.derivedAt(at) == slotSpill && !keep.Contains(at) && !s.regionCell(at) {225295 gone = append(gone, at)226296 }227297 }@@ -229,7 +299,32 @@ func (s *Sheet) clearSpilled(area, keep Rect) []loc {
229299 s.setDerived(at, s.cells.get(at).leftover())230300 changed = append(changed, loc{s, at})231301 }232- return changed302+ return append(changed, s.wakeBlocked(area, keep)...)303+}304+305+// wakeBlocked returns the anchors of arrays blocked from spilling over306+// the cells of area outside keep, which an array leaving them freed, to307+// compute again, and marks the regions blocked there to be sent again.308+// The cells it left may have been blank, which changing309+// wouldn't find.310+func (s *Sheet) wakeBlocked(area, keep Rect) []loc {311+ var out []loc312+ freed := func(r Rect) bool {313+ x, ok := intersectRect(r, area)314+ return ok && !(keep.Contains(x.From) && keep.Contains(x.To))315+ }316+ for b, sp := range s.spills {317+ if sp.why != "" && freed(sp.area) {318+ sp.stale = true319+ out = append(out, loc{s, b})320+ }321+ }322+ for _, me := range s.regionMeta {323+ if me.why != "" && freed(me.need) {324+ me.stale = true // sent again, as a region blocked by a cell cleared is325+ }326+ }327+ return out233328 }234329 235330 // dropSpill forgets the anchor at a's spill and clears its cells,@@ -204,3 +204,101 @@ func TestSpillRules(t *testing.T) {
204204 t.Errorf("A4's look = %+v, want styled and invalid", l)205205 }206206 }207+208+// When two arrays need the same cells, the one anchored first, row by209+// row, gets them, whichever was typed first, even where the other's210+// cells are blank; reopening the file agrees.211+func TestOverlappingArraysFirstAnchorWins(t *testing.T) {212+ cases := []struct {213+ first, second [2]string214+ win, lose string215+ }{216+ {[2]string{"C9", "=SORT(E4:G8)"}, [2]string{"D8", "=SEQUENCE(2)"}, "D8", "C9"},217+ {[2]string{"D8", "=SEQUENCE(2)"}, [2]string{"C9", "=SORT(E4:G8)"}, "D8", "C9"},218+ {[2]string{"E5", "=SEQUENCE(3,2)"}, [2]string{"F4", "=SEQUENCE(3)"}, "F4", "E5"},219+ {[2]string{"F4", "=SEQUENCE(3)"}, [2]string{"E5", "=SEQUENCE(3,2)"}, "F4", "E5"},220+ }221+ for _, c := range cases {222+ s := sheetOf(t, map[string]string{"E4": "5", "G8": "1"})223+ s.Set(at(c.first[0]), c.first[1])224+ s.Set(at(c.second[0]), c.second[1])225+ for name, sh := range map[string]*Sheet{"typed": s, "reopened": roundTrip(t, s)} {226+ if v := sh.Value(at(c.lose)); v != ErrRef {227+ t.Errorf("%v then %v, %s: %s = %v", c.first, c.second, name, c.lose, v)228+ }229+ if _, ok := sh.SpillArea(at(c.win)); !ok {230+ t.Errorf("%v then %v, %s: %s doesn't spill", c.first, c.second, name, c.win)231+ }232+ }233+ // Taking the second back frees the first to spill.234+ s.Undo()235+ if _, ok := s.SpillArea(at(c.first[0])); !ok {236+ t.Errorf("%v then %v, undone: %s doesn't spill", c.first, c.second, c.first[0])237+ }238+ }239+}240+241+// A formula reading an array blocked by that formula's own cell reads242+// the anchor's #REF!, settling rather than going back and forth as the243+// formula's own array comes and goes.244+func TestReadingABlockedArray(t *testing.T) {245+ s := New()246+ s.Set(at("A18"), "=SORT(A16:C16)")247+ s.Set(at("A16"), "=SEQUENCE(3)")248+ for name, sh := range map[string]*Sheet{"typed": s, "reopened": roundTrip(t, s)} {249+ for _, a := range []string{"A16", "A18"} {250+ if v := sh.Value(at(a)); v != ErrRef {251+ t.Errorf("%s: %s = %v", name, a, v)252+ }253+ }254+ if sh.Book().Circular {255+ t.Errorf("%s: arrays kept moving", name)256+ }257+ }258+}259+260+// An array whose formula reads its own cells through another formula261+// is a circular dependency too: #REF! for it and what reads it, typed,262+// recalculated or opened, without going back and forth.263+func TestSpillIntoItsInputThroughAFormula(t *testing.T) {264+ s := New()265+ s.Set(at("F11"), "=VLOOKUP(F14,C17:D19,1,FALSE)")266+ s.Set(at("C19"), "=SORT(E9:F11)")267+ s.Set(at("H15"), "=MAX(E:C)")268+ check := func(name string, sh *Sheet) {269+ t.Helper()270+ for _, a := range []string{"C19", "H15"} {271+ if v := sh.Value(at(a)); v != ErrRef {272+ t.Errorf("%s: %s = %v", name, a, v)273+ }274+ }275+ if got := sh.ExplainError(at("C19")); !strings.Contains(got, "Circular dependency") {276+ t.Errorf("%s: %q", name, got)277+ }278+ }279+ check("typed", s)280+ check("reopened", roundTrip(t, s))281+ s.RecalcAll()282+ check("recalculated", s)283+ if s.Book().Circular {284+ t.Error("arrays kept moving")285+ }286+}287+288+// An array that would spill into the cells its formula reads is a289+// circular dependency: #REF!, whether typed or opened, rather than a290+// result that depends on what was computed first.291+func TestSpillIntoItsInput(t *testing.T) {292+ s := sheetOf(t, map[string]string{"C8": "3", "A9": "=SORT(B8:D9)"})293+ for name, sh := range map[string]*Sheet{"typed": s, "reopened": roundTrip(t, s)} {294+ if v := sh.Value(at("A9")); v != ErrRef {295+ t.Errorf("%s: A9 = %v", name, v)296+ }297+ if sh.Filled(at("B9")) || sh.Filled(at("B10")) {298+ t.Errorf("%s: spilled into its input", name)299+ }300+ if got := sh.ExplainError(at("A9")); !strings.Contains(got, "Circular dependency") {301+ t.Errorf("%s: %q", name, got)302+ }303+ }304+}@@ -0,0 +1,156 @@
1+package sheet2+3+// spillsIntoItself finds whether the array at a would spill over a cell4+// its formula reads, other than a itself, directly or through other5+// formulas and the arrays they spill: spilling would change what6+// computed it, a circular dependency, whose values would depend on the7+// order cells were computed in. It returns why, or "", and the other8+// arrays on the way, which are part of the cycle and give way too.9+//10+// It walks the formulas that read the area's cells, and those that read11+// them or what they spill, looking for the anchor: the cells a12+// recalculation after the spill would visit anyway. An array blocked by13+// a cycle counts as spilling over the cells it needs, so the arrays of14+// a cycle stay blocked together whichever was computed first.15+func (s *Sheet) spillsIntoItself(a Addr, area Rect) (string, []loc) {16+ if area.From == area.To {17+ return "", nil18+ }19+ w := &spillWalk{look: s.wb.readerLookup(), target: loc{s, a}, seen: map[loc]bool{{s, a}: true}, parent: -1}20+ if w.cells(s, area, a, loc{}, true) {21+ return circular(w.origin), nil22+ }23+ for i := 0; i < len(w.queue); i++ {24+ u := w.queue[i]25+ w.origin, w.parent = w.nodes[i].from, i26+ // An array's cells first, so a cycle through them is found as27+ // one, and the array blocked with it, rather than through the28+ // anchor alone.29+ if sp := u.s.spills[u.a]; sp != nil && (sp.why == "" || sp.circular) {30+ w.cells(u.s, sp.area, u.a, u, false)31+ }32+ if !w.found {33+ w.look.each(u, w.visitVia(loc{}))34+ }35+ if w.found {36+ return circular(w.origin), w.arrays()37+ }38+ }39+ return "", nil40+}41+42+// spillWalk is the state of spillsIntoItself: the formulas reached, in43+// the order found, each with how it was reached.44+type spillWalk struct {45+ look readerLookup46+ target loc47+ seen map[loc]bool48+ queue []loc49+ nodes []walkNode50+ origin Addr // the cell of the area the walk is on51+ parent int // the node the walk is on, -1 at the start52+ via loc // the array whose cells the walk is on, if any53+ found bool54+ last int // the node the target was reached from55+}56+57+// walkNode is how the walk reached a formula: from which cell of the58+// area, from which node, and through the cells of which array.59+type walkNode struct {60+ from Addr61+ parent int62+ via loc63+}64+65+// visitVia returns what the walk does with each formula it reaches66+// through the cells of the array via (none when zero).67+func (w *spillWalk) visitVia(via loc) func(loc) {68+ w.via = via69+ return w.visit70+}71+72+func (w *spillWalk) visit(u loc) {73+ switch {74+ case w.found:75+ case u == w.target:76+ w.found, w.last = true, len(w.nodes)77+ w.nodes = append(w.nodes, walkNode{w.origin, w.parent, w.via})78+ case !w.seen[u]:79+ w.seen[u] = true80+ w.queue = append(w.queue, u)81+ w.nodes = append(w.nodes, walkNode{w.origin, w.parent, w.via})82+ }83+}84+85+// cells visits the formulas reading the cells of area on s other than86+// skip, the cells of the array via, and reports whether one leads to87+// the target. At the start, each cell is its own origin.88+func (w *spillWalk) cells(s *Sheet, area Rect, skip Addr, via loc, start bool) bool {89+ visit := w.visitVia(via)90+ for row := area.From.Row; row <= area.To.Row; row++ {91+ for col := area.From.Col; col <= area.To.Col; col++ {92+ at := Addr{Col: col, Row: row}93+ if at == skip {94+ continue95+ }96+ if start {97+ w.origin = at98+ }99+ if w.look.each(loc{s, at}, visit); w.found {100+ return true101+ }102+ }103+ }104+ return false105+}106+107+// arrays are the arrays whose cells the path to the target went108+// through.109+func (w *spillWalk) arrays() []loc {110+ var out []loc111+ for i := w.last; i >= 0; i = w.nodes[i].parent {112+ if v := w.nodes[i].via; v.s != nil {113+ out = append(out, v)114+ }115+ }116+ return out117+}118+119+func circular(at Addr) string {120+ return "Circular dependency: the array would spill into " + at.String() + ", which its formula reads"121+}122+123+// recheckArrays returns every array's anchor but l's, spilling or124+// blocked by a cycle, to compute again: a cycle through arrays that125+// begins or ends at l may take in or let go of others, which checking126+// each again finds, whichever order they're in.127+func (w *Workbook) recheckArrays(l loc) []loc {128+ var out []loc129+ for _, s := range w.sheets {130+ for b, sp := range s.spills {131+ if (loc{s, b}) != l && (sp.why == "" || sp.circular) {132+ sp.stale = true133+ out = append(out, loc{s, b})134+ }135+ }136+ }137+ return out138+}139+140+// blockCircular blocks the arrays at l, part of a cycle with another,141+// and returns the cells that changed, with the anchors, to compute142+// again, finding the cycle themselves.143+func blockCircular(arrays []loc) []loc {144+ var changed []loc145+ for _, l := range arrays {146+ sp := l.s.spills[l.a]147+ if sp == nil || sp.circular {148+ continue149+ }150+ area := sp.area151+ changed = append(changed, l.s.dropSpill(l.a)...)152+ l.s.setSpill(l.a, &spill{area: area, why: "Circular dependency through another array", circular: true})153+ changed = append(changed, l)154+ }155+ return changed156+}@@ -0,0 +1,21 @@
1+{2+ "version": 3,3+ "name": "Haushalt",4+ "names": {"Miete": "B2"},5+ "arithmetic": "decimal",6+ "locale": "de-DE",7+ "widths": {"A": 16},8+ "freeze": {"cols": 1},9+ "filter": {"range": "A1:B4", "columns": {"A": {"condition": "not_empty"}}},10+ "cells": {11+ "A1": "Posten",12+ "B1": "Betrag",13+ "A2": "Miete",14+ "B2": {"input": "1450.10", "format": "currency", "decimals": 2},15+ "A3": "Strom",16+ "B3": {"input": "0.1", "format": "currency", "decimals": 2},17+ "A4": "Summe",18+ "B4": "=SUM(B2:B3)+0.2",19+ "C4": "=B4=1450.4"20+ }21+}@@ -0,0 +1,161 @@
1+{2+ "version": 5,3+ "names": {"Units": "Sales!C2:C9", "Lost": "#REF!", "Rate": "Lists!B1"},4+ "macroOrigin": "fixture-machine",5+ "macros": [6+ {"name": "Bold header", "key": "1", "api": 1, "source": "select(\"A1:E1\")\nrun(\"format.bold\")\n"},7+ {"name": "Fill totals", "api": 1, "source": "set(\"F2\", \"=SUM(C2:C9)\")\n"}8+ ],9+ "shellHistory": ["ls", "open data.csv | first 3"],10+ "active": 0,11+ "sheets": [12+ {13+ "name": "Sales",14+ "widths": {"A": 12, "B": 10, "E": 14},15+ "heights": {"1": 2, "12": 3},16+ "lines": {"A:XFD": {"italic": true}, "D:D": {"format": "currency", "decimals": 2}, "1:1": {"bold": true, "align": "center", "borders": {"bottom": "double"}}},17+ "freeze": {"rows": 1, "cols": 1},18+ "filter": {"range": "A1:E9", "columns": {"B": {"hidden": ["West"]}, "C": {"condition": "gt", "value": "2"}}},19+ "protected": [{"range": "E2:E9", "description": "Totals"}],20+ "merges": ["A11:C11", "A12:A13"],21+ "cells": {22+ "A1": "Date",23+ "B1": "Region",24+ "C1": "Units",25+ "D1": "Price",26+ "E1": "Total",27+ "A2": {"input": "2026-01-05", "format": "date"},28+ "B2": "North",29+ "C2": "3",30+ "D2": "12.5",31+ "E2": {"input": "=C2*D2", "format": "currency", "decimals": 2, "own": true},32+ "A3": {"input": "2026-01-12", "format": "date"},33+ "B3": {"input": "South", "note": "Includes returns"},34+ "C3": "5",35+ "D3": "9.75",36+ "E3": "=C3*D3",37+ "A4": {"input": "2026-02-02", "format": "date"},38+ "B4": "West",39+ "C4": "1",40+ "D4": "40",41+ "E4": "=C4*D4",42+ "A5": {"input": "2026-02-16", "format": "date"},43+ "B5": "North",44+ "C5": "7",45+ "D5": "12.5",46+ "E5": "=C5*D5",47+ "A6": {"input": "2026-03-01", "format": "date"},48+ "B6": "East",49+ "C6": {"input": "2", "bold": true, "italic": true, "underline": true, "strikethrough": true},50+ "D6": "22",51+ "E6": "=C6*D6",52+ "A7": {"input": "2026-03-09", "format": "date"},53+ "B7": "South",54+ "C7": {"input": "4", "align": "left", "valign": "top"},55+ "D7": "9.75",56+ "E7": "=C7*D7",57+ "A8": {"input": "2026-03-20", "format": "date"},58+ "B8": "East",59+ "C8": {"input": "6", "borders": {"top": "thin", "bottom": "double", "left": "thick", "right": "thin", "bottomColor": "red", "leftColor": "blue"}},60+ "D8": "22",61+ "E8": "=C8*D8",62+ "A9": {"input": "2026-03-31", "format": "date"},63+ "B9": "West",64+ "C9": "8",65+ "D9": "40",66+ "E9": "=C9*D9",67+ "G1": "Share",68+ "G2": {"input": "=C2/SUM(Units)", "format": "percent", "decimals": 1},69+ "G3": {"input": "=C3/SUM(Units)", "format": "custom", "pattern": "0.00%;[Red]-0.00%"},70+ "H1": "Regions",71+ "H2": "=SORT(UNIQUE(B2:B9))",72+ "I1": "Scaled",73+ "I2": "=LET(r, Rate, MAP(C2:C4, LAMBDA(x, x*r)))",74+ "J1": {"input": "007", "text": true},75+ "J2": {"input": "=1+1", "text": false, "format": "text"},76+ "A11": {"input": "A long title merged across three columns", "wrap": "wrap", "align": "center"},77+ "A12": {"input": "Tall", "valign": "middle"},78+ "B12": {"input": "clipped text that does not spill over", "wrap": "clip"},79+ "C12": "TRUE",80+ "D12": "=IFERROR(1/0, \"none\")",81+ "E12": {"input": "=Lists!B1*2", "format": "number", "decimals": 3},82+ "F12": "'00123",83+ "F13": "=HYPERLINK(\"https://example.com\", \"site\")"84+ },85+ "charts": [86+ {"type":"column","data":"A1:C9","at":"K2","width":40,"height":12,"header":true,"labels":true,"title":"Units by date"},87+ {"type":"line","data":"C1:E9","at":"K16","width":36,"height":10,"header":true,"stack":"stacked","trend":true,"min":0,"max":400,"gridlines":false,"legend":"none"},88+ {"type":"pie","data":"B1:C5","at":"K28","width":30,"height":10,"header":true,"labels":true,"legend":"right"},89+ {"type":"scatter","data":"C1:D9","at":"K40","width":30,"height":10,"header":true,"trend":true,"log":true},90+ {"type":"bar","data":"B1:C9","at":"K52","width":30,"height":10,"header":true,"stack":"percent"},91+ {"type":"area","data":"C1:D9","at":"K64","width":30,"height":10,"byRow":true,"header":true}92+ ],93+ "conditionalFormats": [94+ {"ranges":"C2:C9","condition":"gt","values":["5"],"text":"red","bold":true},95+ {"ranges":"B2:B9","condition":"contains","values":["th"],"fill":"yellow","italic":true},96+ {"ranges":"A2:A9","condition":"date_is","values":["this week"],"underline":true},97+ {"ranges":"E2:E9","condition":"formula","values":["=$C2>4"],"text":"green","strikethrough":true},98+ {"ranges":"C2:C9","condition":"between","values":["2","4"],"fill":"cyan"},99+ {"ranges":"C2:C9","condition":"top","values":["3"],"fill":"magenta"},100+ {"ranges":"D2:D9","condition":"duplicate","text":"blue"},101+ {"ranges":"C2:C9","condition":"above_average","bold":true},102+ {"ranges":"D2:D9","scale":[{"type":"min","color":"green"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"red"}]},103+ {"ranges":"E2:E9","dataBar":{"color":"blue","min":{"type":"min"},"max":{"type":"num","value":"400"},"barOnly":true}},104+ {"ranges":"C2:C9,D2:D9","iconSet":{"icons":"arrows","points":[{"type":"percent","value":"33"},{"type":"percent","value":"67"}],"reverse":true}}105+ ],106+ "validations": [107+ {"ranges":"B2:B9","criteria":"list","items":["North","South","East","West"],"help":"Pick a region","display":"chip"},108+ {"ranges":"C2:C9","criteria":"number","condition":"between","values":["0","80"],"reject":true},109+ {"ranges":"A2:A9","criteria":"date","condition":"gt","values":["2025-12-31"]},110+ {"ranges":"C12","criteria":"checkbox","items":["TRUE","FALSE"]},111+ {"ranges":"F2:F9","criteria":"range","source":"Lists!A1:A4","display":"plain"},112+ {"ranges":"G5:G9","criteria":"length","condition":"le","values":["10"]},113+ {"ranges":"H12","criteria":"formula","values":["=H12<>\"\""]}114+ ]115+ },116+ {117+ "name": "Lists",118+ "hidden": true,119+ "protected": [{"sheet": true}],120+ "cells": {121+ "A1": "North",122+ "A2": "South",123+ "A3": "East",124+ "A4": "West",125+ "B1": {"input": "1.5", "note": "Rate used by Sales!I2"}126+ }127+ },128+ {129+ "name": "Pivot Table 1",130+ "cells": {},131+ "pivot": {"source":"Sales!A1:E9","rows":[{"column":"B"},{"column":"A","order":"desc","sortBy":1}],"columns":[{"column":"C"}],"values":[{"column":"E","summarize":"sum"},{"column":"C","summarize":"counta","showAs":"percent_of_total","name":"Share"}],"filters":[{"column":"B","hidden":["West"]}],"rowTotals":true,"columnTotals":false}132+ },133+ {134+ "name": "Data",135+ "widths": {"A": 20},136+ "cells": {137+ "A20": "=SUM(nu.files)"138+ },139+ "regions": [140+ {"name":"files","at":"A1","output":true},141+ {"name":"big","at":"A8","output":true},142+ {"name":"app","at":"F2","path":"logs/app.csv","window":500},143+ {"name":"db","at":"F20","path":"data.sqlite","format":"SQLite","table":"orders"},144+ {"name":"q","at":"M20","path":"data.sqlite","format":"SQLite","query":"select * from orders limit 5"}145+ ]146+ },147+ {148+ "name": "Notebook",149+ "cells": {},150+ "tab": "notebook",151+ "reactive": true,152+ "notebookCells": [153+ {"kind":"note","source":"# Files\nWhat the folder holds."},154+ {"source":"files = ls","output":"[[name, size]; [go.mod, 812b], [README.md, 4kb]]"},155+ {"source":"big = $files | where size > 1kb","output":"[[name, size]; [README.md, 4kb]]","note":"2 rows left out"},156+ {"source":"$files | nope","error":"Command `nope` not found","detail":"help: check the spelling"},157+ {"source":"ls **/*","unsaved":true}158+ ]159+ }160+ ]161+}@@ -0,0 +1,20 @@
1+{2+ "version": 3,3+ "name": "Haushalt",4+ "widths": {"A": 16},5+ "names": {"Miete": "B2"},6+ "arithmetic": "decimal",7+ "freeze": {"cols":1},8+ "filter": {"range":"A1:B4","columns":{"A":{"condition":"not_empty"}}},9+ "cells": {10+ "A1": "Posten",11+ "B1": "Betrag",12+ "A2": "Miete",13+ "B2": {"input":"1450.10","format":"currency","decimals":2},14+ "A3": "Strom",15+ "B3": {"input":"0.1","format":"currency","decimals":2},16+ "A4": "Summe",17+ "B4": "=SUM(B2:B3)+0.2",18+ "C4": "=B4=1450.4"19+ }20+}@@ -0,0 +1,127 @@
1+{2+ "version": 5,3+ "names": {"Lost": "#REF!", "Rate": "Lists!B1", "Units": "Sales!C2:C9"},4+ "macroOrigin": "fixture-machine",5+ "macros": [6+ {"name": "Bold header", "key": "1", "api": 1, "source": "select(\"A1:E1\")\nrun(\"format.bold\")\n"},7+ {"name": "Fill totals", "api": 1, "source": "set(\"F2\", \"=SUM(C2:C9)\")\n"}8+ ],9+ "sheets": [10+ {11+ "name": "Sales",12+ "widths": {"A": 12, "E": 14},13+ "lines": {"A:XFD": {"italic":true}, "D:D": {"format":"currency","decimals":2}, "1:1": {"bold":true,"align":"center"}},14+ "freeze": {"rows":1,"cols":1},15+ "filter": {"range":"A1:E9","columns":{"B":{"hidden":["West"]},"C":{"condition":"gt","value":"2"}}},16+ "protected": [{"range":"E2:E9","description":"Totals"}],17+ "cells": {18+ "A1": "Date",19+ "B1": "Region",20+ "C1": "Units",21+ "D1": "Price",22+ "E1": "Total",23+ "G1": "Share",24+ "H1": "Regions",25+ "I1": "Scaled",26+ "A2": {"input":"2026-01-05","format":"date"},27+ "B2": "North",28+ "C2": "3",29+ "D2": "12.5",30+ "E2": {"input":"=C2*D2","format":"currency","decimals":2,"own":true},31+ "G2": {"input":"=C2/SUM(Units)","format":"percent","decimals":1},32+ "H2": "=SORT(UNIQUE(B2:B9))",33+ "I2": "=LET(r, Rate, MAP(C2:C4, LAMBDA(x, x*r)))",34+ "A3": {"input":"2026-01-12","format":"date"},35+ "B3": {"input":"South","note":"Includes returns"},36+ "C3": "5",37+ "D3": "9.75",38+ "E3": "=C3*D3",39+ "G3": {"input":"=C3/SUM(Units)","format":"custom","pattern":"0.00%;[Red]-0.00%"},40+ "A4": {"input":"2026-02-02","format":"date"},41+ "B4": "West",42+ "C4": "1",43+ "D4": "40",44+ "E4": "=C4*D4",45+ "A5": {"input":"2026-02-16","format":"date"},46+ "B5": "North",47+ "C5": "7",48+ "D5": "12.5",49+ "E5": "=C5*D5",50+ "A6": {"input":"2026-03-01","format":"date"},51+ "B6": "East",52+ "C6": {"input":"2","bold":true,"italic":true,"underline":true,"strikethrough":true},53+ "D6": "22",54+ "E6": "=C6*D6",55+ "A7": {"input":"2026-03-09","format":"date"},56+ "B7": "South",57+ "C7": {"input":"4","align":"left"},58+ "D7": "9.75",59+ "E7": "=C7*D7",60+ "A8": {"input":"2026-03-20","format":"date"},61+ "B8": "East",62+ "C8": "6",63+ "D8": "22",64+ "E8": "=C8*D8",65+ "A9": {"input":"2026-03-31","format":"date"},66+ "B9": "West",67+ "C9": "8",68+ "D9": "40",69+ "E9": "=C9*D9",70+ "A11": {"input":"A long title merged across three columns","align":"center"},71+ "A12": "Tall",72+ "B12": "clipped text that does not spill over",73+ "C12": "TRUE",74+ "D12": "=IFERROR(1/0, \"none\")",75+ "E12": {"input":"=Lists!B1*2","format":"number","decimals":3},76+ "F12": "'00123",77+ "F13": "=HYPERLINK(\"https://example.com\", \"site\")"78+ },79+ "charts": [80+ {"type":"column","data":"A1:C9","at":"K2","width":40,"height":12,"header":true,"labels":true,"title":"Units by date"},81+ {"type":"line","data":"C1:E9","at":"K16","width":36,"height":10,"header":true,"stack":"stacked","trend":true,"min":0,"max":400,"gridlines":false,"legend":"none"},82+ {"type":"pie","data":"B1:C5","at":"K28","width":30,"height":10,"header":true,"labels":true,"legend":"right"},83+ {"type":"scatter","data":"C1:D9","at":"K40","width":30,"height":10,"header":true,"trend":true,"log":true},84+ {"type":"bar","data":"B1:C9","at":"K52","width":30,"height":10,"header":true,"stack":"percent"},85+ {"type":"area","data":"C1:D9","at":"K64","width":30,"height":10,"byRow":true,"header":true}86+ ],87+ "conditionalFormats": [88+ {"ranges":"C2:C9","condition":"gt","values":["5"],"text":"red","bold":true},89+ {"ranges":"B2:B9","condition":"contains","values":["th"],"fill":"yellow","italic":true},90+ {"ranges":"E2:E9","condition":"formula","values":["=$C2\u003e4"],"text":"green","strikethrough":true},91+ {"ranges":"C2:C9","condition":"between","values":["2","4"],"fill":"cyan"},92+ {"ranges":"D2:D9","scale":[{"type":"min","color":"green"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"red"}]}93+ ],94+ "validations": [95+ {"ranges":"B2:B9","criteria":"list","items":["North","South","East","West"],"help":"Pick a region"},96+ {"ranges":"C2:C9","criteria":"number","condition":"between","values":["0","80"],"reject":true},97+ {"ranges":"A2:A9","criteria":"date","condition":"gt","values":["2025-12-31"]},98+ {"ranges":"C12","criteria":"checkbox"},99+ {"ranges":"F2:F9","criteria":"range","source":"Lists!A1:A4"},100+ {"ranges":"G5:G9","criteria":"length","condition":"le","values":["10"]},101+ {"ranges":"H12","criteria":"formula","values":["=H12\u003c\u003e\"\""]}102+ ]103+ },104+ {105+ "name": "Lists",106+ "hidden": true,107+ "protected": [{"sheet":true}],108+ "cells": {109+ "A1": "North",110+ "B1": {"input":"1.5","note":"Rate used by Sales!I2"},111+ "A2": "South",112+ "A3": "East",113+ "A4": "West"114+ }115+ },116+ {117+ "name": "Pivot Table 1",118+ "cells": {},119+ "pivot": {"source":"Sales!A1:E9","rows":[{"column":"B"},{"column":"A","order":"desc","sortBy":1}],"columns":[{"column":"C"}],"values":[{"column":"E","summarize":"sum"},{"column":"C","summarize":"counta","showAs":"percent_of_total","name":"Share"}],"filters":[{"column":"B","hidden":["West"]}],"rowTotals":true,"columnTotals":false}120+ },121+ {122+ "name": "Notebook 1",123+ "widths": {"A": 20},124+ "cells": {}125+ }126+ ]127+}@@ -0,0 +1,21 @@
1+{2+ "version": 3,3+ "name": "Haushalt",4+ "widths": {"A": 16},5+ "names": {"Miete": "B2"},6+ "arithmetic": "decimal",7+ "locale": "de-DE",8+ "freeze": {"cols":1},9+ "filter": {"range":"A1:B4","columns":{"A":{"condition":"not_empty"}}},10+ "cells": {11+ "A1": "Posten",12+ "B1": "Betrag",13+ "A2": "Miete",14+ "B2": {"input":"1450.10","format":"currency","decimals":2},15+ "A3": "Strom",16+ "B3": {"input":"0.1","format":"currency","decimals":2},17+ "A4": "Summe",18+ "B4": "=SUM(B2:B3)+0.2",19+ "C4": "=B4=1450.4"20+ }21+}@@ -0,0 +1,159 @@
1+{2+ "version": 5,3+ "names": {"Lost": "#REF!", "Rate": "Lists!B1", "Units": "Sales!C2:C9"},4+ "macroOrigin": "fixture-machine",5+ "macros": [6+ {"name": "Bold header", "key": "1", "api": 1, "source": "select(\"A1:E1\")\nrun(\"format.bold\")\n"},7+ {"name": "Fill totals", "api": 1, "source": "set(\"F2\", \"=SUM(C2:C9)\")\n"}8+ ],9+ "sheets": [10+ {11+ "name": "Sales",12+ "widths": {"A": 12, "E": 14},13+ "heights": {"1": 2, "12": 3},14+ "lines": {"A:XFD": {"italic":true}, "D:D": {"format":"currency","decimals":2}, "1:1": {"bold":true,"align":"center","borders":{"bottom":"double"}}},15+ "freeze": {"rows":1,"cols":1},16+ "filter": {"range":"A1:E9","columns":{"B":{"hidden":["West"]},"C":{"condition":"gt","value":"2"}}},17+ "protected": [{"range":"E2:E9","description":"Totals"}],18+ "merges": ["A11:C11","A12:A13"],19+ "cells": {20+ "A1": "Date",21+ "B1": "Region",22+ "C1": "Units",23+ "D1": "Price",24+ "E1": "Total",25+ "G1": "Share",26+ "H1": "Regions",27+ "I1": "Scaled",28+ "J1": {"input":"007","text":true},29+ "A2": {"input":"2026-01-05","format":"date"},30+ "B2": "North",31+ "C2": "3",32+ "D2": "12.5",33+ "E2": {"input":"=C2*D2","format":"currency","decimals":2,"own":true},34+ "G2": {"input":"=C2/SUM(Units)","format":"percent","decimals":1},35+ "H2": "=SORT(UNIQUE(B2:B9))",36+ "I2": "=LET(r, Rate, MAP(C2:C4, LAMBDA(x, x*r)))",37+ "J2": {"input":"=1+1","text":false,"format":"text"},38+ "A3": {"input":"2026-01-12","format":"date"},39+ "B3": {"input":"South","note":"Includes returns"},40+ "C3": "5",41+ "D3": "9.75",42+ "E3": "=C3*D3",43+ "G3": {"input":"=C3/SUM(Units)","format":"custom","pattern":"0.00%;[Red]-0.00%"},44+ "A4": {"input":"2026-02-02","format":"date"},45+ "B4": "West",46+ "C4": "1",47+ "D4": "40",48+ "E4": "=C4*D4",49+ "A5": {"input":"2026-02-16","format":"date"},50+ "B5": "North",51+ "C5": "7",52+ "D5": "12.5",53+ "E5": "=C5*D5",54+ "A6": {"input":"2026-03-01","format":"date"},55+ "B6": "East",56+ "C6": {"input":"2","bold":true,"italic":true,"underline":true,"strikethrough":true},57+ "D6": "22",58+ "E6": "=C6*D6",59+ "A7": {"input":"2026-03-09","format":"date"},60+ "B7": "South",61+ "C7": {"input":"4","align":"left","valign":"top"},62+ "D7": "9.75",63+ "E7": "=C7*D7",64+ "A8": {"input":"2026-03-20","format":"date"},65+ "B8": "East",66+ "C8": {"input":"6","borders":{"top":"thin","bottom":"double","left":"thick","right":"thin","bottomColor":"red","leftColor":"blue"}},67+ "D8": "22",68+ "E8": "=C8*D8",69+ "A9": {"input":"2026-03-31","format":"date"},70+ "B9": "West",71+ "C9": "8",72+ "D9": "40",73+ "E9": "=C9*D9",74+ "A11": {"input":"A long title merged across three columns","align":"center","wrap":"wrap"},75+ "A12": {"input":"Tall","valign":"middle"},76+ "B12": {"input":"clipped text that does not spill over","wrap":"clip"},77+ "C12": "TRUE",78+ "D12": "=IFERROR(1/0, \"none\")",79+ "E12": {"input":"=Lists!B1*2","format":"number","decimals":3},80+ "F12": "'00123",81+ "F13": "=HYPERLINK(\"https://example.com\", \"site\")"82+ },83+ "charts": [84+ {"type":"column","data":"A1:C9","at":"K2","width":40,"height":12,"header":true,"labels":true,"title":"Units by date"},85+ {"type":"line","data":"C1:E9","at":"K16","width":36,"height":10,"header":true,"stack":"stacked","trend":true,"min":0,"max":400,"gridlines":false,"legend":"none"},86+ {"type":"pie","data":"B1:C5","at":"K28","width":30,"height":10,"header":true,"labels":true,"legend":"right"},87+ {"type":"scatter","data":"C1:D9","at":"K40","width":30,"height":10,"header":true,"trend":true,"log":true},88+ {"type":"bar","data":"B1:C9","at":"K52","width":30,"height":10,"header":true,"stack":"percent"},89+ {"type":"area","data":"C1:D9","at":"K64","width":30,"height":10,"byRow":true,"header":true}90+ ],91+ "conditionalFormats": [92+ {"ranges":"C2:C9","condition":"gt","values":["5"],"text":"red","bold":true},93+ {"ranges":"B2:B9","condition":"contains","values":["th"],"fill":"yellow","italic":true},94+ {"ranges":"A2:A9","condition":"date_is","values":["this week"],"underline":true},95+ {"ranges":"E2:E9","condition":"formula","values":["=$C2\u003e4"],"text":"green","strikethrough":true},96+ {"ranges":"C2:C9","condition":"between","values":["2","4"],"fill":"cyan"},97+ {"ranges":"C2:C9","condition":"top","values":["3"],"fill":"magenta"},98+ {"ranges":"D2:D9","condition":"duplicate","text":"blue"},99+ {"ranges":"C2:C9","condition":"above_average","bold":true},100+ {"ranges":"D2:D9","scale":[{"type":"min","color":"green"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"red"}]},101+ {"ranges":"E2:E9","dataBar":{"color":"blue","min":{"type":"min"},"max":{"type":"num","value":"400"},"barOnly":true}},102+ {"ranges":"C2:C9,D2:D9","iconSet":{"icons":"arrows","points":[{"type":"percent","value":"33"},{"type":"percent","value":"67"}],"reverse":true}}103+ ],104+ "validations": [105+ {"ranges":"B2:B9","criteria":"list","items":["North","South","East","West"],"help":"Pick a region","display":"chip"},106+ {"ranges":"C2:C9","criteria":"number","condition":"between","values":["0","80"],"reject":true},107+ {"ranges":"A2:A9","criteria":"date","condition":"gt","values":["2025-12-31"]},108+ {"ranges":"C12","criteria":"checkbox","items":["TRUE","FALSE"]},109+ {"ranges":"F2:F9","criteria":"range","source":"Lists!A1:A4","display":"plain"},110+ {"ranges":"G5:G9","criteria":"length","condition":"le","values":["10"]},111+ {"ranges":"H12","criteria":"formula","values":["=H12\u003c\u003e\"\""]}112+ ]113+ },114+ {115+ "name": "Lists",116+ "hidden": true,117+ "protected": [{"sheet":true}],118+ "cells": {119+ "A1": "North",120+ "B1": {"input":"1.5","note":"Rate used by Sales!I2"},121+ "A2": "South",122+ "A3": "East",123+ "A4": "West"124+ }125+ },126+ {127+ "name": "Pivot Table 1",128+ "cells": {},129+ "pivot": {"source":"Sales!A1:E9","rows":[{"column":"B"},{"column":"A","order":"desc","sortBy":1}],"columns":[{"column":"C"}],"values":[{"column":"E","summarize":"sum"},{"column":"C","summarize":"counta","showAs":"percent_of_total","name":"Share"}],"filters":[{"column":"B","hidden":["West"]}],"rowTotals":true,"columnTotals":false}130+ },131+ {132+ "name": "Data",133+ "widths": {"A": 20},134+ "cells": {135+ "A20": "=SUM(nu.files)"136+ },137+ "regions": [138+ {"name":"files","at":"A1","output":true},139+ {"name":"big","at":"A8","output":true},140+ {"name":"app","at":"F2","path":"logs/app.csv","window":500},141+ {"name":"db","at":"F20","path":"data.sqlite","format":"SQLite","table":"orders"},142+ {"name":"q","at":"M20","path":"data.sqlite","format":"SQLite","query":"select * from orders limit 5"}143+ ]144+ },145+ {146+ "name": "Notebook",147+ "cells": {},148+ "tab": "notebook",149+ "reactive": true,150+ "notebookCells": [151+ {"kind":"note","source":"# Files\nWhat the folder holds."},152+ {"source":"files = ls","output":"[[name, size]; [go.mod, 812b], [README.md, 4kb]]"},153+ {"source":"big = $files | where size \u003e 1kb","output":"[[name, size]; [README.md, 4kb]]","note":"2 rows left out"},154+ {"source":"$files | nope","error":"Command `nope` not found","detail":"help: check the spelling"},155+ {"source":"ls **/*","unsaved":true}156+ ]157+ }158+ ]159+}@@ -107,23 +107,25 @@ func (s *Sheet) shiftView(rows bool, sp formula.Span) {
107107 }108108 *frozen = min(*frozen, MaxFrozen)109109 }110- v.filter = shiftFilter(v.filter, rows, sp)110+ if v.filter != nil {111+ v.filter = shiftFilter(v.filter, rows, sp)112+ }111113 if !v.equal(s.view) {112114 s.recordView()115+ merged := !slices.Equal(v.merges, s.view.merges)113116 if w := s.wb; w.hist.open != nil {114117 w.hist.dirty = append(w.hist.dirty, w.tableUsers(s.view.tables, v.tables)...)115118 }116119 s.view = v120+ if merged { // a merge gone may free an array to spill121+ s.respill(Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}})122+ }117123 }118124 }119125 120-// shiftFilter is f once rows or columns are inserted or deleted: its121-// range moved like a range reference, with its columns' criteria, or nil122-// when all of it is deleted.126+// shiftFilter is f with rows or columns inserted or deleted, nil once127+// its range is all deleted.123128 func shiftFilter(f *Filter, rows bool, sp formula.Span) *Filter {124- if f == nil {125- return nil126- }127129 _, rng := formula.AxisMaps(rows, sp)128130 r, ok := rng(f.Range)129131 if !ok {@@ -0,0 +1,246 @@
1+package ui2+3+import (4+ "errors"5+ "fmt"6+ "os"7+ "path/filepath"8+ "runtime"9+ "runtime/debug"10+ "strings"11+ "time"12+13+ tea "charm.land/bubbletea/v2"14+)15+16+// Crashes: a panic anywhere in the model (Update, View, Init, or a17+// command it started) stops the program without taking the terminal18+// down with it. Guard wraps the model for Bubble Tea: it recovers the19+// panic, remembers where it was and what with, and quits, so Bubble Tea20+// restores the terminal as on any quit. View can't return a command to21+// quit with, so a panic there goes on to Bubble Tea, which restores the22+// terminal itself and prints the stack. The caller then keeps the23+// unsaved work for recovery (Keep, recovery.go) and writes a report24+// (Crash.Report). See docs/files/saving.md.25+26+// Crash is a panic Guard caught.27+type Crash struct {28+ Where string // "update", "view", "init" or "a command"29+ Value any // what was panicked with30+ Stack []byte // the panicking goroutine's stack; nil when Bubble Tea caught it31+}32+33+// Guarded is a model run under Guard.34+type Guarded struct {35+ m *Model36+ crash *Crash37+ last tea.View // the last frame drawn, shown until the program quits38+ // viewPanics makes View panic, for crashTest.39+ viewPanics bool40+}41+42+// Guard wraps m so a panic in it stops the program without losing the43+// terminal; see Finish for what's left afterwards.44+func Guard(m *Model) *Guarded {45+ return &Guarded{m: m}46+}47+48+// Model is the model guarded.49+func (g *Guarded) Model() *Model { return g.m }50+51+// crashMsg is a panic caught in a command, brought to Update.52+type crashMsg struct{ crash *Crash }53+54+// caught makes a Crash of a recovered panic.55+func caught(where string, r any) *Crash {56+ return &Crash{Where: where, Value: r, Stack: debug.Stack()}57+}58+59+// Init implements tea.Model.60+func (g *Guarded) Init() (cmd tea.Cmd) {61+ defer func() {62+ if r := recover(); r != nil {63+ g.crash = caught("init", r)64+ cmd = tea.Quit65+ }66+ }()67+ return g.guard(g.m.Init())68+}69+70+// Update implements tea.Model.71+func (g *Guarded) Update(msg tea.Msg) (_ tea.Model, cmd tea.Cmd) {72+ if c, ok := msg.(crashMsg); ok {73+ if g.crash == nil {74+ g.crash = c.crash75+ }76+ return g, tea.Quit77+ }78+ if g.crash != nil {79+ return g, nil // quitting; the model may be broken80+ }81+ defer func() {82+ if r := recover(); r != nil {83+ g.crash = caught("update", r)84+ cmd = tea.Quit85+ }86+ }()87+ if crashTest != nil {88+ if c := g.test(crashTest(msg)); c != nil {89+ return g, c90+ }91+ }92+ next, cmd := g.m.Update(msg)93+ if m, ok := next.(*Model); ok {94+ g.m = m95+ }96+ return g, g.guard(cmd)97+}98+99+// View implements tea.Model. It keeps showing the last frame while the100+// program quits after a crash.101+func (g *Guarded) View() tea.View {102+ if g.crash != nil {103+ return g.last104+ }105+ defer func() {106+ if r := recover(); r != nil {107+ g.crash = caught("view", r)108+ panic(r) // for Bubble Tea to stop the program: see above109+ }110+ }()111+ if g.viewPanics {112+ panic("crash test in view")113+ }114+ g.last = g.m.View()115+ return g.last116+}117+118+// guard wraps cmd so a panic in it comes to Update as a crashMsg, and119+// so do the commands of a batch it returns. A sequence's (tea.Sequence)120+// are run by Bubble Tea itself, which catches their panics: see Finish.121+func (g *Guarded) guard(cmd tea.Cmd) tea.Cmd {122+ if cmd == nil {123+ return nil124+ }125+ return func() (msg tea.Msg) {126+ defer func() {127+ if r := recover(); r != nil {128+ msg = crashMsg{caught("a command", r)}129+ }130+ }()131+ msg = cmd()132+ if batch, ok := msg.(tea.BatchMsg); ok {133+ out := make(tea.BatchMsg, len(batch))134+ for i, c := range batch {135+ out[i] = g.guard(c)136+ }137+ return out138+ }139+ return msg140+ }141+}142+143+// Finish is called once the program has stopped, with what Run144+// returned. It reports the crash that stopped it, if one did: one Guard145+// caught, or one only Bubble Tea caught (a panic in a sequence's146+// command), whose stack it printed on the terminal.147+func (g *Guarded) Finish(runErr error) *Crash {148+ if g.crash == nil && errors.Is(runErr, tea.ErrProgramPanic) {149+ g.crash = &Crash{Where: "a command", Value: "a panic Bubble Tea caught, its stack printed on the terminal"}150+ }151+ return g.crash152+}153+154+// Keep writes the model's unsaved work to a recovery file, returning its155+// name ("" when there was nothing unsaved). A model a panic left too156+// broken to write is reported as an error rather than panicking again.157+func (g *Guarded) Keep(now time.Time) (kept string, err error) {158+ defer func() {159+ if r := recover(); r != nil {160+ kept, err = "", fmt.Errorf("writing the workbook failed too: %v", r)161+ }162+ }()163+ if !g.m.Unsaved() {164+ return "", nil165+ }166+ return g.m.Recover(now)167+}168+169+// crashReportsKept is how many reports Report leaves in its directory;170+// older ones are removed.171+const crashReportsKept = 10172+173+// Report writes a short report of the crash into dir, for the user to174+// send with an issue, and returns its path. It says where the panic was,175+// what with, the stack, the build (version), the system, the file open176+// and where its unsaved work was kept; never the workbook's contents.177+func (c *Crash) Report(dir, version, file, kept string, keepErr error, now time.Time) (string, error) {178+ if err := os.MkdirAll(dir, 0o700); err != nil {179+ return "", err180+ }181+ var b strings.Builder182+ fmt.Fprintf(&b, "012 stopped on an internal error (a panic in %s)\n\n", c.Where)183+ fmt.Fprintf(&b, "Time: %s\n", now.Format(time.RFC3339))184+ fmt.Fprintf(&b, "Version: %s\n", version)185+ fmt.Fprintf(&b, "System: %s/%s, %s\n", runtime.GOOS, runtime.GOARCH, runtime.Version())186+ fmt.Fprintf(&b, "File: %s\n", orNone(file, "(untitled)"))187+ switch {188+ case keepErr != nil:189+ fmt.Fprintf(&b, "Kept: couldn't keep the unsaved changes: %v\n", keepErr)190+ default:191+ fmt.Fprintf(&b, "Kept: %s\n", orNone(kept, "nothing unsaved"))192+ }193+ fmt.Fprintf(&b, "\nPanic: %v\n", c.Value)194+ if len(c.Stack) > 0 {195+ fmt.Fprintf(&b, "\n%s", c.Stack)196+ }197+ path := filepath.Join(dir, "crash-"+now.Format(recoveryStamp)+".txt")198+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)199+ for i := 2; errors.Is(err, os.ErrExist) && i < 100; i++ {200+ path = filepath.Join(dir, fmt.Sprintf("crash-%s-%d.txt", now.Format(recoveryStamp), i))201+ f, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)202+ }203+ if err != nil {204+ return "", err205+ }206+ _, err = f.WriteString(b.String())207+ if cerr := f.Close(); err == nil {208+ err = cerr209+ }210+ pruneReports(dir)211+ return path, err212+}213+214+// pruneReports removes the oldest crash reports in dir beyond215+// crashReportsKept. Their names sort by the time they were written.216+func pruneReports(dir string) {217+ names, _ := filepath.Glob(filepath.Join(dir, "crash-*.txt"))218+ for _, n := range names[:max(0, len(names)-crashReportsKept)] {219+ os.Remove(n)220+ }221+}222+223+func orNone(s, none string) string {224+ if s == "" {225+ return none226+ }227+ return s228+}229+230+// crashTest, when set (only in builds tagged crashtest, for the e2e231+// tests: crashtest.go), says where a message should make the program232+// panic: "update", "view", "command", or "" for nowhere.233+var crashTest func(tea.Msg) string234+235+// test panics where crashTest said, or returns a command that will.236+func (g *Guarded) test(where string) tea.Cmd {237+ switch where {238+ case "update":239+ panic("crash test in update")240+ case "view":241+ g.viewPanics = true242+ case "command":243+ return g.guard(func() tea.Msg { panic("crash test in a command") })244+ }245+ return nil246+}@@ -0,0 +1,200 @@
1+package ui2+3+import (4+ "errors"5+ "fmt"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+ "time"11+12+ tea "charm.land/bubbletea/v2"13+)14+15+// panicOn makes F12 panic where, as the crashtest build does, for the16+// test.17+func panicOn(t *testing.T, where string) {18+ t.Helper()19+ crashTest = func(msg tea.Msg) string {20+ if k, ok := msg.(tea.KeyPressMsg); ok && k.Code == tea.KeyF12 {21+ return where22+ }23+ return ""24+ }25+ t.Cleanup(func() { crashTest = nil })26+}27+28+var f12 = tea.KeyPressMsg{Code: tea.KeyF12}29+30+// isQuit reports whether cmd quits the program.31+func isQuit(cmd tea.Cmd) bool {32+ if cmd == nil {33+ return false34+ }35+ _, ok := cmd().(tea.QuitMsg)36+ return ok37+}38+39+// A panic in Update quits the program, and the crash says where and40+// what with; the model's unsaved work is kept in the recovery directory41+// and offered back when the file is opened again.42+func TestGuardUpdatePanicKeepsWork(t *testing.T) {43+ t.Chdir(t.TempDir())44+ dir := filepath.Join(t.TempDir(), "recovery")45+ panicOn(t, "update")46+ m := newModel()47+ m.prefs.RecoveryDir = dir48+ m.filename = "book.012"49+ press(t, m, "mine", "<enter>")50+ g := Guard(m)51+ if _, cmd := g.Update(f12); !isQuit(cmd) {52+ t.Fatal("a panic in Update didn't quit")53+ }54+ if _, cmd := g.Update(tea.KeyPressMsg{Code: 'x', Text: "x"}); cmd != nil {55+ t.Error("a crashed model took more input")56+ }57+ c := g.Finish(nil)58+ if c == nil || c.Where != "update" || fmt.Sprint(c.Value) != "crash test in update" || !strings.Contains(string(c.Stack), "crash.go") {59+ t.Fatalf("crash %+v", c)60+ }61+ kept, err := g.Keep(time.Now())62+ if err != nil || filepath.Dir(kept) != dir || !strings.Contains(filepath.Base(kept), "book-") {63+ t.Fatalf("kept %q, %v", kept, err)64+ }65+66+ again := New(m.sheet, "book.012") // the file on disk doesn't matter here67+ again.Update(tea.WindowSizeMsg{Width: 80, Height: 20})68+ again.prefs.RecoveryDir = dir69+ again.OfferKept()70+ run(again, again.Init())71+ if !strings.Contains(line(again, contextLine), "Unsaved changes to book.012 were kept.") {72+ t.Fatalf("no offer: %q", line(again, contextLine))73+ }74+ // Another directory's book.012 is another file.75+ other := newModel()76+ other.prefs.RecoveryDir = dir77+ t.Chdir(t.TempDir())78+ other.filename = "book.012"79+ other.OfferKept()80+ run(other, other.Init())81+ if strings.Contains(line(other, contextLine), "were kept") {82+ t.Errorf("offered another directory's book: %q", line(other, contextLine))83+ }84+}85+86+// Nothing unsaved, nothing kept.87+func TestGuardKeepsOnlyUnsaved(t *testing.T) {88+ m := newModel()89+ m.prefs.RecoveryDir = t.TempDir()90+ if kept, err := Guard(m).Keep(time.Now()); kept != "" || err != nil {91+ t.Errorf("kept %q, %v", kept, err)92+ }93+}94+95+// A panic in a command, or in one of a batch it returns, comes back to96+// Update as a crash, which quits.97+func TestGuardCommandPanic(t *testing.T) {98+ panicOn(t, "command")99+ g := Guard(newModel())100+ _, cmd := g.Update(f12)101+ msg := cmd()102+ if _, ok := msg.(crashMsg); !ok {103+ t.Fatalf("got %T", msg)104+ }105+ if _, cmd := g.Update(msg); !isQuit(cmd) {106+ t.Error("a crash didn't quit")107+ }108+ if c := g.Finish(nil); c == nil || c.Where != "a command" {109+ t.Errorf("crash %+v", c)110+ }111+112+ g = Guard(newModel())113+ fine := func() tea.Msg { return nil }114+ boom := func() tea.Msg { panic("in a batch") }115+ batch, ok := g.guard(tea.Batch(fine, boom))().(tea.BatchMsg)116+ if !ok || len(batch) != 2 {117+ t.Fatalf("batch %v", batch)118+ }119+ if c, ok := batch[1]().(crashMsg); !ok || fmt.Sprint(c.crash.Value) != "in a batch" {120+ t.Errorf("a batch's panic: %v", batch[1]())121+ }122+}123+124+// A panic in View goes on to Bubble Tea, to stop the program, once the125+// crash is noted; the last frame is shown after it.126+func TestGuardViewPanic(t *testing.T) {127+ panicOn(t, "view")128+ g := Guard(newModel())129+ before := g.View()130+ g.Update(f12)131+ func() {132+ defer func() {133+ if recover() == nil {134+ t.Error("View didn't panic")135+ }136+ }()137+ g.View()138+ }()139+ if c := g.Finish(errors.Join(tea.ErrProgramKilled, tea.ErrProgramPanic)); c == nil || c.Where != "view" {140+ t.Fatalf("crash %+v", c)141+ }142+ if g.View().Content != before.Content {143+ t.Error("after the crash, View isn't the last frame")144+ }145+}146+147+// A panic only Bubble Tea caught is still a crash.148+func TestGuardFinishBubbleTeaPanic(t *testing.T) {149+ g := Guard(newModel())150+ if c := g.Finish(nil); c != nil {151+ t.Errorf("a clean quit crashed: %+v", c)152+ }153+ if c := g.Finish(tea.ErrProgramPanic); c == nil || c.Where != "a command" {154+ t.Errorf("crash %+v", c)155+ }156+}157+158+// The report says where, what with, the build, the file and where its159+// work went, with the stack; at most crashReportsKept are left.160+func TestCrashReport(t *testing.T) {161+ dir := t.TempDir()162+ c := &Crash{Where: "update", Value: "boom", Stack: []byte("goroutine 1 [running]:\nmain.main()\n")}163+ at := time.Date(2026, 9, 29, 10, 11, 12, 0, time.Local)164+ path, err := c.Report(dir, "v9.9.9", "book.012", "/r/book-1.012", nil, at)165+ if err != nil {166+ t.Fatal(err)167+ }168+ if filepath.Base(path) != "crash-20260929-101112.txt" {169+ t.Errorf("path %s", path)170+ }171+ data, _ := os.ReadFile(path)172+ for _, want := range []string{"a panic in update", "Version: v9.9.9", "File: book.012", "Kept: /r/book-1.012", "Panic: boom", "main.main()"} {173+ if !strings.Contains(string(data), want) {174+ t.Errorf("report lacks %q:\n%s", want, data)175+ }176+ }177+ for i := range crashReportsKept + 3 {178+ if _, err := c.Report(dir, "", "", "", errors.New("disk full"), at.Add(time.Duration(i+1)*time.Second)); err != nil {179+ t.Fatal(err)180+ }181+ }182+ names, _ := filepath.Glob(filepath.Join(dir, "crash-*.txt"))183+ if len(names) != crashReportsKept || filepath.Base(names[0]) != "crash-20260929-101116.txt" {184+ t.Errorf("kept %d reports, first %s", len(names), filepath.Base(names[0]))185+ }186+ data, _ = os.ReadFile(names[0])187+ if !strings.Contains(string(data), "couldn't keep the unsaved changes: disk full") || !strings.Contains(string(data), "(untitled)") {188+ t.Errorf("report:\n%s", data)189+ }190+}191+192+// A path too long for a file name still makes a key that fits, and193+// different long paths make different keys.194+func TestRecoveryKeyLong(t *testing.T) {195+ long := "/" + strings.Repeat("dir/", 80)196+ a, b := recoveryKey(long+"a.012"), recoveryKey(long+"b.012")197+ if len(a) > recoveryKeyMax || a == b || !strings.HasSuffix(a, "a") {198+ t.Errorf("keys %q, %q", a, b)199+ }200+}@@ -0,0 +1,22 @@
1+//go:build crashtest2+3+package ui4+5+import (6+ "os"7+8+ tea "charm.land/bubbletea/v2"9+)10+11+// In builds tagged crashtest (the e2e tests' binary), F12 makes the12+// program panic where O12_CRASH_TEST says: "update", "view" or13+// "command". Release builds have no such key.14+func init() {15+ where := os.Getenv("O12_CRASH_TEST")16+ crashTest = func(msg tea.Msg) string {17+ if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "f12" {18+ return where19+ }20+ return ""21+ }22+}@@ -0,0 +1,53 @@
1+package ui2+3+import (4+ "io"5+6+ tea "charm.land/bubbletea/v2"7+ uv "github.com/charmbracelet/ultraviolet"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/stress"11+)12+13+// The shapes and the fake terminal below are shared by the stress14+// benchmarks (stress_test.go) and the speed gate (speed_test.go), so15+// both draw the same frames.16+17+// scaled is dense-8192x26 under a 3-point color scale over all of it,18+// every visible cell a shade: what conditional formatting costs a frame.19+func scaled() *sheet.Sheet {20+ s := stress.Dense(stress.Rows, 26)21+ f, err := sheet.ParseCondFormat(`{"ranges":"A1:Z8192","scale":[{"type":"min","color":"red"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"green"}]}`)22+ if err != nil || s.LoadCondFormats([]sheet.CondFormat{f}) > 0 {23+ panic(err)24+ }25+ return s26+}27+28+// fakeTerm stands in for Bubble Tea's renderer: a cell buffer the frame29+// is drawn into and a terminal renderer diffing it to io.Discard.30+type fakeTerm struct {31+ buf uv.ScreenBuffer32+ scr *uv.TerminalRenderer33+}34+35+func newFakeTerm(w, h int) *fakeTerm {36+ t := &fakeTerm{buf: uv.NewScreenBuffer(w, h), scr: uv.NewTerminalRenderer(io.Discard, nil)}37+ t.scr.Resize(w, h)38+ return t39+}40+41+func (t *fakeTerm) frame(m *Model) {42+ v := m.View()43+ t.buf.Clear()44+ uv.NewStyledString(v.Content).Draw(t.buf, t.buf.Bounds())45+ t.scr.Render(t.buf.RenderBuffer)46+ t.scr.Flush()47+}48+49+func sized(s *sheet.Sheet, w, h int) *Model {50+ m := New(s, "")51+ m.Update(tea.WindowSizeMsg{Width: w, Height: h})52+ return m53+}@@ -84,6 +84,7 @@ type Model struct {
8484 root confine.Root // where file names resolve; confined when served over SSH8585 start *string // the file a served session opens first (OpenOnStart); nil once opened8686 recovered string // the recovery file restored into this book, removed once it's saved: recovery.go87+ offerKept bool // offer the recovery file kept for the file once started (OfferKept)8788 8889 mode mode8990 protectOK bool // an edit to a protected range was agreed to: protect.go@@ -31,6 +31,10 @@ type Settings struct {
3131 // Connect makes a JEV client for the configured service with key.3232 Connect func(key string) (jev.Client, error)3333 Notes []string // said on the context line at startup, e.g. config warnings34+ // RecoveryDir is where a local session keeps its unsaved work when35+ // it stops on an internal error, and finds it again: recovery.go.36+ // "" keeps none; 012 serve keeps them in the served directory.37+ RecoveryDir string3438 }3539 3640 // prefs is the settings in effect: the component behind File >@@ -2,6 +2,8 @@ package ui
22 33 import (44 "bytes"5+ "crypto/sha256"6+ "encoding/hex"57 "errors"68 "fmt"79 "io/fs"@@ -27,6 +29,11 @@ import (
2729 // back. The directory is hidden, so names typed in a session never reach2830 // it (internal/confine); only this file reads and writes it. See2931 // docs/terminal/ssh.md.32+//33+// A local session keeps them too when it stops on an internal error34+// (crash.go), in Settings.RecoveryDir, keyed by the file's absolute path,35+// and offers them back when that file is opened again. See36+// docs/files/saving.md.3037 3138 // RecoveryDir is the directory, inside the served one, recovery files3239 // are kept in.@@ -36,6 +43,9 @@ const (
3643 recoveryKeep = 3 // recovery files kept per file name; older ones are removed3744 recoveryUntitled = "(untitled)" // the key for a book never saved; "(" is escaped in file names3845 recoveryStamp = "20060102-150405"46+ // recoveryKeyMax keeps a recovery file's name (the key, the time and47+ // .012) under the 255 bytes file systems allow.48+ recoveryKeyMax = 2003949 )4050 4151 // recoveryName matches a recovery file's name after its key and "-":@@ -50,9 +60,19 @@ func (m *Model) OpenOnStart(name string) {
5060 m.start = &name5161 }5262 63+// OfferKept makes a local session offer, once it starts, the changes64+// kept for its file (or for an untitled book) when 012 last stopped on65+// an internal error. cmd/012 asks for it when started on a file or on66+// nothing, not on an import or standard input.67+func (m *Model) OfferKept() { m.offerKept = true }68+5369 // startOpenCmd opens what OpenOnStart set, once, when the program starts.5470 func (m *Model) startOpenCmd() tea.Cmd {5571 if m.start == nil {72+ if m.offerKept {73+ m.offerKept = false74+ m.offerRecovery(m.filename)75+ }5676 return nil5777 }5878 name := *m.start@@ -101,22 +121,19 @@ func (m *Model) Unsaved() bool {
101121 func (m *Model) Filename() string { return m.filename }102122 103123 // Recover writes the workbook to a new recovery file for its name,104-// removing the oldest beyond recoveryKeep, and returns the file's name105-// relative to the served directory. It's for 012 serve, when the program106-// has stopped; a model that isn't served has nowhere to put one.124+// removing the oldest beyond recoveryKeep, and returns the file's name:125+// relative to the served directory in 012 serve, its full path in a126+// local session. It's for when the program has stopped; a local model127+// without Settings.RecoveryDir has nowhere to put one.107128 func (m *Model) Recover(now time.Time) (string, error) {108- if !m.root.Confined() {109- return "", errors.New("recovery files are only kept for served sessions")129+ dir, key, err := m.recoveryPlace(m.filename, true)130+ if err != nil {131+ return "", err110132 }111133 var buf bytes.Buffer112134 if err := m.sheet.Write(&buf); err != nil {113135 return "", err114136 }115- dir, err := recoveryDir(m.root.Dir(), true)116- if err != nil {117- return "", err118- }119- key := recoveryKey(m.filename)120137 base := key + "-" + now.Format(recoveryStamp)121138 for i := 1; i < 100; i++ {122139 name := base + sheet.FileExt@@ -139,11 +156,41 @@ func (m *Model) Recover(now time.Time) (string, error) {
139156 return "", err140157 }141158 prune(dir, key)159+ if !m.root.Confined() {160+ return filepath.Join(dir, name), nil161+ }142162 return filepath.Join(RecoveryDir, name), nil143163 }144164 return "", errors.New("too many recovery files this second")145165 }146166 167+// recoveryPlace is the directory recovery files for name are kept in,168+// made when create is set, and the start of their names there.169+func (m *Model) recoveryPlace(name string, create bool) (dir, key string, err error) {170+ if m.root.Confined() {171+ dir, err = recoveryDir(m.root.Dir(), create)172+ return dir, recoveryKey(name), err173+ }174+ dir = m.prefs.RecoveryDir175+ if dir == "" {176+ return "", "", errors.New("this session keeps no recovery files")177+ }178+ if create {179+ if err := os.MkdirAll(dir, 0o700); err != nil {180+ return "", "", err181+ }182+ }183+ if st, err := os.Stat(dir); err != nil || !st.IsDir() {184+ return "", "", fmt.Errorf("no recovery directory at %s", dir)185+ }186+ if name != "" {187+ if abs, err := filepath.Abs(name); err == nil {188+ name = abs189+ }190+ }191+ return dir, recoveryKey(name), nil192+}193+147194 // recoveryDir is the recovery directory in root, made 0700 when create148195 // is set. It must be a directory of its own, not a link elsewhere.149196 func recoveryDir(root string, create bool) (string, error) {@@ -165,13 +212,19 @@ func recoveryDir(root string, create bool) (string, error) {
165212 166213 // recoveryKey is the start of the recovery files' names for a file name167214 // as typed: the name without its extension, escaped so any path is one168-// file name, or recoveryUntitled for a book never saved.215+// file name, or recoveryUntitled for a book never saved. A key too long216+// for a file name keeps its end after a hash of the whole.169217 func recoveryKey(name string) string {170218 if name == "" {171219 return recoveryUntitled172220 }173221 name = filepath.ToSlash(filepath.Clean(name))174- return url.PathEscape(strings.TrimSuffix(name, filepath.Ext(name)))222+ key := url.PathEscape(strings.TrimSuffix(name, filepath.Ext(name)))223+ if len(key) > recoveryKeyMax {224+ sum := sha256.Sum256([]byte(key))225+ key = hex.EncodeToString(sum[:8]) + "-" + key[len(key)-(recoveryKeyMax-17):]226+ }227+ return key175228 }176229 177230 // recoveryFile is a recovery file found for a name.@@ -219,16 +272,13 @@ func prune(dir, key string) {
219272 }220273 221274 // offerRecovery asks, on the context line, whether to restore the222-// newest recovery file kept for name, when a served session opens it.275+// newest recovery file kept for name, when a session opens it.223276 func (m *Model) offerRecovery(name string) {224- if !m.root.Confined() {225- return226- }227- dir, err := recoveryDir(m.root.Dir(), false)277+ dir, key, err := m.recoveryPlace(name, false)228278 if err != nil {229279 return230280 }231- files := recoveries(dir, recoveryKey(name))281+ files := recoveries(dir, key)232282 if len(files) == 0 {233283 return234284 }@@ -39,7 +39,8 @@ func TestRecoveryKey(t *testing.T) {
3939 }4040 }4141 42-// Only changed books that hold something are kept, only when served,42+// Only changed books that hold something are kept, only when served (or43+// given a recovery directory, recovery tests in crash_test.go),4344 // and at most recoveryKeep per name, newest kept.4445 func TestRecoverKeepsFew(t *testing.T) {4546 local := newModel()@@ -123,7 +124,7 @@ func TestRecoveryOfferUntitled(t *testing.T) {
123124 124125 // Opening a file with kept changes offers them; restoring makes them125126 // unsaved changes to that file, and saving them removes the recovery126-// file. A local model never offers.127+// file. A local model without a recovery directory never offers.127128 func TestRecoveryRestore(t *testing.T) {128129 m, dir := servedModel(t)129130 writeSheet(t, filepath.Join(dir, "book.012"), "on disk")@@ -0,0 +1,133 @@
1+package ui2+3+import (4+ "flag"5+ "fmt"6+ "runtime"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/stress"13+)14+15+// The speed gate (make speed, part of make check): frames and16+// recalculation on mid-sized sheets from internal/stress, held to17+// testdata/speed.json. See docs/contributing/limits.md#the-speed-gate.18+var (19+ speedFlag = flag.Bool("speed", false, "run the speed gate against testdata/speed.json")20+ speedUpdateFlag = flag.Bool("speed-update", false, "measure the speed gate and rewrite testdata/speed.json")21+)22+23+// speedCase is one measured operation: setup builds its state and24+// returns the operation, which must leave the state as it found it25+// after an even number of calls.26+type speedCase struct {27+ name string28+ setup func() func()29+}30+31+// frameCase is an arrow key through to its frame at 200 x 60, down and32+// up in turn, on the sheet build makes.33+func frameCase(name string, build func() *sheet.Sheet) speedCase {34+ return speedCase{"frame/" + name, func() func() {35+ m := sized(build(), 200, 60)36+ t := newFakeTerm(200, 60)37+ keys := [2]tea.KeyPressMsg{{Code: tea.KeyDown}, {Code: tea.KeyUp}}38+ i := 039+ return func() {40+ m.Update(keys[i%2])41+ t.frame(m)42+ i++43+ }44+ }}45+}46+47+// editCase is typing into one cell of a stress shape and the48+// incremental recalculation that follows, two entries in turn.49+func editCase(sh stress.Shape) speedCase {50+ return speedCase{"edit/" + sh.Name, func() func() {51+ s := sh.Build()52+ inputs := [2]string{sh.Input, sh.Input + "1"}53+ i := 054+ return func() {55+ if err := s.Set(sh.Edit, inputs[i%2]); err != nil {56+ panic(err)57+ }58+ i++59+ }60+ }}61+}62+63+// recalcCase is a full recalculation of the sheet build makes.64+func recalcCase(name string, build func() *sheet.Sheet) speedCase {65+ return speedCase{"recalc/" + name, func() func() {66+ s := build()67+ return s.RecalcAll68+ }}69+}70+71+func speedCases() []speedCase {72+ dense := func() *sheet.Sheet { return stress.Dense(stress.Rows, 26) }73+ laidout := func() *sheet.Sheet { return stress.Laidout(stress.Rows, 26) }74+ charts := func() *sheet.Sheet {75+ s := stress.Table(200, 3)76+ stress.Charts(s, 8)77+ return s78+ }79+ shape := func(name string, build func() *sheet.Sheet, a string, input string) stress.Shape {80+ at, _ := sheet.ParseAddr(a)81+ return stress.Shape{Name: name, Build: build, Edit: at, Input: input}82+ }83+ fanin := shape("fanin-1000xSUM8192", func() *sheet.Sheet { return stress.FanIn(stress.Rows, 1000) }, "A4001", "7")84+ return []speedCase{85+ frameCase("dense-8192x26", dense),86+ frameCase("laidout-8192x26", laidout),87+ frameCase("scale-8192x26", scaled),88+ frameCase("charts-8", charts),89+ editCase(fanin),90+ editCase(shape("chain-8192", func() *sheet.Sheet { return stress.Chain(stress.Rows) }, "A1", "2")),91+ editCase(shape("criteria-10xSUMIF8192", func() *sheet.Sheet { return stress.Criteria(stress.Rows, 10) }, "A4001", "7")),92+ editCase(shape("lookup-100xVLOOKUP8192", func() *sheet.Sheet { return stress.Lookup(stress.Rows, 100) }, "A4001", "4000")),93+ recalcCase("running-8192", func() *sheet.Sheet { return stress.RunningTotals(stress.Rows) }),94+ recalcCase("arrays-50xFILTER8192", func() *sheet.Sheet { return stress.Arrays(stress.Rows, 50) }),95+ }96+}97+98+// TestSpeed measures every case and compares it with the baseline:99+// allocations per operation, and time per operation relative to a100+// calibration workload timed beside it. A case over its margin is101+// measured once more before it fails, so one noisy run doesn't.102+func TestSpeed(t *testing.T) {103+ if !*speedFlag && !*speedUpdateFlag {104+ t.Skip("the speed gate runs with -speed (make speed)")105+ }106+ unlock := benchLock(t)107+ defer unlock()108+ base, err := readSpeedBaseline()109+ if err != nil && !*speedUpdateFlag {110+ t.Fatal(err)111+ }112+ next := speedBaseline{GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, Cases: map[string]speedResult{}}113+ for _, c := range speedCases() {114+ op := c.setup()115+ got := measureSpeed(op)116+ if !*speedUpdateFlag {117+ if verdict := base.judge(c.name, got); verdict != "" {118+ got = measureSpeed(op) // a second chance, against noise119+ if verdict = base.judge(c.name, got); verdict != "" {120+ t.Errorf("%s: %s", c.name, verdict)121+ }122+ }123+ }124+ t.Logf("%-36s %8.3f ms %6.2fx calibration %8.0f allocs", c.name, got.NS/1e6, got.Ratio, got.Allocs)125+ next.Cases[c.name] = got126+ }127+ if *speedUpdateFlag {128+ if err := next.write(); err != nil {129+ t.Fatal(err)130+ }131+ fmt.Println("wrote", speedBaselinePath)132+ }133+}@@ -0,0 +1,71 @@
1+//go:build unix2+3+package ui4+5+import (6+ "errors"7+ "io/fs"8+ "os"9+ "syscall"10+ "testing"11+ "time"12+)13+14+// benchLockPath is the lock timed runs on this machine share, so their15+// timings don't overlap. Two conventions meet there: a file locked with16+// flock(2), as `flock /tmp/012-bench.lock <command>` takes it where the17+// flock command exists, and a directory made with mkdir and removed18+// after, which needs no flock (macOS has none). The speed gate takes19+// whichever the path holds, a directory when it holds nothing.20+const benchLockPath = "/tmp/012-bench.lock"21+22+// benchLockWait is how long the gate waits for a directory lock before23+// timing without it (the lock may be stale, left by a run that was24+// killed).25+const benchLockWait = 10 * time.Minute26+27+// benchLock waits for the lock and returns its release.28+func benchLock(t *testing.T) func() {29+ t.Helper()30+ deadline := time.Now().Add(benchLockWait)31+ logged := false32+ for {33+ err := os.Mkdir(benchLockPath, 0o755)34+ if err == nil {35+ return func() { os.Remove(benchLockPath) }36+ }37+ info, serr := os.Stat(benchLockPath)38+ switch {39+ case !errors.Is(err, fs.ErrExist) || serr != nil:40+ t.Logf("timing without the lock: %v", err)41+ return func() {}42+ case info.Mode().IsRegular():43+ return flockFile(t)44+ case time.Now().After(deadline):45+ t.Logf("timing without the lock: %s held for %v", benchLockPath, benchLockWait)46+ return func() {}47+ }48+ if !logged {49+ t.Logf("waiting for %s", benchLockPath)50+ logged = true51+ }52+ time.Sleep(200 * time.Millisecond)53+ }54+}55+56+// flockFile takes the lock as a file, with flock(2).57+func flockFile(t *testing.T) func() {58+ f, err := os.OpenFile(benchLockPath, os.O_RDWR, 0)59+ if err != nil {60+ t.Logf("timing without the lock: %v", err)61+ return func() {}62+ }63+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {64+ f.Close()65+ t.Fatalf("locking %s: %v", benchLockPath, err)66+ }67+ return func() {68+ syscall.Flock(int(f.Fd()), syscall.LOCK_UN)69+ f.Close()70+ }71+}@@ -0,0 +1,144 @@
1+package ui2+3+import (4+ "encoding/json"5+ "fmt"6+ "math"7+ "os"8+ "runtime"9+ "slices"10+ "strconv"11+ "testing"12+ "time"13+)14+15+// speedBaselinePath holds the speed gate's baseline, rewritten by16+// make speed-update.17+const speedBaselinePath = "testdata/speed.json"18+19+// The margins a case may exceed its baseline by before it fails: time20+// relative to the calibration may double (the ratio still moves with21+// the CPU, the Go version and the machine's load, and a clear22+// regression is a multiple, not a percentage), and allocations, which23+// are counted, not timed, may grow by a quarter plus a few.24+const (25+ ratioMargin = 2.026+ allocsMargin = 1.2527+ allocsSlack = 1628+)29+30+// speedRounds is how many times each case is timed, beside the31+// calibration, keeping the fastest of each; speedBatch is about how long32+// one timing of a case runs.33+const (34+ speedRounds = 735+ speedBatch = 2 * time.Millisecond36+)37+38+// speedResult is one case measured: nanoseconds per operation (for39+// reading), the same relative to the calibration workload, and40+// allocations per operation.41+type speedResult struct {42+ NS float64 `json:"ns"`43+ Ratio float64 `json:"ratio"`44+ Allocs float64 `json:"allocs"`45+}46+47+// speedBaseline is testdata/speed.json: the machine it was measured on48+// and each case's result. Ratios are compared only on the same OS and49+// architecture; allocations everywhere.50+type speedBaseline struct {51+ GOOS string `json:"goos"`52+ GOARCH string `json:"goarch"`53+ Cases map[string]speedResult `json:"cases"`54+}55+56+func readSpeedBaseline() (speedBaseline, error) {57+ var b speedBaseline58+ data, err := os.ReadFile(speedBaselinePath)59+ if err != nil {60+ return b, fmt.Errorf("%w (make speed-update writes it)", err)61+ }62+ return b, json.Unmarshal(data, &b)63+}64+65+func (b speedBaseline) write() error {66+ for name, r := range b.Cases {67+ r.NS = math.Round(r.NS)68+ r.Ratio = math.Round(r.Ratio*1000) / 100069+ r.Allocs = math.Round(r.Allocs)70+ b.Cases[name] = r71+ }72+ data, err := json.MarshalIndent(b, "", " ")73+ if err != nil {74+ return err75+ }76+ return os.WriteFile(speedBaselinePath, append(data, '\n'), 0o644)77+}78+79+// judge says how got is over the baseline's margins, or "" when it80+// isn't.81+func (b speedBaseline) judge(name string, got speedResult) string {82+ want, ok := b.Cases[name]83+ if !ok {84+ return "not in " + speedBaselinePath + " (make speed-update)"85+ }86+ if limit := want.Allocs*allocsMargin + allocsSlack; got.Allocs > limit {87+ return fmt.Sprintf("%.0f allocations an operation, over %.0f (baseline %.0f)", got.Allocs, limit, want.Allocs)88+ }89+ sameMachine := b.GOOS == runtime.GOOS && b.GOARCH == runtime.GOARCH90+ if limit := want.Ratio * ratioMargin; sameMachine && got.Ratio > limit {91+ return fmt.Sprintf("%.2fx the calibration, over %.2fx (baseline %.2fx, %.3f ms now)", got.Ratio, limit, want.Ratio, got.NS/1e6)92+ }93+ return ""94+}95+96+// measureSpeed times op against the calibration and counts its allocations.97+// Calls come in even numbers, so an operation alternating two states98+// ends where it started.99+func measureSpeed(op func()) speedResult {100+ op()101+ op()102+ n := 2103+ for start := time.Now(); time.Since(start) < speedBatch/2; n += 2 {104+ op()105+ op()106+ }107+ allocs := testing.AllocsPerRun(n, op)108+ op() // AllocsPerRun calls it once more than n109+ best, calib := time.Duration(math.MaxInt64), time.Duration(math.MaxInt64)110+ for range speedRounds {111+ calib = min(calib, calibrate())112+ start := time.Now()113+ for range n {114+ op()115+ }116+ best = min(best, time.Since(start))117+ }118+ per := float64(best) / float64(n)119+ return speedResult{NS: per, Ratio: per / float64(calib), Allocs: allocs}120+}121+122+// calibrationSink keeps the calibration's result alive.123+var calibrationSink float64124+125+// calibrate times a fixed workload of the kinds of work the cases do126+// (formatting numbers, map lookups by string, sorting, allocating) and127+// returns how long it took: the unit the cases' times are measured in.128+func calibrate() time.Duration {129+ start := time.Now()130+ for range 4 {131+ m := make(map[string]float64)132+ xs := make([]float64, 0, 2048)133+ for i := range 2048 {134+ k := strconv.Itoa(i * 7919 % 4099)135+ m[k] += float64(i)136+ xs = append(xs, float64(i*7919%4099))137+ }138+ slices.Sort(xs)139+ for i := range 2048 {140+ calibrationSink += m[strconv.Itoa(i)] + xs[i]141+ }142+ }143+ return time.Since(start)144+}@@ -10,7 +10,6 @@ package ui
1010 import (1111 "context"1212 "fmt"13- "io"1413 "log/slog"1514 "path/filepath"1615 "strings"@@ -19,7 +18,6 @@ import (
1918 2019 tea "charm.land/bubbletea/v2"2120 typesafe "github.com/FelineStateMachine/typesafe-go"22- uv "github.com/charmbracelet/ultraviolet"2321 2422 "github.com/FelineStateMachine/012/internal/jev"2523 "github.com/FelineStateMachine/012/internal/sheet"@@ -75,44 +73,6 @@ func barred() *sheet.Sheet {
7573 return s7674 }7775 78-// scaled is dense-8192x26 under a 3-point color scale over all of it,79-// every visible cell a shade: what conditional formatting costs a frame.80-func scaled() *sheet.Sheet {81- s := stress.Dense(stress.Rows, 26)82- f, err := sheet.ParseCondFormat(`{"ranges":"A1:Z8192","scale":[{"type":"min","color":"red"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"green"}]}`)83- if err != nil || s.LoadCondFormats([]sheet.CondFormat{f}) > 0 {84- panic(err)85- }86- return s87-}88-89-// fakeTerm stands in for Bubble Tea's renderer: a cell buffer the frame90-// is drawn into and a terminal renderer diffing it to io.Discard.91-type fakeTerm struct {92- buf uv.ScreenBuffer93- scr *uv.TerminalRenderer94-}95-96-func newFakeTerm(w, h int) *fakeTerm {97- t := &fakeTerm{buf: uv.NewScreenBuffer(w, h), scr: uv.NewTerminalRenderer(io.Discard, nil)}98- t.scr.Resize(w, h)99- return t100-}101-102-func (t *fakeTerm) frame(m *Model) {103- v := m.View()104- t.buf.Clear()105- uv.NewStyledString(v.Content).Draw(t.buf, t.buf.Bounds())106- t.scr.Render(t.buf.RenderBuffer)107- t.scr.Flush()108-}109-110-func sized(s *sheet.Sheet, w, h int) *Model {111- m := New(s, "")112- m.Update(tea.WindowSizeMsg{Width: w, Height: h})113- return m114-}115-11676 // BenchmarkView is View alone: 012's share of a frame.11777 func BenchmarkView(b *testing.B) {11878 for _, sh := range uiShapes() {@@ -0,0 +1,56 @@
1+{2+ "goos": "darwin",3+ "goarch": "arm64",4+ "cases": {5+ "edit/chain-8192": {6+ "ns": 2078146,7+ "ratio": 3.462,8+ "allocs": 83139+ },10+ "edit/criteria-10xSUMIF8192": {11+ "ns": 4529240,12+ "ratio": 7.243,13+ "allocs": 37614+ },15+ "edit/fanin-1000xSUM8192": {16+ "ns": 509885,17+ "ratio": 0.872,18+ "allocs": 109919+ },20+ "edit/lookup-100xVLOOKUP8192": {21+ "ns": 2861823,22+ "ratio": 4.177,23+ "allocs": 90724+ },25+ "frame/charts-8": {26+ "ns": 1859188,27+ "ratio": 3.108,28+ "allocs": 729529+ },30+ "frame/dense-8192x26": {31+ "ns": 821740,32+ "ratio": 1.254,33+ "allocs": 486734+ },35+ "frame/laidout-8192x26": {36+ "ns": 1233156,37+ "ratio": 2.045,38+ "allocs": 522339+ },40+ "frame/scale-8192x26": {41+ "ns": 1215229,42+ "ratio": 2.076,43+ "allocs": 1014244+ },45+ "recalc/arrays-50xFILTER8192": {46+ "ns": 7554927,47+ "ratio": 12.613,48+ "allocs": 330549+ },50+ "recalc/running-8192": {51+ "ns": 1884083,52+ "ratio": 3.023,53+ "allocs": 827054+ }55+ }56+}