Files
aego/tools/i18n/scan.go
T
sanspie f22242e5aa
ci / release (push) Successful in 5m5s
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / checks (push) Successful in 4m41s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 7m14s
Stage 11g2–11g3: registry texts, CLI and the site in Russian (D348)
The remaining editor strings and undo step names; component, field, enum, setting, diagnostic and Script API texts under schema.*, enum.*, setting.*, diag.* and api.*; the launcher, the in-game Settings View with a Language setting, the CLI and aego schema show --lang. Every documentation page has a .ru.md translation; the site has a full Russian tree with hreflang, a language switch and ru/llms.txt; aego docs check --lang ru. New doc.tutorial and doc.recipe icons. Fix: an animator restored by a rewind prepares its controller before the replayed step.
2026-10-25 15:31:22 +03:00

369 lines
9.1 KiB
Go

package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
const (
pkgPath = "aego/engine/i18n"
commandPath = "aego/editor/core/command"
)
// Key is one translatable text found in the code.
type Key struct {
ID string
En string
One string
Plural bool
Pos string
}
var skipDirs = map[string]bool{".cache": true, ".git": true, "testdata": true, "node_modules": true, "internal": true, "site": true}
type file struct {
path string
dir string
ast *ast.File
}
// Scan collects every i18n.Tr, TrN, T and Note call and command.Desc/Info literal; a key or text that is not a literal is an error.
func Scan(root string) (map[string]*Key, []error) {
keys := map[string]*Key{}
var errs []error
fset := token.NewFileSet()
var files []file
consts := map[string]string{}
filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if p != root && (skipDirs[d.Name()] || hasMod(p)) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
return nil
}
f, err := parser.ParseFile(fset, p, nil, parser.SkipObjectResolution)
if err != nil {
errs = append(errs, err)
return nil
}
dir, _ := filepath.Rel(root, filepath.Dir(p))
dir = filepath.ToSlash(dir)
collectConsts(f, dir, consts)
files = append(files, file{path: p, dir: dir, ast: f})
return nil
})
add := func(k *Key) {
if !validKey(k.ID) {
errs = append(errs, fmt.Errorf("%s: key %q is not editor.*, launcher.*, settings.*, cli.*, schema.*, enum.* or i18n.<module>.*", k.Pos, k.ID))
return
}
if old, ok := keys[k.ID]; ok {
if old.En != k.En || old.One != k.One || old.Plural != k.Plural {
errs = append(errs, fmt.Errorf("%s: key %q has other English text than at %s: %q vs %q", k.Pos, k.ID, old.Pos, k.En, old.En))
}
return
}
keys[k.ID] = k
}
for _, fl := range files {
f := fl.ast
if cmd := importAs(f, commandPath); cmd != "" || fl.dir == "editor/core/command" {
commandKeys(f, fl.dir, cmd, consts, func(pos token.Pos, id, field, en string) {
at := fset.Position(pos)
rel, _ := filepath.Rel(root, at.Filename)
key := "editor.command." + id
if field == "Tip" {
key += ".tip"
}
add(&Key{ID: key, En: en, Pos: fmt.Sprintf("%s:%d", filepath.ToSlash(rel), at.Line)})
})
}
widgetTr(f, fl.dir, func(pos token.Pos, id, en string, ok bool) {
at := fset.Position(pos)
rel, _ := filepath.Rel(root, at.Filename)
where := fmt.Sprintf("%s:%d", filepath.ToSlash(rel), at.Line)
if !ok {
errs = append(errs, fmt.Errorf("%s: widgets.Tr needs a literal key and English text", where))
return
}
add(&Key{ID: id, En: en, Pos: where})
})
name := importAs(f, pkgPath)
if name == "" || fl.dir == "engine/i18n" {
continue
}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
if id, ok := sel.X.(*ast.Ident); !ok || id.Name != name {
return true
}
pos := fset.Position(call.Pos())
rel, _ := filepath.Rel(root, pos.Filename)
where := fmt.Sprintf("%s:%d", filepath.ToSlash(rel), pos.Line)
var k *Key
switch sel.Sel.Name {
case "Tr", "T", "Note":
if len(call.Args) < 2 {
return true
}
id, ok1 := lit(call.Args[0])
en, ok2 := lit(call.Args[1])
if !ok1 || !ok2 {
errs = append(errs, fmt.Errorf("%s: %s.%s needs a literal key and English text", where, name, sel.Sel.Name))
return true
}
k = &Key{ID: id, En: en, Pos: where}
case "TrN":
if len(call.Args) < 4 {
return true
}
id, ok1 := lit(call.Args[0])
one, ok2 := lit(call.Args[2])
other, ok3 := lit(call.Args[3])
if !ok1 || !ok2 || !ok3 {
errs = append(errs, fmt.Errorf("%s: %s.TrN needs a literal key and English forms", where, name))
return true
}
k = &Key{ID: id, En: other, One: one, Plural: true, Pos: where}
default:
return true
}
add(k)
return true
})
}
sort.Slice(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() })
return keys, errs
}
func hasMod(dir string) bool {
_, err := os.Stat(filepath.Join(dir, "go.mod"))
return err == nil
}
func importAs(f *ast.File, path string) string {
for _, im := range f.Imports {
if p, _ := strconv.Unquote(im.Path.Value); p == path {
if im.Name != nil {
return im.Name.Name
}
return path[strings.LastIndexByte(path, '/')+1:]
}
}
return ""
}
// collectConsts records the string constants of a file as dir.Name.
func collectConsts(f *ast.File, dir string, out map[string]string) {
for _, d := range f.Decls {
g, ok := d.(*ast.GenDecl)
if !ok || g.Tok != token.CONST {
continue
}
for _, sp := range g.Specs {
vs := sp.(*ast.ValueSpec)
for i, n := range vs.Names {
if i < len(vs.Values) {
if s, ok := lit(vs.Values[i]); ok {
out[dir+"."+n.Name] = s
}
}
}
}
}
}
// resolve is the value of a literal, a constant of the file's package or a qualified constant of another package.
func resolve(e ast.Expr, f *ast.File, dir string, consts map[string]string) (string, bool) {
if s, ok := lit(e); ok {
return s, true
}
switch v := e.(type) {
case *ast.Ident:
s, ok := consts[dir+"."+v.Name]
return s, ok
case *ast.SelectorExpr:
x, ok := v.X.(*ast.Ident)
if !ok {
return "", false
}
for _, im := range f.Imports {
p, _ := strconv.Unquote(im.Path.Value)
name := p[strings.LastIndexByte(p, '/')+1:]
if im.Name != nil {
name = im.Name.Name
}
if name == x.Name && strings.HasPrefix(p, "aego/") {
s, ok := consts[strings.TrimPrefix(p, "aego/")+"."+v.Sel.Name]
return s, ok
}
}
case *ast.BinaryExpr:
if v.Op == token.ADD {
a, ok1 := resolve(v.X, f, dir, consts)
b, ok2 := resolve(v.Y, f, dir, consts)
return a + b, ok1 && ok2
}
}
return "", false
}
// commandKeys reports the Name and Tip of every command.Desc and command.Info literal whose ID resolves.
func commandKeys(f *ast.File, dir, cmd string, consts map[string]string, emit func(pos token.Pos, id, field, en string)) {
isCmd := func(t ast.Expr) bool {
switch v := t.(type) {
case *ast.SelectorExpr:
x, ok := v.X.(*ast.Ident)
return ok && x.Name == cmd && (v.Sel.Name == "Desc" || v.Sel.Name == "Info")
case *ast.Ident:
return dir == "editor/core/command" && (v.Name == "Desc" || v.Name == "Info")
case *ast.StarExpr:
return false
}
return false
}
visit := func(cl *ast.CompositeLit) {
var id string
texts := map[string]*ast.BasicLit{}
for _, el := range cl.Elts {
kv, ok := el.(*ast.KeyValueExpr)
if !ok {
continue
}
k, _ := kv.Key.(*ast.Ident)
if k == nil {
continue
}
switch k.Name {
case "ID":
id, _ = resolve(kv.Value, f, dir, consts)
case "Name", "Tip":
if b, ok := kv.Value.(*ast.BasicLit); ok {
texts[k.Name] = b
}
}
}
if id == "" {
return
}
for field, b := range texts {
if s, ok := lit(b); ok && s != "" {
emit(b.Pos(), id, field, s)
}
}
}
ast.Inspect(f, func(n ast.Node) bool {
cl, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
if isCmd(cl.Type) {
visit(cl)
return true
}
var elt ast.Expr
switch t := cl.Type.(type) {
case *ast.ArrayType:
elt = t.Elt
case *ast.MapType:
elt = t.Value
}
if elt != nil && isCmd(elt) {
for _, e := range cl.Elts {
if kv, ok := e.(*ast.KeyValueExpr); ok {
e = kv.Value
}
if c, ok := e.(*ast.CompositeLit); ok && c.Type == nil {
visit(c)
}
}
}
return true
})
}
func lit(e ast.Expr) (string, bool) {
switch v := e.(type) {
case *ast.BasicLit:
if v.Kind != token.STRING {
return "", false
}
s, err := strconv.Unquote(v.Value)
return s, err == nil
case *ast.BinaryExpr:
if v.Op != token.ADD {
return "", false
}
a, ok1 := lit(v.X)
b, ok2 := lit(v.Y)
return a + b, ok1 && ok2
case *ast.ParenExpr:
return lit(v.X)
}
return "", false
}
var prefixes = []string{"editor.", "launcher.", "settings.", "cli.", "schema.", "enum.", "i18n."}
func validKey(k string) bool {
for _, p := range prefixes {
if strings.HasPrefix(k, p) && len(k) > len(p) && !strings.HasSuffix(k, ".") && !strings.Contains(k, " ") {
return true
}
}
return false
}
// widgetTr reports the widgets.Tr calls of a file, and the bare Tr calls inside ui/widgets.
func widgetTr(f *ast.File, dir string, emit func(pos token.Pos, id, en string, ok bool)) {
w := importAs(f, "aego/ui/widgets")
inside := dir == "ui/widgets"
if w == "" && !inside {
return
}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) != 2 {
return true
}
switch fn := call.Fun.(type) {
case *ast.Ident:
if !inside || fn.Name != "Tr" {
return true
}
case *ast.SelectorExpr:
x, ok := fn.X.(*ast.Ident)
if !ok || w == "" || x.Name != w || fn.Sel.Name != "Tr" {
return true
}
default:
return true
}
id, ok1 := lit(call.Args[0])
en, ok2 := lit(call.Args[1])
emit(call.Pos(), id, en, ok1 && ok2)
return true
})
}