From 0bcddd4ed0aff66452397dc0973337c535c0d2ed Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 27 Jul 2026 20:29:31 +0200 Subject: feat(cli): lectio --corpus-check validator --- internal/bible/booktable.go | 16 +++++ internal/bible/testdata/corpora/badbook.tsv | 1 + internal/bible/testdata/corpora/badsystem.ini | 4 ++ internal/bible/testdata/corpora/badsystem.tsv | 1 + internal/bible/testdata/corpora/gap.tsv | 3 + internal/bible/testdata/corpora/good.ini | 4 ++ internal/bible/testdata/corpora/good.tsv | 4 ++ internal/bible/testdata/corpora/nosidecar.tsv | 2 + internal/bible/validate.go | 93 +++++++++++++++++++++++++++ internal/bible/validate_test.go | 24 +++++++ internal/cli/cli.go | 10 +++ internal/cli/corpus.go | 69 ++++++++++++++++++++ internal/cli/corpus_test.go | 41 ++++++++++++ 13 files changed, 272 insertions(+) create mode 100644 internal/bible/testdata/corpora/badbook.tsv create mode 100644 internal/bible/testdata/corpora/badsystem.ini create mode 100644 internal/bible/testdata/corpora/badsystem.tsv create mode 100644 internal/bible/testdata/corpora/gap.tsv create mode 100644 internal/bible/testdata/corpora/good.ini create mode 100644 internal/bible/testdata/corpora/good.tsv create mode 100644 internal/bible/testdata/corpora/nosidecar.tsv create mode 100644 internal/bible/validate.go create mode 100644 internal/bible/validate_test.go create mode 100644 internal/cli/corpus.go create mode 100644 internal/cli/corpus_test.go diff --git a/internal/bible/booktable.go b/internal/bible/booktable.go index ffa3907..360f526 100644 --- a/internal/bible/booktable.go +++ b/internal/bible/booktable.go @@ -92,6 +92,22 @@ func mustReadEmbedded() []byte { // /settings editor shows when the user has no override yet). func DefaultBooksINI() []byte { return mustReadEmbedded() } +// CanonicalBooks returns the 73 canonical English book keys -- the exact +// vocabulary a corpus .tsv's Book column must use -- read from the embedded +// books.ini [en] section. It reuses the existing embedded parse (parseBooks +// + mustReadEmbedded); the book list is never re-embedded or hardcoded here. +func CanonicalBooks() map[string]bool { + out := map[string]bool{} + def, err := parseBooks(mustReadEmbedded()) + if err != nil { + return out // our bug, not the caller's; an empty set flags everything + } + for canon := range def["en"] { + out[canon] = true + } + return out +} + func parseBooks(data []byte) (map[string]map[string][]string, error) { secs, err := ini.Parse(data) if err != nil { diff --git a/internal/bible/testdata/corpora/badbook.tsv b/internal/bible/testdata/corpora/badbook.tsv new file mode 100644 index 0000000..55d5571 --- /dev/null +++ b/internal/bible/testdata/corpora/badbook.tsv @@ -0,0 +1 @@ +Genessis Gen 1 1 1 In the beginning God created heaven and earth. diff --git a/internal/bible/testdata/corpora/badsystem.ini b/internal/bible/testdata/corpora/badsystem.ini new file mode 100644 index 0000000..aca54c3 --- /dev/null +++ b/internal/bible/testdata/corpora/badsystem.ini @@ -0,0 +1,4 @@ +; test fixture: invalid psalm_system +lang = en +name = Bad System Test +psalm_system = klingon diff --git a/internal/bible/testdata/corpora/badsystem.tsv b/internal/bible/testdata/corpora/badsystem.tsv new file mode 100644 index 0000000..fd051c3 --- /dev/null +++ b/internal/bible/testdata/corpora/badsystem.tsv @@ -0,0 +1 @@ +Genesis Gen 1 1 1 In the beginning God created heaven and earth. diff --git a/internal/bible/testdata/corpora/gap.tsv b/internal/bible/testdata/corpora/gap.tsv new file mode 100644 index 0000000..f0d67bf --- /dev/null +++ b/internal/bible/testdata/corpora/gap.tsv @@ -0,0 +1,3 @@ +Genesis Gen 1 1 1 In the beginning God created heaven and earth. +Genesis Gen 1 1 2 And the earth was void and empty, and darkness was upon the face of the deep. +Genesis Gen 1 1 4 And God said: Be light made. And light was made. diff --git a/internal/bible/testdata/corpora/good.ini b/internal/bible/testdata/corpora/good.ini new file mode 100644 index 0000000..6169a64 --- /dev/null +++ b/internal/bible/testdata/corpora/good.ini @@ -0,0 +1,4 @@ +; test fixture: valid sidecar +lang = en +name = Good Test Corpus +psalm_system = vulgate diff --git a/internal/bible/testdata/corpora/good.tsv b/internal/bible/testdata/corpora/good.tsv new file mode 100644 index 0000000..7cb9015 --- /dev/null +++ b/internal/bible/testdata/corpora/good.tsv @@ -0,0 +1,4 @@ +Genesis Gen 1 1 1 In the beginning God created heaven and earth. +Genesis Gen 1 1 2 And the earth was void and empty, and darkness was upon the face of the deep. +Exodus Exod 2 1 1 Now there went out a man of the house of Levi. +Exodus Exod 2 1 2 And he took a wife of his own kindred. diff --git a/internal/bible/testdata/corpora/nosidecar.tsv b/internal/bible/testdata/corpora/nosidecar.tsv new file mode 100644 index 0000000..bba582a --- /dev/null +++ b/internal/bible/testdata/corpora/nosidecar.tsv @@ -0,0 +1,2 @@ +Genesis Gen 1 1 1 In the beginning God created heaven and earth. +Genesis Gen 1 1 2 And the earth was void and empty, and darkness was upon the face of the deep. diff --git a/internal/bible/validate.go b/internal/bible/validate.go new file mode 100644 index 0000000..27ec519 --- /dev/null +++ b/internal/bible/validate.go @@ -0,0 +1,93 @@ +package bible + +import ( + "fmt" + "sort" +) + +// CorpusReport is the outcome of validating one corpus's sidecar + text (see +// CheckCorpus). Errors mean the corpus is unusable/malformed and fail +// --corpus-check (exit 1); Warnings flag coverage gaps against the reference +// Vulgate ("vul") and never fail the check -- a corpus may legitimately be +// incomplete (the built-in wuj is) and still be a valid drop-in. +type CorpusReport struct { + Code string + Errors []string + Warnings []string +} + +// OK reports whether the corpus is usable: no errors. Warnings never affect it. +func (r CorpusReport) OK() bool { return len(r.Errors) == 0 } + +var validPsalmSystems = map[string]bool{"vulgate": true, "hebrew": true, "drb": true} + +// CheckCorpus validates code's sidecar and text and reports coverage gaps vs +// "vul" as warnings. +// +// Sidecar: lang, name and psalm_system are required (psalm_system must be +// vulgate/hebrew/drb); sigla and autoselect are optional -- their absence, or +// autoselect=false, is never flagged (the built-in wuj sets autoselect=false +// and must pass clean). +// +// Text: every Book column value must be one of CanonicalBooks' 73 keys +// (error). A chapter missing entirely versus vul, a verse-number gap within a +// chapter, or a repeated verse number within a chapter, is a warning only -- +// real source texts legitimately do this (e.g. the LXX's lettered doublet +// verses in 3 Kingdoms, or a scanned translation's occasional merged verse), +// and it must never turn a corpus that otherwise loads fine into a failure. +func CheckCorpus(code string) CorpusReport { + r := CorpusReport{Code: code} + + m, ok := Meta(code) + if !ok || m.Lang == "" || m.Name == "" || m.PsalmSystem == "" { + r.Errors = append(r.Errors, "missing or incomplete sidecar (need lang, name, psalm_system)") + } + if m.PsalmSystem != "" && !validPsalmSystems[m.PsalmSystem] { + r.Errors = append(r.Errors, fmt.Sprintf("invalid psalm_system %q (want vulgate|hebrew|drb)", m.PsalmSystem)) + } + + c := load(code) + if len(c.books) == 0 { + r.Errors = append(r.Errors, "no verses parsed (empty or malformed .tsv)") + sort.Strings(r.Errors) + return r + } + + canon := CanonicalBooks() + for book := range c.books { + if !canon[book] { + r.Errors = append(r.Errors, "unknown book name: "+book) + } + } + + ref := load("vul") + for book, chaps := range c.books { + for ch, verses := range chaps { + seen := map[int]bool{} + maxV := 0 + for _, v := range verses { + if seen[v.Verse] { + r.Warnings = append(r.Warnings, fmt.Sprintf("%s %d: duplicate verse %d", book, ch, v.Verse)) + } + seen[v.Verse] = true + if v.Verse > maxV { + maxV = v.Verse + } + } + if maxV > len(seen) { + r.Warnings = append(r.Warnings, fmt.Sprintf("%s %d: verse gap (have %d verse(s), highest numbered %d)", book, ch, len(seen), maxV)) + } + } + if refChaps, ok := ref.books[book]; ok { + for ch := range refChaps { + if _, have := chaps[ch]; !have { + r.Warnings = append(r.Warnings, fmt.Sprintf("%s: missing chapter %d (present in vul)", book, ch)) + } + } + } + } + + sort.Strings(r.Errors) + sort.Strings(r.Warnings) + return r +} diff --git a/internal/bible/validate_test.go b/internal/bible/validate_test.go new file mode 100644 index 0000000..9cfec26 --- /dev/null +++ b/internal/bible/validate_test.go @@ -0,0 +1,24 @@ +package bible + +import "testing" + +func TestCheckCorpus(t *testing.T) { + SetUserCorporaDir("testdata/corpora") + t.Cleanup(func() { SetUserCorporaDir("") }) + + if r := CheckCorpus("good"); !r.OK() { + t.Fatalf("good corpus flagged: %v", r.Errors) + } + if r := CheckCorpus("badbook"); r.OK() { + t.Fatal("unknown book not caught") + } + if r := CheckCorpus("gap"); len(r.Warnings) == 0 { + t.Fatal("verse gap not warned") + } + if r := CheckCorpus("nosidecar"); r.OK() { + t.Fatal("missing sidecar not caught") + } + if r := CheckCorpus("badsystem"); r.OK() { + t.Fatal("bad psalm_system not caught") + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 65be274..eb01039 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -51,6 +51,9 @@ Flags: -L, --liturgy print the computed liturgical day (offline, no network) and exit --cal-new NAME scaffold a custom calendar layer ~/.config/lectio/calendars/NAME.ini --cal-check NAME validate a custom calendar layer NAME.ini and exit + --corpus-check X validate a bible corpus (code, e.g. drb, or a path to + a .tsv) and exit; errors fail, coverage gaps warn + --json with --corpus-check, print the report as JSON --rand, --rand-v print a random verse and exit (uses default_version or -b VER; bt has no corpus, so it falls back to a corpus version) --rand-ch print a random chapter and exit @@ -124,6 +127,8 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { var ref string var liturgy bool var calNew, calCheck string + var corpusCheck string + var jsonOut bool fs := flag.NewFlagSet("lectio", flag.ContinueOnError) fs.SetOutput(stderr) @@ -172,6 +177,8 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs.BoolVar(&liturgy, "liturgy", false, "print the computed liturgical day (offline calendar engine) and exit") fs.StringVar(&calNew, "cal-new", "", "scaffold a new calendar-layer file NAME.ini and exit") fs.StringVar(&calCheck, "cal-check", "", "validate the calendar-layer file NAME.ini and exit") + fs.StringVar(&corpusCheck, "corpus-check", "", "validate a bible corpus (code or path to .tsv) and exit") + fs.BoolVar(&jsonOut, "json", false, "with --corpus-check, print the report as JSON") if err := fs.Parse(rest); err != nil { return 2 @@ -203,6 +210,9 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if calCheck != "" { return runCalCheck(calCheck, stdout, stderr) } + if corpusCheck != "" { + return runCorpusCheck(corpusCheck, jsonOut, stdout, stderr) + } cfg, err := config.Load() if err != nil { diff --git a/internal/cli/corpus.go b/internal/cli/corpus.go new file mode 100644 index 0000000..15a140c --- /dev/null +++ b/internal/cli/corpus.go @@ -0,0 +1,69 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/lukaszkasprzak/lectio/internal/bible" + "github.com/lukaszkasprzak/lectio/internal/config" +) + +// runCorpusCheck handles --corpus-check : validates a bible +// corpus's sidecar + text (bible.CheckCorpus) and prints the report, plain or +// (with asJSON) as JSON. It exits 1 if the report has any errors, 0 +// otherwise -- coverage warnings never fail the check. +// +// arg is either a bare corpus code, resolved against the user corpora dir +// (config.CorporaDir(), which overrides the embedded built-ins the same way +// normal rendering does), or a path to a .tsv file -- its directory +// becomes the user corpora dir for this run and its basename (minus ".tsv") +// the code. The path form lets a corpus be validated before it is copied +// into internal/bible/corpora/ (see scripts/corpus-validate.sh, make +// add-corpus). +func runCorpusCheck(arg string, asJSON bool, stdout, stderr io.Writer) int { + code := arg + if strings.Contains(arg, "/") || strings.HasSuffix(arg, ".tsv") { + dir := filepath.Dir(arg) + code = strings.TrimSuffix(filepath.Base(arg), ".tsv") + bible.SetUserCorporaDir(dir) + } else if dir, err := config.CorporaDir(); err == nil { + bible.SetUserCorporaDir(dir) + } + + report := bible.CheckCorpus(code) + + if asJSON { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + } else { + printCorpusReport(stdout, report) + } + + if !report.OK() { + return 1 + } + return 0 +} + +// printCorpusReport renders a CorpusReport as plain text: one line per +// error/warning, then a summary line. +func printCorpusReport(w io.Writer, r bible.CorpusReport) { + for _, e := range r.Errors { + fmt.Fprintf(w, "ERROR: %s\n", e) + } + for _, wm := range r.Warnings { + fmt.Fprintf(w, "WARNING: %s\n", wm) + } + if r.OK() { + fmt.Fprintf(w, "ok: %s (%d warning(s))\n", r.Code, len(r.Warnings)) + } else { + fmt.Fprintf(w, "FAIL: %s (%d error(s), %d warning(s))\n", r.Code, len(r.Errors), len(r.Warnings)) + } +} diff --git a/internal/cli/corpus_test.go b/internal/cli/corpus_test.go new file mode 100644 index 0000000..0ed17bc --- /dev/null +++ b/internal/cli/corpus_test.go @@ -0,0 +1,41 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/lukaszkasprzak/lectio/internal/bible" +) + +func TestRunCorpusCheckPathArg(t *testing.T) { + t.Cleanup(func() { bible.SetUserCorporaDir("") }) + + var buf bytes.Buffer + if code := runCorpusCheck("../bible/testdata/corpora/badbook.tsv", false, &buf, &buf); code != 1 { + t.Fatalf("badbook: exit %d, want 1 (out=%s)", code, buf.String()) + } + if !strings.Contains(buf.String(), "ERROR:") { + t.Errorf("expected an ERROR: line, got %s", buf.String()) + } + + buf.Reset() + if code := runCorpusCheck("../bible/testdata/corpora/good.tsv", false, &buf, &buf); code != 0 { + t.Fatalf("good: exit %d, want 0 (out=%s)", code, buf.String()) + } + if !strings.Contains(buf.String(), "ok:") { + t.Errorf("expected an ok: summary, got %s", buf.String()) + } +} + +func TestRunCorpusCheckJSON(t *testing.T) { + t.Cleanup(func() { bible.SetUserCorporaDir("") }) + + var buf bytes.Buffer + if code := runCorpusCheck("../bible/testdata/corpora/badbook.tsv", true, &buf, &buf); code != 1 { + t.Fatalf("badbook --json: exit %d, want 1 (out=%s)", code, buf.String()) + } + if !strings.Contains(buf.String(), `"Errors"`) { + t.Errorf("expected a JSON report, got %s", buf.String()) + } +} -- cgit v1.3