Skip to content

Commit

Permalink
Support configurable template parsers (#317)
Browse files Browse the repository at this point in the history
  • Loading branch information
nicksnyder authored Jan 31, 2024
1 parent 5257e26 commit 62e3978
Show file tree
Hide file tree
Showing 9 changed files with 218 additions and 43 deletions.
30 changes: 25 additions & 5 deletions v2/i18n/localizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -152,15 +171,16 @@ 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
}

// 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
}
Expand Down
51 changes: 51 additions & 0 deletions v2/i18n/localizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion v2/i18n/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
25 changes: 21 additions & 4 deletions v2/i18n/message_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -53,13 +53,30 @@ 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{
pluralForm: pluralForm,
messageID: mt.Message.ID,
}
}
return t.Execute(funcs, data)
return t.Execute(parser, data)
}
21 changes: 21 additions & 0 deletions v2/i18n/template/identity_parser.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions v2/i18n/template/parser.go
Original file line number Diff line number Diff line change
@@ -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)
}
57 changes: 57 additions & 0 deletions v2/i18n/template/text_parser.go
Original file line number Diff line number Diff line change
@@ -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
}
37 changes: 11 additions & 26 deletions v2/internal/template.go
Original file line number Diff line number Diff line change
@@ -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)
}
21 changes: 14 additions & 7 deletions v2/internal/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -53,15 +57,18 @@ 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)
}
if result != test.result {
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)
Expand Down

0 comments on commit 62e3978

Please sign in to comment.