Skip to content

fix: i18n.interpolate tolerates leftover % and arg-count mismatch#7980

Open
keithharvey wants to merge 1 commit into
beyond-all-reason:masterfrom
keithharvey:interpolate_variable_limit
Open

fix: i18n.interpolate tolerates leftover % and arg-count mismatch#7980
keithharvey wants to merge 1 commit into
beyond-all-reason:masterfrom
keithharvey:interpolate_variable_limit

Conversation

@keithharvey

Copy link
Copy Markdown
Collaborator

What it does

Make the trailing positional string.format pass in interpolate() optional and non-fatal: run it only when the string still has real positional specs (%d, %s, … — ignoring escaped %%), pass one argument per spec, and pcall-guard it so a spec/argument mismatch falls back to the un-formatted string instead of erroring.

Why

TLDR: callers can pass named variables freely without i18n blowing up.

string.format(str, unpack(vars)) ran unconditionally after %{name} interpolation, so a leftover literal % (e.g. 50% off) or a positional spec/arg mismatch threw an error — even when a translation only used named variables. That made it unsafe to pass a variable a given string doesn't consume.

Intermediate systems need this. In another branch, gui_chat sits between my code and i18n and infers a field's color from the translation variable key names — fragile, and my keys are resource-agnostic now (amountSendable, not energyAmount). I wanted to pass it a resourceType hint, but any extra variable exploded translation. This unblocks that.

Note

This is NOT fixed in the upstream kikito I18n, that's sad.

After named (%{name}) and formatted (%<name>.fmt) interpolation, the trailing
string.format(str, unpack(vars)) errored whenever a translation left a literal
% or had a different number of positional specs than vars. Run that pass only
when real format specs remain (ignoring %%), pass one arg per spec, and
pcall-guard it so a mismatch falls back to the un-formatted string.
@keithharvey

Copy link
Copy Markdown
Collaborator Author

See https://github.com/beyond-all-reason/Beyond-All-Reason/pull/7962/changes#r3424568782 for the upstream branch that also contains this fix.

@github-actions

Copy link
Copy Markdown
Contributor

Integration Test Results

16 tests  ±0   8 ✅ ±0   3s ⏱️ -1s
 1 suites ±0   8 💤 ±0 
 1 files   ±0   0 ❌ ±0 

Results for commit 3e9f680. ± Comparison against base commit 04cd76e.

@sprunk

sprunk commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Got an example input that currently fails?

@keithharvey

keithharvey commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator Author

Got an example input that currently fails?

resource_transfer_comms is a good one: I build a dynamic hash from a classifier and then pass it uniformly to translate. Some translations only consume a subset of those keys, so today any unused variable (or a literal % in the string) blows up string.format.

@sprunk

sprunk commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Some translations only consume a subset of those keys

I think you're just not meant to pass anything you won't consume. This is to guard against missing key errors (e.g. catching Welcome player when it was meant to be Welcome %{player} and such).

Probably best for @WatchTheFort to say which design he prefers.

@WatchTheFort

Copy link
Copy Markdown
Member

How can you tell the difference between a literal % and one that the developer meant to be part of an escape sequence but messed up?

@WatchTheFort

Copy link
Copy Markdown
Member

Being able to put more entries in the parameters table than interpolated variables specified in the translation entry seems pretty reasonable. No reason it should crash if you pass more data than it consumes.

@keithharvey

keithharvey commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

How can you tell the difference between a literal % and one that the developer meant to be part of an escape sequence but messed up?

You can't. A lone %x is inherently ambiguous.

The current code treats every leftover % as a programmer error and crashes. The PR flips the assumption to "probably literal" and renders it raw. The pcall fallback means whether the % was an intended literal or a fat-fingered escape, you get the same graceful outcome (raw string shown) instead of a runtime error. I think this is a better default behavior than forcing every translator to escape, but happy to call that out explicitly if the lost typo-crash worries you.

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.

3 participants