From b597d2a4e2b681fd24c19982ca6423f3e26d2a8d Mon Sep 17 00:00:00 2001 From: Nick Snyder Date: Sat, 27 Jan 2024 22:52:15 -0800 Subject: [PATCH] Support pluggable template engines --- v2/goi18n/merge_command.go | 7 +- v2/i18n/localizer.go | 25 ++++- v2/i18n/message.go | 2 +- v2/i18n/message_template.go | 28 +++-- v2/i18n/template.go | 34 ++++++ v2/i18n/template_engine.go | 150 +++++++++++++++++++++++++ v2/{internal => i18n}/template_test.go | 19 ++-- v2/internal/template.go | 51 --------- 8 files changed, 243 insertions(+), 73 deletions(-) create mode 100644 v2/i18n/template.go create mode 100644 v2/i18n/template_engine.go rename v2/{internal => i18n}/template_test.go (77%) delete mode 100644 v2/internal/template.go diff --git a/v2/goi18n/merge_command.go b/v2/goi18n/merge_command.go index 79dc3f32..b3cf2086 100644 --- a/v2/goi18n/merge_command.go +++ b/v2/goi18n/merge_command.go @@ -10,7 +10,6 @@ import ( "github.com/BurntSushi/toml" "github.com/nicksnyder/go-i18n/v2/i18n" - "github.com/nicksnyder/go-i18n/v2/internal" "github.com/nicksnyder/go-i18n/v2/internal/plural" "golang.org/x/text/language" yaml "gopkg.in/yaml.v2" @@ -171,7 +170,7 @@ func merge(messageFiles map[string][]byte, sourceLanguageTag language.Tag, outdi Description: srcTemplate.Description, Hash: srcTemplate.Hash, }, - PluralTemplates: make(map[plural.Form]*internal.Template), + PluralTemplates: make(map[plural.Form]*i18n.Template), } all[dstLangTag][srcTemplate.ID] = dstMessageTemplate } @@ -269,7 +268,7 @@ func activeDst(src, dst *i18n.MessageTemplate, pluralRule *plural.Rule) (active Description: src.Description, Hash: src.Hash, }, - PluralTemplates: make(map[plural.Form]*internal.Template), + PluralTemplates: make(map[plural.Form]*i18n.Template), } } srcPlural := src.PluralTemplates[pluralForm] @@ -286,7 +285,7 @@ func activeDst(src, dst *i18n.MessageTemplate, pluralRule *plural.Rule) (active Description: src.Description, Hash: src.Hash, }, - PluralTemplates: make(map[plural.Form]*internal.Template), + PluralTemplates: make(map[plural.Form]*i18n.Template), } } active.PluralTemplates[pluralForm] = dt diff --git a/v2/i18n/localizer.go b/v2/i18n/localizer.go index de82360b..f685f973 100644 --- a/v2/i18n/localizer.go +++ b/v2/i18n/localizer.go @@ -66,8 +66,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 is used to extend the Go template engine's built in functions if TemplateEngine is not set. Funcs template.FuncMap + + // The TemplateEngine to use for parsing templates. + // If one is not set, a TextTemplateEngine is used. + TemplateEngine TemplateEngine +} + +var defaultTextTemplateEngine = &TextTemplateEngine{} + +func (lc *LocalizeConfig) getTemplateEngine() TemplateEngine { + if lc.TemplateEngine != nil { + return lc.TemplateEngine + } + if lc.Funcs != nil { + return &TextTemplateEngine{ + Funcs: lc.Funcs, + } + } + return defaultTextTemplateEngine } type invalidPluralCountErr struct { @@ -152,7 +170,8 @@ func (l *Localizer) LocalizeWithTag(lc *LocalizeConfig) (string, language.Tag, e } pluralForm := l.pluralForm(tag, operands) - msg, err2 := template.Execute(pluralForm, templateData, lc.Funcs) + templateEngine := lc.getTemplateEngine() + msg, err2 := template.executeEngine(pluralForm, templateData, templateEngine) if err2 != nil { if err == nil { err = err2 @@ -160,7 +179,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.executeEngine(plural.Other, templateData, templateEngine) if err3 == nil { msg = msg2 } 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..40d3d59e 100644 --- a/v2/i18n/message_template.go +++ b/v2/i18n/message_template.go @@ -2,22 +2,20 @@ package i18n import ( "fmt" - "text/template" - "github.com/nicksnyder/go-i18n/v2/internal" "github.com/nicksnyder/go-i18n/v2/internal/plural" ) // MessageTemplate is an executable template for a message. type MessageTemplate struct { *Message - PluralTemplates map[plural.Form]*internal.Template + PluralTemplates map[plural.Form]*Template } // NewMessageTemplate returns a new message template. func NewMessageTemplate(m *Message) *MessageTemplate { - pluralTemplates := map[plural.Form]*internal.Template{} + pluralTemplates := map[plural.Form]*Template{} setPluralTemplate(pluralTemplates, plural.Zero, m.Zero, m.LeftDelim, m.RightDelim) setPluralTemplate(pluralTemplates, plural.One, m.One, m.LeftDelim, m.RightDelim) setPluralTemplate(pluralTemplates, plural.Two, m.Two, m.LeftDelim, m.RightDelim) @@ -33,9 +31,9 @@ func NewMessageTemplate(m *Message) *MessageTemplate { } } -func setPluralTemplate(pluralTemplates map[plural.Form]*internal.Template, pluralForm plural.Form, src, leftDelim, rightDelim string) { +func setPluralTemplate(pluralTemplates map[plural.Form]*Template, pluralForm plural.Form, src, leftDelim, rightDelim string) { if src != "" { - pluralTemplates[pluralForm] = &internal.Template{ + pluralTemplates[pluralForm] = &Template{ Src: src, LeftDelim: leftDelim, RightDelim: rightDelim, @@ -61,5 +59,21 @@ func (mt *MessageTemplate) Execute(pluralForm plural.Form, data interface{}, fun messageID: mt.Message.ID, } } - return t.Execute(funcs, data) + engine := &TextTemplateEngine{ + LeftDelim: t.LeftDelim, + RightDelim: t.RightDelim, + Funcs: funcs, + } + return t.execute(engine, data) +} + +func (mt *MessageTemplate) executeEngine(pluralForm plural.Form, data interface{}, engine TemplateEngine) (string, error) { + t := mt.PluralTemplates[pluralForm] + if t == nil { + return "", pluralFormNotFoundError{ + pluralForm: pluralForm, + messageID: mt.Message.ID, + } + } + return t.execute(engine, data) } diff --git a/v2/i18n/template.go b/v2/i18n/template.go new file mode 100644 index 00000000..3c190c1f --- /dev/null +++ b/v2/i18n/template.go @@ -0,0 +1,34 @@ +package i18n + +import ( + "sync" +) + +// Template stores the template for a string. +type Template struct { + Src string + LeftDelim string + RightDelim string + + parseOnce sync.Once + parsedTemplate ParsedTemplate + parseError error +} + +func (t *Template) execute(engine TemplateEngine, data interface{}) (string, error) { + var pt ParsedTemplate + var err error + if engine.Cacheable() { + t.parseOnce.Do(func() { + t.parsedTemplate, t.parseError = engine.ParseTemplate(t.Src, t.LeftDelim, t.RightDelim) + }) + pt, err = t.parsedTemplate, t.parseError + } else { + pt, err = engine.ParseTemplate(t.Src, t.LeftDelim, t.RightDelim) + } + + if err != nil { + return "", err + } + return pt.Execute(data) +} diff --git a/v2/i18n/template_engine.go b/v2/i18n/template_engine.go new file mode 100644 index 00000000..086b3e87 --- /dev/null +++ b/v2/i18n/template_engine.go @@ -0,0 +1,150 @@ +package i18n + +import ( + "bytes" + "strings" + texttemplate "text/template" +) + +type ParsedTemplate interface { + Execute(data any) (string, error) +} + +type TemplateEngine interface { + // Cacheable returns true if the ParsedTemplate returned by ParseTemplate is safe to cache. + Cacheable() bool + + ParseTemplate(src, leftDelim, rightDelim string) (ParsedTemplate, error) +} + +type NoTemplateEngine struct{} + +func (NoTemplateEngine) Cacheable() bool { + // Caching is not necessary because ParseTemplate is cheap. + return false +} + +func (NoTemplateEngine) ParseTemplate(src, leftDelim, rightDelim string) (ParsedTemplate, error) { + return &identityParsedTemplate{src: src}, nil +} + +type TextTemplateEngine struct { + LeftDelim string + RightDelim string + Funcs texttemplate.FuncMap + Option string +} + +func (te *TextTemplateEngine) Cacheable() bool { + return te.Funcs == nil +} + +func (te *TextTemplateEngine) ParseTemplate(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 := texttemplate.New("").Delims(leftDelim, rightDelim).Funcs(te.Funcs).Parse(src) + if err != nil { + return nil, err + } + return &parsedTextTemplate{tmpl: tmpl}, nil +} + +type identityParsedTemplate struct { + src string +} + +func (t *identityParsedTemplate) Execute(data any) (string, error) { + return t.src, nil +} + +type parsedTextTemplate struct { + tmpl *texttemplate.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 +} + +// type TemplateEngine interface { +// Execute(src string, data any) (string, error) +// } + +// type NoTemplateEngine struct{} + +// func (*NoTemplateEngine) Execute(src string, data any) (string, error) { +// return src, nil +// } + +// type TextTemplateEngine2 struct { +// LeftDelim string +// RightDelim string +// Funcs texttemplate.FuncMap +// Option string + +// cache map[string]*executeResult +// cacheMutex sync.RWMutex +// } + +// type executeResult struct { +// tmpl *texttemplate.Template +// err error +// } + +// func (t *TextTemplateEngine2) Execute(src string, data any) (string, error) { +// tmpl, err := t.getTemplate(src) +// if err != nil { +// return "", err +// } +// var buf bytes.Buffer +// if err := tmpl.Execute(&buf, data); err != nil { +// return "", err +// } +// return buf.String(), nil +// } + +// func (t *TextTemplateEngine2) getTemplate(template string) (*texttemplate.Template, error) { +// // It is not safe to use the cache if t.Funcs or t.Option is set. +// if t.Funcs != nil || t.Option != "" { +// return texttemplate.New("").Delims(t.LeftDelim, t.RightDelim).Funcs(t.Funcs).Option(t.Option).Parse(template) +// } + +// // If there is a cached result, return it. +// t.cacheMutex.RLock() +// result := t.cache[template] +// t.cacheMutex.RUnlock() +// if result != nil { +// return result.tmpl, result.err +// } + +// // Parse the template and save it to the cache +// tmpl, err := texttemplate.New("").Delims(t.LeftDelim, t.RightDelim).Parse(template) +// r := &executeResult{ +// tmpl: tmpl, +// err: err, +// } +// t.cacheMutex.Lock() +// t.cache[template] = r +// t.cacheMutex.Unlock() + +// return tmpl, err +// } diff --git a/v2/internal/template_test.go b/v2/i18n/template_test.go similarity index 77% rename from v2/internal/template_test.go rename to v2/i18n/template_test.go index 4f569b9c..366e95d8 100644 --- a/v2/internal/template_test.go +++ b/v2/i18n/template_test.go @@ -1,4 +1,4 @@ -package internal +package i18n import ( "strings" @@ -9,7 +9,7 @@ import ( func TestExecute(t *testing.T) { tests := []struct { template *Template - funcs template.FuncMap + engine TemplateEngine data interface{} result string err string @@ -35,9 +35,11 @@ func TestExecute(t *testing.T) { template: &Template{ Src: "hello {{world}}", }, - funcs: template.FuncMap{ - "world": func() string { - return "world" + engine: &TextTemplateEngine{ + Funcs: template.FuncMap{ + "world": func() string { + return "world" + }, }, }, result: "hello world", @@ -53,7 +55,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.engine == nil { + test.engine = &TextTemplateEngine{} + } + result, err := test.template.execute(test.engine, 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 +66,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.engine, test.data) }) if test.noallocs && allocs > 0 { t.Errorf("expected no allocations; got %f", allocs) diff --git a/v2/internal/template.go b/v2/internal/template.go deleted file mode 100644 index 2fe99235..00000000 --- a/v2/internal/template.go +++ /dev/null @@ -1,51 +0,0 @@ -package internal - -import ( - "bytes" - "strings" - "sync" - gotemplate "text/template" -) - -// Template stores the template for a string. -type Template struct { - Src string - LeftDelim string - RightDelim string - - parseOnce sync.Once - parsedTemplate *gotemplate.Template - 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 - var err error - if funcs == nil { - 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) - }) - gt, err = t.parsedTemplate, t.parseError - } else { - gt, err = gotemplate.New("").Delims(t.LeftDelim, t.RightDelim).Funcs(funcs).Parse(t.Src) - } - - if err != nil { - return "", err - } - var buf bytes.Buffer - if err := gt.Execute(&buf, data); err != nil { - return "", err - } - return buf.String(), nil -}