commit e8a87b0f50c7a770865accec2b3b0255bbb26e99 Author: Claude Date: Mon Feb 16 19:51:27 2026 +0000 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 diff --git a/compose.go b/compose.go new file mode 100644 index 0000000..5e53dd0 --- /dev/null +++ b/compose.go @@ -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, + } +} diff --git a/context.go b/context.go new file mode 100644 index 0000000..7c53b5e --- /dev/null +++ b/context.go @@ -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 +} diff --git a/debug.go b/debug.go new file mode 100644 index 0000000..e97aa98 --- /dev/null +++ b/debug.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..24d1f5c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module forge.lthn.ai/core/go-i18n + +go 1.25.5 + +require golang.org/x/text v0.33.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..62a1313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/grammar.go b/grammar.go new file mode 100644 index 0000000..9968a63 --- /dev/null +++ b/grammar.go @@ -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 +} diff --git a/grammar_test.go b/grammar_test.go new file mode 100644 index 0000000..6a62526 --- /dev/null +++ b/grammar_test.go @@ -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) + } + } +} diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..962d3f2 --- /dev/null +++ b/handler.go @@ -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{} +) diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..3a954fa --- /dev/null +++ b/handler_test.go @@ -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)) + } +} diff --git a/hooks.go b/hooks.go new file mode 100644 index 0000000..9fcf03e --- /dev/null +++ b/hooks.go @@ -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}) +} diff --git a/i18n.go b/i18n.go new file mode 100644 index 0000000..87de625 --- /dev/null +++ b/i18n.go @@ -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() +} diff --git a/language.go b/language.go new file mode 100644 index 0000000..7e85f08 --- /dev/null +++ b/language.go @@ -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 } diff --git a/language_test.go b/language_test.go new file mode 100644 index 0000000..dec8ed6 --- /dev/null +++ b/language_test.go @@ -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") + } +} diff --git a/loader.go b/loader.go new file mode 100644 index 0000000..1173fef --- /dev/null +++ b/loader.go @@ -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 +} diff --git a/loader_test.go b/loader_test.go new file mode 100644 index 0000000..e3fcfae --- /dev/null +++ b/loader_test.go @@ -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") + } +} diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..7739beb --- /dev/null +++ b/locales/en.json @@ -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" + } +} diff --git a/localise.go b/localise.go new file mode 100644 index 0000000..b59da6a --- /dev/null +++ b/localise.go @@ -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 "" +} diff --git a/numbers.go b/numbers.go new file mode 100644 index 0000000..49468f2 --- /dev/null +++ b/numbers.go @@ -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() +} diff --git a/numbers_test.go b/numbers_test.go new file mode 100644 index 0000000..e86f86a --- /dev/null +++ b/numbers_test.go @@ -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) + } + } +} diff --git a/service.go b/service.go new file mode 100644 index 0000000..45226f6 --- /dev/null +++ b/service.go @@ -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 +} diff --git a/service_test.go b/service_test.go new file mode 100644 index 0000000..a3af27c --- /dev/null +++ b/service_test.go @@ -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)) + } +} diff --git a/time.go b/time.go new file mode 100644 index 0000000..241b846 --- /dev/null +++ b/time.go @@ -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 +} diff --git a/transform.go b/transform.go new file mode 100644 index 0000000..2552ddb --- /dev/null +++ b/transform.go @@ -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 +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..1f5ba9a --- /dev/null +++ b/types.go @@ -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, +} diff --git a/types_test.go b/types_test.go new file mode 100644 index 0000000..6f41853 --- /dev/null +++ b/types_test.go @@ -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) + } + } +}