aboutsummaryrefslogtreecommitdiff
path: root/internal/bible/ref.go
blob: 9e44060ab1bf8df549a3a9d41d6e76179e6ba026 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
package bible

import (
	"regexp"
	"strconv"
	"strings"
)

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). 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 {
		return []string{ref}
	}
	book, chap, verses := m[1], m[2], m[3]
	if !strings.Contains(verses, ",") {
		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)
			cur = g[:strings.IndexByte(g, ':')]
			continue
		}
		out = append(out, book+" "+cur+":"+g)
	}
	return out
}

// Lookup resolves an English-style reference against a version, returning the
// matched verses (in order) and the sub-refs the corpus had no entry for.
func Lookup(version, ref string) ([]Verse, []string) {
	var verses []Verse
	var missing []string
	for _, part := range SplitRef(ref) {
		m := refRe.FindStringSubmatch(part)
		if m == nil {
			missing = append(missing, part)
			continue
		}
		book, ok := ResolveBook(m[1])
		if !ok {
			missing = append(missing, part)
			continue
		}
		chap, _ := strconv.Atoi(m[2])
		found := false
		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 {
			missing = append(missing, part)
		}
	}
	return verses, missing
}

// KeyedVerse is a resolved verse tagged with its canonical book, for tools that
// need the (book, chapter, verse) key rather than the text -- e.g. extracting
// the exact set of pericopes a lectionary cites (cmd/clectio-gen).
type KeyedVerse struct {
	Book           string
	Chapter, Verse int
}

// LookupKeyed resolves a reference like Lookup but returns each matched verse's
// (book, chapter, verse) key instead of its text, in the same order and with the
// same splitting/cross-chapter/versification handling -- so the keys correspond
// exactly to the verses Lookup would return for the same version and ref.
func LookupKeyed(version, ref string) []KeyedVerse {
	var out []KeyedVerse
	for _, part := range SplitRef(ref) {
		m := refRe.FindStringSubmatch(part)
		if m == nil {
			continue
		}
		book, ok := ResolveBook(m[1])
		if !ok {
			continue
		}
		chap, _ := strconv.Atoi(m[2])
		if cm := crossChapRangeRe.FindStringSubmatch(m[3]); cm != nil {
			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 {
						out = append(out, KeyedVerse{book, v.Chapter, v.Verse})
					}
				}
			}
		} else {
			from, to := verseRange(m[3])
			for _, v := range Verses(version, book, chap) {
				if v.Verse >= from && v.Verse <= to {
					out = append(out, KeyedVerse{book, v.Chapter, v.Verse})
				}
			}
		}
	}
	return out
}

func verseRange(s string) (int, int) {
	s = strings.TrimSpace(s)
	if i := strings.IndexAny(s, "-–—"); i >= 0 {
		from, _ := strconv.Atoi(strings.TrimSpace(s[:i]))
		to, _ := strconv.Atoi(strings.TrimSpace(s[i+1:]))
		return from, to
	}
	n, _ := strconv.Atoi(s)
	return n, n
}