active_sanction 1.0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Phonetics
|
|
8
|
+
# Double Metaphone, as Lawrence Philips published it. Stage 3c of the
|
|
9
|
+
# matching pipeline.
|
|
10
|
+
#
|
|
11
|
+
# ActiveSanction::Phonetics::DoubleMetaphone.call("mohammed") # => ["MHMT"]
|
|
12
|
+
# ActiveSanction::Phonetics::DoubleMetaphone.call("muhammad") # => ["MHMT"]
|
|
13
|
+
# ActiveSanction::Phonetics::DoubleMetaphone.call("jusuf") # => ["JSF", "ASF"]
|
|
14
|
+
# ActiveSanction::Phonetics::DoubleMetaphone.call("yusuf") # => ["ASF"]
|
|
15
|
+
#
|
|
16
|
+
# DoubleMetaphone.match?("qaddafi", "gaddafi") # => true
|
|
17
|
+
#
|
|
18
|
+
# ### One word, not one name
|
|
19
|
+
#
|
|
20
|
+
# Every method here takes a single folded token. A name is keyed token by
|
|
21
|
+
# token, which is what the index (#31) needs -- a bucket per token is what
|
|
22
|
+
# makes a query's tokens findable -- and it is the reason the algorithm's
|
|
23
|
+
# multi-word rules are not exercised here. Philips' table has rules keyed
|
|
24
|
+
# on `VAN `, `VON `, `SAN ` and `MAC C`, and they need the following space
|
|
25
|
+
# to fire; a token handed over on its own never has one. The trade is
|
|
26
|
+
# deliberate: those rules cover a handful of surname prefixes, and keying
|
|
27
|
+
# per token is what the whole stage is for.
|
|
28
|
+
#
|
|
29
|
+
# ### What the two keys are
|
|
30
|
+
#
|
|
31
|
+
# Philips returns a primary and an alternate, and the alternate is the
|
|
32
|
+
# point. It is not a fallback or a fuzzier version of the same key: it is
|
|
33
|
+
# the second way the word is pronounced when a spelling is genuinely
|
|
34
|
+
# ambiguous about its language of origin. `JUSUF` is `JSF` read as
|
|
35
|
+
# English and `ASF` read as Slavic, and `YUSUF` is `ASF` outright. Two
|
|
36
|
+
# names match phonetically when *any* of their keys agree, which is what
|
|
37
|
+
# `match?` does and what an index has to do -- comparing primaries alone
|
|
38
|
+
# would miss exactly the transliteration pairs this stage exists for.
|
|
39
|
+
#
|
|
40
|
+
# `call` returns one key or two: the alternate is dropped when it repeats
|
|
41
|
+
# the primary, so a caller indexing every key never writes the same bucket
|
|
42
|
+
# twice.
|
|
43
|
+
#
|
|
44
|
+
# ### Keys are not truncated
|
|
45
|
+
#
|
|
46
|
+
# Philips' 1990 Metaphone truncated to four characters and the article
|
|
47
|
+
# version of Double Metaphone kept that; the reference C++ raised the cap
|
|
48
|
+
# to 32, which no name reaches. Nothing is truncated here. A caller who
|
|
49
|
+
# wants the classic four-character key takes `key[0, 4]` -- the keys are
|
|
50
|
+
# built left to right, so a prefix of the full key is exactly what a
|
|
51
|
+
# truncating implementation would have produced.
|
|
52
|
+
#
|
|
53
|
+
# The choice matters to the index and not much else: four characters put
|
|
54
|
+
# `GAZPROM` and `GAZPROMBANK` in one bucket, which is good recall and
|
|
55
|
+
# poor precision, and #31 can decide that for itself. Throwing the
|
|
56
|
+
# characters away here would not leave it the choice.
|
|
57
|
+
#
|
|
58
|
+
# ### Latin script only, and it is a mitigation rather than a fix
|
|
59
|
+
#
|
|
60
|
+
# This is a table of English, Germanic, Slavic, Romance and Greek
|
|
61
|
+
# spelling conventions written in the Latin alphabet. Handed anything
|
|
62
|
+
# else it produces no key at all: every character that is not an ASCII
|
|
63
|
+
# letter is skipped, so `путин` and `محمد` come back as `[]` and are not
|
|
64
|
+
# phonetically indexable. That is the same recall limitation Normalizer
|
|
65
|
+
# states about non-Latin script, in the same place in the pipeline, and
|
|
66
|
+
# for the same reason -- these lists publish a romanized name alongside
|
|
67
|
+
# the original, and the romanized one is what an English-language query
|
|
68
|
+
# finds.
|
|
69
|
+
#
|
|
70
|
+
# Within Latin script it is a mitigation for transliteration variance and
|
|
71
|
+
# not a substitute for real transliteration. It collapses `MOHAMMED`,
|
|
72
|
+
# `MUHAMMAD`, `MOHAMAD` and `MOHAMED` onto `MHMT`, and `QADDAFI`,
|
|
73
|
+
# `GADDAFI`, `KADAFI` and `QADHAFI` onto `KTF`, which is the variance
|
|
74
|
+
# these lists actually contain. It also collapses names that are not the
|
|
75
|
+
# same name at all: `HUSSEIN`, `HASSAN` and `HASAN` are one key, and so
|
|
76
|
+
# are `PUTIN`, `PATTON` and `BUTTON`. So a phonetic agreement is a signal
|
|
77
|
+
# the scorer (#32) weighs, never a match on its own. It is deliberately
|
|
78
|
+
# generous, in a pipeline whose other columns are not.
|
|
79
|
+
#
|
|
80
|
+
# And it misses things the other columns catch. `ZAWAHIRI` keys as `SHR`
|
|
81
|
+
# and `ZAWAHRI` as `SR`, because dropping the `I` moves the `H` to where
|
|
82
|
+
# the rules make it silent -- a pair Levenshtein scores 0.85. Neither
|
|
83
|
+
# direction is a defect to be tuned out. Four columns exist because each
|
|
84
|
+
# of them is wrong somewhere the others are right.
|
|
85
|
+
#
|
|
86
|
+
# ### What it costs, and where
|
|
87
|
+
#
|
|
88
|
+
# About 30 us a word without a JIT and 17 with one, which is a whole index
|
|
89
|
+
# build's worth of keys -- 46,000 names, call it 138,000 tokens -- in
|
|
90
|
+
# around four seconds, and a three-token query's worth in under a tenth of
|
|
91
|
+
# a millisecond. The cost lands on the build rather than on the query,
|
|
92
|
+
# which is the right end for it: a key is written once per sync and read
|
|
93
|
+
# on every screening call after it.
|
|
94
|
+
#
|
|
95
|
+
# Most of that is the string slicing the rules are written in, and it
|
|
96
|
+
# stays. Comparing without allocating would mean rewriting a hundred
|
|
97
|
+
# published rules into index arithmetic nobody can check against the
|
|
98
|
+
# source, to save three seconds of a job that runs when a government
|
|
99
|
+
# publishes a file.
|
|
100
|
+
#
|
|
101
|
+
# ### Transcribed, not reimplemented
|
|
102
|
+
#
|
|
103
|
+
# The rules below are a transcription of Philips' reference C++ -- the
|
|
104
|
+
# 2000 C/C++ Users Journal algorithm, in the widely mirrored
|
|
105
|
+
# `double_metaphone.cc` -- kept in its order, with its section comments,
|
|
106
|
+
# so that the two can be read side by side. That is the only way anybody
|
|
107
|
+
# can check this file: the rules are a hundred-odd special cases about
|
|
108
|
+
# spelling that cannot be derived from anything, only compared against
|
|
109
|
+
# the source they came from. The specs compare the output against that
|
|
110
|
+
# implementation's own, over a corpus of several thousand words.
|
|
111
|
+
#
|
|
112
|
+
# Three things in that transcription are deliberate departures, all of
|
|
113
|
+
# them faithful to the C rather than to the ports that are easier to find:
|
|
114
|
+
#
|
|
115
|
+
# 1. A character outside `A-Z` advances one position and contributes
|
|
116
|
+
# nothing, which is the C's `default:`. Several widely used ports
|
|
117
|
+
# instead re-apply the previous character's rule, so `putin2` keys as
|
|
118
|
+
# `PTNN`. Folded names carry digits.
|
|
119
|
+
# 2. The word is padded with spaces, as the C pads it. Rules that look
|
|
120
|
+
# for a trailing space -- the French `IER ` ending among them -- fire
|
|
121
|
+
# at the end of a word, which is where they were meant to fire.
|
|
122
|
+
# 3. `-UMB` is read from the character before the `M`, so `dumb` keys as
|
|
123
|
+
# `TM` rather than `TMP`.
|
|
124
|
+
class DoubleMetaphone
|
|
125
|
+
extend T::Sig
|
|
126
|
+
|
|
127
|
+
# What one letter's rule decides: what to append to each key, and how
|
|
128
|
+
# far to move. An empty string appends nothing, which is how the C
|
|
129
|
+
# writes a code that exists on one side only (`primary += ""`).
|
|
130
|
+
Step = T.type_alias { [String, String, Integer] }
|
|
131
|
+
|
|
132
|
+
# `Y` is a vowel here. It is in Philips' `IsVowel`, and dropping it
|
|
133
|
+
# would change the initial-vowel rule that `YUSUF` depends on.
|
|
134
|
+
VOWELS = T.let(%w[A E I O U Y].freeze, T::Array[String])
|
|
135
|
+
|
|
136
|
+
# The first letter of these pairs is not pronounced: `KNIGHT`, `WRIGHT`,
|
|
137
|
+
# `PSALM`.
|
|
138
|
+
SILENT_STARTERS = T.let(%w[GN KN PN WR PS].freeze, T::Array[String])
|
|
139
|
+
|
|
140
|
+
# Five, as the reference pads, and semantic rather than defensive --
|
|
141
|
+
# see the class notes. Every lookahead in the table stays inside it.
|
|
142
|
+
PADDING = T.let(" ", String)
|
|
143
|
+
|
|
144
|
+
# A word's phonetic keys: one, or two when the spelling is ambiguous,
|
|
145
|
+
# and none at all when it holds no Latin letters.
|
|
146
|
+
sig { params(word: String).returns(T::Array[String]).checked(:tests) }
|
|
147
|
+
def self.call(word) = new(word).keys
|
|
148
|
+
|
|
149
|
+
# Whether two words agree on any key. The cross comparisons are the
|
|
150
|
+
# reason this is a method rather than an `==`: `JUSUF` and `YUSUF` agree
|
|
151
|
+
# on `JUSUF`'s alternate, and a caller comparing primaries would miss it.
|
|
152
|
+
#
|
|
153
|
+
# A word with no keys agrees with nothing, itself included. Two names
|
|
154
|
+
# written in a script this table cannot read are not evidence of
|
|
155
|
+
# anything, and an empty key that matched every other empty key would
|
|
156
|
+
# make every Cyrillic name a phonetic hit against every other one.
|
|
157
|
+
sig { params(left: String, right: String).returns(T::Boolean).checked(:tests) }
|
|
158
|
+
def self.match?(left, right) = call(left).intersect?(call(right))
|
|
159
|
+
|
|
160
|
+
# The primary key, or an empty string for a word with no Latin letters.
|
|
161
|
+
sig { returns(String).checked(:tests) }
|
|
162
|
+
attr_reader :primary
|
|
163
|
+
|
|
164
|
+
# The second pronunciation, or nil when the word has only one. Nil
|
|
165
|
+
# rather than a repeat of the primary, so that "this word is ambiguous"
|
|
166
|
+
# and "this word is not" are different answers.
|
|
167
|
+
sig { returns(T.nilable(String)).checked(:tests) }
|
|
168
|
+
attr_reader :alternate
|
|
169
|
+
|
|
170
|
+
# Every distinct non-empty key, primary first. This is what an index
|
|
171
|
+
# writes and what `match?` intersects.
|
|
172
|
+
sig { returns(T::Array[String]).checked(:tests) }
|
|
173
|
+
attr_reader :keys
|
|
174
|
+
|
|
175
|
+
# Encoding happens here, once, and the result is frozen: a key is a
|
|
176
|
+
# pure function of the word, and the same word is keyed thousands of
|
|
177
|
+
# times during an index build.
|
|
178
|
+
#
|
|
179
|
+
# Uppercasing is not a fold. The rule table is written in capitals and
|
|
180
|
+
# this is the alphabet it reads; the fold that decides what a name *is*
|
|
181
|
+
# happened once already, in Normalizer, for both sides of every
|
|
182
|
+
# comparison.
|
|
183
|
+
sig { params(word: String).void.checked(:tests) }
|
|
184
|
+
def initialize(word)
|
|
185
|
+
upper = word.to_s.upcase
|
|
186
|
+
@buffer = T.let("#{upper}#{PADDING}", String)
|
|
187
|
+
@last = T.let(upper.length - 1, Integer)
|
|
188
|
+
@slavo_germanic = T.let(slavo_germanic?(upper), T::Boolean)
|
|
189
|
+
@position = T.let(0, Integer)
|
|
190
|
+
@primary = T.let(+"", String)
|
|
191
|
+
@secondary = T.let(+"", String)
|
|
192
|
+
encode
|
|
193
|
+
@primary = -@primary
|
|
194
|
+
@secondary = -@secondary
|
|
195
|
+
@alternate = T.let(@secondary == @primary ? nil : presence(@secondary), T.nilable(String))
|
|
196
|
+
@keys = T.let([@primary, @secondary].reject(&:empty?).uniq.freeze, T::Array[String])
|
|
197
|
+
freeze
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
sig { returns(String) }
|
|
201
|
+
def inspect = "#<#{self.class} #{@keys.inspect}>"
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
# The main loop. Philips' runs until both keys reach a length cap; this
|
|
206
|
+
# one runs to the end of the word, for the reason the class notes give.
|
|
207
|
+
sig { void }
|
|
208
|
+
def encode
|
|
209
|
+
skip_silent_start
|
|
210
|
+
while @position <= @last
|
|
211
|
+
primary, secondary, advance = rule
|
|
212
|
+
@primary << primary
|
|
213
|
+
@secondary << secondary
|
|
214
|
+
@position += advance
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The two things that happen before the first letter is read.
|
|
219
|
+
sig { void }
|
|
220
|
+
def skip_silent_start
|
|
221
|
+
@position += 1 if SILENT_STARTERS.include?(span(0, 2))
|
|
222
|
+
return unless at(0) == "X" # initial 'X' is pronounced 'Z', e.g. 'Xavier'
|
|
223
|
+
|
|
224
|
+
@primary << "S"
|
|
225
|
+
@secondary << "S"
|
|
226
|
+
@position += 1
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# `W`, `K`, `CZ` or `WITZ` anywhere in the word. Philips uses this to
|
|
230
|
+
# decide whether a spelling is Slavic or Germanic, which changes what
|
|
231
|
+
# `G`, `J`, `S` and `Z` are worth.
|
|
232
|
+
sig { params(word: String).returns(T::Boolean) }
|
|
233
|
+
def slavo_germanic?(word)
|
|
234
|
+
word.include?("W") || word.include?("K") || word.include?("CZ") || word.include?("WITZ")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# One character of the buffer, or "" past either end. Philips' `GetAt`
|
|
238
|
+
# returns a NUL there and his `StringAt` refuses a start past the end;
|
|
239
|
+
# both compare false against every rule, which is what "" does here.
|
|
240
|
+
sig { params(index: Integer).returns(String) }
|
|
241
|
+
def at(index) = index.negative? ? "" : @buffer[index].to_s
|
|
242
|
+
|
|
243
|
+
sig { params(index: Integer, length: Integer).returns(String) }
|
|
244
|
+
def span(index, length) = index.negative? ? "" : @buffer[index, length].to_s
|
|
245
|
+
|
|
246
|
+
sig { params(index: Integer).returns(T::Boolean) }
|
|
247
|
+
def vowel?(index) = VOWELS.include?(at(index))
|
|
248
|
+
|
|
249
|
+
sig { params(string: String).returns(T.nilable(String)) }
|
|
250
|
+
def presence(string) = string.empty? ? nil : string
|
|
251
|
+
|
|
252
|
+
# The same code on both keys.
|
|
253
|
+
sig { params(code: String, advance: Integer).returns(Step) }
|
|
254
|
+
def both(code, advance) = [code, code, advance]
|
|
255
|
+
|
|
256
|
+
# A different code on each -- the ambiguity the second key exists for.
|
|
257
|
+
sig { params(primary: String, secondary: String, advance: Integer).returns(Step) }
|
|
258
|
+
def either(primary, secondary, advance) = [primary, secondary, advance]
|
|
259
|
+
|
|
260
|
+
# Move on and say nothing: a silent letter, a space, a digit, or a
|
|
261
|
+
# letter this table cannot read.
|
|
262
|
+
sig { params(advance: Integer).returns(Step) }
|
|
263
|
+
def silent(advance) = ["", "", advance]
|
|
264
|
+
|
|
265
|
+
sig { returns(Step) }
|
|
266
|
+
def rule
|
|
267
|
+
case at(@position)
|
|
268
|
+
when "A", "E", "I", "O", "U", "Y" then @position.zero? ? both("A", 1) : silent(1)
|
|
269
|
+
when "B" then both("P", at(@position + 1) == "B" ? 2 : 1)
|
|
270
|
+
when "C" then letter_c
|
|
271
|
+
when "D" then letter_d
|
|
272
|
+
when "F" then both("F", at(@position + 1) == "F" ? 2 : 1)
|
|
273
|
+
when "G" then letter_g
|
|
274
|
+
when "H" then letter_h
|
|
275
|
+
when "J" then letter_j
|
|
276
|
+
when "K" then both("K", at(@position + 1) == "K" ? 2 : 1)
|
|
277
|
+
when "L" then letter_l
|
|
278
|
+
when "M" then letter_m
|
|
279
|
+
when "N" then both("N", at(@position + 1) == "N" ? 2 : 1)
|
|
280
|
+
when "P" then letter_p
|
|
281
|
+
when "Q" then both("K", at(@position + 1) == "Q" ? 2 : 1)
|
|
282
|
+
when "R" then letter_r
|
|
283
|
+
when "S" then letter_s
|
|
284
|
+
when "T" then letter_t
|
|
285
|
+
when "V" then both("F", at(@position + 1) == "V" ? 2 : 1)
|
|
286
|
+
when "W" then letter_w
|
|
287
|
+
when "X" then letter_x
|
|
288
|
+
when "Z" then letter_z
|
|
289
|
+
else silent(1)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# 'C' -- around a hundred contexts, which is what the algorithm is
|
|
294
|
+
# famous for.
|
|
295
|
+
sig { returns(Step) }
|
|
296
|
+
def letter_c
|
|
297
|
+
return both("K", 2) if germanic_ach?
|
|
298
|
+
return both("S", 2) if @position.zero? && span(@position, 6) == "CAESAR" # 'caesar'
|
|
299
|
+
return both("K", 2) if span(@position, 4) == "CHIA" # italian 'chianti'
|
|
300
|
+
return letter_ch if span(@position, 2) == "CH"
|
|
301
|
+
return either("S", "X", 2) if span(@position, 2) == "CZ" && span(@position - 2, 4) != "WICZ" # 'czerny'
|
|
302
|
+
return both("X", 3) if span(@position + 1, 3) == "CIA" # 'focaccia'
|
|
303
|
+
return letter_cc if span(@position, 2) == "CC" && !(@position == 1 && at(0) == "M") # not 'McClellan'
|
|
304
|
+
return both("K", 2) if %w[CK CG CQ].include?(span(@position, 2))
|
|
305
|
+
return letter_ci if %w[CI CE CY].include?(span(@position, 2))
|
|
306
|
+
|
|
307
|
+
both("K", trailing_c_advance)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Various germanic, e.g. 'bacher', 'macher'.
|
|
311
|
+
sig { returns(T::Boolean) }
|
|
312
|
+
def germanic_ach?
|
|
313
|
+
@position > 1 && !vowel?(@position - 2) && span(@position - 1, 3) == "ACH" &&
|
|
314
|
+
at(@position + 2) != "I" &&
|
|
315
|
+
(at(@position + 2) != "E" || %w[BACHER MACHER].include?(span(@position - 2, 6)))
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
sig { returns(Step) }
|
|
319
|
+
def letter_ch
|
|
320
|
+
return either("K", "X", 2) if @position.positive? && span(@position, 4) == "CHAE" # find 'michael'
|
|
321
|
+
return both("K", 2) if greek_ch? || germanic_ch?
|
|
322
|
+
return both("X", 2) if @position.zero?
|
|
323
|
+
return both("K", 2) if span(0, 2) == "MC" # e.g. 'McHugh'
|
|
324
|
+
|
|
325
|
+
either("X", "K", 2)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Greek roots, e.g. 'chemistry', 'chorus'.
|
|
329
|
+
sig { returns(T::Boolean) }
|
|
330
|
+
def greek_ch?
|
|
331
|
+
@position.zero? &&
|
|
332
|
+
(%w[HARAC HARIS].include?(span(@position + 1, 5)) ||
|
|
333
|
+
%w[HOR HYM HIA HEM].include?(span(@position + 1, 3))) &&
|
|
334
|
+
span(0, 5) != "CHORE"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Germanic, greek, or otherwise 'ch' for 'kh' sound.
|
|
338
|
+
sig { returns(T::Boolean) }
|
|
339
|
+
def germanic_ch?
|
|
340
|
+
["VAN ", "VON "].include?(span(0, 4)) || span(0, 3) == "SCH" ||
|
|
341
|
+
# 'architect' but not 'arch', 'orchestra', 'orchid'
|
|
342
|
+
%w[ORCHES ARCHIT ORCHID].include?(span(@position - 2, 6)) ||
|
|
343
|
+
%w[T S].include?(at(@position + 2)) ||
|
|
344
|
+
((%w[A O U E].include?(at(@position - 1)) || @position.zero?) &&
|
|
345
|
+
# e.g. 'wachtler', 'wechsler', but not 'tichner'
|
|
346
|
+
["L", "R", "N", "M", "B", "H", "F", "V", "W", " "].include?(at(@position + 2)))
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Double 'C'.
|
|
350
|
+
sig { returns(Step) }
|
|
351
|
+
def letter_cc
|
|
352
|
+
# 'bellocchio' but not 'bacchus'
|
|
353
|
+
return both("K", 2) unless %w[I E H].include?(at(@position + 2)) && span(@position + 2, 2) != "HU"
|
|
354
|
+
# 'accident', 'accede', 'succeed'
|
|
355
|
+
return both("KS", 3) if (@position == 1 && at(0) == "A") || %w[UCCEE UCCES].include?(span(@position - 1, 5))
|
|
356
|
+
|
|
357
|
+
both("X", 3) # 'bacci', 'bertucci', other italian
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
sig { returns(Step) }
|
|
361
|
+
def letter_ci
|
|
362
|
+
# italian vs. english
|
|
363
|
+
return either("S", "X", 2) if %w[CIO CIE CIA].include?(span(@position, 3))
|
|
364
|
+
|
|
365
|
+
both("S", 2)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
sig { returns(Integer) }
|
|
369
|
+
def trailing_c_advance
|
|
370
|
+
return 3 if [" C", " Q", " G"].include?(span(@position + 1, 2)) # 'mac caffrey', 'mac gregor'
|
|
371
|
+
return 2 if %w[C K Q].include?(at(@position + 1)) && !%w[CE CI].include?(span(@position + 1, 2))
|
|
372
|
+
|
|
373
|
+
1
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
sig { returns(Step) }
|
|
377
|
+
def letter_d
|
|
378
|
+
if span(@position, 2) == "DG"
|
|
379
|
+
return %w[I E Y].include?(at(@position + 2)) ? both("J", 3) : both("TK", 2) # 'edge' / 'edgar'
|
|
380
|
+
end
|
|
381
|
+
return both("T", 2) if %w[DT DD].include?(span(@position, 2))
|
|
382
|
+
|
|
383
|
+
both("T", 1)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
sig { returns(Step) }
|
|
387
|
+
def letter_g
|
|
388
|
+
return letter_gh if at(@position + 1) == "H"
|
|
389
|
+
return letter_gn if at(@position + 1) == "N"
|
|
390
|
+
return either("KL", "L", 2) if span(@position + 1, 2) == "LI" && !@slavo_germanic # 'tagliaro'
|
|
391
|
+
return either("K", "J", 2) if initial_soft_g? || soft_ger?
|
|
392
|
+
return italian_g if %w[E I Y].include?(at(@position + 1)) || %w[AGGI OGGI].include?(span(@position - 1, 4))
|
|
393
|
+
|
|
394
|
+
both("K", at(@position + 1) == "G" ? 2 : 1)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
sig { returns(Step) }
|
|
398
|
+
def letter_gh
|
|
399
|
+
return both("K", 2) if @position.positive? && !vowel?(@position - 1)
|
|
400
|
+
# 'ghislane', 'ghiradelli'
|
|
401
|
+
return both(at(@position + 2) == "I" ? "J" : "K", 2) if @position.zero?
|
|
402
|
+
# Parker's rule (with some further refinements), e.g. 'hugh'
|
|
403
|
+
return silent(2) if parkers_rule?
|
|
404
|
+
# e.g. 'laugh', 'McLaughlin', 'cough', 'gough', 'rough', 'tough'
|
|
405
|
+
return both("F", 2) if @position > 2 && at(@position - 1) == "U" && %w[C G L R T].include?(at(@position - 3))
|
|
406
|
+
return both("K", 2) if @position.positive? && at(@position - 1) != "I"
|
|
407
|
+
|
|
408
|
+
silent(2)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
sig { returns(T::Boolean) }
|
|
412
|
+
def parkers_rule?
|
|
413
|
+
(@position > 1 && %w[B H D].include?(at(@position - 2))) ||
|
|
414
|
+
(@position > 2 && %w[B H D].include?(at(@position - 3))) || # e.g. 'bough'
|
|
415
|
+
(@position > 3 && %w[B H].include?(at(@position - 4))) # e.g. 'broughton'
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
sig { returns(Step) }
|
|
419
|
+
def letter_gn
|
|
420
|
+
return either("KN", "N", 2) if @position == 1 && vowel?(0) && !@slavo_germanic
|
|
421
|
+
# not e.g. 'cagney'
|
|
422
|
+
return either("N", "KN", 2) if span(@position + 2, 2) != "EY" && at(@position + 1) != "Y" && !@slavo_germanic
|
|
423
|
+
|
|
424
|
+
both("KN", 2)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# -ges-, -gep-, -gel-, -gie- at beginning.
|
|
428
|
+
sig { returns(T::Boolean) }
|
|
429
|
+
def initial_soft_g?
|
|
430
|
+
@position.zero? &&
|
|
431
|
+
(at(@position + 1) == "Y" ||
|
|
432
|
+
%w[ES EP EB EL EY IB IL IN IE EI ER].include?(span(@position + 1, 2)))
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# -ger-, -gy-.
|
|
436
|
+
sig { returns(T::Boolean) }
|
|
437
|
+
def soft_ger?
|
|
438
|
+
(span(@position + 1, 2) == "ER" || at(@position + 1) == "Y") &&
|
|
439
|
+
!%w[DANGER RANGER MANGER].include?(span(0, 6)) &&
|
|
440
|
+
!%w[E I].include?(at(@position - 1)) &&
|
|
441
|
+
!%w[RGY OGY].include?(span(@position - 1, 3))
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Italian, e.g. 'biaggi'.
|
|
445
|
+
sig { returns(Step) }
|
|
446
|
+
def italian_g
|
|
447
|
+
# obvious germanic
|
|
448
|
+
return both("K", 2) if ["VAN ", "VON "].include?(span(0, 4)) || span(0, 3) == "SCH" ||
|
|
449
|
+
span(@position + 1, 2) == "ET"
|
|
450
|
+
# always soft if french ending
|
|
451
|
+
return both("J", 2) if span(@position + 1, 4) == "IER "
|
|
452
|
+
|
|
453
|
+
either("J", "K", 2)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Only keep 'H' first, or between two vowels. Also takes care of 'HH'.
|
|
457
|
+
sig { returns(Step) }
|
|
458
|
+
def letter_h
|
|
459
|
+
return both("H", 2) if (@position.zero? || vowel?(@position - 1)) && vowel?(@position + 1)
|
|
460
|
+
|
|
461
|
+
silent(1)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
sig { returns(Step) }
|
|
465
|
+
def letter_j
|
|
466
|
+
return spanish_j if span(@position, 4) == "JOSE" || span(0, 4) == "SAN " # 'jose', 'san jacinto'
|
|
467
|
+
|
|
468
|
+
advance = at(@position + 1) == "J" ? 2 : 1 # it could happen!
|
|
469
|
+
return either("J", "A", advance) if @position.zero? # Yankelovich/Jankelowicz
|
|
470
|
+
# spanish pronunciation of e.g. 'bajador'
|
|
471
|
+
return either("J", "H", advance) if vowel?(@position - 1) && !@slavo_germanic &&
|
|
472
|
+
%w[A O].include?(at(@position + 1))
|
|
473
|
+
return either("J", "", advance) if @position == @last
|
|
474
|
+
return both("J", advance) if !%w[L T K S N M B Z].include?(at(@position + 1)) &&
|
|
475
|
+
!%w[S K L].include?(at(@position - 1))
|
|
476
|
+
|
|
477
|
+
silent(advance)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
sig { returns(Step) }
|
|
481
|
+
def spanish_j
|
|
482
|
+
return both("H", 1) if (@position.zero? && at(@position + 4) == " ") || span(0, 4) == "SAN "
|
|
483
|
+
|
|
484
|
+
either("J", "H", 1)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
sig { returns(Step) }
|
|
488
|
+
def letter_l
|
|
489
|
+
return both("L", 1) unless at(@position + 1) == "L"
|
|
490
|
+
# spanish e.g. 'cabrillo', 'gallegos'
|
|
491
|
+
return either("L", "", 2) if spanish_ll?
|
|
492
|
+
|
|
493
|
+
both("L", 2)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
sig { returns(T::Boolean) }
|
|
497
|
+
def spanish_ll?
|
|
498
|
+
(@position == @last - 2 && %w[ILLO ILLA ALLE].include?(span(@position - 1, 4))) ||
|
|
499
|
+
((%w[AS OS].include?(span(@last - 1, 2)) || %w[A O].include?(at(@last))) &&
|
|
500
|
+
span(@position - 1, 4) == "ALLE")
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# 'dumb', 'thumb': the 'B' is silent, so the 'M' consumes it.
|
|
504
|
+
sig { returns(Step) }
|
|
505
|
+
def letter_m
|
|
506
|
+
umb = span(@position - 1, 3) == "UMB" && (@position + 1 == @last || span(@position + 2, 2) == "ER")
|
|
507
|
+
|
|
508
|
+
both("M", umb || at(@position + 1) == "M" ? 2 : 1)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
sig { returns(Step) }
|
|
512
|
+
def letter_p
|
|
513
|
+
return both("F", 2) if at(@position + 1) == "H"
|
|
514
|
+
|
|
515
|
+
# also account for 'campbell', 'raspberry'
|
|
516
|
+
both("P", %w[P B].include?(at(@position + 1)) ? 2 : 1)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
sig { returns(Step) }
|
|
520
|
+
def letter_r
|
|
521
|
+
advance = at(@position + 1) == "R" ? 2 : 1
|
|
522
|
+
# french e.g. 'rogier', but exclude 'hochmeier'
|
|
523
|
+
french = @position == @last && !@slavo_germanic &&
|
|
524
|
+
span(@position - 2, 2) == "IE" && !%w[ME MA].include?(span(@position - 4, 2))
|
|
525
|
+
|
|
526
|
+
french ? either("", "R", advance) : both("R", advance)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
sig { returns(Step) }
|
|
530
|
+
def letter_s
|
|
531
|
+
return silent(1) if %w[ISL YSL].include?(span(@position - 1, 3)) # 'island', 'isle', 'carlisle'
|
|
532
|
+
return either("X", "S", 1) if @position.zero? && span(@position, 5) == "SUGAR"
|
|
533
|
+
return letter_sh if span(@position, 2) == "SH"
|
|
534
|
+
return italian_s if %w[SIO SIA].include?(span(@position, 3)) || span(@position, 4) == "SIAN"
|
|
535
|
+
# german & anglicisations: 'smith' matches 'schmidt', 'snider' matches 'schneider'
|
|
536
|
+
return either("S", "X", at(@position + 1) == "Z" ? 2 : 1) if germanic_s?
|
|
537
|
+
return letter_sc if span(@position, 2) == "SC"
|
|
538
|
+
|
|
539
|
+
trailing_s
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
sig { returns(Step) }
|
|
543
|
+
def letter_sh
|
|
544
|
+
# germanic
|
|
545
|
+
return both("S", 2) if %w[HEIM HOEK HOLM HOLZ].include?(span(@position + 1, 4))
|
|
546
|
+
|
|
547
|
+
both("X", 2)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Italian & armenian.
|
|
551
|
+
sig { returns(Step) }
|
|
552
|
+
def italian_s
|
|
553
|
+
@slavo_germanic ? both("S", 3) : either("S", "X", 3)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
sig { returns(T::Boolean) }
|
|
557
|
+
def germanic_s?
|
|
558
|
+
(@position.zero? && %w[M N L W].include?(at(@position + 1))) || at(@position + 1) == "Z"
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
sig { returns(Step) }
|
|
562
|
+
def letter_sc
|
|
563
|
+
return schlesingers_rule if at(@position + 2) == "H"
|
|
564
|
+
return both("S", 3) if %w[I E Y].include?(at(@position + 2))
|
|
565
|
+
|
|
566
|
+
both("SK", 3)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
sig { returns(Step) }
|
|
570
|
+
def schlesingers_rule
|
|
571
|
+
# dutch origin, e.g. 'school', 'schooner'
|
|
572
|
+
if %w[OO ER EN UY ED EM].include?(span(@position + 3, 2))
|
|
573
|
+
# 'schermerhorn', 'schenker'
|
|
574
|
+
return %w[ER EN].include?(span(@position + 3, 2)) ? either("X", "SK", 3) : both("SK", 3)
|
|
575
|
+
end
|
|
576
|
+
return either("X", "S", 3) if @position.zero? && !vowel?(3) && at(3) != "W"
|
|
577
|
+
|
|
578
|
+
both("X", 3)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
sig { returns(Step) }
|
|
582
|
+
def trailing_s
|
|
583
|
+
advance = %w[S Z].include?(at(@position + 1)) ? 2 : 1
|
|
584
|
+
# french e.g. 'resnais', 'artois'
|
|
585
|
+
french = @position == @last && %w[AI OI].include?(span(@position - 2, 2))
|
|
586
|
+
|
|
587
|
+
french ? either("", "S", advance) : both("S", advance)
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
sig { returns(Step) }
|
|
591
|
+
def letter_t
|
|
592
|
+
return both("X", 3) if span(@position, 4) == "TION"
|
|
593
|
+
return both("X", 3) if %w[TIA TCH].include?(span(@position, 3))
|
|
594
|
+
return letter_th if span(@position, 2) == "TH" || span(@position, 3) == "TTH"
|
|
595
|
+
|
|
596
|
+
both("T", %w[T D].include?(at(@position + 1)) ? 2 : 1)
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
sig { returns(Step) }
|
|
600
|
+
def letter_th
|
|
601
|
+
# special case 'thomas', 'thames', or germanic
|
|
602
|
+
return both("T", 2) if %w[OM AM].include?(span(@position + 2, 2)) ||
|
|
603
|
+
["VAN ", "VON "].include?(span(0, 4)) || span(0, 3) == "SCH"
|
|
604
|
+
|
|
605
|
+
either("0", "T", 2) # yes, zero: the character Philips uses for the 'th' sound
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# 'W' is the one letter whose rules stack: an initial 'WA-' can be
|
|
609
|
+
# scored and then scored again by the ending rule below it, which is
|
|
610
|
+
# why this reads as an accumulator rather than as a chain of returns.
|
|
611
|
+
sig { returns(Step) }
|
|
612
|
+
def letter_w
|
|
613
|
+
return both("R", 2) if span(@position, 2) == "WR"
|
|
614
|
+
|
|
615
|
+
primary, secondary = initial_w
|
|
616
|
+
# 'Arnow' should match 'Arnoff'
|
|
617
|
+
return either(primary, "#{secondary}F", 1) if final_w?
|
|
618
|
+
# polish e.g. 'filipowicz'
|
|
619
|
+
return either("#{primary}TS", "#{secondary}FX", 4) if %w[WICZ WITZ].include?(span(@position, 4))
|
|
620
|
+
|
|
621
|
+
either(primary, secondary, 1) # else skip it
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
sig { returns([String, String]) }
|
|
625
|
+
def initial_w
|
|
626
|
+
return ["", ""] unless @position.zero? && (vowel?(@position + 1) || span(@position, 2) == "WH")
|
|
627
|
+
# 'Wasserman' should match 'Vasserman'; 'Uomo' should match 'Womo'
|
|
628
|
+
return %w[A F] if vowel?(@position + 1)
|
|
629
|
+
|
|
630
|
+
%w[A A]
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
sig { returns(T::Boolean) }
|
|
634
|
+
def final_w?
|
|
635
|
+
(@position == @last && vowel?(@position - 1)) ||
|
|
636
|
+
%w[EWSKI EWSKY OWSKI OWSKY].include?(span(@position - 1, 5)) ||
|
|
637
|
+
span(0, 3) == "SCH"
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
sig { returns(Step) }
|
|
641
|
+
def letter_x
|
|
642
|
+
advance = %w[C X].include?(at(@position + 1)) ? 2 : 1
|
|
643
|
+
# french e.g. 'breaux'
|
|
644
|
+
french = @position == @last &&
|
|
645
|
+
(%w[IAU EAU].include?(span(@position - 3, 3)) || %w[AU OU].include?(span(@position - 2, 2)))
|
|
646
|
+
|
|
647
|
+
french ? silent(advance) : both("KS", advance)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
sig { returns(Step) }
|
|
651
|
+
def letter_z
|
|
652
|
+
return both("J", 2) if at(@position + 1) == "H" # chinese pinyin e.g. 'zhao'
|
|
653
|
+
|
|
654
|
+
advance = at(@position + 1) == "Z" ? 2 : 1
|
|
655
|
+
return either("S", "TS", advance) if %w[ZO ZI ZA].include?(span(@position + 1, 2)) ||
|
|
656
|
+
(@slavo_germanic && @position.positive? && at(@position - 1) != "T")
|
|
657
|
+
|
|
658
|
+
both("S", advance)
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
end
|