swear_jar 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +22 -0
- data/README.md +313 -0
- data/config/locales/en.yml +4 -0
- data/config/locales/pl.yml +4 -0
- data/lib/swear_jar/configuration.rb +56 -0
- data/lib/swear_jar/detector.rb +58 -0
- data/lib/swear_jar/entry.rb +82 -0
- data/lib/swear_jar/errors.rb +7 -0
- data/lib/swear_jar/fold_table.rb +115 -0
- data/lib/swear_jar/match.rb +10 -0
- data/lib/swear_jar/matcher.rb +289 -0
- data/lib/swear_jar/normalizer.rb +163 -0
- data/lib/swear_jar/pattern_builder.rb +221 -0
- data/lib/swear_jar/railtie.rb +36 -0
- data/lib/swear_jar/skeleton.rb +116 -0
- data/lib/swear_jar/slicer.rb +58 -0
- data/lib/swear_jar/swear_jar_validator.rb +44 -0
- data/lib/swear_jar/version.rb +5 -0
- data/lib/swear_jar/word_list.rb +167 -0
- data/lib/swear_jar.rb +27 -0
- data/locales/en/words.yml +266 -0
- data/locales/pl/words.yml +213 -0
- metadata +67 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwearJar
|
|
4
|
+
# Per-character folds (NFKC, downcase, diacritics, `#` and `%` masks, invisible characters
|
|
5
|
+
# dropped), cached in blocks of 256 code points. The Latin and fullwidth blocks are built at
|
|
6
|
+
# load time. Any other block is built the first time a message contains one of its characters,
|
|
7
|
+
# up to MAX_BLOCKS, so text in another script or full of emoji pays for Unicode normalization
|
|
8
|
+
# once per process instead of on every call. A table is immutable: adding blocks publishes a
|
|
9
|
+
# new table under a mutex, and readers never lock.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class FoldTable
|
|
13
|
+
BLOCK_BITS = 8
|
|
14
|
+
BLOCK_SIZE = 1 << BLOCK_BITS
|
|
15
|
+
INITIAL_BLOCKS = [0x00, 0x01, 0x02, 0xFF].freeze
|
|
16
|
+
MAX_BLOCKS = 64
|
|
17
|
+
SURROGATES = 0xD800..0xDFFF
|
|
18
|
+
POLISH = {
|
|
19
|
+
'ą' => 'a', 'ć' => 'c', 'ę' => 'e', 'ł' => 'l', 'ń' => 'n', 'ó' => 'o', 'ś' => 's', 'ź' => 'z', 'ż' => 'z'
|
|
20
|
+
}.freeze
|
|
21
|
+
POLISH_RE = /[ąćęłńóśźż]/
|
|
22
|
+
MARKS_RE = /\p{Mn}/
|
|
23
|
+
# Format characters (zero-width space and joiners, soft hyphen, BOM...) and other default
|
|
24
|
+
# ignorables (Hangul fillers...) render as nothing, so they must not split a word.
|
|
25
|
+
INVISIBLE_RE = /\A[\p{Cf}\p{Default_Ignorable_Code_Point}]\z/
|
|
26
|
+
|
|
27
|
+
# +slow+: matches a character the whole-string fast path does not fold: an uncovered one,
|
|
28
|
+
# or one whose fold contains `_` (a mask only between letters, which needs the context).
|
|
29
|
+
attr_reader :blocks, :folds, :changing, :uncovered, :slow
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def compute(char)
|
|
33
|
+
return '' if INVISIBLE_RE.match?(char)
|
|
34
|
+
|
|
35
|
+
folded = char.unicode_normalize(:nfkc).downcase.gsub(POLISH_RE, POLISH)
|
|
36
|
+
folded = folded.unicode_normalize(:nfd).gsub(MARKS_RE, '')
|
|
37
|
+
folded.tr('#%', '**')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :current
|
|
41
|
+
|
|
42
|
+
# The current table, first extended with the blocks of +text+'s characters when needed.
|
|
43
|
+
def for(text)
|
|
44
|
+
table = @current
|
|
45
|
+
return table if table.full? || !text.match?(table.uncovered)
|
|
46
|
+
|
|
47
|
+
@mutex.synchronize { @current = @current.with_blocks_of(text) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def block_folds(block)
|
|
51
|
+
first = block << BLOCK_BITS
|
|
52
|
+
(first...first + BLOCK_SIZE).each_with_object({}) do |codepoint, folds|
|
|
53
|
+
next if SURROGATES.cover?(codepoint)
|
|
54
|
+
|
|
55
|
+
char = codepoint.chr(Encoding::UTF_8)
|
|
56
|
+
folds[-char] = -compute(char)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initial
|
|
61
|
+
new(INITIAL_BLOCKS, INITIAL_BLOCKS.reduce({}) { |folds, block| folds.merge(block_folds(block)) })
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initialize(blocks, folds)
|
|
66
|
+
@blocks = blocks.sort.freeze
|
|
67
|
+
@folds = folds.freeze
|
|
68
|
+
build_patterns
|
|
69
|
+
freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def full?
|
|
73
|
+
blocks.size >= MAX_BLOCKS
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def inspect
|
|
77
|
+
"#<#{self.class.name} blocks=#{blocks.size}>"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A table that also holds the blocks of +text+'s uncovered characters, or self when none fit.
|
|
81
|
+
def with_blocks_of(text)
|
|
82
|
+
return self if full?
|
|
83
|
+
|
|
84
|
+
added = text.scan(uncovered).map { |char| char.ord >> BLOCK_BITS }.uniq.first(MAX_BLOCKS - blocks.size)
|
|
85
|
+
return self if added.empty?
|
|
86
|
+
|
|
87
|
+
self.class.new(blocks + added, added.reduce(folds) { |all, block| all.merge(self.class.block_folds(block)) })
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def build_patterns
|
|
93
|
+
changing = folds.filter_map { |char, fold| char.ord if char != fold }
|
|
94
|
+
underscores = folds.filter_map { |char, fold| char.ord if fold.include?('_') }
|
|
95
|
+
@changing = Regexp.new("[#{char_class(ranges(changing))}]")
|
|
96
|
+
@uncovered = Regexp.new("[^#{char_class(block_ranges)}]")
|
|
97
|
+
@slow = Regexp.new("#{@uncovered.source}|[#{char_class(ranges(underscores))}]")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def block_ranges
|
|
101
|
+
ranges(blocks).map { |range| (range.begin << BLOCK_BITS)..((range.end << BLOCK_BITS) + BLOCK_SIZE - 1) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def ranges(values)
|
|
105
|
+
values.sort.uniq.slice_when { |a, b| b != a + 1 }.map { |run| run.first..run.last }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def char_class(ranges)
|
|
109
|
+
ranges.map { |r| r.size == 1 ? format('\u{%X}', r.begin) : format('\u{%X}-\u{%X}', r.begin, r.end) }.join
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
@mutex = Mutex.new
|
|
113
|
+
@current = initial
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwearJar
|
|
4
|
+
# The detection loop. A clean message costs one scanner pass over the skeleton; entry
|
|
5
|
+
# identification and allowlist checks run only on the words around scanner hits.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Matcher
|
|
9
|
+
# Leet symbols that are trimmed from word edges, so `kurwa!!!` reports `kurwa`.
|
|
10
|
+
EDGE_SYMBOLS = ['!', '|'].freeze
|
|
11
|
+
NO_SPANS = [].freeze
|
|
12
|
+
NO_CANDIDATES = [].freeze
|
|
13
|
+
NO_RETRIES = [].freeze
|
|
14
|
+
# One-letter words of the bundled languages. A spaced run that starts with one of them may
|
|
15
|
+
# have swallowed it (`a k u r w a`, `i a s s`); after any other character (`p a s s`) the
|
|
16
|
+
# retry skips exact entries, which would otherwise match the tail of an innocent word.
|
|
17
|
+
ONE_LETTER_WORDS = %w[a i o u w z].freeze
|
|
18
|
+
# Invisible characters (zero-width space, word joiner, BOM...) between two letters. They are
|
|
19
|
+
# dropped from the skeleton, so `ku\u200Brwa` is one word; :words mode also retries the
|
|
20
|
+
# letter after them as a word start, so `you\u200Bidiot` still finds `idiot`.
|
|
21
|
+
INVISIBLE_GAP = /(?<=\p{L})[\p{Cf}\p{Default_Ignorable_Code_Point}]+(?=\p{L})/
|
|
22
|
+
# The word being examined: its bounds for the allowlist, the allowlisted spans in it
|
|
23
|
+
# (computed on first use) and the best [entry, from, to] so far.
|
|
24
|
+
Word = Struct.new(:first, :last, :spans, :best)
|
|
25
|
+
# A character before a position that makes it no word start (see PatternBuilder::LEFT).
|
|
26
|
+
LEFT_WORD_CHAR = /[\p{L}\p{N}*]/
|
|
27
|
+
|
|
28
|
+
def initialize(pattern_builder)
|
|
29
|
+
@scanner = pattern_builder.scanner
|
|
30
|
+
@words_mode = pattern_builder.mode == :words
|
|
31
|
+
@buckets = build_buckets(pattern_builder)
|
|
32
|
+
@prefixes = build_prefix_index(pattern_builder.prefixes)
|
|
33
|
+
@entry_count = pattern_builder.entry_patterns.map(&:first).uniq.size
|
|
34
|
+
allow = pattern_builder.allow_pattern
|
|
35
|
+
@allow_scanner = allow && Regexp.new(allow.source.delete_prefix('\G'))
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def inspect
|
|
40
|
+
"#<#{self.class.name} entries=#{@entry_count}>"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# nil stays nil; anything else becomes a valid UTF-8 String of class String (so a Rails
|
|
44
|
+
# SafeBuffer gives plain String texts). The caller's string is never mutated.
|
|
45
|
+
def self.coerce(text)
|
|
46
|
+
return if text.nil?
|
|
47
|
+
|
|
48
|
+
string = text.to_s
|
|
49
|
+
string = String.new(string) unless string.instance_of?(String)
|
|
50
|
+
unless string.encoding == Encoding::UTF_8
|
|
51
|
+
string = string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
|
|
52
|
+
end
|
|
53
|
+
string.valid_encoding? ? string : string.scrub('')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Yields each Match in order of position. Returns an Enumerator without a block, which
|
|
57
|
+
# is lazy: `each_match(text).any?` stops at the first verified match.
|
|
58
|
+
def each_match(text)
|
|
59
|
+
return enum_for(:each_match, text) unless block_given?
|
|
60
|
+
|
|
61
|
+
original = Matcher.coerce(text)
|
|
62
|
+
return if original.nil?
|
|
63
|
+
|
|
64
|
+
skeleton, index_map, retries = prepare(original)
|
|
65
|
+
hit = @scanner.match(skeleton, 0)
|
|
66
|
+
return if hit.nil? && retries.empty?
|
|
67
|
+
|
|
68
|
+
slicer = nil
|
|
69
|
+
each_found(Skeleton.new(skeleton), retries, hit && hit.offset(0)) do |found|
|
|
70
|
+
# The index map is only needed to report a match, so a clean message never builds it.
|
|
71
|
+
index_map ||= Normalizer.call(original).last
|
|
72
|
+
slicer ||= Slicer.new(original)
|
|
73
|
+
yield build_match(slicer, index_map, *found)
|
|
74
|
+
end
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# [skeleton, index map or nil, retries]. Retries are extra word starts that :words mode
|
|
81
|
+
# tries when the scanner cannot see them: [start, start of the word for the allowlist,
|
|
82
|
+
# whether exact entries may match]. The index map is built here only when invisible
|
|
83
|
+
# characters need it; otherwise it waits for the first match.
|
|
84
|
+
def prepare(original)
|
|
85
|
+
skeleton, joins = Normalizer.skeleton_with_joins(original)
|
|
86
|
+
return [skeleton, nil, NO_RETRIES] unless @words_mode
|
|
87
|
+
|
|
88
|
+
# Dropped characters shorten the skeleton, so equal lengths without joins rule out
|
|
89
|
+
# invisible ones without scanning the text.
|
|
90
|
+
if (skeleton.length != original.length || !joins.empty?) && INVISIBLE_GAP.match?(original)
|
|
91
|
+
skeleton, index_map, joins = Normalizer.analyze(original)
|
|
92
|
+
gaps = gap_starts(original, index_map)
|
|
93
|
+
end
|
|
94
|
+
[skeleton, index_map, retries(skeleton, joins, gaps)]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def gap_starts(original, index_map)
|
|
98
|
+
starts = []
|
|
99
|
+
original.scan(INVISIBLE_GAP) do
|
|
100
|
+
letter = Regexp.last_match.end(0)
|
|
101
|
+
start = index_map.bsearch_index { |index| index >= letter }
|
|
102
|
+
starts << start if start
|
|
103
|
+
end
|
|
104
|
+
starts
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def retries(skeleton, joins, gaps)
|
|
108
|
+
return NO_RETRIES if joins.empty? && gaps.nil?
|
|
109
|
+
|
|
110
|
+
list = joins.map { |join| [join + 1, join, ONE_LETTER_WORDS.include?(skeleton[join])].freeze }
|
|
111
|
+
gaps&.each { |start| list << [start, start, true].freeze }
|
|
112
|
+
list.sort_by!(&:first)
|
|
113
|
+
list.uniq!(&:first)
|
|
114
|
+
list
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Candidates [entry, stem pattern, bounded, prefixed] grouped by the character their stem
|
|
118
|
+
# can start with, in word-list order, so a stem start tries only the entries that fit.
|
|
119
|
+
# bounded: in :embedded mode the scanner hits inside words, so an entry that needs a word
|
|
120
|
+
# boundary checks it at the hit. In :words mode every hit already has one.
|
|
121
|
+
# prefixed: the entry may follow a prefix of its locale.
|
|
122
|
+
def build_buckets(builder)
|
|
123
|
+
buckets = Hash.new { |hash, char| hash[char] = [] }
|
|
124
|
+
builder.entry_patterns.each do |entry, pattern, first_chars|
|
|
125
|
+
bounded = builder.mode == :embedded && (!entry.embeddable || entry.exact)
|
|
126
|
+
prefixed = !entry.exact && !builder.prefixes[entry.locale].empty?
|
|
127
|
+
candidate = [entry, Regexp.new(pattern.source.delete_prefix('\G')), bounded, prefixed].freeze
|
|
128
|
+
first_chars.each { |char| buckets[char] << candidate }
|
|
129
|
+
end
|
|
130
|
+
buckets.to_h { |char, list| [char, list.freeze] }.freeze
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# locale => first character => the locale's prefixes starting with it, longest first.
|
|
134
|
+
def build_prefix_index(prefixes)
|
|
135
|
+
prefixes.reject { |_, list| list.empty? }.transform_values do |list|
|
|
136
|
+
list.group_by { |prefix| prefix[0] }.transform_values(&:freeze).freeze
|
|
137
|
+
end.freeze
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Yields [first, last, entry] of each match, in order. Words come from scanner hits ([begin,
|
|
141
|
+
# end] pairs) and from +retries+ (see +prepare+), which may have no hit: `a k u r w a` is
|
|
142
|
+
# joined into `akurwa`, where :words mode finds nothing at the word start. A retry inside a
|
|
143
|
+
# word whose hits had no match still runs (`duplikat\u200Bidiot`).
|
|
144
|
+
def each_found(skeleton, retries, hit)
|
|
145
|
+
matched_end = 0
|
|
146
|
+
queue = 0
|
|
147
|
+
loop do
|
|
148
|
+
queue += 1 while queue < retries.size && retries[queue][0] < matched_end
|
|
149
|
+
retry_at = retries[queue]
|
|
150
|
+
if retry_at && (hit.nil? || retry_at[0] <= hit[0])
|
|
151
|
+
queue += 1
|
|
152
|
+
pos, found = examine_retry(skeleton, *retry_at)
|
|
153
|
+
elsif hit
|
|
154
|
+
pos, found, hit = examine_word(skeleton, hit)
|
|
155
|
+
else
|
|
156
|
+
break
|
|
157
|
+
end
|
|
158
|
+
if found
|
|
159
|
+
matched_end = pos
|
|
160
|
+
yield found
|
|
161
|
+
end
|
|
162
|
+
hit = skeleton.search(@scanner, pos) if hit && hit[0] < pos
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Handles the word around +hit+. Returns [position to continue from, [first, last, entry] of
|
|
167
|
+
# the match or nil, the first scanner hit after the word's hits (or nil when there is none)].
|
|
168
|
+
def examine_word(skeleton, hit)
|
|
169
|
+
first = skeleton.word_start(hit[0])
|
|
170
|
+
scan_end = skeleton.word_end(hit[1])
|
|
171
|
+
winner, next_hit = best_candidate(skeleton, hit, first, scan_end)
|
|
172
|
+
return [scan_end, nil, next_hit] unless winner
|
|
173
|
+
|
|
174
|
+
[*finish(skeleton, winner, first, scan_end), next_hit]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Tries the entries at +start+ as a word start. In :words mode the scanner only hits at word
|
|
178
|
+
# starts, so no hit can begin later inside this word. Returns [position to continue from,
|
|
179
|
+
# [first, last, entry] or nil].
|
|
180
|
+
def examine_retry(skeleton, start, word_start, exact)
|
|
181
|
+
word_end = skeleton.word_end(start)
|
|
182
|
+
word = Word.new(word_start, word_end)
|
|
183
|
+
best_at(skeleton, start, word, left: true, exact: exact) if start < word_end
|
|
184
|
+
return [word_end, nil] unless word.best
|
|
185
|
+
|
|
186
|
+
finish(skeleton, word.best, start, word_end)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# [position to continue from, [first, last, entry]] for the +winner+ of the word that starts
|
|
190
|
+
# at +first+ and whose hits end at +scan_end+.
|
|
191
|
+
def finish(skeleton, winner, first, scan_end)
|
|
192
|
+
entry, from, to = winner
|
|
193
|
+
last = to > scan_end ? skeleton.word_end(to) : scan_end
|
|
194
|
+
first, last = trim(skeleton, first, last, from, to)
|
|
195
|
+
[last, [first, last, entry]]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Tries the entries at every hit inside the word and keeps the best one that no allowlisted
|
|
199
|
+
# word covers: the highest severity, then the longest stem. Returns [[entry, from, to], next_hit].
|
|
200
|
+
def best_candidate(skeleton, hit, word_start, scan_end)
|
|
201
|
+
word = Word.new(word_start, scan_end)
|
|
202
|
+
while hit && hit[0] < scan_end
|
|
203
|
+
best_at(skeleton, hit[0], word)
|
|
204
|
+
hit = skeleton.search(@scanner, hit[0] + 1)
|
|
205
|
+
end
|
|
206
|
+
[word.best, hit]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Updates +word.best+ with the entries that match at +at+. +left+ is true when +at+ is
|
|
210
|
+
# known to be a word start. +exact+ false skips exact entries.
|
|
211
|
+
def best_at(skeleton, at, word, left: nil, exact: true)
|
|
212
|
+
each_candidate(skeleton, at, left) do |entry, stem_begin, to|
|
|
213
|
+
next if !exact && entry.exact
|
|
214
|
+
next if word.best && !better?(entry, word.best[0])
|
|
215
|
+
|
|
216
|
+
word.spans ||= allow_spans(skeleton, word.first, word.last)
|
|
217
|
+
word.best = [entry, at, to] unless covered?(word.spans, stem_begin, to)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Yields entry, stem begin and match end for each entry that matches at +at+. A prefixed
|
|
222
|
+
# entry tries the prefixes found at +at+ (longest first) and then none, and yields only
|
|
223
|
+
# its first match, which is what an optional prefix group in a regexp would match.
|
|
224
|
+
def each_candidate(skeleton, at, left)
|
|
225
|
+
matched = nil
|
|
226
|
+
stem_starts(skeleton, at).each do |start, locale|
|
|
227
|
+
@buckets.fetch(skeleton[start], NO_CANDIDATES).each do |candidate|
|
|
228
|
+
entry, pattern, bounded, prefixed = candidate
|
|
229
|
+
next if locale && !(prefixed && entry.locale == locale)
|
|
230
|
+
next if matched&.include?(candidate)
|
|
231
|
+
next if bounded && !(left = left.nil? ? boundary?(skeleton, at) : left)
|
|
232
|
+
next unless (to = skeleton.match(pattern, start))
|
|
233
|
+
|
|
234
|
+
(matched ||= []) << candidate if locale
|
|
235
|
+
yield entry, start, to
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# [stem start, locale of the prefix before it] pairs: each prefix found at +at+, then +at+
|
|
241
|
+
# itself (locale nil, open to every entry).
|
|
242
|
+
def stem_starts(skeleton, at)
|
|
243
|
+
starts = []
|
|
244
|
+
@prefixes.each do |locale, by_char|
|
|
245
|
+
by_char[skeleton[at]]&.each do |prefix|
|
|
246
|
+
starts << [at + prefix.length, locale] if skeleton.literal?(prefix, at)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
starts << [at, nil]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def boundary?(skeleton, at)
|
|
253
|
+
at.zero? || !LEFT_WORD_CHAR.match?(skeleton[at - 1])
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def better?(entry, current)
|
|
257
|
+
entry.rank > current.rank || (entry.rank == current.rank && entry.stem.length > current.stem.length)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def allow_spans(skeleton, word_start, word_end)
|
|
261
|
+
return NO_SPANS unless @allow_scanner
|
|
262
|
+
|
|
263
|
+
word = Skeleton.new(skeleton.slice(word_start, word_end))
|
|
264
|
+
spans = []
|
|
265
|
+
offset = 0
|
|
266
|
+
while (found = word.search(@allow_scanner, offset))
|
|
267
|
+
spans << [found[0] + word_start, found[1] + word_start]
|
|
268
|
+
offset = found[0] + 1
|
|
269
|
+
end
|
|
270
|
+
spans
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def covered?(spans, stem_begin, stem_end)
|
|
274
|
+
spans.any? { |from, to| from <= stem_begin && to >= stem_end }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def trim(skeleton, first, last, from, to)
|
|
278
|
+
first += 1 while first < from && EDGE_SYMBOLS.include?(skeleton[first])
|
|
279
|
+
last -= 1 while last > to && EDGE_SYMBOLS.include?(skeleton[last - 1])
|
|
280
|
+
[first, last]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def build_match(slicer, index_map, first, last, entry)
|
|
284
|
+
range, text = slicer.slice(index_map[first], index_map[last - 1] + 1)
|
|
285
|
+
Match.new(text: text.freeze, range: range, stem: entry.stem, locale: entry.locale,
|
|
286
|
+
severity: entry.severity, categories: entry.categories)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwearJar
|
|
4
|
+
# Turns text into a lowercase, diacritic-free skeleton plus a map from skeleton
|
|
5
|
+
# character index to original character index.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module Normalizer
|
|
9
|
+
LETTER_RE = /\p{L}/
|
|
10
|
+
ASCII_UNDERSCORE_RE = /(?<=[A-Za-z])_(?=[A-Za-z])/
|
|
11
|
+
ASCII_MASK_RE = /[#%_]/
|
|
12
|
+
|
|
13
|
+
# Cheap screen: three single characters in a row with separators between them.
|
|
14
|
+
SPACED_GATE = /(?:\A|[ .-])[[:alnum:]@$!|][ .-][[:alnum:]@$!|][ .-][[:alnum:]@$!|]/
|
|
15
|
+
SEPARATORS = ' .-'
|
|
16
|
+
# A run of three or more tokens (maximal runs of non-separators) split by one repeated
|
|
17
|
+
# separator: single characters, and the last one may carry trailing punctuation (`o!`).
|
|
18
|
+
SINGLE = '[[:alnum:]@$!|]'
|
|
19
|
+
SPACED_RUN = Regexp.new(
|
|
20
|
+
"(?<![^ .-])#{SINGLE}(?<sep>[ .-])(?:#{SINGLE}\\k<sep>)+(?:#{SINGLE}|[[:alnum:]][!|,?;:]+)(?![^ .-])"
|
|
21
|
+
)
|
|
22
|
+
NO_JOINS = [].freeze
|
|
23
|
+
# A run of masks with no word character on one side is a marker around a word (`**kurwa**`,
|
|
24
|
+
# `#tag`), not a masked letter: it becomes `_`, which is no word character, keeping lengths.
|
|
25
|
+
EDGE_MASKS = /(?<![\p{L}\p{N}@$!|*])\*+|\*+(?![\p{L}\p{N}@$!|*])/
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# [skeleton, index map].
|
|
29
|
+
def call(text)
|
|
30
|
+
normalize(text).first(2)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The skeleton alone (equal to +call(text).first+), without the index map.
|
|
34
|
+
def skeleton(text)
|
|
35
|
+
skeleton_with_joins(text).first
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# [skeleton, joins]: +joins+ lists the skeleton index where each joined spaced-letter run
|
|
39
|
+
# starts (empty when there is none). Like +skeleton+, it folds the whole string with one
|
|
40
|
+
# gsub instead of character by character, unless the text has a character the fold table
|
|
41
|
+
# cannot handle that way (see FoldTable#slow).
|
|
42
|
+
def skeleton_with_joins(text)
|
|
43
|
+
return join_spaced(fold_ascii(text), nil).values_at(0, 2) if text.ascii_only?
|
|
44
|
+
|
|
45
|
+
table = FoldTable.current
|
|
46
|
+
if text.match?(table.slow)
|
|
47
|
+
table = FoldTable.for(text)
|
|
48
|
+
return normalize(text).values_at(0, 2) if text.match?(table.slow)
|
|
49
|
+
end
|
|
50
|
+
join_spaced(text.gsub(table.changing, table.folds), nil).values_at(0, 2)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# [skeleton, index map, joins] in one pass, for callers that need all three.
|
|
54
|
+
def analyze(text)
|
|
55
|
+
normalize(text)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fold(string)
|
|
59
|
+
string.ascii_only? ? fold_ascii(string) : scan_general(string).first
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# [skeleton, index map, joins].
|
|
65
|
+
def normalize(text)
|
|
66
|
+
text.ascii_only? ? join_spaced(*scan_ascii(text)) : join_spaced(*scan_general(text))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The general (character by character) path of +call+, also for ASCII text.
|
|
70
|
+
def call_general(text)
|
|
71
|
+
join_spaced(*scan_general(text)).first(2)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Leaves `_` alone: whether it is a mask depends on its neighbours.
|
|
75
|
+
def compute_fold(char)
|
|
76
|
+
FoldTable.compute(char)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def scan_ascii(text)
|
|
80
|
+
skeleton = fold_ascii(text)
|
|
81
|
+
[skeleton, (0...skeleton.length).to_a]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fold_ascii(text)
|
|
85
|
+
skeleton = text.downcase
|
|
86
|
+
if skeleton.match?(ASCII_MASK_RE)
|
|
87
|
+
skeleton.tr!('#%', '**')
|
|
88
|
+
skeleton.gsub!(ASCII_UNDERSCORE_RE, '*')
|
|
89
|
+
end
|
|
90
|
+
skeleton
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def scan_general(text)
|
|
94
|
+
folds = FoldTable.for(text).folds
|
|
95
|
+
chars = text.chars
|
|
96
|
+
out = +''
|
|
97
|
+
map = []
|
|
98
|
+
chars.each_with_index do |char, index|
|
|
99
|
+
folded = folds[char] || compute_fold(char)
|
|
100
|
+
if folded.length == 1
|
|
101
|
+
folded = mask_underscore(chars, index) if folded == '_'
|
|
102
|
+
out << folded
|
|
103
|
+
map << index
|
|
104
|
+
else
|
|
105
|
+
folded.each_char do |piece|
|
|
106
|
+
piece = mask_underscore(chars, index) if piece == '_'
|
|
107
|
+
out << piece
|
|
108
|
+
map << index
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
[out, map]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def mask_underscore(chars, index)
|
|
116
|
+
return '_' if index.zero? || index == chars.length - 1
|
|
117
|
+
|
|
118
|
+
LETTER_RE.match?(chars[index - 1]) && LETTER_RE.match?(chars[index + 1]) ? '*' : '_'
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Second pass: turn edge masks into `_` (see EDGE_MASKS), then join runs of >= 3
|
|
122
|
+
# single-character tokens split by one repeated separator, dropping the separators.
|
|
123
|
+
# +map+ may be nil when only the skeleton is needed.
|
|
124
|
+
# Returns [skeleton, map, joins] (see +skeleton_with_joins+).
|
|
125
|
+
def join_spaced(skeleton, map)
|
|
126
|
+
skeleton = skeleton.gsub(EDGE_MASKS) { |run| '_' * run.length } if skeleton.include?('*')
|
|
127
|
+
return [skeleton, map, NO_JOINS] unless skeleton.match?(SPACED_GATE)
|
|
128
|
+
return join_spaced_skeleton(skeleton) unless map
|
|
129
|
+
|
|
130
|
+
out = +''
|
|
131
|
+
new_map = []
|
|
132
|
+
joins = []
|
|
133
|
+
pos = 0
|
|
134
|
+
skeleton.scan(SPACED_RUN) do
|
|
135
|
+
from, to = Regexp.last_match.offset(0)
|
|
136
|
+
out << skeleton[pos...from]
|
|
137
|
+
new_map.concat(map[pos...from])
|
|
138
|
+
joins << out.length
|
|
139
|
+
(from...to).each do |index|
|
|
140
|
+
next if SEPARATORS.include?(skeleton[index])
|
|
141
|
+
|
|
142
|
+
out << skeleton[index]
|
|
143
|
+
new_map << map[index]
|
|
144
|
+
end
|
|
145
|
+
pos = to
|
|
146
|
+
end
|
|
147
|
+
[out << skeleton[pos..], new_map.concat(map[pos..]), joins]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def join_spaced_skeleton(skeleton)
|
|
151
|
+
joins = []
|
|
152
|
+
removed = 0
|
|
153
|
+
out = skeleton.gsub(SPACED_RUN) do |run|
|
|
154
|
+
joined = run.delete(SEPARATORS)
|
|
155
|
+
joins << (Regexp.last_match.begin(0) - removed)
|
|
156
|
+
removed += run.length - joined.length
|
|
157
|
+
joined
|
|
158
|
+
end
|
|
159
|
+
[out, nil, joins]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|