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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +109 -0
  4. data/lib/polytypo/data/README.md +20 -0
  5. data/lib/polytypo/data/UNICODE +1 -0
  6. data/lib/polytypo/data/VERSION +1 -0
  7. data/lib/polytypo/data/fixtures/de-CH.json +501 -0
  8. data/lib/polytypo/data/fixtures/de-DE.json +547 -0
  9. data/lib/polytypo/data/fixtures/el.json +239 -0
  10. data/lib/polytypo/data/fixtures/en-GB.json +1274 -0
  11. data/lib/polytypo/data/fixtures/en-US.json +1807 -0
  12. data/lib/polytypo/data/fixtures/fi.json +1306 -0
  13. data/lib/polytypo/data/fixtures/fr-CA.json +268 -0
  14. data/lib/polytypo/data/fixtures/fr.json +603 -0
  15. data/lib/polytypo/data/fixtures/locale-resolution.json +209 -0
  16. data/lib/polytypo/data/fixtures/ru.json +688 -0
  17. data/lib/polytypo/data/fixtures/sv.json +1290 -0
  18. data/lib/polytypo/data/locales/de-CH.json +77 -0
  19. data/lib/polytypo/data/locales/de-DE.json +76 -0
  20. data/lib/polytypo/data/locales/el.json +90 -0
  21. data/lib/polytypo/data/locales/en-GB.json +115 -0
  22. data/lib/polytypo/data/locales/en-US.json +133 -0
  23. data/lib/polytypo/data/locales/fi.json +136 -0
  24. data/lib/polytypo/data/locales/fr-CA.json +78 -0
  25. data/lib/polytypo/data/locales/fr.json +84 -0
  26. data/lib/polytypo/data/locales/registry.json +9 -0
  27. data/lib/polytypo/data/locales/ru.json +112 -0
  28. data/lib/polytypo/data/locales/sv.json +124 -0
  29. data/lib/polytypo/data/rules/dashes.md +1238 -0
  30. data/lib/polytypo/data/rules/order.json +78 -0
  31. data/lib/polytypo/data/schema/fixtures.schema.json +79 -0
  32. data/lib/polytypo/data/schema/locale.schema.json +235 -0
  33. data/lib/polytypo/data/schema/registry.schema.json +29 -0
  34. data/lib/polytypo/data/schema/resolution.schema.json +50 -0
  35. data/lib/polytypo/engine/codepoints.rb +24 -0
  36. data/lib/polytypo/engine/edits.rb +64 -0
  37. data/lib/polytypo/engine/locale.rb +138 -0
  38. data/lib/polytypo/engine/pipeline.rb +61 -0
  39. data/lib/polytypo/engine/registry.rb +47 -0
  40. data/lib/polytypo/engine/rules/apostrophe.rb +127 -0
  41. data/lib/polytypo/engine/rules/dash_shared.rb +342 -0
  42. data/lib/polytypo/engine/rules/dashes.rb +125 -0
  43. data/lib/polytypo/engine/rules/ellipsis.rb +100 -0
  44. data/lib/polytypo/engine/rules/hyphen.rb +207 -0
  45. data/lib/polytypo/engine/rules/nbsp.rb +616 -0
  46. data/lib/polytypo/engine/rules/quote_ambiguity.rb +241 -0
  47. data/lib/polytypo/engine/rules/quotes.rb +420 -0
  48. data/lib/polytypo/engine/rules/ranges.rb +124 -0
  49. data/lib/polytypo/engine/rules/spaces.rb +232 -0
  50. data/lib/polytypo/engine/rules/symbols.rb +291 -0
  51. data/lib/polytypo/engine/rules.rb +19 -0
  52. data/lib/polytypo/engine/sentinels.rb +23 -0
  53. data/lib/polytypo/engine/unicode_util.rb +390 -0
  54. data/lib/polytypo/errors.rb +24 -0
  55. data/lib/polytypo/modes/html.rb +233 -0
  56. data/lib/polytypo/modes/markdown.rb +187 -0
  57. data/lib/polytypo/modes/parse_error.rb +19 -0
  58. data/lib/polytypo/modes/runner.rb +57 -0
  59. data/lib/polytypo/modes/spans.rb +132 -0
  60. data/lib/polytypo/version.rb +5 -0
  61. data/lib/polytypo.rb +91 -0
  62. data/polytypo.gemspec +37 -0
  63. 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