From 62e39785cb2275f526926ce5deda34702bc7ed84 Mon Sep 17 00:00:00 2001 From: Nick Snyder Date: Tue, 30 Jan 2024 20:16:20 -0800 Subject: [PATCH] Support configurable template parsers (#317) --- v2/i18n/localizer.go | 30 ++++++++++++--- v2/i18n/localizer_test.go | 51 ++++++++++++++++++++++++++ v2/i18n/message.go | 2 +- v2/i18n/message_template.go | 25 +++++++++++-- v2/i18n/template/identity_parser.go | 21 +++++++++++ v2/i18n/template/parser.go | 17 +++++++++ v2/i18n/template/text_parser.go | 57 +++++++++++++++++++++++++++++ v2/internal/template.go | 37 ++++++------------- v2/internal/template_test.go | 21 +++++++---- 9 files changed, 218 insertions(+), 43 deletions(-) create mode 100644 v2/i18n/template/identity_parser.go create mode 100644 v2/i18n/template/parser.go create mode 100644 v2/i18n/template/text_parser.go diff --git a/v2/i18n/localizer.go b/v2/i18n/localizer.go index de82360b..b13160fe 100644 --- a/v2/i18n/localizer.go +++ b/v2/i18n/localizer.go @@ -2,8 +2,9 @@ package i18n import ( "fmt" - "text/template" + texttemplate "text/template" + "github.com/nicksnyder/go-i18n/v2/i18n/template" "github.com/nicksnyder/go-i18n/v2/internal/plural" "golang.org/x/text/language" ) @@ -66,8 +67,26 @@ type LocalizeConfig struct { // DefaultMessage is used if the message is not found in any message files. DefaultMessage *Message - // Funcs is used to extend the Go template engine's built in functions - Funcs template.FuncMap + // Funcs is used to configure a template.TextParser if TemplateParser is not set. + Funcs texttemplate.FuncMap + + // The TemplateParser to use for parsing templates. + // If one is not set, a template.TextParser is used (configured with Funcs if it is set). + TemplateParser template.Parser +} + +var defaultTextParser = &template.TextParser{} + +func (lc *LocalizeConfig) getTemplateParser() template.Parser { + if lc.TemplateParser != nil { + return lc.TemplateParser + } + if lc.Funcs != nil { + return &template.TextParser{ + Funcs: lc.Funcs, + } + } + return defaultTextParser } type invalidPluralCountErr struct { @@ -152,7 +171,8 @@ func (l *Localizer) LocalizeWithTag(lc *LocalizeConfig) (string, language.Tag, e } pluralForm := l.pluralForm(tag, operands) - msg, err2 := template.Execute(pluralForm, templateData, lc.Funcs) + templateParser := lc.getTemplateParser() + msg, err2 := template.execute(pluralForm, templateData, templateParser) if err2 != nil { if err == nil { err = err2 @@ -160,7 +180,7 @@ func (l *Localizer) LocalizeWithTag(lc *LocalizeConfig) (string, language.Tag, e // Attempt to fallback to "Other" pluralization in case translations are incomplete. if pluralForm != plural.Other { - msg2, err3 := template.Execute(plural.Other, templateData, lc.Funcs) + msg2, err3 := template.execute(plural.Other, templateData, templateParser) if err3 == nil { msg = msg2 } diff --git a/v2/i18n/localizer_test.go b/v2/i18n/localizer_test.go index 97faa043..f1c80524 100644 --- a/v2/i18n/localizer_test.go +++ b/v2/i18n/localizer_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + "github.com/nicksnyder/go-i18n/v2/i18n/template" "github.com/nicksnyder/go-i18n/v2/internal/plural" "golang.org/x/text/language" ) @@ -362,6 +363,56 @@ func localizerTests() []localizerTest { }, expectedLocalized: "Hello Nick", }, + { + name: "identity parser, bundle message", + defaultLanguage: language.English, + messages: map[language.Tag][]*Message{ + language.English: {{ + ID: "HelloPerson", + Other: "Hello {{.Person}}", + }}, + }, + acceptLangs: []string{"en"}, + conf: &LocalizeConfig{ + MessageID: "HelloPerson", + TemplateData: map[string]string{ + "Person": "Nick", + }, + TemplateParser: template.IdentityParser{}, + }, + expectedLocalized: "Hello {{.Person}}", + }, + { + name: "identity parser, default message", + defaultLanguage: language.English, + acceptLangs: []string{"en"}, + conf: &LocalizeConfig{ + DefaultMessage: &Message{ + ID: "HelloPerson", + Other: "Hello {{.Person}}", + }, + TemplateData: map[string]string{ + "Person": "Nick", + }, + TemplateParser: template.IdentityParser{}, + }, + expectedLocalized: "Hello {{.Person}}", + }, + { + name: "custom funcs, default message", + defaultLanguage: language.English, + acceptLangs: []string{"en"}, + conf: &LocalizeConfig{ + DefaultMessage: &Message{ + ID: "HelloWorld", + Other: "{{HelloWorldFunc}}", + }, + Funcs: map[string]any{ + "HelloWorldFunc": func() string { return "Hello World" }, + }, + }, + expectedLocalized: "Hello World", + }, { name: "template data, custom delims, bundle message", defaultLanguage: language.English, diff --git a/v2/i18n/message.go b/v2/i18n/message.go index f8f789a5..73cd2f6d 100644 --- a/v2/i18n/message.go +++ b/v2/i18n/message.go @@ -21,7 +21,7 @@ type Message struct { // LeftDelim is the left Go template delimiter. LeftDelim string - // RightDelim is the right Go template delimiter.`` + // RightDelim is the right Go template delimiter. RightDelim string // Zero is the content of the message for the CLDR plural form "zero". diff --git a/v2/i18n/message_template.go b/v2/i18n/message_template.go index a1a619e2..24a890b8 100644 --- a/v2/i18n/message_template.go +++ b/v2/i18n/message_template.go @@ -2,9 +2,9 @@ package i18n import ( "fmt" + texttemplate "text/template" - "text/template" - + "github.com/nicksnyder/go-i18n/v2/i18n/template" "github.com/nicksnyder/go-i18n/v2/internal" "github.com/nicksnyder/go-i18n/v2/internal/plural" ) @@ -53,7 +53,24 @@ func (e pluralFormNotFoundError) Error() string { } // Execute executes the template for the plural form and template data. -func (mt *MessageTemplate) Execute(pluralForm plural.Form, data interface{}, funcs template.FuncMap) (string, error) { +// Deprecated: This message is no longer used internally by go-i18n and it probably should not have been exported to +// begin with. Its replacement is not exported. If you depend on this method for some reason and/or have +// a use case for exporting execute, please file an issue. +func (mt *MessageTemplate) Execute(pluralForm plural.Form, data interface{}, funcs texttemplate.FuncMap) (string, error) { + t := mt.PluralTemplates[pluralForm] + if t == nil { + return "", pluralFormNotFoundError{ + pluralForm: pluralForm, + messageID: mt.Message.ID, + } + } + parser := &template.TextParser{ + Funcs: funcs, + } + return t.Execute(parser, data) +} + +func (mt *MessageTemplate) execute(pluralForm plural.Form, data interface{}, parser template.Parser) (string, error) { t := mt.PluralTemplates[pluralForm] if t == nil { return "", pluralFormNotFoundError{ @@ -61,5 +78,5 @@ func (mt *MessageTemplate) Execute(pluralForm plural.Form, data interface{}, fun messageID: mt.Message.ID, } } - return t.Execute(funcs, data) + return t.Execute(parser, data) } diff --git a/v2/i18n/template/identity_parser.go b/v2/i18n/template/identity_parser.go new file mode 100644 index 00000000..baaa1ac3 --- /dev/null +++ b/v2/i18n/template/identity_parser.go @@ -0,0 +1,21 @@ +package template + +// IdentityParser is an Parser that does no parsing and returns tempalte string unchanged. +type IdentityParser struct{} + +func (IdentityParser) Cacheable() bool { + // Caching is not necessary because Parse is cheap. + return false +} + +func (IdentityParser) Parse(src, leftDelim, rightDelim string) (ParsedTemplate, error) { + return &identityParsedTemplate{src: src}, nil +} + +type identityParsedTemplate struct { + src string +} + +func (t *identityParsedTemplate) Execute(data any) (string, error) { + return t.src, nil +} diff --git a/v2/i18n/template/parser.go b/v2/i18n/template/parser.go new file mode 100644 index 00000000..9a01fd5d --- /dev/null +++ b/v2/i18n/template/parser.go @@ -0,0 +1,17 @@ +// Package template defines a generic interface for template parsers and implementations of that interface. +package template + +// Parser parses strings into executable templates. +type Parser interface { + // Parse parses src and returns a ParsedTemplate. + Parse(src, leftDelim, rightDelim string) (ParsedTemplate, error) + + // Cacheable returns true if Parse returns ParsedTemplates that are always safe to cache. + Cacheable() bool +} + +// ParsedTemplate is an executable template. +type ParsedTemplate interface { + // Execute applies a parsed template to the specified data. + Execute(data any) (string, error) +} diff --git a/v2/i18n/template/text_parser.go b/v2/i18n/template/text_parser.go new file mode 100644 index 00000000..76b4ba2d --- /dev/null +++ b/v2/i18n/template/text_parser.go @@ -0,0 +1,57 @@ +package template + +import ( + "bytes" + "strings" + "text/template" +) + +// TextParser is a Parser that uses text/template. +type TextParser struct { + LeftDelim string + RightDelim string + Funcs template.FuncMap + Option string +} + +func (te *TextParser) Cacheable() bool { + return te.Funcs == nil +} + +func (te *TextParser) Parse(src, leftDelim, rightDelim string) (ParsedTemplate, error) { + if leftDelim == "" { + leftDelim = te.LeftDelim + } + if leftDelim == "" { + leftDelim = "{{" + } + if !strings.Contains(src, leftDelim) { + // Fast path to avoid parsing a template that has no actions. + return &identityParsedTemplate{src: src}, nil + } + + if rightDelim == "" { + rightDelim = te.RightDelim + } + if rightDelim == "" { + rightDelim = "}}" + } + + tmpl, err := template.New("").Delims(leftDelim, rightDelim).Funcs(te.Funcs).Parse(src) + if err != nil { + return nil, err + } + return &parsedTextTemplate{tmpl: tmpl}, nil +} + +type parsedTextTemplate struct { + tmpl *template.Template +} + +func (t *parsedTextTemplate) Execute(data any) (string, error) { + var buf bytes.Buffer + if err := t.tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} diff --git a/v2/internal/template.go b/v2/internal/template.go index 2fe99235..e4b5f476 100644 --- a/v2/internal/template.go +++ b/v2/internal/template.go @@ -1,51 +1,36 @@ package internal import ( - "bytes" - "strings" "sync" - gotemplate "text/template" + + "github.com/nicksnyder/go-i18n/v2/i18n/template" ) -// Template stores the template for a string. +// Template stores the template for a string and a cached version of the parsed template if they are cacheable. type Template struct { Src string LeftDelim string RightDelim string parseOnce sync.Once - parsedTemplate *gotemplate.Template + parsedTemplate template.ParsedTemplate parseError error } -func (t *Template) Execute(funcs gotemplate.FuncMap, data interface{}) (string, error) { - leftDelim := t.LeftDelim - if leftDelim == "" { - leftDelim = "{{" - } - if !strings.Contains(t.Src, leftDelim) { - // Fast path to avoid parsing a template that has no actions. - return t.Src, nil - } - - var gt *gotemplate.Template +func (t *Template) Execute(parser template.Parser, data interface{}) (string, error) { + var pt template.ParsedTemplate var err error - if funcs == nil { + if parser.Cacheable() { t.parseOnce.Do(func() { - // If funcs is nil, then we only need to parse this template once. - t.parsedTemplate, t.parseError = gotemplate.New("").Delims(t.LeftDelim, t.RightDelim).Parse(t.Src) + t.parsedTemplate, t.parseError = parser.Parse(t.Src, t.LeftDelim, t.RightDelim) }) - gt, err = t.parsedTemplate, t.parseError + pt, err = t.parsedTemplate, t.parseError } else { - gt, err = gotemplate.New("").Delims(t.LeftDelim, t.RightDelim).Funcs(funcs).Parse(t.Src) + pt, err = parser.Parse(t.Src, t.LeftDelim, t.RightDelim) } if err != nil { return "", err } - var buf bytes.Buffer - if err := gt.Execute(&buf, data); err != nil { - return "", err - } - return buf.String(), nil + return pt.Execute(data) } diff --git a/v2/internal/template_test.go b/v2/internal/template_test.go index 4f569b9c..069fd8d3 100644 --- a/v2/internal/template_test.go +++ b/v2/internal/template_test.go @@ -3,13 +3,15 @@ package internal import ( "strings" "testing" - "text/template" + texttemplate "text/template" + + "github.com/nicksnyder/go-i18n/v2/i18n/template" ) func TestExecute(t *testing.T) { tests := []struct { template *Template - funcs template.FuncMap + parser template.Parser data interface{} result string err string @@ -35,9 +37,11 @@ func TestExecute(t *testing.T) { template: &Template{ Src: "hello {{world}}", }, - funcs: template.FuncMap{ - "world": func() string { - return "world" + parser: &template.TextParser{ + Funcs: texttemplate.FuncMap{ + "world": func() string { + return "world" + }, }, }, result: "hello world", @@ -53,7 +57,10 @@ func TestExecute(t *testing.T) { for _, test := range tests { t.Run(test.template.Src, func(t *testing.T) { - result, err := test.template.Execute(test.funcs, test.data) + if test.parser == nil { + test.parser = &template.TextParser{} + } + result, err := test.template.Execute(test.parser, test.data) if actual := str(err); !strings.Contains(str(err), test.err) { t.Errorf("expected err %q to contain %q", actual, test.err) } @@ -61,7 +68,7 @@ func TestExecute(t *testing.T) { t.Errorf("expected result %q; got %q", test.result, result) } allocs := testing.AllocsPerRun(10, func() { - _, _ = test.template.Execute(test.funcs, test.data) + _, _ = test.template.Execute(test.parser, test.data) }) if test.noallocs && allocs > 0 { t.Errorf("expected no allocations; got %f", allocs)