summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 20:29:31 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 20:29:31 +0200
commit0bcddd4ed0aff66452397dc0973337c535c0d2ed (patch)
treefffae4df5ba0efe80c0f7c2501ebf0396e438fdd /internal
parent399f0d428050ee104f16e8d4a289b65a4a68eb13 (diff)
downloadlectio-0bcddd4ed0aff66452397dc0973337c535c0d2ed.tar.gz
lectio-0bcddd4ed0aff66452397dc0973337c535c0d2ed.zip
feat(cli): lectio --corpus-check validator
Diffstat (limited to 'internal')
-rw-r--r--internal/bible/booktable.go16
-rw-r--r--internal/bible/testdata/corpora/badbook.tsv1
-rw-r--r--internal/bible/testdata/corpora/badsystem.ini4
-rw-r--r--internal/bible/testdata/corpora/badsystem.tsv1
-rw-r--r--internal/bible/testdata/corpora/gap.tsv3
-rw-r--r--internal/bible/testdata/corpora/good.ini4
-rw-r--r--internal/bible/testdata/corpora/good.tsv4
-rw-r--r--internal/bible/testdata/corpora/nosidecar.tsv2
-rw-r--r--internal/bible/validate.go93
-rw-r--r--internal/bible/validate_test.go24
-rw-r--r--internal/cli/cli.go10
-rw-r--r--internal/cli/corpus.go69
-rw-r--r--internal/cli/corpus_test.go41
13 files changed, 272 insertions, 0 deletions
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 <code>.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 <code>.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 <code|path>: 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 <code>.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())
+ }
+}