From 1c44ce2d4dc76f3033da222b1497415c4f7dc19b Mon Sep 17 00:00:00 2001 From: Nick Snyder Date: Tue, 7 May 2019 12:45:26 -0700 Subject: export message (#170) --- v2/goi18n/extract_command.go | 7 +- v2/goi18n/marshal.go | 6 +- v2/goi18n/merge_command.go | 34 +++--- v2/i18n/bundle.go | 16 +-- v2/i18n/bundle_test.go | 9 +- v2/i18n/localizer.go | 7 +- v2/i18n/message.go | 219 +++++++++++++++++++++++++++++++++- v2/i18n/message_template.go | 72 ++++++++++++ v2/i18n/message_template_test.go | 33 ++++++ v2/i18n/message_test.go | 160 +++++++++++++++++++++++++ v2/i18n/parse.go | 166 ++++++++++++++++++++++++++ v2/i18n/parse_test.go | 210 +++++++++++++++++++++++++++++++++ v2/internal/message.go | 221 ----------------------------------- v2/internal/message_template.go | 71 ----------- v2/internal/message_template_test.go | 33 ------ v2/internal/message_test.go | 160 ------------------------- v2/internal/parse.go | 169 --------------------------- v2/internal/parse_test.go | 210 --------------------------------- v2/internal/template.go | 2 +- v2/internal/template_test.go | 6 +- 20 files changed, 898 insertions(+), 913 deletions(-) create mode 100644 v2/i18n/message_template.go create mode 100644 v2/i18n/message_template_test.go create mode 100644 v2/i18n/message_test.go create mode 100644 v2/i18n/parse.go create mode 100644 v2/i18n/parse_test.go delete mode 100644 v2/internal/message.go delete mode 100644 v2/internal/message_template.go delete mode 100644 v2/internal/message_template_test.go delete mode 100644 v2/internal/message_test.go delete mode 100644 v2/internal/parse.go delete mode 100644 v2/internal/parse_test.go diff --git a/v2/goi18n/extract_command.go b/v2/goi18n/extract_command.go index 87debbb..b7bee37 100644 --- a/v2/goi18n/extract_command.go +++ b/v2/goi18n/extract_command.go @@ -12,7 +12,6 @@ import ( "strings" "github.com/nicksnyder/go-i18n/v2/i18n" - "github.com/nicksnyder/go-i18n/v2/internal" ) func usageExtract() { @@ -100,9 +99,9 @@ func (ec *extractCommand) execute() error { return err } } - messageTemplates := map[string]*internal.MessageTemplate{} + messageTemplates := map[string]*i18n.MessageTemplate{} for _, m := range messages { - if mt := internal.NewMessageTemplate(m); mt != nil { + if mt := i18n.NewMessageTemplate(m); mt != nil { messageTemplates[m.ID] = mt } } @@ -229,7 +228,7 @@ func (e *extractor) extractMessage(cl *ast.CompositeLit) { if messageID := data["MessageID"]; messageID != "" { data["ID"] = messageID } - e.messages = append(e.messages, internal.MustNewMessage(data)) + e.messages = append(e.messages, i18n.MustNewMessage(data)) } func extractStringLiteral(expr ast.Expr) (string, bool) { diff --git a/v2/goi18n/marshal.go b/v2/goi18n/marshal.go index 5fd908b..751b698 100644 --- a/v2/goi18n/marshal.go +++ b/v2/goi18n/marshal.go @@ -7,13 +7,13 @@ import ( "path/filepath" "github.com/BurntSushi/toml" - "github.com/nicksnyder/go-i18n/v2/internal" + "github.com/nicksnyder/go-i18n/v2/i18n" "github.com/nicksnyder/go-i18n/v2/internal/plural" "golang.org/x/text/language" yaml "gopkg.in/yaml.v2" ) -func writeFile(outdir, label string, langTag language.Tag, format string, messageTemplates map[string]*internal.MessageTemplate, sourceLanguage bool) (path string, content []byte, err error) { +func writeFile(outdir, label string, langTag language.Tag, format string, messageTemplates map[string]*i18n.MessageTemplate, sourceLanguage bool) (path string, content []byte, err error) { v := marshalValue(messageTemplates, sourceLanguage) content, err = marshal(v, format) if err != nil { @@ -23,7 +23,7 @@ func writeFile(outdir, label string, langTag language.Tag, format string, messag return } -func marshalValue(messageTemplates map[string]*internal.MessageTemplate, sourceLanguage bool) interface{} { +func marshalValue(messageTemplates map[string]*i18n.MessageTemplate, sourceLanguage bool) interface{} { v := make(map[string]interface{}, len(messageTemplates)) for id, template := range messageTemplates { if other := template.PluralTemplates[plural.Other]; sourceLanguage && len(template.PluralTemplates) == 1 && diff --git a/v2/goi18n/merge_command.go b/v2/goi18n/merge_command.go index 7a02598..6e6041f 100644 --- a/v2/goi18n/merge_command.go +++ b/v2/goi18n/merge_command.go @@ -108,21 +108,21 @@ type fileSystemOp struct { } func merge(messageFiles map[string][]byte, sourceLanguageTag language.Tag, outdir, outputFormat string) (*fileSystemOp, error) { - unmerged := make(map[language.Tag][]map[string]*internal.MessageTemplate) - sourceMessageTemplates := make(map[string]*internal.MessageTemplate) - unmarshalFuncs := map[string]internal.UnmarshalFunc{ + unmerged := make(map[language.Tag][]map[string]*i18n.MessageTemplate) + sourceMessageTemplates := make(map[string]*i18n.MessageTemplate) + unmarshalFuncs := map[string]i18n.UnmarshalFunc{ "json": json.Unmarshal, "toml": toml.Unmarshal, "yaml": yaml.Unmarshal, } for path, content := range messageFiles { - mf, err := internal.ParseMessageFileBytes(content, path, unmarshalFuncs) + mf, err := i18n.ParseMessageFileBytes(content, path, unmarshalFuncs) if err != nil { return nil, fmt.Errorf("failed to load message file %s: %s", path, err) } - templates := map[string]*internal.MessageTemplate{} + templates := map[string]*i18n.MessageTemplate{} for _, m := range mf.Messages { - templates[m.ID] = internal.NewMessageTemplate(m) + templates[m.ID] = i18n.NewMessageTemplate(m) } if mf.Tag == sourceLanguageTag { for _, template := range templates { @@ -141,7 +141,7 @@ func merge(messageFiles map[string][]byte, sourceLanguageTag language.Tag, outdi } pluralRules := plural.DefaultRules() - all := make(map[language.Tag]map[string]*internal.MessageTemplate) + all := make(map[language.Tag]map[string]*i18n.MessageTemplate) all[sourceLanguageTag] = sourceMessageTemplates for _, srcTemplate := range sourceMessageTemplates { for dstLangTag, messageTemplates := range unmerged { @@ -155,11 +155,11 @@ func merge(messageFiles map[string][]byte, sourceLanguageTag language.Tag, outdi continue } if all[dstLangTag] == nil { - all[dstLangTag] = make(map[string]*internal.MessageTemplate) + all[dstLangTag] = make(map[string]*i18n.MessageTemplate) } dstMessageTemplate := all[dstLangTag][srcTemplate.ID] if dstMessageTemplate == nil { - dstMessageTemplate = &internal.MessageTemplate{ + dstMessageTemplate = &i18n.MessageTemplate{ Message: &i18n.Message{ ID: srcTemplate.ID, Description: srcTemplate.Description, @@ -193,10 +193,10 @@ func merge(messageFiles map[string][]byte, sourceLanguageTag language.Tag, outdi } } - translate := make(map[language.Tag]map[string]*internal.MessageTemplate) - active := make(map[language.Tag]map[string]*internal.MessageTemplate) + translate := make(map[language.Tag]map[string]*i18n.MessageTemplate) + active := make(map[language.Tag]map[string]*i18n.MessageTemplate) for langTag, messageTemplates := range all { - active[langTag] = make(map[string]*internal.MessageTemplate) + active[langTag] = make(map[string]*i18n.MessageTemplate) if langTag == sourceLanguageTag { active[langTag] = messageTemplates continue @@ -212,7 +212,7 @@ func merge(messageFiles map[string][]byte, sourceLanguageTag language.Tag, outdi activeMessageTemplate, translateMessageTemplate := activeDst(srcMessageTemplate, messageTemplate, pluralRule) if translateMessageTemplate != nil { if translate[langTag] == nil { - translate[langTag] = make(map[string]*internal.MessageTemplate) + translate[langTag] = make(map[string]*i18n.MessageTemplate) } translate[langTag][messageTemplate.ID] = translateMessageTemplate } @@ -246,7 +246,7 @@ func merge(messageFiles map[string][]byte, sourceLanguageTag language.Tag, outdi } // activeDst returns the active part of the dst and whether dst is a complete translation of src. -func activeDst(src, dst *internal.MessageTemplate, pluralRule *plural.Rule) (active *internal.MessageTemplate, translateMessageTemplate *internal.MessageTemplate) { +func activeDst(src, dst *i18n.MessageTemplate, pluralRule *plural.Rule) (active *i18n.MessageTemplate, translateMessageTemplate *i18n.MessageTemplate) { pluralForms := pluralRule.PluralForms if len(src.PluralTemplates) == 1 { pluralForms = map[plural.Form]struct{}{ @@ -257,7 +257,7 @@ func activeDst(src, dst *internal.MessageTemplate, pluralRule *plural.Rule) (act dt := dst.PluralTemplates[pluralForm] if dt == nil || dt.Src == "" { if translateMessageTemplate == nil { - translateMessageTemplate = &internal.MessageTemplate{ + translateMessageTemplate = &i18n.MessageTemplate{ Message: &i18n.Message{ ID: src.ID, Description: src.Description, @@ -270,7 +270,7 @@ func activeDst(src, dst *internal.MessageTemplate, pluralRule *plural.Rule) (act continue } if active == nil { - active = &internal.MessageTemplate{ + active = &i18n.MessageTemplate{ Message: &i18n.Message{ ID: src.ID, Description: src.Description, @@ -284,7 +284,7 @@ func activeDst(src, dst *internal.MessageTemplate, pluralRule *plural.Rule) (act return } -func hash(t *internal.MessageTemplate) string { +func hash(t *i18n.MessageTemplate) string { h := sha1.New() io.WriteString(h, t.Description) io.WriteString(h, t.PluralTemplates[plural.Other].Src) diff --git a/v2/i18n/bundle.go b/v2/i18n/bundle.go index a25eb87..9977cde 100644 --- a/v2/i18n/bundle.go +++ b/v2/i18n/bundle.go @@ -4,14 +4,13 @@ import ( "fmt" "io/ioutil" - "github.com/nicksnyder/go-i18n/v2/internal" "github.com/nicksnyder/go-i18n/v2/internal/plural" "golang.org/x/text/language" ) // UnmarshalFunc unmarshals data into v. -type UnmarshalFunc = internal.UnmarshalFunc +type UnmarshalFunc func(data []byte, v interface{}) error // Bundle stores a set of messages and pluralization rules. // Most applications only need a single bundle @@ -21,7 +20,7 @@ type UnmarshalFunc = internal.UnmarshalFunc type Bundle struct { defaultLanguage language.Tag unmarshalFuncs map[string]UnmarshalFunc - messageTemplates map[language.Tag]map[string]*internal.MessageTemplate + messageTemplates map[language.Tag]map[string]*MessageTemplate pluralRules plural.Rules tags []language.Tag matcher language.Matcher @@ -62,16 +61,13 @@ func (b *Bundle) MustLoadMessageFile(path string) { } } -// MessageFile represents a parsed message file. -type MessageFile = internal.MessageFile - // ParseMessageFileBytes parses the bytes in buf to add translations to the bundle. // // The format of the file is everything after the last ".". // // The language tag of the file is everything after the second to last "." or after the last path separator, but before the format. func (b *Bundle) ParseMessageFileBytes(buf []byte, path string) (*MessageFile, error) { - messageFile, err := internal.ParseMessageFileBytes(buf, path, b.unmarshalFuncs) + messageFile, err := ParseMessageFileBytes(buf, path, b.unmarshalFuncs) if err != nil { return nil, err } @@ -97,14 +93,14 @@ func (b *Bundle) AddMessages(tag language.Tag, messages ...*Message) error { return fmt.Errorf("no plural rule registered for %s", tag) } if b.messageTemplates == nil { - b.messageTemplates = map[language.Tag]map[string]*internal.MessageTemplate{} + b.messageTemplates = map[language.Tag]map[string]*MessageTemplate{} } if b.messageTemplates[tag] == nil { - b.messageTemplates[tag] = map[string]*internal.MessageTemplate{} + b.messageTemplates[tag] = map[string]*MessageTemplate{} b.addTag(tag) } for _, m := range messages { - b.messageTemplates[tag][m.ID] = internal.NewMessageTemplate(m) + b.messageTemplates[tag][m.ID] = NewMessageTemplate(m) } return nil } diff --git a/v2/i18n/bundle_test.go b/v2/i18n/bundle_test.go index e14c846..327cc08 100644 --- a/v2/i18n/bundle_test.go +++ b/v2/i18n/bundle_test.go @@ -5,23 +5,22 @@ import ( "testing" "github.com/BurntSushi/toml" - "github.com/nicksnyder/go-i18n/v2/internal" "golang.org/x/text/language" yaml "gopkg.in/yaml.v2" ) -var simpleMessage = internal.MustNewMessage(map[string]string{ +var simpleMessage = MustNewMessage(map[string]string{ "id": "simple", "other": "simple translation", }) -var detailMessage = internal.MustNewMessage(map[string]string{ +var detailMessage = MustNewMessage(map[string]string{ "id": "detail", "description": "detail description", "other": "detail translation", }) -var everythingMessage = internal.MustNewMessage(map[string]string{ +var everythingMessage = MustNewMessage(map[string]string{ "id": "everything", "description": "everything description", "zero": "zero translation", @@ -216,7 +215,7 @@ func TestV1FlatFormat(t *testing.T) { } func expectMessage(t *testing.T, bundle *Bundle, tag language.Tag, messageID string, message *Message) { - expected := internal.NewMessageTemplate(message) + expected := NewMessageTemplate(message) actual := bundle.messageTemplates[tag][messageID] if !reflect.DeepEqual(actual, expected) { t.Errorf("bundle.MessageTemplates[%q][%q] = %#v; want %#v", tag, messageID, actual, expected) diff --git a/v2/i18n/localizer.go b/v2/i18n/localizer.go index e02f66b..e149f45 100644 --- a/v2/i18n/localizer.go +++ b/v2/i18n/localizer.go @@ -5,7 +5,6 @@ import ( "text/template" - "github.com/nicksnyder/go-i18n/v2/internal" "github.com/nicksnyder/go-i18n/v2/internal/plural" "golang.org/x/text/language" ) @@ -145,7 +144,7 @@ func (l *Localizer) LocalizeWithTag(lc *LocalizeConfig) (string, language.Tag, e return msg, tag, nil } -func (l *Localizer) getTemplate(id string, defaultMessage *Message) (language.Tag, *internal.MessageTemplate) { +func (l *Localizer) getTemplate(id string, defaultMessage *Message) (language.Tag, *MessageTemplate) { // Fast path. // Optimistically assume this message id is defined in each language. fastTag, template := l.matchTemplate(id, defaultMessage, l.bundle.matcher, l.bundle.tags) @@ -175,7 +174,7 @@ func (l *Localizer) getTemplate(id string, defaultMessage *Message) (language.Ta return l.matchTemplate(id, defaultMessage, language.NewMatcher(foundTags), foundTags) } -func (l *Localizer) matchTemplate(id string, defaultMessage *Message, matcher language.Matcher, tags []language.Tag) (language.Tag, *internal.MessageTemplate) { +func (l *Localizer) matchTemplate(id string, defaultMessage *Message, matcher language.Matcher, tags []language.Tag) (language.Tag, *MessageTemplate) { _, i, _ := matcher.Match(l.tags...) tag := tags[i] templates := l.bundle.messageTemplates[tag] @@ -183,7 +182,7 @@ func (l *Localizer) matchTemplate(id string, defaultMessage *Message, matcher la return tag, templates[id] } if tag == l.bundle.defaultLanguage && defaultMessage != nil { - return tag, internal.NewMessageTemplate(defaultMessage) + return tag, NewMessageTemplate(defaultMessage) } return tag, nil } diff --git a/v2/i18n/message.go b/v2/i18n/message.go index 7c881a6..f8f789a 100644 --- a/v2/i18n/message.go +++ b/v2/i18n/message.go @@ -1,6 +1,221 @@ package i18n -import "github.com/nicksnyder/go-i18n/v2/internal" +import ( + "fmt" + "strings" +) // Message is a string that can be localized. -type Message = internal.Message +type Message struct { + // ID uniquely identifies the message. + ID string + + // Hash uniquely identifies the content of the message + // that this message was translated from. + Hash string + + // Description describes the message to give additional + // context to translators that may be relevant for translation. + Description string + + // LeftDelim is the left Go template delimiter. + LeftDelim string + + // RightDelim is the right Go template delimiter.`` + RightDelim string + + // Zero is the content of the message for the CLDR plural form "zero". + Zero string + + // One is the content of the message for the CLDR plural form "one". + One string + + // Two is the content of the message for the CLDR plural form "two". + Two string + + // Few is the content of the message for the CLDR plural form "few". + Few string + + // Many is the content of the message for the CLDR plural form "many". + Many string + + // Other is the content of the message for the CLDR plural form "other". + Other string +} + +// NewMessage parses data and returns a new message. +func NewMessage(data interface{}) (*Message, error) { + m := &Message{} + if err := m.unmarshalInterface(data); err != nil { + return nil, err + } + return m, nil +} + +// MustNewMessage is similar to NewMessage except it panics if an error happens. +func MustNewMessage(data interface{}) *Message { + m, err := NewMessage(data) + if err != nil { + panic(err) + } + return m +} + +// unmarshalInterface unmarshals a message from data. +func (m *Message) unmarshalInterface(v interface{}) error { + strdata, err := stringMap(v) + if err != nil { + return err + } + for k, v := range strdata { + switch strings.ToLower(k) { + case "id": + m.ID = v + case "description": + m.Description = v + case "hash": + m.Hash = v + case "leftdelim": + m.LeftDelim = v + case "rightdelim": + m.RightDelim = v + case "zero": + m.Zero = v + case "one": + m.One = v + case "two": + m.Two = v + case "few": + m.Few = v + case "many": + m.Many = v + case "other": + m.Other = v + } + } + return nil +} + +type keyTypeErr struct { + key interface{} +} + +func (err *keyTypeErr) Error() string { + return fmt.Sprintf("expected key to be a string but got %#v", err.key) +} + +type valueTypeErr struct { + value interface{} +} + +func (err *valueTypeErr) Error() string { + return fmt.Sprintf("unsupported type %#v", err.value) +} + +func stringMap(v interface{}) (map[string]string, error) { + switch value := v.(type) { + case string: + return map[string]string{ + "other": value, + }, nil + case map[string]string: + return value, nil + case map[string]interface{}: + strdata := make(map[string]string, len(value)) + for k, v := range value { + err := stringSubmap(k, v, strdata) + if err != nil { + return nil, err + } + } + return strdata, nil + case map[interface{}]interface{}: + strdata := make(map[string]string, len(value)) + for k, v := range value { + kstr, ok := k.(string) + if !ok { + return nil, &keyTypeErr{key: k} + } + err := stringSubmap(kstr, v, strdata) + if err != nil { + return nil, err + } + } + return strdata, nil + default: + return nil, &valueTypeErr{value: value} + } +} + +func stringSubmap(k string, v interface{}, strdata map[string]string) error { + if k == "translation" { + switch vt := v.(type) { + case string: + strdata["other"] = vt + default: + v1Message, err := stringMap(v) + if err != nil { + return err + } + for kk, vv := range v1Message { + strdata[kk] = vv + } + } + return nil + } + + switch vt := v.(type) { + case string: + strdata[k] = vt + return nil + case nil: + return nil + default: + return fmt.Errorf("expected value for key %q be a string but got %#v", k, v) + } +} + +// isMessage tells whether the given data is a message, or a map containing +// nested messages. +// A map is assumed to be a message if it contains any of the "reserved" keys: +// "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other" +// with a string value. +// e.g., +// - {"message": {"description": "world"}} is a message +// - {"message": {"description": "world", "foo": "bar"}} is a message ("foo" key is ignored) +// - {"notmessage": {"description": {"hello": "world"}}} is not +// - {"notmessage": {"foo": "bar"}} is not +func isMessage(v interface{}) bool { + reservedKeys := []string{"id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"} + switch data := v.(type) { + case string: + return true + case map[string]interface{}: + for _, key := range reservedKeys { + val, ok := data[key] + if !ok { + continue + } + _, ok = val.(string) + if !ok { + continue + } + // v is a message if it contains a "reserved" key holding a string value + return true + } + case map[interface{}]interface{}: + for _, key := range reservedKeys { + val, ok := data[key] + if !ok { + continue + } + _, ok = val.(string) + if !ok { + continue + } + // v is a message if it contains a "reserved" key holding a string value + return true + } + } + return false +} diff --git a/v2/i18n/message_template.go b/v2/i18n/message_template.go new file mode 100644 index 0000000..65a16cb --- /dev/null +++ b/v2/i18n/message_template.go @@ -0,0 +1,72 @@ +package i18n + +import ( + "bytes" + "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 +} + +// NewMessageTemplate returns a new message template. +func NewMessageTemplate(m *Message) *MessageTemplate { + pluralTemplates := map[plural.Form]*internal.Template{} + setPluralTemplate(pluralTemplates, plural.Zero, m.Zero) + setPluralTemplate(pluralTemplates, plural.One, m.One) + setPluralTemplate(pluralTemplates, plural.Two, m.Two) + setPluralTemplate(pluralTemplates, plural.Few, m.Few) + setPluralTemplate(pluralTemplates, plural.Many, m.Many) + setPluralTemplate(pluralTemplates, plural.Other, m.Other) + if len(pluralTemplates) == 0 { + return nil + } + return &MessageTemplate{ + Message: m, + PluralTemplates: pluralTemplates, + } +} + +func setPluralTemplate(pluralTemplates map[plural.Form]*internal.Template, pluralForm plural.Form, src string) { + if src != "" { + pluralTemplates[pluralForm] = &internal.Template{Src: src} + } +} + +type pluralFormNotFoundError struct { + pluralForm plural.Form + messageID string +} + +func (e pluralFormNotFoundError) Error() string { + return fmt.Sprintf("message %q has no plural form %q", e.messageID, e.pluralForm) +} + +// 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) { + t := mt.PluralTemplates[pluralForm] + if t == nil { + return "", pluralFormNotFoundError{ + pluralForm: pluralForm, + messageID: mt.Message.ID, + } + } + if err := t.Parse(mt.LeftDelim, mt.RightDelim, funcs); err != nil { + return "", err + } + if t.Template == nil { + return t.Src, nil + } + var buf bytes.Buffer + if err := t.Template.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} diff --git a/v2/i18n/message_template_test.go b/v2/i18n/message_template_test.go new file mode 100644 index 0000000..d920cd4 --- /dev/null +++ b/v2/i18n/message_template_test.go @@ -0,0 +1,33 @@ +package i18n + +import ( + "reflect" + "testing" + + "github.com/nicksnyder/go-i18n/v2/internal/plural" +) + +func TestMessageTemplate(t *testing.T) { + mt := NewMessageTemplate(&Message{ID: "HelloWorld", Other: "Hello World"}) + if mt.PluralTemplates[plural.Other].Src != "Hello World" { + panic(mt.PluralTemplates) + } +} + +func TestNilMessageTemplate(t *testing.T) { + if mt := NewMessageTemplate(&Message{ID: "HelloWorld"}); mt != nil { + panic(mt) + } +} + +func TestMessageTemplatePluralFormMissing(t *testing.T) { + mt := NewMessageTemplate(&Message{ID: "HelloWorld", Other: "Hello World"}) + s, err := mt.Execute(plural.Few, nil, nil) + if s != "" { + t.Errorf("expected %q; got %q", "", s) + } + expectedErr := pluralFormNotFoundError{pluralForm: plural.Few, messageID: "HelloWorld"} + if !reflect.DeepEqual(err, expectedErr) { + t.Errorf("expected error %#v; got %#v", expectedErr, err) + } +} diff --git a/v2/i18n/message_test.go b/v2/i18n/message_test.go new file mode 100644 index 0000000..b37063a --- /dev/null +++ b/v2/i18n/message_test.go @@ -0,0 +1,160 @@ +package i18n + +import ( + "reflect" + "testing" +) + +func TestNewMessage(t *testing.T) { + tests := []struct { + name string + data interface{} + message *Message + err error + }{ + { + name: "string", + data: "other", + message: &Message{ + Other: "other", + }, + }, + { + name: "nil value", + data: map[interface{}]interface{}{ + "ID": "id", + "Zero": nil, + "Other": "other", + }, + message: &Message{ + ID: "id", + Other: "other", + }, + }, + { + name: "map[string]string", + data: map[string]string{ + "ID": "id", + "Hash": "hash", + "Description": "description", + "LeftDelim": "leftdelim", + "RightDelim": "rightdelim", + "Zero": "zero", + "One": "one", + "Two": "two", + "Few": "few", + "Many": "many", + "Other": "other", + }, + message: &Message{ + ID: "id", + Hash: "hash", + Description: "description", + LeftDelim: "leftdelim", + RightDelim: "rightdelim", + Zero: "zero", + One: "one", + Two: "two", + Few: "few", + Many: "many", + Other: "other", + }, + }, + { + name: "map[string]interface{}", + data: map[string]interface{}{ + "ID": "id", + "Hash": "hash", + "Description": "description", + "LeftDelim": "leftdelim", + "RightDelim": "rightdelim", + "Zero": "zero", + "One": "one", + "Two": "two", + "Few": "few", + "Many": "many", + "Other": "other", + }, + message: &Message{ + ID: "id", + Hash: "hash", + Description: "description", + LeftDelim: "leftdelim", + RightDelim: "rightdelim", + Zero: "zero", + One: "one", + Two: "two", + Few: "few", + Many: "many", + Other: "other", + }, + }, + { + name: "map[interface{}]interface{}", + data: map[interface{}]interface{}{ + "ID": "id", + "Hash": "hash", + "Description": "description", + "LeftDelim": "leftdelim", + "RightDelim": "rightdelim", + "Zero": "zero", + "One": "one", + "Two": "two", + "Few": "few", + "Many": "many", + "Other": "other", + }, + message: &Message{ + ID: "id", + Hash: "hash", + Description: "description", + LeftDelim: "leftdelim", + RightDelim: "rightdelim", + Zero: "zero", + One: "one", + Two: "two", + Few: "few", + Many: "many", + Other: "other", + }, + }, + { + name: "map[int]int", + data: map[interface{}]interface{}{ + 1: 2, + }, + err: &keyTypeErr{key: 1}, + }, + { + name: "int", + data: 1, + err: &valueTypeErr{value: 1}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := NewMessage(test.data) + if !reflect.DeepEqual(err, test.err) { + t.Fatalf("expected %#v; got %#v", test.err, err) + } + if !reflect.DeepEqual(actual, test.message) { + t.Fatalf("\nexpected\n%#v\ngot\n%#v", test.message, actual) + } + }) + } +} + +func TestKeyTypeErr(t *testing.T) { + expected := "expected key to be a string but got 1" + if actual := (&keyTypeErr{key: 1}).Error(); actual != expected { + t.Fatalf("expected %#v; got %#v", expected, actual) + } +} + +func TestValueTypeErr(t *testing.T) { + expected := "unsupported type 1" + if actual := (&valueTypeErr{value: 1}).Error(); actual != expected { + t.Fatalf("expected %#v; got %#v", expected, actual) + } +} diff --git a/v2/i18n/parse.go b/v2/i18n/parse.go new file mode 100644 index 0000000..57dd7fe --- /dev/null +++ b/v2/i18n/parse.go @@ -0,0 +1,166 @@ +package i18n + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "golang.org/x/text/language" +) + +// MessageFile represents a parsed message file. +type MessageFile struct { + Path string + Tag language.Tag + Format string + Messages []*Message +} + +// ParseMessageFileBytes returns the messages parsed from file. +func ParseMessageFileBytes(buf []byte, path string, unmarshalFuncs map[string]UnmarshalFunc) (*MessageFile, error) { + lang, format := parsePath(path) + tag := language.Make(lang) + messageFile := &MessageFile{ + Path: path, + Tag: tag, + Format: format, + } + if len(buf) == 0 { + return messageFile, nil + } + unmarshalFunc := unmarshalFuncs[messageFile.Format] + if unmarshalFunc == nil { + if messageFile.Format == "json" { + unmarshalFunc = json.Unmarshal + } else { + return nil, fmt.Errorf("no unmarshaler registered for %s", messageFile.Format) + } + } + var err error + var raw interface{} + if err = unmarshalFunc(buf, &raw); err != nil { + return nil, err + } + + if messageFile.Messages, err = recGetMessages(raw, isMessage(raw), true); err != nil { + return nil, err + } + + return messageFile, nil +} + +const nestedSeparator = "." + +var errInvalidTranslationFile = errors.New("invalid translation file, expected key-values, got a single value") + +// recGetMessages looks for translation messages inside "raw" parameter, +// scanning nested maps using recursion. +func recGetMessages(raw interface{}, isMapMessage, isInitialCall bool) ([]*Message, error) { + var messages []*Message + var err error + + switch data := raw.(type) { + case string: + if isInitialCall { + return nil, errInvalidTranslationFile + } + m, err := NewMessage(data) + return []*Message{m}, err + + case map[string]interface{}: + if isMapMessage { + m, err := NewMessage(data) + return []*Message{m}, err + } + messages = make([]*Message, 0, len(data)) + for id, data := range data { + // recursively scan map items + messages, err = addChildMessages(id, data, messages) + if err != nil { + return nil, err + } + } + + case map[interface{}]interface{}: + if isMapMessage { + m, err := NewMessage(data) + return []*Message{m}, err + } + messages = make([]*Message, 0, len(data)) + for id, data := range data { + strid, ok := id.(string) + if !ok { + return nil, fmt.Errorf("expected key to be string but got %#v", id) + } + // recursively scan map items + messages, err = addChildMessages(strid, data, messages) + if err != nil { + return nil, err + } + } + + case []interface{}: + // Backward compatibility for v1 file format. + messages = make([]*Message, 0, len(data)) + for _, data := range data { + // recursively scan slice items + childMessages, err := recGetMessages(data, isMessage(data), false) + if err != nil { + return nil, err + } + messages = append(messages, childMessages...) + } + + default: + return nil, fmt.Errorf("unsupported file format %T", raw) + } + + return messages, nil +} + +func addChildMessages(id string, data interface{}, messages []*Message) ([]*Message, error) { + isChildMessage := isMessage(data) + childMessages, err := recGetMessages(data, isChildMessage, false) + if err != nil { + return nil, err + } + for _, m := range childMessages { + if isChildMessage { + if m.ID == "" { + m.ID = id // start with innermost key + } + } else { + m.ID = id + nestedSeparator + m.ID // update ID with each nested key on the way + } + messages = append(messages, m) + } + return messages, nil +} + +func parsePath(path string) (langTag, format string) { + formatStartIdx := -1 + for i := len(path) - 1; i >= 0; i-- { + c := path[i] + if os.IsPathSeparator(c) { + if formatStartIdx != -1 { + langTag = path[i+1 : formatStartIdx] + } + return + } + if path[i] == '.' { + if formatStartIdx != -1 { + langTag = path[i+1 : formatStartIdx] + return + } + if formatStartIdx == -1 { + format = path[i+1:] + formatStartIdx = i + } + } + } + if formatStartIdx != -1 { + langTag = path[:formatStartIdx] + } + return +} diff --git a/v2/i18n/parse_test.go b/v2/i18n/parse_test.go new file mode 100644 index 0000000..3272e52 --- /dev/null +++ b/v2/i18n/parse_test.go @@ -0,0 +1,210 @@ +package i18n + +import ( + "reflect" + "sort" + "testing" + + "golang.org/x/text/language" + yaml "gopkg.in/yaml.v2" +) + +func TestParseMessageFileBytes(t *testing.T) { + testCases := []struct { + name string + file string + path string + unmarshalFuncs map[string]UnmarshalFunc + messageFile *MessageFile + err error + }{ + { + name: "basic test", + file: `{"hello": "world"}`, + path: "en.json", + messageFile: &MessageFile{ + Path: "en.json", + Tag: language.English, + Format: "json", + Messages: []*Message{{ + ID: "hello", + Other: "world", + }}, + }, + }, + { + name: "basic test with dot separator in key", + file: `{"prepended.hello": "world"}`, + path: "en.json", + messageFile: &MessageFile{ + Path: "en.json", + Tag: language.English, + Format: "json", + Messages: []*Message{{ + ID: "prepended.hello", + Other: "world", + }}, + }, + }, + { + name: "invalid test (no key)", + file: `"hello"`, + path: "en.json", + err: errInvalidTranslationFile, + }, + { + name: "nested test", + file: `{"nested": {"hello": "world"}}`, + path: "en.json", + messageFile: &MessageFile{ + Path: "en.json", + Tag: language.English, + Format: "json", + Messages: []*Message{{ + ID: "nested.hello", + Other: "world", + }}, + }, + }, + { + name: "basic test with description", + file: `{"notnested": {"description": "world"}}`, + path: "en.json", + messageFile: &MessageFile{ + Path: "en.json", + Tag: language.English, + Format: "json", + Messages: []*Message{{ + ID: "notnested", + Description: "world", + }}, + }, + }, + { + name: "basic test with id", + file: `{"key": {"id": "forced.id"}}`, + path: "en.json", + messageFile: &MessageFile{ + Path: "en.json", + Tag: language.English, + Format: "json", + Messages: []*Message{{ + ID: "forced.id", + }}, + }, + }, + { + name: "basic test with description and dummy", + file: `{"notnested": {"description": "world", "dummy": "nothing"}}`, + path: "en.json", + messageFile: &MessageFile{ + Path: "en.json", + Tag: language.English, + Format: "json", + Messages: []*Message{{ + ID: "notnested", + Description: "world", + }}, + }, + }, + { + name: "deeply nested test", + file: `{"outer": {"nested": {"inner": "value"}}}`, + path: "en.json", + messageFile: &MessageFile{ + Path: "en.json", + Tag: language.English, + Format: "json", + Messages: []*Message{{ + ID: "outer.nested.inner", + Other: "value", + }}, + }, + }, + { + name: "multiple nested test", + file: `{"nested": {"hello": "world", "bye": "all"}}`, + path: "en.json", + messageFile: &MessageFile{ + Path: "en.json", + Tag: language.English, + Format: "json", + Messages: []*Message{{ + ID: "nested.hello", + Other: "world", + }, { + ID: "nested.bye", + Other: "all", + }}, + }, + }, + { + name: "YAML nested test", + file: ` +outer: + nested: + inner: "value"`, + path: "en.yaml", + unmarshalFuncs: map[string]UnmarshalFunc{"yaml": yaml.Unmarshal}, + messageFile: &MessageFile{ + Path: "en.yaml", + Tag: language.English, + Format: "yaml", + Messages: []*Message{{ + ID: "outer.nested.inner", + Other: "value", + }}, + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + actual, err := ParseMessageFileBytes([]byte(testCase.file), testCase.path, testCase.unmarshalFuncs) + if (err == nil && testCase.err != nil) || + (err != nil && testCase.err == nil) || + (err != nil && testCase.err != nil && err.Error() != testCase.err.Error()) { + t.Fatalf("expected error %#v; got %#v", testCase.err, err) + } + if testCase.messageFile == nil && actual != nil || testCase.messageFile != nil && actual == nil { + t.Fatalf("expected message file %#v; got %#v", testCase.messageFile, actual) + } + if testCase.messageFile != nil { + if actual.Path != testCase.messageFile.Path { + t.Errorf("expected path %q; got %q", testCase.messageFile.Path, actual.Path) + } + if actual.Tag != testCase.messageFile.Tag { + t.Errorf("expected tag %q; got %q", testCase.messageFile.Tag, actual.Tag) + } + if actual.Format != testCase.messageFile.Format { + t.Errorf("expected format %q; got %q", testCase.messageFile.Format, actual.Format) + } + if !equalMessages(actual.Messages, testCase.messageFile.Messages) { + t.Errorf("expected %#v; got %#v", testCase.messageFile.Messages, actual.Messages) + } + } + }) + } +} + +// equalMessages compares two slices of messages, ignoring private fields and order. +// Sorts both input slices, which are therefore modified by this function. +func equalMessages(m1, m2 []*Message) bool { + if len(m1) != len(m2) { + return false + } + + var less = func(m []*Message) func(int, int) bool { + return func(i, j int) bool { + return m[i].ID < m[j].ID + } + } + sort.Slice(m1, less(m1)) + sort.Slice(m2, less(m2)) + + for i, m := range m1 { + if !reflect.DeepEqual(m, m2[i]) { + return false + } + } + return true +} diff --git a/v2/internal/message.go b/v2/internal/message.go deleted file mode 100644 index 9274404..0000000 --- a/v2/internal/message.go +++ /dev/null @@ -1,221 +0,0 @@ -package internal - -import ( - "fmt" - "strings" -) - -// Message is a string that can be localized. -type Message struct { - // ID uniquely identifies the message. - ID string - - // Hash uniquely identifies the content of the message - // that this message was translated from. - Hash string - - // Description describes the message to give additional - // context to translators that may be relevant for translation. - Description string - - // LeftDelim is the left Go template delimiter. - LeftDelim string - - // RightDelim is the right Go template delimiter.`` - RightDelim string - - // Zero is the content of the message for the CLDR plural form "zero". - Zero string - - // One is the content of the message for the CLDR plural form "one". - One string - - // Two is the content of the message for the CLDR plural form "two". - Two string - - // Few is the content of the message for the CLDR plural form "few". - Few string - - // Many is the content of the message for the CLDR plural form "many". - Many string - - // Other is the content of the message for the CLDR plural form "other". - Other string -} - -// NewMessage parses data and returns a new message. -func NewMessage(data interface{}) (*Message, error) { - m := &Message{} - if err := m.unmarshalInterface(data); err != nil { - return nil, err - } - return m, nil -} - -// MustNewMessage is similar to NewMessage except it panics if an error happens. -func MustNewMessage(data interface{}) *Message { - m, err := NewMessage(data) - if err != nil { - panic(err) - } - return m -} - -// unmarshalInterface unmarshals a message from data. -func (m *Message) unmarshalInterface(v interface{}) error { - strdata, err := stringMap(v) - if err != nil { - return err - } - for k, v := range strdata { - switch strings.ToLower(k) { - case "id": - m.ID = v - case "description": - m.Description = v - case "hash": - m.Hash = v - case "leftdelim": - m.LeftDelim = v - case "rightdelim": - m.RightDelim = v - case "zero": - m.Zero = v - case "one": - m.One = v - case "two": - m.Two = v - case "few": - m.Few = v - case "many": - m.Many = v - case "other": - m.Other = v - } - } - return nil -} - -type keyTypeErr struct { - key interface{} -} - -func (err *keyTypeErr) Error() string { - return fmt.Sprintf("expected key to be a string but got %#v", err.key) -} - -type valueTypeErr struct { - value interface{} -} - -func (err *valueTypeErr) Error() string { - return fmt.Sprintf("unsupported type %#v", err.value) -} - -func stringMap(v interface{}) (map[string]string, error) { - switch value := v.(type) { - case string: - return map[string]string{ - "other": value, - }, nil - case map[string]string: - return value, nil - case map[string]interface{}: - strdata := make(map[string]string, len(value)) - for k, v := range value { - err := stringSubmap(k, v, strdata) - if err != nil { - return nil, err - } - } - return strdata, nil - case map[interface{}]interface{}: - strdata := make(map[string]string, len(value)) - for k, v := range value { - kstr, ok := k.(string) - if !ok { - return nil, &keyTypeErr{key: k} - } - err := stringSubmap(kstr, v, strdata) - if err != nil { - return nil, err - } - } - return strdata, nil - default: - return nil, &valueTypeErr{value: value} - } -} - -func stringSubmap(k string, v interface{}, strdata map[string]string) error { - if k == "translation" { - switch vt := v.(type) { - case string: - strdata["other"] = vt - default: - v1Message, err := stringMap(v) - if err != nil { - return err - } - for kk, vv := range v1Message { - strdata[kk] = vv - } - } - return nil - } - - switch vt := v.(type) { - case string: - strdata[k] = vt - return nil - case nil: - return nil - default: - return fmt.Errorf("expected value for key %q be a string but got %#v", k, v) - } -} - -// isMessage tells whether the given data is a message, or a map containing -// nested messages. -// A map is assumed to be a message if it contains any of the "reserved" keys: -// "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other" -// with a string value. -// e.g., -// - {"message": {"description": "world"}} is a message -// - {"message": {"description": "world", "foo": "bar"}} is a message ("foo" key is ignored) -// - {"notmessage": {"description": {"hello": "world"}}} is not -// - {"notmessage": {"foo": "bar"}} is not -func isMessage(v interface{}) bool { - reservedKeys := []string{"id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"} - switch data := v.(type) { - case string: - return true - case map[string]interface{}: - for _, key := range reservedKeys { - val, ok := data[key] - if !ok { - continue - } - _, ok = val.(string) - if !ok { - continue - } - // v is a message if it contains a "reserved" key holding a string value - return true - } - case map[interface{}]interface{}: - for _, key := range reservedKeys { - val, ok := data[key] - if !ok { - continue - } - _, ok = val.(string) - if !ok { - continue - } - // v is a message if it contains a "reserved" key holding a string value - return true - } - } - return false -} diff --git a/v2/internal/message_template.go b/v2/internal/message_template.go deleted file mode 100644 index 134dae7..0000000 --- a/v2/internal/message_template.go +++ /dev/null @@ -1,71 +0,0 @@ -package internal - -import ( - "bytes" - "fmt" - - "text/template" - - "github.com/nicksnyder/go-i18n/v2/internal/plural" -) - -// MessageTemplate is an executable template for a message. -type MessageTemplate struct { - *Message - PluralTemplates map[plural.Form]*Template -} - -// NewMessageTemplate returns a new message template. -func NewMessageTemplate(m *Message) *MessageTemplate { - pluralTemplates := map[plural.Form]*Template{} - setPluralTemplate(pluralTemplates, plural.Zero, m.Zero) - setPluralTemplate(pluralTemplates, plural.One, m.One) - setPluralTemplate(pluralTemplates, plural.Two, m.Two) - setPluralTemplate(pluralTemplates, plural.Few, m.Few) - setPluralTemplate(pluralTemplates, plural.Many, m.Many) - setPluralTemplate(pluralTemplates, plural.Other, m.Other) - if len(pluralTemplates) == 0 { - return nil - } - return &MessageTemplate{ - Message: m, - PluralTemplates: pluralTemplates, - } -} - -func setPluralTemplate(pluralTemplates map[plural.Form]*Template, pluralForm plural.Form, src string) { - if src != "" { - pluralTemplates[pluralForm] = &Template{Src: src} - } -} - -type pluralFormNotFoundError struct { - pluralForm plural.Form - messageID string -} - -func (e pluralFormNotFoundError) Error() string { - return fmt.Sprintf("message %q has no plural form %q", e.messageID, e.pluralForm) -} - -// 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) { - t := mt.PluralTemplates[pluralForm] - if t == nil { - return "", pluralFormNotFoundError{ - pluralForm: pluralForm, - messageID: mt.Message.ID, - } - } - if err := t.parse(mt.LeftDelim, mt.RightDelim, funcs); err != nil { - return "", err - } - if t.Template == nil { - return t.Src, nil - } - var buf bytes.Buffer - if err := t.Template.Execute(&buf, data); err != nil { - return "", err - } - return buf.String(), nil -} diff --git a/v2/internal/message_template_test.go b/v2/internal/message_template_test.go deleted file mode 100644 index a32708c..0000000 --- a/v2/internal/message_template_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package internal - -import ( - "reflect" - "testing" - - "github.com/nicksnyder/go-i18n/v2/internal/plural" -) - -func TestMessageTemplate(t *testing.T) { - mt := NewMessageTemplate(&Message{ID: "HelloWorld", Other: "Hello World"}) - if mt.PluralTemplates[plural.Other].Src != "Hello World" { - panic(mt.PluralTemplates) - } -} - -func TestNilMessageTemplate(t *testing.T) { - if mt := NewMessageTemplate(&Message{ID: "HelloWorld"}); mt != nil { - panic(mt) - } -} - -func TestMessageTemplatePluralFormMissing(t *testing.T) { - mt := NewMessageTemplate(&Message{ID: "HelloWorld", Other: "Hello World"}) - s, err := mt.Execute(plural.Few, nil, nil) - if s != "" { - t.Errorf("expected %q; got %q", "", s) - } - expectedErr := pluralFormNotFoundError{pluralForm: plural.Few, messageID: "HelloWorld"} - if !reflect.DeepEqual(err, expectedErr) { - t.Errorf("expected error %#v; got %#v", expectedErr, err) - } -} diff --git a/v2/internal/message_test.go b/v2/internal/message_test.go deleted file mode 100644 index cab5735..0000000 --- a/v2/internal/message_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package internal - -import ( - "reflect" - "testing" -) - -func TestNewMessage(t *testing.T) { - tests := []struct { - name string - data interface{} - message *Message - err error - }{ - { - name: "string", - data: "other", - message: &Message{ - Other: "other", - }, - }, - { - name: "nil value", - data: map[interface{}]interface{}{ - "ID": "id", - "Zero": nil, - "Other": "other", - }, - message: &Message{ - ID: "id", - Other: "other", - }, - }, - { - name: "map[string]string", - data: map[string]string{ - "ID": "id", - "Hash": "hash", - "Description": "description", - "LeftDelim": "leftdelim", - "RightDelim": "rightdelim", - "Zero": "zero", - "One": "one", - "Two": "two", - "Few": "few", - "Many": "many", - "Other": "other", - }, - message: &Message{ - ID: "id", - Hash: "hash", - Description: "description", - LeftDelim: "leftdelim", - RightDelim: "rightdelim", - Zero: "zero", - One: "one", - Two: "two", - Few: "few", - Many: "many", - Other: "other", - }, - }, - { - name: "map[string]interface{}", - data: map[string]interface{}{ - "ID": "id", - "Hash": "hash", - "Description": "description", - "LeftDelim": "leftdelim", - "RightDelim": "rightdelim", - "Zero": "zero", - "One": "one", - "Two": "two", - "Few": "few", - "Many": "many", - "Other": "other", - }, - message: &Message{ - ID: "id", - Hash: "hash", - Description: "description", - LeftDelim: "leftdelim", - RightDelim: "rightdelim", - Zero: "zero", - One: "one", - Two: "two", - Few: "few", - Many: "many", - Other: "other", - }, - }, - { - name: "map[interface{}]interface{}", - data: map[interface{}]interface{}{ - "ID": "id", - "Hash": "hash", - "Description": "description", - "LeftDelim": "leftdelim", - "RightDelim": "rightdelim", - "Zero": "zero", - "One": "one", - "Two": "two", - "Few": "few", - "Many": "many", - "Other": "other", - }, - message: &Message{ - ID: "id", - Hash: "hash", - Description: "description", - LeftDelim: "leftdelim", - RightDelim: "rightdelim", - Zero: "zero", - One: "one", - Two: "two", - Few: "few", - Many: "many", - Other: "other", - }, - }, - { - name: "map[int]int", - data: map[interface{}]interface{}{ - 1: 2, - }, - err: &keyTypeErr{key: 1}, - }, - { - name: "int", - data: 1, - err: &valueTypeErr{value: 1}, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - actual, err := NewMessage(test.data) - if !reflect.DeepEqual(err, test.err) { - t.Fatalf("expected %#v; got %#v", test.err, err) - } - if !reflect.DeepEqual(actual, test.message) { - t.Fatalf("\nexpected\n%#v\ngot\n%#v", test.message, actual) - } - }) - } -} - -func TestKeyTypeErr(t *testing.T) { - expected := "expected key to be a string but got 1" - if actual := (&keyTypeErr{key: 1}).Error(); actual != expected { - t.Fatalf("expected %#v; got %#v", expected, actual) - } -} - -func TestValueTypeErr(t *testing.T) { - expected := "unsupported type 1" - if actual := (&valueTypeErr{value: 1}).Error(); actual != expected { - t.Fatalf("expected %#v; got %#v", expected, actual) - } -} diff --git a/v2/internal/parse.go b/v2/internal/parse.go deleted file mode 100644 index da37d43..0000000 --- a/v2/internal/parse.go +++ /dev/null @@ -1,169 +0,0 @@ -package internal - -import ( - "encoding/json" - "errors" - "fmt" - "os" - - "golang.org/x/text/language" -) - -// UnmarshalFunc unmarshals data into v. -type UnmarshalFunc func(data []byte, v interface{}) error - -// MessageFile represents a parsed message file. -type MessageFile struct { - Path string - Tag language.Tag - Format string - Messages []*Message -} - -// ParseMessageFileBytes returns the messages parsed from file. -func ParseMessageFileBytes(buf []byte, path string, unmarshalFuncs map[string]UnmarshalFunc) (*MessageFile, error) { - lang, format := parsePath(path) - tag := language.Make(lang) - messageFile := &MessageFile{ - Path: path, - Tag: tag, - Format: format, - } - if len(buf) == 0 { - return messageFile, nil - } - unmarshalFunc := unmarshalFuncs[messageFile.Format] - if unmarshalFunc == nil { - if messageFile.Format == "json" { - unmarshalFunc = json.Unmarshal - } else { - return nil, fmt.Errorf("no unmarshaler registered for %s", messageFile.Format) - } - } - var err error - var raw interface{} - if err = unmarshalFunc(buf, &raw); err != nil { - return nil, err - } - - if messageFile.Messages, err = recGetMessages(raw, isMessage(raw), true); err != nil { - return nil, err - } - - return messageFile, nil -} - -const nestedSeparator = "." - -var errInvalidTranslationFile = errors.New("invalid translation file, expected key-values, got a single value") - -// recGetMessages looks for translation messages inside "raw" parameter, -// scanning nested maps using recursion. -func recGetMessages(raw interface{}, isMapMessage, isInitialCall bool) ([]*Message, error) { - var messages []*Message - var err error - - switch data := raw.(type) { - case string: - if isInitialCall { - return nil, errInvalidTranslationFile - } - m, err := NewMessage(data) - return []*Message{m}, err - - case map[string]interface{}: - if isMapMessage { - m, err := NewMessage(data) - return []*Message{m}, err - } - messages = make([]*Message, 0, len(data)) - for id, data := range data { - // recursively scan map items - messages, err = addChildMessages(id, data, messages) - if err != nil { - return nil, err - } - } - - case map[interface{}]interface{}: - if isMapMessage { - m, err := NewMessage(data) - return []*Message{m}, err - } - messages = make([]*Message, 0, len(data)) - for id, data := range data { - strid, ok := id.(string) - if !ok { - return nil, fmt.Errorf("expected key to be string but got %#v", id) - } - // recursively scan map items - messages, err = addChildMessages(strid, data, messages) - if err != nil { - return nil, err - } - } - - case []interface{}: - // Backward compatibility for v1 file format. - messages = make([]*Message, 0, len(data)) - for _, data := range data { - // recursively scan slice items - childMessages, err := recGetMessages(data, isMessage(data), false) - if err != nil { - return nil, err - } - messages = append(messages, childMessages...) - } - - default: - return nil, fmt.Errorf("unsupported file format %T", raw) - } - - return messages, nil -} - -func addChildMessages(id string, data interface{}, messages []*Message) ([]*Message, error) { - isChildMessage := isMessage(data) - childMessages, err := recGetMessages(data, isChildMessage, false) - if err != nil { - return nil, err - } - for _, m := range childMessages { - if isChildMessage { - if m.ID == "" { - m.ID = id // start with innermost key - } - } else { - m.ID = id + nestedSeparator + m.ID // update ID with each nested key on the way - } - messages = append(messages, m) - } - return messages, nil -} - -func parsePath(path string) (langTag, format string) { - formatStartIdx := -1 - for i := len(path) - 1; i >= 0; i-- { - c := path[i] - if os.IsPathSeparator(c) { - if formatStartIdx != -1 { - langTag = path[i+1 : formatStartIdx] - } - return - } - if path[i] == '.' { - if formatStartIdx != -1 { - langTag = path[i+1 : formatStartIdx] - return - } - if formatStartIdx == -1 { - format = path[i+1:] - formatStartIdx = i - } - } - } - if formatStartIdx != -1 { - langTag = path[:formatStartIdx] - } - return -} diff --git a/v2/internal/parse_test.go b/v2/internal/parse_test.go deleted file mode 100644 index 5581b27..0000000 --- a/v2/internal/parse_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package internal - -import ( - "reflect" - "sort" - "testing" - - "golang.org/x/text/language" - yaml "gopkg.in/yaml.v2" -) - -func TestParseMessageFileBytes(t *testing.T) { - testCases := []struct { - name string - file string - path string - unmarshalFuncs map[string]UnmarshalFunc - messageFile *MessageFile - err error - }{ - { - name: "basic test", - file: `{"hello": "world"}`, - path: "en.json", - messageFile: &MessageFile{ - Path: "en.json", - Tag: language.English, - Format: "json", - Messages: []*Message{{ - ID: "hello", - Other: "world", - }}, - }, - }, - { - name: "basic test with dot separator in key", - file: `{"prepended.hello": "world"}`, - path: "en.json", - messageFile: &MessageFile{ - Path: "en.json", - Tag: language.English, - Format: "json", - Messages: []*Message{{ - ID: "prepended.hello", - Other: "world", - }}, - }, - }, - { - name: "invalid test (no key)", - file: `"hello"`, - path: "en.json", - err: errInvalidTranslationFile, - }, - { - name: "nested test", - file: `{"nested": {"hello": "world"}}`, - path: "en.json", - messageFile: &MessageFile{ - Path: "en.json", - Tag: language.English, - Format: "json", - Messages: []*Message{{ - ID: "nested.hello", - Other: "world", - }}, - }, - }, - { - name: "basic test with description", - file: `{"notnested": {"description": "world"}}`, - path: "en.json", - messageFile: &MessageFile{ - Path: "en.json", - Tag: language.English, - Format: "json", - Messages: []*Message{{ - ID: "notnested", - Description: "world", - }}, - }, - }, - { - name: "basic test with id", - file: `{"key": {"id": "forced.id"}}`, - path: "en.json", - messageFile: &MessageFile{ - Path: "en.json", - Tag: language.English, - Format: "json", - Messages: []*Message{{ - ID: "forced.id", - }}, - }, - }, - { - name: "basic test with description and dummy", - file: `{"notnested": {"description": "world", "dummy": "nothing"}}`, - path: "en.json", - messageFile: &MessageFile{ - Path: "en.json", - Tag: language.English, - Format: "json", - Messages: []*Message{{ - ID: "notnested", - Description: "world", - }}, - }, - }, - { - name: "deeply nested test", - file: `{"outer": {"nested": {"inner": "value"}}}`, - path: "en.json", - messageFile: &MessageFile{ - Path: "en.json", - Tag: language.English, - Format: "json", - Messages: []*Message{{ - ID: "outer.nested.inner", - Other: "value", - }}, - }, - }, - { - name: "multiple nested test", - file: `{"nested": {"hello": "world", "bye": "all"}}`, - path: "en.json", - messageFile: &MessageFile{ - Path: "en.json", - Tag: language.English, - Format: "json", - Messages: []*Message{{ - ID: "nested.hello", - Other: "world", - }, { - ID: "nested.bye", - Other: "all", - }}, - }, - }, - { - name: "YAML nested test", - file: ` -outer: - nested: - inner: "value"`, - path: "en.yaml", - unmarshalFuncs: map[string]UnmarshalFunc{"yaml": yaml.Unmarshal}, - messageFile: &MessageFile{ - Path: "en.yaml", - Tag: language.English, - Format: "yaml", - Messages: []*Message{{ - ID: "outer.nested.inner", - Other: "value", - }}, - }, - }, - } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - actual, err := ParseMessageFileBytes([]byte(testCase.file), testCase.path, testCase.unmarshalFuncs) - if (err == nil && testCase.err != nil) || - (err != nil && testCase.err == nil) || - (err != nil && testCase.err != nil && err.Error() != testCase.err.Error()) { - t.Fatalf("expected error %#v; got %#v", testCase.err, err) - } - if testCase.messageFile == nil && actual != nil || testCase.messageFile != nil && actual == nil { - t.Fatalf("expected message file %#v; got %#v", testCase.messageFile, actual) - } - if testCase.messageFile != nil { - if actual.Path != testCase.messageFile.Path { - t.Errorf("expected path %q; got %q", testCase.messageFile.Path, actual.Path) - } - if actual.Tag != testCase.messageFile.Tag { - t.Errorf("expected tag %q; got %q", testCase.messageFile.Tag, actual.Tag) - } - if actual.Format != testCase.messageFile.Format { - t.Errorf("expected format %q; got %q", testCase.messageFile.Format, actual.Format) - } - if !equalMessages(actual.Messages, testCase.messageFile.Messages) { - t.Errorf("expected %#v; got %#v", testCase.messageFile.Messages, actual.Messages) - } - } - }) - } -} - -// equalMessages compares two slices of messages, ignoring private fields and order. -// Sorts both input slices, which are therefore modified by this function. -func equalMessages(m1, m2 []*Message) bool { - if len(m1) != len(m2) { - return false - } - - var less = func(m []*Message) func(int, int) bool { - return func(i, j int) bool { - return m[i].ID < m[j].ID - } - } - sort.Slice(m1, less(m1)) - sort.Slice(m2, less(m2)) - - for i, m := range m1 { - if !reflect.DeepEqual(m, m2[i]) { - return false - } - } - return true -} diff --git a/v2/internal/template.go b/v2/internal/template.go index 2ef1eea..4079f52 100644 --- a/v2/internal/template.go +++ b/v2/internal/template.go @@ -12,7 +12,7 @@ type Template struct { ParseErr *error } -func (t *Template) parse(leftDelim, rightDelim string, funcs gotemplate.FuncMap) error { +func (t *Template) Parse(leftDelim, rightDelim string, funcs gotemplate.FuncMap) error { if t.ParseErr == nil { if strings.Contains(t.Src, leftDelim) { gt, err := gotemplate.New("").Funcs(funcs).Delims(leftDelim, rightDelim).Parse(t.Src) diff --git a/v2/internal/template_test.go b/v2/internal/template_test.go index 68e60d0..dcc7b27 100644 --- a/v2/internal/template_test.go +++ b/v2/internal/template_test.go @@ -9,7 +9,7 @@ import ( func TestParse(t *testing.T) { tmpl := &Template{Src: "hello"} - if err := tmpl.parse("", "", nil); err != nil { + if err := tmpl.Parse("", "", nil); err != nil { t.Fatal(err) } if tmpl.ParseErr == nil { @@ -23,7 +23,7 @@ func TestParse(t *testing.T) { func TestParseError(t *testing.T) { expectedErr := fmt.Errorf("expected error") tmpl := &Template{ParseErr: &expectedErr} - if err := tmpl.parse("", "", nil); err != expectedErr { + if err := tmpl.Parse("", "", nil); err != expectedErr { t.Fatalf("expected %#v; got %#v", expectedErr, err) } } @@ -35,7 +35,7 @@ func TestParseWithFunc(t *testing.T) { return "bar" }, } - if err := tmpl.parse("", "", funcs); err != nil { + if err := tmpl.Parse("", "", funcs); err != nil { t.Fatal(err) } if tmpl.ParseErr == nil { -- cgit v1.2.3