mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-09-11 05:17:25 +00:00
Print out a list of all unused msgids Handle Go files that make calls to translation. Handle `models/unit/unit.go`, which stores msgids in `$Unit.NameKey` Handle .locale.Tr in templates Handle simple dynamically constructed `Tr("msgid-prefix." + SomeFunctionCall())`. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8736 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: Ellen Εμιλία Άννα Zscheile <fogti+devel@ytrizja.de> Co-committed-by: Ellen Εμιλία Άννα Zscheile <fogti+devel@ytrizja.de>
372 lines
9.2 KiB
Go
372 lines
9.2 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"go/token"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"forgejo.org/modules/container"
|
|
"forgejo.org/modules/translation/localeiter"
|
|
)
|
|
|
|
// this works by first gathering all valid source string IDs from `en-US` reference files
|
|
// and then checking if all used source strings are actually defined
|
|
|
|
type LocatedError struct {
|
|
Location string
|
|
Kind string
|
|
Err error
|
|
}
|
|
|
|
func (e LocatedError) Error() string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(e.Location)
|
|
sb.WriteString(":\t")
|
|
if e.Kind != "" {
|
|
sb.WriteString(e.Kind)
|
|
sb.WriteString(": ")
|
|
}
|
|
sb.WriteString("ERROR: ")
|
|
sb.WriteString(e.Err.Error())
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func InitLocaleTrFunctions() map[string][]uint {
|
|
ret := make(map[string][]uint)
|
|
|
|
f0 := []uint{0}
|
|
ret["Tr"] = f0
|
|
ret["TrString"] = f0
|
|
ret["TrHTML"] = f0
|
|
|
|
ret["TrPluralString"] = []uint{1}
|
|
ret["TrN"] = []uint{1, 2}
|
|
|
|
return ret
|
|
}
|
|
|
|
type Handler struct {
|
|
OnMsgid func(fset *token.FileSet, pos token.Pos, msgid string)
|
|
OnMsgidPrefix func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool)
|
|
OnUnexpectedInvoke func(fset *token.FileSet, pos token.Pos, funcname string, argc int)
|
|
OnWarning func(fset *token.FileSet, pos token.Pos, msg string)
|
|
LocaleTrFunctions map[string][]uint
|
|
}
|
|
|
|
type StringTrie interface {
|
|
Matches(key []string) bool
|
|
}
|
|
|
|
type StringTrieMap map[string]StringTrie
|
|
|
|
func (m StringTrieMap) Matches(key []string) bool {
|
|
if len(key) == 0 || m == nil {
|
|
return true
|
|
}
|
|
value, ok := m[key[0]]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if value == nil {
|
|
return true
|
|
}
|
|
return value.Matches(key[1:])
|
|
}
|
|
|
|
func (m StringTrieMap) Insert(key []string) {
|
|
if m == nil {
|
|
return
|
|
}
|
|
|
|
switch len(key) {
|
|
case 0:
|
|
return
|
|
|
|
case 1:
|
|
m[key[0]] = nil
|
|
|
|
default:
|
|
if value, ok := m[key[0]]; ok {
|
|
if value == nil {
|
|
return
|
|
}
|
|
} else {
|
|
m[key[0]] = make(StringTrieMap)
|
|
}
|
|
m[key[0]].(StringTrieMap).Insert(key[1:])
|
|
}
|
|
}
|
|
|
|
func ParseAllowedMaskedUsages(fname string, usedMsgids container.Set[string], allowedMaskedPrefixes StringTrieMap, chkMsgid func(msgid string) bool) error {
|
|
file, err := os.Open(fname)
|
|
if err != nil {
|
|
return LocatedError{
|
|
Location: fname,
|
|
Kind: "Open",
|
|
Err: err,
|
|
}
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
lno := 0
|
|
for scanner.Scan() {
|
|
lno++
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if linePrefix, found := strings.CutSuffix(line, "."); found {
|
|
allowedMaskedPrefixes.Insert(strings.Split(linePrefix, "."))
|
|
} else {
|
|
if !chkMsgid(line) {
|
|
return LocatedError{
|
|
Location: fmt.Sprintf("%s: line %d", fname, lno),
|
|
Kind: "undefined msgid",
|
|
Err: errors.New(line),
|
|
}
|
|
}
|
|
usedMsgids.Add(line)
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return LocatedError{
|
|
Location: fname,
|
|
Kind: "Scanner",
|
|
Err: err,
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Truncating a message id prefix to the last dot
|
|
func PrepareMsgidPrefix(s string) (string, bool) {
|
|
index := strings.LastIndexByte(s, 0x2e)
|
|
if index == -1 {
|
|
return "", true
|
|
}
|
|
return s[:index], index != len(s)-1
|
|
}
|
|
|
|
func Usage() {
|
|
outp := flag.CommandLine.Output()
|
|
fmt.Fprintf(outp, "Usage of %s:\n", os.Args[0])
|
|
flag.PrintDefaults()
|
|
|
|
fmt.Fprintf(outp, "\nThis command assumes that it gets started from the project root directory.\n")
|
|
|
|
fmt.Fprintf(outp, "\nExit codes:\n")
|
|
for _, i := range []string{
|
|
"0\tsuccess, no issues found",
|
|
"1\tunable to walk directory tree",
|
|
"2\tunable to parse locale ini/json files",
|
|
"3\tunable to parse go or text/template files",
|
|
"4\tfound missing message IDs",
|
|
"5\tfound unused message IDs",
|
|
} {
|
|
fmt.Fprintf(outp, "\t%s\n", i)
|
|
}
|
|
|
|
fmt.Fprintf(outp, "\nSpecial Go doc comments:\n")
|
|
for _, i := range []string{
|
|
"//llu:returnsTrKey",
|
|
"\tcan be used in front of functions to indicate",
|
|
"\tthat the function returns message IDs",
|
|
"\tWARNING: this currently doesn't support nested functions properly",
|
|
"",
|
|
"//llu:returnsTrKeySuffix prefix.",
|
|
"\tsimilar to llu:returnsTrKey, but the given prefix is prepended",
|
|
"\tto the found strings before interpreting them as msgids",
|
|
"",
|
|
"// llu:TrKeys",
|
|
"\tcan be used in front of 'const' and 'var' blocks",
|
|
"\tin order to mark all contained strings as message IDs",
|
|
"",
|
|
"// llu:TrKeysSuffix prefix.",
|
|
"\tlike llu:returnsTrKeySuffix, but for 'const' and 'var' blocks",
|
|
} {
|
|
if i == "" {
|
|
fmt.Fprintf(outp, "\n")
|
|
} else {
|
|
fmt.Fprintf(outp, "\t%s\n", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
//nolint:forbidigo
|
|
func main() {
|
|
allowMissingMsgids := false
|
|
allowUnusedMsgids := false
|
|
usedMsgids := make(container.Set[string])
|
|
allowedMaskedPrefixes := make(StringTrieMap)
|
|
|
|
// It's possible for execl to hand us an empty os.Args.
|
|
if len(os.Args) == 0 {
|
|
flag.CommandLine = flag.NewFlagSet("lint-locale-usage", flag.ExitOnError)
|
|
} else {
|
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
|
}
|
|
flag.CommandLine.Usage = Usage
|
|
flag.Usage = Usage
|
|
|
|
flag.BoolVar(
|
|
&allowMissingMsgids,
|
|
"allow-missing-msgids",
|
|
false,
|
|
"don't return an error code if missing message IDs are found",
|
|
)
|
|
flag.BoolVar(
|
|
&allowUnusedMsgids,
|
|
"allow-unused-msgids",
|
|
false,
|
|
"don't return an error code if unused message IDs are found",
|
|
)
|
|
|
|
msgids := make(container.Set[string])
|
|
|
|
localeFile := filepath.Join(filepath.Join("options", "locale"), "locale_en-US.ini")
|
|
localeContent, err := os.ReadFile(localeFile)
|
|
if err != nil {
|
|
fmt.Printf("%s:\tERROR: %s\n", localeFile, err.Error())
|
|
os.Exit(2)
|
|
}
|
|
|
|
if err = localeiter.IterateMessagesContent(localeContent, func(trKey, trValue string) error {
|
|
msgids[trKey] = struct{}{}
|
|
return nil
|
|
}); err != nil {
|
|
fmt.Printf("%s:\tERROR: %s\n", localeFile, err.Error())
|
|
os.Exit(2)
|
|
}
|
|
|
|
localeFile = filepath.Join(filepath.Join("options", "locale_next"), "locale_en-US.json")
|
|
localeContent, err = os.ReadFile(localeFile)
|
|
if err != nil {
|
|
fmt.Printf("%s:\tERROR: %s\n", localeFile, err.Error())
|
|
os.Exit(2)
|
|
}
|
|
|
|
if err := localeiter.IterateMessagesNextContent(localeContent, func(trKey, pluralForm, trValue string) error {
|
|
// ignore plural form
|
|
msgids[trKey] = struct{}{}
|
|
return nil
|
|
}); err != nil {
|
|
fmt.Printf("%s:\tERROR: %s\n", localeFile, err.Error())
|
|
os.Exit(2)
|
|
}
|
|
|
|
gotAnyMsgidError := false
|
|
|
|
flag.Func(
|
|
"allow-masked-usages-from",
|
|
"supply a file containing a newline-separated list of allowed masked usages",
|
|
func(argval string) error {
|
|
return ParseAllowedMaskedUsages(argval, usedMsgids, allowedMaskedPrefixes, func(msgid string) bool {
|
|
return msgids.Contains(msgid)
|
|
})
|
|
},
|
|
)
|
|
flag.Parse()
|
|
|
|
onError := func(err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
fmt.Println(err.Error())
|
|
os.Exit(3)
|
|
}
|
|
|
|
handler := Handler{
|
|
OnMsgidPrefix: func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool) {
|
|
msgidPrefixSplit := strings.Split(msgidPrefix, ".")
|
|
if !truncated {
|
|
allowedMaskedPrefixes.Insert(msgidPrefixSplit)
|
|
} else if !allowedMaskedPrefixes.Matches(msgidPrefixSplit) {
|
|
gotAnyMsgidError = true
|
|
fmt.Printf("%s:\tmissing msgid prefix: %s\n", fset.Position(pos).String(), msgidPrefix)
|
|
}
|
|
},
|
|
OnMsgid: func(fset *token.FileSet, pos token.Pos, msgid string) {
|
|
if !msgids.Contains(msgid) {
|
|
gotAnyMsgidError = true
|
|
fmt.Printf("%s:\tmissing msgid: %s\n", fset.Position(pos).String(), msgid)
|
|
} else {
|
|
usedMsgids.Add(msgid)
|
|
}
|
|
},
|
|
OnUnexpectedInvoke: func(fset *token.FileSet, pos token.Pos, funcname string, argc int) {
|
|
gotAnyMsgidError = true
|
|
fmt.Printf("%s:\tunexpected invocation of %s with %d arguments\n", fset.Position(pos).String(), funcname, argc)
|
|
},
|
|
OnWarning: func(fset *token.FileSet, pos token.Pos, msg string) {
|
|
fmt.Printf("%s:\tWARNING: %s\n", fset.Position(pos).String(), msg)
|
|
},
|
|
LocaleTrFunctions: InitLocaleTrFunctions(),
|
|
}
|
|
|
|
if err := filepath.WalkDir(".", func(fpath string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
name := d.Name()
|
|
if d.IsDir() {
|
|
if name == "docker" || name == ".git" || name == "node_modules" {
|
|
return fs.SkipDir
|
|
}
|
|
} else if name == "bindata.go" || fpath == "modules/translation/i18n/i18n_test.go" {
|
|
// skip false positives
|
|
} else if strings.HasSuffix(name, ".go") {
|
|
onError(handler.HandleGoFile(fpath, nil))
|
|
} else if strings.HasSuffix(name, ".tmpl") {
|
|
if strings.HasPrefix(fpath, "tests") && strings.HasSuffix(name, ".ini.tmpl") {
|
|
// skip false positives
|
|
} else {
|
|
onError(handler.HandleTemplateFile(fpath, nil))
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
fmt.Printf("walkdir ERROR: %s\n", err.Error())
|
|
os.Exit(1)
|
|
}
|
|
|
|
unusedMsgids := []string{}
|
|
|
|
for msgid := range msgids {
|
|
if !usedMsgids.Contains(msgid) && !allowedMaskedPrefixes.Matches(strings.Split(msgid, ".")) {
|
|
unusedMsgids = append(unusedMsgids, msgid)
|
|
}
|
|
}
|
|
|
|
sort.Strings(unusedMsgids)
|
|
|
|
if len(unusedMsgids) != 0 {
|
|
fmt.Printf("=== unused msgids (%d): ===\n", len(unusedMsgids))
|
|
for _, msgid := range unusedMsgids {
|
|
fmt.Printf("- %s\n", msgid)
|
|
}
|
|
}
|
|
|
|
if !allowMissingMsgids && gotAnyMsgidError {
|
|
os.Exit(4)
|
|
}
|
|
if !allowUnusedMsgids && len(unusedMsgids) != 0 {
|
|
os.Exit(5)
|
|
}
|
|
}
|