Skip to content

feat(i18n): phase 4 — plural forms - #660

Merged
apotema merged 5 commits into
mainfrom
feat/i18n-plurals
Aug 8, 2026
Merged

apotema merged 5 commits into
mainfrom
feat/i18n-plurals

Conversation

@apotema

@apotema apotema commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

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 other among 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, because easy breaks 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

tp(K.hud.items, n)                          // plural, {count} only
tpf(K.hud.gift, n, .{ .name = who })        // plural + extra placeholders, comptime-checked
  • t/tf on a plural key, tp/tpf on a non-plural key, tpf on a count-only key, tp on a key with extra placeholders, and passing .count as a tpf argument are all comptime errors with pointed messages.
  • The category is selected at runtime by the active locale's rule (ru picks few/many where en picks one/other); the rule is fixed at build time per locale. Results share tf's ring-buffer lifetime.
  • Counts are integers; CLDR classification uses the absolute value, rendering keeps the sign.

Rule model

src/i18n_plurals.zig maps 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 (pt and pt-BR pluralise alike):

rule languages reachable categories
other_only ja zh ko th vi id ms other
one_other default (en, de, nl, …) one, other
one_from_zero fr pt (one at 0..1; nonzero whole millions → many) one, many, other
one_other_millions es it pt-PT (one at exactly 1, millions → many; pt-PT: zero is plural) one, many, other
east_slavic ru uk be one, few, many
polish pl one, few, many
czech_slovak cs sk one, few, other
arabic ar all six

The table is a flat list — adding a language is one row. The runtime selectCat emitted into i18n.zig is the textual twin of the build-time select; the unit tests in i18n_plurals.zig are the executable spec for both (a brute-force test also proves reachable ⇔ select agree over 0..500).

Fallback chain — the table stays total

Every (key, locale, category) slot is resolved at build time:

locale's variant  →  locale's own `other`  →  reference's variant  →  reference's `other`

A locale's own language with approximate grammar beats the reference's foreign text with the right grammar, which is why the locale's other sits before the reference in the chain. Detection guarantees other exists 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

  • Kind stability: a key plural in the reference and a plain string in another locale (or the reverse) is a build error, in the same pass as the §3 rename catch.
  • Placeholder parity (§4, extended): per locale, the union of non-count placeholders 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.
  • Usage-aware variant coverage (§3.1, one level deeper): a used plural key in a locale that defines it but lacks a variant its own rule can reach warns (error under strict); unreachable variants (few in an en file) and unused keys stay silent. A locale missing the whole key only gets the existing key-level warning, not per-variant noise.
  • Packs: plural keys ride the existing phase-3 merge — they surface <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-count union 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 with main'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, and tp/tpf are emitted only when a plural key exists.

RFC ambiguities resolved (the RFC's phase-4 line is one sentence)

  1. Detection rule: plural ⇔ all children are category-named string leaves AND other present. 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.
  2. Guarded footgun: two or more all-category children without other is a build error (a plural set missing its fallback is far likelier than a namespace spelling one/few); a single category-named child (level: { one: "Level One" }) is too weak a signal and stays a namespace.
  3. Fallback order: locale's other before reference's same-category variant (own language beats foreign grammar).
  4. Parity model: non-count placeholder union per locale, not per-variant equality — variants legitimately differ, locales must not.
  5. {count} is implicit and reserved: never listed in the parity set, never a tpf argument, optional in any variant.
  6. Default rule for unlisted languages is one/other, not CLDR-root's other-only: the other-only languages a game plausibly ships are all listed explicitly, and for the long tail one/other errs toward a grammar blemish rather than a wrong-category surprise in European languages.
  7. Counts are integers (CLDR integer rules only); fractional counts are out of scope alongside the RFC's number-formatting non-goal. Negative counts classify by absolute value.
  8. Per-key API split mirrors t/tf: tp for count-only keys, tpf for keys with extra placeholders, each refusing the other's keys at comptime — same "keep the simple path honest" reasoning as §4.

Tests

  • 26 new unit tests: rule table + select/reachable spec (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 test failure set is byte-for-byte identical to clean main in the same environment (this Windows box cannot run some cargo/quickjs/crystal e2e targets either way); every new and every previously-passing test passes.
  • Verified end-to-end locally (not committed): generated a plural module, compiled and ran it — per-locale selection (en vs ru across one/few/many), variant backfill through other, tpf extra args, negative counts, and all four comptime guard errors fire with the intended messages.
  • Golden tests: untouched (no example project uses locales/; non-plural output is byte-identical anyway).

🤖 Generated with Claude Code

https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk

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
@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The localization system now supports CLDR plural values. It parses plural sets, validates locale and pack contracts, resolves fallbacks, and generates tp and tpf APIs with runtime category selection.

Plural localization

Layer / File(s) Summary
Plural categories and locale value contracts
src/i18n_plurals.zig, src/i18n_locales.zig
Defines CLDR categories, locale rule lookup, category selection, and typed locale values. Locale objects can represent plural sets or namespaces.
Plural validation and fallback resolution
src/i18n_phase.zig
Validates plural shapes, placeholders, reachable variants, pack translations, and game overrides. It resolves locale and reference fallbacks.
Generated plural APIs and integration
src/i18n_phase.zig, src/root.zig
Generates plural metadata and tables, adds tp and tpf, rejects t and tf for plural keys, and updates test discovery.

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
Loading

Possibly related PRs

Poem

A rabbit counts one, two, three,
CLDR blooms on every tree.
tp hops in, tpf follows near,
Fallback paths become clear.
Plural sets cheer!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding plural forms to the internationalization phase.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/i18n-plurals

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/i18n_phase.zig (2)

1224-1233: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Constrain the accepted count width, or the wide-integer path fails with an opaque error.

The comptime check accepts every .int type. @abs returns an unsigned type with the same bit count, so count: i128, u128, or u65 produces a value that does not coerce to u64. The caller then sees a type-mismatch error inside the generated module instead of the intended tp/tpf: count must be an integer message.

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 win

Derive the generated PluralCat / PluralRule lists 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 to plurals.Rule, locale_rules can name a tag that the generated PluralRule enum does not declare, and the generated module stops compiling. The same drift class applies to PluralCat versus plurals.Category.

Generating both lists from @typeInfo removes 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 selectCat body at lines 1260-1290 stays a hand-kept twin of plurals.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

📥 Commits

Reviewing files that changed from the base of the PR and between 1cf89ad and 92b50e7.

📒 Files selected for processing (4)
  • src/i18n_locales.zig
  • src/i18n_phase.zig
  • src/i18n_plurals.zig
  • src/root.zig

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/i18n_plurals.zig
Comment on lines +95 to +98
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/i18n_plurals.zig Outdated
return switch (rule) {
.other_only => .other,
.one_other => if (n == 1) .one else .other,
.one_from_zero => if (n <= 1) .one else .other,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 92b50e7 and fc15986.

📒 Files selected for processing (2)
  • src/i18n_phase.zig
  • src/i18n_plurals.zig
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/i18n_phase.zig

Comment thread src/i18n_plurals.zig Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/i18n_plurals.zig Outdated
Comment on lines +53 to +54
/// one at exactly 1: en, de, es, it, nl, sv, ... (and the default).
one_other,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/i18n_phase.zig
had_error = true;
continue;
};
if (std.meta.activeTag(ref_value) != std.meta.activeTag(e.value)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/i18n_phase.zig
had_error = true;
continue;
};
if (std.meta.activeTag(ref_value) != std.meta.activeTag(e.value)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/i18n_phase.zig
had_error = true;
continue;
};
if (std.meta.activeTag(rv) != std.meta.activeTag(e.value)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/i18n_phase.zig
// 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lift

Derive the generated plural enums from i18n_plurals.zig.

PluralCat and PluralRule duplicate plurals.Category and plurals.Rule without a compile-time link. Generate their field names from the build-time enum types. This prevents new or renamed Rule variants from breaking locale_rules, and prevents Category reordering from mis-indexing plural_segs.

Keep selectCat exhaustive so new Rule variants 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 win

Pin 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 value

Reject integer counts wider than 64 bits in the tp/tpf guard. @abs returns the same-width unsigned type, so i128 and u128 counts fail at const n: u64 with a generated-code diagnostic. Restrict .int to i.bits <= 64 while 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

📥 Commits

Reviewing files that changed from the base of the PR and between fc15986 and 154d281.

📒 Files selected for processing (2)
  • src/i18n_phase.zig
  • src/i18n_plurals.zig
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/i18n_plurals.zig

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/i18n_phase.zig
.plural => |p| p,
.str => continue,
};
if (!marks.covers(re.key)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread src/i18n_phase.zig
Comment on lines +481 to +482
const an = (try valueArgNames(arena, a)) orelse return true;
const bn = (try valueArgNames(arena, b)) orelse return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@apotema
apotema merged commit 494312f into main Aug 8, 2026
6 checks passed
@apotema
apotema deleted the feat/i18n-plurals branch August 8, 2026 13:58
apotema added a commit that referenced this pull request Aug 8, 2026
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
apotema added a commit that referenced this pull request Aug 8, 2026
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
apotema added a commit that referenced this pull request Aug 8, 2026
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
apotema added a commit that referenced this pull request Aug 8, 2026
…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>
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