calc: generate the reference corpus from a real LibreOffice
tools/gencorpus had never been run. Its first real run, against
LibreOffice 26.8.0.3, was treated as the tool's own first test, and
found three defects that would each have produced a wrong corpus rather
than a failed run:
- the mimetype zip entry used zip.CreateHeader, whose data-descriptor
flag makes LibreOffice refuse the whole document ("source file could
not be loaded"). Now written with CreateRaw.
- content.xml did not declare xmlns:of. The "of:" in table:formula is
an XML namespace prefix; without the declaration every cell came
back Err:510, which the old code would have recorded as a string.
- IFS and CONCAT came back #NAME?: ODF vendor-namespaces post-ODF
Microsoft functions as COM.MICROSOFT.*, and the old translation
emitted their bare Excel names. Recording those would have turned
"LibreOffice has never heard of this spelling" into "a real
spreadsheet says these functions are errors".
Also: -soffice flag (PATH is not the only place LibreOffice lives),
-env:UserInstallation on every invocation, and the formula column moved
to D so the support table sits at A1:B3 with C1 blank — the exact table
calc's corpusEnv and xlsx's oracle workbook provide, so generated
expressions are evaluable by the harnesses meant to read them. The old
layout would have emitted B1:C3 references that no consuming test could
resolve.
VERIFIED
Recalculation on export, not assumed: all 60 formula cells are written
with a poisoned office:value cache of 999999 and the run fails if that
value comes back. It does not.
Every guard was tested by breaking the tool on purpose: dropping
xmlns:of, dropping the COM.MICROSOFT mapping, reverting to
CreateHeader, and pointing -soffice at nothing each fail the run and
write no output file.
59 of the 60 hand-authored "want" values were confirmed byte-for-byte
by LibreOffice. The one change is SQRT(2), 1.4142135623730951 ->
1.4142135623731 — LibreOffice's CSV export rendering the same double
at ~15 significant digits. valuesMatch's 1e-9 epsilon covers it.
Two consecutive runs produce a byte-identical file.
SUM(1,2,"a",TRUE): ours #VALUE!, LibreOffice #VALUE!, excelize 4.
Ours is right; excelize is the outlier. Not a corpus case; recorded in
oracle_divergences_test.go.
TRIM(" a b "): LibreOffice returns "a b", agreeing with ours
against excelize — a third engine corroborating the one divergence
the oracle harness already records.
NOT VERIFIED
This is conformance to LibreOffice Calc, which is not Excel. Measured:
SUM(1,2,"3") is 6 in Excel, in ours and in excelize, but #VALUE! in
Calc. No current corpus case lands on such a difference, but nothing
here would stop a future one from importing Calc's answer as truth;
the excelize cross-check is what guards that.
No corpus value was traced to real Microsoft Excel. Excel's answers
are cited from documentation and from excelize's agreement, not from
running Excel.
Nothing was hand-authored back in and nothing was dropped: 60 entries
before, 60 after, same expressions, same order.
No formula semantics were changed. corpusSHA256 re-pinned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
8 files changed,
+513
−135
internal/sheets/calc/corpus_test.go+20 −13internal/sheets/calc/testdata/corpus/formulas.json+2 −2internal/sheets/xlsx/oracle_divergences_test.go+17 −1internal/sheets/xlsx/oracle_test.go+16 −10tools/gencorpus/corpus.go+114 −19tools/gencorpus/main.go+72 −63tools/gencorpus/ods.go+129 −27tools/gencorpus/soffice.go+143 −0
CI passed — run details
Intent
diff --git a/internal/sheets/calc/corpus_test.go b/internal/sheets/calc/corpus_test.go| index 534b35c..f1a5565 100644 |
| --- a/internal/sheets/calc/corpus_test.go |
| +++ b/internal/sheets/calc/corpus_test.go |
| @@ -21,7 +21,7 @@ import ( |
| // 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 = "8f79b9dc83b611810adb4add49ad1878a0bf9c0a7570ce0424a7849f4f36a491" |
| +const corpusSHA256 = "235a35080ea75bfc15e697c60f39e4995b793f7a3a41257c3f6e5d7b94948b05" |
| // 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 |
| @@ -92,18 +92,25 @@ 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. TestAgainstReferenceCorpus |
| -// 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. |
| +// Provenance, as of 2026-08-31: this file IS now generated. Every "want" |
| +// value is what LibreOffice 26.8.0.3 computed for that exact formula in |
| +// a real headless `--convert-to csv` run driven by tools/gencorpus, so |
| +// TestAgainstReferenceCorpus checks agreement with an independent |
| +// implementation, not self-consistency with the reasoning that produced |
| +// the corpus. It replaced a hand-authored file, and the regeneration |
| +// changed exactly one value out of sixty: SQRT(2), from |
| +// 1.4142135623730951 to 1.4142135623731, which is LibreOffice's CSV |
| +// export rendering the same double at ~15 significant digits, not a |
| +// disagreement (valuesMatch's 1e-9 epsilon covers it). The other 59 |
| +// hand-traced values were confirmed unchanged. |
| +// |
| +// Two things this corpus is still NOT. It is not Excel: LibreOffice |
| +// Calc differs from Excel in real places (SUM(1,2,"3") is 6 in Excel |
| +// and #VALUE! in Calc, for one measured example), so agreement here is |
| +// agreement with Calc. And it is not a second opinion on its own scope |
| +// — a value no case asks about is not covered by anything. The |
| +// Excel-facing cross-check is TestAgainstExcelizeOracle |
| +// (internal/sheets/xlsx/oracle_test.go), against a third engine. |
| func loadCorpus(t *testing.T) []corpusEntry { |
| t.Helper() |
| data, err := os.ReadFile(corpusPath) |
diff --git a/internal/sheets/calc/testdata/corpus/formulas.json b/internal/sheets/calc/testdata/corpus/formulas.json| index 1725a8e..6fa1fca 100644 |
| --- a/internal/sheets/calc/testdata/corpus/formulas.json |
| +++ b/internal/sheets/calc/testdata/corpus/formulas.json |
| @@ -1,5 +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. TestAgainstReferenceCorpus (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.", |
| + "note": "GENERATED by tools/gencorpus from a real headless LibreOffice 26.8.0.3 run: each entry's \"want\" is what that LibreOffice computed for that exact formula, exported via `--convert-to csv`, not a value anyone reasoned out. Recalculation on export is proven per run, not assumed: every formula cell is written with a poisoned office:value cache (999999) and the run fails if that value comes back. Two honest caveats. (1) The values come through LibreOffice's CSV export, which renders a number at roughly 15 significant digits with trailing zeros stripped -- SQRT(2) is recorded as 1.4142135623731, not the full 1.4142135623730951; corpus_test.go's valuesMatch compares numbers with a 1e-9 epsilon, so this is a rendering artifact, not a disagreement. (2) LibreOffice Calc is not Excel. Where the two are known to differ, the difference is recorded and reasoned about in internal/sheets/xlsx/oracle_divergences_test.go, cross-checked against excelize's independent formula engine -- this file records Calc's answer. Regenerate with `go run ./tools/gencorpus -out internal/sheets/calc/testdata/corpus/formulas.json` (add -soffice /path/to/soffice if it is not on PATH) whenever a case is added; commit the result and update corpusSHA256 in internal/sheets/calc/corpus_test.go.", |
| "entries": [ |
| {"function": "SUM", "expr": "SUM(1,2,3)", "want": {"kind": "num", "num": 6}}, |
| {"function": "SUM", "expr": "SUM(1,2,3,4,5)", "want": {"kind": "num", "num": 15}}, |
| @@ -21,7 +21,7 @@ |
| {"function": "ABS", "expr": "ABS(-7)", "want": {"kind": "num", "num": 7}}, |
| {"function": "ABS", "expr": "ABS(7)", "want": {"kind": "num", "num": 7}}, |
| {"function": "SQRT", "expr": "SQRT(16)", "want": {"kind": "num", "num": 4}}, |
| - {"function": "SQRT", "expr": "SQRT(2)", "want": {"kind": "num", "num": 1.4142135623730951}}, |
| + {"function": "SQRT", "expr": "SQRT(2)", "want": {"kind": "num", "num": 1.4142135623731}}, |
| {"function": "POWER", "expr": "POWER(2,10)", "want": {"kind": "num", "num": 1024}}, |
| {"function": "POWER", "expr": "POWER(9,0.5)", "want": {"kind": "num", "num": 3}}, |
| {"function": "MOD", "expr": "MOD(10,3)", "want": {"kind": "num", "num": 1}}, |
diff --git a/internal/sheets/xlsx/oracle_divergences_test.go b/internal/sheets/xlsx/oracle_divergences_test.go| index b789923..33d8d93 100644 |
| --- a/internal/sheets/xlsx/oracle_divergences_test.go |
| +++ b/internal/sheets/xlsx/oracle_divergences_test.go |
| @@ -31,6 +31,16 @@ type oracleDivergence struct { |
| // investigation. That is a real finding, not a placeholder: 59 of 60 |
| // entries have excelize, an entirely independent formula engine, |
| // agreeing with ours. |
| +// |
| +// One disagreement deliberately NOT listed here, because it is not |
| +// about a corpus entry: SUM(1,2,"a",TRUE). Ours returns #VALUE!, |
| +// LibreOffice 26.8.0.3 returns #VALUE!, and excelize returns 4 — |
| +// excelize silently skips a text constant SUM cannot coerce, where |
| +// Excel treats a literal text argument passed DIRECTLY to SUM as a type |
| +// error (text reached through a range reference is the case that is |
| +// ignored). Measured 2026-08-31, not assumed. It is absent from the |
| +// corpus, so this harness never compares it; adding it as a case would |
| +// make it a listed divergence with excelize on the wrong side. |
| var oracleDivergences = []oracleDivergence{ |
| { |
| Expr: `TRIM(" a b ")`, |
| @@ -54,9 +64,15 @@ var oracleDivergences = []oracleDivergence{ |
| // documented contract, not a legitimate alternate reading of |
| // TRIM's semantics. |
| // |
| + // Corroborated 2026-08-31 by a THIRD engine: the corpus is now |
| + // generated from a real LibreOffice 26.8.0.3 headless run, and |
| + // Calc returns "a b" for this same expression -- the same answer |
| + // ours gives. So two independent implementations and Microsoft's |
| + // own documentation agree, and excelize alone does not. |
| + // |
| // We believe OUR engine here: it matches real Excel's documented |
| // and observable behavior; excelize's TRIM does not match its own |
| // doc comment, let alone Excel's. |
| - Note: "excelize v2.11.0's TRIM is strings.TrimSpace only (does not collapse interior whitespace runs), contradicting both its own doc comment and real Excel's documented TRIM behavior. Ours is correct.", |
| + Note: "excelize v2.11.0's TRIM is strings.TrimSpace only (does not collapse interior whitespace runs), contradicting both its own doc comment and real Excel's documented TRIM behavior. Ours is correct, and LibreOffice 26.8.0.3 independently agrees with ours.", |
| }, |
| } |
diff --git a/internal/sheets/xlsx/oracle_test.go b/internal/sheets/xlsx/oracle_test.go| index 7234756..2844e32 100644 |
| --- a/internal/sheets/xlsx/oracle_test.go |
| +++ b/internal/sheets/xlsx/oracle_test.go |
| @@ -1,15 +1,21 @@ |
| // Cross-checking against excelize — why this lives here, not in calc. |
| // |
| -// calc/testdata/corpus/formulas.json (internal/sheets/calc) was meant to |
| -// be generated by driving a real LibreOffice headless, so the formula |
| -// engine is checked against an independent implementation rather than |
| -// its own author's reasoning. No machine that has touched this repo has |
| -// had soffice on PATH, so the corpus was hand-authored instead — see |
| -// loadCorpus's doc comment in calc/corpus_test.go. That corpus proves |
| -// self-consistency, not conformance: it shipped, for a while, with |
| -// COUNT(1,2,"a",TRUE) expecting 2 (a wrong implementation's answer) until |
| -// a human reviewer noticed real spreadsheets return 3 — a mistake the |
| -// corpus, checking only itself, could not have caught. |
| +// calc/testdata/corpus/formulas.json (internal/sheets/calc) is generated |
| +// by driving a real LibreOffice headless (26.8.0.3, as of 2026-08-31), |
| +// so the formula engine is checked against an independent implementation |
| +// rather than its own author's reasoning. It was hand-authored for a |
| +// while, and that corpus proved self-consistency, not conformance: it |
| +// shipped, for a while, with COUNT(1,2,"a",TRUE) expecting 2 (a wrong |
| +// implementation's answer) until a human reviewer noticed real |
| +// spreadsheets return 3 — a mistake the corpus, checking only itself, |
| +// could not have caught. |
| +// |
| +// A LibreOffice-generated corpus closes that gap but opens a narrower |
| +// one: LibreOffice Calc is not Excel. Measured directly, Calc returns |
| +// #VALUE! for SUM(1,2,"3") where Excel (and this engine, and excelize) |
| +// return 6. So conformance to Calc is not conformance to Excel, and a |
| +// second independent engine is still worth having — which is what this |
| +// file is. |
| // |
| // excelize (github.com/xuri/excelize/v2, already in go.mod for XLSX |
| // import/export) ships its own formula engine, CalcCellValue — a |
diff --git a/tools/gencorpus/corpus.go b/tools/gencorpus/corpus.go| index 41dd80e..7e22917 100644 |
| --- a/tools/gencorpus/corpus.go |
| +++ b/tools/gencorpus/corpus.go |
| @@ -3,13 +3,19 @@ package main |
| import ( |
| "encoding/csv" |
| "encoding/json" |
| + "fmt" |
| "os" |
| "strconv" |
| + "strings" |
| ) |
| -// 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. |
| +// readResults parses the CSV soffice wrote into plain strings, in row |
| +// order — pairing them back up with cases is main's job, by index. The |
| +// value read is field formulaColumnIndex (column D), NOT field 0: |
| +// columns A:C are the fixed support table the lookup cases reference |
| +// (see ods.go's lookupTableRows), so field 0 is "10", "20", "30", then |
| +// nothing. A short row means the formula cell exported nothing at all, |
| +// which checkResults treats as a failed run rather than an empty value. |
| func readResults(path string) ([]string, error) { |
| f, err := os.Open(path) |
| if err != nil { |
| @@ -18,14 +24,15 @@ func readResults(path string) ([]string, error) { |
| defer f.Close() |
| r := csv.NewReader(f) |
| + r.FieldsPerRecord = -1 |
| 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] |
| + if len(row) > formulaColumnIndex { |
| + out[i] = row[formulaColumnIndex] |
| } |
| } |
| return out, nil |
| @@ -74,25 +81,86 @@ func classify(s string) corpusValueJSON { |
| 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.", |
| +// writeCorpusJSON writes the corpus file. It does NOT hand the whole |
| +// document to json.MarshalIndent: that puts every field of every entry |
| +// on its own line, so a regeneration that changed one value still |
| +// produces a several-hundred-line diff and the one row a reviewer needs |
| +// to see is buried in it. Each entry is written on a single line |
| +// instead, in the exact spacing the file has always had, so `git diff` |
| +// after a regeneration shows precisely the rows LibreOffice answered |
| +// differently and nothing else. Reviewing that diff is the whole point |
| +// of this tool's workflow (see main's closing message); a format that |
| +// defeats it is not a neutral choice. |
| +func writeCorpusJSON(path string, cases []corpusCase, results []string, version string) error { |
| + note, err := json.Marshal(corpusNote(path, version)) |
| + if err != nil { |
| + return err |
| } |
| + var b strings.Builder |
| + b.WriteString("{\n \"note\": ") |
| + b.Write(note) |
| + b.WriteString(",\n \"entries\": [\n") |
| for i, c := range cases { |
| - cf.Entries = append(cf.Entries, corpusEntryJSON{ |
| - Function: c.Function, |
| - Expr: c.Expr, |
| - Want: classify(results[i]), |
| - }) |
| + line, err := entryLine(corpusEntryJSON{Function: c.Function, Expr: c.Expr, Want: classify(results[i])}) |
| + if err != nil { |
| + return err |
| + } |
| + b.WriteString(" " + line) |
| + if i < len(cases)-1 { |
| + b.WriteString(",") |
| + } |
| + b.WriteString("\n") |
| } |
| - data, err := json.MarshalIndent(cf, "", " ") |
| + b.WriteString(" ]\n}\n") |
| + return os.WriteFile(path, []byte(b.String()), 0o644) |
| +} |
| + |
| +// entryLine renders one entry as a single JSON object. Every scalar goes |
| +// through json.Marshal rather than fmt, so quoting and float formatting |
| +// are the encoding/json package's problem, not this function's; only the |
| +// whitespace between fields is chosen here. |
| +func entryLine(e corpusEntryJSON) (string, error) { |
| + fn, err := json.Marshal(e.Function) |
| if err != nil { |
| - return err |
| + return "", err |
| + } |
| + expr, err := json.Marshal(e.Expr) |
| + if err != nil { |
| + return "", err |
| } |
| - return os.WriteFile(path, append(data, '\n'), 0o644) |
| + want, err := wantLine(e.Want) |
| + if err != nil { |
| + return "", err |
| + } |
| + return fmt.Sprintf(`{"function": %s, "expr": %s, "want": %s}`, fn, expr, want), nil |
| +} |
| + |
| +// wantLine renders one "want" value. The kind's own field is always |
| +// written, zero value included: corpusValueJSON's omitempty tags would |
| +// drop "num": 0 and "bool": false entirely, leaving a bare {"kind": |
| +// "num"} that reads like a value the tool failed to record rather than |
| +// like the number zero. A corpus is read by people deciding whether it |
| +// is trustworthy; it should not make its own honest answers look like |
| +// gaps. |
| +func wantLine(v corpusValueJSON) (string, error) { |
| + var payload any |
| + switch v.Kind { |
| + case "num": |
| + payload = v.Num |
| + case "str": |
| + payload = v.Str |
| + case "bool": |
| + payload = v.Bool |
| + case "err": |
| + payload = v.Err |
| + default: |
| + return `{"kind": "` + v.Kind + `"}`, nil |
| + } |
| + enc, err := json.Marshal(payload) |
| + if err != nil { |
| + return "", err |
| + } |
| + return fmt.Sprintf(`{"kind": %q, "%s": %s}`, v.Kind, v.Kind, enc), nil |
| } |
| // existingEntryCount reads path's current entry count, if the file |
| @@ -111,3 +179,30 @@ func existingEntryCount(path string) (int, error) { |
| } |
| return len(cf.Entries), nil |
| } |
| + |
| +// corpusNote is the provenance line written into the corpus file |
| +// itself. It names the exact LibreOffice build that computed the values |
| +// (captured from `soffice --version` at run time, not typed in by hand) |
| +// because "generated" without a version is not a reproducible claim, and |
| +// it names the one place where the recorded values are not literally |
| +// what LibreOffice printed. |
| +func corpusNote(path, version string) string { |
| + return "GENERATED by tools/gencorpus from a real headless " + version + |
| + " run: each entry's \"want\" is what that LibreOffice computed for that " + |
| + "exact formula, exported via `--convert-to csv`, not a value anyone " + |
| + "reasoned out. Recalculation on export is proven per run, not assumed: " + |
| + "every formula cell is written with a poisoned office:value cache (999999) " + |
| + "and the run fails if that value comes back. Two honest caveats. (1) The " + |
| + "values come through LibreOffice's CSV export, which renders a number at " + |
| + "roughly 15 significant digits with trailing zeros stripped -- SQRT(2) is " + |
| + "recorded as 1.4142135623731, not the full 1.4142135623730951; " + |
| + "corpus_test.go's valuesMatch compares numbers with a 1e-9 epsilon, so this " + |
| + "is a rendering artifact, not a disagreement. (2) LibreOffice Calc is not Excel. Where the two are known " + |
| + "to differ, the difference is recorded and reasoned about in " + |
| + "internal/sheets/xlsx/oracle_divergences_test.go, cross-checked against " + |
| + "excelize's independent formula engine -- this file records Calc's answer. " + |
| + "Regenerate with `go run ./tools/gencorpus -out " + path + |
| + "` (add -soffice /path/to/soffice if it is not on PATH) whenever a case is " + |
| + "added; commit the result and update corpusSHA256 in " + |
| + "internal/sheets/calc/corpus_test.go." |
| +} |
diff --git a/tools/gencorpus/main.go b/tools/gencorpus/main.go| index 201612c..5d45879 100644 |
| --- a/tools/gencorpus/main.go |
| +++ b/tools/gencorpus/main.go |
| @@ -4,7 +4,9 @@ |
| // |
| // go run ./tools/gencorpus -out internal/sheets/calc/testdata/corpus/formulas.json |
| // |
| -// Requires `soffice` (LibreOffice) on PATH. CI does not run this — CI |
| +// Requires LibreOffice. `soffice` is looked up on PATH by default; pass |
| +// -soffice /path/to/soffice when it is installed somewhere PATH does not |
| +// reach (a user-space install, for instance). 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 |
| @@ -16,7 +18,7 @@ |
| // # Mechanism |
| // |
| // gencorpus writes a real .ods (OpenDocument spreadsheet) with one |
| -// formula per row in column A. Each cell carries an ODF-syntax |
| +// formula per row in column D. 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 |
| @@ -26,24 +28,31 @@ |
| // 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). |
| +// # First real run, and what it found |
| +// |
| +// This tool was written without a LibreOffice to run it against, and its |
| +// first real run (LibreOffice 26.8.0.3, 2026-08-31) was treated as its |
| +// own first test. It found three real defects, all now fixed and all |
| +// documented at the code that fixes them: |
| +// |
| +// - the mimetype zip entry was written with zip.CreateHeader, whose |
| +// data-descriptor flag makes LibreOffice refuse the whole document |
| +// ("source file could not be loaded") — see writeODS; |
| +// - content.xml did not declare xmlns:of, without which every formula |
| +// evaluates to Err:510 — see contentXML; |
| +// - soffice was invoked with no -env:UserInstallation, which can drop |
| +// it into an interactive first-run flow — see runSoffice. |
| +// |
| +// Recalculation on export is not assumed: every formula cell is written |
| +// with a poisoned office:value cache (poisonedCacheValue) that no case |
| +// can legitimately produce, and checkResults fails the run if that value |
| +// comes back. It does not — LibreOffice recalculates on convert-to. |
| package main |
| import ( |
| "flag" |
| "fmt" |
| "os" |
| - "os/exec" |
| "path/filepath" |
| ) |
| @@ -59,33 +68,33 @@ type corpusCase struct { |
| Expr string |
| } |
| -// cases has the same 60-case shape and per-function coverage as |
| -// testdata/corpus/formulas.json's hand-authored entries — a prior |
| -// version of this list stopped at 41, silently dropping 9 plain scalar |
| -// cases with no structural excuse plus the entire VLOOKUP/INDEX/MATCH |
| -// family and three more cases that read a genuinely blank cell, which |
| -// would have made a real run overwrite the corpus's honest |
| -// "hand-authored, unverified" note with a claim of full generation while |
| -// actually producing a SMALLER corpus. Caught by code review, not by |
| -// this tool running successfully, since nothing here checked its own |
| -// output size against the file it was about to overwrite. |
| +// cases is the full case list, in the same order and with the same cell |
| +// references as testdata/corpus/formulas.json — so a run replaces the |
| +// file's "want" values and nothing else, and the diff is purely a |
| +// statement about what LibreOffice computed. |
| // |
| -// The ten cases that need real cell references (VLOOKUP/INDEX/MATCH, and |
| -// the three that read a deliberately blank cell) use writeODS's own |
| -// fixed lookup table at B1:C3 (see ods.go's lookupTableRows) rather than |
| -// A1:B3, because column A here is already occupied by each row's own |
| -// formula case — row 1 is SUM's first case, not lookup data. That means |
| -// their Expr text will NOT come out byte-identical to |
| -// formulas.json's hand-authored VLOOKUP/INDEX/MATCH rows (which use |
| -// A1:B3, matching corpus_test.go's own corpusEnv) — expected and fine; |
| -// a real gencorpus run replaces the hand-authored file wholesale, it |
| -// does not need to diff field-by-field against it. |
| +// The cell references matter. Ten cases read real cells |
| +// (VLOOKUP/INDEX/MATCH, and the three reading a deliberately blank |
| +// cell), and they use A1:B3 with C1 blank — exactly the table |
| +// calc/corpus_test.go's corpusEnv and xlsx/oracle_excelize_test.go's |
| +// newOracleWorkbook set up. That is why writeODS puts each row's own |
| +// formula in column D rather than column A: column A is support data |
| +// here, not case data. An earlier version of this list put the table at |
| +// B1:C3 and the formulas in column A, which would have produced a corpus |
| +// whose lookup entries no consuming test could evaluate — corpusEnv's |
| +// B1:B3 are the English names, not the numbers, so every generated |
| +// lookup row would have failed against the harness meant to read it. |
| // |
| -// Keep this list at parity with testdata/corpus/formulas.json — same |
| -// per-function coverage, not necessarily identical cell references — |
| -// whenever a new function family is added, so a real gencorpus run |
| -// always produces a corpus at least as large as the one it replaces, |
| -// never a smaller one that silently narrows coverage. |
| +// A prior version of this list also stopped at 41, silently dropping 9 |
| +// plain scalar cases with no structural excuse plus the entire |
| +// VLOOKUP/INDEX/MATCH family and three more cases that read a genuinely |
| +// blank cell. Caught by code review, not by this tool running |
| +// successfully, since nothing here checked its own output size against |
| +// the file it was about to overwrite; main now does. |
| +// |
| +// Keep this list at parity with testdata/corpus/formulas.json whenever a |
| +// new function family is added, so a real gencorpus run always produces |
| +// a corpus at least as large as the one it replaces. |
| var cases = []corpusCase{ |
| {"SUM", "SUM(1,2,3)"}, |
| {"SUM", "SUM(1,2,3,4,5)"}, |
| @@ -93,11 +102,8 @@ var cases = []corpusCase{ |
| {"SUM", "SUM(1.5,2.5)"}, |
| {"AVERAGE", "AVERAGE(2,4,6)"}, |
| {"AVERAGE", "AVERAGE(1,2,3,4)"}, |
| - {"AVERAGE", "AVERAGE(B1,B2,B3)"}, |
| {"COUNT", `COUNT(1,2,"a",TRUE)`}, |
| - {"COUNT", "COUNT(B1,D1)"}, |
| {"COUNTA", `COUNTA(1,2,"a",TRUE)`}, |
| - {"COUNTA", "COUNTA(B1,D1)"}, |
| {"MIN", "MIN(4,2,9,1)"}, |
| {"MAX", "MAX(4,2,9,1)"}, |
| {"ROUND", "ROUND(3.14159,2)"}, |
| @@ -140,26 +146,26 @@ var cases = []corpusCase{ |
| {"TRIM", `TRIM(" a b ")`}, |
| {"UPPER", `UPPER("MixedCase")`}, |
| {"LOWER", `LOWER("MixedCase")`}, |
| - {"VLOOKUP", "VLOOKUP(20,B1:C3,2,FALSE)"}, |
| - {"VLOOKUP", "VLOOKUP(25,B1:C3,2,TRUE)"}, |
| - {"VLOOKUP", "VLOOKUP(99,B1:C3,2,FALSE)"}, |
| - {"INDEX", "INDEX(B1:C3,2,2)"}, |
| - {"INDEX", "INDEX(B1:C3,3,1)"}, |
| - {"MATCH", "MATCH(20,B1:B3,0)"}, |
| - {"MATCH", "MATCH(25,B1:B3,1)"}, |
| + {"VLOOKUP", "VLOOKUP(20,A1:B3,2,FALSE)"}, |
| + {"VLOOKUP", "VLOOKUP(25,A1:B3,2,TRUE)"}, |
| + {"VLOOKUP", "VLOOKUP(99,A1:B3,2,FALSE)"}, |
| + {"INDEX", "INDEX(A1:B3,2,2)"}, |
| + {"INDEX", "INDEX(A1:B3,3,1)"}, |
| + {"MATCH", "MATCH(20,A1:A3,0)"}, |
| + {"MATCH", "MATCH(25,A1:A3,1)"}, |
| + {"COUNT", "COUNT(A1,C1)"}, |
| + {"COUNTA", "COUNTA(A1,C1)"}, |
| + {"AVERAGE", "AVERAGE(A1,A2,A3)"}, |
| } |
| func main() { |
| out := flag.String("out", "", "path to write the corpus JSON to") |
| + soffice := flag.String("soffice", "soffice", "path to the LibreOffice `soffice` binary") |
| 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 { |
| @@ -167,15 +173,21 @@ func main() { |
| } |
| defer os.RemoveAll(tmpDir) |
| + version, err := sofficeVersion(*soffice, tmpDir) |
| + if err != nil { |
| + fatal(fmt.Errorf("cannot run %q: %w (install LibreOffice, or pass -soffice /path/to/soffice)", *soffice, err)) |
| + } |
| + fmt.Printf("gencorpus: using %s (%s)\n", version, *soffice) |
| + |
| // Refuse to silently narrow an existing corpus: if *out already has |
| // more entries than this run is about to produce, something regressed |
| - // (a case dropped from the list below without a function actually |
| + // (a case dropped from the list above without a function actually |
| // going away) and overwriting it would replace an honest "hand- |
| // authored, unverified" corpus — or a real one — with a smaller file |
| // that still claims full coverage. See cases' own doc comment for the |
| // concrete incident this guards against. |
| if existing, err := existingEntryCount(*out); err == nil && existing > len(cases) { |
| - fatal(fmt.Errorf("gencorpus: %s already has %d entries; this run would only produce %d — refusing to narrow the corpus (add the missing cases to gencorpus's own list, don't just let it overwrite)", *out, existing, len(cases))) |
| + fatal(fmt.Errorf("%s already has %d entries; this run would only produce %d — refusing to narrow the corpus (add the missing cases to gencorpus's own list, don't just let it overwrite)", *out, existing, len(cases))) |
| } |
| odsPath := filepath.Join(tmpDir, "corpus.ods") |
| @@ -183,22 +195,19 @@ func main() { |
| 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) |
| + csvPath, err := runSoffice(*soffice, tmpDir, odsPath) |
| + if err != nil { |
| 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 := checkResults(cases, results); err != nil { |
| + fatal(err) |
| } |
| - if err := writeCorpusJSON(*out, cases, results); err != nil { |
| + if err := writeCorpusJSON(*out, cases, results, version); err != nil { |
| fatal(err) |
| } |
| fmt.Printf("gencorpus: wrote %d entries to %s\n", len(cases), *out) |
diff --git a/tools/gencorpus/ods.go b/tools/gencorpus/ods.go| index d5e154f..1f84bed 100644 |
| --- a/tools/gencorpus/ods.go |
| +++ b/tools/gencorpus/ods.go |
| @@ -3,16 +3,22 @@ package main |
| import ( |
| "archive/zip" |
| "fmt" |
| + "hash/crc32" |
| "os" |
| + "regexp" |
| "strconv" |
| "strings" |
| ) |
| +// odsMimetype is the ODF package media type, written as the archive's |
| +// first, uncompressed entry. |
| +const odsMimetype = "application/vnd.oasis.opendocument.spreadsheet" |
| + |
| // writeODS writes a minimal OpenDocument spreadsheet at path with one |
| -// formula per row in column A, plus a small fixed lookup table in |
| -// columns B:C, rows 1-3 (see lookupTableRows) that the VLOOKUP/INDEX/ |
| -// MATCH cases reference. See main.go's package doc comment for the |
| -// overall mechanism and this tool's untested status. |
| +// formula per row in column D, plus the same small fixed lookup table |
| +// calc's own corpusEnv provides in columns A:B, rows 1-3 (see |
| +// lookupTableRows), with C1 left blank. See main.go's package doc |
| +// comment for the overall mechanism. |
| func writeODS(path string, cases []corpusCase) error { |
| f, err := os.Create(path) |
| if err != nil { |
| @@ -22,14 +28,25 @@ func writeODS(path string, cases []corpusCase) error { |
| 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}) |
| + // mimetype must be the first entry, stored uncompressed, per the ODF |
| + // package format spec. It is written with CreateRaw, not |
| + // CreateHeader, on purpose: CreateHeader streams the entry and |
| + // therefore sets the "sizes follow in a data descriptor" flag bit |
| + // even for a Store entry, and LibreOffice's ODF package reader |
| + // rejects that outright -- the whole document fails to open with |
| + // "source file could not be loaded", not with a per-cell error. |
| + // Verified against LibreOffice 26.8.0.3: CreateHeader here produces |
| + // an unloadable file, CreateRaw with the sizes and CRC filled in |
| + // produces a loadable one. |
| + mh := &zip.FileHeader{Name: "mimetype", Method: zip.Store} |
| + mh.CRC32 = crc32.ChecksumIEEE([]byte(odsMimetype)) |
| + mh.CompressedSize64 = uint64(len(odsMimetype)) |
| + mh.UncompressedSize64 = uint64(len(odsMimetype)) |
| + mw, err := zw.CreateRaw(mh) |
| if err != nil { |
| return err |
| } |
| - if _, err := mw.Write([]byte("application/vnd.oasis.opendocument.spreadsheet")); err != nil { |
| + if _, err := mw.Write([]byte(odsMimetype)); err != nil { |
| return err |
| } |
| @@ -59,15 +76,16 @@ const manifestXML = `<?xml version="1.0" encoding="UTF-8"?> |
| </manifest:manifest> |
| ` |
| -// lookupTableRows is the fixed B:C data VLOOKUP/INDEX/MATCH cases |
| -// reference as "B1:C3" (numbers) or "B1:B3" (the number column alone) — |
| -// the same 10/20/30 -> ten/twenty/thirty table |
| -// internal/sheets/calc/fn_lookup_test.go's lookupTable() and |
| -// corpus_test.go's corpusEnv use, just shifted one column right because |
| -// column A here is already occupied by each row's own formula case (row |
| -// 1 is SUM's first case, not lookup data — the table lives beside it, |
| -// not inside it). D1 is deliberately left unwritten so it reads as a |
| -// genuinely blank cell for the COUNT(B1,D1)/COUNTA(B1,D1) cases. |
| +// lookupTableRows is the fixed A:B data VLOOKUP/INDEX/MATCH cases |
| +// reference as "A1:B3" (both columns) or "A1:A3" (the number column |
| +// alone) -- byte-identical to the table |
| +// internal/sheets/calc/corpus_test.go's corpusEnv and |
| +// internal/sheets/xlsx/oracle_excelize_test.go's newOracleWorkbook both |
| +// set up, so a generated corpus entry's Expr can be evaluated unchanged |
| +// by both of those harnesses. C1 is deliberately left blank so it reads |
| +// as a genuinely empty cell for the COUNT(A1,C1)/COUNTA(A1,C1) cases; |
| +// each row's own formula therefore goes in column D, clear of the |
| +// table. |
| var lookupTableRows = []struct { |
| num float64 |
| text string |
| @@ -77,21 +95,43 @@ var lookupTableRows = []struct { |
| {30, "thirty"}, |
| } |
| +// formulaColumnIndex is the zero-based CSV field the formula cell lands |
| +// in: columns A, B and C are the support table (and its deliberately |
| +// blank C1), so column D is index 3. readResults reads this field, not |
| +// field 0. |
| +const formulaColumnIndex = 3 |
| + |
| func contentXML(cases []corpusCase) string { |
| var rows strings.Builder |
| for i, c := range cases { |
| rows.WriteString(" <table:table-row>") |
| - rows.WriteString(fmt.Sprintf(`<table:table-cell table:formula="of:=%s"/>`, toODFFormula(c.Expr))) |
| if i < len(lookupTableRows) { |
| rows.WriteString(numberCell(lookupTableRows[i].num)) |
| rows.WriteString(stringCell(lookupTableRows[i].text)) |
| + rows.WriteString(`<table:table-cell/>`) |
| + } else { |
| + rows.WriteString(`<table:table-cell table:number-columns-repeated="3"/>`) |
| } |
| + // office:value is deliberately poisoned with a value no case can |
| + // produce, so a run in which LibreOffice failed to recalculate |
| + // would come back as 999999 everywhere rather than as plausible |
| + // answers. See main's poisonedCacheValue check. |
| + rows.WriteString(fmt.Sprintf( |
| + `<table:table-cell table:formula="of:=%s" office:value-type="float" office:value="%s"><text:p>%s</text:p></table:table-cell>`, |
| + escapeXML(toODFFormula(c.Expr)), poisonedCacheValue, poisonedCacheValue)) |
| rows.WriteString("</table:table-row>\n") |
| } |
| + // xmlns:of is load-bearing, not decoration: the "of:" in each cell's |
| + // table:formula="of:=..." is an XML namespace prefix, and without |
| + // this declaration LibreOffice does not recognise the formula |
| + // grammar and every single cell comes back as Err:510 rather than a |
| + // value. Verified against LibreOffice 26.8.0.3 by running the same |
| + // document with and without it. |
| 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" |
| + xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2" |
| office:version="1.2"> |
| <office:body> |
| <office:spreadsheet> |
| @@ -103,7 +143,7 @@ func contentXML(cases []corpusCase) string { |
| ` |
| } |
| -// numberCell/stringCell write a literal (not formula) value cell — the |
| +// numberCell/stringCell write a literal (not formula) value cell -- the |
| // lookup table's own data, which must be reflected in the file's actual |
| // value/type attributes, not just visible text, or LibreOffice would not |
| // see it as a number/string for VLOOKUP/INDEX/MATCH to compare against. |
| @@ -116,14 +156,76 @@ func stringCell(s string) string { |
| return fmt.Sprintf(`<table:table-cell office:value-type="string"><text:p>%s</text:p></table:table-cell>`, s) |
| } |
| +// escapeXML escapes a formula for use inside an XML attribute value. |
| +// Every case with a string argument contains double quotes, which would |
| +// otherwise close the table:formula attribute early. |
| +func escapeXML(s string) string { |
| + s = strings.ReplaceAll(s, "&", "&") |
| + s = strings.ReplaceAll(s, "<", "<") |
| + s = strings.ReplaceAll(s, ">", ">") |
| + return strings.ReplaceAll(s, `"`, """) |
| +} |
| + |
| +// odfFunctionNames maps a function's Excel/English spelling to the name |
| +// LibreOffice actually answers to inside an ODF of: formula. ODF's own |
| +// function set is a standard; functions Microsoft added after it are |
| +// vendor-namespaced, and LibreOffice writes and reads them under the |
| +// COM.MICROSOFT. prefix. Calling one by its bare Excel name in an of: |
| +// formula is not an error LibreOffice reports as such -- it is simply an |
| +// unknown name, and the cell comes back #NAME?. |
| +// |
| +// This was found the hard way on this tool's first real run: IFS and |
| +// CONCAT both came back #NAME?, which classify would have happily |
| +// recorded as the corpus's expected answer, turning "LibreOffice has |
| +// never heard of this ODF spelling" into "a real spreadsheet says these |
| +// functions are errors". checkResults (soffice.go) now refuses a #NAME? |
| +// result outright for exactly that reason. |
| +// |
| +// Add an entry here whenever a case uses a post-ODF Microsoft function. |
| +// The symptom is always the same: a #NAME? that fails the run. |
| +var odfFunctionNames = map[string]string{ |
| + "IFS": "COM.MICROSOFT.IFS", |
| + "CONCAT": "COM.MICROSOFT.CONCAT", |
| +} |
| + |
| +// funcCallPattern matches an identifier immediately followed by "(", |
| +// i.e. a function call's name. Like the comma substitution below it does |
| +// not skip quoted spans; no case has a bare word followed by "(" inside |
| +// a string literal. |
| +var funcCallPattern = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_.]*\(`) |
| + |
| // toODFFormula translates a comma-argument-separated Excel-style formula |
| -// (this tool's own case list, and this project's own formula syntax — |
| +// (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. |
| +// uses semicolons between arguments. The translation is required, not |
| +// cosmetic: verified against LibreOffice 26.8.0.3, an of:= formula with |
| +// comma separators evaluates to #NAME?, not to a value. |
| +// |
| +// Two things this deliberately does NOT do, both checked by measurement |
| +// rather than assumed: |
| +// - It does not rewrite A1-style references into ODF's bracketed |
| +// [.A1:.B3] form. LibreOffice accepts the plain form and resolves it |
| +// identically (VLOOKUP(20;A1:B3;2;FALSE()) and |
| +// VLOOKUP(20;[.A1:.B3];2;FALSE()) both return "twenty"). |
| +// - It does not rewrite bare TRUE/FALSE into TRUE()/FALSE(). |
| +// LibreOffice accepts the bare form (IF(TRUE;"yes";"no") returns |
| +// "yes"). |
| +// |
| +// It DOES rewrite function names that ODF vendor-namespaces -- see |
| +// odfFunctionNames. |
| +// |
| +// It is a blunt, comma-to-semicolon substitution -- 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, ",", ";") |
| + s := strings.ReplaceAll(expr, ",", ";") |
| + return funcCallPattern.ReplaceAllStringFunc(s, func(call string) string { |
| + name := strings.ToUpper(call[:len(call)-1]) |
| + if odf, ok := odfFunctionNames[name]; ok { |
| + return odf + "(" |
| + } |
| + return call |
| + }) |
| } |
diff --git a/tools/gencorpus/soffice.go b/tools/gencorpus/soffice.go| new file mode 100644 |
| index 0000000..ac8c71b |
| --- /dev/null |
| +++ b/tools/gencorpus/soffice.go |
| @@ -0,0 +1,143 @@ |
| +package main |
| + |
| +import ( |
| + "fmt" |
| + "os/exec" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| +) |
| + |
| +// poisonedCacheValue is written into every formula cell's office:value |
| +// cache before LibreOffice ever sees the file. No case in the list can |
| +// produce it, so if convert-to exported the cached value instead of |
| +// recalculating, every row would come back as this number and |
| +// checkResults would refuse the run rather than committing 60 identical |
| +// wrong answers. (It does not: LibreOffice 26.8.0.3 recalculates on |
| +// convert-to, proven by this exact poisoning.) |
| +const poisonedCacheValue = "999999" |
| + |
| +// representableErrors are the error tokens calc's own ErrKind can name |
| +// (errors.go, via corpus_test.go's corpusErrByName) and that classify |
| +// therefore records as an "err" value. Any OTHER #-prefixed token -- |
| +// #NULL!, #SPILL!, #CALC!, #GETTING_DATA -- would fall through |
| +// classify's switch and be written into the corpus as an ordinary |
| +// STRING whose text happens to start with "#", which no reader would |
| +// notice and which calc could never match. checkResults refuses one |
| +// instead, so widening calc's error set stays a deliberate act. |
| +var representableErrors = map[string]bool{ |
| + "#DIV/0!": true, "#VALUE!": true, "#REF!": true, |
| + "#NAME?": true, "#NUM!": true, "#N/A": true, |
| +} |
| + |
| +// libreOfficeErrPattern matches LibreOffice's own internal formula |
| +// errors ("Err:510" for a formula it could not parse, and friends). |
| +// These are NOT Excel-style error values — they mean the document, not |
| +// the formula, is wrong, and classify would otherwise silently record |
| +// them as ordinary string results. |
| +var libreOfficeErrPattern = regexp.MustCompile(`^Err:\d+$`) |
| + |
| +// userProfileDir is where soffice is told to keep its user installation. |
| +// Passing -env:UserInstallation is required, not optional: without it |
| +// soffice uses (and may need to create) the invoking user's real |
| +// profile, and a first run there can drop into an interactive setup flow |
| +// that never returns in a headless context. A throwaway directory under |
| +// this run's temp dir keeps the run hermetic and repeatable. |
| +func userProfileDir(tmpDir string) string { |
| + return "file://" + filepath.Join(tmpDir, "profile") |
| +} |
| + |
| +// sofficeVersion runs `soffice --version`, both to confirm the binary is |
| +// usable before any work is done and to capture the exact build string |
| +// that is recorded in the generated corpus's note — the corpus says |
| +// which LibreOffice produced it because it was asked, not because a |
| +// human typed a version number in by hand. |
| +func sofficeVersion(soffice, tmpDir string) (string, error) { |
| + cmd := exec.Command(soffice, "--headless", "--norestore", |
| + "-env:UserInstallation="+userProfileDir(tmpDir), "--version") |
| + out, err := cmd.CombinedOutput() |
| + if err != nil { |
| + return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) |
| + } |
| + // The output is "LibreOffice 26.8.0.3 <git hash>"; the hash is noise |
| + // for a provenance note, the version is the point. |
| + fields := strings.Fields(string(out)) |
| + if len(fields) >= 2 && fields[0] == "LibreOffice" { |
| + return "LibreOffice " + fields[1], nil |
| + } |
| + return strings.TrimSpace(string(out)), nil |
| +} |
| + |
| +// runSoffice converts the .ods to CSV and returns the path of the CSV |
| +// soffice wrote. A document soffice cannot open at all exits non-zero |
| +// with "source file could not be loaded" on stdout (measured against |
| +// 26.8.0.3, by feeding it a deliberately malformed package), so the |
| +// exit status is checked and its combined output is included in the |
| +// error — the failure that actually happens here is a bad .ods, and the |
| +// message soffice prints is the only clue to which part of it is bad. |
| +func runSoffice(soffice, tmpDir, odsPath string) (string, error) { |
| + cmd := exec.Command(soffice, "--headless", "--norestore", |
| + "-env:UserInstallation="+userProfileDir(tmpDir), |
| + "--convert-to", "csv", "--outdir", tmpDir, odsPath) |
| + output, err := cmd.CombinedOutput() |
| + if err != nil { |
| + return "", fmt.Errorf("soffice failed: %w\n%s", err, output) |
| + } |
| + csvPath := filepath.Join(tmpDir, strings.TrimSuffix(filepath.Base(odsPath), ".ods")+".csv") |
| + return csvPath, nil |
| +} |
| + |
| +// checkResults refuses a run whose output cannot be trusted, rather than |
| +// letting a broken pipeline write a plausible-looking corpus. Each of |
| +// these has been an actual observed failure mode of this tool, not a |
| +// hypothetical: |
| +// |
| +// - a row count that does not match the case list (rows lost, or the |
| +// support table read as a case); |
| +// - the poisoned cache value coming back, meaning convert-to exported |
| +// the stale office:value instead of recalculating; |
| +// - "Err:NNN", LibreOffice's own parse/document error, which classify |
| +// would otherwise record as a perfectly ordinary string; |
| +// - an empty cell, which is what an unrecalculated or unparsed formula |
| +// looks like when it has no cached value at all; |
| +// - an error token calc's ErrKind cannot name (see |
| +// representableErrors), which classify would write into the corpus |
| +// as an ordinary string; |
| +// - "#NAME?", which in this tool always means LibreOffice does not |
| +// recognise the function under the ODF spelling gencorpus emitted |
| +// (add it to ods.go's odfFunctionNames), never a real answer any |
| +// case is asking for. This is a statement about spelling, not about |
| +// semantics: a case that genuinely expects #NAME? would go in |
| +// producesUnknownName, which is empty. |
| +func checkResults(cases []corpusCase, results []string) error { |
| + if len(results) != len(cases) { |
| + return fmt.Errorf("soffice returned %d rows, expected %d — did headless convert-to actually recalculate?", len(results), len(cases)) |
| + } |
| + for i, r := range results { |
| + switch { |
| + case r == poisonedCacheValue: |
| + return fmt.Errorf("row %d (%s): got the poisoned cache value %s — soffice exported the stale cached result instead of recalculating", i+1, cases[i].Expr, poisonedCacheValue) |
| + case libreOfficeErrPattern.MatchString(r): |
| + return fmt.Errorf("row %d (%s): LibreOffice reported %s — the generated formula is not valid ODF, fix toODFFormula (ods.go), do not record this as a value", i+1, cases[i].Expr, r) |
| + case r == "" && !producesEmpty(cases[i].Expr): |
| + return fmt.Errorf("row %d (%s): empty result — the cell was not recalculated, or the formula was not recognised", i+1, cases[i].Expr) |
| + case r == "#NAME?" && !producesUnknownName(cases[i].Expr): |
| + return fmt.Errorf("row %d (%s): LibreOffice returned #NAME? — it does not know this function under the ODF name gencorpus emitted (%s); add it to odfFunctionNames in ods.go rather than recording #NAME? as the expected answer", i+1, cases[i].Expr, toODFFormula(cases[i].Expr)) |
| + case strings.HasPrefix(r, "#") && !representableErrors[r]: |
| + return fmt.Errorf("row %d (%s): LibreOffice returned %s, an error calc's own ErrKind cannot name — it would be written into the corpus as a plain string, not an error; add the kind to calc/errors.go first, then to classify", i+1, cases[i].Expr, r) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// producesEmpty names the expressions whose correct answer really is an |
| +// empty cell, so checkResults' empty-cell guard cannot be widened by |
| +// accident into "empties are fine". Empty today: no case in the list |
| +// legitimately produces one. |
| +func producesEmpty(expr string) bool { return false } |
| + |
| +// producesUnknownName names the expressions whose correct answer really |
| +// is #NAME?. Empty today: every case in the list names a function |
| +// LibreOffice knows, once ods.go's odfFunctionNames has spelled it the |
| +// way ODF does. |
| +func producesUnknownName(expr string) bool { return false } |