claude go reimplement
This commit is contained in:
1 parent
4bcb924158
commit
f523c79618
83 files changed
+3714
-3454
No files matched your search
@@ -0,0 +1,357 @@
|
||||
package kjv
|
||||
|
||||
// Scripture reference resolver. Understands the many ways people type a
|
||||
// reference ("1 jn 3.16", "II Kings 2:1-4", "song of songs 2", "Exodus ") and
|
||||
// turns it into ranges of verse ids (book*1000000 + chapter*1000 + verse).
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Extra spellings people use that the abbreviations table does not know.
|
||||
var extraBookAliases = map[string][]string{
|
||||
"Song of Solomon": {"song of songs", "songs of solomon", "songs of songs", "song of sol",
|
||||
"song of sol", "canticles", "canticle of canticles", "sos", "sng", "ss"},
|
||||
"Psalms": {"psalm", "psalter", "pslm", "psa", "pss", "psm", "ps"},
|
||||
"Revelation": {"revelations", "revelation of john", "revelation of st john", "the revelation", "apocalypse"},
|
||||
"Ecclesiastes": {"ecclesiasts", "eccles", "ecc", "qoheleth"},
|
||||
"Philemon": {"phlm", "phm", "philem"},
|
||||
"Judges": {"jdg", "jdgs", "jg"},
|
||||
"Mark": {"mrk", "mk", "mr"},
|
||||
"John": {"jhn", "jn"},
|
||||
"Acts": {"acts of the apostles", "act"},
|
||||
"James": {"jas", "jm"},
|
||||
}
|
||||
|
||||
// Words that are books but also ordinary search words: a bare "numbers" or
|
||||
// "mark" still shows the book, but /find offers a keyword search as well.
|
||||
var bareBookExtra = map[string]bool{
|
||||
"psalm": true, "songofsongs": true, "songsofsolomon": true, "songofsol": true, "canticles": true,
|
||||
"canticleofcanticles": true, "revelations": true, "revelationofjohn": true, "therevelation": true,
|
||||
}
|
||||
|
||||
const maxChapterIDSpan = 999
|
||||
|
||||
// A reference is never this long; longer text goes to the keyword search. Bounds the fuzzy matching work.
|
||||
const maxRefQueryLen = 300
|
||||
|
||||
// Go's regexp has no lookaheads, so they are written as a consumed [\s.] here, which matches the same text.
|
||||
var (
|
||||
bookPieceRE = regexp.MustCompile(`(?i)^\s*(?P<book>(?:(?:[1-3]|(?:iii|ii|i)[\s.]|1st|2nd|3rd|first|second|third)\.?\s*)?[a-z][a-z.\s]*?)` +
|
||||
`\s*(?P<rest>\d[\d\s:.\-–—]*)?\s*$`)
|
||||
continuationRE = regexp.MustCompile(`^\s*\d[\d\s:.\-–—]*$`)
|
||||
|
||||
reBookOf = regexp.MustCompile(`^(?:the\s+)?(?:book\s+of\s+)`)
|
||||
reFirst = regexp.MustCompile(`^(?:1st|first)([\s.a-z]|$)`)
|
||||
reSecond = regexp.MustCompile(`^(?:2nd|second)([\s.a-z]|$)`)
|
||||
reThird = regexp.MustCompile(`^(?:3rd|third)([\s.a-z]|$)`)
|
||||
reRoman3 = regexp.MustCompile(`^iii([\s.]|$)`)
|
||||
reRoman2 = regexp.MustCompile(`^ii([\s.]|$)`)
|
||||
reRoman1 = regexp.MustCompile(`^i([\s.])`)
|
||||
reNotAlnum = regexp.MustCompile(`[^a-z0-9]`)
|
||||
reSepSpace = regexp.MustCompile(`\s*([:\-])\s*`)
|
||||
|
||||
reChapter = regexp.MustCompile(`^(\d+)$`)
|
||||
reChapterAll = regexp.MustCompile(`^(\d+)(?::-|-)$`)
|
||||
reChapterRange = regexp.MustCompile(`^(\d+)-(\d+)$`)
|
||||
reVerse = regexp.MustCompile(`^(\d+)[:.\s](\d+)$`)
|
||||
reVerseOn = regexp.MustCompile(`^(\d+)[:.\s](\d+)-$`)
|
||||
reVerseRange = regexp.MustCompile(`^(\d+)[:.\s](\d+)-(\d+)$`)
|
||||
reVerseAcrossCh = regexp.MustCompile(`^(\d+)[:.\s](\d+)-(\d+)[:.](\d+)$`)
|
||||
)
|
||||
|
||||
// normalizeBookText: 'I Cor.' / '1st Corinthians' / '1 corinthians' -> '1corinthians'
|
||||
func normalizeBookText(text string) string {
|
||||
text = strings.ToLower(strings.TrimSpace(text))
|
||||
text = reBookOf.ReplaceAllString(text, "")
|
||||
text = reFirst.ReplaceAllString(text, "1${1}")
|
||||
text = reSecond.ReplaceAllString(text, "2${1}")
|
||||
text = reThird.ReplaceAllString(text, "3${1}")
|
||||
text = reRoman3.ReplaceAllString(text, "3${1}")
|
||||
text = reRoman2.ReplaceAllString(text, "2${1}")
|
||||
text = reRoman1.ReplaceAllString(text, "1${1}")
|
||||
return reNotAlnum.ReplaceAllString(text, "")
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
store *Store
|
||||
canonical map[string]int
|
||||
aliases map[string]int
|
||||
aliasKeys []string
|
||||
canonKeys []string
|
||||
}
|
||||
|
||||
func NewResolver(s *Store) *Resolver {
|
||||
r := &Resolver{store: s, canonical: map[string]int{}, aliases: map[string]int{}}
|
||||
for id, name := range s.bookName {
|
||||
r.canonical[normalizeBookText(name)] = id
|
||||
}
|
||||
for k, v := range r.canonical {
|
||||
r.aliases[k] = v
|
||||
}
|
||||
for _, a := range s.abbrevs {
|
||||
if k := normalizeBookText(a.Name); r.aliases[k] == 0 {
|
||||
r.aliases[k] = a.Book
|
||||
}
|
||||
}
|
||||
for name, extras := range extraBookAliases {
|
||||
id := s.bookByName[name]
|
||||
for _, extra := range extras {
|
||||
r.aliases[normalizeBookText(extra)] = id
|
||||
}
|
||||
}
|
||||
for k := range r.aliases {
|
||||
r.aliasKeys = append(r.aliasKeys, k)
|
||||
}
|
||||
sort.Strings(r.aliasKeys)
|
||||
for k := range r.canonical {
|
||||
r.canonKeys = append(r.canonKeys, k)
|
||||
}
|
||||
sort.Strings(r.canonKeys)
|
||||
return r
|
||||
}
|
||||
|
||||
type bookMatch struct {
|
||||
ID int
|
||||
How string // exact | prefix | fuzzy
|
||||
}
|
||||
|
||||
// resolveBook finds a book from a typed name. loose=false only accepts full
|
||||
// names and spellings that are unmistakably a book (used for a bare word with
|
||||
// no chapter number).
|
||||
func (r *Resolver) resolveBook(token string, loose bool) (bookMatch, bool) {
|
||||
key := normalizeBookText(token)
|
||||
if key == "" {
|
||||
return bookMatch{}, false
|
||||
}
|
||||
if !loose {
|
||||
if _, ok := r.canonical[key]; ok || bareBookExtra[key] {
|
||||
return bookMatch{r.aliases[key], "exact"}, true
|
||||
}
|
||||
// a clear misspelling of a long full name ("genisis")
|
||||
if len(key) >= 6 && len(key) <= 40 && !(key[0] >= '0' && key[0] <= '9') {
|
||||
if close, ok := closestMatch(key, r.canonKeys, 0.85); ok {
|
||||
return bookMatch{r.canonical[close], "fuzzy"}, true
|
||||
}
|
||||
}
|
||||
return bookMatch{}, false
|
||||
}
|
||||
if id, ok := r.aliases[key]; ok {
|
||||
return bookMatch{id, "exact"}, true
|
||||
}
|
||||
// unique (or best) prefix: "deut", "1cor", "matth"
|
||||
books := map[int]bool{}
|
||||
for _, k := range r.aliasKeys {
|
||||
if strings.HasPrefix(k, key) {
|
||||
books[r.aliases[k]] = true
|
||||
}
|
||||
}
|
||||
if len(books) > 0 {
|
||||
canonHits := map[int]bool{}
|
||||
for _, k := range r.canonKeys {
|
||||
if strings.HasPrefix(k, key) {
|
||||
canonHits[r.canonical[k]] = true
|
||||
}
|
||||
}
|
||||
if len(canonHits) == 1 {
|
||||
return bookMatch{anyKey(canonHits), "prefix"}, true
|
||||
}
|
||||
if len(books) == 1 {
|
||||
return bookMatch{anyKey(books), "prefix"}, true
|
||||
}
|
||||
pool := books
|
||||
if len(canonHits) > 0 {
|
||||
pool = canonHits
|
||||
}
|
||||
best := 0
|
||||
for id := range pool {
|
||||
if id > best {
|
||||
best = id // ambiguous: the later book, as before
|
||||
}
|
||||
}
|
||||
return bookMatch{best, "prefix"}, true
|
||||
}
|
||||
if len(key) >= 4 && len(key) <= 40 {
|
||||
if close, ok := closestMatch(key, r.aliasKeys, 0.78); ok {
|
||||
return bookMatch{r.aliases[close], "fuzzy"}, true
|
||||
}
|
||||
}
|
||||
return bookMatch{}, false
|
||||
}
|
||||
|
||||
func anyKey(m map[int]bool) int {
|
||||
for k := range m {
|
||||
return k
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// clampChapter: 0 -> 1, past the end -> the last chapter.
|
||||
func (r *Resolver) clampChapter(book, chapter int) int {
|
||||
last := r.store.chapterLen[book]
|
||||
if chapter > last {
|
||||
chapter = last
|
||||
}
|
||||
if chapter < 1 {
|
||||
chapter = 1
|
||||
}
|
||||
return chapter
|
||||
}
|
||||
|
||||
// atoi never fails: absurdly long numbers saturate.
|
||||
func atoi(s string) int {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 1_000_000
|
||||
}
|
||||
if n > 1_000_000 {
|
||||
return 1_000_000
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type refPos struct{ book, chapter, verse int }
|
||||
|
||||
// parseRefRest: '3:16-18', '3 16', '3.16', '3-4', '3:16-4:2', '3', '3:-' -> (start, end or 0, position)
|
||||
func (r *Resolver) parseRefRest(rest string, book int, previous *refPos) (start, end int64, pos refPos, ok bool) {
|
||||
rest = strings.NewReplacer("–", "-", "—", "-").Replace(rest)
|
||||
rest = reSepSpace.ReplaceAllString(strings.TrimSpace(rest), "$1")
|
||||
base := int64(book) * 1_000_000
|
||||
at := func(chapter, verse int) int64 { return base + int64(chapter)*1000 + int64(verse) }
|
||||
pos.book = book
|
||||
if m := reChapter.FindStringSubmatch(rest); m != nil {
|
||||
n := atoi(m[1])
|
||||
if previous != nil && previous.verse != 0 { // "John 3:16, 18": a verse of the same chapter
|
||||
pos.chapter, pos.verse = previous.chapter, n
|
||||
return at(previous.chapter, n), 0, pos, true
|
||||
}
|
||||
ch := r.clampChapter(book, n)
|
||||
pos.chapter = ch
|
||||
return at(ch, 1), at(ch, maxChapterIDSpan), pos, true
|
||||
}
|
||||
if m := reChapterAll.FindStringSubmatch(rest); m != nil { // "3:-" or "3-": the whole chapter
|
||||
ch := r.clampChapter(book, atoi(m[1]))
|
||||
pos.chapter = ch
|
||||
return at(ch, 1), at(ch, maxChapterIDSpan), pos, true
|
||||
}
|
||||
if m := reChapterRange.FindStringSubmatch(rest); m != nil { // chapters 3 to 4
|
||||
first, last := r.clampChapter(book, atoi(m[1])), r.clampChapter(book, atoi(m[2]))
|
||||
if last < first {
|
||||
first, last = last, first
|
||||
}
|
||||
pos.chapter = first
|
||||
return at(first, 1), at(last, maxChapterIDSpan), pos, true
|
||||
}
|
||||
if m := reVerse.FindStringSubmatch(rest); m != nil {
|
||||
ch := r.clampChapter(book, atoi(m[1]))
|
||||
verse := max(1, atoi(m[2]))
|
||||
pos.chapter, pos.verse = ch, verse
|
||||
return at(ch, verse), 0, pos, true
|
||||
}
|
||||
if m := reVerseOn.FindStringSubmatch(rest); m != nil { // "3:16-": from verse 16 to the end
|
||||
ch := r.clampChapter(book, atoi(m[1]))
|
||||
pos.chapter, pos.verse = ch, atoi(m[2])
|
||||
return at(ch, max(1, atoi(m[2]))), at(ch, maxChapterIDSpan), pos, true
|
||||
}
|
||||
if m := reVerseRange.FindStringSubmatch(rest); m != nil {
|
||||
ch := r.clampChapter(book, atoi(m[1]))
|
||||
first, last := max(1, atoi(m[2])), max(1, atoi(m[3]))
|
||||
if last < first {
|
||||
first, last = last, first
|
||||
}
|
||||
pos.chapter, pos.verse = ch, first
|
||||
return at(ch, first), at(ch, last), pos, true
|
||||
}
|
||||
if m := reVerseAcrossCh.FindStringSubmatch(rest); m != nil { // 3:16-4:2 across chapters
|
||||
c1, c2 := r.clampChapter(book, atoi(m[1])), r.clampChapter(book, atoi(m[3]))
|
||||
s, e := at(c1, max(1, atoi(m[2]))), at(c2, max(1, atoi(m[4])))
|
||||
if e < s {
|
||||
return 0, 0, pos, false
|
||||
}
|
||||
pos.chapter, pos.verse = c1, atoi(m[2])
|
||||
return s, e, pos, true
|
||||
}
|
||||
return 0, 0, pos, false
|
||||
}
|
||||
|
||||
type RefRange struct{ Start, End int64 } // End == 0: a single verse
|
||||
|
||||
type Note struct{ Original, Name string }
|
||||
|
||||
// Resolved is a whole parsed query.
|
||||
type Resolved struct {
|
||||
Refs []RefRange
|
||||
Notes []Note // short explanations of guesses that were made
|
||||
Bare bool // the query was just a book name (shows its first chapter)
|
||||
Label string // the name of the first book, for messages
|
||||
}
|
||||
|
||||
// Resolve parses a whole query ('John 3:16, Romans 5:8') into verse id ranges.
|
||||
// It returns nil when the text does not read as scripture references, so the
|
||||
// caller can treat it as a keyword search.
|
||||
func (r *Resolver) Resolve(text string, looseBare bool) *Resolved {
|
||||
if strings.TrimSpace(text) == "" || len(text) > maxRefQueryLen {
|
||||
return nil
|
||||
}
|
||||
var pieces []string
|
||||
for _, p := range regexp.MustCompile(`[,;\n]+`).Split(text, -1) {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
pieces = append(pieces, p)
|
||||
}
|
||||
}
|
||||
if len(pieces) == 0 || len(pieces) > 20 {
|
||||
return nil
|
||||
}
|
||||
out := &Resolved{}
|
||||
var previous *refPos
|
||||
for _, piece := range pieces {
|
||||
var start, end int64
|
||||
var pos refPos
|
||||
var name string
|
||||
if m := bookPieceRE.FindStringSubmatchIndex(piece); m != nil {
|
||||
bi, ri := bookPieceRE.SubexpIndex("book"), bookPieceRE.SubexpIndex("rest")
|
||||
token := piece[m[2*bi]:m[2*bi+1]]
|
||||
hasRest := m[2*ri] >= 0
|
||||
found, ok := r.resolveBook(token, hasRest || looseBare)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
name = r.store.BookName(found.ID)
|
||||
if found.How == "fuzzy" {
|
||||
out.Notes = append(out.Notes, Note{strings.TrimSpace(token), name})
|
||||
}
|
||||
if !hasRest {
|
||||
if len(pieces) == 1 {
|
||||
out.Bare = true
|
||||
}
|
||||
base := int64(found.ID) * 1_000_000
|
||||
start, end, pos = base+1001, base+1999, refPos{found.ID, 1, 0}
|
||||
} else {
|
||||
var ok bool
|
||||
start, end, pos, ok = r.parseRefRest(piece[m[2*ri]:m[2*ri+1]], found.ID, nil)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} else if continuationRE.MatchString(piece) && previous != nil {
|
||||
name = r.store.BookName(previous.book)
|
||||
var ok bool
|
||||
start, end, pos, ok = r.parseRefRest(piece, previous.book, previous)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
p := pos
|
||||
previous = &p
|
||||
if out.Label == "" {
|
||||
out.Label = name
|
||||
}
|
||||
out.Refs = append(out.Refs, RefRange{start, end})
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user