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.
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwearJar
4
+ Match = Struct.new(:text, :range, :stem, :locale, :severity, :categories, keyword_init: true) do
5
+ def initialize(*)
6
+ super
7
+ freeze
8
+ end
9
+ end
10
+ 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