Skip to content

Commit bda2a77

Browse files
donislawdevclaude
andcommitted
gui/text, guard: sixteen sentences reach a translator, and a guard asks whether they do
gen-locale.py writes the catalogue from the calls it can see, so a sentence that stated its own words was absent from en.json - and somebody copying that file would translate every line in it and ship a window still speaking English in sixteen places. The existing guard asks whether a literal was born outside this package, which is a different question and was answering correctly all along. Two decisions about carrying a value, because until now no catalogue entry did. Named fields rather than printf verbs: a catalogue is data, nothing compiles it, and a translator who writes %s where the code hands a number puts %!d(string=...) on a user's screen with nothing to catch it. And real plural forms for the message that counts files, so a translation may carry as many forms as its language needs - which the comment above that function has been promising since 2026-08-12. The boundary is drawn by the program rather than by a list: a string with no letter in it is punctuation. The seven that carry letters are named with their reasons, among them the sentence saying the catalogue could not be read, which cannot come from the catalogue by definition. All 25 stored screens render bit for bit the same, with no reference regenerated. O130. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6dd965f commit bda2a77

6 files changed

Lines changed: 411 additions & 37 deletions

File tree

‎internal/guard/catalogue_test.go‎

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,12 @@ type entryInCatalogue struct {
3434
// stale. This is the same choice two of the boundary guards made on 2026-08-20
3535
// and for the same reason: where there is nothing to assert against, reading
3636
// what was written is better than asserting nothing.
37-
func englishInTheCode(t *testing.T) map[string]string {
37+
func englishInTheCode(t *testing.T) (map[string]string, map[string]string) {
3838
t.Helper()
3939
out := map[string]string{}
40+
// The singular of every plural message, kept apart because only some
41+
// messages have one.
42+
singular := map[string]string{}
4043
for _, name := range []string{"screens.go", "text.go"} {
4144
path := filepath.Join("../gui/text", name)
4245
file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
@@ -49,11 +52,36 @@ func englishInTheCode(t *testing.T) map[string]string {
4952
return true
5053
}
5154
fn, ok := call.Fun.(*ast.Ident)
52-
if !ok || fn.Name != "say" || len(call.Args) != 2 {
55+
if !ok {
56+
return true
57+
}
58+
// Three ways of stating a message, and all three have to be read
59+
// here. Reading only say(, which is what this did until 2026-08-26,
60+
// makes every sentence carrying a value invisible to the guard -
61+
// and the guard would then report the catalogue's own entries as
62+
// orphans, which is how this was noticed.
63+
var id, english string
64+
var okID, okText bool
65+
switch {
66+
case fn.Name == "say" && len(call.Args) == 2:
67+
id, okID = literal(call.Args[0])
68+
english, okText = literal(call.Args[1])
69+
case fn.Name == "sayf" && len(call.Args) == 3:
70+
id, okID = literal(call.Args[0])
71+
english, okText = literal(call.Args[1])
72+
case fn.Name == "sayN" && len(call.Args) == 5:
73+
// The plural form is what the catalogue calls "other", and it
74+
// is the one this compares. The singular is checked beside it
75+
// below, because a catalogue holding one of the two is worse
76+
// than one holding neither: it looks complete.
77+
id, okID = literal(call.Args[0])
78+
english, okText = literal(call.Args[2])
79+
if one, ok := literal(call.Args[1]); ok && okID {
80+
singular[id] = one
81+
}
82+
default:
5383
return true
5484
}
55-
id, okID := literal(call.Args[0])
56-
english, okText := literal(call.Args[1])
5785
if !okID || !okText {
5886
t.Errorf("%s: a message is stated with something other than plain text", path)
5987
return true
@@ -68,7 +96,10 @@ func englishInTheCode(t *testing.T) map[string]string {
6896
if len(out) == 0 {
6997
t.Fatal("no message was found in the text package, so this guard is asserting about nothing")
7098
}
71-
return out
99+
if len(singular) == 0 {
100+
t.Fatal("no message with a plural was found, so the half of this guard that reads them proves nothing")
101+
}
102+
return out, singular
72103
}
73104

74105
func literal(e ast.Expr) (string, bool) {
@@ -94,7 +125,7 @@ func literal(e ast.Expr) (string, bool) {
94125
// would then be handed a file missing that sentence, translate everything in
95126
// it, and ship a window with one English line nobody can find the source of.
96127
func TestTheEnglishCatalogueSaysWhatTheCodeSays(t *testing.T) {
97-
code := englishInTheCode(t)
128+
code, singular := englishInTheCode(t)
98129

99130
raw, err := os.ReadFile(filepath.Join(localeDir, "en.json"))
100131
if err != nil {
@@ -121,6 +152,12 @@ func TestTheEnglishCatalogueSaysWhatTheCodeSays(t *testing.T) {
121152
if strings.TrimSpace(entry.Description) == "" {
122153
t.Errorf("%s has no sentence saying where it appears", id)
123154
}
155+
// A plural message carries two English forms and the catalogue has to
156+
// hold both. One of the two looks complete and is not.
157+
if one, plural := singular[id]; plural && entry.One != one {
158+
t.Errorf("%s says %q in the singular in the code and %q in the English catalogue.\n"+
159+
"Run: python tools/gen-locale.py", id, one, entry.One)
160+
}
124161
}
125162
for id := range have {
126163
if _, still := code[id]; !still {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package guard
2+
3+
import (
4+
"go/ast"
5+
"go/parser"
6+
"go/token"
7+
"path/filepath"
8+
"sort"
9+
"strconv"
10+
"strings"
11+
"testing"
12+
"unicode"
13+
)
14+
15+
// Every sentence the text package holds goes through the catalogue.
16+
//
17+
// There were two questions to ask about the window's words and only one was
18+
// being asked. TestNoWordAPersonReadsIsBornOutsideTheTextPackage asks whether a
19+
// literal was written OUTSIDE this package, which is about where a sentence
20+
// lives. This asks whether a sentence inside it ever reaches a translator, and
21+
// measured on 2026-08-25 the answer was no for eighteen of them - the section
22+
// heading over a format's settings, the line saying where the files will go, the
23+
// progress line, the outcome of a run. O130.
24+
//
25+
// The failure is quiet in the way this project keeps finding. gen-locale.py
26+
// writes the catalogue from the calls it can see, so a sentence that never calls
27+
// one is simply absent from en.json - and a translator copying that file gets an
28+
// incomplete one with nothing to say so. They would translate every line in it
29+
// and ship a window still speaking English in eighteen places.
30+
//
31+
// The rule, decided with the owner on 2026-08-26: anything that reads as
32+
// language goes through the catalogue. A separator does not, and nothing that
33+
// carries no letter at all is language - which is a line a program can draw, so
34+
// the punctuation looks after itself and the list below only holds the awkward
35+
// cases.
36+
var notThroughTheCatalogue = map[string]string{
37+
"WindowTitle": "the name of the program, which is not translated. It is the same three words in every language, and a catalogue entry for it would be an invitation to change them.",
38+
"HeadingAbout": "the same name again, at the top of the About screen. Same reason.",
39+
"CatalogueNotLoaded": "the sentence saying the catalogue could not be read. It cannot come from the catalogue - that is what it is about - so this one is English wherever it appears, and it is written to a terminal rather than to the window.",
40+
"NoWindowInThisBuild": "written to standard error by a window binary with no window in it, so it is terminal text and D9 keeps the terminal English forever.",
41+
"ExactBytes": "the byte symbol, which follows what the command line prints rather than the language of the window - the comment above it says so. Translating one and not the other would make two numbers on one screen disagree about their unit.",
42+
"PlaceholderNameTemplate": "an example of a file name template, so it is a value somebody could type rather than a sentence. Translating it would produce an example that does not work.",
43+
"SupportURL": "an address.",
44+
}
45+
46+
// wordsIn is every string literal a declaration states for itself, ignoring the
47+
// ones handed to the catalogue.
48+
//
49+
// Only literals carrying a letter. A separator, a mark and a piece of spacing
50+
// are punctuation in every language, and a rule that demanded catalogue entries
51+
// for them would fill a translator's file with lines to copy unchanged - which
52+
// is the opposite of what an inventory is for.
53+
func wordsIn(node ast.Node) []string {
54+
var found []string
55+
ast.Inspect(node, func(n ast.Node) bool {
56+
if call, ok := n.(*ast.CallExpr); ok {
57+
if fn, ok := call.Fun.(*ast.Ident); ok {
58+
switch fn.Name {
59+
case "say", "sayf", "sayN":
60+
// The English inside a catalogue call is the entry itself.
61+
// Its other arguments are still walked, because a sentence
62+
// built out of a second literal on the way in would be a
63+
// sentence the catalogue never sees.
64+
for i, arg := range call.Args {
65+
if i == 0 {
66+
continue
67+
}
68+
if _, isLit := arg.(*ast.BasicLit); isLit {
69+
continue
70+
}
71+
found = append(found, wordsIn(arg)...)
72+
}
73+
return false
74+
}
75+
}
76+
}
77+
// The key of a map entry is a placeholder name, not language. It has to
78+
// match the spelling inside the sentence exactly, so translating it
79+
// would break the sentence rather than translate it - the values beside
80+
// it are still walked.
81+
if kv, ok := n.(*ast.KeyValueExpr); ok {
82+
found = append(found, wordsIn(kv.Value)...)
83+
return false
84+
}
85+
lit, ok := n.(*ast.BasicLit)
86+
if !ok || lit.Kind != token.STRING {
87+
return true
88+
}
89+
value, err := strconv.Unquote(lit.Value)
90+
if err != nil || !strings.ContainsFunc(value, unicode.IsLetter) {
91+
return true
92+
}
93+
found = append(found, value)
94+
return true
95+
})
96+
return found
97+
}
98+
99+
func TestEverySentenceInTheTextPackageGoesThroughTheCatalogue(t *testing.T) {
100+
checked, offenders := 0, map[string][]string{}
101+
for _, name := range []string{"screens.go", "text.go"} {
102+
path := filepath.Join("../gui/text", name)
103+
file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
104+
if err != nil {
105+
t.Fatalf("%s: %v", path, err)
106+
}
107+
for _, decl := range file.Decls {
108+
switch d := decl.(type) {
109+
case *ast.FuncDecl:
110+
checked++
111+
if words := wordsIn(d.Body); len(words) > 0 {
112+
offenders[d.Name.Name] = words
113+
}
114+
case *ast.GenDecl:
115+
// Constants and variables as well as functions. A sentence held
116+
// as a constant is read by exactly the same person.
117+
for _, spec := range d.Specs {
118+
value, ok := spec.(*ast.ValueSpec)
119+
if !ok {
120+
continue
121+
}
122+
for i, ident := range value.Names {
123+
checked++
124+
if i >= len(value.Values) {
125+
continue
126+
}
127+
if words := wordsIn(value.Values[i]); len(words) > 0 {
128+
offenders[ident.Name] = words
129+
}
130+
}
131+
}
132+
}
133+
}
134+
}
135+
if checked == 0 {
136+
t.Fatal("no declaration was read in the text package, so this guard checked nothing")
137+
}
138+
139+
var unexplained, stale []string
140+
for name := range offenders {
141+
if _, allowed := notThroughTheCatalogue[name]; !allowed {
142+
unexplained = append(unexplained, name)
143+
}
144+
}
145+
// An allowance for something that now goes through the catalogue is an
146+
// allowance nobody needs, and a list nobody prunes is where this drift
147+
// would hide next - the same rule the regression table guard keeps.
148+
for name := range notThroughTheCatalogue {
149+
if _, still := offenders[name]; !still {
150+
stale = append(stale, name)
151+
}
152+
}
153+
sort.Strings(unexplained)
154+
sort.Strings(stale)
155+
156+
for _, name := range unexplained {
157+
t.Errorf("%s states %q itself, so it never reaches a translator.\n"+
158+
"Reason: gen-locale.py writes the catalogue from the calls it can see, so this sentence\n"+
159+
"is absent from en.json and a translator copying that file is handed an incomplete one\n"+
160+
"with nothing to say so.\n"+
161+
"What to do: state it with say, sayf or sayN - or name it in notThroughTheCatalogue and\n"+
162+
"say why it is not language.", name, offenders[name])
163+
}
164+
for _, name := range stale {
165+
t.Errorf("%s is excused from the catalogue and no longer needs to be.\n"+
166+
"What to do: take it off notThroughTheCatalogue.", name)
167+
}
168+
t.Logf("%d declaration(s) read, %d hold words of their own, all of them named", checked, len(offenders))
169+
}

‎internal/gui/text/catalogue.go‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"encoding/json"
66
"io/fs"
77
"path"
8+
"strings"
9+
"text/template"
810

911
"github.com/nicksnyder/go-i18n/v2/i18n"
1012
"golang.org/x/text/language"
@@ -48,6 +50,92 @@ var builtIn embed.FS
4850
// translations. See docs/STACK.md.
4951
var localiser *i18n.Localizer
5052

53+
// sayf is say for a sentence that has values in it.
54+
//
55+
// Named fields rather than the printf verbs the rest of this tool writes, and
56+
// the reason is rule 6 rather than taste. A catalogue is DATA: it arrives as a
57+
// file, nothing compiles it, and no test of ours stands between it and the
58+
// window. A translator who writes %s where the code hands a number puts
59+
// "%!d(string=...)" on somebody's screen at run time and nothing says a word. A
60+
// named field cannot be the wrong type, and one that is missing renders as
61+
// "<no value>" - wrong, but visible.
62+
//
63+
// Decided with the owner on 2026-08-26. The printf spelling was the cheaper of
64+
// the two and was turned down: the generator already reads it, the call sites
65+
// stay one line, and the failure it allows is silent and lands on a user.
66+
//
67+
// The fallback renders the English with the same template engine rather than
68+
// returning it raw, because a sentence with {{.Directory}} still in it is not a
69+
// fallback, it is a different defect.
70+
func sayf(id, english string, data map[string]any) string {
71+
if localiser == nil {
72+
return fill(english, data)
73+
}
74+
out, err := localiser.Localize(&i18n.LocalizeConfig{
75+
MessageID: id,
76+
DefaultMessage: &i18n.Message{ID: id, Other: english},
77+
TemplateData: data,
78+
})
79+
if err != nil {
80+
return fill(english, data)
81+
}
82+
return out
83+
}
84+
85+
// sayN is sayf where a count chooses the form of the sentence.
86+
//
87+
// English has two forms and Polish has three, and the library knows which rule
88+
// belongs to which language - so this hands over both English forms and the
89+
// number, and a translator's file may carry as many forms as their language
90+
// needs. Writing "1 file" and "%d files" as two flat entries would have put a
91+
// thing into the catalogue that is broken for Polish before anybody starts,
92+
// with no way to fix it from a translation file.
93+
//
94+
// The count is always available to the sentence as Count, because a plural form
95+
// that cannot say the number is not much use.
96+
func sayN(id, one, other string, count int, data map[string]any) string {
97+
values := map[string]any{"Count": count}
98+
for k, v := range data {
99+
values[k] = v
100+
}
101+
if localiser == nil {
102+
if count == 1 {
103+
return fill(one, values)
104+
}
105+
return fill(other, values)
106+
}
107+
out, err := localiser.Localize(&i18n.LocalizeConfig{
108+
MessageID: id,
109+
DefaultMessage: &i18n.Message{ID: id, One: one, Other: other},
110+
PluralCount: count,
111+
TemplateData: values,
112+
})
113+
if err != nil {
114+
if count == 1 {
115+
return fill(one, values)
116+
}
117+
return fill(other, values)
118+
}
119+
return out
120+
}
121+
122+
// fill puts the values into a sentence without the catalogue.
123+
//
124+
// Both failures return the layout untouched rather than an empty string: a
125+
// sentence with its placeholders showing is readable and says something is
126+
// wrong, and nothing at all says the window is broken.
127+
func fill(layout string, data map[string]any) string {
128+
t, err := template.New("").Parse(layout)
129+
if err != nil {
130+
return layout
131+
}
132+
var out strings.Builder
133+
if err := t.Execute(&out, data); err != nil {
134+
return layout
135+
}
136+
return out.String()
137+
}
138+
51139
// say is what every entry in this package asks, and what makes the English
52140
// beside it a default rather than the answer.
53141
//

0 commit comments

Comments
 (0)