psychowl 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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +212 -0
  5. data/exe/psychowl +106 -0
  6. data/ext/psychowl_native/core/data/LICENSE-whatlang +26 -0
  7. data/ext/psychowl_native/core/data/README.md +32 -0
  8. data/ext/psychowl_native/core/data/alphabets.tsv +16 -0
  9. data/ext/psychowl_native/core/data/languages.tsv +20 -0
  10. data/ext/psychowl_native/core/data/scripts.tsv +28 -0
  11. data/ext/psychowl_native/core/data/trigrams/ara.txt +302 -0
  12. data/ext/psychowl_native/core/data/trigrams/deu.txt +302 -0
  13. data/ext/psychowl_native/core/data/trigrams/eng.txt +302 -0
  14. data/ext/psychowl_native/core/data/trigrams/fra.txt +302 -0
  15. data/ext/psychowl_native/core/data/trigrams/hin.txt +302 -0
  16. data/ext/psychowl_native/core/data/trigrams/ind.txt +302 -0
  17. data/ext/psychowl_native/core/data/trigrams/ita.txt +302 -0
  18. data/ext/psychowl_native/core/data/trigrams/nld.txt +302 -0
  19. data/ext/psychowl_native/core/data/trigrams/pol.txt +302 -0
  20. data/ext/psychowl_native/core/data/trigrams/por.txt +302 -0
  21. data/ext/psychowl_native/core/data/trigrams/rus.txt +302 -0
  22. data/ext/psychowl_native/core/data/trigrams/spa.txt +302 -0
  23. data/ext/psychowl_native/core/data/trigrams/tur.txt +302 -0
  24. data/ext/psychowl_native/core/data/trigrams/ukr.txt +302 -0
  25. data/ext/psychowl_native/core/data/trigrams/vie.txt +302 -0
  26. data/lib/psychowl/active_model.rb +64 -0
  27. data/lib/psychowl/data_file.rb +14 -0
  28. data/lib/psychowl/detector.rb +123 -0
  29. data/lib/psychowl/engine.rb +23 -0
  30. data/lib/psychowl/info.rb +39 -0
  31. data/lib/psychowl/lang.rb +55 -0
  32. data/lib/psychowl/locale/en.yml +6 -0
  33. data/lib/psychowl/lookup.rb +25 -0
  34. data/lib/psychowl/native_speedup.rb +46 -0
  35. data/lib/psychowl/postgres.rb +35 -0
  36. data/lib/psychowl/railtie.rb +10 -0
  37. data/lib/psychowl/ruby_engine/segmentation.rb +84 -0
  38. data/lib/psychowl/ruby_engine/trigrams.rb +74 -0
  39. data/lib/psychowl/ruby_engine.rb +166 -0
  40. data/lib/psychowl/script.rb +39 -0
  41. data/lib/psychowl/segment.rb +24 -0
  42. data/lib/psychowl/tables.rb +55 -0
  43. data/lib/psychowl/text.rb +26 -0
  44. data/lib/psychowl/version.rb +5 -0
  45. data/lib/psychowl.rb +82 -0
  46. metadata +91 -0
@@ -0,0 +1,302 @@
1
+ # vie: 300 most frequent trigrams, most frequent first. "_" marks a word boundary.
2
+ # Ported from whatlang 0.18.0 (MIT), see ../LICENSE-whatlang.
3
+ ng_
4
+ ̣c_
5
+ ́c_
6
+ _qu
7
+ _th
8
+ à_
9
+ nh_
10
+ _ng
11
+ ̣i_
12
+ _nh
13
+ và
14
+ _va
15
+ ̀n_
16
+ uyê
17
+ _ph
18
+ _ca
19
+ quy
20
+ ền
21
+ yề
22
+ ̀i_
23
+ _ch
24
+ ̀nh
25
+ _tr
26
+ _cu
27
+ ngư
28
+ i_n
29
+ gươ
30
+ ườ
31
+ ́t_
32
+ ời
33
+ _gi
34
+ ác
35
+ _co
36
+ ̣t_
37
+ ó_
38
+ c_t
39
+ ự_
40
+ n_t
41
+ cá
42
+ ông
43
+ _kh
44
+ ượ
45
+ ợc
46
+ _tư
47
+ _đư
48
+ iệ
49
+ đươ
50
+ ìn
51
+ ́i_
52
+ _ha
53
+ có
54
+ i_đ
55
+ gia
56
+ _đê
57
+ pha
58
+ _mo
59
+ ọi
60
+ mọ
61
+ như
62
+ n_n
63
+ củ
64
+ _ba
65
+ ̣n_
66
+ ̉a_
67
+ ủa
68
+ n_c
69
+ ̀u_
70
+ ̃ng
71
+ ân_
72
+ ều
73
+ ất
74
+ _bi
75
+ tự
76
+ hôn
77
+ _vi
78
+ g_t
79
+ _la
80
+ n_đ
81
+ đề
82
+ nhâ
83
+ _ti
84
+ t_c
85
+ _đô
86
+ ên_
87
+ bả
88
+ hiê
89
+ u_c
90
+ _tô
91
+ do_
92
+ hân
93
+ _do
94
+ ch_
95
+ ́_q
96
+ ̀_t
97
+ _na
98
+ ́n_
99
+ ay_
100
+ _hi
101
+ àn
102
+ ̣_d
103
+ ới
104
+ há
105
+ _đi
106
+ hay
107
+ g_n
108
+ _mô
109
+ ốc
110
+ uố
111
+ n_v
112
+ ội
113
+ hữ
114
+ thư
115
+ ́p_
116
+ quô
117
+ _ho
118
+ ̣p_
119
+ nà
120
+ ào
121
+ ̀ng
122
+ ̉n_
123
+ ị_
124
+ ́ch
125
+ ôn_
126
+ ̀o_
127
+ khô
128
+ c_h
129
+ i_c
130
+ c_đ
131
+ _hô
132
+ i_v
133
+ tro
134
+ _đa
135
+ ́ng
136
+ mộ
137
+ i_t
138
+ ột
139
+ g_v
140
+ ia_
141
+ ̣ng
142
+ ản
143
+ ướ
144
+ ữn
145
+ ̉ng
146
+ h_t
147
+ hư_
148
+ ện
149
+ n_b
150
+ ộc
151
+ ả_
152
+ là
153
+ c_c
154
+ g_c
155
+ _đo
156
+ ̉_c
157
+ n_h
158
+ hà
159
+ hộ
160
+ _bâ
161
+ ã_
162
+ ̀y_
163
+ _vơ
164
+ ̣_t
165
+ ̉i_
166
+ iế
167
+ _cô
168
+ t_t
169
+ g_đ
170
+ ức
171
+ iên
172
+ _vê
173
+ viê
174
+ vớ
175
+ h_v
176
+ ớc
177
+ ực
178
+ ật
179
+ tha
180
+ ̉m_
181
+ ron
182
+ ong
183
+ áp
184
+ g_b
185
+ hươ
186
+ _sư
187
+ a_c
188
+ sự
189
+ ̉o_
190
+ ảo
191
+ h_c
192
+ ể_
193
+ o_v
194
+ uậ
195
+ a_m
196
+ ế_
197
+ iá
198
+ ̀_c
199
+ cho
200
+ qua
201
+ hạ
202
+ ục
203
+ _mi
204
+ ̀_n
205
+ phâ
206
+ c_q
207
+ côn
208
+ o_c
209
+ á_
210
+ i_h
211
+ ại
212
+ _hơ
213
+ ̃_h
214
+ _cư
215
+ n_l
216
+ bị
217
+ _lu
218
+ bấ
219
+ cả
220
+ ín
221
+ h_đ
222
+ _xa
223
+ độ
224
+ g_h
225
+ c_n
226
+ c_p
227
+ thu
228
+ ải
229
+ ệ_
230
+ _hư
231
+ ́_c
232
+ o_n
233
+ _nư
234
+ ốn
235
+ ́o_
236
+ áo
237
+ xã
238
+ oà
239
+ y_t
240
+ hả
241
+ tộ
242
+ ̣_c
243
+ _tâ
244
+ thô
245
+ _du
246
+ m_v
247
+ mì
248
+ ho_
249
+ hứ
250
+ ệc
251
+ ́_t
252
+ hợ
253
+ án
254
+ n_p
255
+ cũ
256
+ ũn
257
+ iể
258
+ ối
259
+ tiê
260
+ ề_
261
+ hấ
262
+ ợp
263
+ hoa
264
+ y_đ
265
+ chi
266
+ o_h
267
+ ở_
268
+ ày
269
+ ̉_t
270
+ đó
271
+ c_l
272
+ về
273
+ ̀_đ
274
+ i_b
275
+ kha
276
+ c_b
277
+ _đâ
278
+ luâ
279
+ ai_
280
+ ̉_n
281
+ đố
282
+ ết
283
+ hự
284
+ tri
285
+ p_q
286
+ nươ
287
+ dụ
288
+ hí
289
+ g_q
290
+ yên
291
+ họ
292
+ ́nh
293
+ _ta
294
+ _bă
295
+ c_g
296
+ n_g
297
+ thê
298
+ o_t
299
+ c_v
300
+ am_
301
+ c_m
302
+ an_
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+ require 'psychowl'
5
+
6
+ I18n.load_path << File.expand_path('locale/en.yml', __dir__)
7
+
8
+ module ActiveModel
9
+ module Validations
10
+ # Validates the language an attribute is written in.
11
+ #
12
+ # validates :body, language: { in: %i[en fr] }
13
+ # validates :title, language: { not_in: :ru, reliable: true }, allow_blank: true
14
+ # validates :bio, language: { in: :en, minimum_confidence: 0.5 }
15
+ #
16
+ # Options:
17
+ # * +in+ / +not_in+ - languages (codes or Psychowl::Lang) to accept / reject
18
+ # * +reliable+ - require a reliable detection
19
+ # * +minimum_confidence+ - require at least this confidence (0.0 to 1.0)
20
+ #
21
+ # Errors: +:language_undetected+, +:language_not_allowed+,
22
+ # +:language_uncertain+, each with a +%{language}+ interpolation.
23
+ class LanguageValidator < EachValidator
24
+ def check_validity!
25
+ raise ArgumentError, 'pass either :in or :not_in, not both' if options[:in] && options[:not_in]
26
+
27
+ @allowed = resolve(options[:in])
28
+ @denied = resolve(options[:not_in])
29
+ minimum = options[:minimum_confidence]
30
+ return if minimum.nil? || (minimum.is_a?(Numeric) && minimum.between?(0, 1))
31
+
32
+ raise ArgumentError, ':minimum_confidence must be between 0 and 1'
33
+ end
34
+
35
+ def validate_each(record, attribute, value)
36
+ info = Psychowl.detect(value.to_s)
37
+ return add_error(record, attribute, :language_undetected, nil) if info.nil?
38
+
39
+ lang = info.lang
40
+ return add_error(record, attribute, :language_not_allowed, lang) if @allowed && !@allowed.include?(lang)
41
+ return add_error(record, attribute, :language_not_allowed, lang) if @denied&.include?(lang)
42
+
43
+ add_error(record, attribute, :language_uncertain, lang) if uncertain?(info)
44
+ end
45
+
46
+ private
47
+
48
+ def uncertain?(info)
49
+ return true if options[:reliable] && !info.reliable?
50
+
51
+ minimum = options[:minimum_confidence]
52
+ !minimum.nil? && info.confidence < minimum
53
+ end
54
+
55
+ def resolve(list) = list && Array(list).map { Psychowl::Lang.fetch(it) }
56
+
57
+ def add_error(record, attribute, type, lang)
58
+ details = { language: lang&.eng_name }
59
+ details[:message] = options[:message] if options[:message]
60
+ record.errors.add(attribute, type, **details)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Psychowl
4
+ # Reads the plain-text data files. No load-time side effects, so the data
5
+ # tools can use it before the tables exist.
6
+ module DataFile
7
+ # Lines of a file, without blank lines and # comments.
8
+ def self.lines(path)
9
+ File.readlines(path, chomp: true, encoding: Encoding::UTF_8).reject do |line|
10
+ line.empty? || line.start_with?('#')
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Psychowl
4
+ # A reusable, frozen detector, optionally limited by an allowlist or denylist.
5
+ #
6
+ # detector = Psychowl::Detector.new(allowlist: %i[en fr de])
7
+ # detector.detect_lang("Traversez la rue, je vous trouverai un travail.")
8
+ # # => #<Psychowl::Lang fra (French)>
9
+ class Detector
10
+ # @return [Array<Lang>, nil]
11
+ attr_reader :allowlist
12
+
13
+ # @return [Array<Lang>, nil]
14
+ attr_reader :denylist
15
+
16
+ # @param allowlist [Array<Lang, String, Symbol>, nil] only consider these
17
+ # @param denylist [Array<Lang, String, Symbol>, nil] never consider these
18
+ # @raise [ArgumentError] both lists given, an empty allowlist, or an
19
+ # unsupported language code
20
+ def initialize(allowlist: nil, denylist: nil)
21
+ raise ArgumentError, 'pass either allowlist: or denylist:, not both' unless allowlist.nil? || denylist.nil?
22
+
23
+ @allowlist = allowlist && resolve(allowlist)
24
+ @denylist = denylist && resolve(denylist)
25
+ raise ArgumentError, 'allowlist must not be empty' if @allowlist && @allowlist.empty?
26
+
27
+ @filter_mode, @filter_langs = engine_filter
28
+ freeze
29
+ end
30
+
31
+ # @param text [String]
32
+ # @return [Info, nil] nil when no supported language was found
33
+ # @raise [TypeError] text is not a String
34
+ # @raise [EncodingError] text is not valid UTF-8 (or convertible to it)
35
+ def detect(text)
36
+ result = Engine.detect(Text.prepare(text), @filter_mode, @filter_langs)
37
+ result && Info.from_engine(result)
38
+ end
39
+
40
+ # @param (see #detect)
41
+ # @return [Lang, nil]
42
+ def detect_lang(text) = detect(text)&.lang
43
+
44
+ # Scripts ignore the language filter.
45
+ #
46
+ # @param (see #detect)
47
+ # @return [Script, nil]
48
+ def detect_script(text)
49
+ index = Engine.detect_script(Text.prepare(text))
50
+ index && Script.at(index)
51
+ end
52
+
53
+ # Every candidate language of the text's script with its score, best
54
+ # first. Scores are relative to each other, not probabilities.
55
+ #
56
+ # @param (see #detect)
57
+ # @param limit [Integer, nil] keep only the first +limit+ candidates
58
+ # @return [Array<Array(Lang, Float)>]
59
+ def candidates(text, limit: nil)
60
+ ranked = Engine.candidates(Text.prepare(text), @filter_mode, @filter_langs)
61
+ ranked = ranked.first(limit) if limit
62
+ ranked.map { |lang, score| [Lang.at(lang), score] }
63
+ end
64
+
65
+ # On all CPU cores with the native engine.
66
+ #
67
+ # @param texts [Array<String>]
68
+ # @return [Array<Info, nil>] one result per text, in order
69
+ def detect_many(texts)
70
+ prepared = texts.map { Text.prepare(it) }
71
+ Engine.detect_many(prepared, @filter_mode, @filter_langs).map { |result| result && Info.from_engine(result) }
72
+ end
73
+
74
+ # Runs of same-language sentences, for mixed-language text.
75
+ #
76
+ # @param (see #detect)
77
+ # @return [Array<Segment>]
78
+ def segments(text)
79
+ text = Text.prepare(text)
80
+ Engine.segments(text, @filter_mode, @filter_langs).map do |start, finish, *result|
81
+ Segment.new(text: text[start...finish], range: start...finish, info: Info.from_engine(result))
82
+ end
83
+ end
84
+
85
+ # Share of each writing system among the text's letters, largest first.
86
+ # Ignores the language filter.
87
+ #
88
+ # @param (see #detect)
89
+ # @return [Hash{Script => Float}] shares summing to 1.0; empty without letters
90
+ def scripts(text)
91
+ counts = Engine.script_counts(Text.prepare(text))
92
+ total = counts.sum
93
+ return {} if total.zero?
94
+
95
+ shares = counts.each_with_index.filter_map do |count, index|
96
+ [Script.at(index), count.fdiv(total)] if count.positive?
97
+ end
98
+ shares.sort_by { |_script, share| -share }.to_h
99
+ end
100
+
101
+ def inspect
102
+ filter =
103
+ if allowlist then " allowlist=#{allowlist.map(&:code)}"
104
+ elsif denylist then " denylist=#{denylist.map(&:code)}"
105
+ end
106
+ "#<#{self.class}#{filter}>"
107
+ end
108
+
109
+ private
110
+
111
+ # [mode, language indices] as the engines take them
112
+ def engine_filter
113
+ if allowlist then [Engine::FILTER_ALLOW, indices(allowlist)]
114
+ elsif denylist then [Engine::FILTER_DENY, indices(denylist)]
115
+ else [Engine::FILTER_ALL, [].freeze]
116
+ end
117
+ end
118
+
119
+ def indices(langs) = langs.map { Lang.index(it) }.freeze
120
+
121
+ def resolve(list) = Array(list).map { Lang.fetch(it) }.uniq.freeze
122
+ end
123
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Psychowl
4
+ # Delegates to RubyEngine until native_speedup.rb prepends the Rust
5
+ # extension. Same primitives either way.
6
+ module Engine
7
+ FILTER_ALL = 0
8
+ FILTER_ALLOW = 1
9
+ FILTER_DENY = 2
10
+
11
+ class << self
12
+ def native? = false
13
+
14
+ def detect(text, filter_mode, filter_langs) = RubyEngine.detect(text, filter_mode, filter_langs)
15
+ def candidates(text, filter_mode, filter_langs) = RubyEngine.candidates(text, filter_mode, filter_langs)
16
+ def detect_many(texts, filter_mode, filter_langs) = RubyEngine.detect_many(texts, filter_mode, filter_langs)
17
+ def segments(text, filter_mode, filter_langs) = RubyEngine.segments(text, filter_mode, filter_langs)
18
+ def sentences(text) = RubyEngine.sentences(text)
19
+ def detect_script(text) = RubyEngine.detect_script(text)
20
+ def script_counts(text) = RubyEngine.script_counts(text)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Psychowl
4
+ # Outcome of a detection. Supports pattern matching:
5
+ #
6
+ # case Psychowl.detect(speech)
7
+ # in {lang: {code: "eng"}, reliable: true} then :tremendous
8
+ # in {reliable: false} then :fake_news
9
+ # end
10
+ #
11
+ # @!attribute [r] lang
12
+ # @return [Lang]
13
+ # @!attribute [r] script
14
+ # @return [Script]
15
+ # @!attribute [r] confidence
16
+ # @return [Float] 0.0 to 1.0
17
+ # @!attribute [r] reliable
18
+ # @return [Boolean] confidence above {RELIABLE_CONFIDENCE}
19
+ Info = ::Data.define(:lang, :script, :confidence, :reliable) do
20
+ # @return [Boolean]
21
+ def reliable? = reliable
22
+
23
+ def inspect
24
+ "#<#{self.class} lang=#{lang.code} script=#{script.name} " \
25
+ "confidence=#{confidence.round(4)} reliable=#{reliable}>"
26
+ end
27
+ end
28
+
29
+ # Reliability threshold and construction from engine results.
30
+ class Info
31
+ RELIABLE_CONFIDENCE = 0.9
32
+
33
+ # @api private
34
+ def self.from_engine((lang, script, confidence))
35
+ new(lang: Lang.at(lang), script: Script.at(script), confidence:,
36
+ reliable: confidence > RELIABLE_CONFIDENCE)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Psychowl
4
+ # A supported language. Instances are canonical: every API hands out the
5
+ # same frozen object for a given language.
6
+ #
7
+ # Psychowl::Lang[:de] # ISO 639-1
8
+ # Psychowl::Lang["deu"] # ISO 639-3
9
+ # Psychowl::Lang.fetch(:x) # raises ArgumentError
10
+ #
11
+ # @!attribute [r] code
12
+ # @return [String] ISO 639-3 code, e.g. "deu"
13
+ # @!attribute [r] iso639_1
14
+ # @return [String] ISO 639-1 code, e.g. "de"
15
+ # @!attribute [r] name
16
+ # @return [String] name in the language itself, e.g. "Deutsch"
17
+ # @!attribute [r] eng_name
18
+ # @return [String] English name, e.g. "German"
19
+ Lang = ::Data.define(:code, :iso639_1, :name, :eng_name) do
20
+ # @return [Symbol] the ISO 639-1 code as an I18n-style locale, e.g. :de
21
+ def locale = iso639_1.to_sym
22
+
23
+ # @return [String] the ISO 639-3 code
24
+ def to_s = code
25
+
26
+ def inspect = "#<#{self.class} #{code} (#{eng_name})>"
27
+ end
28
+
29
+ # The canonical instances and lookup by code.
30
+ class Lang
31
+ singleton_class.prepend(Lookup) # ahead of Data.define's own `[]` constructor
32
+
33
+ ALL = Tables::LANGUAGES.map do |code, iso639_1, eng_name, name|
34
+ new(code:, iso639_1:, name:, eng_name:)
35
+ end.freeze
36
+ BY_CODE = ALL.flat_map { |lang| [[lang.code, lang], [lang.iso639_1, lang]] }.to_h.freeze
37
+ INDEX = ALL.each_with_index.to_h.compare_by_identity.freeze
38
+ private_constant :ALL, :BY_CODE, :INDEX
39
+
40
+ class << self
41
+ # @return [Array<Lang>] every supported language (frozen)
42
+ def all = ALL
43
+
44
+ # @api private
45
+ def index(lang) = INDEX.fetch(lang)
46
+
47
+ private :new
48
+
49
+ private
50
+
51
+ def by_key = BY_CODE
52
+ def unknown_key_message = 'unsupported language'
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,6 @@
1
+ en:
2
+ errors:
3
+ messages:
4
+ language_undetected: "is not written in a recognizable language"
5
+ language_not_allowed: "is written in %{language}, which is not allowed"
6
+ language_uncertain: "could not be identified reliably (looks like %{language})"
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Psychowl
4
+ # Case-insensitive lookup for Lang and Script. Prepended, because Data
5
+ # defines its own `[]`; expects `all`, `by_key` and `unknown_key_message`.
6
+ module Lookup
7
+ # @param key [self, String, Symbol]
8
+ # @return [self, nil]
9
+ def [](key)
10
+ return key if key.is_a?(self)
11
+
12
+ by_key[key.to_s.downcase]
13
+ end
14
+
15
+ # @param (see #[])
16
+ # @return [self]
17
+ # @raise [ArgumentError] unknown key
18
+ def fetch(key)
19
+ self[key] or raise ArgumentError, "#{unknown_key_message}: #{key.inspect}"
20
+ end
21
+
22
+ # @api private
23
+ def at(index) = all.fetch(index)
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A non-empty DISABLE_PSYCHOWL_NATIVE or DISABLE_MATRYOSHKA_NATIVE forces pure
4
+ # Ruby; an empty one (as CI matrices set) does not.
5
+ return if %w[DISABLE_PSYCHOWL_NATIVE DISABLE_MATRYOSHKA_NATIVE].any? { !ENV.fetch(it, '').empty? }
6
+
7
+ # Platform gems ship the binary under the Ruby ABI version; a dev build (rake
8
+ # compile) leaves it next to this file. The pure Ruby gem has neither.
9
+ shipped = File.join(__dir__, RUBY_VERSION[/\A\d+\.\d+/], "psychowl_native.#{RbConfig::CONFIG['DLEXT']}")
10
+ begin
11
+ if File.exist?(shipped)
12
+ require shipped
13
+ else
14
+ require 'psychowl/psychowl_native'
15
+ end
16
+ rescue LoadError => e
17
+ # A shipped binary that will not load (glibc older than 2.34, Alpine without
18
+ # libgcc) is a real fault, not the pure Ruby gem: say so.
19
+ warn "psychowl: native extension failed to load (#{e.message}); using the pure Ruby engine" if File.exist?(shipped)
20
+ return
21
+ end
22
+
23
+ # An extension built from other data files would map indices to the wrong
24
+ # languages: keep the Ruby engine instead.
25
+ expected = [Psychowl::Tables::LANGUAGES.map(&:first), Psychowl::Tables::SCRIPTS.map(&:name)]
26
+ unless PsychowlNative.tables == expected
27
+ warn "psychowl: native extension was built from different language data (rebuild it); using the pure Ruby engine"
28
+ return
29
+ end
30
+
31
+ # Swaps the Rust extension in behind Engine.
32
+ module Psychowl
33
+ # Routes every Engine method to the Rust extension (PsychowlNative).
34
+ module NativeSpeedup
35
+ def native? = true
36
+ def detect(text, filter_mode, filter_langs) = PsychowlNative.detect(text, filter_mode, filter_langs)
37
+ def candidates(text, filter_mode, filter_langs) = PsychowlNative.candidates(text, filter_mode, filter_langs)
38
+ def detect_script(text) = PsychowlNative.detect_script(text)
39
+ def script_counts(text) = PsychowlNative.script_counts(text)
40
+ def detect_many(texts, filter_mode, filter_langs) = PsychowlNative.detect_many(texts, filter_mode, filter_langs)
41
+ def segments(text, filter_mode, filter_langs) = PsychowlNative.segments(text, filter_mode, filter_langs)
42
+ def sentences(text) = PsychowlNative.sentences(text)
43
+ end
44
+
45
+ Engine.singleton_class.prepend(NativeSpeedup)
46
+ end