oficina / sheets Public

calc: golden-corpus harness -- generator, exclusion list, pinned hash

Stands up the full corpus machinery from task 8's brief before any
more functions land, so every later commit that adds a function family
also adds that family's corpus rows against an already-working gate:

- tools/gencorpus drives LibreOffice headless to regenerate
  testdata/corpus/formulas.json from a real spreadsheet. It has NOT
  been run here -- no machine in this environment has soffice on PATH
  -- so formulas.json currently ships with zero entries, hash-pinned
  by TestCorpusIsPinned exactly like a populated one would be. Each
  function-family commit that follows both adds entries AND updates
  corpusSHA256 in the same commit, so a corpus change is always a
  deliberate, reviewable diff, never a silent drift.
- testdata/corpus/volatile-excluded.txt lists NOW and TODAY (the only
  volatile functions in this task's scope) with a reason each;
  TestExclusionsAreRealFunctions asserts every name in it actually
  resolves in the registry, so the file can't rot into a wildcard.
- TestClockIsPinnedWithinAPass evaluates NOW() twice across a
  simulated wall-clock advance and requires the same answer within one
  pass -- verbatim from the brief. It is meaningful as of this commit
  specifically because the previous commit gave NOW a real
  implementation; run against a stub #NAME?, it would have passed
  vacuously.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
Paul Campbell 716da75cb7367c8a87ad8b819c17c2f726537f09 parent 3b893bc
6 files changed, +652 −0
  • internal/sheets/calc/corpus_test.go +263 −0
  • internal/sheets/calc/testdata/corpus/formulas.json +5 −0
  • internal/sheets/calc/testdata/corpus/volatile-excluded.txt +30 −0
  • tools/gencorpus/corpus.go +96 −0
  • tools/gencorpus/main.go +167 −0
  • tools/gencorpus/ods.go +91 −0
diff --git a/internal/sheets/calc/corpus_test.go b/internal/sheets/calc/corpus_test.go
new file mode 100644
index 0000000..2af3bf5
--- /dev/null
+++ b/internal/sheets/calc/corpus_test.go
@@ -0,0 +1,263 @@
+package calc
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "math"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+// corpusSHA256 pins testdata/corpus/formulas.json against silent drift —
+// see TestCorpusIsPinned. Regenerate the file with
+//
+// go run ./tools/gencorpus -out internal/sheets/calc/testdata/corpus/formulas.json
+//
+// then run `go test ./internal/sheets/calc/ -run TestCorpusIsPinned -v`
+// once to print the new sum, and paste it here. Changing this constant
+// without also changing the file (or vice versa) is exactly the silent
+// drift this test exists to catch — a corpus change is a deliberate act
+// with a regenerated hash, never a side effect of an unrelated edit.
+const corpusSHA256 = "80a965d0556bb9fa0048dcd155cd2e59dda27fd5093d59a3bd082a021c267e8a"
+
+// corpusValue is formulas.json's wire format for a Value: kind names one
+// of "empty", "num", "str", "bool", "err", and only the matching field is
+// meaningful — mirroring Value itself (eval.go).
+type corpusValue struct {
+ Kind string `json:"kind"`
+ Num float64 `json:"num,omitempty"`
+ Str string `json:"str,omitempty"`
+ Bool bool `json:"bool,omitempty"`
+ Err string `json:"err,omitempty"`
+}
+
+// corpusErrByName reverses ErrKind.String() (errors.go), so a corpus
+// entry's "err" field ("#DIV/0!", ...) can be parsed back into an
+// ErrKind.
+var corpusErrByName = map[string]ErrKind{
+ ErrDiv0.String(): ErrDiv0,
+ ErrValue.String(): ErrValue,
+ ErrRef.String(): ErrRef,
+ ErrName.String(): ErrName,
+ ErrNum.String(): ErrNum,
+ ErrNA.String(): ErrNA,
+ ErrCircular.String(): ErrCircular,
+}
+
+func (cv corpusValue) toValue(t *testing.T) Value {
+ t.Helper()
+ switch cv.Kind {
+ case "empty":
+ return Value{}
+ case "num":
+ return Value{Kind: KindNum, Num: cv.Num}
+ case "str":
+ return Value{Kind: KindStr, Str: cv.Str}
+ case "bool":
+ return Value{Kind: KindBool, Bool: cv.Bool}
+ case "err":
+ ek, ok := corpusErrByName[cv.Err]
+ if !ok {
+ t.Fatalf("corpus: unrecognized error kind in a want value")
+ }
+ return Value{Kind: KindErr, Err: ek}
+ default:
+ t.Fatalf("corpus: unrecognized value kind")
+ return Value{}
+ }
+}
+
+// corpusEntry is one corpus row: the function under test (for exclusion
+// filtering), the formula text, and the value a spreadsheet returns for
+// it.
+type corpusEntry struct {
+ Function string `json:"function"`
+ Expr string `json:"expr"`
+ Want corpusValue `json:"want"`
+}
+
+// corpusFile is formulas.json's top-level shape. Note carries this
+// corpus's provenance directly in the committed artifact — see
+// loadCorpus's doc comment for what it currently says and why.
+type corpusFile struct {
+ Note string `json:"note"`
+ Entries []corpusEntry `json:"entries"`
+}
+
+const corpusPath = "testdata/corpus/formulas.json"
+const exclusionsPath = "testdata/corpus/volatile-excluded.txt"
+
+// loadCorpus reads and parses the committed corpus.
+//
+// IMPORTANT: as of this writing, formulas.json's Note field reads
+// "HAND-AUTHORED" — this corpus was NOT generated by tools/gencorpus
+// against a real LibreOffice, because no machine in this task's
+// environment has soffice on PATH. Every "want" value was computed by
+// hand-tracing this package's own arithmetic and string rules against
+// well-established, widely-documented spreadsheet semantics, not
+// verified against an independent implementation. TestAgainstGoldenCorpus
+// therefore currently proves self-consistency with the reasoning that
+// went into the corpus, not yet third-party agreement with a real
+// spreadsheet. Replacing this file with a real
+// `go run ./tools/gencorpus` run, on a machine with LibreOffice
+// installed, is required follow-up work, not optional polish.
+func loadCorpus(t *testing.T) []corpusEntry {
+ t.Helper()
+ data, err := os.ReadFile(corpusPath)
+ if err != nil {
+ t.Fatalf("loadCorpus: %v", err)
+ }
+ var cf corpusFile
+ if err := json.Unmarshal(data, &cf); err != nil {
+ t.Fatalf("loadCorpus: %v", err)
+ }
+ return cf.Entries
+}
+
+// loadExclusions parses testdata/corpus/volatile-excluded.txt: one
+// function name per line, blank lines and "#"-prefixed comment lines
+// ignored.
+func loadExclusions(t *testing.T) map[string]bool {
+ t.Helper()
+ data, err := os.ReadFile(exclusionsPath)
+ if err != nil {
+ t.Fatalf("loadExclusions: %v", err)
+ }
+ out := map[string]bool{}
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ out[line] = true
+ }
+ return out
+}
+
+func sha256File(t *testing.T, path string) string {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("sha256File: %v", err)
+ }
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:])
+}
+
+// valuesMatch compares an evaluated Value against a corpus entry's want.
+// Num uses a small epsilon rather than exact equality: some corpus
+// entries (e.g. SQRT(2)) are irrational and only meaningful to within
+// floating-point precision.
+func valuesMatch(got, want Value) bool {
+ if got.Kind != want.Kind {
+ return false
+ }
+ switch got.Kind {
+ case KindNum:
+ return got.Num == want.Num || math.Abs(got.Num-want.Num) < 1e-9
+ case KindStr:
+ return got.Str == want.Str
+ case KindBool:
+ return got.Bool == want.Bool
+ case KindErr:
+ return got.Err == want.Err
+ default: // KindEmpty
+ return true
+ }
+}
+
+// corpusEnv backs the small lookup table VLOOKUP/INDEX/MATCH's corpus
+// entries reference: A1:A3 hold 10/20/30, B1:B3 hold their English
+// names, C1 is deliberately left unset (blank) for the COUNT/COUNTA
+// entries. Every other corpus entry is self-contained (no cell
+// references).
+var corpusEnv = mapEnv{
+ {1, 1}: {Kind: KindNum, Num: 10}, {1, 2}: {Kind: KindStr, Str: "ten"},
+ {2, 1}: {Kind: KindNum, Num: 20}, {2, 2}: {Kind: KindStr, Str: "twenty"},
+ {3, 1}: {Kind: KindNum, Num: 30}, {3, 2}: {Kind: KindStr, Str: "thirty"},
+}
+
+func TestAgainstGoldenCorpus(t *testing.T) {
+ entries := loadCorpus(t)
+ excluded := loadExclusions(t)
+ for _, e := range entries {
+ if excluded[e.Function] {
+ continue
+ }
+ got := Eval(mustParse(t, e.Expr), corpusEnv)
+ want := e.Want.toValue(t)
+ if !valuesMatch(got, want) {
+ t.Errorf("%s: got %+v, corpus says %+v", e.Function, got, want)
+ }
+ }
+}
+
+// TestCorpusIsPinned: see corpusSHA256's doc comment.
+func TestCorpusIsPinned(t *testing.T) {
+ sum := sha256File(t, corpusPath)
+ if sum != corpusSHA256 {
+ t.Fatalf("corpus changed: sha256 is now %s\nIf deliberate, update corpusSHA256 to this value.", sum)
+ }
+}
+
+// TestExclusionsAreRealFunctions: see isRegisteredFunc (registry.go). A
+// name in volatile-excluded.txt that resolves to nothing means the list
+// has rotted — either a typo, or a name that was never actually wired
+// up, silently excluding nothing.
+func TestExclusionsAreRealFunctions(t *testing.T) {
+ for name := range loadExclusions(t) {
+ if !isRegisteredFunc(name) {
+ t.Errorf("%s is excluded but is not a registered function", name)
+ }
+ }
+}
+
+// clockEnv models one recalculation pass for TestClockIsPinnedWithinAPass:
+// Now() answers the instant pinned at construction and never re-reads
+// anything. wall exists only so the test can move something OTHER than
+// the pinned instant and assert it made no difference — deliberately not
+// what Now() answers, which is the entire point (see the test's own doc
+// comment).
+type clockEnv struct {
+ pinned time.Time
+ wall time.Time
+}
+
+func newEnvAt(t time.Time) *clockEnv {
+ return &clockEnv{pinned: t, wall: t}
+}
+
+func (e *clockEnv) advanceWallClock(d time.Duration) {
+ e.wall = e.wall.Add(d)
+}
+
+func (e *clockEnv) CellValue(CellKey) Value { return Value{} }
+func (e *clockEnv) Now() time.Time { return e.pinned }
+
+// A recalculation pass sees one instant. If anything under us reads the
+// wall clock directly, this fails — which is the point. A pinned clock
+// that something beneath re-derives is a pin that only looks like one.
+//
+// This test's own power to catch that is bounded: clockEnv.wall is a
+// value nothing in this package other than this test ever reads, so
+// advancing it cannot, by construction, change what a correctly-written
+// fnNow computes — the load-bearing guarantee is that fn_date.go's fnNow
+// and fnToday call env.Now() and nothing else (no time.Now(), no package
+// clock). This test is the regression pin for that fact once true, not a
+// substitute for having checked it: `grep -rn 'time\.Now' internal/sheets/calc`
+// finds no hits outside this file (which uses time.Date, not time.Now)
+// and fn_date_test.go's TestNowTodayRejectArguments (which uses
+// time.Now() only to seed an env whose CellValue/Now never gets called
+// through NOW/TODAY's args path).
+func TestClockIsPinnedWithinAPass(t *testing.T) {
+ env := newEnvAt(time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC))
+ first := Eval(mustParse(t, "NOW()"), env)
+ env.advanceWallClock(time.Hour) // does not touch the pass's instant
+ second := Eval(mustParse(t, "NOW()"), env)
+ if first.Num != second.Num {
+ t.Fatal("NOW() moved inside one pass; something is reading the wall clock")
+ }
+}
diff --git a/internal/sheets/calc/testdata/corpus/formulas.json b/internal/sheets/calc/testdata/corpus/formulas.json
new file mode 100644
index 0000000..fe9815b
--- /dev/null
+++ b/internal/sheets/calc/testdata/corpus/formulas.json
@@ -0,0 +1,5 @@
+{
+ "note": "HAND-AUTHORED, NOT YET LIBREOFFICE-VERIFIED. tools/gencorpus (repo root) exists to regenerate this file from a real LibreOffice headless run, but no machine in the environment that wrote this file has soffice on PATH. Every \"want\" value below was computed by a person hand-tracing this package's arithmetic/string/lookup rules against well-established, widely-documented spreadsheet semantics -- not against an independent implementation. TestAgainstGoldenCorpus (corpus_test.go) currently proves self-consistency with that reasoning, not third-party agreement with a real spreadsheet. Regenerate with `go run ./tools/gencorpus -out internal/sheets/calc/testdata/corpus/formulas.json` on a machine with LibreOffice installed, review the diff, then update corpusSHA256 in corpus_test.go and this note.",
+ "entries": [
+ ]
+}
diff --git a/internal/sheets/calc/testdata/corpus/volatile-excluded.txt b/internal/sheets/calc/testdata/corpus/volatile-excluded.txt
new file mode 100644
index 0000000..7080045
--- /dev/null
+++ b/internal/sheets/calc/testdata/corpus/volatile-excluded.txt
@@ -0,0 +1,30 @@
+# testdata/corpus/volatile-excluded.txt
+#
+# Functions listed here are volatile: they return a different value on
+# every recalculation, so no single "golden" value captured by one
+# LibreOffice run (or one gencorpus run) can ever match a later run's
+# answer -- including this package's own re-evaluation of the same
+# corpus file in a later `go test` invocation. TestAgainstGoldenCorpus
+# (corpus_test.go) skips any corpus row whose Function is listed here.
+#
+# This file is a list somebody edits, not a filter somebody widens: it is
+# NOT the place to silence an ordinary failing corpus row. Every name
+# below must be spelled exactly as calc.Register (or registerClock)
+# registered it, and TestExclusionsAreRealFunctions asserts each one is a
+# real, currently-registered function -- so this file cannot rot into a
+# wildcard that quietly swallows an unrelated regression.
+#
+# RAND and RANDBETWEEN are the other two volatile functions the plan
+# names, but neither is implemented in this task (outside its ~30
+# function scope) -- add each here in the SAME batch that registers it,
+# not before, or TestExclusionsAreRealFunctions fails immediately.
+
+# NOW: returns the current recalculation pass's pinned instant
+# (env.Now()) as a date serial. Deterministic WITHIN one pass -- see
+# TestClockIsPinnedWithinAPass in corpus_test.go -- but a corpus value
+# captured at generation time can never match what a later pass's real
+# wall clock produces.
+NOW
+
+# TODAY: same reasoning as NOW, truncated to midnight.
+TODAY
diff --git a/tools/gencorpus/corpus.go b/tools/gencorpus/corpus.go
new file mode 100644
index 0000000..b06166e
--- /dev/null
+++ b/tools/gencorpus/corpus.go
@@ -0,0 +1,96 @@
+package main
+
+import (
+ "encoding/csv"
+ "encoding/json"
+ "os"
+ "strconv"
+)
+
+// readResults parses the CSV soffice wrote (one column: the computed
+// value of that row's formula) into plain strings, in row order — pairing
+// them back up with cases is main's job, by index.
+func readResults(path string) ([]string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ r := csv.NewReader(f)
+ rows, err := r.ReadAll()
+ if err != nil {
+ return nil, err
+ }
+ out := make([]string, len(rows))
+ for i, row := range rows {
+ if len(row) > 0 {
+ out[i] = row[0]
+ }
+ }
+ return out, nil
+}
+
+// corpusValueJSON mirrors calc's own corpusValue wire format (see
+// internal/sheets/calc/corpus_test.go) — duplicated here rather than
+// imported, since gencorpus is a standalone tool and calc's copy is
+// unexported test-only plumbing that this package has no business
+// depending on.
+type corpusValueJSON struct {
+ Kind string `json:"kind"`
+ Num float64 `json:"num,omitempty"`
+ Str string `json:"str,omitempty"`
+ Bool bool `json:"bool,omitempty"`
+ Err string `json:"err,omitempty"`
+}
+
+type corpusEntryJSON struct {
+ Function string `json:"function"`
+ Expr string `json:"expr"`
+ Want corpusValueJSON `json:"want"`
+}
+
+type corpusFileJSON struct {
+ Note string `json:"note"`
+ Entries []corpusEntryJSON `json:"entries"`
+}
+
+// classify turns one CSV cell's text into corpusValueJSON. LibreOffice's
+// CSV export renders TRUE/FALSE for booleans, the six Excel error
+// strings verbatim for errors, a plain decimal for numbers, and anything
+// else as text.
+func classify(s string) corpusValueJSON {
+ switch s {
+ case "TRUE":
+ return corpusValueJSON{Kind: "bool", Bool: true}
+ case "FALSE":
+ return corpusValueJSON{Kind: "bool", Bool: false}
+ case "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A":
+ return corpusValueJSON{Kind: "err", Err: s}
+ }
+ if n, err := strconv.ParseFloat(s, 64); err == nil {
+ return corpusValueJSON{Kind: "num", Num: n}
+ }
+ return corpusValueJSON{Kind: "str", Str: s}
+}
+
+func writeCorpusJSON(path string, cases []corpusCase, results []string) error {
+ cf := corpusFileJSON{
+ Note: "Generated by tools/gencorpus from a real LibreOffice headless run. " +
+ "Regenerate with `go run ./tools/gencorpus -out " + path + "` whenever a " +
+ "case is added; commit the result and update corpusSHA256 in " +
+ "internal/sheets/calc/corpus_test.go.",
+ }
+ for i, c := range cases {
+ cf.Entries = append(cf.Entries, corpusEntryJSON{
+ Function: c.Function,
+ Expr: c.Expr,
+ Want: classify(results[i]),
+ })
+ }
+ data, err := json.MarshalIndent(cf, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, append(data, '\n'), 0o644)
+}
diff --git a/tools/gencorpus/main.go b/tools/gencorpus/main.go
new file mode 100644
index 0000000..2882846
--- /dev/null
+++ b/tools/gencorpus/main.go
@@ -0,0 +1,167 @@
+// Command gencorpus regenerates internal/sheets/calc/testdata/corpus/formulas.json
+// from a real spreadsheet. Run it when adding functions; commit the
+// result.
+//
+// go run ./tools/gencorpus -out internal/sheets/calc/testdata/corpus/formulas.json
+//
+// Requires `soffice` (LibreOffice) on PATH. CI does not run this — CI
+// reads the committed file, which is why the file is hash-pinned by
+// calc.TestCorpusIsPinned (internal/sheets/calc/corpus_test.go). After a
+// successful run, review the diff, then run
+//
+// go test ./internal/sheets/calc/ -run TestCorpusIsPinned -v
+//
+// and paste the printed sha256 into corpusSHA256.
+//
+// # Mechanism
+//
+// gencorpus writes a real .ods (OpenDocument spreadsheet) with one
+// formula per row in column A. Each cell carries an ODF-syntax
+// table:formula attribute (an "of:=" prefix, semicolon argument
+// separators — ODF's own formula language, not Excel's comma-separated
+// one), which is what makes LibreOffice treat the cell as a formula to
+// (re)compute, not literal text. It then shells out to
+// `soffice --headless --convert-to csv`, which opens the document,
+// recalculates, and exports the computed values as CSV — not the
+// formula source. Each output row is paired back up with the case that
+// produced it, by row order, into the corpus JSON.
+//
+// This tool has not been run against a real LibreOffice as part of
+// writing it — no machine in that task's environment had soffice on
+// PATH. It is believed correct by careful reading of the ODF and
+// LibreOffice CLI documentation, but the first real run should be
+// treated as this tool's own first test, not a routine regeneration:
+// review its output closely, especially the ODF formula translation
+// (toODFFormula) and whether soffice's headless convert-to actually
+// forces a recalculation before export (if it does not, dates/volatile
+// entries aside, every row would come back as an empty/uncached formula
+// result — in that case the fix is a --convert-to filter option or a
+// recalculation macro, not a change to this file's overall shape).
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+)
+
+// corpusCase is one row to feed LibreOffice: Function names the
+// spreadsheet function under test (matched against
+// testdata/corpus/volatile-excluded.txt and used for
+// TestAgainstGoldenCorpus's per-row skip), Expr is the formula text as a
+// person would type it after "=" — Excel/English syntax, comma argument
+// separators; toODFFormula (ods.go) translates to ODF's own syntax for
+// the file this tool actually writes.
+type corpusCase struct {
+ Function string
+ Expr string
+}
+
+// cases mirrors testdata/corpus/formulas.json's hand-authored entries as
+// closely as a flat, cell-reference-free list can — VLOOKUP/INDEX/MATCH
+// need real cell references (a table_array) that a standalone expression
+// list cannot supply; see the comment below the SUM/AVERAGE/... rows.
+// Keep this list in step with the hand-authored corpus when adding a new
+// function family, so a real gencorpus run reproduces (and can then
+// replace) the same shape of corpus rather than a smaller one.
+var cases = []corpusCase{
+ {"SUM", "SUM(1,2,3)"},
+ {"SUM", "SUM(1,2,3,4,5)"},
+ {"SUM", "SUM(-5,5)"},
+ {"SUM", "SUM(1.5,2.5)"},
+ {"AVERAGE", "AVERAGE(2,4,6)"},
+ {"AVERAGE", "AVERAGE(1,2,3,4)"},
+ {"COUNT", `COUNT(1,2,"a",TRUE)`},
+ {"COUNTA", `COUNTA(1,2,"a",TRUE)`},
+ {"MIN", "MIN(4,2,9,1)"},
+ {"MAX", "MAX(4,2,9,1)"},
+ {"ROUND", "ROUND(3.14159,2)"},
+ {"ROUND", "ROUND(2.5,0)"},
+ {"ROUND", "ROUND(-2.5,0)"},
+ {"ROUNDUP", "ROUNDUP(3.14159,2)"},
+ {"ROUNDUP", "ROUNDUP(3.001,0)"},
+ {"ROUNDDOWN", "ROUNDDOWN(3.99,0)"},
+ {"ROUNDDOWN", "ROUNDDOWN(-3.99,0)"},
+ {"ABS", "ABS(-7)"},
+ {"SQRT", "SQRT(16)"},
+ {"SQRT", "SQRT(2)"},
+ {"POWER", "POWER(2,10)"},
+ {"POWER", "POWER(9,0.5)"},
+ {"MOD", "MOD(10,3)"},
+ {"MOD", "MOD(-10,3)"},
+ {"MOD", "MOD(10,-3)"},
+ {"IF", `IF(TRUE,"yes","no")`},
+ {"IF", `IF(FALSE,1/0,5)`},
+ {"IFS", "IFS(FALSE,1,TRUE,2,TRUE,3)"},
+ {"AND", "AND(TRUE,TRUE,TRUE)"},
+ {"AND", "AND(TRUE,FALSE)"},
+ {"OR", "OR(FALSE,FALSE,TRUE)"},
+ {"NOT", "NOT(TRUE)"},
+ {"IFERROR", `IFERROR(1/0,"fallback")`},
+ {"CONCAT", `CONCAT("foo","bar")`},
+ {"LEFT", `LEFT("hello",3)`},
+ {"RIGHT", `RIGHT("hello",3)`},
+ {"MID", `MID("hello",2,3)`},
+ {"LEN", `LEN("hello")`},
+ {"TRIM", `TRIM(" a b ")`},
+ {"UPPER", `UPPER("MixedCase")`},
+ {"LOWER", `LOWER("MixedCase")`},
+ // VLOOKUP/INDEX/MATCH need real cell references, which this flat
+ // list of standalone expressions cannot supply on their own — a
+ // human extending this tool for the lookup family should add a
+ // small fixed table to a few cells in the sheet (e.g. columns C:D)
+ // alongside the formula column, and reference it here, rather than
+ // trying to express a table_array as a formula literal.
+}
+
+func main() {
+ out := flag.String("out", "", "path to write the corpus JSON to")
+ flag.Parse()
+ if *out == "" {
+ fmt.Fprintln(os.Stderr, "gencorpus: -out is required")
+ os.Exit(2)
+ }
+ if _, err := exec.LookPath("soffice"); err != nil {
+ fmt.Fprintln(os.Stderr, "gencorpus: soffice not found on PATH — install LibreOffice")
+ os.Exit(1)
+ }
+
+ tmpDir, err := os.MkdirTemp("", "gencorpus-*")
+ if err != nil {
+ fatal(err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ odsPath := filepath.Join(tmpDir, "corpus.ods")
+ if err := writeODS(odsPath, cases); err != nil {
+ fatal(err)
+ }
+
+ cmd := exec.Command("soffice", "--headless", "--convert-to", "csv", "--outdir", tmpDir, odsPath)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ fmt.Fprintf(os.Stderr, "gencorpus: soffice failed:\n%s\n", output)
+ fatal(err)
+ }
+
+ csvPath := filepath.Join(tmpDir, "corpus.csv")
+ results, err := readResults(csvPath)
+ if err != nil {
+ fatal(err)
+ }
+ if len(results) != len(cases) {
+ fatal(fmt.Errorf("gencorpus: soffice returned %d rows, expected %d — did headless convert-to actually recalculate?", len(results), len(cases)))
+ }
+
+ if err := writeCorpusJSON(*out, cases, results); err != nil {
+ fatal(err)
+ }
+ fmt.Printf("gencorpus: wrote %d entries to %s\n", len(cases), *out)
+ fmt.Println("gencorpus: next: review the diff, then run `go test ./internal/sheets/calc/ -run TestCorpusIsPinned -v` and update corpusSHA256")
+}
+
+func fatal(err error) {
+ fmt.Fprintln(os.Stderr, "gencorpus:", err)
+ os.Exit(1)
+}
diff --git a/tools/gencorpus/ods.go b/tools/gencorpus/ods.go
new file mode 100644
index 0000000..10631aa
--- /dev/null
+++ b/tools/gencorpus/ods.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+ "archive/zip"
+ "fmt"
+ "os"
+ "strings"
+)
+
+// writeODS writes a minimal OpenDocument spreadsheet at path with one
+// formula per row in column A. See main.go's package doc comment for the
+// overall mechanism and this tool's untested status.
+func writeODS(path string, cases []corpusCase) error {
+ f, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ zw := zip.NewWriter(f)
+
+ // mimetype must be the first entry, stored uncompressed, per the
+ // ODF package format spec — some readers sniff the format from this
+ // exact layout without unzipping the whole archive.
+ mw, err := zw.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
+ if err != nil {
+ return err
+ }
+ if _, err := mw.Write([]byte("application/vnd.oasis.opendocument.spreadsheet")); err != nil {
+ return err
+ }
+
+ cw, err := zw.Create("content.xml")
+ if err != nil {
+ return err
+ }
+ if _, err := cw.Write([]byte(contentXML(cases))); err != nil {
+ return err
+ }
+
+ mfw, err := zw.Create("META-INF/manifest.xml")
+ if err != nil {
+ return err
+ }
+ if _, err := mfw.Write([]byte(manifestXML)); err != nil {
+ return err
+ }
+
+ return zw.Close()
+}
+
+const manifestXML = `<?xml version="1.0" encoding="UTF-8"?>
+<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
+ <manifest:file-entry manifest:full-path="/" manifest:version="1.2" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>
+ <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
+</manifest:manifest>
+`
+
+func contentXML(cases []corpusCase) string {
+ var rows strings.Builder
+ for _, c := range cases {
+ rows.WriteString(" <table:table-row>")
+ rows.WriteString(fmt.Sprintf(`<table:table-cell table:formula="of:=%s"/>`, toODFFormula(c.Expr)))
+ rows.WriteString("</table:table-row>\n")
+ }
+ return `<?xml version="1.0" encoding="UTF-8"?>
+<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
+ xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
+ xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
+ office:version="1.2">
+ <office:body>
+ <office:spreadsheet>
+ <table:table table:name="Corpus">
+` + rows.String() + ` </table:table>
+ </office:spreadsheet>
+ </office:body>
+</office:document-content>
+`
+}
+
+// toODFFormula translates a comma-argument-separated Excel-style formula
+// (this tool's own case list, and this project's own formula syntax —
+// see internal/sheets/calc/parse.go) into ODF's own formula syntax, which
+// uses semicolons between arguments. This is a blunt, comma-to-semicolon
+// substitution — it is wrong for any formula whose comma appears inside a
+// quoted string argument (a text literal that itself contains a comma).
+// None of this tool's own cases do; a human adding one that does needs to
+// rewrite this function to skip quoted spans, or avoid the comma.
+func toODFFormula(expr string) string {
+ return strings.ReplaceAll(expr, ",", ";")
+}