feat(build): add support for the base.Messenger, $.locale.Tr, Form structs to lint-locale-usage (#9095)

- Move a file around to avoid a circular dependency.
- Make lint-locale-usage aware of `base.Messenger`, form struct tags and `$.locale.Tr`.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9095
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>
This commit is contained in:
Ellen Εμιλία Άννα Zscheile 2025-09-30 03:25:45 +02:00 committed by Gusted
parent 710600f459
commit 5687a8ef65
11 changed files with 375 additions and 202 deletions

View file

@ -38,6 +38,10 @@ routers/.* @gusted
options/locale/.* @0ko
options/locale_next/.* @0ko
# Personal interest
# lint-locale-usage
build/lint-locale-usage/.* @fogti
models/unit/.* @fogti
services/migrations/lint-locale-usage/.* @fogti
# Personal interest
.*/webhook.* @oliverpool

View file

@ -475,7 +475,7 @@ lint-locale:
.PHONY: lint-locale-usage
lint-locale-usage:
$(GO) run ./build/lint-locale-usage --allow-masked-usages-from=build/lint-locale-usage/allowed-masked-usage.txt
$(GO) run ./build/lint-locale-usage/bin --allow-masked-usages-from=build/lint-locale-usage/allowed-masked-usage.txt
.PHONY: lint-md
lint-md: node_modules

View file

@ -23,12 +23,6 @@ themes.names.
# services/context/context.go
relativetime.
# templates/mail/issue/default.tmpl: $.locale.Tr
mail.issue.in_tree_path
# templates/package/metadata/arch.tmpl: $.locale.Tr
packages.details.license
# templates/repo/issue/view_content.tmpl: indirection via $closeTranslationKey
repo.issues.close
repo.pulls.close
@ -48,7 +42,3 @@ projects.type-3.display_name
# templates/repo/settings/webhook/link_menu.tmpl, templates/webhook/new.tmpl: repo.settings.web_hook_name_
# tests/integration/repo_archive_text_test.go
repo.settings.
# services/migrations/migrate.go: messenger calls
# ToDo: give them a unique prefix
repo.migrate.

View file

@ -0,0 +1,177 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package main
import (
"fmt"
"go/ast"
goParser "go/parser"
"go/token"
"reflect"
"slices"
"strconv"
"strings"
llu "forgejo.org/build/lint-locale-usage"
lluUnit "forgejo.org/models/unit/lint-locale-usage"
lluMigrate "forgejo.org/services/migrations/lint-locale-usage"
)
// the `Handle*File` functions follow the following calling convention:
// * `fname` is the name of the input file
// * `src` is either `nil` (then the function invokes `ReadFile` to read the file)
// or the contents of the file as {`[]byte`, or a `string`}
func HandleGoFile(handler llu.Handler, fname string, src any) error {
fset := token.NewFileSet()
node, err := goParser.ParseFile(fset, fname, src, goParser.SkipObjectResolution|goParser.ParseComments)
if err != nil {
return llu.LocatedError{
Location: fname,
Kind: "Go parser",
Err: err,
}
}
ast.Inspect(node, func(n ast.Node) bool {
// search for function calls of the form `anything.Tr(any-string-lit, ...)`
switch n2 := n.(type) {
case *ast.CallExpr:
if len(n2.Args) == 0 {
return true
}
funSel, ok := n2.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ltf, ok := handler.LocaleTrFunctions[funSel.Sel.Name]
if !ok {
return true
}
var gotUnexpectedInvoke *int
for _, argNum := range ltf {
if len(n2.Args) <= int(argNum) {
argc := len(n2.Args)
gotUnexpectedInvoke = &argc
} else {
handler.HandleGoTrArgument(fset, n2.Args[int(argNum)], "")
}
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, funSel.Sel.NamePos, funSel.Sel.Name, *gotUnexpectedInvoke)
}
case *ast.CompositeLit:
if strings.HasSuffix(fname, "models/unit/unit.go") {
lluUnit.HandleCompositeUnit(handler, fset, n2)
}
case *ast.FuncDecl:
matchInsPrefix := handler.HandleGoCommentGroup(fset, n2.Doc, "llu:returnsTrKey")
if matchInsPrefix != nil {
results := n2.Type.Results.List
if len(results) != 1 {
handler.OnWarning(fset, n2.Type.Func, fmt.Sprintf("function %s has unexpected return type; expected single return value", n2.Name.Name))
return true
}
ast.Inspect(n2.Body, func(n ast.Node) bool {
// search for return stmts
// TODO: what about nested functions?
if ret, ok := n.(*ast.ReturnStmt); ok {
for _, res := range ret.Results {
ast.Inspect(res, func(n ast.Node) bool {
if expr, ok := n.(ast.Expr); ok {
handler.HandleGoTrArgument(fset, expr, *matchInsPrefix)
}
return true
})
}
return false
}
return true
})
}
if strings.HasSuffix(fname, "services/migrations/migrate.go") {
lluMigrate.HandleMessengerInFunc(handler, fset, n2)
}
return true
case *ast.GenDecl:
switch n2.Tok {
case token.CONST, token.VAR:
matchInsPrefix := handler.HandleGoCommentGroup(fset, n2.Doc, " llu:TrKeys")
if matchInsPrefix == nil {
return true
}
for _, spec := range n2.Specs {
// interpret all contained strings as message IDs
ast.Inspect(spec, func(n ast.Node) bool {
if argLit, ok := n.(*ast.BasicLit); ok {
handler.HandleGoTrBasicLit(fset, argLit, *matchInsPrefix)
return false
}
return true
})
}
case token.TYPE:
// modules/web/middleware/binding.go:Validate uses the convention that structs
// entries can have tags.
// In particular, `locale:$msgid` should be handled; any fields with `form:-` shouldn't.
// Problem: we don't know which structs are forms, actually.
for _, spec := range n2.Specs {
tspec := spec.(*ast.TypeSpec)
structNode, ok := tspec.Type.(*ast.StructType)
if !ok || !(strings.HasSuffix(tspec.Name.Name, "Form") ||
(tspec.Doc != nil &&
slices.ContainsFunc(tspec.Doc.List, func(c *ast.Comment) bool {
return c.Text == "// swagger:model"
}))) {
continue
}
for _, field := range structNode.Fields.List {
if field.Names == nil {
continue
}
if len(field.Names) != 1 {
handler.OnWarning(fset, field.Type.Pos(), "unsupported multiple field names")
continue
}
msgidPos := field.Names[0].NamePos
msgid := "form." + field.Names[0].Name
if field.Tag != nil && field.Tag.Kind == token.STRING {
rawTag, err := strconv.Unquote(field.Tag.Value)
if err != nil {
handler.OnWarning(fset, field.Tag.ValuePos, "invalid tag value encountered")
continue
}
tag := reflect.StructTag(rawTag)
if tag.Get("form") == "-" {
continue
}
tmp := tag.Get("locale")
if len(tmp) != 0 {
msgidPos = field.Tag.ValuePos
msgid = tmp
}
}
handler.OnMsgid(fset, msgidPos, msgid, true)
}
}
}
}
return true
})
return nil
}

View file

@ -16,6 +16,7 @@ import (
"sort"
"strings"
llu "forgejo.org/build/lint-locale-usage"
"forgejo.org/modules/container"
"forgejo.org/modules/translation/localeiter"
)
@ -23,27 +24,6 @@ import (
// 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)
@ -58,14 +38,6 @@ func InitLocaleTrFunctions() map[string][]uint {
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
}
@ -113,7 +85,7 @@ func (m StringTrieMap) Insert(key []string) {
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{
return llu.LocatedError{
Location: fname,
Kind: "Open",
Err: err,
@ -133,7 +105,7 @@ func ParseAllowedMaskedUsages(fname string, usedMsgids container.Set[string], al
allowedMaskedPrefixes.Insert(strings.Split(linePrefix, "."))
} else {
if !chkMsgid(line) {
return LocatedError{
return llu.LocatedError{
Location: fmt.Sprintf("%s: line %d", fname, lno),
Kind: "undefined msgid",
Err: errors.New(line),
@ -143,7 +115,7 @@ func ParseAllowedMaskedUsages(fname string, usedMsgids container.Set[string], al
}
}
if err := scanner.Err(); err != nil {
return LocatedError{
return llu.LocatedError{
Location: fname,
Kind: "Scanner",
Err: err,
@ -152,15 +124,6 @@ func ParseAllowedMaskedUsages(fname string, usedMsgids container.Set[string], al
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])
@ -210,6 +173,7 @@ func Usage() {
func main() {
allowMissingMsgids := false
allowUnusedMsgids := false
allowWeakMissingMsgids := true
usedMsgids := make(container.Set[string])
allowedMaskedPrefixes := make(StringTrieMap)
@ -228,6 +192,12 @@ func main() {
false,
"don't return an error code if missing message IDs are found",
)
flag.BoolVar(
&allowWeakMissingMsgids,
"allow-weak-missing-msgids",
true,
"Don't return an error code if missing 'weak' (e.g. \"form.$msgid\") message IDs are found",
)
flag.BoolVar(
&allowUnusedMsgids,
"allow-unused-msgids",
@ -289,7 +259,7 @@ func main() {
os.Exit(3)
}
handler := Handler{
handler := llu.Handler{
OnMsgidPrefix: func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool) {
msgidPrefixSplit := strings.Split(msgidPrefix, ".")
if !truncated {
@ -299,8 +269,11 @@ func main() {
fmt.Printf("%s:\tmissing msgid prefix: %s\n", fset.Position(pos).String(), msgidPrefix)
}
},
OnMsgid: func(fset *token.FileSet, pos token.Pos, msgid string) {
OnMsgid: func(fset *token.FileSet, pos token.Pos, msgid string, weak bool) {
if !msgids.Contains(msgid) {
if weak && allowWeakMissingMsgids {
return
}
gotAnyMsgidError = true
fmt.Printf("%s:\tmissing msgid: %s\n", fset.Position(pos).String(), msgid)
} else {
@ -314,7 +287,7 @@ func main() {
OnWarning: func(fset *token.FileSet, pos token.Pos, msg string) {
fmt.Printf("%s:\tWARNING: %s\n", fset.Position(pos).String(), msg)
},
LocaleTrFunctions: InitLocaleTrFunctions(),
LocaleTrFunctions: llu.InitLocaleTrFunctions(),
}
if err := filepath.WalkDir(".", func(fpath string, d fs.DirEntry, err error) error {
@ -332,7 +305,7 @@ func main() {
} 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))
onError(HandleGoFile(handler, fpath, nil))
} else if strings.HasSuffix(name, ".tmpl") {
if strings.HasPrefix(fpath, "tests") && strings.HasSuffix(name, ".ini.tmpl") {
// skip false positives

View file

@ -7,24 +7,26 @@ import (
"go/token"
"testing"
llu "forgejo.org/build/lint-locale-usage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func buildHandler(ret *[]string) Handler {
return Handler{
OnMsgid: func(fset *token.FileSet, pos token.Pos, msgid string) {
func buildHandler(ret *[]string) llu.Handler {
return llu.Handler{
OnMsgid: func(fset *token.FileSet, pos token.Pos, msgid string, weak bool) {
*ret = append(*ret, msgid)
},
OnUnexpectedInvoke: func(fset *token.FileSet, pos token.Pos, funcname string, argc int) {},
LocaleTrFunctions: InitLocaleTrFunctions(),
LocaleTrFunctions: llu.InitLocaleTrFunctions(),
}
}
func HandleGoFileWrapped(t *testing.T, fname, src string) []string {
var ret []string
handler := buildHandler(&ret)
require.NoError(t, handler.HandleGoFile(fname, src))
require.NoError(t, HandleGoFile(handler, fname, src))
return ret
}

View file

@ -2,18 +2,17 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package main
package lintLocaleUsage
import (
"fmt"
"go/ast"
goParser "go/parser"
"go/token"
"strconv"
"strings"
)
func (handler Handler) handleGoTrBasicLit(fset *token.FileSet, argLit *ast.BasicLit, prefix string) {
func (handler Handler) HandleGoTrBasicLit(fset *token.FileSet, argLit *ast.BasicLit, prefix string) {
if argLit.Kind == token.STRING {
// extract string content
arg, err := strconv.Unquote(argLit.Value)
@ -29,14 +28,14 @@ func (handler Handler) handleGoTrBasicLit(fset *token.FileSet, argLit *ast.Basic
}
handler.OnMsgidPrefix(fset, argLit.ValuePos, prep, trunc)
} else {
handler.OnMsgid(fset, argLit.ValuePos, arg)
handler.OnMsgid(fset, argLit.ValuePos, arg, false)
}
}
}
func (handler Handler) handleGoTrArgument(fset *token.FileSet, n ast.Expr, prefix string) {
func (handler Handler) HandleGoTrArgument(fset *token.FileSet, n ast.Expr, prefix string) {
if argLit, ok := n.(*ast.BasicLit); ok {
handler.handleGoTrBasicLit(fset, argLit, prefix)
handler.HandleGoTrBasicLit(fset, argLit, prefix)
} else if argBinExpr, ok := n.(*ast.BinaryExpr); ok {
if argBinExpr.Op != token.ADD {
// pass
@ -57,7 +56,7 @@ func (handler Handler) handleGoTrArgument(fset *token.FileSet, n ast.Expr, prefi
}
}
func (handler Handler) handleGoCommentGroup(fset *token.FileSet, cg *ast.CommentGroup, commentPrefix string) *string {
func (handler Handler) HandleGoCommentGroup(fset *token.FileSet, cg *ast.CommentGroup, commentPrefix string) *string {
if cg == nil {
return nil
}
@ -87,131 +86,3 @@ func (handler Handler) handleGoCommentGroup(fset *token.FileSet, cg *ast.Comment
return &matchInsPrefix
}
}
// the `Handle*File` functions follow the following calling convention:
// * `fname` is the name of the input file
// * `src` is either `nil` (then the function invokes `ReadFile` to read the file)
// or the contents of the file as {`[]byte`, or a `string`}
func (handler Handler) HandleGoFile(fname string, src any) error {
fset := token.NewFileSet()
node, err := goParser.ParseFile(fset, fname, src, goParser.SkipObjectResolution|goParser.ParseComments)
if err != nil {
return LocatedError{
Location: fname,
Kind: "Go parser",
Err: err,
}
}
ast.Inspect(node, func(n ast.Node) bool {
// search for function calls of the form `anything.Tr(any-string-lit, ...)`
switch n2 := n.(type) {
case *ast.CallExpr:
if len(n2.Args) == 0 {
return true
}
funSel, ok := n2.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ltf, ok := handler.LocaleTrFunctions[funSel.Sel.Name]
if !ok {
return true
}
var gotUnexpectedInvoke *int
for _, argNum := range ltf {
if len(n2.Args) <= int(argNum) {
argc := len(n2.Args)
gotUnexpectedInvoke = &argc
} else {
handler.handleGoTrArgument(fset, n2.Args[int(argNum)], "")
}
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, funSel.Sel.NamePos, funSel.Sel.Name, *gotUnexpectedInvoke)
}
case *ast.CompositeLit:
ident, ok := n2.Type.(*ast.Ident)
if !ok {
return true
}
// special case: models/unit/unit.go
if strings.HasSuffix(fname, "unit.go") && ident.Name == "Unit" {
if len(n2.Elts) != 6 {
handler.OnWarning(fset, n2.Pos(), "unexpected initialization of 'Unit' (unexpected number of arguments)")
}
// NameKey has index 2
// invoked like '{{ctx.Locale.Tr $unit.NameKey}}'
nameKey, ok := n2.Elts[2].(*ast.BasicLit)
if !ok || nameKey.Kind != token.STRING {
handler.OnWarning(fset, n2.Elts[2].Pos(), "unexpected initialization of 'Unit' (expected string literal as NameKey)")
return true
}
// extract string content
arg, err := strconv.Unquote(nameKey.Value)
if err == nil {
// found interesting strings
handler.OnMsgid(fset, nameKey.ValuePos, arg)
}
}
case *ast.FuncDecl:
matchInsPrefix := handler.handleGoCommentGroup(fset, n2.Doc, "llu:returnsTrKey")
if matchInsPrefix == nil {
return true
}
results := n2.Type.Results.List
if len(results) != 1 {
handler.OnWarning(fset, n2.Type.Func, fmt.Sprintf("function %s has unexpected return type; expected single return value", n2.Name.Name))
return true
}
ast.Inspect(n2.Body, func(n ast.Node) bool {
// search for return stmts
// TODO: what about nested functions?
if ret, ok := n.(*ast.ReturnStmt); ok {
for _, res := range ret.Results {
ast.Inspect(res, func(n ast.Node) bool {
if expr, ok := n.(ast.Expr); ok {
handler.handleGoTrArgument(fset, expr, *matchInsPrefix)
}
return true
})
}
return false
}
return true
})
return true
case *ast.GenDecl:
if !(n2.Tok == token.CONST || n2.Tok == token.VAR) {
return true
}
matchInsPrefix := handler.handleGoCommentGroup(fset, n2.Doc, " llu:TrKeys")
if matchInsPrefix == nil {
return true
}
for _, spec := range n2.Specs {
// interpret all contained strings as message IDs
ast.Inspect(spec, func(n ast.Node) bool {
if argLit, ok := n.(*ast.BasicLit); ok {
handler.handleGoTrBasicLit(fset, argLit, *matchInsPrefix)
return false
}
return true
})
}
}
return true
})
return nil
}

View file

@ -2,7 +2,7 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package main
package lintLocaleUsage
import (
"fmt"
@ -48,18 +48,29 @@ func (handler Handler) handleTemplateNode(fset *token.FileSet, node tmplParser.N
}
funcname := ""
if nodeChain, ok := nodeCommand.Args[0].(*tmplParser.ChainNode); ok {
switch nodeCommand.Args[0].Type() {
case tmplParser.NodeChain:
nodeChain := nodeCommand.Args[0].(*tmplParser.ChainNode)
if nodeIdent, ok := nodeChain.Node.(*tmplParser.IdentifierNode); ok {
if nodeIdent.Ident != "ctx" || len(nodeChain.Field) != 2 || nodeChain.Field[0] != "Locale" {
return
}
funcname = nodeChain.Field[1]
}
} else if nodeField, ok := nodeCommand.Args[0].(*tmplParser.FieldNode); ok {
case tmplParser.NodeField:
nodeField := nodeCommand.Args[0].(*tmplParser.FieldNode)
if len(nodeField.Ident) != 2 || !(nodeField.Ident[0] == "locale" || nodeField.Ident[0] == "Locale") {
return
}
funcname = nodeField.Ident[1]
case tmplParser.NodeVariable:
nodeVar := nodeCommand.Args[0].(*tmplParser.VariableNode)
if len(nodeVar.Ident) != 3 || !(nodeVar.Ident[0] == "$" && nodeVar.Ident[1] == "locale") {
return
}
funcname = nodeVar.Ident[2]
}
var gotUnexpectedInvoke *int
@ -93,7 +104,7 @@ func (handler Handler) handleTemplateMsgid(fset *token.FileSet, node tmplParser.
case tmplParser.NodeString:
nodeString := node.(*tmplParser.StringNode)
// found interesting strings
handler.OnMsgid(fset, pos, nodeString.Text)
handler.OnMsgid(fset, pos, nodeString.Text, false)
case tmplParser.NodePipe:
nodePipe := node.(*tmplParser.PipeNode)
@ -132,7 +143,7 @@ func (handler Handler) handleTemplateMsgid(fset *token.FileSet, node tmplParser.
if len(nodeCommand.Args) == 2 {
// found interesting strings
handler.OnMsgid(fset, stringPos, msgidPrefix)
handler.OnMsgid(fset, stringPos, msgidPrefix, false)
} else {
if nodeIdent.Ident == "printf" {
parts := strings.SplitN(msgidPrefix, "%", 2)

View file

@ -0,0 +1,62 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lintLocaleUsage
import (
"go/token"
"strings"
)
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, weak bool)
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
}
// 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
}

View file

@ -0,0 +1,38 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lintLocaleUsage
import (
"go/ast"
"go/token"
"strconv"
llu "forgejo.org/build/lint-locale-usage"
)
func HandleCompositeUnit(handler llu.Handler, fset *token.FileSet, n *ast.CompositeLit) {
ident, ok := n.Type.(*ast.Ident)
if !ok || ident.Name != "Unit" {
return
}
if len(n.Elts) != 6 {
handler.OnWarning(fset, n.Pos(), "unexpected initialization of 'Unit' (unexpected number of arguments)")
return
}
// NameKey has index 2
// invoked like '{{ctx.Locale.Tr $unit.NameKey}}'
nameKey, ok := n.Elts[2].(*ast.BasicLit)
if !ok || nameKey.Kind != token.STRING {
handler.OnWarning(fset, n.Elts[2].Pos(), "unexpected initialization of 'Unit' (expected string literal as NameKey)")
return
}
// extract string content
arg, err := strconv.Unquote(nameKey.Value)
if err == nil {
// found interesting strings
handler.OnMsgid(fset, nameKey.ValuePos, arg, false)
}
}

View file

@ -0,0 +1,45 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lintLocaleUsage
import (
"go/ast"
"go/token"
llu "forgejo.org/build/lint-locale-usage"
"forgejo.org/modules/container"
)
// special case: services/migrations/migrate.go
func HandleMessengerInFunc(handler llu.Handler, fset *token.FileSet, n2 *ast.FuncDecl) {
messenger := make(container.Set[string])
for _, i := range n2.Type.Params.List {
if ret, ok := i.Type.(*ast.SelectorExpr); ok && ret.Sel.Name == "Messenger" {
if ret, ok := ret.X.(*ast.Ident); ok && ret.Name == "base" {
for _, j := range i.Names {
messenger.Add(j.Name)
}
}
}
}
if len(messenger) == 0 {
return
}
ast.Inspect(n2.Body, func(n ast.Node) bool {
// search for "messenger" function calls
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if ret, ok := call.Fun.(*ast.Ident); !(ok && messenger.Contains(ret.Name)) {
return true
}
if len(call.Args) != 1 {
handler.OnWarning(fset, call.Lparen, "unexpected invocation of base.Messenger (expected exactly 1 argument)")
return true
}
handler.HandleGoTrArgument(fset, call.Args[0], "")
return true
})
}