polytypo 1.1.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.
- checksums.yaml +4 -4
- data/README.md +33 -1
- data/lib/polytypo/data/VERSION +1 -1
- data/lib/polytypo/data/fixtures/cs.json +161 -0
- data/lib/polytypo/data/fixtures/de-CH.json +9 -1
- data/lib/polytypo/data/fixtures/de-DE.json +227 -6
- data/lib/polytypo/data/fixtures/el.json +9 -1
- data/lib/polytypo/data/fixtures/en-GB.json +28 -1
- data/lib/polytypo/data/fixtures/en-US.json +690 -1
- data/lib/polytypo/data/fixtures/es.json +193 -0
- data/lib/polytypo/data/fixtures/fi.json +9 -1
- data/lib/polytypo/data/fixtures/fr-CA.json +50 -1
- data/lib/polytypo/data/fixtures/fr.json +282 -1
- data/lib/polytypo/data/fixtures/it.json +161 -0
- data/lib/polytypo/data/fixtures/locale-resolution.json +76 -4
- data/lib/polytypo/data/fixtures/nl.json +121 -0
- data/lib/polytypo/data/fixtures/pl.json +137 -0
- data/lib/polytypo/data/fixtures/pt-BR.json +156 -0
- data/lib/polytypo/data/fixtures/pt-PT.json +156 -0
- data/lib/polytypo/data/fixtures/ru.json +47 -1
- data/lib/polytypo/data/fixtures/sv.json +9 -1
- data/lib/polytypo/data/fixtures/uk.json +153 -0
- data/lib/polytypo/data/locales/cs.json +90 -0
- data/lib/polytypo/data/locales/de-DE.json +7 -2
- data/lib/polytypo/data/locales/en-US.json +3 -3
- data/lib/polytypo/data/locales/es.json +111 -0
- data/lib/polytypo/data/locales/fr-CA.json +7 -1
- data/lib/polytypo/data/locales/fr.json +7 -1
- data/lib/polytypo/data/locales/it.json +95 -0
- data/lib/polytypo/data/locales/nl.json +84 -0
- data/lib/polytypo/data/locales/pl.json +96 -0
- data/lib/polytypo/data/locales/pt-BR.json +82 -0
- data/lib/polytypo/data/locales/pt-PT.json +84 -0
- data/lib/polytypo/data/locales/registry.json +23 -3
- data/lib/polytypo/data/locales/ru.json +2 -2
- data/lib/polytypo/data/locales/uk.json +130 -0
- data/lib/polytypo/data/rules/analyze.md +157 -0
- data/lib/polytypo/data/rules/apostrophe.md +432 -0
- data/lib/polytypo/data/rules/dashes.md +128 -37
- data/lib/polytypo/data/rules/ellipsis.md +271 -0
- data/lib/polytypo/data/rules/hyphen.md +353 -0
- data/lib/polytypo/data/rules/locale-resolution.md +239 -0
- data/lib/polytypo/data/rules/modes.md +1281 -0
- data/lib/polytypo/data/rules/nbsp.md +1157 -0
- data/lib/polytypo/data/rules/order.json +11 -11
- data/lib/polytypo/data/rules/pipeline-idempotency.md +605 -0
- data/lib/polytypo/data/rules/quotes.md +1324 -0
- data/lib/polytypo/data/rules/ranges.md +489 -0
- data/lib/polytypo/data/rules/spaces.md +649 -0
- data/lib/polytypo/data/rules/symbols.md +540 -0
- data/lib/polytypo/data/schema/fixtures.schema.json +18 -3
- data/lib/polytypo/engine/origin.rb +75 -0
- data/lib/polytypo/engine/pipeline.rb +72 -1
- data/lib/polytypo/engine/rules/apostrophe.rb +10 -1
- data/lib/polytypo/engine/rules/dash_shared.rb +85 -3
- data/lib/polytypo/engine/rules/dashes.rb +4 -1
- data/lib/polytypo/engine/rules/nbsp.rb +53 -20
- data/lib/polytypo/engine/rules/ranges.rb +24 -20
- data/lib/polytypo/engine/rules/spaces.rb +8 -1
- data/lib/polytypo/errors.rb +3 -0
- data/lib/polytypo/modes/runner.rb +17 -0
- data/lib/polytypo/modes/spans.rb +30 -2
- data/lib/polytypo/modes/yaml.rb +312 -0
- data/lib/polytypo/version.rb +1 -1
- data/lib/polytypo.rb +126 -15
- metadata +31 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# `analyze` — the reported-edits entry point
|
|
2
|
+
|
|
3
|
+
**Not a rule.** No entry in `spec/rules/order.json`, no locale data of its own, no edits. This
|
|
4
|
+
document specifies a **second public entry point** beside `transform`, which runs the identical
|
|
5
|
+
pipeline and reports what it would do instead of doing it.
|
|
6
|
+
**Spec version:** 1.3.0 (new in 1.3.0).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Why a second function and not an option
|
|
11
|
+
|
|
12
|
+
`transform` returns a string. A `dryRun: true`-style option would make the **return type depend
|
|
13
|
+
on the value of an argument**, and that is not portable across the five runtimes this project
|
|
14
|
+
targets: TypeScript could express it with overloads, but Go's `Transform(string, Options)
|
|
15
|
+
(string, error)` has no room for a second result shape, PHP would have to declare
|
|
16
|
+
`string|array`, and Python `str | list[Change]` — a union every caller must narrow before it can
|
|
17
|
+
use either half. One function, one return type, in all five.
|
|
18
|
+
|
|
19
|
+
So `transform` is untouched and keeps its signature. `analyze` is a sibling:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
transform(input, options) -> string
|
|
23
|
+
analyze(input, options) -> Change[]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Same `options`, same validation, same errors, same purity (`ARCHITECTURE.md` §7: no I/O, no
|
|
27
|
+
clock, no globals, reentrant). Everything §2 of every rule document says about what the pipeline
|
|
28
|
+
does applies unchanged — `analyze` **is** the pipeline; it merely keeps the edits instead of
|
|
29
|
+
discarding them after applying.
|
|
30
|
+
|
|
31
|
+
This is the feature `ARCHITECTURE.md` §7.1 reserved the engine's shape for: *rules produce edits,
|
|
32
|
+
the pipeline applies them*. Nothing in the engine changes to support it.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 2. What a `Change` is
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
Change {
|
|
40
|
+
ruleId a rule id from spec/rules/order.json
|
|
41
|
+
start code-point offset into `input`, inclusive
|
|
42
|
+
end code-point offset into `input`, exclusive
|
|
43
|
+
before the text this rule replaced — empty for a pure insertion
|
|
44
|
+
after the text it replaced it with — empty for a pure deletion
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- **Offsets are code points, never native string indices** (`ARCHITECTURE.md` §4.2). A runtime
|
|
49
|
+
whose strings are UTF-16 must convert; a runtime whose strings are bytes must convert.
|
|
50
|
+
- **Offsets are into `input` exactly as the caller passed it.** In `html`, `markdown` and
|
|
51
|
+
`yaml` mode that means offsets into the **document**, not into the span the rules actually ran
|
|
52
|
+
over. This
|
|
53
|
+
is not a new obligation: [modes.md](modes.md) §4 already defines the output as "the input
|
|
54
|
+
source with a set of disjoint substring replacements applied **at recorded offsets**", and
|
|
55
|
+
those are the offsets meant.
|
|
56
|
+
- `start == end` is a pure insertion; `before` is then empty. `after` empty with `end > start`
|
|
57
|
+
is a pure deletion. Both occur: `nbsp` inserts, `spaces` deletes.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 3. Order
|
|
62
|
+
|
|
63
|
+
Changes are reported in **pipeline order**: rules in the order `spec/rules/order.json` declares,
|
|
64
|
+
and within one rule ascending by `start`. That is the order in which the work actually happened,
|
|
65
|
+
and it is the order a reader needs to understand a result — `spaces` deleting a space and `nbsp`
|
|
66
|
+
putting a no-break one back at the same index is intelligible in that order and baffling in any
|
|
67
|
+
other.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 4. What is contract and what is observation
|
|
72
|
+
|
|
73
|
+
This is the part to read before building anything on top.
|
|
74
|
+
|
|
75
|
+
**Contract, conformance-tested:**
|
|
76
|
+
|
|
77
|
+
- **A1.** `analyze` accepts exactly what `transform` accepts and rejects exactly what it rejects,
|
|
78
|
+
with the same error codes — including `POLYTYPO_MALFORMED_INPUT` for a document that does not
|
|
79
|
+
parse as its declared dialect.
|
|
80
|
+
- **A2.** `analyze` is pure and returns the same list for the same arguments, always.
|
|
81
|
+
- **A3.** The list is **empty if and only if** `transform(input, options) == input`. A document
|
|
82
|
+
that needs nothing produces no changes; a document that produces no changes needs nothing.
|
|
83
|
+
- **A4.** Every `ruleId` is a rule that was **enabled for that call** — a rule turned off through
|
|
84
|
+
`rules`, and `ranges` when it was not turned on, can never appear.
|
|
85
|
+
- **A5.** Every `start` and `end` is within `0 … length(input)` in code points, and
|
|
86
|
+
`start <= end`.
|
|
87
|
+
|
|
88
|
+
**Observation, not conformance-tested:**
|
|
89
|
+
|
|
90
|
+
- **The decomposition itself.** How a runtime splits one visible change into `Change` records —
|
|
91
|
+
one edit or two, where exactly a boundary falls when two rules touch adjacent characters — is
|
|
92
|
+
that runtime's report of its own work. Five runtimes are **not** required to produce
|
|
93
|
+
identical lists, and no fixture asserts one.
|
|
94
|
+
|
|
95
|
+
That line is drawn deliberately. Making the decomposition contract would freeze the internal
|
|
96
|
+
shape of every rule forever: two rules touching adjacent code points would have to agree, across
|
|
97
|
+
five languages, on how many edits that is. The cost is real and the benefit is not — a caller
|
|
98
|
+
wants to know *what changes and which rule did it*, which A1–A5 give.
|
|
99
|
+
|
|
100
|
+
**The output of `transform` remains the only byte-level contract.** If a caller needs the
|
|
101
|
+
transformed text, the way to get it is to call `transform`.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 5. Changes may overlap, and a naive patch does not reconstruct the output
|
|
106
|
+
|
|
107
|
+
The pipeline is sequential: each rule sees the text the previous rules left. Two rules may
|
|
108
|
+
therefore touch the **same original range**, and both changes are reported, both in original
|
|
109
|
+
coordinates.
|
|
110
|
+
|
|
111
|
+
The standing example is French, where `spaces` (order 10) deletes the space before `:` and
|
|
112
|
+
`nbsp` (order 70) inserts U+00A0 at the same place:
|
|
113
|
+
|
|
114
|
+
| | ruleId | start | end | before | after |
|
|
115
|
+
| --- | --- | --- | --- | --- | --- |
|
|
116
|
+
| 1 | `spaces` | 3 | 4 | `␣` | |
|
|
117
|
+
| 2 | `nbsp` | 4 | 4 | | `⍽` |
|
|
118
|
+
|
|
119
|
+
Applying that list to the input as if it were a patch — even in order, even accumulating
|
|
120
|
+
offsets — is **not** guaranteed to reproduce `transform`'s output, and this specification does
|
|
121
|
+
not promise that it does. A consumer that wants the output calls `transform`; a consumer that
|
|
122
|
+
wants to show a reviewer what will change uses the list as the report it is.
|
|
123
|
+
|
|
124
|
+
An implementation **must not** silently merge or drop changes to make the list patchable. A
|
|
125
|
+
report that omits work the pipeline did is worse than one a caller cannot replay.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## 6. Conformance
|
|
130
|
+
|
|
131
|
+
No new fixture format. `spec/fixtures/*.json` keeps its `in`/`out` shape, and `analyze` is not
|
|
132
|
+
expressible in it — a fixture asserting a specific `Change[]` would be asserting the very
|
|
133
|
+
decomposition §4 declines to make contract.
|
|
134
|
+
|
|
135
|
+
Each runtime proves A1–A5 with its own tests, and the two that are cheap to get wrong are worth
|
|
136
|
+
naming:
|
|
137
|
+
|
|
138
|
+
- **A3 against the whole fixture corpus.** For every canonical fixture case, `analyze` returns
|
|
139
|
+
an empty list exactly when `in == out`. That is a strong test and it costs one loop.
|
|
140
|
+
- **A5 under the mode adapters.** A runtime that reports span-local offsets in `html`,
|
|
141
|
+
`markdown` or `yaml` mode passes every text-mode test and is still wrong. Test a document
|
|
142
|
+
whose first span does not start at offset 0. `yaml` is the cheapest of the three to get wrong
|
|
143
|
+
and the cheapest to test: no span in it ever starts at offset 0, since every one of them is
|
|
144
|
+
preceded by at least a key and a colon.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 7. Open questions
|
|
149
|
+
|
|
150
|
+
1. **A contract-level decomposition, if anyone ever needs one.** §4 makes the split an
|
|
151
|
+
observation. If a consumer appears that genuinely needs byte-identical `Change[]` across
|
|
152
|
+
runtimes — a distributed review tool, say, diffing one runtime's report against another's —
|
|
153
|
+
that is a later, larger spec change: it would need a canonical edit-merging rule and fixtures
|
|
154
|
+
in a new format. Nothing in this document forecloses it; A1–A5 stay true either way.
|
|
155
|
+
2. **`analyze` over a document with no spans.** `html` mode on a document that is markup from
|
|
156
|
+
end to end returns an empty list, which A3 already requires, since `transform` returns the
|
|
157
|
+
input unchanged. Recorded because it reads like an edge case and is not one.
|
|
@@ -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.
|