Repository navigation
feat(i18n): phase 4 — plural forms - #660
Conversation
RFC-I18N's deferred phase 4: CLDR plural categories
(zero/one/two/few/many/other) as a nested key convention with per-locale
category sets.
- An object of category-named string leaves with `other` present is ONE
plural key; anything else stays a namespace. All-category children
missing `other` (two or more) error rather than silently degrading.
- src/i18n_plurals.zig: seven CLDR cardinal-integer rule shapes keyed by
primary language subtag (default one/other), with `reachable` category
sets; the emitted `selectCat` is its textual twin and the unit tests
are the spec for both.
- tp(key, count) / tpf(key, count, args) with {count} implicit; t/tf
refuse plural keys at comptime, and every cross-shape misuse is a
comptime error.
- Every (key, locale, category) slot resolves at build time through
variant -> own `other` -> reference variant -> reference `other`, so
the rectangular no-runtime-failure guarantee holds unchanged.
- Kind stability (plural vs string) errors in the §3 rename-catch pass;
placeholder parity compares the non-count UNION across variants;
variant-level coverage is usage-aware and rule-aware (§3.1), strict
promotes it; packs ride the phase-3 merge unchanged.
- Zero cost: a project without plural keys emits a byte-identical
module (verified against main's output; unit-tested for absence of
plural machinery).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
📝 WalkthroughWalkthroughChangesThe localization system now supports CLDR plural values. It parses plural sets, validates locale and pack contracts, resolves fallbacks, and generates Plural localization
Estimated code review effort: 5 (Critical) | ~100 minutes Sequence Diagram(s)sequenceDiagram
participant LocaleFiles
participant I18nPhase
participant GeneratedModule
participant Caller
LocaleFiles->>I18nPhase: Parse plural objects
I18nPhase->>I18nPhase: Validate shapes, placeholders, and fallbacks
I18nPhase->>GeneratedModule: Emit plural tables and locale rules
Caller->>GeneratedModule: Call tp or tpf with count
GeneratedModule-->>Caller: Select and format plural translation
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/i18n_phase.zig (2)
1224-1233: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winConstrain the accepted
countwidth, or the wide-integer path fails with an opaque error.The comptime check accepts every
.inttype.@absreturns an unsigned type with the same bit count, socount: i128,u128, oru65produces a value that does not coerce tou64. The caller then sees a type-mismatch error inside the generated module instead of the intendedtp/tpf: count must be an integermessage.Either reject widths above 64 bits with a clear message, or cast explicitly.
♻️ Proposed change (reject the unsupported widths)
\\fn pluralFormat(comptime p: u16, count: anytype, args: anytype) [:0]const u8 { \\ comptime switch (`@typeInfo`(`@TypeOf`(count))) { - \\ .int, .comptime_int => {}, + \\ .comptime_int => {}, + \\ .int => |i| if (i.bits > 64) + \\ `@compileError`("tp/tpf: count must fit in 64 bits, got " ++ `@typeName`(`@TypeOf`(count))), \\ else => `@compileError`("tp/tpf: count must be an integer, got " ++ `@typeName`(`@TypeOf`(count))), \\ };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/i18n_phase.zig` around lines 1224 - 1233, Update the comptime type validation in pluralFormat to reject integer count types wider than 64 bits before evaluating `@abs`, while continuing to accept supported integer types. Emit the existing “tp/tpf: count must be an integer” compile error for unsupported widths so wide inputs fail with the intended message, and preserve the current u64 conversion and plural-category flow for valid counts.
874-892: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDerive the generated
PluralCat/PluralRulelists from the source enums.The generated enum bodies are hardcoded string literals, but line 890 emits
@tagName(plurals.ruleForTag(t)). If a new variant is added toplurals.Rule,locale_rulescan name a tag that the generatedPluralRuleenum does not declare, and the generated module stops compiling. The same drift class applies toPluralCatversusplurals.Category.Generating both lists from
@typeInforemoves that class of drift at zero runtime cost.♻️ Proposed refactor
- try w.writeAll( - \\// Plural data (RFC-I18N phase 4): CLDR categories as a nested key - \\// convention. Every (key, locale, category) slot was resolved at - \\// build time (variant -> own 'other' -> reference variant -> - \\// reference 'other'), so selection below can never miss. - \\const PluralCat = enum(u3) { zero, one, two, few, many, other }; - \\const PluralRule = enum { other_only, one_other, one_from_zero, east_slavic, polish, czech_slovak, arabic }; - \\ - \\ - ); + try w.writeAll( + \\// Plural data (RFC-I18N phase 4): CLDR categories as a nested key + \\// convention. Every (key, locale, category) slot was resolved at + \\// build time (variant -> own 'other' -> reference variant -> + \\// reference 'other'), so selection below can never miss. + \\ + ); + try w.writeAll("const PluralCat = enum(u3) {"); + inline for (`@typeInfo`(plurals.Category).@"enum".fields, 0..) |f, i| { + try w.print("{s} {s}", .{ if (i != 0) "," else "", f.name }); + } + try w.writeAll(" };\n"); + try w.writeAll("const PluralRule = enum {"); + inline for (`@typeInfo`(plurals.Rule).@"enum".fields, 0..) |f, i| { + try w.print("{s} {s}", .{ if (i != 0) "," else "", f.name }); + } + try w.writeAll(" };\n\n");Note: the emitted
selectCatbody at lines 1260-1290 stays a hand-kept twin ofplurals.select. Consider adding a test that runs both over a dense count range, or a test that at least asserts the two enum field lists match.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/i18n_phase.zig` around lines 874 - 892, Update the generated declarations in the writer around PluralCat and PluralRule to derive their enum field lists from the source plurals.Category and plurals.Rule definitions via `@typeInfo`, rather than hardcoded literals. Preserve the generated enum syntax and ensure locale_rules values from plurals.ruleForTag remain valid whenever either source enum gains a variant; leave the hand-maintained selectCat logic unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/i18n_phase.zig`:
- Around line 1224-1233: Update the comptime type validation in pluralFormat to
reject integer count types wider than 64 bits before evaluating `@abs`, while
continuing to accept supported integer types. Emit the existing “tp/tpf: count
must be an integer” compile error for unsupported widths so wide inputs fail
with the intended message, and preserve the current u64 conversion and
plural-category flow for valid counts.
- Around line 874-892: Update the generated declarations in the writer around
PluralCat and PluralRule to derive their enum field lists from the source
plurals.Category and plurals.Rule definitions via `@typeInfo`, rather than
hardcoded literals. Preserve the generated enum syntax and ensure locale_rules
values from plurals.ruleForTag remain valid whenever either source enum gains a
variant; leave the hand-maintained selectCat logic unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 418ac235-2720-4c17-8af6-3037a6aeeb2f
📒 Files selected for processing (4)
src/i18n_locales.zigsrc/i18n_phase.zigsrc/i18n_plurals.zigsrc/root.zig
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 92b50e7149
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const dash = std.mem.indexOfScalar(u8, tag, '-') orelse tag.len; | ||
| const lang = tag[0..dash]; | ||
| for (tag_rules) |row| { | ||
| if (std.ascii.eqlIgnoreCase(row.lang, lang)) return row.rule; |
There was a problem hiding this comment.
Respect the pt-PT regional plural rule
When the active locale is pt-PT, reducing the tag to its primary subtag assigns .one_from_zero, so tp(key, 0) selects the singular one variant. CLDR distinguishes European Portuguese here: zero is other, unlike base/Brazilian Portuguese where zero is one. Add a region-specific override rather than deriving every rule solely from the primary subtag.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in fc15986. A full-tag override table now runs before the primary-subtag rows: pt-PT maps to the new .one_other_millions shape (one at exactly 1, so 0 renders the plural; nonzero whole millions select many). Case-insensitive like every other tag comparison, and deliberately just that one row rather than CLDR locale inheritance. Pinned by rule-spec tests (pt-PT: 0→other, 1→one, 2_000_000→many) and a phase test asserting pt-PT's row in the emitted locale_rules.
| return switch (rule) { | ||
| .other_only => .other, | ||
| .one_other => if (n == 1) .one else .other, | ||
| .one_from_zero => if (n <= 1) .one else .other, |
There was a problem hiding this comment.
Select the CLDR many form for million multiples
For the fr and pt locales assigned to .one_from_zero, integer multiples such as 1_000_000 belong to CLDR's many category, but this branch always selects other for every value above one. Consequently a provided many translation is never rendered, and reachable() also treats that category as unreachable so strict coverage cannot detect its absence; update this rule family, its reachability set, and the emitted selectCat twin.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in fc15986. .one_from_zero (fr, pt) now selects many for nonzero whole-million integers, reachable() reports it — so a used key missing many warns, and fails under i18n.strict — and the emitted selectCat twin in the generated module mirrors the same branch (pt-PT's new .one_other_millions gets it too). Covered by rule-spec tests (fr: 0→one, 999_999→other, 1_000_000→many, 1_000_001→other), widened brute-force samples for the reachable⇔select contract, and verified end-to-end by compiling and running a generated fr/pt-PT module.
Two review findings, both genuine per CLDR cardinal-integer rules: - pt-PT: European Portuguese classifies 0 as plural (one is exactly n=1), unlike base/Brazilian pt. A full-tag override table now runs before the primary-subtag rows, mapping pt-PT to the new .one_other_millions shape. Just that one row, not CLDR inheritance. - fr/pt: nonzero whole millions belong to `many` (CLDR: e = 0 and i % 1000000 = 0). .one_from_zero now selects it, reachable() reports it (so strict coverage can see a missing `many`), and the emitted selectCat twin mirrors both changes. Covered by new rule-spec tests (0/1/999999/1e6/2e6/1e6+1 per family), a phase test pinning pt-PT's row in the emitted locale_rules, and the widened reachable<->select brute-force samples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/i18n_plurals.zig`:
- Around line 105-112: Update ruleForTag to identify the region subtag in
composed BCP-47 tags before applying full_tag_rules, so tags containing the
pt-PT region—including script or extension subtags—reuse the pt-PT override and
return .one_other_millions. Preserve case-insensitive matching and add
regression tests covering pt-Latn-PT and pt-PT-u-nu-latn.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 9f886940-823b-4d9f-8088-7a7f9c9605cf
📒 Files selected for processing (2)
src/i18n_phase.zigsrc/i18n_plurals.zig
🚧 Files skipped from review as they are similar to previous changes (1)
- src/i18n_phase.zig
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fc1598650c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| /// one at exactly 1: en, de, es, it, nl, sv, ... (and the default). | ||
| one_other, |
There was a problem hiding this comment.
Preserve the many category for Spanish and Italian
When the active locale is es or it, ruleForTag has no matching row and falls through to .one_other, although their CLDR cardinal-integer rules classify nonzero whole multiples of 1,000,000 as many. Consequently tp(..., 1_000_000) always renders other, a supplied many variant is unreachable, and strict coverage cannot require it. The follow-up added million-aware handling for fr/pt/pt-PT, but es and it also need a one-at-1 plus millions-many rule.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0bcb547. es and it now map to .one_other_millions (one at exactly 1, nonzero whole millions → many) instead of riding the one_other default — same shape pt-PT already used, so no new rule was needed. reachable() reports many for them, strict coverage can require it, and the emitted selectCat twin already carried the branch. Tests: es, es-419, it-IT.
| had_error = true; | ||
| continue; | ||
| }; | ||
| if (std.meta.activeTag(ref_value) != std.meta.activeTag(e.value)) { |
There was a problem hiding this comment.
Validate pack overrides against the pack key kind
When a game overrides a pack key in the game's reference locale with the opposite shape—for example, replacing the pack's plural meals key with a plain string—and the game ships only that locale or the pack has no corresponding entry in another offered locale, the merge suppresses the pack value and this cross-locale check sees only the game's shape. The invalid override is therefore accepted and the generated key exposes t instead of tp, breaking the pack's translation call sites; validate each game-side override's active union tag against pd.reference.get(rest) before applying precedence.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0bcb547. The §2.1 override-site validation now compares kinds as well as existence: a game write under a pack namespace whose shape differs from the pack reference's is a build error naming both shapes ("the pack's call sites compile against that shape"). That closes exactly the one-locale hole — no second locale is needed to disagree. Regression test: a single-locale game overriding a pack plural key with a plain string fails generate.
… whole tag An exact full-tag match misses composed BCP-47 forms -- pt-Latn-PT and pt-PT-u-nu-latn fell through to base pt's rule. The override table is now keyed (language, region), with the region extracted per RFC 5646 subtag order: after the primary language and an optional 4-letter script subtag, two letters or three digits, stopping at the first subtag that can be neither (variants, extension singletons and everything after them are never regions). Regression tests: pt-Latn-PT and pt-PT-u-nu-latn -> .one_other_millions; pt-Latn-BR and pt-419 keep base pt's .one_from_zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 047c09e373
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| had_error = true; | ||
| continue; | ||
| }; | ||
| if (std.meta.activeTag(ref_value) != std.meta.activeTag(e.value)) { |
There was a problem hiding this comment.
Reject key-kind drift within pack locales
When a pack declares a reference locale different from the game's reference—for example, a plural key in the pack's de reference but a plain string for that key in its en translation—and a one-locale en game consumes it, mergePacks installs the en value into the merged reference. This comparison then sees only the plain shape and accepts it, so the generated key exposes t rather than tp and the pack's translation call sites stop compiling. Compare every pack locale entry's active union tag with the pack's own reference value during the per-realm validation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0bcb547. The per-pack rename-catch pass now also compares each pack locale's kind against the pack's own reference, so a plural key in the pack's de reference with a plain-string en translation fails at the pack — before the flipped shape can become a one-locale game's merged reference. Regression test: pack with .i18n_reference = de and a kind-flipped en, consumed by an en-only game, fails generate.
Three more review findings, same CLDR/consistency classes: - es and it share pt-PT's shape per CLDR cardinal-integer rules -- one at exactly 1, many at nonzero whole millions -- so they join .one_other_millions instead of riding the one_other default. - A game override of a pack key must keep the pack's kind. The general reference-vs-locale check cannot catch a flipped override in a game shipping only its reference locale (nothing left to disagree), yet the pack's call sites compile against the pack's shape; the override-site validation now compares kinds too. - Kind drift inside a pack's own locales (plural in the pack's reference, plain string in a translation) now fails in the per-pack rename-catch pass -- checked on merged data only, a flipped pack locale could BECOME the merged reference and slip through. Covered by rule-table tests (es, es-419, it-IT) and two pack regression tests (one-locale flipped override; pack-internal drift under a pack-declared reference). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0bcb5479f8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| had_error = true; | ||
| continue; | ||
| }; | ||
| if (std.meta.activeTag(rv) != std.meta.activeTag(e.value)) { |
There was a problem hiding this comment.
Validate plural placeholders within each pack
When a pack's reference is de but a one-locale game offers en, an en pack translation can remain a plural set while dropping or renaming a non-count placeholder. This branch checks only the union tag, and the merge then installs the en translation as the merged reference, so downstream parity compares it against itself and generates the wrong tp/tpf argument contract for the pack's call sites. Compare each pack locale's plural placeholder union with pack_ref here as well.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 154d281. The per-pack pass now compares each pack locale's argument contract — full placeholder set for plain strings, non-count union for plural sets — against the pack's reference, in the same place the kind is checked, so a de-reference pack whose en translation drops {name} fails at the pack before the flipped contract can become a one-locale game's merged reference. Regression test covers exactly that shape.
| // disagree with, and the pack's own shape was just suppressed | ||
| // by the override -- yet every call site the pack authored | ||
| // compiles against the pack's shape. | ||
| if (std.meta.activeTag(pack_value) != std.meta.activeTag(e.value)) { |
There was a problem hiding this comment.
Preserve pack placeholders in game overrides
When a one-locale game overrides a pack plural key with another plural set but drops or renames an extra placeholder, this validation accepts it because the active union tag still matches. The override becomes the merged reference, leaving no downstream locale disagreement to detect, and plural_extra then exposes an API incompatible with the pack-authored tpf calls. Validate the override's non-count placeholder union against pack_value, not only its value kind.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 154d281. The §2.1 override-site validation now checks the argument contract as well as the kind: an override whose non-count placeholder union (or, for plain strings, placeholder set) differs from the pack's is a build error naming both sets — no second locale needed. Regression test: an en-only game overriding a pack plural key while dropping {name} fails generate.
The kind checks of 0bcb547 closed half the one-locale hole; the placeholder CONTRACT had the same one. A pack translation (or a game override) that keeps the shape but drops or renames a non-count placeholder could become the merged reference in a one-locale game -- downstream parity then compares it against itself and the generated Args type breaks the pack-authored tf/tpf call sites. Both sites now compare the argument-name set -- the full placeholder set for plain strings, the non-count union for plural sets -- against the pack's reference, right where the kind is checked, with messages naming both sets. Unparseable strings skip the comparison; the downstream per-locale parse still reports their syntax errors at a better site. Regression tests: a pack en translation dropping {name} from its de reference's plural key, and an en-only game override dropping {name} from a pack plural key, both fail generate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/i18n_phase.zig (1)
964-965: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy liftDerive the generated plural enums from
i18n_plurals.zig.
PluralCatandPluralRuleduplicateplurals.Categoryandplurals.Rulewithout a compile-time link. Generate their field names from the build-time enum types. This prevents new or renamedRulevariants from breakinglocale_rules, and preventsCategoryreordering from mis-indexingplural_segs.Keep
selectCatexhaustive so newRulevariants produce a compile-time error until their selector logic is added.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/i18n_phase.zig` around lines 964 - 965, Update the generated PluralCat and PluralRule declarations to derive their field names from the build-time plurals.Category and plurals.Rule enum types in i18n_plurals.zig, preserving the existing enum representations and index alignment used by plural_segs and locale_rules. Keep selectCat exhaustive over the derived PluralRule so adding a new rule causes a compile-time error until selector logic is implemented.
🧹 Nitpick comments (2)
src/i18n_phase.zig (2)
1976-1976: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPin the new negative tests to their specific diagnostics.
Each of these four tests asserts only
error.I18nInvalid. That single error value is shared by every validation branch in the same code path, including the pre-existing absent-key and pack-reference checks.A test can therefore pass for the wrong reason. If the new kind check at Line 591 or the new contract check at Line 602 were removed, the fixture in the test at Lines 1979-2004 could still fail through an unrelated branch, and the suite would stay green.
Consider capturing the printed diagnostic, or returning a distinct error per check, so each new branch is pinned.
Also applies to: 2003-2003, 2031-2031, 2057-2057
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/i18n_phase.zig` at line 1976, Strengthen the four new negative tests around runPhase so each verifies the diagnostic produced by its intended validation branch, not merely the shared error.I18nInvalid result. Capture the printed diagnostic and assert the expected kind or contract message for the fixtures near the relevant tests, while preserving the existing absent-key and pack-reference checks.
1309-1318: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueReject integer counts wider than 64 bits in the
tp/tpfguard.@absreturns the same-width unsigned type, soi128andu128counts fail atconst n: u64with a generated-code diagnostic. Restrict.inttoi.bits <= 64while retaining.comptime_int.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/i18n_phase.zig` around lines 1309 - 1318, Update the comptime type guard in pluralFormat to reject integer types whose bit width exceeds 64, while continuing to accept comptime_int and integer types up to 64 bits. Use the integer metadata from `@typeInfo`(`@TypeOf`(count)) and preserve the existing compile error for unsupported types and the subsequent u64 conversion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/i18n_phase.zig`:
- Around line 964-965: Update the generated PluralCat and PluralRule
declarations to derive their field names from the build-time plurals.Category
and plurals.Rule enum types in i18n_plurals.zig, preserving the existing enum
representations and index alignment used by plural_segs and locale_rules. Keep
selectCat exhaustive over the derived PluralRule so adding a new rule causes a
compile-time error until selector logic is implemented.
---
Nitpick comments:
In `@src/i18n_phase.zig`:
- Line 1976: Strengthen the four new negative tests around runPhase so each
verifies the diagnostic produced by its intended validation branch, not merely
the shared error.I18nInvalid result. Capture the printed diagnostic and assert
the expected kind or contract message for the fixtures near the relevant tests,
while preserving the existing absent-key and pack-reference checks.
- Around line 1309-1318: Update the comptime type guard in pluralFormat to
reject integer types whose bit width exceeds 64, while continuing to accept
comptime_int and integer types up to 64 bits. Use the integer metadata from
`@typeInfo`(`@TypeOf`(count)) and preserve the existing compile error for
unsupported types and the subsequent u64 conversion.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 60e5dbdb-ecb3-411c-ac67-8077def7c811
📒 Files selected for processing (2)
src/i18n_phase.zigsrc/i18n_plurals.zig
🚧 Files skipped from review as they are similar to previous changes (1)
- src/i18n_plurals.zig
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 154d28101e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .plural => |p| p, | ||
| .str => continue, | ||
| }; | ||
| if (!marks.covers(re.key)) continue; |
There was a problem hiding this comment.
Include pack call sites in plural coverage
When a plural key is referenced only by a pack whose resolved source directory is outside game_dir (such as a cached or @libs pack), this gate always treats it as unused: marks is populated only by collectMarks(..., game_dir), never from packs[].src_dir. Consequently, missing reachable variants in a shipped locale produce neither a warning nor a strict error even though the compiled pack calls tp/tpf and players receive the fallback form; scan pack sources and account for their prefixed key namespace before applying this usage gate.
Useful? React with 👍 / 👎.
| const an = (try valueArgNames(arena, a)) orelse return true; | ||
| const bn = (try valueArgNames(arena, b)) orelse return true; |
There was a problem hiding this comment.
Report unparseable pack contracts instead of accepting them
When a pack declares a reference such as de, supplies a valid translation for the game's reference locale such as en, and a plural variant in the pack's de reference has malformed braces, these returns treat the values as contract-compatible. The merged game-reference slot then uses the valid en translation, so the downstream parser never encounters the malformed de value despite the comment claiming it will; the build therefore accepts an invalid pack reference and can derive an argument contract unrelated to the pack-authored call sites. Propagate or report parse failures during the per-pack validation rather than returning true here.
Useful? React with 👍 / 👎.
The keyword-lint/sentinel tests and the phase-4 plural tests were added at the same insertion points; the zipper resolution keeps main's test region intact and re-appends this branch's four test blocks. Failure set verified against main's baseline: zero new. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
The prior commit resolved the #660 test-suite zipper but lost MERGE_HEAD to an aborted first commit, recording a single parent; GitHub therefore still saw main unmerged and reported conflicts. Identical tree, correct parents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
The earlier resolution regressed i18n_locales.zig to the pre-#660 shape (this branch never touched the file), which broke the exe compile at the activeTag shape check. Restored main's version verbatim and moved the keyword-lint test fixtures to the union spelling (.value = .{ .str = .. }). Exe builds; test failure set is byte-identical to main's baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
…latform#786 friction #2/#3) (#663) * feat(i18n): keyword-key lint + null-terminated t() variants (flying-platform#786 friction #2/#3) Friction #2 -- Zig-keyword key segments (FP had to rename pause.resume -> pause.resume_game after the fact): - new src/zig_keywords.zig: detection defers to std.zig.Token.getKeyword (the compiler's own table, never drifts) + the @""-quoted call-site path builder. Primitives (bool, u8) deliberately unlinted: K.hud.bool is ordinary field access. - i18n phase: generation-time warning against the reference locale (the key space -- one warning per key however many files carry it) and each pack's reference, naming file, key, offending segment, the exact K.pause.@"resume" call-site spelling and the resume_ rename. Warning, never an error: the @"" path works (usage_scan covers quoted segments since #656 round 3). Diagnostics-gated like the coverage warnings. - constants phase: same lint, same shared helper, over defining yaml files with file:line, plus a keyword filename namespace (error.yaml -> C.@"error"). Override files skipped -- linted where the pack defined the keys. Pure collectors do the finding (unit-testable without capturing stderr); runPhase owns the printing. Friction #3 -- null-terminated access for cimgui: VERDICT, already sound since #656, so no tz/tfz siblings. t() returns [:0]const u8 off the sentinel-typed literal table (decoded-escape strings re-emitted as literals, so they carry the NUL too); tf() returns [:0]const u8 and the ring discipline holds through truncation (appendBytes never consumes the last byte, the sentinel lands in-bounds and frame_len steps past it, wrap only between results). Shipped the regression locks instead: - text-level signature/table asserts beside the emitter - test/i18n_sentinel_tests.zig: generates a real i18n.zig, then COMPILES AND RUNS a harness against it (zig test via the #586 zig_exe seam) -- comptime @typeof asserts + runtime .ptr[len]==0 probes over the static, decoded, keyword-keyed and ring-formatted paths - src/root.zig: i18n_phase goes pub for that suite Plurals (tp/tpf, #660) untouched -- #660 is still open. Projects without locales/ or constants/ stay byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * fix(i18n): one lint warning per surfaced pack key; quote non-bare segments in diagnostic paths CodeRabbit round 1 (both Minor, both real): - a game override of a keyword-bearing pack key warned twice -- once from the game-side pass, once from the pack-side pass over the same surfaced key. The pack's key space owns the diagnostic now: the game-side pass skips pack-namespaced keys (same raw-name `<pack>__` prefixing mergePacks uses). - quoteDottedPath keys off std.zig.isValidId instead of isKeyword alone: i18n composes surfaced paths from the RAW pack name, so a segment like `my-pack__hunger` needs @"" for shape, not keyword-ness -- the shown call-site path is now valid Zig for those packs too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * Merge main (plurals #660) — keep both i18n_phase test suites The keyword-lint/sentinel tests and the phase-4 plural tests were added at the same insertion points; the zipper resolution keeps main's test region intact and re-appends this branch's four test blocks. Failure set verified against main's baseline: zero new. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * fix merge: take main's i18n_locales (plural Value union) wholesale The earlier resolution regressed i18n_locales.zig to the pre-#660 shape (this branch never touched the file), which broke the exe compile at the activeTag shape check. Restored main's version verbatim and moved the keyword-lint test fixtures to the union spelling (.value = .{ .str = .. }). Exe builds; test failure set is byte-identical to main's baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Implements the deferred phase 4 of RFC-I18N (labelle-engine#811, branch
docs/rfc-i18n-and-constants): "Plurals — CLDR categories (zero/one/two/few/many/other) as a nested key convention with per-locale category sets." That one line is the whole spec; everything below it follows CLDR cardinal-integer conventions with deliberately minimal scope.JSONC shape — the nested key convention
An object whose children are all string leaves named from the CLDR category set, with
otheramong them, is one plural key:{ "hud": { "items": { "one": "{count} item", "other": "{count} items" }, // ONE key: K.hud.items "title": "Inventory", // plain key, as before }, }Anything else stays a namespace —
difficulty: { easy, normal, other }never misdetects, becauseeasybreaks the all-categories rule.{count}is the implicit placeholder bound to the count argument; variants may use it or not ("one": "an item"is fine).Call sites
t/tfon a plural key,tp/tpfon a non-plural key,tpfon a count-only key,tpon a key with extra placeholders, and passing.countas atpfargument are all comptime errors with pointed messages.rupicksfew/manywhereenpicksone/other); the rule is fixed at build time per locale. Results sharetf's ring-buffer lifetime.Rule model
src/i18n_plurals.zigmaps the tag (case-insensitive) to one of eight CLDR cardinal-integer rule shapes — a (language, region) override table first (pt-PT, composed forms like pt-Latn-PT included), then the primary language subtag (ptandpt-BRpluralise alike):other_onlyone_otherone_from_zeroone_other_millionseast_slavicpolishczech_slovakarabicThe table is a flat list — adding a language is one row. The runtime
selectCatemitted intoi18n.zigis the textual twin of the build-timeselect; the unit tests ini18n_plurals.zigare the executable spec for both (a brute-force test also provesreachable⇔selectagree over 0..500).Fallback chain — the table stays total
Every (key, locale, category) slot is resolved at build time:
A locale's own language with approximate grammar beats the reference's foreign text with the right grammar, which is why the locale's
othersits before the reference in the chain. Detection guaranteesotherexists wherever the key does, so the chain always lands — no runtime path can fail and no fallback code exists, exactly the §3.1/§5 guarantee.Validation
countplaceholders across its variants must equal the reference's union. Variants may differ within a locale ("an item" vs "{count} items"); a locale-wide dropped{name}is drift and errors showing both sets.strict); unreachable variants (fewin anenfile) and unused keys stay silent. A locale missing the whole key only gets the existing key-level warning, not per-variant noise.<pack>__-prefixed, the game overrides whole-key, the §2.1 must-exist check applies, and kind drift is refused at its source: an override must keep the pack's kind (checked at the override site, so a one-locale game cannot slip a flipped shape past the pack's call sites), and a pack's own locales must agree with its reference's kinds AND argument contracts (placeholder sets; non-countunion for plurals) — both checked at the source, where a one-locale game's merged reference could otherwise hide the drift.Zero cost
A project with no plural keys generates a byte-identical
i18n.zig(verified by generating the same non-plural project withmain's code and this branch's and diffing — identical; plus a unit test asserting the emitted module contains no plural machinery at all). All plural data, rules, andtp/tpfare emitted only when a plural key exists.RFC ambiguities resolved (the RFC's phase-4 line is one sentence)
otherpresent. A namespace whose keys are exactly category names (e.g.{ one, other }meaning "One"/"Other") is indistinguishable by construction — inherent to the RFC's "nested key convention"; rename a child to break it.otheris a build error (a plural set missing its fallback is far likelier than a namespace spellingone/few); a single category-named child (level: { one: "Level One" }) is too weak a signal and stays a namespace.otherbefore reference's same-category variant (own language beats foreign grammar).countplaceholder union per locale, not per-variant equality — variants legitimately differ, locales must not.{count}is implicit and reserved: never listed in the parity set, never atpfargument, optional in any variant.one/other, not CLDR-root'sother-only: theother-only languages a game plausibly ships are all listed explicitly, and for the long tailone/othererrs toward a grammar blemish rather than a wrong-category surprise in European languages.t/tf:tpfor count-only keys,tpffor keys with extra placeholders, each refusing the other's keys at comptime — same "keep the simple path honest" reasoning as §4.Tests
select/reachablespec (10), detection shapes (4), phase tests (12): emission, pt-PT rule wiring, backfill, kind + argument-contract drift (game, pack-internal, and override-site), parity, pack override, strict usage coverage.zig build testfailure set is byte-for-byte identical to cleanmainin the same environment (this Windows box cannot run some cargo/quickjs/crystal e2e targets either way); every new and every previously-passing test passes.other,tpfextra args, negative counts, and all four comptime guard errors fire with the intended messages.locales/; non-plural output is byte-identical anyway).🤖 Generated with Claude Code
https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk