diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index 0e65770..ca8b57a 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -61,7 +61,7 @@ func mustLoad() lexiconFile { panic(fmt.Sprintf("lexicon: parse %s: %v", ruFile, err)) } for _, name := range []string{ - "interrogatives", "capture_verbs", "narrative_requests", "cardinals", + "interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals", "day_offsets", "weekdays", "months_genitive", "hours_spoken", "not_place_after_v", "parts_of_day", "reminder_verbs", } { @@ -120,6 +120,58 @@ func Cardinal(word string) (int, bool) { return n, ok } +// Ordinal reports the 1-based position a position word names, with -1 for the +// last one. Same lookup shape as Cardinal, and the same reason: "второй" and +// "вторым" are one position, and a caller matching stems would also match +// "вторник". +func Ordinal(word string) (int, bool) { + n, ok := ru.Sets["ordinals"].Values[norm(word)] + return n, ok +} + +// Ordinals returns the position words with their positions, sorted, so a caller +// that needs a form this set does not list can ask a morphological dictionary +// whether one of these is the same word. Sorted because map order is not stable +// and a caller folding these into a pattern would otherwise build a different one +// every run. +func Ordinals() []struct { + Word string + N int +} { + vals := ru.Sets["ordinals"].Values + out := make([]struct { + Word string + N int + }, 0, len(vals)) + for w, n := range vals { + out = append(out, struct { + Word string + N int + }{w, n}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Word < out[j].Word }) + return out +} + +// OrdinalIn reports the position word that comes FIRST in a sentence, so a +// caller does not have to tokenize before asking. Word-boundary matched for the +// reason above, and earliest-wins rather than first-found: map iteration order +// would otherwise answer "отметь первый и второй" differently between runs. +func OrdinalIn(text string) (int, bool) { + lower := norm(text) + best, at := 0, -1 + for w, n := range ru.Sets["ordinals"].Values { + i := indexWord(lower, w) + if i < 0 || (at >= 0 && i > at) { + continue + } + // Two different words cannot match at one offset: both ends are + // boundary-checked, so no key is a prefix of another as matched. + best, at = n, i + } + return best, at >= 0 +} + // DayOffset reports how many days a relative day word moves from today. // // The zero value is a real answer here — "сегодня" is offset 0 — so the second @@ -192,6 +244,28 @@ func abs(n int) int { return n } +// indexWord is containsWord returning where the match starts, or -1. +func indexWord(haystack, needle string) int { + if needle == "" { + return -1 + } + from := 0 + for { + i := strings.Index(haystack[from:], needle) + if i < 0 { + return -1 + } + i += from + if boundaryBefore(haystack, i) && boundaryAfter(haystack, i+len(needle)) { + return i + } + from = i + len(needle) + if from >= len(haystack) { + return -1 + } + } +} + // containsWord reports whether haystack holds needle on word boundaries. Go's // \b is ASCII-only and never fires after a Cyrillic letter, so the boundary is // checked here instead: a rune on either side must not be a letter or a digit. diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index 0087e13..f3b1291 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -37,6 +37,22 @@ "tell", "explain", "describe", "list" ] }, + "ordinals": { + "note": "Position words, as spoken, with the gender and oblique forms Russian requires: \"отметь второй пункт\" and \"закрепи вторым\" name one position (Vikunja #516). Values are the 1-based position, and -1 is the last one, which is a position rather than a count. The genitive forms here are also what a half-past hour needs (\"в половине восьмого\", V-538), so this set is written for two callers.", + "values": { + "первый": 1, "первая": 1, "первое": 1, "первого": 1, "первую": 1, "первым": 1, "первой": 1, "first": 1, + "второй": 2, "вторая": 2, "второе": 2, "второго": 2, "вторую": 2, "вторым": 2, "second": 2, + "третий": 3, "третья": 3, "третье": 3, "третьего": 3, "третью": 3, "третьим": 3, "третьей": 3, "third": 3, + "четвёртый": 4, "четвертый": 4, "четвёртая": 4, "четвертая": 4, "четвёртого": 4, "четвертого": 4, "четвёртую": 4, "четвертую": 4, "четвёртым": 4, "четвертым": 4, "fourth": 4, + "пятый": 5, "пятая": 5, "пятое": 5, "пятого": 5, "пятую": 5, "пятым": 5, "пятой": 5, "fifth": 5, + "шестой": 6, "шестая": 6, "шестое": 6, "шестого": 6, "шестую": 6, "шестым": 6, "sixth": 6, + "седьмой": 7, "седьмая": 7, "седьмое": 7, "седьмого": 7, "седьмую": 7, "седьмым": 7, "seventh": 7, + "восьмой": 8, "восьмая": 8, "восьмое": 8, "восьмого": 8, "восьмую": 8, "восьмым": 8, "eighth": 8, + "девятый": 9, "девятая": 9, "девятое": 9, "девятого": 9, "девятую": 9, "девятым": 9, "ninth": 9, + "десятый": 10, "десятая": 10, "десятое": 10, "десятого": 10, "десятую": 10, "десятым": 10, "tenth": 10, + "последний": -1, "последняя": -1, "последнее": -1, "последнего": -1, "последнюю": -1, "последним": -1, "last": -1 + } + }, "cardinals": { "note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.", "values": { diff --git a/internal/lexicon/lexicon_test.go b/internal/lexicon/lexicon_test.go index 3daf095..26c1a65 100644 --- a/internal/lexicon/lexicon_test.go +++ b/internal/lexicon/lexicon_test.go @@ -112,3 +112,51 @@ func TestCallerCannotEditTheLexicon(t *testing.T) { t.Fatal("the lexicon handed out its own backing array") } } + +// The positions carry gender and oblique forms, because "второй пункт" and +// "закрепи вторым" name one position (Vikunja #516). "last" is a position and not +// a count, so it is -1 rather than a large number. +func TestOrdinalsSpanGenderAndCase(t *testing.T) { + for _, w := range []string{"второй", "вторая", "второе", "вторым", "второго", "second"} { + n, ok := Ordinal(w) + if !ok || n != 2 { + t.Errorf("Ordinal(%q) = %d, %v; want 2, true", w, n, ok) + } + } + for _, w := range []string{"последний", "последнюю", "last"} { + if n, ok := Ordinal(w); !ok || n != -1 { + t.Errorf("Ordinal(%q) = %d, %v; want -1, true", w, n, ok) + } + } + // A weekday shares a stem with a position and is not one. + if n, ok := Ordinal("вторник"); ok { + t.Errorf("Ordinal(\"вторник\") = %d; a weekday is not a position", n) + } +} + +// Earliest wins, not map order: the same sentence must answer the same way twice. +func TestOrdinalInTakesTheFirstPosition(t *testing.T) { + for i := 0; i < 50; i++ { + n, ok := OrdinalIn("отметь первый и второй пункт") + if !ok || n != 1 { + t.Fatalf("run %d: OrdinalIn = %d, %v; want 1, true", i, n, ok) + } + } + if _, ok := OrdinalIn("отметь пункт"); ok { + t.Error("a sentence with no position reported one") + } +} + +// Ordinals is the escape hatch for the cases the file does not list, so it must +// hand out every entry and hand out the same order twice. +func TestOrdinalsListIsCompleteAndStable(t *testing.T) { + a, b := Ordinals(), Ordinals() + if len(a) != len(ru.Sets["ordinals"].Values) { + t.Errorf("Ordinals returned %d of %d entries", len(a), len(ru.Sets["ordinals"].Values)) + } + for i := range a { + if a[i] != b[i] { + t.Fatalf("Ordinals order is not stable at %d: %v vs %v", i, a[i], b[i]) + } + } +}