polytypo 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -0
  3. data/lib/polytypo/data/VERSION +1 -1
  4. data/lib/polytypo/data/fixtures/bg.json +1 -1
  5. data/lib/polytypo/data/fixtures/cs.json +1 -1
  6. data/lib/polytypo/data/fixtures/de-CH.json +1 -1
  7. data/lib/polytypo/data/fixtures/de-DE.json +77 -1
  8. data/lib/polytypo/data/fixtures/el.json +1 -1
  9. data/lib/polytypo/data/fixtures/en-GB.json +89 -1
  10. data/lib/polytypo/data/fixtures/en-US.json +450 -1
  11. data/lib/polytypo/data/fixtures/es.json +1 -1
  12. data/lib/polytypo/data/fixtures/fi.json +1 -1
  13. data/lib/polytypo/data/fixtures/fr-CA.json +1 -1
  14. data/lib/polytypo/data/fixtures/fr.json +41 -1
  15. data/lib/polytypo/data/fixtures/hr.json +1 -1
  16. data/lib/polytypo/data/fixtures/hu.json +1 -1
  17. data/lib/polytypo/data/fixtures/it.json +1 -1
  18. data/lib/polytypo/data/fixtures/locale-resolution.json +1 -1
  19. data/lib/polytypo/data/fixtures/nl.json +1 -1
  20. data/lib/polytypo/data/fixtures/pl.json +1 -1
  21. data/lib/polytypo/data/fixtures/pt-BR.json +1 -1
  22. data/lib/polytypo/data/fixtures/pt-PT.json +1 -1
  23. data/lib/polytypo/data/fixtures/ro.json +1 -1
  24. data/lib/polytypo/data/fixtures/ru.json +1 -1
  25. data/lib/polytypo/data/fixtures/sk.json +1 -1
  26. data/lib/polytypo/data/fixtures/sv.json +1 -1
  27. data/lib/polytypo/data/fixtures/tr.json +1 -1
  28. data/lib/polytypo/data/fixtures/uk.json +1 -1
  29. data/lib/polytypo/data/locales/registry.json +1 -1
  30. data/lib/polytypo/data/rules/analyze.md +16 -4
  31. data/lib/polytypo/data/rules/modes.md +262 -9
  32. data/lib/polytypo/data/rules/order.json +1 -1
  33. data/lib/polytypo/data/schema/fixtures.schema.json +160 -18
  34. data/lib/polytypo/engine/origin.rb +13 -1
  35. data/lib/polytypo/engine/protect.rb +122 -0
  36. data/lib/polytypo/modes/runner.rb +0 -1
  37. data/lib/polytypo/version.rb +1 -1
  38. data/lib/polytypo.rb +34 -19
  39. metadata +2 -1
@@ -3,7 +3,8 @@
3
3
  **Not a rule.** No entry in `spec/rules/order.json`, no locale data of its own, no edits. This
4
4
  document specifies a **second public entry point** beside `transform`, which runs the identical
5
5
  pipeline and reports what it would do instead of doing it.
6
- **Spec version:** 1.3.0 (new in 1.3.0).
6
+ **Spec version:** 1.13.0 (new in 1.3.0; `end` of a non-insertion defined in §2, and the matching
7
+ obligation in §6, in 1.13.0).
7
8
 
8
9
  ---
9
10
 
@@ -23,7 +24,7 @@ transform(input, options) -> string
23
24
  analyze(input, options) -> Change[]
24
25
  ```
25
26
 
26
- Same `options`, same validation, same errors, same purity (`ARCHITECTURE.md` §7: no I/O, no
27
+ Same `options` (`protect` included, modes.md §3.9), same validation, same errors, same purity (`ARCHITECTURE.md` §7: no I/O, no
27
28
  clock, no globals, reentrant). Everything §2 of every rule document says about what the pipeline
28
29
  does applies unchanged — `analyze` **is** the pipeline; it merely keeps the edits instead of
29
30
  discarding them after applying.
@@ -49,8 +50,10 @@ Change {
49
50
  whose strings are UTF-16 must convert; a runtime whose strings are bytes must convert.
50
51
  - **Offsets are into `input` exactly as the caller passed it.** In `html`, `markdown` and
51
52
  `yaml` mode that means offsets into the **document**, not into the span the rules actually ran
52
- over. This
53
- is not a new obligation: [modes.md](modes.md) §4 already defines the output as "the input
53
+ over. A non-insertion's `end` is one past the offset of the last code point it replaced that
54
+ came from the input, so it can never run across a skipped region or a protected run that
55
+ follows (since 1.13.0; 1.12.0 scanned forward to the next code point with an origin). The
56
+ document-offset rule is not a new obligation: [modes.md](modes.md) §4 already defines the output as "the input
54
57
  source with a set of disjoint substring replacements applied **at recorded offsets**", and
55
58
  those are the offsets meant.
56
59
  - `start == end` is a pure insertion; `before` is then empty. `after` empty with `end > start`
@@ -137,6 +140,15 @@ naming:
137
140
 
138
141
  - **A3 against the whole fixture corpus.** For every canonical fixture case, `analyze` returns
139
142
  an empty list exactly when `in == out`. That is a strong test and it costs one loop.
143
+ - **A range that crosses nothing it did not change (since 1.13.0).** A change's `start` and `end`
144
+ come from the code points it replaced, and `end` is not found by scanning forward from the
145
+ edit's end index to the next code point that has an origin: the boundary marker has none, so a
146
+ change ending beside a skipped element or a protected run (modes.md §3.9) would be carried
147
+ across it. Test a change whose last code point is a span's last, followed by a skipped region
148
+ longer than the change: no reported range may overlap a skipped region or a protected run, and
149
+ a non-insertion's `end` is one past the input offset of the last replaced code point that has
150
+ one. (`end - start` need not equal the length of `before`: `before` is what a later rule saw,
151
+ §5.) 1.12.0 reported `<p>x--<code>cc</code>y</p>` as `start` 4, `end` 21.
140
152
  - **A5 under the mode adapters.** A runtime that reports span-local offsets in `html`,
141
153
  `markdown` or `yaml` mode passes every text-mode test and is still wrong. Test a document
142
154
  whose first span does not start at offset 0. `yaml` is the cheapest of the three to get wrong
@@ -7,7 +7,7 @@ for all five runtimes and is parser-agnostic by construction: `parse5`, `nokogir
7
7
  `golang.org/x/net/html` and PHP's DOM disagree about almost everything this document does not
8
8
  forbid them from doing. `yaml` mode is parser-**free** rather than parser-agnostic, for the
9
9
  reason §3.8.1 measures.
10
- **Spec version:** 1.11.0 (0.1.0 for everything except §3.3's class-membership table rows for
10
+ **Spec version:** 1.13.0 (0.1.0 for everything except §3.3's class-membership table rows for
11
11
  `nbsp` and `apostrophe`, split in 1.2.0, §3.8, added in 1.3.0, §3.3's note on `quotes`'
12
12
  span-boundary elision veto reading the marker as a trigger, added in 1.4.0 — which changes no
13
13
  row of the table it follows — §3.3's `CLOSEDELIM` entry, added in 1.5.0, and §3.7.4 with the
@@ -18,7 +18,8 @@ until 1.9.0, with the matching amendments to §3.7.4.1, §3.8.7, §5, §7.13 and
18
18
  step 9 with §3.8.5's content-line condition, added in 1.10.0, §3.8.4's carriage-return region, which replaces step 9's decline of a line carrying a U+000D, added in 1.11.0, §3.8.4 step 7's definition of a
19
19
  continuation line with the bails of §3.8.6 that use it, added in 1.11.0, and §3.8.4's node lines,
20
20
  declined keys, node markers and own-line region, with the changes to steps 1, 3, 4, 5, 6, 7 and 8
21
- that go with them, added in 1.11.0).
21
+ that go with them, added in 1.11.0, and §3.9 with the amendments it carries to §3.1, §3.5 step 2,
22
+ §4, §5, §7 item 7 and §8, added in 1.13.0).
22
23
 
23
24
  ---
24
25
 
@@ -62,7 +63,8 @@ sees; the pipeline decides what to do with them.
62
63
  frontmatter block's spans form a second unit and the pipeline runs once per unit. The term is
63
64
  defined here because §4 and §6 already used it informally for "the concatenation".
64
65
  - `text` mode is the degenerate case: one span covering the whole input, no skipped regions.
65
- Every statement below holds for it trivially.
66
+ Every statement below holds for it trivially. **Since 1.13.0 the `protect` option (§3.9) gives
67
+ `text` mode skipped regions of the caller's choosing**; without it the statement is unchanged.
66
68
 
67
69
  ### 3.2 The span model — the decision everything follows from
68
70
 
@@ -379,6 +381,8 @@ reaches the edge-growth rule. `--` is the live carrier.)
379
381
  gives flow collections no spans — see `quotes.md` §3.2, which depends on the answer for
380
382
  nothing but states the obligation it creates: a rule must key off the marker, never off the
381
383
  mode.
384
+ **With `protect` (§3.9, 1.13.0) the array also carries a marker at the start or the end when the
385
+ input begins or ends with a protected run**, and may be nothing but one marker.
382
386
  3. Run the pipeline **once**, in `order.json` order, over that array. A document with a second
383
387
  text unit — `markdown` with `frontmatterKeys`, and only that (§3.1, §3.7.4) — repeats steps 1
384
388
  to 4 for it. The two edit sets are disjoint, because no span of one unit lies inside the other.
@@ -1355,9 +1359,237 @@ The third row is the mode working as specified, and the first is why the option
1355
1359
 
1356
1360
  ---
1357
1361
 
1362
+ ### 3.9 `protect` — the caller's own skipped regions, `text` mode only (spec 1.13.0)
1363
+
1364
+ `html`, `markdown` and `yaml` find their skipped regions themselves. A caller whose text has holes
1365
+ that no mode knows about — a JS template literal with `${…}`, an ICU MessageFormat string, a
1366
+ Django or Handlebars template, a Python f-string — has the same problem and no mode to name. Until
1367
+ 1.13.0 the only course was to split the string at every hole and call `transform` on each
1368
+ fragment, which is safe and loses every rule's lookaround at every seam:
1369
+
1370
+ | Input, `text` mode | `en-US` | `en-GB` |
1371
+ | ------------------ | ------- | ------- |
1372
+ | `" - Rules"` (the fragment after the hole) | unchanged | unchanged |
1373
+ | `"${x} - Rules"` (the whole literal, no `protect`) | `${x}—Rules` | `${x} – Rules` |
1374
+ | `"${x} - Rules"` with `protect: [{ start: 0, end: 4 }]` | `${x}—Rules` | `${x} – Rules` |
1375
+
1376
+ and in ICU, where the ASCII apostrophe is the escape character, a conversion can change what a
1377
+ message means and not only how it looks: `a '{b}' c` is the literal text `{b}`, and `a ‘{b}’ c`
1378
+ (what `en-GB` produces without `protect`) is an argument placeholder between two curly quotes.
1379
+ Both are the same request, and polytypo/polytypo#14 asked for the mechanism this section
1380
+ specifies. **It is an option and not a fourth-or-fifth mode** (the issue's `mode: "icu"` was
1381
+ declined): a mode would specify one host format's grammar, and the format list has no end. The
1382
+ option specifies none. The caller says which code points are not text and the library reads them
1383
+ as what a skipped element already is between two spans in `html` mode — opaque content.
1384
+
1385
+ #### 3.9.1 The mechanism is Model C, unchanged
1386
+
1387
+ A protected run is a **skipped region** (§3.1) whose position the caller supplied instead of a
1388
+ parser. Everything in §3.2 to §3.5 applies to it: the processable spans are the complement of the
1389
+ protected runs, they are concatenated with a boundary marker where a run was, the pipeline runs
1390
+ **once**, and each edit is mapped back to the input by offset. §3.3's table gives the marker its
1391
+ memberships, §3.4's edge-growth rule governs what may be written beside it, and §4's round-trip
1392
+ guarantee extends to the protected runs, which are returned exactly as written.
1393
+
1394
+ Two things differ from the mode adapters, and they are the only two.
1395
+
1396
+ 1. **A marker is written at the very start or end of the array when the input begins or ends
1397
+ with a protected run.** In `html` and `markdown` a skipped region before the first span or
1398
+ after the last yields no marker (§3.5 step 2: the array is `S₁ ⌢ [m₁] ⌢ … ⌢ Sₘ`). Here it does,
1399
+ because the example above has its hole at the start and must behave as the whole literal
1400
+ does: without the marker the dash has `NONE` on its left, which declines the token, and the
1401
+ option would be useless for the case it exists for. The array is therefore
1402
+ `[S₀] ⌢ m₁ ⌢ S₁ ⌢ m₂ ⌢ … ⌢ mₖ ⌢ [Sₖ]` with every `Sᵢ` possibly empty, and an input that is
1403
+ protected from end to end is the one-element array `[m₁]`. An empty piece between two
1404
+ markers cannot occur, because touching ranges are one run (§3.9.2).
1405
+ 2. **There is no parser, so there is no parse error.** Nothing here raises
1406
+ `POLYTYPO_MALFORMED_INPUT`. The only new throws are the option's own (§3.9.2).
1407
+
1408
+ **Which marker.** The marker is **−2** if the protected run contains a line terminator, **−1**
1409
+ otherwise, by §3.2's test applied to the run's code points, and for the same reasons (decidable
1410
+ without asking anything, identical in five runtimes). A line terminator here is one of the seven code points of `BREAK` (`spaces.md` §3.1): U+000A, U+000D, U+000B,
1411
+ U+000C, U+0085, U+2028, U+2029. A port that tests only U+000A and U+000D disagrees with the
1412
+ others on the first hole that contains a U+2028, and a caller puts arbitrary text inside a hole.
1413
+ The test reads the run **as written in the
1414
+ input, not as it will render**: `${\n x\n}` is −2, and a `"` that touches it sees `BREAK` on that
1415
+ side whatever the template engine later puts there. This is an accepted cost, pinned by a fixture,
1416
+ and the alternative (the caller declaring the class per range) was rejected because it adds a
1417
+ second field to every range to avoid a rare multi-line hole.
1418
+
1419
+ #### 3.9.2 The option
1420
+
1421
+ ```
1422
+ protect: Range[] Range = { start, end }
1423
+ ```
1424
+
1425
+ - **Offsets are Unicode code points** into the input exactly as passed, 0-based, and the range is
1426
+ half-open: it protects the code points at `start … end − 1`. They are never UTF-16 units and
1427
+ never bytes (ARCHITECTURE.md §4.2). This is stated because the obvious implementation in two of
1428
+ the five runtimes gets it wrong silently: a JS caller holding `String.prototype.indexOf`
1429
+ results holds UTF-16 indices, and a Go or PHP caller holding the offsets a template parser
1430
+ reports holds byte offsets. Both agree with code points on ASCII, and the difference appears on
1431
+ the first emoji. The conformance fixtures therefore carry an astral character before a hole and
1432
+ inside one (§3.9.7).
1433
+ - **Each element is a mapping with integer `start` and `end`.** A runtime written for a language
1434
+ with no such mapping type (Go, Ruby, PHP, Python) takes its idiomatic pair or struct; the shape
1435
+ is not the contract, the two integers are.
1436
+ - **`start` is not negative, `start < end`, `end` is at most the input's length in code points,
1437
+ and the list is ascending and non-overlapping.** Touching ranges (`end` of one equal to `start`
1438
+ of the next) are allowed and are **one protected run**. The empty range is refused rather than
1439
+ ignored: a caller that emits one has computed an offset wrongly, and a zero-width hole has no
1440
+ code point to protect. An unsorted list is refused rather than sorted, for the same reason the
1441
+ rule contract (analyze.md; ARCHITECTURE.md §7.1) refuses unsorted edits instead of repairing
1442
+ them.
1443
+ - **An absent option and `[]` are the same call**, and so is the value the language uses for
1444
+ "absent" (`undefined` or `null`, `None`, `nil`). Every pre-1.13.0 caller is unaffected. Other
1445
+ members of a range mapping are ignored.
1446
+ - **Every violation of the above, and a value that is not a list of such mappings, throws
1447
+ `POLYTYPO_INVALID_OPTION`** (spec 1.3.0: an option value outside its permitted set, for an
1448
+ option with no more specific code).
1449
+ - **In `html`, `markdown` and `yaml` mode a non-empty `protect` throws
1450
+ `POLYTYPO_INVALID_OPTION`.** This is not the treatment `dialect` and `keys` receive (ignored
1451
+ outside their mode, §3.7.1 and §3.8.2), and the difference is deliberate: those options ask
1452
+ for something and cost nothing to ignore, whereas ignoring `protect` silently typesets text the
1453
+ caller marked as not theirs to touch. In the modes a parser already chooses the spans, so the
1454
+ combination has no meaning to refuse gracefully. `[]` is accepted everywhere, being absent.
1455
+ - **Validation order.** `protect` is checked after every option that precedes it in that mode's
1456
+ chain (`mode`, `narrowNbsp`, `rules`, `locale`, then the mode's own `dialect` and `keys` or
1457
+ `frontmatterKeys`) and before the input is read, so a call with two bad options reports the
1458
+ earlier one. The bound check needs the input's length in code points and nothing else.
1459
+ - **Input that is not a valid sequence of Unicode scalar values is outside this contract.** A
1460
+ lone surrogate is one code point in JS, an invalid byte is a replacement character in Go, and
1461
+ PHP sees bytes; `end <= length` would be answered differently by three runtimes, and making
1462
+ `text` mode reject such input to settle it would make it partial for every caller, protected or
1463
+ not. No fixture covers it and none may.
1464
+
1465
+ #### 3.9.3 What a protected run does to its neighbours
1466
+
1467
+ It is opaque content with §3.3's memberships and nothing special on top. Read with the table of
1468
+ §3.3 and the fixtures of §3.9.7:
1469
+
1470
+ - `dashes`, `ellipsis`, `ranges`, `symbols`: the marker is in none of their classes, so a token
1471
+ stops at it and a neighbour on the far side counts as content. `${x} - Rules` converts because
1472
+ the space-hyphen-space token has the marker (content) on its left.
1473
+ - `quotes`: the marker is both `OPENISH` and `CLOSEISH`, so `"${name}"` pairs, `"Say 'hi' to
1474
+ ${name}"` nests, and the elision veto of `quotes.md` §3.2 reads a marker next to a `NARROW` mark
1475
+ as it reads one in `html`: `${name}'s` keeps its possessive apostrophe.
1476
+ - `spaces`: a run of spaces that borders the marker is left alone (§3.3's edge test), so
1477
+ `Hello··${n}` keeps both spaces. This is a miss and not damage.
1478
+ - `nbsp`: a space the author typed next to a hole is converted in place when the rule calls for
1479
+ it (`Bonjour ${name} !` in `fr` takes U+202F). A code point the rule would **insert** next to
1480
+ the hole is not written; see §3.9.4.
1481
+
1482
+ #### 3.9.4 Accepted costs
1483
+
1484
+ **The edge-growth rule (§3.4) applies at a hole exactly as at an element boundary.** An edit that
1485
+ would place new code points at the extremity of the span next to a hole is discarded, so the
1486
+ conversions that a *contraction* allows survive and the ones that grow do not (§7.9 lists the
1487
+ locales). Measured on the released 1.12.0 engine with this option's prototype:
1488
+
1489
+ | Input | Locale | With `protect` | Without |
1490
+ | ----- | ------ | -------------- | ------- |
1491
+ | `${a}--${b}`, both holes protected | `en-US` | `${a}—${b}` | `${a}—${b}` |
1492
+ | `${a}--${b}`, both holes protected | `de-DE` | `${a}--${b}` | `${a} – ${b}` |
1493
+ | `Bonjour ${name}!` | `fr` | `Bonjour ${name}!` | `Bonjour ${name}` U+202F `!` (inserted) |
1494
+ | `Bonjour ${name} !` | `fr` | `Bonjour ${name}` U+202F `!` | the same |
1495
+
1496
+ So a text with `protect` converts **less than the same characters would without it**, never more
1497
+ and never differently. §7.4 calls a mode that diverges from `text` on identical characters
1498
+ indefensible, and this is the same divergence accepted for the same reason, with one difference
1499
+ worth stating: for an element boundary the reason is that the library must not put a character
1500
+ inside an element it did not come from. A template hole has no such reason, and the rule is kept
1501
+ here for a different one — the `analyze` origin map attributes an insertion to the next real
1502
+ character (`analyze.md` §2), which across a hole would be a code point on the **far** side of it.
1503
+ Removing the rule for holes later is additive (more conversions, no fewer), and keeping it now
1504
+ is the conservative direction §7.1 argues for.
1505
+
1506
+ **A multi-line hole is a line marker**, §3.9.1.
1507
+
1508
+ **Nothing protects what the caller did not mark.** The library cannot know that `{`, `#` or `|`
1509
+ means something in the host format. In ICU the caller marks `'{b}'` including both apostrophes,
1510
+ and the argument syntax `{n, plural, …}` the same way; the literal branches between them keep
1511
+ their lookaround. The exact `en-GB` result of `a '{b}' c`: without `protect`, `a ‘{b}’ c`; with
1512
+ `protect: [{ start: 2, end: 7 }]`, `a '{b}' c`.
1513
+
1514
+ #### 3.9.5 Idempotency is conditional on the caller moving the ranges
1515
+
1516
+ `M(M(x)) = M(x)` (§5) needs the span partition to be stable, and here the partition is the
1517
+ caller's. An edit before a hole changes its offset (`...` → `…` shortens), so `transform(out)`
1518
+ with the **original** `protect` protects different code points than the first call did and is not
1519
+ the same call. The property that holds is
1520
+
1521
+ > `transform(out, protect′) = out`, where `protect′` locates in `out` the same protected code
1522
+ > points `protect` located in `in`.
1523
+
1524
+ The three obligations of §5 are unchanged: the markers are inert, no edit contains one, and the
1525
+ edge-growth rule's verdict depends only on the array. What changes is that item 2 is no longer
1526
+ something `M` can discharge by itself.
1527
+
1528
+ `transform` does **not** return `protect′`. A second return value would make the return type
1529
+ depend on an argument, which `analyze.md` §1 rules out for the reason it gives, and a caller who
1530
+ needs `protect′` needs it only to cut the output back into the pieces it came from: that is a
1531
+ caller-side mapping the caller can compute from the two lengths in each gap, because **a protected
1532
+ run is returned byte for byte and its neighbours change length independently**. A caller that
1533
+ cannot do that cannot use the option for a template literal, and the specification records this
1534
+ rather than hiding it.
1535
+
1536
+ **Fixtures carry `protectOut`** (`fixtures.schema.json`), the ranges as they sit in `out`. A runner
1537
+ passes it on the idempotency call, and `scripts/validate-spec.mjs` checks that the code points
1538
+ `protectOut` selects in `out` are exactly the ones `protect` selects in `in`. Every runtime's own
1539
+ property test builds `protect′` from the edits of the first call, which is where the sweep of
1540
+ §3.9.7 gets it.
1541
+
1542
+ #### 3.9.6 `analyze`
1543
+
1544
+ `analyze` accepts `protect` with the same validation and throws the same errors (analyze.md A1).
1545
+ Its offsets are into the input as passed (A5). One consequence is not obvious and was measured:
1546
+ a change's `end` is one past the input offset of the last code point it replaced that has an
1547
+ origin (analyze.md §2), not what a scan forward from the end index to the next code point with
1548
+ an origin finds. The marker has none, so a
1549
+ change ending beside a hole is carried past it, and the reported range then contains the hole. In
1550
+ the prototype's first sweep **2 968 reported ranges** over 54 000 inputs contained a hole, one of
1551
+ them a `before` of three code points with `end − start` forty-seven. The same fault is in the
1552
+ released 1.12.0 `analyze` for `html`: `<p>x--<code>cc</code>y</p>` in `en-US` reports the `--` as
1553
+ `start` 4, `end` 21, which runs across the skipped element. A5 does not catch it, because the
1554
+ offsets are in bounds. The obligation added to analyze.md §6 is that no reported range overlaps a protected run
1555
+ or a skipped region. (It is not that `end − start` equals the length of `before`: `before` is
1556
+ what the later rule saw, after an earlier one deleted code points, `analyze.md` §5, and four
1557
+ released `spaces`/`ellipsis` fixtures report a longer range than their `before` for that reason.)
1558
+
1559
+ #### 3.9.7 What this was tested against
1560
+
1561
+ The prototype was built on `polytypo-js` 1.12.0, rules unchanged; the fixtures were generated from
1562
+ it and not typed (`protectOut` included), and each was run a second time over its own output with
1563
+ `protectOut`.
1564
+
1565
+ - **Fixtures:** 35 cases in `en-US`, `en-GB`, `de-DE` and `fr`: a hole at the start, at the end,
1566
+ between two, around a quotation, inside a nested quotation, after a possessive apostrophe,
1567
+ between ellipses, over the whole input, with an astral character before and inside it, with a
1568
+ line terminator inside it, touching another, beside spaces and beside the `fr` space before `!`,
1569
+ the two growth costs of §3.9.4, the ICU `'{b}'` case, the empty list in `text` and in `html`, holes containing U+2028 and U+0085, and twelve
1570
+ refusals — overlap, descending, empty, past the end, past the end **only in code points**,
1571
+ negative, non-integer, not a list, a non-empty list in `html`, `markdown` and `yaml`, and an
1572
+ unknown rule id reported ahead of a malformed list (§3.9.2's order). The order between `protect`
1573
+ and `dialect` or `keys` cannot be expressed in a fixture (the schema fixes `dialect` to its
1574
+ valid values) and is each runtime's own test.
1575
+ - **Sweep:** 54 000 inputs, 3 000 in each of 18 locales (`en-US`, `en-GB`, `de-DE`, `fr`, `ru`,
1576
+ `fi`, `pl`, `tr`, `es`, `it`, `nl`, `cs`, `sv`, `el`, `uk`, `hu`, `bg`, `hr`). Each input is 3 to 10 tokens from an alphabet of
1577
+ letters, digits, spaces, `-` `--` `---` `...` `.` `,` `!` `?` `:` `;`, U+2013, U+2014, both quote characters and
1578
+ the typographic ones, `(` `)`, a line feed, `'s`, `n't`, `km`, `%`, an emoji, `é`, `«` `»` `„` `“` `”`, with each
1579
+ token replaced by a hole with probability 0.2 from `${x}`, `{n}`, `<b>`, `{a, plural, one {#}}`
1580
+ and `'{b}'`. Holes touch freely. Result: **0 idempotency failures** under `protect′`, **0**
1581
+ violations of A3 and A5, and **0** reported ranges overlapping a hole (after the §3.9.6 fix).
1582
+ - **Not measured, deliberately:** how often a text with holes converts less than the same text
1583
+ without. On this alphabet it is a property of the alphabet; the two rows of §3.9.4 are the
1584
+ claim, and they are single measured inputs.
1585
+ - **Not covered:** input that is not well-formed (§3.9.2), and any host grammar. Whether `{`
1586
+ begins an argument is the caller's knowledge.
1587
+
1588
+ ---
1589
+
1358
1590
  ## 4. The round-trip guarantee
1359
1591
 
1360
- > **`transform` in `html`, `markdown` and `yaml` mode returns the input source with a set of
1592
+ > **`transform` in `html`, `markdown` and `yaml` mode, and in `text` mode with `protect`, returns the input source with a set of
1361
1593
  > disjoint substring replacements applied at recorded offsets. No other byte of the input
1362
1594
  > changes, ever.**
1363
1595
 
@@ -1380,6 +1612,8 @@ it. It is stated as a prohibition because that is the only form five parsers can
1380
1612
  - **[P] Malformed input is not repaired.** Unclosed tags, stray `<`, mismatched nesting: the
1381
1613
  parser's error recovery affects only where spans are found, never what is emitted.
1382
1614
 
1615
+ - **[P] A protected run (`protect`, §3.9) is returned exactly as written.** The caller said it is
1616
+ not text, and the output contains its code points unchanged and in place.
1383
1617
  - **[P] YAML quoting style, indentation, anchors, tag handles and chomping indicators are
1384
1618
  preserved** — `yaml` mode uses no parser and no emitter, so none of them is ever decoded and
1385
1619
  rewritten (§3.8.1). A block scalar's trailing line terminators lie outside every span and are
@@ -1413,6 +1647,12 @@ they do not carry it over for free either. Write `M` for the whole mode transfor
1413
1647
  the second run would see different spans, the concatenation would differ, and the rules would
1414
1648
  legitimately reach different conclusions.
1415
1649
 
1650
+ **With `protect` (§3.9) the partition is the caller's, and `M` cannot discharge this item
1651
+ alone.** An edit before a hole moves it, so the second call must be given the ranges where the
1652
+ first call left them (§3.9.5). The property then holds on the argument of the next paragraph
1653
+ (the hole's own code points are never edited, so the partition is exactly the first call's),
1654
+ and it is a property of the call, not of the input.
1655
+
1416
1656
  An earlier revision discharged this by claiming that **every code point the rules emit is
1417
1657
  syntactically inert in `html` and `markdown`**. That claim is **false**, and the
1418
1658
  counterexample is the most ordinary character in the set. **U+0020 is not a markup character
@@ -1544,7 +1784,7 @@ means the claim holds in those modes only.
1544
1784
 
1545
1785
  - **"Anything inside a skipped region"**, asserted in every rule's §4, is
1546
1786
  **[P: html, markdown, yaml]** and is now _defined_ rather than assumed — §3.6, §3.7 and §3.8
1547
- are what those bullets refer to. In `text` mode there are no skipped regions and the bullet is
1787
+ are what those bullets refer to. In `text` mode there are no skipped regions, unless `protect` is given (§3.9), and the bullet is
1548
1788
  vacuous. In `yaml` the definition runs the other way round (§3.8.2): the skipped region is
1549
1789
  everything the scan did not claim. Since 1.7.0 one region in `markdown` is skipped
1550
1790
  **conditionally** — the frontmatter block, whenever `frontmatterKeys` names a key in it
@@ -1660,9 +1900,15 @@ rule-local.
1660
1900
  have them inside a span and could let a rule edit next to them. The tree-walking approach is
1661
1901
  assumed throughout; it is not stated as a requirement because it names an implementation
1662
1902
  strategy, and it probably should be.
1663
- 7. **No mode covers plain-text email or reStructuredText**, and the `rules` option cannot
1664
- express "skip this region" for a caller with their own format. That is out of scope for v1
1665
- (PLAN.md §4) and is recorded so the span model is not mistaken for an extension point. _(YAML
1903
+ 7. _(Settled in 1.13.0 — an option, not an extension point.)_ **No mode covers plain-text email
1904
+ or reStructuredText**, and until 1.13.0 the `rules` option could not express "skip this
1905
+ region" for a caller with their own format; this item said so, and said the span model was
1906
+ not to be mistaken for an extension point. The first report of it costing real work
1907
+ (polytypo/polytypo#14, a template literal with interpolation, then ICU MessageFormat in
1908
+ #26) was an argument to revisit it, and §3.9 does: the caller names the regions with
1909
+ `protect`, in `text` mode only, and the span model is reused as it stands. What was not
1910
+ reopened is the format list. No mode was added for email, reStructuredText, ICU, Django or
1911
+ Handlebars, and the option specifies none of their grammars. _(YAML
1666
1912
  was in this item until 1.3.0 and is now §3.8; the two formats named here are not, and the
1667
1913
  argument that moved YAML does not carry over — it turned on a measured corpus and a
1668
1914
  corruption report, neither of which exists for these.)_
@@ -1884,6 +2130,12 @@ changes.
1884
2130
  scanners and five different answers. The bounded sweep of §3.8.7 is part of this item, not an
1885
2131
  extra — and so is the end-to-end corpus run, for the reason §3.8.7 gives: a sweep that
1886
2132
  compares structure cannot see a string being damaged without being destroyed.
2133
+ 3b. **`protect`** (since 1.13.0) — the offsets as code points with an astral character before and
2134
+ inside a hole, a hole at each end and over the whole input, a line terminator inside a hole,
2135
+ touching ranges, the two growth costs of §3.9.4, each refusal of §3.9.2 including the one
2136
+ that is a refusal only in code points, and the moved-range idempotency call (§3.9.5). The
2137
+ sweep of §3.9.7 is part of this item. Each runtime proves it with its own tests; the
2138
+ fixtures cover `en-US`, `en-GB`, `de-DE` and `fr`.
1887
2139
  4. **At least two materially different locale outputs per mode/dialect**, so a fixture is not
1888
2140
  merely "the same English output with a different `locale` field": `spec/fixtures/fr.json` and
1889
2141
  `fr-CA.json` carry `html`/`markdown`/`mdx` cases whose guillemets-plus-U+00A0 output is
@@ -1917,7 +2169,8 @@ covers all eight:
1917
2169
  respectively), the parser-boundary half of item 5 (an astral-character/surrogate-pair
1918
2170
  preservation case in HTML mode), and item 6 (the round-trip guarantee — "returns a
1919
2171
  document/article that needs no changes byte for byte" — asserted directly for HTML, CommonMark,
1920
- MDX and YAML). Those files are not in this repository, which has no engine to run them through,
2172
+ MDX and YAML). Item 3b is each runtime's own `protect` tests, which also run the moved-range
2173
+ idempotency call of §3.9.5 over the fixtures. Those files are not in this repository, which has no engine to run them through,
1921
2174
  so this bullet is an obligation on every runtime: one that ships `yaml` without them is not
1922
2175
  conformant for the mode however green its fixtures are.
1923
2176
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.12.0",
2
+ "spec": "1.13.0",
3
3
  "$comment": "Single source of truth for pipeline order. Rules run in ascending `order`. Disabling a rule removes it from the sequence and never reorders the rest. Rule ids are public API (see docs/ARCHITECTURE.md section 5). \"spec\" here must track spec/VERSION exactly — it is not itself the global version source; scripts/validate-spec.mjs enforces the match.",
4
4
  "rules": [
5
5
  {
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://polytypo.org/schema/fixtures.schema.json",
4
4
  "title": "polytypo conformance fixtures",
5
- "description": "Executable form of the rule semantics. Every case is also an idempotency case: a runner must additionally assert transform(out) == out, passing the case's own options (mode, dialect, keys, frontmatterKeys, rules, narrowNbsp) on that second call exactly as on the first — a case is a fixed point under its own options, not under the defaults.",
5
+ "description": "Executable form of the rule semantics. Every case is also an idempotency case: a runner must additionally assert transform(out) == out, passing the case's own options (mode, dialect, keys, frontmatterKeys, rules, narrowNbsp, and, for `protect`, its `protectOut` counterpart, since the ranges move with the edits) on that second call exactly as on the first — a case is a fixed point under its own options, not under the defaults.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": ["spec", "locale", "cases"],
@@ -11,7 +11,10 @@
11
11
  "type": "string",
12
12
  "description": "The current global spec contract version. Must equal spec/VERSION exactly — not an authoring-time stamp and not a per-case introduction version. scripts/validate-spec.mjs enforces this for every fixture root."
13
13
  },
14
- "locale": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" },
14
+ "locale": {
15
+ "type": "string",
16
+ "pattern": "^[a-z]{2}(-[A-Z]{2})?$"
17
+ },
15
18
  "cases": {
16
19
  "type": "array",
17
20
  "minItems": 1,
@@ -21,22 +24,124 @@
21
24
  "required": ["id", "rule", "mode", "in"],
22
25
  "allOf": [
23
26
  {
24
- "if": { "properties": { "mode": { "const": "markdown" } }, "required": ["mode"] },
25
- "then": { "required": ["dialect"] },
26
- "else": { "not": { "required": ["dialect"] } }
27
+ "if": {
28
+ "properties": {
29
+ "mode": {
30
+ "const": "markdown"
31
+ }
32
+ },
33
+ "required": ["mode"]
34
+ },
35
+ "then": {
36
+ "required": ["dialect"]
37
+ },
38
+ "else": {
39
+ "not": {
40
+ "required": ["dialect"]
41
+ }
42
+ }
27
43
  },
28
44
  {
29
- "if": { "properties": { "mode": { "const": "yaml" } }, "required": ["mode"] },
30
- "then": { "required": ["keys"] },
31
- "else": { "not": { "required": ["keys"] } }
45
+ "if": {
46
+ "properties": {
47
+ "mode": {
48
+ "const": "yaml"
49
+ }
50
+ },
51
+ "required": ["mode"]
52
+ },
53
+ "then": {
54
+ "required": ["keys"]
55
+ },
56
+ "else": {
57
+ "not": {
58
+ "required": ["keys"]
59
+ }
60
+ }
32
61
  },
33
62
  {
34
- "if": { "properties": { "mode": { "const": "markdown" } }, "required": ["mode"] },
63
+ "if": {
64
+ "properties": {
65
+ "mode": {
66
+ "const": "markdown"
67
+ }
68
+ },
69
+ "required": ["mode"]
70
+ },
35
71
  "then": true,
36
- "else": { "not": { "required": ["frontmatterKeys"] } }
72
+ "else": {
73
+ "not": {
74
+ "required": ["frontmatterKeys"]
75
+ }
76
+ }
77
+ },
78
+ {
79
+ "if": {
80
+ "required": ["protect", "out"]
81
+ },
82
+ "then": {
83
+ "properties": {
84
+ "protect": {
85
+ "type": "array",
86
+ "items": {
87
+ "type": "object",
88
+ "additionalProperties": false,
89
+ "required": ["start", "end"],
90
+ "properties": {
91
+ "start": {
92
+ "type": "integer"
93
+ },
94
+ "end": {
95
+ "type": "integer"
96
+ }
97
+ }
98
+ }
99
+ }
100
+ },
101
+ "required": ["protectOut"]
102
+ }
103
+ },
104
+ {
105
+ "if": {
106
+ "required": ["throws"]
107
+ },
108
+ "then": {
109
+ "not": {
110
+ "required": ["protectOut"]
111
+ }
112
+ }
113
+ },
114
+ {
115
+ "if": {
116
+ "required": ["protect"],
117
+ "properties": {
118
+ "protect": {
119
+ "type": "array",
120
+ "minItems": 1
121
+ }
122
+ },
123
+ "not": {
124
+ "required": ["mode"],
125
+ "properties": {
126
+ "mode": {
127
+ "const": "text"
128
+ }
129
+ }
130
+ }
131
+ },
132
+ "then": {
133
+ "required": ["throws"]
134
+ }
135
+ }
136
+ ],
137
+ "oneOf": [
138
+ {
139
+ "required": ["out"]
140
+ },
141
+ {
142
+ "required": ["throws"]
37
143
  }
38
144
  ],
39
- "oneOf": [{ "required": ["out"] }, { "required": ["throws"] }],
40
145
  "properties": {
41
146
  "id": {
42
147
  "type": "string",
@@ -57,24 +162,34 @@
57
162
  ],
58
163
  "description": "The rule under test. Required so a runtime can report partial conformance honestly."
59
164
  },
60
- "mode": { "enum": ["text", "html", "markdown", "yaml"] },
165
+ "mode": {
166
+ "enum": ["text", "html", "markdown", "yaml"]
167
+ },
61
168
  "dialect": {
62
169
  "enum": ["commonmark", "mdx"],
63
170
  "description": "Required when mode is \"markdown\" — spec/rules/modes.md §3.7.1 forbids detection, so a fixture must name the dialect exactly as a caller would. Meaningless for the other modes."
64
171
  },
65
172
  "keys": {
66
173
  "type": "array",
67
- "items": { "type": "string" },
174
+ "items": {
175
+ "type": "string"
176
+ },
68
177
  "description": "Required when mode is \"yaml\" — spec/rules/modes.md §3.8.2 gives the option no default, so a fixture must name the processable keys exactly as a caller would. An empty array is legal and processes nothing. Meaningless for the other modes."
69
178
  },
70
179
  "frontmatterKeys": {
71
180
  "type": "array",
72
- "items": { "type": "string" },
181
+ "items": {
182
+ "type": "string"
183
+ },
73
184
  "description": "Optional, spec 1.7.0, and only meaningful when mode is \"markdown\" — spec/rules/modes.md §3.7.4's opt-out from the frontmatter skip. Absent means the block is skipped whole, exactly as before 1.7.0; an empty array is legal and yields no spans. A runner passes it through to transform() as it passes dialect.",
74
185
  "$comment": "The block is its own text unit (§3.7.4), so this option cannot change a byte of the body — every case released before 1.7.0 keeps its recorded output whatever it is set to."
75
186
  },
76
- "in": { "type": "string" },
77
- "out": { "type": "string" },
187
+ "in": {
188
+ "type": "string"
189
+ },
190
+ "out": {
191
+ "type": "string"
192
+ },
78
193
  "throws": {
79
194
  "enum": [
80
195
  "POLYTYPO_UNKNOWN_LOCALE",
@@ -88,16 +203,43 @@
88
203
  ],
89
204
  "description": "Expected error code instead of an output string. Messages are not part of the contract; codes are."
90
205
  },
91
- "note": { "type": "string" },
206
+ "note": {
207
+ "type": "string"
208
+ },
92
209
  "rules": {
93
210
  "type": "object",
94
- "additionalProperties": { "type": "boolean" },
211
+ "additionalProperties": {
212
+ "type": "boolean"
213
+ },
95
214
  "description": "Optional rule overrides for this case, same shape as the public `rules` option."
96
215
  },
97
216
  "narrowNbsp": {
98
217
  "enum": ["narrow", "nbsp"],
99
218
  "description": "Optional, spec 1.3.0. Same shape as the public `narrowNbsp` option (spec/rules/nbsp.md §3.1a): \"nbsp\" makes the engine emit U+00A0 wherever it would emit U+202F. Omitted means \"narrow\", the default, so every pre-1.3.0 case keeps its meaning. A runner passes this through to transform() exactly as it passes `rules`."
219
+ },
220
+ "protect": {
221
+ "description": "Optional, spec 1.13.0. The public `protect` option (spec/rules/modes.md §3.9): ranges of `in`, in Unicode code points, [start, end), that the engine never edits and reads as opaque content. A runner passes it to transform() as it passes `rules`. Unconstrained here because a `throws` case carries the malformed values §3.9.2 refuses; a case with `out` must carry a list of ranges, and a non-empty list with a mode other than \"text\" is only legal in a `throws` case."
222
+ },
223
+ "protectOut": {
224
+ "description": "Required when `protect` and `out` are both present, forbidden otherwise. The same protected runs located in `out`, in code points. Edits before a hole move it, so the idempotency call transform(out) must pass THESE ranges, not `protect`. scripts/validate-spec.mjs checks that the code points they select are exactly the ones `protect` selects in `in`. Measured from the engine, never written by hand.",
225
+ "type": "array",
226
+ "items": {
227
+ "type": "object",
228
+ "additionalProperties": false,
229
+ "required": ["start", "end"],
230
+ "properties": {
231
+ "start": {
232
+ "type": "integer"
233
+ },
234
+ "end": {
235
+ "type": "integer"
236
+ }
237
+ }
238
+ }
100
239
  }
240
+ },
241
+ "dependentRequired": {
242
+ "protectOut": ["protect"]
101
243
  }
102
244
  }
103
245
  }