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 "; 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 }