polytypo 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/LICENSE +21 -0
- data/README.md +109 -0
- data/lib/polytypo/data/README.md +20 -0
- data/lib/polytypo/data/UNICODE +1 -0
- data/lib/polytypo/data/VERSION +1 -0
- data/lib/polytypo/data/fixtures/de-CH.json +501 -0
- data/lib/polytypo/data/fixtures/de-DE.json +547 -0
- data/lib/polytypo/data/fixtures/el.json +239 -0
- data/lib/polytypo/data/fixtures/en-GB.json +1274 -0
- data/lib/polytypo/data/fixtures/en-US.json +1807 -0
- data/lib/polytypo/data/fixtures/fi.json +1306 -0
- data/lib/polytypo/data/fixtures/fr-CA.json +268 -0
- data/lib/polytypo/data/fixtures/fr.json +603 -0
- data/lib/polytypo/data/fixtures/locale-resolution.json +209 -0
- data/lib/polytypo/data/fixtures/ru.json +688 -0
- data/lib/polytypo/data/fixtures/sv.json +1290 -0
- data/lib/polytypo/data/locales/de-CH.json +77 -0
- data/lib/polytypo/data/locales/de-DE.json +76 -0
- data/lib/polytypo/data/locales/el.json +90 -0
- data/lib/polytypo/data/locales/en-GB.json +115 -0
- data/lib/polytypo/data/locales/en-US.json +133 -0
- data/lib/polytypo/data/locales/fi.json +136 -0
- data/lib/polytypo/data/locales/fr-CA.json +78 -0
- data/lib/polytypo/data/locales/fr.json +84 -0
- data/lib/polytypo/data/locales/registry.json +9 -0
- data/lib/polytypo/data/locales/ru.json +112 -0
- data/lib/polytypo/data/locales/sv.json +124 -0
- data/lib/polytypo/data/rules/dashes.md +1238 -0
- data/lib/polytypo/data/rules/order.json +78 -0
- data/lib/polytypo/data/schema/fixtures.schema.json +79 -0
- data/lib/polytypo/data/schema/locale.schema.json +235 -0
- data/lib/polytypo/data/schema/registry.schema.json +29 -0
- data/lib/polytypo/data/schema/resolution.schema.json +50 -0
- data/lib/polytypo/engine/codepoints.rb +24 -0
- data/lib/polytypo/engine/edits.rb +64 -0
- data/lib/polytypo/engine/locale.rb +138 -0
- data/lib/polytypo/engine/pipeline.rb +61 -0
- data/lib/polytypo/engine/registry.rb +47 -0
- data/lib/polytypo/engine/rules/apostrophe.rb +127 -0
- data/lib/polytypo/engine/rules/dash_shared.rb +342 -0
- data/lib/polytypo/engine/rules/dashes.rb +125 -0
- data/lib/polytypo/engine/rules/ellipsis.rb +100 -0
- data/lib/polytypo/engine/rules/hyphen.rb +207 -0
- data/lib/polytypo/engine/rules/nbsp.rb +616 -0
- data/lib/polytypo/engine/rules/quote_ambiguity.rb +241 -0
- data/lib/polytypo/engine/rules/quotes.rb +420 -0
- data/lib/polytypo/engine/rules/ranges.rb +124 -0
- data/lib/polytypo/engine/rules/spaces.rb +232 -0
- data/lib/polytypo/engine/rules/symbols.rb +291 -0
- data/lib/polytypo/engine/rules.rb +19 -0
- data/lib/polytypo/engine/sentinels.rb +23 -0
- data/lib/polytypo/engine/unicode_util.rb +390 -0
- data/lib/polytypo/errors.rb +24 -0
- data/lib/polytypo/modes/html.rb +233 -0
- data/lib/polytypo/modes/markdown.rb +187 -0
- data/lib/polytypo/modes/parse_error.rb +19 -0
- data/lib/polytypo/modes/runner.rb +57 -0
- data/lib/polytypo/modes/spans.rb +132 -0
- data/lib/polytypo/version.rb +5 -0
- data/lib/polytypo.rb +91 -0
- data/polytypo.gemspec +37 -0
- metadata +122 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sentinels"
|
|
4
|
+
|
|
5
|
+
module Polytypo
|
|
6
|
+
module Engine
|
|
7
|
+
module Rules
|
|
8
|
+
# Structural primitives shared by `dashes` (spec/rules/dashes.md, order 30) and `ranges`
|
|
9
|
+
# (spec/rules/ranges.md, order 25). Both rules scan the same DASH-token shape and share the
|
|
10
|
+
# same symmetry/isolation/cluster/joiner guards (dashes.md 3.2, 3.2a, 3.2b) -- this module is
|
|
11
|
+
# the single source of truth for that shared machinery, mirroring the JS reference
|
|
12
|
+
# implementation's dash-shared.ts, the Python port's _dash_shared.py and the Go port's
|
|
13
|
+
# dash_shared.go. Each rule adds only its own branch-specific guards (P1/P4/P5 for `dashes`;
|
|
14
|
+
# G1-G5 for `ranges`) and its own locale style (`dash.parenthetical` vs `dash.range`) on top
|
|
15
|
+
# of what `find_tokens` returns.
|
|
16
|
+
#
|
|
17
|
+
# Not a registered rule itself -- an internal helper module for the two rules that are.
|
|
18
|
+
module DashShared
|
|
19
|
+
HYPHEN_MINUS = 0x2d
|
|
20
|
+
HYPHEN = 0x2010
|
|
21
|
+
FIGURE_DASH = 0x2012
|
|
22
|
+
EN_DASH = 0x2013
|
|
23
|
+
EM_DASH = 0x2014
|
|
24
|
+
HORIZONTAL_BAR = 0x2015
|
|
25
|
+
MINUS_SIGN = 0x2212
|
|
26
|
+
SOFT_HYPHEN = 0x00ad
|
|
27
|
+
NON_BREAKING_HYPHEN = 0x2011
|
|
28
|
+
SMALL_EM_DASH = 0xfe58
|
|
29
|
+
SMALL_HYPHEN_MINUS = 0xfe63
|
|
30
|
+
FULLWIDTH_HYPHEN_MINUS = 0xff0d
|
|
31
|
+
|
|
32
|
+
SPACE = 0x20
|
|
33
|
+
NO_BREAK_SPACE = 0x00a0
|
|
34
|
+
NARROW_NO_BREAK_SPACE = 0x202f
|
|
35
|
+
|
|
36
|
+
DIGIT_ZERO = 0x30
|
|
37
|
+
DIGIT_NINE = 0x39
|
|
38
|
+
|
|
39
|
+
LF = 0x0a
|
|
40
|
+
CR = 0x0d
|
|
41
|
+
VT = 0x0b
|
|
42
|
+
FF = 0x0c
|
|
43
|
+
NEL = 0x85
|
|
44
|
+
LS = 0x2028
|
|
45
|
+
PS = 0x2029
|
|
46
|
+
|
|
47
|
+
# dashes.md 3.1 JOINER: U+2060, emitted only around a tight range dash (ranges.md 3.3.1).
|
|
48
|
+
WORD_JOINER = 0x2060
|
|
49
|
+
|
|
50
|
+
COMMA = 0x2c
|
|
51
|
+
FULL_STOP = 0x2e
|
|
52
|
+
SEMICOLON = 0x3b
|
|
53
|
+
COLON = 0x3a
|
|
54
|
+
EXCLAMATION = 0x21
|
|
55
|
+
QUESTION = 0x3f
|
|
56
|
+
ELLIPSIS_CHAR = 0x2026
|
|
57
|
+
|
|
58
|
+
PAREN_OPEN = 0x28
|
|
59
|
+
PAREN_CLOSE = 0x29
|
|
60
|
+
SQUARE_OPEN = 0x5b
|
|
61
|
+
SQUARE_CLOSE = 0x5d
|
|
62
|
+
CURLY_OPEN = 0x7b
|
|
63
|
+
CURLY_CLOSE = 0x7d
|
|
64
|
+
|
|
65
|
+
# One shared dash-token shape returned by find_tokens (dashes.md 3.2, 3.2a).
|
|
66
|
+
DashToken = Struct.new(
|
|
67
|
+
:s, :e, # the DASH run itself, in input-array indices [s, e)
|
|
68
|
+
:lsp, :rsp, # outer spacing (0 or 1 U+0020 on each side)
|
|
69
|
+
:left, :right, # indices immediately outside the token's content, after the joiner
|
|
70
|
+
# walk (dashes.md 3.2a) -- L*/R* in the spec
|
|
71
|
+
:left_cp, :right_cp,
|
|
72
|
+
:span_start, :span_end, # full edit span, including outer spacing and any crossed joiner
|
|
73
|
+
:crossed_joiner,
|
|
74
|
+
keyword_init: true
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
module_function
|
|
78
|
+
|
|
79
|
+
# dashes.md 3.1 DASH.
|
|
80
|
+
def dash?(cp)
|
|
81
|
+
cp == HYPHEN_MINUS || cp == HYPHEN || cp == EN_DASH || cp == EM_DASH || cp == MINUS_SIGN
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# dashes.md 3.1 INERT-DASH: never a candidate, never produced, by either rule.
|
|
85
|
+
def inert_dash?(cp)
|
|
86
|
+
cp == SOFT_HYPHEN || cp == FIGURE_DASH || cp == NON_BREAKING_HYPHEN ||
|
|
87
|
+
cp == HORIZONTAL_BAR || cp == SMALL_EM_DASH || cp == SMALL_HYPHEN_MINUS ||
|
|
88
|
+
cp == FULLWIDTH_HYPHEN_MINUS
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# DASH union INERT-DASH -- the alphabet G2 and T1 read as "a dash".
|
|
92
|
+
def dash_union?(cp)
|
|
93
|
+
dash?(cp) || inert_dash?(cp)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# dashes.md 3.1 DIGIT: ASCII only, deliberately -- see ranges.md 7.1.
|
|
97
|
+
def digit?(cp)
|
|
98
|
+
cp >= DIGIT_ZERO && cp <= DIGIT_NINE
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# BREAK, including Engine::LINE_MARKER: a member of BREAK for every rule everywhere
|
|
102
|
+
# (modes.md 3.2).
|
|
103
|
+
def break?(cp)
|
|
104
|
+
cp == LF || cp == CR || cp == VT || cp == FF || cp == NEL || cp == LS || cp == PS ||
|
|
105
|
+
cp == Polytypo::Engine::LINE_MARKER
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def no_break_space?(cp)
|
|
109
|
+
cp == NO_BREAK_SPACE || cp == NARROW_NO_BREAK_SPACE
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Whether a dash.parenthetical/dash.range value is one of the two "-spaced" forms.
|
|
113
|
+
def spaced_style?(style)
|
|
114
|
+
style == "em-spaced" || style == "en-spaced"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The target dash glyph for a style value.
|
|
118
|
+
def dash_code_point(style)
|
|
119
|
+
style == "em-tight" || style == "em-spaced" ? EM_DASH : EN_DASH
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Whether cp[start...end] is exactly `next`, code point for code point.
|
|
123
|
+
def same_content?(cp, start, stop, next_cp)
|
|
124
|
+
return false if stop - start != next_cp.length
|
|
125
|
+
|
|
126
|
+
next_cp.each_with_index { |want, i| return false if cp[start + i] != want }
|
|
127
|
+
true
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# dashes.md 3.6: every replacement is built from U+0020 alone plus the target dash glyph.
|
|
131
|
+
# `bind` is meaningful only for `ranges` (ranges.md 3.3.1) -- `dashes`' parenthetical
|
|
132
|
+
# branch always calls this with bind=false, since a parenthetical dash never binds (an
|
|
133
|
+
# interrupting dash is exactly where a line may break).
|
|
134
|
+
def build_replacement(style, bind)
|
|
135
|
+
unless spaced_style?(style)
|
|
136
|
+
return [WORD_JOINER, dash_code_point(style), WORD_JOINER] if bind
|
|
137
|
+
|
|
138
|
+
return [dash_code_point(style)]
|
|
139
|
+
end
|
|
140
|
+
[SPACE, dash_code_point(style), SPACE]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# dashes.md 3.2 step 9 (T2)'s own set union CLOSE-BRACKET -- the positions from which
|
|
144
|
+
# `spaces` (order 10) deletes a U+0020, plus U+2026 (T2's set is a strict superset of
|
|
145
|
+
# `spaces`' STRIP-BEFORE by exactly that one code point -- dashes.md 3.2 step 9's own
|
|
146
|
+
# note).
|
|
147
|
+
def strip_before_or_close_bracket?(cp)
|
|
148
|
+
cp == COMMA || cp == FULL_STOP || cp == SEMICOLON || cp == COLON || cp == EXCLAMATION ||
|
|
149
|
+
cp == QUESTION || cp == ELLIPSIS_CHAR ||
|
|
150
|
+
cp == PAREN_CLOSE || cp == SQUARE_CLOSE || cp == CURLY_CLOSE
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# T2's OPEN-BRACKET set.
|
|
154
|
+
def open_bracket?(cp)
|
|
155
|
+
cp == PAREN_OPEN || cp == SQUARE_OPEN || cp == CURLY_OPEN
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# dashes.md 3.2 step 7's cluster alphabet: DASH union INERT-DASH union DIGIT union
|
|
159
|
+
# JOINER (a joiner `ranges` emitted on an earlier pass must not split a cluster it sits
|
|
160
|
+
# inside -- 3.2b).
|
|
161
|
+
def cluster_member?(cp)
|
|
162
|
+
dash?(cp) || inert_dash?(cp) || digit?(cp) || cp == WORD_JOINER
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# dashes.md 3.2 step 7 -- the cluster guard. A maximal span of cluster members containing
|
|
166
|
+
# this run is inert (declines every token in it) if it holds two or more maximal
|
|
167
|
+
# DASH-union-INERT-DASH runs.
|
|
168
|
+
def cluster_inert?(cp, s, e)
|
|
169
|
+
n = cp.length
|
|
170
|
+
start = s
|
|
171
|
+
start -= 1 while start.positive? && cluster_member?(cp[start - 1])
|
|
172
|
+
stop = e
|
|
173
|
+
stop += 1 while stop < n && cluster_member?(cp[stop])
|
|
174
|
+
|
|
175
|
+
runs = 0
|
|
176
|
+
i = start
|
|
177
|
+
while i < stop
|
|
178
|
+
unless dash_union?(cp[i])
|
|
179
|
+
i += 1
|
|
180
|
+
next
|
|
181
|
+
end
|
|
182
|
+
runs += 1
|
|
183
|
+
return true if runs >= 2
|
|
184
|
+
|
|
185
|
+
i += 1 while i < stop && dash_union?(cp[i])
|
|
186
|
+
end
|
|
187
|
+
false
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# dashes.md 3.2b's "effective neighbour" walk: step from `from` in `step` direction
|
|
191
|
+
# (+1/-1) across a maximal run of JOINER, returning the resulting index, or nil if the
|
|
192
|
+
# walk leaves the array.
|
|
193
|
+
def effective_index(cp, from, step)
|
|
194
|
+
n = cp.length
|
|
195
|
+
i = from
|
|
196
|
+
i += step while i >= 0 && i < n && cp[i] == WORD_JOINER
|
|
197
|
+
return nil if i.negative? || i >= n
|
|
198
|
+
|
|
199
|
+
i
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# effective_index's code point, or Engine::NONE if the walk leaves the array.
|
|
203
|
+
def effective_neighbour(cp, from, step)
|
|
204
|
+
i = effective_index(cp, from, step)
|
|
205
|
+
return Polytypo::Engine::NONE if i.nil?
|
|
206
|
+
|
|
207
|
+
cp[i]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# dashes.md 3.2 step 8 (T1) -- the spacing-transition guard. A tight token must not become
|
|
211
|
+
# spaced when doing so would insert a U+0020 between itself and a digit run that has
|
|
212
|
+
# another dash on its far side, read through effective neighbours (3.2b). Shared because a
|
|
213
|
+
# `dashes` token becoming spaced can insert a space next to a `ranges` token's digit run,
|
|
214
|
+
# and vice versa.
|
|
215
|
+
def spacing_transition_blocked?(cp, left, right)
|
|
216
|
+
n = cp.length
|
|
217
|
+
|
|
218
|
+
if left >= 0 && left < n && digit?(cp[left])
|
|
219
|
+
d = left
|
|
220
|
+
d -= 1 while d.positive? && digit?(cp[d - 1])
|
|
221
|
+
i1 = effective_index(cp, d - 1, -1)
|
|
222
|
+
one = i1.nil? ? Polytypo::Engine::NONE : cp[i1]
|
|
223
|
+
two = i1.nil? ? Polytypo::Engine::NONE : effective_neighbour(cp, i1 - 1, -1)
|
|
224
|
+
return true if dash_union?(one)
|
|
225
|
+
return true if (one == SPACE || no_break_space?(one)) && dash_union?(two)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
if right >= 0 && right < n && digit?(cp[right])
|
|
229
|
+
d = right
|
|
230
|
+
d += 1 while d + 1 < n && digit?(cp[d + 1])
|
|
231
|
+
i1 = effective_index(cp, d + 1, 1)
|
|
232
|
+
one = i1.nil? ? Polytypo::Engine::NONE : cp[i1]
|
|
233
|
+
two = i1.nil? ? Polytypo::Engine::NONE : effective_neighbour(cp, i1 + 1, 1)
|
|
234
|
+
return true if dash_union?(one)
|
|
235
|
+
return true if (one == SPACE || no_break_space?(one)) && dash_union?(two)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
false
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# dashes.md 3.2 steps 1-7 and 3.2a, exactly as they read before the `ranges` split -- the
|
|
242
|
+
# common prefix every DASH-run token must pass before either rule's own branch-specific
|
|
243
|
+
# guards run. Returns every token that survives symmetry, content, joiner-crossing,
|
|
244
|
+
# isolation and cluster guards; each rule then filters to the tokens it owns:
|
|
245
|
+
#
|
|
246
|
+
# - `ranges` only ever processes a token whose left_cp/right_cp are both DIGIT.
|
|
247
|
+
# - `dashes` must decline every such token unconditionally (operator decision, spec
|
|
248
|
+
# 0.5.0) -- never reinterpreting a digit-flanked stroke as a parenthetical dash,
|
|
249
|
+
# regardless of whether `ranges` is enabled.
|
|
250
|
+
#
|
|
251
|
+
# A token with crossed_joiner=true and non-digit flanks is NOT returned at all (declined
|
|
252
|
+
# inline, exactly as dashes.md 3.2a specifies: "if a joiner was crossed in any other
|
|
253
|
+
# configuration, emit nothing") -- a joiner is `ranges`' own emission alphabet, and an
|
|
254
|
+
# author who types one next to a dash meant it, exactly as with INERT-DASH.
|
|
255
|
+
#
|
|
256
|
+
# Sequencing is load-bearing and must not be reordered: (1) the symmetry guard runs first;
|
|
257
|
+
# (2) the array-bounds check on the raw L/R runs next; (3) the joiner walk (3.2a) runs
|
|
258
|
+
# immediately after that, extending across any adjacent WORD_JOINER run, bounds-checked
|
|
259
|
+
# again; (4) the crossed-joiner test is evaluated against the POST-WALK left_cp/right_cp;
|
|
260
|
+
# (5) the BREAK check reads the POST-WALK neighbours too; (6) the isolation guard; then
|
|
261
|
+
# (7) the cluster guard. Evaluating BREAK/isolation/cluster before the joiner walk is a
|
|
262
|
+
# known bug class (it diverged from the JS reference implementation during an earlier port
|
|
263
|
+
# and had to be fixed): a joiner-adjacent BREAK or space-like character must be read past
|
|
264
|
+
# the joiner, not at it.
|
|
265
|
+
def find_tokens(cp)
|
|
266
|
+
n = cp.length
|
|
267
|
+
tokens = []
|
|
268
|
+
i = 0
|
|
269
|
+
|
|
270
|
+
while i < n
|
|
271
|
+
unless dash?(cp[i])
|
|
272
|
+
i += 1
|
|
273
|
+
next
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
s = i
|
|
277
|
+
e = s
|
|
278
|
+
e += 1 while e < n && dash?(cp[e])
|
|
279
|
+
i = e
|
|
280
|
+
|
|
281
|
+
# dashes.md 3.2 step 2 -- a run longer than three is decoration, not a dash.
|
|
282
|
+
next if e - s > 3
|
|
283
|
+
|
|
284
|
+
lsp = s.positive? && cp[s - 1] == SPACE ? 1 : 0
|
|
285
|
+
rsp = e < n && cp[e] == SPACE ? 1 : 0
|
|
286
|
+
|
|
287
|
+
# dashes.md 3.2 step 4 -- symmetry guard.
|
|
288
|
+
next if lsp != rsp
|
|
289
|
+
|
|
290
|
+
# dashes.md 3.2 step 5 -- content on both sides (array-bounds half; BREAK is checked
|
|
291
|
+
# below, after the joiner walk, against the post-walk neighbour).
|
|
292
|
+
left = s - 1 - lsp
|
|
293
|
+
right = e + rsp
|
|
294
|
+
next if left.negative? || right >= n
|
|
295
|
+
|
|
296
|
+
# dashes.md 3.2a -- joiner neighbours. This walk, and everything that reads its
|
|
297
|
+
# result, must run before the BREAK/isolation/cluster guards below: those guards read
|
|
298
|
+
# the effective (post-walk) neighbour, not the raw one.
|
|
299
|
+
join_start = left + 1
|
|
300
|
+
join_end = right
|
|
301
|
+
left -= 1 while left >= 0 && cp[left] == WORD_JOINER
|
|
302
|
+
right += 1 while right < n && cp[right] == WORD_JOINER
|
|
303
|
+
next if left.negative? || right >= n
|
|
304
|
+
|
|
305
|
+
crossed_joiner = (left + 1 != join_start) || (right != join_end)
|
|
306
|
+
join_start = left + 1
|
|
307
|
+
join_end = right
|
|
308
|
+
|
|
309
|
+
left_cp = cp[left]
|
|
310
|
+
right_cp = cp[right]
|
|
311
|
+
|
|
312
|
+
next if crossed_joiner && !(digit?(left_cp) && digit?(right_cp))
|
|
313
|
+
next if break?(left_cp) || break?(right_cp)
|
|
314
|
+
|
|
315
|
+
# dashes.md 3.2 step 6 -- isolation guard.
|
|
316
|
+
next if inert_dash?(left_cp) || inert_dash?(right_cp)
|
|
317
|
+
next if dash?(left_cp) || dash?(right_cp)
|
|
318
|
+
next if left_cp == SPACE || no_break_space?(left_cp)
|
|
319
|
+
next if right_cp == SPACE || no_break_space?(right_cp)
|
|
320
|
+
|
|
321
|
+
# dashes.md 3.2 step 7 -- cluster guard.
|
|
322
|
+
next if cluster_inert?(cp, s, e)
|
|
323
|
+
|
|
324
|
+
span_start = [s - lsp, join_start].min
|
|
325
|
+
span_end = [e + rsp, join_end].max
|
|
326
|
+
|
|
327
|
+
tokens << DashToken.new(
|
|
328
|
+
s: s, e: e,
|
|
329
|
+
lsp: lsp, rsp: rsp,
|
|
330
|
+
left: left, right: right,
|
|
331
|
+
left_cp: left_cp, right_cp: right_cp,
|
|
332
|
+
span_start: span_start, span_end: span_end,
|
|
333
|
+
crossed_joiner: crossed_joiner
|
|
334
|
+
)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
tokens
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dash_shared"
|
|
4
|
+
require_relative "../edits"
|
|
5
|
+
require_relative "../registry"
|
|
6
|
+
require_relative "../unicode_util"
|
|
7
|
+
|
|
8
|
+
module Polytypo
|
|
9
|
+
module Engine
|
|
10
|
+
module Rules
|
|
11
|
+
# `dashes` -- spec/rules/dashes.md, order 30. Parenthetical-dash processing only, as of spec
|
|
12
|
+
# 0.5.0: numeric/date-range recognition moved to the `ranges` rule (order 25, off by
|
|
13
|
+
# default), which owns `dash.range` and shares this rule's token-scanning and guard
|
|
14
|
+
# machinery via DashShared. See dashes.md 1 and 7.11, and ranges.md 1, for why the split
|
|
15
|
+
# happened and why range detection is opt-in rather than fixed structurally.
|
|
16
|
+
#
|
|
17
|
+
# A digit-flanked dash token is declined here unconditionally -- never reinterpreted as a
|
|
18
|
+
# parenthetical dash -- regardless of whether `ranges` is enabled (operator decision, spec
|
|
19
|
+
# 0.5.0). That was already true of every prior spec version: the range/parenthetical
|
|
20
|
+
# branches have always been mutually exclusive per token, on the same "both flanks DIGIT"
|
|
21
|
+
# test that now decides which rule a token belongs to rather than which branch of one rule
|
|
22
|
+
# it takes.
|
|
23
|
+
#
|
|
24
|
+
# Explicit index-based scanning only: no regex anywhere, and every index addresses the
|
|
25
|
+
# code-point array, never a native string (ARCHITECTURE.md 4.1, 4.2).
|
|
26
|
+
module Dashes
|
|
27
|
+
ROMAN_I = 0x49
|
|
28
|
+
ROMAN_V = 0x56
|
|
29
|
+
ROMAN_X = 0x58
|
|
30
|
+
ROMAN_L = 0x4c
|
|
31
|
+
ROMAN_C = 0x43
|
|
32
|
+
ROMAN_D = 0x44
|
|
33
|
+
ROMAN_M = 0x4d
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# dashes.md 3.1 ROMAN: the seven uppercase Roman-numeral letters only. Lower-case forms
|
|
38
|
+
# are not members -- see dashes.md 3.4 P4.
|
|
39
|
+
def roman?(cp)
|
|
40
|
+
cp == ROMAN_I || cp == ROMAN_V || cp == ROMAN_X || cp == ROMAN_L || cp == ROMAN_C ||
|
|
41
|
+
cp == ROMAN_D || cp == ROMAN_M
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# dashes.md 3.4 P4 -- the Roman-numeral veto. A tight dash between two word-bounded ROMAN
|
|
45
|
+
# runs is a range already in its correct Russian form (`в XV—XVII веках`); `ranges` cannot
|
|
46
|
+
# see it, because ranges.md 3.2 needs a DIGIT on each side, so without this the
|
|
47
|
+
# parenthetical branch would space out input that was already right.
|
|
48
|
+
#
|
|
49
|
+
# A veto only: it never converts. Admitting ROMAN runs as range candidates would also fix
|
|
50
|
+
# `XV-XVII`, but it fires on all-caps words built from the same letters (`MIX`, `CIVIL`),
|
|
51
|
+
# and converting is the direction that damages -- see dashes.md 7.10.
|
|
52
|
+
def roman_flanked?(cp, left, right)
|
|
53
|
+
n = cp.length
|
|
54
|
+
|
|
55
|
+
return false unless roman?(cp[left]) && roman?(cp[right])
|
|
56
|
+
|
|
57
|
+
a = left
|
|
58
|
+
a -= 1 while a.positive? && roman?(cp[a - 1])
|
|
59
|
+
return false if a.positive? && Polytypo::Engine::UnicodeUtil.letter?(cp[a - 1])
|
|
60
|
+
|
|
61
|
+
b = right
|
|
62
|
+
b += 1 while b + 1 < n && roman?(cp[b + 1])
|
|
63
|
+
return false if b + 1 < n && Polytypo::Engine::UnicodeUtil.letter?(cp[b + 1])
|
|
64
|
+
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def scan(cp, locale_data, _ctx)
|
|
69
|
+
edits = []
|
|
70
|
+
style = locale_data["dash"]["parenthetical"]
|
|
71
|
+
|
|
72
|
+
DashShared.find_tokens(cp).each do |token|
|
|
73
|
+
# A digit-flanked token is `ranges`' territory, never `dashes`' -- declined
|
|
74
|
+
# unconditionally, whether or not `ranges` is enabled (operator decision, spec 0.5.0).
|
|
75
|
+
next if DashShared.digit?(token.left_cp) && DashShared.digit?(token.right_cp)
|
|
76
|
+
|
|
77
|
+
# dashes.md 3.4 P5 -- authored en-dash mark-identity veto (spec 0.6.0). A run
|
|
78
|
+
# consisting of exactly one U+2013 is declined unconditionally: every locale, tight or
|
|
79
|
+
# spaced, regardless of dash.parenthetical's target glyph.
|
|
80
|
+
next if token.e - token.s == 1 && cp[token.s] == DashShared::EN_DASH
|
|
81
|
+
|
|
82
|
+
# dashes.md 3.4 P1 -- a bare hyphen-shaped stroke must be spaced (the compound-word
|
|
83
|
+
# guard): well-known, e-mail, Jean-Luc, well-being, and their U+2010/U+2212 spellings.
|
|
84
|
+
if token.e - token.s == 1 &&
|
|
85
|
+
[DashShared::HYPHEN_MINUS, DashShared::HYPHEN, DashShared::MINUS_SIGN].include?(cp[token.s]) &&
|
|
86
|
+
token.lsp.zero?
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# dashes.md 3.4 P4 -- Roman-numeral veto.
|
|
91
|
+
next if token.lsp.zero? && token.rsp.zero? && roman_flanked?(cp, token.left, token.right)
|
|
92
|
+
|
|
93
|
+
# "none": the locale has no verified convention, so nothing is substituted.
|
|
94
|
+
next if style == "none"
|
|
95
|
+
|
|
96
|
+
if DashShared.spaced_style?(style)
|
|
97
|
+
# T1: a tight token may not become spaced across a digit run that has a far dash.
|
|
98
|
+
next if token.lsp.zero? && token.rsp.zero? &&
|
|
99
|
+
DashShared.spacing_transition_blocked?(cp, token.left, token.right)
|
|
100
|
+
|
|
101
|
+
# T2: the emitted U+0020 must not land where `spaces` (order 10) would delete it.
|
|
102
|
+
next if DashShared.strip_before_or_close_bracket?(token.right_cp)
|
|
103
|
+
next if DashShared.open_bracket?(token.left_cp)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# `dashes` never binds: an interrupting dash is exactly where a line may break
|
|
107
|
+
# (dashes.md 3.3.1's binding is `ranges`-only). Every token this rule accepts has
|
|
108
|
+
# crossed_joiner=false (find_tokens never returns a crossed-joiner, non-digit-flanked
|
|
109
|
+
# token), so the plain s-lsp/e+rsp span is always exactly the token's own span here.
|
|
110
|
+
replacement = DashShared.build_replacement(style, false)
|
|
111
|
+
span_start = token.s - token.lsp
|
|
112
|
+
span_end = token.e + token.rsp
|
|
113
|
+
next if DashShared.same_content?(cp, span_start, span_end, replacement)
|
|
114
|
+
|
|
115
|
+
edits << Polytypo::Engine::Edit.new(span_start, span_end, replacement, "dashes")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
edits
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
Polytypo::Engine::Registry.register("dashes", ->(cp, locale_data, ctx) { Dashes.scan(cp, locale_data, ctx) })
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../edits"
|
|
4
|
+
require_relative "../sentinels"
|
|
5
|
+
require_relative "../registry"
|
|
6
|
+
|
|
7
|
+
module Polytypo
|
|
8
|
+
module Engine
|
|
9
|
+
module Rules
|
|
10
|
+
# spec/rules/ellipsis.md. No regex anywhere (ARCHITECTURE.md section 4.1): a single
|
|
11
|
+
# left-to-right scan over the code-point array, deciding each maximal DOTLIKE run as a
|
|
12
|
+
# pure function of the run itself and the one code point to its left.
|
|
13
|
+
module Ellipsis
|
|
14
|
+
DOT = 0x2E
|
|
15
|
+
ELL = 0x2026
|
|
16
|
+
EXCLAMATION = 0x21
|
|
17
|
+
QUESTION = 0x3F
|
|
18
|
+
|
|
19
|
+
# cp[i], or NONE if i is out of bounds -- the spec's own boundary value.
|
|
20
|
+
def self.at(cp, i)
|
|
21
|
+
return NONE if i.negative? || i >= cp.length
|
|
22
|
+
|
|
23
|
+
cp[i]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.dotlike?(value)
|
|
27
|
+
value == DOT || value == ELL
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.terminal?(value)
|
|
31
|
+
value == EXCLAMATION || value == QUESTION
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether cp[s...e] is already exactly target, so a would-be no-op edit is never
|
|
35
|
+
# emitted.
|
|
36
|
+
def self.same_run?(cp, s, e, target)
|
|
37
|
+
return false if e - s != target.length
|
|
38
|
+
|
|
39
|
+
target.each_with_index do |want, j|
|
|
40
|
+
return false if at(cp, s + j) != want
|
|
41
|
+
end
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ellipsis.md 3.3 steps 4-6. nil means "emit nothing"; otherwise the final form of the
|
|
46
|
+
# whole run.
|
|
47
|
+
def self.run_target(k, q, left, abbreviated)
|
|
48
|
+
if k == 2 && q.zero?
|
|
49
|
+
# The two-dot run is unconditionally inert where "?.." is the correct output form;
|
|
50
|
+
# that is what stops "?.." <-> "?…" from oscillating (ellipsis.md 5).
|
|
51
|
+
return nil if abbreviated
|
|
52
|
+
return [ELL] if terminal?(left)
|
|
53
|
+
|
|
54
|
+
return nil
|
|
55
|
+
end
|
|
56
|
+
return nil if k == 1 && q.zero?
|
|
57
|
+
|
|
58
|
+
# k = 1 with an existing U+2026, or any run of 2+ containing one: normalise, then
|
|
59
|
+
# decide the abbreviated form on the same span (ellipsis.md 3.3 step 6).
|
|
60
|
+
return [DOT, DOT] if abbreviated && terminal?(left)
|
|
61
|
+
|
|
62
|
+
[ELL]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.scan(cp, locale_data, _ctx)
|
|
66
|
+
n = cp.length
|
|
67
|
+
abbreviated = locale_data["ellipsis"]["abbreviatedAfterTerminal"]
|
|
68
|
+
edits = []
|
|
69
|
+
i = 0
|
|
70
|
+
|
|
71
|
+
while i < n
|
|
72
|
+
unless dotlike?(at(cp, i))
|
|
73
|
+
i += 1
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
s = i
|
|
78
|
+
e = s
|
|
79
|
+
q = 0
|
|
80
|
+
while e < n && dotlike?(at(cp, e))
|
|
81
|
+
q += 1 if at(cp, e) == ELL
|
|
82
|
+
e += 1
|
|
83
|
+
end
|
|
84
|
+
k = e - s
|
|
85
|
+
left = at(cp, s - 1)
|
|
86
|
+
|
|
87
|
+
# One decision per run, one edit per run (ellipsis.md 3.3 step 6).
|
|
88
|
+
target = run_target(k, q, left, abbreviated)
|
|
89
|
+
edits << Edit.new(s, e, target, "ellipsis") if !target.nil? && !same_run?(cp, s, e, target)
|
|
90
|
+
i = e
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
edits
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Registry.register("ellipsis", ->(cp, locale_data, ctx) { Rules::Ellipsis.scan(cp, locale_data, ctx) })
|
|
99
|
+
end
|
|
100
|
+
end
|