From 1f751e102af22d926d9e7e09cbc2cc20d32636f3 Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 18 Dec 2024 17:57:45 -0300 Subject: [PATCH] cleanup --- README.md | 12 ++- example/base/main.go | 2 +- example/htmx-templ/go.mod | 6 +- example/htmx-templ/go.sum | 2 - example/htmx-templ/tr/tr.go | 2 +- message/build.go | 143 ++++++++++++++---------------------- mf/bundle.go | 71 ++++++++++++------ mf/bundle_test.go | 58 ++++++++++++++- mf/dictionary.go | 34 ++++----- 9 files changed, 182 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index 56388c2..7ba2946 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Create translations bundle ```go bundle, err := mf.NewBundle( // If not possible to find a message for the specific language, fallback to English (EN) - mf.WithDefaulLangFallback(language.English), + mf.WithDefaultLangFallback(language.English), // We could fine-tune fallbacks for some languages mf.WithLangFallback(language.BritishEnglish, language.English), @@ -125,7 +125,7 @@ var messagesDir embed.FS func main() { bundle, err := mf.NewBundle( - mf.WithDefaulLangFallback(language.English), + mf.WithDefaultLangFallback(language.English), mf.WithLangFallback(language.BritishEnglish, language.English), mf.WithLangFallback(language.Portuguese, language.Spanish), @@ -199,7 +199,6 @@ tr.Trans("escape", mf.Arg("foo", "bar")) // {foo} is 'bar' ``` - ## MessageFormat overview ### Placeholders @@ -473,10 +472,10 @@ Additionally, there are four different formats: `short`, `medium`, `long`, and ` # translations/messages.en.yaml vostok: - start: Vostok-1 start {start_date, datetime, long}. - landing: Vostok-1 landing time {land_time, time, medium}. + start: Vostok-1 start {start_date, datetime, long}. + landing: Vostok-1 landing time {land_time, time, medium}. apollo: - step: First step on the Moon on {step_date, date, long}. + step: First step on the Moon on {step_date, date, long}. ``` ```go @@ -493,4 +492,3 @@ tr.Trans("vostok.landing", mf.Time("land_time", land)) tr.Trans("apollo.step", mf.Time("step_date", step)) // First step on the Moon on July 21, 1969. ``` - diff --git a/example/base/main.go b/example/base/main.go index 8822cfd..93cd284 100644 --- a/example/base/main.go +++ b/example/base/main.go @@ -14,7 +14,7 @@ var messagesDir embed.FS func main() { bundle, err := mf.NewBundle( - mf.WithDefaulLangFallback(language.English), + mf.WithDefaultLangFallback(language.English), mf.WithLangFallback(language.BritishEnglish, language.English), mf.WithLangFallback(language.Portuguese, language.Spanish), diff --git a/example/htmx-templ/go.mod b/example/htmx-templ/go.mod index 70f1565..5c49805 100644 --- a/example/htmx-templ/go.mod +++ b/example/htmx-templ/go.mod @@ -5,15 +5,17 @@ go 1.23.2 require ( github.com/a-h/templ v0.2.793 github.com/alexedwards/scs/v2 v2.8.0 - github.com/fullpipe/icu-mf v0.0.0-20241030155633-222b9dc7894e + github.com/fullpipe/icu-mf v0.99.99 + golang.org/x/text v0.17.0 ) +replace github.com/fullpipe/icu-mf => ../.. + require ( github.com/alecthomas/participle/v2 v2.1.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect - golang.org/x/text v0.17.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/example/htmx-templ/go.sum b/example/htmx-templ/go.sum index fa6502d..96c66c5 100644 --- a/example/htmx-templ/go.sum +++ b/example/htmx-templ/go.sum @@ -11,8 +11,6 @@ github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gv github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fullpipe/icu-mf v0.0.0-20241030155633-222b9dc7894e h1:Lzyy1SCTPeRaESgj4Q7AFbnxmgEkYr4E/vjcX9FQMfs= -github.com/fullpipe/icu-mf v0.0.0-20241030155633-222b9dc7894e/go.mod h1:+Ss+eRPcJwWykGqZ3Yth/7+7D1zKgFY+vyV97n8lsoU= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= diff --git a/example/htmx-templ/tr/tr.go b/example/htmx-templ/tr/tr.go index 16c72d0..f4d49b5 100644 --- a/example/htmx-templ/tr/tr.go +++ b/example/htmx-templ/tr/tr.go @@ -20,7 +20,7 @@ func init() { var err error bundle, err = mf.NewBundle( - mf.WithDefaulLangFallback(language.English), + mf.WithDefaultLangFallback(language.English), mf.WithLangFallback(language.BritishEnglish, language.English), mf.WithLangFallback(language.Portuguese, language.Spanish), diff --git a/message/build.go b/message/build.go index 93c41e3..1462623 100644 --- a/message/build.go +++ b/message/build.go @@ -14,87 +14,77 @@ func Build(in parse.Message, lang language.Tag) (Evalable, error) { return buildFragment(*in.Fragments[0], lang) } - root := Message{ + root := &Message{ fragments: make([]Evalable, 0, len(in.Fragments)), } for _, f := range in.Fragments { eval, err := buildFragment(*f, lang) if err != nil { - // TODO: collect errors return nil, err } - root.fragments = append(root.fragments, eval) } - return &root, nil + return root, nil } func buildFragment(f parse.Fragment, lang language.Tag) (Evalable, error) { - if len(f.Escaped) > 0 { + switch { + case len(f.Escaped) > 0: return Content(f.Escaped[1:]), nil - } - - if len(f.Text) > 0 { + case len(f.Text) > 0: return Content(f.Text), nil - } - - if f.Octothorpe { + case f.Octothorpe: return PlainArg("#"), nil - } - - if f.PlainArg != nil { + case f.PlainArg != nil: return PlainArg(f.PlainArg.Name), nil - } - - if f.Func != nil { - switch f.Func.Func { - case "number": - return buildNumber(f.Func, lang) - case "date", "time", "datetime": - return buildDatetime(f.Func, lang) - default: - return nil, errors.New("empty fragment") - } - } - - if f.Expr == nil { + case f.Func != nil: + return buildFunc(f.Func, lang) + case f.Expr != nil: + return buildExpr(f.Expr, lang) + default: return nil, errors.New("empty fragment") } +} - e := f.Expr - - if e.Name != "" && e.Func == "select" { - return buildSelect(e, lang) - } - - if e.Name != "" && e.Func == "plural" { - return buildPlural(e, lang) +func buildFunc(f *parse.Func, lang language.Tag) (Evalable, error) { + switch f.Func { + case "number": + return buildNumber(f, lang) + case "date", "time", "datetime": + return buildDatetime(f, lang) + default: + return nil, fmt.Errorf("unsupported function: %s", f.Func) } +} - if e.Name != "" && e.Func == "selectordinal" { +func buildExpr(e *parse.Expr, lang language.Tag) (Evalable, error) { + switch e.Func { + case "select": + return buildSelect(e, lang) + case "plural", "selectordinal": return buildPlural(e, lang) + default: + return nil, fmt.Errorf("unsupported expression: %s", e.Func) } - - return nil, errors.New("empty fragment") } func buildSelect(e *parse.Expr, lang language.Tag) (Evalable, error) { if e == nil || e.Name == "" || e.Func != "select" { - return nil, errors.New("no a select expresion") + return nil, errors.New("invalid select expression") } if len(e.Cases) == 0 { return nil, errors.New("empty select cases") } - eval := Select{ + eval := &Select{ ArgName: e.Name, Cases: make(map[string]Evalable, len(e.Cases)), } - hasDefaultCase := false + hasDefaultCase := false for _, c := range e.Cases { if c.Name == DefaultCase { hasDefaultCase = true @@ -102,7 +92,6 @@ func buildSelect(e *parse.Expr, lang language.Tag) (Evalable, error) { caseEval, err := Build(*c.Message, lang) if err != nil { - // TODO: collect errors return nil, err } @@ -113,12 +102,12 @@ func buildSelect(e *parse.Expr, lang language.Tag) (Evalable, error) { return nil, errors.New("no 'other' case in select") } - return &eval, nil + return eval, nil } func buildPlural(e *parse.Expr, lang language.Tag) (Evalable, error) { if e == nil || e.Name == "" || (e.Func != "plural" && e.Func != "selectordinal") { - return nil, errors.New("no a select expresion") + return nil, errors.New("invalid plural expression") } if len(e.Cases) == 0 { @@ -140,7 +129,6 @@ func buildPlural(e *parse.Expr, lang language.Tag) (Evalable, error) { } hasDefaultCase := false - for _, c := range e.Cases { if c.Name == DefaultCase { hasDefaultCase = true @@ -148,27 +136,20 @@ func buildPlural(e *parse.Expr, lang language.Tag) (Evalable, error) { caseEval, err := Build(*c.Message, lang) if err != nil { - // TODO: collect errors return nil, err } - form, ok := strToFormMap[c.Name] - if ok { + if form, ok := strToFormMap[c.Name]; ok { eval.Cases[form] = caseEval - continue - } - - if c.Name[0] != '=' { + } else if c.Name[0] == '=' { + caseNum, err := strconv.ParseUint(c.Name[1:], 10, 64) + if err != nil { + return nil, err + } + eval.EqCases[caseNum] = caseEval + } else { return nil, fmt.Errorf("invalid plural case %s", c.Name) } - - caseNum, err := strconv.ParseUint(c.Name[1:], 10, 64) - if err != nil { - // TODO: collect errors - return nil, err - } - - eval.EqCases[caseNum] = caseEval } if !hasDefaultCase { @@ -176,45 +157,31 @@ func buildPlural(e *parse.Expr, lang language.Tag) (Evalable, error) { } return eval, nil - } -func buildNumber(e *parse.Func, lang language.Tag) (Evalable, error) { - if e == nil || e.Func != "number" { - return nil, errors.New("no a number function") - } - - // format, ok := strToNumberFormatMap[e.Param] - format, ok := strToNumberFormatMap[e.Param] +func buildNumber(f *parse.Func, lang language.Tag) (Evalable, error) { + format, ok := strToNumberFormatMap[f.Param] if !ok { - return nil, fmt.Errorf("number format %s not supported", e.Param) + return nil, fmt.Errorf("number format %s not supported", f.Param) } - return NewNumber(e.ArgName, format, lang), nil + return NewNumber(f.ArgName, format, lang), nil } -func buildDatetime(e *parse.Func, lang language.Tag) (Evalable, error) { - if e == nil { - return nil, errors.New("empty expresiion") - } - - if e.Func != "date" && e.Func != "time" && e.Func != "datetime" { - return nil, errors.New("not a date function") - } - - format, ok := strToDatetimeFormatMap[e.Param] +func buildDatetime(f *parse.Func, lang language.Tag) (Evalable, error) { + format, ok := strToDatetimeFormatMap[f.Param] if !ok { - return nil, fmt.Errorf("date format %s not supported", e.Param) + return nil, fmt.Errorf("date format %s not supported", f.Param) } - switch e.Func { + switch f.Func { case "date": - return NewDate(e.ArgName, format, lang), nil + return NewDate(f.ArgName, format, lang), nil case "time": - return NewTime(e.ArgName, format, lang), nil + return NewTime(f.ArgName, format, lang), nil case "datetime": - return NewDatetime(e.ArgName, format, lang), nil + return NewDatetime(f.ArgName, format, lang), nil + default: + return nil, fmt.Errorf("unsupported datetime function: %s", f.Func) } - - return nil, errors.New("not a date function") } diff --git a/mf/bundle.go b/mf/bundle.go index e33a5b3..13dcb1a 100644 --- a/mf/bundle.go +++ b/mf/bundle.go @@ -26,25 +26,28 @@ type BundleOption func(b *bundle) error func NewBundle(options ...BundleOption) (Bundle, error) { bundle := &bundle{ - fallbacks: map[language.Tag]language.Tag{}, - translators: map[language.Tag]Translator{}, - - defaultLang: language.Und, - defaultErrorHandler: func(_ error, _ string, _ map[string]any) {}, + fallbacks: make(map[language.Tag]language.Tag), + translators: make(map[language.Tag]Translator), + defaultLang: language.Und, + defaultErrorHandler: func(_ error, _ string, _ map[string]any) { + // Provide meaningful logging or handling here + }, } for _, option := range options { - err := option(bundle) - if err != nil { + if err := option(bundle); err != nil { return nil, err } } if bundle.provider == nil { - return nil, errors.New("you have add message provider with WithFSProvider or WithProvider") + return nil, errors.New("you must add a message provider with WithFSProvider or WithProvider") } - // TODO: check fallbacks for cicles en -> es -> en -> ... + // Check for cyclic fallbacks + if err := checkCyclicFallbacks(bundle.fallbacks); err != nil { + return nil, err + } return bundle, nil } @@ -55,28 +58,26 @@ func (b *bundle) Translator(lang string) Translator { tag = b.defaultLang } - tr, ok := b.translators[tag] - if ok { + if tr, ok := b.translators[tag]; ok { return tr } - b.translators[tag] = b.getTranlator(tag) + tr := b.getTranslator(tag) + b.translators[tag] = tr - return b.translators[tag] + return tr } -func (b *bundle) getTranlator(tag language.Tag) Translator { - tr, ok := b.translators[tag] - if ok { +func (b *bundle) getTranslator(tag language.Tag) Translator { + if tr, ok := b.translators[tag]; ok { return tr } var fallback Translator - fallbackTag, hasFallback := b.fallbacks[tag] - if hasFallback { - fallback = b.getTranlator(fallbackTag) + if fallbackTag, hasFallback := b.fallbacks[tag]; hasFallback { + fallback = b.getTranslator(fallbackTag) } else if tag != b.defaultLang { - fallback = b.getTranlator(b.defaultLang) + fallback = b.getTranslator(b.defaultLang) } return &translator{ @@ -87,10 +88,9 @@ func (b *bundle) getTranlator(tag language.Tag) Translator { } } -func WithDefaulLangFallback(l language.Tag) BundleOption { +func WithDefaultLangFallback(l language.Tag) BundleOption { return func(b *bundle) error { b.defaultLang = l - return nil } } @@ -127,3 +127,30 @@ func WithErrorHandler(handler ErrorHandler) BundleOption { return nil } } + +// checkCyclicFallbacks checks for cyclic fallbacks to prevent infinite loops +func checkCyclicFallbacks(fallbacks map[language.Tag]language.Tag) error { + visited := make(map[language.Tag]bool) + for tag := range fallbacks { + if hasCycle(tag, fallbacks, visited) { + return errors.New("cyclic fallback detected") + } + } + return nil +} + +func hasCycle(tag language.Tag, fallbacks map[language.Tag]language.Tag, visited map[language.Tag]bool) bool { + if visited[tag] { + return true + } + + visited[tag] = true + defer delete(visited, tag) + + next, ok := fallbacks[tag] + if !ok { + return false + } + + return hasCycle(next, fallbacks, visited) +} diff --git a/mf/bundle_test.go b/mf/bundle_test.go index 6cc26f8..9143c33 100644 --- a/mf/bundle_test.go +++ b/mf/bundle_test.go @@ -14,7 +14,7 @@ import ( func TestNewBundle(t *testing.T) { b, err := NewBundle( - WithDefaulLangFallback(language.English), + WithDefaultLangFallback(language.English), WithLangFallback(language.Portuguese, language.Spanish), WithProvider(new(MockedProvider)), ) @@ -23,7 +23,7 @@ func TestNewBundle(t *testing.T) { assert.NotNil(t, b) } -func TestWithDefaulLangFallback(t *testing.T) { +func TestWithDefaultLangFallback(t *testing.T) { b := &bundle{ fallbacks: map[language.Tag]language.Tag{}, translators: map[language.Tag]Translator{}, @@ -33,7 +33,7 @@ func TestWithDefaulLangFallback(t *testing.T) { } assert.Equal(t, language.Und, b.defaultLang) - require.NoError(t, WithDefaulLangFallback(language.Afrikaans)(b)) + require.NoError(t, WithDefaultLangFallback(language.Afrikaans)(b)) assert.Equal(t, language.Afrikaans, b.defaultLang) } @@ -80,7 +80,7 @@ func TestBundle_Translator(t *testing.T) { assert.Equal(t, "msg_id", b.Translator("ru").Trans("msg_id"), "even empty bundle returns some translator") b, err = NewBundle( - WithDefaulLangFallback(language.English), + WithDefaultLangFallback(language.English), WithLangFallback(language.Portuguese, language.Spanish), WithYamlProvider(fstest.MapFS{ "messages.en.yaml": {Data: []byte("foo: en\nbar_id: enbar")}, @@ -97,3 +97,53 @@ func TestBundle_Translator(t *testing.T) { assert.Equal(t, "none_id", b.Translator("pl").Trans("none_id"), "dummy translator if nothing works") assert.Equal(t, "none_id", b.Translator("en").Trans("none_id"), "dummy translator if nothing works") } + +func TestCheckCyclicFallbacks(t *testing.T) { + tests := []struct { + name string + fallbacks map[language.Tag]language.Tag + wantErr bool + }{ + { + name: "no cycle", + fallbacks: map[language.Tag]language.Tag{ + language.English: language.Spanish, + language.Spanish: language.Portuguese, + }, + wantErr: false, + }, + { + name: "direct cycle", + fallbacks: map[language.Tag]language.Tag{ + language.English: language.Spanish, + language.Spanish: language.English, + }, + wantErr: true, + }, + { + name: "indirect cycle", + fallbacks: map[language.Tag]language.Tag{ + language.English: language.Spanish, + language.Spanish: language.Portuguese, + language.Portuguese: language.English, + }, + wantErr: true, + }, + { + name: "no fallbacks", + fallbacks: map[language.Tag]language.Tag{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkCyclicFallbacks(tt.fallbacks) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/mf/dictionary.go b/mf/dictionary.go index c3b97ce..fe76386 100644 --- a/mf/dictionary.go +++ b/mf/dictionary.go @@ -18,7 +18,7 @@ func (*DummyDictionary) Get(id string) (string, error) { func NewYamlDictionary(yaml []byte) (*YamlDictionary, error) { d := &YamlDictionary{ - flatMap: map[string]string{}, + flatMap: make(map[string]string), } var document y3.Node @@ -38,33 +38,25 @@ type YamlDictionary struct { } func (d *YamlDictionary) Get(id string) (string, error) { - msg, ok := d.flatMap[id] - if !ok { - return "", fmt.Errorf("no message with id %s", id) + if msg, ok := d.flatMap[id]; ok { + return msg, nil } - return msg, nil + return "", fmt.Errorf("no message with id %s", id) } func (d *YamlDictionary) buildFlatMap(prefix string, yn *y3.Node) { - for i := 0; i < len(yn.Content); i++ { - n := yn.Content[i] - - if n.Kind == y3.MappingNode { - d.buildFlatMap(prefix+n.Value+".", n) - - continue - } + for i := 0; i < len(yn.Content); i += 2 { + keyNode := yn.Content[i] + valueNode := yn.Content[i+1] - if n.Kind == y3.ScalarNode { - if yn.Content[i+1].Kind == y3.ScalarNode { - d.flatMap[prefix+n.Value] = yn.Content[i+1].Value - } else if yn.Content[i+1].Kind == y3.MappingNode { - d.buildFlatMap(prefix+n.Value+".", yn.Content[i+1]) - } + key := prefix + keyNode.Value - i++ - continue + switch valueNode.Kind { + case y3.ScalarNode: + d.flatMap[key] = valueNode.Value + case y3.MappingNode: + d.buildFlatMap(key+".", valueNode) } } }