polytypo 1.2.0 → 1.3.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -1
  3. data/lib/polytypo/data/VERSION +1 -1
  4. data/lib/polytypo/data/fixtures/cs.json +161 -0
  5. data/lib/polytypo/data/fixtures/de-CH.json +1 -1
  6. data/lib/polytypo/data/fixtures/de-DE.json +195 -6
  7. data/lib/polytypo/data/fixtures/el.json +1 -1
  8. data/lib/polytypo/data/fixtures/en-GB.json +12 -1
  9. data/lib/polytypo/data/fixtures/en-US.json +626 -1
  10. data/lib/polytypo/data/fixtures/es.json +193 -0
  11. data/lib/polytypo/data/fixtures/fi.json +1 -1
  12. data/lib/polytypo/data/fixtures/fr-CA.json +25 -1
  13. data/lib/polytypo/data/fixtures/fr.json +176 -1
  14. data/lib/polytypo/data/fixtures/it.json +161 -0
  15. data/lib/polytypo/data/fixtures/locale-resolution.json +76 -4
  16. data/lib/polytypo/data/fixtures/nl.json +121 -0
  17. data/lib/polytypo/data/fixtures/pl.json +137 -0
  18. data/lib/polytypo/data/fixtures/pt-BR.json +156 -0
  19. data/lib/polytypo/data/fixtures/pt-PT.json +156 -0
  20. data/lib/polytypo/data/fixtures/ru.json +23 -1
  21. data/lib/polytypo/data/fixtures/sv.json +1 -1
  22. data/lib/polytypo/data/fixtures/uk.json +153 -0
  23. data/lib/polytypo/data/locales/cs.json +90 -0
  24. data/lib/polytypo/data/locales/de-DE.json +7 -2
  25. data/lib/polytypo/data/locales/en-US.json +3 -3
  26. data/lib/polytypo/data/locales/es.json +111 -0
  27. data/lib/polytypo/data/locales/fr-CA.json +7 -1
  28. data/lib/polytypo/data/locales/fr.json +7 -1
  29. data/lib/polytypo/data/locales/it.json +95 -0
  30. data/lib/polytypo/data/locales/nl.json +84 -0
  31. data/lib/polytypo/data/locales/pl.json +96 -0
  32. data/lib/polytypo/data/locales/pt-BR.json +82 -0
  33. data/lib/polytypo/data/locales/pt-PT.json +84 -0
  34. data/lib/polytypo/data/locales/registry.json +23 -3
  35. data/lib/polytypo/data/locales/ru.json +2 -2
  36. data/lib/polytypo/data/locales/uk.json +130 -0
  37. data/lib/polytypo/data/rules/analyze.md +157 -0
  38. data/lib/polytypo/data/rules/apostrophe.md +432 -0
  39. data/lib/polytypo/data/rules/dashes.md +128 -37
  40. data/lib/polytypo/data/rules/ellipsis.md +271 -0
  41. data/lib/polytypo/data/rules/hyphen.md +353 -0
  42. data/lib/polytypo/data/rules/locale-resolution.md +239 -0
  43. data/lib/polytypo/data/rules/modes.md +1281 -0
  44. data/lib/polytypo/data/rules/nbsp.md +1157 -0
  45. data/lib/polytypo/data/rules/order.json +11 -11
  46. data/lib/polytypo/data/rules/pipeline-idempotency.md +605 -0
  47. data/lib/polytypo/data/rules/quotes.md +1324 -0
  48. data/lib/polytypo/data/rules/ranges.md +489 -0
  49. data/lib/polytypo/data/rules/spaces.md +649 -0
  50. data/lib/polytypo/data/rules/symbols.md +540 -0
  51. data/lib/polytypo/data/schema/fixtures.schema.json +18 -3
  52. data/lib/polytypo/engine/origin.rb +75 -0
  53. data/lib/polytypo/engine/pipeline.rb +72 -1
  54. data/lib/polytypo/engine/rules/dash_shared.rb +85 -3
  55. data/lib/polytypo/engine/rules/dashes.rb +4 -1
  56. data/lib/polytypo/engine/rules/nbsp.rb +43 -7
  57. data/lib/polytypo/engine/rules/ranges.rb +24 -20
  58. data/lib/polytypo/errors.rb +3 -0
  59. data/lib/polytypo/modes/runner.rb +17 -0
  60. data/lib/polytypo/modes/spans.rb +30 -2
  61. data/lib/polytypo/modes/yaml.rb +312 -0
  62. data/lib/polytypo/version.rb +1 -1
  63. data/lib/polytypo.rb +126 -15
  64. metadata +31 -1
@@ -0,0 +1,432 @@
1
+ # Rule: `apostrophe`
2
+
3
+ **Order:** 50. **Default:** on. **Modes:** text, html, markdown, yaml.
4
+ **Spec version:** 1.2.0 (0.4.1 for everything except §2, §3.4 and the §6/§7 updates for the
5
+ withdrawal of the shared ambiguity preserve set (1.1.0), and §3.1's `OPENQUOTE` with §3.3's case 3a
6
+ (1.2.0)).
7
+
8
+ ---
9
+
10
+ ## 1. Purpose
11
+
12
+ `apostrophe` converts a straight U+0027 (') to U+2019 (’) where it is genuinely an
13
+ apostrophe: a contraction (`don't`), an elision (`l'été`, `’tis`), a possessive
14
+ (`the dogs' bowls`), or a decade elision (`’90s`). It runs immediately after `quotes`
15
+ (order 40) and sees only the U+0027 marks that `quotes` declined to claim, which is the whole
16
+ reason the two rules are separate and ordered: quotation resolution needs global information
17
+ (pairing across a paragraph), apostrophe resolution needs only local information (two
18
+ neighbours), and mixing them produces a rule that is neither provable nor portable. Every
19
+ edit is one code point replacing one code point. The rule never inserts, never deletes, and
20
+ never touches U+2019 itself.
21
+
22
+ **Spec 1.1.0 restores this rule to a pure two-neighbour decision.** Spec 0.5.0 had it skip a small
23
+ set of positions entirely, before reading their neighbours; that set is withdrawn — see §3.4 —
24
+ and every candidate is again decided from exactly two neighbouring code points (§3.3), with no
25
+ exception.
26
+
27
+ ---
28
+
29
+ ## 2. Locale data consumed
30
+
31
+ **None, as of spec 1.1.0.** `order.json` declares `"localeData": []` for this rule, as it did
32
+ before 0.5.0. §3.3's case ladder is structural, not lexical, and reads no locale data; §3.4's
33
+ preserve set — the only thing that ever did, reaching `quotes.elisionIdioms` indirectly through
34
+ the shared predicate — is withdrawn.
35
+
36
+ Spec 0.5.0 declared `"localeData": ["quotes"]` here because its preserve set had to tell an
37
+ idiom-authorized position (left alone by `quotes`, but meant to be curled by this rule) apart from
38
+ an ambiguous-but-uncited one (left alone by `quotes`, and meant to be left alone here too).
39
+ Spec 1.1.0 removes that distinction at the source: `quotes` now vetoes both kinds of position for
40
+ the same reason and with the same intent — that this rule convert them — so there is nothing left
41
+ for this rule to look up.
42
+
43
+ ---
44
+
45
+ ## 3. Algorithm
46
+
47
+ Input is a code-point array `cp[0 … n-1]`.
48
+
49
+ ### 3.1 Character classes
50
+
51
+ | Class | Members |
52
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
53
+ | `SQ` | U+0027 (apostrophe) — the only candidate character |
54
+ | `DIGIT` | U+0030–U+0039 |
55
+ | `LETTER` | general category `Lu`, `Ll`, `Lt`, `Lm`, `Lo`, `Mn`, `Mc`, `Me` (combining marks count as letter-continuation, so `l'e` + U+0301 behaves like `l'é`) |
56
+ | `ALNUM` | `LETTER` ∪ `DIGIT` |
57
+ | `SPACELIKE` | U+0020, U+0009, U+00A0, U+202F, U+2007, U+2009, U+200A, and every member of `BREAK` |
58
+ | `BREAK` | U+000A, U+000D, U+000B, U+000C, U+0085, U+2028, U+2029 |
59
+ | `OPENISH` | U+0028 `(` U+005B `[` U+007B `{` U+00AB `«` U+2018 U+201A U+201B U+201C U+201E U+201F U+2039 `‹`, U+002D, U+2011, U+2013, U+2014 |
60
+ | `CLOSEISH` | U+0029 `)` U+005D `]` U+007D `}` U+00BB `»` U+2019 U+201D U+203A `›` U+002C U+002E U+003B U+003A U+0021 U+003F U+2026, U+002D, U+2011, U+2013, U+2014 |
61
+ | `OPENQUOTE` | U+00AB `«` U+2018 U+201A U+201B U+201C U+201E U+201F U+2039 `‹` — the quotation glyphs of `OPENISH`, without its brackets and dashes (spec 1.2.0, case 3a) |
62
+ | `NONE` | index out of range |
63
+
64
+ **Unicode version.** The general categories this rule reads are those of the UCD version pinned in `spec/UNICODE` (`17.0`). The pin is normative for the **derived tables**, not for the host runtime — see [pipeline-idempotency.md](pipeline-idempotency.md) §6a, which also specifies the canary fixtures that make it detectable.
65
+
66
+ Note that U+2019 is a member of `CLOSEISH` and U+0027 is a member of neither. This matters for
67
+ idempotency and is argued in §5. U+2011 is listed alongside U+002D because `hyphen` (order 35)
68
+ converts one to the other and a class that held only U+002D would make a neighbouring
69
+ apostrophe's verdict depend on whether `hyphen` had already run (`hyphen.md` §3.2).
70
+
71
+ ### 3.2 Ordering dependency
72
+
73
+ `quotes` has already run. Consequently:
74
+
75
+ - Any U+0027 that formed part of a resolved quotation pair is **gone** — it is now a locale
76
+ quote glyph, which is not in `SQ` and is invisible to this rule. A quotation that `quotes`
77
+ resolved can therefore never be corrupted here.
78
+ - Any U+0027 that `quotes` vetoed as medial (letter on both sides) or left unmatched is
79
+ still present and is this rule's input.
80
+
81
+ This rule must not attempt to second-guess `quotes`. If a U+0027 is still here, `quotes`
82
+ decided it is not a quotation mark or could not prove that it was.
83
+
84
+ ### 3.3 Scan
85
+
86
+ Walk `i` from `0` to `n-1`. If `cp[i]` is not in `SQ`, continue. Otherwise compute
87
+
88
+ - `left = cp[i-1]` if `i > 0`, else `NONE`;
89
+ - `right = cp[i+1]` if `i+1 < n`, else `NONE`;
90
+
91
+ and take the **first** matching case:
92
+
93
+ 1. **Prime guard.** If `left` is in `DIGIT` **and** `right` is not in `LETTER` → emit
94
+ nothing. This is `6' 2"`, `55° 40' N`, `x'` in a formula. A foot mark is not an
95
+ apostrophe, and rendering it as U+2019 is a false positive that a reader will notice.
96
+ Placed first so it wins over case 3.
97
+ 2. **Medial apostrophe.** If `left` is in `ALNUM` **and** `right` is in `ALNUM` → emit an
98
+ edit replacing `cp[i]` with U+2019. Covers `don't`, `l'été`, `O'Brien`, `n'est`,
99
+ `1990's`, `d'accord`, `Hawai'i`, `can't`.
100
+ (Note that case 1 has already removed the `digit` + `non-letter` combination, so the
101
+ `DIGIT`-left half of this case only fires for `1990's`-style forms where a letter follows.)
102
+ 3. **Trailing elision or possessive.** If `left` is in `LETTER` **and**
103
+ (`right` is `NONE`, or `right` is in `SPACELIKE`, or `right` is in `CLOSEISH`) → emit an
104
+ edit replacing `cp[i]` with U+2019. Covers `the dogs' bowls`, `les élèves' cahiers`,
105
+ `Jesus'`, `rock 'n'` (the trailing mark).
106
+ 3a. **Elision before a quotation (spec 1.2.0).** If `left` is in `LETTER` **and** `right` is in
107
+ `OPENQUOTE` → emit an edit replacing `cp[i]` with U+2019. Covers `d'« urine »`, `l'“idea”`,
108
+ `dell'‘arte’`, `qu'« il »`. French and Italian put an elided article or conjunction directly
109
+ against a quotation all the time, and until 1.2.0 no case matched it: case 2 needs `ALNUM` on
110
+ the right, case 3 needs `CLOSEISH`, and an opening quotation glyph is `OPENISH` only. A
111
+ straight `"` is in none of this rule's classes, so no case matched it either. By the time the
112
+ mark reaches this rule, `quotes` (order 40) has usually turned `l'"idée"` into `l'«idée»`, so
113
+ the mark arrives beside `«`. This case does not add U+0022 to any class.
114
+
115
+ **This case reads no locale data.** `OPENQUOTE` is a fixed set, like `OPENISH`, and §2 still
116
+ holds. The same set covers the locales in which one of these glyphs closes a quotation.
117
+ `de-DE` closes `„…“` with U+201C, so `„Hans'“` is a trailing possessive before a closing
118
+ quote. Case 3 misses it because U+201C is not in `CLOSEISH`, and case 3a gives the U+2019 that
119
+ case 3 would have given. `fi` and `sv` close with U+201D, which is in `CLOSEISH`, so case 3
120
+ already covered them.
121
+
122
+ **Brackets and dashes are excluded on purpose.** `f'(x)` is a prime on a function name and
123
+ must stay as typed, and a letter followed by U+0027 and a dash is already case 3.
124
+ 4. **Leading elision.** If (`left` is `NONE`, or `left` is in `SPACELIKE`, or `left` is in
125
+ `OPENISH`) **and** (`right` is in `ALNUM`) → emit an edit replacing `cp[i]` with U+2019.
126
+ Covers `’90s`, `’tis`, `’em`, `’cause`, `’n'` (the leading mark), `(’tis)`.
127
+ **The replacement is U+2019 — never U+2018.** A leading elision is a raised comma marking
128
+ removed characters, not an opening quotation mark. Getting this backwards is the single
129
+ most common apostrophe bug in existing tools, and it is visually obvious in a serif face.
130
+ 5. **Otherwise** → emit nothing. This is a U+0027 that is isolated (`a ' b`), doubled (`''`),
131
+ or adjacent to punctuation on both sides. Nothing can be inferred; leave it.
132
+
133
+ The cases are mutually exclusive after the first-match rule and every one of them is decided
134
+ from exactly two neighbouring code points. There is no lookahead beyond one position and no
135
+ state carried between candidates.
136
+
137
+ ### 3.4 The shared ambiguity preserve-set — withdrawn (spec 1.1.0)
138
+
139
+ **This rule computes no preserve set and skips no position.** `computePreserveIndices` is
140
+ withdrawn along with the veto shape that motivated it. Every U+0027 that `quotes` (order 40)
141
+ declines to claim reaches §3.3's case ladder and is decided there, from its two neighbours, with
142
+ no prior filtering — the state of affairs before spec 0.5.0, restored.
143
+
144
+ Spec 0.5.0 had `quotes` decline to pair two straight ASCII marks in an ambiguous medial shape —
145
+ `rock 'n' roll`, `She chose 'A' today` — for every locale without a cited `quotes.elisionIdioms`
146
+ match, *and* preserve them from this rule. The second half was necessary because this rule's
147
+ ladder would otherwise convert them: the leading mark of `rock 'n' roll` has `SPACELIKE` on its
148
+ left and `LETTER` on its right, matching case 4 (leading elision); the trailing mark has `LETTER`
149
+ on its left and `SPACELIKE` on its right, matching case 3 (trailing elision/possessive). Applied
150
+ independently — this rule has no notion that the two marks are a pair, by design (§1) — both
151
+ convert to U+2019, giving `rock ’n’ roll`. Under 0.5.0 that was a defect to be prevented: it
152
+ undid, one rule later and through a different code path, a decision `quotes` had deliberately
153
+ made not to guess.
154
+
155
+ **Spec 1.1.0 makes that same conversion the specified outcome**, so the mechanism that prevented
156
+ it has nothing left to prevent. `quotes`' universal medial-`n` veto (`quotes.md` §3.2) declines
157
+ the pairing precisely so that this rule's cases 4 and 3 will convert both marks, in every locale —
158
+ exactly what the cited `en-US` idiom already did in spec 0.4.0, now generalised. The shape
159
+ predicate (`src/rules/quote-ambiguity.ts`) is now `quotes`' alone; this rule needs no knowledge of
160
+ the veto whatsoever, which is why §2 is back to reading no locale data.
161
+
162
+ **A port must not reintroduce a skip here.** A position `quotes` vetoed is not a position this
163
+ rule may leave alone: leaving it alone is what produces an unconverted `rock 'n' roll`, and the
164
+ conformance fixtures for all ten locales assert the converted form.
165
+
166
+ ### 3.4a Why the prime guard precedes the medial case
167
+
168
+ `1990's` has a digit on the left and the letter `s` on the right; case 1 does not fire
169
+ (`right` is a letter), case 2 does. `6'` has a digit on the left and a space on the right;
170
+ case 1 fires and the mark survives. `6'2"` has a digit on both sides; case 1 fires
171
+ (`right` is a digit, not a letter) and the mark survives — which is right, because that is a
172
+ feet-and-inches measurement, not a contraction.
173
+
174
+ ---
175
+
176
+ ## 4. Must not touch
177
+
178
+ **Scope.** Per [pipeline-idempotency.md](pipeline-idempotency.md) §5.2 each bullet is **[P]** —
179
+ a guarantee of `transform` as a whole — or **[R]** — true of this rule in isolation but capable
180
+ of being falsified by another rule, which is then named.
181
+
182
+ - **[P] U+2019 that is already present.** Not in `SQ`; never examined. Re-processing corrected
183
+ text is a no-op.
184
+ - **[R] A quotation mark `quotes` already resolved.** It is no longer U+0027 (§3.2).
185
+ - **[R] A foot, minute or prime mark:** `6'`, `55° 40'`, `x'`. Case 1.
186
+ _[R], not [P]. `quotes` (R₅) runs first and can pair a prime with a genuine quotation mark
187
+ before this rule ever sees it: in `"6' 2"` the opening `"` is `canOpen`, the `'` after the
188
+ digit is `canClose`, they pair, and the foot mark is emitted as a closing quote glyph. Bare
189
+ `6' 2"` is safe — neither mark finds a partner — but the protection is not a pipeline-level
190
+ one and must not be advertised as such._
191
+ - **[P] An isolated U+0027** with `SPACELIKE` on both sides, or at the very start or end of the
192
+ text unit with `SPACELIKE` on the inner side. Case 5.
193
+ - **[P] `''`** — two adjacent U+0027 (a typewriter double quote, or a LaTeX close-quote idiom).
194
+ Neither has `ALNUM` on the relevant side, so case 5 applies to both.
195
+ - **[P] U+0060 (`), U+00B4 (´), U+02BC (ʼ), U+02B9, U+2032 (′).** None is in `SQ`. In particular
196
+ U+02BC is a *letter* in several orthographies and converting it would be a data mutation.
197
+ Converting U+0060 or U+00B4 is a separate normalisation concern that `order.json` does not
198
+ authorise.
199
+ - **[P] U+0027 inside a skipped region** — attribute values, code spans, fenced code, URLs.
200
+ Removed by the mode adapter (L2). This rule has no code-awareness and must not acquire any:
201
+ `it's` in prose and `'string'` in a Python snippet are indistinguishable to it.
202
+ - **[R] Spacing.** The rule inserts and deletes nothing. _`nbsp` (R₈) changes spacing._
203
+ - **[R] A mark already curled by someone else.** This rule emits U+2019 only in place of U+0027
204
+ (§1), so a medial span typed with real single quotation marks — `rock ’n’ roll`, `rock ‘n’
205
+ roll` — passes through untouched even though `quotes` (R₅) vetoed its pairing (`quotes.md`
206
+ §3.2). That is what makes the converted form a fixed point on the second pipeline pass.
207
+
208
+ Spec 0.5.0 listed an additional **[P]** guarantee here — an ambiguous medial span with no cited
209
+ idiom (`rock 'n' roll` outside `en-US`, `She chose 'A' today`, `They said 'no' yesterday`) stayed
210
+ literal U+0027 forever via §3.4's preserve set. **Withdrawn in 1.1.0**: `She chose 'A' today` is
211
+ now an ordinary quotation resolved by `quotes`, and `rock 'n' roll` is converted here, in every
212
+ locale.
213
+
214
+ ---
215
+
216
+ ## 5. Idempotency argument
217
+
218
+ Every edit replaces one U+0027 with one U+2019 at the same index. U+2019 is not in `SQ`, so
219
+ an edited position is not a candidate on the second run. The candidate set of the second run
220
+ is therefore a subset of the first's: the U+0027 marks that fell through to case 5, plus the
221
+ ones caught by the prime guard in case 1.
222
+
223
+ For each of those, the decision is a pure function of `left` and `right`. The only code
224
+ points this rule changed are former U+0027 marks that became U+2019. So the question is: can
225
+ a surviving candidate's `left` or `right` have changed class in a way that flips its
226
+ outcome?
227
+
228
+ - U+0027 is in **none** of `ALNUM`, `SPACELIKE`, `OPENISH`, `CLOSEISH`, `OPENQUOTE`, `DIGIT`,
229
+ `LETTER`.
230
+ - U+2019 is in `CLOSEISH` and in none of the others.
231
+
232
+ Neither code point is in `OPENQUOTE`, so a neighbour's edit cannot change case 3a's right-test.
233
+ The argument below needs no new branch for it.
234
+
235
+ So a neighbour changing from U+0027 to U+2019 can only _add_ `CLOSEISH` membership. Where
236
+ does `CLOSEISH` appear in the decision? Only in case 3's right-test. So the only possible
237
+ flip is: a candidate `u` whose right neighbour was U+0027 (giving no case-3 match) and is
238
+ now U+2019 (giving a case-3 match), where additionally `u`'s left neighbour is a `LETTER`.
239
+
240
+ Concretely that shape is `LETTER` U+0027 U+0027 — for example `dogs''`. On run 1: the first
241
+ mark has `left` = `s` (letter), `right` = U+0027, which is in none of `NONE`/`SPACELIKE`/
242
+ `CLOSEISH`, so case 3 does not fire, cases 1, 2, 3a and 4 do not fire, and it falls to case 5.
243
+ The second mark has `left` = U+0027 (not `ALNUM`, not `LETTER`, not `SPACELIKE`, not
244
+ `OPENISH`) so no case fires; case 5. **Neither is edited on run 1**, so no U+2019 appears and
245
+ the flip cannot occur. The premise is vacuous.
246
+
247
+ More generally: the flip requires the _right_ neighbour to have been edited, i.e. the right
248
+ neighbour was a U+0027 that matched one of cases 2, 3, 3a, 4. Cases 2 and 4 require `ALNUM` on
249
+ that mark's **left** — but its left is `u`, which is U+0027, not `ALNUM`. Cases 3 and 3a require
250
+ `LETTER` on its left — same contradiction. So the right neighbour of a surviving U+0027 is
251
+ never edited, and no surviving candidate's classification changes.
252
+
253
+ Hence `T(T(x)) = T(x)`.
254
+
255
+ **What had to be fixed.** Two things:
256
+
257
+ 1. **The naive rule "convert every `'` to `’`" is idempotent but wrong** — it destroys foot
258
+ marks and, more importantly, it is what forces a library to conflate quoting with
259
+ apostrophes. The fix is the case ladder with the prime guard first, which costs one extra
260
+ comparison and removes an entire false-positive class.
261
+ 2. **The naive rule "a `'` at the start of a word is an opening single quote, so use U+2018"**
262
+ is the one that corrupts `’90s` into `‘90s`. The fix is structural: `quotes` has already
263
+ had its chance to claim the mark as an opening quotation and declined (§3.2), so by the
264
+ time this rule sees it, the only remaining reading is an elision, and the elision glyph is
265
+ U+2019. The rule therefore never emits U+2018 at all — that code point does not appear
266
+ anywhere in this algorithm.
267
+
268
+ ---
269
+
270
+ ### Composition obligation
271
+
272
+ Per [pipeline-idempotency.md](pipeline-idempotency.md) §5. This rule is **R₆**; the obligation
273
+ runs against `spaces`, `ellipsis`, `dashes`, `hyphen` and `quotes`.
274
+
275
+ **What this rule emits.** One U+2019 replacing one U+0027 at the same index. Nothing else, ever
276
+ — no insertion, no deletion, no length change. Case 3a (spec 1.2.0) changes *which* U+0027 marks
277
+ are replaced, and does not change what is emitted. Every discharge below is written against the
278
+ emission, and `quotes`' V1 already treats every U+0027 as a possible U+2019 (`V1ID`, below), so
279
+ none of them needs re-deriving.
280
+
281
+ **Against `I₁` (`spaces`) and `I₂` (`ellipsis`).** Discharged: no U+0020 and no `DOTLIKE` code
282
+ point is emitted, and every edit is 1:1, so nothing is brought into contact with anything.
283
+
284
+ **Against `I₃` (`dashes`).** Discharged: U+2019 is in none of `DASH`, `INERT-DASH`, `DIGIT`,
285
+ `SPACE` or `NOBREAK-SPACE`, and neither is U+0027, so a dash token adjacent to an edited
286
+ position sees no class change. Spacing is untouched.
287
+
288
+ **Against `I₄` (`hyphen`).** Discharged: neither U+0027 nor U+2019 is in `WORDISH`, so a listed
289
+ form's word boundaries read identically before and after.
290
+
291
+ **Against `I₅` (`quotes`).** The interesting one, and the argument below is spec 0.4.1's — the
292
+ version this section carried through spec 0.3.0–0.4.0 relied on `quotes` 0.1.0's Claim 3
293
+ ("the second run's capabilities are a subset of the first's"), which `quotes.md` §0 and §5
294
+ **withdrew** when mandate 1 made every quote glyph a re-typesetting candidate: under mandate 1 a
295
+ converted mark is a candidate again on the next run, capabilities are not monotone, and no
296
+ subset argument is available at all. Citing a withdrawn claim here was itself a latent defect —
297
+ `I₅` was asserted on grounds that no longer existed, and it went unnoticed until `fast-check`
298
+ found a live counterexample in `de-CH` (`spec/fixtures/de-CH.json`,
299
+ `de-ch-quotes-041-cobug-apostrophe-v1`), because `quotes` in `STRAIGHT`/`OPENISH`
300
+ terms — the classes this section used to reason about — is not how U+0027 vs. U+2019 actually
301
+ matters to `quotes` (0.3.0+): every `QUOTEMARK` (both) is in both `OPENISH` and `CLOSEISH`
302
+ (`quotes.md` §3.1, Lemma A), so **no class test distinguishes them at all**; the one place they
303
+ were ever distinguishable is `quotes`' V1 same-code-point veto, which compares literal code
304
+ points, not classes.
305
+
306
+ `I₅` is now discharged by `quotes.md` §5's Corollary A1, not by this document: `apostrophe`
307
+ emits `E(apostrophe) = {U+2019}` — the only code point it ever writes for a U+0027 — and as of
308
+ `quotes` spec 0.4.1 that substitution is **inert** to every one of `quotes`' capability tests, V1
309
+ included. **`E(apostrophe) = {U+2019}` is not the claim that every U+0027 this rule sees gets
310
+ that treatment** — §3.3's case 1 (prime guard) and case 5 leave a U+0027 unedited, and §5 above
311
+ already relies on that fact for this rule's own idempotency argument. `quotes`' V1 handles this
312
+ by comparing `V1ID(c) = U+2019 if c = U+0027, else c` rather than the raw code point — a
313
+ *conservative* closure, not a claim about which U+0027s actually convert: `quotes` cannot know,
314
+ from its own pass, whether a given U+0027 will reach this rule's converting cases or its
315
+ non-converting ones without re-deriving this rule's verdict against `quotes`' own not-yet-final
316
+ output, which is circular. `V1ID` sidesteps that by treating every U+0027 as *possibly* about to
317
+ become U+2019 regardless of which case will actually apply — see `quotes.md` §3.2/§5 for the full
318
+ argument. **The cost this trades for is one-directional only at the single V1 comparison** (an
319
+ extra veto, never a granted capability there); it is not a claim that `quotes`' final output only
320
+ ever declines more — `quotes`' pass 2/pass 4 are global over the whole candidate list, so an extra
321
+ local veto can indirectly let a *different* pair certify, reassign a pairing, or change what
322
+ `nbsp` inserts downstream, exactly as the reported counterexample itself does. `quotes.md` §5's
323
+ "V1ID empirical audit" is the inspection this actually rests on. This document still owns half the
324
+ discharge — the emission itself (`E(apostrophe) = {U+2019}`, nothing else, established in §1
325
+ above) — but the *proof that the emission is inert* belongs entirely to `quotes.md`, because only
326
+ that document defines the capability tests being
327
+ discharged against. Restating it here in `quotes`' pre-0.4.1 terms is what went stale last time.
328
+
329
+ Note also that a U+0027 adjacent to another U+0027 is vetoed by `quotes` pass 1 and survives to
330
+ this rule, where case 5 leaves both alone — so the pair `''` is stable in both rules and
331
+ neither can perturb the other's view of it.
332
+
333
+ ---
334
+
335
+ ## 6. Worked examples
336
+
337
+ Locale-independent, **except rows 11/11a/11b** (see there): `␣` = U+0020, `⟶` = no change. Inputs
338
+ are shown as they arrive at this rule, i.e. after `quotes` has run.
339
+
340
+ | # | Input | Output | Case | Why |
341
+ | --- | ------------------------------- | ----------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
342
+ | 1 | `don't` | `don’t` | 2 | letters both sides |
343
+ | 2 | `l'été` | `l’été` | 2 | elision; also correct when `é` arrives decomposed as `e` + U+0301, because U+0301 is in `LETTER` |
344
+ | 3 | `the dogs' bowls` | `the dogs’ bowls` | 3 | letter left, space right |
345
+ | 4 | `Back in the '90s` | `Back in the ’90s` | 4 | space left, digit right → U+2019, **not** U+2018 |
346
+ | 5 | `'Tis the season` | `’Tis the season` | 4 | start of text, letter right |
347
+ | 6 | `He is 6' 2" tall.` | ⟶ | 1 | digit left, space right → prime guard |
348
+ | 7 | `6'2"` | ⟶ | 1 | digit left, digit right → prime guard |
349
+ | 8 | `The 1990's were loud` | `The 1990’s were loud` | 2 | digit left but letter right, so case 1 does not fire |
350
+ | 9 | `a ' b` | ⟶ | 5 | nothing inferable |
351
+ | 10 | `“He said ’tis so,” she noted.` | ⟶ | — | the quotation was already resolved by `quotes` and this apostrophe was already U+2019 on a previous run; no U+0027 remains |
352
+ | 11 | `rock 'n' roll` (any locale with an empty `quotes.elisionIdioms`, e.g. `en-GB`, `de-DE`, `fi`, `sv`) | `rock ’n’ roll` | 4, 3 | **as of spec 1.1.0 this rule sees both marks and converts them, in every locale.** `quotes`' universal medial-`n` veto (`quotes.md` §3.2) declines the pairing for exactly that purpose, and this rule's ordinary ladder does the rest — identical to row 11a, which is the point: the cited-idiom path and the universal path now reach the same output by the same two case decisions. **In spec 0.5.0** this row asserted no change at all, because the marks were in §3.4's preserve set; that set is withdrawn. **Before 0.5.0** the marks never reached this rule — `quotes` paired them as an ordinary quotation, giving `rock ”n” roll` in `fi` and `rock ‘n’ roll` in `en-GB`, the gap `quotes.md` §7 item 8 named |
353
+ | 11a | `rock 'n' roll` (`en-US`, `quotes.elisionIdioms = [{ left: "rock", elided: "n", right: "roll" }]`, spec 0.4.0) | `rock ’n’ roll` | 4, 3 | **unchanged by either 0.5.0 or 1.1.0** — the one row whose behaviour has been constant since 0.4.0, and the model the other nine locales were brought into line with. `quotes`' listed elision veto declines to pair the marks, and this rule's ordinary case ladder sees both — leading mark takes case 4 (space left, letter right), trailing mark takes case 3 (letter left, space right), independently. Two edits, no coordination between them: this rule still has no notion of "the two marks are a pair" |
354
+ | 11b | `The letter 'n' is common.` (`en-US`) | `The letter ’n’ is common.` | 4, 3 | **`quotes`' idiom match does not fire here** (no `left`/`right` context matches "rock"/"roll"), but the universal medial-`n` veto does, so both marks reach this rule and both convert. **This is spec 1.1.0's accepted false positive**, stated in full at `quotes.md` §3.2 and pinned at `quotes.md` §6 row N1: a genuine quotation of the letter *n* comes out as an elision. It is the exact input that made a first, context-free design (a bare `elisionForms` word list) unsafe in 0.4.0; 1.1.0 accepts the cost deliberately, against a measured alternative, rather than by overlooking it — `quotes.md` §7 item 8 records the comparison |
355
+ | 12 | `dogs''` | ⟶ | 5, 5 | neither mark has the neighbour class any converting case requires |
356
+ | 13 | `O'Brien's` | `O’Brien’s` | 2, 2 | two independent medial marks |
357
+ | 14 | `Ma'am, it's 5 o'clock` | `Ma’am, it’s 5 o’clock` | 2 ×3 | |
358
+ | 15 | `d'«urine»` | `d’«urine»` | 3a | letter left, opening quotation glyph right. **Spec 1.2.0**; previously case 5 left it straight |
359
+ | 16 | `l'“idea”` | `l’“idea”` | 3a | same, with U+201C |
360
+ | 17 | `„Hans'“` | `„Hans’“` | 3a | U+201C closes in `de-DE`, and 3a does not need to know that: a letter followed by U+0027 and a quotation glyph is an elision or a possessive either way |
361
+ | 18 | `f'(x) = 2` | ⟶ | 5 | `(` is not in `OPENQUOTE`. A prime on a function name is not an apostrophe |
362
+
363
+ Cases 6, 7, 9, 10, 12 and 18 are "no change" cases.
364
+
365
+ ---
366
+
367
+ ## 7. Open questions
368
+
369
+ 1. **(Closed twice — spec 0.5.0, then differently in spec 1.1.0.) `rock 'n' roll` outside `en-US`
370
+ no longer silently becomes a quotation.** Through spec 0.4.1, `quotes` (order 40) classified the
371
+ two marks as `canOpen`
372
+ and `canClose` for any locale without a cited idiom and paired them at **depth 1**, taking the
373
+ locale's primary glyphs: `rock ”n” roll` in `fi`, `rock ‘n’ roll` in `en-GB`. This rule could
374
+ not fix it — by the time it ran, a mark `quotes` had paired was no longer U+0027 — and the
375
+ schema-data gap this item originally recorded was that only `en-US` had a citable idiom
376
+ (`quotes.md` §7 item 8 explains why: Chicago Manual of Style Online and the American Heritage
377
+ Dictionary attest the spaced English idiom for `en-US`; `en-GB` carries a higher genuine-
378
+ dialogue collision risk since its primary pair is itself the single quote; `fi`/`sv` write the
379
+ loanword's dictionary headword closed up, `rock'n'roll`, not spaced, so a citation for the
380
+ spaced form would be evidenced-inert; the rest were never researched for it).
381
+
382
+ **Spec 0.5.0 closed the gap by no longer guessing at all**: `quotes.md` §3.2's general
383
+ ambiguous-medial-span veto declined to pair *any* ambiguous-shaped span, cited or not, and this
384
+ rule's §3.4 preserve set kept the uncited positions as literal U+0027. The result for
385
+ `en-GB`/`fi`/`sv` and every other uncited locale was the original ASCII text, unchanged — a
386
+ documented false negative in place of an undocumented false positive.
387
+
388
+ **Spec 1.1.0 closes it a third way, and this one is a decision rather than an abstention.** The
389
+ operator ruled on 2026-09-09 that `rock 'n' roll` and `rock'n'roll` are international and take
390
+ U+2019 everywhere, so the veto no longer needs a per-locale citation to justify converting:
391
+ `quotes.md` §3.2's universal medial-`n` veto declines the pairing in all ten locales precisely
392
+ so this rule's cases 4 and 3 will convert both marks. Every locale now behaves as `en-US` did
393
+ from 0.4.0 — row 11 and row 11a have the same output and the same case numbers — and 0.5.0's
394
+ preserve set is withdrawn (§3.4). The evidentiary notes above still govern `elisionIdioms`,
395
+ which is unchanged; they no longer govern this shape.
396
+ 2. **Primes are left straight, not converted to U+2032 / U+2033.** `6'` stays `6'` rather
397
+ than becoming `6′`. Converting it would be defensible, but prime detection has its own
398
+ false-positive profile (a lone `'` after a digit is often just a typo for an apostrophe)
399
+ and there is no `primes` rule in `order.json`. Out of scope; recorded so it is a decision
400
+ rather than an omission.
401
+ 3. **U+0060 (`) and U+00B4 (´) used as apostrophes.** Common in text typed on some keyboard
402
+ layouts and in text pasted from older systems. Converting them is not authorised by
403
+ `order.json` and, for U+00B4, is arguably destructive (it is a real spacing acute in some
404
+ orthographies). Unresolved.
405
+ 4. **Case 5 leaves an isolated `'` visible in the output.** That is deliberate (it is
406
+ recoverable and honest, per `quotes` §3.6), but it means output text can still contain a
407
+ straight mark. A fixture must assert that explicitly so nobody "fixes" it later.
408
+ 5. **`Hawai'i`, `Qur'an`, transliterated Arabic and Hawaiian ʻokina.** Case 2 converts the
409
+ U+0027 to U+2019. The linguistically correct character is often U+02BB (ʻ) or U+02BC (ʼ),
410
+ not U+2019. Distinguishing them requires a word list, which is out of scope for v1 and
411
+ which the schema cannot express. Known wrong-but-conventional behaviour; documented rather
412
+ than hidden.
413
+ 6. **`LETTER` includes combining marks**, which makes decomposed input behave like composed
414
+ input. That is correct for this rule, and it is an assumption about the Unicode general
415
+ category table. **The spec does pin a Unicode version: `spec/UNICODE` contains `17.0`.** An
416
+ earlier revision of this item said no pin existed; it did not when the item was written, and
417
+ the item was not revisited when the file appeared.
418
+
419
+ *(Closed.)* The pin now binds, and it binds to the right thing: `spec/UNICODE` is normative for
420
+ the **derived tables**, not for the host runtime — a host-UCD requirement is unimplementable in
421
+ PHP without `intl` and in Ruby at all, so it is not required. §3.1 of this document and the
422
+ class tables of `dashes`, `hyphen`, `nbsp`, `quotes` and `symbols` all cite it now, which is
423
+ what the file lacked. Detection is by canary fixture, specified in
424
+ [pipeline-idempotency.md](pipeline-idempotency.md) §6a.2 — including the honest note that the
425
+ version-drift canary must be **generated** from a UCD diff rather than hand-picked.
426
+ 7. **(Spec 1.2.0.) Case 3a can curl an unpaired closing single quote.** When a U+0027 with a
427
+ letter on its left and a quotation glyph on its right reaches this rule, `quotes` has already
428
+ declined to pair it. `quotes` pairs every mark it can. So a mark in that position is either an
429
+ elision or an unmatched quotation mark, and this rule cannot tell which. Case 3 has always
430
+ accepted the same trade: it curls an unmatched closing quote before a space. Case 3a extends it
431
+ to a quotation glyph. The issue that motivated it (French and Italian elision before `«` and
432
+ `“`) is common, and the counter-case is a malformed quotation.