├── .codecov.yml ├── .github ├── README.uk-UA.md ├── README.zh-Hans.md ├── dependabot.yml └── workflows │ ├── build.yml │ └── goreleaser.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example ├── README.md ├── active.en.toml ├── active.es.toml └── main.go ├── go.mod ├── go.sum ├── goi18n ├── active.en.toml ├── common_test.go ├── extract_command.go ├── extract_command_test.go ├── main.go ├── main_test.go ├── marshal.go ├── marshal_test.go ├── merge_command.go └── merge_command_test.go ├── i18n ├── bundle.go ├── bundle_test.go ├── bundlefs.go ├── doc.go ├── example_test.go ├── language_test.go ├── localizer.go ├── localizer_test.go ├── message.go ├── message_template.go ├── message_template_test.go ├── message_test.go ├── parse.go ├── parse_test.go └── template │ ├── identity_parser.go │ ├── parser.go │ └── text_parser.go └── internal ├── plural ├── codegen │ ├── README.md │ ├── generate.sh │ ├── main.go │ ├── plurals.xml │ └── xml.go ├── doc.go ├── form.go ├── operands.go ├── operands_test.go ├── rule.go ├── rule_gen.go ├── rule_gen_test.go ├── rule_test.go ├── rules.go └── rules_test.go ├── template.go └── template_test.go /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 50..75 3 | status: 4 | patch: 5 | default: 6 | only_pulls: true 7 | project: 8 | default: 9 | informational: true 10 | -------------------------------------------------------------------------------- /.github/README.uk-UA.md: -------------------------------------------------------------------------------- 1 | # go-i18n 2 | ![Build status](https://github.com/nicksnyder/go-i18n/workflows/Build/badge.svg) [![Report card](https://goreportcard.com/badge/github.com/nicksnyder/go-i18n/v2)](https://goreportcard.com/report/github.com/nicksnyder/go-i18n/v2) [![codecov](https://codecov.io/gh/nicksnyder/go-i18n/graph/badge.svg?token=A9aMfR9vxG)](https://codecov.io/gh/nicksnyder/go-i18n) [![Sourcegraph](https://sourcegraph.com/github.com/nicksnyder/go-i18n/-/badge.svg)](https://sourcegraph.com/github.com/nicksnyder/go-i18n?badge) 3 | 4 | go-i18n — це Go [пакет](#package-i18n) та [інструмент](#command-goi18n), які допомагають перекладати Go програми на різні мови. 5 | 6 | - Підтримує [множинні форми](http://cldr.unicode.org/index/cldr-spec/plural-rules) для всіх 200+ мов у [Unicode Common Locale Data Repository (CLDR)](https://www.unicode.org/cldr/charts/28/supplemental/language_plural_rules.html). 7 | - Код і тести [автоматично генеруються](https://github.com/nicksnyder/go-i18n/tree/main/internal/plural/codegen) з даних [CLDR](http://cldr.unicode.org/index/downloads). 8 | - Підтримує рядки з іменованими змінними, використовуючи синтаксис [text/template](http://golang.org/pkg/text/template/). 9 | - Підтримує файли повідомлень у будь-якому форматі (наприклад, JSON, TOML, YAML). 10 | 11 | ## Пакет i18n 12 | 13 | [![Go Reference](https://pkg.go.dev/badge/github.com/nicksnyder/go-i18n/v2/i18n.svg)](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2/i18n) 14 | 15 | Пакет i18n забезпечує підтримку пошуку повідомлень відповідно до набору мовних уподобань. 16 | 17 | ```go 18 | import "github.com/nicksnyder/go-i18n/v2/i18n" 19 | ``` 20 | 21 | Створіть Bundle, який використовуватимете протягом усього терміну служби вашої програми. 22 | 23 | ```go 24 | bundle := i18n.NewBundle(language.English) 25 | ``` 26 | 27 | Завантажуйте переклади у ваш пакет під час ініціалізації. 28 | 29 | ```go 30 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 31 | bundle.LoadMessageFile("es.toml") 32 | ``` 33 | 34 | ```go 35 | // Якщо використовуєте go:embed 36 | //go:embed locale.*.toml 37 | var LocaleFS embed.FS 38 | 39 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 40 | bundle.LoadMessageFileFS(LocaleFS, "locale.es.toml") 41 | ``` 42 | 43 | Створіть Localizer, який використовуватимете для набору мовних уподобань. 44 | 45 | ```go 46 | func(w http.ResponseWriter, r *http.Request) { 47 | lang := r.FormValue("lang") 48 | accept := r.Header.Get("Accept-Language") 49 | localizer := i18n.NewLocalizer(bundle, lang, accept) 50 | } 51 | ``` 52 | 53 | Використовуйте Localizer для пошуку повідомлень. 54 | 55 | ```go 56 | localizer.Localize(&i18n.LocalizeConfig{ 57 | DefaultMessage: &i18n.Message{ 58 | ID: "PersonCats", 59 | One: "{{.Name}} has {{.Count}} cat.", 60 | Other: "{{.Name}} has {{.Count}} cats.", 61 | }, 62 | TemplateData: map[string]interface{}{ 63 | "Name": "Nick", 64 | "Count": 2, 65 | }, 66 | PluralCount: 2, 67 | }) // Nick has 2 cats. 68 | ``` 69 | 70 | ## Команда goi18n 71 | 72 | [![Go Reference](https://pkg.go.dev/badge/github.com/nicksnyder/go-i18n/v2/goi18n.svg)](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2/goi18n) 73 | 74 | Команда goi18n управляє файлами повідомлень, що використовуються пакетом i18n. 75 | 76 | ``` 77 | go install -v github.com/nicksnyder/go-i18n/v2/goi18n@latest 78 | goi18n -help 79 | ``` 80 | 81 | ### Витяг повідомлень 82 | 83 | Використовуйте команду `goi18n extract`, щоб витягнути всі літерали структури i18n.Message із Go-файлів у файл повідомлень для перекладу. 84 | 85 | ```toml 86 | # active.en.toml 87 | [PersonCats] 88 | description = "The number of cats a person has" 89 | one = "{{.Name}} has {{.Count}} cat." 90 | other = "{{.Name}} has {{.Count}} cats." 91 | ``` 92 | 93 | ### Переклад нової мови 94 | 95 | 1. Створіть порожній файл повідомлень для мови, яку ви хочете додати (наприклад, translate.uk.toml). 96 | 2. Виконайте команду `goi18n merge active.en.toml translate.es.toml`, щоб заповнити `translate.es.toml` повідомленнями для перекладу. 97 | 98 | ```toml 99 | # translate.uk.toml 100 | [HelloPerson] 101 | hash = "sha1-5b49bfdad81fedaeefb224b0ffc2acc58b09cff5" 102 | other = "Hello {{.Name}}" 103 | ``` 104 | 105 | 3. Після перекладу файлу `translate.es.toml` перейменуйте його на `active.es.toml`. 106 | 107 | ```toml 108 | # active.uk.toml 109 | [HelloPerson] 110 | hash = "sha1-5b49bfdad81fedaeefb224b0ffc2acc58b09cff5" 111 | other = "Вітаю {{.Name}}" 112 | ``` 113 | 114 | 4. Завантажте файл `active.es.toml` у свій пакет. 115 | 116 | ```go 117 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 118 | bundle.LoadMessageFile("active.es.toml") 119 | ``` 120 | 121 | ### Переклад нових повідомлень 122 | 123 | Якщо ви додали нові повідомлення до своєї програми: 124 | 125 | 1. Виконайте `goi18n extract`, щоб оновити файл `active.en.toml` новими повідомленнями. 126 | 2. Виконайте `goi18n merge active.*.toml`, щоб згенерувати оновлені файли `translate.*.toml`. 127 | 3. Перекладіть усі повідомлення у файлах `translate.*.toml`. 128 | 4. Виконайте `goi18n merge active.*.toml translate.*.toml`, щоб об’єднати перекладені повідомлення з активними файлами повідомлень. 129 | 130 | ## Для отримання додаткової інформації та прикладів: 131 | 132 | - Ознайомтеся з [документацією](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2). 133 | - Подивіться [приклади коду](https://github.com/nicksnyder/go-i18n/blob/main/i18n/example_test.go) та [тести](https://github.com/nicksnyder/go-i18n/blob/main/i18n/localizer_test.go). 134 | - Перегляньте приклад [додатку](https://github.com/nicksnyder/go-i18n/tree/main/example). 135 | 136 | ## Переклади цього документа 137 | 138 | Переклади цього документа, зроблені спільнотою, можна знайти в папці [.github](.github). 139 | 140 | Ці переклади підтримуються спільнотою і не підтримуються автором цього проєкту. 141 | Немає гарантії, що вони є точними або актуальними. 142 | 143 | ## Ліцензія 144 | 145 | go-i18n доступний під ліцензією MIT. Див. файл [LICENSE](LICENSE) для отримання додаткової інформації. 146 | -------------------------------------------------------------------------------- /.github/README.zh-Hans.md: -------------------------------------------------------------------------------- 1 | # go-i18n 2 | ![Build status](https://github.com/nicksnyder/go-i18n/workflows/Build/badge.svg) [![Report card](https://goreportcard.com/badge/github.com/nicksnyder/go-i18n/v2)](https://goreportcard.com/report/github.com/nicksnyder/go-i18n/v2) [![codecov](https://codecov.io/gh/nicksnyder/go-i18n/graph/badge.svg?token=A9aMfR9vxG)](https://codecov.io/gh/nicksnyder/go-i18n) [![Sourcegraph](https://sourcegraph.com/github.com/nicksnyder/go-i18n/-/badge.svg)](https://sourcegraph.com/github.com/nicksnyder/go-i18n?badge) 3 | 4 | go-i18n 是一个帮助您将 Go 程序翻译成多种语言的 Go [包](#package-i18n)和[命令](#command-goi18n)。 5 | 6 | - 支持 [Unicode Common Locale Data Repository (CLDR)](https://www.unicode.org/cldr/charts/28/supplemental/language_plural_rules.html) 7 | 中所有 200 多种语言的[复数字符串](http://cldr.unicode.org/index/cldr-spec/plural-rules)。 8 | - 代码和测试是基于 [CLDR 数据](http://cldr.unicode.org/index/downloads)[自动生成](https://github.com/nicksnyder/go-i18n/tree/main/internal/plural/codegen)的。 9 | - 使用 [text/template](http://golang.org/pkg/text/template/) 语法支持带有命名变量的字符串。 10 | - 支持所有格式的消息文件(例如:JSON、TOML、YAML)。 11 | 12 | 13 | 14 | 15 | [**English**](../README.md) · [**简体中文**](README.zh-Hans.md) 16 | 17 | 18 | 19 | 20 | ## i18n 包 21 | 22 | [![Go Reference](https://pkg.go.dev/badge/github.com/nicksnyder/go-i18n/v2/i18n.svg)](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2/i18n) 23 | 24 | i18n 包支持根据一组语言环境首选项来查找消息。 25 | 26 | ```go 27 | import "github.com/nicksnyder/go-i18n/v2/i18n" 28 | ``` 29 | 30 | 创建一个 Bundle 以在应用程序的整个生命周期中使用。 31 | 32 | ```go 33 | bundle := i18n.NewBundle(language.English) 34 | ``` 35 | 36 | 在初始化时,将翻译加载到你的 Bundle 中。 37 | 38 | ```go 39 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 40 | bundle.LoadMessageFile("es.toml") 41 | ``` 42 | 43 | ```go 44 | // 如果使用 go:embed 45 | //go:embed locale.*.toml 46 | var LocaleFS embed.FS 47 | 48 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 49 | bundle.LoadMessageFileFS(LocaleFS, "locale.es.toml") 50 | ``` 51 | 52 | 创建一个 Localizer 以便用于一组首选语言。 53 | 54 | ```go 55 | func(w http.ResponseWriter, r *http.Request) { 56 | lang := r.FormValue("lang") 57 | accept := r.Header.Get("Accept-Language") 58 | localizer := i18n.NewLocalizer(bundle, lang, accept) 59 | } 60 | ``` 61 | 62 | 使用此 Localizer 查找消息。 63 | 64 | ```go 65 | localizer.Localize(&i18n.LocalizeConfig{ 66 | DefaultMessage: &i18n.Message{ 67 | ID: "PersonCats", 68 | One: "{{.Name}} has {{.Count}} cat.", 69 | Other: "{{.Name}} has {{.Count}} cats.", 70 | }, 71 | TemplateData: map[string]interface{}{ 72 | "Name": "Nick", 73 | "Count": 2, 74 | }, 75 | PluralCount: 2, 76 | }) // Nick 有两只猫 77 | ``` 78 | 79 | ## goi18n 命令 80 | 81 | [![Go Reference](https://pkg.go.dev/badge/github.com/nicksnyder/go-i18n/v2/goi18n.svg)](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2/goi18n) 82 | 83 | goi18n 命令管理 i18n 包所使用的消息文件。 84 | 85 | ``` 86 | go install -v github.com/nicksnyder/go-i18n/v2/goi18n@latest 87 | goi18n -help 88 | ``` 89 | 90 | ### 提取消息 91 | 92 | 使用 `goi18n extract` 将 Go 源文件中的所有 i18n.Message 结构中的文字提取到消息文件中以进行翻译。 93 | 94 | ```toml 95 | # active.en.toml 96 | [PersonCats] 97 | description = "The number of cats a person has" 98 | one = "{{.Name}} has {{.Count}} cat." 99 | other = "{{.Name}} has {{.Count}} cats." 100 | ``` 101 | 102 | ### 翻译一种新语言 103 | 104 | 1. 为你要添加的语言创建一个空的消息文件(例如:`translate.es.toml`)。 105 | 2. 运行 `goi18n merge active.en.toml translate.es.toml` 以将要翻译的消息填充到 `translate.es.toml` 中。 106 | 107 | ```toml 108 | # translate.es.toml 109 | [HelloPerson] 110 | hash = "sha1-5b49bfdad81fedaeefb224b0ffc2acc58b09cff5" 111 | other = "Hello {{.Name}}" 112 | ``` 113 | 114 | 3. 完成 `translate.es.toml` 的翻译之后,将其重命名为 `active.es.toml`。 115 | 116 | ```toml 117 | # active.es.toml 118 | [HelloPerson] 119 | hash = "sha1-5b49bfdad81fedaeefb224b0ffc2acc58b09cff5" 120 | other = "Hola {{.Name}}" 121 | ``` 122 | 123 | 4. 加载 `active.es.toml` 到你的 Bundle 中。 124 | 125 | ```go 126 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 127 | bundle.LoadMessageFile("active.es.toml") 128 | ``` 129 | 130 | ### 翻译新消息 131 | 132 | 如果你在程序中添加了新消息: 133 | 134 | 1. 运行 `goi18n extract` 以将新的消息更新到 `active.en.toml`。 135 | 2. 运行 `goi18n merge active.*.toml` 以生成更新后的 `translate.*.toml` 文件。 136 | 3. 翻译 `translate.*.toml` 文件中的所有消息。 137 | 4. 运行 `goi18n merge active.*.toml translate.*.toml` 将翻译后的消息合并到活跃消息文件 138 | (Active Message Files)中。 139 | 140 | ## 进一步的信息和示例: 141 | 142 | - 阅读[文档](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2)。 143 | - 查看[代码示例](https://github.com/nicksnyder/go-i18n/blob/main/i18n/example_test.go)和 144 | [测试](https://github.com/nicksnyder/go-i18n/blob/main/i18n/localizer_test.go)。 145 | - 查看示例[程序](https://github.com/nicksnyder/go-i18n/tree/main/example)。 146 | 147 | ## 许可证 148 | 149 | go-i18n 使用在 MIT 许可来提供。更多的相关信息,请参 [LICENSE](LICENSE) 文件。 150 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | - package-ecosystem: "gomod" 8 | directory: "/" 9 | schedule: 10 | interval: "monthly" 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | name: Build (go:${{ matrix.go-version.name }}) 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | go-version: 15 | - name: latest 16 | version: 1.24.x 17 | - name: previous 18 | version: 1.23.x 19 | steps: 20 | - name: Install Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go-version.version }} 24 | - name: Git checkout 25 | uses: actions/checkout@v4 26 | - name: Build 27 | uses: goreleaser/goreleaser-action@v6 28 | with: 29 | version: latest 30 | args: release --clean --snapshot 31 | - name: Test 32 | run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... 33 | - name: Upload coverage 34 | uses: codecov/codecov-action@v5 35 | if: matrix.go-version.name == 'latest' 36 | with: 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | fail_ci_if_error: true 39 | - name: Lint 40 | uses: golangci/golangci-lint-action@v7 41 | if: matrix.go-version.name == 'latest' 42 | with: 43 | version: v2.0 -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: '1.24' 20 | - name: Release 21 | uses: goreleaser/goreleaser-action@v6 22 | with: 23 | version: latest 24 | args: release --clean 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.a 2 | _* 3 | output/ 4 | .DS_Store 5 | *.test 6 | *.swp 7 | 8 | example/example 9 | goi18n/goi18n 10 | dist/ 11 | coverage.txt 12 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | exclusions: 4 | rules: 5 | - path: rule_gen\.go 6 | text: "QF1001:" 7 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - binary: goi18n 4 | main: ./goi18n/ 5 | goos: 6 | - windows 7 | - darwin 8 | - linux 9 | goarch: 10 | - amd64 11 | env: 12 | - CGO_ENABLED=0 13 | archives: 14 | - format: binary 15 | name_template: "{{ .Binary }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" 16 | before: 17 | hooks: 18 | - go mod download 19 | checksum: 20 | name_template: "checksums.txt" 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Major version changes are documented in the changelog. 4 | 5 | To see the documentation for minor or patch version, [view the release notes](https://github.com/nicksnyder/go-i18n/releases). 6 | 7 | ## v2 8 | 9 | ### Motivation 10 | 11 | The first commit to this project was January 2012 (go1 had not yet been released) and v1.0.0 was tagged June 2015 (go1.4). 12 | This project has evolved with the Go ecosystem since then in a backwards compatible way, 13 | but there is a growing list of issues and warts that cannot be addressed without breaking compatibility. 14 | 15 | v2 is rewrite of the API from first principals to make it more idiomatic Go, and to resolve a backlog of issues: https://github.com/nicksnyder/go-i18n/milestone/1 16 | 17 | ### Improvements 18 | 19 | * Use `golang.org/x/text/language` to get standardized behavior for language matching (https://github.com/nicksnyder/go-i18n/issues/30, https://github.com/nicksnyder/go-i18n/issues/44, https://github.com/nicksnyder/go-i18n/issues/76) 20 | * Remove global state so that the race detector does not complain when downstream projects run tests that depend on go-i18n in parallel (https://github.com/nicksnyder/go-i18n/issues/82) 21 | * Automatically extract messages from Go source code (https://github.com/nicksnyder/go-i18n/issues/64) 22 | * Provide clearer documentation and examples (https://github.com/nicksnyder/go-i18n/issues/27) 23 | * Reduce complexity of file format for simple translations (https://github.com/nicksnyder/go-i18n/issues/85) 24 | * Support descriptions for messages (https://github.com/nicksnyder/go-i18n/issues/8) 25 | * Support custom template delimiters (https://github.com/nicksnyder/go-i18n/issues/88) 26 | 27 | ### Upgrading from v1 28 | 29 | The i18n package in v2 is completely different than v1. 30 | Refer to the [documentation](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2/i18n) and [README](https://github.com/nicksnyder/go-i18n/blob/master/README.md) for guidance. 31 | 32 | The goi18n command has similarities and differences: 33 | 34 | * `goi18n merge` has a new implementation but accomplishes the same task. 35 | * `goi18n extract` extracts messages from Go source files. 36 | * `goi18n constants` no longer exists. Prefer to extract messages directly from Go source files. 37 | 38 | v2 makes changes to the canonical message file format, but you can use v1 message files with v2. Message files will be converted to the new format the first time they are processed by the new `goi18n merge` command. 39 | 40 | v2 requires Go 1.9 or newer. 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Nick Snyder https://github.com/nicksnyder 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-i18n 2 | ![Build status](https://github.com/nicksnyder/go-i18n/workflows/Build/badge.svg) [![Report card](https://goreportcard.com/badge/github.com/nicksnyder/go-i18n/v2)](https://goreportcard.com/report/github.com/nicksnyder/go-i18n/v2) [![codecov](https://codecov.io/gh/nicksnyder/go-i18n/graph/badge.svg?token=A9aMfR9vxG)](https://codecov.io/gh/nicksnyder/go-i18n) 3 | 4 | go-i18n is a Go [package](#package-i18n) and a [command](#command-goi18n) that helps you translate Go programs into multiple languages. 5 | 6 | - Supports [pluralized strings](http://cldr.unicode.org/index/cldr-spec/plural-rules) for all 200+ languages in the [Unicode Common Locale Data Repository (CLDR)](https://www.unicode.org/cldr/charts/28/supplemental/language_plural_rules.html). 7 | - Code and tests are [automatically generated](https://github.com/nicksnyder/go-i18n/tree/main/internal/plural/codegen) from [CLDR data](http://cldr.unicode.org/index/downloads). 8 | - Supports strings with named variables using [text/template](http://golang.org/pkg/text/template/) syntax. 9 | - Supports message files of any format (e.g. JSON, TOML, YAML). 10 | 11 | ## Package i18n 12 | 13 | [![Go Reference](https://pkg.go.dev/badge/github.com/nicksnyder/go-i18n/v2/i18n.svg)](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2/i18n) 14 | 15 | The i18n package provides support for looking up messages according to a set of locale preferences. 16 | 17 | ```go 18 | import "github.com/nicksnyder/go-i18n/v2/i18n" 19 | ``` 20 | 21 | Create a Bundle to use for the lifetime of your application. 22 | 23 | ```go 24 | bundle := i18n.NewBundle(language.English) 25 | ``` 26 | 27 | Load translations into your bundle during initialization. 28 | 29 | ```go 30 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 31 | bundle.LoadMessageFile("es.toml") 32 | ``` 33 | 34 | ```go 35 | // If use go:embed 36 | //go:embed locale.*.toml 37 | var LocaleFS embed.FS 38 | 39 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 40 | bundle.LoadMessageFileFS(LocaleFS, "locale.es.toml") 41 | ``` 42 | 43 | Create a Localizer to use for a set of language preferences. 44 | 45 | ```go 46 | func(w http.ResponseWriter, r *http.Request) { 47 | lang := r.FormValue("lang") 48 | accept := r.Header.Get("Accept-Language") 49 | localizer := i18n.NewLocalizer(bundle, lang, accept) 50 | } 51 | ``` 52 | 53 | Use the Localizer to lookup messages. 54 | 55 | ```go 56 | localizer.Localize(&i18n.LocalizeConfig{ 57 | DefaultMessage: &i18n.Message{ 58 | ID: "PersonCats", 59 | One: "{{.Name}} has {{.Count}} cat.", 60 | Other: "{{.Name}} has {{.Count}} cats.", 61 | }, 62 | TemplateData: map[string]interface{}{ 63 | "Name": "Nick", 64 | "Count": 2, 65 | }, 66 | PluralCount: 2, 67 | }) // Nick has 2 cats. 68 | ``` 69 | 70 | ## Command goi18n 71 | 72 | [![Go Reference](https://pkg.go.dev/badge/github.com/nicksnyder/go-i18n/v2/goi18n.svg)](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2/goi18n) 73 | 74 | The goi18n command manages message files used by the i18n package. 75 | 76 | ``` 77 | go install -v github.com/nicksnyder/go-i18n/v2/goi18n@latest 78 | goi18n -help 79 | ``` 80 | 81 | ### Extracting messages 82 | 83 | Use `goi18n extract` to extract all i18n.Message struct literals in Go source files to a message file for translation. 84 | 85 | ```toml 86 | # active.en.toml 87 | [PersonCats] 88 | description = "The number of cats a person has" 89 | one = "{{.Name}} has {{.Count}} cat." 90 | other = "{{.Name}} has {{.Count}} cats." 91 | ``` 92 | 93 | ### Translating a new language 94 | 95 | 1. Create an empty message file for the language that you want to add (e.g. `translate.es.toml`). 96 | 2. Run `goi18n merge active.en.toml translate.es.toml` to populate `translate.es.toml` with the messages to be translated. 97 | 98 | ```toml 99 | # translate.es.toml 100 | [HelloPerson] 101 | hash = "sha1-5b49bfdad81fedaeefb224b0ffc2acc58b09cff5" 102 | other = "Hello {{.Name}}" 103 | ``` 104 | 105 | 3. After `translate.es.toml` has been translated, rename it to `active.es.toml`. 106 | 107 | ```toml 108 | # active.es.toml 109 | [HelloPerson] 110 | hash = "sha1-5b49bfdad81fedaeefb224b0ffc2acc58b09cff5" 111 | other = "Hola {{.Name}}" 112 | ``` 113 | 114 | 4. Load `active.es.toml` into your bundle. 115 | 116 | ```go 117 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 118 | bundle.LoadMessageFile("active.es.toml") 119 | ``` 120 | 121 | ### Translating new messages 122 | 123 | If you have added new messages to your program: 124 | 125 | 1. Run `goi18n extract` to update `active.en.toml` with the new messages. 126 | 2. Run `goi18n merge active.*.toml` to generate updated `translate.*.toml` files. 127 | 3. Translate all the messages in the `translate.*.toml` files. 128 | 4. Run `goi18n merge active.*.toml translate.*.toml` to merge the translated messages into the active message files. 129 | 130 | ## For more information and examples: 131 | 132 | - Read the [documentation](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2). 133 | - Look at the [code examples](https://github.com/nicksnyder/go-i18n/blob/main/i18n/example_test.go) and [tests](https://github.com/nicksnyder/go-i18n/blob/main/i18n/localizer_test.go). 134 | - Look at an example [application](https://github.com/nicksnyder/go-i18n/tree/main/example). 135 | 136 | ## Translations of this document 137 | 138 | Community translations of this document may be found in the [.github](.github) folder. 139 | 140 | These translations are maintained by the community, and are not maintained by the author of this project. 141 | They are not guaranteed to be accurate or up-to-date. 142 | 143 | ## License 144 | 145 | go-i18n is available under the MIT license. See the [LICENSE](LICENSE) file for more info. 146 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | This directory contains an example project that uses go-i18n. 4 | 5 | ``` 6 | go run main.go 7 | ``` 8 | 9 | Then open http://localhost:8080 in your web browser. 10 | 11 | You can customize the template data and locale via query parameters like this: 12 | http://localhost:8080/?name=Nick&unreadEmailCount=2 13 | http://localhost:8080/?name=Nick&unreadEmailCount=2&lang=es 14 | -------------------------------------------------------------------------------- /example/active.en.toml: -------------------------------------------------------------------------------- 1 | HelloPerson = "Hello {{.Name}}" 2 | 3 | [MyUnreadEmails] 4 | description = "The number of unread emails I have" 5 | one = "I have {{.PluralCount}} unread email." 6 | other = "I have {{.PluralCount}} unread emails." 7 | 8 | [PersonUnreadEmails] 9 | description = "The number of unread emails a person has" 10 | one = "{{.Name}} has {{.UnreadEmailCount}} unread email." 11 | other = "{{.Name}} has {{.UnreadEmailCount}} unread emails." 12 | -------------------------------------------------------------------------------- /example/active.es.toml: -------------------------------------------------------------------------------- 1 | [HelloPerson] 2 | hash = "sha1-5b49bfdad81fedaeefb224b0ffc2acc58b09cff5" 3 | other = "Hola {{.Name}}" 4 | 5 | [MyUnreadEmails] 6 | description = "The number of unread emails I have" 7 | hash = "sha1-6a65d17f53981a3657db1897630e9cb069053ea8" 8 | one = "Tengo {{.PluralCount}} correo electrónico sin leer" 9 | other = "Tengo {{.PluralCount}} correos electrónicos no leídos" 10 | 11 | [PersonUnreadEmails] 12 | description = "The number of unread emails a person has" 13 | hash = "sha1-3a672fa89c5c8564bb233c907638004983792464" 14 | one = "{{.Name}} tiene {{.UnreadEmailCount}} correo electrónico no leído" 15 | other = "{{.Name}} tiene {{.UnreadEmailCount}} correos electrónicos no leídos" 16 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | // Command example runs a sample webserver that uses go-i18n/v2/i18n. 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "net/http" 9 | "strconv" 10 | 11 | "github.com/BurntSushi/toml" 12 | "github.com/nicksnyder/go-i18n/v2/i18n" 13 | "golang.org/x/text/language" 14 | ) 15 | 16 | var page = template.Must(template.New("").Parse(` 17 | 18 | 19 | 20 | 21 |

{{.Title}}

22 | 23 | {{range .Paragraphs}}

{{.}}

{{end}} 24 | 25 | 26 | 27 | `)) 28 | 29 | func main() { 30 | bundle := i18n.NewBundle(language.English) 31 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 32 | // No need to load active.en.toml since we are providing default translations. 33 | // bundle.MustLoadMessageFile("active.en.toml") 34 | bundle.MustLoadMessageFile("active.es.toml") 35 | 36 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 37 | lang := r.FormValue("lang") 38 | accept := r.Header.Get("Accept-Language") 39 | localizer := i18n.NewLocalizer(bundle, lang, accept) 40 | 41 | name := r.FormValue("name") 42 | if name == "" { 43 | name = "Bob" 44 | } 45 | 46 | unreadEmailCount, _ := strconv.ParseInt(r.FormValue("unreadEmailCount"), 10, 64) 47 | 48 | helloPerson := localizer.MustLocalize(&i18n.LocalizeConfig{ 49 | DefaultMessage: &i18n.Message{ 50 | ID: "HelloPerson", 51 | Other: "Hello {{.Name}}", 52 | }, 53 | TemplateData: map[string]string{ 54 | "Name": name, 55 | }, 56 | }) 57 | 58 | myUnreadEmails := localizer.MustLocalize(&i18n.LocalizeConfig{ 59 | DefaultMessage: &i18n.Message{ 60 | ID: "MyUnreadEmails", 61 | Description: "The number of unread emails I have", 62 | One: "I have {{.PluralCount}} unread email.", 63 | Other: "I have {{.PluralCount}} unread emails.", 64 | }, 65 | PluralCount: unreadEmailCount, 66 | }) 67 | 68 | personUnreadEmails := localizer.MustLocalize(&i18n.LocalizeConfig{ 69 | DefaultMessage: &i18n.Message{ 70 | ID: "PersonUnreadEmails", 71 | Description: "The number of unread emails a person has", 72 | One: "{{.Name}} has {{.UnreadEmailCount}} unread email.", 73 | Other: "{{.Name}} has {{.UnreadEmailCount}} unread emails.", 74 | }, 75 | PluralCount: unreadEmailCount, 76 | TemplateData: map[string]interface{}{ 77 | "Name": name, 78 | "UnreadEmailCount": unreadEmailCount, 79 | }, 80 | }) 81 | 82 | err := page.Execute(w, map[string]interface{}{ 83 | "Title": helloPerson, 84 | "Paragraphs": []string{ 85 | myUnreadEmails, 86 | personUnreadEmails, 87 | }, 88 | }) 89 | if err != nil { 90 | panic(err) 91 | } 92 | }) 93 | 94 | fmt.Println("Listening on http://localhost:8080") 95 | log.Fatal(http.ListenAndServe(":8080", nil)) 96 | } 97 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nicksnyder/go-i18n/v2 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/BurntSushi/toml v1.5.0 9 | golang.org/x/text v0.24.0 10 | gopkg.in/yaml.v3 v3.0.1 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 2 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 4 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 5 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 6 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 7 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 8 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 9 | -------------------------------------------------------------------------------- /goi18n/active.en.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicksnyder/go-i18n/14b4857faab1be6f8b84c7daa9d14cd5ef6063fe/goi18n/active.en.toml -------------------------------------------------------------------------------- /goi18n/common_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func mustTempDir(prefix string) string { 9 | outdir, err := os.MkdirTemp("", prefix) 10 | if err != nil { 11 | panic(err) 12 | } 13 | return outdir 14 | } 15 | 16 | func mustRemoveAll(t *testing.T, path string) { 17 | if err := os.RemoveAll(path); err != nil { 18 | t.Fatal(err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /goi18n/extract_command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "go/ast" 7 | "go/parser" 8 | "go/token" 9 | "os" 10 | "path/filepath" 11 | "reflect" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/nicksnyder/go-i18n/v2/i18n" 16 | ) 17 | 18 | func usageExtract() { 19 | fmt.Fprintf(os.Stderr, `usage: goi18n extract [options] [paths] 20 | 21 | Extract walks the files and directories in paths and extracts all messages to a single file. 22 | If no files or paths are provided, it walks the current working directory. 23 | 24 | xx-yy.active.format 25 | This file contains messages that should be loaded at runtime. 26 | 27 | Flags: 28 | 29 | -sourceLanguage tag 30 | The language tag of the extracted messages (e.g. en, en-US, zh-Hant-CN). 31 | Default: en 32 | 33 | -outdir directory 34 | Write message files to this directory. 35 | Default: . 36 | 37 | -format format 38 | Output message files in this format. 39 | Supported formats: json, toml, yaml 40 | Default: toml 41 | `) 42 | } 43 | 44 | type extractCommand struct { 45 | paths []string 46 | sourceLanguage languageTag 47 | outdir string 48 | format string 49 | } 50 | 51 | func (ec *extractCommand) name() string { 52 | return "extract" 53 | } 54 | 55 | func (ec *extractCommand) parse(args []string) error { 56 | flags := flag.NewFlagSet("extract", flag.ExitOnError) 57 | flags.Usage = usageExtract 58 | 59 | flags.Var(&ec.sourceLanguage, "sourceLanguage", "en") 60 | flags.StringVar(&ec.outdir, "outdir", ".", "") 61 | flags.StringVar(&ec.format, "format", "toml", "") 62 | if err := flags.Parse(args); err != nil { 63 | return err 64 | } 65 | 66 | ec.paths = flags.Args() 67 | return nil 68 | } 69 | 70 | func (ec *extractCommand) execute() error { 71 | if len(ec.paths) == 0 { 72 | ec.paths = []string{"."} 73 | } 74 | messages := []*i18n.Message{} 75 | for _, path := range ec.paths { 76 | if err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { 77 | if err != nil { 78 | return err 79 | } 80 | if info.IsDir() { 81 | return nil 82 | } 83 | if filepath.Ext(path) != ".go" { 84 | return nil 85 | } 86 | 87 | // Don't extract from test files. 88 | if strings.HasSuffix(path, "_test.go") { 89 | return nil 90 | } 91 | 92 | buf, err := os.ReadFile(path) 93 | if err != nil { 94 | return err 95 | } 96 | msgs, err := extractMessages(buf) 97 | if err != nil { 98 | return err 99 | } 100 | messages = append(messages, msgs...) 101 | return nil 102 | }); err != nil { 103 | return err 104 | } 105 | } 106 | messageTemplates := map[string]*i18n.MessageTemplate{} 107 | for _, m := range messages { 108 | if mt := i18n.NewMessageTemplate(m); mt != nil { 109 | if duplicateMessage, ok := messageTemplates[m.ID]; ok && !reflect.DeepEqual(mt, duplicateMessage) { 110 | return &duplicateMessageIDErr{messageID: m.ID} 111 | } 112 | messageTemplates[m.ID] = mt 113 | } 114 | } 115 | path, content, err := writeFile(ec.outdir, "active", ec.sourceLanguage.Tag(), ec.format, messageTemplates, true) 116 | if err != nil { 117 | return err 118 | } 119 | return os.WriteFile(path, content, 0666) 120 | } 121 | 122 | type duplicateMessageIDErr struct { 123 | messageID string 124 | } 125 | 126 | func (e *duplicateMessageIDErr) Error() string { 127 | return fmt.Sprintf("duplicate message ID: %s", e.messageID) 128 | } 129 | 130 | // extractMessages extracts messages from the bytes of a Go source file. 131 | func extractMessages(buf []byte) ([]*i18n.Message, error) { 132 | fset := token.NewFileSet() 133 | file, err := parser.ParseFile(fset, "", buf, parser.AllErrors) 134 | if err != nil { 135 | return nil, err 136 | } 137 | extractor := newExtractor(file) 138 | ast.Walk(extractor, file) 139 | return extractor.messages, nil 140 | } 141 | 142 | func newExtractor(file *ast.File) *extractor { 143 | return &extractor{i18nPackageName: i18nPackageName(file)} 144 | } 145 | 146 | type extractor struct { 147 | i18nPackageName string 148 | messages []*i18n.Message 149 | } 150 | 151 | func (e *extractor) Visit(node ast.Node) ast.Visitor { 152 | e.extractMessages(node) 153 | return e 154 | } 155 | 156 | func (e *extractor) extractMessages(node ast.Node) { 157 | cl, ok := node.(*ast.CompositeLit) 158 | if !ok { 159 | return 160 | } 161 | switch t := cl.Type.(type) { 162 | case *ast.SelectorExpr: 163 | if !e.isMessageType(t) { 164 | return 165 | } 166 | e.extractMessage(cl) 167 | case *ast.ArrayType: 168 | if !e.isMessageType(t.Elt) { 169 | return 170 | } 171 | for _, el := range cl.Elts { 172 | ecl, ok := el.(*ast.CompositeLit) 173 | if !ok { 174 | continue 175 | } 176 | e.extractMessage(ecl) 177 | } 178 | case *ast.MapType: 179 | if !e.isMessageType(t.Value) { 180 | return 181 | } 182 | for _, el := range cl.Elts { 183 | kve, ok := el.(*ast.KeyValueExpr) 184 | if !ok { 185 | continue 186 | } 187 | vcl, ok := kve.Value.(*ast.CompositeLit) 188 | if !ok { 189 | continue 190 | } 191 | e.extractMessage(vcl) 192 | } 193 | } 194 | } 195 | 196 | func (e *extractor) isMessageType(expr ast.Expr) bool { 197 | se := unwrapSelectorExpr(expr) 198 | if se == nil { 199 | return false 200 | } 201 | if se.Sel.Name != "Message" && se.Sel.Name != "LocalizeConfig" { 202 | return false 203 | } 204 | x, ok := se.X.(*ast.Ident) 205 | if !ok { 206 | return false 207 | } 208 | return x.Name == e.i18nPackageName 209 | } 210 | 211 | func unwrapSelectorExpr(e ast.Expr) *ast.SelectorExpr { 212 | switch et := e.(type) { 213 | case *ast.SelectorExpr: 214 | return et 215 | case *ast.StarExpr: 216 | se, _ := et.X.(*ast.SelectorExpr) 217 | return se 218 | default: 219 | return nil 220 | } 221 | } 222 | 223 | func (e *extractor) extractMessage(cl *ast.CompositeLit) { 224 | data := make(map[string]string) 225 | for _, elt := range cl.Elts { 226 | kve, ok := elt.(*ast.KeyValueExpr) 227 | if !ok { 228 | continue 229 | } 230 | key, ok := kve.Key.(*ast.Ident) 231 | if !ok { 232 | continue 233 | } 234 | v, ok := extractStringLiteral(kve.Value) 235 | if !ok { 236 | continue 237 | } 238 | data[key.Name] = v 239 | } 240 | if len(data) == 0 { 241 | return 242 | } 243 | if messageID := data["MessageID"]; messageID != "" { 244 | data["ID"] = messageID 245 | } 246 | e.messages = append(e.messages, i18n.MustNewMessage(data)) 247 | } 248 | 249 | func extractStringLiteral(expr ast.Expr) (string, bool) { 250 | switch v := expr.(type) { 251 | case *ast.BasicLit: 252 | if v.Kind != token.STRING { 253 | return "", false 254 | } 255 | s, err := strconv.Unquote(v.Value) 256 | if err != nil { 257 | return "", false 258 | } 259 | return s, true 260 | case *ast.BinaryExpr: 261 | if v.Op != token.ADD { 262 | return "", false 263 | } 264 | x, ok := extractStringLiteral(v.X) 265 | if !ok { 266 | return "", false 267 | } 268 | y, ok := extractStringLiteral(v.Y) 269 | if !ok { 270 | return "", false 271 | } 272 | return x + y, true 273 | case *ast.Ident: 274 | if v.Obj == nil { 275 | return "", false 276 | } 277 | switch z := v.Obj.Decl.(type) { 278 | case *ast.ValueSpec: 279 | if len(z.Values) == 0 { 280 | return "", false 281 | } 282 | s, ok := extractStringLiteral(z.Values[0]) 283 | if !ok { 284 | return "", false 285 | } 286 | return s, true 287 | } 288 | case *ast.CallExpr: 289 | if fun, ok := v.Fun.(*ast.Ident); ok && fun.Name == "string" { 290 | return extractStringLiteral(v.Args[0]) 291 | } 292 | } 293 | return "", false 294 | } 295 | 296 | func i18nPackageName(file *ast.File) string { 297 | for _, i := range file.Imports { 298 | if i.Path.Kind == token.STRING && i.Path.Value == `"github.com/nicksnyder/go-i18n/v2/i18n"` { 299 | if i.Name == nil { 300 | return "i18n" 301 | } 302 | return i.Name.Name 303 | } 304 | } 305 | return "" 306 | } 307 | -------------------------------------------------------------------------------- /goi18n/extract_command_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestExtract(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | fileName string 14 | file string 15 | activeFile []byte 16 | expectedExitCode int 17 | expectedErr error 18 | }{ 19 | { 20 | name: "no translations", 21 | fileName: "file.go", 22 | file: `package main`, 23 | }, 24 | { 25 | name: "global declaration", 26 | fileName: "file.go", 27 | file: `package main 28 | 29 | import "github.com/nicksnyder/go-i18n/v2/i18n" 30 | 31 | var m = &i18n.Message{ 32 | ID: "Plural ID", 33 | } 34 | `, 35 | }, 36 | { 37 | name: "duplicate ID with same message is not an error", 38 | fileName: "file.go", 39 | file: `package main 40 | 41 | import "github.com/nicksnyder/go-i18n/v2/i18n" 42 | 43 | var m1 = &i18n.Message{ 44 | ID: "m", 45 | Other: "m", 46 | } 47 | var m2 = &i18n.Message{ 48 | ID: "m", 49 | Other: "m", 50 | } 51 | `, 52 | activeFile: []byte(`m = "m" 53 | `), 54 | }, 55 | { 56 | name: "duplicate ID with different message is an error", 57 | fileName: "file.go", 58 | file: `package main 59 | 60 | import "github.com/nicksnyder/go-i18n/v2/i18n" 61 | 62 | var m1 = &i18n.Message{ 63 | ID: "m", 64 | Other: "m1", 65 | } 66 | var m2 = &i18n.Message{ 67 | ID: "m", 68 | Other: "m2", 69 | } 70 | `, 71 | expectedExitCode: 1, 72 | }, 73 | { 74 | name: "escape newline", 75 | fileName: "file.go", 76 | file: `package main 77 | 78 | import "github.com/nicksnyder/go-i18n/v2/i18n" 79 | 80 | var hasnewline = &i18n.Message{ 81 | ID: "hasnewline", 82 | Other: "\nfoo\nbar\\", 83 | } 84 | `, 85 | activeFile: []byte(`hasnewline = "\nfoo\nbar\\" 86 | `), 87 | }, 88 | { 89 | name: "escape", 90 | fileName: "file.go", 91 | file: `package main 92 | 93 | import "github.com/nicksnyder/go-i18n/v2/i18n" 94 | 95 | var a = &i18n.Message{ 96 | ID: "a", 97 | Other: "a \" b", 98 | } 99 | var b = &i18n.Message{ 100 | ID: "b", 101 | Other: ` + "`" + `a " b` + "`" + `, 102 | } 103 | `, 104 | activeFile: []byte(`a = "a \" b" 105 | b = "a \" b" 106 | `), 107 | }, 108 | { 109 | name: "array", 110 | fileName: "file.go", 111 | file: `package main 112 | 113 | import "github.com/nicksnyder/go-i18n/v2/i18n" 114 | 115 | var a = []*i18n.Message{ 116 | { 117 | ID: "a", 118 | Other: "a", 119 | }, 120 | { 121 | ID: "b", 122 | Other: "b", 123 | }, 124 | } 125 | `, 126 | activeFile: []byte(`a = "a" 127 | b = "b" 128 | `), 129 | }, 130 | { 131 | name: "map", 132 | fileName: "file.go", 133 | file: `package main 134 | 135 | import "github.com/nicksnyder/go-i18n/v2/i18n" 136 | 137 | var a = map[string]*i18n.Message{ 138 | "a": { 139 | ID: "a", 140 | Other: "a", 141 | }, 142 | "b": { 143 | ID: "b", 144 | Other: "b", 145 | }, 146 | } 147 | `, 148 | activeFile: []byte(`a = "a" 149 | b = "b" 150 | `), 151 | }, 152 | { 153 | name: "no extract from test", 154 | fileName: "file_test.go", 155 | file: `package main 156 | 157 | import "github.com/nicksnyder/go-i18n/v2/i18n" 158 | 159 | func main() { 160 | bundle := i18n.NewBundle(language.English) 161 | l := i18n.NewLocalizer(bundle, "en") 162 | l.Localize(&i18n.LocalizeConfig{MessageID: "Plural ID"}) 163 | } 164 | `, 165 | }, 166 | { 167 | name: "must short form id only", 168 | fileName: "file.go", 169 | file: `package main 170 | 171 | import "github.com/nicksnyder/go-i18n/v2/i18n" 172 | 173 | func main() { 174 | bundle := i18n.NewBundle(language.English) 175 | l := i18n.NewLocalizer(bundle, "en") 176 | l.MustLocalize(&i18n.LocalizeConfig{MessageID: "Plural ID"}) 177 | } 178 | `, 179 | }, 180 | { 181 | name: "custom package name", 182 | fileName: "file.go", 183 | file: `package main 184 | 185 | import bar "github.com/nicksnyder/go-i18n/v2/i18n" 186 | 187 | func main() { 188 | _ := &bar.Message{ 189 | ID: "Plural ID", 190 | } 191 | } 192 | `, 193 | }, 194 | { 195 | name: "exhaustive plural translation", 196 | fileName: "file.go", 197 | file: `package main 198 | 199 | import "github.com/nicksnyder/go-i18n/v2/i18n" 200 | 201 | func main() { 202 | _ := &i18n.Message{ 203 | ID: "Plural ID", 204 | Description: "Plural description", 205 | Zero: "Zero translation", 206 | One: "One translation", 207 | Two: "Two translation", 208 | Few: "Few translation", 209 | Many: "Many translation", 210 | Other: "Other translation", 211 | } 212 | } 213 | `, 214 | activeFile: []byte(`["Plural ID"] 215 | description = "Plural description" 216 | few = "Few translation" 217 | many = "Many translation" 218 | one = "One translation" 219 | other = "Other translation" 220 | two = "Two translation" 221 | zero = "Zero translation" 222 | `), 223 | }, 224 | { 225 | name: "concat id", 226 | fileName: "file.go", 227 | file: `package main 228 | 229 | import "github.com/nicksnyder/go-i18n/v2/i18n" 230 | 231 | func main() { 232 | _ := &i18n.Message{ 233 | ID: "Plural" + 234 | " " + 235 | "ID", 236 | } 237 | } 238 | `, 239 | }, 240 | { 241 | name: "global declaration", 242 | fileName: "file.go", 243 | file: `package main 244 | 245 | import "github.com/nicksnyder/go-i18n/v2/i18n" 246 | 247 | const constID = "ConstantID" 248 | 249 | var m = &i18n.Message{ 250 | ID: constID, 251 | Other: "ID is a constant", 252 | } 253 | `, 254 | activeFile: []byte(`ConstantID = "ID is a constant" 255 | `), 256 | }, 257 | { 258 | name: "undefined identifier in composite lit", 259 | fileName: "file.go", 260 | file: `package main 261 | 262 | import "github.com/nicksnyder/go-i18n/v2/i18n" 263 | 264 | var m = &i18n.LocalizeConfig{ 265 | Funcs: Funcs, 266 | } 267 | `, 268 | }, 269 | { 270 | name: "casted const", 271 | fileName: "file.go", 272 | file: `package main 273 | 274 | import "github.com/nicksnyder/go-i18n/v2/i18n" 275 | 276 | type ConstType string 277 | 278 | const Const ConstType = "my const" 279 | 280 | var m = &i18n.LocalizeConfig{ 281 | ID: "id", 282 | Other: string(Const), 283 | } 284 | `, 285 | activeFile: []byte(`id = "my const" 286 | `), 287 | }, 288 | } 289 | 290 | for _, test := range tests { 291 | t.Run(test.name, func(t *testing.T) { 292 | indir := mustTempDir("TestExtractCommandIn") 293 | defer mustRemoveAll(t, indir) 294 | outdir := mustTempDir("TestExtractCommandOut") 295 | defer mustRemoveAll(t, outdir) 296 | 297 | inpath := filepath.Join(indir, test.fileName) 298 | if err := os.WriteFile(inpath, []byte(test.file), 0666); err != nil { 299 | t.Fatal(err) 300 | } 301 | 302 | code := testableMain([]string{"extract", "-outdir", outdir, indir}) 303 | if code != test.expectedExitCode { 304 | t.Fatalf("expected exit code %d; got %d\n", test.expectedExitCode, code) 305 | } 306 | 307 | files, err := os.ReadDir(outdir) 308 | if err != nil { 309 | t.Fatal(err) 310 | } 311 | 312 | if code != 0 { 313 | if len(files) != 0 { 314 | t.Fatalf("expected 0 files; got %#v", files) 315 | } 316 | return 317 | } 318 | 319 | if len(files) != 1 { 320 | t.Fatalf("expected 1 file; got %#v", files) 321 | } 322 | actualFile := files[0] 323 | expectedName := "active.en.toml" 324 | if actualFile.Name() != expectedName { 325 | t.Fatalf("expected %s; got %s", expectedName, actualFile.Name()) 326 | } 327 | 328 | outpath := filepath.Join(outdir, actualFile.Name()) 329 | actual, err := os.ReadFile(outpath) 330 | if err != nil { 331 | t.Fatal(err) 332 | } 333 | if !bytes.Equal(actual, test.activeFile) { 334 | t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", test.activeFile, actual) 335 | } 336 | }) 337 | } 338 | } 339 | 340 | func TestExtractCommand(t *testing.T) { 341 | outdir, err := os.MkdirTemp("", "TestExtractCommand") 342 | if err != nil { 343 | t.Fatal(err) 344 | } 345 | defer mustRemoveAll(t, outdir) 346 | if code := testableMain([]string{"extract", "-outdir", outdir, "../example/"}); code != 0 { 347 | t.Fatalf("expected exit code 0; got %d", code) 348 | } 349 | actual, err := os.ReadFile(filepath.Join(outdir, "active.en.toml")) 350 | if err != nil { 351 | t.Fatal(err) 352 | } 353 | expected := []byte(`HelloPerson = "Hello {{.Name}}" 354 | 355 | [MyUnreadEmails] 356 | description = "The number of unread emails I have" 357 | one = "I have {{.PluralCount}} unread email." 358 | other = "I have {{.PluralCount}} unread emails." 359 | 360 | [PersonUnreadEmails] 361 | description = "The number of unread emails a person has" 362 | one = "{{.Name}} has {{.UnreadEmailCount}} unread email." 363 | other = "{{.Name}} has {{.UnreadEmailCount}} unread emails." 364 | `) 365 | if !bytes.Equal(actual, expected) { 366 | t.Fatalf("files not equal\nactual:\n%s\nexpected:\n%s", actual, expected) 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /goi18n/main.go: -------------------------------------------------------------------------------- 1 | // Command goi18n manages message files used by the i18n package. 2 | // 3 | // go get -u github.com/nicksnyder/go-i18n/v2/goi18n 4 | // goi18n -help 5 | // 6 | // Use `goi18n extract` to create a message file that contains the messages defined in your Go source files. 7 | // 8 | // # en.toml 9 | // [PersonCats] 10 | // description = "The number of cats a person has" 11 | // one = "{{.Name}} has {{.Count}} cat." 12 | // other = "{{.Name}} has {{.Count}} cats." 13 | // 14 | // Use `goi18n merge` to create message files for translation. 15 | // 16 | // # translate.es.toml 17 | // [PersonCats] 18 | // description = "The number of cats a person has" 19 | // hash = "sha1-f937a0e05e19bfe6cd70937c980eaf1f9832f091" 20 | // one = "{{.Name}} has {{.Count}} cat." 21 | // other = "{{.Name}} has {{.Count}} cats." 22 | // 23 | // Use `goi18n merge` to merge translated message files with your existing message files. 24 | // 25 | // # active.es.toml 26 | // [PersonCats] 27 | // description = "The number of cats a person has" 28 | // hash = "sha1-f937a0e05e19bfe6cd70937c980eaf1f9832f091" 29 | // one = "{{.Name}} tiene {{.Count}} gato." 30 | // other = "{{.Name}} tiene {{.Count}} gatos." 31 | // 32 | // Load the active messages into your bundle. 33 | // 34 | // bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 35 | // bundle.MustLoadMessageFile("active.es.toml") 36 | package main 37 | 38 | import ( 39 | "flag" 40 | "fmt" 41 | "os" 42 | 43 | "golang.org/x/text/language" 44 | ) 45 | 46 | func mainUsage() { 47 | fmt.Fprintf(os.Stderr, `goi18n (v2) is a tool for managing message translations. 48 | 49 | Usage: 50 | 51 | goi18n command [arguments] 52 | 53 | The commands are: 54 | 55 | merge merge message files 56 | extract extract messages from Go files 57 | 58 | Workflow: 59 | 60 | Use 'goi18n extract' to create a message file that contains the messages defined in your Go source files. 61 | 62 | # en.toml 63 | [PersonCats] 64 | description = "The number of cats a person has" 65 | one = "{{.Name}} has {{.Count}} cat." 66 | other = "{{.Name}} has {{.Count}} cats." 67 | 68 | Use 'goi18n merge' to create message files for translation. 69 | 70 | # translate.es.toml 71 | [PersonCats] 72 | description = "The number of cats a person has" 73 | hash = "sha1-f937a0e05e19bfe6cd70937c980eaf1f9832f091" 74 | one = "{{.Name}} has {{.Count}} cat." 75 | other = "{{.Name}} has {{.Count}} cats." 76 | 77 | Use 'goi18n merge' to merge translated message files with your existing message files. 78 | 79 | # active.es.toml 80 | [PersonCats] 81 | description = "The number of cats a person has" 82 | hash = "sha1-f937a0e05e19bfe6cd70937c980eaf1f9832f091" 83 | one = "{{.Name}} tiene {{.Count}} gato." 84 | other = "{{.Name}} tiene {{.Count}} gatos." 85 | 86 | Load the active messages into your bundle. 87 | 88 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 89 | bundle.MustLoadMessageFile("active.es.toml") 90 | `) 91 | } 92 | 93 | type command interface { 94 | name() string 95 | parse(arguments []string) error 96 | execute() error 97 | } 98 | 99 | func main() { 100 | os.Exit(testableMain(os.Args[1:])) 101 | } 102 | 103 | func testableMain(args []string) int { 104 | flags := flag.NewFlagSet("goi18n", flag.ContinueOnError) 105 | flags.Usage = mainUsage 106 | if err := flags.Parse(args); err != nil { 107 | if err == flag.ErrHelp { 108 | return 2 109 | } 110 | return 1 111 | } 112 | if flags.NArg() == 0 { 113 | mainUsage() 114 | return 2 115 | } 116 | commands := []command{ 117 | &mergeCommand{}, 118 | &extractCommand{}, 119 | } 120 | cmdName := flags.Arg(0) 121 | for _, cmd := range commands { 122 | if cmd.name() == cmdName { 123 | if err := cmd.parse(flags.Args()[1:]); err != nil { 124 | fmt.Fprintln(os.Stderr, err) 125 | return 1 126 | } 127 | if err := cmd.execute(); err != nil { 128 | fmt.Fprintln(os.Stderr, err) 129 | return 1 130 | } 131 | return 0 132 | } 133 | } 134 | fmt.Fprintf(os.Stderr, "goi18n: unknown subcommand %s\n", cmdName) 135 | return 1 136 | } 137 | 138 | type languageTag language.Tag 139 | 140 | func (lt languageTag) String() string { 141 | return lt.Tag().String() 142 | } 143 | 144 | func (lt *languageTag) Set(value string) error { 145 | t, err := language.Parse(value) 146 | if err != nil { 147 | return err 148 | } 149 | *lt = languageTag(t) 150 | return nil 151 | } 152 | 153 | func (lt languageTag) Tag() language.Tag { 154 | tag := language.Tag(lt) 155 | if tag.IsRoot() { 156 | return language.English 157 | } 158 | return tag 159 | } 160 | -------------------------------------------------------------------------------- /goi18n/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestMain(t *testing.T) { 9 | testCases := []struct { 10 | args []string 11 | exitCode int 12 | }{ 13 | { 14 | args: []string{"-help"}, 15 | exitCode: 2, 16 | }, 17 | { 18 | args: []string{"extract"}, 19 | exitCode: 0, 20 | }, 21 | { 22 | args: []string{"merge"}, 23 | exitCode: 1, 24 | }, 25 | } 26 | for _, testCase := range testCases { 27 | t.Run(strings.Join(testCase.args, " "), func(t *testing.T) { 28 | if code := testableMain(testCase.args); code != testCase.exitCode { 29 | t.Fatalf("expected exit code %d; got %d", testCase.exitCode, code) 30 | } 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /goi18n/marshal.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "path/filepath" 8 | 9 | "github.com/BurntSushi/toml" 10 | "github.com/nicksnyder/go-i18n/v2/i18n" 11 | "github.com/nicksnyder/go-i18n/v2/internal/plural" 12 | "golang.org/x/text/language" 13 | yaml "gopkg.in/yaml.v3" 14 | ) 15 | 16 | func writeFile(outdir, label string, langTag language.Tag, format string, messageTemplates map[string]*i18n.MessageTemplate, sourceLanguage bool) (path string, content []byte, err error) { 17 | v := marshalValue(messageTemplates, sourceLanguage) 18 | content, err = marshal(v, format) 19 | if err != nil { 20 | return "", nil, fmt.Errorf("failed to marshal %s strings to %s: %s", langTag, format, err) 21 | } 22 | path = filepath.Join(outdir, fmt.Sprintf("%s.%s.%s", label, langTag, format)) 23 | return 24 | } 25 | 26 | func marshalValue(messageTemplates map[string]*i18n.MessageTemplate, sourceLanguage bool) interface{} { 27 | v := make(map[string]interface{}, len(messageTemplates)) 28 | for id, template := range messageTemplates { 29 | if other := template.PluralTemplates[plural.Other]; sourceLanguage && len(template.PluralTemplates) == 1 && 30 | other != nil && template.Description == "" && template.LeftDelim == "" && template.RightDelim == "" { 31 | v[id] = other.Src 32 | } else { 33 | m := map[string]string{} 34 | if template.Description != "" { 35 | m["description"] = template.Description 36 | } 37 | if !sourceLanguage { 38 | m["hash"] = template.Hash 39 | } 40 | for pluralForm, template := range template.PluralTemplates { 41 | m[string(pluralForm)] = template.Src 42 | } 43 | v[id] = m 44 | } 45 | } 46 | return v 47 | } 48 | 49 | func marshal(v interface{}, format string) ([]byte, error) { 50 | switch format { 51 | case "json": 52 | var buf bytes.Buffer 53 | enc := json.NewEncoder(&buf) 54 | enc.SetEscapeHTML(false) 55 | enc.SetIndent("", " ") 56 | err := enc.Encode(v) 57 | return buf.Bytes(), err 58 | case "toml": 59 | var buf bytes.Buffer 60 | enc := toml.NewEncoder(&buf) 61 | enc.Indent = "" 62 | err := enc.Encode(v) 63 | return buf.Bytes(), err 64 | case "yaml": 65 | return yaml.Marshal(v) 66 | } 67 | return nil, fmt.Errorf("unsupported format: %s", format) 68 | } 69 | -------------------------------------------------------------------------------- /goi18n/marshal_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestMarshal(t *testing.T) { 6 | actual, err := marshal(map[string]string{ 7 | "&": "&", 8 | }, "json") 9 | 10 | if err != nil { 11 | t.Fatal(err) 12 | } 13 | 14 | expected := `{ 15 | "&": "&" 16 | } 17 | ` 18 | if a := string(actual); a != expected { 19 | t.Fatalf("\nexpected:\n%s\n\ngot\n%s", expected, a) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /goi18n/merge_command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "os" 10 | 11 | "github.com/BurntSushi/toml" 12 | "github.com/nicksnyder/go-i18n/v2/i18n" 13 | "github.com/nicksnyder/go-i18n/v2/internal" 14 | "github.com/nicksnyder/go-i18n/v2/internal/plural" 15 | "golang.org/x/text/language" 16 | yaml "gopkg.in/yaml.v3" 17 | ) 18 | 19 | func usageMerge() { 20 | fmt.Fprintf(os.Stderr, `usage: goi18n merge [options] [message files] 21 | 22 | Merge reads all messages in the message files and produces two files per language. 23 | 24 | xx-yy.active.format 25 | This file contains messages that should be loaded at runtime. 26 | 27 | xx-yy.translate.format 28 | This file contains messages that are empty and should be translated. 29 | 30 | Message file names must have a suffix of a supported format (e.g. ".json") and 31 | contain a valid language tag as defined by RFC 5646 (e.g. "en-us", "fr", "zh-hant", etc.). 32 | 33 | To add support for a new language, create an empty translation file with the 34 | appropriate name and pass it in to goi18n merge. 35 | 36 | Flags: 37 | 38 | -sourceLanguage tag 39 | Translate messages from this language (e.g. en, en-US, zh-Hant-CN) 40 | Default: en 41 | 42 | -outdir directory 43 | Write message files to this directory. 44 | Default: . 45 | 46 | -format format 47 | Output message files in this format. 48 | Supported formats: json, toml, yaml 49 | Default: toml 50 | `) 51 | } 52 | 53 | type mergeCommand struct { 54 | messageFiles []string 55 | sourceLanguage languageTag 56 | outdir string 57 | format string 58 | } 59 | 60 | func (mc *mergeCommand) name() string { 61 | return "merge" 62 | } 63 | 64 | func (mc *mergeCommand) parse(args []string) error { 65 | flags := flag.NewFlagSet("merge", flag.ExitOnError) 66 | flags.Usage = usageMerge 67 | 68 | flags.Var(&mc.sourceLanguage, "sourceLanguage", "en") 69 | flags.StringVar(&mc.outdir, "outdir", ".", "") 70 | flags.StringVar(&mc.format, "format", "toml", "") 71 | if err := flags.Parse(args); err != nil { 72 | return err 73 | } 74 | 75 | mc.messageFiles = flags.Args() 76 | return nil 77 | } 78 | 79 | func (mc *mergeCommand) execute() error { 80 | if len(mc.messageFiles) < 1 { 81 | return fmt.Errorf("need at least one message file to parse") 82 | } 83 | inFiles := make(map[string][]byte) 84 | for _, path := range mc.messageFiles { 85 | content, err := os.ReadFile(path) 86 | if err != nil { 87 | return err 88 | } 89 | inFiles[path] = content 90 | } 91 | ops, err := merge(inFiles, mc.sourceLanguage.Tag(), mc.outdir, mc.format) 92 | if err != nil { 93 | return err 94 | } 95 | for path, content := range ops.writeFiles { 96 | if err := os.WriteFile(path, content, 0666); err != nil { 97 | return err 98 | } 99 | } 100 | for _, path := range ops.deleteFiles { 101 | // Ignore error since it isn't guaranteed to exist. 102 | _ = os.Remove(path) 103 | } 104 | return nil 105 | } 106 | 107 | type fileSystemOp struct { 108 | writeFiles map[string][]byte 109 | deleteFiles []string 110 | } 111 | 112 | func merge(messageFiles map[string][]byte, sourceLanguageTag language.Tag, outdir, outputFormat string) (*fileSystemOp, error) { 113 | unmerged := make(map[language.Tag][]map[string]*i18n.MessageTemplate) 114 | sourceMessageTemplates := make(map[string]*i18n.MessageTemplate) 115 | unmarshalFuncs := map[string]i18n.UnmarshalFunc{ 116 | "json": json.Unmarshal, 117 | "toml": toml.Unmarshal, 118 | "yaml": yaml.Unmarshal, 119 | } 120 | for path, content := range messageFiles { 121 | mf, err := i18n.ParseMessageFileBytes(content, path, unmarshalFuncs) 122 | if err != nil { 123 | return nil, fmt.Errorf("failed to load message file %s: %s", path, err) 124 | } 125 | templates := map[string]*i18n.MessageTemplate{} 126 | for _, m := range mf.Messages { 127 | template := i18n.NewMessageTemplate(m) 128 | if template == nil { 129 | continue 130 | } 131 | templates[m.ID] = template 132 | } 133 | if mf.Tag == sourceLanguageTag { 134 | for _, template := range templates { 135 | if sourceMessageTemplates[template.ID] != nil { 136 | return nil, fmt.Errorf("multiple source translations for id %q", template.ID) 137 | } 138 | template.Hash = hash(template) 139 | sourceMessageTemplates[template.ID] = template 140 | } 141 | } 142 | unmerged[mf.Tag] = append(unmerged[mf.Tag], templates) 143 | } 144 | 145 | if len(sourceMessageTemplates) == 0 { 146 | return nil, fmt.Errorf("no messages found for source locale %s", sourceLanguageTag) 147 | } 148 | 149 | pluralRules := plural.DefaultRules() 150 | all := make(map[language.Tag]map[string]*i18n.MessageTemplate) 151 | all[sourceLanguageTag] = sourceMessageTemplates 152 | for _, srcTemplate := range sourceMessageTemplates { 153 | for dstLangTag, messageTemplates := range unmerged { 154 | if dstLangTag == sourceLanguageTag { 155 | continue 156 | } 157 | pluralRule := pluralRules.Rule(dstLangTag) 158 | if pluralRule == nil { 159 | // Non-standard languages not supported because 160 | // we don't know if translations are complete or not. 161 | continue 162 | } 163 | if all[dstLangTag] == nil { 164 | all[dstLangTag] = make(map[string]*i18n.MessageTemplate) 165 | } 166 | dstMessageTemplate := all[dstLangTag][srcTemplate.ID] 167 | if dstMessageTemplate == nil { 168 | dstMessageTemplate = &i18n.MessageTemplate{ 169 | Message: &i18n.Message{ 170 | ID: srcTemplate.ID, 171 | Description: srcTemplate.Description, 172 | Hash: srcTemplate.Hash, 173 | }, 174 | PluralTemplates: make(map[plural.Form]*internal.Template), 175 | } 176 | all[dstLangTag][srcTemplate.ID] = dstMessageTemplate 177 | } 178 | 179 | // Check all unmerged message templates for this message id. 180 | for _, messageTemplates := range messageTemplates { 181 | unmergedTemplate := messageTemplates[srcTemplate.ID] 182 | if unmergedTemplate == nil { 183 | continue 184 | } 185 | // Ignore empty hashes for v1 backward compatibility. 186 | if unmergedTemplate.Hash != "" && unmergedTemplate.Hash != srcTemplate.Hash { 187 | // This was translated from different content so discard. 188 | continue 189 | } 190 | 191 | // Merge in the translated messages. 192 | for pluralForm := range pluralRule.PluralForms { 193 | dt := unmergedTemplate.PluralTemplates[pluralForm] 194 | if dt != nil && dt.Src != "" { 195 | dstMessageTemplate.PluralTemplates[pluralForm] = dt 196 | } 197 | } 198 | } 199 | } 200 | } 201 | 202 | translate := make(map[language.Tag]map[string]*i18n.MessageTemplate) 203 | active := make(map[language.Tag]map[string]*i18n.MessageTemplate) 204 | for langTag, messageTemplates := range all { 205 | active[langTag] = make(map[string]*i18n.MessageTemplate) 206 | if langTag == sourceLanguageTag { 207 | active[langTag] = messageTemplates 208 | continue 209 | } 210 | pluralRule := pluralRules.Rule(langTag) 211 | if pluralRule == nil { 212 | // Non-standard languages not supported because 213 | // we don't know if translations are complete or not. 214 | continue 215 | } 216 | for _, messageTemplate := range messageTemplates { 217 | srcMessageTemplate := sourceMessageTemplates[messageTemplate.ID] 218 | activeMessageTemplate, translateMessageTemplate := activeDst(srcMessageTemplate, messageTemplate, pluralRule) 219 | if translateMessageTemplate != nil { 220 | if translate[langTag] == nil { 221 | translate[langTag] = make(map[string]*i18n.MessageTemplate) 222 | } 223 | translate[langTag][messageTemplate.ID] = translateMessageTemplate 224 | } 225 | if activeMessageTemplate != nil { 226 | active[langTag][messageTemplate.ID] = activeMessageTemplate 227 | } 228 | } 229 | } 230 | 231 | writeFiles := make(map[string][]byte, len(translate)+len(active)) 232 | for langTag, messageTemplates := range translate { 233 | path, content, err := writeFile(outdir, "translate", langTag, outputFormat, messageTemplates, false) 234 | if err != nil { 235 | return nil, err 236 | } 237 | writeFiles[path] = content 238 | } 239 | deleteFiles := []string{} 240 | for langTag, messageTemplates := range active { 241 | path, content, err := writeFile(outdir, "active", langTag, outputFormat, messageTemplates, langTag == sourceLanguageTag) 242 | if err != nil { 243 | return nil, err 244 | } 245 | if len(content) > 0 { 246 | writeFiles[path] = content 247 | } else { 248 | deleteFiles = append(deleteFiles, path) 249 | } 250 | } 251 | return &fileSystemOp{writeFiles: writeFiles, deleteFiles: deleteFiles}, nil 252 | } 253 | 254 | // activeDst returns the active part of the dst and whether dst is a complete translation of src. 255 | func activeDst(src, dst *i18n.MessageTemplate, pluralRule *plural.Rule) (active *i18n.MessageTemplate, translateMessageTemplate *i18n.MessageTemplate) { 256 | pluralForms := pluralRule.PluralForms 257 | if len(src.PluralTemplates) == 1 { 258 | pluralForms = map[plural.Form]struct{}{ 259 | plural.Other: {}, 260 | } 261 | } 262 | for pluralForm := range pluralForms { 263 | dt := dst.PluralTemplates[pluralForm] 264 | if dt == nil || dt.Src == "" { 265 | if translateMessageTemplate == nil { 266 | translateMessageTemplate = &i18n.MessageTemplate{ 267 | Message: &i18n.Message{ 268 | ID: src.ID, 269 | Description: src.Description, 270 | Hash: src.Hash, 271 | }, 272 | PluralTemplates: make(map[plural.Form]*internal.Template), 273 | } 274 | } 275 | srcPlural := src.PluralTemplates[pluralForm] 276 | if srcPlural == nil { 277 | srcPlural = src.PluralTemplates[plural.Other] 278 | } 279 | translateMessageTemplate.PluralTemplates[pluralForm] = srcPlural 280 | continue 281 | } 282 | if active == nil { 283 | active = &i18n.MessageTemplate{ 284 | Message: &i18n.Message{ 285 | ID: src.ID, 286 | Description: src.Description, 287 | Hash: src.Hash, 288 | }, 289 | PluralTemplates: make(map[plural.Form]*internal.Template), 290 | } 291 | } 292 | active.PluralTemplates[pluralForm] = dt 293 | } 294 | return 295 | } 296 | 297 | func hash(t *i18n.MessageTemplate) string { 298 | h := sha1.New() 299 | _, _ = io.WriteString(h, t.Description) 300 | _, _ = io.WriteString(h, t.PluralTemplates[plural.Other].Src) 301 | return fmt.Sprintf("sha1-%x", h.Sum(nil)) 302 | } 303 | -------------------------------------------------------------------------------- /goi18n/merge_command_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "golang.org/x/text/language" 10 | ) 11 | 12 | type testCase struct { 13 | name string 14 | inFiles map[string][]byte 15 | sourceLanguage language.Tag 16 | outFiles map[string][]byte 17 | deleteFiles []string 18 | } 19 | 20 | func expectFile(s string) []byte { 21 | // Trimming leading newlines gives nicer formatting for file literals in test cases. 22 | return bytes.TrimLeft([]byte(s), "\n") 23 | } 24 | 25 | func TestMerge(t *testing.T) { 26 | testCases := []*testCase{ 27 | { 28 | name: "single identity", 29 | sourceLanguage: language.AmericanEnglish, 30 | inFiles: map[string][]byte{ 31 | "one.en-US.toml": []byte("1HelloMessage = \"Hello\"\n"), 32 | }, 33 | outFiles: map[string][]byte{ 34 | "active.en-US.toml": []byte("1HelloMessage = \"Hello\"\n"), 35 | }, 36 | }, 37 | { 38 | name: "single identity, one localization text missing", 39 | sourceLanguage: language.AmericanEnglish, 40 | inFiles: map[string][]byte{ 41 | "one.en-US.toml": []byte(` 42 | 1HelloMessage = "" 43 | Body = "Finally some text!" 44 | `), 45 | }, 46 | outFiles: map[string][]byte{ 47 | "active.en-US.toml": []byte(`Body = "Finally some text!" 48 | `), 49 | }, 50 | }, 51 | { 52 | name: "plural identity", 53 | sourceLanguage: language.AmericanEnglish, 54 | inFiles: map[string][]byte{ 55 | "active.en-US.toml": []byte(` 56 | [UnreadEmails] 57 | description = "Message that tells the user how many unread emails they have" 58 | one = "{{.Count}} unread email" 59 | other = "{{.Count}} unread emails" 60 | `), 61 | }, 62 | outFiles: map[string][]byte{ 63 | "active.en-US.toml": expectFile(` 64 | [UnreadEmails] 65 | description = "Message that tells the user how many unread emails they have" 66 | one = "{{.Count}} unread email" 67 | other = "{{.Count}} unread emails" 68 | `), 69 | }, 70 | }, 71 | { 72 | name: "plural identity, missing localization text", 73 | sourceLanguage: language.AmericanEnglish, 74 | inFiles: map[string][]byte{ 75 | "active.en-US.toml": []byte(` 76 | Body = "Some text!" 77 | 78 | [MissingTranslation] 79 | description = "I wonder what it was?!" 80 | one = "" 81 | other = "" 82 | `), 83 | }, 84 | outFiles: map[string][]byte{ 85 | "active.en-US.toml": expectFile(`Body = "Some text!" 86 | `), 87 | }, 88 | }, 89 | { 90 | name: "migrate source lang from v1 format", 91 | sourceLanguage: language.AmericanEnglish, 92 | inFiles: map[string][]byte{ 93 | "one.en-US.json": []byte(`[ 94 | { 95 | "id": "simple", 96 | "translation": "simple translation" 97 | }, 98 | { 99 | "id": "everything", 100 | "translation": { 101 | "zero": "zero translation", 102 | "one": "one translation", 103 | "two": "two translation", 104 | "few": "few translation", 105 | "many": "many translation", 106 | "other": "other translation" 107 | } 108 | } 109 | ]`), 110 | }, 111 | outFiles: map[string][]byte{ 112 | "active.en-US.toml": expectFile(` 113 | simple = "simple translation" 114 | 115 | [everything] 116 | few = "few translation" 117 | many = "many translation" 118 | one = "one translation" 119 | other = "other translation" 120 | two = "two translation" 121 | zero = "zero translation" 122 | `), 123 | }, 124 | }, 125 | { 126 | name: "migrate source lang from v1 flat format", 127 | sourceLanguage: language.AmericanEnglish, 128 | inFiles: map[string][]byte{ 129 | "one.en-US.json": []byte(`{ 130 | "simple": { 131 | "other": "simple translation" 132 | }, 133 | "everything": { 134 | "zero": "zero translation", 135 | "one": "one translation", 136 | "two": "two translation", 137 | "few": "few translation", 138 | "many": "many translation", 139 | "other": "other translation" 140 | } 141 | }`), 142 | }, 143 | outFiles: map[string][]byte{ 144 | "active.en-US.toml": expectFile(` 145 | simple = "simple translation" 146 | 147 | [everything] 148 | few = "few translation" 149 | many = "many translation" 150 | one = "one translation" 151 | other = "other translation" 152 | two = "two translation" 153 | zero = "zero translation" 154 | `), 155 | }, 156 | }, 157 | { 158 | name: "merge source files", 159 | sourceLanguage: language.AmericanEnglish, 160 | inFiles: map[string][]byte{ 161 | "one.en-US.toml": []byte("1HelloMessage = \"Hello\"\n"), 162 | "two.en-US.toml": []byte("2GoodbyeMessage = \"Goodbye\"\n"), 163 | }, 164 | outFiles: map[string][]byte{ 165 | "active.en-US.toml": []byte("1HelloMessage = \"Hello\"\n2GoodbyeMessage = \"Goodbye\"\n"), 166 | }, 167 | }, 168 | { 169 | name: "missing hash", 170 | sourceLanguage: language.AmericanEnglish, 171 | inFiles: map[string][]byte{ 172 | "en-US.toml": []byte(` 173 | 1HelloMessage = "Hello" 174 | `), 175 | "es-ES.toml": []byte(` 176 | [1HelloMessage] 177 | other = "Hola" 178 | `), 179 | }, 180 | outFiles: map[string][]byte{ 181 | "active.en-US.toml": expectFile(` 182 | 1HelloMessage = "Hello" 183 | `), 184 | "active.es-ES.toml": expectFile(` 185 | [1HelloMessage] 186 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 187 | other = "Hola" 188 | `), 189 | }, 190 | }, 191 | { 192 | name: "add single translation", 193 | sourceLanguage: language.AmericanEnglish, 194 | inFiles: map[string][]byte{ 195 | "en-US.toml": []byte(` 196 | 1HelloMessage = "Hello" 197 | 2GoodbyeMessage = "Goodbye" 198 | `), 199 | "es-ES.toml": []byte(` 200 | [1HelloMessage] 201 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 202 | other = "Hola" 203 | `), 204 | }, 205 | outFiles: map[string][]byte{ 206 | "active.en-US.toml": expectFile(` 207 | 1HelloMessage = "Hello" 208 | 2GoodbyeMessage = "Goodbye" 209 | `), 210 | "active.es-ES.toml": expectFile(` 211 | [1HelloMessage] 212 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 213 | other = "Hola" 214 | `), 215 | "translate.es-ES.toml": expectFile(` 216 | [2GoodbyeMessage] 217 | hash = "sha1-b5b29c53e3c71cb9c6581ab053d7758fab8ca24d" 218 | other = "Goodbye" 219 | `), 220 | }, 221 | }, 222 | { 223 | name: "remove single translation", 224 | sourceLanguage: language.AmericanEnglish, 225 | inFiles: map[string][]byte{ 226 | "en-US.toml": []byte(` 227 | 1HelloMessage = "Hello" 228 | `), 229 | "es-ES.toml": []byte(` 230 | [1HelloMessage] 231 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 232 | other = "Hola" 233 | 234 | [2GoodbyeMessage] 235 | hash = "sha1-b5b29c53e3c71cb9c6581ab053d7758fab8ca24d" 236 | other = "Goodbye" 237 | `), 238 | }, 239 | outFiles: map[string][]byte{ 240 | "active.en-US.toml": expectFile(` 241 | 1HelloMessage = "Hello" 242 | `), 243 | "active.es-ES.toml": expectFile(` 244 | [1HelloMessage] 245 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 246 | other = "Hola" 247 | `), 248 | }, 249 | }, 250 | { 251 | name: "edit single translation", 252 | sourceLanguage: language.AmericanEnglish, 253 | inFiles: map[string][]byte{ 254 | "en-US.toml": []byte(` 255 | 1HelloMessage = "Hi" 256 | `), 257 | "es-ES.toml": []byte(` 258 | [1HelloMessage] 259 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 260 | other = "Hola" 261 | `), 262 | }, 263 | outFiles: map[string][]byte{ 264 | "active.en-US.toml": expectFile(` 265 | 1HelloMessage = "Hi" 266 | `), 267 | "translate.es-ES.toml": expectFile(` 268 | [1HelloMessage] 269 | hash = "sha1-94dd9e08c129c785f7f256e82fbe0a30e6d1ae40" 270 | other = "Hi" 271 | `), 272 | }, 273 | }, 274 | { 275 | name: "add plural translation", 276 | sourceLanguage: language.AmericanEnglish, 277 | inFiles: map[string][]byte{ 278 | "en-US.toml": []byte(` 279 | [UnreadEmails] 280 | description = "Message that tells the user how many unread emails they have" 281 | one = "{{.Count}} unread email" 282 | other = "{{.Count}} unread emails" 283 | `), 284 | "es-ES.toml": nil, 285 | "ar-AR.toml": nil, 286 | "zh-CN.toml": nil, 287 | }, 288 | outFiles: map[string][]byte{ 289 | "active.en-US.toml": expectFile(` 290 | [UnreadEmails] 291 | description = "Message that tells the user how many unread emails they have" 292 | one = "{{.Count}} unread email" 293 | other = "{{.Count}} unread emails" 294 | `), 295 | "translate.es-ES.toml": expectFile(` 296 | [UnreadEmails] 297 | description = "Message that tells the user how many unread emails they have" 298 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 299 | many = "{{.Count}} unread emails" 300 | one = "{{.Count}} unread email" 301 | other = "{{.Count}} unread emails" 302 | `), 303 | "translate.ar-AR.toml": expectFile(` 304 | [UnreadEmails] 305 | description = "Message that tells the user how many unread emails they have" 306 | few = "{{.Count}} unread emails" 307 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 308 | many = "{{.Count}} unread emails" 309 | one = "{{.Count}} unread email" 310 | other = "{{.Count}} unread emails" 311 | two = "{{.Count}} unread emails" 312 | zero = "{{.Count}} unread emails" 313 | `), 314 | "translate.zh-CN.toml": expectFile(` 315 | [UnreadEmails] 316 | description = "Message that tells the user how many unread emails they have" 317 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 318 | other = "{{.Count}} unread emails" 319 | `), 320 | }, 321 | }, 322 | { 323 | name: "remove plural translation", 324 | sourceLanguage: language.AmericanEnglish, 325 | inFiles: map[string][]byte{ 326 | "en-US.toml": []byte(` 327 | 1HelloMessage = "Hello" 328 | `), 329 | "es-ES.toml": []byte(` 330 | [1HelloMessage] 331 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 332 | other = "Hola" 333 | 334 | [UnreadEmails] 335 | description = "Message that tells the user how many unread emails they have" 336 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 337 | one = "{{.Count}} unread emails" 338 | other = "{{.Count}} unread emails" 339 | `), 340 | "ar-AR.toml": []byte(` 341 | [1HelloMessage] 342 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 343 | other = "Hello" 344 | 345 | [UnreadEmails] 346 | description = "Message that tells the user how many unread emails they have" 347 | few = "{{.Count}} unread emails" 348 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 349 | many = "{{.Count}} unread emails" 350 | one = "{{.Count}} unread emails" 351 | other = "{{.Count}} unread emails" 352 | two = "{{.Count}} unread emails" 353 | zero = "{{.Count}} unread emails" 354 | `), 355 | "zh-CN.toml": []byte(` 356 | [1HelloMessage] 357 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 358 | other = "Hello" 359 | 360 | [UnreadEmails] 361 | description = "Message that tells the user how many unread emails they have" 362 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 363 | other = "{{.Count}} unread emails" 364 | `), 365 | }, 366 | outFiles: map[string][]byte{ 367 | "active.en-US.toml": expectFile(` 368 | 1HelloMessage = "Hello" 369 | `), 370 | "active.es-ES.toml": expectFile(` 371 | [1HelloMessage] 372 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 373 | other = "Hola" 374 | `), 375 | "active.ar-AR.toml": expectFile(` 376 | [1HelloMessage] 377 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 378 | other = "Hello" 379 | `), 380 | "active.zh-CN.toml": expectFile(` 381 | [1HelloMessage] 382 | hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" 383 | other = "Hello" 384 | `), 385 | }, 386 | }, 387 | { 388 | name: "edit plural translation", 389 | sourceLanguage: language.AmericanEnglish, 390 | inFiles: map[string][]byte{ 391 | "en-US.toml": []byte(` 392 | [UnreadEmails] 393 | description = "Message that tells the user how many unread emails they have" 394 | one = "{{.Count}} unread emails!" 395 | other = "{{.Count}} unread emails!" 396 | `), 397 | "es-ES.toml": []byte(` 398 | [UnreadEmails] 399 | description = "Message that tells the user how many unread emails they have" 400 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 401 | one = "{{.Count}} unread emails" 402 | other = "{{.Count}} unread emails" 403 | `), 404 | "ar-AR.toml": []byte(` 405 | [UnreadEmails] 406 | description = "Message that tells the user how many unread emails they have" 407 | few = "{{.Count}} unread emails" 408 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 409 | many = "{{.Count}} unread emails" 410 | one = "{{.Count}} unread emails" 411 | other = "{{.Count}} unread emails" 412 | two = "{{.Count}} unread emails" 413 | zero = "{{.Count}} unread emails" 414 | `), 415 | "zh-CN.toml": []byte(` 416 | [UnreadEmails] 417 | description = "Message that tells the user how many unread emails they have" 418 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 419 | other = "{{.Count}} unread emails" 420 | `), 421 | }, 422 | deleteFiles: []string{ 423 | "active.es-ES.toml", 424 | "active.ar-AR.toml", 425 | "active.zh-CN.toml", 426 | }, 427 | outFiles: map[string][]byte{ 428 | "active.en-US.toml": expectFile(` 429 | [UnreadEmails] 430 | description = "Message that tells the user how many unread emails they have" 431 | one = "{{.Count}} unread emails!" 432 | other = "{{.Count}} unread emails!" 433 | `), 434 | "translate.es-ES.toml": expectFile(` 435 | [UnreadEmails] 436 | description = "Message that tells the user how many unread emails they have" 437 | hash = "sha1-92a24983c5bbc0c42462cdc252dca68ebdb46501" 438 | many = "{{.Count}} unread emails!" 439 | one = "{{.Count}} unread emails!" 440 | other = "{{.Count}} unread emails!" 441 | `), 442 | "translate.ar-AR.toml": expectFile(` 443 | [UnreadEmails] 444 | description = "Message that tells the user how many unread emails they have" 445 | few = "{{.Count}} unread emails!" 446 | hash = "sha1-92a24983c5bbc0c42462cdc252dca68ebdb46501" 447 | many = "{{.Count}} unread emails!" 448 | one = "{{.Count}} unread emails!" 449 | other = "{{.Count}} unread emails!" 450 | two = "{{.Count}} unread emails!" 451 | zero = "{{.Count}} unread emails!" 452 | `), 453 | "translate.zh-CN.toml": expectFile(` 454 | [UnreadEmails] 455 | description = "Message that tells the user how many unread emails they have" 456 | hash = "sha1-92a24983c5bbc0c42462cdc252dca68ebdb46501" 457 | other = "{{.Count}} unread emails!" 458 | `), 459 | }, 460 | }, 461 | { 462 | name: "merge plural translation", 463 | sourceLanguage: language.AmericanEnglish, 464 | inFiles: map[string][]byte{ 465 | "en-US.toml": []byte(` 466 | [UnreadEmails] 467 | description = "Message that tells the user how many unread emails they have" 468 | one = "{{.Count}} unread emails" 469 | other = "{{.Count}} unread emails" 470 | `), 471 | "zero.ar-AR.toml": []byte(` 472 | [UnreadEmails] 473 | description = "Message that tells the user how many unread emails they have" 474 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 475 | zero = "{{.Count}} unread emails" 476 | `), 477 | "one.ar-AR.toml": []byte(` 478 | [UnreadEmails] 479 | description = "Message that tells the user how many unread emails they have" 480 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 481 | one = "{{.Count}} unread emails" 482 | `), 483 | "two.ar-AR.toml": []byte(` 484 | [UnreadEmails] 485 | description = "Message that tells the user how many unread emails they have" 486 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 487 | two = "{{.Count}} unread emails" 488 | `), 489 | "few.ar-AR.toml": []byte(` 490 | [UnreadEmails] 491 | description = "Message that tells the user how many unread emails they have" 492 | few = "{{.Count}} unread emails" 493 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 494 | `), 495 | "many.ar-AR.toml": []byte(` 496 | [UnreadEmails] 497 | description = "Message that tells the user how many unread emails they have" 498 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 499 | many = "{{.Count}} unread emails" 500 | `), 501 | "other.ar-AR.toml": []byte(` 502 | [UnreadEmails] 503 | description = "Message that tells the user how many unread emails they have" 504 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 505 | other = "{{.Count}} unread emails" 506 | `), 507 | }, 508 | outFiles: map[string][]byte{ 509 | "active.en-US.toml": expectFile(` 510 | [UnreadEmails] 511 | description = "Message that tells the user how many unread emails they have" 512 | one = "{{.Count}} unread emails" 513 | other = "{{.Count}} unread emails" 514 | `), 515 | "active.ar-AR.toml": expectFile(` 516 | [UnreadEmails] 517 | description = "Message that tells the user how many unread emails they have" 518 | few = "{{.Count}} unread emails" 519 | hash = "sha1-5afbc91dfedb9755627655c365eb47a89e541099" 520 | many = "{{.Count}} unread emails" 521 | one = "{{.Count}} unread emails" 522 | other = "{{.Count}} unread emails" 523 | two = "{{.Count}} unread emails" 524 | zero = "{{.Count}} unread emails" 525 | `), 526 | }, 527 | }, 528 | } 529 | 530 | for _, testCase := range testCases { 531 | t.Run(testCase.name, func(t *testing.T) { 532 | indir := mustTempDir("TestMergeCommandIn") 533 | defer mustRemoveAll(t, indir) 534 | outdir := mustTempDir("TestMergeCommandOut") 535 | defer mustRemoveAll(t, outdir) 536 | 537 | infiles := make([]string, 0, len(testCase.inFiles)) 538 | for name, content := range testCase.inFiles { 539 | path := filepath.Join(indir, name) 540 | infiles = append(infiles, path) 541 | if err := os.WriteFile(path, content, 0666); err != nil { 542 | t.Fatal(err) 543 | } 544 | } 545 | 546 | for _, name := range testCase.deleteFiles { 547 | path := filepath.Join(outdir, name) 548 | if err := os.WriteFile(path, []byte(`this file should get deleted`), 0666); err != nil { 549 | t.Fatal(err) 550 | } 551 | } 552 | 553 | args := append([]string{"merge", "-sourceLanguage", testCase.sourceLanguage.String(), "-outdir", outdir}, infiles...) 554 | if code := testableMain(args); code != 0 { 555 | t.Fatalf("expected exit code 0; got %d\n", code) 556 | } 557 | 558 | files, err := os.ReadDir(outdir) 559 | if err != nil { 560 | t.Fatal(err) 561 | } 562 | 563 | // Verify that all actual files have expected contents. 564 | actualFiles := make(map[string]struct{}, len(files)) 565 | for _, f := range files { 566 | actualFiles[f.Name()] = struct{}{} 567 | if f.IsDir() { 568 | t.Errorf("found unexpected dir %s", f.Name()) 569 | continue 570 | } 571 | path := filepath.Join(outdir, f.Name()) 572 | actual, err := os.ReadFile(path) 573 | if err != nil { 574 | t.Error(err) 575 | continue 576 | } 577 | expected, ok := testCase.outFiles[f.Name()] 578 | if !ok { 579 | t.Errorf("found unexpected file %s with contents:\n%s\n", f.Name(), actual) 580 | continue 581 | } 582 | if !bytes.Equal(actual, expected) { 583 | t.Errorf("unexpected contents %s\ngot\n%s\nexpected\n%s", f.Name(), actual, expected) 584 | continue 585 | } 586 | } 587 | 588 | // Verify that all expected files are accounted for. 589 | for name := range testCase.outFiles { 590 | if _, ok := actualFiles[name]; !ok { 591 | t.Errorf("did not find expected file %s", name) 592 | } 593 | } 594 | }) 595 | } 596 | } 597 | -------------------------------------------------------------------------------- /i18n/bundle.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/nicksnyder/go-i18n/v2/internal/plural" 8 | 9 | "golang.org/x/text/language" 10 | ) 11 | 12 | // UnmarshalFunc unmarshals data into v. 13 | type UnmarshalFunc func(data []byte, v interface{}) error 14 | 15 | // Bundle stores a set of messages and pluralization rules. 16 | // Most applications only need a single bundle 17 | // that is initialized early in the application's lifecycle. 18 | // It is not goroutine safe to modify the bundle while Localizers 19 | // are reading from it. 20 | type Bundle struct { 21 | defaultLanguage language.Tag 22 | unmarshalFuncs map[string]UnmarshalFunc 23 | messageTemplates map[language.Tag]map[string]*MessageTemplate 24 | pluralRules plural.Rules 25 | tags []language.Tag 26 | matcher language.Matcher 27 | } 28 | 29 | // artTag is the language tag used for artificial languages 30 | // https://en.wikipedia.org/wiki/Codes_for_constructed_languages 31 | var artTag = language.MustParse("art") 32 | 33 | // NewBundle returns a bundle with a default language and a default set of plural rules. 34 | func NewBundle(defaultLanguage language.Tag) *Bundle { 35 | b := &Bundle{ 36 | defaultLanguage: defaultLanguage, 37 | pluralRules: plural.DefaultRules(), 38 | } 39 | b.pluralRules[artTag] = b.pluralRules.Rule(language.English) 40 | b.addTag(defaultLanguage) 41 | return b 42 | } 43 | 44 | // RegisterUnmarshalFunc registers an UnmarshalFunc for format. 45 | func (b *Bundle) RegisterUnmarshalFunc(format string, unmarshalFunc UnmarshalFunc) { 46 | if b.unmarshalFuncs == nil { 47 | b.unmarshalFuncs = make(map[string]UnmarshalFunc) 48 | } 49 | b.unmarshalFuncs[format] = unmarshalFunc 50 | } 51 | 52 | // LoadMessageFile loads the bytes from path 53 | // and then calls ParseMessageFileBytes. 54 | func (b *Bundle) LoadMessageFile(path string) (*MessageFile, error) { 55 | buf, err := os.ReadFile(path) 56 | if err != nil { 57 | return nil, err 58 | } 59 | return b.ParseMessageFileBytes(buf, path) 60 | } 61 | 62 | // MustLoadMessageFile is similar to LoadMessageFile 63 | // except it panics if an error happens. 64 | func (b *Bundle) MustLoadMessageFile(path string) { 65 | if _, err := b.LoadMessageFile(path); err != nil { 66 | panic(err) 67 | } 68 | } 69 | 70 | // ParseMessageFileBytes parses the bytes in buf to add translations to the bundle. 71 | // 72 | // The format of the file is everything after the last ".". 73 | // 74 | // The language tag of the file is everything after the second to last "." or after the last path separator, but before the format. 75 | func (b *Bundle) ParseMessageFileBytes(buf []byte, path string) (*MessageFile, error) { 76 | messageFile, err := ParseMessageFileBytes(buf, path, b.unmarshalFuncs) 77 | if err != nil { 78 | return nil, err 79 | } 80 | if err := b.AddMessages(messageFile.Tag, messageFile.Messages...); err != nil { 81 | return nil, err 82 | } 83 | return messageFile, nil 84 | } 85 | 86 | // MustParseMessageFileBytes is similar to ParseMessageFileBytes 87 | // except it panics if an error happens. 88 | func (b *Bundle) MustParseMessageFileBytes(buf []byte, path string) { 89 | if _, err := b.ParseMessageFileBytes(buf, path); err != nil { 90 | panic(err) 91 | } 92 | } 93 | 94 | // AddMessages adds messages for a language. 95 | // It is useful if your messages are in a format not supported by ParseMessageFileBytes. 96 | func (b *Bundle) AddMessages(tag language.Tag, messages ...*Message) error { 97 | pluralRule := b.pluralRules.Rule(tag) 98 | if pluralRule == nil { 99 | return fmt.Errorf("no plural rule registered for %s", tag) 100 | } 101 | if b.messageTemplates == nil { 102 | b.messageTemplates = map[language.Tag]map[string]*MessageTemplate{} 103 | } 104 | if b.messageTemplates[tag] == nil { 105 | b.messageTemplates[tag] = map[string]*MessageTemplate{} 106 | b.addTag(tag) 107 | } 108 | for _, m := range messages { 109 | b.messageTemplates[tag][m.ID] = NewMessageTemplate(m) 110 | } 111 | return nil 112 | } 113 | 114 | // MustAddMessages is similar to AddMessages except it panics if an error happens. 115 | func (b *Bundle) MustAddMessages(tag language.Tag, messages ...*Message) { 116 | if err := b.AddMessages(tag, messages...); err != nil { 117 | panic(err) 118 | } 119 | } 120 | 121 | func (b *Bundle) addTag(tag language.Tag) { 122 | for _, t := range b.tags { 123 | if t == tag { 124 | // Tag already exists 125 | return 126 | } 127 | } 128 | b.tags = append(b.tags, tag) 129 | b.matcher = language.NewMatcher(b.tags) 130 | } 131 | 132 | // LanguageTags returns the list of language tags 133 | // of all the translations loaded into the bundle 134 | func (b *Bundle) LanguageTags() []language.Tag { 135 | return b.tags 136 | } 137 | 138 | func (b *Bundle) getMessageTemplate(tag language.Tag, id string) *MessageTemplate { 139 | templates := b.messageTemplates[tag] 140 | if templates == nil { 141 | return nil 142 | } 143 | return templates[id] 144 | } 145 | -------------------------------------------------------------------------------- /i18n/bundle_test.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/BurntSushi/toml" 9 | "golang.org/x/text/language" 10 | yaml "gopkg.in/yaml.v3" 11 | ) 12 | 13 | var simpleMessage = MustNewMessage(map[string]string{ 14 | "id": "simple", 15 | "other": "simple translation", 16 | }) 17 | 18 | var detailMessage = MustNewMessage(map[string]string{ 19 | "id": "detail", 20 | "description": "detail description", 21 | "other": "detail translation", 22 | }) 23 | 24 | var everythingMessage = MustNewMessage(map[string]string{ 25 | "id": "everything", 26 | "description": "everything description", 27 | "zero": "zero translation", 28 | "one": "one translation", 29 | "two": "two translation", 30 | "few": "few translation", 31 | "many": "many translation", 32 | "other": "other translation", 33 | "leftDelim": "<<", 34 | "rightDelim": ">>", 35 | }) 36 | 37 | func TestConcurrentAccess(t *testing.T) { 38 | bundle := NewBundle(language.English) 39 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 40 | bundle.MustParseMessageFileBytes([]byte(` 41 | # Comment 42 | hello = "world" 43 | `), "en.toml") 44 | 45 | count := 10 46 | errch := make(chan error, count) 47 | for i := 0; i < count; i++ { 48 | go func() { 49 | localized := NewLocalizer(bundle, "en").MustLocalize(&LocalizeConfig{MessageID: "hello"}) 50 | if localized != "world" { 51 | errch <- fmt.Errorf(`expected "world"; got %q`, localized) 52 | } else { 53 | errch <- nil 54 | } 55 | }() 56 | } 57 | 58 | for i := 0; i < count; i++ { 59 | if err := <-errch; err != nil { 60 | t.Fatal(err) 61 | } 62 | } 63 | } 64 | 65 | func TestPseudoLanguage(t *testing.T) { 66 | bundle := NewBundle(language.English) 67 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 68 | expected := "nuqneH" 69 | bundle.MustParseMessageFileBytes([]byte(` 70 | # Comment 71 | hello = "`+expected+`" 72 | `), "art-x-klingon.toml") 73 | { 74 | localized, err := NewLocalizer(bundle, "art-x-klingon").Localize(&LocalizeConfig{MessageID: "hello"}) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | if localized != expected { 79 | t.Fatalf("expected %q\ngot %q", expected, localized) 80 | } 81 | } 82 | { 83 | localized, err := NewLocalizer(bundle, "art").Localize(&LocalizeConfig{MessageID: "hello"}) 84 | if err != nil { 85 | t.Fatal(err) 86 | } 87 | if localized != expected { 88 | t.Fatalf("expected %q\ngot %q", expected, localized) 89 | } 90 | } 91 | { 92 | expected := "" 93 | localized, err := NewLocalizer(bundle, "en").Localize(&LocalizeConfig{MessageID: "hello"}) 94 | if err == nil { 95 | t.Fatal(err) 96 | } 97 | if localized != expected { 98 | t.Fatalf("expected %q\ngot %q", expected, localized) 99 | } 100 | } 101 | } 102 | 103 | func TestJSON(t *testing.T) { 104 | bundle := NewBundle(language.English) 105 | bundle.MustParseMessageFileBytes([]byte(`{ 106 | "simple": "simple translation", 107 | "detail": { 108 | "description": "detail description", 109 | "other": "detail translation" 110 | }, 111 | "everything": { 112 | "description": "everything description", 113 | "zero": "zero translation", 114 | "one": "one translation", 115 | "two": "two translation", 116 | "few": "few translation", 117 | "many": "many translation", 118 | "other": "other translation", 119 | "leftDelim": "<<", 120 | "rightDelim": ">>" 121 | } 122 | }`), "en-US.json") 123 | 124 | expectMessage(t, bundle, language.AmericanEnglish, "simple", simpleMessage) 125 | expectMessage(t, bundle, language.AmericanEnglish, "detail", detailMessage) 126 | expectMessage(t, bundle, language.AmericanEnglish, "everything", everythingMessage) 127 | } 128 | 129 | func TestYAML(t *testing.T) { 130 | bundle := NewBundle(language.English) 131 | bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal) 132 | bundle.MustParseMessageFileBytes([]byte(` 133 | # Comment 134 | simple: simple translation 135 | 136 | # Comment 137 | detail: 138 | description: detail description 139 | other: detail translation 140 | 141 | # Comment 142 | everything: 143 | description: everything description 144 | zero: zero translation 145 | one: one translation 146 | two: two translation 147 | few: few translation 148 | many: many translation 149 | other: other translation 150 | leftDelim: "<<" 151 | rightDelim: ">>" 152 | `), "en-US.yaml") 153 | 154 | expectMessage(t, bundle, language.AmericanEnglish, "simple", simpleMessage) 155 | expectMessage(t, bundle, language.AmericanEnglish, "detail", detailMessage) 156 | expectMessage(t, bundle, language.AmericanEnglish, "everything", everythingMessage) 157 | } 158 | 159 | func TestInvalidYAML(t *testing.T) { 160 | bundle := NewBundle(language.English) 161 | bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal) 162 | _, err := bundle.ParseMessageFileBytes([]byte(` 163 | # Comment 164 | simple: simple translation 165 | 166 | # Comment 167 | detail: 168 | description: detail description 169 | other: detail translation 170 | 171 | # Comment 172 | everything: 173 | description: everything description 174 | zero: zero translation 175 | one: one translation 176 | two: two translation 177 | few: few translation 178 | many: many translation 179 | other: other translation 180 | leftDelim: "<<" 181 | rightDelmin: ">>" 182 | garbage: something 183 | 184 | description: translation 185 | `), "en-US.yaml") 186 | 187 | expectedErr := &mixedKeysError{ 188 | reservedKeys: []string{"description"}, 189 | unreservedKeys: []string{"detail", "everything", "simple"}, 190 | } 191 | if err == nil { 192 | t.Fatalf("expected error %#v; got nil", expectedErr) 193 | } 194 | if err.Error() != expectedErr.Error() { 195 | t.Fatalf("expected error %q; got %q", expectedErr, err) 196 | } 197 | if c := len(bundle.messageTemplates); c > 0 { 198 | t.Fatalf("expected no message templates in bundle; got %d", c) 199 | } 200 | } 201 | 202 | func TestTOML(t *testing.T) { 203 | bundle := NewBundle(language.English) 204 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 205 | bundle.MustParseMessageFileBytes([]byte(` 206 | # Comment 207 | simple = "simple translation" 208 | 209 | # Comment 210 | [detail] 211 | description = "detail description" 212 | other = "detail translation" 213 | 214 | # Comment 215 | [everything] 216 | description = "everything description" 217 | zero = "zero translation" 218 | one = "one translation" 219 | two = "two translation" 220 | few = "few translation" 221 | many = "many translation" 222 | other = "other translation" 223 | leftDelim = "<<" 224 | rightDelim = ">>" 225 | `), "en-US.toml") 226 | 227 | expectMessage(t, bundle, language.AmericanEnglish, "simple", simpleMessage) 228 | expectMessage(t, bundle, language.AmericanEnglish, "detail", detailMessage) 229 | expectMessage(t, bundle, language.AmericanEnglish, "everything", everythingMessage) 230 | } 231 | 232 | func TestV1Format(t *testing.T) { 233 | bundle := NewBundle(language.English) 234 | bundle.MustParseMessageFileBytes([]byte(`[ 235 | { 236 | "id": "simple", 237 | "translation": "simple translation" 238 | }, 239 | { 240 | "id": "everything", 241 | "translation": { 242 | "zero": "zero translation", 243 | "one": "one translation", 244 | "two": "two translation", 245 | "few": "few translation", 246 | "many": "many translation", 247 | "other": "other translation" 248 | } 249 | } 250 | ] 251 | `), "en-US.json") 252 | 253 | expectMessage(t, bundle, language.AmericanEnglish, "simple", simpleMessage) 254 | expectMessage(t, bundle, language.AmericanEnglish, "everything", newV1EverythingMessage()) 255 | } 256 | 257 | func TestV1FlatFormat(t *testing.T) { 258 | bundle := NewBundle(language.English) 259 | bundle.MustParseMessageFileBytes([]byte(`{ 260 | "simple": { 261 | "other": "simple translation" 262 | }, 263 | "everything": { 264 | "zero": "zero translation", 265 | "one": "one translation", 266 | "two": "two translation", 267 | "few": "few translation", 268 | "many": "many translation", 269 | "other": "other translation" 270 | } 271 | } 272 | `), "en-US.json") 273 | 274 | expectMessage(t, bundle, language.AmericanEnglish, "simple", simpleMessage) 275 | expectMessage(t, bundle, language.AmericanEnglish, "everything", newV1EverythingMessage()) 276 | } 277 | 278 | func expectMessage(t *testing.T, bundle *Bundle, tag language.Tag, messageID string, message *Message) { 279 | expected := NewMessageTemplate(message) 280 | actual := bundle.messageTemplates[tag][messageID] 281 | if !reflect.DeepEqual(actual, expected) { 282 | t.Errorf("bundle.MessageTemplates[%q][%q]\ngot %#v\nwant %#v", tag, messageID, actual, expected) 283 | } 284 | } 285 | 286 | func newV1EverythingMessage() *Message { 287 | e := *everythingMessage 288 | e.Description = "" 289 | e.LeftDelim = "" 290 | e.RightDelim = "" 291 | return &e 292 | } 293 | -------------------------------------------------------------------------------- /i18n/bundlefs.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "io/fs" 5 | ) 6 | 7 | // LoadMessageFileFS is like LoadMessageFile but instead of reading from the 8 | // hosts operating system's file system it reads from the fs file system. 9 | func (b *Bundle) LoadMessageFileFS(fsys fs.FS, path string) (*MessageFile, error) { 10 | buf, err := fs.ReadFile(fsys, path) 11 | if err != nil { 12 | return nil, err 13 | } 14 | 15 | return b.ParseMessageFileBytes(buf, path) 16 | } 17 | -------------------------------------------------------------------------------- /i18n/doc.go: -------------------------------------------------------------------------------- 1 | // Package i18n provides support for looking up messages 2 | // according to a set of locale preferences. 3 | // 4 | // Create a Bundle to use for the lifetime of your application. 5 | // 6 | // bundle := i18n.NewBundle(language.English) 7 | // 8 | // Load translations into your bundle during initialization. 9 | // 10 | // bundle.LoadMessageFile("en-US.yaml") 11 | // 12 | // Create a Localizer to use for a set of language preferences. 13 | // 14 | // func(w http.ResponseWriter, r *http.Request) { 15 | // lang := r.FormValue("lang") 16 | // accept := r.Header.Get("Accept-Language") 17 | // localizer := i18n.NewLocalizer(bundle, lang, accept) 18 | // } 19 | // 20 | // Use the Localizer to lookup messages. 21 | // 22 | // localizer.MustLocalize(&i18n.LocalizeConfig{ 23 | // DefaultMessage: &i18n.Message{ 24 | // ID: "HelloWorld", 25 | // Other: "Hello World!", 26 | // }, 27 | // }) 28 | package i18n 29 | -------------------------------------------------------------------------------- /i18n/example_test.go: -------------------------------------------------------------------------------- 1 | package i18n_test 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/nicksnyder/go-i18n/v2/i18n" 8 | "golang.org/x/text/language" 9 | ) 10 | 11 | func ExampleLocalizer_MustLocalize() { 12 | bundle := i18n.NewBundle(language.English) 13 | localizer := i18n.NewLocalizer(bundle, "en") 14 | fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{ 15 | DefaultMessage: &i18n.Message{ 16 | ID: "HelloWorld", 17 | Other: "Hello World!", 18 | }, 19 | })) 20 | // Output: 21 | // Hello World! 22 | } 23 | 24 | func ExampleLocalizer_MustLocalize_noDefaultMessage() { 25 | bundle := i18n.NewBundle(language.English) 26 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 27 | bundle.MustParseMessageFileBytes([]byte(` 28 | HelloWorld = "Hello World!" 29 | `), "en.toml") 30 | bundle.MustParseMessageFileBytes([]byte(` 31 | HelloWorld = "Hola Mundo!" 32 | `), "es.toml") 33 | 34 | { 35 | localizer := i18n.NewLocalizer(bundle, "en-US") 36 | fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "HelloWorld"})) 37 | } 38 | { 39 | localizer := i18n.NewLocalizer(bundle, "es-ES") 40 | fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "HelloWorld"})) 41 | } 42 | // Output: 43 | // Hello World! 44 | // Hola Mundo! 45 | } 46 | 47 | func ExampleLocalizer_MustLocalize_plural() { 48 | bundle := i18n.NewBundle(language.English) 49 | localizer := i18n.NewLocalizer(bundle, "en") 50 | catsMessage := &i18n.Message{ 51 | ID: "Cats", 52 | One: "I have {{.PluralCount}} cat.", 53 | Other: "I have {{.PluralCount}} cats.", 54 | } 55 | fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{ 56 | DefaultMessage: catsMessage, 57 | PluralCount: 1, 58 | })) 59 | fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{ 60 | DefaultMessage: catsMessage, 61 | PluralCount: 2, 62 | })) 63 | fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{ 64 | DefaultMessage: catsMessage, 65 | PluralCount: "2.5", 66 | })) 67 | // Output: 68 | // I have 1 cat. 69 | // I have 2 cats. 70 | // I have 2.5 cats. 71 | } 72 | 73 | func ExampleLocalizer_MustLocalize_template() { 74 | bundle := i18n.NewBundle(language.English) 75 | localizer := i18n.NewLocalizer(bundle, "en") 76 | helloPersonMessage := &i18n.Message{ 77 | ID: "HelloPerson", 78 | Other: "Hello {{.Name}}!", 79 | } 80 | fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{ 81 | DefaultMessage: helloPersonMessage, 82 | TemplateData: map[string]string{"Name": "Nick"}, 83 | })) 84 | // Output: 85 | // Hello Nick! 86 | } 87 | 88 | func ExampleLocalizer_MustLocalize_plural_template() { 89 | bundle := i18n.NewBundle(language.English) 90 | localizer := i18n.NewLocalizer(bundle, "en") 91 | personCatsMessage := &i18n.Message{ 92 | ID: "PersonCats", 93 | One: "{{.Name}} has {{.Count}} cat.", 94 | Other: "{{.Name}} has {{.Count}} cats.", 95 | } 96 | fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{ 97 | DefaultMessage: personCatsMessage, 98 | PluralCount: 1, 99 | TemplateData: map[string]interface{}{ 100 | "Name": "Nick", 101 | "Count": 1, 102 | }, 103 | })) 104 | fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{ 105 | DefaultMessage: personCatsMessage, 106 | PluralCount: 2, 107 | TemplateData: map[string]interface{}{ 108 | "Name": "Nick", 109 | "Count": 2, 110 | }, 111 | })) 112 | fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{ 113 | DefaultMessage: personCatsMessage, 114 | PluralCount: "2.5", 115 | TemplateData: map[string]interface{}{ 116 | "Name": "Nick", 117 | "Count": "2.5", 118 | }, 119 | })) 120 | // Output: 121 | // Nick has 1 cat. 122 | // Nick has 2 cats. 123 | // Nick has 2.5 cats. 124 | } 125 | 126 | func ExampleLocalizer_MustLocalize_customTemplateDelims() { 127 | bundle := i18n.NewBundle(language.English) 128 | localizer := i18n.NewLocalizer(bundle, "en") 129 | helloPersonMessage := &i18n.Message{ 130 | ID: "HelloPerson", 131 | Other: "Hello <<.Name>>!", 132 | LeftDelim: "<<", 133 | RightDelim: ">>", 134 | } 135 | fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{ 136 | DefaultMessage: helloPersonMessage, 137 | TemplateData: map[string]string{"Name": "Nick"}, 138 | })) 139 | // Output: 140 | // Hello Nick! 141 | } 142 | -------------------------------------------------------------------------------- /i18n/language_test.go: -------------------------------------------------------------------------------- 1 | package i18n_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "golang.org/x/text/language" 7 | ) 8 | 9 | var matcher language.Matcher 10 | 11 | func BenchmarkNewMatcher(b *testing.B) { 12 | langs := []language.Tag{ 13 | language.English, 14 | language.AmericanEnglish, 15 | language.BritishEnglish, 16 | language.Spanish, 17 | language.EuropeanSpanish, 18 | language.Portuguese, 19 | language.French, 20 | } 21 | b.ResetTimer() 22 | for i := 0; i < b.N; i++ { 23 | matcher = language.NewMatcher(langs) 24 | } 25 | } 26 | 27 | func BenchmarkMatchStrings(b *testing.B) { 28 | langs := []language.Tag{ 29 | language.English, 30 | language.AmericanEnglish, 31 | language.BritishEnglish, 32 | language.Spanish, 33 | language.EuropeanSpanish, 34 | language.Portuguese, 35 | language.French, 36 | } 37 | matcher := language.NewMatcher(langs) 38 | b.ResetTimer() 39 | for i := 0; i < b.N; i++ { 40 | language.MatchStrings(matcher, "en-US,en;q=0.9") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /i18n/localizer.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "fmt" 5 | texttemplate "text/template" 6 | 7 | "github.com/nicksnyder/go-i18n/v2/i18n/template" 8 | "github.com/nicksnyder/go-i18n/v2/internal/plural" 9 | "golang.org/x/text/language" 10 | ) 11 | 12 | // Localizer provides Localize and MustLocalize methods that return localized messages. 13 | // Localize and MustLocalize methods use a language.Tag matching algorithm based 14 | // on the best possible value. This algorithm may cause an unexpected language.Tag returned 15 | // value depending on the order of the tags stored in memory. For example, if the bundle 16 | // used to create a Localizer instance ingested locales following this order 17 | // ["en-US", "en-GB", "en-IE", "en"] and the locale "en" is asked, the underlying matching 18 | // algorithm will return "en-US" thinking it is the best match possible. More information 19 | // about the algorithm in this Github issue: https://github.com/golang/go/issues/49176. 20 | // There is additional informations inside the Go code base: 21 | // https://github.com/golang/text/blob/master/language/match.go#L142 22 | type Localizer struct { 23 | // bundle contains the messages that can be returned by the Localizer. 24 | bundle *Bundle 25 | 26 | // tags is the list of language tags that the Localizer checks 27 | // in order when localizing a message. 28 | tags []language.Tag 29 | } 30 | 31 | // NewLocalizer returns a new Localizer that looks up messages 32 | // in the bundle according to the language preferences in langs. 33 | // It can parse Accept-Language headers as defined in http://www.ietf.org/rfc/rfc2616.txt. 34 | func NewLocalizer(bundle *Bundle, langs ...string) *Localizer { 35 | return &Localizer{ 36 | bundle: bundle, 37 | tags: parseTags(langs), 38 | } 39 | } 40 | 41 | func parseTags(langs []string) []language.Tag { 42 | tags := []language.Tag{} 43 | for _, lang := range langs { 44 | t, _, err := language.ParseAcceptLanguage(lang) 45 | if err != nil { 46 | continue 47 | } 48 | tags = append(tags, t...) 49 | } 50 | return tags 51 | } 52 | 53 | // LocalizeConfig configures a call to the Localize method on Localizer. 54 | type LocalizeConfig struct { 55 | // MessageID is the id of the message to lookup. 56 | // This field is ignored if DefaultMessage is set. 57 | MessageID string 58 | 59 | // TemplateData is the data passed when executing the message's template. 60 | // If TemplateData is nil and PluralCount is not nil, then the message template 61 | // will be executed with data that contains the plural count. 62 | TemplateData interface{} 63 | 64 | // PluralCount determines which plural form of the message is used. 65 | PluralCount interface{} 66 | 67 | // DefaultMessage is used if the message is not found in any message files. 68 | DefaultMessage *Message 69 | 70 | // Funcs is used to configure a template.TextParser if TemplateParser is not set. 71 | Funcs texttemplate.FuncMap 72 | 73 | // The TemplateParser to use for parsing templates. 74 | // If one is not set, a template.TextParser is used (configured with Funcs if it is set). 75 | TemplateParser template.Parser 76 | } 77 | 78 | var defaultTextParser = &template.TextParser{} 79 | 80 | func (lc *LocalizeConfig) getTemplateParser() template.Parser { 81 | if lc.TemplateParser != nil { 82 | return lc.TemplateParser 83 | } 84 | if lc.Funcs != nil { 85 | return &template.TextParser{ 86 | Funcs: lc.Funcs, 87 | } 88 | } 89 | return defaultTextParser 90 | } 91 | 92 | type invalidPluralCountErr struct { 93 | messageID string 94 | pluralCount interface{} 95 | err error 96 | } 97 | 98 | func (e *invalidPluralCountErr) Error() string { 99 | return fmt.Sprintf("invalid plural count %#v for message id %q: %s", e.pluralCount, e.messageID, e.err) 100 | } 101 | 102 | // MessageNotFoundErr is returned from Localize when a message could not be found. 103 | type MessageNotFoundErr struct { 104 | Tag language.Tag 105 | MessageID string 106 | } 107 | 108 | func (e *MessageNotFoundErr) Error() string { 109 | return fmt.Sprintf("message %q not found in language %q", e.MessageID, e.Tag) 110 | } 111 | 112 | type messageIDMismatchErr struct { 113 | messageID string 114 | defaultMessageID string 115 | } 116 | 117 | func (e *messageIDMismatchErr) Error() string { 118 | return fmt.Sprintf("message id %q does not match default message id %q", e.messageID, e.defaultMessageID) 119 | } 120 | 121 | // Localize returns a localized message. 122 | func (l *Localizer) Localize(lc *LocalizeConfig) (string, error) { 123 | msg, _, err := l.LocalizeWithTag(lc) 124 | return msg, err 125 | } 126 | 127 | // LocalizeMessage returns a localized message. 128 | func (l *Localizer) LocalizeMessage(msg *Message) (string, error) { 129 | return l.Localize(&LocalizeConfig{ 130 | DefaultMessage: msg, 131 | }) 132 | } 133 | 134 | // TODO: uncomment this (and the test) when extract has been updated to extract these call sites too. 135 | // Localize returns a localized message. 136 | // func (l *Localizer) LocalizeMessageID(messageID string) (string, error) { 137 | // return l.Localize(&LocalizeConfig{ 138 | // MessageID: messageID, 139 | // }) 140 | // } 141 | 142 | // LocalizeWithTag returns a localized message and the language tag. 143 | // It may return a best effort localized message even if an error happens. 144 | func (l *Localizer) LocalizeWithTag(lc *LocalizeConfig) (string, language.Tag, error) { 145 | messageID := lc.MessageID 146 | if lc.DefaultMessage != nil { 147 | if messageID != "" && messageID != lc.DefaultMessage.ID { 148 | return "", language.Und, &messageIDMismatchErr{messageID: messageID, defaultMessageID: lc.DefaultMessage.ID} 149 | } 150 | messageID = lc.DefaultMessage.ID 151 | } 152 | 153 | var operands *plural.Operands 154 | templateData := lc.TemplateData 155 | if lc.PluralCount != nil { 156 | var err error 157 | operands, err = plural.NewOperands(lc.PluralCount) 158 | if err != nil { 159 | return "", language.Und, &invalidPluralCountErr{messageID: messageID, pluralCount: lc.PluralCount, err: err} 160 | } 161 | if templateData == nil { 162 | templateData = map[string]interface{}{ 163 | "PluralCount": lc.PluralCount, 164 | } 165 | } 166 | } 167 | 168 | tag, template, err := l.getMessageTemplate(messageID, lc.DefaultMessage) 169 | if template == nil { 170 | return "", language.Und, err 171 | } 172 | 173 | pluralForm := l.pluralForm(tag, operands) 174 | templateParser := lc.getTemplateParser() 175 | msg, err2 := template.execute(pluralForm, templateData, templateParser) 176 | if err2 != nil { 177 | if err == nil { 178 | err = err2 179 | } 180 | 181 | // Attempt to fallback to "Other" pluralization in case translations are incomplete. 182 | if pluralForm != plural.Other { 183 | msg2, err3 := template.execute(plural.Other, templateData, templateParser) 184 | if err3 == nil { 185 | msg = msg2 186 | } 187 | } 188 | } 189 | return msg, tag, err 190 | } 191 | 192 | func (l *Localizer) getMessageTemplate(id string, defaultMessage *Message) (language.Tag, *MessageTemplate, error) { 193 | _, i, _ := l.bundle.matcher.Match(l.tags...) 194 | tag := l.bundle.tags[i] 195 | mt := l.bundle.getMessageTemplate(tag, id) 196 | if mt != nil { 197 | return tag, mt, nil 198 | } 199 | 200 | if tag == l.bundle.defaultLanguage { 201 | if defaultMessage == nil { 202 | return language.Und, nil, &MessageNotFoundErr{Tag: tag, MessageID: id} 203 | } 204 | mt := NewMessageTemplate(defaultMessage) 205 | if mt == nil { 206 | return language.Und, nil, &MessageNotFoundErr{Tag: tag, MessageID: id} 207 | } 208 | return tag, mt, nil 209 | } 210 | 211 | // Fallback to default language in bundle. 212 | mt = l.bundle.getMessageTemplate(l.bundle.defaultLanguage, id) 213 | if mt != nil { 214 | return l.bundle.defaultLanguage, mt, &MessageNotFoundErr{Tag: tag, MessageID: id} 215 | } 216 | 217 | // Fallback to default message. 218 | if defaultMessage == nil { 219 | return language.Und, nil, &MessageNotFoundErr{Tag: tag, MessageID: id} 220 | } 221 | return l.bundle.defaultLanguage, NewMessageTemplate(defaultMessage), &MessageNotFoundErr{Tag: tag, MessageID: id} 222 | } 223 | 224 | func (l *Localizer) pluralForm(tag language.Tag, operands *plural.Operands) plural.Form { 225 | if operands == nil { 226 | return plural.Other 227 | } 228 | return l.bundle.pluralRules.Rule(tag).PluralFormFunc(operands) 229 | } 230 | 231 | // MustLocalize is similar to Localize, except it panics if an error happens. 232 | func (l *Localizer) MustLocalize(lc *LocalizeConfig) string { 233 | localized, err := l.Localize(lc) 234 | if err != nil { 235 | panic(err) 236 | } 237 | return localized 238 | } 239 | 240 | // MustLocalizeMessage is similar to LocalizeMessage, except it panics if an error happens. 241 | func (l *Localizer) MustLocalizeMessage(msg *Message) string { 242 | localized, err := l.LocalizeMessage(msg) 243 | if err != nil { 244 | panic(err) 245 | } 246 | return localized 247 | } 248 | -------------------------------------------------------------------------------- /i18n/message.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | // Message is a string that can be localized. 10 | type Message struct { 11 | // ID uniquely identifies the message. 12 | ID string 13 | 14 | // Hash uniquely identifies the content of the message 15 | // that this message was translated from. 16 | Hash string 17 | 18 | // Description describes the message to give additional 19 | // context to translators that may be relevant for translation. 20 | Description string 21 | 22 | // LeftDelim is the left Go template delimiter. 23 | LeftDelim string 24 | 25 | // RightDelim is the right Go template delimiter. 26 | RightDelim string 27 | 28 | // Zero is the content of the message for the CLDR plural form "zero". 29 | Zero string 30 | 31 | // One is the content of the message for the CLDR plural form "one". 32 | One string 33 | 34 | // Two is the content of the message for the CLDR plural form "two". 35 | Two string 36 | 37 | // Few is the content of the message for the CLDR plural form "few". 38 | Few string 39 | 40 | // Many is the content of the message for the CLDR plural form "many". 41 | Many string 42 | 43 | // Other is the content of the message for the CLDR plural form "other". 44 | Other string 45 | } 46 | 47 | // NewMessage parses data and returns a new message. 48 | func NewMessage(data interface{}) (*Message, error) { 49 | m := &Message{} 50 | if err := m.unmarshalInterface(data); err != nil { 51 | return nil, err 52 | } 53 | return m, nil 54 | } 55 | 56 | // MustNewMessage is similar to NewMessage except it panics if an error happens. 57 | func MustNewMessage(data interface{}) *Message { 58 | m, err := NewMessage(data) 59 | if err != nil { 60 | panic(err) 61 | } 62 | return m 63 | } 64 | 65 | // unmarshalInterface unmarshals a message from data. 66 | func (m *Message) unmarshalInterface(v interface{}) error { 67 | strdata, err := stringMap(v) 68 | if err != nil { 69 | return err 70 | } 71 | for k, v := range strdata { 72 | switch strings.ToLower(k) { 73 | case "id": 74 | m.ID = v 75 | case "description": 76 | m.Description = v 77 | case "hash": 78 | m.Hash = v 79 | case "leftdelim": 80 | m.LeftDelim = v 81 | case "rightdelim": 82 | m.RightDelim = v 83 | case "zero": 84 | m.Zero = v 85 | case "one": 86 | m.One = v 87 | case "two": 88 | m.Two = v 89 | case "few": 90 | m.Few = v 91 | case "many": 92 | m.Many = v 93 | case "other": 94 | m.Other = v 95 | } 96 | } 97 | return nil 98 | } 99 | 100 | type keyTypeErr struct { 101 | key interface{} 102 | } 103 | 104 | func (err *keyTypeErr) Error() string { 105 | return fmt.Sprintf("expected key to be a string but got %#v", err.key) 106 | } 107 | 108 | type valueTypeErr struct { 109 | value interface{} 110 | } 111 | 112 | func (err *valueTypeErr) Error() string { 113 | return fmt.Sprintf("unsupported type %#v", err.value) 114 | } 115 | 116 | func stringMap(v interface{}) (map[string]string, error) { 117 | switch value := v.(type) { 118 | case string: 119 | return map[string]string{ 120 | "other": value, 121 | }, nil 122 | case map[string]string: 123 | return value, nil 124 | case map[string]interface{}: 125 | strdata := make(map[string]string, len(value)) 126 | for k, v := range value { 127 | err := stringSubmap(k, v, strdata) 128 | if err != nil { 129 | return nil, err 130 | } 131 | } 132 | return strdata, nil 133 | case map[interface{}]interface{}: 134 | strdata := make(map[string]string, len(value)) 135 | for k, v := range value { 136 | kstr, ok := k.(string) 137 | if !ok { 138 | return nil, &keyTypeErr{key: k} 139 | } 140 | err := stringSubmap(kstr, v, strdata) 141 | if err != nil { 142 | return nil, err 143 | } 144 | } 145 | return strdata, nil 146 | default: 147 | return nil, &valueTypeErr{value: value} 148 | } 149 | } 150 | 151 | func stringSubmap(k string, v interface{}, strdata map[string]string) error { 152 | if k == "translation" { 153 | switch vt := v.(type) { 154 | case string: 155 | strdata["other"] = vt 156 | default: 157 | v1Message, err := stringMap(v) 158 | if err != nil { 159 | return err 160 | } 161 | for kk, vv := range v1Message { 162 | strdata[kk] = vv 163 | } 164 | } 165 | return nil 166 | } 167 | 168 | switch vt := v.(type) { 169 | case string: 170 | strdata[k] = vt 171 | return nil 172 | case nil: 173 | return nil 174 | default: 175 | return fmt.Errorf("expected value for key %q be a string but got %#v", k, v) 176 | } 177 | } 178 | 179 | var reservedKeys = map[string]struct{}{ 180 | "id": {}, 181 | "description": {}, 182 | "hash": {}, 183 | "leftdelim": {}, 184 | "rightdelim": {}, 185 | "zero": {}, 186 | "one": {}, 187 | "two": {}, 188 | "few": {}, 189 | "many": {}, 190 | "other": {}, 191 | "translation": {}, 192 | } 193 | 194 | func isReserved(key string, val any) bool { 195 | lk := strings.ToLower(key) 196 | if _, ok := reservedKeys[lk]; ok { 197 | if key == "translation" { 198 | return true 199 | } 200 | if _, ok := val.(string); ok { 201 | return true 202 | } 203 | } 204 | return false 205 | } 206 | 207 | // isMessage returns true if v contains only message keys and false if it contains no message keys. 208 | // It returns an error if v contains both message and non-message keys. 209 | // - {"message": {"description": "world"}} is a message 210 | // - {"error": {"description": "world", "foo": "bar"}} is an error 211 | // - {"notmessage": {"description": {"hello": "world"}}} is not a message 212 | // - {"notmessage": {"foo": "bar"}} is not a message 213 | func isMessage(v interface{}) (bool, error) { 214 | switch data := v.(type) { 215 | case nil, string: 216 | return true, nil 217 | case map[string]interface{}: 218 | reservedKeys := make([]string, 0, len(reservedKeys)) 219 | unreservedKeys := make([]string, 0, len(data)) 220 | for k, v := range data { 221 | if isReserved(k, v) { 222 | reservedKeys = append(reservedKeys, k) 223 | } else { 224 | unreservedKeys = append(unreservedKeys, k) 225 | } 226 | } 227 | hasReservedKeys := len(reservedKeys) > 0 228 | if hasReservedKeys && len(unreservedKeys) > 0 { 229 | return false, &mixedKeysError{ 230 | reservedKeys: reservedKeys, 231 | unreservedKeys: unreservedKeys, 232 | } 233 | } 234 | return hasReservedKeys, nil 235 | case map[interface{}]interface{}: 236 | reservedKeys := make([]string, 0, len(reservedKeys)) 237 | unreservedKeys := make([]string, 0, len(data)) 238 | for key, v := range data { 239 | k, ok := key.(string) 240 | if !ok { 241 | unreservedKeys = append(unreservedKeys, fmt.Sprintf("%+v", key)) 242 | } else if isReserved(k, v) { 243 | reservedKeys = append(reservedKeys, k) 244 | } else { 245 | unreservedKeys = append(unreservedKeys, k) 246 | } 247 | } 248 | hasReservedKeys := len(reservedKeys) > 0 249 | if hasReservedKeys && len(unreservedKeys) > 0 { 250 | return false, &mixedKeysError{ 251 | reservedKeys: reservedKeys, 252 | unreservedKeys: unreservedKeys, 253 | } 254 | } 255 | return hasReservedKeys, nil 256 | } 257 | return false, nil 258 | } 259 | 260 | type mixedKeysError struct { 261 | reservedKeys []string 262 | unreservedKeys []string 263 | } 264 | 265 | func (e *mixedKeysError) Error() string { 266 | sort.Strings(e.reservedKeys) 267 | sort.Strings(e.unreservedKeys) 268 | return fmt.Sprintf("reserved keys %v mixed with unreserved keys %v", e.reservedKeys, e.unreservedKeys) 269 | } 270 | -------------------------------------------------------------------------------- /i18n/message_template.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "fmt" 5 | texttemplate "text/template" 6 | 7 | "github.com/nicksnyder/go-i18n/v2/i18n/template" 8 | "github.com/nicksnyder/go-i18n/v2/internal" 9 | "github.com/nicksnyder/go-i18n/v2/internal/plural" 10 | ) 11 | 12 | // MessageTemplate is an executable template for a message. 13 | type MessageTemplate struct { 14 | *Message 15 | PluralTemplates map[plural.Form]*internal.Template 16 | } 17 | 18 | // NewMessageTemplate returns a new message template. 19 | func NewMessageTemplate(m *Message) *MessageTemplate { 20 | pluralTemplates := map[plural.Form]*internal.Template{} 21 | setPluralTemplate(pluralTemplates, plural.Zero, m.Zero, m.LeftDelim, m.RightDelim) 22 | setPluralTemplate(pluralTemplates, plural.One, m.One, m.LeftDelim, m.RightDelim) 23 | setPluralTemplate(pluralTemplates, plural.Two, m.Two, m.LeftDelim, m.RightDelim) 24 | setPluralTemplate(pluralTemplates, plural.Few, m.Few, m.LeftDelim, m.RightDelim) 25 | setPluralTemplate(pluralTemplates, plural.Many, m.Many, m.LeftDelim, m.RightDelim) 26 | setPluralTemplate(pluralTemplates, plural.Other, m.Other, m.LeftDelim, m.RightDelim) 27 | if len(pluralTemplates) == 0 { 28 | return nil 29 | } 30 | return &MessageTemplate{ 31 | Message: m, 32 | PluralTemplates: pluralTemplates, 33 | } 34 | } 35 | 36 | func setPluralTemplate(pluralTemplates map[plural.Form]*internal.Template, pluralForm plural.Form, src, leftDelim, rightDelim string) { 37 | if src != "" { 38 | pluralTemplates[pluralForm] = &internal.Template{ 39 | Src: src, 40 | LeftDelim: leftDelim, 41 | RightDelim: rightDelim, 42 | } 43 | } 44 | } 45 | 46 | type pluralFormNotFoundError struct { 47 | pluralForm plural.Form 48 | messageID string 49 | } 50 | 51 | func (e pluralFormNotFoundError) Error() string { 52 | return fmt.Sprintf("message %q has no plural form %q", e.messageID, e.pluralForm) 53 | } 54 | 55 | // Execute executes the template for the plural form and template data. 56 | // Deprecated: This method is no longer used internally by go-i18n and it probably should not have been exported to 57 | // begin with. Its replacement is not exported. If you depend on this method for some reason and/or have 58 | // a use case for exporting execute, please file an issue. 59 | func (mt *MessageTemplate) Execute(pluralForm plural.Form, data interface{}, funcs texttemplate.FuncMap) (string, error) { 60 | t := mt.PluralTemplates[pluralForm] 61 | if t == nil { 62 | return "", pluralFormNotFoundError{ 63 | pluralForm: pluralForm, 64 | messageID: mt.ID, 65 | } 66 | } 67 | parser := &template.TextParser{ 68 | Funcs: funcs, 69 | } 70 | return t.Execute(parser, data) 71 | } 72 | 73 | func (mt *MessageTemplate) execute(pluralForm plural.Form, data interface{}, parser template.Parser) (string, error) { 74 | t := mt.PluralTemplates[pluralForm] 75 | if t == nil { 76 | return "", pluralFormNotFoundError{ 77 | pluralForm: pluralForm, 78 | messageID: mt.ID, 79 | } 80 | } 81 | return t.Execute(parser, data) 82 | } 83 | -------------------------------------------------------------------------------- /i18n/message_template_test.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/nicksnyder/go-i18n/v2/internal/plural" 8 | ) 9 | 10 | func TestMessageTemplate(t *testing.T) { 11 | mt := NewMessageTemplate(&Message{ID: "HelloWorld", Other: "Hello World"}) 12 | if mt.PluralTemplates[plural.Other].Src != "Hello World" { 13 | t.Fatal(mt.PluralTemplates) 14 | } 15 | } 16 | 17 | func TestNilMessageTemplate(t *testing.T) { 18 | if mt := NewMessageTemplate(&Message{ID: "HelloWorld"}); mt != nil { 19 | t.Fatal(mt) 20 | } 21 | } 22 | 23 | func TestMessageTemplatePluralFormMissing(t *testing.T) { 24 | mt := NewMessageTemplate(&Message{ID: "HelloWorld", Other: "Hello World"}) 25 | s, err := mt.Execute(plural.Few, nil, nil) 26 | if s != "" { 27 | t.Errorf("expected %q; got %q", "", s) 28 | } 29 | expectedErr := pluralFormNotFoundError{pluralForm: plural.Few, messageID: "HelloWorld"} 30 | if !reflect.DeepEqual(err, expectedErr) { 31 | t.Errorf("expected error %#v; got %#v", expectedErr, err) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /i18n/message_test.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestNewMessage(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | data interface{} 12 | message *Message 13 | err error 14 | }{ 15 | { 16 | name: "string", 17 | data: "other", 18 | message: &Message{ 19 | Other: "other", 20 | }, 21 | }, 22 | { 23 | name: "nil value", 24 | data: map[interface{}]interface{}{ 25 | "ID": "id", 26 | "Zero": nil, 27 | "Other": "other", 28 | }, 29 | message: &Message{ 30 | ID: "id", 31 | Other: "other", 32 | }, 33 | }, 34 | { 35 | name: "map[string]string", 36 | data: map[string]string{ 37 | "ID": "id", 38 | "Hash": "hash", 39 | "Description": "description", 40 | "LeftDelim": "leftdelim", 41 | "RightDelim": "rightdelim", 42 | "Zero": "zero", 43 | "One": "one", 44 | "Two": "two", 45 | "Few": "few", 46 | "Many": "many", 47 | "Other": "other", 48 | }, 49 | message: &Message{ 50 | ID: "id", 51 | Hash: "hash", 52 | Description: "description", 53 | LeftDelim: "leftdelim", 54 | RightDelim: "rightdelim", 55 | Zero: "zero", 56 | One: "one", 57 | Two: "two", 58 | Few: "few", 59 | Many: "many", 60 | Other: "other", 61 | }, 62 | }, 63 | { 64 | name: "map[string]interface{}", 65 | data: map[string]interface{}{ 66 | "ID": "id", 67 | "Hash": "hash", 68 | "Description": "description", 69 | "LeftDelim": "leftdelim", 70 | "RightDelim": "rightdelim", 71 | "Zero": "zero", 72 | "One": "one", 73 | "Two": "two", 74 | "Few": "few", 75 | "Many": "many", 76 | "Other": "other", 77 | }, 78 | message: &Message{ 79 | ID: "id", 80 | Hash: "hash", 81 | Description: "description", 82 | LeftDelim: "leftdelim", 83 | RightDelim: "rightdelim", 84 | Zero: "zero", 85 | One: "one", 86 | Two: "two", 87 | Few: "few", 88 | Many: "many", 89 | Other: "other", 90 | }, 91 | }, 92 | { 93 | name: "map[interface{}]interface{}", 94 | data: map[interface{}]interface{}{ 95 | "ID": "id", 96 | "Hash": "hash", 97 | "Description": "description", 98 | "LeftDelim": "leftdelim", 99 | "RightDelim": "rightdelim", 100 | "Zero": "zero", 101 | "One": "one", 102 | "Two": "two", 103 | "Few": "few", 104 | "Many": "many", 105 | "Other": "other", 106 | }, 107 | message: &Message{ 108 | ID: "id", 109 | Hash: "hash", 110 | Description: "description", 111 | LeftDelim: "leftdelim", 112 | RightDelim: "rightdelim", 113 | Zero: "zero", 114 | One: "one", 115 | Two: "two", 116 | Few: "few", 117 | Many: "many", 118 | Other: "other", 119 | }, 120 | }, 121 | { 122 | name: "map[int]int", 123 | data: map[interface{}]interface{}{ 124 | 1: 2, 125 | }, 126 | err: &keyTypeErr{key: 1}, 127 | }, 128 | { 129 | name: "int", 130 | data: 1, 131 | err: &valueTypeErr{value: 1}, 132 | }, 133 | } 134 | 135 | for _, test := range tests { 136 | t.Run(test.name, func(t *testing.T) { 137 | actual, err := NewMessage(test.data) 138 | if !reflect.DeepEqual(err, test.err) { 139 | t.Fatalf("expected %#v; got %#v", test.err, err) 140 | } 141 | if !reflect.DeepEqual(actual, test.message) { 142 | t.Fatalf("\nexpected\n%#v\ngot\n%#v", test.message, actual) 143 | } 144 | }) 145 | } 146 | } 147 | 148 | func TestKeyTypeErr(t *testing.T) { 149 | expected := "expected key to be a string but got 1" 150 | if actual := (&keyTypeErr{key: 1}).Error(); actual != expected { 151 | t.Fatalf("expected %#v; got %#v", expected, actual) 152 | } 153 | } 154 | 155 | func TestValueTypeErr(t *testing.T) { 156 | expected := "unsupported type 1" 157 | if actual := (&valueTypeErr{value: 1}).Error(); actual != expected { 158 | t.Fatalf("expected %#v; got %#v", expected, actual) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /i18n/parse.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "golang.org/x/text/language" 10 | ) 11 | 12 | // MessageFile represents a parsed message file. 13 | type MessageFile struct { 14 | Path string 15 | Tag language.Tag 16 | Format string 17 | Messages []*Message 18 | } 19 | 20 | // ParseMessageFileBytes returns the messages parsed from file. 21 | func ParseMessageFileBytes(buf []byte, path string, unmarshalFuncs map[string]UnmarshalFunc) (*MessageFile, error) { 22 | lang, format := parsePath(path) 23 | tag := language.Make(lang) 24 | messageFile := &MessageFile{ 25 | Path: path, 26 | Tag: tag, 27 | Format: format, 28 | } 29 | if len(buf) == 0 { 30 | return messageFile, nil 31 | } 32 | unmarshalFunc := unmarshalFuncs[messageFile.Format] 33 | if unmarshalFunc == nil { 34 | if messageFile.Format == "json" { 35 | unmarshalFunc = json.Unmarshal 36 | } else { 37 | return nil, fmt.Errorf("no unmarshaler registered for %s", messageFile.Format) 38 | } 39 | } 40 | var err error 41 | var raw interface{} 42 | if err = unmarshalFunc(buf, &raw); err != nil { 43 | return nil, err 44 | } 45 | 46 | m, err := isMessage(raw) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | if messageFile.Messages, err = recGetMessages(raw, m, true); err != nil { 52 | return nil, err 53 | } 54 | 55 | return messageFile, nil 56 | } 57 | 58 | const nestedSeparator = "." 59 | 60 | var errInvalidTranslationFile = errors.New("invalid translation file, expected key-values, got a single value") 61 | 62 | // recGetMessages looks for translation messages inside "raw" parameter, 63 | // scanning nested maps using recursion. 64 | func recGetMessages(raw interface{}, isMapMessage, isInitialCall bool) ([]*Message, error) { 65 | var messages []*Message 66 | var err error 67 | 68 | switch data := raw.(type) { 69 | case string: 70 | if isInitialCall { 71 | return nil, errInvalidTranslationFile 72 | } 73 | m, err := NewMessage(data) 74 | return []*Message{m}, err 75 | 76 | case map[string]interface{}: 77 | if isMapMessage { 78 | m, err := NewMessage(data) 79 | return []*Message{m}, err 80 | } 81 | messages = make([]*Message, 0, len(data)) 82 | for id, data := range data { 83 | // recursively scan map items 84 | messages, err = addChildMessages(id, data, messages) 85 | if err != nil { 86 | return nil, err 87 | } 88 | } 89 | 90 | case map[interface{}]interface{}: 91 | if isMapMessage { 92 | m, err := NewMessage(data) 93 | return []*Message{m}, err 94 | } 95 | messages = make([]*Message, 0, len(data)) 96 | for id, data := range data { 97 | strid, ok := id.(string) 98 | if !ok { 99 | return nil, fmt.Errorf("expected key to be string but got %#v", id) 100 | } 101 | // recursively scan map items 102 | messages, err = addChildMessages(strid, data, messages) 103 | if err != nil { 104 | return nil, err 105 | } 106 | } 107 | 108 | case []interface{}: 109 | // Backward compatibility for v1 file format. 110 | messages = make([]*Message, 0, len(data)) 111 | for _, data := range data { 112 | // recursively scan slice items 113 | m, err := isMessage(data) 114 | if err != nil { 115 | return nil, err 116 | } 117 | childMessages, err := recGetMessages(data, m, false) 118 | if err != nil { 119 | return nil, err 120 | } 121 | messages = append(messages, childMessages...) 122 | } 123 | 124 | case nil: 125 | if isInitialCall { 126 | return nil, errInvalidTranslationFile 127 | } 128 | m, err := NewMessage("") 129 | return []*Message{m}, err 130 | 131 | default: 132 | return nil, fmt.Errorf("unsupported file format %T", raw) 133 | } 134 | 135 | return messages, nil 136 | } 137 | 138 | func addChildMessages(id string, data interface{}, messages []*Message) ([]*Message, error) { 139 | isChildMessage, err := isMessage(data) 140 | if err != nil { 141 | return nil, err 142 | } 143 | childMessages, err := recGetMessages(data, isChildMessage, false) 144 | if err != nil { 145 | return nil, err 146 | } 147 | for _, m := range childMessages { 148 | if isChildMessage { 149 | if m.ID == "" { 150 | m.ID = id // start with innermost key 151 | } 152 | } else { 153 | m.ID = id + nestedSeparator + m.ID // update ID with each nested key on the way 154 | } 155 | messages = append(messages, m) 156 | } 157 | return messages, nil 158 | } 159 | 160 | func parsePath(path string) (langTag, format string) { 161 | formatStartIdx := -1 162 | for i := len(path) - 1; i >= 0; i-- { 163 | c := path[i] 164 | if os.IsPathSeparator(c) { 165 | if formatStartIdx != -1 { 166 | langTag = path[i+1 : formatStartIdx] 167 | } 168 | return 169 | } 170 | if path[i] == '.' { 171 | if formatStartIdx != -1 { 172 | langTag = path[i+1 : formatStartIdx] 173 | return 174 | } 175 | if formatStartIdx == -1 { 176 | format = path[i+1:] 177 | formatStartIdx = i 178 | } 179 | } 180 | } 181 | if formatStartIdx != -1 { 182 | langTag = path[:formatStartIdx] 183 | } 184 | return 185 | } 186 | -------------------------------------------------------------------------------- /i18n/parse_test.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "sort" 7 | "testing" 8 | 9 | "golang.org/x/text/language" 10 | yaml "gopkg.in/yaml.v3" 11 | ) 12 | 13 | func TestParseMessageFileBytes(t *testing.T) { 14 | testCases := []struct { 15 | name string 16 | file string 17 | path string 18 | unmarshalFuncs map[string]UnmarshalFunc 19 | messageFile *MessageFile 20 | err error 21 | }{ 22 | { 23 | name: "basic test", 24 | file: `{"hello": "world"}`, 25 | path: "en.json", 26 | messageFile: &MessageFile{ 27 | Path: "en.json", 28 | Tag: language.English, 29 | Format: "json", 30 | Messages: []*Message{{ 31 | ID: "hello", 32 | Other: "world", 33 | }}, 34 | }, 35 | }, 36 | { 37 | name: "nested with reserved key", 38 | file: `{"nested": {"description": {"other": "world"}}}`, 39 | path: "en.json", 40 | messageFile: &MessageFile{ 41 | Path: "en.json", 42 | Tag: language.English, 43 | Format: "json", 44 | Messages: []*Message{{ 45 | ID: "nested.description", 46 | Other: "world", 47 | }}, 48 | }, 49 | }, 50 | { 51 | name: "basic test reserved key top level", 52 | file: `{"other": "world", "foo": "bar"}`, 53 | path: "en.json", 54 | err: &mixedKeysError{ 55 | reservedKeys: []string{"other"}, 56 | unreservedKeys: []string{"foo"}, 57 | }, 58 | }, 59 | { 60 | name: "basic test with dot separator in key", 61 | file: `{"prepended.hello": "world"}`, 62 | path: "en.json", 63 | messageFile: &MessageFile{ 64 | Path: "en.json", 65 | Tag: language.English, 66 | Format: "json", 67 | Messages: []*Message{{ 68 | ID: "prepended.hello", 69 | Other: "world", 70 | }}, 71 | }, 72 | }, 73 | { 74 | name: "invalid test (no key)", 75 | file: `"hello"`, 76 | path: "en.json", 77 | err: errInvalidTranslationFile, 78 | }, 79 | { 80 | name: "nested test", 81 | file: `{"nested": {"hello": "world"}}`, 82 | path: "en.json", 83 | messageFile: &MessageFile{ 84 | Path: "en.json", 85 | Tag: language.English, 86 | Format: "json", 87 | Messages: []*Message{{ 88 | ID: "nested.hello", 89 | Other: "world", 90 | }}, 91 | }, 92 | }, 93 | { 94 | name: "basic test with description", 95 | file: `{"notnested": {"description": "world"}}`, 96 | path: "en.json", 97 | messageFile: &MessageFile{ 98 | Path: "en.json", 99 | Tag: language.English, 100 | Format: "json", 101 | Messages: []*Message{{ 102 | ID: "notnested", 103 | Description: "world", 104 | }}, 105 | }, 106 | }, 107 | { 108 | name: "basic test with id", 109 | file: `{"key": {"id": "forced.id"}}`, 110 | path: "en.json", 111 | messageFile: &MessageFile{ 112 | Path: "en.json", 113 | Tag: language.English, 114 | Format: "json", 115 | Messages: []*Message{{ 116 | ID: "forced.id", 117 | }}, 118 | }, 119 | }, 120 | { 121 | name: "basic test with description and dummy", 122 | file: `{"notnested": {"description": "world", "dummy": "nothing"}}`, 123 | path: "en.json", 124 | err: &mixedKeysError{ 125 | reservedKeys: []string{"description"}, 126 | unreservedKeys: []string{"dummy"}, 127 | }, 128 | }, 129 | { 130 | name: "deeply nested test", 131 | file: `{"outer": {"nested": {"inner": "value"}}}`, 132 | path: "en.json", 133 | messageFile: &MessageFile{ 134 | Path: "en.json", 135 | Tag: language.English, 136 | Format: "json", 137 | Messages: []*Message{{ 138 | ID: "outer.nested.inner", 139 | Other: "value", 140 | }}, 141 | }, 142 | }, 143 | { 144 | name: "multiple nested test", 145 | file: `{"nested": {"hello": "world", "bye": "all"}}`, 146 | path: "en.json", 147 | messageFile: &MessageFile{ 148 | Path: "en.json", 149 | Tag: language.English, 150 | Format: "json", 151 | Messages: []*Message{{ 152 | ID: "nested.hello", 153 | Other: "world", 154 | }, { 155 | ID: "nested.bye", 156 | Other: "all", 157 | }}, 158 | }, 159 | }, 160 | { 161 | name: "YAML nested test", 162 | file: ` 163 | outer: 164 | nested: 165 | inner: "value"`, 166 | path: "en.yaml", 167 | unmarshalFuncs: map[string]UnmarshalFunc{"yaml": yaml.Unmarshal}, 168 | messageFile: &MessageFile{ 169 | Path: "en.yaml", 170 | Tag: language.English, 171 | Format: "yaml", 172 | Messages: []*Message{{ 173 | ID: "outer.nested.inner", 174 | Other: "value", 175 | }}, 176 | }, 177 | }, 178 | { 179 | name: "YAML empty key test", 180 | file: ` 181 | some-keys: 182 | non-empty-key: not empty 183 | empty-key-but-type-specified: "" 184 | empty-key: 185 | null-key: null`, 186 | path: "en.yaml", 187 | unmarshalFuncs: map[string]UnmarshalFunc{"yaml": yaml.Unmarshal}, 188 | messageFile: &MessageFile{ 189 | Path: "en.yaml", 190 | Tag: language.English, 191 | Format: "yaml", 192 | Messages: []*Message{ 193 | { 194 | ID: "some-keys.non-empty-key", 195 | Other: "not empty", 196 | }, 197 | { 198 | ID: "some-keys.empty-key-but-type-specified", 199 | }, 200 | { 201 | ID: "some-keys.empty-key", 202 | }, 203 | { 204 | ID: "some-keys.null-key", 205 | }, 206 | }, 207 | }, 208 | }, 209 | { 210 | name: "YAML number key test", 211 | file: ` 212 | some-keys: 213 | hello: world 214 | 2: legit`, 215 | path: "en.yaml", 216 | unmarshalFuncs: map[string]UnmarshalFunc{"yaml": yaml.Unmarshal}, 217 | err: errors.New("expected key to be string but got 2"), 218 | }, 219 | { 220 | name: "YAML extra number key test", 221 | file: ` 222 | some-keys: 223 | other: world 224 | 2: legit`, 225 | path: "en.yaml", 226 | unmarshalFuncs: map[string]UnmarshalFunc{"yaml": yaml.Unmarshal}, 227 | err: &mixedKeysError{ 228 | reservedKeys: []string{"other"}, 229 | unreservedKeys: []string{"2"}, 230 | }, 231 | }, 232 | } 233 | for _, testCase := range testCases { 234 | t.Run(testCase.name, func(t *testing.T) { 235 | actual, err := ParseMessageFileBytes([]byte(testCase.file), testCase.path, testCase.unmarshalFuncs) 236 | if (err == nil && testCase.err != nil) || 237 | (err != nil && testCase.err == nil) || 238 | (err != nil && testCase.err != nil && err.Error() != testCase.err.Error()) { 239 | t.Fatalf("expected error %#v; got %#v", testCase.err, err) 240 | } 241 | if testCase.messageFile == nil && actual != nil || testCase.messageFile != nil && actual == nil { 242 | t.Fatalf("expected message file %#v; got %#v", testCase.messageFile, actual) 243 | } 244 | if testCase.messageFile != nil { 245 | if actual.Path != testCase.messageFile.Path { 246 | t.Errorf("expected path %q; got %q", testCase.messageFile.Path, actual.Path) 247 | } 248 | if actual.Tag != testCase.messageFile.Tag { 249 | t.Errorf("expected tag %q; got %q", testCase.messageFile.Tag, actual.Tag) 250 | } 251 | if actual.Format != testCase.messageFile.Format { 252 | t.Errorf("expected format %q; got %q", testCase.messageFile.Format, actual.Format) 253 | } 254 | if !equalMessages(actual.Messages, testCase.messageFile.Messages) { 255 | t.Errorf("expected %#v; got %#v", deref(testCase.messageFile.Messages), deref(actual.Messages)) 256 | } 257 | } 258 | }) 259 | } 260 | } 261 | 262 | func deref(mptrs []*Message) []Message { 263 | messages := make([]Message, len(mptrs)) 264 | for i, m := range mptrs { 265 | messages[i] = *m 266 | } 267 | return messages 268 | } 269 | 270 | // equalMessages compares two slices of messages, ignoring private fields and order. 271 | // Sorts both input slices, which are therefore modified by this function. 272 | func equalMessages(m1, m2 []*Message) bool { 273 | if len(m1) != len(m2) { 274 | return false 275 | } 276 | 277 | var less = func(m []*Message) func(int, int) bool { 278 | return func(i, j int) bool { 279 | return m[i].ID < m[j].ID 280 | } 281 | } 282 | sort.Slice(m1, less(m1)) 283 | sort.Slice(m2, less(m2)) 284 | 285 | for i, m := range m1 { 286 | if !reflect.DeepEqual(m, m2[i]) { 287 | return false 288 | } 289 | } 290 | return true 291 | } 292 | -------------------------------------------------------------------------------- /i18n/template/identity_parser.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | // IdentityParser is an Parser that does no parsing and returns template string unchanged. 4 | type IdentityParser struct{} 5 | 6 | func (IdentityParser) Cacheable() bool { 7 | // Caching is not necessary because Parse is cheap. 8 | return false 9 | } 10 | 11 | func (IdentityParser) Parse(src, leftDelim, rightDelim string) (ParsedTemplate, error) { 12 | return &identityParsedTemplate{src: src}, nil 13 | } 14 | 15 | type identityParsedTemplate struct { 16 | src string 17 | } 18 | 19 | func (t *identityParsedTemplate) Execute(data any) (string, error) { 20 | return t.src, nil 21 | } 22 | -------------------------------------------------------------------------------- /i18n/template/parser.go: -------------------------------------------------------------------------------- 1 | // Package template defines a generic interface for template parsers and implementations of that interface. 2 | package template 3 | 4 | // Parser parses strings into executable templates. 5 | type Parser interface { 6 | // Parse parses src and returns a ParsedTemplate. 7 | Parse(src, leftDelim, rightDelim string) (ParsedTemplate, error) 8 | 9 | // Cacheable returns true if Parse returns ParsedTemplates that are always safe to cache. 10 | Cacheable() bool 11 | } 12 | 13 | // ParsedTemplate is an executable template. 14 | type ParsedTemplate interface { 15 | // Execute applies a parsed template to the specified data. 16 | Execute(data any) (string, error) 17 | } 18 | -------------------------------------------------------------------------------- /i18n/template/text_parser.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "text/template" 7 | ) 8 | 9 | // TextParser is a Parser that uses text/template. 10 | type TextParser struct { 11 | LeftDelim string 12 | RightDelim string 13 | Funcs template.FuncMap 14 | Option string 15 | } 16 | 17 | func (te *TextParser) Cacheable() bool { 18 | return te.Funcs == nil 19 | } 20 | 21 | func (te *TextParser) Parse(src, leftDelim, rightDelim string) (ParsedTemplate, error) { 22 | if leftDelim == "" { 23 | leftDelim = te.LeftDelim 24 | } 25 | if leftDelim == "" { 26 | leftDelim = "{{" 27 | } 28 | if !strings.Contains(src, leftDelim) { 29 | // Fast path to avoid parsing a template that has no actions. 30 | return &identityParsedTemplate{src: src}, nil 31 | } 32 | 33 | if rightDelim == "" { 34 | rightDelim = te.RightDelim 35 | } 36 | if rightDelim == "" { 37 | rightDelim = "}}" 38 | } 39 | 40 | option := "missingkey=default" 41 | if te.Option != "" { 42 | option = te.Option 43 | } 44 | 45 | tmpl, err := template.New("").Delims(leftDelim, rightDelim).Option(option).Funcs(te.Funcs).Parse(src) 46 | if err != nil { 47 | return nil, err 48 | } 49 | return &parsedTextTemplate{tmpl: tmpl}, nil 50 | } 51 | 52 | type parsedTextTemplate struct { 53 | tmpl *template.Template 54 | } 55 | 56 | func (t *parsedTextTemplate) Execute(data any) (string, error) { 57 | var buf bytes.Buffer 58 | if err := t.tmpl.Execute(&buf, data); err != nil { 59 | return "", err 60 | } 61 | return buf.String(), nil 62 | } 63 | -------------------------------------------------------------------------------- /internal/plural/codegen/README.md: -------------------------------------------------------------------------------- 1 | # How to upgrade CLDR data 2 | 3 | 1. Go to https://github.com/unicode-org/cldr/releases to find the latest release and download the source code. 4 | 1. Unzip and copy `common/supplemental/plurals.xml` to this directory. 5 | 1. Run `generate.sh`. 6 | -------------------------------------------------------------------------------- /internal/plural/codegen/generate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | OUT=.. 3 | go build && ./codegen -cout $OUT/rule_gen.go -tout $OUT/rule_gen_test.go && \ 4 | gofmt -w=true $OUT/rule_gen.go && \ 5 | gofmt -w=true $OUT/rule_gen_test.go && \ 6 | rm codegen 7 | -------------------------------------------------------------------------------- /internal/plural/codegen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/xml" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "text/template" 9 | ) 10 | 11 | var usage = `%[1]s generates Go code to support CLDR plural rules. 12 | 13 | Usage: %[1]s [options] 14 | 15 | Options: 16 | 17 | ` 18 | 19 | func main() { 20 | flag.Usage = func() { 21 | fmt.Fprintf(os.Stderr, usage, os.Args[0]) 22 | flag.PrintDefaults() 23 | } 24 | var in, cout, tout string 25 | flag.StringVar(&in, "i", "plurals.xml", "the input XML file containing CLDR plural rules") 26 | flag.StringVar(&cout, "cout", "", "the code output file") 27 | flag.StringVar(&tout, "tout", "", "the test output file") 28 | flag.BoolVar(&verbose, "v", false, "verbose output") 29 | flag.Parse() 30 | 31 | buf, err := os.ReadFile(in) 32 | if err != nil { 33 | fatalf("failed to read file: %s", err) 34 | } 35 | 36 | var data SupplementalData 37 | if err := xml.Unmarshal(buf, &data); err != nil { 38 | fatalf("failed to unmarshal xml: %s", err) 39 | } 40 | 41 | count := 0 42 | for _, pg := range data.PluralGroups { 43 | count += len(pg.SplitLocales()) 44 | } 45 | infof("parsed %d locales", count) 46 | 47 | if cout != "" { 48 | file := openWritableFile(cout) 49 | if err := codeTemplate.Execute(file, data); err != nil { 50 | fatalf("unable to execute code template because %s", err) 51 | } else { 52 | infof("generated %s", cout) 53 | } 54 | } else { 55 | infof("not generating code file (use -cout)") 56 | } 57 | 58 | if tout != "" { 59 | file := openWritableFile(tout) 60 | if err := testTemplate.Execute(file, data); err != nil { 61 | fatalf("unable to execute test template because %s", err) 62 | } else { 63 | infof("generated %s", tout) 64 | } 65 | } else { 66 | infof("not generating test file (use -tout)") 67 | } 68 | } 69 | 70 | func openWritableFile(name string) *os.File { 71 | file, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 72 | if err != nil { 73 | fatalf("failed to write file %s because %s", name, err) 74 | } 75 | return file 76 | } 77 | 78 | var codeTemplate = template.Must(template.New("rule").Parse(`// This file is generated by i18n/plural/codegen/generate.sh; DO NOT EDIT 79 | 80 | package plural 81 | 82 | // DefaultRules returns a map of Rules generated from CLDR language data. 83 | func DefaultRules() Rules { 84 | rules := Rules{} 85 | 86 | {{range .PluralGroups}} 87 | addPluralRules(rules, {{printf "%#v" .SplitLocales}}, &Rule{ 88 | PluralForms: newPluralFormSet({{range $i, $e := .PluralRules}}{{if $i}}, {{end}}{{$e.CountTitle}}{{end}}), 89 | PluralFormFunc: func(ops *Operands) Form { {{range .PluralRules}}{{if .GoCondition}} 90 | // {{.Condition}} 91 | if {{.GoCondition}} { 92 | return {{.CountTitle}} 93 | }{{end}}{{end}} 94 | return Other 95 | }, 96 | }){{end}} 97 | 98 | return rules 99 | } 100 | `)) 101 | 102 | var testTemplate = template.Must(template.New("rule").Parse(`// This file is generated by i18n/plural/codegen/generate.sh; DO NOT EDIT 103 | 104 | package plural 105 | 106 | import "testing" 107 | 108 | {{range .PluralGroups}} 109 | func Test{{.Name}}(t *testing.T) { 110 | var tests []pluralFormTest 111 | {{range .PluralRules}} 112 | {{if .IntegerExamples}}tests = appendIntegerTests(tests, {{.CountTitle}}, {{printf "%#v" .IntegerExamples}}){{end}} 113 | {{if .DecimalExamples}}tests = appendDecimalTests(tests, {{.CountTitle}}, {{printf "%#v" .DecimalExamples}}){{end}} 114 | {{end}} 115 | locales := {{printf "%#v" .SplitLocales}} 116 | for _, locale := range locales { 117 | runTests(t, locale, tests) 118 | } 119 | } 120 | {{end}} 121 | `)) 122 | 123 | func infof(format string, args ...interface{}) { 124 | fmt.Fprintf(os.Stderr, format+"\n", args...) 125 | } 126 | 127 | var verbose bool 128 | 129 | func fatalf(format string, args ...interface{}) { 130 | infof("fatal: "+format+"\n", args...) 131 | os.Exit(1) 132 | } 133 | -------------------------------------------------------------------------------- /internal/plural/codegen/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 18 | 19 | 20 | 21 | 22 | 23 | i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04 24 | @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 25 | 26 | 27 | i = 0,1 @integer 0, 1 @decimal 0.0~1.5 28 | @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 29 | 30 | 31 | i = 1 and v = 0 @integer 1 32 | @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 33 | 34 | 35 | n = 0,1 or i = 0 and f = 1 @integer 0, 1 @decimal 0.0, 0.1, 1.0, 0.00, 0.01, 1.00, 0.000, 0.001, 1.000, 0.0000, 0.0001, 1.0000 36 | @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.2~0.9, 1.1~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 37 | 38 | 39 | n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000 40 | @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 41 | 42 | 43 | n = 0..1 or n = 11..99 @integer 0, 1, 11~24 @decimal 0.0, 1.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0 44 | @integer 2~10, 100~106, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 45 | 46 | 47 | n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 48 | @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 49 | 50 | 51 | n = 1 or t != 0 and i = 0,1 @integer 1 @decimal 0.1~1.6 52 | @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 2.0~3.4, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 53 | 54 | 55 | t = 0 and i % 10 = 1 and i % 100 != 11 or t % 10 = 1 and t % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … 56 | @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.2~0.9, 1.2~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 57 | 58 | 59 | v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … 60 | @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.2~1.0, 1.2~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 61 | 62 | 63 | v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 @integer 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.3, 0.5, 0.7, 0.8, 1.0~1.3, 1.5, 1.7, 1.8, 2.0, 2.1, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 64 | @integer 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, … @decimal 0.4, 0.6, 0.9, 1.4, 1.6, 1.9, 2.4, 2.6, 10.4, 100.4, 1000.4, … 65 | 66 | 67 | 68 | 69 | 70 | n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19 @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 71 | n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … 72 | @integer 2~9, 22~29, 102, 1002, … @decimal 0.2~0.9, 1.2~1.9, 10.2, 100.2, 1000.2, … 73 | 74 | 75 | n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 76 | i = 0,1 and n != 0 @integer 1 @decimal 0.1~1.6 77 | @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 78 | 79 | 80 | n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 81 | n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 82 | @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 83 | 84 | 85 | n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 86 | n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 87 | @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 88 | 89 | 90 | 91 | 92 | 93 | i = 1 and v = 0 or i = 0 and v != 0 @integer 1 @decimal 0.0~0.9, 0.00~0.05 94 | i = 2 and v = 0 @integer 2 95 | @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.0~2.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 96 | 97 | 98 | n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 99 | n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 100 | @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 101 | 102 | 103 | 104 | 105 | 106 | i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04 107 | n = 2..10 @integer 2~10 @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 2.00, 3.00, 4.00, 5.00, 6.00, 7.00, 8.00 108 | @integer 11~26, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~1.9, 2.1~2.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 109 | 110 | 111 | i = 1 and v = 0 @integer 1 112 | v != 0 or n = 0 or n != 1 and n % 100 = 1..19 @integer 0, 2~16, 101, 1001, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 113 | @integer 20~35, 100, 1000, 10000, 100000, 1000000, … 114 | 115 | 116 | v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … 117 | v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, … 118 | @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 119 | 120 | 121 | 122 | 123 | 124 | i = 0,1 @integer 0, 1 @decimal 0.0~1.5 125 | e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … 126 | @integer 2~17, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … 127 | 128 | 129 | i = 0..1 @integer 0, 1 @decimal 0.0~1.5 130 | e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … 131 | @integer 2~17, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … 132 | 133 | 134 | i = 1 and v = 0 @integer 1 135 | e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … 136 | @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … 137 | 138 | 139 | n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 140 | e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … 141 | @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … 142 | 143 | 144 | 145 | 146 | 147 | n = 1,11 @integer 1, 11 @decimal 1.0, 11.0, 1.00, 11.00, 1.000, 11.000, 1.0000 148 | n = 2,12 @integer 2, 12 @decimal 2.0, 12.0, 2.00, 12.00, 2.000, 12.000, 2.0000 149 | n = 3..10,13..19 @integer 3~10, 13~19 @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 3.00 150 | @integer 0, 20~34, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 151 | 152 | 153 | v = 0 and i % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … 154 | v = 0 and i % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … 155 | v = 0 and i % 100 = 3..4 or v != 0 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 156 | @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … 157 | 158 | 159 | v = 0 and i % 100 = 1 or f % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … 160 | v = 0 and i % 100 = 2 or f % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … @decimal 0.2, 1.2, 2.2, 3.2, 4.2, 5.2, 6.2, 7.2, 10.2, 100.2, 1000.2, … 161 | v = 0 and i % 100 = 3..4 or f % 100 = 3..4 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.3, 0.4, 1.3, 1.4, 2.3, 2.4, 3.3, 3.4, 4.3, 4.4, 5.3, 5.4, 6.3, 6.4, 7.3, 7.4, 10.3, 100.3, 1000.3, … 162 | @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 163 | 164 | 165 | 166 | 167 | 168 | i = 1 and v = 0 @integer 1 169 | i = 2..4 and v = 0 @integer 2~4 170 | v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 171 | @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … 172 | 173 | 174 | i = 1 and v = 0 @integer 1 175 | v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … 176 | v = 0 and i != 1 and i % 10 = 0..1 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 12..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … 177 | @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 178 | 179 | 180 | n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, … 181 | n % 10 = 2..4 and n % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 2.0, 3.0, 4.0, 22.0, 23.0, 24.0, 32.0, 33.0, 102.0, 1002.0, … 182 | n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 183 | @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, … 184 | 185 | 186 | n % 10 = 1 and n % 100 != 11..19 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, … 187 | n % 10 = 2..9 and n % 100 != 11..19 @integer 2~9, 22~29, 102, 1002, … @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 22.0, 102.0, 1002.0, … 188 | f != 0 @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, … 189 | @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 190 | 191 | 192 | v = 0 and i % 10 = 1 and i % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … 193 | v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … 194 | v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … 195 | @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 196 | 197 | 198 | 199 | 200 | 201 | n % 10 = 1 and n % 100 != 11,71,91 @integer 1, 21, 31, 41, 51, 61, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 81.0, 101.0, 1001.0, … 202 | n % 10 = 2 and n % 100 != 12,72,92 @integer 2, 22, 32, 42, 52, 62, 82, 102, 1002, … @decimal 2.0, 22.0, 32.0, 42.0, 52.0, 62.0, 82.0, 102.0, 1002.0, … 203 | n % 10 = 3..4,9 and n % 100 != 10..19,70..79,90..99 @integer 3, 4, 9, 23, 24, 29, 33, 34, 39, 43, 44, 49, 103, 1003, … @decimal 3.0, 4.0, 9.0, 23.0, 24.0, 29.0, 33.0, 34.0, 103.0, 1003.0, … 204 | n != 0 and n % 1000000 = 0 @integer 1000000, … @decimal 1000000.0, 1000000.00, 1000000.000, 1000000.0000, … 205 | @integer 0, 5~8, 10~20, 100, 1000, 10000, 100000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, … 206 | 207 | 208 | n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 209 | n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 210 | n = 0 or n % 100 = 3..10 @integer 0, 3~10, 103~109, 1003, … @decimal 0.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, … 211 | n % 100 = 11..19 @integer 11~19, 111~117, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, … 212 | @integer 20~35, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 213 | 214 | 215 | n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 216 | n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 217 | n = 3..6 @integer 3~6 @decimal 3.0, 4.0, 5.0, 6.0, 3.00, 4.00, 5.00, 6.00, 3.000, 4.000, 5.000, 6.000, 3.0000, 4.0000, 5.0000, 6.0000 218 | n = 7..10 @integer 7~10 @decimal 7.0, 8.0, 9.0, 10.0, 7.00, 8.00, 9.00, 10.00, 7.000, 8.000, 9.000, 10.000, 7.0000, 8.0000, 9.0000, 10.0000 219 | @integer 0, 11~25, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 220 | 221 | 222 | v = 0 and i % 10 = 1 @integer 1, 11, 21, 31, 41, 51, 61, 71, 101, 1001, … 223 | v = 0 and i % 10 = 2 @integer 2, 12, 22, 32, 42, 52, 62, 72, 102, 1002, … 224 | v = 0 and i % 100 = 0,20,40,60,80 @integer 0, 20, 40, 60, 80, 100, 120, 140, 1000, 10000, 100000, 1000000, … 225 | v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 226 | @integer 3~10, 13~19, 23, 103, 1003, … 227 | 228 | 229 | 230 | 231 | 232 | n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 233 | n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 234 | n % 100 = 2,22,42,62,82 or n % 1000 = 0 and n % 100000 = 1000..20000,40000,60000,80000 or n != 0 and n % 1000000 = 100000 @integer 2, 22, 42, 62, 82, 102, 122, 142, 1000, 10000, 100000, … @decimal 2.0, 22.0, 42.0, 62.0, 82.0, 102.0, 122.0, 142.0, 1000.0, 10000.0, 100000.0, … 235 | n % 100 = 3,23,43,63,83 @integer 3, 23, 43, 63, 83, 103, 123, 143, 1003, … @decimal 3.0, 23.0, 43.0, 63.0, 83.0, 103.0, 123.0, 143.0, 1003.0, … 236 | n != 1 and n % 100 = 1,21,41,61,81 @integer 21, 41, 61, 81, 101, 121, 141, 161, 1001, … @decimal 21.0, 41.0, 61.0, 81.0, 101.0, 121.0, 141.0, 161.0, 1001.0, … 237 | @integer 4~19, 100, 1004, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.1, 1000000.0, … 238 | 239 | 240 | n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 241 | n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 242 | n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 243 | n % 100 = 3..10 @integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, … 244 | n % 100 = 11..99 @integer 11~26, 111, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, … 245 | @integer 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 246 | 247 | 248 | n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 249 | n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 250 | n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 251 | n = 3 @integer 3 @decimal 3.0, 3.00, 3.000, 3.0000 252 | n = 6 @integer 6 @decimal 6.0, 6.00, 6.000, 6.0000 253 | @integer 4, 5, 7~20, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … 254 | 255 | 256 | 257 | -------------------------------------------------------------------------------- /internal/plural/codegen/xml.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "golang.org/x/text/cases" 11 | "golang.org/x/text/language" 12 | ) 13 | 14 | // SupplementalData is the top level struct of plural.xml 15 | type SupplementalData struct { 16 | XMLName xml.Name `xml:"supplementalData"` 17 | PluralGroups []PluralGroup `xml:"plurals>pluralRules"` 18 | } 19 | 20 | // PluralGroup is a group of locales with the same plural rules. 21 | type PluralGroup struct { 22 | Locales string `xml:"locales,attr"` 23 | PluralRules []PluralRule `xml:"pluralRule"` 24 | } 25 | 26 | // Name returns a unique name for this plural group. 27 | func (pg *PluralGroup) Name() string { 28 | n := cases.Title(language.AmericanEnglish).String(pg.Locales) 29 | return strings.ReplaceAll(n, " ", "") 30 | } 31 | 32 | // SplitLocales returns all the locales in the PluralGroup as a slice. 33 | func (pg *PluralGroup) SplitLocales() []string { 34 | return strings.Split(pg.Locales, " ") 35 | } 36 | 37 | // PluralRule is the rule for a single plural form. 38 | type PluralRule struct { 39 | Count string `xml:"count,attr"` 40 | Rule string `xml:",innerxml"` 41 | } 42 | 43 | // CountTitle returns the title case of the PluralRule's count. 44 | func (pr *PluralRule) CountTitle() string { 45 | return cases.Title(language.AmericanEnglish).String(pr.Count) 46 | } 47 | 48 | // Condition returns the condition where the PluralRule applies. 49 | func (pr *PluralRule) Condition() string { 50 | i := strings.Index(pr.Rule, "@") 51 | return pr.Rule[:i] 52 | } 53 | 54 | // Examples returns the integer and decimal examples for the PluralRule. 55 | func (pr *PluralRule) Examples() (integers []string, decimals []string) { 56 | ex := strings.ReplaceAll(pr.Rule, ", …", "") 57 | ddelim := "@decimal" 58 | if i := strings.Index(ex, ddelim); i > 0 { 59 | dex := strings.TrimSpace(ex[i+len(ddelim):]) 60 | dex = strings.ReplaceAll(dex, "c", "e") 61 | decimals = strings.Split(dex, ", ") 62 | ex = ex[:i] 63 | } 64 | idelim := "@integer" 65 | if i := strings.Index(ex, idelim); i > 0 { 66 | iex := strings.TrimSpace(ex[i+len(idelim):]) 67 | integers = strings.Split(iex, ", ") 68 | for j, integer := range integers { 69 | ii := strings.IndexAny(integer, "eEcC") 70 | if ii > 0 { 71 | zeros, err := strconv.ParseInt(integer[ii+1:], 10, 0) 72 | if err != nil { 73 | panic(err) 74 | } 75 | integers[j] = integer[:ii] + strings.Repeat("0", int(zeros)) 76 | } 77 | } 78 | } 79 | return integers, decimals 80 | } 81 | 82 | // IntegerExamples returns the integer examples for the PluralRule. 83 | func (pr *PluralRule) IntegerExamples() []string { 84 | integer, _ := pr.Examples() 85 | return integer 86 | } 87 | 88 | // DecimalExamples returns the decimal examples for the PluralRule. 89 | func (pr *PluralRule) DecimalExamples() []string { 90 | _, decimal := pr.Examples() 91 | return decimal 92 | } 93 | 94 | var relationRegexp = regexp.MustCompile(`([niftvwce])(?:\s*%\s*([0-9]+))?\s*(!=|=)(.*)`) 95 | 96 | // GoCondition converts the XML condition to valid Go code. 97 | func (pr *PluralRule) GoCondition() string { 98 | var ors []string 99 | for _, and := range strings.Split(pr.Condition(), "or") { 100 | var ands []string 101 | for _, relation := range strings.Split(and, "and") { 102 | parts := relationRegexp.FindStringSubmatch(relation) 103 | if parts == nil { 104 | continue 105 | } 106 | lvar := cases.Title(language.AmericanEnglish).String(parts[1]) 107 | lmod, op, rhs := parts[2], parts[3], strings.TrimSpace(parts[4]) 108 | if op == "=" { 109 | op = "==" 110 | } 111 | if lvar == "E" { 112 | // E is a deprecated symbol for C 113 | // https://unicode.org/reports/tr35/tr35-numbers.html#Plural_Operand_Meanings 114 | lvar = "C" 115 | } 116 | lvar = "ops." + lvar 117 | var rhor []string 118 | var rany []string 119 | for _, rh := range strings.Split(rhs, ",") { 120 | if parts := strings.Split(rh, ".."); len(parts) == 2 { 121 | from, to := parts[0], parts[1] 122 | if lvar == "ops.N" { 123 | if lmod != "" { 124 | rhor = append(rhor, fmt.Sprintf("ops.NModInRange(%s, %s, %s)", lmod, from, to)) 125 | } else { 126 | rhor = append(rhor, fmt.Sprintf("ops.NInRange(%s, %s)", from, to)) 127 | } 128 | } else if lmod != "" { 129 | rhor = append(rhor, fmt.Sprintf("intInRange(%s %% %s, %s, %s)", lvar, lmod, from, to)) 130 | } else { 131 | rhor = append(rhor, fmt.Sprintf("intInRange(%s, %s, %s)", lvar, from, to)) 132 | } 133 | } else { 134 | rany = append(rany, rh) 135 | } 136 | } 137 | 138 | if len(rany) > 0 { 139 | rh := strings.Join(rany, ",") 140 | if lvar == "ops.N" { 141 | if lmod != "" { 142 | rhor = append(rhor, fmt.Sprintf("ops.NModEqualsAny(%s, %s)", lmod, rh)) 143 | } else { 144 | rhor = append(rhor, fmt.Sprintf("ops.NEqualsAny(%s)", rh)) 145 | } 146 | } else if lmod != "" { 147 | rhor = append(rhor, fmt.Sprintf("intEqualsAny(%s %% %s, %s)", lvar, lmod, rh)) 148 | } else { 149 | rhor = append(rhor, fmt.Sprintf("intEqualsAny(%s, %s)", lvar, rh)) 150 | } 151 | } 152 | r := strings.Join(rhor, " || ") 153 | if len(rhor) > 1 { 154 | r = "(" + r + ")" 155 | } 156 | if op == "!=" { 157 | r = "!" + r 158 | } 159 | ands = append(ands, r) 160 | } 161 | ors = append(ors, strings.Join(ands, " && ")) 162 | } 163 | return strings.Join(ors, " ||\n") 164 | } 165 | -------------------------------------------------------------------------------- /internal/plural/doc.go: -------------------------------------------------------------------------------- 1 | // Package plural provides support for pluralizing messages 2 | // according to CLDR rules http://cldr.unicode.org/index/cldr-spec/plural-rules 3 | package plural 4 | -------------------------------------------------------------------------------- /internal/plural/form.go: -------------------------------------------------------------------------------- 1 | package plural 2 | 3 | // Form represents a language pluralization form as defined here: 4 | // http://cldr.unicode.org/index/cldr-spec/plural-rules 5 | type Form string 6 | 7 | // All defined plural forms. 8 | const ( 9 | Invalid Form = "" 10 | Zero Form = "zero" 11 | One Form = "one" 12 | Two Form = "two" 13 | Few Form = "few" 14 | Many Form = "many" 15 | Other Form = "other" 16 | ) 17 | -------------------------------------------------------------------------------- /internal/plural/operands.go: -------------------------------------------------------------------------------- 1 | package plural 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // Operands is a representation of http://unicode.org/reports/tr35/tr35-numbers.html#Operands 10 | // If there is a compact decimal exponent value C, then the N, I, V, W, F, and T values are computed after shifting the decimal point in the original by the ‘c’ value. 11 | // So for 1.2c3, the values are the same as those of 1200: i=1200 and f=0. 12 | // Similarly, 1.2005c3 has i=1200 and f=5 (corresponding to 1200.5). 13 | type Operands struct { 14 | N float64 // absolute value of the source number (integer and decimals) 15 | I int64 // integer digits of n 16 | V int64 // number of visible fraction digits in n, with trailing zeros 17 | W int64 // number of visible fraction digits in n, without trailing zeros 18 | F int64 // visible fractional digits in n, with trailing zeros 19 | T int64 // visible fractional digits in n, without trailing zeros 20 | C int64 // compact decimal exponent value: exponent of the power of 10 used in compact decimal formatting. 21 | } 22 | 23 | // NEqualsAny returns true if o represents an integer equal to any of the arguments. 24 | func (o *Operands) NEqualsAny(any ...int64) bool { 25 | for _, i := range any { 26 | if o.I == i && o.T == 0 { 27 | return true 28 | } 29 | } 30 | return false 31 | } 32 | 33 | // NModEqualsAny returns true if o represents an integer equal to any of the arguments modulo mod. 34 | func (o *Operands) NModEqualsAny(mod int64, any ...int64) bool { 35 | modI := o.I % mod 36 | for _, i := range any { 37 | if modI == i && o.T == 0 { 38 | return true 39 | } 40 | } 41 | return false 42 | } 43 | 44 | // NInRange returns true if o represents an integer in the closed interval [from, to]. 45 | func (o *Operands) NInRange(from, to int64) bool { 46 | return o.T == 0 && from <= o.I && o.I <= to 47 | } 48 | 49 | // NModInRange returns true if o represents an integer in the closed interval [from, to] modulo mod. 50 | func (o *Operands) NModInRange(mod, from, to int64) bool { 51 | modI := o.I % mod 52 | return o.T == 0 && from <= modI && modI <= to 53 | } 54 | 55 | // NewOperands returns the operands for number. 56 | func NewOperands(number interface{}) (*Operands, error) { 57 | switch number := number.(type) { 58 | case int: 59 | return newOperandsInt64(int64(number)), nil 60 | case int8: 61 | return newOperandsInt64(int64(number)), nil 62 | case int16: 63 | return newOperandsInt64(int64(number)), nil 64 | case int32: 65 | return newOperandsInt64(int64(number)), nil 66 | case int64: 67 | return newOperandsInt64(number), nil 68 | case string: 69 | return newOperandsString(number) 70 | case float32, float64: 71 | return nil, fmt.Errorf("floats should be formatted into a string") 72 | default: 73 | return nil, fmt.Errorf("invalid type %T; expected integer or string", number) 74 | } 75 | } 76 | 77 | func newOperandsInt64(i int64) *Operands { 78 | if i < 0 { 79 | i = -i 80 | } 81 | return &Operands{float64(i), i, 0, 0, 0, 0, 0} 82 | } 83 | 84 | func splitSignificandExponent(s string) (significand, exponent string) { 85 | i := strings.IndexAny(s, "eE") 86 | if i < 0 { 87 | return s, "" 88 | } 89 | return s[:i], s[i+1:] 90 | } 91 | 92 | func shiftDecimalLeft(s string, n int) string { 93 | if n <= 0 { 94 | return s 95 | } 96 | i := strings.IndexRune(s, '.') 97 | tilt := 0 98 | if i < 0 { 99 | i = len(s) 100 | tilt = -1 101 | } 102 | switch { 103 | case n == i: 104 | return "0." + s[:i] + s[i+1+tilt:] 105 | case n > i: 106 | return "0." + strings.Repeat("0", n-i) + s[:i] + s[i+1+tilt:] 107 | default: 108 | return s[:i-n] + "." + s[i-n:i] + s[i+1+tilt:] 109 | } 110 | } 111 | 112 | func shiftDecimalRight(s string, n int) string { 113 | if n <= 0 { 114 | return s 115 | } 116 | i := strings.IndexRune(s, '.') 117 | if i < 0 { 118 | return s + strings.Repeat("0", n) 119 | } 120 | switch rest := len(s) - i - 1; { 121 | case n == rest: 122 | return s[:i] + s[i+1:] 123 | case n > rest: 124 | return s[:i] + s[i+1:] + strings.Repeat("0", n-rest) 125 | default: 126 | return s[:i] + s[i+1:i+1+n] + "." + s[i+1+n:] 127 | } 128 | } 129 | 130 | func applyExponent(s string, exponent int) string { 131 | switch { 132 | case exponent > 0: 133 | return shiftDecimalRight(s, exponent) 134 | case exponent < 0: 135 | return shiftDecimalLeft(s, -exponent) 136 | } 137 | return s 138 | } 139 | 140 | func newOperandsString(s string) (*Operands, error) { 141 | if s[0] == '-' { 142 | s = s[1:] 143 | } 144 | ops := &Operands{} 145 | var err error 146 | ops.N, err = strconv.ParseFloat(s, 64) 147 | if err != nil { 148 | return nil, err 149 | } 150 | significand, exponent := splitSignificandExponent(s) 151 | if exponent != "" { 152 | // We are storing C as an int64 but only allowing 153 | // numbers that fit into the bitsize of an int 154 | // so C is safe to cast as a int later. 155 | ops.C, err = strconv.ParseInt(exponent, 10, 0) 156 | if err != nil { 157 | return nil, err 158 | } 159 | } 160 | value := applyExponent(significand, int(ops.C)) 161 | parts := strings.SplitN(value, ".", 2) 162 | ops.I, err = strconv.ParseInt(parts[0], 10, 64) 163 | if err != nil { 164 | return nil, err 165 | } 166 | if len(parts) == 1 { 167 | return ops, nil 168 | } 169 | fraction := parts[1] 170 | ops.V = int64(len(fraction)) 171 | for i := ops.V - 1; i >= 0; i-- { 172 | if fraction[i] != '0' { 173 | ops.W = i + 1 174 | break 175 | } 176 | } 177 | if ops.V > 0 { 178 | f, err := strconv.ParseInt(fraction, 10, 0) 179 | if err != nil { 180 | return nil, err 181 | } 182 | ops.F = f 183 | } 184 | if ops.W > 0 { 185 | t, err := strconv.ParseInt(fraction[:ops.W], 10, 0) 186 | if err != nil { 187 | return nil, err 188 | } 189 | ops.T = t 190 | } 191 | return ops, nil 192 | } 193 | -------------------------------------------------------------------------------- /internal/plural/operands_test.go: -------------------------------------------------------------------------------- 1 | package plural 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestNewOperands(t *testing.T) { 9 | tests := []struct { 10 | input interface{} 11 | ops *Operands 12 | err bool 13 | }{ 14 | {int64(0), &Operands{0.0, 0, 0, 0, 0, 0, 0}, false}, 15 | {int64(1), &Operands{1.0, 1, 0, 0, 0, 0, 0}, false}, 16 | {"0", &Operands{0.0, 0, 0, 0, 0, 0, 0}, false}, 17 | {"1", &Operands{1.0, 1, 0, 0, 0, 0, 0}, false}, 18 | {"1.0", &Operands{1.0, 1, 1, 0, 0, 0, 0}, false}, 19 | {"1.00", &Operands{1.0, 1, 2, 0, 0, 0, 0}, false}, 20 | {"1.3", &Operands{1.3, 1, 1, 1, 3, 3, 0}, false}, 21 | {"1.30", &Operands{1.3, 1, 2, 1, 30, 3, 0}, false}, 22 | {"1.03", &Operands{1.03, 1, 2, 2, 3, 3, 0}, false}, 23 | {"1.230", &Operands{1.23, 1, 3, 2, 230, 23, 0}, false}, 24 | {"20.0230", &Operands{20.023, 20, 4, 3, 230, 23, 0}, false}, 25 | {20.0230, nil, true}, 26 | 27 | {"1200", &Operands{1200, 1200, 0, 0, 0, 0, 0}, false}, 28 | {"1.2e3", &Operands{1200, 1200, 0, 0, 0, 0, 3}, false}, 29 | {"1.2E3", &Operands{1200, 1200, 0, 0, 0, 0, 3}, false}, 30 | 31 | {"1234", &Operands{1234, 1234, 0, 0, 0, 0, 0}, false}, 32 | {"1234e0", &Operands{1234, 1234, 0, 0, 0, 0, 0}, false}, 33 | {"123.4e1", &Operands{1234, 1234, 0, 0, 0, 0, 1}, false}, 34 | {"12.34e2", &Operands{1234, 1234, 0, 0, 0, 0, 2}, false}, 35 | {"1.234e3", &Operands{1234, 1234, 0, 0, 0, 0, 3}, false}, 36 | {"0.1234e4", &Operands{1234, 1234, 0, 0, 0, 0, 4}, false}, 37 | {"0.01234e5", &Operands{1234, 1234, 0, 0, 0, 0, 5}, false}, 38 | 39 | {"1234.0", &Operands{1234, 1234, 1, 0, 0, 0, 0}, false}, 40 | {"12340e-1", &Operands{1234, 1234, 1, 0, 0, 0, -1}, false}, 41 | 42 | {"1200.5", &Operands{1200.5, 1200, 1, 1, 5, 5, 0}, false}, 43 | {"1.2005e3", &Operands{1200.5, 1200, 1, 1, 5, 5, 3}, false}, 44 | 45 | {"1200e3", &Operands{1200000, 1200000, 0, 0, 0, 0, 3}, false}, 46 | 47 | {"0.0012340", &Operands{0.001234, 0, 7, 6, 12340, 1234, 0}, false}, 48 | {"0.012340e-1", &Operands{0.001234, 0, 7, 6, 12340, 1234, -1}, false}, 49 | {"0.12340e-2", &Operands{0.001234, 0, 7, 6, 12340, 1234, -2}, false}, 50 | {"1.2340e-3", &Operands{0.001234, 0, 7, 6, 12340, 1234, -3}, false}, 51 | {"12.340e-4", &Operands{0.001234, 0, 7, 6, 12340, 1234, -4}, false}, 52 | {"123.40e-5", &Operands{0.001234, 0, 7, 6, 12340, 1234, -5}, false}, 53 | {"1234.0e-6", &Operands{0.001234, 0, 7, 6, 12340, 1234, -6}, false}, 54 | {"12340e-7", &Operands{0.001234, 0, 7, 6, 12340, 1234, -7}, false}, 55 | } 56 | for _, test := range tests { 57 | ops, err := NewOperands(test.input) 58 | if err != nil && !test.err { 59 | t.Errorf("NewOperands(%#v) unexpected error: %s", test.input, err) 60 | } else if err == nil && test.err { 61 | t.Errorf("NewOperands(%#v) returned %#v; expected error", test.input, ops) 62 | } else if !reflect.DeepEqual(ops, test.ops) { 63 | t.Errorf("NewOperands(%#v) returned %#v; expected %#v", test.input, ops, test.ops) 64 | } 65 | } 66 | } 67 | 68 | func BenchmarkNewOperand(b *testing.B) { 69 | for i := 0; i < b.N; i++ { 70 | if _, err := NewOperands("1234.56780000"); err != nil { 71 | b.Fatal(err) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/plural/rule.go: -------------------------------------------------------------------------------- 1 | package plural 2 | 3 | import ( 4 | "golang.org/x/text/language" 5 | ) 6 | 7 | // Rule defines the CLDR plural rules for a language. 8 | // http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html 9 | // http://unicode.org/reports/tr35/tr35-numbers.html#Operands 10 | type Rule struct { 11 | PluralForms map[Form]struct{} 12 | PluralFormFunc func(*Operands) Form 13 | } 14 | 15 | func addPluralRules(rules Rules, ids []string, ps *Rule) { 16 | for _, id := range ids { 17 | if id == "root" { 18 | continue 19 | } 20 | tag := language.MustParse(id) 21 | rules[tag] = ps 22 | } 23 | } 24 | 25 | func newPluralFormSet(pluralForms ...Form) map[Form]struct{} { 26 | set := make(map[Form]struct{}, len(pluralForms)) 27 | for _, plural := range pluralForms { 28 | set[plural] = struct{}{} 29 | } 30 | return set 31 | } 32 | 33 | func intInRange(i, from, to int64) bool { 34 | return from <= i && i <= to 35 | } 36 | 37 | func intEqualsAny(i int64, any ...int64) bool { 38 | for _, a := range any { 39 | if i == a { 40 | return true 41 | } 42 | } 43 | return false 44 | } 45 | -------------------------------------------------------------------------------- /internal/plural/rule_gen.go: -------------------------------------------------------------------------------- 1 | // This file is generated by i18n/plural/codegen/generate.sh; DO NOT EDIT 2 | 3 | package plural 4 | 5 | // DefaultRules returns a map of Rules generated from CLDR language data. 6 | func DefaultRules() Rules { 7 | rules := Rules{} 8 | 9 | addPluralRules(rules, []string{"bm", "bo", "dz", "hnj", "id", "ig", "ii", "in", "ja", "jbo", "jv", "jw", "kde", "kea", "km", "ko", "lkt", "lo", "ms", "my", "nqo", "osa", "root", "sah", "ses", "sg", "su", "th", "to", "tpi", "vi", "wo", "yo", "yue", "zh"}, &Rule{ 10 | PluralForms: newPluralFormSet(Other), 11 | PluralFormFunc: func(ops *Operands) Form { 12 | return Other 13 | }, 14 | }) 15 | addPluralRules(rules, []string{"am", "as", "bn", "doi", "fa", "gu", "hi", "kn", "pcm", "zu"}, &Rule{ 16 | PluralForms: newPluralFormSet(One, Other), 17 | PluralFormFunc: func(ops *Operands) Form { 18 | // i = 0 or n = 1 19 | if intEqualsAny(ops.I, 0) || 20 | ops.NEqualsAny(1) { 21 | return One 22 | } 23 | return Other 24 | }, 25 | }) 26 | addPluralRules(rules, []string{"ff", "hy", "kab"}, &Rule{ 27 | PluralForms: newPluralFormSet(One, Other), 28 | PluralFormFunc: func(ops *Operands) Form { 29 | // i = 0,1 30 | if intEqualsAny(ops.I, 0, 1) { 31 | return One 32 | } 33 | return Other 34 | }, 35 | }) 36 | addPluralRules(rules, []string{"ast", "de", "en", "et", "fi", "fy", "gl", "ia", "io", "ji", "lij", "nl", "sc", "sv", "sw", "ur", "yi"}, &Rule{ 37 | PluralForms: newPluralFormSet(One, Other), 38 | PluralFormFunc: func(ops *Operands) Form { 39 | // i = 1 and v = 0 40 | if intEqualsAny(ops.I, 1) && intEqualsAny(ops.V, 0) { 41 | return One 42 | } 43 | return Other 44 | }, 45 | }) 46 | addPluralRules(rules, []string{"si"}, &Rule{ 47 | PluralForms: newPluralFormSet(One, Other), 48 | PluralFormFunc: func(ops *Operands) Form { 49 | // n = 0,1 or i = 0 and f = 1 50 | if ops.NEqualsAny(0, 1) || 51 | intEqualsAny(ops.I, 0) && intEqualsAny(ops.F, 1) { 52 | return One 53 | } 54 | return Other 55 | }, 56 | }) 57 | addPluralRules(rules, []string{"ak", "bho", "csw", "guw", "ln", "mg", "nso", "pa", "ti", "wa"}, &Rule{ 58 | PluralForms: newPluralFormSet(One, Other), 59 | PluralFormFunc: func(ops *Operands) Form { 60 | // n = 0..1 61 | if ops.NInRange(0, 1) { 62 | return One 63 | } 64 | return Other 65 | }, 66 | }) 67 | addPluralRules(rules, []string{"tzm"}, &Rule{ 68 | PluralForms: newPluralFormSet(One, Other), 69 | PluralFormFunc: func(ops *Operands) Form { 70 | // n = 0..1 or n = 11..99 71 | if ops.NInRange(0, 1) || 72 | ops.NInRange(11, 99) { 73 | return One 74 | } 75 | return Other 76 | }, 77 | }) 78 | addPluralRules(rules, []string{"af", "an", "asa", "az", "bal", "bem", "bez", "bg", "brx", "ce", "cgg", "chr", "ckb", "dv", "ee", "el", "eo", "eu", "fo", "fur", "gsw", "ha", "haw", "hu", "jgo", "jmc", "ka", "kaj", "kcg", "kk", "kkj", "kl", "ks", "ksb", "ku", "ky", "lb", "lg", "mas", "mgo", "ml", "mn", "mr", "nah", "nb", "nd", "ne", "nn", "nnh", "no", "nr", "ny", "nyn", "om", "or", "os", "pap", "ps", "rm", "rof", "rwk", "saq", "sd", "sdh", "seh", "sn", "so", "sq", "ss", "ssy", "st", "syr", "ta", "te", "teo", "tig", "tk", "tn", "tr", "ts", "ug", "uz", "ve", "vo", "vun", "wae", "xh", "xog"}, &Rule{ 79 | PluralForms: newPluralFormSet(One, Other), 80 | PluralFormFunc: func(ops *Operands) Form { 81 | // n = 1 82 | if ops.NEqualsAny(1) { 83 | return One 84 | } 85 | return Other 86 | }, 87 | }) 88 | addPluralRules(rules, []string{"da"}, &Rule{ 89 | PluralForms: newPluralFormSet(One, Other), 90 | PluralFormFunc: func(ops *Operands) Form { 91 | // n = 1 or t != 0 and i = 0,1 92 | if ops.NEqualsAny(1) || 93 | !intEqualsAny(ops.T, 0) && intEqualsAny(ops.I, 0, 1) { 94 | return One 95 | } 96 | return Other 97 | }, 98 | }) 99 | addPluralRules(rules, []string{"is"}, &Rule{ 100 | PluralForms: newPluralFormSet(One, Other), 101 | PluralFormFunc: func(ops *Operands) Form { 102 | // t = 0 and i % 10 = 1 and i % 100 != 11 or t % 10 = 1 and t % 100 != 11 103 | if intEqualsAny(ops.T, 0) && intEqualsAny(ops.I%10, 1) && !intEqualsAny(ops.I%100, 11) || 104 | intEqualsAny(ops.T%10, 1) && !intEqualsAny(ops.T%100, 11) { 105 | return One 106 | } 107 | return Other 108 | }, 109 | }) 110 | addPluralRules(rules, []string{"mk"}, &Rule{ 111 | PluralForms: newPluralFormSet(One, Other), 112 | PluralFormFunc: func(ops *Operands) Form { 113 | // v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 114 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I%10, 1) && !intEqualsAny(ops.I%100, 11) || 115 | intEqualsAny(ops.F%10, 1) && !intEqualsAny(ops.F%100, 11) { 116 | return One 117 | } 118 | return Other 119 | }, 120 | }) 121 | addPluralRules(rules, []string{"ceb", "fil", "tl"}, &Rule{ 122 | PluralForms: newPluralFormSet(One, Other), 123 | PluralFormFunc: func(ops *Operands) Form { 124 | // v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 125 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I, 1, 2, 3) || 126 | intEqualsAny(ops.V, 0) && !intEqualsAny(ops.I%10, 4, 6, 9) || 127 | !intEqualsAny(ops.V, 0) && !intEqualsAny(ops.F%10, 4, 6, 9) { 128 | return One 129 | } 130 | return Other 131 | }, 132 | }) 133 | addPluralRules(rules, []string{"lv", "prg"}, &Rule{ 134 | PluralForms: newPluralFormSet(Zero, One, Other), 135 | PluralFormFunc: func(ops *Operands) Form { 136 | // n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19 137 | if ops.NModEqualsAny(10, 0) || 138 | ops.NModInRange(100, 11, 19) || 139 | intEqualsAny(ops.V, 2) && intInRange(ops.F%100, 11, 19) { 140 | return Zero 141 | } 142 | // n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1 143 | if ops.NModEqualsAny(10, 1) && !ops.NModEqualsAny(100, 11) || 144 | intEqualsAny(ops.V, 2) && intEqualsAny(ops.F%10, 1) && !intEqualsAny(ops.F%100, 11) || 145 | !intEqualsAny(ops.V, 2) && intEqualsAny(ops.F%10, 1) { 146 | return One 147 | } 148 | return Other 149 | }, 150 | }) 151 | addPluralRules(rules, []string{"lag"}, &Rule{ 152 | PluralForms: newPluralFormSet(Zero, One, Other), 153 | PluralFormFunc: func(ops *Operands) Form { 154 | // n = 0 155 | if ops.NEqualsAny(0) { 156 | return Zero 157 | } 158 | // i = 0,1 and n != 0 159 | if intEqualsAny(ops.I, 0, 1) && !ops.NEqualsAny(0) { 160 | return One 161 | } 162 | return Other 163 | }, 164 | }) 165 | addPluralRules(rules, []string{"blo"}, &Rule{ 166 | PluralForms: newPluralFormSet(Zero, One, Other), 167 | PluralFormFunc: func(ops *Operands) Form { 168 | // n = 0 169 | if ops.NEqualsAny(0) { 170 | return Zero 171 | } 172 | // n = 1 173 | if ops.NEqualsAny(1) { 174 | return One 175 | } 176 | return Other 177 | }, 178 | }) 179 | addPluralRules(rules, []string{"ksh"}, &Rule{ 180 | PluralForms: newPluralFormSet(Zero, One, Other), 181 | PluralFormFunc: func(ops *Operands) Form { 182 | // n = 0 183 | if ops.NEqualsAny(0) { 184 | return Zero 185 | } 186 | // n = 1 187 | if ops.NEqualsAny(1) { 188 | return One 189 | } 190 | return Other 191 | }, 192 | }) 193 | addPluralRules(rules, []string{"he", "iw"}, &Rule{ 194 | PluralForms: newPluralFormSet(One, Two, Other), 195 | PluralFormFunc: func(ops *Operands) Form { 196 | // i = 1 and v = 0 or i = 0 and v != 0 197 | if intEqualsAny(ops.I, 1) && intEqualsAny(ops.V, 0) || 198 | intEqualsAny(ops.I, 0) && !intEqualsAny(ops.V, 0) { 199 | return One 200 | } 201 | // i = 2 and v = 0 202 | if intEqualsAny(ops.I, 2) && intEqualsAny(ops.V, 0) { 203 | return Two 204 | } 205 | return Other 206 | }, 207 | }) 208 | addPluralRules(rules, []string{"iu", "naq", "sat", "se", "sma", "smi", "smj", "smn", "sms"}, &Rule{ 209 | PluralForms: newPluralFormSet(One, Two, Other), 210 | PluralFormFunc: func(ops *Operands) Form { 211 | // n = 1 212 | if ops.NEqualsAny(1) { 213 | return One 214 | } 215 | // n = 2 216 | if ops.NEqualsAny(2) { 217 | return Two 218 | } 219 | return Other 220 | }, 221 | }) 222 | addPluralRules(rules, []string{"shi"}, &Rule{ 223 | PluralForms: newPluralFormSet(One, Few, Other), 224 | PluralFormFunc: func(ops *Operands) Form { 225 | // i = 0 or n = 1 226 | if intEqualsAny(ops.I, 0) || 227 | ops.NEqualsAny(1) { 228 | return One 229 | } 230 | // n = 2..10 231 | if ops.NInRange(2, 10) { 232 | return Few 233 | } 234 | return Other 235 | }, 236 | }) 237 | addPluralRules(rules, []string{"mo", "ro"}, &Rule{ 238 | PluralForms: newPluralFormSet(One, Few, Other), 239 | PluralFormFunc: func(ops *Operands) Form { 240 | // i = 1 and v = 0 241 | if intEqualsAny(ops.I, 1) && intEqualsAny(ops.V, 0) { 242 | return One 243 | } 244 | // v != 0 or n = 0 or n != 1 and n % 100 = 1..19 245 | if !intEqualsAny(ops.V, 0) || 246 | ops.NEqualsAny(0) || 247 | !ops.NEqualsAny(1) && ops.NModInRange(100, 1, 19) { 248 | return Few 249 | } 250 | return Other 251 | }, 252 | }) 253 | addPluralRules(rules, []string{"bs", "hr", "sh", "sr"}, &Rule{ 254 | PluralForms: newPluralFormSet(One, Few, Other), 255 | PluralFormFunc: func(ops *Operands) Form { 256 | // v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 257 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I%10, 1) && !intEqualsAny(ops.I%100, 11) || 258 | intEqualsAny(ops.F%10, 1) && !intEqualsAny(ops.F%100, 11) { 259 | return One 260 | } 261 | // v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 262 | if intEqualsAny(ops.V, 0) && intInRange(ops.I%10, 2, 4) && !intInRange(ops.I%100, 12, 14) || 263 | intInRange(ops.F%10, 2, 4) && !intInRange(ops.F%100, 12, 14) { 264 | return Few 265 | } 266 | return Other 267 | }, 268 | }) 269 | addPluralRules(rules, []string{"fr"}, &Rule{ 270 | PluralForms: newPluralFormSet(One, Many, Other), 271 | PluralFormFunc: func(ops *Operands) Form { 272 | // i = 0,1 273 | if intEqualsAny(ops.I, 0, 1) { 274 | return One 275 | } 276 | // e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 277 | if intEqualsAny(ops.C, 0) && !intEqualsAny(ops.I, 0) && intEqualsAny(ops.I%1000000, 0) && intEqualsAny(ops.V, 0) || 278 | !intInRange(ops.C, 0, 5) { 279 | return Many 280 | } 281 | return Other 282 | }, 283 | }) 284 | addPluralRules(rules, []string{"pt"}, &Rule{ 285 | PluralForms: newPluralFormSet(One, Many, Other), 286 | PluralFormFunc: func(ops *Operands) Form { 287 | // i = 0..1 288 | if intInRange(ops.I, 0, 1) { 289 | return One 290 | } 291 | // e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 292 | if intEqualsAny(ops.C, 0) && !intEqualsAny(ops.I, 0) && intEqualsAny(ops.I%1000000, 0) && intEqualsAny(ops.V, 0) || 293 | !intInRange(ops.C, 0, 5) { 294 | return Many 295 | } 296 | return Other 297 | }, 298 | }) 299 | addPluralRules(rules, []string{"ca", "it", "lld", "pt_PT", "scn", "vec"}, &Rule{ 300 | PluralForms: newPluralFormSet(One, Many, Other), 301 | PluralFormFunc: func(ops *Operands) Form { 302 | // i = 1 and v = 0 303 | if intEqualsAny(ops.I, 1) && intEqualsAny(ops.V, 0) { 304 | return One 305 | } 306 | // e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 307 | if intEqualsAny(ops.C, 0) && !intEqualsAny(ops.I, 0) && intEqualsAny(ops.I%1000000, 0) && intEqualsAny(ops.V, 0) || 308 | !intInRange(ops.C, 0, 5) { 309 | return Many 310 | } 311 | return Other 312 | }, 313 | }) 314 | addPluralRules(rules, []string{"es"}, &Rule{ 315 | PluralForms: newPluralFormSet(One, Many, Other), 316 | PluralFormFunc: func(ops *Operands) Form { 317 | // n = 1 318 | if ops.NEqualsAny(1) { 319 | return One 320 | } 321 | // e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 322 | if intEqualsAny(ops.C, 0) && !intEqualsAny(ops.I, 0) && intEqualsAny(ops.I%1000000, 0) && intEqualsAny(ops.V, 0) || 323 | !intInRange(ops.C, 0, 5) { 324 | return Many 325 | } 326 | return Other 327 | }, 328 | }) 329 | addPluralRules(rules, []string{"gd"}, &Rule{ 330 | PluralForms: newPluralFormSet(One, Two, Few, Other), 331 | PluralFormFunc: func(ops *Operands) Form { 332 | // n = 1,11 333 | if ops.NEqualsAny(1, 11) { 334 | return One 335 | } 336 | // n = 2,12 337 | if ops.NEqualsAny(2, 12) { 338 | return Two 339 | } 340 | // n = 3..10,13..19 341 | if ops.NInRange(3, 10) || ops.NInRange(13, 19) { 342 | return Few 343 | } 344 | return Other 345 | }, 346 | }) 347 | addPluralRules(rules, []string{"sl"}, &Rule{ 348 | PluralForms: newPluralFormSet(One, Two, Few, Other), 349 | PluralFormFunc: func(ops *Operands) Form { 350 | // v = 0 and i % 100 = 1 351 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I%100, 1) { 352 | return One 353 | } 354 | // v = 0 and i % 100 = 2 355 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I%100, 2) { 356 | return Two 357 | } 358 | // v = 0 and i % 100 = 3..4 or v != 0 359 | if intEqualsAny(ops.V, 0) && intInRange(ops.I%100, 3, 4) || 360 | !intEqualsAny(ops.V, 0) { 361 | return Few 362 | } 363 | return Other 364 | }, 365 | }) 366 | addPluralRules(rules, []string{"dsb", "hsb"}, &Rule{ 367 | PluralForms: newPluralFormSet(One, Two, Few, Other), 368 | PluralFormFunc: func(ops *Operands) Form { 369 | // v = 0 and i % 100 = 1 or f % 100 = 1 370 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I%100, 1) || 371 | intEqualsAny(ops.F%100, 1) { 372 | return One 373 | } 374 | // v = 0 and i % 100 = 2 or f % 100 = 2 375 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I%100, 2) || 376 | intEqualsAny(ops.F%100, 2) { 377 | return Two 378 | } 379 | // v = 0 and i % 100 = 3..4 or f % 100 = 3..4 380 | if intEqualsAny(ops.V, 0) && intInRange(ops.I%100, 3, 4) || 381 | intInRange(ops.F%100, 3, 4) { 382 | return Few 383 | } 384 | return Other 385 | }, 386 | }) 387 | addPluralRules(rules, []string{"cs", "sk"}, &Rule{ 388 | PluralForms: newPluralFormSet(One, Few, Many, Other), 389 | PluralFormFunc: func(ops *Operands) Form { 390 | // i = 1 and v = 0 391 | if intEqualsAny(ops.I, 1) && intEqualsAny(ops.V, 0) { 392 | return One 393 | } 394 | // i = 2..4 and v = 0 395 | if intInRange(ops.I, 2, 4) && intEqualsAny(ops.V, 0) { 396 | return Few 397 | } 398 | // v != 0 399 | if !intEqualsAny(ops.V, 0) { 400 | return Many 401 | } 402 | return Other 403 | }, 404 | }) 405 | addPluralRules(rules, []string{"pl"}, &Rule{ 406 | PluralForms: newPluralFormSet(One, Few, Many, Other), 407 | PluralFormFunc: func(ops *Operands) Form { 408 | // i = 1 and v = 0 409 | if intEqualsAny(ops.I, 1) && intEqualsAny(ops.V, 0) { 410 | return One 411 | } 412 | // v = 0 and i % 10 = 2..4 and i % 100 != 12..14 413 | if intEqualsAny(ops.V, 0) && intInRange(ops.I%10, 2, 4) && !intInRange(ops.I%100, 12, 14) { 414 | return Few 415 | } 416 | // v = 0 and i != 1 and i % 10 = 0..1 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 12..14 417 | if intEqualsAny(ops.V, 0) && !intEqualsAny(ops.I, 1) && intInRange(ops.I%10, 0, 1) || 418 | intEqualsAny(ops.V, 0) && intInRange(ops.I%10, 5, 9) || 419 | intEqualsAny(ops.V, 0) && intInRange(ops.I%100, 12, 14) { 420 | return Many 421 | } 422 | return Other 423 | }, 424 | }) 425 | addPluralRules(rules, []string{"be"}, &Rule{ 426 | PluralForms: newPluralFormSet(One, Few, Many, Other), 427 | PluralFormFunc: func(ops *Operands) Form { 428 | // n % 10 = 1 and n % 100 != 11 429 | if ops.NModEqualsAny(10, 1) && !ops.NModEqualsAny(100, 11) { 430 | return One 431 | } 432 | // n % 10 = 2..4 and n % 100 != 12..14 433 | if ops.NModInRange(10, 2, 4) && !ops.NModInRange(100, 12, 14) { 434 | return Few 435 | } 436 | // n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14 437 | if ops.NModEqualsAny(10, 0) || 438 | ops.NModInRange(10, 5, 9) || 439 | ops.NModInRange(100, 11, 14) { 440 | return Many 441 | } 442 | return Other 443 | }, 444 | }) 445 | addPluralRules(rules, []string{"lt"}, &Rule{ 446 | PluralForms: newPluralFormSet(One, Few, Many, Other), 447 | PluralFormFunc: func(ops *Operands) Form { 448 | // n % 10 = 1 and n % 100 != 11..19 449 | if ops.NModEqualsAny(10, 1) && !ops.NModInRange(100, 11, 19) { 450 | return One 451 | } 452 | // n % 10 = 2..9 and n % 100 != 11..19 453 | if ops.NModInRange(10, 2, 9) && !ops.NModInRange(100, 11, 19) { 454 | return Few 455 | } 456 | // f != 0 457 | if !intEqualsAny(ops.F, 0) { 458 | return Many 459 | } 460 | return Other 461 | }, 462 | }) 463 | addPluralRules(rules, []string{"ru", "uk"}, &Rule{ 464 | PluralForms: newPluralFormSet(One, Few, Many, Other), 465 | PluralFormFunc: func(ops *Operands) Form { 466 | // v = 0 and i % 10 = 1 and i % 100 != 11 467 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I%10, 1) && !intEqualsAny(ops.I%100, 11) { 468 | return One 469 | } 470 | // v = 0 and i % 10 = 2..4 and i % 100 != 12..14 471 | if intEqualsAny(ops.V, 0) && intInRange(ops.I%10, 2, 4) && !intInRange(ops.I%100, 12, 14) { 472 | return Few 473 | } 474 | // v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14 475 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I%10, 0) || 476 | intEqualsAny(ops.V, 0) && intInRange(ops.I%10, 5, 9) || 477 | intEqualsAny(ops.V, 0) && intInRange(ops.I%100, 11, 14) { 478 | return Many 479 | } 480 | return Other 481 | }, 482 | }) 483 | addPluralRules(rules, []string{"br"}, &Rule{ 484 | PluralForms: newPluralFormSet(One, Two, Few, Many, Other), 485 | PluralFormFunc: func(ops *Operands) Form { 486 | // n % 10 = 1 and n % 100 != 11,71,91 487 | if ops.NModEqualsAny(10, 1) && !ops.NModEqualsAny(100, 11, 71, 91) { 488 | return One 489 | } 490 | // n % 10 = 2 and n % 100 != 12,72,92 491 | if ops.NModEqualsAny(10, 2) && !ops.NModEqualsAny(100, 12, 72, 92) { 492 | return Two 493 | } 494 | // n % 10 = 3..4,9 and n % 100 != 10..19,70..79,90..99 495 | if (ops.NModInRange(10, 3, 4) || ops.NModEqualsAny(10, 9)) && !(ops.NModInRange(100, 10, 19) || ops.NModInRange(100, 70, 79) || ops.NModInRange(100, 90, 99)) { 496 | return Few 497 | } 498 | // n != 0 and n % 1000000 = 0 499 | if !ops.NEqualsAny(0) && ops.NModEqualsAny(1000000, 0) { 500 | return Many 501 | } 502 | return Other 503 | }, 504 | }) 505 | addPluralRules(rules, []string{"mt"}, &Rule{ 506 | PluralForms: newPluralFormSet(One, Two, Few, Many, Other), 507 | PluralFormFunc: func(ops *Operands) Form { 508 | // n = 1 509 | if ops.NEqualsAny(1) { 510 | return One 511 | } 512 | // n = 2 513 | if ops.NEqualsAny(2) { 514 | return Two 515 | } 516 | // n = 0 or n % 100 = 3..10 517 | if ops.NEqualsAny(0) || 518 | ops.NModInRange(100, 3, 10) { 519 | return Few 520 | } 521 | // n % 100 = 11..19 522 | if ops.NModInRange(100, 11, 19) { 523 | return Many 524 | } 525 | return Other 526 | }, 527 | }) 528 | addPluralRules(rules, []string{"ga"}, &Rule{ 529 | PluralForms: newPluralFormSet(One, Two, Few, Many, Other), 530 | PluralFormFunc: func(ops *Operands) Form { 531 | // n = 1 532 | if ops.NEqualsAny(1) { 533 | return One 534 | } 535 | // n = 2 536 | if ops.NEqualsAny(2) { 537 | return Two 538 | } 539 | // n = 3..6 540 | if ops.NInRange(3, 6) { 541 | return Few 542 | } 543 | // n = 7..10 544 | if ops.NInRange(7, 10) { 545 | return Many 546 | } 547 | return Other 548 | }, 549 | }) 550 | addPluralRules(rules, []string{"gv"}, &Rule{ 551 | PluralForms: newPluralFormSet(One, Two, Few, Many, Other), 552 | PluralFormFunc: func(ops *Operands) Form { 553 | // v = 0 and i % 10 = 1 554 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I%10, 1) { 555 | return One 556 | } 557 | // v = 0 and i % 10 = 2 558 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I%10, 2) { 559 | return Two 560 | } 561 | // v = 0 and i % 100 = 0,20,40,60,80 562 | if intEqualsAny(ops.V, 0) && intEqualsAny(ops.I%100, 0, 20, 40, 60, 80) { 563 | return Few 564 | } 565 | // v != 0 566 | if !intEqualsAny(ops.V, 0) { 567 | return Many 568 | } 569 | return Other 570 | }, 571 | }) 572 | addPluralRules(rules, []string{"kw"}, &Rule{ 573 | PluralForms: newPluralFormSet(Zero, One, Two, Few, Many, Other), 574 | PluralFormFunc: func(ops *Operands) Form { 575 | // n = 0 576 | if ops.NEqualsAny(0) { 577 | return Zero 578 | } 579 | // n = 1 580 | if ops.NEqualsAny(1) { 581 | return One 582 | } 583 | // n % 100 = 2,22,42,62,82 or n % 1000 = 0 and n % 100000 = 1000..20000,40000,60000,80000 or n != 0 and n % 1000000 = 100000 584 | if ops.NModEqualsAny(100, 2, 22, 42, 62, 82) || 585 | ops.NModEqualsAny(1000, 0) && (ops.NModInRange(100000, 1000, 20000) || ops.NModEqualsAny(100000, 40000, 60000, 80000)) || 586 | !ops.NEqualsAny(0) && ops.NModEqualsAny(1000000, 100000) { 587 | return Two 588 | } 589 | // n % 100 = 3,23,43,63,83 590 | if ops.NModEqualsAny(100, 3, 23, 43, 63, 83) { 591 | return Few 592 | } 593 | // n != 1 and n % 100 = 1,21,41,61,81 594 | if !ops.NEqualsAny(1) && ops.NModEqualsAny(100, 1, 21, 41, 61, 81) { 595 | return Many 596 | } 597 | return Other 598 | }, 599 | }) 600 | addPluralRules(rules, []string{"ar", "ars"}, &Rule{ 601 | PluralForms: newPluralFormSet(Zero, One, Two, Few, Many, Other), 602 | PluralFormFunc: func(ops *Operands) Form { 603 | // n = 0 604 | if ops.NEqualsAny(0) { 605 | return Zero 606 | } 607 | // n = 1 608 | if ops.NEqualsAny(1) { 609 | return One 610 | } 611 | // n = 2 612 | if ops.NEqualsAny(2) { 613 | return Two 614 | } 615 | // n % 100 = 3..10 616 | if ops.NModInRange(100, 3, 10) { 617 | return Few 618 | } 619 | // n % 100 = 11..99 620 | if ops.NModInRange(100, 11, 99) { 621 | return Many 622 | } 623 | return Other 624 | }, 625 | }) 626 | addPluralRules(rules, []string{"cy"}, &Rule{ 627 | PluralForms: newPluralFormSet(Zero, One, Two, Few, Many, Other), 628 | PluralFormFunc: func(ops *Operands) Form { 629 | // n = 0 630 | if ops.NEqualsAny(0) { 631 | return Zero 632 | } 633 | // n = 1 634 | if ops.NEqualsAny(1) { 635 | return One 636 | } 637 | // n = 2 638 | if ops.NEqualsAny(2) { 639 | return Two 640 | } 641 | // n = 3 642 | if ops.NEqualsAny(3) { 643 | return Few 644 | } 645 | // n = 6 646 | if ops.NEqualsAny(6) { 647 | return Many 648 | } 649 | return Other 650 | }, 651 | }) 652 | 653 | return rules 654 | } 655 | -------------------------------------------------------------------------------- /internal/plural/rule_test.go: -------------------------------------------------------------------------------- 1 | package plural 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "testing" 7 | 8 | "golang.org/x/text/language" 9 | ) 10 | 11 | type pluralFormTest struct { 12 | num interface{} 13 | form Form 14 | } 15 | 16 | func runTests(t *testing.T, pluralRuleID string, tests []pluralFormTest) { 17 | if pluralRuleID == "root" { 18 | return 19 | } 20 | pluralRules := DefaultRules() 21 | tag := language.MustParse(pluralRuleID) 22 | if rule := pluralRules.Rule(tag); rule != nil { 23 | for _, test := range tests { 24 | ops, err := NewOperands(test.num) 25 | if err != nil { 26 | t.Errorf("%s: NewOperands(%d) errored with %s", pluralRuleID, test.num, err) 27 | break 28 | } 29 | if pluralForm := rule.PluralFormFunc(ops); pluralForm != test.form { 30 | t.Errorf("%s: PluralFormFunc(%#v) returned %q, %v; expected %q", pluralRuleID, ops, pluralForm, err, test.form) 31 | } 32 | } 33 | } else { 34 | t.Errorf("could not find plural rule for locale %s", pluralRuleID) 35 | } 36 | 37 | } 38 | 39 | func appendIntegerTests(tests []pluralFormTest, form Form, examples []string) []pluralFormTest { 40 | for _, ex := range expandExamples(examples) { 41 | i, err := strconv.ParseInt(ex, 10, 64) 42 | if err != nil { 43 | panic(err) 44 | } 45 | tests = append(tests, pluralFormTest{ex, form}, pluralFormTest{i, form}) 46 | } 47 | return tests 48 | } 49 | 50 | func appendDecimalTests(tests []pluralFormTest, form Form, examples []string) []pluralFormTest { 51 | for _, ex := range expandExamples(examples) { 52 | tests = append(tests, pluralFormTest{ex, form}) 53 | } 54 | return tests 55 | } 56 | 57 | func expandExamples(examples []string) []string { 58 | var expanded []string 59 | for _, ex := range examples { 60 | if parts := strings.Split(ex, "~"); len(parts) == 2 { 61 | for ex := parts[0]; ; ex = increment(ex) { 62 | expanded = append(expanded, ex) 63 | if ex == parts[1] { 64 | break 65 | } 66 | } 67 | } else { 68 | expanded = append(expanded, ex) 69 | } 70 | } 71 | return expanded 72 | } 73 | 74 | func increment(dec string) string { 75 | runes := []rune(dec) 76 | carry := true 77 | for i := len(runes) - 1; carry && i >= 0; i-- { 78 | switch runes[i] { 79 | case '.': 80 | continue 81 | case '9': 82 | runes[i] = '0' 83 | default: 84 | runes[i]++ 85 | carry = false 86 | } 87 | } 88 | if carry { 89 | runes = append([]rune{'1'}, runes...) 90 | } 91 | return string(runes) 92 | } 93 | -------------------------------------------------------------------------------- /internal/plural/rules.go: -------------------------------------------------------------------------------- 1 | package plural 2 | 3 | import "golang.org/x/text/language" 4 | 5 | // Rules is a set of plural rules by language tag. 6 | type Rules map[language.Tag]*Rule 7 | 8 | // Rule returns the closest matching plural rule for the language tag 9 | // or nil if no rule could be found. 10 | func (r Rules) Rule(tag language.Tag) *Rule { 11 | t := tag 12 | for { 13 | if rule := r[t]; rule != nil { 14 | return rule 15 | } 16 | t = t.Parent() 17 | if t.IsRoot() { 18 | break 19 | } 20 | } 21 | base, _ := tag.Base() 22 | baseTag, _ := language.Parse(base.String()) 23 | return r[baseTag] 24 | } 25 | -------------------------------------------------------------------------------- /internal/plural/rules_test.go: -------------------------------------------------------------------------------- 1 | package plural 2 | 3 | import ( 4 | "testing" 5 | 6 | "golang.org/x/text/language" 7 | ) 8 | 9 | func TestRules(t *testing.T) { 10 | expectedRule := &Rule{} 11 | 12 | testCases := []struct { 13 | name string 14 | rules Rules 15 | tag language.Tag 16 | rule *Rule 17 | }{ 18 | { 19 | name: "exact match", 20 | rules: Rules{ 21 | language.English: expectedRule, 22 | language.Spanish: &Rule{}, 23 | }, 24 | tag: language.English, 25 | rule: expectedRule, 26 | }, 27 | { 28 | name: "inexact match", 29 | rules: Rules{ 30 | language.English: expectedRule, 31 | }, 32 | tag: language.AmericanEnglish, 33 | rule: expectedRule, 34 | }, 35 | { 36 | name: "portuguese doesn't match european portuguese", 37 | rules: Rules{ 38 | language.EuropeanPortuguese: &Rule{}, 39 | }, 40 | tag: language.Portuguese, 41 | rule: nil, 42 | }, 43 | { 44 | name: "european portuguese preferred", 45 | rules: Rules{ 46 | language.Portuguese: &Rule{}, 47 | language.EuropeanPortuguese: expectedRule, 48 | }, 49 | tag: language.EuropeanPortuguese, 50 | rule: expectedRule, 51 | }, 52 | { 53 | name: "zh-Hans", 54 | rules: Rules{ 55 | language.Chinese: expectedRule, 56 | }, 57 | tag: language.SimplifiedChinese, 58 | rule: expectedRule, 59 | }, 60 | { 61 | name: "zh-Hant", 62 | rules: Rules{ 63 | language.Chinese: expectedRule, 64 | }, 65 | tag: language.TraditionalChinese, 66 | rule: expectedRule, 67 | }, 68 | } 69 | 70 | for _, testCase := range testCases { 71 | t.Run(testCase.name, func(t *testing.T) { 72 | if rule := testCase.rules.Rule(testCase.tag); rule != testCase.rule { 73 | panic(rule) 74 | } 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /internal/template.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/nicksnyder/go-i18n/v2/i18n/template" 7 | ) 8 | 9 | // Template stores the template for a string and a cached version of the parsed template if they are cacheable. 10 | type Template struct { 11 | Src string 12 | LeftDelim string 13 | RightDelim string 14 | 15 | parseOnce sync.Once 16 | parsedTemplate template.ParsedTemplate 17 | parseError error 18 | } 19 | 20 | func (t *Template) Execute(parser template.Parser, data interface{}) (string, error) { 21 | var pt template.ParsedTemplate 22 | var err error 23 | if parser.Cacheable() { 24 | t.parseOnce.Do(func() { 25 | t.parsedTemplate, t.parseError = parser.Parse(t.Src, t.LeftDelim, t.RightDelim) 26 | }) 27 | pt, err = t.parsedTemplate, t.parseError 28 | } else { 29 | pt, err = parser.Parse(t.Src, t.LeftDelim, t.RightDelim) 30 | } 31 | 32 | if err != nil { 33 | return "", err 34 | } 35 | return pt.Execute(data) 36 | } 37 | -------------------------------------------------------------------------------- /internal/template_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | texttemplate "text/template" 7 | 8 | "github.com/nicksnyder/go-i18n/v2/i18n/template" 9 | ) 10 | 11 | func TestExecute(t *testing.T) { 12 | tests := []struct { 13 | template *Template 14 | parser template.Parser 15 | data interface{} 16 | result string 17 | err string 18 | noallocs bool 19 | }{ 20 | { 21 | template: &Template{ 22 | Src: "hello", 23 | }, 24 | result: "hello", 25 | noallocs: true, 26 | }, 27 | { 28 | template: &Template{ 29 | Src: "hello {{.Noun}}", 30 | }, 31 | data: map[string]string{ 32 | "Noun": "world", 33 | }, 34 | result: "hello world", 35 | }, 36 | { 37 | template: &Template{ 38 | Src: "hello {{world}}", 39 | }, 40 | parser: &template.TextParser{ 41 | Funcs: texttemplate.FuncMap{ 42 | "world": func() string { 43 | return "world" 44 | }, 45 | }, 46 | }, 47 | result: "hello world", 48 | }, 49 | { 50 | template: &Template{ 51 | Src: "hello {{", 52 | }, 53 | err: "unclosed action", 54 | noallocs: true, 55 | }, 56 | } 57 | 58 | for _, test := range tests { 59 | t.Run(test.template.Src, func(t *testing.T) { 60 | if test.parser == nil { 61 | test.parser = &template.TextParser{} 62 | } 63 | result, err := test.template.Execute(test.parser, test.data) 64 | if actual := str(err); !strings.Contains(str(err), test.err) { 65 | t.Errorf("expected err %q to contain %q", actual, test.err) 66 | } 67 | if result != test.result { 68 | t.Errorf("expected result %q; got %q", test.result, result) 69 | } 70 | allocs := testing.AllocsPerRun(10, func() { 71 | _, _ = test.template.Execute(test.parser, test.data) 72 | }) 73 | if test.noallocs && allocs > 0 { 74 | t.Errorf("expected no allocations; got %f", allocs) 75 | } 76 | }) 77 | } 78 | } 79 | 80 | func str(err error) string { 81 | if err == nil { 82 | return "" 83 | } 84 | return err.Error() 85 | } 86 | --------------------------------------------------------------------------------