From 1acf630dbf9fa751d2ba006f9d855baac0a5fe19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=88=CE=BB=CE=BB=CE=B5=CE=BD=20=CE=95=CE=BC=CE=AF=CE=BB?= =?UTF-8?q?=CE=B9=CE=B1=20=CE=86=CE=BD=CE=BD=CE=B1=20Zscheile?= Date: Fri, 1 May 2026 02:46:01 +0200 Subject: [PATCH] feat(build): Support go "fmt" format strings as masked usage patterns (#12013) This idea is perhaps a bit more far-fetched. It implements the ability in `lint-locale-usage` to basically fully handle "printf" invocations by transforming format strings to regexps when "%" wildcards are present. Currently, it doesn't cache the transformation from format string to compiled regex because this doesn't make a performance difference (yet), given that most of these wildcards are only hit once or twice. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12013 Reviewed-by: Gusted --- .../allowed-masked-usage.txt | 20 -- build/lint-locale-usage/bin/handle-go.go | 260 ++++++++++-------- .../bin/lint-locale-usage.go | 63 ++++- build/lint-locale-usage/handle-go.go | 45 ++- build/lint-locale-usage/handle-tmpl.go | 12 +- build/lint-locale-usage/handler.go | 1 + models/project/project.go | 2 +- models/project/template.go | 2 +- models/system/notice.go | 2 + templates/repo/issue/view_content.tmpl | 8 +- .../repo/issue/view_content/comments.tmpl | 10 +- 11 files changed, 263 insertions(+), 162 deletions(-) diff --git a/build/lint-locale-usage/allowed-masked-usage.txt b/build/lint-locale-usage/allowed-masked-usage.txt index ca899c7d38..205d3c3cca 100644 --- a/build/lint-locale-usage/allowed-masked-usage.txt +++ b/build/lint-locale-usage/allowed-masked-usage.txt @@ -6,32 +6,12 @@ translation_meta.test # this also gets instantiated as a Messenger once repo.migrate.migrating_failed.error -# models/system/notice.go: func (n *Notice) TrStr() string -admin.notices.type_1 -admin.notices.type_2 - # modules/setting/ui.go themes.names. # services/context/context.go relativetime. -# templates/repo/issue/view_content.tmpl: indirection via $closeTranslationKey -repo.issues.close -repo.pulls.close - -# templates/repo/issue/view_content/comments.tmpl: indirection via $refTr -repo.issues.ref_closing_from -repo.issues.ref_issue_from -repo.issues.ref_pull_from -repo.issues.ref_reopening_from - -# templates/repo/issue/view_content/comments.tmpl: ctx.Locale.Tr (printf "projects.type-%d.display_name" .OldProject.Type) -projects. -projects.type-1.display_name -projects.type-2.display_name -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. diff --git a/build/lint-locale-usage/bin/handle-go.go b/build/lint-locale-usage/bin/handle-go.go index ffa6b13165..5b68543e9d 100644 --- a/build/lint-locale-usage/bin/handle-go.go +++ b/build/lint-locale-usage/bin/handle-go.go @@ -37,144 +37,172 @@ func HandleGoFile(handler llu.Handler, fname string, src any) error { } ast.Inspect(node, func(n ast.Node) bool { - // search for function calls of the form `anything.Tr(any-string-lit, ...)` + return HandleGoNode(handler, fset, fname, n) + }) - switch n2 := n.(type) { - case *ast.CallExpr: - if len(n2.Args) == 0 { - return true + return nil +} + +func HandleGoNode(handler llu.Handler, fset *token.FileSet, fname string, 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)], "") } - funSel, ok := n2.Fun.(*ast.SelectorExpr) - if !ok { + } + + 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) + } else if strings.Contains(fname, "models/asymkey/") { + lluAsymKey.HandleCompositeErrorReason(handler, fset, n2) + } + + case *ast.FuncDecl: + if matchInsPrefix := handler.HandleGoCommentGroup(fset, n2.Doc, "llu:returnsTrKeyWeak"); 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 } - 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)], "") + 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 matchInsPrefix := handler.HandleGoCommentGroup(fset, n2.Doc, "llu:returnsTrKey"); 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 } - 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) - } else if strings.Contains(fname, "models/asymkey/") { - lluAsymKey.HandleCompositeErrorReason(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 + if ret, ok := n.(*ast.ReturnStmt); ok { + for _, res := range ret.Results { + handler.HandleGoTrArgument(fset, res, *matchInsPrefix) + } + return false + } else if _, ok := n.(*ast.FuncDecl); ok { + ast.Inspect(n, func(n2 ast.Node) bool { + return HandleGoNode(handler, fset, fname, n2) + }) + // don't search inside nested functions for return stmts + return false } + 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 - }) - } + 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 }) } - 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. - 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" - }))) { + 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 } - 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) + 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 + return true } diff --git a/build/lint-locale-usage/bin/lint-locale-usage.go b/build/lint-locale-usage/bin/lint-locale-usage.go index 311e632461..f04b14f3b2 100644 --- a/build/lint-locale-usage/bin/lint-locale-usage.go +++ b/build/lint-locale-usage/bin/lint-locale-usage.go @@ -13,6 +13,7 @@ import ( "io/fs" "os" "path/filepath" + "regexp" "sort" "strings" @@ -44,12 +45,57 @@ type StringTrie interface { type StringTrieMap map[string]StringTrie +func printfPatternToRegex(key string) (string, bool) { + parts := strings.Split(key, "%") + if len(parts) < 2 { + return key, false + } + var pattern strings.Builder + pattern.WriteString("^") + pattern.WriteString(parts[0]) + skip := false + for _, part := range parts[1:] { + if skip { + skip = false + continue + } + if len(part) == 0 { + // "%%" + pattern.WriteString("%") + continue + } + switch part[0] { + case 'd': + pattern.WriteString("[0-9]+") + default: + pattern.WriteString("[A-Za-z0-9]*") + } + pattern.WriteString(part[1:]) + } + pattern.WriteString("$") + return pattern.String(), true +} + func (m StringTrieMap) Matches(key []string) bool { if len(key) == 0 || m == nil { return true } value, ok := m[key[0]] if !ok { + for altKey, value := range m { + // TODO: cache mapping $printfFormatString -> $regexpCompileOutput + pattern, found := printfPatternToRegex(altKey) + if !found { + continue + } + matched, err := regexp.MatchString(pattern, key[0]) + if err != nil { + panic(fmt.Sprintf("unable to compile regexp '%s': %s", pattern, err.Error())) + } + if matched && (value == nil || value.Matches(key[1:])) { + return true + } + } return false } if value == nil { @@ -101,7 +147,7 @@ func ParseAllowedMaskedUsages(fname string, usedMsgids container.Set[string], al if line == "" || strings.HasPrefix(line, "#") { continue } - if linePrefix, found := strings.CutSuffix(line, "."); found { + if linePrefix, found := strings.CutSuffix(line, "."); found || strings.Contains(line, "%") { allowedMaskedPrefixes.Insert(strings.Split(linePrefix, ".")) } else { if !chkMsgid(line) { @@ -145,9 +191,14 @@ func Usage() { fmt.Fprintf(outp, "\nSpecial Go doc comments:\n") for _, i := range []string{ + "//llu:returnsTrKeyWeak", + "\tcan be used in front of functions to indicate", + "\tthat the function returns message IDs (allows nesting inside complicated function calls)", + "\tWARNING: this currently doesn't support nested functions properly", + "", "//llu:returnsTrKey", "\tcan be used in front of functions to indicate", - "\tthat the function returns message IDs", + "\tthat the function returns message IDs (doesn't allow nesting inside complicated function calls)", "\tWARNING: this currently doesn't support nested functions properly", "", "//llu:returnsTrKeySuffix prefix.", @@ -260,6 +311,10 @@ func main() { } handler := llu.Handler{ + OnMsgidPattern: func(fset *token.FileSet, pos token.Pos, msgidPattern string) { + msgidPatternSplit := strings.Split(msgidPattern, ".") + allowedMaskedPrefixes.Insert(msgidPatternSplit) + }, OnMsgidPrefix: func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool) { msgidPrefixSplit := strings.Split(msgidPrefix, ".") if !truncated { @@ -270,6 +325,10 @@ func main() { } }, OnMsgid: func(fset *token.FileSet, pos token.Pos, msgid string, weak bool) { + if strings.Contains(msgid, "%") { + fmt.Printf("%s:\tunexpected msgid pattern: %s\n", fset.Position(pos).String(), msgid) + return + } if !msgids.Contains(msgid) { if weak && allowWeakMissingMsgids { return diff --git a/build/lint-locale-usage/handle-go.go b/build/lint-locale-usage/handle-go.go index 44229e52f7..a8e478a6ef 100644 --- a/build/lint-locale-usage/handle-go.go +++ b/build/lint-locale-usage/handle-go.go @@ -34,12 +34,14 @@ func (handler Handler) HandleGoTrBasicLit(fset *token.FileSet, argLit *ast.Basic } func (handler Handler) HandleGoTrArgument(fset *token.FileSet, n ast.Expr, prefix string) { - if argLit, ok := n.(*ast.BasicLit); ok { - handler.HandleGoTrBasicLit(fset, argLit, prefix) - } else if argBinExpr, ok := n.(*ast.BinaryExpr); ok { - if argBinExpr.Op != token.ADD { + switch n := n.(type) { + case *ast.BasicLit: + handler.HandleGoTrBasicLit(fset, n, prefix) + + case *ast.BinaryExpr: + if n.Op != token.ADD { // pass - } else if argLit, ok := argBinExpr.X.(*ast.BasicLit); ok && argLit.Kind == token.STRING { + } else if argLit, ok := n.X.(*ast.BasicLit); ok && argLit.Kind == token.STRING { // extract string content arg, err := strconv.Unquote(argLit.Value) if err != nil { @@ -53,6 +55,39 @@ func (handler Handler) HandleGoTrArgument(fset *token.FileSet, n ast.Expr, prefi } handler.OnMsgidPrefix(fset, argLit.ValuePos, prep, trunc) } + + case *ast.CallExpr: + if selExpr, ok := n.Fun.(*ast.SelectorExpr); ok { + if xIdent, xok := selExpr.X.(*ast.Ident); !xok || xIdent.Name != "fmt" { + return + } + if selExpr.Sel.Name != "Sprintf" { + handler.OnWarning(fset, selExpr.Sel.NamePos, fmt.Sprintf("unexpected formatting function encountered: %s", selExpr.Sel.Name)) + return + } + if len(n.Args) == 0 { + handler.OnWarning(fset, selExpr.Sel.NamePos, fmt.Sprintf("unexpected formatting function invocation (no arguments) of '%s'", selExpr.Sel.Name)) + return + } + + if argLit, ok := n.Args[0].(*ast.BasicLit); ok && argLit.Kind == token.STRING { + // extract string content + arg, err := strconv.Unquote(argLit.Value) + if err != nil { + return + } + if strings.Contains(arg, " ") { + handler.OnWarning(fset, argLit.ValuePos, fmt.Sprintf( + "formatting function invocation of '%s' with weird msgid format string: %s", + selExpr.Sel.Name, + arg, + )) + return + } + // found interesting strings + handler.OnMsgidPattern(fset, argLit.ValuePos, prefix+arg) + } + } } } diff --git a/build/lint-locale-usage/handle-tmpl.go b/build/lint-locale-usage/handle-tmpl.go index 7fbda8ffd5..e37c1eb486 100644 --- a/build/lint-locale-usage/handle-tmpl.go +++ b/build/lint-locale-usage/handle-tmpl.go @@ -150,16 +150,12 @@ func (handler Handler) handleTemplateMsgid(fset *token.FileSet, node tmplParser. handler.OnMsgid(fset, stringPos, msgidPrefix, false) } else { if nodeIdent.Ident == "printf" { - parts := strings.SplitN(msgidPrefix, "%", 2) - if len(parts) != 2 { - handler.OnWarning( - fset, - stringPos, - fmt.Sprintf("unsupported invocation of locate function (format string doesn't match \"prefix%%smth\" pattern): %s", nodeString.String()), - ) + // found interesting strings + if !(strings.HasSuffix(msgidPrefix, ".%s") && strings.Count(msgidPrefix, "%") == 1) { + handler.OnMsgidPattern(fset, stringPos, msgidPrefix) return } - msgidPrefix = parts[0] + msgidPrefix = strings.TrimSuffix(msgidPrefix, "%s") } msgidPrefixFin, truncated := PrepareMsgidPrefix(msgidPrefix) diff --git a/build/lint-locale-usage/handler.go b/build/lint-locale-usage/handler.go index 6673ac3a4d..ef517cd040 100644 --- a/build/lint-locale-usage/handler.go +++ b/build/lint-locale-usage/handler.go @@ -47,6 +47,7 @@ func InitLocaleTrFunctions() map[string][]uint { 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) + OnMsgidPattern func(fset *token.FileSet, pos token.Pos, msgidPattern string) 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 diff --git a/models/project/project.go b/models/project/project.go index 8f3d09b956..be1b9d59c6 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -184,7 +184,7 @@ func init() { // GetCardConfig retrieves the types of configurations project column cards could have // -//llu:returnsTrKey +//llu:returnsTrKeyWeak func GetCardConfig() []CardConfig { return []CardConfig{ {CardTypeTextOnly, "repo.projects.card_type.text_only"}, diff --git a/models/project/template.go b/models/project/template.go index 278cf5b781..3d55cd27f2 100644 --- a/models/project/template.go +++ b/models/project/template.go @@ -27,7 +27,7 @@ const ( // GetTemplateConfigs retrieves the template configs of configurations project columns could have // -//llu:returnsTrKey +//llu:returnsTrKeyWeak func GetTemplateConfigs() []TemplateConfig { return []TemplateConfig{ {TemplateTypeNone, "repo.projects.type.none"}, diff --git a/models/system/notice.go b/models/system/notice.go index b1fdd2e4f2..7801686889 100644 --- a/models/system/notice.go +++ b/models/system/notice.go @@ -38,6 +38,8 @@ func init() { } // TrStr returns a translation format string. +// +//llu:returnsTrKey func (n *Notice) TrStr() string { return fmt.Sprintf("admin.notices.type_%d", n.Type) } diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index 08a8daade9..235112b47b 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -110,14 +110,14 @@ {{else}} - {{$closeTranslationKey := "repo.issues.close"}} + {{$closeTranslation := ctx.Locale.Tr "repo.issues.close"}} {{if .Issue.IsPull}} - {{$closeTranslationKey = "repo.pulls.close"}} + {{$closeTranslation = ctx.Locale.Tr "repo.pulls.close"}} {{end}} - {{end}} diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index bde6a0b5b2..b3de7ed155 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -129,13 +129,13 @@ {{if ne .RefRepoID .Issue.RepoID}} {{$refFrom = ctx.Locale.Tr "repo.issues.ref_from" .RefRepo.FullName}} {{end}} - {{$refTr := "repo.issues.ref_issue_from"}} + {{$refTr := "issue"}} {{if .Issue.IsPull}} - {{$refTr = "repo.issues.ref_pull_from"}} + {{$refTr = "pull"}} {{else if eq .RefAction 1}} - {{$refTr = "repo.issues.ref_closing_from"}} + {{$refTr = "closing"}} {{else if eq .RefAction 2}} - {{$refTr = "repo.issues.ref_reopening_from"}} + {{$refTr = "reopening"}} {{end}}
{{svg "octicon-bookmark"}} @@ -143,7 +143,7 @@ {{if eq .RefAction 3}}{{end}} {{template "shared/user/authorlink" .Poster}} - {{ctx.Locale.Tr $refTr $createdStr (.RefCommentLink ctx) $refFrom}} + {{ctx.Locale.Tr (printf "repo.issues.ref_%s_from" $refTr) $createdStr (.RefCommentLink ctx) $refFrom}} {{if eq .RefAction 3}}{{end}}