Skip to content

[FIX] ICU plural evaluation in the Rust formatter, and the swift-computed-property detector - #420

Open
justin13888 wants to merge 10 commits into
chore/merge-v1-head-397from
fix/i18n-plurals-and-swift-detector-414
Open

[FIX] ICU plural evaluation in the Rust formatter, and the swift-computed-property detector#420
justin13888 wants to merge 10 commits into
chore/merge-v1-head-397from
fix/i18n-plurals-and-swift-detector-414

Conversation

@justin13888

@justin13888 justin13888 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Description

Two halves of one issue (#414). The Rust i18n runtime could not evaluate an ICU
plural — it refused one with a debug_assert! and, in release, copied the
message source through to the user. And the swift-computed-property detector
could not catch the example its own doc comment gives as the shape it exists to
catch (#394).

Summary

Plural evaluation (S-I7, the half it owed).

  • capsule_i18n::plural (new): CLDR integer cardinal rules for the twelve
    language subtags in locales/config.jsonCategory, category(locale, n),
    selectable(locale). No new dependency; icu_plurals would have meant a
    provider, a data crate and a dependencies.md row on a crate whose whole
    dependency list is serde_json + tracing.
  • format_message_in(locale, template, args) evaluates {name, plural, …} with
    =N exact arms, CLDR category arms, #, and nesting. format_message keeps
    its signature and means English. Bundle::format was dropping self.locale
    before calling the formatter — that was the API gap, and it is closed.
  • A category the message does not carry falls back to other. That is
    load-bearing today: every translated plural is still an English one/other
    copy, so a Russian few has nowhere else to go.
  • The refusal is narrowed, not removed. select, selectordinal,
    offset:, a malformed plural, and nesting past 32 levels keep the assertion
    and the verbatim pass-through, so the next construct this runtime cannot
    express is a test failure rather than something a user reads.
  • xtask held a second copy of the selectable-category table. It now reads
    capsule_i18n::plural::selectable, and two tests close the loop: the rules and
    the selectable set are pinned equal in both directions, and — over the real
    catalogs, in every locale, at eight boundary counts — the sentence the runtime
    builds by substituting # is asserted equal to the sentence Android builds
    from the %d format string the generator lowered.

The detector (#394).

  • The literal rule was "a capital, then a space somewhere", which excluded every
    single-word display string, case .places: "Places" included. It is now "a
    capital, then a lowercase letter", which keeps "HEIC", "HDR10", "HLG" and
    "key.fill" out just as well. Measured across capsule-swift/{App,Modules} in
    every position the detector scans: zero strings lost by the swap, one
    gained.
  • The four blind spots i18n-guard: the swift-computed-property detector cannot catch its own documented example #394 lists close with it: four body shapes instead of
    only case .foo: (explicit return, implicit return, dictionary value), four
    more name stems (heading, text, summary, prompt — not value, which is
    rawValue), func … -> String as well as var, and six API positions that
    were in one regex or neither. Both Swift regexes now build from one shared
    position list, so confirmationDialog being in one and not the other cannot
    recur.
  • The widening finds exactly one string in the tree: "Dolby Vision", in
    AssetInfoFormatting.hdrName(_:), whose own doc says it names an HDR encoding
    "as its owner spells it". A trademark, spelled identically in every locale —
    the allowlist's stated category, and why its siblings "HDR10" and "HLG"
    never tripped the gate. One allowlist line. No file under capsule-swift/
    is touched.

Validation

Run in the worktree, in this order.

Command Outcome
cargo nextest run -p capsule-i18n pass — 55 tests
cargo test -p capsule-i18n --release pass — 40 + 10 + 1. Failed on the base (f433d918): an_unrenderable_icu_construct_is_refused_in_debug_builds was #[should_panic] with no cfg(debug_assertions), so it asserted a panic a release build cannot produce. Classified pre-existing, fixed here.
cargo nextest run -p xtask pass — 106 tests
mise run i18n-guard pass — "no hardcoded user-facing literals found across web/swift/compose/cli"
mise run i18n-check pass (inside check-rust) — no generated file drifts; this change renders nothing
mise run check-docs pass — 59 pages, all internal links valid. Needed bun install in capsule-docs/ first; node_modules is absent from a fresh worktree
mise run check-docs-truth pass — 473 cross-links, 84 endpoint citations, 119 module paths
mise run lint-check-md pass — 168 files, 0 issues
mise run check-rust pass — fmt, clippy (strict flags), i18n-check, i18n-guard, openapi-check-kynos, architecture-check, license-check, translate-readme-check, build-rust, build-check-wasm, build-ffi, lint-check-ffi, gen-bindings, verify-examples. Exit 0
mise run test-rust pass — 1746 + 734 (capsule-core --features ffi) + 160 (capsule-sdk --features ffi), 0 failed. Exit 0

Adversarial read of the formatter before it was committed (one focused
sub-agent, differential and fuzz harness against the previous implementation)
found six things worth acting on. Three were defects, all fixed in the same
commit:

  1. Regression. An unterminated { abandoned the rest of the template, so one
    stray brace silently blanked every later argument — behaviour the previous
    scanner did not have. It is now one character of output and scanning
    continues.
  2. Unbounded recursion. ~50 000 levels of nesting aborted the process with
    stack overflow, which no caller can catch, through a pub entry point.
    Capped at 32.
  3. Inconsistent string counts. Selection trimmed the string, # did not, so
    " 1 " could select one arm and print another.

The other three were test-quality: two tautological assertions and one
tautological test (format_message(x) == format_message_in("en", x), which is
the definition of format_message), all replaced with assertions that can fail.
It also confirmed no memory-safety, UTF-8-boundary or overflow defects across
800 000 random templates, and checked the CLDR table against CLDR 46.

The plan's xtask cross-check was written, run, and found to assert a false
property — see decision 8.

Review-repair round (head 8c191fb4)

Three findings from the orchestrator's reviewer, all closed; base merged forward
(chore/merge-v1-head-397 had moved to 99dd4bc8) by merge commit, so the
two-dot diff no longer shows a reverse capsule-web/bun.lock hunk.

Command Outcome
cargo nextest run -p capsule-i18n -p xtask pass — 167 tests
cargo test -p capsule-i18n --release pass — 43 + 10 + 1
mise run i18n-guard pass — still zero findings
mise run lint-check-md pass — 168 files, 0 issues
mise run check-rust pass, exit 0 — all 14 sub-gates, re-run after the merge

Finding 1 (medium, O(n²) on the public entry point) — closed. Mechanism
reproduced first, against a copy of the shipped scan: bytes read are exactly
n(n+1)/2, and time quadruples per doubling (22 ms at n=10 000, 104 ms at 20 000,
369 ms at 40 000, 1.63 s at 80 000). render now pairs every brace in one stack
pass (brace_pairs) and walks a cursor over the result; matching_brace stays
only for Arms::parse, where the arms are disjoint. The "if pairing fails here it
fails later" shortcut is unsound — { {a} has an unmatched outer brace and a
matched inner one — so the real pairing is the fix, and that case is in the
equivalence test. Pinned by byte count, not by clock: a thread-local counter in
both scanners, asserted at ≤4n and ≤8n. Measured after: 100 000 bytes for a
100 000-byte unmatched template (was ~5×10⁹) and 118 000 for a 101 000-byte
template of 10 000 placeholders plus 1 000 plurals.

Finding 2 (low, wrong test count in S-I7) — closed. Three, not two, and
they are now named, with #428 cited.

Finding 3 (low, 4 of 15 API positions had a fixture) — closed. The fixture is
now derived from SWIFT_TEXT_POSITIONS — one plain and one interpolated literal
per entry, plus a helper name that merely ends in the entry — so a position
cannot be added without a case. A second test rejects an entry that is not a bare
identifier, or a duplicate. Verified non-vacuous: appending inspector Prompt to
the list fails it by name. The hand-written test stays, for the real call shapes a
derived fixture cannot express.

Risks and rollout

Pure-function rendering. No persisted state, no wire format, no generated file
changes, no new dependency. Bundle::format's output changes for the eleven
plural keys — from ICU source text to rendered text — and no Rust code resolves
one of those keys today, so no live output changes. Backout is a per-commit
git revert; the four commits are independent in that order.

Residual risk: three tests in this diff are cfg(not(debug_assertions)) and no
CI job runs cargo test --release, so they are compiled out of every build CI
makes. They were run by hand here. Filed as #428 and recorded in S-I7 as
owed-CI; mise.toml is outside this lane's manifest.

Known limitation, now written down rather than implied: ICU apostrophe quoting
('#' for a literal #) is not implemented here or in the ahead-of-time
generators, so a literal #, { or } in a plural arm cannot be escaped. No
catalog message needs one, and an apostrophe away from ICU syntax ("couldn't")
is ordinary text under ICU's own rule.

Related Issues

Refs #414
Closes #394
Files #428 (owed-CI: no job runs cargo test --release)

Decisions taken

Issue 414 - i18n: ICU plural evaluation in the Rust formatter, and the swift-computed-property detector (S-I7, #394)
Plan:     v1 (against f433d918, the head of PR #418)
Branch:   fix/i18n-plurals-and-swift-detector-414
Base:     chore/merge-v1-head-397 (head of PR #418); the PR targets that branch
Worktree: /var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-fix-i18n-plurals-and-swift-detector-414
Cause:    capsule-i18n/src/format.rs:74 refuses every non-identifier ICU construct because the runtime has no CLDR rules; i18n_guard.rs:402 requires a space in the literal, which excludes every single-word display string including the doc's own example.
Touches:  capsule-i18n (plural.rs new, format.rs, catalog.rs, lib.rs, tests/integration.rs), xtask (Cargo.toml + capsule-i18n path dep, src/i18n.rs PLURAL_RULES, src/i18n_guard.rs), Cargo.lock (regenerated), locales/README.md, locales/i18n-guard-allowlist.txt (+1 line), capsule-docs/src/content/docs/design/i18n.md, SLICES.md (S-I7 row + detail block ONLY)
Will not: touch capsule-swift/**, capsule-android/**, capsule-web/**, any locales/*.json message, any generated catalog; implement select/selectordinal/offset:/number/date; touch ROADMAP.md or SLICES.md outside S-I7
Lane:     parallel (stacked on #418)
Settled:  Base branch = head of PR #418 (run decision)

Decisions taken.

1. Deliverable boundary - plural evaluation and the whole detector fix land together; closes #394.
   Taken:    Both halves of #414 in one lane: the widened detector produces exactly one new finding ("Dolby Vision", capsule-swift/Modules/FeatureViewer/Sources/Info/AssetInfoFormatting.swift:134), a trademark the property's own doc at :129 says is spelled by its owner, so it is one allowlist line (locales/i18n-guard-allowlist.txt), not a migration. Nothing under capsule-swift/ is touched.
   Rejected: Ship plurals now and file the detector - #414 says it closes #394 and the detector's incremental cost is one line.
   Rejected: Ship the detector now and file plural evaluation - S-I7's record (SLICES.md:4856) names plural evaluation as the owed half.
   Reverses: Drop slice 5 and file "widen swift-computed-property (#394)" against the same manifest minus i18n_guard.rs and the allowlist.
   Filed:    -

2. CLDR plural rules: in-house table, not a crate.
   Taken:    ~40 lines in capsule-i18n/src/plural.rs covering the 12 language subtags in locales/config.json, exported so xtask/src/i18n.rs:403 stops holding a second copy. No dependencies.md row, no new crate.
   Rejected: icu_plurals - absent from Cargo.lock today, so a genuine new dependency (provider + data crate + row) on a crate whose whole dependency list is serde_json + tracing, to decide 13 locales' integer cardinal categories. Licence is not the blocker (Unicode-3.0 allowed at deny.toml:47); volume is, per AGENTS.md minimalism.
   Reverses: Delete plural.rs's rule bodies, add icu_plurals plus a dependencies.md row, keep category/selectable as wrappers.

3. Locale threading: additive format_message_in, format_message kept.
   Taken:    format_message_in(locale, template, args) is new public API; format_message delegates with "en" and keeps its signature; Bundle::format passes self.locale.
   Rejected: Change format_message's signature - cheap (two call sites) but a locale-free formatter is the right default for a template from nowhere in particular, and keeping it makes the diff reviewable as behaviour rather than churn.
   Reverses: Delete the shim, add the locale parameter, update catalog.rs:55 and integration.rs:54.

4. The refusal is narrowed, not removed.
   Taken:    plural leaves the refused set; select, selectordinal, offset:, unknown kinds and unterminated braces keep the debug_assert! + verbatim pass-through; the two tests at format.rs:146/:157 are retargeted onto select.
   Rejected: Remove the assertion entirely - the next select message would reach a user as source text, the exact failure S-I6 fixed on Android (SLICES.md:4837).
   Reverses: Delete the select arm's debug_assert! and its two tests.
Decisions taken inside the manifest during delivery (appended to the record above,
same shape).

5. `plural::selectable` answers `Option`, not a bare slice.
   Taken:    `selectable(locale) -> Option<&'static [Category]>`; `None` for a language
             with no row. The generator must refuse a locale it has no rules for rather
             than emit a single-arm resource for a language that may need six, while the
             runtime falls back to `other`. A bare slice cannot express both.
   Rejected: `-> &'static [Category]` returning `&[Other]` for an unknown language, as
             planned - it silently turns `xtask i18n`'s existing loud failure
             (`with_context` at the old `PLURAL_RULES` lookup) into a wrong resource.
   Reverses: Change the return type, restore the generator's own rules table for the
             unknown-language error.

6. CLDR's Romance `many` is implemented, not omitted.
   Taken:    `es`/`fr`/`it`/`pt` select `many` at a non-zero multiple of a million, per
             CLDR (`e = 0 and i != 0 and i % 1000000 = 0 and v = 0`), and a test pins that
             nothing below a million reaches it.
   Rejected: The plan's rule table, which omitted `many` for these four and pinned it as
             unreachable from an integer selector. That is wrong: CLDR states the rule over
             the *integer* operands, so `2000000` selects `many` with `v = 0`. Omitting it
             would have made `xtask`'s selectable list unreconcilable with the rules the
             moment the two tables merged.
   Reverses: Delete `romance_many`, make `es`/`it` use `exactly_one` and `fr`/`pt` use
             `zero_or_one`, drop `Many` from those four selectable sets.

7. Three defects found by the pre-commit adversarial read are fixed in the same slice.
   Taken:    (a) an unterminated `{` is one character of output and scanning continues -
             it used to abandon the remainder, so a stray brace silently blanked every
             later argument, a regression against the old scanner; (b) plural nesting is
             capped at 32 levels, because unbounded recursion in a public formatter aborts
             the process at ~50 000 levels and an abort is not catchable; (c) a string
             count is no longer trimmed for selection but not for `#`, so `" 1 "` cannot
             pick one arm and print another. Two tautological assertions and one
             tautological test the same read found were replaced with real ones.
   Rejected: File them and ship the plan's diff - (a) is a regression this change
             introduces, so shipping it would mean knowingly landing a defect; (b) and (c)
             are three lines each inside the function under change.
   Reverses: Revert the three hunks in `format.rs`; the tests naming them fail first.

8. The `xtask` cross-check is arm-body agreement, not category reachability.
   Taken:    `the_runtime_renders_what_the_android_resource_carries`: over the real
             catalogs, in every locale, at eight boundary counts, the sentence the runtime
             builds by substituting `#` equals the sentence Android builds from the `%d`
             format string the generator lowered (1048 comparisons, compared in Android's
             escaped spelling). The two-table reconciliation moved into `capsule-i18n`,
             where both halves live, as an exact equality.
   Rejected: The plan's "the category the runtime picks is present among the emitted arms".
             Written and run; it is false in two ways, which is how the truth surfaced.
             Russian never selects `other` for an integer (its `other` is for fractions),
             so the mandatory `other` arm looks dead; and the Apple renderer, unlike the
             Android one, does not filter unselectable arms, so `ja` carries a `one`
             variation. Asserting a false property would have forced either a wrong
             loosening or a change to the Apple renderer, which is outside this manifest.
   Reverses: Delete the agreement test; nothing else depends on it.

9. The two Swift regexes read one shared position list.
   Taken:    `SWIFT_TEXT_POSITIONS` is a constant both the literal and the interpolation
             regexes interpolate, so the six positions the plan adds land in both and the
             two cannot diverge again. `confirmationDialog` being in one and not the other
             is the defect #394 records; adding names to two lists repeats it.
   Rejected: Edit the two literal patterns in place, as planned.
   Reverses: Inline the constant into both regex literals.

10. The release-profile gate is reported, not added.
    Taken:    `cargo test -p capsule-i18n --release` is run by hand for this change and
              recorded in Validation; the CI hole it exposes is filed as #428 and recorded
              in `S-I7` as owed-CI. Three `cfg(not(debug_assertions))` tests in this diff
              pin production behaviour and no CI job compiles them.
    Rejected: Add a `--release` arm to `mise run test-rust` - `mise.toml` is outside this
              lane's manifest, and widening silently is what the manifest rule forbids.
    Reverses: Close #428 unfixed.
    Filed:    #428 - ci: no job runs cargo test --release, so every cfg(not(debug_assertions)) test is dead

11. A plural with no `other` arm renders its first arm in CLDR order.
    Taken:    The code stands (format.rs:220-225 in the reviewed head). Decision 4's
              sentence listing "a malformed plural" among the verbatim pass-through set is
              amended: `xtask i18n` (xtask/src/i18n.rs:586) refuses to emit a plural
              without `other`, so the case is unreachable from the catalogs, and for a
              hand-written template that slipped past the generator, rendering text beats
              showing a user ICU source. The two malformed shapes are now named
              separately - arms that do not parse pass through verbatim, arms that parse
              without `other` render the first arm - in the module doc, the assertion
              message, SLICES.md S-I7 and i18n.md.
    Rejected: Verbatim pass-through for this case too, for symmetry with the rest of the
              refused set - it shows the reader message source, which is the failure the
              whole refusal exists to prevent.
    Reverses: Delete `Arms::first` and the release test
              `release_builds_render_the_first_arm_of_a_plural_with_no_other`.

12. An unterminated `{` is copied through with no `debug_assert!`.
    Taken:    The code stands (format.rs:114-118 in the reviewed head). Decision 4's
              "unterminated braces keep the debug_assert!" is amended: a lone `{` in
              literal copy ("50% off {sale") is indistinguishable from a mistyped
              placeholder, and asserting on it would panic every debug run over legitimate
              text. It is emitted as an ordinary character and scanning continues past it.
              Recorded in i18n.md alongside decision 11.
    Rejected: Restore the assertion - it would make a legitimate literal brace a crash in
              every test and debug build, and the old formatter did not assert on it
              either (its `debug_assert!(!closed, ...)` was a no-op on that path).
    Reverses: Add the `debug_assert!` back on the unmatched-brace path.

Unresolved review notes

None.

Contributor Checklist

  • I agree to the Contributor License Agreement for this and future contributions.
  • My code follows the project's style guidelines according to CONTRIBUTING.md.
  • Tests pass
  • No sensitive info / secrets
  • Docs updated if needed

The per-platform renderers never needed a plural-rule table: `xtask i18n`
compiles an ICU plural into an Apple String Catalog variation or an Android
`<plurals>`, and the platform's own CLDR data picks the arm at display time.
The Rust runtime has no platform underneath it, which is why `format_message`
refuses a plural outright instead of evaluating one.

`capsule_i18n::plural` is the missing half: `Category`, `category(locale, n)`
and `selectable(locale)` over the twelve language subtags of
`locales/config.json`. Integer cardinals only — every plural in `locales/`
selects on a count and `Value` has no decimal variant, so the CLDR operands
reduce to `n = i`, `v = 0` and each rule is arithmetic on the absolute value.

Rules and their selectable sets live in one row per language so a test can
assert the pair is consistent; `xtask`'s second copy of the selectable table
is re-derived from this one in a later commit. `selectable` returns `None`
for an unknown language, which is what lets the generator keep failing loudly
on a locale it has no rules for while the runtime falls back to `other`.

The table is asserted cell by cell rather than derived, across the CLDR
boundary counts, plus the properties that matter downstream: selection never
leaves the selectable set, a region or script subtag resolves to its
language, and negative counts use the absolute value (`i64::MIN` included).
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8c191fb
Status: ✅  Deploy successful!
Preview URL: https://9a57130d.capsule-22k.pages.dev
Branch Preview URL: https://fix-i18n-plurals-and-swift-d.capsule-22k.pages.dev

View logs

`format_message` substituted `{identifier}` and refused everything else: a
plural hit a `debug_assert!` and, in release, was copied through verbatim — the
user reading the message source. That refusal was the cheap half of `S-I7`; this
is the half it owed.

`{name, plural, …}` now evaluates, with `=N` exact arms, CLDR category arms, `#`
for the count, and nesting — an arm may hold `{name}` placeholders and further
plurals. Arm selection comes from `capsule_i18n::plural`, so the arm chosen is
the one the locale's rules select, and a category the message does not carry
falls back to `other`. That fallback is what makes the shipped catalogs render
at all: every translated plural is still an English `one`/`other` copy, so a
Russian `few` has nowhere else to go.

`Bundle::format` dropped `self.locale` before calling the formatter, which is
the API gap plural selection had to close. `format_message_in(locale, …)` is
new; `format_message` keeps its signature and means English, for a template that
came from nowhere in particular.

The refusal is narrowed, not removed. `select`, `selectordinal`, `offset:`, a
malformed plural, and nesting past 32 levels keep the assertion and the
verbatim pass-through, so the next construct this runtime cannot express is a
test failure rather than something a user reads. The two tests that pinned the
refusal are retargeted onto `select` rather than deleted — and the debug one
gains the `cfg(debug_assertions)` it always needed, without which
`cargo test --release` failed on it.

Three defects found reviewing this change before committing it:

- A stray `{` used to abandon the rest of the template, so one unbalanced brace
  silently blanked every later argument. It is now one character of output and
  scanning continues.
- Recursion was bounded only by the input. A public formatter that aborts the
  process on a deeply nested template is not acceptable, so 32 levels is the
  limit and past it is a refusal.
- A string count was trimmed for selection but not for `#`, so `" 1 "` could
  choose one arm and print another. Both now read the same value.

The whole-catalog matrix asserts the acceptance property directly: every plural
in every one of the thirteen locales, at eight boundary counts, renders with no
braces, no `plural,`, and the count wherever the arm spells `#` — including the
keys a locale has not translated, which reach the reader through the source
catalog under the reader's own rules.
`xtask` held its own copy of which categories each language can select. It
predates the runtime having any rules at all, and the moment the runtime gained
them the two tables had to agree about thirteen locales — the shape that had
already produced this slice's bug once.

`android_plural_arms` and its test now read `capsule_i18n::plural::selectable`.
The generator keeps failing loudly on a language with no rules, which is why
`selectable` answers `None` rather than "only `other`": the runtime is happy to
fall back, the generator must not guess.

Two tests close the loop the merge opens.

`the_selectable_set_is_exactly_what_selection_can_produce` pins both directions
of every row: a category listed but unreachable is a dead `<item quantity=…>`
the generator would emit, and a category reachable but unlisted is one it would
drop and the runtime would then ask for. Russian is the interesting row — it
never selects `other` for an integer, since its `other` is for fractions, yet
every plural resource must carry the arm.

`the_runtime_renders_what_the_android_resource_carries` is the agreement nothing
checked before: over the real catalogs, in every locale, at the CLDR boundary
counts, the sentence the runtime builds by substituting `#` equals the sentence
Android builds from the `%d` format string the generator lowered — 1048
comparisons, in Android's own escaped spelling so `android_escape` is compared
against itself.
…mple

The detector's doc comment gives `case .places: "Places"` as the shape it was
added to catch. Its literal pattern required a capital followed by a space, so
that exact string — and every other single-word display string — could not be
caught (#394). The space was there to keep `case .heic: "HEIC"` out; requiring a
**lowercase letter** in position two does the same job and lets one capitalized
word through.

The trade is that prose whose second character is neither lowercase nor a space
("E-mail sent", "AI Insights") stops being caught. Measured across
`capsule-swift/{App,Modules}` in every position this detector scans: zero
strings are lost by the swap, one is gained.

Four blind spots #394 lists close with it:

- Only `case .foo:` inside the body was scanned. A literal returned explicitly,
  returned implicitly, or held as a dictionary value was invisible. All four
  shapes are scanned, keyed by absolute offset so an overlap is one finding.
- Six property-name stems matched; `heading`, `text`, `summary` and `prompt` now
  do too. `value` deliberately still does not — `var rawValue: String` returns
  an identifier two dozen times in `CapsuleDomain`.
- `var` was required, so `func hdrName(_:) -> String` was outside the gate.
- `confirmationDialog` was watched by the interpolation regex and not the
  literal one, and `help`, `searchable`, `accessibilityValue`,
  `ContentUnavailableView` and `tabItem` by neither. Both regexes now build from
  one shared list, so they cannot disagree again.

The widening finds exactly one string in the tree: "Dolby Vision", in
`AssetInfoFormatting.hdrName(_:)`, whose own doc says it spells an HDR encoding
"as its owner spells it". It is a trademark, spelled identically in every
locale — the allowlist's stated category, and the same reason its sibling arms
"HDR10" and "HLG" never tripped the gate. One allowlist line with its
justification; no Swift source is touched.
The design doc said the runtime "currently handles literal text and `{name}`
interpolation" and listed plurals as follow-up. Both stopped being true when
`S-I7` landed plural evaluation, and a confident wrong answer in this document
is exactly what let Android ship raw ICU for as long as it did.

The runtime section now names the supported subset, the CLDR table behind arm
selection and the fact that `xtask` reads the same one, the `other` fallback and
why it is load-bearing today, and — spelled out rather than implied — what is
still refused: `select`, `selectordinal`, `offset:`, number and date skeletons,
and ICU apostrophe quoting, which no target implements. The codegen table's Rust
row and the future-work bullet follow.

`SLICES.md` records the second half of `S-I7` as landed, why the plural rules
are an in-house table rather than a crate, that `xtask`'s copy was the second
one, and the three non-plural defects the pre-commit review of the formatter
found. It also records one owed-CI item: the tests that pin release-build
behaviour are `cfg(not(debug_assertions))` and no CI job runs
`cargo test --release`, so they never execute.

`locales/README.md` tells a translator the one thing that changed for them:
every target evaluates a `plural` block now, and the constructs that still fail
the build.
Reviewing the previous commit's own diff: the new dictionary-value pattern
matched any `label: "Some text"` followed by a comma, which reads the
`defaultValue:` of `String(localized:defaultValue:comment:)` as a dictionary
entry. That argument is the English source text the ICU arguments hang off — the
migrated shape, deliberately never captured — so the guard would have failed a
call site that is correct.

The key must now begin with `.`, which is what a dictionary key in this codebase
looks like and what an argument label never does. Measured across
`capsule-swift/{App,Modules}`: the finding set is unchanged, still exactly
"Dolby Vision".

Also records the `func` form's own limit: the signature is read with
`\([^)]*\)`, so a parameter list containing a closure type is not matched. A
blind spot, but a narrowing one — it can never produce a false positive.
`render` asked `matching_brace` at every `{`, and `matching_brace` scans forward
from the brace it was given. On the matched path that is fine — it stops at the
partner. On the **unmatched** path it has to read the whole remainder to learn
there is no partner, and then the next unmatched brace reads the same remainder
again.

Measured against a copy of the shipped scan: bytes read are exactly n(n+1)/2,
and a template of n unmatched braces takes 22 ms at n=10 000, 104 ms at 20 000,
369 ms at 40 000 and 1.63 s at 80 000 — quadruples per doubling. `format_message`
is a `pub` entry point, so this is the same threat model `MAX_DEPTH` was capped
for two commits ago: a template that need not come from a catalog.

`brace_pairs` now pairs every brace in one stack pass, and `render` walks a
cursor over the result in source order, skipping the entries the recursive render
of a placeholder body consumed. `matching_brace` stays for `Arms::parse`, where
the arms are disjoint so its scans add up to a single pass, and its doc says why
the two coexist.

An unmatched brace was the case that could not be shortcut cheaply: "if pairing
fails here it fails later too" is false — in `{ {a} ` the outer brace has no
partner and the inner one does — so the fix has to be the real pairing, not a
watermark. That case is in the equivalence test.

Two pins, and they count bytes rather than watching a clock: a wall-clock budget
on this host would be either flaky or too loose to prove anything, while a
thread-local counter incremented by both scanners is deterministic and fails the
moment a per-brace rescan returns. Measured after: 100 000 bytes scanned for a
100 000-byte unmatched template (was ~5 x 10^9), and 118 000 for a 101 000-byte
template of 10 000 placeholders and 1 000 plurals.

Also makes the module doc and the refusal message say which malformed plural
does what: a plural whose arms do not parse is passed through verbatim, while one
whose arms parse but carry no `other` asserts and renders its first arm in CLDR
order. Those are different behaviours and the docs called both "malformed".
Two records described the formatter's fallback behaviour as a verbatim
pass-through, which is right for most of the refused set and wrong for the two
cases that matter most, because they are the ones a reader could actually hit.

- A plural whose arms parse but carry **no `other`** asserts and renders its
  first arm in CLDR order. It is not passed through. `xtask i18n` refuses to emit
  such a message, so it is unreachable from the catalogs; for a hand-written
  template that slipped past the generator, some text beats showing the reader
  ICU source. Both records now say so, and `i18n.md` gains the case at all.
- An unterminated `{` is **not** an assertion case. A lone brace in literal copy
  ("50% off {sale") cannot be told apart from a mistyped placeholder, and
  asserting on it would panic every debug run over legitimate text. It is emitted
  as an ordinary character and scanning continues past it.

Also corrects the owed-CI count in `S-I7`: three tests pin release-build
behaviour, not two, and names them and the issue (#428).
…list

`swift_watches_every_api_position_in_both_regexes` exercised four of the fifteen
entries in `SWIFT_TEXT_POSITIONS`. `searchable` and `tabItem` had no fixture and
no call site anywhere in the tree, so a broken alternative in the shared list —
a stray space, an empty alternative, a regex metacharacter — would have been
silent in both regexes it is spliced into.

Two derived tests replace the gap. `every_watched_api_position_is_a_bare
_identifier` rejects an entry that is not a plain identifier, and rejects a
duplicate. `every_watched_api_position_is_caught_in_both_regexes` builds its
fixture *from* the list — one plain literal and one interpolated literal per
entry, plus a helper name that merely ends in the entry — so a position added
without a case cannot happen, which is the failure #394 records for
`confirmationDialog`.

Verified non-vacuous: appending `inspector Prompt` to the list fails the
identifier test with that entry named.

The hand-written test stays, and says why: a derived fixture proves each
alternative matches, and the concrete one proves the real call shapes do, with
the arguments SwiftUI actually puts after the string
(`ContentUnavailableView("…", systemImage: …)`).
…detector-414

The base moved to 99dd4bc (a `capsule-web/bun.lock` regeneration) after this
branch was cut at f433d91, which made the pull request's two-dot diff show that
lockfile as a reverse hunk. Merged rather than rebased: the branch is pushed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant