polytypo 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +109 -0
- data/lib/polytypo/data/README.md +20 -0
- data/lib/polytypo/data/UNICODE +1 -0
- data/lib/polytypo/data/VERSION +1 -0
- data/lib/polytypo/data/fixtures/de-CH.json +501 -0
- data/lib/polytypo/data/fixtures/de-DE.json +547 -0
- data/lib/polytypo/data/fixtures/el.json +239 -0
- data/lib/polytypo/data/fixtures/en-GB.json +1274 -0
- data/lib/polytypo/data/fixtures/en-US.json +1807 -0
- data/lib/polytypo/data/fixtures/fi.json +1306 -0
- data/lib/polytypo/data/fixtures/fr-CA.json +268 -0
- data/lib/polytypo/data/fixtures/fr.json +603 -0
- data/lib/polytypo/data/fixtures/locale-resolution.json +209 -0
- data/lib/polytypo/data/fixtures/ru.json +688 -0
- data/lib/polytypo/data/fixtures/sv.json +1290 -0
- data/lib/polytypo/data/locales/de-CH.json +77 -0
- data/lib/polytypo/data/locales/de-DE.json +76 -0
- data/lib/polytypo/data/locales/el.json +90 -0
- data/lib/polytypo/data/locales/en-GB.json +115 -0
- data/lib/polytypo/data/locales/en-US.json +133 -0
- data/lib/polytypo/data/locales/fi.json +136 -0
- data/lib/polytypo/data/locales/fr-CA.json +78 -0
- data/lib/polytypo/data/locales/fr.json +84 -0
- data/lib/polytypo/data/locales/registry.json +9 -0
- data/lib/polytypo/data/locales/ru.json +112 -0
- data/lib/polytypo/data/locales/sv.json +124 -0
- data/lib/polytypo/data/rules/dashes.md +1238 -0
- data/lib/polytypo/data/rules/order.json +78 -0
- data/lib/polytypo/data/schema/fixtures.schema.json +79 -0
- data/lib/polytypo/data/schema/locale.schema.json +235 -0
- data/lib/polytypo/data/schema/registry.schema.json +29 -0
- data/lib/polytypo/data/schema/resolution.schema.json +50 -0
- data/lib/polytypo/engine/codepoints.rb +24 -0
- data/lib/polytypo/engine/edits.rb +64 -0
- data/lib/polytypo/engine/locale.rb +138 -0
- data/lib/polytypo/engine/pipeline.rb +61 -0
- data/lib/polytypo/engine/registry.rb +47 -0
- data/lib/polytypo/engine/rules/apostrophe.rb +127 -0
- data/lib/polytypo/engine/rules/dash_shared.rb +342 -0
- data/lib/polytypo/engine/rules/dashes.rb +125 -0
- data/lib/polytypo/engine/rules/ellipsis.rb +100 -0
- data/lib/polytypo/engine/rules/hyphen.rb +207 -0
- data/lib/polytypo/engine/rules/nbsp.rb +616 -0
- data/lib/polytypo/engine/rules/quote_ambiguity.rb +241 -0
- data/lib/polytypo/engine/rules/quotes.rb +420 -0
- data/lib/polytypo/engine/rules/ranges.rb +124 -0
- data/lib/polytypo/engine/rules/spaces.rb +232 -0
- data/lib/polytypo/engine/rules/symbols.rb +291 -0
- data/lib/polytypo/engine/rules.rb +19 -0
- data/lib/polytypo/engine/sentinels.rb +23 -0
- data/lib/polytypo/engine/unicode_util.rb +390 -0
- data/lib/polytypo/errors.rb +24 -0
- data/lib/polytypo/modes/html.rb +233 -0
- data/lib/polytypo/modes/markdown.rb +187 -0
- data/lib/polytypo/modes/parse_error.rb +19 -0
- data/lib/polytypo/modes/runner.rb +57 -0
- data/lib/polytypo/modes/spans.rb +132 -0
- data/lib/polytypo/version.rb +5 -0
- data/lib/polytypo.rb +91 -0
- data/polytypo.gemspec +37 -0
- metadata +122 -0
|
@@ -0,0 +1,1238 @@
|
|
|
1
|
+
# Rule: `dashes`
|
|
2
|
+
|
|
3
|
+
**Order:** 30. **Default:** on. **Modes:** text, html, markdown.
|
|
4
|
+
**Spec version:** 0.6.0 (0.2.0 for everything except the 0.5.0/0.6.0 changes noted inline and in
|
|
5
|
+
§8 History).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Purpose
|
|
10
|
+
|
|
11
|
+
`dashes` renders the **parenthetical dash** — the one that interrupts a sentence — like this —
|
|
12
|
+
in the form the locale prescribes. **As of spec 0.5.0, this rule no longer recognises the
|
|
13
|
+
numeric/date range dash** (`1914–1918`); that moved to [ranges.md](ranges.md) (order 25, off by
|
|
14
|
+
default).
|
|
15
|
+
|
|
16
|
+
**§1 through §7 below are current and normative for `dashes` alone.** §3.2's shared token-scanning
|
|
17
|
+
steps are consumed identically by `ranges` (`ranges.md` §3.1) and are described here because this
|
|
18
|
+
is their canonical home, not because they are historical. Only **§8, History**, is non-normative:
|
|
19
|
+
it is where the pre-0.5.0 combined rule's development — the guards it took to reach the current
|
|
20
|
+
algorithm, and the range-specific reasoning that used to live here and now lives in `ranges.md` —
|
|
21
|
+
is preserved for readers who want the reasoning trail. Nothing in §1-§7 requires the reader to
|
|
22
|
+
mentally subtract old range-branch statements from current parenthetical ones; where §1-§7
|
|
23
|
+
mentions `ranges` at all, it is describing a current, live interaction between the two rules, not
|
|
24
|
+
a retired one.
|
|
25
|
+
|
|
26
|
+
`dashes` does not touch the ordinary hyphen inside a compound word — which, in the few
|
|
27
|
+
morphological forms where the hyphen must additionally be protected from a line break, belongs
|
|
28
|
+
to `hyphen` at order 35 — and does not touch a digit-flanked stroke at all: that shape belongs to
|
|
29
|
+
`ranges` exclusively, and `dashes` declines it **unconditionally**, whether or not `ranges` is
|
|
30
|
+
enabled (operator decision, spec 0.5.0; see §3.2's note after step 7, and ranges.md §3.2's G1-G5).
|
|
31
|
+
`dashes` never reinterprets a digit-flanked hyphen as a parenthetical dash — that was already true
|
|
32
|
+
in every prior spec version, since the two branches were always mutually exclusive per token; the
|
|
33
|
+
0.5.0 split makes that exclusivity a boundary between two rules instead of two branches of one.
|
|
34
|
+
|
|
35
|
+
The parenthetical form is governed by one enum in the locale file: a dash _length_ (`em` or `en`)
|
|
36
|
+
and a _spacing_ (`tight` or `spaced`), or the opt-out value `none`.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 2. Locale data consumed
|
|
41
|
+
|
|
42
|
+
- `dash.parenthetical` — one of `"em-tight"`, `"em-spaced"`, `"en-tight"`, `"en-spaced"`,
|
|
43
|
+
`"none"`.
|
|
44
|
+
|
|
45
|
+
**As of spec 0.5.0, `dashes` no longer reads `dash.range`** — that field is now read exclusively
|
|
46
|
+
by `ranges` ([ranges.md](ranges.md) §2), under the same key, with the same values and the same
|
|
47
|
+
citations; the 0.5.0 split moved which rule reads the field, not the field itself or what
|
|
48
|
+
supports it (operator decision: no new locale claim was made by the split).
|
|
49
|
+
|
|
50
|
+
**Evidence for this list.** A locale attests **membership** — that these tokens belong in this
|
|
51
|
+
list — and this rule owns the **mechanism** it applies to them. A `sources` citation is not
|
|
52
|
+
required to name a code point or a binding the locale file has no way to vary. The principle is
|
|
53
|
+
stated once, normatively, in [nbsp.md](nbsp.md) §2.1 and governs every list-valued field in
|
|
54
|
+
`locale.schema.json`. **`"none"` means the locale has no verified convention for that use, and
|
|
55
|
+
the rule must emit nothing at all for a token of that kind** — not a fallback, not a "sensible
|
|
56
|
+
default", nothing. A missing citation is never a licence to guess (PLAN.md §6.1), and a locale
|
|
57
|
+
that has not been researched must round-trip its input untouched rather than acquire a
|
|
58
|
+
plausible-looking dash.
|
|
59
|
+
|
|
60
|
+
Nothing else. The choice between a breaking and a no-break space on the left of a spaced
|
|
61
|
+
dash is not made here: this rule always emits U+0020, and `nbsp` (order 70) may later
|
|
62
|
+
promote it.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 3. Algorithm
|
|
67
|
+
|
|
68
|
+
Input is a code-point array `cp[0 … n-1]`.
|
|
69
|
+
|
|
70
|
+
### 3.1 Character classes
|
|
71
|
+
|
|
72
|
+
| Class | Members |
|
|
73
|
+
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
74
|
+
| `DASH` | U+002D (hyphen-minus), U+2010 (hyphen), U+2013 (en dash), U+2014 (em dash), U+2212 (minus sign) — see the note below the table |
|
|
75
|
+
| `DIGIT` | U+0030–U+0039 only. **ASCII digits only** — see [ranges.md](ranges.md) §7 (the limitation matters for `ranges`' digit-value comparisons; `dashes` never reads a digit's value) |
|
|
76
|
+
| `LETTER` | any code point whose Unicode general category is `Lu`, `Ll`, `Lt`, `Lm` or `Lo`, or `Mn`, `Mc`, `Me` (combining marks count as letter-continuation, so a decomposed `é` is treated as one letter followed by a mark) |
|
|
77
|
+
| `SPACE` | U+0020 only |
|
|
78
|
+
| `BREAK` | U+000A, U+000D, U+000B, U+000C, U+0085, U+2028, U+2029 |
|
|
79
|
+
| `NOBREAK-SPACE` | U+00A0, U+202F |
|
|
80
|
+
| `ROMAN` | the seven uppercase Roman-numeral letters only: U+0049 `I`, U+0056 `V`, U+0058 `X`, U+004C `L`, U+0043 `C`, U+0044 `D`, U+004D `M`. Lower-case forms are **not** members — see §3.4 P4 |
|
|
81
|
+
| `INERT-DASH` | U+00AD (soft hyphen), U+2011 (non-breaking hyphen), U+2012 (figure dash), U+2015 (horizontal bar), U+FE58, U+FE63, U+FF0D |
|
|
82
|
+
| `JOINER` | U+2060 (word joiner) only. As of spec 0.5.0, produced exclusively by `ranges` (ranges.md §3.3.1) — `dashes` itself never emits one, but still reads through an adjacent run of them (§3.2a, §3.2b), since a `ranges`-produced joiner can sit next to a `dashes` candidate token |
|
|
83
|
+
|
|
84
|
+
`INERT-DASH` members are **never** candidates and are **never** produced. Two of the seven are
|
|
85
|
+
protective markers owned by someone else — U+00AD is invisible formatting, and U+2011 is
|
|
86
|
+
`hyphen` (order 35)'s own output — and stay excluded on that basis alone. The rest —
|
|
87
|
+
U+2012, U+2015, U+FE58, U+FE63, U+FF0D — are each a *specialised* dash with its own reason to
|
|
88
|
+
exist (digit-width tabular alignment, the dialogue dash some traditions use on purpose, CJK
|
|
89
|
+
compatibility forms), not a substitute for a missing key, and reclassifying one would erase the
|
|
90
|
+
distinction the author reached for that code point to make.
|
|
91
|
+
|
|
92
|
+
**`DASH` beyond U+002D/U+2013/U+2014.** U+2010 (hyphen) and U+2212 (minus sign) are members too
|
|
93
|
+
(added in spec 0.2.0): both are plain typewriter or OCR substitutes for an ordinary hyphen-minus,
|
|
94
|
+
governed by exactly the same rationale as U+002D in step 2 below. U+2013 and U+2014 are ordinary
|
|
95
|
+
`DASH` members too, with **no token-level special case** (spec 0.2.0 retires the guard that used
|
|
96
|
+
to give them one — see step 2a's history note), **with one narrow exception added in spec 0.6.0
|
|
97
|
+
(§3.4 P5): a run consisting of exactly one U+2013 is declined unconditionally, in every locale**.
|
|
98
|
+
Every other mixture of `DASH` glyphs — including a run mixing U+2013 with U+002D/U+2010/U+2212/
|
|
99
|
+
U+2014, and every run containing U+2014 — is still promoted to the locale's form exactly as a
|
|
100
|
+
pure-hyphen run would be. This treats the dash's *length* the same way this rule has always
|
|
101
|
+
treated its spacing — as something to correct, not preserve — on the view that a dash's length in
|
|
102
|
+
ordinary prose is at least as often a copy-paste artefact or plain unfamiliarity with which mark
|
|
103
|
+
is which as it is a deliberate choice, and an author who wants their own dash typography left
|
|
104
|
+
alone has always had the option of not running the pipeline. **P5 is not a retraction of that
|
|
105
|
+
view for dashes in general — it is a narrower, evidence-driven claim about one specific glyph
|
|
106
|
+
used alone: see §3.4 P5 and §8.8.**
|
|
107
|
+
|
|
108
|
+
**Unicode version.** The general categories and case mappings 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 the pin detectable. If an author wrote
|
|
109
|
+
U+2011 they meant it.
|
|
110
|
+
|
|
111
|
+
### 3.2 The dash token
|
|
112
|
+
|
|
113
|
+
1. Scan left to right. At the first index `i` with `cp[i]` in `DASH`, find the maximal run of
|
|
114
|
+
`DASH` members: `s = i`, `e` = first index `> s` not in `DASH`, `k = e - s`.
|
|
115
|
+
2. If `k > 3`, the run is decoration (a horizontal rule, a signature line, a Markdown
|
|
116
|
+
`---` setext underline of arbitrary length). Emit nothing; set `i = e`; continue.
|
|
117
|
+
2a. **(Retired, spec 0.2.0. Number kept — see the note at the end of this step.)** Spec 0.1.0
|
|
118
|
+
had an authored-dash guard here: any run containing U+2013 or U+2014 was declined outright,
|
|
119
|
+
glyph and spacing both, whatever the locale said. It is gone. U+2013 and U+2014 carry no
|
|
120
|
+
token-level special case anywhere in this algorithm; a run holding them is classified and
|
|
121
|
+
replaced exactly as the same run spelled with U+002D would be. Length is corrected along
|
|
122
|
+
with spacing, by the same replacement tables in §3.3 and §3.4.
|
|
123
|
+
|
|
124
|
+
**Full development history — why the guard existed, why it was narrowed once, and why it was
|
|
125
|
+
then retired outright — is §8.1**, kept there because none of it is required to implement the
|
|
126
|
+
current algorithm; the two lines above are the complete current rule.
|
|
127
|
+
|
|
128
|
+
Numbered `2a` rather than renumbered to `3`: six documents cite the guards of this section by
|
|
129
|
+
number, and a step that is retired but still occupies its number costs less than a
|
|
130
|
+
renumbering would.
|
|
131
|
+
|
|
132
|
+
3. Determine the **outer spacing** of the token:
|
|
133
|
+
- `lsp = 1` if `s > 0` and `cp[s-1]` is `SPACE`, else `0`;
|
|
134
|
+
- `rsp = 1` if `e < n` and `cp[e]` is `SPACE`, else `0`.
|
|
135
|
+
After `spaces` (order 10) any space run adjacent to the token has length exactly 1 unless
|
|
136
|
+
it borders a line terminator, so a single-code-point probe is sufficient.
|
|
137
|
+
|
|
138
|
+
**`SPACE` here means U+0020 and nothing else. A `NOBREAK-SPACE` is not this rule's
|
|
139
|
+
spacing.** If `cp[s-1]` or `cp[e]` is U+00A0 or U+202F, that side's `lsp`/`rsp` is `0`, the
|
|
140
|
+
no-break space becomes `cp[L]`/`cp[R]`, and **step 6 then declines the token** because
|
|
141
|
+
`cp[L]`/`cp[R]` is space-like. A dash touching a no-break space is therefore never edited,
|
|
142
|
+
on either side.
|
|
143
|
+
|
|
144
|
+
**This is a principle, not a spacing heuristic, and it replaces two earlier attempts that
|
|
145
|
+
were.** `dashes` normalises _ordinary sentence spacing_, which is U+0020. A no-break space
|
|
146
|
+
beside a dash was put there by somebody else — by the author, or by `nbsp` (order 70), whose
|
|
147
|
+
entire emission alphabet is U+00A0 and U+202F. It is not this rule's to reinterpret, and a
|
|
148
|
+
token that touches one is not this rule's to claim.
|
|
149
|
+
|
|
150
|
+
The consequence is the one that matters: **every code point `nbsp` can emit is inert for
|
|
151
|
+
this rule**, so `nbsp` cannot create a dash token, cannot change one's spacing verdict, and
|
|
152
|
+
cannot revive one this rule declined. That discharges the composition obligation against
|
|
153
|
+
`nbsp` structurally rather than case by case — see
|
|
154
|
+
[pipeline-idempotency.md](pipeline-idempotency.md) §5.1a, where it is generalised as
|
|
155
|
+
condition **CO-S**. The two earlier formulations ("a `NOBREAK-SPACE` counts as spacing",
|
|
156
|
+
then "counts on the left only") each fixed the witness in front of them and left the next
|
|
157
|
+
one reachable; §8.2 defect (d) records how.
|
|
158
|
+
|
|
159
|
+
4. **Symmetry guard.** If `lsp ≠ rsp`, emit nothing and continue. A stroke that is spaced on
|
|
160
|
+
one side and tight on the other is not a dash: it is `--force`, `-5 °C`, a Markdown bullet
|
|
161
|
+
`- item`, a signature `-- Iurii`, or an arrow `->`. This single guard removes the large
|
|
162
|
+
majority of false positives and it applies to both the range and the parenthetical
|
|
163
|
+
branches.
|
|
164
|
+
5. Let `L` be the index of the code point immediately left of the token's outer spacing
|
|
165
|
+
(`s - 1 - lsp`), and `R` the index immediately right (`e + rsp`). If either index is out of
|
|
166
|
+
range, or `cp[L]` is in `BREAK`, or `cp[R]` is in `BREAK`, emit nothing and continue. A
|
|
167
|
+
dash needs content on both sides on the same line. (This is what protects a list item at
|
|
168
|
+
the start of a line and a trailing `--` at the end of one.)
|
|
169
|
+
6. **Isolation guard.** If `cp[L]` or `cp[R]` is in `INERT-DASH`, or in `DASH`, or in
|
|
170
|
+
`SPACE` ∪ `NOBREAK-SPACE`, emit nothing and continue.
|
|
171
|
+
Three separate things are being excluded here and all three are load-bearing:
|
|
172
|
+
- an `INERT-DASH` neighbour — U+2011 is produced by `hyphen` at order 35 from text this rule
|
|
173
|
+
has already seen, and U+2012/U+2015/the fullwidth and small forms are each a specialised
|
|
174
|
+
dash with its own reason to exist (§3.1); U+2010 and U+2212 are **not** in this set as of
|
|
175
|
+
spec 0.2.0 — they are ordinary `DASH` members and fall into the next bullet instead;
|
|
176
|
+
- a `DASH` neighbour — the pattern `dash space dash` (`a- - a`). Without this clause the
|
|
177
|
+
rule normalises the second stroke, the two dash runs become adjacent, and on the next
|
|
178
|
+
run they read as **one** run of length 2 with a completely different verdict. That is
|
|
179
|
+
idempotency defect (b), §8.2;
|
|
180
|
+
- a space-like neighbour — `cp[L]` is by construction the code point _beyond_ the outer
|
|
181
|
+
spacing, so a space there means two consecutive space-like characters (a U+00A0 next to
|
|
182
|
+
a U+0020, say — `spaces` cannot produce two U+0020). The spacing of such a token is not
|
|
183
|
+
something this rule should be rewriting.
|
|
184
|
+
7. **Cluster guard.** Define a **dash cluster** as a maximal span of code points every one of
|
|
185
|
+
which is in `DASH` ∪ `INERT-DASH` ∪ `DIGIT` ∪ `JOINER` (§3.2b — a joiner `ranges` emitted on
|
|
186
|
+
an earlier pass must not split a cluster it sits inside). (Spaces, letters and punctuation all end a
|
|
187
|
+
cluster.) Let `C` be the cluster containing this token's run. **If `C` contains two or more
|
|
188
|
+
maximal runs of `DASH` ∪ `INERT-DASH`, emit nothing for every token in `C`** — the whole
|
|
189
|
+
cluster is inert.
|
|
190
|
+
This covers idempotency defect (a), §8.2: the classification of a range token reads
|
|
191
|
+
`before`/`after`, which may be a dash belonging to a _different_ token, and normalising
|
|
192
|
+
that other token to a spaced form replaces the dash with a space and flips the range
|
|
193
|
+
verdict on the next run.
|
|
194
|
+
`2026-08-15`, `978-3-16-148410-0`, `212-555-1234`, `a—0–0` and `1914-1918—annexation` are
|
|
195
|
+
all single clusters with more than one dash run, and are all inert in their entirety.
|
|
196
|
+
**This guard is not sufficient on its own, and it does not subsume G2.** A cluster ends at
|
|
197
|
+
the first space-like code point, so a _spaced_ token's cluster contains only its own run:
|
|
198
|
+
its `cp[L]` is a digit in a neighbouring cluster and its `before` is a dash in a third one,
|
|
199
|
+
neither of which step 7 can see. That gap is idempotency defect (c), §8.2, and step 8 closes it.
|
|
200
|
+
8. **Spacing-transition guard (T1).** This guard depends on which replacement form the token's
|
|
201
|
+
branch will choose, so it is evaluated **immediately before emitting**, in §3.3 and §3.4
|
|
202
|
+
alike; it is stated here because it is a property of the token, not of the branch.
|
|
203
|
+
|
|
204
|
+
It applies only when **all** of the following hold:
|
|
205
|
+
- `lsp = rsp = 0` — the token is currently tight; and
|
|
206
|
+
- the chosen form is `em-spaced` or `en-spaced` — the replacement will insert a U+0020 on
|
|
207
|
+
each side.
|
|
208
|
+
|
|
209
|
+
In that case, for each side independently:
|
|
210
|
+
- **left:** if `cp[L]` is in `DIGIT`, let `D` be the maximal `DIGIT` run ending at `L` and
|
|
211
|
+
starting at index `d`. Reading **effective neighbours** (§3.2b) outward from `d`: if the
|
|
212
|
+
first is in `DASH` ∪ `INERT-DASH`, **or** the first is in `SPACE` ∪ `NOBREAK-SPACE` and the
|
|
213
|
+
second is in `DASH` ∪ `INERT-DASH`, emit nothing for this token.
|
|
214
|
+
- **right:** if `cp[R]` is in `DIGIT`, let `D` be the maximal `DIGIT` run starting at `R`
|
|
215
|
+
and ending at index `d`. If `cp[d+1]` is in `DASH` ∪ `INERT-DASH`, **or** `cp[d+1]` is in
|
|
216
|
+
`SPACE` ∪ `NOBREAK-SPACE` and `cp[d+2]` is in `DASH` ∪ `INERT-DASH`, emit nothing.
|
|
217
|
+
|
|
218
|
+
Read plainly: **a tight token must not become spaced when doing so would insert a space
|
|
219
|
+
between itself and a digit run that has another dash on its far side.** That inserted space
|
|
220
|
+
is the only thing this rule can do that changes a _different_ token's `before`/`after` from
|
|
221
|
+
a dash into a space, which is the single remaining way a range verdict can flip between
|
|
222
|
+
runs (§5.3). The two-code-point reach on each side exists because the other dash may be
|
|
223
|
+
tight against the digit run (`a–1-1`) or spaced away from it (`a–1 - 1`); both distances
|
|
224
|
+
have to be caught, and no third distance is reachable, because a `-spaced` replacement
|
|
225
|
+
inserts exactly one U+0020.
|
|
226
|
+
|
|
227
|
+
The guard is deliberately narrow. It never fires when the token is already spaced, never
|
|
228
|
+
when the locale form is `-tight` or `none`, and never when the token's neighbours are
|
|
229
|
+
letters — so `Der Plan--falls es einen gibt--scheitert.` and `Seiten 34-36` are unaffected,
|
|
230
|
+
and `— 1914-1918 годы` still converts its range. What it does cost is written up in §7.4.
|
|
231
|
+
|
|
232
|
+
9. **Composition guard (T2).** Like T1 this depends on the chosen replacement form, so it is
|
|
233
|
+
evaluated immediately before emitting, in either branch. It exists to discharge the
|
|
234
|
+
composition obligation against `spaces` (order 10) — see
|
|
235
|
+
[pipeline-idempotency.md](pipeline-idempotency.md) §4, defect family 1.
|
|
236
|
+
|
|
237
|
+
If the chosen form is `em-spaced` or `en-spaced`, so that the replacement emits a U+0020 on
|
|
238
|
+
each side, then **emit nothing** if either:
|
|
239
|
+
- `cp[R]` is in **T2's own set** — { U+002C, U+002E, U+003B, U+003A, U+0021, U+003F, U+2026 }
|
|
240
|
+
— or in `CLOSE-BRACKET` = { U+0029, U+005D, U+007D }; or
|
|
241
|
+
- `cp[L]` is in `OPEN-BRACKET` = { U+0028, U+005B, U+007B }.
|
|
242
|
+
|
|
243
|
+
**T2's set is not `spaces`' `STRIP-BEFORE`, and this document used to claim it was.** They
|
|
244
|
+
differ by exactly one code point: U+2026 is in T2's set and is **not** in `STRIP-BEFORE`
|
|
245
|
+
(`spaces.md` §3.1, §3.4). The old claim — "these are precisely the positions from which
|
|
246
|
+
`spaces` deletes a U+0020" — was true when written and became false when U+2026 left
|
|
247
|
+
`STRIP-BEFORE`; a port transcribing the *rationale* rather than the list would convert
|
|
248
|
+
`a--…` to `a – …` in every `-spaced` locale, where the JS implementation declines. Both
|
|
249
|
+
behaviours are idempotent, so no property test separates them: only a fixture can, and one is
|
|
250
|
+
needed (§7.7).
|
|
251
|
+
|
|
252
|
+
**U+2026 stays in T2's set.** T2 is deliberately the wider of the two: its job is to avoid
|
|
253
|
+
emitting a space that something else will remove, and declining one code point more than
|
|
254
|
+
strictly necessary costs a conversion nobody writes (`a--…`) while keeping the guard's
|
|
255
|
+
verdict independent of `spaces`' exact membership — which has now changed once. The two sets
|
|
256
|
+
are related but not equal, and the relationship is stated rather than assumed:
|
|
257
|
+
**T2's set ⊃ `STRIP-BEFORE`, by U+2026.**
|
|
258
|
+
|
|
259
|
+
The positions in `STRIP-BEFORE` and the bracket sets are those from which `spaces` deletes a
|
|
260
|
+
U+0020 (`spaces.md` §3.2
|
|
261
|
+
step 5). Emitting a space there produces text that is a fixed point of _this_ rule and not
|
|
262
|
+
of `spaces`, so the next pipeline pass deletes the space, the token becomes asymmetrically
|
|
263
|
+
spaced, and this rule then declines it — a two-pass divergence:
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
de-DE: .--. → . – . → . –.
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
A dash spaced against a full stop, a comma or a bracket is not a construction that occurs in
|
|
270
|
+
real copy, so declining costs nothing. Note that the guard is only needed for the `-spaced`
|
|
271
|
+
forms: a `-tight` form emits no U+0020 at all, and `en-US` (`em-tight`) never triggered the
|
|
272
|
+
defect.
|
|
273
|
+
|
|
274
|
+
### 3.2a `JOINER` neighbours
|
|
275
|
+
|
|
276
|
+
A `JOINER` adjacent to the token is examined before the branch is chosen:
|
|
277
|
+
|
|
278
|
+
- Let `L*` be `L` moved left across a maximal run of `JOINER`, and `R*` be `R` moved right
|
|
279
|
+
across a maximal run of `JOINER`. If either walk runs off the array, emit nothing.
|
|
280
|
+
- If no joiner was crossed, `L* = L` and `R* = R` and nothing about the rest of the algorithm
|
|
281
|
+
changes.
|
|
282
|
+
- If a joiner **was** crossed and `cp[L*]` and `cp[R*]` are both in `DIGIT`, the token is a
|
|
283
|
+
bound range `ranges` produced on an earlier pass ([ranges.md](ranges.md) §3.3.1): continue with
|
|
284
|
+
`L*`/`R*` in place of `L`/`R`, and extend the token's span to cover the crossed joiners. `dashes`
|
|
285
|
+
itself never reaches this shape as a candidate — extending the span here only ever feeds the
|
|
286
|
+
digit-flanked check that routes the token to `ranges`' exclusive territory (§1, §3.3), never to
|
|
287
|
+
this rule's own parenthetical branch.
|
|
288
|
+
- If a joiner was crossed in any other configuration, **emit nothing**. An author who typed
|
|
289
|
+
U+2060 next to a dash meant it, exactly as with `INERT-DASH` (§3.1).
|
|
290
|
+
|
|
291
|
+
This is what makes a `ranges`-produced binding survive a second pass without a second joiner
|
|
292
|
+
being added, and what keeps this shared scan from rewriting a joiner that only `ranges` — never
|
|
293
|
+
`dashes` — puts there.
|
|
294
|
+
|
|
295
|
+
### 3.2b `JOINER` is transparent to every lookaround that leaves the token
|
|
296
|
+
|
|
297
|
+
§3.2a makes a joiner transparent to the token that **owns** it. That is not enough, and the gap
|
|
298
|
+
cost an idempotency defect (§8.2, defect (e)). A joiner also sits in the lookaround of a
|
|
299
|
+
**neighbouring** token, where nothing was skipping it.
|
|
300
|
+
|
|
301
|
+
> **Effective neighbour.** For an index `i` and a direction, step outward across any maximal run
|
|
302
|
+
> of `JOINER` and take the first code point that is not a `JOINER`, or `NONE` if the walk leaves
|
|
303
|
+
> the array.
|
|
304
|
+
>
|
|
305
|
+
> **Every guard that inspects a code point outside its own token's dash run reads an effective
|
|
306
|
+
> neighbour, not a raw index.** That is: `before` and `after`, owned by `ranges` since spec 0.5.0
|
|
307
|
+
> ([ranges.md](ranges.md) §3.2-§3.3) — and therefore G1, G2 and G3 — and the two-code-point reach
|
|
308
|
+
> of T1 (§3.2 step 8, shared by both rules). `cp[L]` and `cp[R]` are already joiner-skipping
|
|
309
|
+
> through §3.2a's `L*`/`R*`.
|
|
310
|
+
>
|
|
311
|
+
> **`JOINER` is additionally a member of the cluster alphabet** (§3.2 step 7), so a joiner cannot
|
|
312
|
+
> split a cluster that would otherwise have contained two dash runs.
|
|
313
|
+
|
|
314
|
+
**Why this is forced rather than chosen.** `ranges` (order 25) emits `JOINER`; `dashes` (order 30)
|
|
315
|
+
runs later in the same pipeline pass and consumes the text `ranges` already produced. That is a
|
|
316
|
+
**current cross-rule composition obligation**, not an own-emission one: `dashes`' shared token
|
|
317
|
+
guards and lookarounds must be invariant under `JOINER`, so a verdict this rule reaches does not
|
|
318
|
+
depend on whether an earlier `ranges` pass has already bound the range sitting next to it. A guard
|
|
319
|
+
that could not see through a neighbouring `ranges` token's joiners would read a bound range's
|
|
320
|
+
`JOINER dash JOINER` as something other than the plain dash it wraps, and reach a different
|
|
321
|
+
verdict than it would against the same input before `ranges` ran — which is exactly the
|
|
322
|
+
composition failure [pipeline-idempotency.md](pipeline-idempotency.md) §4 and §5 forbid between
|
|
323
|
+
two rules in one pipeline. **Before the spec 0.5.0 split, when range handling was still part of
|
|
324
|
+
this document's own algorithm, the identical requirement was instead an *own-emission* CO-S
|
|
325
|
+
obligation** ([pipeline-idempotency.md](pipeline-idempotency.md) §5.1a) — the single rule had to
|
|
326
|
+
be inert to its own `JOINER` output, on the same footing it had to be inert to its own dash
|
|
327
|
+
output. That ownership is historical now (§8.4 records it); the shape of the requirement did not
|
|
328
|
+
change when the rule that emits `JOINER` did. §3.2a supplies this reading
|
|
329
|
+
for the token that sits next to a joiner; §3.2b supplies it for every other guard's lookaround,
|
|
330
|
+
and the two together are the complete current statement of `dashes`' joiner-transparent reading
|
|
331
|
+
behaviour.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
### 3.3 Range branch — retired here, moved to `ranges` (spec 0.5.0)
|
|
336
|
+
|
|
337
|
+
**This section described the range branch through spec 0.4.1. As of spec 0.5.0, range detection
|
|
338
|
+
is [ranges.md](ranges.md) §3.2-§3.3.1 in full** — the admissibility test (`cp[L]`/`cp[R]` both
|
|
339
|
+
`DIGIT`), guards G1-G5, the replacement table, and binding a tight range (§3.3.1) all moved there
|
|
340
|
+
verbatim, still reading the same `dash.range` locale field under the same key.
|
|
341
|
+
|
|
342
|
+
**What stays true here, restated because it is `dashes`' own contract now rather than a
|
|
343
|
+
consequence of one rule's two branches:** a digit-flanked dash token (`cp[L]` and `cp[R]` both
|
|
344
|
+
`DIGIT`) is never processed by `dashes` — not converted, not declined-and-then-reconsidered,
|
|
345
|
+
simply never reached. This holds **unconditionally**, whether `ranges` is enabled or not (§1).
|
|
346
|
+
`ranges` disabled does not mean the token falls back to parenthetical treatment; it means nothing
|
|
347
|
+
in the pipeline touches it at all, and `5-10`, `Figure 5-10`, `9-11` and `7-11` are all
|
|
348
|
+
byte-identical no-ops with default options.
|
|
349
|
+
|
|
350
|
+
The section number is kept, not renumbered away, for the same reason §3.2 step 2a keeps its
|
|
351
|
+
number after being retired: several documents (this one's own §4, §6, §7; `ranges.md`;
|
|
352
|
+
`pipeline-idempotency.md`) cite sections of this document by number, and a retired section that
|
|
353
|
+
still occupies its number costs less than a renumbering would.
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
### 3.4 Parenthetical branch
|
|
358
|
+
|
|
359
|
+
Reached for every token this rule sees — a digit-flanked token (both `cp[L]` and `cp[R]` in
|
|
360
|
+
`DIGIT`) is never handed to `dashes` at all (§3.3, §1), not merely excluded from this branch, so
|
|
361
|
+
"is not a range candidate" (i.e. at least one of `cp[L]`, `cp[R]` is not a `DIGIT`) is true of
|
|
362
|
+
every token that reaches here by construction. Additional guards:
|
|
363
|
+
|
|
364
|
+
- **P5 (spec 0.6.0) — authored en-dash mark-identity veto.** If `k = 1` and `cp[s]` is U+2013,
|
|
365
|
+
emit nothing — **unconditionally**: every locale, tight or spaced, regardless of
|
|
366
|
+
`dash.parenthetical`'s target glyph. A run mixing U+2013 with any other `DASH` member (`a-–b`,
|
|
367
|
+
`k = 2` or `3`) is **not** covered and still promotes under P2/P3 below, exactly as before.
|
|
368
|
+
|
|
369
|
+
This is the one narrow exception to P2's "no distinction based on which `DASH` glyph the
|
|
370
|
+
author typed": a pure, single, authored en-dash is treated as carrying its own mark identity —
|
|
371
|
+
interruption (em-dash) and connection/range (en-dash) are two established, different
|
|
372
|
+
conventional roles, not merely two lengths of the same mark — and is never silently rewritten
|
|
373
|
+
into a different mark, in any locale, including one whose own `dash.parenthetical` target
|
|
374
|
+
glyph is en-dash (there, only the *spacing* of an authored en-dash may still be corrected; the
|
|
375
|
+
*glyph* is never touched by any path in this rule, so the distinction is moot but the veto
|
|
376
|
+
still applies structurally, for the same input-invariance reason P4 declines rather than
|
|
377
|
+
reasons about the locale's own target).
|
|
378
|
+
|
|
379
|
+
**Full argument for why this does not reopen the guards §8.1 records retiring — fresh M4
|
|
380
|
+
evidence, the narrower scope, and why it is unconditional across every locale rather than
|
|
381
|
+
keyed to the target glyph — is §8.8.**
|
|
382
|
+
- **P1 — a bare hyphen-shaped stroke must be spaced.** If `k = 1` and `cp[s]` is U+002D,
|
|
383
|
+
U+2010 or U+2212 and `lsp = 0`, emit nothing. This is the compound-word guard: `well-known`,
|
|
384
|
+
`e-mail`, `Jean-Luc`, `по-русски`, `well‐known` (U+2010) and `a−b` (U+2212, the same tight
|
|
385
|
+
attached shape) are all untouched. U+2013/U+2014 are never a compound-word substitute — no
|
|
386
|
+
author writes a compound word with a real en or em dash — so this guard never covers them.
|
|
387
|
+
(`lsp = rsp` by §3.2 step 4, so testing one side suffices.)
|
|
388
|
+
- **P2 (restored, spec 0.2.0) — an en or em dash is a parenthetical dash, promoted to the
|
|
389
|
+
locale's form exactly as a hyphen would be.** Spec 0.1.0's step 2a made every token holding
|
|
390
|
+
U+2013/U+2014 unreachable in this branch, which made this clause dead code, so it was deleted
|
|
391
|
+
rather than left to rot. 0.2.0 retired step 2a outright (its history note explains why), so
|
|
392
|
+
U+2013/U+2014 reach this branch again on the same terms as U+002D, and the clause is restored
|
|
393
|
+
under its original label and its original, unconditional wording: length and spacing are both
|
|
394
|
+
corrected, with no distinction based on which `DASH` glyph the author typed.
|
|
395
|
+
- **P4 — Roman-numeral veto.** If `lsp = rsp = 0` (the token is tight), and the maximal run of
|
|
396
|
+
`ROMAN` code points ending at `cp[L]` is non-empty, and the maximal run of `ROMAN` code points
|
|
397
|
+
starting at `cp[R]` is non-empty, and each of those runs is bounded on its outer side by a
|
|
398
|
+
code point that is not in `LETTER`, then **emit nothing**.
|
|
399
|
+
|
|
400
|
+
A tight dash between two Roman numerals is a **range** — `в XV—XVII веках`, `Louis XIV—XVI` —
|
|
401
|
+
and Russian sets ranges with a tight em dash, which is exactly the form already present. The
|
|
402
|
+
range branch cannot see it, because §3.3 admits a range candidate only when a `DIGIT` stands
|
|
403
|
+
on each side; so without this veto the token falls through to the parenthetical branch and an
|
|
404
|
+
`em-spaced` or `en-spaced` locale **damages correct input**: `в XV—XVII веках` becomes
|
|
405
|
+
`в XV — XVII веках`. That is the worst category of defect in this project — not a missed
|
|
406
|
+
improvement but a corruption of text that was already right — and the `ru` locale file's own
|
|
407
|
+
citation uses that very phrase as its range example.
|
|
408
|
+
|
|
409
|
+
P4 is a **veto, not a range-enabler**: it declines, and never converts anything. Making
|
|
410
|
+
`ROMAN` runs into full range candidates would let `XV-XVII` (typed with a hyphen) become a
|
|
411
|
+
proper range, but it would also fire on ordinary all-caps words built only from Roman letters
|
|
412
|
+
— `VIVID`, `CIVIL`, `MIX`, `DID` — and converting is the direction that can damage. §7.2
|
|
413
|
+
records the miss.
|
|
414
|
+
|
|
415
|
+
Lower-case is excluded for the same reason: `mix—did` must still normalise, and lower-case
|
|
416
|
+
Roman numerals in running prose are vanishingly rare next to lower-case words made of the same
|
|
417
|
+
letters.
|
|
418
|
+
|
|
419
|
+
- **P3 — a run of 2 or 3 qualifies regardless of spacing**, subject to §3.2 step 4 having
|
|
420
|
+
already required the spacing to be symmetric. `word--word` and `word -- word` both qualify;
|
|
421
|
+
`--force` does not (asymmetric); `---` used as a Markdown thematic break does not (it sits
|
|
422
|
+
alone on its line, so §3.2 step 5 rejects it for want of content on both sides).
|
|
423
|
+
|
|
424
|
+
Emit one edit replacing `cp[s - lsp … e - 1 + rsp]` with:
|
|
425
|
+
|
|
426
|
+
| `dash.parenthetical` | replacement |
|
|
427
|
+
| -------------------- | ------------------------------------------------------- |
|
|
428
|
+
| `"em-tight"` | U+2014 |
|
|
429
|
+
| `"em-spaced"` | U+0020 U+2014 U+0020 |
|
|
430
|
+
| `"en-tight"` | U+2013 |
|
|
431
|
+
| `"en-spaced"` | U+0020 U+2013 U+0020 |
|
|
432
|
+
| `"none"` | _(emit nothing — the token is left exactly as written)_ |
|
|
433
|
+
|
|
434
|
+
**Subject to guards T1 and T2 (§3.2 steps 8 and 9), evaluated now that the form is known.** If the replacement is identical to the span it replaces, emit
|
|
435
|
+
nothing.
|
|
436
|
+
|
|
437
|
+
### 3.5 Continue, and the non-overlap guarantee
|
|
438
|
+
|
|
439
|
+
Set `i` to the index just past the token's run in the **input** array and continue from §3.2
|
|
440
|
+
step 1. Because every edit is computed against input indices and the pipeline applies edits
|
|
441
|
+
afterwards, no index arithmetic on a partially rewritten array is ever required.
|
|
442
|
+
|
|
443
|
+
An edit's span is `cp[s - lsp … e - 1 + rsp]`, which includes the outer spacing. Two such
|
|
444
|
+
spans could in principle overlap: if two tokens are separated by exactly one space, that one
|
|
445
|
+
space is the first token's `rsp` and the second token's `lsp`, both spans claim it, and the
|
|
446
|
+
pipeline sees overlapping edits — which is a `POLYTYPO_RULE_CONTRACT` violation, not a
|
|
447
|
+
merge.
|
|
448
|
+
|
|
449
|
+
**This cannot happen after §3.2 step 6.** Overlap requires `rsp₁ = 1`, `lsp₂ = 1`, and the
|
|
450
|
+
shared space at index `e₁ = s₂ - 1`. But then the second token's `cp[L]` is `cp[s₂ - 2] =
|
|
451
|
+
cp[e₁ - 1]`, the last code point of the first token's run, which is in `DASH` — so step 6
|
|
452
|
+
makes the second token inert. Symmetrically the first token's `cp[R]` is the second token's
|
|
453
|
+
first dash, also in `DASH`, so it too is inert. The `dash space dash` shape produces **no
|
|
454
|
+
edits at all**, and every pair of emitted spans is therefore separated by at least one code
|
|
455
|
+
point that is neither a dash nor a claimed space.
|
|
456
|
+
|
|
457
|
+
**Normative fallback.** If an implementation nevertheless computes two overlapping spans, the
|
|
458
|
+
leftmost token wins and the later token is discarded — the same first-claim-wins policy
|
|
459
|
+
`nbsp` uses (see `nbsp.md` §3.2). This is stated so that behaviour is defined rather than
|
|
460
|
+
undefined; it is unreachable under the algorithm as written, and an implementation that finds
|
|
461
|
+
it reachable has found a bug in this document and should report it rather than rely on the
|
|
462
|
+
fallback.
|
|
463
|
+
|
|
464
|
+
### 3.6 No-break spaces are never touched, and never need preserving
|
|
465
|
+
|
|
466
|
+
A no-break space adjacent to a dash makes the token inert (§3.2 step 3 + step 6), so this rule
|
|
467
|
+
never edits a span containing one and there is nothing to preserve. **Earlier revisions of this
|
|
468
|
+
document carried a preservation clause here** — the emitted replacement was to carry a
|
|
469
|
+
pre-existing U+00A0 across rather than substitute U+0020 — and it existed only because those
|
|
470
|
+
revisions counted a no-break space as spacing and therefore _did_ edit such tokens. With that
|
|
471
|
+
gone the clause is vacuous, and vacuous clauses in a spec are a liability: an implementer who
|
|
472
|
+
finds one either writes dead code or invents a case to justify it.
|
|
473
|
+
|
|
474
|
+
The Russian case the clause was written for still comes out right, by a shorter route. Pass 1
|
|
475
|
+
converts `Москва - столица` to `Москва — столица` (`em-spaced`, both sides U+0020); `nbsp` then
|
|
476
|
+
promotes the leading space to U+00A0 because `ru` lists U+2014 in `nbsp.beforePunctuation`,
|
|
477
|
+
giving `Москва⍽— столица`. On the next pass this rule finds `lsp = 0`, `rsp = 1`, declines at
|
|
478
|
+
the symmetry guard, and emits nothing. Same output, reached by declining rather than by
|
|
479
|
+
recomputing an identical span.
|
|
480
|
+
|
|
481
|
+
**Every space in an emitted replacement is therefore U+0020 and nothing else.** A `-spaced`
|
|
482
|
+
form emits U+0020 on each side, a `-tight` form emits none, and no other _space_ character can
|
|
483
|
+
appear in an edit this rule produces. **This rule's current emission alphabet is exactly U+2013
|
|
484
|
+
or U+2014 — the target dash glyph itself — plus, for a spaced form, U+0020 on each side; nothing
|
|
485
|
+
else.** In particular it emits no U+2060. Through spec 0.4.1, a converted *tight* range dash could
|
|
486
|
+
additionally carry U+2060 immediately before and after the emitted dash glyph (§3.3.1, pre-0.5.0);
|
|
487
|
+
range binding — and every U+2060 this rule's output can now contain — is exclusively `ranges`'
|
|
488
|
+
behaviour ([ranges.md](ranges.md) §3.3.1) as of spec 0.5.0, since `dashes` never processes the
|
|
489
|
+
digit-flanked token a binding would apply to in the first place (§1). `dashes` still *reads*
|
|
490
|
+
through an adjacent run of joiners a neighbouring `ranges` token produced (§3.2a, §3.2b), which is
|
|
491
|
+
a different thing from emitting one itself.
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## 4. Must not touch
|
|
496
|
+
|
|
497
|
+
**Scope.** Per [pipeline-idempotency.md](pipeline-idempotency.md) §5.2 each bullet is **[P]** —
|
|
498
|
+
a guarantee of `transform` as a whole — or **[R]** — true of this rule in isolation but capable
|
|
499
|
+
of being falsified by another rule, which is then named.
|
|
500
|
+
|
|
501
|
+
- **[P] The hyphen in a compound word.** `well-known`, `e-mail`, `Jean-Luc`, `well-being`,
|
|
502
|
+
`из-под`, `кое-что`, `-таки`. Guard P1.
|
|
503
|
+
- **[P] A leading or trailing hyphen with asymmetric spacing:** `--force`, `-v`, `- item`
|
|
504
|
+
(Markdown/YAML list), `-- signature`, `->`, `<-`. Guard §3.2 step 4.
|
|
505
|
+
- **[P] A Markdown thematic break or setext underline** (`---`, `----`, a line of hyphens):
|
|
506
|
+
rejected by §3.2 step 2 (`k > 3`) or step 5 (no content on both sides).
|
|
507
|
+
- **[P] ISO dates** `2026-08-15`, **ISBNs**, **phone numbers**, **part numbers**: digit-flanked,
|
|
508
|
+
so they never reach `dashes` at all as of spec 0.5.0 (§1, §3.3). When `ranges` is explicitly
|
|
509
|
+
enabled, its own guards G2 (chain) and G4 (equal digit count) additionally decline them on
|
|
510
|
+
their own terms ([ranges.md](ranges.md) §3.2, §6).
|
|
511
|
+
- **[P] `COVID-19`, `MP3-4`, `Windows-1252`, `ISO 8859-1`, `UTF-8`**: `COVID-19`'s hyphen is
|
|
512
|
+
letter-flanked (`cp[L]` is a letter, so P1's compound-word guard applies); the other four are
|
|
513
|
+
digit-flanked and never reach `dashes` at all as of spec 0.5.0 (§1). `ranges.md` §3.2's G1/G4
|
|
514
|
+
additionally decline the digit-flanked ones on their own terms when `ranges` is explicitly
|
|
515
|
+
enabled.
|
|
516
|
+
- **[P] A tight dash between two Roman numerals**, in either direction: `XV—XVII`, `I—V`,
|
|
517
|
+
`Louis XIV—XVI`. Guard P4. Note this is a _preservation_ claim, not a conversion one — the
|
|
518
|
+
input is already correct and the rule's job is to leave it alone.
|
|
519
|
+
- **[P] Any member of `INERT-DASH`** (spec 0.2.0): U+00AD, U+2011, U+2012, U+2015, and the
|
|
520
|
+
fullwidth/small forms U+FE58, U+FE63, U+FF0D. Neither read as a candidate nor produced. **Not
|
|
521
|
+
a member since 0.2.0: U+2010 and U+2212 are ordinary `DASH` candidates** (§3.1) — `x ‐ y`
|
|
522
|
+
(U+2010) and `x − y` (U+2212) both convert to the locale's parenthetical form. `1990−2000` is
|
|
523
|
+
digit-flanked, so it never reaches `dashes`' parenthetical form at all — with `ranges`
|
|
524
|
+
explicitly enabled it converts and binds as a range there (ranges.md §3.3, §3.3.1).
|
|
525
|
+
- **[P] A negative number.** `-5` has no space to the left of the digit and a letter/space to
|
|
526
|
+
the left of the hyphen → asymmetric → rejected. This holds for U+002D, U+2010 and U+2212
|
|
527
|
+
alike — the same asymmetry guard, not a glyph-specific one.
|
|
528
|
+
- **[P] Every digit-flanked stroke, unconditionally** — `5-10`, `Figure 5-10`, `9-11`: never
|
|
529
|
+
reached by `dashes` at all, let alone reinterpreted as parenthetical (§1, §3.3). This holds
|
|
530
|
+
regardless of whether `ranges`' own guards would have accepted or declined the token —
|
|
531
|
+
`dashes` does not evaluate them and does not need to.
|
|
532
|
+
- **[P] URLs, code spans, fenced code, HTML attributes.** Removed by the mode adapter before this
|
|
533
|
+
rule sees them. This rule has no notion of a URL and must not grow one.
|
|
534
|
+
- **[P] Line terminators**, which are never inserted, deleted or crossed.
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## 5. Idempotency argument
|
|
539
|
+
|
|
540
|
+
Write `T` for `dashes`. `T` edits only **parenthetical dash tokens**: at least one of `cp[L]`,
|
|
541
|
+
`cp[R]` is not `DIGIT` (§1, §3.3) — a digit-flanked token is never reached by `T` on any pass, so
|
|
542
|
+
nothing below needs to reason about one. Each edit `T` makes replaces a span consisting of one
|
|
543
|
+
maximal `DASH` run plus at most one space-like code point on each side, with a span of the same
|
|
544
|
+
shape (`space? dash space?`). So every edit is one of:
|
|
545
|
+
|
|
546
|
+
- **(E1)** replace one dash with another dash at the run's position;
|
|
547
|
+
- **(E2)** shorten or lengthen the run (`--` → `—`), without creating a space;
|
|
548
|
+
- **(E3)** delete a U+0020 adjacent to the run (producing a `-tight` form);
|
|
549
|
+
- **(E4)** insert a U+0020 adjacent to the run (producing a `-spaced` form).
|
|
550
|
+
|
|
551
|
+
`T` never touches a letter, a digit, a full stop, a comma, a solidus, a line terminator, or any
|
|
552
|
+
space not immediately adjacent to a dash run. §3.5 establishes that emitted spans are pairwise
|
|
553
|
+
disjoint, so no edit's output can be reinterpreted as part of a different edit's span on the same
|
|
554
|
+
pass.
|
|
555
|
+
|
|
556
|
+
### 5.1 Emitted forms are fixed points
|
|
557
|
+
|
|
558
|
+
| Emitted form | Re-read as | Recomputed replacement |
|
|
559
|
+
| ---------------------------------------- | ------------------------- | ------------------------ |
|
|
560
|
+
| `X` tight (`em-tight`/`en-tight`) | `k = 1`, `lsp = rsp = 0` | same single code point |
|
|
561
|
+
| `␣X␣` spaced (`em-spaced`/`en-spaced`) | `k = 1`, `lsp = rsp = 1` | same three code points |
|
|
562
|
+
| nothing (`"none"`, or a declined token) | unchanged input | nothing |
|
|
563
|
+
|
|
564
|
+
A tight form is re-admitted by P2 (`k = 1`, and the glyph is a `DASH` member — spec 0.2.0 gives
|
|
565
|
+
U+2013/U+2014 no token-level special case, §3.2 step 2a, so this holds regardless of which `DASH`
|
|
566
|
+
glyph the token holds). A spaced form is re-admitted the same way, with `lsp = rsp = 1` read back
|
|
567
|
+
by §3.2 step 3. A token `nbsp` has since promoted (`ru`: U+00A0 before an em dash) is not
|
|
568
|
+
recomputed at all — §3.2 step 3 makes a no-break-space neighbour space-like, so the isolation
|
|
569
|
+
guard (step 6) declines it and nothing is emitted (§3.6). A digit-flanked token stays outside `T`'s
|
|
570
|
+
domain on every pass, by construction, so it is trivially a fixed point of `T` regardless of what
|
|
571
|
+
`ranges` does to it.
|
|
572
|
+
|
|
573
|
+
### 5.2 A declined token stays declined
|
|
574
|
+
|
|
575
|
+
For a token `T'` that `T` declines, its verdict depends only on: `k` and the run's position;
|
|
576
|
+
`lsp`, `rsp`; `cp[L]`, `cp[R]`; the dash cluster containing its run (§3.2 step 7); the `ROMAN`
|
|
577
|
+
runs P4 reads; the run's own code point, which P5 reads; and, when relevant, the digit run T1
|
|
578
|
+
reads. None of these can be changed by an edit `T` itself makes elsewhere in the same pass:
|
|
579
|
+
|
|
580
|
+
- **The run's own code point (P5, spec 0.6.0).** P5's verdict — `k = 1` and `cp[s] = U+2013` —
|
|
581
|
+
reads only the run's own single code point, which by the same disjoint-spans argument as `k`
|
|
582
|
+
above sits outside every edit span and so is byte-identical on every pass. A token P5 declines
|
|
583
|
+
stays declined on exactly the same footing as P4: the input P5 reads is one this rule's own
|
|
584
|
+
edits cannot produce or destroy (no edit's replacement contains U+2013 anywhere in this rule's
|
|
585
|
+
emission alphabet — §3.6 — so a *new* pure-U+2013 run this rule itself created is not even a
|
|
586
|
+
reachable state to worry about).
|
|
587
|
+
|
|
588
|
+
- **The run and its spacing (`k`, `lsp`, `rsp`).** `T'`'s run sits outside every edit span
|
|
589
|
+
(spans are pairwise disjoint, §3.5). `lsp`/`rsp` probe `cp[s-1]`/`cp[e]`; if either lay inside
|
|
590
|
+
another edited token's span, the two tokens would be adjacent or separated by exactly one
|
|
591
|
+
shared space — both shapes the isolation guard (step 6) already declines (a `DASH` or
|
|
592
|
+
space-like neighbour), so neither arises for a token that reaches a verdict at all.
|
|
593
|
+
- **`cp[L]`, `cp[R]`.** If `cp[L]` lies inside an edited token's span, it is a dash or an outer
|
|
594
|
+
space of that token — either way step 6 already declined `T'` (it rejects both `DASH` and
|
|
595
|
+
space-like at that position), and E1–E4 never put a letter or digit there, so `T'` is declined
|
|
596
|
+
again on the next pass, for the same reason. The mirror argument gives `cp[R]`.
|
|
597
|
+
- **The cluster.** A cluster with ≥2 dash runs emits nothing at all (step 7), so it is
|
|
598
|
+
byte-identical next pass and stays inert. A cluster with exactly one dash run may be edited,
|
|
599
|
+
but the edit cannot merge it with a neighbouring cluster except by deleting a space (E3), and a
|
|
600
|
+
merge only ever adds dash runs — which can only make more tokens inert, never fewer. It cannot
|
|
601
|
+
split a live cluster into something revivable (E4 turns a one-run cluster into one-run pieces),
|
|
602
|
+
and it cannot split an inert cluster at all, since an inert cluster emits nothing. No token
|
|
603
|
+
moves from inert to live via the cluster guard.
|
|
604
|
+
- **`ROMAN` adjacency (P4).** No edit touches a letter, so the `ROMAN` runs flanking a token are
|
|
605
|
+
untouched by any of `T`'s own edits.
|
|
606
|
+
- **The digit run T1 reads.** No edit touches a digit, so the digit run itself is stable. T1's
|
|
607
|
+
concern is not this token's own recomputation but a *neighbouring* token's verdict — see §5.3.
|
|
608
|
+
|
|
609
|
+
Every input to a declined token's verdict is therefore unaffected by any edit `T` makes, on every
|
|
610
|
+
pass: `T(T(x))` declines exactly where `T(x)` declined. The historical counterexamples that led to
|
|
611
|
+
this guard set — before it closed every gap — are summarised in §8.2.
|
|
612
|
+
|
|
613
|
+
### 5.3 Interaction with `ranges` is stable
|
|
614
|
+
|
|
615
|
+
`ranges` (order 25) runs before `dashes` (order 30) in every pass. Three guarantees hold between
|
|
616
|
+
them, and all three are current:
|
|
617
|
+
|
|
618
|
+
- **`dashes` emits only U+2013 or U+2014, plus zero or one U+0020 on each side — never U+2060.**
|
|
619
|
+
Range binding ([ranges.md](ranges.md) §3.3.1) is exclusively `ranges`' emission; an interrupting
|
|
620
|
+
parenthetical dash is exactly where a line *may* break, so there is nothing for `dashes` to bind
|
|
621
|
+
(§3.6).
|
|
622
|
+
- **A digit-flanked token is never reinterpreted as parenthetical, on any pass.** The `isDigit`
|
|
623
|
+
test that routes a token to `ranges` instead of `dashes` (§1, §3.3) is evaluated after the
|
|
624
|
+
joiner-crossing walk (§3.2a), so a token `ranges` bound on an earlier pass — where the walk
|
|
625
|
+
re-enters across the `JOINER` pair and finds `DIGIT` on both effective neighbours — presents to
|
|
626
|
+
`dashes` with digit-flanked `leftCp`/`rightCp` exactly as an unbound one would, and `dashes`
|
|
627
|
+
declines it on the same unconditional test. `dashes` cannot strip a binding it never
|
|
628
|
+
reconsiders, and cannot create one, since it never emits `JOINER`.
|
|
629
|
+
- **`dashes`' own edits cannot flip a *neighbouring* range token's guard verdict on the next
|
|
630
|
+
pass, even though `dashes` itself never reads that token's guards.** The one edit shape that
|
|
631
|
+
could — a tight token becoming spaced, inserting a U+0020 next to a digit run that has another
|
|
632
|
+
dash on its far side — is exactly what the spacing-transition guard (T1, §3.2 step 8) declines.
|
|
633
|
+
This is `dashes`' composition obligation *toward* `ranges`, symmetric to the rule-order argument
|
|
634
|
+
in [ranges.md](ranges.md) §4: `ranges` must not see an adjacency `dashes` disturbed, and T1 is
|
|
635
|
+
how `dashes` upholds that on every subsequent pass. The full historical derivation of why this
|
|
636
|
+
is the only verdict input `dashes` can perturb — and the defects that motivated T1 and the
|
|
637
|
+
cluster guard — is §8.2 and §8.3.
|
|
638
|
+
|
|
639
|
+
### 5.4 Composition obligations
|
|
640
|
+
|
|
641
|
+
Per [pipeline-idempotency.md](pipeline-idempotency.md) §5. This rule is R₃, so the obligation runs
|
|
642
|
+
against `spaces` (R₁) and `ellipsis` (R₂), and it must itself survive every later rule.
|
|
643
|
+
|
|
644
|
+
**What this rule emits.** U+2013 or U+2014 at a dash run's position; zero or one U+0020
|
|
645
|
+
immediately on each side. It deletes only U+0020 and dash code points. It never emits a no-break
|
|
646
|
+
space, a letter, a digit, a full stop, or U+2060.
|
|
647
|
+
|
|
648
|
+
**Against `spaces` (I₁).** The only rule before `hyphen` that emits U+0020, so a `-spaced` form
|
|
649
|
+
could place a space where `spaces` deletes one — guard T2 (§3.2 step 9) exists for exactly this.
|
|
650
|
+
A run of two or more spaces is structurally impossible: the replacement emits exactly one U+0020
|
|
651
|
+
per side, and step 6 guarantees the code point beyond it is not space-like.
|
|
652
|
+
|
|
653
|
+
**Against `ellipsis` (I₂).** Discharged trivially: this rule never emits U+002E or U+2026, and
|
|
654
|
+
every deletion is a U+0020 or a dash adjacent to the token — never a code point standing between
|
|
655
|
+
two dot runs.
|
|
656
|
+
|
|
657
|
+
**What must be preserved *for* this rule.** `nbsp` (R₈)'s entire emission alphabet is U+00A0 and
|
|
658
|
+
U+202F, both of which make an adjacent dash token inert (§3.2 step 3 + step 6) — so `nbsp` cannot
|
|
659
|
+
create a dash token, alter a spacing verdict, or revive a declined one. This is condition **CO-S**
|
|
660
|
+
in [pipeline-idempotency.md](pipeline-idempotency.md) §5.1a, discharged structurally rather than
|
|
661
|
+
case by case. `hyphen` (R₄) converts some U+002D to U+2011: no verdict changes, because a U+002D
|
|
662
|
+
`hyphen` claims is intra-word and `dashes` had already declined it under P1, and because step 6
|
|
663
|
+
and the cluster alphabet treat `DASH` and `INERT-DASH` alike. `quotes` (R₅), `apostrophe` (R₆) and
|
|
664
|
+
`symbols` (R₇) emit characters in none of this rule's classes and never change spacing. `ranges`
|
|
665
|
+
(order 25, runs earlier in the same pass) is covered by §5.3 above.
|
|
666
|
+
|
|
667
|
+
### 5.5 Conclusion
|
|
668
|
+
|
|
669
|
+
Every input to every verdict — declined or emitted — is stable across a re-run of `dashes` alone
|
|
670
|
+
(§5.1, §5.2), stable under `ranges`' own edits and vice versa (§5.3), and stable under every other
|
|
671
|
+
rule in the pipeline (§5.4). Hence `T(T(x)) = T(x)`, discharging `dashes`' contribution to the
|
|
672
|
+
pipeline-level fixed point [pipeline-idempotency.md](pipeline-idempotency.md) §2 requires. ∎
|
|
673
|
+
|
|
674
|
+
---
|
|
675
|
+
|
|
676
|
+
## 6. Worked examples
|
|
677
|
+
|
|
678
|
+
`␣` = U+0020, `⍽` = U+00A0, `⟨J⟩` = U+2060 (word joiner), `⟶` = no change.
|
|
679
|
+
|
|
680
|
+
**A row containing an invisible code point must be checked in the escaped mirror, never read.**
|
|
681
|
+
U+2060 is zero-width: a range row that omits it looks _exactly_ right on screen and is wrong at
|
|
682
|
+
the byte level — the same caution [ranges.md](ranges.md) §6 states for its own rows, which is
|
|
683
|
+
where every range row (formerly numbered 3, 3a–3d, 3j, 13 and 17 in this table) now lives. `⟨J⟩`
|
|
684
|
+
is a notation used only in these two tables; the fixture files carry the real U+2060, and
|
|
685
|
+
`spec/fixtures/.escaped/` is the only rendering in which its presence or absence can actually be
|
|
686
|
+
seen. The same applies to any future rule emitting U+00AD, U+200B or a variation selector.
|
|
687
|
+
|
|
688
|
+
**As of spec 0.5.0, this table contains `dashes`' own behaviour only — parenthetical dashes.**
|
|
689
|
+
Every digit-flanked input below shows this rule's current, actual behaviour: no change at all,
|
|
690
|
+
because `dashes` never processes a digit-flanked token (§1, §3.3). Row numbers 3, 3a–3j, 13 and
|
|
691
|
+
17 are kept exactly where they were — not renumbered, not deleted — because fixtures and other
|
|
692
|
+
documents cite them by number; each now points to [ranges.md](ranges.md) §6, which is the current
|
|
693
|
+
normative source for what happens to that same input when `ranges` is explicitly enabled.
|
|
694
|
+
|
|
695
|
+
### `en-US` — `parenthetical: "em-tight"` (`range` is now [ranges.md](ranges.md)'s field, `"en-tight"`, not this rule's)
|
|
696
|
+
|
|
697
|
+
| # | Input | Output | Why |
|
|
698
|
+
| --- | --------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
699
|
+
| 1 | `The plan␣-␣if there is one␣-␣fails.` | `The plan—if there is one—fails.` | P1 satisfied (spaced), `em-tight` collapses the spacing |
|
|
700
|
+
| 2 | `The plan--if there is one--fails.` | `The plan—if there is one—fails.` | P3, tight both sides |
|
|
701
|
+
| 3 | `1914-1918 and pp. 34-36` | ⟶ | digit-flanked on both sides of both tokens — never reaches `dashes` at all (§1, §3.3); see [ranges.md](ranges.md) §6 row 3 for what `ranges` does with this input when explicitly enabled |
|
|
702
|
+
| 3a | `Takes 5-10 days` | ⟶ | digit-flanked — see [ranges.md](ranges.md) §6 row 3a |
|
|
703
|
+
| 3b | `aged 9-10 years` | ⟶ | digit-flanked — see [ranges.md](ranges.md) §6 row 3b |
|
|
704
|
+
| 3c | `chapters 1-12` | ⟶ | digit-flanked — see [ranges.md](ranges.md) §6 row 3c |
|
|
705
|
+
| 3d | `0-60 in six seconds` | ⟶ | digit-flanked — see [ranges.md](ranges.md) §6 row 3d |
|
|
706
|
+
| 3e | `won 10-7` | ⟶ | digit-flanked; also declined by `ranges` itself when enabled — see [ranges.md](ranges.md) §6 row 3e |
|
|
707
|
+
| 3f | `code 9-05` | ⟶ | digit-flanked; also declined by `ranges` itself when enabled — see [ranges.md](ranges.md) §6 row 3f |
|
|
708
|
+
| 3g | `Call 555-1234` | ⟶ | digit-flanked; also declined by `ranges` itself when enabled — see [ranges.md](ranges.md) §6 row 3g |
|
|
709
|
+
| 3h | `Call 1-800 now` | ⟶ | digit-flanked; also declined by `ranges` itself when enabled — see [ranges.md](ranges.md) §6 row 3h |
|
|
710
|
+
| 3i | `the 2020-24 season` | ⟶ | digit-flanked; also declined by `ranges` itself when enabled — see [ranges.md](ranges.md) §6 row 3i |
|
|
711
|
+
| 3j | `Figure 5-10` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3), so this compound label is a byte-identical no-op with default options. See [ranges.md](ranges.md) §5 and §6 row 3j for the documented, opt-in-only limitation |
|
|
712
|
+
| 4 | `A well-known e-mail address` | ⟶ | P1: bare hyphen, unspaced |
|
|
713
|
+
| 5 | `COVID-19 and ISO 8859-1` | ⟶ | `COVID-19`: `cp[L]` is a letter, P1's compound-word guard declines it. `ISO 8859-1`: digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 5 for what `ranges` does with the digit-flanked token when explicitly enabled (G4 rejects it there, 4 vs 1) |
|
|
714
|
+
| 6 | `Released 2026-08-15, ISBN 978-3-16-148410-0` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 6 (G2 chain rejects both there when `ranges` is enabled) |
|
|
715
|
+
| 7 | `Call 212-555-1234` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 7 (G2 chain rejects it there when `ranges` is enabled) |
|
|
716
|
+
| 8 | `run --force to override` | ⟶ | asymmetric spacing |
|
|
717
|
+
| 9 | `- first item` | ⟶ | no content to the left on the line |
|
|
718
|
+
| 10 | `Scores: 20-10` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 10 (G5 rejects it there when `ranges` is enabled, 20 > 10) |
|
|
719
|
+
|
|
720
|
+
### `de-DE` — `parenthetical: "en-spaced"` (`range` is now [ranges.md](ranges.md)'s field, `"en-tight"`, not this rule's)
|
|
721
|
+
|
|
722
|
+
| # | Input | Output | Why |
|
|
723
|
+
| --- | --------------------------------------------- | --------------------------------------------- | ------------------------------------------------------ |
|
|
724
|
+
| 11 | `Der Plan--falls es einen gibt--scheitert.` | `Der Plan␣–␣falls es einen gibt␣–␣scheitert.` | `en-spaced` inserts the spacing the tight input lacked |
|
|
725
|
+
| 12 | `Der Plan␣–␣falls es einen gibt␣–␣scheitert.` | ⟶ | already the target form |
|
|
726
|
+
| 13 | `Seiten 34-36` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 for what `ranges` does with this input when explicitly enabled |
|
|
727
|
+
|
|
728
|
+
### `ru` — `parenthetical: "em-spaced"` (`range` is now [ranges.md](ranges.md)'s field, `"em-tight"`, not this rule's)
|
|
729
|
+
|
|
730
|
+
| # | Input | Output | Why |
|
|
731
|
+
| --- | ------------------------------ | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
732
|
+
| 14 | `Москва␣-␣столица` | `Москва␣—␣столица` | `em-spaced` |
|
|
733
|
+
| 15 | `Москва⍽—␣столица` | ⟶ | declined by the **symmetry guard** (`lsp = 0`, since a U+00A0 is not this rule's spacing; `rsp = 1`) — unaffected by step 2a's retirement, because the symmetry guard has never read which `DASH` glyph the token holds |
|
|
734
|
+
| 16 | `из-под стола, кое-что` | ⟶ | P1. (`hyphen`, order 35, will bind these two hyphens with U+2011 — that is a different rule) |
|
|
735
|
+
| 17 | `Годы 1941-1945 были тяжёлыми` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 17 for what `ranges` does with this input when explicitly enabled (Russian sets an em dash in ranges, `range: "em-tight"`) |
|
|
736
|
+
| 17a | `в XV—XVII веках` | ⟶ | **guard P4.** A tight em dash between two Roman-numeral runs is a range already in its correct form. Previously produced `в XV — XVII веках`, damaging input that was already right |
|
|
737
|
+
| 17b | `в XV-XVII веках` | ⟶ | P4 fires here too (it tests the token's spacing and its neighbours, not which dash was typed), so the hyphen is left as written rather than converted. A miss, not damage — §7.2 |
|
|
738
|
+
| 17c | `Москва—столица` | `Москва␣—␣столица` | Cyrillic is not `ROMAN`, so P4 does not fire. ru's parenthetical is `em-spaced`; the token's length already matches (`em`), so only its spacing changes — but a length mismatch would convert here too, since spec 0.2.0 retired the token-level distinction entirely (§3.2 step 2a) |
|
|
739
|
+
|
|
740
|
+
### Idempotency regression cases
|
|
741
|
+
|
|
742
|
+
These are the shipped defects from §8.2. Both are now "no change" cases, and both must be
|
|
743
|
+
fixtures.
|
|
744
|
+
|
|
745
|
+
**Spec 0.5.0 note.** Every row below has been re-verified against `dashes`' own current output
|
|
746
|
+
(default options — `ranges` disabled), not the old combined rule's. Every one is still "no
|
|
747
|
+
change", but for a digit-flanked row (18, 20, 24, 25) that involves a would-be range token, the
|
|
748
|
+
reason is no longer "the shared guards evaluate and reject it" — with `ranges` off, that token is
|
|
749
|
+
simply never looked at by anything. The output these rows assert is unaffected; the "Why" column's
|
|
750
|
+
reasoning about a digit-flanked sub-token predates the split and should be read as "and, if
|
|
751
|
+
`ranges` is also enabled, its own G-guards independently reject the same token" rather than as a
|
|
752
|
+
claim about default-options behaviour.
|
|
753
|
+
|
|
754
|
+
| # | Locale | Input | Output | Why |
|
|
755
|
+
| --- | ------- | ----------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
756
|
+
| 18 | `ru` | `a—0–0` | ⟶ | §3.2 step 7: one cluster (`—`,`0`,`–`,`0`) with two dash runs → entirely inert. Previously converged only after two runs |
|
|
757
|
+
| 19 | `en-US` | `a-␣-␣a` | ⟶ | §3.2 step 6: the second token's `cp[L]` is `-`, in `DASH`. Previously produced `a-—a` and then `a—a` |
|
|
758
|
+
| 20 | `de-DE` | `1914-1918--annexation` | ⟶ | one cluster, three dash runs. Previously the `--` became `␣–␣` on run 1 and the range fired on run 2 |
|
|
759
|
+
| 21 | `en-US` | `a␣-␣-␣b` | ⟶ | both tokens see a `DASH` at `cp[L]` or `cp[R]`; also the shape that would have produced overlapping edit spans (§3.5) |
|
|
760
|
+
| 24 | `de-DE` | `a–1␣-␣1` | ⟶ | **defect (c), §8.2 (historical).** Step 8 (shared): the `–` is tight, `en-spaced` would make it spaced, and the digit run `1` to its right has a `-` two code points beyond → the `–` is inert. The `-` was rejected by `ranges`' own G2 (`before` is `–`) at the time this row was written, pre-0.5.0. Previously `a – 1 - 1` then `a – 1–1` |
|
|
761
|
+
| 25 | `ru` | `a–1␣-␣1` | ⟶ | same shape, `em-spaced` parenthetical and `em-tight` range. Previously `a — 1 - 1` then `a — 1—1` |
|
|
762
|
+
| 26 | `de-DE` | `a–1-1` | ⟶ | the tight sibling of case 24, but **step 7** is what rejects it: with no space anywhere, `–1-1` is a single cluster containing two dash runs, so the whole cluster is inert. Step 8 is load-bearing only for the _spaced_ form in case 24, where the range token's own spacing ends its cluster and step 7 cannot see the second dash |
|
|
763
|
+
| 27 | `en-US` | `a–1␣-␣1` | `a—1␣-␣1` | en-US's parenthetical is `em-tight` (not spaced), so **T1 never applies** — the token converts freely, length included. `de-DE`'s `en-spaced` parenthetical, by contrast, makes this token spaced, and T1 blocks it (the trailing `- 1` sits a far dash away): `run("a–1 - 1", deDE)` stays `a–1 - 1`, unconverted — this is what row 27 originally tested, and the mechanism is unchanged by step 2a's retirement |
|
|
764
|
+
|
|
765
|
+
### Composition regression cases
|
|
766
|
+
|
|
767
|
+
Both families from [pipeline-idempotency.md](pipeline-idempotency.md) §4. Each rule was a fixed
|
|
768
|
+
point on its own output; the pipeline was not.
|
|
769
|
+
|
|
770
|
+
| # | Locale | Input | Output | Why |
|
|
771
|
+
| --- | ------- | ----------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
772
|
+
| 28 | `de-DE` | `.--.` | ⟶ | **family 1.** T2: the form is `en-spaced` and `cp[R]` is U+002E, from which `spaces` deletes a U+0020. Previously `. – .` then `. –.` |
|
|
773
|
+
| 29 | `ru` | `a--.` | ⟶ | same, `em-spaced` |
|
|
774
|
+
| 30 | `de-DE` | `(--a` | ⟶ | T2 on the left: `cp[L]` is U+0028, after which `spaces` deletes a U+0020 |
|
|
775
|
+
| 31 | `de-DE` | `a--)` | ⟶ | T2 on the right: `cp[R]` is U+0029 |
|
|
776
|
+
| 32 | `en-US` | `.--.` | `.—.` | `em-tight` emits no U+0020, so T2 never applies and there is nothing for `spaces` to undo |
|
|
777
|
+
| 33 | `fr` | `«⍽-⍽»` | ⟶ | **family 2.** Neither U+00A0 is spacing, so `lsp = rsp = 0`; the token is symmetric but `cp[L]` is the U+00A0, and **step 6** declines it as space-like. Previously the second pipeline pass produced `«⍽–⍽»` |
|
|
778
|
+
| 34 | `ru` | `слово⍽—␣столица` | ⟶ | `lsp = 0`, `rsp = 1` → the symmetry guard declines. The Russian `nbsp`-promoted form survives by being refused, not by being recomputed |
|
|
779
|
+
| 35 | `fr` | `«⍽–␣"` | ⟶ | **defect (d), §8.2.** The U+00A0 that `nbsp` N8 inserted after `«` is not spacing, so `lsp = 0` against `rsp = 1` and the symmetry guard declines. Under the previous left-only rule this read as symmetric and was promoted to `«⍽—␣"` on the second pass |
|
|
780
|
+
| 36 | `fr` | `«–␣"` | ⟶ | the pass-1 form of case 35: `cp[s-1]` is `«`, not a space, so the token is asymmetric from the start. `nbsp` then inserts the inner space, producing case 35, which is now a fixed point |
|
|
781
|
+
| 37 | `fr` | `«⍽–␣a` | ⟶ | the same family with a letter to the right; all five witnesses (`«`, `"`, `'`, `a`, `1`) behave identically |
|
|
782
|
+
|
|
783
|
+
### Dash-glyph reclassification, spec 0.2.0 (formerly "the authored-dash guard — §3.2 step 2a")
|
|
784
|
+
|
|
785
|
+
The M4 gate rejected 1063 lines of exactly this shape, 30% of the whole diff: the author's spaced
|
|
786
|
+
em dashes rewritten to the `en-US` `em-tight` convention. `en-GB` was no escape — there the same
|
|
787
|
+
em dashes became en dashes on 1060 lines. Both English locales were restyling a deliberate
|
|
788
|
+
authorial choice across an entire corpus.
|
|
789
|
+
|
|
790
|
+
| # | Locale | Input | Output | Why |
|
|
791
|
+
| --- | ------- | -------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
792
|
+
| 38 | `en-US` | `documents␣—␣PDFs, invoices␣—␣and you` | `documents—PDFs, invoices—and you` | **the M4 blocker, reopened deliberately in spec 0.2.0** (§8.1). en-US's parenthetical is `em-tight`; the author's dash is already EM, so only its spacing is corrected — length happens to already match here |
|
|
793
|
+
| 39 | `en-GB` | `documents␣—␣PDFs␣—␣and you` | `documents – PDFs – and you` | the same input in the other English locale: en-GB's parenthetical is `en-spaced`, so the length converts (em → en) as well as the spacing already being correct — this is the M4 corpus's own shape, reopened on the same operator decision as row 38 |
|
|
794
|
+
| 40 | `en-US` | `The plan␣-␣if there is one␣-␣fails.` | `The plan—if there is one—fails.` | **hyphen input is unaffected** — this is the conversion the rule exists for, and it still fires |
|
|
795
|
+
| 41 | `de-DE` | `Der Plan—falls es einen gibt—fällt.` | `Der Plan – falls es einen gibt – fällt.` | an em dash typed by the author; de-DE's parenthetical is `en-spaced`, so both the length (em → en) and the spacing (tight → spaced) are corrected. Under 0.1.0's guard, and under the briefly-shipped 0.2.0 narrowing, this row stayed unchanged — retiring the guard outright is what converts it |
|
|
796
|
+
| 42 | `en-US` | `a-–b` | `a—b` | a **mixed run** of `DASH` glyphs is a single token, promoted exactly as the equivalent hyphen run (`a--b`) would be — P3 admits a `k = 2` run regardless of spacing |
|
|
797
|
+
| 43 | `en-US` | `1914–1918` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); already correctly a no-op under default options regardless. See [ranges.md](ranges.md) §6 row 43 for `ranges`' own behaviour (also a no-op here, since the token is already exactly `en-tight`) when explicitly enabled |
|
|
798
|
+
| 44 | `en-US` | `1914-1918` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); a byte-identical no-op with default options. See [ranges.md](ranges.md) §6 row 44 for what `ranges` does with this input when explicitly enabled (converts and binds) |
|
|
799
|
+
|
|
800
|
+
### The joiner-transparency repair — §3.2b, dashes.md's current scope
|
|
801
|
+
|
|
802
|
+
Every row in the pre-0.5.0 version of this subsection was a digit-flanked token — `ranges`'
|
|
803
|
+
territory exclusively now. They are relocated to [ranges.md](ranges.md) §6 (rows 45-47), which is
|
|
804
|
+
the current normative source; row numbers are unchanged for citation stability. With default
|
|
805
|
+
options every one of them is a no-op here, for the same reason as rows 43-44 above: `dashes`
|
|
806
|
+
never reaches a digit-flanked token at all.
|
|
807
|
+
|
|
808
|
+
| # | Locale | Input | Output | Why |
|
|
809
|
+
| --- | ------- | ----------- | ------ | -------------------------------------------------------------------------------------------------------- |
|
|
810
|
+
| 45 | `de-DE` | `1-1␣-␣1` | ⟶ | digit-flanked — never reaches `dashes`; see [ranges.md](ranges.md) §6 row 45 (defect (e), §8.2, reproduced there under explicit opt-in) |
|
|
811
|
+
| 46 | `de-DE` | `1–1␣-␣1` | ⟶ | digit-flanked — never reaches `dashes`; see [ranges.md](ranges.md) §6 row 46 (the control for row 45) |
|
|
812
|
+
| 47 | `ru` | `1-1␣-␣1` | ⟶ | digit-flanked — never reaches `dashes`; see [ranges.md](ranges.md) §6 row 47 |
|
|
813
|
+
|
|
814
|
+
### `el` — `parenthetical: "none"` (`range` is now [ranges.md](ranges.md)'s field, also `"none"`, not this rule's)
|
|
815
|
+
|
|
816
|
+
The first locale with `"none"` on **both** fields, so the rule is a **total no-op** for it in the
|
|
817
|
+
same provable sense `hyphen` is a no-op for a locale with empty lists. Tokens are still
|
|
818
|
+
classified — §3.3's "a range token is never reconsidered as a parenthetical" still holds — but no
|
|
819
|
+
classification has an emission to make. The rows below are therefore all "no change" rows by
|
|
820
|
+
construction, and they are worth pinning precisely because nothing else in the suite exercises
|
|
821
|
+
the both-`none` combination.
|
|
822
|
+
|
|
823
|
+
**The two fields are `"none"` for two different reasons, and collapsing them into one sentence
|
|
824
|
+
misrepresents the source.** An earlier revision of this section said the Greek source "is not
|
|
825
|
+
silent here, it is _ambivalent_". That is true of `range` and **false of `parenthetical`**, where
|
|
826
|
+
the guide is explicit.
|
|
827
|
+
|
|
828
|
+
- **`range: "none"` — genuine ambivalence.** The rule appears **twice** in §10.1.8 of the EU
|
|
829
|
+
Interinstitutional Style Guide (Greek edition), once under «Ενωτικό (-)» and once under «Παύλα
|
|
830
|
+
μεσαίου μεγέθους (–)», and **each occurrence explicitly concedes the other mark**. A source
|
|
831
|
+
that names both forms in both places is not being vague; it is declining to rank them.
|
|
832
|
+
Emitting either would express a preference the citation does not carry, so the rule emits
|
|
833
|
+
nothing.
|
|
834
|
+
- **`parenthetical: "none"` — a schema limit, not ambivalence.** The guide prescribes a U+2014
|
|
835
|
+
pair (its own header gives «Alt 0151») and states the spacing exactly: «Όπως η παρένθεση, η
|
|
836
|
+
διπλή παύλα δεν χωρίζεται με κενά διαστήματα από τη λέξη, φράση ή πρόταση που περικλείει·
|
|
837
|
+
αντίθετα, μπαίνουν διαστήματα πριν από την πρώτη και μετά τη δεύτερη παύλα.» — ordinary spaces
|
|
838
|
+
**outside** the pair, none on the **inner** edges. `dash.parenthetical`'s enum has no value for
|
|
839
|
+
that asymmetry: `"em-spaced"` puts a space on each side of _each_ dash, which is precisely what
|
|
840
|
+
the sentence forbids, and `"em-tight"` drops the outer spaces the sentence requires. So the
|
|
841
|
+
source is clear and the schema cannot record it. `"none"` is what the file must say until the
|
|
842
|
+
enum grows; the gap is with the operator and is **not** a licence to approximate.
|
|
843
|
+
|
|
844
|
+
In both cases `"none"` is the honest encoding, not a placeholder awaiting a default (§2) — but
|
|
845
|
+
only one of them is waiting on a _source_. `spec/locales/el.json`'s `dashes.note` carries the
|
|
846
|
+
detail, including a **source conflict** this document does not try to settle: the EU guide sets
|
|
847
|
+
U+2014 parenthetically while the state school grammar appears to set U+2013.
|
|
848
|
+
|
|
849
|
+
| # | Input | Output | Why |
|
|
850
|
+
| --- | -------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------- |
|
|
851
|
+
| 48 | `άτομα ηλικίας 25-45 ετών` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 48 for `ranges`' own no-op here (`range: "none"` — nothing to emit even when explicitly enabled) |
|
|
852
|
+
| 49 | `Το σχέδιο␣-␣αν υπάρχει␣-␣αποτυγχάνει` | ⟶ | classified as a parenthetical, `parenthetical: "none"` → nothing emitted |
|
|
853
|
+
| 50 | `την περίοδο 1989–1991` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); already an en dash in the form the source prints, so [ranges.md](ranges.md) is inert here too even when explicitly enabled |
|
|
854
|
+
|
|
855
|
+
### A locale with no verified convention — `parenthetical: "none"` (`range` is now [ranges.md](ranges.md)'s field, also `"none"`, not this rule's)
|
|
856
|
+
|
|
857
|
+
| # | Input | Output | Why |
|
|
858
|
+
| --- | ------------------------------------- | ------ | ------------------------------------------------------------------------------- |
|
|
859
|
+
| 51 | `The plan␣-␣if there is one␣-␣fails.` | ⟶ | the token is classified as parenthetical and then nothing is emitted |
|
|
860
|
+
| 52 | `1914-1918` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 52 for `ranges`' own no-op here (locale has no verified `range` convention) |
|
|
861
|
+
|
|
862
|
+
Cases 3e, 3f, 3g, 3h, 3i, 4, 5, 6, 7, 8, 9, 10, 12, 15, 16, 17a, 17b, 18, 19, 20, 21, 24, 25, 26,
|
|
863
|
+
28, 29, 30, 31, 33, 34, 35, 36, 37, 43, 46, 48, 49, 50, 51 and 52 are "no change" cases. (Rebuilt
|
|
864
|
+
programmatically from the rows themselves and re-checked against spec 0.2.0's live
|
|
865
|
+
`transform()` output. **17c, 27, 38, 39, 41 and 42 are no longer "no change" as of 0.2.0** and
|
|
866
|
+
were removed from this list — each converts visibly now that the authored-dash guard is
|
|
867
|
+
retired. 17a, 17b and 34 use guards unrelated to authorship — P4 and the symmetry guard — and
|
|
868
|
+
remain no-change exactly as before. Re-derive this list from the table, not from memory,
|
|
869
|
+
before trusting it: it has been wrong twice already in this document's history.)
|
|
870
|
+
|
|
871
|
+
---
|
|
872
|
+
|
|
873
|
+
## 7. Open questions
|
|
874
|
+
|
|
875
|
+
**Every item below is current** — an open question or a decided limitation of `dashes` itself, as
|
|
876
|
+
of spec 0.6.0. Range-specific open questions (ASCII-only digits, decimal ranges, abbreviated-year
|
|
877
|
+
ranges, scores, the U+2060 review hazard) moved to [ranges.md](ranges.md) §7, since `ranges` is
|
|
878
|
+
the rule they are now open *against*; purely historical material (the authored-dash guard's
|
|
879
|
+
development, the compound-label widening's original acceptance) moved to §8.
|
|
880
|
+
|
|
881
|
+
1. **Named date ranges typed with an ASCII hyphen remain a genuine cross-rule miss — narrowed but
|
|
882
|
+
not closed by P5 (spec 0.6.0).** P5 only ever declines an *already-authored* U+2013; it has no
|
|
883
|
+
opinion about a hyphen. `1 May - 3 June` has a letter on one side of the hyphen, so `dashes`
|
|
884
|
+
(not `ranges`) still processes it and still produces an em dash in `en-US`; only
|
|
885
|
+
`1 May – 3 June` — where the author already typed U+2013 — is now protected. `dashes` owns the
|
|
886
|
+
remaining failure on its own side — it has no month-name data and P1/P4 give it no way to
|
|
887
|
+
recognise the hyphen-typed shape as anything but an ordinary parenthetical dash — and `ranges`
|
|
888
|
+
cannot help either, since `ranges` never sees a letter-flanked token at all (§1, §3.3).
|
|
889
|
+
Recognising the hyphen-typed case would need month-name lists, which is locale data the schema
|
|
890
|
+
does not have and which is not mine to add
|
|
891
|
+
unilaterally. Recorded as a known miss on `dashes`' side of the boundary.
|
|
892
|
+
2. **P4 declines rather than converts, so `XV-XVII` keeps its hyphen** (§6 case 17b). The full fix
|
|
893
|
+
is to admit `ROMAN` runs as range-like candidates with guards of their own — but a
|
|
894
|
+
length-based guard does not transfer cleanly (`XV`/`XVII` differ in length) and a
|
|
895
|
+
non-decreasing check would need Roman-to-integer evaluation, a parser rather than a comparison.
|
|
896
|
+
Given all-caps English words built from Roman letters (`MIX`, `CIVIL`, `VIVID`), converting is
|
|
897
|
+
the direction that can damage, so v1 declines. If Russian fixtures show the miss matters, the
|
|
898
|
+
right shape is a length-bounded `ROMAN` run (say ≤ 7 code points) plus a real numeral
|
|
899
|
+
evaluation, specified here first.
|
|
900
|
+
3. **A dash whose spacing is partly no-break is never normalised at all.** If an author writes
|
|
901
|
+
`mot⍽- autre` by hand, this rule declines rather than converting, because it cannot tell that
|
|
902
|
+
U+00A0 from one `nbsp` put there. That is a deliberate miss on the conservative side, and it is
|
|
903
|
+
the price of the structural discharge in §5.4. If real content shows it, the fix is **not** to
|
|
904
|
+
reinterpret no-break spacing here — that road has produced defects (§8.2) — but to have `nbsp`
|
|
905
|
+
avoid creating the shape, which requires it to know what a dash token is.
|
|
906
|
+
4. **T1 (§3.2 step 8) costs a small class of legitimate normalisations**, and the cost is worth
|
|
907
|
+
naming rather than hiding. In a `-spaced` parenthetical locale, a tight dash that sits directly
|
|
908
|
+
against a digit run which has another dash beyond it is left alone: `de-DE` leaves
|
|
909
|
+
`Anhang A–1-2` untouched, where an unguarded rule would produce `Anhang A – 1-2`. I judge that
|
|
910
|
+
a _gain_ — `A–1`, `B-2`, `Teil C-3` are identifiers, and spacing them out is a false positive of
|
|
911
|
+
the kind the M4 gate exists to catch — but it is a behaviour change reached for a composition
|
|
912
|
+
reason (protecting `ranges`' next-pass verdict, §5.3), not a typographic one, and it should be
|
|
913
|
+
confirmed against real content. The guard fires only when a second dash is present near the
|
|
914
|
+
digit run, so the ordinary `Kapitel 3—Einleitung` still normalises.
|
|
915
|
+
5. **Two of this rule's guards now exist mainly for composition with `ranges` rather than for
|
|
916
|
+
`dashes`' own typography** (step 7's cluster guard and step 8, T1). That is real machinery for
|
|
917
|
+
inputs — `a–1 - 1`, `a- - a` — that no author writes deliberately, kept because it protects a
|
|
918
|
+
*different* rule's next-pass verdict (§5.3) as much as it protects this rule's own. The
|
|
919
|
+
alternative design, which was not taken, is to declare a token inert whenever *any* other dash
|
|
920
|
+
lies within some fixed radius — one guard instead of two, but one that would also drop
|
|
921
|
+
`pp. 34-36 — see notes` and `— 1914-1918 годы`, both real Russian and English copy. Recorded so
|
|
922
|
+
the trade is visible if the guard set ever needs simplifying.
|
|
923
|
+
6. **Decided refusals, verified against Lebedev's live service.** Recorded here so they are not
|
|
924
|
+
re-litigated:
|
|
925
|
+
- **A direct-speech dash at line start** — `—` opening a line of dialogue, and the conversion
|
|
926
|
+
of a leading `-` into one. Refused, and it is refused _by construction_ rather than by
|
|
927
|
+
preference: in `markdown` a leading `- ` is a list marker, so a rule that rewrote it would
|
|
928
|
+
silently destroy list structure in the author's own content format. §3.2 step 5 already
|
|
929
|
+
declines a token with no content to its left, and that is now a deliberate guarantee rather
|
|
930
|
+
than a side effect.
|
|
931
|
+
- **ISO date reformatting** (`2026-08-15` → any other form). Refused, and as of spec 0.5.0 this
|
|
932
|
+
is entirely [ranges.md](ranges.md)'s question to answer, not this rule's — `dashes` never
|
|
933
|
+
sees a digit-flanked token at all (§1, §3.3). Recorded here because the refusal predates the
|
|
934
|
+
split and the reasoning (declining the whole cluster rather than guessing) is the shared
|
|
935
|
+
philosophy both rules still apply via the cluster guard (§3.2 step 7).
|
|
936
|
+
- **`->` and `=>` as arrows.** Refused — they are source code far more often than prose, and
|
|
937
|
+
the symmetry guard already declines them.
|
|
938
|
+
- **Wrapping output in markup.** Refused as an architectural matter — see `symbols.md` §7.8.
|
|
939
|
+
Encoding a _guess_ about what a digit-dash chain means, rather than declining it outright, is
|
|
940
|
+
what makes an external service's markup-wrapping approach dangerous (it once wrapped a date
|
|
941
|
+
as a phone number); declining the whole cluster is the better trade.
|
|
942
|
+
7. **T2's set is deliberately wider than `spaces`' `STRIP-BEFORE`, by U+2026, and no fixture
|
|
943
|
+
covers the difference.** §3.2 step 9 declines a `-spaced` form before U+2026 although `spaces`
|
|
944
|
+
would not delete a space there. Both readings are idempotent, so the property suite cannot
|
|
945
|
+
separate them — **only a fixture can**, and `a--…` in a `-spaced` locale has none. Needed: one
|
|
946
|
+
positive row per `-spaced` locale asserting that the input is returned unchanged. Until it
|
|
947
|
+
exists, a port could transcribe either set and stay green. (T2 is shared with `ranges`, §3.3
|
|
948
|
+
there — the same gap applies to a `-spaced` range replacement before U+2026.)
|
|
949
|
+
|
|
950
|
+
---
|
|
951
|
+
|
|
952
|
+
## 8. History
|
|
953
|
+
|
|
954
|
+
**This section is explicitly non-normative.** §1 through §7 above are the complete current
|
|
955
|
+
contract for `dashes`; nothing below is required of a conformant implementation. §8 exists so the
|
|
956
|
+
reasoning trail and defect record that produced that contract are not lost — several guards in
|
|
957
|
+
§3.2 and guarantees in §5 exist *because* of a specific historical counterexample, and an
|
|
958
|
+
implementer who understands why a guard exists is less likely to remove it by mistake. Every
|
|
959
|
+
subsection is dated and points to whichever current document — this one or
|
|
960
|
+
[ranges.md](ranges.md) — now normatively owns the behaviour it describes.
|
|
961
|
+
|
|
962
|
+
### 8.1 The authored-dash guard: introduction and full retirement (spec 0.1.0 → 0.2.0)
|
|
963
|
+
|
|
964
|
+
Spec 0.1.0 introduced an unconditional guard at what is now §3.2 step 2a: any dash run containing
|
|
965
|
+
U+2013 or U+2014 was declined outright, glyph and spacing both, whatever the locale said. It was
|
|
966
|
+
itself a repair — an M4 corpus run found 1063 lines across 155 files where restyling an author's
|
|
967
|
+
dash (length and spacing changed together) was pure damage against a criterion (PLAN.md §8) that
|
|
968
|
+
names exactly that as a ship blocker.
|
|
969
|
+
|
|
970
|
+
A later revision inside 0.2.0 narrowed the guard rather than removing it — preserve the author's
|
|
971
|
+
dash length, correct only the spacing — after a `Минус — при разногласиях`-shaped operator
|
|
972
|
+
complaint showed the unconditional form leaving genuinely mis-spaced dashes broken forever. That
|
|
973
|
+
narrower design shipped only briefly: it rested on treating a dash's length as reliably an
|
|
974
|
+
authorial decision, and on review that premise did not hold in general — a dash's length in
|
|
975
|
+
ordinary prose is at least as often a copy-paste artefact or plain unfamiliarity with which mark
|
|
976
|
+
is which as it is deliberate, and this project has no way to tell the two apart from context.
|
|
977
|
+
**The guard was retired outright, on the same footing this rule has always used for spacing:
|
|
978
|
+
correcting an author's mechanical error is what a normalising pass is for, and an author who
|
|
979
|
+
wants their own typography untouched has always had the option of not running one.**
|
|
980
|
+
|
|
981
|
+
From spec 0.2.0 onward, U+2013/U+2014 carry no token-level special case anywhere in the algorithm:
|
|
982
|
+
a run holding them is classified and replaced exactly as the same run spelled with U+002D would be
|
|
983
|
+
(current: §3.1, §3.2 step 2a, §3.4 P2). Two consequences, current and worth restating plainly:
|
|
984
|
+
|
|
985
|
+
- **Every locale's dash convention now governs every dash in the document, hyphen-typed or not.**
|
|
986
|
+
`documents — PDFs, invoices — and you` and `documents–PDFs, invoices–and you` both become
|
|
987
|
+
`documents—PDFs, invoices—and you` in `en-US` (§6 case 38). This is the M4 corpus's 1063-line
|
|
988
|
+
class, reopened deliberately: the operator judged the M4 gate's finding reflected a false
|
|
989
|
+
premise about dash length being authorial, not a property the tool had to preserve regardless
|
|
990
|
+
of that premise.
|
|
991
|
+
- **A range already in its exact target form is still never bound; a range needing any visible
|
|
992
|
+
correction is** — current, [ranges.md](ranges.md) §3.3.1's invisible-edit test, not this rule's
|
|
993
|
+
concern at all as of spec 0.5.0 (§1). This asymmetry is unaffected by the guard's retirement —
|
|
994
|
+
it was never about authorship, only about reviewability (§8.6) — and remains the one most likely
|
|
995
|
+
to be reported as a bug (§6 cases 43, 44).
|
|
996
|
+
|
|
997
|
+
The length restriction could be reopened only by a locale field distinguishing "normalise dash
|
|
998
|
+
length" from "promote hyphens only" — a caller preference, not a typographic fact about a
|
|
999
|
+
language, and so not something that belongs in a locale file.
|
|
1000
|
+
|
|
1001
|
+
The section number `2a` (§3.2) is kept rather than renumbered, for the same reason a retired step
|
|
1002
|
+
generally keeps its number in this document: several documents cite it (this one's own §3.1,
|
|
1003
|
+
§3.4; `ranges.md` §3.1).
|
|
1004
|
+
|
|
1005
|
+
### 8.2 Idempotency defects of the pre-0.5.0 combined rule
|
|
1006
|
+
|
|
1007
|
+
Through spec 0.4.x, this rule evaluated both the parenthetical and the digit-flanked (range)
|
|
1008
|
+
branches of every dash token, and five idempotency defects were found and closed during that
|
|
1009
|
+
period. Two remain live concerns for `dashes` alone today (§5.2 restates their guards directly,
|
|
1010
|
+
without walking through the defect history); three were purely an interaction between the two
|
|
1011
|
+
branches and are now `ranges`' own composition obligation ([ranges.md](ranges.md) §4).
|
|
1012
|
+
|
|
1013
|
+
**Defect (b) — `DASH SPACE DASH`, current guard: §3.2 step 6.** `en-US`, input `a- - a`. The first
|
|
1014
|
+
hyphen was asymmetric and rejected; the second, symmetric with `cp[L] = "-"`, was not rejected by
|
|
1015
|
+
the guard then in place, so it normalised to a tight em dash — placing it directly against the
|
|
1016
|
+
first hyphen, where the two formed a single run of length 2 with a different verdict on the next
|
|
1017
|
+
pass (`a- - a → a-—a → a—a`). **Repaired by extending §3.2 step 6** to reject a `DASH` neighbour at
|
|
1018
|
+
`cp[L]` or `cp[R]`, which is current §5.2's "`cp[L]`, `cp[R]`" argument and also what gives §3.5
|
|
1019
|
+
its non-overlap guarantee.
|
|
1020
|
+
|
|
1021
|
+
**Defect (d) — a manufactured left space, current guard: §3.2 step 3.** `fr`, where
|
|
1022
|
+
`quotes.primary.innerSpace` is `nbsp` and `dash.parenthetical` is `em-spaced`: `«– " → « – " → «
|
|
1023
|
+
— "`. Five witnesses, one family, `fr` only: `«` followed by U+002D or U+2013, then U+0020, then
|
|
1024
|
+
any content. On pass 1 the token was asymmetric (`«` is not a space) and declined; `nbsp` then
|
|
1025
|
+
inserted the guillemet's inner U+00A0; on pass 2, under the *previous* rule ("a no-break space
|
|
1026
|
+
counts as spacing on the left"), the token read as symmetric and was wrongly promoted from en to
|
|
1027
|
+
em. This mattered more than (a)–(c) below: the input was *already-typeset text* built from
|
|
1028
|
+
characters the pipeline itself emits (`«` from `quotes`, U+00A0 from `nbsp`, the dash from this
|
|
1029
|
+
rule), reachable by re-processing the pipeline's own output — the CMS-on-every-save case
|
|
1030
|
+
PLAN.md §1 names as the reason idempotency is the package's headline property. It was also
|
|
1031
|
+
self-inflicted: the left-only rule was introduced as the repair for an earlier `nbsp`-composition
|
|
1032
|
+
defect, and was precisely what made this one reachable. **Repaired by removing that whole class of
|
|
1033
|
+
reasoning** (current §3.2 step 3): only U+0020 is this rule's spacing, a no-break space on either
|
|
1034
|
+
side makes the token inert, and every character `nbsp` can emit is therefore inert here — the
|
|
1035
|
+
structural CO-S discharge current §5.4 states.
|
|
1036
|
+
|
|
1037
|
+
**Defect (a) — `DASH digits DASH`, historical: now `ranges`' concern.** `ru`, input `a—0–0`. The
|
|
1038
|
+
em dash was a parenthetical token that normalised to `␣—␣`; the en dash was a range candidate
|
|
1039
|
+
whose `before` was that em dash (G2 rejected it); after the first edit, `before` became a space,
|
|
1040
|
+
G2 no longer rejected, and the second pass emitted `0—0` — not a fixed point. Repaired by the
|
|
1041
|
+
cluster guard (§3.2 step 7, shared): `a—0–0` is one cluster with two dash runs, so both tokens are
|
|
1042
|
+
inert from the start. The cluster guard remains current and shared (§5.2), but the *reason* it
|
|
1043
|
+
mattered here — a range token's G2 reading a neighbour's dash — is [ranges.md](ranges.md) §4's own
|
|
1044
|
+
composition-obligation proof now, not this document's.
|
|
1045
|
+
|
|
1046
|
+
**Defect (c) — the spaced sibling of (a), historical: now `ranges`' concern.** `de-DE`/`ru`, input
|
|
1047
|
+
`a–1 - 1`: the cluster guard from defect (a) was not enough, because a *spaced* range token's own
|
|
1048
|
+
outer spacing ends its cluster before it reaches the neighbouring dash. Witness: `a–1 - 1 → a – 1
|
|
1049
|
+
- 1 → a – 1–1`, reproduced in every locale whose parenthetical is `-spaced` and whose range is not
|
|
1050
|
+
`none`. Repaired by the spacing-transition guard (T1, §3.2 step 8): a tight token may not become
|
|
1051
|
+
spaced when doing so would insert a space between itself and a digit run that has another dash on
|
|
1052
|
+
its far side. T1 is current and lives in `dashes` (current §5.3) precisely because it is
|
|
1053
|
+
`dashes`' own edit that must not disturb `ranges`' verdict on the next pass — the guard itself is
|
|
1054
|
+
current, but the G2-side half of the story (why this is the only verdict input `dashes` can
|
|
1055
|
+
perturb) is `ranges`' concern; the full historical argument is §8.3.
|
|
1056
|
+
|
|
1057
|
+
**Defect (e) — the joiner blinds a neighbour's G2, historical: now `ranges`' concern.** Every
|
|
1058
|
+
locale whose range is not `none`, witness `1-1 - 1 → 1⟨J⟩–⟨J⟩1 - 1 → 1⟨J⟩–⟨J⟩1⟨J⟩–⟨J⟩1` (seven
|
|
1059
|
+
characters — beyond the length-4 exhaustive bound normative at the time, which is why the shipped
|
|
1060
|
+
suite did not catch it). §3.3.1's joiner sat between a digit run and a dash, so on the next pass
|
|
1061
|
+
the neighbouring token's `before` read as a `JOINER`, in neither `DASH` nor `INERT-DASH`, and G2
|
|
1062
|
+
wrongly admitted a range that should still have been rejected. Repaired by §3.2b: every guard that
|
|
1063
|
+
leaves its own token reads an *effective neighbour* that skips joiners, so `before`/`after` see
|
|
1064
|
+
through the joiner to the real dash underneath. §3.2b is current and shared; the G2-specific
|
|
1065
|
+
consequence is [ranges.md](ranges.md)'s own concern now.
|
|
1066
|
+
|
|
1067
|
+
**Summary of fixes**, for citation stability (§6 cases 18–21, 24–26 pin these):
|
|
1068
|
+
|
|
1069
|
+
1. Defect (b): extending step 6 to reject a `DASH` neighbour.
|
|
1070
|
+
2. Defect (a): the cluster guard, step 7.
|
|
1071
|
+
3. Defect (c): the spacing-transition guard, step 8.
|
|
1072
|
+
4. Defect (d): removing "no-break space counts as spacing" entirely, step 3.
|
|
1073
|
+
5. Defect (e): joiner-transparent effective neighbours, §3.2b.
|
|
1074
|
+
|
|
1075
|
+
### 8.3 The pre-0.5.0 range-token verdict argument (G2 as the one perturbable input)
|
|
1076
|
+
|
|
1077
|
+
Reproduced as history — this is the fullest statement of *why* T1 was the only guard defects (c)
|
|
1078
|
+
and (e) needed, from when `before`/`after`/G1-G5 were still this document's own concern rather
|
|
1079
|
+
than `ranges`':
|
|
1080
|
+
|
|
1081
|
+
For a range token `T`, the verdict inputs `Lrun`, `Rrun` (digit runs) are untouched by any edit —
|
|
1082
|
+
no edit touches a digit. `before`/`after` are the code points adjacent to `Lrun`/`Rrun`, and for a
|
|
1083
|
+
*spaced* token they lie two positions beyond `cp[L]`/`cp[R]`, outside the token's own cluster,
|
|
1084
|
+
where neither the isolation guard (step 6) nor the cluster guard (step 7) can see them. G1, G3 and
|
|
1085
|
+
G5 read them for membership in `LETTER`, `.`/`,`/`/` and digit value — none of which any edit can
|
|
1086
|
+
produce or destroy. **G2 reads them for membership in `DASH` ∪ `INERT-DASH`, and that is the one
|
|
1087
|
+
verdict input the rule was capable of changing**, via a `-spaced` replacement: a neighbouring
|
|
1088
|
+
tight token that becomes spaced replaces a dash at that position with a space, turning a G2
|
|
1089
|
+
rejection into a G2 admission.
|
|
1090
|
+
|
|
1091
|
+
Every instance of that flip has the same shape. Let `T` be a range token G2 rejected, so `before`
|
|
1092
|
+
(say) is a dash at index `d-1`, where `d` is the start of `Lrun`. For that dash to become a space,
|
|
1093
|
+
it must be inside some edited token `A`'s span, and `A`'s replacement must place a U+0020 at
|
|
1094
|
+
exactly that index — which requires `A`'s run to end at `d-1` with `rsp_A = 0`, and `A`'s chosen
|
|
1095
|
+
form to be `-spaced`. In other words: `A` is tight, `A` is about to become spaced, and the side of
|
|
1096
|
+
`A` about to gain a space faces `Lrun`, which has a dash on its far side. That is precisely, and
|
|
1097
|
+
only, the configuration T1 (§3.2 step 8) declares inert. The mirrored argument gives the `after`
|
|
1098
|
+
side. T1's two-code-point reach is exactly what the case requires and no more: `T`'s dash sits at
|
|
1099
|
+
distance 1 from `Lrun` if `T` is tight and distance 2 if `T` is spaced, and a `-spaced` replacement
|
|
1100
|
+
inserts exactly one U+0020, so no other distance is reachable. The reverse flip — `before` going
|
|
1101
|
+
from a space to a dash — is benign and needs no guard: it turns a G2 admission into a G2
|
|
1102
|
+
rejection, and a rejection emits nothing, so a token converted on an earlier pass simply keeps the
|
|
1103
|
+
form it was given.
|
|
1104
|
+
|
|
1105
|
+
The current version of this argument, for the tokens `ranges` now owns exclusively, is
|
|
1106
|
+
[ranges.md](ranges.md) §4.
|
|
1107
|
+
|
|
1108
|
+
### 8.4 Range binding and CO-S under the retired authored-dash guard (spec 0.2.0)
|
|
1109
|
+
|
|
1110
|
+
Reproduced as history: at the time this was written, a tight range was bound (§3.3.1, pre-0.5.0)
|
|
1111
|
+
by this same rule, and the CO-S argument below is what proved that binding survived the authored
|
|
1112
|
+
dash guard's retirement. A tight range this rule bound was, on its next pass, a single U+2013 or
|
|
1113
|
+
U+2014 reached by crossing its own `JOINER` pair. Under 0.1.0's guard this was the case the guard
|
|
1114
|
+
was built to recognise (via the crossed-joiner check re-admitting a digit-flanked token); under
|
|
1115
|
+
0.2.0 the same token was just an ordinary range candidate that happened to already hold the
|
|
1116
|
+
correct glyph, and §3.3.1's (pre-0.5.0) *invisible-edit test* — compute the unbound form, and bind
|
|
1117
|
+
only if the unbound form would itself be a visible change — is what kept it a fixed point: the
|
|
1118
|
+
unbound form equalled the input exactly (right glyph, right spacing), so `bind` stayed `false` and
|
|
1119
|
+
the joiners were re-affirmed rather than stripped. This was confirmed, not merely argued, at the
|
|
1120
|
+
time: the then-combined rule's exhaustive bounded sweep and its `fast-check` idempotency property
|
|
1121
|
+
both covered the 0.2.0 code path and were green.
|
|
1122
|
+
|
|
1123
|
+
The current version of this proof, for `ranges`' own binding, is [ranges.md](ranges.md) §4's
|
|
1124
|
+
rule-order argument together with `tests/rules/ranges.test.ts`.
|
|
1125
|
+
|
|
1126
|
+
### 8.5 Compound labels and the G4 `(1,2)` branch: why the widening was accepted (spec ≤ 0.4.1)
|
|
1127
|
+
|
|
1128
|
+
`Figure 5-10`, `Table 3-12` and `Section 2-14` mean "chapter 5, figure 10" — a two-part label, not
|
|
1129
|
+
a range — and, when the rule that owned range detection also owned an equal-length-or-`(1,2)`
|
|
1130
|
+
guard, the hyphen converted to an en dash. So did the proper nouns `9-11` and `7-11`. The exposure
|
|
1131
|
+
was pre-existing, not introduced by the `(1,2)` branch: `Figure 3-7` is `(1,1)`, satisfies the
|
|
1132
|
+
equal-length branch, and had converted since the first version of the rule. What the `(1,2)`
|
|
1133
|
+
branch did was *widen* an exposure that already shipped — from `Figure 3-7` to `Figure 5-10` as
|
|
1134
|
+
well. That distinction is why the widening was judged acceptable at the time: declining `5-10`
|
|
1135
|
+
bought no protection against the class that was actually exposed, while costing a construction
|
|
1136
|
+
(`takes 5-10 days`, `aged 9-10`, `0-60`) about as common as ranges get in English.
|
|
1137
|
+
|
|
1138
|
+
Separating the two needs the preceding noun — `Figure`, `Table`, `Section`, `Fig.`, `Abb.`,
|
|
1139
|
+
`рис.` — an open-ended, per-locale, per-house-style word list with no natural closure and no
|
|
1140
|
+
citable evidence at the time (or since). The failure mode was judged mild relative to the
|
|
1141
|
+
phone-number case: a hyphen becoming an en dash inside a label is a normalisation a reader may not
|
|
1142
|
+
even notice, whereas converting `555-1234` would corrupt a number.
|
|
1143
|
+
|
|
1144
|
+
**Current status.** As of spec 0.5.0, this entire question belongs to `ranges` (order 25, off by
|
|
1145
|
+
default) — see [ranges.md](ranges.md) §1 and §5 for the current statement of the same tradeoff,
|
|
1146
|
+
which is unchanged in substance from what is reproduced above, only in which document and which
|
|
1147
|
+
rule's default-on status governs its reachability. `dashes.md` §6 row 3j shows the current
|
|
1148
|
+
byte-identical no-op with default options; `ranges.md` §6 row 3j shows what `ranges` still does
|
|
1149
|
+
with the same input when explicitly enabled.
|
|
1150
|
+
|
|
1151
|
+
### 8.6 The U+2060 range binding: a review hazard, and what it cost (spec 0.3.x)
|
|
1152
|
+
|
|
1153
|
+
Every other change this rule made, before the split, was catchable by eye — a hyphen becomes a
|
|
1154
|
+
dash, a space appears or disappears. §3.3.1's joiner was zero-width: the bound form
|
|
1155
|
+
`1914⟨J⟩–⟨J⟩1918` and the unbound `1914–1918` were pixel-identical in every font, and a unified
|
|
1156
|
+
diff showed a changed line with no visible difference on it. Two consequences, both paid for once:
|
|
1157
|
+
|
|
1158
|
+
- A worked-example table row that omitted the joiner looked correct on screen and was wrong at
|
|
1159
|
+
the byte level — several rows stayed stale after §3.3.1 landed, caught only by a fixture author
|
|
1160
|
+
checking raw bytes rather than trusting the rendering.
|
|
1161
|
+
- The change was spec-first and legitimate, and still broke 32 fixture cases in another agent's
|
|
1162
|
+
files without announcing itself, because a normative change whose effect cannot be seen needs
|
|
1163
|
+
to be *told*, not shown.
|
|
1164
|
+
|
|
1165
|
+
The joiner also cost one idempotency defect — defect (e), §8.2 — needing a seven-character witness
|
|
1166
|
+
to find, beyond the length-4 exhaustive bound normative at the time. Neither cost is an argument
|
|
1167
|
+
against the binding, which is well-sourced, correct, and what stops a range breaking across lines
|
|
1168
|
+
(the alternative, markup, is forbidden by `modes.md` §4). It is a record of what a zero-width
|
|
1169
|
+
emission costs in reviewability, kept so the next person weighing one can read it rather than
|
|
1170
|
+
re-derive it.
|
|
1171
|
+
|
|
1172
|
+
**Current status.** Range binding is exclusively [ranges.md](ranges.md)'s behaviour now (§3.3.1
|
|
1173
|
+
there); the still-open, general lesson — that a future zero-width emission needs its own
|
|
1174
|
+
announcement, and that fixture rows containing one must be checked in the escaped mirror, never
|
|
1175
|
+
read on screen — is restated as a current open question at [ranges.md](ranges.md) §7.
|
|
1176
|
+
|
|
1177
|
+
### 8.7 Retired locale-field notes (spec 0.5.0)
|
|
1178
|
+
|
|
1179
|
+
`dash.range` carries the same five-value enum as `dash.parenthetical`, including `em-*` (Russian
|
|
1180
|
+
uses it) and `none` — this was already true before the split and needed no schema change to move
|
|
1181
|
+
which rule reads the field (§2). §3.6's old preservation clause (a no-break space beside a dash
|
|
1182
|
+
was to be carried across an edit rather than replaced) is gone: a no-break space beside a dash now
|
|
1183
|
+
makes the token inert (§3.2 step 3 + step 6), so no edit can contain one and there is nothing to
|
|
1184
|
+
preserve — the Russian case it was written for is covered by declining instead (§6 case 34).
|
|
1185
|
+
|
|
1186
|
+
### 8.8 P5, the authored en-dash mark-identity veto: fresh M4 evidence and why it does not reopen
|
|
1187
|
+
§8.1's retired guards (spec 0.6.0)
|
|
1188
|
+
|
|
1189
|
+
A fresh M4 dogfooding pass against the author's own blog corpus, run against the spec 0.5.0
|
|
1190
|
+
implementation candidate, surfaced 8 human-rejected review rows, every one an authored U+2013 that this rule's P2 promoted
|
|
1191
|
+
to U+2014: a cost range (`$0.005–$0.018`), a price-placeholder range (`€X–€Y`), a date range
|
|
1192
|
+
occurring three times (`18 Oct–2 Nov`, tight and spaced), a compound article-title label
|
|
1193
|
+
(`Senior–Junior Gap`), and both dashes of a joint eponymous algorithm name (`Fowler–Noll–Vo`).
|
|
1194
|
+
Independently computed against the same corpus: of the 8598 rows in that review, **exactly 8**
|
|
1195
|
+
touch an authored U+2013 as their edit's source character, and all 8 were the rejects above —
|
|
1196
|
+
**zero** of the corpus's other 8589 accepted edits involve an authored en-dash at all. In this
|
|
1197
|
+
real corpus, every single instance of "author typed U+2013" was range/joint-name damage, and
|
|
1198
|
+
correcting an authored en-dash's glyph never once helped.
|
|
1199
|
+
|
|
1200
|
+
**Why this does not reopen 0.1.0's guard or 0.2.0's narrower one (§8.1).** Both of those declined
|
|
1201
|
+
*any* dash whose length "looked off," including a hyphen-typed compound word's edge case, an
|
|
1202
|
+
authored em-dash that merely needed re-spacing, and a run mixing glyphs — a broad claim the
|
|
1203
|
+
operator judged, on the 1063-line M4 evidence available then, to be wrong more often than right
|
|
1204
|
+
("a dash's length in ordinary prose is at least as often a copy-paste artefact... as it is
|
|
1205
|
+
deliberate"). P5 makes a categorically narrower claim: not about dash *length* in general, but
|
|
1206
|
+
about one specific, already-established mark — U+2013, used **alone**, as a pure single-code-point
|
|
1207
|
+
run — which is not merely "a differently-sized em dash" the way `--` or `---` are, but a distinct
|
|
1208
|
+
conventional glyph with its own established roles (range, connection, joint-naming) that a
|
|
1209
|
+
length-based or "any DASH glyph" argument does not reach. §8.1's own reasoning for retiring the
|
|
1210
|
+
guard — treating length as unreliable authorial signal — does not transfer to treating *which of
|
|
1211
|
+
two established marks the author reached for* as unreliable, and the fresh evidence above is
|
|
1212
|
+
specific to exactly that distinction: not "any authored dash," but "authored U+2013 alone."
|
|
1213
|
+
`a-–b` (a mixed run) and every input holding U+2014 are unaffected by P5 and continue exactly as
|
|
1214
|
+
0.2.0 left them (§6 case 42, case 38) — P5 does not touch em-dash handling at all, and this
|
|
1215
|
+
corpus's evidence gives no reason to: it found zero em-dash rejects.
|
|
1216
|
+
|
|
1217
|
+
**Why P5 is unconditional across every locale, not keyed to `dash.parenthetical`'s target
|
|
1218
|
+
glyph.** An earlier draft of this guard declined only when the locale's own target glyph was not
|
|
1219
|
+
U+2013 (reasoning: if the target *is* U+2013, only spacing changes, never the glyph, so nothing
|
|
1220
|
+
mark-identity-relevant happens). That is true of *this rule's own single-token replacement*, but
|
|
1221
|
+
it does not follow that a target-glyph-conditioned guard is safe: the failure P5 exists to
|
|
1222
|
+
prevent is an authored en-dash range or joint name being silently re-spaced into ordinary
|
|
1223
|
+
sentence punctuation, and that risk is exactly as real when the target glyph is U+2013
|
|
1224
|
+
(`en-GB`/`de-DE`/`de-CH`/`fi`/`sv`, all `en-spaced`) as when it is U+2014 — a tight authored
|
|
1225
|
+
`18 Oct–2 Nov` respaced to `18 Oct – 2 Nov` in an en-spaced locale reads exactly as much like
|
|
1226
|
+
ordinary parenthetical punctuation as the em-dash case does; only the glyph substitution is
|
|
1227
|
+
absent, not the risk. P5 therefore declines the whole token — glyph *and* spacing both — in every
|
|
1228
|
+
locale, on the same footing P4's Roman-numeral veto declines regardless of what
|
|
1229
|
+
`dash.parenthetical` says: a veto that depended on the locale's own target would leave the
|
|
1230
|
+
identical semantic failure reachable in exactly the locales whose target happens to already be
|
|
1231
|
+
U+2013, which is precisely backwards from what fresh evidence about *this* glyph is meant to
|
|
1232
|
+
protect.
|
|
1233
|
+
|
|
1234
|
+
**Fixture cost, computed, not estimated.** Every locale's `dashes` fixtures were checked for a
|
|
1235
|
+
row exercising "authored en-dash promoted to a different glyph": none exists anywhere in the
|
|
1236
|
+
canonical set. The only prose claim P5 falsifies is `en-us-dashes-authored-em-dash-respaced`'s
|
|
1237
|
+
note (§6), which asserted an authored en-dash converts identically to an authored em-dash; that
|
|
1238
|
+
note is corrected in the same change that introduces P5.
|