diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/bible/booktable.go | 15 | ||||
| -rw-r--r-- | internal/bible/crosschapter_test.go | 158 | ||||
| -rw-r--r-- | internal/bible/ref.go | 62 |
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 { |
