- commit
- 8d8b8cef78d166245f85b1fc68cefcead1a73552
- parent
- f8b00baea04e0fd6015c4480c7f03fa87adc02ff
- parent
- 4cdc742e4327bb56d4790a14d681e9fb4c658257
- 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@@ -147,6 +147,14 @@ SITE_NODE := NODE_OPTIONS=--disable-warning=ExperimentalWarning
147147 site: site-deps148148 cd website && $(SITE_NODE) npm run build149149 150+# The site as published: the build above plus a release's archives and151+# SHA256SUMS under /releases/<version>/ and /releases/latest/, and152+# install.sh and install.ps1 at the root. VERSION is the newest version153+# tag unless set; the archives are dist/'s when it holds that version,154+# else built from the tag. See docs/contributing/releasing.md.155+site-release: site156+ VERSION=$(VERSION) scripts/site-release.sh157+150158 site-serve: site-deps151159 cd website && $(SITE_NODE) npm run start152160 @@ -15,9 +15,12 @@ It is one pure-Go binary.
1515 ## Install1616 1717 ```sh18-go install github.com/FelineStateMachine/012/cmd/012@latest18+curl -fsSL https://f58b.n.zip/install.sh | sh # macOS, Linux: into ~/.local/bin19+go install github.com/FelineStateMachine/012/cmd/012@latest # or build it with Go 1.271920 ```2021 22+On Windows, `irm https://f58b.n.zip/install.ps1 | iex` in PowerShell.23+2124 Then run `012`, or `012 budget.012` to open a sheet. F1 shows every2225 shortcut, F10 or Alt+letter opens the menus, and Ctrl+K searches every2326 command. [Install and run](docs/getting-started/install.md) has the@@ -58,14 +58,12 @@ shared editing in section 3.
5858 | Item | Result | Size |5959 |---|---|---|6060 | 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 |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 |6261 6362 ### 5. Around the grid6463 6564 | Item | Result | Size |6665 |---|---|---|6766 | A sheet or chart exported as a static HTML page in 012's look | Sharing a sheet with someone without 012 | S |68-| Release archives and an install script served from the owner's nzip server | Installing without Go | S |6967 7068 ### 6. Agents7169 @@ -109,6 +107,7 @@ stream above rather than a design of their own.
109107 - An Excel-sized grid in compact column storage, with a ten-million-cell `max-cells` budget; operations cost the data, not the grid: [Bounds of support](docs/contributing/limits.md#sheet-size)110108 - Undo steps in the compact form: clearing a full ten-million-cell sheet holds about what the sheet does, and a step past 1 GB asks first: [Bounds of support](docs/contributing/limits.md#undo)111109 - Spilled cells and pivot results in the compact form, about 20 B each: [Bounds of support](docs/contributing/limits.md#sheet-size)110+- Cycles found from the formulas as written, and arrays and notebook outputs in each other's way settled the same in any order, so a workbook reads the same when reopened: [Formulas](docs/formulas/README.md#values-and-errors)112111 113112 **Finding and using features**114113 @@ -165,6 +164,7 @@ stream above rather than a design of their own.
165164 166165 - `make check` before every push; demo tapes (`make demos`); annotated version tags with release notes (v0.2.0 onward; v0.1.0 remains on the Go module proxy): [Testing](docs/contributing/testing.md)167166 - A release checklist (XLSX output opened in Excel, LibreOffice and Google Sheets) and `make dist`, release archives cross-compiled locally with SHA256SUMS: [Releasing](docs/contributing/releasing.md)167+- Installing without Go: `curl -fsSL https://f58b.n.zip/install.sh | sh` (and `install.ps1` on Windows) with the release archives served from the docs site by `make site-release`: [Install and run](docs/getting-started/install.md), [Releasing](docs/contributing/releasing.md#publishing)168168 - `make stress-report` flags regressions against the last release's run, allowing for noise: [Observability](docs/contributing/observability.md#regressions-against-the-last-release)169169 - Grafana: recent traces and a trace view of the nested spans: [Observability](docs/contributing/observability.md#the-stack)170170 - 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)@@ -1,5 +1,5 @@
11 # Decimal arithmetic: binary floating point makes $4.35 come to 4342-# whole cents and $0.10 + $0.20 miss 0.3. File > Settings > Decimal2+# whole cents and $0.10 + $0.20 - 0.3 miss 0. File > Settings > Decimal33 # arithmetic, here from the palette, makes this file's money math exact.44 Output out/decimal.gif55 Source lib/setup.tape@@ -14,7 +14,7 @@ Type "Coffee" Tab Type "$4.35" Enter
1414 Type "Tip" Tab Type "$0.10" Enter1515 Type "Fee" Tab Type "$0.20" Enter1616 Type "Cents" Tab Type "=INT(B1*100)" Enter17-Type "Is 0.30?" Tab Type "=B2+B3=0.3" Enter17+Type "Is 0.30?" Tab Type "=B2+B3-0.3=0" Enter1818 Type "Residue" Tab Type "=0.1+0.2-0.3" Enter1919 Sleep 1.5s2020 Ctrl+K@@ -177,8 +177,13 @@ style.
177177 name and resolved when evaluated, so renaming rewrites them and a deleted178178 sheet's references wait, as `#REF!`, for a sheet of that name. Changing a179179 cell marks it and everything that transitively depends on it, on any180- sheet, then evaluates the marked cells lazily in dependency order; a cell181- reached again while it is being evaluated is part of a cycle. Volatile180+ sheet, then evaluates the marked cells lazily in dependency order.181+ Before evaluating, cycles among the marked formulas are found from the182+ references as written, IF branches not taken and range reads cut short183+ included, with a segment tree per column standing in for ranges and184+ Tarjan's algorithm (`cyclefind.go`), so a cycle's cells are `#REF!`185+ whatever order they're computed in; a cell reached again while it is186+ being evaluated is part of a cycle too. Volatile182187 functions (TODAY, RAND, the JEV functions) are recomputed on every183188 recalculation (`recalc.go`). Formulas read other cells through each184189 sheet's `reader`, the engine's side of the function library's `Book`@@ -243,7 +248,9 @@ style.
243248 undo history, only those that differ, with the region's cells it no244249 longer needs cleared; a table that would overwrite other contents245250 isn't shown, its first cell saying where, and clearing that cell shows246- it. Rows arrive only as live operations, the change stream below, and251+ it; regions in each other's way settle as arrays do, the first anchor252+ winning (`regionclash.go`), so the order rows arrive in doesn't253+ matter. Rows arrive only as live operations, the change stream below, and247254 are never part of the undo state, since undo can't bring back what a248255 file held or a run printed: a region undo brings back or moves is249256 emptied and marked stale, and the UI sends its rows again, the file@@ -7,8 +7,9 @@ sidebar_position: 8
77 88 A release is an annotated version tag whose message is the release99 notes, and archives of the binaries built from it on a maintainer's10-machine. The GitHub repository only hosts the code: nothing builds or11-publishes there, and `make dist` uploads nothing.10+machine, served with the docs site on the owner's nzip server along11+with the install scripts that download them. The GitHub repository only12+hosts the code: nothing builds or publishes there.1213 1314 ## Checklist1415 @@ -26,6 +27,9 @@ flowchart TD
2627 tag -->|the module proxy| install[go install ...@v1.2.3]2728 tag --> dist[make dist]2829 dist --> archives[dist/: archives, SHA256SUMS]30+ archives --> site[make site-release]31+ site --> push[nzip site push website/build public:012]32+ push --> live[install.sh, /releases/v1.2.3/ and /releases/latest/ live]2933 ```3034 3135 1. **`make check`** passes on the commit to be tagged (see@@ -56,6 +60,18 @@ flowchart TD
5660 The Go module proxy picks the tag up, so `go install5761 github.com/FelineStateMachine/012/cmd/012@v1.2.3` works from then on.5862 6. **Binaries**: `make dist VERSION=v1.2.3` on the tagged commit.63+7. **Publish** the site with the release64+ ([Publishing](#publishing)):65+66+ ```sh67+ SITE_URL=https://f58b.n.zip make site-release VERSION=v1.2.368+ nzip site push website/build public:01269+ ```70+71+ then check the live copy: `SHA256SUMS` at72+ `https://f58b.n.zip/releases/v1.2.3/` matches `dist/SHA256SUMS`, and73+ `curl -fsSL https://f58b.n.zip/install.sh | PREFIX=$(mktemp -d) sh`74+ installs a `012` whose `012 version` prints v1.2.3.5975 6076 ## File fixtures6177 @@ -106,4 +122,40 @@ dist/SHA256SUMS
106122 refuses a version that isn't `vMAJOR.MINOR.PATCH` (with an optional107123 `-suffix`) or that is tagged at another commit than the one checked108124 out, and warns when the tree has uncommitted changes. The archives and109-`SHA256SUMS` are what a release attaches, wherever it is published.125+`SHA256SUMS` are what a release publishes.126+127+## Publishing128+129+`make site-release` (`scripts/site-release.sh`) builds the docs site as130+`make site` does and adds the release to `website/build`:131+132+```133+website/build/install.sh134+website/build/install.ps1135+website/build/releases/v1.2.3/ archives, SHA256SUMS, an index page136+website/build/releases/latest/ the same files137+```138+139+`VERSION` defaults to the newest version tag. The archives are `dist/`'s140+when its `SHA256SUMS` names that version; otherwise the script checks141+the tag out in a temporary git worktree, runs `make dist` there and142+keeps the result in `dist/`, so the site can be published from a commit143+after the tag. It checks the archives against `SHA256SUMS` before144+copying them. `SITE_URL` also goes into the copied install scripts, so a145+site built for another address serves scripts that download from it.146+147+The site serves one release, `VERSION`, under both addresses; earlier148+releases stay installable with `go install ...@v1.2.3`. Every push149+replaces the whole site, so the docs are always published with150+`make site-release` ([The docs site](site.md#where-it-lives)): a push151+of a plain `make site` build leaves the install script without its152+archives.153+154+The install scripts live in `scripts/install/`. `install.sh` is POSIX155+sh (it runs under dash as well as sh) and needs curl or wget, tar, and156+sha256sum, shasum or openssl. `install_test.go`, in `make test`, runs157+it as a file and piped to the shell against a local server holding a158+fake release: the latest into `~/.local/bin`, a version under `PREFIX`,159+and a refusal of an archive whose checksum doesn't match. `install.ps1`160+has no automated test, as no PowerShell is assumed on a maintainer's161+machine; try it on Windows when it changes.@@ -26,14 +26,19 @@ site`); the site sits at the root of its host.
2626 ## Where it lives2727 2828 The site is published at [f58b.n.zip](https://f58b.n.zip/), a public,29-permanent address on the owner's nzip server. To publish a new build to30-the same address:29+permanent address on the owner's nzip server, which also serves the30+install scripts and the latest release's archives. To publish a new31+build to the same address:3132 3233 ```sh33-SITE_URL=https://f58b.n.zip make site34+SITE_URL=https://f58b.n.zip make site-release3435 nzip site push website/build public:0123536 ```3637 38+`make site-release` is `make site` with the release added39+([Publishing](releasing.md#publishing)); a push of a plain `make site`40+build would take the install script and the archives off the site.41+3742 The target keeps its policies (public, no expiry) between pushes.3843 3944 ## The build is a docs check@@ -44,8 +44,6 @@ them again after each edit, undo and reopening, as the UI does. A
4444 failure names the fewest of the seed's edits that still fail. It takes4545 about a second; `-randedit.seeds=20000` runs more, and4646 `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.4947 5048 The UI is tested by sending5149 Bubble Tea messages (keys, mouse, paste, window size) to the model and@@ -134,7 +132,12 @@ they agree (for two errors, compare C and F by eye). The exporter saves
134132 formulas Excel has nothing like (`SPLIT`, `REGEXMATCH`, `SORTN`,135133 `FLATTEN`, `ARRAYFORMULA` inside a formula) as values, so those rows136134 leave C empty and say so in the note: type the formula from B into C137-before downloading, or `-update-from` leaves them unconfirmed.135+before downloading, or `-update-from` leaves them unconfirmed. It also136+leaves a row unconfirmed where Sheets says `#NAME?` and 012 doesn't: the137+import didn't recognize a function, as Sheets doesn't read `FILTER` and138+`SORT` written with the `_xlfn._xlws.` prefix Excel gives them. A number139+Sheets downloads with a trailing point (`110.` for `=100*1.1`) is read140+without it.138141 139142 ## The XLSX differential test140143 @@ -46,7 +46,10 @@ file's rows, typed as an import types them. The region is the file's:
4646 cell (`#REF!`) and the context line say which cell is in the way;4747 clearing that cell reads the file again. An [array](../formulas/arrays.md)'s4848 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;49+ cells takes them, whichever came first, as when the file opens.50+ Another region's first cell is in its way too, and when two regions'51+ tables need the same cells, the one whose first cell comes first, row52+ by row, shows and the other shows `#REF!`, whichever grew first;5053 - inserting or deleting rows or columns before it moves it, and it reads5154 the file again there; deleting its first cell's row or column unlinks5255 it.@@ -88,7 +91,11 @@ a file that doesn't change costs one check.
8891 8992 Rows arrive outside the undo history, as an array's spilled values do:9093 undo takes back edits, not the file's rows, and rows arriving don't mark91-the spreadsheet modified.94+the spreadsheet modified. The first rows a region shows widen its95+columns to their text, up to 30 characters, where no width was set, as96+a [pivot table](../sheets/pivots.md)'s results do. The widening comes97+with the rows rather than an edit, so it stays when undo takes the98+region back; set the width to change it.9299 93100 ## Pause, read again, unlink94101 @@ -36,20 +36,35 @@ caret on the problem and says what's wrong on the context line, e.g.
3636 tighter than `^` as in Sheets and Excel, so `=-2^2` is 4. 1-2-3's `#AND#`,3737 `#OR#` and `#NOT#` still work; `AND()`, `OR()` and `NOT()` are the Sheets way.3838 39+Numbers are binary floating point, as in Sheets and Excel, and follow40+Sheets where that shows:41+42+- Comparisons, and criteria such as `COUNTIF`'s, look at the 1543+ significant digits a cell shows, so `=0.1+0.2=0.3` is TRUE. A44+ difference itself keeps every digit: `=0.1+0.2-0.3` is 5.551E-1745+ ([decimal arithmetic](decimal.md) makes it 0).46+- A number written in a formula keeps 15 significant digits:47+ `=123456789012345678` is 123456789012345000.48+- A negative number has odd roots: `=(-8)^(1/3)` and `=POWER(-8, 1/3)`49+ are -2; other fractional powers of it are `#NUM!`.50+3951 ## Values and errors4052 4153 Text in arithmetic is `#VALUE!` unless it reads as a number (`="3"*2` is 6);42-blanks count as 0 or empty text. Aggregates such as `SUM` over a range skip43-text. Errors use Sheets' codes:54+blanks count as 0 or empty text. A condition (`IF`, `IFS`, `AND`, `OR`,55+`NOT`) also reads the text `TRUE` or `FALSE`, and empty text as FALSE, so56+`=IF("TRUE", 1, 2)` is 1. Aggregates such as `SUM` over a range skip57+text; `COUNT` skips errors too and `COUNTA` counts them, where the others58+give the first error in the range. Errors use Sheets' codes:4459 4560 | Error | Meaning |4661 |---|---|4762 | `#DIV/0!` | division by zero |4863 | `#VALUE!` | the wrong kind of value, e.g. text in arithmetic |4964 | `#NAME?` | an unknown name |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 |65+| `#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`), and one reached only through an `IF` branch not taken or a part of a range the formula doesn't read (`INDEX`, `VLOOKUP`), as in Sheets |5166 | `#N/A` | not available, e.g. a lookup found nothing |52-| `#NUM!` | a number out of range, e.g. `SQRT(-1)` |67+| `#NUM!` | a number out of range, e.g. `SQRT(-1)`, or a position outside what `CHOOSE`, `INDEX` or `MID` reads from, as in Sheets (Excel says `#VALUE!` or `#REF!`) |5368 5469 Error cells are red with a curly underline, and the context line explains the5570 active cell's error and where it came from, e.g.@@ -29,6 +29,8 @@ right and below, as in Sheets:
2929 The arguments of functions that take ranges are computed the same way,3030 so `=SUM(LEN(A2:A9))` counts every character and3131 `=SUMPRODUCT((B2:B99="north")*C2:C99)` sums a column by a condition.32+ This follows Excel 365 on purpose: Sheets computes `=SUM(C2:C4*2)` only33+ inside `ARRAYFORMULA`, and is `#VALUE!` without it.3234 - Elsewhere an array reads as its first value, as Sheets does:3335 `=LEN(SEQUENCE(3)*100)` is 3. `IF`, `IFERROR`, `IFNA`, `IFS`, `SWITCH`,3436 `CHOOSE` and `INDEX` pass an array through, so@@ -66,7 +68,9 @@ the same cells, blank ones of theirs included, the one whose formula
6668 comes first, row by row, spills and the other shows `#REF!`, whichever6769 was typed first, so a sheet looks the same when opened again. An array that would6870 spill into cells its formula reads, `=SORT(B8:D9)` in A9, is a circular69-dependency and shows `#REF!` too.71+dependency and shows `#REF!` too, as are arrays that would spill into72+each other's inputs, even when one of them, blocked and read as73+`#REF!`, would come out smaller and free the other.7074 7175 ## Names in a formula: LET and LAMBDA7276 @@ -6,7 +6,9 @@ sidebar_position: 5
66 # Decimal arithmetic77 88 Like Sheets and Excel, 012 computes in binary floating point, so9-`=0.1+0.2=0.3` is FALSE and `=INT($4.35*100)` is 434. File > Settings >9+`=0.1+0.2-0.3` is 5.551E-17 rather than 0 and `=INT($4.35*100)` is 434.10+Comparisons look only at the 15 significant digits a cell shows, as in11+Sheets, so `=0.1+0.2=0.3` is TRUE, but `=0.1+0.2-0.3=0` is FALSE. File > Settings >1012 Decimal arithmetic (or search the palette for "decimal") switches the whole1113 file, every sheet of it, to decimal math for money. The status line then says1214 `decimal`, the menu shows a check mark, and the setting is saved with the@@ -5,12 +5,37 @@ sidebar_position: 2
55 66 # Install and run77 8+012 is one binary. On macOS or Linux, the install script puts the latest9+release in `~/.local/bin`:10+11+```sh12+curl -fsSL https://f58b.n.zip/install.sh | sh13+```14+15+It picks the archive for your OS and architecture, checks it against the16+release's `SHA256SUMS` and says where it put `012`. `PREFIX=/opt/012`17+installs into `/opt/012/bin` instead, and `sh -s -- --system` into18+`/usr/local/bin`, running `sudo` (and printing the command first) only19+when that directory isn't writable; `sh -s -- --version v0.3.0` picks a20+release. On Windows, in PowerShell:21+22+```powershell23+irm https://f58b.n.zip/install.ps1 | iex24+```25+26+which installs `012.exe` into `%LOCALAPPDATA%\Programs\012\bin` and adds27+that folder to your PATH, without administrator rights. The archives and28+`SHA256SUMS` are also at29+[f58b.n.zip/releases/latest/](https://f58b.n.zip/releases/latest/) to30+download by hand.31+32+With Go 1.27 or later, build it from source instead:33+834 ```sh935 go install github.com/FelineStateMachine/012/cmd/012@latest1036 ```1137 12-012 needs Go 1.27 to build and is one pure-Go binary. From a clone,13-`make build` puts it in `bin/012`.38+From a clone, `make build` puts it in `bin/012`.1439 1540 ```sh1641 012 # a new sheet@@ -20,20 +20,20 @@ Aliases: `AVG` for `AVERAGE`.
2020 | `ABS(value)` | Absolute value |2121 | `AND(logical1, [logical2, ...])` | TRUE if all arguments are true |2222 | `AVERAGE(value1, [value2, ...])` | Average of numbers, ignoring text |23-| `COUNT(value1, [value2, ...])` | Count of numeric values |24-| `COUNTA(value1, [value2, ...])` | Count of non-empty values |23+| `COUNT(value1, [value2, ...])` | Count of numeric values, skipping errors |24+| `COUNTA(value1, [value2, ...])` | Count of non-empty values, errors included |2525 | `FALSE()` | The logical value FALSE |2626 | `IF(condition, value_if_true, [value_if_false])` | Choose a value by a condition |27-| `IFERROR(value, [value_if_error])` | A fallback when a value is an error |27+| `IFERROR(value, [value_if_error])` | A fallback when a value is an error; blank without one |2828 | `INT(value)` | Round down to the nearest integer |2929 | `MAX(value1, [value2, ...])` | Largest number |3030 | `MIN(value1, [value2, ...])` | Smallest number |31-| `MOD(dividend, divisor)` | Remainder, with the sign of the divisor |31+| `MOD(dividend, divisor)` | Remainder, with the sign of the divisor: MOD(0.3, 0.1) is 0.1 as in Excel, where Sheets gives -5.55E-17 |3232 | `NA()` | The #N/A error |3333 | `NOT(logical)` | The opposite of a logical value |3434 | `OR(logical1, [logical2, ...])` | TRUE if any argument is true |3535 | `PI()` | The number pi |36-| `ROUND(value, [places])` | Round to a number of decimal places, halves away from zero |36+| `ROUND(value, [places])` | Round to a number of decimal places, halves away from zero, on the digits a cell shows: ROUND(1.5, 14) is 1.5 |3737 | `SQRT(value)` | Square root |3838 | `SUM(value1, [value2, ...])` | Sum of numbers |3939 | `TRUE()` | The logical value TRUE |@@ -48,7 +48,7 @@ Aliases: `AVG` for `AVERAGE`.
4848 | `LN(value)` | Natural logarithm |4949 | `LOG(value, [base])` | Logarithm, base 10 by default |5050 | `LOG10(value)` | Base-10 logarithm |51-| `POWER(base, exponent)` | A number raised to a power |51+| `POWER(base, exponent)` | A number raised to a power, odd roots of negatives included |5252 | `PRODUCT(factor1, [factor2, ...])` | Product of numbers |5353 | `QUOTIENT(dividend, divisor)` | Integer part of a division |5454 | `RAND()` | A random number from 0 up to 1, new on every change (recalculates on every change) |@@ -58,7 +58,7 @@ Aliases: `AVG` for `AVERAGE`.
5858 | `SIGN(value)` | 1, 0 or -1 by the sign of a number |5959 | `SUMIF(range, criterion, [sum_range])` | Sum of the cells that meet a condition |6060 | `SUMIFS(sum_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...])` | Sum of the cells that meet every condition |61-| `SUMPRODUCT(array1, [array2, ...])` | Sum of the products of matching entries |61+| `SUMPRODUCT(array1, [array2, ...])` | Sum of the products of matching entries, TRUE counting as 1 |6262 | `TRUNC(value, [places])` | Drop decimals past a number of places |6363 6464 ## Statistics@@ -105,7 +105,7 @@ Aliases: `AVG` for `AVERAGE`.
105105 | `EXACT(string1, string2)` | TRUE if two texts are identical, case included |106106 | `FIND(search_for, text_to_search, [starting_at])` | Position of text, case-sensitive |107107 | `LEFT(string, [number_of_characters])` | The first characters of text |108-| `LEN(text)` | Number of characters in text |108+| `LEN(text)` | Number of characters in text: an emoji is one, where Sheets counts two |109109 | `LOWER(text)` | Text in lower case |110110 | `MID(string, starting_at, extract_length)` | Characters from the middle of text |111111 | `PROPER(text)` | Text with each word capitalized |@@ -117,7 +117,7 @@ Aliases: `AVG` for `AVERAGE`.
117117 | `TEXT(number, format)` | A number as text in a format, e.g. "$#,##0.00" or "yyyy-mm-dd" |118118 | `TEXTJOIN(delimiter, ignore_empty, text1, [text2, ...])` | Join text with a delimiter |119119 | `TRIM(text)` | Text without leading, trailing and repeated spaces |120-| `UPPER(text)` | Text in upper case |120+| `UPPER(text)` | Text in upper case: ß is SS |121121 | `VALUE(text)` | Text as a number; dates and times too |122122 123123 ## Split and regular expressions@@ -136,7 +136,7 @@ Aliases: `AVG` for `AVERAGE`.
136136 | `CHOOSE(index, choice1, [choice2, ...])` | The choice at a position |137137 | `COLUMNS(range)` | Number of columns in a range |138138 | `HLOOKUP(search_key, range, index, [is_sorted])` | Find a key in the first row and return a value from its column |139-| `INDEX(reference, [row], [column])` | The value at a row and column of a range |139+| `INDEX(reference, [row], [column])` | The value at a row and column of a range; row or column 0 for a whole column or row |140140 | `MATCH(search_key, range, [search_type])` | Position of a key in a row or column |141141 | `ROWS(range)` | Number of rows in a range |142142 | `VLOOKUP(search_key, range, index, [is_sorted])` | Find a key in the first column and return a value from its row |@@ -164,7 +164,7 @@ Aliases: `AVG` for `AVERAGE`.
164164 | `BYCOL(array_or_range, LAMBDA)` | Each column of an array passed to a LAMBDA, one value per column |165165 | `BYROW(array_or_range, LAMBDA)` | Each row of an array passed to a LAMBDA, one value per row |166166 | `LAMBDA([name, ...], formula_expression)` | A function of names, called with values: LAMBDA(x, x*2)(3) |167-| `LET(name1, value_expression1, [name2, value_expression2, ...], formula_expression)` | Name values for use in a formula |167+| `LET(name1, value_expression1, [name2, value_expression2, ...], formula_expression)` | Name values for use in a formula; an error counts only where its name is used |168168 | `MAKEARRAY(rows, columns, LAMBDA)` | An array of a size, each entry a LAMBDA of its row and column |169169 | `MAP(array1, [array2, ...], LAMBDA)` | Each entry of arrays passed to a LAMBDA |170170 | `REDUCE(initial_value, array_or_range, LAMBDA)` | An array folded into one value by a LAMBDA of the total so far and each entry |@@ -3,14 +3,14 @@ package e2e
33 import "testing"44 55 // money types a few amounts where binary floating point is off by a6-// hair: whole cents of $4.35 come to 434, $0.10 plus $0.20 isn't 0.3,6+// hair: whole cents of $4.35 come to 434, $0.10 plus $0.20 less 0.3 isn't 0,77 // and the difference shows in General format.88 func money(s *session) {99 s.keys("Coffee", "<tab>", "$4.35", "<enter>")1010 s.keys("Tip", "<tab>", "$0.10", "<enter>")1111 s.keys("Fee", "<tab>", "$0.20", "<enter>")1212 s.keys("Cents", "<tab>", "=INT(B1*100)", "<enter>")13- s.keys("Is 0.30?", "<tab>", "=B2+B3=0.3", "<enter>")13+ s.keys("Is 0.30?", "<tab>", "=B2+B3-0.3=0", "<enter>")1414 s.keys("Residue", "<tab>", "=0.1+0.2-0.3", "<enter>")1515 s.waitForBar("A7", "")1616 }@@ -14,7 +14,7 @@
1414 <span style="color:var(--vt-palette-8)">│</span> AVERAGE<span style="color:var(--vt-palette-4);font-weight:bold">IF</span>(criteria_range, criterion, [average_range]) <span style="color:var(--vt-palette-0)">Average</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">the</span> <span style="color:var(--vt-palette-0)">cells</span> <span style="color:var(--vt-palette-0)">that</span> <span style="color:var(--vt-palette-0)">meet</span> <span style="color:var(--vt-palette-0)">a</span> <span style="color:var(--vt-palette-0)">…</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="color:var(--vt-palette-8)">│</span> AVERAGE<span style="color:var(--vt-palette-4);font-weight:bold">IF</span>S(average_range, criteria_range1, criterion1, [c… <span style="color:var(--vt-palette-0)">Average</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">the</span> <span style="color:var(--vt-palette-0)">cells</span> <span style="color:var(--vt-palette-0)">that</span> <span style="color:var(--vt-palette-0)">meet</span> <span style="color:var(--vt-palette-0)">ev…</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="color:var(--vt-palette-8)">│</span> JEV.CLASS<span style="color:var(--vt-palette-4);font-weight:bold">IF</span>Y(value, question, labels, [descriptions]) <span style="color:var(--vt-palette-0)">The</span> <span style="color:var(--vt-palette-0)">label</span> <span style="color:var(--vt-palette-0)">that</span> <span style="color:var(--vt-palette-0)">fits</span> <span style="color:var(--vt-palette-0)">best,</span> <span style="color:var(--vt-palette-0)">chosen</span> <span style="color:var(--vt-palette-0)">…</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">80</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">142</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">81</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">142</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>@@ -14,7 +14,7 @@
1414 <span style="color:var(--vt-palette-8)">│</span> AVERAGE<span style="color:var(--vt-palette-3);font-weight:bold">IF</span>(criteria_range, criterion, [average_range]) <span style="color:var(--vt-palette-8)">Average</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">cells</span> <span style="color:var(--vt-palette-8)">that</span> <span style="color:var(--vt-palette-8)">meet</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">…</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="color:var(--vt-palette-8)">│</span> AVERAGE<span style="color:var(--vt-palette-3);font-weight:bold">IF</span>S(average_range, criteria_range1, criterion1, [c… <span style="color:var(--vt-palette-8)">Average</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">cells</span> <span style="color:var(--vt-palette-8)">that</span> <span style="color:var(--vt-palette-8)">meet</span> <span style="color:var(--vt-palette-8)">ev…</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="color:var(--vt-palette-8)">│</span> JEV.CLASS<span style="color:var(--vt-palette-3);font-weight:bold">IF</span>Y(value, question, labels, [descriptions]) <span style="color:var(--vt-palette-8)">The</span> <span style="color:var(--vt-palette-8)">label</span> <span style="color:var(--vt-palette-8)">that</span> <span style="color:var(--vt-palette-8)">fits</span> <span style="color:var(--vt-palette-8)">best,</span> <span style="color:var(--vt-palette-8)">chosen</span> <span style="color:var(--vt-palette-8)">…</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">80</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">142</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">81</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">142</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>@@ -6,6 +6,7 @@ import (
66 "os"77 "slices"88 "strconv"9+ "strings"910 "testing"1011 1112 "github.com/FelineStateMachine/012/internal/sheet"@@ -59,8 +60,8 @@ func excelKeeps(formula string) bool {
5960 6061 // foldSheets records Sheets' results from name, a CSV download of the6162 // corpus sheet after importing the XLSX workbook into Sheets. Rows left62-// out of the download, and formulas saved as values that weren't typed63-// in, stay as they are.63+// out of the download, formulas saved as values that weren't typed in,64+// and functions the import didn't recognize stay as they are.6465 func foldSheets(t *testing.T, rows []*corpusLine, name string) {6566 t.Helper()6667 f, err := os.Open(name)@@ -78,13 +79,22 @@ func foldSheets(t *testing.T, rows []*corpusLine, name string) {
7879 sheets := map[string]string{}7980 for _, rec := range recs[1:] {8081 if len(rec) > colKind {81- sheets[rec[colFormula]] = encodeResult(rec[colText], rec[colKind])82+ text := rec[colText]83+ if rec[colKind] == "number" {84+ // Sheets writes a number a hair off a whole one, such as85+ // 100*1.1, as "110." when it rounds to 15 digits.86+ text = strings.TrimSuffix(text, ".")87+ }88+ sheets[rec[colFormula]] = encodeResult(text, rec[colKind])8289 }8390 }8491 skipped := 08592 for _, r := range rows {8693 got, ok := sheets[r.formula]87- if !ok || (got == "(blank)" && !excelKeeps(r.formula)) {94+ // #NAME? where 012 knows every name means the import didn't95+ // recognize a function (Sheets reads Excel's _xlfn._xlws.FILTER96+ // so): the formula wasn't computed.97+ if !ok || (got == "(blank)" && !excelKeeps(r.formula)) || (got == "#NAME?" && r.ours != got) {8898 skipped++8999 continue90100 }@@ -6,315 +6,315 @@
66 # Every formula computes one value; array results are measured with ROWS,77 # COLUMNS, INDEX or TEXTJOIN. Data! is the fixture sheet (sheetscorpus_test.go).88 ## Dates and times9-=DATE(2026, 9, 26) 46291 unconfirmed10-=DATE(2026, 14, 1) 46419 unconfirmed11-=DATE(2026, 1, 0) 46022 unconfirmed12-=DATE(26, 1, 1) 9498 unconfirmed13-=DATE(1899, 12, 30) 693961 unconfirmed14-=DATE(1900, 1, 1) 2 unconfirmed15-=DATE(1900, 2, 29) 61 unconfirmed16-=DATE(-1, 1, 1) #NUM! unconfirmed17-=DATE("2026", "9", "26") 46291 unconfirmed18-=TIME(14, 30, 0) 0.604166666666667 unconfirmed19-=TIME(25, 0, 0) 0.0416666666666667 unconfirmed20-=TIME(0, -1, 0) #NUM! unconfirmed21-=YEAR(0) 1899 unconfirmed22-=DAY(Data!A5) 30 unconfirmed23-=YEAR(Data!A4) #VALUE! unconfirmed24-=WEEKDAY(Data!F1) 7 unconfirmed25-=WEEKDAY(Data!F1, 2) 6 unconfirmed26-=WEEKDAY(Data!F1, 4) #NUM! unconfirmed27-=HOUR(0.99999999) 0 unconfirmed28-=HOUR(1.75) 18 unconfirmed29-=MINUTE("14:30:59") 30 unconfirmed30-=SECOND(59.6/86400) 0 unconfirmed31-=EDATE(DATE(2024, 2, 29), 12) 45716 unconfirmed32-=EDATE(Data!F1, 1.9) 46321 unconfirmed33-=EOMONTH(DATE(2026, 1, 31), 1) 46081 unconfirmed34-=DATEDIF(Data!F4, Data!F1, "MD") 28 unconfirmed35-=DATEDIF(Data!F4, Data!F1, "YD") 210 unconfirmed36-=DATEDIF(DATE(2024, 1, 31), DATE(2024, 3, 1), "MD") -1 unconfirmed37-=DATEDIF(Data!F2, Data!F5, "D") #NUM! unconfirmed38-=DAYS(Data!F1, Data!F2) 238 unconfirmed39-=NETWORKDAYS(Data!F5, Data!F2, Data!F5) 22 unconfirmed40-=NETWORKDAYS(Data!F1, Data!F1) 0 unconfirmed41-=DATEVALUE("9/26/2026") 46291 unconfirmed42-=DATEVALUE("Sep 26, 2026") 46291 unconfirmed43-=DATEVALUE("9/26/2026 18:00") 46291 unconfirmed44-=DATEVALUE("2/30/2026") #VALUE! unconfirmed45-=DATEVALUE(46291) #VALUE! unconfirmed46-=TIMEVALUE("2:30 PM") 0.604166666666667 unconfirmed47-=TIMEVALUE("9/26/2026 6:00") 0.25 unconfirmed48-=TIMEVALUE("noon") #VALUE! unconfirmed49-="2026-09-27"-Data!F1 1 unconfirmed50-=Data!F1+Data!F3 46291.6041666667 unconfirmed51-=Data!F1="2026-09-26" FALSE unconfirmed9+=DATE(2026, 9, 26) 46291 4629110+=DATE(2026, 14, 1) 46419 4641911+=DATE(2026, 1, 0) 46022 4602212+=DATE(26, 1, 1) 9498 949813+=DATE(1899, 12, 30) 693961 69396114+=DATE(1900, 1, 1) 2 215+=DATE(1900, 2, 29) 61 6116+=DATE(-1, 1, 1) #NUM! #NUM!17+=DATE("2026", "9", "26") 46291 4629118+=TIME(14, 30, 0) 0.604166666666667 0.60416666666666719+=TIME(25, 0, 0) 0.0416666666666667 0.041666666666666720+=TIME(0, -1, 0) #NUM! #NUM!21+=YEAR(0) 1899 189922+=DAY(Data!A5) 30 3023+=YEAR(Data!A4) #VALUE! #VALUE!24+=WEEKDAY(Data!F1) 7 725+=WEEKDAY(Data!F1, 2) 6 626+=WEEKDAY(Data!F1, 4) #NUM! #NUM!27+=HOUR(0.99999999) 0 028+=HOUR(1.75) 18 1829+=MINUTE("14:30:59") 30 3030+=SECOND(59.6/86400) 0 031+=EDATE(DATE(2024, 2, 29), 12) 45716 4571632+=EDATE(Data!F1, 1.9) 46321 4632133+=EOMONTH(DATE(2026, 1, 31), 1) 46081 4608134+=DATEDIF(Data!F4, Data!F1, "MD") 28 2835+=DATEDIF(Data!F4, Data!F1, "YD") 210 21036+=DATEDIF(DATE(2024, 1, 31), DATE(2024, 3, 1), "MD") -1 -137+=DATEDIF(Data!F2, Data!F5, "D") #NUM! #NUM!38+=DAYS(Data!F1, Data!F2) 238 23839+=NETWORKDAYS(Data!F5, Data!F2, Data!F5) 22 2240+=NETWORKDAYS(Data!F1, Data!F1) 0 041+=DATEVALUE("9/26/2026") 46291 4629142+=DATEVALUE("Sep 26, 2026") 46291 4629143+=DATEVALUE("9/26/2026 18:00") 46291 4629144+=DATEVALUE("2/30/2026") #VALUE! #VALUE!45+=DATEVALUE(46291) #VALUE! #VALUE!46+=TIMEVALUE("2:30 PM") 0.604166666666667 0.60416666666666747+=TIMEVALUE("9/26/2026 6:00") 0.25 0.2548+=TIMEVALUE("noon") #VALUE! #VALUE!49+="2026-09-27"-Data!F1 1 150+=Data!F1+Data!F3 46291.6041666667 46291.604166666751+=Data!F1="2026-09-26" FALSE FALSE5252 ## Text53-=CONCATENATE("a", 1, TRUE) "a1TRUE" unconfirmed54-=CONCATENATE(Data!A1:A3) "102030" unconfirmed55-=TEXTJOIN(", ", TRUE, Data!A3:A5) "30, apple" unconfirmed56-=TEXTJOIN(",", FALSE, Data!B5:B7) "north,,Ab*d" unconfirmed57-=LEFT("hi", -1) #VALUE! unconfirmed58-=LEFT(12345, 2) "12" unconfirmed59-=LEFT(TRUE, 1) "T" unconfirmed60-=MID("abc", 5, 2) "" unconfirmed61-=MID("abc", 0, 2) #VALUE! unconfirmed62-=LEN(Data!F1) 5 unconfirmed63-=LEN(1/4) 4 unconfirmed64-=LEN("héllo") 5 unconfirmed65-=LEN("😀") 1 unconfirmed66-=LEFT("日本語", 2) "日本" unconfirmed67-=UPPER("straße") "STRAßE" unconfirmed68-=UPPER(1.5) "1.5" unconfirmed69-=PROPER("hello wORLD it's") "Hello World It'S" unconfirmed70-=PROPER("o'neil mcdonald 3rd") "O'Neil Mcdonald 3Rd" unconfirmed71-=TRIM(" a b ") "a b" unconfirmed72-=SUBSTITUTE("aaa", "aa", "b") "ba" unconfirmed73-=SUBSTITUTE("abc", "", "x") "abc" unconfirmed74-=REPLACE("abc", 10, 1, "X") "abcX" unconfirmed75-=FIND("B", "abc") #VALUE! unconfirmed76-=FIND("", "abc") 1 unconfirmed77-=SEARCH("b?d", "abcbxd") 4 unconfirmed78-=SEARCH("~*", "a*b") 2 unconfirmed79-=REPT("ab", 2.9) "abab" unconfirmed80-=REPT("ab", -1) #VALUE! unconfirmed81-=EXACT(1, "1") TRUE unconfirmed82-=EXACT(TRUE, "TRUE") TRUE unconfirmed83-="a"&Data!A5 "a" unconfirmed84-=1&2 "12" unconfirmed85-=VALUE("1,234.5") 1234.5 unconfirmed86-=VALUE(" 42 ") 42 unconfirmed87-=VALUE("") 0 unconfirmed88-=VALUE("abc") #VALUE! unconfirmed89-=VALUE("9/26/2026") 46291 unconfirmed90-=VALUE(TRUE) #VALUE! unconfirmed53+=CONCATENATE("a", 1, TRUE) "a1TRUE" "a1TRUE"54+=CONCATENATE(Data!A1:A3) "102030" "102030"55+=TEXTJOIN(", ", TRUE, Data!A3:A5) "30, apple" "30, apple"56+=TEXTJOIN(",", FALSE, Data!B5:B7) "north,,Ab*d" "north,,Ab*d"57+=LEFT("hi", -1) #VALUE! #VALUE!58+=LEFT(12345, 2) "12" "12"59+=LEFT(TRUE, 1) "T" "T"60+=MID("abc", 5, 2) "" ""61+=MID("abc", 0, 2) #NUM! #NUM!62+=LEN(Data!F1) 5 9 on purpose: F1 is a date, stored in the XLSX as its serial with a m/d/yyyy format; Sheets' text functions read a date cell as it shows (9/26/2026), 012 reads its serial, as Excel does63+=LEN(1/4) 4 464+=LEN("héllo") 5 565+=LEN("😀") 1 2 on purpose: LEN counts characters (code points), where Sheets counts UTF-16 units66+=LEFT("日本語", 2) "日本" "日本"67+=UPPER("straße") "STRASSE" "STRASSE"68+=UPPER(1.5) "1.5" "1.5"69+=PROPER("hello wORLD it's") "Hello World It'S" "Hello World It'S"70+=PROPER("o'neil mcdonald 3rd") "O'Neil Mcdonald 3Rd" "O'Neil Mcdonald 3Rd"71+=TRIM(" a b ") "a b" "a b"72+=SUBSTITUTE("aaa", "aa", "b") "ba" "ba"73+=SUBSTITUTE("abc", "", "x") "abc" "abc"74+=REPLACE("abc", 10, 1, "X") "abcX" "abcX"75+=FIND("B", "abc") #VALUE! #VALUE!76+=FIND("", "abc") 1 177+=SEARCH("b?d", "abcbxd") 4 478+=SEARCH("~*", "a*b") 2 279+=REPT("ab", 2.9) "abab" "abab"80+=REPT("ab", -1) #VALUE! #VALUE!81+=EXACT(1, "1") TRUE TRUE82+=EXACT(TRUE, "TRUE") TRUE TRUE83+="a"&Data!A5 "a" "a"84+=1&2 "12" "12"85+=VALUE("1,234.5") 1234.5 1234.586+=VALUE(" 42 ") 42 4287+=VALUE("") 0 088+=VALUE("abc") #VALUE! #VALUE!89+=VALUE("9/26/2026") 46291 4629190+=VALUE(TRUE) #VALUE! #VALUE!9191 ## Regular expressions and SPLIT9292 =REGEXMATCH("ABC", "(?i)abc") TRUE unconfirmed9393 =REGEXMATCH(123, "1") #VALUE! unconfirmed9494 =REGEXEXTRACT("abc", "\d+") #N/A unconfirmed95-=TEXTJOIN("|", TRUE, REGEXEXTRACT("a1b2", "(\d)b(\d)")) "1" unconfirmed96-=REGEXREPLACE("john smith", "(\w+) (\w+)", "$2, $1") "smith, john" unconfirmed95+=TEXTJOIN("|", TRUE, REGEXEXTRACT("a1b2", "(\d)b(\d)")) "1|2" unconfirmed96+=REGEXREPLACE("john smith", "(\w+) (\w+)", "$2, $1") "smith, john" "smith, john"9797 =COLUMNS(SPLIT("a,b,,c", ",", TRUE, FALSE)) 4 unconfirmed9898 =SUM(SPLIT("1,2,3", ",")) 6 unconfirmed9999 =COLUMNS(SPLIT("a-b_c", "-_")) 3 unconfirmed100100 ## TEXT and number formats101-=TEXT(1234.567, "$#,##0.00") "$1,234.57" unconfirmed102-=TEXT(1234567.891, "#,##0,") "1,235" unconfirmed103-=TEXT(1234.5, "0.00E+00") "1.23E+03" unconfirmed104-=TEXT(0.0000000001, "0.00E+00") "1.00E-10" unconfirmed105-=TEXT(-1234.5, "#,##0.00;(#,##0.00)") "(1,234.50)" unconfirmed106-=TEXT(0, "0;-0;\z\e\r\o") "zero" unconfirmed107-=TEXT(3, "0.###") "3." unconfirmed108-=TEXT(2.5, "0") "3" unconfirmed109-=TEXT(-0.5, "0") "-1" unconfirmed110-=TEXT(1.005, "0.00") "1.01" unconfirmed111-=TEXT(0.75, "# ?/?") " /1" unconfirmed112-=TEXT("abc", "0") "abc" unconfirmed113-=TEXT("12", "0.00") "12.00" unconfirmed114-=TEXT(TRUE, "0") "1" unconfirmed115-=TEXT(Data!F1, "dddd") "Saturday" unconfirmed116-=TEXT(Data!F1, "mmm d, yyyy") "Sep 26, 2026" unconfirmed117-=TEXT(Data!F1, "dddd, mmmm d, yyyy") "Saturday, September 26, 2026" unconfirmed118-=TEXT(Data!F3, "h:mm AM/PM") "2:30 PM" unconfirmed119-=TEXT(0.75, "h AM/PM") "6 PM" unconfirmed120-=TEXT(Data!F1+Data!F3, "yyyy-mm-dd hh:mm") "2026-09-26 14:30" unconfirmed121-=TEXT(1.5, "[h]:mm") "36:00" unconfirmed122-=TEXT(90/1440, "[mm]:ss") "90:00" unconfirmed101+=TEXT(1234.567, "$#,##0.00") "$1,234.57" "$1,234.57"102+=TEXT(1234567.891, "#,##0,") "1,235" "1,235"103+=TEXT(1234.5, "0.00E+00") "1.23E+03" "1.23E+03"104+=TEXT(0.0000000001, "0.00E+00") "1.00E-10" "1.00E-10"105+=TEXT(-1234.5, "#,##0.00;(#,##0.00)") "(1,234.50)" "(1,234.50)"106+=TEXT(0, "0;-0;\z\e\r\o") "zero" "zero"107+=TEXT(3, "0.###") "3." "3."108+=TEXT(2.5, "0") "3" "3"109+=TEXT(-0.5, "0") "-1" "-1"110+=TEXT(1.005, "0.00") "1.01" "1.01"111+=TEXT(0.75, "# ?/?") "3/4" "3/4"112+=TEXT("abc", "0") "abc" "abc"113+=TEXT("12", "0.00") "12.00" "12.00"114+=TEXT(TRUE, "0") "TRUE" "TRUE"115+=TEXT(Data!F1, "dddd") "Saturday" "Saturday"116+=TEXT(Data!F1, "mmm d, yyyy") "Sep 26, 2026" "Sep 26, 2026"117+=TEXT(Data!F1, "dddd, mmmm d, yyyy") "Saturday, September 26, 2026" "Saturday, September 26, 2026"118+=TEXT(Data!F3, "h:mm AM/PM") "2:30 PM" "2:30 PM"119+=TEXT(0.75, "h AM/PM") "6 PM" "6 PM"120+=TEXT(Data!F1+Data!F3, "yyyy-mm-dd hh:mm") "2026-09-26 14:30" "2026-09-26 14:30"121+=TEXT(1.5, "[h]:mm") "36:00" "36:00"122+=TEXT(90/1440, "[mm]:ss") "90:00" "90:00"123123 ## Rounding and floating point124-=ROUND(1.005, 2) 1.01 unconfirmed125-=ROUND(2.675, 2) 2.68 unconfirmed126-=ROUND(0.285, 2) 0.29 unconfirmed127-=ROUND(-2.5) -3 unconfirmed128-=ROUND(1.5, 0.9) 2 unconfirmed129-=ROUND(1.23456789012345, 14) 1.23456789012345 unconfirmed130-=ROUNDUP(0.1+0.2, 1) 0.3 unconfirmed131-=ROUNDDOWN(1-0.9, 1) 0.1 unconfirmed132-=TRUNC(1234, -2) 1200 unconfirmed133-=INT(-1.5) -2 unconfirmed134-=INT(1-0.9+0.9) 1 unconfirmed135-=CEILING(0.3, 0.1) 0.3 unconfirmed136-=CEILING(-2.5, 1) -2 unconfirmed137-=CEILING(-2.5, -1) -3 unconfirmed138-=CEILING(2.5, -1) #NUM! unconfirmed139-=CEILING(2.5, 0) 0 unconfirmed140-=FLOOR(-2.5, 1) -3 unconfirmed141-=FLOOR(-2.5, -1) -2 unconfirmed142-=MOD(-7, 3) 2 unconfirmed143-=MOD(10, 0) #DIV/0! unconfirmed144-=MOD(0.3, 0.1) 0.1 unconfirmed145-=QUOTIENT(-7, 2) -3 unconfirmed146-=0.1+0.2 0.3 unconfirmed147-=0.1+0.2=0.3 FALSE unconfirmed148-=0.1+0.2-0.3 5.55111512312578E-17 unconfirmed149-=1-0.9 0.1 unconfirmed150-=(1-0.9)=0.1 FALSE unconfirmed151-=SUM(0.1, 0.2, 0.3)=0.6 FALSE unconfirmed152-=1/3 0.333333333333333 unconfirmed153-=100*1.1 110 unconfirmed154-=1E+21 1E+21 unconfirmed155-=123456789012345678 123456789012346000 unconfirmed156-=9007199254740993 9007199254740990 unconfirmed157-=0.000001 0.000001 unconfirmed158-=-0 0 unconfirmed159-=-2^2 4 unconfirmed160-=0^0 1 unconfirmed161-=POWER(0, 0) 1 unconfirmed162-=POWER(-8, 1/3) #NUM! unconfirmed163-=(-8)^(1/3) #NUM! unconfirmed164-=1E+308*10 #NUM! unconfirmed165-=LOG(10, 1) #DIV/0! unconfirmed124+=ROUND(1.005, 2) 1.01 1.01125+=ROUND(2.675, 2) 2.68 2.68126+=ROUND(0.285, 2) 0.29 0.29127+=ROUND(-2.5) -3 -3128+=ROUND(1.5, 0.9) 2 2129+=ROUND(1.23456789012345, 14) 1.23456789012345 1.23456789012346 on purpose: 012 rounds the shortest decimal half away from zero, so an exact input stays exact; Sheets' ...346 fits only a rule that also turns ROUND(1.5, 14) into 1.50000000000001130+=ROUNDUP(0.1+0.2, 1) 0.3 0.3131+=ROUNDDOWN(1-0.9, 1) 0.1 0.1132+=TRUNC(1234, -2) 1200 1200133+=INT(-1.5) -2 -2134+=INT(1-0.9+0.9) 1 1135+=CEILING(0.3, 0.1) 0.3 0.3136+=CEILING(-2.5, 1) -2 -2137+=CEILING(-2.5, -1) -3 -3138+=CEILING(2.5, -1) #NUM! #NUM!139+=CEILING(2.5, 0) 0 0140+=FLOOR(-2.5, 1) -3 -3141+=FLOOR(-2.5, -1) -2 -2142+=MOD(-7, 3) 2 2143+=MOD(10, 0) #DIV/0! #DIV/0!144+=MOD(0.3, 0.1) 0.1 -5.55111512312578E-17 on purpose: 012 computes n - d*INT(n/d) as Excel does, keeping the divisor's sign; Sheets' -5.55E-17 is a floating artefact145+=QUOTIENT(-7, 2) -3 -3146+=0.1+0.2 0.3 0.3147+=0.1+0.2=0.3 TRUE TRUE148+=0.1+0.2-0.3 5.55111512312578E-17 5.55111512312578E-17149+=1-0.9 0.1 0.1150+=(1-0.9)=0.1 TRUE TRUE151+=SUM(0.1, 0.2, 0.3)=0.6 TRUE TRUE152+=1/3 0.333333333333333 0.333333333333333153+=100*1.1 110 110154+=1E+21 1E+21 1E+21155+=123456789012345678 123456789012345000 123456789012345000156+=9007199254740993 9007199254740990 9007199254740990157+=0.000001 0.000001 0.000001158+=-0 0 0159+=-2^2 4 4160+=0^0 1 1161+=POWER(0, 0) 1 1162+=POWER(-8, 1/3) -2 -2163+=(-8)^(1/3) -2 -2164+=1E+308*10 #NUM! #NUM!165+=LOG(10, 1) #DIV/0! #DIV/0!166166 ## Errors and their propagation167-=0/0 #DIV/0! unconfirmed168-=Data!J2+1 #DIV/0! unconfirmed169-=SUM(Data!J2, 1) #DIV/0! unconfirmed170-=SUM(1, NA()) #N/A unconfirmed171-=NA()+1/0 #N/A unconfirmed172-=1/0+NA() #DIV/0! unconfirmed173-=IFERROR(1/0, "x") "x" unconfirmed174-=IFERROR(NA()) "" unconfirmed175-=IFERROR(Data!J2, Data!J4) #N/A unconfirmed176-=IFNA(1/0, 0) #DIV/0! unconfirmed177-=ISERR(NA()) FALSE unconfirmed178-=IF(1/0, 1, 2) #DIV/0! unconfirmed179-=IF("abc", 1, 2) #VALUE! unconfirmed180-=AND(TRUE, 1/0) #DIV/0! unconfirmed181-=OR(TRUE, NA()) #N/A unconfirmed182-=Data!A1+nosuchname #NAME? unconfirmed183-=CHOOSE(0, "a") #VALUE! unconfirmed184-=INDEX(Data!A1:A3, 5) #REF! unconfirmed185-=AVERAGEIF(Data!A1:A7, ">100") #DIV/0! unconfirmed186-=SWITCH(Data!B4, "north", 1) #N/A unconfirmed187-=PMT(0, 0, 1000) #NUM! unconfirmed188-=COUNT(Data!J1:J4) #DIV/0! unconfirmed189-=COUNTA(Data!J1:J4) #DIV/0! unconfirmed167+=0/0 #DIV/0! #DIV/0!168+=Data!J2+1 #DIV/0! #DIV/0!169+=SUM(Data!J2, 1) #DIV/0! #DIV/0!170+=SUM(1, NA()) #N/A #N/A171+=NA()+1/0 #N/A #N/A172+=1/0+NA() #DIV/0! #DIV/0!173+=IFERROR(1/0, "x") "x" "x"174+=IFERROR(NA()) (blank) (blank)175+=IFERROR(Data!J2, Data!J4) #N/A #N/A176+=IFNA(1/0, 0) #DIV/0! #DIV/0!177+=ISERR(NA()) FALSE FALSE178+=IF(1/0, 1, 2) #DIV/0! #DIV/0!179+=IF("abc", 1, 2) #VALUE! #VALUE!180+=AND(TRUE, 1/0) #DIV/0! #DIV/0!181+=OR(TRUE, NA()) #N/A #N/A182+=Data!A1+nosuchname #NAME? #NAME?183+=CHOOSE(0, "a") #NUM! #NUM!184+=INDEX(Data!A1:A3, 5) #NUM! #NUM!185+=AVERAGEIF(Data!A1:A7, ">100") #DIV/0! #DIV/0!186+=SWITCH(Data!B4, "north", 1) #N/A #N/A187+=PMT(0, 0, 1000) #NUM! #NUM!188+=COUNT(Data!J1:J4) 0 0189+=COUNTA(Data!J1:J4) 4 4190190 ## Coercion191-=" 3 "*1 3 unconfirmed192-="1,000"*1 1000 unconfirmed193-="$5"*2 10 unconfirmed194-="50%"*2 1 unconfirmed195-="9/26/2026"*1 46291 unconfirmed196-="TRUE"*1 #VALUE! unconfirmed197-=-TRUE -1 unconfirmed198-=Data!A5&"x" "x" unconfirmed199-=Data!A5="" TRUE unconfirmed200-=Data!B6=0 FALSE unconfirmed201-=ISBLANK(Data!B6) FALSE unconfirmed202-=Data!J1+1 4 unconfirmed203-=SUM(Data!J1) 0 unconfirmed204-=SUM("3", 1) 4 unconfirmed205-=SUM(Data!E6, 1) 1 unconfirmed206-=AVERAGE(Data!E6, 3) 3 unconfirmed207-=COUNT("1", 2) 2 unconfirmed208-=COUNT(TRUE) 1 unconfirmed209-=COUNTBLANK(Data!B1:B7) 1 unconfirmed210-="10"<9 FALSE unconfirmed211-=1<"a" TRUE unconfirmed212-=TRUE>1 TRUE unconfirmed213-=TRUE="TRUE" FALSE unconfirmed214-=Data!A5<"a" TRUE unconfirmed215-=""=0 FALSE unconfirmed216-=IF("TRUE", 1, 2) #VALUE! unconfirmed217-=IF(Data!A5, "y", "n") "n" unconfirmed218-=AND("abc") #VALUE! unconfirmed219-=AND(Data!A4) #VALUE! unconfirmed220-=NOT("") #VALUE! unconfirmed221-=ROUND("2.5") 3 unconfirmed222-=LEFT(Data!F1, 3) "462" unconfirmed191+=" 3 "*1 3 3192+="1,000"*1 1000 1000193+="$5"*2 10 10194+="50%"*2 1 1195+="9/26/2026"*1 46291 46291196+="TRUE"*1 #VALUE! #VALUE!197+=-TRUE -1 -1198+=Data!A5&"x" "x" "x"199+=Data!A5="" TRUE TRUE200+=Data!B6=0 FALSE FALSE201+=ISBLANK(Data!B6) FALSE FALSE202+=Data!J1+1 4 4203+=SUM(Data!J1) 0 0204+=SUM("3", 1) 4 4205+=SUM(Data!E6, 1) 1 1206+=AVERAGE(Data!E6, 3) 3 3207+=COUNT("1", 2) 2 2208+=COUNT(TRUE) 1 1209+=COUNTBLANK(Data!B1:B7) 1 1210+="10"<9 FALSE FALSE211+=1<"a" TRUE TRUE212+=TRUE>1 TRUE TRUE213+=TRUE="TRUE" FALSE FALSE214+=Data!A5<"a" TRUE TRUE215+=""=0 FALSE FALSE216+=IF("TRUE", 1, 2) 1 1217+=IF(Data!A5, "y", "n") "n" "n"218+=AND("abc") #VALUE! #VALUE!219+=AND(Data!A4) #VALUE! #VALUE!220+=NOT("") TRUE TRUE221+=ROUND("2.5") 3 3222+=LEFT(Data!F1, 3) "462" "9/2" on purpose: F1 is a date, stored in the XLSX as its serial with a m/d/yyyy format; Sheets' text functions read a date cell as it shows (9/26/2026), 012 reads its serial, as Excel does223223 ## Lookups and criteria224-=VLOOKUP("B*", Data!D1:E3, 2, FALSE) 2.5 unconfirmed225-=VLOOKUP("Blueberry", Data!D1:E3, 2) 2.5 unconfirmed226-=VLOOKUP("Aardvark", Data!D1:E3, 2, TRUE) #N/A unconfirmed227-=VLOOKUP(2.5, Data!C1:D5, 2) "Banana" unconfirmed228-=VLOOKUP("1", Data!C1:D5, 2, FALSE) #N/A unconfirmed229-=VLOOKUP("Apple", Data!D1:E3, 3, FALSE) #REF! unconfirmed230-=VLOOKUP("Apple", Data!D1:E3, 0, FALSE) #VALUE! unconfirmed231-=HLOOKUP("Apple", Data!D1:E2, 2, FALSE) "Banana" unconfirmed232-=MATCH("cherry", Data!D1:D3, 0) 3 unconfirmed233-=MATCH("B*", Data!D1:D3, 0) 2 unconfirmed234-=MATCH(3.2, Data!C1:C7) 3 unconfirmed235-=MATCH(0, Data!C1:C7) #N/A unconfirmed236-=MATCH(35, Data!I1:I3, -1) #N/A unconfirmed237-=MATCH(3, {1, 3, 5}, 0) 2 unconfirmed238-=INDEX(Data!C1:E1, 2) "Apple" unconfirmed239-=INDEX(Data!D1:E3, 4, 1) #REF! unconfirmed240-=ROWS(INDEX(Data!A1:C3, 0, 1)) 1 unconfirmed241-=SUM(INDEX(Data!C1:E3, 0, 1)) 1 unconfirmed242-=XLOOKUP("kiwi", Data!D1:D3, Data!E1:E3) #N/A unconfirmed243-=XLOOKUP("Apple", Data!D1:D5, Data!E1:E5, , 0, -1) 9 unconfirmed244-=XLOOKUP(3.2, Data!C1:C7, Data!D1:D7, , -1) "Cherry" unconfirmed245-=XLOOKUP(3.2, Data!C1:C7, Data!D1:D7, , 1) "date" unconfirmed246-=XLOOKUP("Ch*", Data!D1:D3, Data!E1:E3, , 2) 3.5 unconfirmed247-=XLOOKUP(2, Data!G1:G4, Data!H1:H4, , 0, -1) 400 unconfirmed248-=CHOOSE(2.9, "a", "b", "c") "b" unconfirmed249-=SUMIF(Data!A1:A7, "<>10") 45 unconfirmed250-=SUMIF(Data!C1:C7, ">=" & Data!C5) 18 unconfirmed251-=SUMIFS(Data!C1:C5, Data!B1:B5, "north", Data!A1:A5, ">10") 3 unconfirmed252-=COUNTIF(Data!B1:B7, "?o*") 4 unconfirmed253-=COUNTIF(Data!B1:B7, "Ab~*d") 1 unconfirmed254-=COUNTIF(Data!B1:B7, "<>north") 4 unconfirmed255-=COUNTIF(Data!A1:A7, "") 2 unconfirmed256-=COUNTIF(Data!A1:A7, "10") 1 unconfirmed257-=COUNTIF(Data!J1:J4, 3) 0 unconfirmed258-=COUNTIF(Data!E1:E7, TRUE) 1 unconfirmed259-=COUNTIF(Data!F1:F5, ">" & DATE(2026, 1, 1)) 2 unconfirmed224+=VLOOKUP("B*", Data!D1:E3, 2, FALSE) 2.5 2.5225+=VLOOKUP("Blueberry", Data!D1:E3, 2) 2.5 2.5226+=VLOOKUP("Aardvark", Data!D1:E3, 2, TRUE) #N/A #N/A227+=VLOOKUP(2.5, Data!C1:D5, 2) "Banana" "Banana"228+=VLOOKUP("1", Data!C1:D5, 2, FALSE) #N/A #N/A229+=VLOOKUP("Apple", Data!D1:E3, 3, FALSE) #REF! #REF!230+=VLOOKUP("Apple", Data!D1:E3, 0, FALSE) #VALUE! #VALUE!231+=HLOOKUP("Apple", Data!D1:E2, 2, FALSE) "Banana" "Banana"232+=MATCH("cherry", Data!D1:D3, 0) 3 3233+=MATCH("B*", Data!D1:D3, 0) 2 2234+=MATCH(3.2, Data!C1:C7) 3 3235+=MATCH(0, Data!C1:C7) #N/A #N/A236+=MATCH(35, Data!I1:I3, -1) #N/A #N/A237+=MATCH(3, {1, 3, 5}, 0) 2 2238+=INDEX(Data!C1:E1, 2) "Apple" "Apple"239+=INDEX(Data!D1:E3, 4, 1) #NUM! #NUM!240+=ROWS(INDEX(Data!A1:C3, 0, 1)) 3 3241+=SUM(INDEX(Data!C1:E3, 0, 1)) 6 6242+=XLOOKUP("kiwi", Data!D1:D3, Data!E1:E3) #N/A #N/A243+=XLOOKUP("Apple", Data!D1:D5, Data!E1:E5, , 0, -1) 9 9244+=XLOOKUP(3.2, Data!C1:C7, Data!D1:D7, , -1) "Cherry" "Cherry"245+=XLOOKUP(3.2, Data!C1:C7, Data!D1:D7, , 1) "date" "date"246+=XLOOKUP("Ch*", Data!D1:D3, Data!E1:E3, , 2) 3.5 3.5247+=XLOOKUP(2, Data!G1:G4, Data!H1:H4, , 0, -1) 400 400248+=CHOOSE(2.9, "a", "b", "c") "b" "b"249+=SUMIF(Data!A1:A7, "<>10") 45 45250+=SUMIF(Data!C1:C7, ">=" & Data!C5) 18 18251+=SUMIFS(Data!C1:C5, Data!B1:B5, "north", Data!A1:A5, ">10") 3 3252+=COUNTIF(Data!B1:B7, "?o*") 4 4253+=COUNTIF(Data!B1:B7, "Ab~*d") 1 1254+=COUNTIF(Data!B1:B7, "<>north") 4 4255+=COUNTIF(Data!A1:A7, "") 2 2256+=COUNTIF(Data!A1:A7, "10") 1 1257+=COUNTIF(Data!J1:J4, 3) 1 1258+=COUNTIF(Data!E1:E7, TRUE) 1 1259+=COUNTIF(Data!F1:F5, ">" & DATE(2026, 1, 1)) 2 2260260 ## Arrays and spills261-=SUM(SEQUENCE(3, 3, 0, 2)) 72 unconfirmed262-=INDEX(SEQUENCE(3, 2, 10, 5), 3, 2) 35 unconfirmed263-=ROWS(SEQUENCE(0)) 1 unconfirmed264-=ROWS(FILTER(Data!A1:A7, Data!C1:C7>3)) 4 unconfirmed265-=TEXTJOIN(",", TRUE, FILTER(Data!D1:D5, Data!B1:B5="north")) "Apple,Cherry,Apple" unconfirmed266-=FILTER(Data!A1:A3, Data!A1:A3>100) #N/A unconfirmed267-=TEXTJOIN(",", TRUE, SORT(Data!C1:C5, 1, FALSE)) "5,4,3,2,1" unconfirmed268-=INDEX(SORT(Data!D1:E5, 2, FALSE), 1, 1) "date" unconfirmed269-=TEXTJOIN(",", TRUE, SORT(Data!D1:D5)) "Apple,Apple,Banana,Cherry,date" unconfirmed270-=TEXTJOIN(",", TRUE, UNIQUE(Data!B1:B5)) "north,south,east" unconfirmed271-=TEXTJOIN(",", TRUE, UNIQUE(Data!G1:G4, FALSE, TRUE)) "1,3" unconfirmed261+=SUM(SEQUENCE(3, 3, 0, 2)) 72 72262+=INDEX(SEQUENCE(3, 2, 10, 5), 3, 2) 35 35263+=ROWS(SEQUENCE(0)) #NUM! #NUM!264+=ROWS(FILTER(Data!A1:A7, Data!C1:C7>3)) 4 unconfirmed Sheets read _xlfn._xlws.FILTER and _xlfn._xlws.SORT, which is how Excel writes them, as #NAME?: confirm on the next import265+=TEXTJOIN(",", TRUE, FILTER(Data!D1:D5, Data!B1:B5="north")) "Apple,Cherry,Apple" unconfirmed Sheets read _xlfn._xlws.FILTER and _xlfn._xlws.SORT, which is how Excel writes them, as #NAME?: confirm on the next import266+=FILTER(Data!A1:A3, Data!A1:A3>100) #N/A unconfirmed Sheets read _xlfn._xlws.FILTER and _xlfn._xlws.SORT, which is how Excel writes them, as #NAME?: confirm on the next import267+=TEXTJOIN(",", TRUE, SORT(Data!C1:C5, 1, FALSE)) "5,4,3,2,1" unconfirmed Sheets read _xlfn._xlws.FILTER and _xlfn._xlws.SORT, which is how Excel writes them, as #NAME?: confirm on the next import268+=INDEX(SORT(Data!D1:E5, 2, FALSE), 1, 1) "date" unconfirmed Sheets read _xlfn._xlws.FILTER and _xlfn._xlws.SORT, which is how Excel writes them, as #NAME?: confirm on the next import269+=TEXTJOIN(",", TRUE, SORT(Data!D1:D5)) "Apple,Apple,Banana,Cherry,date" unconfirmed Sheets read _xlfn._xlws.FILTER and _xlfn._xlws.SORT, which is how Excel writes them, as #NAME?: confirm on the next import270+=TEXTJOIN(",", TRUE, UNIQUE(Data!B1:B5)) "north,south,east" "north,south,east"271+=TEXTJOIN(",", TRUE, UNIQUE(Data!G1:G4, FALSE, TRUE)) "1,3" "1,3"272272 =TEXTJOIN(",", TRUE, FLATTEN(Data!C1:D2)) "1,Apple,2,Banana" unconfirmed273273 =ROWS(FLATTEN(Data!C1:D2, Data!G1:G2)) 6 unconfirmed274274 =TEXTJOIN(",", TRUE, SORTN(Data!C1:C7, 3, 0, 1, FALSE)) "7,6,5" unconfirmed275275 =ROWS(SORTN(Data!G1:G4, 2, 1, 1, TRUE)) 3 unconfirmed276-=TEXTJOIN(",", TRUE, CHOOSECOLS(Data!C1:E1, -1, 1)) "1.5,1" unconfirmed277-=ROWS(CHOOSEROWS(Data!A1:A7, 1, 3)) 2 unconfirmed276+=TEXTJOIN(",", TRUE, CHOOSECOLS(Data!C1:E1, -1, 1)) "1.5,1" "1.5,1"277+=ROWS(CHOOSEROWS(Data!A1:A7, 1, 3)) 2 2278278 =SUM(ARRAYFORMULA(Data!C1:C3*Data!G1:G3)) 11 unconfirmed279-=ARRAYFORMULA(SUM(Data!C1:C3^2)) 14 unconfirmed280-=SUM(Data!C1:C3*2) 12 unconfirmed281-=SUMPRODUCT(Data!C1:C3>1) 0 unconfirmed282-=SUMPRODUCT(Data!A1:A4, Data!C1:C4) 140 unconfirmed283-=SUM({1, 2; 3, 4}) 10 unconfirmed284-=INDEX({1, 2; 3, 4}, 2, 1) 3 unconfirmed279+=ARRAYFORMULA(SUM(Data!C1:C3^2)) 14 14280+=SUM(Data!C1:C3*2) 12 #VALUE! on purpose: 012 computes the arguments of functions that take ranges as arrays, as Excel 365 does; Sheets needs ARRAYFORMULA281+=SUMPRODUCT(Data!C1:C3>1) 2 2282+=SUMPRODUCT(Data!A1:A4, Data!C1:C4) 140 140283+=SUM({1, 2; 3, 4}) 10 10284+=INDEX({1, 2; 3, 4}, 2, 1) 3 3285285 ## LET and LAMBDA286-=LET(x, 2, y, x+1, x*y) 6 unconfirmed287-=LET(x, SEQUENCE(3), SUM(x)) 6 unconfirmed288-=LET(x, 1/0, 5) #DIV/0! unconfirmed289-=LAMBDA(x, x+1)() #N/A unconfirmed290-=LET(f, LAMBDA(x, x+1), f(f(1))) 3 unconfirmed291-=SUM(MAP({1, 2, 3}, LAMBDA(x, x*10))) 60 unconfirmed292-=SUM(MAP(Data!A1:A3, Data!C1:C3, LAMBDA(a, b, a+b))) 66 unconfirmed293-=REDUCE(0, Data!C1:C5, LAMBDA(a, b, a+b)) 15 unconfirmed294-=TEXTJOIN(",", TRUE, SCAN(0, {1, 2, 3}, LAMBDA(a, b, a+b))) "1,3,6" unconfirmed295-=SUM(BYROW(Data!C1:E3, LAMBDA(r, COUNT(r)))) 6 unconfirmed296-=TEXTJOIN(",", TRUE, BYCOL(Data!C1:E3, LAMBDA(c, COUNTA(c)))) "3,3,3" unconfirmed297-=SUM(MAKEARRAY(3, 3, LAMBDA(r, c, r*c))) 36 unconfirmed286+=LET(x, 2, y, x+1, x*y) 6 6287+=LET(x, SEQUENCE(3), SUM(x)) 6 6288+=LET(x, 1/0, 5) 5 5289+=LAMBDA(x, x+1)() #N/A #N/A290+=LET(f, LAMBDA(x, x+1), f(f(1))) 3 3291+=SUM(MAP({1, 2, 3}, LAMBDA(x, x*10))) 60 60292+=SUM(MAP(Data!A1:A3, Data!C1:C3, LAMBDA(a, b, a+b))) 66 66293+=REDUCE(0, Data!C1:C5, LAMBDA(a, b, a+b)) 15 15294+=TEXTJOIN(",", TRUE, SCAN(0, {1, 2, 3}, LAMBDA(a, b, a+b))) "1,3,6" "1,3,6"295+=SUM(BYROW(Data!C1:E3, LAMBDA(r, COUNT(r)))) 6 6296+=TEXTJOIN(",", TRUE, BYCOL(Data!C1:E3, LAMBDA(c, COUNTA(c)))) "3,3,3" "3,3,3"297+=SUM(MAKEARRAY(3, 3, LAMBDA(r, c, r*c))) 36 36298298 ## Logic299-=IFS(Data!A1>20, "big", Data!A1>5, "medium") "medium" unconfirmed300-=SWITCH(Data!B2, "north", 1, "south", 2) 2 unconfirmed301-=SWITCH(Data!B4, "north", 1, "south", 2, 0) 0 unconfirmed302-=XOR(Data!A1:A3) TRUE unconfirmed299+=IFS(Data!A1>20, "big", Data!A1>5, "medium") "medium" "medium"300+=SWITCH(Data!B2, "north", 1, "south", 2) 2 2301+=SWITCH(Data!B4, "north", 1, "south", 2, 0) 0 0302+=XOR(Data!A1:A3) TRUE TRUE303303 ## Math, statistics and finance304-=PRODUCT(2, "3") 6 unconfirmed305-=MEDIAN(1, 2, 3, 4) 2.5 unconfirmed306-=MODE(Data!G1:G4) 2 unconfirmed307-=MODE(Data!C1:C5) #N/A unconfirmed308-=STDEV(Data!A1:A7) 14.9303940559741 unconfirmed309-=VAR(Data!A1:A7) 222.916666666667 unconfirmed310-=RANK(2, Data!G1:G4) 2 unconfirmed311-=RANK(99, Data!A1:A7) #N/A unconfirmed312-=ROUND(PMT(0.05/12, 360, 200000), 6) -1073.643246 unconfirmed313-=PMT(0, 10, 1000) -100 unconfirmed314-=ROUND(PV(0.05/12, 360, -1000), 6) 186281.617046 unconfirmed315-=ROUND(NPER(0.01, -1000, 10000), 9) 10.588644459 unconfirmed316-=NPER(0.01, 100, 10000) -69.6607168935748 unconfirmed317-=ROUND(RATE(48, -200, 8000), 9) 0.007701472 unconfirmed318-=ROUND(NPV(0.1, Data!H1:H4), 6) -19.124377 unconfirmed319-=ROUND(IRR(Data!H1:H4), 9) 0.088963395 unconfirmed320-=IRR(Data!C1:C5) #NUM! unconfirmed304+=PRODUCT(2, "3") 6 6305+=MEDIAN(1, 2, 3, 4) 2.5 2.5306+=MODE(Data!G1:G4) 2 2307+=MODE(Data!C1:C5) #N/A #N/A308+=STDEV(Data!A1:A7) 14.9303940559741 14.9303940559741309+=VAR(Data!A1:A7) 222.916666666667 222.916666666667310+=RANK(2, Data!G1:G4) 2 2311+=RANK(99, Data!A1:A7) #N/A #N/A312+=ROUND(PMT(0.05/12, 360, 200000), 6) -1073.643246 -1073.643246313+=PMT(0, 10, 1000) -100 -100314+=ROUND(PV(0.05/12, 360, -1000), 6) 186281.617046 186281.617046315+=ROUND(NPER(0.01, -1000, 10000), 9) 10.588644459 10.588644459316+=NPER(0.01, 100, 10000) -69.6607168935748 -69.6607168935748317+=ROUND(RATE(48, -200, 8000), 9) 0.007701472 0.007701472318+=ROUND(NPV(0.1, Data!H1:H4), 6) -19.124377 -19.124377319+=ROUND(IRR(Data!H1:H4), 9) 0.088963395 0.088963395320+=IRR(Data!C1:C5) #NUM! #NUM!@@ -4,6 +4,8 @@ import (
44 "fmt"55 "strconv"66 "strings"7+8+ "github.com/FelineStateMachine/012/internal/numfmt"79 )810 911 // Binding powers, lowest to highest, following Sheets' precedence.@@ -133,7 +135,7 @@ func (p *parser) prefix() (Node, error) {
133135 if r, ok := p.lines(t, ""); ok {134136 return r, nil135137 }136- v, err := strconv.ParseFloat(t.text, 64)138+ v, err := strconv.ParseFloat(numfmt.Entered(t.text), 64)137139 if err != nil {138140 return nil, &ParseError{Pos: t.pos, Msg: "Invalid number " + t.text}139141 }@@ -1,6 +1,7 @@
11 package functions22 33 import (4+ "cmp"45 "strings"56 "unicode/utf8"67 @@ -345,18 +346,26 @@ func (c criterion) test(v Value) bool {
345346 }346347 return cmpResult(c.op, strings.Compare(strings.ToLower(v.Str), strings.ToLower(c.text)))347348 }348- // Numbers and booleans only match their own kind.349+ // Numbers and booleans only match their own kind, except that a350+ // number equals text that reads as it, as in Sheets: 3 matches "3".349351 if v.Kind != c.kind {352+ if v.Kind == value.Text && c.kind == value.Number && (c.op == "=" || c.op == "<>") {353+ if n, ok := value.ParseNumber(v.Str); ok {354+ return cmpResult(c.op, numCmp(n, c.num))355+ }356+ }350357 return c.op == "<>"351358 }352- d := 0353- switch {354- case v.Num < c.num:355- d = -1356- case v.Num > c.num:357- d = 1359+ return cmpResult(c.op, numCmp(v.Num, c.num))360+}361+362+// numCmp orders two numbers as the comparison operators do, equal when363+// they agree to the 15 digits a cell shows.364+func numCmp(a, b float64) int {365+ if value.SameShown(a, b) {366+ return 0358367 }359- return cmpResult(c.op, d)368+ return cmp.Compare(a, b)360369 }361370 362371 func cmpResult(op string, d int) bool {@@ -296,7 +296,10 @@ func sequence(args []Node, get lookup) Value {
296296 if err != nil {297297 return *err298298 }299- if rows < 1 || cols < 1 || tooBig(rows, cols) {299+ switch {300+ case rows < 1 || cols < 1:301+ return value.ErrNum // as Sheets; Excel says #CALC!302+ case tooBig(rows, cols):300303 return value.ErrValue301304 }302305 out := NewArray(rows, cols)@@ -1,8 +1,6 @@
11 package functions22 33 import (4- "math"5-64 "github.com/FelineStateMachine/012/internal/formula"75 "github.com/FelineStateMachine/012/internal/value"86 )@@ -278,7 +276,7 @@ func binaryOp(op string, l, r Value, dec bool) Value {
278276 case "&":279277 return Value{Kind: value.Text, Str: text(l) + text(r)}280278 case "=", "<>", "<", ">", "<=", ">=":281- return boolean(cmpResult(op, compare(l, r)))279+ return boolean(cmpResult(op, value.CompareShown(l, r)))282280 }283281 a, err := toNum(l)284282 if err != nil {@@ -306,7 +304,7 @@ func binaryOp(op string, l, r Value, dec bool) Value {
306304 }307305 return num(a / b)308306 case "^":309- return num(math.Pow(a, b))307+ return powerOf(a, b)310308 case "#AND#":311309 return boolean(a != 0 && b != 0)312310 case "#OR#":@@ -119,10 +119,10 @@ func init() {
119119 }120120 return num(s.sum / float64(s.nums))121121 }), format: inherit},122- &FuncDef{Name: "COUNT", Args: "value1, [value2, ...]", Desc: "Count of numeric values", Min: 1, Max: -1,123- eval: aggregate(func(s Agg) Value { return num(float64(s.nums)) })},124- &FuncDef{Name: "COUNTA", Args: "value1, [value2, ...]", Desc: "Count of non-empty values", Min: 1, Max: -1,125- eval: aggregate(func(s Agg) Value { return num(float64(s.count)) })},122+ &FuncDef{Name: "COUNT", Args: "value1, [value2, ...]", Desc: "Count of numeric values, skipping errors", Min: 1, Max: -1,123+ eval: counter(func(s Agg) Value { return num(float64(s.nums)) })},124+ &FuncDef{Name: "COUNTA", Args: "value1, [value2, ...]", Desc: "Count of non-empty values, errors included", Min: 1, Max: -1,125+ eval: counter(func(s Agg) Value { return num(float64(s.count + s.errs)) })},126126 &FuncDef{Name: "MIN", Args: "value1, [value2, ...]", Desc: "Smallest number", Min: 1, Max: -1,127127 eval: aggregate(func(s Agg) Value {128128 if s.nums == 0 {@@ -140,9 +140,9 @@ func init() {
140140 &FuncDef{Name: "ABS", Args: "value", Desc: "Absolute value", Min: 1, Max: 1, eval: math1(math.Abs), format: inherit},141141 &FuncDef{Name: "INT", Args: "value", Desc: "Round down to the nearest integer", Min: 1, Max: 1, eval: math1(math.Floor), format: inherit},142142 &FuncDef{Name: "SQRT", Args: "value", Desc: "Square root", Min: 1, Max: 1, eval: math1(math.Sqrt)},143- &FuncDef{Name: "ROUND", Args: "value, [places]", Desc: "Round to a number of decimal places, halves away from zero", Min: 1, Max: 2,143+ &FuncDef{Name: "ROUND", Args: "value, [places]", Desc: "Round to a number of decimal places, halves away from zero, on the digits a cell shows: ROUND(1.5, 14) is 1.5", Min: 1, Max: 2,144144 eval: rounder(numfmt.HalfUp), format: inheritFrom(0)},145- &FuncDef{Name: "MOD", Args: "dividend, divisor", Desc: "Remainder, with the sign of the divisor", Min: 2, Max: 2,145+ &FuncDef{Name: "MOD", Args: "dividend, divisor", Desc: "Remainder, with the sign of the divisor: MOD(0.3, 0.1) is 0.1 as in Excel, where Sheets gives -5.55E-17", Min: 2, Max: 2,146146 eval: numeric(func(x []float64) Value {147147 if x[1] == 0 {148148 return value.ErrDiv0@@ -155,15 +155,11 @@ func init() {
155155 &FuncDef{Name: "NA", Desc: "The #N/A error", Max: 0, eval: constant(value.ErrNA)},156156 &FuncDef{Name: "IF", Args: "condition, value_if_true, [value_if_false]", Desc: "Choose a value by a condition", Min: 2, Max: 3, arrays: liftPass,157157 eval: func(args []Node, get lookup) Value {158- c := eval1(args[0], get)159- if c.Kind == value.Error {160- return c161- }162- f, err := toNum(c)158+ t, err := truth(eval1(args[0], get))163159 if err != nil {164160 return *err165161 }166- if f != 0 {162+ if t {167163 return eval(args[1], get)168164 }169165 if len(args) < 3 {@@ -171,14 +167,14 @@ func init() {
171167 }172168 return eval(args[2], get)173169 }, format: func(args []Node, infer func(Node) Format) Format { return inherit(args[1:], infer) }},174- &FuncDef{Name: "IFERROR", Args: "value, [value_if_error]", Desc: "A fallback when a value is an error", Min: 1, Max: 2, arrays: liftPass,170+ &FuncDef{Name: "IFERROR", Args: "value, [value_if_error]", Desc: "A fallback when a value is an error; blank without one", Min: 1, Max: 2, arrays: liftPass,175171 eval: func(args []Node, get lookup) Value {176172 v := eval(args[0], get)177173 if v.Kind != value.Error {178174 return v179175 }180176 if len(args) < 2 {181- return Value{Kind: value.Text}177+ return Value{} // blank, as in Sheets182178 }183179 return eval(args[1], get)184180 }},@@ -187,7 +183,13 @@ func init() {
187183 &FuncDef{Name: "OR", Args: "logical1, [logical2, ...]", Desc: "TRUE if any argument is true", Min: 1, Max: -1,188184 eval: logical(func(t, _ int) bool { return t > 0 })},189185 &FuncDef{Name: "NOT", Args: "logical", Desc: "The opposite of a logical value", Min: 1, Max: 1,190- eval: numeric(func(x []float64) Value { return boolean(x[0] == 0) })},186+ eval: func(args []Node, get lookup) Value {187+ t, err := truth(eval(args[0], get))188+ if err != nil {189+ return *err190+ }191+ return boolean(!t)192+ }},191193 )192194 }193195 @@ -198,9 +200,13 @@ func init() {
198200 type Agg struct {199201 sum float64200202 prod float64201- count int // non-empty values203+ count int // non-empty values other than errors202204 nums int203205 min, max float64206+ // counting is set for COUNT and COUNTA, which count errors (errs)207+ // where the others stop at the first.208+ counting bool209+ errs int204210 }205211 206212 // each calls fn for every value in args: every cell of a range that@@ -264,10 +270,15 @@ func eachEntry(a *Array, fn func(v Value, direct bool) *Value) *Value {
264270 }265271 266272 // Add counts v into the aggregate, with SUM's rules: blanks are skipped,267-// errors returned, and text counts only when given directly.273+// errors returned (counted, when counting), and text counts only when274+// given directly.268275 func (s *Agg) Add(v Value, direct bool) *Value {269276 switch v.Kind {270277 case value.Error:278+ if s.counting {279+ s.errs++280+ return nil281+ }271282 return errOf(v)272283 case value.Empty:273284 return nil@@ -298,8 +309,21 @@ func NewAgg() Agg { return Agg{prod: 1, min: math.Inf(1), max: math.Inf(-1)} }
298309 // read each cell once; other ranges the engine adds up itself299310 // (Book.Fold), as it reads them.300311 func aggregate(done func(Agg) Value) func([]Node, lookup) Value {312+ return aggregateFrom(NewAgg(), done)313+}314+315+// counter builds COUNT and COUNTA, which read past errors, as Sheets316+// does: COUNT skips them and COUNTA counts them. Their ranges are read317+// directly, as the shared aggregates stop at an error.318+func counter(done func(Agg) Value) func([]Node, lookup) Value {319+ s := NewAgg()320+ s.counting = true321+ return aggregateFrom(s, done)322+}323+324+func aggregateFrom(start Agg, done func(Agg) Value) func([]Node, lookup) Value {301325 return func(args []Node, get lookup) Value {302- s := NewAgg()326+ s := start303327 for _, arg := range args {304328 if rn, ok := get.refOf(arg).(formula.Range); ok {305329 var e *Value@@ -319,7 +343,7 @@ func aggregate(done func(Agg) Value) func([]Node, lookup) Value {
319343 // rangeAgg adds the range rn to s: from the running aggregates when s is320344 // empty and the range is shared, and otherwise as the engine reads it.321345 func rangeAgg(rn formula.Range, s Agg, get lookup) (Agg, *Value) {322- if s.count == 0 {346+ if s.count == 0 && !s.counting {323347 if a, e, ok := get.book.RangeAgg(rn.Sheet, rn.Rect); ok {324348 return a, e325349 }@@ -337,12 +361,12 @@ func logical(test func(trues, n int) bool) func([]Node, lookup) Value {
337361 case v.Kind == value.Empty, v.Kind == value.Text && !direct:338362 return nil339363 }340- f, err := toNum(v)364+ t, err := truth(v)341365 if err != nil {342366 return err343367 }344368 n++345- if f != 0 {369+ if t {346370 trues++347371 }348372 return nil@@ -13,7 +13,7 @@ import (
1313 1414 func init() {1515 define(16- &FuncDef{Name: "LET", Args: "name1, value_expression1, [name2, value_expression2, ...], formula_expression", Desc: "Name values for use in a formula", Min: 3, Max: -1, step: 2,16+ &FuncDef{Name: "LET", Args: "name1, value_expression1, [name2, value_expression2, ...], formula_expression", Desc: "Name values for use in a formula; an error counts only where its name is used", Min: 3, Max: -1, step: 2,1717 eval: let, arrays: takesArrays, binds: formula.BindLet, format: func(args []Node, infer func(Node) Format) Format { return infer(args[len(args)-1]) }},1818 &FuncDef{Name: "LAMBDA", Args: "[name, ...], formula_expression", Desc: "A function of names, called with values: LAMBDA(x, x*2)(3)", Min: 1, Max: -1,1919 eval: makeLambda, arrays: takesArrays, binds: formula.BindLambda},@@ -37,11 +37,9 @@ func let(args []Node, get lookup) Value {
3737 defer func() { get.scope = get.scope[:outer] }()3838 last := len(args) - 13939 for i := 0; i < last; i += 2 {40- b := get.bind(args[i].(formula.Local).Name, args[i+1])41- if b.ref == nil && b.val.Kind == value.Error {42- return b.val43- }44- get.scope = append(get.scope, b)40+ // An error is the result only where a name bound to it is used:41+ // LET(x, 1/0, 5) is 5, as in Sheets.42+ get.scope = append(get.scope, get.bind(args[i].(formula.Local).Name, args[i+1]))4543 }4644 return eval(args[last], get)4745 }@@ -88,7 +88,8 @@ func liftCall(f *FuncDef, args []Node, get lookup) Value {
8888 lift, want := get.lift, get.wantArr8989 get.lift, get.wantArr = 0, f.arrays == liftPass9090 defer func() { get.lift, get.wantArr = lift, want }()91- v := get.first(f.call(stand, get))91+ whole := f.call(stand, get)92+ v := get.first(whole)9293 var s shape9394 var arrs []*Array9495 for i, r := range fr.read {@@ -99,6 +100,9 @@ func liftCall(f *FuncDef, args []Node, get lookup) Value {
99100 }100101 }101102 if len(arrs) == 0 {103+ if f.arrays == liftPass {104+ return whole // mapped over nothing: an array it returns stays whole105+ }102106 return v103107 }104108 s.data(arrs...)@@ -1,6 +1,10 @@
11 package functions22 3-import "github.com/FelineStateMachine/012/internal/value"3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/value"7+)48 59 func init() {610 define(@@ -33,11 +37,11 @@ func init() {
3337 3438 func ifs(args []Node, get lookup) Value {3539 for i := 0; i+1 < len(args); i += 2 {36- c, err := numArg(args[i], get)40+ c, err := truth(eval1(args[i], get))3741 if err != nil {3842 return *err3943 }40- if c != 0 {44+ if c {4145 return eval(args[i+1], get)4246 }4347 }@@ -97,3 +101,22 @@ func sameKind(a, b Value) bool {
97101 }98102 return ka == kb99103 }104+105+// truth reads v as a condition, as Sheets does: a number is true unless106+// 0, a blank or "" is false, and text reads as TRUE, FALSE or a number,107+// so IF("TRUE", 1, 2) is 1; other text is #VALUE!.108+func truth(v Value) (bool, *Value) {109+ switch v.Kind {110+ case value.Error:111+ return false, errOf(v)112+ case value.Text:113+ switch {114+ case v.Str == "", strings.EqualFold(v.Str, "FALSE"):115+ return false, nil116+ case strings.EqualFold(v.Str, "TRUE"):117+ return true, nil118+ }119+ }120+ f, err := toNum(v)121+ return f != 0, err122+}@@ -14,16 +14,16 @@ func init() {
1414 eval: func(args []Node, get lookup) Value { return tableLookup(args, get, false) }},1515 &FuncDef{Name: "MATCH", Args: "search_key, range, [search_type]", Desc: "Position of a key in a row or column", Min: 2, Max: 3,1616 eval: match},17- &FuncDef{Name: "INDEX", Args: "reference, [row], [column]", Desc: "The value at a row and column of a range", Min: 1, Max: 3,17+ &FuncDef{Name: "INDEX", Args: "reference, [row], [column]", Desc: "The value at a row and column of a range; row or column 0 for a whole column or row", Min: 1, Max: 3, arrays: liftPass,1818 eval: index, format: inheritFrom(0)},1919 &FuncDef{Name: "XLOOKUP", Args: "search_key, lookup_range, result_range, [missing_value], [match_mode], [search_mode]", Desc: "Find a key and return the matching entry of another range", Min: 3, Max: 6,2020 eval: xlookup, format: inheritFrom(2)},2121 &FuncDef{Name: "CHOOSE", Args: "index, choice1, [choice2, ...]", Desc: "The choice at a position", Min: 2, Max: -1, arrays: liftPass,2222 eval: choose},2323 &FuncDef{Name: "ROWS", Args: "range", Desc: "Number of rows in a range", Min: 1, Max: 1,24- eval: func(args []Node, get lookup) Value { return num(float64(matrixArg(args[0], get).rows)) }},24+ eval: func(args []Node, get lookup) Value { return measure(args[0], get, true) }},2525 &FuncDef{Name: "COLUMNS", Args: "range", Desc: "Number of columns in a range", Min: 1, Max: 1,26- eval: func(args []Node, get lookup) Value { return num(float64(matrixArg(args[0], get).cols)) }},26+ eval: func(args []Node, get lookup) Value { return measure(args[0], get, false) }},2727 )2828 }2929 @@ -83,7 +83,7 @@ func index(args []Node, get lookup) Value {
8383 }8484 switch {8585 case row > m.rows || col > m.cols:86- return value.ErrRef86+ return value.ErrNum // as Sheets; Excel says #REF!8787 case row == 0 || col == 0: // a whole column, row or both: an array8888 return get.arrayValue(m.slice(row, col))8989 }@@ -113,13 +113,26 @@ func (m matrix) slice(row, col int) *Array {
113113 return a114114 }115115 116+// measure is ROWS, or COLUMNS when rows is false. An error in place of117+// a range or array is the answer: ROWS(SEQUENCE(0)) is #NUM!.118+func measure(n Node, get lookup, rows bool) Value {119+ m := matrixArg(n, get)120+ switch {121+ case !m.ref && m.rows == 1 && m.cols == 1 && m.blank.Kind == value.Error:122+ return m.blank123+ case rows:124+ return num(float64(m.rows))125+ }126+ return num(float64(m.cols))127+}128+116129 func choose(args []Node, get lookup) Value {117130 i, err := intArg(args, 0, 0, get)118131 if err != nil {119132 return *err120133 }121134 if i < 1 || i >= len(args) {122- return value.ErrValue135+ return value.ErrNum // as Sheets; Excel says #VALUE!123136 }124137 return eval(args[i], get)125138 }@@ -281,8 +281,8 @@ func sumProductTerms(args []Node, get lookup, term func(fs []float64)) *Value {
281281 switch v := m.cell(pc.r, pc.c); v.Kind {282282 case value.Error:283283 return &v284- case value.Number:285- fs[i] = v.Num284+ case value.Number, value.Bool:285+ fs[i] = v.Num // TRUE is 1, as in Sheets286286 default:287287 fs[i] = 0288288 }@@ -14,7 +14,7 @@ func init() {
1414 eval: sumIf, format: inheritFrom(2)},1515 &FuncDef{Name: "SUMIFS", Args: "sum_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...]", Desc: "Sum of the cells that meet every condition", Min: 3, Max: -1, step: 2,1616 eval: sumIfs, format: inheritFrom(0)},17- &FuncDef{Name: "SUMPRODUCT", Args: "array1, [array2, ...]", Desc: "Sum of the products of matching entries", Min: 1, Max: -1,17+ &FuncDef{Name: "SUMPRODUCT", Args: "array1, [array2, ...]", Desc: "Sum of the products of matching entries, TRUE counting as 1", Min: 1, Max: -1,1818 eval: sumProduct},1919 &FuncDef{Name: "PRODUCT", Args: "factor1, [factor2, ...]", Desc: "Product of numbers", Min: 1, Max: -1,2020 eval: aggregate(func(s Agg) Value {@@ -23,7 +23,7 @@ func init() {
2323 }2424 return num(s.prod)2525 })},26- &FuncDef{Name: "POWER", Args: "base, exponent", Desc: "A number raised to a power", Min: 2, Max: 2,26+ &FuncDef{Name: "POWER", Args: "base, exponent", Desc: "A number raised to a power, odd roots of negatives included", Min: 2, Max: 2,2727 eval: numeric(func(x []float64) Value { return powerOf(x[0], x[1]) })},2828 &FuncDef{Name: "ROUNDUP", Args: "value, [places]", Desc: "Round away from zero", Min: 1, Max: 2,2929 eval: rounder(numfmt.Up), format: inheritFrom(0)},@@ -102,7 +102,21 @@ func powerOf(b, e float64) Value {
102102 if b == 0 && e < 0 {103103 return value.ErrDiv0104104 }105- return num(math.Pow(b, e))105+ return num(power(b, e))106+}107+108+// power is b raised to e, as POWER and ^ compute it. A negative base109+// has a real odd root, as in Sheets: (-8)^(1/3) is -2, where math.Pow110+// gives NaN (#NUM!). Other fractional powers of a negative stay NaN.111+func power(b, e float64) float64 {112+ if b >= 0 || e == math.Trunc(e) {113+ return math.Pow(b, e)114+ }115+ n := math.Round(1 / e) // e is 1/n, or near it: 1/3 isn't exact116+ if math.Abs(1/e-n) > 1e-9*math.Abs(n) || math.Mod(n, 2) == 0 {117+ return math.NaN()118+ }119+ return -math.Pow(-b, e)106120 }107121 108122 // toMultiple builds CEILING and FLOOR with Sheets semantics: the factor@@ -3,6 +3,10 @@ package functions
33 import (44 "strings"55 "unicode"6+ "unicode/utf8"7+8+ "golang.org/x/text/cases"9+ "golang.org/x/text/language"610 711 "github.com/FelineStateMachine/012/internal/numfmt"812 "github.com/FelineStateMachine/012/internal/value"@@ -25,10 +29,10 @@ func init() {
2529 eval: textSlice(func(r []rune, n int) string { return string(r[len(r)-min(n, len(r)):]) })},2630 &FuncDef{Name: "MID", Args: "string, starting_at, extract_length", Desc: "Characters from the middle of text", Min: 3, Max: 3,2731 eval: mid},28- &FuncDef{Name: "LEN", Args: "text", Desc: "Number of characters in text", Min: 1, Max: 1,32+ &FuncDef{Name: "LEN", Args: "text", Desc: "Number of characters in text: an emoji is one, where Sheets counts two", Min: 1, Max: 1,2933 eval: textFn(func(s string) Value { return num(float64(runeLen(s))) })},30- &FuncDef{Name: "UPPER", Args: "text", Desc: "Text in upper case", Min: 1, Max: 1,31- eval: textFn(func(s string) Value { return str(strings.ToUpper(s)) })},34+ &FuncDef{Name: "UPPER", Args: "text", Desc: "Text in upper case: ß is SS", Min: 1, Max: 1,35+ eval: textFn(func(s string) Value { return str(upper(s)) })},3236 &FuncDef{Name: "LOWER", Args: "text", Desc: "Text in lower case", Min: 1, Max: 1,3337 eval: textFn(func(s string) Value { return str(strings.ToLower(s)) })},3438 &FuncDef{Name: "PROPER", Args: "text", Desc: "Text with each word capitalized", Min: 1, Max: 1,@@ -113,7 +117,10 @@ func mid(args []Node, get lookup) Value {
113117 if err != nil {114118 return *err115119 }116- if start < 1 || n < 0 {120+ switch {121+ case start < 1:122+ return value.ErrNum // as Sheets; Excel says #VALUE!123+ case n < 0:117124 return value.ErrValue118125 }119126 r := []rune(s)@@ -123,6 +130,18 @@ func mid(args []Node, get lookup) Value {
123130 return str(string(r[start-1 : min(start-1+n, len(r))]))124131 }125132 133+// upper is UPPER's full case mapping, which Sheets uses: "straße" is134+// "STRASSE", where mapping each letter alone would keep the ß. A Caser135+// keeps state, so each call makes its own, and ASCII needs none.136+func upper(s string) string {137+ for i := 0; i < len(s); i++ {138+ if s[i] >= utf8.RuneSelf {139+ return cases.Upper(language.Und).String(s)140+ }141+ }142+ return strings.ToUpper(s)143+}144+126145 func trimSpaces(s string) Value {127146 return str(strings.Join(strings.FieldsFunc(s, func(r rune) bool { return r == ' ' }), " "))128147 }@@ -162,6 +181,9 @@ func textFormat(args []Node, get lookup) Value {
162181 if err != nil {163182 return *err164183 }184+ if v.Kind == value.Bool {185+ return str(v.String()) // TRUE stays TRUE whatever the format, as in Sheets186+ }165187 if v.Kind == value.Text {166188 n, _, ok := value.ParseValue(v.Str)167189 if !ok {@@ -0,0 +1,35 @@
1+package numfmt2+3+// Entered keeps the first 15 significant digits of a number written in4+// decimal (digits, a point, an exponent) and makes the digits after them5+// zeros, as Sheets and Excel do with a number typed into a formula:6+// =123456789012345678 is 123456789012345000. Text with no more than 157+// digits comes back as it is. Numbers typed into cells keep every digit,8+// as files and imports write them with 17 to round-trip.9+func Entered(s string) string {10+ sig := 011+ for i := 0; i < len(s); i++ {12+ switch c := s[i]; {13+ case c == 'e' || c == 'E':14+ return s15+ case c < '0' || c > '9':16+ case sig > 0 || c != '0':17+ sig++18+ if sig > 15 {19+ return zeroDigits(s, i)20+ }21+ }22+ }23+ return s24+}25+26+// zeroDigits makes the digits of s from i up to any exponent zeros.27+func zeroDigits(s string, i int) string {28+ b := []byte(s)29+ for ; i < len(b) && b[i] != 'e' && b[i] != 'E'; i++ {30+ if b[i] >= '1' && b[i] <= '9' {31+ b[i] = '0'32+ }33+ }34+ return string(b)35+}@@ -0,0 +1,166 @@
1+package numfmt2+3+import (4+ "math"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/locale"9+)10+11+// Fraction patterns: "# ?/?" shows 1.75 as 1 3/4, "# ??/??" allows12+// denominators up to 99, "# ?/8" shows eighths, and "?/?" with no whole13+// part shows 7/4. Placeholders pad as in numbers: ? with a space, 0 with14+// a zero, # with nothing.15+16+// fracLayout is where a fraction section puts each part: the tokens of17+// the whole number (with anything before it), those between it and the18+// numerator, the numerator's and denominator's placeholders, and the19+// rest.20+type fracLayout struct {21+ whole, gap, num, den, rest []ptok22+ fixed int // the denominator written in the pattern, or 023+}24+25+// layoutFraction finds a fraction in a number section: digit26+// placeholders, a /, and placeholders or digits for the denominator.27+func layoutFraction(toks []ptok) (fracLayout, bool) {28+ slash := -129+ for i := 1; i+1 < len(toks); i++ {30+ if toks[i].kind == ptLit && toks[i].s == "/" && toks[i-1].kind == ptDigit && denToken(toks[i+1]) {31+ slash = i32+ break33+ }34+ }35+ if slash < 0 {36+ return fracLayout{}, false37+ }38+ start := slash39+ for start > 0 && toks[start-1].kind == ptDigit {40+ start--41+ }42+ end := slash + 143+ for end < len(toks) && denToken(toks[end]) {44+ end++45+ }46+ l := fracLayout{num: toks[start:slash], den: toks[slash+1 : end], rest: toks[end:]}47+ last := start - 148+ for last >= 0 && toks[last].kind != ptDigit {49+ last--50+ }51+ l.whole, l.gap = toks[:last+1], toks[last+1:start]52+ var d strings.Builder53+ for _, t := range l.den {54+ d.WriteString(t.s)55+ }56+ if n, err := strconv.Atoi(d.String()); err == nil && n > 0 {57+ l.fixed = n58+ }59+ return l, true60+}61+62+// denToken reports whether t can be part of a denominator: a digit63+// placeholder or a digit written out.64+func denToken(t ptok) bool {65+ return t.kind == ptDigit || t.kind == ptLit && len(t.s) == 1 && t.s[0] >= '0' && t.s[0] <= '9'66+}67+68+// hasWhole reports whether the section shows a whole number before the69+// fraction.70+func (l fracLayout) hasWhole() bool { return hasKind(l.whole, ptDigit) }71+72+// formatFraction renders |v| with a fraction section as shown in loc;73+// neg adds a minus sign.74+func formatFraction(v float64, neg bool, l fracLayout, loc *locale.Locale) string {75+ whole := math.Floor(v)76+ n, d := l.approximate(v - whole)77+ if n == d {78+ whole, n = whole+1, 079+ }80+ if !l.hasWhole() {81+ n += int(whole) * d82+ whole = 083+ }84+ var b strings.Builder85+ if neg && (whole != 0 || n != 0) {86+ b.WriteByte('-')87+ }88+ w := ""89+ if l.hasWhole() {90+ w = formatNumber(whole, false, l.whole, loc)91+ } else {92+ w = literals(l.whole)93+ }94+ switch {95+ case n == 0 && l.hasWhole():96+ if strings.TrimSpace(w) == "" {97+ w = "0"98+ }99+ b.WriteString(w)100+ case n == 0 && !l.hasWhole() && l.fixed == 0:101+ b.WriteString(w + "0/1")102+ default:103+ b.WriteString(w)104+ if strings.TrimSpace(w) != "" {105+ b.WriteString(literals(l.gap))106+ }107+ b.WriteString(padDigits(strconv.Itoa(n), l.num, true))108+ b.WriteByte('/')109+ if l.fixed > 0 {110+ b.WriteString(strconv.Itoa(l.fixed))111+ } else {112+ b.WriteString(padDigits(strconv.Itoa(d), l.den, false))113+ }114+ }115+ b.WriteString(literals(l.rest))116+ return b.String()117+}118+119+// approximate is the fraction n/d nearest f (0 <= f < 1): with the120+// pattern's denominator, or the nearest with a denominator of as many121+// digits as its placeholders, the smallest such denominator on a tie.122+func (l fracLayout) approximate(f float64) (n, d int) {123+ if l.fixed > 0 {124+ return int(math.Round(f * float64(l.fixed))), l.fixed125+ }126+ maxD := int(math.Pow10(min(len(l.den), 5))) - 1127+ n, d = int(math.Round(f)), 1128+ best := math.Abs(f - float64(n))129+ for q := 2; q <= maxD && best > 0; q++ {130+ p := math.Round(f * float64(q))131+ if e := math.Abs(f - p/float64(q)); e < best {132+ n, d, best = int(p), q, e133+ }134+ }135+ return n, d136+}137+138+// padDigits fills the placeholders phs with digits: those it has room139+// for are padded, a ? with a space and a 0 with a zero, on the left of a140+// numerator (right) or the right of a denominator (left).141+func padDigits(digits string, phs []ptok, right bool) string {142+ var pad strings.Builder143+ for i := len(digits); i < len(phs); i++ {144+ switch phs[i].s {145+ case "?":146+ pad.WriteByte(' ')147+ case "0":148+ pad.WriteByte('0')149+ }150+ }151+ if right {152+ return pad.String() + digits153+ }154+ return digits + pad.String()155+}156+157+// literals is the text of the literal tokens among toks.158+func literals(toks []ptok) string {159+ var b strings.Builder160+ for _, t := range toks {161+ if t.kind == ptLit {162+ b.WriteString(t.s)163+ }164+ }165+ return b.String()166+}@@ -41,6 +41,24 @@ func TestFormat(t *testing.T) {
4141 {12.5, "General", "12.5"},4242 {5551234, "000-0000", "555-1234"},4343 {1.5, "General.00", "1.5.00"}, // placeholders after General get zeros44+ // Fractions, as Sheets shows them.45+ {0.75, "# ?/?", "3/4"},46+ {1.75, "# ?/?", "1 3/4"},47+ {-1.75, "# ?/?", "-1 3/4"},48+ {0.75, "0 ?/?", "0 3/4"},49+ {2, "# ?/?", "2"},50+ {0, "# ?/?", "0"},51+ {1.99, "# ?/?", "2"},52+ {0.3333, "# ?/?", "1/3"},53+ {3.14159, "# ??/??", "3 14/99"},54+ {1.5, "# ??/??", "1 1/2 "},55+ {0.1234, "# ??/??", "10/81"},56+ {1.3, "# ?/4", "1 1/4"},57+ {0.5, "# ?/8", "4/8"},58+ {0.37, "# ??/100", "37/100"},59+ {1.75, "?/?", "7/4"},60+ {1.5, `# ?/? "cups"`, "1 1/2 cups"},61+ {1234.5, "#,##0 ?/?", "1,234 1/2"},4462 // Dates and times: 46291 is 2026-09-26, a Saturday.4563 {46291, "m/d/yyyy", "9/26/2026"},4664 {46291, "yyyy-mm-dd", "2026-09-26"},@@ -122,3 +140,43 @@ func TestFormatLCID(t *testing.T) {
122140 }123141 }124142 }143+144+func TestRound(t *testing.T) {145+ tests := []struct {146+ x float64147+ places int148+ mode Rounding149+ want float64150+ }{151+ {1.005, 2, HalfUp, 1.01},152+ {2.675, 2, HalfUp, 2.68},153+ {-2.5, 0, HalfUp, -3},154+ {1234.5, -2, HalfUp, 1200},155+ {-1.005, 2, HalfUp, -1.01},156+ {1.5, 14, HalfUp, 1.5}, // exact inputs stay exact157+ {0.1 + 0.2, 15, HalfUp, 0.3},158+ {1.23456789012345, 14, HalfUp, 1.23456789012345}, // Sheets says ...346159+ {1.23456789012345, 15, HalfUp, 1.23456789012345},160+ {2.301, 1, Up, 2.4},161+ {-2.39, 1, Down, -2.3},162+ }163+ for _, tt := range tests {164+ if got := Round(tt.x, tt.places, tt.mode); got != tt.want {165+ t.Errorf("Round(%v, %d, %v) = %v, want %v", tt.x, tt.places, tt.mode, got, tt.want)166+ }167+ }168+}169+170+func TestEntered(t *testing.T) {171+ for in, want := range map[string]string{172+ "123456789012345678": "123456789012345000",173+ "123456789012345": "123456789012345",174+ "0.00012345678901234567": "0.00012345678901234500",175+ "1.2345678901234567e10": "1.2345678901234500e10",176+ "12.5": "12.5",177+ } {178+ if got := Entered(in); got != want {179+ t.Errorf("Entered(%s) = %s, want %s", in, got, want)180+ }181+ }182+}@@ -65,6 +65,9 @@ func FormatIn(v float64, pat string, loc *locale.Locale) string {
6565 if isDatePattern(toks) {6666 return formatDate(v, toks, patternNames(sec, loc))6767 }68+ if l, ok := layoutFraction(toks); ok {69+ return formatFraction(math.Abs(v), neg, l, loc)70+ }6871 return formatNumber(math.Abs(v), neg, toks, loc)6972 }7073 @@ -0,0 +1,377 @@
1+package sheet2+3+import (4+ "slices"5+6+ "github.com/FelineStateMachine/012/internal/formula"7+)8+9+// Cycles are found from the formulas as written, as Sheets and Excel10+// find them, before a recalculation evaluates anything: a formula is11+// part of a cycle when what it references (cells, ranges, names, tables,12+// regions, other sheets) leads back to it, whether or not evaluating it13+// reads that far: an IF branch not taken, a range read that stops at an14+// error, INDEX reading one cell of its range. Evaluation meeting a cell15+// it is evaluating finds the cycles it walks (evaluate.go), but only16+// those, so what the cells of a cycle it didn't walk showed depended on17+// the order they were evaluated in. Every cell of a cycle is #REF!.18+//19+// Only the formulas to recalculate are looked at: a cycle through one of20+// them runs through formulas that read it, which recalculate too.21+//22+// The graph's nodes are those formulas, each leading to the formulas it23+// references. A range leads to the formulas in it through a segment tree24+// over each column's formulas, whose nodes lead to their halves, so a25+// range costs the logarithm of the formulas in its columns rather than26+// their number, and a column of running totals over computed cells27+// stays linear. Tarjan's algorithm finds the strongly connected28+// components; the formulas of a component with more than one node, or29+// that reference themselves, are a cycle's. The graph's buffers are kept30+// for the next recalculation, up to maxCycleKeep formulas.31+32+// maxCycleKeep is the most formulas whose graph's buffers are kept.33+const maxCycleKeep = 1 << 1634+35+// cycleGraph is the graph of the formulas to recalculate: formulas are36+// nodes 0 to formulas-1, in sheet order and by column and row within a37+// sheet, and segment nodes follow. The edges leaving node v are38+// edges[start[v]:start[v+1]].39+type cycleGraph struct {40+ w *Workbook41+ formulas int3242+ sheets []cycleSheet43+ keys []uint64 // each sheet's formulas' keys, sorted44+ cols []cycleCol // each sheet's columns holding formulas, in order45+ last *cycleSheet46+ start []int3247+ edges []int3248+ tarjan49+}50+51+// cycleSheet is a sheet's formulas in the graph: keys[from:to] (column in52+// the high half, row in the low), which are nodes from to to, and its53+// columns, cols[col0:col1].54+type cycleSheet struct {55+ s *Sheet56+ from, to int3257+ col0, col1 int3258+}59+60+// cycleCol is a column's formulas: keys[from:from+m], and the node of its61+// segment tree's first inner node (the tree has m-1).62+type cycleCol struct {63+ col int64+ from, m int3265+ seg int3266+}67+68+func cycleKey(a Addr) uint64 { return uint64(a.Col)<<32 | uint64(uint32(a.Row)) }69+70+func keyAddr(k uint64) Addr { return Addr{Col: int(k >> 32), Row: int(uint32(k))} }71+72+// findCycles returns the formulas to recalculate, dirty in each sheet's73+// calc, that are part of a cycle, or nil when none is.74+func (w *Workbook) findCycles() map[loc]bool {75+ g := w.cycles76+ if g == nil {77+ g = &cycleGraph{w: w}78+ }79+ var cycle map[loc]bool80+ if g.collect() {81+ g.link()82+ cycle = g.components()83+ }84+ g.reset()85+ w.cycles = nil86+ if cap(g.keys) <= maxCycleKeep {87+ w.cycles = g88+ }89+ return cycle90+}91+92+// reset empties the graph for the next recalculation, keeping its93+// buffers.94+func (g *cycleGraph) reset() {95+ clear(g.sheets)96+ g.sheets, g.keys, g.cols, g.start, g.edges = g.sheets[:0], g.keys[:0], g.cols[:0], g.start[:0], g.edges[:0]97+ g.formulas, g.last = 0, nil98+ g.tarjan = tarjan{index: g.index[:0], low: g.low[:0], on: g.on[:0], stack: g.stack[:0], path: g.path[:0], next: g.next[:0]}99+}100+101+// collect gathers the formulas to recalculate, reporting whether a102+// cycle may run through them: not when there are none, or when each103+// references only cells before it, row by row, as a column of running104+// totals or a chain down a column does, since then no reference leads105+// back.106+func (g *cycleGraph) collect() bool {107+ back := true108+ for _, s := range g.w.sheets {109+ from := len(g.keys)110+ for a, st := range s.calc {111+ if c := s.cells.richAt(a); st == dirty && c != nil && c.expr != nil && !c.derived && !c.spilled && readsCells(c) {112+ g.keys = append(g.keys, cycleKey(a))113+ back = back && readsBefore(c, a)114+ }115+ }116+ if len(g.keys) > from {117+ g.sheets = append(g.sheets, cycleSheet{s: s, from: int32(from), to: int32(len(g.keys))})118+ }119+ }120+ g.formulas = int32(len(g.keys))121+ if back {122+ return false123+ }124+ for _, cs := range g.sheets {125+ slices.Sort(g.keys[cs.from:cs.to])126+ }127+ seg := g.formulas128+ for i := range g.sheets {129+ cs := &g.sheets[i]130+ cs.col0 = int32(len(g.cols))131+ for j := cs.from; j < cs.to; j++ {132+ col := int(g.keys[j] >> 32)133+ if len(g.cols) == int(cs.col0) || g.cols[len(g.cols)-1].col != col {134+ g.cols = append(g.cols, cycleCol{col: col, from: j})135+ }136+ g.cols[len(g.cols)-1].m++137+ }138+ cs.col1 = int32(len(g.cols))139+ for c := cs.col0; c < cs.col1; c++ {140+ g.cols[c].seg = seg141+ seg += max(g.cols[c].m-1, 0)142+ }143+ }144+ g.start = slices.Grow(g.start, int(seg)+1)[:seg+1]145+ return true146+}147+148+// readsBefore reports whether the formula c, at a, references only149+// cells of its own sheet before a, row by row.150+func readsBefore(c *Cell, a Addr) bool {151+ if len(c.xrefs) > 0 || len(c.names) > 0 {152+ return false153+ }154+ for _, r := range c.refs {155+ if !before(r, a) {156+ return false157+ }158+ }159+ for _, r := range c.ranges {160+ if !before(r.To, a) {161+ return false162+ }163+ }164+ return true165+}166+167+// readsCells reports whether the formula c references anything.168+func readsCells(c *Cell) bool {169+ return len(c.refs) > 0 || len(c.ranges) > 0 || len(c.xrefs) > 0 || len(c.names) > 0170+}171+172+// link writes the edges: each formula's to what it references, then173+// each segment node's to its halves.174+func (g *cycleGraph) link() {175+ for _, cs := range g.sheets {176+ for j := cs.from; j < cs.to; j++ {177+ g.start[j] = int32(len(g.edges))178+ g.references(cs.s, keyAddr(g.keys[j]))179+ }180+ }181+ v := g.formulas182+ for _, col := range g.cols {183+ for p := int32(1); p < col.m; p++ {184+ g.start[v] = int32(len(g.edges))185+ g.edges = append(g.edges, col.node(2*p), col.node(2*p+1))186+ v++187+ }188+ }189+ g.start[v] = int32(len(g.edges))190+}191+192+// node is the node at position p of the column's segment tree: an inner193+// node below m, a formula from m on.194+func (col cycleCol) node(p int32) int32 {195+ if p >= col.m {196+ return col.from + p - col.m197+ }198+ return col.seg + p - 1199+}200+201+// references adds the edges from the formula at a on s to the formulas202+// it references. One naming a name, a table or a region is bound first,203+// as evaluating it is, so a table's row (Sales[@Price]) leads to that204+// row alone.205+func (g *cycleGraph) references(s *Sheet, a Addr) {206+ c := s.cells.richAt(a)207+ if len(c.names) > 0 {208+ formula.WalkRefs(s.bound(a, c),209+ func(sheet string, r Addr) { g.cell(g.w.resolve(s, sheet), r) },210+ func(sheet string, r Rect) { g.rect(g.w.resolve(s, sheet), r) })211+ return212+ }213+ for _, r := range c.refs {214+ g.cell(s, r)215+ }216+ for _, r := range c.ranges {217+ g.rect(s, r)218+ }219+ for _, x := range c.xrefs {220+ g.rect(g.w.byKey[x.key], x.r)221+ }222+}223+224+// sheet is t's formulas in the graph, if it has any.225+func (g *cycleGraph) sheet(t *Sheet) (*cycleSheet, bool) {226+ if g.last != nil && g.last.s == t {227+ return g.last, true228+ }229+ for i := range g.sheets {230+ if g.sheets[i].s == t {231+ g.last = &g.sheets[i]232+ return g.last, true233+ }234+ }235+ return nil, false236+}237+238+// cell adds an edge to the formula at a on t, if it is one.239+func (g *cycleGraph) cell(t *Sheet, a Addr) {240+ if cs, ok := g.sheet(t); ok {241+ if i, found := slices.BinarySearch(g.keys[cs.from:cs.to], cycleKey(a)); found {242+ g.edges = append(g.edges, cs.from+int32(i))243+ }244+ }245+}246+247+// rect adds edges to the formulas of r on t: in each of its columns, to248+// the fewest segment nodes that cover them.249+func (g *cycleGraph) rect(t *Sheet, r Rect) {250+ cs, ok := g.sheet(t)251+ switch {252+ case !ok:253+ return254+ case r.From == r.To:255+ g.cell(t, r.From)256+ return257+ }258+ cols := g.cols[cs.col0:cs.col1]259+ c0, _ := slices.BinarySearchFunc(cols, r.From.Col, func(c cycleCol, col int) int { return c.col - col })260+ for _, col := range cols[c0:] {261+ if col.col > r.To.Col {262+ break263+ }264+ keys := g.keys[col.from : col.from+col.m]265+ lo, _ := slices.BinarySearch(keys, cycleKey(Addr{Col: col.col, Row: r.From.Row}))266+ hi, _ := slices.BinarySearch(keys, cycleKey(Addr{Col: col.col, Row: r.To.Row})+1)267+ for lp, hp := int32(lo)+col.m, int32(hi)+col.m; lp < hp; lp, hp = lp/2, hp/2 {268+ if lp&1 == 1 {269+ g.edges = append(g.edges, col.node(lp))270+ lp++271+ }272+ if hp&1 == 1 {273+ hp--274+ g.edges = append(g.edges, col.node(hp))275+ }276+ }277+ }278+}279+280+// components runs Tarjan's algorithm from each formula, without281+// recursion, and returns the formulas in cycles.282+func (g *cycleGraph) components() map[loc]bool {283+ n := len(g.start) - 1284+ g.index = slices.Grow(g.index, n)[:n]285+ g.low = slices.Grow(g.low, n)[:n]286+ g.on = slices.Grow(g.on, n)[:n]287+ clear(g.index)288+ for v := range g.formulas {289+ if g.index[v] == 0 {290+ g.visit(v)291+ }292+ }293+ return g.cycle294+}295+296+// tarjan is the state of Tarjan's algorithm: each node's index, from 1297+// (0 while unvisited), and lowest index reached, the nodes on the stack,298+// the path being followed, with the next edge of each, and the formulas299+// found in cycles.300+type tarjan struct {301+ index, low []int32302+ on []bool303+ stack, path []int32304+ next []int32305+ count int32306+ cycle map[loc]bool307+}308+309+func (g *cycleGraph) push(v int32) {310+ g.count++311+ g.index[v], g.low[v] = g.count, g.count312+ g.stack = append(g.stack, v)313+ g.on[v] = true314+ g.path = append(g.path, v)315+ g.next = append(g.next, g.start[v])316+}317+318+func (g *cycleGraph) visit(root int32) {319+ g.push(root)320+ for len(g.path) > 0 {321+ top := len(g.path) - 1322+ v := g.path[top]323+ if e := g.next[top]; e < g.start[v+1] {324+ g.next[top]++325+ switch u := g.edges[e]; {326+ case g.index[u] == 0:327+ g.push(u)328+ case g.on[u]:329+ g.low[v] = min(g.low[v], g.index[u])330+ }331+ continue332+ }333+ g.path, g.next = g.path[:top], g.next[:top]334+ if top > 0 {335+ p := g.path[top-1]336+ g.low[p] = min(g.low[p], g.low[v])337+ }338+ if g.low[v] == g.index[v] {339+ g.pop(v)340+ }341+ }342+}343+344+// pop takes the component rooted at v off the stack, keeping its345+// formulas when it is a cycle.346+func (g *cycleGraph) pop(v int32) {347+ i := len(g.stack) - 1348+ for g.stack[i] != v {349+ i--350+ }351+ comp := g.stack[i:]352+ g.stack = g.stack[:i]353+ for _, u := range comp {354+ g.on[u] = false355+ }356+ if len(comp) == 1 && !slices.Contains(g.edges[g.start[v]:g.start[v+1]], v) {357+ return358+ }359+ if g.cycle == nil {360+ g.cycle = map[loc]bool{}361+ }362+ for _, u := range comp {363+ if u < g.formulas {364+ g.cycle[g.loc(u)] = true365+ }366+ }367+}368+369+// loc is the cell of the formula node v.370+func (g *cycleGraph) loc(v int32) loc {371+ for _, cs := range g.sheets {372+ if v < cs.to {373+ return loc{cs.s, keyAddr(g.keys[v])}374+ }375+ }376+ panic("sheet: no formula node")377+}@@ -0,0 +1,116 @@
1+package sheet2+3+import (4+ "maps"5+ "strings"6+ "testing"7+)8+9+// Cycles evaluation doesn't walk are cycles all the same, #REF! in every10+// cell, typed in any order or opened (cyclefind.go).11+func TestCyclesFoundAsWritten(t *testing.T) {12+ for _, tc := range []cycleCase{13+ {"an IF branch not taken", map[string]string{"A1": "=IF(FALSE,B1,1)", "B1": "=A1+1"}, []string{"A1", "B1"}, nil},14+ {"a range read cut short by an error", map[string]string{"A1": "=SUM(B1:B3)", "B1": "=1/0", "B3": "=A1"}, []string{"A1", "B3"}, map[string]string{"B1": "#DIV/0!"}},15+ {"INDEX reading one cell", map[string]string{"A1": "=INDEX(A2:A5,1)", "A2": "7", "A4": "=A1"}, []string{"A1", "A4"}, nil},16+ {"itself, behind a branch", map[string]string{"C3": "=IF(TRUE,1,C3)", "D3": "=IFERROR(C3,5)"}, []string{"C3"}, map[string]string{"D3": "5"}},17+ {"another sheet", map[string]string{"A1": "=IF(FALSE,Data!A1,1)", "Data!A1": "=Sheet1!A1"}, []string{"A1", "Data!A1"}, nil},18+ {"a name", map[string]string{"A1": "=IF(FALSE,SUM(Near),1)", "B2": "=A1"}, []string{"A1", "B2"}, nil},19+ } {20+ orders := [][]string{nil, nil}21+ for a := range tc.cells {22+ orders[0] = append(orders[0], a)23+ orders[1] = append([]string{a}, orders[1]...)24+ }25+ for _, order := range orders {26+ tc.check(t, order)27+ }28+ }29+}30+31+// cycleCase is cells to type, those of the cycle they make, and what32+// others show.33+type cycleCase struct {34+ name string35+ cells map[string]string36+ cycle []string37+ others map[string]string38+}39+40+// check types the cells in order, on a workbook with a sheet Data and a41+// name Near for A1:B2, and checks what they show, and show once opened.42+func (tc cycleCase) check(t *testing.T, order []string) {43+ t.Helper()44+ s := New()45+ w := s.Book()46+ w.AddSheet("Data", 1)47+ w.DefineName("Near", s, rect("A1:B2"))48+ for _, a := range order {49+ setIn(t, w, a, tc.cells[a])50+ }51+ reopened := roundTrip(t, s).Book()52+ if !reopened.Circular {53+ t.Errorf("%s: not circular", tc.name)54+ }55+ want := map[string]string{}56+ maps.Copy(want, tc.others)57+ for _, a := range tc.cycle {58+ want[a] = "#REF!"59+ }60+ for _, sh := range []*Workbook{w, reopened} {61+ for a, v := range want {62+ if got := show(t, sh, qualified(a)); got != v {63+ t.Errorf("%s, typed %v: %s = %s, want %s", tc.name, order, a, got, v)64+ }65+ }66+ }67+}68+69+// setIn types in into the cell a names, on Sheet1 unless it names a70+// sheet.71+func setIn(t *testing.T, w *Workbook, a, in string) {72+ t.Helper()73+ s := w.Sheet(0)74+ if sheet, cell, ok := strings.Cut(a, "!"); ok {75+ s, a = w.Lookup(sheet), cell76+ }77+ if err := s.Set(at(a), in); err != nil {78+ t.Fatal(err)79+ }80+}81+82+func qualified(a string) string {83+ if strings.Contains(a, "!") {84+ return a85+ }86+ return "Sheet1!" + a87+}88+89+// Formulas reading each other without a cycle aren't taken for one:90+// a table's formulas reading their own row, running totals over a91+// column of formulas, and a total above the column it adds up.92+func TestNoCycleWithout(t *testing.T) {93+ s := salesSheet(t)94+ s.Set(at("D1"), "Total")95+ w := s.Book()96+ w.ResizeTable("Sales", rect("A1:D4"))97+ for row := 2; row <= 4; row++ {98+ s.Set(Addr{Col: 3, Row: row - 1}, "=Sales[@Units]*Sales[@Amount]")99+ }100+ for row := 1; row <= 30; row++ {101+ s.Set(Addr{Col: 6, Row: row}, "=F"+itoa(row+1)+"*2")102+ s.Set(Addr{Col: 7, Row: row}, "=SUM(G$2:G"+itoa(row+1)+")")103+ }104+ s.Set(at("G1"), "=SUM(G2:G31)")105+ for _, sh := range []*Workbook{w, roundTrip(t, s).Book()} {106+ if sh.Circular {107+ t.Error("circular")108+ }109+ if got := show(t, sh, "Sheet1!D4"); got != "150" {110+ t.Errorf("D4 = %s", got)111+ }112+ if got := show(t, sh, "Sheet1!H31"); got != "0" {113+ t.Errorf("H31 = %s", got)114+ }115+ }116+}@@ -36,12 +36,13 @@ func TestDecimalArithmetic(t *testing.T) {
3636 binary, decim string3737 }{3838 {"sum of two", "=0.1+0.2", nil, "0.30000000000000004", "0.3"},39- {"comparison", "=0.1+0.2=0.3", nil, "FALSE", "TRUE"},39+ {"comparison", "=0.1+0.2=0.3", nil, "TRUE", "TRUE"}, // at the 15 digits shown40+ {"comparison with zero", "=0.1+0.2-0.3=0", nil, "FALSE", "TRUE"},4041 {"residue", "=1.1-1-0.1", nil, "8.326672684688674E-17", "0"},4142 {"product", "=0.07*3", nil, "0.21000000000000002", "0.21"},4243 {"cents to whole", "=INT(A1*100)", []string{"$4.35"}, "434", "435"},4344 {"currency sum", "=SUM(A1:A10)", tenCents, "0.9999999999999999", "1"},44- {"currency sum equals", "=SUM(A1:A10)=1", tenCents, "FALSE", "TRUE"},45+ {"currency sum equals", "=SUM(A1:A10)-1=0", tenCents, "FALSE", "TRUE"},4546 {"average", "=AVERAGE(0.1, 0.2)", nil, "0.15000000000000002", "0.15"},4647 {"division", "=0.3/0.1", nil, "2.9999999999999996", "3"},4748 {"postfix percent", "=1.1%", nil, "0.011000000000000001", "0.011"},@@ -77,7 +78,7 @@ func TestDecimalArithmetic(t *testing.T) {
7778 {"product of text", `=PRODUCT("a")`, nil, "#VALUE!", "#VALUE!"},7879 {"huge product falls back", "=PRODUCT(1E308, 10)", nil, "#NUM!", "#NUM!"},7980 {"sumif", `=SUMIF(A1:A10, ">0")`, tenCents, "0.9999999999999999", "1"},80- {"sumif equals", `=SUMIF(A1:A10, ">0")=1`, tenCents, "FALSE", "TRUE"},81+ {"sumif equals", `=SUMIF(A1:A10, ">0")-1=0`, tenCents, "FALSE", "TRUE"},8182 {"sumif with sum range", `=SUMIF(A1:A3, "<>x", A1:A3)`, []string{"0.1", "x", "0.2"}, "0.30000000000000004", "0.3"},8283 {"sumif error", `=SUMIF(A1:A2, ">0")`, []string{"1", "=1/0"}, "1", "1"},8384 {"sumifs", `=SUMIFS(A1:A10, A1:A10, "0.1")`, tenCents, "0.9999999999999999", "1"},@@ -110,7 +111,7 @@ func TestDecimalArithmetic(t *testing.T) {
110111 // Turning the setting on and off recalculates, is one undo step, and111112 // undo and redo recalculate too.112113 func TestDecimalUndo(t *testing.T) {113- s := decimalSheet(t, false, "=0.1+0.2=0.3")114+ s := decimalSheet(t, false, "=0.1+0.2-0.3=0")114115 s.ClearHistory()115116 check := func(when string, want bool) {116117 t.Helper()@@ -51,8 +51,8 @@ type evaluator struct {
5151 // the sheets, keyed by address as on one sheet, and each sheet's lookup5252 // is made once, so evaluating a formula allocates nothing for sheets.5353 func (w *Workbook) evaluate() {54- w.Circular = false55- e := &evaluator{w: w}54+ e := &evaluator{w: w, cycle: w.findCycles()}55+ w.Circular = e.cycle != nil5656 // Running aggregates shared by the recalculation (rangememo.go). A5757 // cell put off abandons an extension part way: its checkpoints stop5858 // at the last row it finished, and the next read goes on from there.@@ -85,7 +85,7 @@ func TestArrayFunctions(t *testing.T) {
8585 {"=SEQUENCE(3)", []string{"1", "2", "3"}},8686 {"=SEQUENCE(2, 3)", []string{"1|2|3", "4|5|6"}},8787 {"=SEQUENCE(2, 2, 10, -5)", []string{"10|5", "0|-5"}},88- {"=SEQUENCE(0)", []string{"#VALUE!"}},88+ {"=SEQUENCE(0)", []string{"#NUM!"}}, // as Sheets8989 {"=FILTER(D1:D5, C1:C5>3)", []string{"date", "Apple"}},9090 {`=FILTER(C1:D5, B1:B5="north", C1:C5>1)`, []string{"3|Cherry", "5|Apple"}},9191 {"=FILTER(C1:E1, {TRUE,FALSE,TRUE})", []string{"1|1.5"}},@@ -25,8 +25,8 @@ func TestLookupFunctions(t *testing.T) {
2525 {`=MATCH(2, C1:D2, 0)`, ErrNA}, // not a single row or column2626 {"=INDEX(D1:E3, 2, 2)", num(2.5)},2727 {"=INDEX(D1:D3, 3)", txt("Cherry")},28- {"=INDEX(C1:E1, 2)", txt("Apple")}, // a single row counts across29- {"=INDEX(D1:E3, 4, 1)", ErrRef},28+ {"=INDEX(C1:E1, 2)", txt("Apple")}, // a single row counts across29+ {"=INDEX(D1:E3, 4, 1)", ErrNum}, // as Sheets; Excel says #REF!3030 {"=INDEX(D1:E3, 2)", txt("Banana")}, // a whole row, spilled3131 {`=XLOOKUP("Cherry", D1:D3, E1:E3)`, num(3.5)},3232 {`=XLOOKUP("kiwi", D1:D3, E1:E3)`, ErrNA},@@ -39,7 +39,8 @@ func TestLookupFunctions(t *testing.T) {
3939 {`=XLOOKUP(1, C1:C3, E1:E2)`, ErrValue},4040 {`=CHOOSE(2, "a", "b", "c")`, txt("b")},4141 {`=CHOOSE(2.9, "a", "b", "c")`, txt("b")},42- {`=CHOOSE(4, "a", "b", "c")`, ErrValue},42+ {`=CHOOSE(4, "a", "b", "c")`, ErrNum},43+ {`=CHOOSE(0, "a")`, ErrNum},4344 {`=CHOOSE(1, "a", 1/0)`, txt("a")}, // only the chosen value is evaluated4445 {"=ROWS(A1:C7)", num(7)},4546 {"=COLUMNS(A1:C7)", num(3)},@@ -24,7 +24,12 @@ func TestMathFunctions(t *testing.T) {
2424 {"=PRODUCT(2, \"3\")", num(6)},2525 {"=POWER(2, 10)", num(1024)},2626 {"=POWER(0, -1)", ErrDiv0},27- {"=POWER(-8, 1/3)", ErrNum},27+ {"=POWER(-8, 1/3)", num(-2)}, // an odd root, as Sheets28+ {"=(-8)^(1/3)", num(-2)},29+ {"=POWER(-32, 0.2)", num(-2)},30+ {"=POWER(-8, 0.5)", ErrNum},31+ {"=POWER(-8, 2/3)", ErrNum},32+ {"=POWER(-8, 2)", num(64)},2833 {"=ROUND(1.005, 2)", num(1.01)},2934 {"=ROUND(-2.5)", num(-3)},3035 {"=ROUND(1234.5, -2)", num(1200)},@@ -0,0 +1,71 @@
1+package sheet2+3+import "testing"4+5+// Where Sheets and Excel differ, 012 follows Sheets (the Sheets corpus in6+// internal/fileio checks these against Sheets itself).7+func TestSheetsSemantics(t *testing.T) {8+ s := fixture(t)9+ for a, in := range map[string]string{"J1": "'3", "J2": "=1/0", "J3": "FALSE", "J4": "=NA()"} {10+ if err := s.Set(at(a), in); err != nil {11+ t.Fatal(err)12+ }13+ }14+ checkFormulas(t, s, []fnCase{15+ // Text.16+ {`=UPPER("straße")`, txt("STRASSE")},17+ {`=UPPER("dž")`, txt("DŽ")},18+ {`=TEXT(TRUE, "0")`, txt("TRUE")},19+ {`=TEXT(FALSE, "0.00")`, txt("FALSE")},20+ {`=TEXT(0.75, "# ?/?")`, txt("3/4")},21+ {`=TEXT(2.5, "# ?/?")`, txt("2 1/2")},22+ // Numbers compare at the 15 digits a cell shows.23+ {"=0.1+0.2=0.3", boolean(true)},24+ {"=(1-0.9)=0.1", boolean(true)},25+ {"=SUM(0.1, 0.2, 0.3)=0.6", boolean(true)},26+ {"=0.1+0.2<0.3", boolean(false)},27+ {"=0.1+0.2>=0.3", boolean(true)},28+ {"=0.1+0.2-0.3=0", boolean(false)},29+ {"=1.00000000000001=1", boolean(false)},30+ {`=COUNTIF(E1:E3, 0.1+0.2+1.2)`, num(1)},31+ {"=123456789012345678", num(123456789012345000)},32+ {"=123456789012345678=123456789012345000", boolean(true)},33+ // ROUND is half away from zero on the shortest decimal, as in34+ // Sheets and Excel; exact inputs stay exact.35+ {"=ROUND(2.675, 2)=2.68", boolean(true)},36+ {"=ROUND(-1.005, 2)=-1.01", boolean(true)},37+ {"=ROUND(1.5, 14)-1.5=0", boolean(true)},38+ {"=ROUND(0.1+0.2, 15)-0.3=0", boolean(true)},39+ {"=ROUND(-2.5)", num(-3)},40+ // Errors and conditions.41+ {"=IFERROR(NA())", Value{}},42+ {`=ISBLANK(IFERROR(NA()))`, boolean(true)},43+ {`=IF("TRUE", 1, 2)`, num(1)},44+ {`=IF("false", 1, 2)`, num(2)},45+ {`=IF("", 1, 2)`, num(2)},46+ {`=IF("abc", 1, 2)`, ErrValue},47+ {`=NOT("")`, boolean(true)},48+ {`=NOT("TRUE")`, boolean(false)},49+ {`=AND("TRUE", 1)`, boolean(true)},50+ {"=COUNT(J1:J4)", num(0)},51+ {"=COUNTA(J1:J4)", num(4)},52+ {"=COUNT(1/0, 2)", num(1)},53+ {"=SUM(J1:J4)", ErrDiv0},54+ {"=COUNTIF(J1:J4, 3)", num(1)},55+ {`=COUNTIF(J1:J4, "<>3")`, num(3)},56+ {`=COUNTIF(J1:J4, ">2")`, num(0)},57+ // Positions out of range, and whole rows and columns.58+ {`=CHOOSE(0, "a")`, ErrNum},59+ {"=INDEX(A1:A3, 5)", ErrNum},60+ {"=ROWS(INDEX(A1:C3, 0, 1))", num(3)},61+ {"=COLUMNS(INDEX(A1:C3, 2, 0))", num(3)},62+ {"=SUM(INDEX(C1:E3, 0, 1))", num(6)},63+ {"=ROWS(SEQUENCE(0))", ErrNum},64+ {"=ROWS(1/0)", ErrDiv0},65+ // Arrays and names.66+ {"=SUMPRODUCT(C1:C3>1)", num(2)},67+ {"=SUMPRODUCT(C1:C3>1, C1:C3)", num(5)},68+ {"=LET(x, 1/0, 5)", num(5)},69+ {"=LET(x, 1/0, x+1)", ErrDiv0},70+ })71+}@@ -16,7 +16,7 @@ func TestTextFunctions(t *testing.T) {
1616 {`=RIGHT("hello", 3)`, txt("llo")},1717 {`=MID("spreadsheet", 7, 5)`, txt("sheet")},1818 {`=MID("abc", 5, 2)`, txt("")},19- {`=MID("abc", 0, 2)`, ErrValue},19+ {`=MID("abc", 0, 2)`, ErrNum}, // as Sheets; Excel says #VALUE!2020 {`=LEN("héllo")`, num(5)},2121 {"=LEN(A1)", num(2)},2222 {`=UPPER("abc")`, txt("ABC")},@@ -183,11 +183,8 @@ func (s *Sheet) Merge(r Rect, k MergeKind) error {
183183 s.place(a, c.leftover())184184 }185185 }186- next := slices.DeleteFunc(slices.Clone(s.view.merges), func(m Rect) bool {187- _, overlaps := intersectRect(m, r)188- return overlaps189- })190- s.setMerges(append(next, areas...), r)186+ next, span := s.mergesOutside(r)187+ s.setMerges(append(next, areas...), span)191188 })192189 return nil193190 }@@ -211,18 +208,27 @@ func (s *Sheet) MergeAcross(rows bool, n int) (Rect, bool) {
211208 // Unmerge splits the merges overlapping r back into cells, as one undo212209 // step, and returns how many there were.213210 func (s *Sheet) Unmerge(r Rect) int {214- n := 0211+ next, span := s.mergesOutside(r)212+ n := len(s.view.merges) - len(next)213+ if n > 0 {214+ s.change("unmerge "+r.String(), r, func() { s.setMerges(next, span) })215+ }216+ return n217+}218+219+// mergesOutside returns the merges that don't overlap r, and the range220+// holding r and those that do: where arrays blocked by them may spill221+// once they go.222+func (s *Sheet) mergesOutside(r Rect) ([]Rect, Rect) {223+ span := r215224 next := slices.DeleteFunc(slices.Clone(s.view.merges), func(m Rect) bool {216225 _, overlaps := intersectRect(m, r)217226 if overlaps {218- n++227+ span = union(span, m)219228 }220229 return overlaps221230 })222- if n > 0 {223- s.change("unmerge "+r.String(), r, func() { s.setMerges(next, r) })224- }225- return n231+ return next, span226232 }227233 228234 // setMerges replaces the merges, recording them for undo, and has the@@ -52,3 +52,20 @@ func TestDeletedMergeFreesArray(t *testing.T) {
5252 t.Errorf("B18 once the merge went = %v", v)5353 }5454 }55+56+// Unmerging frees the arrays blocked by the merges it takes away, where57+// they reach past the range unmerged.58+func TestUnmergeFreesArrayPastTheRange(t *testing.T) {59+ s := New()60+ s.Merge(NewRect(at("H11"), at("H13")), MergeVertically)61+ s.Set(at("H11"), "=SEQUENCE(2)")62+ if v := s.Value(at("H11")); v != ErrRef {63+ t.Fatalf("H11 over a merge = %v", v)64+ }65+ if n := s.Unmerge(NewRect(at("G13"), at("H15"))); n != 1 {66+ t.Fatalf("unmerged %d", n)67+ }68+ if v := s.Value(at("H12")); v != num(2) {69+ t.Errorf("H12 once the merge went = %v", v)70+ }71+}@@ -300,8 +300,7 @@ func (r *randBook) editSheets(e *edits) string {
300300 return fmt.Sprintf("rename %s (%v)", s.name, err)301301 }302302 rg := e.rect()303- r.wb.DeleteName("Total")304- err := r.wb.DefineName("Total", s, rg)303+ err := r.wb.EditName("Total", "Total", s, rg)305304 return fmt.Sprintf("Total = %s!%s (%v)", s.name, rg, err)306305 }307306 @@ -44,24 +44,10 @@ func TestRandomEdits(t *testing.T) {
4444 for i := range data {4545 data[i] = byte(rng.Uint32())4646 }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- })47+ t.Run(fmt.Sprint(seed), func(t *testing.T) { checkRandomEdits(t, data, *randSteps) })5348 }5449 }5550 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-6551 // FuzzRandomEdits reads the edits from the fuzzer's bytes: go test6652 // -run '^$' -fuzz FuzzRandomEdits ./internal/sheet.6753 func FuzzRandomEdits(f *testing.F) {@@ -132,7 +118,7 @@ func randomEdits(data []byte, steps int) (string, []string) {
132118 break133119 }134120 log = append(log, r.edit(e))135- r.feedStale()121+ feedStale(r.wb, r.feeds)136122 }137123 end, endVals := r.save(), bookValues(r.wb)138124 if d := r.reopenDiff(end, endVals); d != "" {@@ -144,6 +130,7 @@ func randomEdits(data []byte, steps int) (string, []string) {
144130 undone++145131 }146132 feedAll(r.wb, startFeeds)133+ feedStale(r.wb, startFeeds)147134 if got := r.save(); !bytes.Equal(got, start) {148135 return fmt.Sprintf("undoing %d steps saves differently:\n%s", undone, lineDiff(string(start), string(got))), log149136 }@@ -154,6 +141,7 @@ func randomEdits(data []byte, steps int) (string, []string) {
154141 r.wb.Redo()155142 }156143 feedAll(r.wb, r.feeds)144+ feedStale(r.wb, r.feeds)157145 if got := r.save(); !bytes.Equal(got, end) {158146 return "redoing saves differently:\n" + lineDiff(string(end), string(got)), log159147 }@@ -161,7 +149,7 @@ func randomEdits(data []byte, steps int) (string, []string) {
161149 return "redoing: " + d, log162150 }163151 r.wb.RecalcAll()164- r.feedStale()152+ feedStale(r.wb, r.feeds)165153 if d := diffValues(endVals, bookValues(r.wb)); d != "" {166154 return "recalculating everything: " + d, log167155 }@@ -194,6 +182,7 @@ func newRandBook(e *edits) *randBook {
194182 wb.DefineName("Total", s, NewRect(Addr{}, Addr{Col: 1, Row: 9}))195183 nb.SetNotebookCells("add cells", []notebook.Cell{{Kind: notebook.Note, Source: "# Files"}, {Source: "r1 = ls"}})196184 r.addOutput(s2, "r1", e)185+ feedStale(wb, r.feeds)197186 wb.ClearHistory()198187 return r199188 }@@ -232,11 +221,19 @@ func (r *randBook) feed(name string, e *edits) {
232221 }233222 234223 // 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)224+// undo, no longer blocked) their source's rows, as the UI does after225+// every change, until none is: a region sent may free cells another226+// was blocked by, or take cells another gives way with.227+func feedStale(wb *Workbook, feeds map[string]LiveOp) {228+ for range 8 {229+ stale := wb.StaleOutputs()230+ if len(stale) == 0 {231+ return232+ }233+ for _, name := range stale {234+ if op, ok := feeds[name]; ok {235+ sendRows(wb, op)236+ }240237 }241238 }242239 }@@ -250,8 +247,9 @@ func feedAll(wb *Workbook, feeds map[string]LiveOp) {
250247 251248 // sendRows sends a region its rows. A table widens its columns to its252249 // 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.250+// are, so undoing the region leaves them wide, as intended251+// (docs/files/following.md); the edits change widths themselves252+// instead.255253 func sendRows(wb *Workbook, op LiveOp) {256254 if s, r, ok := wb.Region(op.Region); ok {257255 s.meta(nameKey(r.Name)).fitted = true@@ -282,6 +280,7 @@ func (r *randBook) reopenDiff(file []byte, vals map[string]cellValue) string {
282280 return "saves differently:\n" + lineDiff(string(file), string(again))283281 }284282 feedAll(wb, r.feeds)283+ feedStale(wb, r.feeds)285284 return diffValues(vals, bookValues(wb))286285 }287286 @@ -130,11 +130,13 @@ func (w *Workbook) readerLookup() readerLookup {
130130 return r131131 }132132 133-// namedUsers are the formulas that use a named range, with its cells.133+// namedUsers are the formulas that use a named range, with its cells,134+// and whether it is a region's table.134135 type namedUsers struct {135- s *Sheet136- r Rect137- users map[loc]struct{}136+ s *Sheet137+ r Rect138+ users map[loc]struct{}139+ region bool138140 }139141 140142 // namedInUse returns the defined named ranges, tables and regions that@@ -143,7 +145,7 @@ func (w *Workbook) namedInUse() []namedUsers {
143145 var named []namedUsers144146 for k, users := range w.nameUsers {145147 if nm, ok := w.names[k]; ok && !nm.Gone() {146- named = append(named, namedUsers{nm.Sheet, nm.Range, users})148+ named = append(named, namedUsers{s: nm.Sheet, r: nm.Range, users: users})147149 }148150 }149151 named = append(named, w.tablesInUse()...)@@ -65,6 +65,7 @@ type regionMeta struct {
6565 written Rect6666 has bool // written holds something6767 why string // why the table isn't shown, or ""68+ placed bool // contents were placed in a blank cell of its table6869 need Rect // the cells the table needs while it isn't shown6970 // rows and cols are the table's size as written, header included.7071 rows, cols int@@ -281,8 +282,8 @@ func (s *Sheet) emptyUnder(img *image) []loc {
281282 if c == nil || c.Blank() || c.Spilled() {282283 return283284 }284- if _, me, ok := s.ownerOf(a); ok {285- changed = append(changed, s.emptyRegion(me)...)285+ if r, me, ok := s.ownerOf(a); ok {286+ changed = append(changed, s.emptyRegion(r, me)...)286287 }287288 })288289 return changed@@ -297,7 +298,7 @@ func (s *Sheet) emptyMoved(st regionState) []loc {
297298 for _, r := range s.regions.list {298299 i := slices.IndexFunc(st.list, func(x Region) bool { return nameKey(x.Name) == nameKey(r.Name) })299300 if me := s.regionMeta[nameKey(r.Name)]; me != nil && (i < 0 || st.list[i] != r) {300- changed = append(changed, s.emptyRegion(me)...)301+ changed = append(changed, s.emptyRegion(r, me)...)301302 }302303 }303304 return changed@@ -0,0 +1,134 @@
1+package sheet2+3+// Regions in each other's way (see region.go). What a region shows4+// can't depend on the order their rows arrived in, or a file would open5+// differently from how it was saved, its regions sent their rows in6+// another order. So, as for arrays (spill.go):7+//8+// - a region's anchor is in the way of every other region, whether9+// it shows a table, why it can't, or nothing yet, as an array's10+// formula is in the way of other arrays;11+// - when two tables need the same cells, the region whose anchor12+// comes first, row by row, gets them, whichever arrived first: the13+// other gives way, showing why at its anchor.14+//15+// A region blocked by another tries again, its source sending its rows,16+// when a cell it needs is freed, or a region on its sheet goes.17+18+// inTheWay returns a cell of need, the cells the region r with meta me19+// needs, holding something r didn't write and that keeps it from20+// showing, and the regions whose tables are to give way to it. Unless21+// whole is set, only what r didn't hold is looked at: nothing else is22+// placed in a region's cells without it knowing (regionTouched).23+func (s *Sheet) inTheWay(r Region, need Rect, me *regionMeta, whole bool) (at Addr, blocked bool, later []Region) {24+ // An array's cells are in the way even where it spilled blanks, as25+ // another array's are (spill.go).26+ for a, sp := range s.spills {27+ if x, ok := intersectRect(sp.area, need); ok && sp.why == "" {28+ if x.From != a {29+ return x.From, true, nil30+ }31+ return a, true, nil32+ }33+ }34+ for _, q := range s.regions.list {35+ if q.Name != r.Name && need.Contains(q.At) {36+ return q.At, true, nil37+ }38+ }39+ parts := []Rect{need}40+ if !whole {41+ parts = fresh(need, me)42+ }43+ for _, part := range parts {44+ if at, blocked = s.filledIn(part, r, me, &later); blocked {45+ return at, true, nil46+ }47+ }48+ return Addr{}, false, later49+}50+51+// filledIn returns a cell of part holding something the region r with52+// meta me didn't write that keeps it from showing, adding to later the53+// regions whose cells there come after r's anchor.54+func (s *Sheet) filledIn(part Rect, r Region, me *regionMeta, later *[]Region) (Addr, bool) {55+ for a := range s.cells.anyKeysIn(part) {56+ if !s.cells.filledAt(a) || s.regionOwns(a, me) {57+ continue58+ }59+ q, _, ok := s.ownerOf(a)60+ if !ok || before(q.At, r.At) {61+ return a, true62+ }63+ if !regionListed(*later, q) {64+ *later = append(*later, q)65+ }66+ }67+ return Addr{}, false68+}69+70+// before reports whether the cell at a comes before the cell at b, row71+// by row.72+func before(a, b Addr) bool { return a.Row < b.Row || a.Row == b.Row && a.Col < b.Col }73+74+func regionListed(list []Region, r Region) bool {75+ for _, x := range list {76+ if x.Name == r.Name {77+ return true78+ }79+ }80+ return false81+}82+83+// makeRoomAt has the regions whose tables hold r's anchor give way to84+// it, and returns the cells that changed.85+func (s *Sheet) makeRoomAt(r Region) []loc {86+ var changed []loc87+ for _, q := range s.regions.list {88+ if me := s.regionMeta[nameKey(q.Name)]; q.Name != r.Name && me != nil && me.why == "" && s.regionOwns(r.At, me) {89+ changed = append(changed, s.giveWay(q, Rect{From: r.At, To: r.At})...)90+ }91+ }92+ return changed93+}94+95+// giveWay has the region q, showing a table, show why it can't instead:96+// another region needs cells of it in taken. It returns the cells that97+// changed, with the formulas naming q.98+func (s *Sheet) giveWay(q Region, taken Rect) []loc {99+ me := s.meta(nameKey(q.Name))100+ at, _ := intersectRect(me.written, taken)101+ need, anchor := me.written, Rect{From: q.At, To: q.At}102+ changed := s.clearOwned(me, anchor)103+ me.why = "can't show: it would overwrite data in " + at.From.String()104+ me.need, me.written, me.rows, me.cols = need, anchor, 0, 0105+ changed = s.writeRegionCell(q.At, LiveCell{V: ErrRef}, me, changed)106+ return s.regionReaders(q, changed)107+}108+109+// wakeRegions marks the regions other than me blocked from showing by a110+// cell of gone, which me has just cleared, to be sent again.111+func (s *Sheet) wakeRegions(me *regionMeta, gone []Addr) {112+ for _, other := range s.regionMeta {113+ if other == me || other.why == "" {114+ continue115+ }116+ for _, a := range gone {117+ if other.need.Contains(a) {118+ other.stale = true119+ break120+ }121+ }122+ }123+}124+125+// wakeAllRegions marks every region of the sheet blocked from showing126+// to be sent again, as when a region goes: its anchor, which may have127+// blocked them, goes with it.128+func (s *Sheet) wakeAllRegions() {129+ for _, me := range s.regionMeta {130+ if me.why != "" {131+ me.stale = true132+ }133+ }134+}@@ -0,0 +1,103 @@
1+package sheet2+3+import "testing"4+5+// Regions meeting each other show the same whichever order their rows6+// arrive in, as a file reopened sends them in another order than they7+// arrived in (regionclash.go).8+9+// sendBoth is a sheet with outputs a and b at the cells aAt and bAt,10+// sent opA and opB in that order, or the other with bFirst.11+func sendBoth(t *testing.T, aAt, bAt string, opA, opB LiveOp, bFirst bool) *Sheet {12+ t.Helper()13+ s := New()14+ sent(t, s, "a", aAt)15+ sent(t, s, "b", bAt)16+ s.Set(at("H1"), "=SUM(nu.a)")17+ s.Set(at("H2"), "=SUM(nu.b)")18+ ops := []LiveOp{opA, opB}19+ if bFirst {20+ ops = []LiveOp{opB, opA}21+ }22+ for _, op := range ops {23+ apply(t, s, op)24+ }25+ return s26+}27+28+// A region's anchor is in the way of another's table, whether or not29+// its own rows have arrived.30+func TestRegionAnchorBlocksAnother(t *testing.T) {31+ opA := LiveOp{Region: "a", Reset: true, Header: liveRow("x", "y"), Rows: []LiveRow{liveRow("1", "2")}}32+ opB := LiveOp{Region: "b", Reset: true, Header: liveRow("p", "q"), Rows: []LiveRow{liveRow("3", "4")}}33+ for _, bFirst := range []bool{false, true} {34+ s := sendBoth(t, "A1", "B2", opA, opB, bFirst)35+ wantShown(t, s, map[string]string{"A1": "#REF!", "A2": "", "B2": "p", "C3": "4", "H1": "#REF!", "H2": "7"})36+ }37+}38+39+// When two tables need the same cells, the region whose anchor comes40+// first, row by row, gets them, whichever arrived first.41+func TestRegionFirstAnchorWins(t *testing.T) {42+ // a needs B1:C3 and b A2:C2: neither holds the other's anchor.43+ opA := LiveOp{Region: "a", Reset: true, Header: liveRow("x", "y"), Rows: []LiveRow{liveRow("1", "2"), liveRow("3", "4")}}44+ opB := LiveOp{Region: "b", Reset: true, Header: liveRow("5", "6", "7")}45+ for _, bFirst := range []bool{false, true} {46+ s := sendBoth(t, "B1", "A2", opA, opB, bFirst)47+ wantShown(t, s, map[string]string{"B1": "x", "B2": "1", "C3": "4", "A2": "#REF!", "H1": "10", "H2": "#REF!"})48+ }49+}50+51+// A region an array spills over gives way, and the formulas naming it52+// read #REF! until it's sent again.53+func TestRegionGivingWayToAnArray(t *testing.T) {54+ s := New()55+ sent(t, s, "a", "B1")56+ s.Set(at("H1"), "=SUM(nu.a)")57+ apply(t, s, LiveOp{Region: "a", Reset: true, Header: liveRow("x"), Rows: []LiveRow{liveRow("5")}})58+ wantShown(t, s, map[string]string{"H1": "5"})59+ s.Set(at("A1"), "=SEQUENCE(1,2)")60+ wantShown(t, s, map[string]string{"A1": "1", "B1": "2", "B2": "", "H1": "#REF!"})61+}62+63+// An array spilling over a region's table isn't blocked by a formula64+// naming the region that it reads: the region gives way, so the array65+// doesn't depend on it, whichever came first.66+func TestArrayOverRegionItReadsByName(t *testing.T) {67+ for _, arrayFirst := range []bool{false, true} {68+ s := New()69+ sent(t, s, "a", "B1")70+ s.Set(at("D5"), "=SUM(nu.a)")71+ op := LiveOp{Region: "a", Reset: true, Header: liveRow("x"), Rows: []LiveRow{liveRow("5")}}72+ if !arrayFirst {73+ apply(t, s, op)74+ }75+ s.Set(at("A1"), "=IF(ISERROR(D5),SEQUENCE(1,2),SEQUENCE(1,2))") // over B176+ if arrayFirst {77+ apply(t, s, op)78+ }79+ wantShown(t, s, map[string]string{"A1": "1", "B1": "2", "D5": "#REF!"})80+ if _, ok := s.SpillAnchor(at("B1")); !ok {81+ t.Errorf("array first %v: the array doesn't spill over the region", arrayFirst)82+ }83+ }84+}85+86+// A paste into a blank cell of a region's table has the region find it87+// in its way when its rows are sent again, as they are when the file is88+// opened.89+func TestPasteIntoARegionsBlankCell(t *testing.T) {90+ s := New()91+ sent(t, s, "a", "A1")92+ op := LiveOp{Region: "a", Reset: true, Header: liveRow("x", "y"), Rows: []LiveRow{liveRow("1", "")}}93+ apply(t, s, op)94+ s.Set(at("E1"), "9")95+ if _, err := s.Paste(s.Copy(Rect{From: at("E1"), To: at("E1")}), Rect{From: at("B2"), To: at("B2")}, false); err != nil {96+ t.Fatal(err)97+ }98+ if got := s.Book().StaleOutputs(); len(got) != 1 {99+ t.Fatalf("stale outputs %v, want a", got)100+ }101+ apply(t, s, op)102+ wantShown(t, s, map[string]string{"A1": "#REF!", "A2": "", "B2": "9"})103+}@@ -76,7 +76,7 @@ func (s *Sheet) shiftRegions(rows bool, sp formula.Span) {
7676 st.list = append(st.list, r) // before the lines: untouched7777 continue7878 }79- for _, c := range s.emptyRegion(s.meta(nameKey(r.Name))) {79+ for _, c := range s.emptyRegion(r, s.meta(nameKey(r.Name))) {8080 s.wb.markDirty(c)8181 }8282 at, ok := cell(r.At)@@ -121,7 +121,7 @@ func (w *Workbook) regionsInUse() []namedUsers {
121121 continue122122 }123123 if t, ok := s.RegionTable(r.Name); ok {124- out = append(out, namedUsers{s, t, users})124+ out = append(out, namedUsers{s: s, r: t, users: users, region: true})125125 }126126 }127127 }@@ -21,12 +21,19 @@ func (s *Sheet) meta(k string) *regionMeta {
2121 return me2222 }2323 24-// regionTouched notes that the cell at a is being placed: a region25-// blocked by it tries again, its source sending its rows again.26-func (s *Sheet) regionTouched(a Addr) {24+// regionTouched notes that c is being placed at a: a region blocked by25+// it tries again, and one whose table it lands in (a blank cell of it,26+// pasted over) finds it in its way, its source sending its rows again27+// either way.28+func (s *Sheet) regionTouched(a Addr, c *Cell) {2729 for _, r := range s.regions.list {28- if me := s.regionMeta[nameKey(r.Name)]; me != nil && me.why != "" && me.need.Contains(a) {30+ me := s.regionMeta[nameKey(r.Name)]31+ switch {32+ case me == nil:33+ case me.why != "" && me.need.Contains(a):2934 me.stale = true35+ case me.why == "" && me.has && me.written.Contains(a) && !c.Blank():36+ me.stale, me.placed = true, true3037 }3138 }3239 }@@ -70,6 +77,7 @@ func (s *Sheet) writeRegions() []loc {
7077 if s.regionIndex(k) < 0 {7178 changed = append(changed, s.clearOwned(me, noRect)...)7279 delete(s.regionMeta, k)80+ s.wakeAllRegions()7381 for u := range s.wb.nameUsers[nameKey(regionPrefix+k)] {7482 changed = append(changed, u) // the name reads nothing now7583 }@@ -78,16 +86,20 @@ func (s *Sheet) writeRegions() []loc {
7886 for _, r := range s.regions.list {7987 if me := s.meta(nameKey(r.Name)); me.reread {8088 me.reread = false81- changed = append(changed, s.emptyRegion(me)...)89+ changed = append(changed, s.emptyRegion(r, me)...)8290 }8391 }8492 return changed8593 }8694 87-// emptyRegion clears a region's cells, outside the undo history, to be88-// fed again whole, which it is marked stale for.89-func (s *Sheet) emptyRegion(me *regionMeta) []loc {95+// emptyRegion clears the region r's cells, outside the undo history,96+// to be fed again whole, which it is marked stale for. The formulas97+// naming it read #REF! until then, so they're among the cells returned.98+func (s *Sheet) emptyRegion(r Region, me *regionMeta) []loc {9099 changed := s.clearOwned(me, noRect)100+ if me.has {101+ changed = s.regionReaders(r, changed)102+ }91103 me.has, me.rows, me.cols, me.why = false, 0, 0, ""92104 me.data, me.stale = 0, true93105 return changed@@ -104,16 +116,22 @@ func (s *Sheet) writeTable(r Region, me *regionMeta, header LiveRow, rows []Live
104116 cols, nrows := tableSize(me, header, rows, reset)105117 was, had, shape := me.written, me.has, [2]int{me.rows, me.cols}106118 need := s.tableArea(r, cols, nrows)107- me.why = ""119+ changed := s.makeRoomAt(r)120+ whole := me.why != "" || me.placed // it may not hold every cell it wrote121+ me.why, me.placed = "", false108122 switch {109123 case need.To.Row >= MaxRows || need.To.Col >= MaxCols:110124 me.why = "can't show: it would go past the edge of the sheet"111125 default:112- if at, ok := s.inTheWay(need, me); ok {126+ at, blocked, later := s.inTheWay(r, need, me, whole)127+ if blocked {113128 me.why = "can't show: it would overwrite data in " + at.String()129+ break130+ }131+ for _, q := range later {132+ changed = append(changed, s.giveWay(q, need)...)114133 }115134 }116- var changed []loc117135 if me.why != "" {118136 me.need, me.rows, me.cols = need, 0, 0119137 need, header, rows, cols, nrows = Rect{From: r.At, To: r.At}, nil, nil, 0, 0@@ -143,10 +161,16 @@ func (s *Sheet) writeTable(r Region, me *regionMeta, header LiveRow, rows []Live
143161 s.freedFor(c.a)144162 }145163 if !had || was != me.written || shape != [2]int{me.rows, me.cols} { // moved, or its table changed shape146- for _, k := range []string{nameKey(r.FormulaName()), nameKey(r.Name)} {147- for u := range s.wb.nameUsers[k] {148- changed = append(changed, u)149- }164+ changed = s.regionReaders(r, changed)165+ }166+ return changed167+}168+169+// regionReaders adds the formulas naming the region r to changed.170+func (s *Sheet) regionReaders(r Region, changed []loc) []loc {171+ for _, k := range []string{nameKey(r.FormulaName()), nameKey(r.Name)} {172+ for u := range s.wb.nameUsers[k] {173+ changed = append(changed, u)150174 }151175 }152176 return changed@@ -205,30 +229,6 @@ func (s *Sheet) writeRegionCell(a Addr, c LiveCell, me *regionMeta, changed []lo
205229 return changed206230 }207231 208-// inTheWay returns a cell of need holding something the region with209-// meta me didn't write. Only what the region didn't hold is looked at:210-// nothing else can be placed in a region's cells.211-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- }222- for _, part := range fresh(need, me) {223- for a := range s.cells.anyKeysIn(part) {224- if s.cells.filledAt(a) && !s.regionOwns(a, me) {225- return a, true226- }227- }228- }229- return Addr{}, false230-}231-232232 // regionCell reports whether a region holds the cell at a.233233 func (s *Sheet) regionCell(a Addr) bool {234234 if len(s.regions.list) == 0 {@@ -274,7 +274,7 @@ func (s *Sheet) regionsGiveWay(area Rect) []loc {
274274 var changed []loc275275 for _, r := range s.regions.list {276276 if me := s.regionMeta[nameKey(r.Name)]; me != nil && me.has && overlaps(me.written, area) {277- changed = append(changed, s.emptyRegion(me)...)277+ changed = append(changed, s.emptyRegion(r, me)...)278278 }279279 }280280 return changed@@ -309,5 +309,6 @@ func (s *Sheet) clearOwned(me *regionMeta, keep Rect) []loc {
309309 s.setDerived(a, s.cells.get(a).leftover())310310 changed[i] = loc{s, a}311311 }312+ s.wakeRegions(me, gone)312313 return changed313314 }@@ -398,7 +398,7 @@ func (s *Sheet) place(a Addr, c *Cell) {
398398 c = c.leftover()399399 }400400 }401- s.regionTouched(a)401+ s.regionTouched(a, c)402402 }403403 s.trackShape(a, c)404404 s.spillTouched(a, c)@@ -88,6 +88,7 @@ func (w *Workbook) noteSpill(s *Sheet, a Addr, c *Cell, arr *functions.Array) {
8888 // sheets whose pivots to refresh: stale, and those whose source a spill8989 // changed.9090 func (w *Workbook) settleSpills(stale []*Sheet) []*Sheet {91+ defer func() { w.spillCycles, w.spillRechecked = nil, false }()9192 for pass := 0; len(w.spillWork) > 0; pass++ {9293 var changed, anchors []loc9394 for _, l := range w.spillOrder() {@@ -133,24 +134,24 @@ func (w *Workbook) spillOrder() []loc {
133134 func (s *Sheet) applySpill(a Addr, p pendingSpill) ([]loc, bool) {134135 c := s.cells.get(a)135136 if c == nil || !c.IsFormula() || p.arr == nil {136- return s.dropSpill(a), false137+ return s.forgetSpill(a), false137138 }138139 area, why, later := s.spillArea(a, p.arr)139140 var cycle []loc140141 circ := false142+ old := s.spills[a]141143 if why == "" {142144 // Checked each time the anchor is computed: a formula typed since143145 // it spilled may lead from its cells back to it.144- why, cycle = s.spillsIntoItself(a, area)146+ why, cycle = s.spillCycle(a, area, old)145147 circ = why != ""146148 }147- old := s.spills[a]148149 if why == "" && len(later) == 0 && old != nil && old.why == "" && !old.stale && old.auto == c.auto && sameArray(old.arr, p.arr) {149150 return nil, false // written already, and nothing has changed in its way150151 }151152 var changed []loc152- if circ != (old != nil && old.circular) {153- changed = s.wb.recheckArrays(loc{s, a}) // a cycle through arrays began or ended here153+ if s.wb.cycleMoved(circ, old) {154+ changed = s.wb.recheckArrays(loc{s, a})154155 }155156 if why != "" {156157 changed = append(changed, s.dropSpill(a)...)@@ -166,6 +167,9 @@ func (s *Sheet) applySpill(a Addr, p pendingSpill) ([]loc, bool) {
166167 changed = append(changed, s.regionsGiveWay(area)...)167168 if old != nil && old.why == "" {168169 changed = append(changed, s.clearSpilled(old.area, area)...)170+ if old.area != area {171+ changed = append(changed, s.wb.recheckCircular()...)172+ }169173 }170174 s.setSpill(a, &spill{area: area, arr: p.arr, auto: c.auto})171175 c.Value = p.top@@ -338,7 +342,7 @@ func (s *Sheet) dropSpill(a Addr) []loc {
338342 if old.why != "" {339343 return nil340344 }341- return s.clearSpilled(old.area, Rect{From: a, To: a})345+ return append(s.clearSpilled(old.area, Rect{From: a, To: a}), s.wb.recheckCircular()...)342346 }343347 344348 // setSpill records the anchor at a's spill (nil for none), indexing@@ -347,10 +351,16 @@ func (s *Sheet) setSpill(a Addr, sp *spill) {
347351 if old := s.spills[a]; old != nil {348352 s.spillAt.remove(a, []Rect{old.area})349353 delete(s.spills, a)354+ if old.circular {355+ s.wb.circArrays--356+ }350357 }351358 if sp == nil {352359 return353360 }361+ if sp.circular {362+ s.wb.circArrays++363+ }354364 if s.spills == nil {355365 s.spills = map[Addr]*spill{}356366 }@@ -366,7 +376,7 @@ func (s *Sheet) spillTouched(a Addr, c *Cell) {
366376 return367377 }368378 if s.spills[a] != nil && (c == nil || !c.IsFormula()) {369- for _, l := range s.dropSpill(a) {379+ for _, l := range s.forgetSpill(a) {370380 s.wb.markDirty(l)371381 }372382 }@@ -259,7 +259,9 @@ func TestReadingABlockedArray(t *testing.T) {
259259 260260 // An array whose formula reads its own cells through another formula261261 // is a circular dependency too: #REF! for it and what reads it, typed,262-// recalculated or opened, without going back and forth.262+// recalculated or opened, without going back and forth. Here the263+// formula reads the array's anchor too (VLOOKUP's range holds C19), a264+// cycle of formulas, found whether or not VLOOKUP reads that far.263265 func TestSpillIntoItsInputThroughAFormula(t *testing.T) {264266 s := New()265267 s.Set(at("F11"), "=VLOOKUP(F14,C17:D19,1,FALSE)")@@ -272,17 +274,17 @@ func TestSpillIntoItsInputThroughAFormula(t *testing.T) {
272274 t.Errorf("%s: %s = %v", name, a, v)273275 }274276 }275- if got := sh.ExplainError(at("C19")); !strings.Contains(got, "Circular dependency") {277+ if got := sh.ExplainError(at("C19")); !strings.Contains(got, "Circular") {276278 t.Errorf("%s: %q", name, got)277279 }280+ if sh.Filled(at("C20")) {281+ t.Errorf("%s: the array spilled", name)282+ }278283 }279284 check("typed", s)280285 check("reopened", roundTrip(t, s))281286 s.RecalcAll()282287 check("recalculated", s)283- if s.Book().Circular {284- t.Error("arrays kept moving")285- }286288 }287289 288290 // An array that would spill into the cells its formula reads is a@@ -302,3 +304,18 @@ func TestSpillIntoItsInput(t *testing.T) {
302304 }303305 }304306 }307+308+// An array isn't left blocked by a cycle through the cells of an array309+// that stopped spilling in the same recalculation: deleting a row moves310+// the array at A3 to A2, and puts at A3 a formula reading B2 whose311+// array spilled over C3, which G1 reads, until it's computed again.312+func TestNoCycleThroughAnArrayGone(t *testing.T) {313+ s := sheetOf(t, map[string]string{"A3": "=D11:F11", "A4": "=B3", "G2": "=C4", "F11": "=G2"})314+ if sp := s.spills[at("A3")]; sp == nil || sp.why != "" {315+ t.Fatalf("A3's array: %+v", sp)316+ }317+ s.DeleteRows(0, 1)318+ if sp := s.spills[at("A2")]; sp == nil || sp.why != "" {319+ t.Fatalf("A2's array after deleting a row: %+v", sp)320+ }321+}@@ -1,5 +1,7 @@
11 package sheet22 3+import "slices"4+35 // spillsIntoItself finds whether the array at a would spill over a cell46 // its formula reads, other than a itself, directly or through other57 // formulas and the arrays they spill: spilling would change what@@ -12,11 +14,19 @@ package sheet
1214 // recalculation after the spill would visit anyway. An array blocked by1315 // a cycle counts as spilling over the cells it needs, so the arrays of1416 // a cycle stay blocked together whichever was computed first.17+//18+// A region whose table is in area gives way to the array19+// (regionsGiveWay), so what reads it by name reads #REF! once the array20+// spills, whatever the array holds: the walk doesn't follow it, or the21+// array would be blocked or not by whether the region's rows arrived22+// before it spilled.1523 func (s *Sheet) spillsIntoItself(a Addr, area Rect) (string, []loc) {1624 if area.From == area.To {1725 return "", nil1826 }19- w := &spillWalk{look: s.wb.readerLookup(), target: loc{s, a}, seen: map[loc]bool{{s, a}: true}, parent: -1}27+ look := s.wb.readerLookup()28+ look.named = slices.DeleteFunc(look.named, func(nu namedUsers) bool { return nu.region && nu.s == s && overlaps(nu.r, area) })29+ w := &spillWalk{look: look, target: loc{s, a}, seen: map[loc]bool{{s, a}: true}, parent: -1}2030 if w.cells(s, area, a, loc{}, true) {2131 return circular(w.origin), nil2232 }@@ -39,6 +49,67 @@ func (s *Sheet) spillsIntoItself(a Addr, area Rect) (string, []loc) {
3949 return "", nil4050 }4151 52+// spillCycle is spillsIntoItself for the array at a, whose spill was53+// old, holding it blocked once a cycle has blocked it twice in the54+// recalculation, freed between. Arrays whose sizes follow what they read55+// can go around a cycle without settling: blocked, one shows #REF!,56+// which the other reads, and its smaller array frees the first, which57+// spills and blocks it again. Held, they end blocked together, as they58+// are when the cycle is found in one go, whichever came first.59+func (s *Sheet) spillCycle(a Addr, area Rect, old *spill) (string, []loc) {60+ l, w := loc{s, a}, s.wb61+ why, cycle := s.spillsIntoItself(a, area)62+ blocked := old != nil && old.circular63+ checked := w.checkedCycle(l)64+ switch {65+ case why != "" && (!blocked || !checked):66+ w.noteCycle(l)67+ case why == "" && blocked && w.spillCycles[l] >= 2:68+ why = old.why69+ }70+ return why, cycle71+}72+73+// checkedCycle reports whether the array at l was checked for a cycle74+// already in the recalculation, and notes that it has been.75+func (w *Workbook) checkedCycle(l loc) bool {76+ if _, ok := w.spillCycles[l]; ok {77+ return true78+ }79+ if w.spillCycles == nil {80+ w.spillCycles = map[loc]int{}81+ }82+ w.spillCycles[l] = 083+ return false84+}85+86+// cycleMoved reports whether the arrays are to be checked again, the87+// array whose spill was old found blocked by a cycle (circ) or not: a88+// cycle through arrays began or ended at it, which may take in or let go89+// of others, or it is blocked still, the first time in the90+// recalculation: something changed on the way around its cycle, as a91+// formula typed reading another array's cells, which may have closed a92+// cycle through another array, which isn't computed again for it.93+func (w *Workbook) cycleMoved(circ bool, old *spill) bool {94+ switch was := old != nil && old.circular; {95+ case circ != was:96+ return true97+ case circ && !w.spillRechecked:98+ w.spillRechecked = true99+ return true100+ }101+ return false102+}103+104+// noteCycle counts the array at l found blocked by a cycle, from spilling105+// or not, in the recalculation.106+func (w *Workbook) noteCycle(l loc) {107+ if w.spillCycles == nil {108+ w.spillCycles = map[loc]int{}109+ }110+ w.spillCycles[l]++111+}112+42113 // spillWalk is the state of spillsIntoItself: the formulas reached, in43114 // the order found, each with how it was reached.44115 type spillWalk struct {@@ -137,6 +208,40 @@ func (w *Workbook) recheckArrays(l loc) []loc {
137208 return out138209 }139210 211+// recheckCircular returns the anchors of the arrays blocked by a cycle,212+// to compute again, when an array stops spilling or spills over other213+// cells: the cycle may have gone through its cells, and the arrays214+// found it blocked in the same pass, before it changed, which nothing215+// they read may tell them.216+func (w *Workbook) recheckCircular() []loc {217+ if w.circArrays <= 0 {218+ return nil219+ }220+ var out []loc221+ for _, s := range w.sheets {222+ for b, sp := range s.spills {223+ if sp.circular {224+ sp.stale = true225+ out = append(out, loc{s, b})226+ }227+ }228+ }229+ return out230+}231+232+// forgetSpill drops the anchor at a's spill for good, its formula233+// computing one value or none, returning the cells that changed. When a234+// cycle blocked it, it counted as spilling over the cells it needed, so235+// the arrays blocked by a cycle are checked again.236+func (s *Sheet) forgetSpill(a Addr) []loc {237+ old := s.spills[a]238+ changed := s.dropSpill(a)239+ if old != nil && old.circular {240+ changed = append(changed, s.wb.recheckCircular()...)241+ }242+ return changed243+}244+140245 // blockCircular blocks the arrays at l, part of a cycle with another,141246 // and returns the cells that changed, with the anchors, to compute142247 // again, finding the cycle themselves.@@ -150,6 +255,7 @@ func blockCircular(arrays []loc) []loc {
150255 area := sp.area151256 changed = append(changed, l.s.dropSpill(l.a)...)152257 l.s.setSpill(l.a, &spill{area: area, why: "Circular dependency through another array", circular: true})258+ l.s.wb.noteCycle(l)153259 changed = append(changed, l)154260 }155261 return changed@@ -0,0 +1,66 @@
1+package sheet2+3+import "testing"4+5+// Cycles through arrays settle the same typed in any order, recalculated6+// or opened (spillblock.go).7+8+// wantSettled types cells in order, then checks what the cells of want9+// show, as typed, once everything is recalculated, and opened.10+func wantSettled(t *testing.T, name string, order []string, cells, want map[string]string) {11+ t.Helper()12+ s := New()13+ for _, a := range order {14+ s.Set(at(a), cells[a])15+ }16+ check := func(how string, sh *Sheet) {17+ t.Helper()18+ for a, v := range want {19+ if got := sh.Value(at(a)).String(); got != v {20+ t.Errorf("%s, typed %v, %s: %s = %q, want %q", name, order, how, a, got, v)21+ }22+ }23+ }24+ check("typed", s)25+ reopened := roundTrip(t, s)26+ check("reopened", reopened)27+ s.RecalcAll()28+ check("recalculated", s)29+}30+31+// Two arrays reading each other's cells, one's size following what it32+// reads: blocked, each shows #REF!, which the other reads, and its33+// smaller array would free the first. They end blocked together.34+func TestArraysGoingAroundACycle(t *testing.T) {35+ cells := map[string]string{"C3": "=SORT(C13:E16)", "D13": "-5", "B14": "=SORT(C2:E5)"}36+ want := map[string]string{"C3": "#REF!", "B14": "#REF!", "D3": ""}37+ for _, order := range [][]string{{"C3", "D13", "B14"}, {"B14", "D13", "C3"}, {"D13", "C3", "B14"}} {38+ wantSettled(t, "around", order, cells, want)39+ }40+}41+42+// A formula typed last that reads an array's cells closes a cycle43+// through it and another array, which is computed again for it: both44+// are blocked, as when the file opens.45+func TestCycleClosedThroughAnotherArray(t *testing.T) {46+ cells := map[string]string{"D9": "=SORT(G15:H19)", "H19": "=SORT(D13:F16)", "D16": "=ABS($A$9)+1", "G16": `=COUNTIF(H20,">2")`}47+ wantSettled(t, "closed", []string{"D9", "H19", "D16", "G16"}, cells, map[string]string{"D9": "#REF!", "H19": "#REF!"})48+}49+50+// An array blocked by a cycle whose formula gives way to a value frees51+// the arrays the cycle went through.52+func TestCycleGoneWithAnArray(t *testing.T) {53+ s := sheetOf(t, map[string]string{"A9": "=SORT(B9:D12)"})54+ s.Set(at("C9"), "=SORT(A11:C14)")55+ s.Set(at("C11"), `=COUNTIF(A10:A13,">2")`)56+ if v := s.Value(at("C9")); v != ErrRef {57+ t.Fatalf("C9 in a cycle = %v", v)58+ }59+ s.Set(at("A9"), "-5")60+ if sp := s.spills[at("C9")]; sp == nil || sp.why != "" {61+ t.Errorf("C9 once A9 is a value: %+v", sp)62+ }63+ if sp := roundTrip(t, s).spills[at("C9")]; sp == nil || sp.why != "" {64+ t.Errorf("C9 opened: %+v", sp)65+ }66+}@@ -185,7 +185,7 @@ func (w *Workbook) tablesInUse() []namedUsers {
185185 continue186186 }187187 if v, ok := w.findTable(k); ok && v.ok {188- out = append(out, namedUsers{v.s, v.r, users})188+ out = append(out, namedUsers{s: v.s, r: v.r, users: users})189189 }190190 }191191 return out@@ -0,0 +1,2 @@
1+go test fuzz v12+[]byte("\x00\x01\x02{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{\x80\x04\x05\x06\a\b\t\n\v\f\r\x0e\x0f\x10\x11\x12\x13\x14")@@ -39,6 +39,17 @@ type Workbook struct {
3939 4040 // Circular is set when the last recalculation found a cycle.4141 Circular bool42+ // cycles keeps the buffers finding cycles takes (cyclefind.go).43+ cycles *cycleGraph44+ // spillCycles counts, while arrays settle, the times each was found45+ // blocked by a cycle (spillblock.go).46+ spillCycles map[loc]int47+ // spillRechecked is set once an array still blocked by a cycle has48+ // had every array checked again in the recalculation.49+ spillRechecked bool50+ // circArrays counts the arrays blocked by a cycle (spillblock.go),51+ // so arrays that stop spilling look for them only when there are.52+ circArrays int4253 4354 remote RemoteSource // answers JEV functions, see remote.go4455 trace any // the owner's telemetry trace, see observe.go@@ -383,7 +383,7 @@ func TestSingleSheetFileStaysCompatible(t *testing.T) {
383383 func TestDecimalIsWorkbookWide(t *testing.T) {384384 w := bookOf(t,385385 page{"Sheet1", map[string]string{"A1": "=0.1+0.2"}},386- page{"Data", map[string]string{"A1": "=Sheet1!A1=0.3"}},386+ page{"Data", map[string]string{"A1": "=Sheet1!A1-0.3=0"}},387387 )388388 w.SetDecimal(true)389389 expect(t, w, map[string]string{"Sheet1!A1": "0.3", "Data!A1": "TRUE"})@@ -11,7 +11,7 @@ import (
1111 // and Ctrl+Z turns it back off.1212 func TestDecimalSetting(t *testing.T) {1313 m := newModel()14- press(t, m, "=0.1+0.2=0.3", "<enter>")14+ press(t, m, "=0.1+0.2-0.3=0", "<enter>")1515 value := func() string { return m.sheet.Value(addr("A1")).String() }1616 if value() != "FALSE" {1717 t.Fatalf("binary: A1 = %s", value())@@ -9,6 +9,7 @@ package value
99 import (1010 "cmp"1111 "math"12+ "strconv"1213 "strings"1314 1415 "github.com/FelineStateMachine/012/internal/numfmt"@@ -169,3 +170,29 @@ func Compare(l, r Value) int {
169170 }170171 return 0171172 }173+174+// CompareShown is Compare as the comparison operators and criteria see175+// numbers: at the 15 significant digits a cell shows, as Sheets and176+// Excel do, so =0.1+0.2=0.3 is TRUE though the sum is177+// 0.30000000000000004. Sorting and lookups use Compare, which is exact178+// and so orders values consistently.179+func CompareShown(l, r Value) int {180+ d := Compare(l, r)181+ if d != 0 && l.Kind == Number && r.Kind == Number && SameShown(l.Num, r.Num) {182+ return 0183+ }184+ return d185+}186+187+// SameShown reports whether a and b agree to 15 significant digits.188+func SameShown(a, b float64) bool {189+ switch {190+ case a == b:191+ return true192+ case a == 0 || b == 0 || (a < 0) != (b < 0):193+ return false194+ case math.Abs(a-b) > 1e-14*math.Max(math.Abs(a), math.Abs(b)):195+ return false // differ before the 15th digit: the common case, at once196+ }197+ return strconv.FormatFloat(a, 'e', 14, 64) == strconv.FormatFloat(b, 'e', 14, 64)198+}@@ -111,7 +111,10 @@ var formulas = []string{
111111 // Excel disagree, or where excelize departs from Excel. Each says why.112112 var skipped = map[string]string{113113 // Sheets and Excel disagree; 012 follows Sheets.114- `=ROUND(-2.5)`: "Sheets makes ROUND's places optional; Excel requires them",114+ `=ROUND(-2.5)`: "Sheets makes ROUND's places optional; Excel requires them",115+ `=MID("abc", 0, 2)`: "Sheets gives #NUM! for a start before the text; Excel gives #VALUE!",116+ `=INDEX(D1:E3, 4, 1)`: "Sheets gives #NUM! for a row past the range; Excel gives #REF!",117+ `=CHOOSE(4, "a", "b", "c")`: "Sheets gives #NUM! for an index past the choices; Excel gives #VALUE!",115118 116119 // excelize departs from Excel (and Sheets) here.117120 `=SUM(C1:C2, E6)`: "excelize counts TRUE in a referenced cell as 1; Excel and Sheets ignore it",@@ -0,0 +1,77 @@
1+# install.ps1 - installs a released 012 binary on Windows. Served at the2+# docs site's root with the release archives under /releases/; see3+# docs/getting-started/install.md.4+#5+# irm https://f58b.n.zip/install.ps1 | iex6+#7+# It picks the archive for this architecture, checks its SHA256 against8+# the release's SHA256SUMS, puts 012.exe in9+# %LOCALAPPDATA%\Programs\012\bin and adds that folder to the user's10+# PATH. It never asks for administrator rights.11+#12+# $env:O12_VERSION a release other than the latest, like v1.2.313+# $env:O12_INSTALL_DIR another folder for 012.exe14+# $env:O12_BASE_URL the site serving /releases/ (https://f58b.n.zip)15+16+$ErrorActionPreference = 'Stop'17+$ProgressPreference = 'SilentlyContinue'18+19+function Install-012 {20+ $base = if ($env:O12_BASE_URL) { $env:O12_BASE_URL } else { 'https://f58b.n.zip' }21+ $version = if ($env:O12_VERSION) { $env:O12_VERSION } else { 'latest' }22+ if ($version -match '^[0-9]') { $version = "v$version" }23+ $dir = if ($env:O12_INSTALL_DIR) { $env:O12_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'Programs\012\bin' }24+25+ $arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }26+ switch ($arch) {27+ 'AMD64' { $arch = 'amd64' }28+ 'ARM64' { $arch = 'arm64' }29+ default { throw "012 install: no release for $arch; build from source: go install github.com/FelineStateMachine/012/cmd/012@latest" }30+ }31+32+ $tmp = Join-Path ([IO.Path]::GetTempPath()) ("012-install-" + [Guid]::NewGuid())33+ New-Item -ItemType Directory -Path $tmp | Out-Null34+ try {35+ $url = "$base/releases/$version"36+ $sums = Join-Path $tmp 'SHA256SUMS'37+ Invoke-WebRequest -UseBasicParsing -Uri "$url/SHA256SUMS" -OutFile $sums38+39+ # A line of SHA256SUMS: the digest, two spaces, 012_1.2.3_windows_amd64.zip.40+ $line = Get-Content $sums | Where-Object { $_ -match "^([0-9a-f]{64}) (012_\S+_windows_$arch\.zip)$" } | Select-Object -First 141+ if (-not $line) { throw "012 install: release $version has no archive for windows/$arch" }42+ $null = $line -match "^([0-9a-f]{64}) (012_\S+_windows_$arch\.zip)$"43+ $want = $Matches[1]44+ $file = $Matches[2]45+ $name = $file -replace '\.zip$', ''46+ $got_version = 'v' + ($name -replace '^012_', '' -replace "_windows_$arch$", '')47+48+ Write-Host "012 install: downloading $url/$file"49+ $zip = Join-Path $tmp $file50+ Invoke-WebRequest -UseBasicParsing -Uri "$url/$file" -OutFile $zip51+ $got = (Get-FileHash -Algorithm SHA256 -Path $zip).Hash.ToLowerInvariant()52+ if ($got -ne $want) { throw "012 install: $file has SHA256 $got, SHA256SUMS says $want; nothing installed" }53+ Write-Host "012 install: SHA256 checked against SHA256SUMS: $want"54+55+ Expand-Archive -Path $zip -DestinationPath $tmp -Force56+ $exe = Join-Path $tmp "$name\012.exe"57+ if (-not (Test-Path $exe)) { throw "012 install: $file holds no 012.exe" }58+ New-Item -ItemType Directory -Force -Path $dir | Out-Null59+ Copy-Item -Force $exe (Join-Path $dir '012.exe')60+ Write-Host "012 install: installed 012 $got_version (windows/$arch) as $(Join-Path $dir '012.exe')"61+62+ $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')63+ $parts = @()64+ if ($userPath) { $parts = $userPath -split ';' | Where-Object { $_ } }65+ if ($parts -notcontains $dir) {66+ [Environment]::SetEnvironmentVariable('Path', (($parts + $dir) -join ';'), 'User')67+ $env:Path = "$env:Path;$dir"68+ Write-Host "012 install: added $dir to your user PATH; new terminals find 012"69+ } else {70+ Write-Host "012 install: run 012 to start"71+ }72+ } finally {73+ Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue74+ }75+}76+77+Install-012@@ -0,0 +1,152 @@
1+2+# install.sh - installs a released 012 binary on macOS or Linux. Served3+# at the docs site's root with the release archives under /releases/;4+# see docs/getting-started/install.md.5+#6+# curl -fsSL https://f58b.n.zip/install.sh | sh7+# curl -fsSL https://f58b.n.zip/install.sh | sh -s -- --system8+#9+# It picks the archive for this OS and architecture, checks its SHA25610+# against the release's SHA256SUMS, and puts 012 in ~/.local/bin, in11+# $PREFIX/bin when PREFIX is set, or in /usr/local/bin with --system.12+# sudo is only run for --system when that directory isn't writable, and13+# the command is printed first.14+#15+# --version v1.2.3 a release other than the latest (or O12_VERSION)16+# --system install into /usr/local/bin17+# O12_BASE_URL the site serving /releases/ (https://f58b.n.zip)18+set -eu19+20+base=${O12_BASE_URL:-https://f58b.n.zip}21+version=${O12_VERSION:-latest}22+system=023+24+say() { printf '012 install: %s\n' "$*"; }25+die() {26+ printf '012 install: %s\n' "$*" >&227+ exit 128+}29+30+while [ $# -gt 0 ]; do31+ case $1 in32+ --version)33+ [ $# -ge 2 ] || die "--version needs a version, like v1.2.3"34+ version=$235+ shift 236+ ;;37+ --version=*)38+ version=${1#--version=}39+ shift40+ ;;41+ --system)42+ system=143+ shift44+ ;;45+ -h | --help)46+ sed -n '2,17p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' || true47+ exit 048+ ;;49+ *) die "unknown option $1 (--version v1.2.3, --system)" ;;50+ esac51+done52+53+case $version in54+latest | v[0-9]*) ;;55+[0-9]*) version=v$version ;;56+*) die "--version must be a release like v1.2.3 (got $version)" ;;57+esac58+59+os=$(uname -s)60+case $os in61+Darwin) os=darwin ;;62+Linux) os=linux ;;63+MINGW* | MSYS* | CYGWIN* | Windows*)64+ die "on Windows, run in PowerShell: irm $base/install.ps1 | iex"65+ ;;66+*) die "no release for $os; build from source: go install github.com/FelineStateMachine/012/cmd/012@latest" ;;67+esac68+69+arch=$(uname -m)70+case $arch in71+x86_64 | amd64) arch=amd64 ;;72+arm64 | aarch64) arch=arm64 ;;73+*) die "no release for $arch; build from source: go install github.com/FelineStateMachine/012/cmd/012@latest" ;;74+esac75+# A shell under Rosetta reports x86_64 on an Apple silicon Mac.76+if [ "$os/$arch" = darwin/amd64 ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null || echo 0)" = 1 ]; then77+ arch=arm6478+fi79+80+if command -v curl >/dev/null 2>&1; then81+ fetch() { curl -fsSL -o "$2" "$1"; }82+elif command -v wget >/dev/null 2>&1; then83+ fetch() { wget -q -O "$2" "$1"; }84+else85+ die "needs curl or wget"86+fi87+88+if command -v sha256sum >/dev/null 2>&1; then89+ sha256() { sha256sum "$1" | cut -d ' ' -f 1; }90+elif command -v shasum >/dev/null 2>&1; then91+ sha256() { shasum -a 256 "$1" | cut -d ' ' -f 1; }92+elif command -v openssl >/dev/null 2>&1; then93+ sha256() { openssl dgst -sha256 -r "$1" | cut -d ' ' -f 1; }94+else95+ die "needs sha256sum, shasum or openssl to check the download"96+fi97+98+if [ "$system" = 1 ]; then99+ bindir=/usr/local/bin100+elif [ -n "${PREFIX:-}" ]; then101+ bindir=$PREFIX/bin102+else103+ [ -n "${HOME:-}" ] || die "HOME is not set; set PREFIX to install into \$PREFIX/bin"104+ bindir=$HOME/.local/bin105+fi106+107+tmp=$(mktemp -d 2>/dev/null || mktemp -d -t 012install)108+trap 'rm -rf "$tmp"' EXIT109+trap 'exit 1' INT TERM110+111+url=$base/releases/$version112+fetch "$url/SHA256SUMS" "$tmp/SHA256SUMS" || die "couldn't download $url/SHA256SUMS"113+114+# A line of SHA256SUMS: the digest, two spaces, 012_1.2.3_linux_amd64.tar.gz.115+line=$(grep " 012_[^ ]*_${os}_${arch}\.tar\.gz\$" "$tmp/SHA256SUMS" | head -n 1 || true)116+[ -n "$line" ] || die "release $version has no archive for $os/$arch"117+want=${line%% *}118+file=${line##* }119+dir=${file%.tar.gz}120+got_version=${dir#012_}121+got_version=v${got_version%_"${os}"_"${arch}"}122+123+say "downloading $url/$file"124+fetch "$url/$file" "$tmp/$file" || die "couldn't download $url/$file"125+got=$(sha256 "$tmp/$file")126+[ "$got" = "$want" ] || die "$file has SHA256 $got, SHA256SUMS says $want; nothing installed"127+say "SHA256 checked against SHA256SUMS: $want"128+129+tar -xzf "$tmp/$file" -C "$tmp" || die "couldn't unpack $file"130+[ -f "$tmp/$dir/012" ] || die "$file holds no 012 binary"131+132+if [ ! -d "$bindir" ] && ! mkdir -p "$bindir" 2>/dev/null; then133+ [ "$system" = 1 ] || die "couldn't create $bindir"134+fi135+if [ -d "$bindir" ] && [ -w "$bindir" ]; then136+ cp "$tmp/$dir/012" "$bindir/012.new"137+ chmod 755 "$bindir/012.new"138+ mv -f "$bindir/012.new" "$bindir/012"139+elif [ "$system" = 1 ]; then140+ command -v sudo >/dev/null 2>&1 || die "$bindir isn't writable and there is no sudo; run as root or leave out --system"141+ say "$bindir isn't writable by $(id -un); running: sudo install -m 755 012 $bindir/012"142+ sudo mkdir -p "$bindir"143+ sudo install -m 755 "$tmp/$dir/012" "$bindir/012"144+else145+ die "$bindir isn't writable; set PREFIX to another directory"146+fi147+148+say "installed 012 $got_version ($os/$arch) as $bindir/012"149+case ":${PATH:-}:" in150+*":$bindir:"*) say "run 012 to start" ;;151+*) say "$bindir is not on your PATH; add it (export PATH=\"$bindir:\$PATH\") or run $bindir/012" ;;152+esac@@ -0,0 +1,228 @@
1+// Package install holds the install scripts served at the docs site's2+// root (install.sh, install.ps1) and the tests that run install.sh3+// against a local server standing in for the site's /releases/.4+package install5+6+import (7+ "archive/tar"8+ "bytes"9+ "compress/gzip"10+ "crypto/sha256"11+ "encoding/hex"12+ "fmt"13+ "net/http"14+ "net/http/httptest"15+ "os"16+ "os/exec"17+ "path/filepath"18+ "runtime"19+ "strings"20+ "testing"21+)22+23+// release is a fake release: a SHA256SUMS and an archive per Unix24+// platform whose 012 is a shell script printing its version.25+type release struct {26+ files map[string][]byte27+}28+29+func newRelease(t *testing.T, version string) release {30+ t.Helper()31+ r := release{files: map[string][]byte{}}32+ var sums strings.Builder33+ for _, goos := range []string{"darwin", "linux"} {34+ for _, arch := range []string{"amd64", "arm64"} {35+ name := fmt.Sprintf("012_%s_%s_%s", strings.TrimPrefix(version, "v"), goos, arch)36+ bin := fmt.Sprintf("#!/bin/sh\necho 012 %s %s/%s\n", version, goos, arch)37+ data := tarball(t, name, bin)38+ r.files[name+".tar.gz"] = data39+ sum := sha256.Sum256(data)40+ fmt.Fprintf(&sums, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)41+ }42+ }43+ r.files["SHA256SUMS"] = []byte(sums.String())44+ return r45+}46+47+func tarball(t *testing.T, dir, bin string) []byte {48+ t.Helper()49+ var buf bytes.Buffer50+ gz := gzip.NewWriter(&buf)51+ tw := tar.NewWriter(gz)52+ for _, f := range []struct {53+ name string54+ body string55+ mode int6456+ }{{dir + "/012", bin, 0o755}, {dir + "/LICENSE", "license\n", 0o644}} {57+ if err := tw.WriteHeader(&tar.Header{Name: f.name, Mode: f.mode, Size: int64(len(f.body)), Typeflag: tar.TypeReg}); err != nil {58+ t.Fatal(err)59+ }60+ if _, err := tw.Write([]byte(f.body)); err != nil {61+ t.Fatal(err)62+ }63+ }64+ if err := tw.Close(); err != nil {65+ t.Fatal(err)66+ }67+ if err := gz.Close(); err != nil {68+ t.Fatal(err)69+ }70+ return buf.Bytes()71+}72+73+// serve stands in for the site: /releases/<version>/<file>, with latest74+// as one of the versions.75+func serve(t *testing.T, releases map[string]release) *httptest.Server {76+ t.Helper()77+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {78+ version, file, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/releases/"), "/")79+ data, found := releases[version].files[file]80+ if !ok || !found {81+ http.NotFound(w, r)82+ return83+ }84+ _, _ = w.Write(data)85+ }))86+ t.Cleanup(srv.Close)87+ return srv88+}89+90+// shells are the POSIX shells install.sh runs under: sh, and dash when91+// it's installed, as Debian's /bin/sh is.92+func shells(t *testing.T) []string {93+ t.Helper()94+ if runtime.GOOS == "windows" {95+ t.Skip("install.sh is for macOS and Linux")96+ }97+ out := []string{"sh"}98+ if _, err := exec.LookPath("dash"); err == nil {99+ out = append(out, "dash")100+ }101+ return out102+}103+104+type run struct {105+ shell string106+ env []string107+ args []string108+ stdin bool // the script piped to the shell, as curl | sh does109+}110+111+func (r run) do(t *testing.T, home, base string) (string, error) {112+ t.Helper()113+ script, err := filepath.Abs("install.sh")114+ if err != nil {115+ t.Fatal(err)116+ }117+ var cmd *exec.Cmd118+ if r.stdin {119+ cmd = exec.Command(r.shell, append([]string{"-s", "--"}, r.args...)...)120+ f, err := os.Open(script)121+ if err != nil {122+ t.Fatal(err)123+ }124+ defer f.Close()125+ cmd.Stdin = f126+ } else {127+ cmd = exec.Command(r.shell, append([]string{script}, r.args...)...)128+ }129+ cmd.Dir = home130+ cmd.Env = append([]string{"HOME=" + home, "PATH=" + os.Getenv("PATH"), "O12_BASE_URL=" + base}, r.env...)131+ out, err := cmd.CombinedOutput()132+ return string(out), err133+}134+135+func TestInstallsTheLatestIntoLocalBin(t *testing.T) {136+ srv := serve(t, map[string]release{"latest": newRelease(t, "v9.9.9")})137+ for _, sh := range shells(t) {138+ for _, stdin := range []bool{false, true} {139+ t.Run(fmt.Sprintf("%s/stdin=%v", sh, stdin), func(t *testing.T) {140+ home := t.TempDir()141+ out, err := run{shell: sh, stdin: stdin}.do(t, home, srv.URL)142+ if err != nil {143+ t.Fatalf("install.sh: %v\n%s", err, out)144+ }145+ bin := filepath.Join(home, ".local", "bin", "012")146+ got, err := exec.Command(bin).Output()147+ if err != nil {148+ t.Fatalf("running the installed 012: %v\n%s", err, out)149+ }150+ if !strings.HasPrefix(string(got), "012 v9.9.9 "+runtime.GOOS+"/") {151+ t.Errorf("installed 012 prints %q", got)152+ }153+ for _, want := range []string{"SHA256 checked", "installed 012 v9.9.9", bin, "is not on your PATH"} {154+ if !strings.Contains(out, want) {155+ t.Errorf("output lacks %q:\n%s", want, out)156+ }157+ }158+ })159+ }160+ }161+}162+163+func TestInstallsAVersionUnderPrefix(t *testing.T) {164+ srv := serve(t, map[string]release{165+ "latest": newRelease(t, "v9.9.9"),166+ "v1.2.3": newRelease(t, "v1.2.3"),167+ })168+ for _, sh := range shells(t) {169+ t.Run(sh, func(t *testing.T) {170+ home := t.TempDir()171+ prefix := filepath.Join(home, "opt")172+ out, err := run{shell: sh, env: []string{"PREFIX=" + prefix}, args: []string{"--version", "1.2.3"}}.do(t, home, srv.URL)173+ if err != nil {174+ t.Fatalf("install.sh: %v\n%s", err, out)175+ }176+ got, err := exec.Command(filepath.Join(prefix, "bin", "012")).Output()177+ if err != nil || !strings.HasPrefix(string(got), "012 v1.2.3 ") {178+ t.Fatalf("installed 012 prints %q (%v)\n%s", got, err, out)179+ }180+ if _, err := os.Stat(filepath.Join(home, ".local")); !os.IsNotExist(err) {181+ t.Errorf("PREFIX set, but ~/.local was touched: %v", err)182+ }183+ })184+ }185+}186+187+func TestRefusesAnArchiveThatFailsItsChecksum(t *testing.T) {188+ rel := newRelease(t, "v9.9.9")189+ for name, data := range rel.files {190+ if strings.HasSuffix(name, ".tar.gz") {191+ rel.files[name] = append(append([]byte{}, data...), 0)192+ }193+ }194+ srv := serve(t, map[string]release{"latest": rel})195+ for _, sh := range shells(t) {196+ t.Run(sh, func(t *testing.T) {197+ home := t.TempDir()198+ out, err := run{shell: sh}.do(t, home, srv.URL)199+ if err == nil {200+ t.Fatalf("install.sh succeeded with a bad archive:\n%s", out)201+ }202+ if !strings.Contains(out, "nothing installed") {203+ t.Errorf("output doesn't say why:\n%s", out)204+ }205+ if _, err := os.Stat(filepath.Join(home, ".local", "bin", "012")); !os.IsNotExist(err) {206+ t.Errorf("012 was installed anyway: %v", err)207+ }208+ })209+ }210+}211+212+func TestFailsOnAMissingReleaseOrOption(t *testing.T) {213+ srv := serve(t, map[string]release{"latest": newRelease(t, "v9.9.9")})214+ for _, tc := range []struct {215+ args []string216+ want string217+ }{218+ {[]string{"--version", "v0.0.1"}, "couldn't download"},219+ {[]string{"--version", "next"}, "must be a release"},220+ {[]string{"--frobnicate"}, "unknown option"},221+ } {222+ home := t.TempDir()223+ out, err := run{shell: "sh", args: tc.args}.do(t, home, srv.URL)224+ if err == nil || !strings.Contains(out, tc.want) {225+ t.Errorf("install.sh %v: err %v, output lacks %q:\n%s", tc.args, err, tc.want, out)226+ }227+ }228+}@@ -0,0 +1,83 @@
1+2+# site-release.sh - what `make site-release` does after `make site`:3+# puts a release's archives and SHA256SUMS in the built site under4+# /releases/<version>/ and /releases/latest/, and the install scripts at5+# its root. See docs/contributing/releasing.md.6+#7+# VERSION=v1.2.3 the release to serve; the newest version tag by default8+# SITE_URL the site's address, which the install scripts download from9+#10+# The archives come from dist/ when it holds that version; otherwise11+# `make dist` builds them in a temporary checkout of the tag, so the12+# site can be published from a later commit, and they're kept in dist/.13+set -euo pipefail14+15+root=$(cd "$(dirname "$0")/.." && pwd)16+cd "$root"17+18+site="$root/website/build"19+if [ ! -f "$site/index.html" ]; then20+ echo "site-release: no site in website/build; run make site first" >&221+ exit 122+fi23+24+version=${VERSION:-$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1)}25+if [ -z "$version" ]; then26+ echo "site-release: no version tag to serve; set VERSION" >&227+ exit 128+fi29+if ! git rev-parse -q --verify "refs/tags/$version" >/dev/null; then30+ echo "site-release: $version isn't a tag" >&231+ exit 132+fi33+34+# dist/ holds this version when every archive in SHA256SUMS is named for it.35+holds() {36+ [ -f "$1/SHA256SUMS" ] && [ -s "$1/SHA256SUMS" ] &&37+ ! grep -qv " 012_${version#v}_" "$1/SHA256SUMS"38+}39+40+dist="$root/dist"41+if ! holds "$dist"; then42+ tmp=$(mktemp -d)43+ trap 'git -C "$root" worktree remove --force "$tmp/src" 2>/dev/null || true; rm -rf "$tmp"' EXIT44+ echo "site-release: building $version in a checkout of the tag"45+ git worktree add -q --detach "$tmp/src" "$version"46+ make -C "$tmp/src" dist VERSION="$version"47+ holds "$tmp/src/dist" || {48+ echo "site-release: make dist left no archives for $version" >&249+ exit 150+ }51+ rm -rf "$dist"52+ cp -R "$tmp/src/dist" "$dist"53+fi54+(55+ cd "$dist"56+ if command -v sha256sum >/dev/null 2>&1; then57+ sha256sum -c --quiet SHA256SUMS58+ else59+ shasum -a 256 -c --quiet SHA256SUMS60+ fi61+)62+63+# The scripts name https://f58b.n.zip; a site built for another SITE_URL64+# serves scripts that download from itself.65+url=${SITE_URL:-https://f58b.n.zip}66+url=${url%/}67+68+rm -rf "$site/releases"69+for dir in "$version" latest; do70+ mkdir -p "$site/releases/$dir"71+ (cd "$dist" && cp SHA256SUMS 012_* "$site/releases/$dir/")72+ {73+ printf '<!doctype html>\n<meta charset="utf-8">\n<title>012 %s</title>\n' "$version"74+ printf '<h1>012 %s</h1>\n<p>Checksums: <a href="SHA256SUMS">SHA256SUMS</a>. ' "$version"75+ printf 'Install with <code>curl -fsSL %s/install.sh | sh</code>.</p>\n<ul>\n' "$url"76+ (cd "$dist" && for f in 012_*; do printf '<li><a href="%s">%s</a></li>\n' "$f" "$f"; done)77+ printf '</ul>\n'78+ } >"$site/releases/$dir/index.html"79+done80+for f in install.sh install.ps1; do81+ sed "s|https://f58b\.n\.zip|$url|g" "scripts/install/$f" >"$site/$f"82+done83+echo "site-release: $version in website/build/releases/{$version,latest}, install.sh and install.ps1 at the root"