oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "encoding/csv"
5 "encoding/json"
6 "fmt"
7 "os"
8 "strconv"
9 "strings"
10)
11
12// readResults parses the CSV soffice wrote into plain strings, in row
13// order — pairing them back up with cases is main's job, by index. The
14// value read is field formulaColumnIndex (column D), NOT field 0:
15// columns A:C are the fixed support table the lookup cases reference
16// (see ods.go's lookupTableRows), so field 0 is "10", "20", "30", then
17// nothing. A short row means the formula cell exported nothing at all,
18// which checkResults treats as a failed run rather than an empty value.
19func readResults(path string) ([]string, error) {
20 f, err := os.Open(path)
21 if err != nil {
22 return nil, err
23 }
24 defer f.Close()
25
26 r := csv.NewReader(f)
27 r.FieldsPerRecord = -1
28 rows, err := r.ReadAll()
29 if err != nil {
30 return nil, err
31 }
32 out := make([]string, len(rows))
33 for i, row := range rows {
34 if len(row) > formulaColumnIndex {
35 out[i] = row[formulaColumnIndex]
36 }
37 }
38 return out, nil
39}
40
41// corpusValueJSON mirrors calc's own corpusValue wire format (see
42// internal/sheets/calc/corpus_test.go) — duplicated here rather than
43// imported, since gencorpus is a standalone tool and calc's copy is
44// unexported test-only plumbing that this package has no business
45// depending on.
46type corpusValueJSON struct {
47 Kind string `json:"kind"`
48 Num float64 `json:"num,omitempty"`
49 Str string `json:"str,omitempty"`
50 Bool bool `json:"bool,omitempty"`
51 Err string `json:"err,omitempty"`
52}
53
54type corpusEntryJSON struct {
55 Function string `json:"function"`
56 Expr string `json:"expr"`
57 Want corpusValueJSON `json:"want"`
58}
59
60type corpusFileJSON struct {
61 Note string `json:"note"`
62 Entries []corpusEntryJSON `json:"entries"`
63}
64
65// classify turns one CSV cell's text into corpusValueJSON. LibreOffice's
66// CSV export renders TRUE/FALSE for booleans, the six Excel error
67// strings verbatim for errors, a plain decimal for numbers, and anything
68// else as text.
69func classify(s string) corpusValueJSON {
70 switch s {
71 case "TRUE":
72 return corpusValueJSON{Kind: "bool", Bool: true}
73 case "FALSE":
74 return corpusValueJSON{Kind: "bool", Bool: false}
75 case "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A":
76 return corpusValueJSON{Kind: "err", Err: s}
77 }
78 if n, err := strconv.ParseFloat(s, 64); err == nil {
79 return corpusValueJSON{Kind: "num", Num: n}
80 }
81 return corpusValueJSON{Kind: "str", Str: s}
82}
83
84// writeCorpusJSON writes the corpus file. It does NOT hand the whole
85// document to json.MarshalIndent: that puts every field of every entry
86// on its own line, so a regeneration that changed one value still
87// produces a several-hundred-line diff and the one row a reviewer needs
88// to see is buried in it. Each entry is written on a single line
89// instead, in the exact spacing the file has always had, so `git diff`
90// after a regeneration shows precisely the rows LibreOffice answered
91// differently and nothing else. Reviewing that diff is the whole point
92// of this tool's workflow (see main's closing message); a format that
93// defeats it is not a neutral choice.
94func writeCorpusJSON(path string, cases []corpusCase, results []string, version string) error {
95 note, err := json.Marshal(corpusNote(path, version))
96 if err != nil {
97 return err
98 }
99 var b strings.Builder
100 b.WriteString("{\n \"note\": ")
101 b.Write(note)
102 b.WriteString(",\n \"entries\": [\n")
103 for i, c := range cases {
104 line, err := entryLine(corpusEntryJSON{Function: c.Function, Expr: c.Expr, Want: classify(results[i])})
105 if err != nil {
106 return err
107 }
108 b.WriteString(" " + line)
109 if i < len(cases)-1 {
110 b.WriteString(",")
111 }
112 b.WriteString("\n")
113 }
114 b.WriteString(" ]\n}\n")
115 return os.WriteFile(path, []byte(b.String()), 0o644)
116}
117
118// entryLine renders one entry as a single JSON object. Every scalar goes
119// through json.Marshal rather than fmt, so quoting and float formatting
120// are the encoding/json package's problem, not this function's; only the
121// whitespace between fields is chosen here.
122func entryLine(e corpusEntryJSON) (string, error) {
123 fn, err := json.Marshal(e.Function)
124 if err != nil {
125 return "", err
126 }
127 expr, err := json.Marshal(e.Expr)
128 if err != nil {
129 return "", err
130 }
131 want, err := wantLine(e.Want)
132 if err != nil {
133 return "", err
134 }
135 return fmt.Sprintf(`{"function": %s, "expr": %s, "want": %s}`, fn, expr, want), nil
136}
137
138// wantLine renders one "want" value. The kind's own field is always
139// written, zero value included: corpusValueJSON's omitempty tags would
140// drop "num": 0 and "bool": false entirely, leaving a bare {"kind":
141// "num"} that reads like a value the tool failed to record rather than
142// like the number zero. A corpus is read by people deciding whether it
143// is trustworthy; it should not make its own honest answers look like
144// gaps.
145func wantLine(v corpusValueJSON) (string, error) {
146 var payload any
147 switch v.Kind {
148 case "num":
149 payload = v.Num
150 case "str":
151 payload = v.Str
152 case "bool":
153 payload = v.Bool
154 case "err":
155 payload = v.Err
156 default:
157 return `{"kind": "` + v.Kind + `"}`, nil
158 }
159 enc, err := json.Marshal(payload)
160 if err != nil {
161 return "", err
162 }
163 return fmt.Sprintf(`{"kind": %q, "%s": %s}`, v.Kind, v.Kind, enc), nil
164}
165
166// existingEntryCount reads path's current entry count, if the file
167// exists and parses — used by main to refuse a run that would silently
168// narrow an already-committed corpus. A missing or unparsable file
169// returns a non-nil error, which main treats as "nothing to compare
170// against" rather than a reason to fail.
171func existingEntryCount(path string) (int, error) {
172 data, err := os.ReadFile(path)
173 if err != nil {
174 return 0, err
175 }
176 var cf corpusFileJSON
177 if err := json.Unmarshal(data, &cf); err != nil {
178 return 0, err
179 }
180 return len(cf.Entries), nil
181}
182
183// corpusNote is the provenance line written into the corpus file
184// itself. It names the exact LibreOffice build that computed the values
185// (captured from `soffice --version` at run time, not typed in by hand)
186// because "generated" without a version is not a reproducible claim, and
187// it names the one place where the recorded values are not literally
188// what LibreOffice printed.
189func corpusNote(path, version string) string {
190 return "GENERATED by tools/gencorpus from a real headless " + version +
191 " run: each entry's \"want\" is what that LibreOffice computed for that " +
192 "exact formula, exported via `--convert-to csv`, not a value anyone " +
193 "reasoned out. Recalculation on export is proven per run, not assumed: " +
194 "every formula cell is written with a poisoned office:value cache (999999) " +
195 "and the run fails if that value comes back. Two honest caveats. (1) The " +
196 "values come through LibreOffice's CSV export, which renders a number at " +
197 "roughly 15 significant digits with trailing zeros stripped -- SQRT(2) is " +
198 "recorded as 1.4142135623731, not the full 1.4142135623730951; " +
199 "corpus_test.go's valuesMatch compares numbers with a 1e-9 epsilon, so this " +
200 "is a rendering artifact, not a disagreement. (2) LibreOffice Calc is not Excel. Where the two are known " +
201 "to differ, the difference is recorded and reasoned about in " +
202 "internal/sheets/xlsx/oracle_divergences_test.go, cross-checked against " +
203 "excelize's independent formula engine -- this file records Calc's answer. " +
204 "Regenerate with `go run ./tools/gencorpus -out " + path +
205 "` (add -soffice /path/to/soffice if it is not on PATH) whenever a case is " +
206 "added; commit the result and update corpusSHA256 in " +
207 "internal/sheets/calc/corpus_test.go."
208}
209