polytypo 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +21 -1
- data/lib/polytypo/data/VERSION +1 -1
- data/lib/polytypo/data/fixtures/bg.json +1 -1
- data/lib/polytypo/data/fixtures/cs.json +1 -1
- data/lib/polytypo/data/fixtures/de-CH.json +1 -1
- data/lib/polytypo/data/fixtures/de-DE.json +77 -1
- data/lib/polytypo/data/fixtures/el.json +1 -1
- data/lib/polytypo/data/fixtures/en-GB.json +89 -1
- data/lib/polytypo/data/fixtures/en-US.json +450 -1
- data/lib/polytypo/data/fixtures/es.json +1 -1
- data/lib/polytypo/data/fixtures/fi.json +1 -1
- data/lib/polytypo/data/fixtures/fr-CA.json +1 -1
- data/lib/polytypo/data/fixtures/fr.json +41 -1
- data/lib/polytypo/data/fixtures/hr.json +1 -1
- data/lib/polytypo/data/fixtures/hu.json +1 -1
- data/lib/polytypo/data/fixtures/it.json +1 -1
- data/lib/polytypo/data/fixtures/locale-resolution.json +1 -1
- data/lib/polytypo/data/fixtures/nl.json +1 -1
- data/lib/polytypo/data/fixtures/pl.json +1 -1
- data/lib/polytypo/data/fixtures/pt-BR.json +1 -1
- data/lib/polytypo/data/fixtures/pt-PT.json +1 -1
- data/lib/polytypo/data/fixtures/ro.json +1 -1
- data/lib/polytypo/data/fixtures/ru.json +1 -1
- data/lib/polytypo/data/fixtures/sk.json +1 -1
- data/lib/polytypo/data/fixtures/sr.json +183 -0
- data/lib/polytypo/data/fixtures/sv.json +1 -1
- data/lib/polytypo/data/fixtures/tr.json +1 -1
- data/lib/polytypo/data/fixtures/uk.json +1 -1
- data/lib/polytypo/data/locales/registry.json +3 -2
- data/lib/polytypo/data/locales/sr.json +82 -0
- data/lib/polytypo/data/rules/analyze.md +16 -4
- data/lib/polytypo/data/rules/modes.md +262 -9
- data/lib/polytypo/data/rules/order.json +1 -1
- data/lib/polytypo/data/schema/fixtures.schema.json +160 -18
- data/lib/polytypo/engine/origin.rb +13 -1
- data/lib/polytypo/engine/protect.rb +122 -0
- data/lib/polytypo/errors.rb +1 -1
- data/lib/polytypo/modes/runner.rb +0 -1
- data/lib/polytypo/version.rb +1 -1
- data/lib/polytypo.rb +34 -19
- metadata +4 -1
|
@@ -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.
|
|
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
|
|
1664
|
-
|
|
1665
|
-
|
|
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).
|
|
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.
|
|
2
|
+
"spec": "1.14.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": {
|
|
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": {
|
|
25
|
-
|
|
26
|
-
|
|
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": {
|
|
30
|
-
|
|
31
|
-
|
|
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": {
|
|
63
|
+
"if": {
|
|
64
|
+
"properties": {
|
|
65
|
+
"mode": {
|
|
66
|
+
"const": "markdown"
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"required": ["mode"]
|
|
70
|
+
},
|
|
35
71
|
"then": true,
|
|
36
|
-
"else": {
|
|
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": {
|
|
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": {
|
|
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": {
|
|
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": {
|
|
77
|
-
|
|
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": {
|
|
206
|
+
"note": {
|
|
207
|
+
"type": "string"
|
|
208
|
+
},
|
|
92
209
|
"rules": {
|
|
93
210
|
"type": "object",
|
|
94
|
-
"additionalProperties": {
|
|
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
|
}
|
|
@@ -54,6 +54,18 @@ module Polytypo
|
|
|
54
54
|
out
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Where a change ends in the input (analyze.md section 2). A non-empty edit ends one past
|
|
58
|
+
# the input offset of the last code point it replaced that has an origin. Scanning forward
|
|
59
|
+
# from edit.end instead, as origin_at does for a start, would carry the end across a skipped
|
|
60
|
+
# element or a protected run that follows -- the boundary marker has no origin -- and the
|
|
61
|
+
# reported range would contain text the rules never saw.
|
|
62
|
+
def self.end_of(origin, edit, input_length)
|
|
63
|
+
(edit.end - 1).downto(edit.start) do |i|
|
|
64
|
+
return origin[i] + 1 unless origin[i] == NO_ORIGIN
|
|
65
|
+
end
|
|
66
|
+
origin_at(origin, edit.end, input_length)
|
|
67
|
+
end
|
|
68
|
+
|
|
57
69
|
# One rule's edits, in the coordinates that rule saw, rendered as Changes in input
|
|
58
70
|
# coordinates. +before+ is the text this rule replaced and +after+ what it replaced it with
|
|
59
71
|
# (analyze.md section 2), so on a text two rules have both touched, +before+ is what the
|
|
@@ -64,7 +76,7 @@ module Polytypo
|
|
|
64
76
|
Polytypo::Change.new(
|
|
65
77
|
rule_id: rule_id,
|
|
66
78
|
start: origin_at(origin, edit.start, input_length),
|
|
67
|
-
end:
|
|
79
|
+
end: end_of(origin, edit, input_length),
|
|
68
80
|
before: Codepoints.from_codepoints(cp[edit.start...edit.end]),
|
|
69
81
|
after: Codepoints.from_codepoints(edit.replacement)
|
|
70
82
|
)
|