feat: grammar-aware i18n module extracted from core
Standalone grammar-aware translation engine with: - 3-tier verb/noun fallback (JSON locale → irregular maps → regular rules) - 6 built-in i18n.* namespace handlers (label, progress, count, done, fail, numeric) - Nested en.json with gram/prompt/time/lang sections (no flat command keys) - CLDR plural rules for 10 languages - Subject fluent API, number/time formatting, RTL detection - 55 tests passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
e8a87b0f50
25 changed files with 4337 additions and 0 deletions
101
compose.go
Normal file
101
compose.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package i18n
|
||||
|
||||
import "fmt"
|
||||
|
||||
// S creates a new Subject with the given noun and value.
|
||||
//
|
||||
// S("file", "config.yaml")
|
||||
// S("file", path).Count(3).In("workspace")
|
||||
func S(noun string, value any) *Subject {
|
||||
return &Subject{Noun: noun, Value: value, count: 1}
|
||||
}
|
||||
|
||||
func (s *Subject) Count(n int) *Subject {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.count = n
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Subject) Gender(g string) *Subject {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.gender = g
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Subject) In(location string) *Subject {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.location = location
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Subject) Formal() *Subject {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.formality = FormalityFormal
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Subject) Informal() *Subject {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.formality = FormalityInformal
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Subject) SetFormality(f Formality) *Subject {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.formality = f
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Subject) String() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
if stringer, ok := s.Value.(fmt.Stringer); ok {
|
||||
return stringer.String()
|
||||
}
|
||||
return fmt.Sprint(s.Value)
|
||||
}
|
||||
|
||||
func (s *Subject) IsPlural() bool { return s != nil && s.count != 1 }
|
||||
func (s *Subject) CountInt() int { if s == nil { return 1 }; return s.count }
|
||||
func (s *Subject) CountString() string { if s == nil { return "1" }; return fmt.Sprint(s.count) }
|
||||
func (s *Subject) GenderString() string { if s == nil { return "" }; return s.gender }
|
||||
func (s *Subject) LocationString() string { if s == nil { return "" }; return s.location }
|
||||
func (s *Subject) NounString() string { if s == nil { return "" }; return s.Noun }
|
||||
func (s *Subject) FormalityString() string {
|
||||
if s == nil {
|
||||
return FormalityNeutral.String()
|
||||
}
|
||||
return s.formality.String()
|
||||
}
|
||||
func (s *Subject) IsFormal() bool { return s != nil && s.formality == FormalityFormal }
|
||||
func (s *Subject) IsInformal() bool { return s != nil && s.formality == FormalityInformal }
|
||||
|
||||
func newTemplateData(s *Subject) templateData {
|
||||
if s == nil {
|
||||
return templateData{Count: 1}
|
||||
}
|
||||
return templateData{
|
||||
Subject: s.String(),
|
||||
Noun: s.Noun,
|
||||
Count: s.count,
|
||||
Gender: s.gender,
|
||||
Location: s.location,
|
||||
Formality: s.formality,
|
||||
IsFormal: s.formality == FormalityFormal,
|
||||
IsPlural: s.count != 1,
|
||||
Value: s.Value,
|
||||
}
|
||||
}
|
||||
70
context.go
Normal file
70
context.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package i18n
|
||||
|
||||
// TranslationContext provides disambiguation for translations.
|
||||
//
|
||||
// T("direction.right", C("navigation")) // "rechts" (German)
|
||||
// T("status.right", C("correctness")) // "richtig" (German)
|
||||
type TranslationContext struct {
|
||||
Context string
|
||||
Gender string
|
||||
Formality Formality
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
// C creates a TranslationContext.
|
||||
func C(context string) *TranslationContext {
|
||||
return &TranslationContext{Context: context}
|
||||
}
|
||||
|
||||
func (c *TranslationContext) WithGender(gender string) *TranslationContext {
|
||||
if c == nil { return nil }
|
||||
c.Gender = gender
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) Formal() *TranslationContext {
|
||||
if c == nil { return nil }
|
||||
c.Formality = FormalityFormal
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) Informal() *TranslationContext {
|
||||
if c == nil { return nil }
|
||||
c.Formality = FormalityInformal
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) WithFormality(f Formality) *TranslationContext {
|
||||
if c == nil { return nil }
|
||||
c.Formality = f
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) Set(key string, value any) *TranslationContext {
|
||||
if c == nil { return nil }
|
||||
if c.Extra == nil {
|
||||
c.Extra = make(map[string]any)
|
||||
}
|
||||
c.Extra[key] = value
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) Get(key string) any {
|
||||
if c == nil || c.Extra == nil { return nil }
|
||||
return c.Extra[key]
|
||||
}
|
||||
|
||||
func (c *TranslationContext) ContextString() string {
|
||||
if c == nil { return "" }
|
||||
return c.Context
|
||||
}
|
||||
|
||||
func (c *TranslationContext) GenderString() string {
|
||||
if c == nil { return "" }
|
||||
return c.Gender
|
||||
}
|
||||
|
||||
func (c *TranslationContext) FormalityValue() Formality {
|
||||
if c == nil { return FormalityNeutral }
|
||||
return c.Formality
|
||||
}
|
||||
24
debug.go
Normal file
24
debug.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package i18n
|
||||
|
||||
// SetDebug enables or disables debug mode on the default service.
|
||||
func SetDebug(enabled bool) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.SetDebug(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SetDebug(enabled bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.debug = enabled
|
||||
}
|
||||
|
||||
func (s *Service) Debug() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.debug
|
||||
}
|
||||
|
||||
func debugFormat(key, text string) string {
|
||||
return "[" + key + "] " + text
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module forge.lthn.ai/core/go-i18n
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require golang.org/x/text v0.33.0
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
391
grammar.go
Normal file
391
grammar.go
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"text/template"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// GetGrammarData returns the grammar data for the specified language.
|
||||
func GetGrammarData(lang string) *GrammarData {
|
||||
grammarCacheMu.RLock()
|
||||
defer grammarCacheMu.RUnlock()
|
||||
return grammarCache[lang]
|
||||
}
|
||||
|
||||
// SetGrammarData sets the grammar data for a language.
|
||||
func SetGrammarData(lang string, data *GrammarData) {
|
||||
grammarCacheMu.Lock()
|
||||
defer grammarCacheMu.Unlock()
|
||||
grammarCache[lang] = data
|
||||
}
|
||||
|
||||
func getVerbForm(lang, verb, form string) string {
|
||||
data := GetGrammarData(lang)
|
||||
if data == nil || data.Verbs == nil {
|
||||
return ""
|
||||
}
|
||||
verb = strings.ToLower(verb)
|
||||
if forms, ok := data.Verbs[verb]; ok {
|
||||
switch form {
|
||||
case "past":
|
||||
return forms.Past
|
||||
case "gerund":
|
||||
return forms.Gerund
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getWord(lang, word string) string {
|
||||
data := GetGrammarData(lang)
|
||||
if data == nil || data.Words == nil {
|
||||
return ""
|
||||
}
|
||||
return data.Words[strings.ToLower(word)]
|
||||
}
|
||||
|
||||
func getPunct(lang, rule, defaultVal string) string {
|
||||
data := GetGrammarData(lang)
|
||||
if data == nil {
|
||||
return defaultVal
|
||||
}
|
||||
switch rule {
|
||||
case "label":
|
||||
if data.Punct.LabelSuffix != "" {
|
||||
return data.Punct.LabelSuffix
|
||||
}
|
||||
case "progress":
|
||||
if data.Punct.ProgressSuffix != "" {
|
||||
return data.Punct.ProgressSuffix
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getNounForm(lang, noun, form string) string {
|
||||
data := GetGrammarData(lang)
|
||||
if data == nil || data.Nouns == nil {
|
||||
return ""
|
||||
}
|
||||
noun = strings.ToLower(noun)
|
||||
if forms, ok := data.Nouns[noun]; ok {
|
||||
switch form {
|
||||
case "one":
|
||||
return forms.One
|
||||
case "other":
|
||||
return forms.Other
|
||||
case "gender":
|
||||
return forms.Gender
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func currentLangForGrammar() string {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.Language()
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// PastTense returns the past tense of a verb.
|
||||
// 3-tier fallback: JSON locale -> irregular verbs -> regular rules.
|
||||
//
|
||||
// PastTense("delete") // "deleted"
|
||||
// PastTense("run") // "ran"
|
||||
// PastTense("copy") // "copied"
|
||||
func PastTense(verb string) string {
|
||||
verb = strings.ToLower(strings.TrimSpace(verb))
|
||||
if verb == "" {
|
||||
return ""
|
||||
}
|
||||
if form := getVerbForm(currentLangForGrammar(), verb, "past"); form != "" {
|
||||
return form
|
||||
}
|
||||
if forms, ok := irregularVerbs[verb]; ok {
|
||||
return forms.Past
|
||||
}
|
||||
return applyRegularPastTense(verb)
|
||||
}
|
||||
|
||||
func applyRegularPastTense(verb string) string {
|
||||
if strings.HasSuffix(verb, "ed") && len(verb) > 2 {
|
||||
thirdFromEnd := verb[len(verb)-3]
|
||||
if !isVowel(rune(thirdFromEnd)) && thirdFromEnd != 'e' {
|
||||
return verb
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(verb, "e") {
|
||||
return verb + "d"
|
||||
}
|
||||
if strings.HasSuffix(verb, "y") && len(verb) > 1 {
|
||||
prev := rune(verb[len(verb)-2])
|
||||
if !isVowel(prev) {
|
||||
return verb[:len(verb)-1] + "ied"
|
||||
}
|
||||
}
|
||||
if len(verb) >= 2 && shouldDoubleConsonant(verb) {
|
||||
return verb + string(verb[len(verb)-1]) + "ed"
|
||||
}
|
||||
return verb + "ed"
|
||||
}
|
||||
|
||||
func shouldDoubleConsonant(verb string) bool {
|
||||
if len(verb) < 3 {
|
||||
return false
|
||||
}
|
||||
if noDoubleConsonant[verb] {
|
||||
return false
|
||||
}
|
||||
lastChar := rune(verb[len(verb)-1])
|
||||
secondLast := rune(verb[len(verb)-2])
|
||||
if isVowel(lastChar) || lastChar == 'w' || lastChar == 'x' || lastChar == 'y' {
|
||||
return false
|
||||
}
|
||||
if !isVowel(secondLast) {
|
||||
return false
|
||||
}
|
||||
if len(verb) <= 4 {
|
||||
thirdLast := rune(verb[len(verb)-3])
|
||||
return !isVowel(thirdLast)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Gerund returns the present participle (-ing form) of a verb.
|
||||
//
|
||||
// Gerund("delete") // "deleting"
|
||||
// Gerund("run") // "running"
|
||||
// Gerund("die") // "dying"
|
||||
func Gerund(verb string) string {
|
||||
verb = strings.ToLower(strings.TrimSpace(verb))
|
||||
if verb == "" {
|
||||
return ""
|
||||
}
|
||||
if form := getVerbForm(currentLangForGrammar(), verb, "gerund"); form != "" {
|
||||
return form
|
||||
}
|
||||
if forms, ok := irregularVerbs[verb]; ok {
|
||||
return forms.Gerund
|
||||
}
|
||||
return applyRegularGerund(verb)
|
||||
}
|
||||
|
||||
func applyRegularGerund(verb string) string {
|
||||
if strings.HasSuffix(verb, "ie") {
|
||||
return verb[:len(verb)-2] + "ying"
|
||||
}
|
||||
if strings.HasSuffix(verb, "e") && len(verb) > 1 {
|
||||
secondLast := rune(verb[len(verb)-2])
|
||||
if secondLast != 'e' && secondLast != 'y' && secondLast != 'o' {
|
||||
return verb[:len(verb)-1] + "ing"
|
||||
}
|
||||
}
|
||||
if shouldDoubleConsonant(verb) {
|
||||
return verb + string(verb[len(verb)-1]) + "ing"
|
||||
}
|
||||
return verb + "ing"
|
||||
}
|
||||
|
||||
// Pluralize returns the plural form of a noun based on count.
|
||||
//
|
||||
// Pluralize("file", 1) // "file"
|
||||
// Pluralize("file", 5) // "files"
|
||||
// Pluralize("child", 3) // "children"
|
||||
func Pluralize(noun string, count int) string {
|
||||
if count == 1 {
|
||||
return noun
|
||||
}
|
||||
return PluralForm(noun)
|
||||
}
|
||||
|
||||
// PluralForm returns the plural form of a noun.
|
||||
func PluralForm(noun string) string {
|
||||
noun = strings.TrimSpace(noun)
|
||||
if noun == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(noun)
|
||||
if form := getNounForm(currentLangForGrammar(), lower, "other"); form != "" {
|
||||
if unicode.IsUpper(rune(noun[0])) && len(form) > 0 {
|
||||
return strings.ToUpper(string(form[0])) + form[1:]
|
||||
}
|
||||
return form
|
||||
}
|
||||
if plural, ok := irregularNouns[lower]; ok {
|
||||
if unicode.IsUpper(rune(noun[0])) {
|
||||
return strings.ToUpper(string(plural[0])) + plural[1:]
|
||||
}
|
||||
return plural
|
||||
}
|
||||
return applyRegularPlural(noun)
|
||||
}
|
||||
|
||||
func applyRegularPlural(noun string) string {
|
||||
lower := strings.ToLower(noun)
|
||||
if strings.HasSuffix(lower, "s") ||
|
||||
strings.HasSuffix(lower, "ss") ||
|
||||
strings.HasSuffix(lower, "sh") ||
|
||||
strings.HasSuffix(lower, "ch") ||
|
||||
strings.HasSuffix(lower, "x") ||
|
||||
strings.HasSuffix(lower, "z") {
|
||||
return noun + "es"
|
||||
}
|
||||
if strings.HasSuffix(lower, "y") && len(noun) > 1 {
|
||||
prev := rune(lower[len(lower)-2])
|
||||
if !isVowel(prev) {
|
||||
return noun[:len(noun)-1] + "ies"
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(lower, "f") {
|
||||
return noun[:len(noun)-1] + "ves"
|
||||
}
|
||||
if strings.HasSuffix(lower, "fe") {
|
||||
return noun[:len(noun)-2] + "ves"
|
||||
}
|
||||
if strings.HasSuffix(lower, "o") && len(noun) > 1 {
|
||||
prev := rune(lower[len(lower)-2])
|
||||
if !isVowel(prev) {
|
||||
if lower == "hero" || lower == "potato" || lower == "tomato" || lower == "echo" || lower == "veto" {
|
||||
return noun + "es"
|
||||
}
|
||||
}
|
||||
}
|
||||
return noun + "s"
|
||||
}
|
||||
|
||||
// Article returns the appropriate indefinite article ("a" or "an").
|
||||
//
|
||||
// Article("file") // "a"
|
||||
// Article("error") // "an"
|
||||
// Article("user") // "a" (sounds like "yoo-zer")
|
||||
// Article("hour") // "an" (silent h)
|
||||
func Article(word string) string {
|
||||
if word == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(word))
|
||||
for key := range consonantSounds {
|
||||
if strings.HasPrefix(lower, key) {
|
||||
return "a"
|
||||
}
|
||||
}
|
||||
for key := range vowelSounds {
|
||||
if strings.HasPrefix(lower, key) {
|
||||
return "an"
|
||||
}
|
||||
}
|
||||
if len(lower) > 0 && isVowel(rune(lower[0])) {
|
||||
return "an"
|
||||
}
|
||||
return "a"
|
||||
}
|
||||
|
||||
func isVowel(r rune) bool {
|
||||
switch unicode.ToLower(r) {
|
||||
case 'a', 'e', 'i', 'o', 'u':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Title capitalises the first letter of each word.
|
||||
func Title(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
prev := ' '
|
||||
for _, r := range s {
|
||||
if !unicode.IsLetter(prev) && unicode.IsLetter(r) {
|
||||
b.WriteRune(unicode.ToUpper(r))
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
prev = r
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Quote wraps a string in double quotes.
|
||||
func Quote(s string) string {
|
||||
return `"` + s + `"`
|
||||
}
|
||||
|
||||
// TemplateFuncs returns the template.FuncMap with all grammar functions.
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"title": Title,
|
||||
"lower": strings.ToLower,
|
||||
"upper": strings.ToUpper,
|
||||
"past": PastTense,
|
||||
"gerund": Gerund,
|
||||
"plural": Pluralize,
|
||||
"pluralForm": PluralForm,
|
||||
"article": Article,
|
||||
"quote": Quote,
|
||||
}
|
||||
}
|
||||
|
||||
// Progress returns a progress message: "Building..."
|
||||
func Progress(verb string) string {
|
||||
lang := currentLangForGrammar()
|
||||
word := getWord(lang, verb)
|
||||
if word == "" {
|
||||
word = verb
|
||||
}
|
||||
g := Gerund(word)
|
||||
if g == "" {
|
||||
return ""
|
||||
}
|
||||
suffix := getPunct(lang, "progress", "...")
|
||||
return Title(g) + suffix
|
||||
}
|
||||
|
||||
// ProgressSubject returns a progress message with subject: "Building project..."
|
||||
func ProgressSubject(verb, subject string) string {
|
||||
lang := currentLangForGrammar()
|
||||
word := getWord(lang, verb)
|
||||
if word == "" {
|
||||
word = verb
|
||||
}
|
||||
g := Gerund(word)
|
||||
if g == "" {
|
||||
return ""
|
||||
}
|
||||
suffix := getPunct(lang, "progress", "...")
|
||||
return Title(g) + " " + subject + suffix
|
||||
}
|
||||
|
||||
// ActionResult returns a completion message: "File deleted"
|
||||
func ActionResult(verb, subject string) string {
|
||||
p := PastTense(verb)
|
||||
if p == "" || subject == "" {
|
||||
return ""
|
||||
}
|
||||
return Title(subject) + " " + p
|
||||
}
|
||||
|
||||
// ActionFailed returns a failure message: "Failed to delete file"
|
||||
func ActionFailed(verb, subject string) string {
|
||||
if verb == "" {
|
||||
return ""
|
||||
}
|
||||
if subject == "" {
|
||||
return "Failed to " + verb
|
||||
}
|
||||
return "Failed to " + verb + " " + subject
|
||||
}
|
||||
|
||||
// Label returns a label with suffix: "Status:" (EN) or "Statut :" (FR)
|
||||
func Label(word string) string {
|
||||
if word == "" {
|
||||
return ""
|
||||
}
|
||||
lang := currentLangForGrammar()
|
||||
translated := getWord(lang, word)
|
||||
if translated == "" {
|
||||
translated = word
|
||||
}
|
||||
suffix := getPunct(lang, "label", ":")
|
||||
return Title(translated) + suffix
|
||||
}
|
||||
357
grammar_test.go
Normal file
357
grammar_test.go
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPastTense(t *testing.T) {
|
||||
// Ensure grammar data is loaded from embedded JSON
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
verb string
|
||||
want string
|
||||
}{
|
||||
// Irregular verbs (from JSON)
|
||||
{"be", "was"},
|
||||
{"go", "went"},
|
||||
{"run", "ran"},
|
||||
{"write", "wrote"},
|
||||
{"build", "built"},
|
||||
{"find", "found"},
|
||||
{"set", "set"},
|
||||
{"put", "put"},
|
||||
{"cut", "cut"},
|
||||
{"commit", "committed"},
|
||||
|
||||
// Irregular verbs (from Go map only)
|
||||
{"break", "broke"},
|
||||
{"speak", "spoke"},
|
||||
{"steal", "stole"},
|
||||
{"freeze", "froze"},
|
||||
|
||||
// Regular verbs
|
||||
{"delete", "deleted"},
|
||||
{"update", "updated"},
|
||||
{"push", "pushed"},
|
||||
{"pull", "pulled"},
|
||||
{"start", "started"},
|
||||
{"copy", "copied"},
|
||||
{"apply", "applied"},
|
||||
|
||||
// Edge cases
|
||||
{"", ""},
|
||||
{" delete ", "deleted"},
|
||||
{"DELETE", "deleted"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.verb, func(t *testing.T) {
|
||||
got := PastTense(tt.verb)
|
||||
if got != tt.want {
|
||||
t.Errorf("PastTense(%q) = %q, want %q", tt.verb, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGerund(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
verb string
|
||||
want string
|
||||
}{
|
||||
// Irregular verbs (from JSON)
|
||||
{"be", "being"},
|
||||
{"go", "going"},
|
||||
{"run", "running"},
|
||||
{"build", "building"},
|
||||
{"write", "writing"},
|
||||
{"commit", "committing"},
|
||||
|
||||
// Regular verbs
|
||||
{"delete", "deleting"},
|
||||
{"push", "pushing"},
|
||||
{"pull", "pulling"},
|
||||
{"start", "starting"},
|
||||
{"die", "dying"},
|
||||
|
||||
// Edge cases
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.verb, func(t *testing.T) {
|
||||
got := Gerund(tt.verb)
|
||||
if got != tt.want {
|
||||
t.Errorf("Gerund(%q) = %q, want %q", tt.verb, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralize(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
noun string
|
||||
count int
|
||||
want string
|
||||
}{
|
||||
// Singular (count=1 always returns original)
|
||||
{"file", 1, "file"},
|
||||
{"child", 1, "child"},
|
||||
|
||||
// From JSON grammar data
|
||||
{"file", 5, "files"},
|
||||
{"repo", 3, "repos"},
|
||||
{"branch", 2, "branches"},
|
||||
{"repository", 2, "repositories"},
|
||||
{"vulnerability", 2, "vulnerabilities"},
|
||||
{"person", 2, "people"},
|
||||
{"child", 3, "children"},
|
||||
|
||||
// From irregular nouns map
|
||||
{"mouse", 2, "mice"},
|
||||
{"sheep", 5, "sheep"},
|
||||
{"knife", 3, "knives"},
|
||||
|
||||
// Regular plurals
|
||||
{"server", 2, "servers"},
|
||||
{"box", 2, "boxes"},
|
||||
|
||||
// Count 0
|
||||
{"file", 0, "files"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.noun, func(t *testing.T) {
|
||||
got := Pluralize(tt.noun, tt.count)
|
||||
if got != tt.want {
|
||||
t.Errorf("Pluralize(%q, %d) = %q, want %q", tt.noun, tt.count, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralForm(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
noun string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
|
||||
// Capitalisation preserved
|
||||
{"File", "Files"},
|
||||
{"Child", "Children"},
|
||||
|
||||
// Regular rules
|
||||
{"bus", "buses"},
|
||||
{"fox", "foxes"},
|
||||
{"city", "cities"},
|
||||
{"key", "keys"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.noun, func(t *testing.T) {
|
||||
got := PluralForm(tt.noun)
|
||||
if got != tt.want {
|
||||
t.Errorf("PluralForm(%q) = %q, want %q", tt.noun, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticle(t *testing.T) {
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"file", "a"},
|
||||
{"error", "an"},
|
||||
{"apple", "an"},
|
||||
{"user", "a"}, // Consonant sound: "yoo-zer"
|
||||
{"hour", "an"}, // Vowel sound: silent h
|
||||
{"honest", "an"}, // Vowel sound
|
||||
{"university", "a"}, // Consonant sound
|
||||
{"one", "a"}, // Consonant sound
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := Article(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("Article(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"hello world", "Hello World"},
|
||||
{"hello", "Hello"},
|
||||
{"", ""},
|
||||
{"HELLO", "HELLO"},
|
||||
{"hello-world", "Hello-World"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := Title(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("Title(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuote(t *testing.T) {
|
||||
if got := Quote("hello"); got != `"hello"` {
|
||||
t.Errorf("Quote(%q) = %q, want %q", "hello", got, `"hello"`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabel(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"status", "Status:"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := Label(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("Label(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgress(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
verb string
|
||||
want string
|
||||
}{
|
||||
{"build", "Building..."},
|
||||
{"delete", "Deleting..."},
|
||||
{"scan", "Scanning..."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.verb, func(t *testing.T) {
|
||||
got := Progress(tt.verb)
|
||||
if got != tt.want {
|
||||
t.Errorf("Progress(%q) = %q, want %q", tt.verb, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressSubject(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
got := ProgressSubject("build", "project")
|
||||
want := "Building project..."
|
||||
if got != want {
|
||||
t.Errorf("ProgressSubject(%q, %q) = %q, want %q", "build", "project", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionResult(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
verb, subject string
|
||||
want string
|
||||
}{
|
||||
{"delete", "config.yaml", "Config.Yaml deleted"},
|
||||
{"build", "project", "Project built"},
|
||||
{"", "file", ""},
|
||||
{"delete", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.verb+"_"+tt.subject, func(t *testing.T) {
|
||||
got := ActionResult(tt.verb, tt.subject)
|
||||
if got != tt.want {
|
||||
t.Errorf("ActionResult(%q, %q) = %q, want %q", tt.verb, tt.subject, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionFailed(t *testing.T) {
|
||||
tests := []struct {
|
||||
verb, subject string
|
||||
want string
|
||||
}{
|
||||
{"delete", "config.yaml", "Failed to delete config.yaml"},
|
||||
{"push", "commits", "Failed to push commits"},
|
||||
{"push", "", "Failed to push"},
|
||||
{"", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.verb+"_"+tt.subject, func(t *testing.T) {
|
||||
got := ActionFailed(tt.verb, tt.subject)
|
||||
if got != tt.want {
|
||||
t.Errorf("ActionFailed(%q, %q) = %q, want %q", tt.verb, tt.subject, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateFuncs(t *testing.T) {
|
||||
funcs := TemplateFuncs()
|
||||
expected := []string{"title", "lower", "upper", "past", "gerund", "plural", "pluralForm", "article", "quote"}
|
||||
for _, name := range expected {
|
||||
if _, ok := funcs[name]; !ok {
|
||||
t.Errorf("TemplateFuncs() missing %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
156
handler.go
Normal file
156
handler.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LabelHandler handles i18n.label.{word} -> "Status:" patterns.
|
||||
type LabelHandler struct{}
|
||||
|
||||
func (h LabelHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.label.")
|
||||
}
|
||||
|
||||
func (h LabelHandler) Handle(key string, args []any, next func() string) string {
|
||||
word := strings.TrimPrefix(key, "i18n.label.")
|
||||
return Label(word)
|
||||
}
|
||||
|
||||
// ProgressHandler handles i18n.progress.{verb} -> "Building..." patterns.
|
||||
type ProgressHandler struct{}
|
||||
|
||||
func (h ProgressHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.progress.")
|
||||
}
|
||||
|
||||
func (h ProgressHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := strings.TrimPrefix(key, "i18n.progress.")
|
||||
if len(args) > 0 {
|
||||
if subj, ok := args[0].(string); ok {
|
||||
return ProgressSubject(verb, subj)
|
||||
}
|
||||
}
|
||||
return Progress(verb)
|
||||
}
|
||||
|
||||
// CountHandler handles i18n.count.{noun} -> "5 files" patterns.
|
||||
type CountHandler struct{}
|
||||
|
||||
func (h CountHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.count.")
|
||||
}
|
||||
|
||||
func (h CountHandler) Handle(key string, args []any, next func() string) string {
|
||||
noun := strings.TrimPrefix(key, "i18n.count.")
|
||||
if len(args) > 0 {
|
||||
count := toInt(args[0])
|
||||
return fmt.Sprintf("%d %s", count, Pluralize(noun, count))
|
||||
}
|
||||
return noun
|
||||
}
|
||||
|
||||
// DoneHandler handles i18n.done.{verb} -> "File deleted" patterns.
|
||||
type DoneHandler struct{}
|
||||
|
||||
func (h DoneHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.done.")
|
||||
}
|
||||
|
||||
func (h DoneHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := strings.TrimPrefix(key, "i18n.done.")
|
||||
if len(args) > 0 {
|
||||
if subj, ok := args[0].(string); ok {
|
||||
return ActionResult(verb, subj)
|
||||
}
|
||||
}
|
||||
return Title(PastTense(verb))
|
||||
}
|
||||
|
||||
// FailHandler handles i18n.fail.{verb} -> "Failed to delete file" patterns.
|
||||
type FailHandler struct{}
|
||||
|
||||
func (h FailHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.fail.")
|
||||
}
|
||||
|
||||
func (h FailHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := strings.TrimPrefix(key, "i18n.fail.")
|
||||
if len(args) > 0 {
|
||||
if subj, ok := args[0].(string); ok {
|
||||
return ActionFailed(verb, subj)
|
||||
}
|
||||
}
|
||||
return ActionFailed(verb, "")
|
||||
}
|
||||
|
||||
// NumericHandler handles i18n.numeric.{format} -> formatted numbers.
|
||||
type NumericHandler struct{}
|
||||
|
||||
func (h NumericHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.numeric.")
|
||||
}
|
||||
|
||||
func (h NumericHandler) Handle(key string, args []any, next func() string) string {
|
||||
if len(args) == 0 {
|
||||
return next()
|
||||
}
|
||||
format := strings.TrimPrefix(key, "i18n.numeric.")
|
||||
switch format {
|
||||
case "number", "int":
|
||||
return FormatNumber(toInt64(args[0]))
|
||||
case "decimal", "float":
|
||||
return FormatDecimal(toFloat64(args[0]))
|
||||
case "percent", "pct":
|
||||
return FormatPercent(toFloat64(args[0]))
|
||||
case "bytes", "size":
|
||||
return FormatBytes(toInt64(args[0]))
|
||||
case "ordinal", "ord":
|
||||
return FormatOrdinal(toInt(args[0]))
|
||||
case "ago":
|
||||
if len(args) >= 2 {
|
||||
if unit, ok := args[1].(string); ok {
|
||||
return FormatAgo(toInt(args[0]), unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
// DefaultHandlers returns the built-in i18n.* namespace handlers.
|
||||
func DefaultHandlers() []KeyHandler {
|
||||
return []KeyHandler{
|
||||
LabelHandler{},
|
||||
ProgressHandler{},
|
||||
CountHandler{},
|
||||
DoneHandler{},
|
||||
FailHandler{},
|
||||
NumericHandler{},
|
||||
}
|
||||
}
|
||||
|
||||
// RunHandlerChain executes a chain of handlers for a key.
|
||||
func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback func() string) string {
|
||||
for i, h := range handlers {
|
||||
if h.Match(key) {
|
||||
next := func() string {
|
||||
remaining := handlers[i+1:]
|
||||
if len(remaining) > 0 {
|
||||
return RunHandlerChain(remaining, key, args, fallback)
|
||||
}
|
||||
return fallback()
|
||||
}
|
||||
return h.Handle(key, args, next)
|
||||
}
|
||||
}
|
||||
return fallback()
|
||||
}
|
||||
|
||||
var (
|
||||
_ KeyHandler = LabelHandler{}
|
||||
_ KeyHandler = ProgressHandler{}
|
||||
_ KeyHandler = CountHandler{}
|
||||
_ KeyHandler = DoneHandler{}
|
||||
_ KeyHandler = FailHandler{}
|
||||
_ KeyHandler = NumericHandler{}
|
||||
)
|
||||
193
handler_test.go
Normal file
193
handler_test.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLabelHandler(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
h := LabelHandler{}
|
||||
|
||||
if !h.Match("i18n.label.status") {
|
||||
t.Error("LabelHandler should match i18n.label.*")
|
||||
}
|
||||
if h.Match("other.key") {
|
||||
t.Error("LabelHandler should not match other.key")
|
||||
}
|
||||
|
||||
got := h.Handle("i18n.label.status", nil, nil)
|
||||
if got != "Status:" {
|
||||
t.Errorf("LabelHandler.Handle(status) = %q, want %q", got, "Status:")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressHandler(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
h := ProgressHandler{}
|
||||
|
||||
if !h.Match("i18n.progress.build") {
|
||||
t.Error("ProgressHandler should match i18n.progress.*")
|
||||
}
|
||||
|
||||
// Without subject
|
||||
got := h.Handle("i18n.progress.build", nil, nil)
|
||||
if got != "Building..." {
|
||||
t.Errorf("ProgressHandler.Handle(build) = %q, want %q", got, "Building...")
|
||||
}
|
||||
|
||||
// With subject
|
||||
got = h.Handle("i18n.progress.build", []any{"project"}, nil)
|
||||
if got != "Building project..." {
|
||||
t.Errorf("ProgressHandler.Handle(build, project) = %q, want %q", got, "Building project...")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHandler(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
h := CountHandler{}
|
||||
|
||||
if !h.Match("i18n.count.file") {
|
||||
t.Error("CountHandler should match i18n.count.*")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
args []any
|
||||
want string
|
||||
}{
|
||||
{"i18n.count.file", []any{1}, "1 file"},
|
||||
{"i18n.count.file", []any{5}, "5 files"},
|
||||
{"i18n.count.file", []any{0}, "0 files"},
|
||||
{"i18n.count.child", []any{3}, "3 children"},
|
||||
{"i18n.count.file", nil, "file"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
got := h.Handle(tt.key, tt.args, nil)
|
||||
if got != tt.want {
|
||||
t.Errorf("CountHandler.Handle(%q, %v) = %q, want %q", tt.key, tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoneHandler(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
h := DoneHandler{}
|
||||
|
||||
if !h.Match("i18n.done.delete") {
|
||||
t.Error("DoneHandler should match i18n.done.*")
|
||||
}
|
||||
|
||||
// With subject
|
||||
got := h.Handle("i18n.done.delete", []any{"config.yaml"}, nil)
|
||||
if got != "Config.Yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, config.yaml) = %q, want %q", got, "Config.Yaml deleted")
|
||||
}
|
||||
|
||||
// Without subject — just past tense
|
||||
got = h.Handle("i18n.done.delete", nil, nil)
|
||||
if got != "Deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete) = %q, want %q", got, "Deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailHandler(t *testing.T) {
|
||||
h := FailHandler{}
|
||||
|
||||
if !h.Match("i18n.fail.push") {
|
||||
t.Error("FailHandler should match i18n.fail.*")
|
||||
}
|
||||
|
||||
got := h.Handle("i18n.fail.push", []any{"commits"}, nil)
|
||||
if got != "Failed to push commits" {
|
||||
t.Errorf("FailHandler.Handle(push, commits) = %q, want %q", got, "Failed to push commits")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.push", nil, nil)
|
||||
if got != "Failed to push" {
|
||||
t.Errorf("FailHandler.Handle(push) = %q, want %q", got, "Failed to push")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericHandler(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
h := NumericHandler{}
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
args []any
|
||||
want string
|
||||
}{
|
||||
{"i18n.numeric.number", []any{int64(1234567)}, "1,234,567"},
|
||||
{"i18n.numeric.ordinal", []any{1}, "1st"},
|
||||
{"i18n.numeric.ordinal", []any{2}, "2nd"},
|
||||
{"i18n.numeric.ordinal", []any{3}, "3rd"},
|
||||
{"i18n.numeric.ordinal", []any{11}, "11th"},
|
||||
{"i18n.numeric.percent", []any{0.85}, "85%"},
|
||||
{"i18n.numeric.bytes", []any{int64(1536000)}, "1.5 MB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
got := h.Handle(tt.key, tt.args, func() string { return "fallback" })
|
||||
if got != tt.want {
|
||||
t.Errorf("NumericHandler.Handle(%q, %v) = %q, want %q", tt.key, tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// No args falls through to next
|
||||
got := h.Handle("i18n.numeric.number", nil, func() string { return "fallback" })
|
||||
if got != "fallback" {
|
||||
t.Errorf("NumericHandler with no args should fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHandlerChain(t *testing.T) {
|
||||
handlers := DefaultHandlers()
|
||||
fallback := func() string { return "missed" }
|
||||
|
||||
// Label handler catches it
|
||||
got := RunHandlerChain(handlers, "i18n.label.status", nil, fallback)
|
||||
if got != "Status:" {
|
||||
t.Errorf("chain label = %q, want %q", got, "Status:")
|
||||
}
|
||||
|
||||
// Non-matching key falls through to fallback
|
||||
got = RunHandlerChain(handlers, "some.other.key", nil, fallback)
|
||||
if got != "missed" {
|
||||
t.Errorf("chain miss = %q, want %q", got, "missed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultHandlers(t *testing.T) {
|
||||
handlers := DefaultHandlers()
|
||||
if len(handlers) != 6 {
|
||||
t.Errorf("DefaultHandlers() returned %d handlers, want 6", len(handlers))
|
||||
}
|
||||
}
|
||||
72
hooks.go
Normal file
72
hooks.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var missingKeyHandler atomic.Value
|
||||
|
||||
type localeRegistration struct {
|
||||
fsys fs.FS
|
||||
dir string
|
||||
}
|
||||
|
||||
var (
|
||||
registeredLocales []localeRegistration
|
||||
registeredLocalesMu sync.Mutex
|
||||
localesLoaded bool
|
||||
)
|
||||
|
||||
// RegisterLocales registers a filesystem containing locale files.
|
||||
// Call this in your package's init() to register translations.
|
||||
//
|
||||
// //go:embed locales/*.json
|
||||
// var localeFS embed.FS
|
||||
//
|
||||
// func init() {
|
||||
// i18n.RegisterLocales(localeFS, "locales")
|
||||
// }
|
||||
func RegisterLocales(fsys fs.FS, dir string) {
|
||||
registeredLocalesMu.Lock()
|
||||
defer registeredLocalesMu.Unlock()
|
||||
registeredLocales = append(registeredLocales, localeRegistration{fsys: fsys, dir: dir})
|
||||
if localesLoaded {
|
||||
if svc := Default(); svc != nil {
|
||||
_ = svc.LoadFS(fsys, dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadRegisteredLocales(svc *Service) {
|
||||
registeredLocalesMu.Lock()
|
||||
defer registeredLocalesMu.Unlock()
|
||||
for _, reg := range registeredLocales {
|
||||
_ = svc.LoadFS(reg.fsys, reg.dir)
|
||||
}
|
||||
localesLoaded = true
|
||||
}
|
||||
|
||||
// OnMissingKey registers a handler for missing translation keys.
|
||||
func OnMissingKey(h MissingKeyHandler) {
|
||||
missingKeyHandler.Store(h)
|
||||
}
|
||||
|
||||
func dispatchMissingKey(key string, args map[string]any) {
|
||||
v := missingKeyHandler.Load()
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
h, ok := v.(MissingKeyHandler)
|
||||
if !ok || h == nil {
|
||||
return
|
||||
}
|
||||
_, file, line, ok := runtime.Caller(2)
|
||||
if !ok {
|
||||
file = "unknown"
|
||||
line = 0
|
||||
}
|
||||
h(MissingKey{Key: key, Args: args, CallerFile: file, CallerLine: line})
|
||||
}
|
||||
129
i18n.go
Normal file
129
i18n.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// T translates a message using the default service.
|
||||
func T(messageID string, args ...any) string {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.T(messageID, args...)
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
// Raw translates without i18n.* namespace magic.
|
||||
func Raw(messageID string, args ...any) string {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.Raw(messageID, args...)
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
// ErrServiceNotInitialized is returned when the service is not initialised.
|
||||
var ErrServiceNotInitialized = errors.New("i18n: service not initialized")
|
||||
|
||||
// SetLanguage sets the language for the default service.
|
||||
func SetLanguage(lang string) error {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return ErrServiceNotInitialized
|
||||
}
|
||||
return svc.SetLanguage(lang)
|
||||
}
|
||||
|
||||
// CurrentLanguage returns the current language code.
|
||||
func CurrentLanguage() string {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.Language()
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// SetMode sets the translation mode for the default service.
|
||||
func SetMode(m Mode) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.SetMode(m)
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentMode returns the current translation mode.
|
||||
func CurrentMode() Mode {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.Mode()
|
||||
}
|
||||
return ModeNormal
|
||||
}
|
||||
|
||||
// N formats a number using the i18n.numeric.* namespace.
|
||||
//
|
||||
// N("number", 1234567) // "1,234,567"
|
||||
// N("percent", 0.85) // "85%"
|
||||
// N("bytes", 1536000) // "1.5 MB"
|
||||
// N("ordinal", 1) // "1st"
|
||||
func N(format string, value any) string {
|
||||
return T("i18n.numeric."+format, value)
|
||||
}
|
||||
|
||||
// AddHandler appends a handler to the default service's handler chain.
|
||||
func AddHandler(h KeyHandler) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.AddHandler(h)
|
||||
}
|
||||
}
|
||||
|
||||
// PrependHandler inserts a handler at the start of the default service's handler chain.
|
||||
func PrependHandler(h KeyHandler) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.PrependHandler(h)
|
||||
}
|
||||
}
|
||||
|
||||
func executeIntentTemplate(tmplStr string, data templateData) string {
|
||||
if tmplStr == "" {
|
||||
return ""
|
||||
}
|
||||
if cached, ok := templateCache.Load(tmplStr); ok {
|
||||
var buf bytes.Buffer
|
||||
if err := cached.(*template.Template).Execute(&buf, data); err != nil {
|
||||
return tmplStr
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(tmplStr)
|
||||
if err != nil {
|
||||
return tmplStr
|
||||
}
|
||||
templateCache.Store(tmplStr, tmpl)
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return tmplStr
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func applyTemplate(text string, data any) string {
|
||||
if !strings.Contains(text, "{{") {
|
||||
return text
|
||||
}
|
||||
if cached, ok := templateCache.Load(text); ok {
|
||||
var buf bytes.Buffer
|
||||
if err := cached.(*template.Template).Execute(&buf, data); err != nil {
|
||||
return text
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
tmpl, err := template.New("").Parse(text)
|
||||
if err != nil {
|
||||
return text
|
||||
}
|
||||
templateCache.Store(text, tmpl)
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return text
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
85
language.go
Normal file
85
language.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package i18n
|
||||
|
||||
// GetPluralRule returns the plural rule for a language code.
|
||||
func GetPluralRule(lang string) PluralRule {
|
||||
if rule, ok := pluralRules[lang]; ok {
|
||||
return rule
|
||||
}
|
||||
if len(lang) > 2 {
|
||||
base := lang[:2]
|
||||
if rule, ok := pluralRules[base]; ok {
|
||||
return rule
|
||||
}
|
||||
}
|
||||
return pluralRuleEnglish
|
||||
}
|
||||
|
||||
// GetPluralCategory returns the plural category for a count in the given language.
|
||||
func GetPluralCategory(lang string, n int) PluralCategory {
|
||||
return GetPluralRule(lang)(n)
|
||||
}
|
||||
|
||||
func pluralRuleEnglish(n int) PluralCategory {
|
||||
if n == 1 {
|
||||
return PluralOne
|
||||
}
|
||||
return PluralOther
|
||||
}
|
||||
|
||||
func pluralRuleGerman(n int) PluralCategory { return pluralRuleEnglish(n) }
|
||||
func pluralRuleSpanish(n int) PluralCategory { return pluralRuleEnglish(n) }
|
||||
|
||||
func pluralRuleFrench(n int) PluralCategory {
|
||||
if n == 0 || n == 1 {
|
||||
return PluralOne
|
||||
}
|
||||
return PluralOther
|
||||
}
|
||||
|
||||
func pluralRuleRussian(n int) PluralCategory {
|
||||
mod10 := n % 10
|
||||
mod100 := n % 100
|
||||
if mod10 == 1 && mod100 != 11 {
|
||||
return PluralOne
|
||||
}
|
||||
if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
|
||||
return PluralFew
|
||||
}
|
||||
return PluralMany
|
||||
}
|
||||
|
||||
func pluralRulePolish(n int) PluralCategory {
|
||||
if n == 1 {
|
||||
return PluralOne
|
||||
}
|
||||
mod10 := n % 10
|
||||
mod100 := n % 100
|
||||
if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
|
||||
return PluralFew
|
||||
}
|
||||
return PluralMany
|
||||
}
|
||||
|
||||
func pluralRuleArabic(n int) PluralCategory {
|
||||
if n == 0 {
|
||||
return PluralZero
|
||||
}
|
||||
if n == 1 {
|
||||
return PluralOne
|
||||
}
|
||||
if n == 2 {
|
||||
return PluralTwo
|
||||
}
|
||||
mod100 := n % 100
|
||||
if mod100 >= 3 && mod100 <= 10 {
|
||||
return PluralFew
|
||||
}
|
||||
if mod100 >= 11 && mod100 <= 99 {
|
||||
return PluralMany
|
||||
}
|
||||
return PluralOther
|
||||
}
|
||||
|
||||
func pluralRuleChinese(n int) PluralCategory { return PluralOther }
|
||||
func pluralRuleJapanese(n int) PluralCategory { return PluralOther }
|
||||
func pluralRuleKorean(n int) PluralCategory { return PluralOther }
|
||||
83
language_test.go
Normal file
83
language_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetPluralCategory(t *testing.T) {
|
||||
tests := []struct {
|
||||
lang string
|
||||
n int
|
||||
want PluralCategory
|
||||
}{
|
||||
// English
|
||||
{"en", 0, PluralOther},
|
||||
{"en", 1, PluralOne},
|
||||
{"en", 2, PluralOther},
|
||||
|
||||
// French (0 and 1 are singular)
|
||||
{"fr", 0, PluralOne},
|
||||
{"fr", 1, PluralOne},
|
||||
{"fr", 2, PluralOther},
|
||||
|
||||
// Russian
|
||||
{"ru", 1, PluralOne},
|
||||
{"ru", 2, PluralFew},
|
||||
{"ru", 5, PluralMany},
|
||||
{"ru", 11, PluralMany},
|
||||
{"ru", 21, PluralOne},
|
||||
{"ru", 22, PluralFew},
|
||||
|
||||
// Polish
|
||||
{"pl", 1, PluralOne},
|
||||
{"pl", 2, PluralFew},
|
||||
{"pl", 5, PluralMany},
|
||||
|
||||
// Arabic
|
||||
{"ar", 0, PluralZero},
|
||||
{"ar", 1, PluralOne},
|
||||
{"ar", 2, PluralTwo},
|
||||
{"ar", 5, PluralFew},
|
||||
{"ar", 11, PluralMany},
|
||||
{"ar", 100, PluralOther},
|
||||
|
||||
// Chinese (always other)
|
||||
{"zh", 0, PluralOther},
|
||||
{"zh", 1, PluralOther},
|
||||
{"zh", 100, PluralOther},
|
||||
|
||||
// Fallback for unknown language uses English rules
|
||||
{"xx", 1, PluralOne},
|
||||
{"xx", 5, PluralOther},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.lang, func(t *testing.T) {
|
||||
got := GetPluralCategory(tt.lang, tt.n)
|
||||
if got != tt.want {
|
||||
t.Errorf("GetPluralCategory(%q, %d) = %v, want %v", tt.lang, tt.n, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluralRule(t *testing.T) {
|
||||
// Known language
|
||||
rule := GetPluralRule("en")
|
||||
if rule == nil {
|
||||
t.Fatal("GetPluralRule(en) returned nil")
|
||||
}
|
||||
if rule(1) != PluralOne {
|
||||
t.Error("English rule(1) should be PluralOne")
|
||||
}
|
||||
|
||||
// Base language extraction
|
||||
rule = GetPluralRule("en-US")
|
||||
if rule(1) != PluralOne {
|
||||
t.Error("English-US rule(1) should be PluralOne")
|
||||
}
|
||||
|
||||
// Unknown falls back to English
|
||||
rule = GetPluralRule("xx-YY")
|
||||
if rule(1) != PluralOne {
|
||||
t.Error("Unknown rule(1) should fallback to English PluralOne")
|
||||
}
|
||||
}
|
||||
241
loader.go
Normal file
241
loader.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FSLoader loads translations from a filesystem (embedded or disk).
|
||||
type FSLoader struct {
|
||||
fsys fs.FS
|
||||
dir string
|
||||
|
||||
languages []string
|
||||
langOnce sync.Once
|
||||
langErr error
|
||||
}
|
||||
|
||||
// NewFSLoader creates a loader for the given filesystem and directory.
|
||||
func NewFSLoader(fsys fs.FS, dir string) *FSLoader {
|
||||
return &FSLoader{fsys: fsys, dir: dir}
|
||||
}
|
||||
|
||||
// Load implements Loader.Load.
|
||||
func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
|
||||
variants := []string{
|
||||
lang + ".json",
|
||||
strings.ReplaceAll(lang, "-", "_") + ".json",
|
||||
strings.ReplaceAll(lang, "_", "-") + ".json",
|
||||
}
|
||||
|
||||
var data []byte
|
||||
var err error
|
||||
for _, filename := range variants {
|
||||
filePath := path.Join(l.dir, filename)
|
||||
data, err = fs.ReadFile(l.fsys, filePath)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("locale %q not found: %w", lang, err)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid JSON in locale %q: %w", lang, err)
|
||||
}
|
||||
|
||||
messages := make(map[string]Message)
|
||||
grammar := &GrammarData{
|
||||
Verbs: make(map[string]VerbForms),
|
||||
Nouns: make(map[string]NounForms),
|
||||
Words: make(map[string]string),
|
||||
}
|
||||
|
||||
flattenWithGrammar("", raw, messages, grammar)
|
||||
|
||||
return messages, grammar, nil
|
||||
}
|
||||
|
||||
// Languages implements Loader.Languages.
|
||||
func (l *FSLoader) Languages() []string {
|
||||
l.langOnce.Do(func() {
|
||||
entries, err := fs.ReadDir(l.fsys, l.dir)
|
||||
if err != nil {
|
||||
l.langErr = fmt.Errorf("failed to read locale directory %q: %w", l.dir, err)
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
lang := strings.TrimSuffix(entry.Name(), ".json")
|
||||
lang = strings.ReplaceAll(lang, "_", "-")
|
||||
l.languages = append(l.languages, lang)
|
||||
}
|
||||
})
|
||||
return l.languages
|
||||
}
|
||||
|
||||
// LanguagesErr returns any error from the directory scan.
|
||||
func (l *FSLoader) LanguagesErr() error {
|
||||
l.Languages()
|
||||
return l.langErr
|
||||
}
|
||||
|
||||
var _ Loader = (*FSLoader)(nil)
|
||||
|
||||
// --- Flatten helpers ---
|
||||
|
||||
func flatten(prefix string, data map[string]any, out map[string]Message) {
|
||||
flattenWithGrammar(prefix, data, out, nil)
|
||||
}
|
||||
|
||||
func flattenWithGrammar(prefix string, data map[string]any, out map[string]Message, grammar *GrammarData) {
|
||||
for key, value := range data {
|
||||
fullKey := key
|
||||
if prefix != "" {
|
||||
fullKey = prefix + "." + key
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if grammar != nil && strings.HasPrefix(fullKey, "gram.word.") {
|
||||
wordKey := strings.TrimPrefix(fullKey, "gram.word.")
|
||||
grammar.Words[strings.ToLower(wordKey)] = v
|
||||
continue
|
||||
}
|
||||
out[fullKey] = Message{Text: v}
|
||||
|
||||
case map[string]any:
|
||||
// Verb form object (has base/past/gerund keys)
|
||||
if grammar != nil && isVerbFormObject(v) {
|
||||
verbName := key
|
||||
if strings.HasPrefix(fullKey, "gram.verb.") {
|
||||
verbName = strings.TrimPrefix(fullKey, "gram.verb.")
|
||||
}
|
||||
forms := VerbForms{}
|
||||
if past, ok := v["past"].(string); ok {
|
||||
forms.Past = past
|
||||
}
|
||||
if gerund, ok := v["gerund"].(string); ok {
|
||||
forms.Gerund = gerund
|
||||
}
|
||||
grammar.Verbs[strings.ToLower(verbName)] = forms
|
||||
continue
|
||||
}
|
||||
|
||||
// Noun form object (under gram.noun.* or has gender field)
|
||||
if grammar != nil && (strings.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) {
|
||||
nounName := key
|
||||
if strings.HasPrefix(fullKey, "gram.noun.") {
|
||||
nounName = strings.TrimPrefix(fullKey, "gram.noun.")
|
||||
}
|
||||
_, hasOne := v["one"]
|
||||
_, hasOther := v["other"]
|
||||
if hasOne && hasOther {
|
||||
forms := NounForms{}
|
||||
if one, ok := v["one"].(string); ok {
|
||||
forms.One = one
|
||||
}
|
||||
if other, ok := v["other"].(string); ok {
|
||||
forms.Other = other
|
||||
}
|
||||
if gender, ok := v["gender"].(string); ok {
|
||||
forms.Gender = gender
|
||||
}
|
||||
grammar.Nouns[strings.ToLower(nounName)] = forms
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Article configuration
|
||||
if grammar != nil && fullKey == "gram.article" {
|
||||
if indef, ok := v["indefinite"].(map[string]any); ok {
|
||||
if def, ok := indef["default"].(string); ok {
|
||||
grammar.Articles.IndefiniteDefault = def
|
||||
}
|
||||
if vowel, ok := indef["vowel"].(string); ok {
|
||||
grammar.Articles.IndefiniteVowel = vowel
|
||||
}
|
||||
}
|
||||
if def, ok := v["definite"].(string); ok {
|
||||
grammar.Articles.Definite = def
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Punctuation rules
|
||||
if grammar != nil && fullKey == "gram.punct" {
|
||||
if label, ok := v["label"].(string); ok {
|
||||
grammar.Punct.LabelSuffix = label
|
||||
}
|
||||
if progress, ok := v["progress"].(string); ok {
|
||||
grammar.Punct.ProgressSuffix = progress
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// CLDR plural object
|
||||
if isPluralObject(v) {
|
||||
msg := Message{}
|
||||
if zero, ok := v["zero"].(string); ok {
|
||||
msg.Zero = zero
|
||||
}
|
||||
if one, ok := v["one"].(string); ok {
|
||||
msg.One = one
|
||||
}
|
||||
if two, ok := v["two"].(string); ok {
|
||||
msg.Two = two
|
||||
}
|
||||
if few, ok := v["few"].(string); ok {
|
||||
msg.Few = few
|
||||
}
|
||||
if many, ok := v["many"].(string); ok {
|
||||
msg.Many = many
|
||||
}
|
||||
if other, ok := v["other"].(string); ok {
|
||||
msg.Other = other
|
||||
}
|
||||
out[fullKey] = msg
|
||||
} else {
|
||||
flattenWithGrammar(fullKey, v, out, grammar)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isVerbFormObject(m map[string]any) bool {
|
||||
_, hasBase := m["base"]
|
||||
_, hasPast := m["past"]
|
||||
_, hasGerund := m["gerund"]
|
||||
return (hasBase || hasPast || hasGerund) && !isPluralObject(m)
|
||||
}
|
||||
|
||||
func isNounFormObject(m map[string]any) bool {
|
||||
_, hasGender := m["gender"]
|
||||
return hasGender
|
||||
}
|
||||
|
||||
func isPluralObject(m map[string]any) bool {
|
||||
_, hasZero := m["zero"]
|
||||
_, hasOne := m["one"]
|
||||
_, hasTwo := m["two"]
|
||||
_, hasFew := m["few"]
|
||||
_, hasMany := m["many"]
|
||||
_, hasOther := m["other"]
|
||||
if !hasZero && !hasOne && !hasTwo && !hasFew && !hasMany && !hasOther {
|
||||
return false
|
||||
}
|
||||
for _, v := range m {
|
||||
if _, isMap := v.(map[string]any); isMap {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
263
loader_test.go
Normal file
263
loader_test.go
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestFSLoaderLanguages(t *testing.T) {
|
||||
loader := NewFSLoader(localeFS, "locales")
|
||||
langs := loader.Languages()
|
||||
if len(langs) == 0 {
|
||||
t.Fatal("FSLoader.Languages() returned empty")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, l := range langs {
|
||||
if l == "en" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Languages() = %v, expected 'en' in list", langs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLoad(t *testing.T) {
|
||||
loader := NewFSLoader(localeFS, "locales")
|
||||
messages, grammar, err := loader.Load("en")
|
||||
if err != nil {
|
||||
t.Fatalf("Load(en) error: %v", err)
|
||||
}
|
||||
|
||||
// Should have messages from the JSON
|
||||
if len(messages) == 0 {
|
||||
t.Error("Load(en) returned 0 messages")
|
||||
}
|
||||
|
||||
// Grammar data should be extracted from nested JSON
|
||||
if grammar == nil {
|
||||
t.Fatal("Load(en) returned nil grammar")
|
||||
}
|
||||
|
||||
// Verbs from gram.verb.*
|
||||
if len(grammar.Verbs) == 0 {
|
||||
t.Error("grammar has 0 verbs")
|
||||
}
|
||||
if v, ok := grammar.Verbs["build"]; !ok {
|
||||
t.Error("grammar missing verb 'build'")
|
||||
} else {
|
||||
if v.Past != "built" {
|
||||
t.Errorf("build.past = %q, want 'built'", v.Past)
|
||||
}
|
||||
if v.Gerund != "building" {
|
||||
t.Errorf("build.gerund = %q, want 'building'", v.Gerund)
|
||||
}
|
||||
}
|
||||
|
||||
// Nouns from gram.noun.*
|
||||
if len(grammar.Nouns) == 0 {
|
||||
t.Error("grammar has 0 nouns")
|
||||
}
|
||||
if n, ok := grammar.Nouns["file"]; !ok {
|
||||
t.Error("grammar missing noun 'file'")
|
||||
} else {
|
||||
if n.One != "file" {
|
||||
t.Errorf("file.one = %q, want 'file'", n.One)
|
||||
}
|
||||
if n.Other != "files" {
|
||||
t.Errorf("file.other = %q, want 'files'", n.Other)
|
||||
}
|
||||
}
|
||||
|
||||
// Articles from gram.article
|
||||
if grammar.Articles.IndefiniteDefault != "a" {
|
||||
t.Errorf("article.indefinite.default = %q, want 'a'", grammar.Articles.IndefiniteDefault)
|
||||
}
|
||||
if grammar.Articles.IndefiniteVowel != "an" {
|
||||
t.Errorf("article.indefinite.vowel = %q, want 'an'", grammar.Articles.IndefiniteVowel)
|
||||
}
|
||||
if grammar.Articles.Definite != "the" {
|
||||
t.Errorf("article.definite = %q, want 'the'", grammar.Articles.Definite)
|
||||
}
|
||||
|
||||
// Punctuation from gram.punct
|
||||
if grammar.Punct.LabelSuffix != ":" {
|
||||
t.Errorf("punct.label = %q, want ':'", grammar.Punct.LabelSuffix)
|
||||
}
|
||||
if grammar.Punct.ProgressSuffix != "..." {
|
||||
t.Errorf("punct.progress = %q, want '...'", grammar.Punct.ProgressSuffix)
|
||||
}
|
||||
|
||||
// Words from gram.word.*
|
||||
if len(grammar.Words) == 0 {
|
||||
t.Error("grammar has 0 words")
|
||||
}
|
||||
if grammar.Words["url"] != "URL" {
|
||||
t.Errorf("word.url = %q, want 'URL'", grammar.Words["url"])
|
||||
}
|
||||
if grammar.Words["api"] != "API" {
|
||||
t.Errorf("word.api = %q, want 'API'", grammar.Words["api"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLoadMissing(t *testing.T) {
|
||||
loader := NewFSLoader(localeFS, "locales")
|
||||
_, _, err := loader.Load("xx")
|
||||
if err == nil {
|
||||
t.Error("Load(xx) should fail for non-existent locale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenWithGrammar(t *testing.T) {
|
||||
messages := make(map[string]Message)
|
||||
grammar := &GrammarData{
|
||||
Verbs: make(map[string]VerbForms),
|
||||
Nouns: make(map[string]NounForms),
|
||||
Words: make(map[string]string),
|
||||
}
|
||||
|
||||
raw := map[string]any{
|
||||
"gram": map[string]any{
|
||||
"verb": map[string]any{
|
||||
"test": map[string]any{
|
||||
"base": "test",
|
||||
"past": "tested",
|
||||
"gerund": "testing",
|
||||
},
|
||||
},
|
||||
"noun": map[string]any{
|
||||
"widget": map[string]any{
|
||||
"one": "widget",
|
||||
"other": "widgets",
|
||||
},
|
||||
},
|
||||
"word": map[string]any{
|
||||
"api": "API",
|
||||
},
|
||||
"punct": map[string]any{
|
||||
"label": ":",
|
||||
"progress": "...",
|
||||
},
|
||||
"article": map[string]any{
|
||||
"indefinite": map[string]any{
|
||||
"default": "a",
|
||||
"vowel": "an",
|
||||
},
|
||||
"definite": "the",
|
||||
},
|
||||
},
|
||||
"prompt": map[string]any{
|
||||
"yes": "y",
|
||||
"no": "n",
|
||||
},
|
||||
}
|
||||
|
||||
flattenWithGrammar("", raw, messages, grammar)
|
||||
|
||||
// Verb extracted
|
||||
if v, ok := grammar.Verbs["test"]; !ok {
|
||||
t.Error("verb 'test' not extracted")
|
||||
} else {
|
||||
if v.Past != "tested" {
|
||||
t.Errorf("test.past = %q, want 'tested'", v.Past)
|
||||
}
|
||||
}
|
||||
|
||||
// Noun extracted
|
||||
if n, ok := grammar.Nouns["widget"]; !ok {
|
||||
t.Error("noun 'widget' not extracted")
|
||||
} else {
|
||||
if n.Other != "widgets" {
|
||||
t.Errorf("widget.other = %q, want 'widgets'", n.Other)
|
||||
}
|
||||
}
|
||||
|
||||
// Word extracted
|
||||
if grammar.Words["api"] != "API" {
|
||||
t.Errorf("word 'api' = %q, want 'API'", grammar.Words["api"])
|
||||
}
|
||||
|
||||
// Punct extracted
|
||||
if grammar.Punct.LabelSuffix != ":" {
|
||||
t.Errorf("punct.label = %q, want ':'", grammar.Punct.LabelSuffix)
|
||||
}
|
||||
|
||||
// Articles extracted
|
||||
if grammar.Articles.IndefiniteDefault != "a" {
|
||||
t.Errorf("article.indefinite.default = %q, want 'a'", grammar.Articles.IndefiniteDefault)
|
||||
}
|
||||
|
||||
// Regular keys flattened
|
||||
if msg, ok := messages["prompt.yes"]; !ok || msg.Text != "y" {
|
||||
t.Errorf("prompt.yes not flattened correctly, got %+v", messages["prompt.yes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenPluralObject(t *testing.T) {
|
||||
messages := make(map[string]Message)
|
||||
raw := map[string]any{
|
||||
"time": map[string]any{
|
||||
"ago": map[string]any{
|
||||
"second": map[string]any{
|
||||
"one": "{{.Count}} second ago",
|
||||
"other": "{{.Count}} seconds ago",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flattenWithGrammar("", raw, messages, nil)
|
||||
|
||||
msg, ok := messages["time.ago.second"]
|
||||
if !ok {
|
||||
t.Fatal("time.ago.second not found")
|
||||
}
|
||||
if !msg.IsPlural() {
|
||||
t.Error("time.ago.second should be plural")
|
||||
}
|
||||
if msg.One != "{{.Count}} second ago" {
|
||||
t.Errorf("time.ago.second.one = %q", msg.One)
|
||||
}
|
||||
if msg.Other != "{{.Count}} seconds ago" {
|
||||
t.Errorf("time.ago.second.other = %q", msg.Other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomFSLoader(t *testing.T) {
|
||||
fs := fstest.MapFS{
|
||||
"locales/test.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"gram": {
|
||||
"verb": {
|
||||
"zap": { "base": "zap", "past": "zapped", "gerund": "zapping" }
|
||||
},
|
||||
"word": {
|
||||
"hello": "Hello"
|
||||
}
|
||||
},
|
||||
"greeting": "Hello, world!"
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
svc, err := NewWithFS(fs, "locales", WithFallback("test"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithFS failed: %v", err)
|
||||
}
|
||||
|
||||
got := svc.T("greeting")
|
||||
if got != "Hello, world!" {
|
||||
t.Errorf("T(greeting) = %q, want 'Hello, world!'", got)
|
||||
}
|
||||
|
||||
// Grammar should be loaded
|
||||
gd := GetGrammarData("test")
|
||||
if gd == nil {
|
||||
t.Fatal("grammar data not loaded for 'test'")
|
||||
}
|
||||
if v, ok := gd.Verbs["zap"]; !ok || v.Past != "zapped" {
|
||||
t.Errorf("verb 'zap' not loaded correctly")
|
||||
}
|
||||
}
|
||||
142
locales/en.json
Normal file
142
locales/en.json
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
{
|
||||
"gram": {
|
||||
"verb": {
|
||||
"be": { "base": "be", "past": "was", "gerund": "being" },
|
||||
"go": { "base": "go", "past": "went", "gerund": "going" },
|
||||
"do": { "base": "do", "past": "did", "gerund": "doing" },
|
||||
"have": { "base": "have", "past": "had", "gerund": "having" },
|
||||
"make": { "base": "make", "past": "made", "gerund": "making" },
|
||||
"get": { "base": "get", "past": "got", "gerund": "getting" },
|
||||
"run": { "base": "run", "past": "ran", "gerund": "running" },
|
||||
"write": { "base": "write", "past": "wrote", "gerund": "writing" },
|
||||
"build": { "base": "build", "past": "built", "gerund": "building" },
|
||||
"send": { "base": "send", "past": "sent", "gerund": "sending" },
|
||||
"find": { "base": "find", "past": "found", "gerund": "finding" },
|
||||
"take": { "base": "take", "past": "took", "gerund": "taking" },
|
||||
"begin": { "base": "begin", "past": "began", "gerund": "beginning" },
|
||||
"keep": { "base": "keep", "past": "kept", "gerund": "keeping" },
|
||||
"hold": { "base": "hold", "past": "held", "gerund": "holding" },
|
||||
"bring": { "base": "bring", "past": "brought", "gerund": "bringing" },
|
||||
"think": { "base": "think", "past": "thought", "gerund": "thinking" },
|
||||
"buy": { "base": "buy", "past": "bought", "gerund": "buying" },
|
||||
"catch": { "base": "catch", "past": "caught", "gerund": "catching" },
|
||||
"choose": { "base": "choose", "past": "chose", "gerund": "choosing" },
|
||||
"lose": { "base": "lose", "past": "lost", "gerund": "losing" },
|
||||
"win": { "base": "win", "past": "won", "gerund": "winning" },
|
||||
"meet": { "base": "meet", "past": "met", "gerund": "meeting" },
|
||||
"lead": { "base": "lead", "past": "led", "gerund": "leading" },
|
||||
"leave": { "base": "leave", "past": "left", "gerund": "leaving" },
|
||||
"spend": { "base": "spend", "past": "spent", "gerund": "spending" },
|
||||
"pay": { "base": "pay", "past": "paid", "gerund": "paying" },
|
||||
"sell": { "base": "sell", "past": "sold", "gerund": "selling" },
|
||||
"commit": { "base": "commit", "past": "committed", "gerund": "committing" },
|
||||
"stop": { "base": "stop", "past": "stopped", "gerund": "stopping" },
|
||||
"scan": { "base": "scan", "past": "scanned", "gerund": "scanning" },
|
||||
"format": { "base": "format", "past": "formatted", "gerund": "formatting" },
|
||||
"set": { "base": "set", "past": "set", "gerund": "setting" },
|
||||
"put": { "base": "put", "past": "put", "gerund": "putting" },
|
||||
"cut": { "base": "cut", "past": "cut", "gerund": "cutting" },
|
||||
"hit": { "base": "hit", "past": "hit", "gerund": "hitting" },
|
||||
"sit": { "base": "sit", "past": "sat", "gerund": "sitting" },
|
||||
"split": { "base": "split", "past": "split", "gerund": "splitting" },
|
||||
"shut": { "base": "shut", "past": "shut", "gerund": "shutting" }
|
||||
},
|
||||
"noun": {
|
||||
"file": { "one": "file", "other": "files" },
|
||||
"repo": { "one": "repo", "other": "repos" },
|
||||
"repository": { "one": "repository", "other": "repositories" },
|
||||
"commit": { "one": "commit", "other": "commits" },
|
||||
"branch": { "one": "branch", "other": "branches" },
|
||||
"change": { "one": "change", "other": "changes" },
|
||||
"item": { "one": "item", "other": "items" },
|
||||
"issue": { "one": "issue", "other": "issues" },
|
||||
"task": { "one": "task", "other": "tasks" },
|
||||
"person": { "one": "person", "other": "people" },
|
||||
"child": { "one": "child", "other": "children" },
|
||||
"package": { "one": "package", "other": "packages" },
|
||||
"artifact": { "one": "artifact", "other": "artifacts" },
|
||||
"vulnerability": { "one": "vulnerability", "other": "vulnerabilities" },
|
||||
"dependency": { "one": "dependency", "other": "dependencies" },
|
||||
"directory": { "one": "directory", "other": "directories" },
|
||||
"category": { "one": "category", "other": "categories" },
|
||||
"query": { "one": "query", "other": "queries" },
|
||||
"passed": { "one": "passed", "other": "passed" },
|
||||
"failed": { "one": "failed", "other": "failed" },
|
||||
"skipped": { "one": "skipped", "other": "skipped" },
|
||||
"check": { "one": "check", "other": "checks" },
|
||||
"test": { "one": "test", "other": "tests" }
|
||||
},
|
||||
"article": {
|
||||
"indefinite": { "default": "a", "vowel": "an" },
|
||||
"definite": "the"
|
||||
},
|
||||
"word": {
|
||||
"url": "URL",
|
||||
"id": "ID",
|
||||
"ok": "OK",
|
||||
"ci": "CI",
|
||||
"qa": "QA",
|
||||
"php": "PHP",
|
||||
"sdk": "SDK",
|
||||
"html": "HTML",
|
||||
"cgo": "CGO",
|
||||
"pid": "PID",
|
||||
"cpus": "CPUs",
|
||||
"ssh": "SSH",
|
||||
"ssl": "SSL",
|
||||
"api": "API",
|
||||
"pr": "PR",
|
||||
"vite": "Vite",
|
||||
"pnpm": "pnpm",
|
||||
"app_url": "app URL",
|
||||
"blocked_by": "blocked by",
|
||||
"claimed_by": "claimed by",
|
||||
"related_files": "related files",
|
||||
"up_to_date": "up to date",
|
||||
"dry_run": "dry run",
|
||||
"go_mod": "go.mod",
|
||||
"test": "test",
|
||||
"package": "package",
|
||||
"filter": "filter",
|
||||
"coverage": "coverage",
|
||||
"passed": "passed",
|
||||
"failed": "failed",
|
||||
"skipped": "skipped"
|
||||
},
|
||||
"punct": {
|
||||
"label": ":",
|
||||
"progress": "..."
|
||||
},
|
||||
"number": {
|
||||
"thousands": ",",
|
||||
"decimal": ".",
|
||||
"percent": "%s%%"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"yes": "y",
|
||||
"no": "n",
|
||||
"continue": "Continue?",
|
||||
"proceed": "Proceed?",
|
||||
"confirm": "Are you sure?",
|
||||
"overwrite": "Overwrite?",
|
||||
"discard": "Discard changes?"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "just now",
|
||||
"ago": {
|
||||
"second": { "one": "{{.Count}} second ago", "other": "{{.Count}} seconds ago" },
|
||||
"minute": { "one": "{{.Count}} minute ago", "other": "{{.Count}} minutes ago" },
|
||||
"hour": { "one": "{{.Count}} hour ago", "other": "{{.Count}} hours ago" },
|
||||
"day": { "one": "{{.Count}} day ago", "other": "{{.Count}} days ago" },
|
||||
"week": { "one": "{{.Count}} week ago", "other": "{{.Count}} weeks ago" }
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"de": "German",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"fr": "French",
|
||||
"zh": "Chinese"
|
||||
}
|
||||
}
|
||||
113
localise.go
Normal file
113
localise.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func (f Formality) String() string {
|
||||
switch f {
|
||||
case FormalityInformal:
|
||||
return "informal"
|
||||
case FormalityFormal:
|
||||
return "formal"
|
||||
default:
|
||||
return "neutral"
|
||||
}
|
||||
}
|
||||
|
||||
func (d TextDirection) String() string {
|
||||
if d == DirRTL {
|
||||
return "rtl"
|
||||
}
|
||||
return "ltr"
|
||||
}
|
||||
|
||||
func (p PluralCategory) String() string {
|
||||
switch p {
|
||||
case PluralZero:
|
||||
return "zero"
|
||||
case PluralOne:
|
||||
return "one"
|
||||
case PluralTwo:
|
||||
return "two"
|
||||
case PluralFew:
|
||||
return "few"
|
||||
case PluralMany:
|
||||
return "many"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
func (g GrammaticalGender) String() string {
|
||||
switch g {
|
||||
case GenderMasculine:
|
||||
return "masculine"
|
||||
case GenderFeminine:
|
||||
return "feminine"
|
||||
case GenderCommon:
|
||||
return "common"
|
||||
default:
|
||||
return "neuter"
|
||||
}
|
||||
}
|
||||
|
||||
// IsRTLLanguage returns true if the language code uses right-to-left text.
|
||||
func IsRTLLanguage(lang string) bool {
|
||||
if rtlLanguages[lang] {
|
||||
return true
|
||||
}
|
||||
if len(lang) > 2 {
|
||||
return rtlLanguages[lang[:2]]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetFormality sets the default formality level on the default service.
|
||||
func SetFormality(f Formality) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.SetFormality(f)
|
||||
}
|
||||
}
|
||||
|
||||
// Direction returns the text direction for the current language.
|
||||
func Direction() TextDirection {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.Direction()
|
||||
}
|
||||
return DirLTR
|
||||
}
|
||||
|
||||
// IsRTL returns true if the current language uses right-to-left text.
|
||||
func IsRTL() bool { return Direction() == DirRTL }
|
||||
|
||||
func detectLanguage(supported []language.Tag) string {
|
||||
langEnv := os.Getenv("LANG")
|
||||
if langEnv == "" {
|
||||
langEnv = os.Getenv("LC_ALL")
|
||||
if langEnv == "" {
|
||||
langEnv = os.Getenv("LC_MESSAGES")
|
||||
}
|
||||
}
|
||||
if langEnv == "" {
|
||||
return ""
|
||||
}
|
||||
baseLang := strings.Split(langEnv, ".")[0]
|
||||
baseLang = strings.ReplaceAll(baseLang, "_", "-")
|
||||
parsedLang, err := language.Parse(baseLang)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(supported) == 0 {
|
||||
return ""
|
||||
}
|
||||
matcher := language.NewMatcher(supported)
|
||||
bestMatch, _, confidence := matcher.Match(parsedLang)
|
||||
if confidence >= language.Low {
|
||||
return bestMatch.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
162
numbers.go
Normal file
162
numbers.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getNumberFormat() NumberFormat {
|
||||
lang := currentLangForGrammar()
|
||||
if idx := strings.IndexAny(lang, "-_"); idx > 0 {
|
||||
lang = lang[:idx]
|
||||
}
|
||||
if fmt, ok := numberFormats[lang]; ok {
|
||||
return fmt
|
||||
}
|
||||
return numberFormats["en"]
|
||||
}
|
||||
|
||||
// FormatNumber formats an integer with locale-specific thousands separators.
|
||||
func FormatNumber(n int64) string {
|
||||
return formatIntWithSep(n, getNumberFormat().ThousandsSep)
|
||||
}
|
||||
|
||||
// FormatDecimal formats a float with locale-specific separators.
|
||||
func FormatDecimal(f float64) string {
|
||||
return FormatDecimalN(f, 2)
|
||||
}
|
||||
|
||||
// FormatDecimalN formats a float with N decimal places.
|
||||
func FormatDecimalN(f float64, decimals int) string {
|
||||
nf := getNumberFormat()
|
||||
intPart := int64(f)
|
||||
fracPart := math.Abs(f - float64(intPart))
|
||||
intStr := formatIntWithSep(intPart, nf.ThousandsSep)
|
||||
if decimals <= 0 || fracPart == 0 {
|
||||
return intStr
|
||||
}
|
||||
multiplier := math.Pow(10, float64(decimals))
|
||||
fracInt := int64(math.Round(fracPart * multiplier))
|
||||
if fracInt == 0 {
|
||||
return intStr
|
||||
}
|
||||
fracStr := fmt.Sprintf("%0*d", decimals, fracInt)
|
||||
fracStr = strings.TrimRight(fracStr, "0")
|
||||
return intStr + nf.DecimalSep + fracStr
|
||||
}
|
||||
|
||||
// FormatPercent formats a decimal as a percentage.
|
||||
func FormatPercent(f float64) string {
|
||||
nf := getNumberFormat()
|
||||
pct := f * 100
|
||||
var numStr string
|
||||
if pct == float64(int64(pct)) {
|
||||
numStr = strconv.FormatInt(int64(pct), 10)
|
||||
} else {
|
||||
numStr = FormatDecimalN(pct, 1)
|
||||
}
|
||||
return fmt.Sprintf(nf.PercentFmt, numStr)
|
||||
}
|
||||
|
||||
// FormatBytes formats bytes as human-readable size.
|
||||
func FormatBytes(bytes int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
GB = MB * 1024
|
||||
TB = GB * 1024
|
||||
)
|
||||
nf := getNumberFormat()
|
||||
var value float64
|
||||
var unit string
|
||||
switch {
|
||||
case bytes >= TB:
|
||||
value = float64(bytes) / TB
|
||||
unit = "TB"
|
||||
case bytes >= GB:
|
||||
value = float64(bytes) / GB
|
||||
unit = "GB"
|
||||
case bytes >= MB:
|
||||
value = float64(bytes) / MB
|
||||
unit = "MB"
|
||||
case bytes >= KB:
|
||||
value = float64(bytes) / KB
|
||||
unit = "KB"
|
||||
default:
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
intPart := int64(value)
|
||||
fracPart := value - float64(intPart)
|
||||
if fracPart < 0.05 {
|
||||
return fmt.Sprintf("%d %s", intPart, unit)
|
||||
}
|
||||
fracDigit := int(math.Round(fracPart * 10))
|
||||
if fracDigit == 10 {
|
||||
return fmt.Sprintf("%d %s", intPart+1, unit)
|
||||
}
|
||||
return fmt.Sprintf("%d%s%d %s", intPart, nf.DecimalSep, fracDigit, unit)
|
||||
}
|
||||
|
||||
// FormatOrdinal formats a number as an ordinal.
|
||||
func FormatOrdinal(n int) string {
|
||||
lang := currentLangForGrammar()
|
||||
if idx := strings.IndexAny(lang, "-_"); idx > 0 {
|
||||
lang = lang[:idx]
|
||||
}
|
||||
switch lang {
|
||||
case "en":
|
||||
return formatEnglishOrdinal(n)
|
||||
default:
|
||||
return fmt.Sprintf("%d.", n)
|
||||
}
|
||||
}
|
||||
|
||||
func formatEnglishOrdinal(n int) string {
|
||||
abs := n
|
||||
if abs < 0 {
|
||||
abs = -abs
|
||||
}
|
||||
if abs%100 >= 11 && abs%100 <= 13 {
|
||||
return fmt.Sprintf("%dth", n)
|
||||
}
|
||||
switch abs % 10 {
|
||||
case 1:
|
||||
return fmt.Sprintf("%dst", n)
|
||||
case 2:
|
||||
return fmt.Sprintf("%dnd", n)
|
||||
case 3:
|
||||
return fmt.Sprintf("%drd", n)
|
||||
default:
|
||||
return fmt.Sprintf("%dth", n)
|
||||
}
|
||||
}
|
||||
|
||||
func formatIntWithSep(n int64, sep string) string {
|
||||
if sep == "" {
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
negative := n < 0
|
||||
if negative {
|
||||
n = -n
|
||||
}
|
||||
str := strconv.FormatInt(n, 10)
|
||||
if len(str) <= 3 {
|
||||
if negative {
|
||||
return "-" + str
|
||||
}
|
||||
return str
|
||||
}
|
||||
var result strings.Builder
|
||||
for i, c := range str {
|
||||
if i > 0 && (len(str)-i)%3 == 0 {
|
||||
result.WriteString(sep)
|
||||
}
|
||||
result.WriteRune(c)
|
||||
}
|
||||
if negative {
|
||||
return "-" + result.String()
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
145
numbers_test.go
Normal file
145
numbers_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormatNumber(t *testing.T) {
|
||||
// Ensure service is initialised for English locale
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
n int64
|
||||
want string
|
||||
}{
|
||||
{0, "0"},
|
||||
{100, "100"},
|
||||
{999, "999"},
|
||||
{1000, "1,000"},
|
||||
{1234567, "1,234,567"},
|
||||
{-1234567, "-1,234,567"},
|
||||
{1000000000, "1,000,000,000"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := FormatNumber(tt.n)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatNumber(%d) = %q, want %q", tt.n, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDecimal(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
f float64
|
||||
want string
|
||||
}{
|
||||
{1.5, "1.5"},
|
||||
{1.0, "1"},
|
||||
{1234.56, "1,234.56"},
|
||||
{0.1, "0.1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := FormatDecimal(tt.f)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatDecimal(%v) = %q, want %q", tt.f, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPercent(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
f float64
|
||||
want string
|
||||
}{
|
||||
{0.85, "85%"},
|
||||
{1.0, "100%"},
|
||||
{0.0, "0%"},
|
||||
{0.333, "33.3%"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := FormatPercent(tt.f)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatPercent(%v) = %q, want %q", tt.f, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBytes(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
bytes int64
|
||||
want string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{512, "512 B"},
|
||||
{1024, "1 KB"},
|
||||
{1536, "1.5 KB"},
|
||||
{1048576, "1 MB"},
|
||||
{1536000, "1.5 MB"},
|
||||
{1073741824, "1 GB"},
|
||||
{1099511627776, "1 TB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := FormatBytes(tt.bytes)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatBytes(%d) = %q, want %q", tt.bytes, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOrdinal(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{1, "1st"},
|
||||
{2, "2nd"},
|
||||
{3, "3rd"},
|
||||
{4, "4th"},
|
||||
{11, "11th"},
|
||||
{12, "12th"},
|
||||
{13, "13th"},
|
||||
{21, "21st"},
|
||||
{22, "22nd"},
|
||||
{23, "23rd"},
|
||||
{100, "100th"},
|
||||
{101, "101st"},
|
||||
{111, "111th"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := FormatOrdinal(tt.n)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatOrdinal(%d) = %q, want %q", tt.n, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
466
service.go
Normal file
466
service.go
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Service provides grammar-aware internationalisation.
|
||||
type Service struct {
|
||||
loader Loader
|
||||
messages map[string]map[string]Message // lang -> key -> message
|
||||
currentLang string
|
||||
fallbackLang string
|
||||
availableLangs []language.Tag
|
||||
mode Mode
|
||||
debug bool
|
||||
formality Formality
|
||||
handlers []KeyHandler
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Option configures a Service during construction.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithFallback sets the fallback language for missing translations.
|
||||
func WithFallback(lang string) Option {
|
||||
return func(s *Service) { s.fallbackLang = lang }
|
||||
}
|
||||
|
||||
// WithFormality sets the default formality level.
|
||||
func WithFormality(f Formality) Option {
|
||||
return func(s *Service) { s.formality = f }
|
||||
}
|
||||
|
||||
// WithHandlers sets custom handlers (replaces default handlers).
|
||||
func WithHandlers(handlers ...KeyHandler) Option {
|
||||
return func(s *Service) { s.handlers = handlers }
|
||||
}
|
||||
|
||||
// WithDefaultHandlers adds the default i18n.* namespace handlers.
|
||||
func WithDefaultHandlers() Option {
|
||||
return func(s *Service) { s.handlers = append(s.handlers, DefaultHandlers()...) }
|
||||
}
|
||||
|
||||
// WithMode sets the translation mode.
|
||||
func WithMode(m Mode) Option {
|
||||
return func(s *Service) { s.mode = m }
|
||||
}
|
||||
|
||||
// WithDebug enables or disables debug mode.
|
||||
func WithDebug(enabled bool) Option {
|
||||
return func(s *Service) { s.debug = enabled }
|
||||
}
|
||||
|
||||
var (
|
||||
defaultService atomic.Pointer[Service]
|
||||
defaultOnce sync.Once
|
||||
defaultErr error
|
||||
)
|
||||
|
||||
//go:embed locales/*.json
|
||||
var localeFS embed.FS
|
||||
|
||||
var _ Translator = (*Service)(nil)
|
||||
|
||||
// New creates a new i18n service with embedded locales.
|
||||
func New(opts ...Option) (*Service, error) {
|
||||
return NewWithLoader(NewFSLoader(localeFS, "locales"), opts...)
|
||||
}
|
||||
|
||||
// NewWithFS creates a new i18n service loading locales from the given filesystem.
|
||||
func NewWithFS(fsys fs.FS, dir string, opts ...Option) (*Service, error) {
|
||||
return NewWithLoader(NewFSLoader(fsys, dir), opts...)
|
||||
}
|
||||
|
||||
// NewWithLoader creates a new i18n service with a custom loader.
|
||||
func NewWithLoader(loader Loader, opts ...Option) (*Service, error) {
|
||||
s := &Service{
|
||||
loader: loader,
|
||||
messages: make(map[string]map[string]Message),
|
||||
fallbackLang: "en",
|
||||
handlers: DefaultHandlers(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
||||
langs := loader.Languages()
|
||||
if len(langs) == 0 {
|
||||
return nil, fmt.Errorf("no languages available from loader")
|
||||
}
|
||||
|
||||
for _, lang := range langs {
|
||||
messages, grammar, err := loader.Load(lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load locale %q: %w", lang, err)
|
||||
}
|
||||
s.messages[lang] = messages
|
||||
if grammar != nil && (len(grammar.Verbs) > 0 || len(grammar.Nouns) > 0 || len(grammar.Words) > 0) {
|
||||
SetGrammarData(lang, grammar)
|
||||
}
|
||||
tag := language.Make(lang)
|
||||
s.availableLangs = append(s.availableLangs, tag)
|
||||
}
|
||||
|
||||
if detected := detectLanguage(s.availableLangs); detected != "" {
|
||||
s.currentLang = detected
|
||||
} else {
|
||||
s.currentLang = s.fallbackLang
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Init initialises the default global service.
|
||||
func Init() error {
|
||||
defaultOnce.Do(func() {
|
||||
svc, err := New()
|
||||
if err == nil {
|
||||
defaultService.Store(svc)
|
||||
loadRegisteredLocales(svc)
|
||||
}
|
||||
defaultErr = err
|
||||
})
|
||||
return defaultErr
|
||||
}
|
||||
|
||||
// Default returns the global i18n service, initialising if needed.
|
||||
func Default() *Service {
|
||||
_ = Init()
|
||||
return defaultService.Load()
|
||||
}
|
||||
|
||||
// SetDefault sets the global i18n service.
|
||||
func SetDefault(s *Service) {
|
||||
if s == nil {
|
||||
panic("i18n: SetDefault called with nil service")
|
||||
}
|
||||
defaultService.Store(s)
|
||||
}
|
||||
|
||||
func (s *Service) loadJSON(lang string, data []byte) error {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
messages := make(map[string]Message)
|
||||
grammarData := &GrammarData{
|
||||
Verbs: make(map[string]VerbForms),
|
||||
Nouns: make(map[string]NounForms),
|
||||
Words: make(map[string]string),
|
||||
}
|
||||
flattenWithGrammar("", raw, messages, grammarData)
|
||||
if existing, ok := s.messages[lang]; ok {
|
||||
for key, msg := range messages {
|
||||
existing[key] = msg
|
||||
}
|
||||
} else {
|
||||
s.messages[lang] = messages
|
||||
}
|
||||
if len(grammarData.Verbs) > 0 || len(grammarData.Nouns) > 0 || len(grammarData.Words) > 0 {
|
||||
SetGrammarData(lang, grammarData)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLanguage sets the language for translations.
|
||||
func (s *Service) SetLanguage(lang string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
requestedLang, err := language.Parse(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid language tag %q: %w", lang, err)
|
||||
}
|
||||
if len(s.availableLangs) == 0 {
|
||||
return fmt.Errorf("no languages available")
|
||||
}
|
||||
matcher := language.NewMatcher(s.availableLangs)
|
||||
bestMatch, _, confidence := matcher.Match(requestedLang)
|
||||
if confidence == language.No {
|
||||
return fmt.Errorf("unsupported language: %q", lang)
|
||||
}
|
||||
s.currentLang = bestMatch.String()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Language() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.currentLang
|
||||
}
|
||||
|
||||
func (s *Service) AvailableLanguages() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
langs := make([]string, len(s.availableLangs))
|
||||
for i, tag := range s.availableLangs {
|
||||
langs[i] = tag.String()
|
||||
}
|
||||
return langs
|
||||
}
|
||||
|
||||
func (s *Service) SetMode(m Mode) { s.mu.Lock(); s.mode = m; s.mu.Unlock() }
|
||||
func (s *Service) Mode() Mode { s.mu.RLock(); defer s.mu.RUnlock(); return s.mode }
|
||||
func (s *Service) SetFormality(f Formality) { s.mu.Lock(); s.formality = f; s.mu.Unlock() }
|
||||
func (s *Service) Formality() Formality { s.mu.RLock(); defer s.mu.RUnlock(); return s.formality }
|
||||
|
||||
func (s *Service) Direction() TextDirection {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if IsRTLLanguage(s.currentLang) {
|
||||
return DirRTL
|
||||
}
|
||||
return DirLTR
|
||||
}
|
||||
|
||||
func (s *Service) IsRTL() bool { return s.Direction() == DirRTL }
|
||||
|
||||
func (s *Service) PluralCategory(n int) PluralCategory {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return GetPluralCategory(s.currentLang, n)
|
||||
}
|
||||
|
||||
func (s *Service) AddHandler(h KeyHandler) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.handlers = append(s.handlers, h)
|
||||
}
|
||||
|
||||
func (s *Service) PrependHandler(h KeyHandler) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.handlers = append([]KeyHandler{h}, s.handlers...)
|
||||
}
|
||||
|
||||
func (s *Service) ClearHandlers() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.handlers = nil
|
||||
}
|
||||
|
||||
func (s *Service) Handlers() []KeyHandler {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]KeyHandler, len(s.handlers))
|
||||
copy(result, s.handlers)
|
||||
return result
|
||||
}
|
||||
|
||||
// T translates a message by its ID with handler chain support.
|
||||
//
|
||||
// T("i18n.label.status") // "Status:"
|
||||
// T("i18n.progress.build") // "Building..."
|
||||
// T("i18n.count.file", 5) // "5 files"
|
||||
// T("i18n.done.delete", "file") // "File deleted"
|
||||
// T("i18n.fail.delete", "file") // "Failed to delete file"
|
||||
func (s *Service) T(messageID string, args ...any) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := RunHandlerChain(s.handlers, messageID, args, func() string {
|
||||
var data any
|
||||
if len(args) > 0 {
|
||||
data = args[0]
|
||||
}
|
||||
text := s.resolveWithFallback(messageID, data)
|
||||
if text == "" {
|
||||
return s.handleMissingKey(messageID, args)
|
||||
}
|
||||
return text
|
||||
})
|
||||
if s.debug {
|
||||
return debugFormat(messageID, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) resolveWithFallback(messageID string, data any) string {
|
||||
if text := s.tryResolve(s.currentLang, messageID, data); text != "" {
|
||||
return text
|
||||
}
|
||||
if text := s.tryResolve(s.fallbackLang, messageID, data); text != "" {
|
||||
return text
|
||||
}
|
||||
if strings.Contains(messageID, ".") {
|
||||
parts := strings.Split(messageID, ".")
|
||||
verb := parts[len(parts)-1]
|
||||
commonKey := "common.action." + verb
|
||||
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
|
||||
return text
|
||||
}
|
||||
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
|
||||
return text
|
||||
}
|
||||
commonKey = "common." + verb
|
||||
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
|
||||
return text
|
||||
}
|
||||
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) tryResolve(lang, key string, data any) string {
|
||||
formality := s.getEffectiveFormality(data)
|
||||
if formality != FormalityNeutral {
|
||||
formalityKey := key + "._" + formality.String()
|
||||
if text := s.resolveMessage(lang, formalityKey, data); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return s.resolveMessage(lang, key, data)
|
||||
}
|
||||
|
||||
func (s *Service) resolveMessage(lang, key string, data any) string {
|
||||
msg, ok := s.getMessage(lang, key)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
text := msg.Text
|
||||
if msg.IsPlural() {
|
||||
count := getCount(data)
|
||||
category := GetPluralCategory(lang, count)
|
||||
text = msg.ForCategory(category)
|
||||
}
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
if data != nil {
|
||||
text = applyTemplate(text, data)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (s *Service) getEffectiveFormality(data any) Formality {
|
||||
if ctx, ok := data.(*TranslationContext); ok && ctx != nil {
|
||||
if ctx.Formality != FormalityNeutral {
|
||||
return ctx.Formality
|
||||
}
|
||||
}
|
||||
if subj, ok := data.(*Subject); ok && subj != nil {
|
||||
if subj.formality != FormalityNeutral {
|
||||
return subj.formality
|
||||
}
|
||||
}
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
switch f := m["Formality"].(type) {
|
||||
case Formality:
|
||||
if f != FormalityNeutral {
|
||||
return f
|
||||
}
|
||||
case string:
|
||||
switch strings.ToLower(f) {
|
||||
case "formal":
|
||||
return FormalityFormal
|
||||
case "informal":
|
||||
return FormalityInformal
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.formality
|
||||
}
|
||||
|
||||
func (s *Service) handleMissingKey(key string, args []any) string {
|
||||
switch s.mode {
|
||||
case ModeStrict:
|
||||
panic(fmt.Sprintf("i18n: missing translation key %q", key))
|
||||
case ModeCollect:
|
||||
var argsMap map[string]any
|
||||
if len(args) > 0 {
|
||||
if m, ok := args[0].(map[string]any); ok {
|
||||
argsMap = m
|
||||
}
|
||||
}
|
||||
dispatchMissingKey(key, argsMap)
|
||||
return "[" + key + "]"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
// Raw translates without i18n.* namespace magic.
|
||||
func (s *Service) Raw(messageID string, args ...any) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var data any
|
||||
if len(args) > 0 {
|
||||
data = args[0]
|
||||
}
|
||||
text := s.resolveWithFallback(messageID, data)
|
||||
if text == "" {
|
||||
return s.handleMissingKey(messageID, args)
|
||||
}
|
||||
if s.debug {
|
||||
return debugFormat(messageID, text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (s *Service) getMessage(lang, key string) (Message, bool) {
|
||||
msgs, ok := s.messages[lang]
|
||||
if !ok {
|
||||
return Message{}, false
|
||||
}
|
||||
msg, ok := msgs[key]
|
||||
return msg, ok
|
||||
}
|
||||
|
||||
// AddMessages adds messages for a language at runtime.
|
||||
func (s *Service) AddMessages(lang string, messages map[string]string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.messages[lang] == nil {
|
||||
s.messages[lang] = make(map[string]Message)
|
||||
}
|
||||
for key, text := range messages {
|
||||
s.messages[lang][key] = Message{Text: text}
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFS loads additional locale files from a filesystem.
|
||||
func (s *Service) LoadFS(fsys fs.FS, dir string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entries, err := fs.ReadDir(fsys, dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read locales directory: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
filePath := path.Join(dir, entry.Name())
|
||||
data, err := fs.ReadFile(fsys, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read locale %q: %w", entry.Name(), err)
|
||||
}
|
||||
lang := strings.TrimSuffix(entry.Name(), ".json")
|
||||
lang = strings.ReplaceAll(lang, "_", "-")
|
||||
if err := s.loadJSON(lang, data); err != nil {
|
||||
return fmt.Errorf("failed to parse locale %q: %w", entry.Name(), err)
|
||||
}
|
||||
tag := language.Make(lang)
|
||||
found := false
|
||||
for _, existing := range s.availableLangs {
|
||||
if existing == tag {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.availableLangs = append(s.availableLangs, tag)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
305
service_test.go
Normal file
305
service_test.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
lang := svc.Language()
|
||||
if lang == "" {
|
||||
t.Error("Language() is empty")
|
||||
}
|
||||
// Language matcher may return canonical form with region (e.g. "en-u-rg-uszzzz")
|
||||
// depending on LANG environment variable. Just check it starts with "en".
|
||||
if lang[:2] != "en" {
|
||||
t.Errorf("Language() = %q, expected to start with 'en'", lang)
|
||||
}
|
||||
|
||||
langs := svc.AvailableLanguages()
|
||||
if len(langs) == 0 {
|
||||
t.Error("AvailableLanguages() is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceT(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
// Label handler
|
||||
got := svc.T("i18n.label.status")
|
||||
if got != "Status:" {
|
||||
t.Errorf("T(i18n.label.status) = %q, want 'Status:'", got)
|
||||
}
|
||||
|
||||
// Progress handler
|
||||
got = svc.T("i18n.progress.build")
|
||||
if got != "Building..." {
|
||||
t.Errorf("T(i18n.progress.build) = %q, want 'Building...'", got)
|
||||
}
|
||||
|
||||
// Count handler
|
||||
got = svc.T("i18n.count.file", 5)
|
||||
if got != "5 files" {
|
||||
t.Errorf("T(i18n.count.file, 5) = %q, want '5 files'", got)
|
||||
}
|
||||
|
||||
// Done handler
|
||||
got = svc.T("i18n.done.delete", "config.yaml")
|
||||
if got != "Config.Yaml deleted" {
|
||||
t.Errorf("T(i18n.done.delete, config.yaml) = %q, want 'Config.Yaml deleted'", got)
|
||||
}
|
||||
|
||||
// Fail handler
|
||||
got = svc.T("i18n.fail.push", "commits")
|
||||
if got != "Failed to push commits" {
|
||||
t.Errorf("T(i18n.fail.push, commits) = %q, want 'Failed to push commits'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTDirectKeys(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
// Direct JSON keys
|
||||
got := svc.T("prompt.yes")
|
||||
if got != "y" {
|
||||
t.Errorf("T(prompt.yes) = %q, want 'y'", got)
|
||||
}
|
||||
|
||||
got = svc.T("prompt.confirm")
|
||||
if got != "Are you sure?" {
|
||||
t.Errorf("T(prompt.confirm) = %q, want 'Are you sure?'", got)
|
||||
}
|
||||
|
||||
got = svc.T("lang.de")
|
||||
if got != "German" {
|
||||
t.Errorf("T(lang.de) = %q, want 'German'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTPluralMessage(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
// time.ago.second has one/other forms
|
||||
got := svc.T("time.ago.second", map[string]any{"Count": 1})
|
||||
if got != "1 second ago" {
|
||||
t.Errorf("T(time.ago.second, 1) = %q, want '1 second ago'", got)
|
||||
}
|
||||
|
||||
got = svc.T("time.ago.second", map[string]any{"Count": 5})
|
||||
if got != "5 seconds ago" {
|
||||
t.Errorf("T(time.ago.second, 5) = %q, want '5 seconds ago'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRaw(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
// Raw bypasses handlers
|
||||
got := svc.Raw("prompt.yes")
|
||||
if got != "y" {
|
||||
t.Errorf("Raw(prompt.yes) = %q, want 'y'", got)
|
||||
}
|
||||
|
||||
// Raw doesn't process i18n.* keys as handlers would
|
||||
got = svc.Raw("i18n.label.status")
|
||||
// Should return the key since it's not in the messages map
|
||||
if got != "i18n.label.status" {
|
||||
t.Errorf("Raw(i18n.label.status) = %q, want key returned", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceModes(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
// Default mode
|
||||
if svc.Mode() != ModeNormal {
|
||||
t.Errorf("default Mode() = %v, want ModeNormal", svc.Mode())
|
||||
}
|
||||
|
||||
// Normal mode returns key for missing
|
||||
got := svc.T("nonexistent.key")
|
||||
if got != "nonexistent.key" {
|
||||
t.Errorf("ModeNormal missing key = %q, want key", got)
|
||||
}
|
||||
|
||||
// Collect mode returns [key] and dispatches event
|
||||
svc.SetMode(ModeCollect)
|
||||
var missing MissingKey
|
||||
OnMissingKey(func(m MissingKey) { missing = m })
|
||||
got = svc.T("nonexistent.key")
|
||||
if got != "[nonexistent.key]" {
|
||||
t.Errorf("ModeCollect missing key = %q, want '[nonexistent.key]'", got)
|
||||
}
|
||||
if missing.Key != "nonexistent.key" {
|
||||
t.Errorf("MissingKey.Key = %q, want 'nonexistent.key'", missing.Key)
|
||||
}
|
||||
|
||||
// Strict mode panics
|
||||
svc.SetMode(ModeStrict)
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Error("ModeStrict should panic on missing key")
|
||||
}
|
||||
}()
|
||||
svc.T("nonexistent.key")
|
||||
}
|
||||
|
||||
func TestServiceDebug(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
svc.SetDebug(true)
|
||||
got := svc.T("prompt.yes")
|
||||
if got != "[prompt.yes] y" {
|
||||
t.Errorf("debug T() = %q, want '[prompt.yes] y'", got)
|
||||
}
|
||||
|
||||
svc.SetDebug(false)
|
||||
got = svc.T("prompt.yes")
|
||||
if got != "y" {
|
||||
t.Errorf("non-debug T() = %q, want 'y'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceFormality(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
if svc.Formality() != FormalityNeutral {
|
||||
t.Errorf("default Formality() = %v, want FormalityNeutral", svc.Formality())
|
||||
}
|
||||
|
||||
svc.SetFormality(FormalityFormal)
|
||||
if svc.Formality() != FormalityFormal {
|
||||
t.Errorf("Formality() = %v, want FormalityFormal", svc.Formality())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceDirection(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
if svc.Direction() != DirLTR {
|
||||
t.Errorf("Direction() = %v, want DirLTR", svc.Direction())
|
||||
}
|
||||
|
||||
if svc.IsRTL() {
|
||||
t.Error("IsRTL() should be false for English")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAddMessages(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
svc.AddMessages("en", map[string]string{
|
||||
"custom.greeting": "Hello!",
|
||||
})
|
||||
|
||||
got := svc.T("custom.greeting")
|
||||
if got != "Hello!" {
|
||||
t.Errorf("T(custom.greeting) = %q, want 'Hello!'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceHandlers(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
initial := len(svc.Handlers())
|
||||
if initial != 6 {
|
||||
t.Errorf("default handlers = %d, want 6", initial)
|
||||
}
|
||||
|
||||
svc.ClearHandlers()
|
||||
if len(svc.Handlers()) != 0 {
|
||||
t.Error("ClearHandlers() should remove all handlers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWithOptions(t *testing.T) {
|
||||
svc, err := New(
|
||||
WithFallback("en"),
|
||||
WithFormality(FormalityFormal),
|
||||
WithMode(ModeCollect),
|
||||
WithDebug(true),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("New() with options failed: %v", err)
|
||||
}
|
||||
|
||||
if svc.Formality() != FormalityFormal {
|
||||
t.Errorf("Formality = %v, want FormalityFormal", svc.Formality())
|
||||
}
|
||||
if svc.Mode() != ModeCollect {
|
||||
t.Errorf("Mode = %v, want ModeCollect", svc.Mode())
|
||||
}
|
||||
if !svc.Debug() {
|
||||
t.Error("Debug should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithFS(t *testing.T) {
|
||||
fs := fstest.MapFS{
|
||||
"i18n/custom.json": &fstest.MapFile{
|
||||
Data: []byte(`{"hello": "Hola!"}`),
|
||||
},
|
||||
}
|
||||
|
||||
svc, err := NewWithFS(fs, "i18n", WithFallback("custom"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithFS failed: %v", err)
|
||||
}
|
||||
|
||||
got := svc.T("hello")
|
||||
if got != "Hola!" {
|
||||
t.Errorf("T(hello) = %q, want 'Hola!'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePluralCategory(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
if svc.PluralCategory(1) != PluralOne {
|
||||
t.Errorf("PluralCategory(1) = %v, want PluralOne", svc.PluralCategory(1))
|
||||
}
|
||||
if svc.PluralCategory(5) != PluralOther {
|
||||
t.Errorf("PluralCategory(5) = %v, want PluralOther", svc.PluralCategory(5))
|
||||
}
|
||||
}
|
||||
39
time.go
Normal file
39
time.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimeAgo returns a localised relative time string.
|
||||
//
|
||||
// TimeAgo(time.Now().Add(-5 * time.Minute)) // "5 minutes ago"
|
||||
func TimeAgo(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
switch {
|
||||
case duration < time.Minute:
|
||||
return T("time.just_now")
|
||||
case duration < time.Hour:
|
||||
return FormatAgo(int(duration.Minutes()), "minute")
|
||||
case duration < 24*time.Hour:
|
||||
return FormatAgo(int(duration.Hours()), "hour")
|
||||
case duration < 7*24*time.Hour:
|
||||
return FormatAgo(int(duration.Hours()/24), "day")
|
||||
default:
|
||||
return FormatAgo(int(duration.Hours()/(24*7)), "week")
|
||||
}
|
||||
}
|
||||
|
||||
// FormatAgo formats "N unit ago" with proper pluralisation.
|
||||
func FormatAgo(count int, unit string) string {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return fmt.Sprintf("%d %ss ago", count, unit)
|
||||
}
|
||||
key := "time.ago." + unit
|
||||
result := svc.T(key, map[string]any{"Count": count})
|
||||
if result == key {
|
||||
return fmt.Sprintf("%d %s ago", count, Pluralize(unit, count))
|
||||
}
|
||||
return result
|
||||
}
|
||||
117
transform.go
Normal file
117
transform.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package i18n
|
||||
|
||||
func getCount(data any) int {
|
||||
if data == nil {
|
||||
return 0
|
||||
}
|
||||
switch d := data.(type) {
|
||||
case map[string]any:
|
||||
if c, ok := d["Count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
case map[string]int:
|
||||
if c, ok := d["Count"]; ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func toInt(v any) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case int32:
|
||||
return int(n)
|
||||
case int16:
|
||||
return int(n)
|
||||
case int8:
|
||||
return int(n)
|
||||
case uint:
|
||||
return int(n)
|
||||
case uint64:
|
||||
return int(n)
|
||||
case uint32:
|
||||
return int(n)
|
||||
case uint16:
|
||||
return int(n)
|
||||
case uint8:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case float32:
|
||||
return int(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func toInt64(v any) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
case int32:
|
||||
return int64(n)
|
||||
case int16:
|
||||
return int64(n)
|
||||
case int8:
|
||||
return int64(n)
|
||||
case uint:
|
||||
return int64(n)
|
||||
case uint64:
|
||||
return int64(n)
|
||||
case uint32:
|
||||
return int64(n)
|
||||
case uint16:
|
||||
return int64(n)
|
||||
case uint8:
|
||||
return int64(n)
|
||||
case float64:
|
||||
return int64(n)
|
||||
case float32:
|
||||
return int64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func toFloat64(v any) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case float32:
|
||||
return float64(n)
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case int32:
|
||||
return float64(n)
|
||||
case int16:
|
||||
return float64(n)
|
||||
case int8:
|
||||
return float64(n)
|
||||
case uint:
|
||||
return float64(n)
|
||||
case uint64:
|
||||
return float64(n)
|
||||
case uint32:
|
||||
return float64(n)
|
||||
case uint16:
|
||||
return float64(n)
|
||||
case uint8:
|
||||
return float64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
423
types.go
Normal file
423
types.go
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
// Package i18n provides grammar-aware internationalisation.
|
||||
//
|
||||
// Unlike flat key-value translation systems, this package composes
|
||||
// grammatically correct output from verbs, nouns, and articles.
|
||||
// The i18n.* namespace provides magic key handlers that auto-compose
|
||||
// labels, progress messages, counts, and action results:
|
||||
//
|
||||
// T("i18n.label.status") // "Status:"
|
||||
// T("i18n.progress.build") // "Building..."
|
||||
// T("i18n.count.file", 5) // "5 files"
|
||||
// T("i18n.done.delete", "config.yaml") // "Config.Yaml deleted"
|
||||
// T("i18n.fail.push", "commits") // "Failed to push commits"
|
||||
package i18n
|
||||
|
||||
import "sync"
|
||||
|
||||
// --- Core Types ---
|
||||
|
||||
// Mode determines how the service handles missing translation keys.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
ModeNormal Mode = iota // Returns key as-is (production)
|
||||
ModeStrict // Panics on missing key (dev/CI)
|
||||
ModeCollect // Dispatches MissingKey events, returns [key] (QA)
|
||||
)
|
||||
|
||||
func (m Mode) String() string {
|
||||
switch m {
|
||||
case ModeNormal:
|
||||
return "normal"
|
||||
case ModeStrict:
|
||||
return "strict"
|
||||
case ModeCollect:
|
||||
return "collect"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Formality represents the level of formality in translations.
|
||||
type Formality int
|
||||
|
||||
const (
|
||||
FormalityNeutral Formality = iota // Context-appropriate (default)
|
||||
FormalityInformal // du, tu, you
|
||||
FormalityFormal // Sie, vous, usted
|
||||
)
|
||||
|
||||
// TextDirection represents text directionality.
|
||||
type TextDirection int
|
||||
|
||||
const (
|
||||
DirLTR TextDirection = iota // Left-to-right
|
||||
DirRTL // Right-to-left
|
||||
)
|
||||
|
||||
// PluralCategory represents CLDR plural categories.
|
||||
type PluralCategory int
|
||||
|
||||
const (
|
||||
PluralOther PluralCategory = iota // Default/fallback
|
||||
PluralZero // n=0 (Arabic, Latvian)
|
||||
PluralOne // n=1 (most languages)
|
||||
PluralTwo // n=2 (Arabic, Welsh)
|
||||
PluralFew // Small numbers (Slavic: 2-4)
|
||||
PluralMany // Larger numbers (Slavic: 5+)
|
||||
)
|
||||
|
||||
// GrammaticalGender represents grammatical gender for nouns.
|
||||
type GrammaticalGender int
|
||||
|
||||
const (
|
||||
GenderNeuter GrammaticalGender = iota // das, it
|
||||
GenderMasculine // der, le
|
||||
GenderFeminine // die, la
|
||||
GenderCommon // Swedish, Dutch
|
||||
)
|
||||
|
||||
// --- Message Types ---
|
||||
|
||||
// Message represents a translation — either a simple string or plural forms.
|
||||
type Message struct {
|
||||
Text string // Simple string value (non-plural)
|
||||
Zero string // count == 0 (Arabic, Latvian, Welsh)
|
||||
One string // count == 1 (most languages)
|
||||
Two string // count == 2 (Arabic, Welsh)
|
||||
Few string // Small numbers (Slavic: 2-4, Arabic: 3-10)
|
||||
Many string // Larger numbers (Slavic: 5+, Arabic: 11-99)
|
||||
Other string // Default/fallback form
|
||||
}
|
||||
|
||||
// ForCategory returns the appropriate text for a plural category.
|
||||
func (m Message) ForCategory(cat PluralCategory) string {
|
||||
switch cat {
|
||||
case PluralZero:
|
||||
if m.Zero != "" {
|
||||
return m.Zero
|
||||
}
|
||||
case PluralOne:
|
||||
if m.One != "" {
|
||||
return m.One
|
||||
}
|
||||
case PluralTwo:
|
||||
if m.Two != "" {
|
||||
return m.Two
|
||||
}
|
||||
case PluralFew:
|
||||
if m.Few != "" {
|
||||
return m.Few
|
||||
}
|
||||
case PluralMany:
|
||||
if m.Many != "" {
|
||||
return m.Many
|
||||
}
|
||||
}
|
||||
if m.Other != "" {
|
||||
return m.Other
|
||||
}
|
||||
if m.One != "" {
|
||||
return m.One
|
||||
}
|
||||
return m.Text
|
||||
}
|
||||
|
||||
// IsPlural returns true if this message has any plural forms.
|
||||
func (m Message) IsPlural() bool {
|
||||
return m.Zero != "" || m.One != "" || m.Two != "" ||
|
||||
m.Few != "" || m.Many != "" || m.Other != ""
|
||||
}
|
||||
|
||||
// --- Subject Types ---
|
||||
|
||||
// Subject represents a typed subject with metadata for semantic translations.
|
||||
type Subject struct {
|
||||
Noun string // The noun type (e.g., "file", "repo")
|
||||
Value any // The actual value (e.g., filename)
|
||||
count int // Count for pluralisation (default 1)
|
||||
gender string // Grammatical gender
|
||||
location string // Location context (e.g., "in workspace")
|
||||
formality Formality // Formality level override
|
||||
}
|
||||
|
||||
// --- Intent Types ---
|
||||
|
||||
// IntentMeta defines the behaviour of an intent.
|
||||
type IntentMeta struct {
|
||||
Type string // "action", "question", "info"
|
||||
Verb string // Reference to verb key
|
||||
Dangerous bool // If true, requires confirmation
|
||||
Default string // Default response: "yes" or "no"
|
||||
Supports []string // Extra options
|
||||
}
|
||||
|
||||
// Composed holds all output forms for an intent after template resolution.
|
||||
type Composed struct {
|
||||
Question string // "Delete config.yaml?"
|
||||
Confirm string // "Really delete config.yaml?"
|
||||
Success string // "config.yaml deleted"
|
||||
Failure string // "Failed to delete config.yaml"
|
||||
Meta IntentMeta // Intent metadata
|
||||
}
|
||||
|
||||
// Intent defines a semantic intent with templates for all output forms.
|
||||
type Intent struct {
|
||||
Meta IntentMeta
|
||||
Question string // Template for question form
|
||||
Confirm string // Template for confirmation form
|
||||
Success string // Template for success message
|
||||
Failure string // Template for failure message
|
||||
}
|
||||
|
||||
// templateData is passed to intent templates during execution.
|
||||
type templateData struct {
|
||||
Subject string
|
||||
Noun string
|
||||
Count int
|
||||
Gender string
|
||||
Location string
|
||||
Formality Formality
|
||||
IsFormal bool
|
||||
IsPlural bool
|
||||
Value any
|
||||
}
|
||||
|
||||
// --- Grammar Types ---
|
||||
|
||||
// GrammarData holds language-specific grammar forms loaded from JSON.
|
||||
type GrammarData struct {
|
||||
Verbs map[string]VerbForms // verb -> forms
|
||||
Nouns map[string]NounForms // noun -> forms
|
||||
Articles ArticleForms // article configuration
|
||||
Words map[string]string // base word translations
|
||||
Punct PunctuationRules // language-specific punctuation
|
||||
}
|
||||
|
||||
// VerbForms holds verb conjugations.
|
||||
type VerbForms struct {
|
||||
Past string // "deleted"
|
||||
Gerund string // "deleting"
|
||||
}
|
||||
|
||||
// NounForms holds plural and gender information for a noun.
|
||||
type NounForms struct {
|
||||
One string // Singular form
|
||||
Other string // Plural form
|
||||
Gender string // Grammatical gender
|
||||
}
|
||||
|
||||
// ArticleForms holds article configuration for a language.
|
||||
type ArticleForms struct {
|
||||
IndefiniteDefault string // "a"
|
||||
IndefiniteVowel string // "an"
|
||||
Definite string // "the"
|
||||
ByGender map[string]string // Gender-specific articles
|
||||
}
|
||||
|
||||
// PunctuationRules holds language-specific punctuation patterns.
|
||||
type PunctuationRules struct {
|
||||
LabelSuffix string // ":" (French uses " :")
|
||||
ProgressSuffix string // "..."
|
||||
}
|
||||
|
||||
// --- Number Formatting ---
|
||||
|
||||
// NumberFormat defines locale-specific number formatting rules.
|
||||
type NumberFormat struct {
|
||||
ThousandsSep string // "," for en, "." for de
|
||||
DecimalSep string // "." for en, "," for de
|
||||
PercentFmt string // "%s%%" for en, "%s %%" for de
|
||||
}
|
||||
|
||||
// --- Function Types ---
|
||||
|
||||
// PluralRule determines the plural category for a count.
|
||||
type PluralRule func(n int) PluralCategory
|
||||
|
||||
// MissingKeyHandler receives missing key events.
|
||||
type MissingKeyHandler func(missing MissingKey)
|
||||
|
||||
// MissingKey is dispatched when a translation key is not found in ModeCollect.
|
||||
type MissingKey struct {
|
||||
Key string
|
||||
Args map[string]any
|
||||
CallerFile string
|
||||
CallerLine int
|
||||
}
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
// KeyHandler processes translation keys before standard lookup.
|
||||
// Handlers form a chain; each can handle a key or delegate to the next.
|
||||
type KeyHandler interface {
|
||||
Match(key string) bool
|
||||
Handle(key string, args []any, next func() string) string
|
||||
}
|
||||
|
||||
// Loader provides translation data to the Service.
|
||||
type Loader interface {
|
||||
Load(lang string) (map[string]Message, *GrammarData, error)
|
||||
Languages() []string
|
||||
}
|
||||
|
||||
// Translator defines the interface for translation services.
|
||||
type Translator interface {
|
||||
T(messageID string, args ...any) string
|
||||
SetLanguage(lang string) error
|
||||
Language() string
|
||||
SetMode(m Mode)
|
||||
Mode() Mode
|
||||
SetDebug(enabled bool)
|
||||
Debug() bool
|
||||
SetFormality(f Formality)
|
||||
Formality() Formality
|
||||
Direction() TextDirection
|
||||
IsRTL() bool
|
||||
PluralCategory(n int) PluralCategory
|
||||
AvailableLanguages() []string
|
||||
}
|
||||
|
||||
// --- Package Variables ---
|
||||
|
||||
var (
|
||||
grammarCache = make(map[string]*GrammarData)
|
||||
grammarCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
var templateCache sync.Map
|
||||
|
||||
var numberFormats = map[string]NumberFormat{
|
||||
"en": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"},
|
||||
"de": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s %%"},
|
||||
"fr": {ThousandsSep: " ", DecimalSep: ",", PercentFmt: "%s %%"},
|
||||
"es": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s%%"},
|
||||
"zh": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"},
|
||||
}
|
||||
|
||||
var rtlLanguages = map[string]bool{
|
||||
"ar": true, "ar-SA": true, "ar-EG": true,
|
||||
"he": true, "he-IL": true,
|
||||
"fa": true, "fa-IR": true,
|
||||
"ur": true, "ur-PK": true,
|
||||
"yi": true, "ps": true, "sd": true, "ug": true,
|
||||
}
|
||||
|
||||
var pluralRules = map[string]PluralRule{
|
||||
"en": pluralRuleEnglish, "en-GB": pluralRuleEnglish, "en-US": pluralRuleEnglish,
|
||||
"de": pluralRuleGerman, "de-DE": pluralRuleGerman, "de-AT": pluralRuleGerman, "de-CH": pluralRuleGerman,
|
||||
"fr": pluralRuleFrench, "fr-FR": pluralRuleFrench, "fr-CA": pluralRuleFrench,
|
||||
"es": pluralRuleSpanish, "es-ES": pluralRuleSpanish, "es-MX": pluralRuleSpanish,
|
||||
"ru": pluralRuleRussian, "ru-RU": pluralRuleRussian,
|
||||
"pl": pluralRulePolish, "pl-PL": pluralRulePolish,
|
||||
"ar": pluralRuleArabic, "ar-SA": pluralRuleArabic,
|
||||
"zh": pluralRuleChinese, "zh-CN": pluralRuleChinese, "zh-TW": pluralRuleChinese,
|
||||
"ja": pluralRuleJapanese, "ja-JP": pluralRuleJapanese,
|
||||
"ko": pluralRuleKorean, "ko-KR": pluralRuleKorean,
|
||||
}
|
||||
|
||||
// --- Irregular Forms ---
|
||||
|
||||
var irregularVerbs = map[string]VerbForms{
|
||||
"be": {Past: "was", Gerund: "being"}, "have": {Past: "had", Gerund: "having"},
|
||||
"do": {Past: "did", Gerund: "doing"}, "go": {Past: "went", Gerund: "going"},
|
||||
"make": {Past: "made", Gerund: "making"}, "get": {Past: "got", Gerund: "getting"},
|
||||
"run": {Past: "ran", Gerund: "running"}, "set": {Past: "set", Gerund: "setting"},
|
||||
"put": {Past: "put", Gerund: "putting"}, "cut": {Past: "cut", Gerund: "cutting"},
|
||||
"let": {Past: "let", Gerund: "letting"}, "hit": {Past: "hit", Gerund: "hitting"},
|
||||
"shut": {Past: "shut", Gerund: "shutting"}, "split": {Past: "split", Gerund: "splitting"},
|
||||
"spread": {Past: "spread", Gerund: "spreading"}, "read": {Past: "read", Gerund: "reading"},
|
||||
"write": {Past: "wrote", Gerund: "writing"}, "send": {Past: "sent", Gerund: "sending"},
|
||||
"build": {Past: "built", Gerund: "building"}, "begin": {Past: "began", Gerund: "beginning"},
|
||||
"find": {Past: "found", Gerund: "finding"}, "take": {Past: "took", Gerund: "taking"},
|
||||
"see": {Past: "saw", Gerund: "seeing"}, "keep": {Past: "kept", Gerund: "keeping"},
|
||||
"hold": {Past: "held", Gerund: "holding"}, "tell": {Past: "told", Gerund: "telling"},
|
||||
"bring": {Past: "brought", Gerund: "bringing"}, "think": {Past: "thought", Gerund: "thinking"},
|
||||
"buy": {Past: "bought", Gerund: "buying"}, "catch": {Past: "caught", Gerund: "catching"},
|
||||
"teach": {Past: "taught", Gerund: "teaching"}, "throw": {Past: "threw", Gerund: "throwing"},
|
||||
"grow": {Past: "grew", Gerund: "growing"}, "know": {Past: "knew", Gerund: "knowing"},
|
||||
"show": {Past: "showed", Gerund: "showing"}, "draw": {Past: "drew", Gerund: "drawing"},
|
||||
"break": {Past: "broke", Gerund: "breaking"}, "speak": {Past: "spoke", Gerund: "speaking"},
|
||||
"choose": {Past: "chose", Gerund: "choosing"}, "forget": {Past: "forgot", Gerund: "forgetting"},
|
||||
"lose": {Past: "lost", Gerund: "losing"}, "win": {Past: "won", Gerund: "winning"},
|
||||
"swim": {Past: "swam", Gerund: "swimming"}, "drive": {Past: "drove", Gerund: "driving"},
|
||||
"rise": {Past: "rose", Gerund: "rising"}, "shine": {Past: "shone", Gerund: "shining"},
|
||||
"sing": {Past: "sang", Gerund: "singing"}, "ring": {Past: "rang", Gerund: "ringing"},
|
||||
"drink": {Past: "drank", Gerund: "drinking"}, "sink": {Past: "sank", Gerund: "sinking"},
|
||||
"sit": {Past: "sat", Gerund: "sitting"}, "stand": {Past: "stood", Gerund: "standing"},
|
||||
"hang": {Past: "hung", Gerund: "hanging"}, "dig": {Past: "dug", Gerund: "digging"},
|
||||
"stick": {Past: "stuck", Gerund: "sticking"}, "bite": {Past: "bit", Gerund: "biting"},
|
||||
"hide": {Past: "hid", Gerund: "hiding"}, "feed": {Past: "fed", Gerund: "feeding"},
|
||||
"meet": {Past: "met", Gerund: "meeting"}, "lead": {Past: "led", Gerund: "leading"},
|
||||
"sleep": {Past: "slept", Gerund: "sleeping"}, "feel": {Past: "felt", Gerund: "feeling"},
|
||||
"leave": {Past: "left", Gerund: "leaving"}, "mean": {Past: "meant", Gerund: "meaning"},
|
||||
"lend": {Past: "lent", Gerund: "lending"}, "spend": {Past: "spent", Gerund: "spending"},
|
||||
"bend": {Past: "bent", Gerund: "bending"}, "deal": {Past: "dealt", Gerund: "dealing"},
|
||||
"lay": {Past: "laid", Gerund: "laying"}, "pay": {Past: "paid", Gerund: "paying"},
|
||||
"say": {Past: "said", Gerund: "saying"}, "sell": {Past: "sold", Gerund: "selling"},
|
||||
"seek": {Past: "sought", Gerund: "seeking"}, "fight": {Past: "fought", Gerund: "fighting"},
|
||||
"fly": {Past: "flew", Gerund: "flying"}, "wear": {Past: "wore", Gerund: "wearing"},
|
||||
"tear": {Past: "tore", Gerund: "tearing"}, "bear": {Past: "bore", Gerund: "bearing"},
|
||||
"swear": {Past: "swore", Gerund: "swearing"}, "wake": {Past: "woke", Gerund: "waking"},
|
||||
"freeze": {Past: "froze", Gerund: "freezing"}, "steal": {Past: "stole", Gerund: "stealing"},
|
||||
"overwrite": {Past: "overwritten", Gerund: "overwriting"}, "reset": {Past: "reset", Gerund: "resetting"},
|
||||
"reboot": {Past: "rebooted", Gerund: "rebooting"},
|
||||
"submit": {Past: "submitted", Gerund: "submitting"}, "permit": {Past: "permitted", Gerund: "permitting"},
|
||||
"admit": {Past: "admitted", Gerund: "admitting"}, "omit": {Past: "omitted", Gerund: "omitting"},
|
||||
"commit": {Past: "committed", Gerund: "committing"}, "transmit": {Past: "transmitted", Gerund: "transmitting"},
|
||||
"prefer": {Past: "preferred", Gerund: "preferring"}, "refer": {Past: "referred", Gerund: "referring"},
|
||||
"transfer": {Past: "transferred", Gerund: "transferring"}, "defer": {Past: "deferred", Gerund: "deferring"},
|
||||
"confer": {Past: "conferred", Gerund: "conferring"}, "infer": {Past: "inferred", Gerund: "inferring"},
|
||||
"occur": {Past: "occurred", Gerund: "occurring"}, "recur": {Past: "recurred", Gerund: "recurring"},
|
||||
"incur": {Past: "incurred", Gerund: "incurring"}, "deter": {Past: "deterred", Gerund: "deterring"},
|
||||
"control": {Past: "controlled", Gerund: "controlling"}, "patrol": {Past: "patrolled", Gerund: "patrolling"},
|
||||
"compel": {Past: "compelled", Gerund: "compelling"}, "expel": {Past: "expelled", Gerund: "expelling"},
|
||||
"propel": {Past: "propelled", Gerund: "propelling"}, "repel": {Past: "repelled", Gerund: "repelling"},
|
||||
"rebel": {Past: "rebelled", Gerund: "rebelling"}, "excel": {Past: "excelled", Gerund: "excelling"},
|
||||
"cancel": {Past: "cancelled", Gerund: "cancelling"}, "travel": {Past: "travelled", Gerund: "travelling"},
|
||||
"label": {Past: "labelled", Gerund: "labelling"}, "model": {Past: "modelled", Gerund: "modelling"},
|
||||
"level": {Past: "levelled", Gerund: "levelling"},
|
||||
"format": {Past: "formatted", Gerund: "formatting"},
|
||||
"analyse": {Past: "analysed", Gerund: "analysing"},
|
||||
"organise": {Past: "organised", Gerund: "organising"},
|
||||
"recognise": {Past: "recognised", Gerund: "recognising"},
|
||||
"realise": {Past: "realised", Gerund: "realising"},
|
||||
"customise": {Past: "customised", Gerund: "customising"},
|
||||
"optimise": {Past: "optimised", Gerund: "optimising"},
|
||||
"initialise": {Past: "initialised", Gerund: "initialising"},
|
||||
"synchronise": {Past: "synchronised", Gerund: "synchronising"},
|
||||
}
|
||||
|
||||
var noDoubleConsonant = map[string]bool{
|
||||
"open": true, "listen": true, "happen": true, "enter": true, "offer": true,
|
||||
"suffer": true, "differ": true, "cover": true, "deliver": true, "develop": true,
|
||||
"visit": true, "limit": true, "edit": true, "credit": true, "orbit": true,
|
||||
"total": true, "target": true, "budget": true, "market": true, "benefit": true, "focus": true,
|
||||
}
|
||||
|
||||
var irregularNouns = map[string]string{
|
||||
"child": "children", "person": "people", "man": "men", "woman": "women",
|
||||
"foot": "feet", "tooth": "teeth", "mouse": "mice", "goose": "geese",
|
||||
"ox": "oxen", "index": "indices", "appendix": "appendices", "matrix": "matrices",
|
||||
"vertex": "vertices", "crisis": "crises", "analysis": "analyses", "diagnosis": "diagnoses",
|
||||
"thesis": "theses", "hypothesis": "hypotheses", "parenthesis": "parentheses",
|
||||
"datum": "data", "medium": "media", "bacterium": "bacteria", "criterion": "criteria",
|
||||
"phenomenon": "phenomena", "curriculum": "curricula", "alumnus": "alumni",
|
||||
"cactus": "cacti", "focus": "foci", "fungus": "fungi", "nucleus": "nuclei",
|
||||
"radius": "radii", "stimulus": "stimuli", "syllabus": "syllabi",
|
||||
"fish": "fish", "sheep": "sheep", "deer": "deer", "species": "species",
|
||||
"series": "series", "aircraft": "aircraft",
|
||||
"life": "lives", "wife": "wives", "knife": "knives", "leaf": "leaves",
|
||||
"half": "halves", "self": "selves", "shelf": "shelves", "wolf": "wolves",
|
||||
"calf": "calves", "loaf": "loaves", "thief": "thieves",
|
||||
}
|
||||
|
||||
var vowelSounds = map[string]bool{
|
||||
"hour": true, "honest": true, "honour": true, "honor": true, "heir": true, "herb": true,
|
||||
}
|
||||
|
||||
var consonantSounds = map[string]bool{
|
||||
"user": true, "union": true, "unique": true, "unit": true, "universe": true,
|
||||
"university": true, "uniform": true, "usage": true, "usual": true, "utility": true,
|
||||
"utensil": true, "one": true, "once": true, "euro": true, "eulogy": true, "euphemism": true,
|
||||
}
|
||||
253
types_test.go
Normal file
253
types_test.go
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMessageForCategory(t *testing.T) {
|
||||
msg := Message{
|
||||
One: "1 item",
|
||||
Other: "{{.Count}} items",
|
||||
Zero: "no items",
|
||||
}
|
||||
|
||||
if got := msg.ForCategory(PluralOne); got != "1 item" {
|
||||
t.Errorf("ForCategory(One) = %q, want '1 item'", got)
|
||||
}
|
||||
if got := msg.ForCategory(PluralOther); got != "{{.Count}} items" {
|
||||
t.Errorf("ForCategory(Other) = %q, want template", got)
|
||||
}
|
||||
if got := msg.ForCategory(PluralZero); got != "no items" {
|
||||
t.Errorf("ForCategory(Zero) = %q, want 'no items'", got)
|
||||
}
|
||||
|
||||
// Falls back to Other when category not set
|
||||
msg2 := Message{Other: "many"}
|
||||
if got := msg2.ForCategory(PluralFew); got != "many" {
|
||||
t.Errorf("ForCategory(Few) fallback = %q, want 'many'", got)
|
||||
}
|
||||
|
||||
// Falls back to One if Other not set
|
||||
msg3 := Message{One: "single"}
|
||||
if got := msg3.ForCategory(PluralFew); got != "single" {
|
||||
t.Errorf("ForCategory(Few) fallback to One = %q, want 'single'", got)
|
||||
}
|
||||
|
||||
// Falls back to Text
|
||||
msg4 := Message{Text: "default"}
|
||||
if got := msg4.ForCategory(PluralOther); got != "default" {
|
||||
t.Errorf("ForCategory fallback to Text = %q, want 'default'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageIsPlural(t *testing.T) {
|
||||
if (Message{Text: "hello"}).IsPlural() {
|
||||
t.Error("simple message should not be plural")
|
||||
}
|
||||
if !(Message{One: "a", Other: "b"}).IsPlural() {
|
||||
t.Error("message with One+Other should be plural")
|
||||
}
|
||||
if !(Message{Zero: "none"}).IsPlural() {
|
||||
t.Error("message with Zero should be plural")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubjectFluent(t *testing.T) {
|
||||
s := S("file", "config.yaml")
|
||||
if s.Noun != "file" {
|
||||
t.Errorf("Noun = %q, want 'file'", s.Noun)
|
||||
}
|
||||
if s.String() != "config.yaml" {
|
||||
t.Errorf("String() = %q, want 'config.yaml'", s.String())
|
||||
}
|
||||
if s.CountInt() != 1 {
|
||||
t.Errorf("CountInt() = %d, want 1", s.CountInt())
|
||||
}
|
||||
if s.IsPlural() {
|
||||
t.Error("count=1 should not be plural")
|
||||
}
|
||||
|
||||
// Chain
|
||||
s.Count(3).Gender("neuter").In("workspace").Formal()
|
||||
if s.CountInt() != 3 {
|
||||
t.Errorf("CountInt() = %d, want 3", s.CountInt())
|
||||
}
|
||||
if !s.IsPlural() {
|
||||
t.Error("count=3 should be plural")
|
||||
}
|
||||
if s.GenderString() != "neuter" {
|
||||
t.Errorf("GenderString() = %q, want 'neuter'", s.GenderString())
|
||||
}
|
||||
if s.LocationString() != "workspace" {
|
||||
t.Errorf("LocationString() = %q, want 'workspace'", s.LocationString())
|
||||
}
|
||||
if !s.IsFormal() {
|
||||
t.Error("should be formal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubjectNil(t *testing.T) {
|
||||
var s *Subject
|
||||
if s.Count(3) != nil {
|
||||
t.Error("nil.Count() should return nil")
|
||||
}
|
||||
if s.Gender("m") != nil {
|
||||
t.Error("nil.Gender() should return nil")
|
||||
}
|
||||
if s.In("x") != nil {
|
||||
t.Error("nil.In() should return nil")
|
||||
}
|
||||
if s.Formal() != nil {
|
||||
t.Error("nil.Formal() should return nil")
|
||||
}
|
||||
if s.Informal() != nil {
|
||||
t.Error("nil.Informal() should return nil")
|
||||
}
|
||||
if s.String() != "" {
|
||||
t.Error("nil.String() should be empty")
|
||||
}
|
||||
if s.IsPlural() {
|
||||
t.Error("nil.IsPlural() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslationContext(t *testing.T) {
|
||||
c := C("navigation")
|
||||
if c.ContextString() != "navigation" {
|
||||
t.Errorf("Context = %q, want 'navigation'", c.ContextString())
|
||||
}
|
||||
|
||||
c.WithGender("masculine").Formal()
|
||||
if c.GenderString() != "masculine" {
|
||||
t.Errorf("Gender = %q, want 'masculine'", c.GenderString())
|
||||
}
|
||||
if c.FormalityValue() != FormalityFormal {
|
||||
t.Error("should be formal")
|
||||
}
|
||||
|
||||
c.Set("key", "value")
|
||||
if c.Get("key") != "value" {
|
||||
t.Errorf("Get(key) = %v, want 'value'", c.Get("key"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslationContextNil(t *testing.T) {
|
||||
var c *TranslationContext
|
||||
if c.WithGender("m") != nil {
|
||||
t.Error("nil.WithGender() should return nil")
|
||||
}
|
||||
if c.Formal() != nil {
|
||||
t.Error("nil.Formal() should return nil")
|
||||
}
|
||||
if c.Informal() != nil {
|
||||
t.Error("nil.Informal() should return nil")
|
||||
}
|
||||
if c.WithFormality(FormalityFormal) != nil {
|
||||
t.Error("nil.WithFormality() should return nil")
|
||||
}
|
||||
if c.Set("k", "v") != nil {
|
||||
t.Error("nil.Set() should return nil")
|
||||
}
|
||||
if c.Get("k") != nil {
|
||||
t.Error("nil.Get() should return nil")
|
||||
}
|
||||
if c.ContextString() != "" {
|
||||
t.Error("nil.ContextString() should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeString(t *testing.T) {
|
||||
tests := []struct {
|
||||
m Mode
|
||||
want string
|
||||
}{
|
||||
{ModeNormal, "normal"},
|
||||
{ModeStrict, "strict"},
|
||||
{ModeCollect, "collect"},
|
||||
{Mode(99), "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.m.String(); got != tt.want {
|
||||
t.Errorf("Mode(%d).String() = %q, want %q", tt.m, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormalityString(t *testing.T) {
|
||||
tests := []struct {
|
||||
f Formality
|
||||
want string
|
||||
}{
|
||||
{FormalityNeutral, "neutral"},
|
||||
{FormalityInformal, "informal"},
|
||||
{FormalityFormal, "formal"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.f.String(); got != tt.want {
|
||||
t.Errorf("Formality(%d).String() = %q, want %q", tt.f, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextDirectionString(t *testing.T) {
|
||||
if DirLTR.String() != "ltr" {
|
||||
t.Errorf("DirLTR.String() = %q", DirLTR.String())
|
||||
}
|
||||
if DirRTL.String() != "rtl" {
|
||||
t.Errorf("DirRTL.String() = %q", DirRTL.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralCategoryString(t *testing.T) {
|
||||
tests := []struct {
|
||||
p PluralCategory
|
||||
want string
|
||||
}{
|
||||
{PluralOther, "other"},
|
||||
{PluralZero, "zero"},
|
||||
{PluralOne, "one"},
|
||||
{PluralTwo, "two"},
|
||||
{PluralFew, "few"},
|
||||
{PluralMany, "many"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.p.String(); got != tt.want {
|
||||
t.Errorf("PluralCategory(%d).String() = %q, want %q", tt.p, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrammaticalGenderString(t *testing.T) {
|
||||
tests := []struct {
|
||||
g GrammaticalGender
|
||||
want string
|
||||
}{
|
||||
{GenderNeuter, "neuter"},
|
||||
{GenderMasculine, "masculine"},
|
||||
{GenderFeminine, "feminine"},
|
||||
{GenderCommon, "common"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.g.String(); got != tt.want {
|
||||
t.Errorf("GrammaticalGender(%d).String() = %q, want %q", tt.g, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRTLLanguage(t *testing.T) {
|
||||
tests := []struct {
|
||||
lang string
|
||||
want bool
|
||||
}{
|
||||
{"en", false},
|
||||
{"de", false},
|
||||
{"ar", true},
|
||||
{"ar-SA", true},
|
||||
{"he", true},
|
||||
{"fa", true},
|
||||
{"ur-PK", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsRTLLanguage(tt.lang); got != tt.want {
|
||||
t.Errorf("IsRTLLanguage(%q) = %v, want %v", tt.lang, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue