| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os/exec" |
| 6 | "path/filepath" |
| 7 | "regexp" |
| 8 | "strings" |
| 9 | ) |
| 10 | |
| 11 | // poisonedCacheValue is written into every formula cell's office:value |
| 12 | // cache before LibreOffice ever sees the file. No case in the list can |
| 13 | // produce it, so if convert-to exported the cached value instead of |
| 14 | // recalculating, every row would come back as this number and |
| 15 | // checkResults would refuse the run rather than committing 60 identical |
| 16 | // wrong answers. (It does not: LibreOffice 26.8.0.3 recalculates on |
| 17 | // convert-to, proven by this exact poisoning.) |
| 18 | const poisonedCacheValue = "999999" |
| 19 | |
| 20 | // representableErrors are the error tokens calc's own ErrKind can name |
| 21 | // (errors.go, via corpus_test.go's corpusErrByName) and that classify |
| 22 | // therefore records as an "err" value. Any OTHER #-prefixed token -- |
| 23 | // #NULL!, #SPILL!, #CALC!, #GETTING_DATA -- would fall through |
| 24 | // classify's switch and be written into the corpus as an ordinary |
| 25 | // STRING whose text happens to start with "#", which no reader would |
| 26 | // notice and which calc could never match. checkResults refuses one |
| 27 | // instead, so widening calc's error set stays a deliberate act. |
| 28 | var representableErrors = map[string]bool{ |
| 29 | "#DIV/0!": true, "#VALUE!": true, "#REF!": true, |
| 30 | "#NAME?": true, "#NUM!": true, "#N/A": true, |
| 31 | } |
| 32 | |
| 33 | // libreOfficeErrPattern matches LibreOffice's own internal formula |
| 34 | // errors ("Err:510" for a formula it could not parse, and friends). |
| 35 | // These are NOT Excel-style error values — they mean the document, not |
| 36 | // the formula, is wrong, and classify would otherwise silently record |
| 37 | // them as ordinary string results. |
| 38 | var libreOfficeErrPattern = regexp.MustCompile(`^Err:\d+$`) |
| 39 | |
| 40 | // userProfileDir is where soffice is told to keep its user installation. |
| 41 | // Passing -env:UserInstallation is required, not optional: without it |
| 42 | // soffice uses (and may need to create) the invoking user's real |
| 43 | // profile, and a first run there can drop into an interactive setup flow |
| 44 | // that never returns in a headless context. A throwaway directory under |
| 45 | // this run's temp dir keeps the run hermetic and repeatable. |
| 46 | func userProfileDir(tmpDir string) string { |
| 47 | return "file://" + filepath.Join(tmpDir, "profile") |
| 48 | } |
| 49 | |
| 50 | // sofficeVersion runs `soffice --version`, both to confirm the binary is |
| 51 | // usable before any work is done and to capture the exact build string |
| 52 | // that is recorded in the generated corpus's note — the corpus says |
| 53 | // which LibreOffice produced it because it was asked, not because a |
| 54 | // human typed a version number in by hand. |
| 55 | func sofficeVersion(soffice, tmpDir string) (string, error) { |
| 56 | cmd := exec.Command(soffice, "--headless", "--norestore", |
| 57 | "-env:UserInstallation="+userProfileDir(tmpDir), "--version") |
| 58 | out, err := cmd.CombinedOutput() |
| 59 | if err != nil { |
| 60 | return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) |
| 61 | } |
| 62 | // The output is "LibreOffice 26.8.0.3 <git hash>"; the hash is noise |
| 63 | // for a provenance note, the version is the point. |
| 64 | fields := strings.Fields(string(out)) |
| 65 | if len(fields) >= 2 && fields[0] == "LibreOffice" { |
| 66 | return "LibreOffice " + fields[1], nil |
| 67 | } |
| 68 | return strings.TrimSpace(string(out)), nil |
| 69 | } |
| 70 | |
| 71 | // runSoffice converts the .ods to CSV and returns the path of the CSV |
| 72 | // soffice wrote. A document soffice cannot open at all exits non-zero |
| 73 | // with "source file could not be loaded" on stdout (measured against |
| 74 | // 26.8.0.3, by feeding it a deliberately malformed package), so the |
| 75 | // exit status is checked and its combined output is included in the |
| 76 | // error — the failure that actually happens here is a bad .ods, and the |
| 77 | // message soffice prints is the only clue to which part of it is bad. |
| 78 | func runSoffice(soffice, tmpDir, odsPath string) (string, error) { |
| 79 | cmd := exec.Command(soffice, "--headless", "--norestore", |
| 80 | "-env:UserInstallation="+userProfileDir(tmpDir), |
| 81 | "--convert-to", "csv", "--outdir", tmpDir, odsPath) |
| 82 | output, err := cmd.CombinedOutput() |
| 83 | if err != nil { |
| 84 | return "", fmt.Errorf("soffice failed: %w\n%s", err, output) |
| 85 | } |
| 86 | csvPath := filepath.Join(tmpDir, strings.TrimSuffix(filepath.Base(odsPath), ".ods")+".csv") |
| 87 | return csvPath, nil |
| 88 | } |
| 89 | |
| 90 | // checkResults refuses a run whose output cannot be trusted, rather than |
| 91 | // letting a broken pipeline write a plausible-looking corpus. Each of |
| 92 | // these has been an actual observed failure mode of this tool, not a |
| 93 | // hypothetical: |
| 94 | // |
| 95 | // - a row count that does not match the case list (rows lost, or the |
| 96 | // support table read as a case); |
| 97 | // - the poisoned cache value coming back, meaning convert-to exported |
| 98 | // the stale office:value instead of recalculating; |
| 99 | // - "Err:NNN", LibreOffice's own parse/document error, which classify |
| 100 | // would otherwise record as a perfectly ordinary string; |
| 101 | // - an empty cell, which is what an unrecalculated or unparsed formula |
| 102 | // looks like when it has no cached value at all; |
| 103 | // - an error token calc's ErrKind cannot name (see |
| 104 | // representableErrors), which classify would write into the corpus |
| 105 | // as an ordinary string; |
| 106 | // - "#NAME?", which in this tool always means LibreOffice does not |
| 107 | // recognise the function under the ODF spelling gencorpus emitted |
| 108 | // (add it to ods.go's odfFunctionNames), never a real answer any |
| 109 | // case is asking for. This is a statement about spelling, not about |
| 110 | // semantics: a case that genuinely expects #NAME? would go in |
| 111 | // producesUnknownName, which is empty. |
| 112 | func checkResults(cases []corpusCase, results []string) error { |
| 113 | if len(results) != len(cases) { |
| 114 | return fmt.Errorf("soffice returned %d rows, expected %d — did headless convert-to actually recalculate?", len(results), len(cases)) |
| 115 | } |
| 116 | for i, r := range results { |
| 117 | switch { |
| 118 | case r == poisonedCacheValue: |
| 119 | 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) |
| 120 | case libreOfficeErrPattern.MatchString(r): |
| 121 | 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) |
| 122 | case r == "" && !producesEmpty(cases[i].Expr): |
| 123 | return fmt.Errorf("row %d (%s): empty result — the cell was not recalculated, or the formula was not recognised", i+1, cases[i].Expr) |
| 124 | case r == "#NAME?" && !producesUnknownName(cases[i].Expr): |
| 125 | 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)) |
| 126 | case strings.HasPrefix(r, "#") && !representableErrors[r]: |
| 127 | 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) |
| 128 | } |
| 129 | } |
| 130 | return nil |
| 131 | } |
| 132 | |
| 133 | // producesEmpty names the expressions whose correct answer really is an |
| 134 | // empty cell, so checkResults' empty-cell guard cannot be widened by |
| 135 | // accident into "empties are fine". Empty today: no case in the list |
| 136 | // legitimately produces one. |
| 137 | func producesEmpty(expr string) bool { return false } |
| 138 | |
| 139 | // producesUnknownName names the expressions whose correct answer really |
| 140 | // is #NAME?. Empty today: every case in the list names a function |
| 141 | // LibreOffice knows, once ods.go's odfFunctionNames has spelled it the |
| 142 | // way ODF does. |
| 143 | func producesUnknownName(expr string) bool { return false } |
| 144 | |