Merge commit '10a394ae' into lang/foundations (nested and list @Text keys derived, trf's trailing "" padding not counted, string(k) no escape site); reseeded, bootstrap-cfree fixpoint holds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-30 00:43:11 +03:00
commit c3a37bf76c
12 changed files with 75642 additions and 73902 deletions

View file

@ -883,7 +883,10 @@ declares its own type named `Key` keeps it; the builtin is then out of reach.)
`@TextKey("steps")` on the registry, `steps.<row>.<field>`. A `@PerMap` table's is
`maps.<map>.<table>.<row>.<field>`. When the field's type is `Key` and a row of a compiled registry
gives it no value, the compiler fills in the derived key (marked, as a literal is), so code writes
`tr(Items[i].name)` and the `.lres` never spells a key; a row may still name one, `name:
`tr(Items[i].name)` and the `.lres` never spells a key. The same holds below the row: a nested
record's field adds `.<field>` and a list item `.<index>` (`disruptions.<row>.arrive.steps.0.text`), and a
`@Text []Key` a row leaves out takes `<...>.0`, `.1`, ... for as many as the source `.po` has; `field:
null` is no text, and is not checked; a row may still name one, `name:
k"items.lamp.name"`, in a resource file or a map's file alike.
```ludic
@ -907,7 +910,8 @@ each diagnostic at its `file:line:col`:
- a `k"..."` literal, a template's `t('...')` literal, or a key a data row names or derives that the
source `.po` does not have is an **error**; so is a `kn"..."` whose entry has no `msgid_plural`;
- a call of `trf` or `trn` with a key literal, and a template's `t('key', ...)`, gives as many values
after the key as the English's highest hole `{n}` (`trn`'s count is `{1}`), or it is a **warning**;
after the key as the English's highest hole `{n}` (`trn`'s count is `{1}`; trailing `""` literals
are padding and not counted), or it is a **warning**;
- **English left** - a template's words outside `translate="no"`, a text attribute's words (`title`,
`label`, `hint`, `text`, `caption`, and any other whose value is not a keyword), a quoted choice
inside a hole that reads as words, and a `@Text` row whose value is still English - is a

View file

@ -0,0 +1,9 @@
bump: patch
type: fix
**Text keys below a row, padding, and `tr`'s cast.** A `@Text Key` in a record nested in a registry
row (and in each item of a list of them) is filled with its derived key, `<registry>.<row>.<field>.<i>.<field>`,
as a top-level one is, and a `@Text []Key` a row leaves out takes `<...>.0`, `.1`, ... for as many as
the source `.po` has - so no `.lres` spells a key. `field: null` is no text. `trf` / `trn`'s trailing
`""` arguments are padding and not counted against the English's holes. And `string(x)` of a string or
a `Key` is no allocation to the escape analysis: it is `x` itself, so a `tr(key)` that returns it
passes `arena strict` (a template's lone hole still copies).

View file

@ -0,0 +1,4 @@
# i18n_nested.ludic's raids: no row spells a key - a nested record's @Text Key, a list of them and a
# @Text []Key are all derived; `null` says a row has no text there
camp { arrive: { steps: [ { need: 2 }, { need: 5 } ] } }
quiet { arrive: { steps: [ { need: 1 } ] }, said: null }

View file

@ -0,0 +1,35 @@
# i18n_nested.ludic — derived text keys below a row (LANGUAGE.md, "Text keys"): a nested record's
# @Text Key is <registry>.<row>.<field>.<field>, a list item's adds .<index>, and a @Text []Key a row
# leaves out takes <...>.0, .1, ... for as many as en.po has. `field: null` is no text. trf's trailing
# "" pad its unused values and are not counted against the English's holes.
#
# Running it prints: raids.camp.arrive.steps.0.text raids.camp.arrive.steps.1.text 2 raids.camp.said.1 0 raids.quiet.arrive.steps.0.text hud.day(3)
program I18nNested {
property Step {
@Text text: Key = null
need: int = 0
}
property Arrive { steps: []Step = null }
property Raid {
key: string = ""
arrive: Arrive = null
@Text said: []Key = null
}
registry Raids of Raid as RD from "data/raids.lres"
function tr(key: Key) -> string {
let s = string(key)
return s[1..len(s)]
}
function trf(key: Key, a: string, b: string, c: string) -> string { return `{tr(key)}({a}{b}{c})` }
function said_n(r: Raid) -> int {
if r.said == null { return 0 }
return len(r.said)
}
entry {
let camp = Raids[RD_CAMP]
let quiet = Raids[RD_QUIET]
var out = `{tr(camp.arrive.steps[0].text)} {tr(camp.arrive.steps[1].text)} {said_n(camp)} {tr(camp.said[1])}`
out = out + ` {said_n(quiet)} {tr(quiet.arrive.steps[0].text)} {trf(k"hud.day", "3", "", "")}`
print(out)
}
}

View file

@ -0,0 +1,28 @@
# The source language of the nested keys example.
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
#. A raid's first step, at the camp.
msgid "raids.camp.arrive.steps.0.text"
msgstr "Find the tracks"
#. A raid's second step, at the camp.
msgid "raids.camp.arrive.steps.1.text"
msgstr "Count {1} footprints"
#. The quiet raid's only step.
msgid "raids.quiet.arrive.steps.0.text"
msgstr "Wait"
#. What Ada says when the camp is raided, first.
msgid "raids.camp.said.0"
msgstr "They came in the night."
#. What Ada says when the camp is raided, second.
msgid "raids.camp.said.1"
msgstr "Check the box."
#. The day on the HUD. {1} = the day number
msgid "hud.day"
msgstr "Day {1}"

View file

@ -0,0 +1,2 @@
# the nested text keys example (i18n_nested.ludic): lang/en.po is the source every key is checked against
lang "lang" en

View file

@ -104,6 +104,8 @@ function es_local_ty(name: pointer) -> pointer {
while i >= 0 { if (g_es_ln[i] == name) { return g_es_lt[i] }; i -= 1 }
return null
}
# a type string() hands back as it is
function es_passes(t: pointer) -> bool { return t != null and ((t == "string") or i18n_is_key_ty(t)) }
# the static type of an expression, as far as the analysis can tell without emitting it
function es_type(e: Node) -> pointer {
if e == null { return null }
@ -317,6 +319,9 @@ function es_call(e: Node) -> int {
return g_es_heap
}
if (nm == "string") {
# string(s) of a string or a Key is s itself (emit_call.ludic) - no text is made, unless a
# template's lone hole copies it
if len(e.kids) == 1 and c.uns != TPL_COPY and es_passes(es_type(e.kids[0])) { return es_val(e.kids[0]) }
es_args_walk(e)
return es_site(e)
}

View file

@ -183,29 +183,72 @@ function i18n_has_text(f: Node) -> bool {
function i18n_is_key_ty(t: pointer) -> bool { return t != null and (t == "Key") and find_comp(t) == null }
# a compiled registry's row (reg_fill): a @Text Key field it gives no value takes its derived key
function i18n_fill_row(r: int, key: pointer, rec: Node, comp: Node, kline: int, kcol: int) -> void {
if comp == null { return }
i18n_fill_rec(rec, comp, `{i18n_prefix(r)}.{key}`, kline, kcol)
}
# a record at `base` (a row, or a record nested in one): a @Text Key it leaves out takes base.field,
# a @Text []Key it leaves out takes base.field.0, .1, ... as many as en.po has; given ones are walked
function i18n_fill_rec(rec: Node, comp: Node, base: pointer, kline: int, kcol: int) -> void {
if rec == null or comp == null { return }
var i = 0
while i < len(comp.kids) {
let f = comp.kids[i]
if f.kind == N_FIELD and i18n_has_text(f) and i18n_is_key_ty(f.ty) and not reg_rec_has(rec, f.s) {
let fi = node(E_FINIT)
fi.s = f.s
fi.file = rec.file
fi.line = kline
fi.col = kcol
let ks = node(E_STR)
ks.s = key_marked(KEY_LIT, `{i18n_prefix(r)}.{key}.{f.s}`)
ks.ival = KEY_LIT
ks.ty = "derived" # the checks know it was not written
ks.file = rec.file
ks.line = kline
ks.col = kcol
fi.a = ks
push(rec.kids, fi)
if f.kind == N_FIELD {
let path = `{base}.{f.s}`
let fi = i18n_field_init(rec, f.s)
if fi != null { i18n_fill_val(fi.a, f.ty, path, kline, kcol) }
else if i18n_has_text(f) and i18n_is_key_ty(f.ty) { push(rec.kids, i18n_finit(f.s, i18n_derived(path, rec.file, kline, kcol), rec.file, kline, kcol)) }
else if i18n_has_text(f) and i18n_is_keys_ty(f.ty) { i18n_fill_list(rec, f.s, path, kline, kcol) }
}
i += 1
}
}
function i18n_fill_val(e: Node, ty: pointer, path: pointer, kline: int, kcol: int) -> void {
if e == null { return }
if e.kind == E_NEW and e.a != null and e.a.kind == E_REC { i18n_fill_rec(e.a, find_comp(e.s), path, kline, kcol) }
else if e.kind == E_REC and ty != null { i18n_fill_rec(e, find_comp(ty), path, kline, kcol) }
else if e.kind == E_LIST {
var el: pointer = null
if ty != null and is_slice_ty(ty) { el = slice_elem(ty) }
var i = 0
while i < len(e.kids) {
i18n_fill_val(e.kids[i], el, `{path}.{itoa(i)}`, kline, kcol)
i += 1
}
}
}
# a []Key the row leaves out: en.po's path.0, path.1, ... while they last (none without an en.po)
function i18n_fill_list(rec: Node, name: pointer, path: pointer, kline: int, kcol: int) -> void {
if g_i18n_en == null { return }
let lst = node(E_LIST)
lst.file = rec.file
lst.line = kline
var i = 0
while po_find(g_i18n_en, `{path}.{itoa(i)}`) >= 0 {
push(lst.kids, i18n_derived(`{path}.{itoa(i)}`, rec.file, kline, kcol))
i += 1
}
if i > 0 { push(rec.kids, i18n_finit(name, lst, rec.file, kline, kcol)) }
}
function i18n_is_keys_ty(t: pointer) -> bool { return t != null and is_slice_ty(t) and i18n_is_key_ty(slice_elem(t)) }
function i18n_derived(key: pointer, file: pointer, line: int, col: int) -> Node {
let ks = node(E_STR)
ks.s = key_marked(KEY_LIT, key)
ks.ival = KEY_LIT
ks.ty = "derived" # the checks know it was not written
ks.file = file
ks.line = line
ks.col = col
return ks
}
function i18n_finit(name: pointer, v: Node, file: pointer, line: int, col: int) -> Node {
let fi = node(E_FINIT)
fi.s = name
fi.file = file
fi.line = line
fi.col = col
fi.a = v
return fi
}
# ---- the sites and the key checks ----------------------------------------------------------------
function i18n_site(key: pointer, kind: int, file: pointer, line: int, col: int) -> bool {
@ -339,8 +382,11 @@ function i18n_call(n: Node) -> void {
perr_at(e.file, e.line, i18n_col(e), `{nm}(kn"{key}", ...): a plural key needs its count - write trn(kn"{key}", n, ...)`)
}
if nm == "tr" or g_i18n_en == null { return }
i18n_holes(key, len(n.kids) - k - 1, nm, e.file, e.line, i18n_col(e))
var last = len(n.kids)
while last > k + 1 and i18n_is_pad(n.kids[last - 1]) { last -= 1 } # trailing "" fill trf / trn's unused slots
i18n_holes(key, last - k - 1, nm, e.file, e.line, i18n_col(e))
}
function i18n_is_pad(e: Node) -> bool { return e != null and e.kind == E_STR and not i18n_is_key(e) and (e.s == null or len(e.s) == 0) }
# a node's column in its file: a template literal's hole is lexed on its own, so its tokens' columns
# are the hole's - the file offset says where it really is
function i18n_col(n: Node) -> int {

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -202,6 +202,7 @@ entry {
if (target != null) { g_target_win = has_sub(target, "windows") }
else { g_target_win = host_is_windows() }
i18n_manifest(path) # phase 26: a `lang` line and its en.po, before the parse fills rows from it
let src = read_file(path)
if (src == null) { die("ludicc: cannot open input\n") }
@ -224,7 +225,6 @@ entry {
maybe_splice_runtime()
fence_manifest(path) # 25.1: a `fence` line in the program's package.ludic
i18n_manifest(path) # phase 26: a `lang` line, and its en.po (i18n.ludic)
ui_register_gotos() # `goto:` buttons -> generated UiClicked listeners
# a game gets a window by default; a plain program stays headless. An explicit

View file

@ -1352,6 +1352,7 @@ function cmd_dev_test() -> int {
# phase 26: text keys - k"..." / kn"..." of type Key, checked against package.ludic's lang en.po
feat_case("lang/i18n/i18n", "", "pause.resume yes no 13 12 hud.day(3) catch.count[2](trout) items.rope.name items.lamp.name steps.gather_wood.hint", "i18n.ludic (phase 26: k\"...\" and kn\"...\" of type Key, their marker byte, == on keys, a @Text Key field filled with its derived key, @TextKey's prefix)")
i18n_check_case()
feat_case("lang/i18n_nested/i18n_nested", "", "raids.camp.arrive.steps.0.text raids.camp.arrive.steps.1.text 2 raids.camp.said.1 0 raids.quiet.arrive.steps.0.text hud.day(3)", "i18n_nested.ludic (phase 26: derived keys below a row - a nested record's, a list item's, a @Text []Key filled from en.po; null is no text; trf's trailing \"\" not counted)")
schema_case("lang/i18n/i18n", "{\"key\": \"items.rope.name\", \"kind\": \"data\", \"kinds\": [\"data\"], \"sites\": [\"examples/lang/i18n/data/i18n_items.lres:2:1\"], \"in_source\": true, \"english\": \"Rope\"", "--emit-schema: lang.keys - a derived key, its kind, its site and en.po's English")
schema_case("lang/i18n/i18n", "\"unused\": [\"map.close\"]", "--emit-schema: lang.unused - en.po's keys the program uses nowhere")
schema_case("lang/i18n/i18n", "\"missing\": [\"hud.day\"], \"fuzzy\": [\"pause.close\"], \"extra\": [\"old.title\"]", "--emit-schema: lang.languages - another .po's missing, fuzzy and extra keys against en.po")