yanagi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "normalize"
4
+ require_relative "rules"
5
+ require_relative "mora"
6
+
7
+ module Yanagi
8
+ Result = Struct.new(:text, :source, :confidence, :notes, keyword_init: true) do
9
+ def to_s
10
+ text.to_s
11
+ end
12
+
13
+ def to_str
14
+ text.to_s
15
+ end
16
+
17
+ def ==(other)
18
+ if other.is_a?(String)
19
+ text == other
20
+ else
21
+ super
22
+ end
23
+ end
24
+
25
+ def inspect
26
+ "#<Yanagi::Result text=#{text.inspect} source=#{source.inspect} confidence=#{confidence.inspect}>"
27
+ end
28
+ end
29
+
30
+ module Cyrillic
31
+ O_COLUMN = %w[
32
+ お こ そ と の ほ も よ ろ を
33
+ ご ぞ ど ぼ ぽ
34
+ きょ しょ ちょ にょ ひょ みょ りょ ぎょ じょ びょ ぴょ
35
+ ふぉ ゔぉ
36
+ ].freeze
37
+
38
+ U_COLUMN = %w[
39
+ う く す つ ぬ ふ む ゆ る
40
+ ぐ ず づ ぶ ぷ
41
+ しゅ ちゅ じゅ きゅ ぎゅ にゅ ひゅ みゅ りゅ びゅ ぴゅ
42
+ ゔ どぅ とぅ
43
+ ].freeze
44
+
45
+ A_COLUMN = %w[
46
+ あ か さ た な は ま や ら わ
47
+ が ざ だ ば ぱ
48
+ きゃ しゃ ちゃ にゃ ひゃ みゃ りゃ ぎゃ じゃ びゃ ぴゃ
49
+ ふぁ ゔぁ
50
+ ].freeze
51
+
52
+ E_COLUMN = %w[
53
+ え け せ て ね へ め れ ゑ
54
+ げ ぜ で べ ぺ
55
+ しぇ ちぇ つぇ ふぇ うぇ ゔぇ
56
+ ].freeze
57
+
58
+ I_COLUMN = %w[
59
+ い き し ち に ひ み り ゐ
60
+ ぎ じ ぢ び ぴ
61
+ ふぃ ゔぃ てぃ でぃ
62
+ ].freeze
63
+
64
+ def self.call(input = nil, kanji: nil, reading: nil)
65
+ raw_str = (reading || input).to_s.strip
66
+ kanji_str = kanji.to_s.strip
67
+
68
+ # 1. Whole-word exonym check
69
+ exonyms = Rules.exonyms
70
+ norm_key = Normalize.to_hiragana(Normalize.nfkc(raw_str))
71
+ if exonyms.key?(norm_key.to_sym)
72
+ entry = exonyms[norm_key.to_sym]
73
+ return Result.new(
74
+ text: entry[:cyrillic],
75
+ source: :exonym,
76
+ confidence: 1.0,
77
+ notes: entry[:note] || "Exonym"
78
+ )
79
+ end
80
+
81
+ # 2. Check exceptions table
82
+ exceptions = Rules.exceptions
83
+ if exceptions.is_a?(Array)
84
+ match = exceptions.find do |e|
85
+ (!kanji_str.empty? && e[:kanji] == kanji_str) ||
86
+ e[:term] == raw_str ||
87
+ e[:expected] == raw_str
88
+ end
89
+ if match
90
+ return Result.new(
91
+ text: match[:canonical] || match[:expected],
92
+ source: :exception_table,
93
+ confidence: 1.0,
94
+ notes: match[:reason]
95
+ )
96
+ end
97
+ end
98
+
99
+ # 3. Check lexicon (if present)
100
+ lexicon = Rules.lexicon
101
+ if lexicon.is_a?(Hash) && !kanji_str.empty? && lexicon.key?(kanji_str.to_sym)
102
+ entry = lexicon[kanji_str.to_sym]
103
+ return Result.new(
104
+ text: entry[:cyrillic] || entry[:reading_cyrillic],
105
+ source: :lexicon,
106
+ confidence: 1.0,
107
+ notes: entry[:gloss]
108
+ )
109
+ end
110
+
111
+ # 4. If input is romaji (only ASCII letters and apostrophes/hyphens/spaces)
112
+ if raw_str.match?(/\A[a-zA-Z'\- ]+\z/)
113
+ converted_kana = romaji_to_hiragana(raw_str)
114
+ return render_kana(converted_kana)
115
+ end
116
+
117
+ # 5. Derived Cyrillic transliteration from kana
118
+ render_kana(raw_str)
119
+ end
120
+
121
+ def self.render_kana(kana_input)
122
+ moras = Tokenizer.tokenize(kana_input)
123
+ mora_map = Rules.mora_map
124
+
125
+ # Step 1: Render each mora into candidate text (handling long vowel collapse and i diphthongs)
126
+ rendered_segments = []
127
+
128
+ moras.each_with_index do |mora, idx|
129
+ prev_mora = idx > 0 ? moras[idx - 1] : nil
130
+
131
+ case mora.kind
132
+ when :passthrough
133
+ rendered_segments << { mora: mora, text: mora.kana.to_s, kind: :passthrough }
134
+
135
+ when :chouonpu
136
+ # Long vowel mark collapses under policy
137
+ rendered_segments << { mora: mora, text: "", kind: :chouonpu }
138
+
139
+ when :sokuon
140
+ # Sokuon is resolved in Step 2 with lookahead
141
+ rendered_segments << { mora: mora, text: nil, kind: :sokuon }
142
+
143
+ when :moraic_n
144
+ # Moraic n is resolved in Step 2 with lookahead
145
+ rendered_segments << { mora: mora, text: nil, kind: :moraic_n }
146
+
147
+ when :syllable
148
+ kana = mora.kana
149
+ base_cyr = mora_map.dig(kana.to_sym, :cyrillic)&.to_s || kana
150
+
151
+ # Long vowel collapse rules:
152
+ # - う after O-column or U-column collapses
153
+ if kana == "う" && prev_mora && prev_mora.syllable?
154
+ if O_COLUMN.include?(prev_mora.kana) || U_COLUMN.include?(prev_mora.kana)
155
+ rendered_segments << { mora: mora, text: "", kind: :collapsed_long_vowel }
156
+ next
157
+ end
158
+ end
159
+
160
+ # - お after O-column collapses (e.g. おおたけ -> Отаке)
161
+ if kana == "お" && prev_mora && prev_mora.syllable? && O_COLUMN.include?(prev_mora.kana)
162
+ rendered_segments << { mora: mora, text: "", kind: :collapsed_long_vowel }
163
+ next
164
+ end
165
+
166
+ # - あ after A-column collapses
167
+ if kana == "あ" && prev_mora && prev_mora.syllable? && A_COLUMN.include?(prev_mora.kana)
168
+ rendered_segments << { mora: mora, text: "", kind: :collapsed_long_vowel }
169
+ next
170
+ end
171
+
172
+ # - え after E-column collapses
173
+ if kana == "え" && prev_mora && prev_mora.syllable? && E_COLUMN.include?(prev_mora.kana)
174
+ rendered_segments << { mora: mora, text: "", kind: :collapsed_long_vowel }
175
+ next
176
+ end
177
+
178
+ # - い diphthong vs vowel:
179
+ # い after E-column -> 'й' (e.g. めい -> мей, せんせい -> сенсей)
180
+ # い after A-column -> 'й' (e.g. しゅうさい -> шюсай, たい -> тай)
181
+ # い after U-column -> 'і' (e.g. かるいざわ -> каруідзава, ぬいもん -> нуімон)
182
+ # い after O-column -> 'і' (e.g. ごい -> ґоі, どい -> доі)
183
+ # い after I-column -> 'і' (e.g. きいん -> кіін, いいだ -> ііда, りいち -> ріічі)
184
+ if kana == "い" && prev_mora && prev_mora.syllable?
185
+ if E_COLUMN.include?(prev_mora.kana) || A_COLUMN.include?(prev_mora.kana)
186
+ rendered_segments << { mora: mora, text: "й", kind: :syllable }
187
+ next
188
+ end
189
+ end
190
+
191
+ rendered_segments << { mora: mora, text: base_cyr, kind: :syllable }
192
+ end
193
+ end
194
+
195
+ # Step 2: Resolve sokuon (っ) and moraic n (ん) based on next rendered segment
196
+ final_parts = []
197
+ rendered_segments.each_with_index do |seg, idx|
198
+ if seg[:kind] == :sokuon
199
+ # Find next non-empty rendered segment
200
+ next_seg = rendered_segments[(idx + 1)..].find { |s| s[:text] && !s[:text].empty? }
201
+ if next_seg
202
+ next_text = next_seg[:text]
203
+ geminated = geminate_consonant(next_text)
204
+ final_parts << geminated if geminated
205
+ end
206
+ elsif seg[:kind] == :moraic_n
207
+ # Find next non-empty rendered segment
208
+ next_seg = rendered_segments[(idx + 1)..].find { |s| s[:text] && !s[:text].empty? }
209
+ if next_seg && starts_with_labial?(next_seg[:text])
210
+ final_parts << "м"
211
+ else
212
+ final_parts << "н"
213
+ end
214
+ else
215
+ final_parts << seg[:text]
216
+ end
217
+ end
218
+
219
+ Result.new(
220
+ text: final_parts.join,
221
+ source: :derived,
222
+ confidence: 1.0,
223
+ notes: nil
224
+ )
225
+ end
226
+
227
+ # Sokuon doubles only before the plosives «п» and «к», which Ukrainian
228
+ # carries comfortably (Іппекі, кеппекі, Ніккай, Хоккекьо). Before the
229
+ # sibilants and affricates it is not rendered: шш, чч and ддж read as
230
+ # foreign, so шічяку, Тешю, доджін-дзаші.
231
+ GEMINATING_CONSONANTS = %w[п к].freeze
232
+
233
+ def self.geminate_consonant(next_rendered_text)
234
+ return "" if next_rendered_text.nil? || next_rendered_text.empty?
235
+
236
+ first_char = next_rendered_text[0]
237
+ GEMINATING_CONSONANTS.include?(first_char) ? first_char : ""
238
+ end
239
+
240
+ def self.starts_with_labial?(text)
241
+ return false if text.nil? || text.empty?
242
+ # Labials: 'п', 'б'
243
+ text.start_with?("п", "б")
244
+ end
245
+
246
+ # Simple romaji to hiragana conversion for Mode A romaji input
247
+ def self.romaji_to_hiragana(str)
248
+ norm = str.downcase.gsub("'", "'")
249
+ mora_map = Rules.mora_map
250
+
251
+ # Build inverted map from romaji -> hiragana kana
252
+ @romaji_to_hira_table ||= begin
253
+ table = {}
254
+ mora_map.each do |kana_sym, data|
255
+ rom = data[:romaji]&.to_s
256
+ table[rom] = kana_sym.to_s if rom
257
+ end
258
+ # Sort by romaji length descending to match longest substrings first
259
+ table.sort_by { |k, _v| -k.length }.to_h
260
+ end
261
+
262
+ res = []
263
+ i = 0
264
+ len = norm.length
265
+ while i < len
266
+ matched = false
267
+
268
+ # Check sokuon in romaji (e.g. kk, pp, tt, ss, tch, ssh, etc.)
269
+ c = norm[i]
270
+ c_next = norm[i + 1]
271
+ c3 = norm[i, 3]
272
+
273
+ if c3 == "tch"
274
+ res << "っ"
275
+ i += 1
276
+ next
277
+ elsif c == c_next && c =~ /[bcdfghjklmpqrstvwxyz]/ && c != "n"
278
+ res << "っ"
279
+ i += 1
280
+ next
281
+ end
282
+
283
+ # Match table
284
+ @romaji_to_hira_table.each do |rom, kana|
285
+ if norm[i..].start_with?(rom)
286
+ res << kana
287
+ i += rom.length
288
+ matched = true
289
+ break
290
+ end
291
+ end
292
+
293
+ unless matched
294
+ res << norm[i]
295
+ i += 1
296
+ end
297
+ end
298
+
299
+ res.join
300
+ end
301
+ end
302
+
303
+ def self.cyrillic(input = nil, kanji: nil, reading: nil)
304
+ Cyrillic.call(input, kanji: kanji, reading: reading)
305
+ end
306
+
307
+ def self.from_romaji(romaji_str)
308
+ Cyrillic.call(romaji_str)
309
+ end
310
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rules"
4
+
5
+ module Yanagi
6
+ class DocSync
7
+ attr_reader :path
8
+
9
+ def self.default_path
10
+ candidates = [
11
+ File.expand_path("~/projects/meijin/shared/transliteration.md"),
12
+ File.expand_path("shared/transliteration.md", Dir.pwd),
13
+ File.expand_path("../../shared/transliteration.md", __dir__)
14
+ ]
15
+ candidates.find { |p| File.exist?(p) } || candidates.first
16
+ end
17
+
18
+ def initialize(path: nil)
19
+ @path = path || self.class.default_path
20
+ end
21
+
22
+ def diff(rules = nil)
23
+ rules ||= Rules.mora_map
24
+ return [{ kind: :file_not_found, path: @path }] unless File.exist?(@path)
25
+
26
+ content = File.read(@path, encoding: "UTF-8")
27
+ differences = []
28
+
29
+ # 1. Parse §1 table
30
+ table_1_entries = parse_section_1_table(content)
31
+ table_1_entries.each do |entry|
32
+ entry[:kanas].each do |kana|
33
+ rule_data = rules[kana.to_sym]
34
+ next unless rule_data
35
+
36
+ expected_cyr = rule_data[:cyrillic]
37
+ expected_forb = rule_data[:forbidden] || []
38
+
39
+ # Check if doc Cyrillic matches expected (either exact mora, base consonant prefix, or included in listed items)
40
+ matched_cyr = entry[:cyrillic_items].include?(expected_cyr) ||
41
+ (entry[:base_cyrillic] && expected_cyr.start_with?(entry[:base_cyrillic]))
42
+
43
+ unless matched_cyr
44
+ differences << {
45
+ kind: :mora_mismatch,
46
+ section: 1,
47
+ kana: kana,
48
+ doc: entry[:base_cyrillic] || entry[:cyrillic_items].join(", "),
49
+ rules: expected_cyr
50
+ }
51
+ end
52
+
53
+ # Check forbidden variants
54
+ if entry[:forbidden_items].any?
55
+ # At least one forbidden form or base prefix in doc should match rule's forbidden
56
+ matched_forb = entry[:forbidden_items].any? do |df|
57
+ expected_forb.any? { |rf| rf == df || rf.start_with?(df) }
58
+ end || (entry[:base_forbidden] && expected_forb.any? { |rf| rf.start_with?(entry[:base_forbidden]) })
59
+
60
+ unless matched_forb
61
+ differences << {
62
+ kind: :forbidden_mismatch,
63
+ section: 1,
64
+ kana: kana,
65
+ doc_forbidden: entry[:forbidden_items].join(", "),
66
+ rules_forbidden: expected_forb
67
+ }
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ # 2. Parse §2 bullet points (Yōon)
74
+ yoon_entries = parse_section_2_yoon(content)
75
+ yoon_entries.each do |entry|
76
+ entry[:kanas].each_with_index do |kana, idx|
77
+ expected_cyr = rules.dig(kana.to_sym, :cyrillic)
78
+ next unless expected_cyr
79
+
80
+ doc_cyr = entry[:cyrillics][idx] || entry[:cyrillics].first
81
+ if doc_cyr && doc_cyr != expected_cyr
82
+ differences << {
83
+ kind: :mora_mismatch,
84
+ section: 2,
85
+ kana: kana,
86
+ doc: doc_cyr,
87
+ rules: expected_cyr
88
+ }
89
+ end
90
+ end
91
+ end
92
+
93
+ differences
94
+ end
95
+
96
+ def synced?(rules = nil)
97
+ diff(rules).empty?
98
+ end
99
+
100
+ private
101
+
102
+ def clean_markdown_markup(str)
103
+ str.to_s.gsub(/\*\*/, "").gsub(/__/, "").gsub(/`/, "").strip
104
+ end
105
+
106
+ def parse_section_1_table(content)
107
+ entries = []
108
+ in_sec_1 = false
109
+
110
+ content.each_line do |line|
111
+ if line =~ /^##\s+1\.\s+/
112
+ in_sec_1 = true
113
+ next
114
+ elsif line =~ /^##\s+2\.\s+/
115
+ break
116
+ end
117
+
118
+ next unless in_sec_1
119
+ next unless line.start_with?("|")
120
+ parts = line.split("|").map(&:strip).reject(&:empty?)
121
+ next if parts.empty? || parts[0].start_with?("-") || parts[0] =~ /Японська/i
122
+
123
+ kana_col = parts[0]
124
+ cyr_col = parts[1]
125
+ forb_col = parts[2]
126
+
127
+ kanas = extract_kanas(kana_col)
128
+
129
+ clean_cyr = clean_markdown_markup(cyr_col)
130
+ clean_forb = clean_markdown_markup(forb_col)
131
+
132
+ base_cyr = clean_cyr.split(/[\s(]/).first
133
+ base_forb = clean_forb.split(/[\s(]/).first
134
+
135
+ cyr_items = if clean_cyr =~ /\((.+?)\)/
136
+ $1.split(",").flat_map { |s| s.split("/") }.map(&:strip).reject(&:empty?)
137
+ else
138
+ [base_cyr]
139
+ end
140
+
141
+ forb_items = if clean_forb =~ /\((.+?)\)/
142
+ $1.split(",").flat_map { |s| s.split("/") }.map(&:strip).reject(&:empty?)
143
+ else
144
+ clean_forb.split(",").flat_map { |s| s.split("/") }.map(&:strip).reject(&:empty?)
145
+ end
146
+
147
+ entries << {
148
+ kanas: kanas,
149
+ base_cyrillic: base_cyr,
150
+ cyrillic_items: cyr_items,
151
+ base_forbidden: base_forb,
152
+ forbidden_items: forb_items
153
+ }
154
+ end
155
+
156
+ entries
157
+ end
158
+
159
+ def parse_section_2_yoon(content)
160
+ entries = []
161
+ in_sec_2 = false
162
+
163
+ content.each_line do |line|
164
+ if line =~ /^##\s+2\.\s+/
165
+ in_sec_2 = true
166
+ next
167
+ elsif line =~ /^##\s+3\.\s+/
168
+ break
169
+ end
170
+
171
+ next unless in_sec_2
172
+ if line =~ /^\s*-\s+(.+?)\s*→\s*(.+?)(?:\(|$)/
173
+ left = $1.strip
174
+ right = $2.strip
175
+
176
+ kanas = extract_kanas(left)
177
+ cyrillics = clean_markdown_markup(right).split(/[\s\/]+/).map(&:strip).reject(&:empty?)
178
+
179
+ entries << { kanas: kanas, cyrillics: cyrillics }
180
+ end
181
+ end
182
+
183
+ entries
184
+ end
185
+
186
+ def extract_kanas(str)
187
+ str.scan(/[\u3040-\u309F]+/)
188
+ end
189
+ end
190
+
191
+ def self.doc_sync(path: nil)
192
+ DocSync.new(path: path)
193
+ end
194
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "rules"
5
+ require_relative "normalize"
6
+ require_relative "cyrillic"
7
+
8
+ module Yanagi
9
+ module Lexicon
10
+ UKRAINIAN_SUFFIXES = %w[
11
+ ами ями ах ях ів їв ою ею єю
12
+ ом ем єм
13
+ а е и і о у ю я ї й
14
+ ].freeze
15
+
16
+ MIN_STEM_LENGTH = 4
17
+
18
+ def self.load_entries
19
+ Rules.lexicon || {}
20
+ end
21
+
22
+ # Terms of non-Japanese origin (Chinese works, place names) are transliterated
23
+ # by their own language's conventions, so Japanese mora rules must not apply.
24
+ # Detected from the glossary note column, which states the provenance.
25
+ NON_JP_NOTE_MARKERS = [
26
+ "Китай", "китайськ", "конфуціанськ", "Даоськ", "даоськ", "Янцзи"
27
+ ].freeze
28
+
29
+ def self.detect_origin(note)
30
+ text = note.to_s
31
+ return "zh" if NON_JP_NOTE_MARKERS.any? { |m| text.include?(m) }
32
+
33
+ "ja"
34
+ end
35
+
36
+ def self.build_from_glossary(glossary_path, out_path: nil)
37
+ content = File.read(glossary_path, encoding: "UTF-8")
38
+ lexicon = {}
39
+
40
+ content.each_line do |line|
41
+ next unless line.start_with?("|")
42
+ parts = line.split("|").map(&:strip).reject(&:empty?)
43
+ next if parts.empty? || parts[0].start_with?("-") || parts[0] =~ /Японське/i
44
+
45
+ col0 = parts[0]
46
+ col1 = parts[1]
47
+ col2 = parts[2]
48
+
49
+ kanji = nil
50
+ reading = nil
51
+ if col0 =~ /^(.+?)\s*\((.+?)\)$/
52
+ kanji = Regexp.last_match(1).strip
53
+ reading = Regexp.last_match(2).strip
54
+ else
55
+ kanji = col0.strip
56
+ reading = nil
57
+ end
58
+
59
+ uk_clean = col1.gsub(/^[«"'\`]|["'\`»]$/, "").strip
60
+ key = kanji.gsub(/^[«"'\`]|["'\`»]$/, "").strip
61
+ next if key.empty?
62
+
63
+ cyr = reading || uk_clean
64
+
65
+ lexicon[key] = {
66
+ "kanji" => kanji,
67
+ "reading" => reading,
68
+ "cyrillic" => cyr,
69
+ "ukrainian" => col1,
70
+ "note" => col2,
71
+ "origin" => detect_origin(col2)
72
+ }
73
+ end
74
+
75
+ out_file = out_path || Rules.path_for("lexicon.yml")
76
+ File.write(out_file, YAML.dump(lexicon))
77
+ reload!
78
+ Rules.reload!
79
+ lexicon
80
+ end
81
+
82
+ def self.merge_proposal(proposal_path, out_path: nil)
83
+ proposals = YAML.safe_load_file(proposal_path, permitted_classes: [Symbol, Date], symbolize_names: true) || []
84
+ proposals = [proposals] unless proposals.is_a?(Array)
85
+
86
+ current_lexicon = Rules.lexicon ? Rules.lexicon.transform_keys(&:to_s) : {}
87
+ new_lexicon = current_lexicon.dup
88
+
89
+ errors = []
90
+
91
+ proposals.each do |prop|
92
+ status = prop[:status]&.to_s
93
+ unless status == "accepted"
94
+ errors << "Proposal #{prop[:term] || prop[:kanji]} status is '#{status}', must be 'accepted' to merge"
95
+ next
96
+ end
97
+
98
+ kanji = prop[:kanji]&.to_s
99
+ reading = prop[:reading]&.to_s
100
+ cyrillic = prop[:cyrillic]&.to_s
101
+
102
+ if reading && !reading.empty? && cyrillic && !cyrillic.empty?
103
+ derived_cyr = Yanagi.cyrillic(reading).text
104
+ if derived_cyr != cyrillic && prop[:override] != true
105
+ errors << "Proposal #{kanji || reading} Cyrillic '#{cyrillic}' does not match engine derived '#{derived_cyr}'"
106
+ next
107
+ end
108
+ end
109
+
110
+ key = (kanji && !kanji.empty?) ? kanji : cyrillic
111
+ new_lexicon[key] = {
112
+ "kanji" => kanji,
113
+ "reading" => reading,
114
+ "cyrillic" => cyrillic,
115
+ "ukrainian" => prop[:ukrainian] || cyrillic,
116
+ "note" => prop[:note]
117
+ }
118
+ end
119
+
120
+ unless errors.empty?
121
+ raise Error, "Cannot merge proposals:\n- #{errors.join("\n- ")}"
122
+ end
123
+
124
+ out_file = out_path || Rules.path_for("lexicon.yml")
125
+ File.write(out_file, YAML.dump(new_lexicon))
126
+ reload!
127
+ Rules.reload!
128
+ new_lexicon
129
+ end
130
+
131
+ def self.strip_inflection(token)
132
+ w = token.downcase.gsub(/^[«"'\`\(\[\{]|["'\`\)\]\}\.,;:!?—–]+$/, "")
133
+ return [w, ""] if w.length <= MIN_STEM_LENGTH
134
+
135
+ UKRAINIAN_SUFFIXES.each do |suffix|
136
+ if w.end_with?(suffix) && (w.length - suffix.length) >= MIN_STEM_LENGTH
137
+ stem = w[0...(w.length - suffix.length)]
138
+ return [stem, suffix]
139
+ end
140
+ end
141
+
142
+ [w, ""]
143
+ end
144
+
145
+ def self.reload!
146
+ @exact_index = nil
147
+ @stem_index = nil
148
+ end
149
+
150
+ def self.exact_index
151
+ @exact_index ||= begin
152
+ idx = {}
153
+ load_entries.each do |k, entry|
154
+ cyr = (entry[:cyrillic] || entry["cyrillic"])&.to_s&.downcase
155
+ next unless cyr
156
+
157
+ # Index full term
158
+ idx[cyr] = { key: k.to_s, entry: entry, stem: cyr, suffix: "", exact: true }
159
+
160
+ # Also index individual words of multi-word phrases (e.g. 'хонінбо', 'шюсай')
161
+ words = cyr.split(/[\s-]+/).map(&:strip).reject(&:empty?)
162
+ if words.length > 1
163
+ words.each do |w|
164
+ idx[w] ||= { key: k.to_s, entry: entry, stem: w, suffix: "", exact: true } if w.length >= MIN_STEM_LENGTH
165
+ end
166
+ end
167
+ end
168
+ idx
169
+ end
170
+ end
171
+
172
+ def self.stem_index
173
+ @stem_index ||= begin
174
+ idx = {}
175
+ load_entries.each do |k, entry|
176
+ cyr = (entry[:cyrillic] || entry["cyrillic"])&.to_s&.downcase
177
+ next unless cyr
178
+
179
+ words = cyr.split(/[\s-]+/).map(&:strip).reject(&:empty?)
180
+ words.each do |w|
181
+ stem, _ = strip_inflection(w)
182
+ idx[stem] ||= { key: k.to_s, entry: entry, stem: stem } if stem.length >= MIN_STEM_LENGTH
183
+ idx[w] ||= { key: k.to_s, entry: entry, stem: w } if w.length >= MIN_STEM_LENGTH
184
+ end
185
+ end
186
+ idx
187
+ end
188
+ end
189
+
190
+ def self.find_by_stem(token)
191
+ clean = token.downcase.gsub(/^[«"'\`\(\[\{]|["'\`\)\]\}\.,;:!?—–]+$/, "")
192
+ return exact_index[clean] if exact_index.key?(clean)
193
+
194
+ stem, suffix = strip_inflection(clean)
195
+ return nil if stem.length < MIN_STEM_LENGTH
196
+
197
+ if stem_index.key?(stem)
198
+ info = stem_index[stem]
199
+ return {
200
+ key: info[:key],
201
+ entry: info[:entry],
202
+ stem: stem,
203
+ suffix: suffix,
204
+ exact: false
205
+ }
206
+ end
207
+
208
+ nil
209
+ end
210
+ end
211
+ end