aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 23:54:29 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 23:54:29 +0200
commit2f20d41921a4ed08d0a7b16246215e0eee5ac512 (patch)
treee09dc26734f16a2285ee3436dcadbe5ccbaf3de9 /internal
parente6eba32facf520d202f7941f43206e0f95141452 (diff)
downloadlectio-2f20d41921a4ed08d0a7b16246215e0eee5ac512.tar.gz
lectio-2f20d41921a4ed08d0a7b16246215e0eee5ac512.zip
feat(bible): resolve and display cross-chapter verse ranges (N:M-P:Q)
Lookup now detects a verse-tail range that crosses a chapter boundary ("M-P:Q", e.g. "30-28:7" in "Sirach 27:30-28:7") and fetches chapter N from verse M to its end, any whole chapters between, and chapter P from verse 1 through Q, instead of misreading the tail as a plain from-to range and coming up empty. SplitRef expands the same pattern inside a comma list so a trailing group after the jump (e.g. the ",8-10" in "Malachi 1:14-2:2,8-10") resolves against the new current chapter. FormatRef renders the range intact as "<sigla> N:M-P:Q" instead of splitting off a fake new-chapter marker from the embedded "P:" and mangling the display (previously "Sir 27:7"). No sentinel value is ever stored in a ref string; it only bounds an internal verse-filter loop in Lookup.
Diffstat (limited to 'internal')
-rw-r--r--internal/bible/booktable.go15
-rw-r--r--internal/bible/crosschapter_test.go158
-rw-r--r--internal/bible/ref.go62
3 files changed, 227 insertions, 8 deletions
diff --git a/internal/bible/booktable.go b/internal/bible/booktable.go
index 360f526..2d4b5b2 100644
--- a/internal/bible/booktable.go
+++ b/internal/bible/booktable.go
@@ -216,6 +216,21 @@ func (t *BookTable) FormatRef(dialect, canonicalRef string) string {
if g == "" {
continue
}
+ if cm := crossChapRangeRe.FindStringSubmatch(g); cm != nil {
+ // "M-P:Q": a range crossing a chapter boundary. Keep it intact --
+ // it already reads naturally as "<chap>:M-P:Q" -- rather than
+ // peeling a fake newChap off the "P:" embedded inside it.
+ switch {
+ case i == 0:
+ b.WriteString(chapVerse(dialect, chap, g))
+ case dialect == "pl":
+ b.WriteString(". " + g)
+ default:
+ b.WriteString("," + g)
+ }
+ chap = cm[2] // later groups (if any) belong to chapter P
+ continue
+ }
newChap := ""
if idx := strings.IndexByte(g, ':'); idx >= 0 {
newChap, g = g[:idx], g[idx+1:]
diff --git a/internal/bible/crosschapter_test.go b/internal/bible/crosschapter_test.go
new file mode 100644
index 0000000..7fb61eb
--- /dev/null
+++ b/internal/bible/crosschapter_test.go
@@ -0,0 +1,158 @@
+package bible
+
+import "testing"
+
+// TestCrossChapterRanges covers the 8 real of-lectionary.ini citations whose
+// verse span crosses a chapter boundary ("N:M-P:Q"), e.g. "Sirach 27:30-28:7".
+// Each must: parse via ParseRef, resolve a non-empty verse slice spanning
+// both chapters via Lookup, and format back to the clean "N:M-P:Q" form via
+// FormatRef (no sentinel, no mangled chapter).
+func TestCrossChapterRanges(t *testing.T) {
+ tbl, err := LoadBookTable(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cases := []struct {
+ citation string
+ fromChap, toChap int
+ wantDisplay string
+ // spanBoth is false only for the one citation where the corpus's own
+ // versification (Vulgate/DRB tradition) doesn't have a chapter-8
+ // verse 23 at all: Isaiah 8:23 (Hebrew/NAB numbering, used by the
+ // lectionary) is folded into Isaiah 9:1 in the Vulgate/DRB/Wujek
+ // tradition -- a real textual/versification difference, not a
+ // parser bug. The range-crossing fetch itself is exercised
+ // correctly (it asks for chapter 8 from v23 and finds nothing
+ // there, then chapter 9 through v3); it just has nothing to find in
+ // chapter 8 of this corpus.
+ spanBoth bool
+ }{
+ {"Genesis 1:1-2:2", 1, 2, "Gen 1:1-2:2", true},
+ {"Isaiah 52:13-53:12", 52, 53, "Isa 52:13-53:12", true},
+ {"Isaiah 8:23-9:3", 8, 9, "Isa 8:23-9:3", false},
+ {"John 18:1-19:42", 18, 19, "Jn 18:1-19:42", true},
+ {"Malachi 1:14-2:2", 1, 2, "Mal 1:14-2:2", true},
+ {"Mat 9:36-10:8", 9, 10, "Matt 9:36-10:8", true},
+ {"Sirach 27:30-28:7", 27, 28, "Sir 27:30-28:7", true},
+ {"1 John 1:5-2:2", 1, 2, "1 Jn 1:5-2:2", true},
+ }
+ for _, c := range cases {
+ t.Run(c.citation, func(t *testing.T) {
+ engRef, ok := tbl.ParseRef("en", c.citation)
+ if !ok {
+ t.Fatalf("ParseRef(%q) failed", c.citation)
+ }
+
+ vs, missing := Lookup("drb", engRef)
+ if len(vs) == 0 {
+ t.Fatalf("Lookup(drb, %q) empty; missing=%v", engRef, missing)
+ }
+ if len(missing) != 0 {
+ t.Errorf("Lookup(drb, %q) missing=%v (want none -- some text should resolve)", engRef, missing)
+ }
+ last := vs[len(vs)-1]
+ if last.Chapter != c.toChap {
+ t.Errorf("last verse chapter = %d want %d (verse %+v)", last.Chapter, c.toChap, last)
+ }
+ for _, v := range vs {
+ if v.Chapter < c.fromChap || v.Chapter > c.toChap {
+ t.Errorf("verse outside expected chapter range: %+v", v)
+ }
+ }
+ if c.spanBoth {
+ first := vs[0]
+ if first.Chapter != c.fromChap {
+ t.Errorf("first verse chapter = %d want %d (verse %+v)", first.Chapter, c.fromChap, first)
+ }
+ sawFrom, sawTo := false, false
+ for _, v := range vs {
+ if v.Chapter == c.fromChap {
+ sawFrom = true
+ }
+ if v.Chapter == c.toChap {
+ sawTo = true
+ }
+ }
+ if !sawFrom || !sawTo {
+ t.Errorf("range does not span both chapters: sawFrom=%v sawTo=%v verses=%d", sawFrom, sawTo, len(vs))
+ }
+ }
+
+ display := tbl.FormatRef("en", engRef)
+ if display != c.wantDisplay {
+ t.Errorf("FormatRef(en, %q) = %q want %q", engRef, display, c.wantDisplay)
+ }
+ })
+ }
+}
+
+// TestCrossChapterRangeWithTrailingGroup covers the compound real citation
+// "Malachi 1:14-2:2,8-10" (Ordinary Sunday 31 A): a cross-chapter range
+// followed by a plain verse group in the new (second) chapter.
+func TestCrossChapterRangeWithTrailingGroup(t *testing.T) {
+ tbl, err := LoadBookTable(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ engRef, ok := tbl.ParseRef("en", "Malachi 1:14-2:2,8-10")
+ if !ok {
+ t.Fatalf("ParseRef failed")
+ }
+ if engRef != "Malachi 1:14-2:2,8-10" {
+ t.Errorf("ParseRef canonical = %q", engRef)
+ }
+
+ vs, missing := Lookup("drb", engRef)
+ if len(vs) == 0 {
+ t.Fatalf("Lookup(drb, %q) empty; missing=%v", engRef, missing)
+ }
+ // Expect: 1:14-19 (end of chap 1), 2:1-2, 2:8-10.
+ var got []struct{ Chapter, Verse int }
+ for _, v := range vs {
+ got = append(got, struct{ Chapter, Verse int }{v.Chapter, v.Verse})
+ }
+ if got[0].Chapter != 1 || got[0].Verse != 14 {
+ t.Errorf("first verse = %+v want chap 1 verse 14", got[0])
+ }
+ last := got[len(got)-1]
+ if last.Chapter != 2 || last.Verse != 10 {
+ t.Errorf("last verse = %+v want chap 2 verse 10", last)
+ }
+ sawChap2v2, sawChap2v8 := false, false
+ for _, g := range got {
+ if g.Chapter == 2 && g.Verse == 2 {
+ sawChap2v2 = true
+ }
+ if g.Chapter == 2 && g.Verse == 8 {
+ sawChap2v8 = true
+ }
+ }
+ if !sawChap2v2 || !sawChap2v8 {
+ t.Errorf("expected verses 2:2 and 2:8 present, got %+v", got)
+ }
+
+ display := tbl.FormatRef("en", engRef)
+ if display != "Mal 1:14-2:2,8-10" {
+ t.Errorf("FormatRef = %q want %q", display, "Mal 1:14-2:2,8-10")
+ }
+}
+
+// TestSplitRefCrossChapter verifies SplitRef's expansion of a cross-chapter
+// group inside a comma list, and that a single cross-chapter range with no
+// comma is left as one part (Lookup expands it directly).
+func TestSplitRefCrossChapter(t *testing.T) {
+ if g := SplitRef("Sirach 27:30-28:7"); len(g) != 1 || g[0] != "Sirach 27:30-28:7" {
+ t.Errorf("SplitRef single cross-chapter = %v", g)
+ }
+ got := SplitRef("Malachi 1:14-2:2,8-10")
+ want := []string{"Malachi 1:14-2:2", "Malachi 2:8-10"}
+ if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
+ t.Errorf("SplitRef cross-chapter + trailing group = %v want %v", got, want)
+ }
+ // Existing semicolon-turned-comma precedent must still work.
+ got2 := SplitRef("Judith 13:22-25,15:10")
+ want2 := []string{"Judith 13:22-25", "Judith 15:10"}
+ if len(got2) != 2 || got2[0] != want2[0] || got2[1] != want2[1] {
+ t.Errorf("SplitRef disjoint cross-chapter = %v want %v", got2, want2)
+ }
+}
diff --git a/internal/bible/ref.go b/internal/bible/ref.go
index c88e722..d0a615d 100644
--- a/internal/bible/ref.go
+++ b/internal/bible/ref.go
@@ -8,8 +8,24 @@ import (
var refRe = regexp.MustCompile(`^(.*?)\s+(\d+):(.+)$`)
+// crossChapRangeRe matches a verse group whose range crosses a chapter
+// boundary: "M-P:Q" -- from verse M of the chapter it opens in, through verse
+// Q of chapter P (e.g. "30-28:7" inside "Sirach 27:30-28:7"). Capture groups:
+// 1=M (from-verse), 2=P (to-chapter), 3=Q (to-verse).
+var crossChapRangeRe = regexp.MustCompile(`^(\d+)-(\d+):(\d+)$`)
+
+// noUpperBound stands in for "through the end of the chapter" when filtering
+// verses in Lookup. It is only ever used to bound a loop over verses that
+// already exist in the corpus -- it never appears in a ref string, so it
+// cannot leak into SplitRef's output or FormatRef's display.
+const noUpperBound = 1<<31 - 1
+
// SplitRef splits a ref whose verse list mixes single verses and ranges into
-// one ref per group (the kjv tools reject a mixed list in a single query).
+// one ref per group (the kjv tools reject a mixed list in a single query). A
+// group may itself be a cross-chapter range ("M-P:Q"); it is kept as one
+// group (Lookup expands it), but it also updates the chapter that later
+// bare-verse groups in the list belong to (e.g. "Malachi 1:14-2:2,8-10" ->
+// ["Malachi 1:14-2:2", "Malachi 2:8-10"]).
func SplitRef(ref string) []string {
m := refRe.FindStringSubmatch(ref)
if m == nil {
@@ -20,16 +36,23 @@ func SplitRef(ref string) []string {
return []string{ref}
}
var out []string
+ cur := chap // chapter the next bare (no ":") group belongs to
for _, g := range strings.Split(verses, ",") {
g = strings.TrimSpace(g)
if g == "" {
continue
}
+ if cm := crossChapRangeRe.FindStringSubmatch(g); cm != nil {
+ out = append(out, book+" "+cur+":"+g)
+ cur = cm[2] // groups after this one belong to chapter P
+ continue
+ }
if strings.Contains(g, ":") {
out = append(out, book+" "+g)
- } else {
- out = append(out, book+" "+chap+":"+g)
+ cur = g[:strings.IndexByte(g, ':')]
+ continue
}
+ out = append(out, book+" "+cur+":"+g)
}
return out
}
@@ -51,12 +74,35 @@ func Lookup(version, ref string) ([]Verse, []string) {
continue
}
chap, _ := strconv.Atoi(m[2])
- from, to := verseRange(m[3])
found := false
- for _, v := range Verses(version, book, chap) {
- if v.Verse >= from && v.Verse <= to {
- verses = append(verses, v)
- found = true
+ if cm := crossChapRangeRe.FindStringSubmatch(m[3]); cm != nil {
+ // "M-P:Q": chapter `chap` from verse M to its end, any whole
+ // chapters in between, then chapter P from verse 1 through Q.
+ from, _ := strconv.Atoi(cm[1])
+ toChap, _ := strconv.Atoi(cm[2])
+ toVerse, _ := strconv.Atoi(cm[3])
+ for c := chap; c <= toChap; c++ {
+ lo, hi := 1, noUpperBound
+ if c == chap {
+ lo = from
+ }
+ if c == toChap {
+ hi = toVerse
+ }
+ for _, v := range Verses(version, book, c) {
+ if v.Verse >= lo && v.Verse <= hi {
+ verses = append(verses, v)
+ found = true
+ }
+ }
+ }
+ } else {
+ from, to := verseRange(m[3])
+ for _, v := range Verses(version, book, chap) {
+ if v.Verse >= from && v.Verse <= to {
+ verses = append(verses, v)
+ found = true
+ }
}
}
if !found {