feat: grammar-aware i18n module extracted from core

Standalone grammar-aware translation engine with:
- 3-tier verb/noun fallback (JSON locale → irregular maps → regular rules)
- 6 built-in i18n.* namespace handlers (label, progress, count, done, fail, numeric)
- Nested en.json with gram/prompt/time/lang sections (no flat command keys)
- CLDR plural rules for 10 languages
- Subject fluent API, number/time formatting, RTL detection
- 55 tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-16 19:51:27 +00:00
commit e8a87b0f50
No known key found for this signature in database
GPG key ID: AF404715446AEB41
25 changed files with 4337 additions and 0 deletions

101
compose.go Normal file
View file

@ -0,0 +1,101 @@
package i18n
import "fmt"
// S creates a new Subject with the given noun and value.
//
// S("file", "config.yaml")
// S("file", path).Count(3).In("workspace")
func S(noun string, value any) *Subject {
return &Subject{Noun: noun, Value: value, count: 1}
}
func (s *Subject) Count(n int) *Subject {
if s == nil {
return nil
}
s.count = n
return s
}
func (s *Subject) Gender(g string) *Subject {
if s == nil {
return nil
}
s.gender = g
return s
}
func (s *Subject) In(location string) *Subject {
if s == nil {
return nil
}
s.location = location
return s
}
func (s *Subject) Formal() *Subject {
if s == nil {
return nil
}
s.formality = FormalityFormal
return s
}
func (s *Subject) Informal() *Subject {
if s == nil {
return nil
}
s.formality = FormalityInformal
return s
}
func (s *Subject) SetFormality(f Formality) *Subject {
if s == nil {
return nil
}
s.formality = f
return s
}
func (s *Subject) String() string {
if s == nil {
return ""
}
if stringer, ok := s.Value.(fmt.Stringer); ok {
return stringer.String()
}
return fmt.Sprint(s.Value)
}
func (s *Subject) IsPlural() bool { return s != nil && s.count != 1 }
func (s *Subject) CountInt() int { if s == nil { return 1 }; return s.count }
func (s *Subject) CountString() string { if s == nil { return "1" }; return fmt.Sprint(s.count) }
func (s *Subject) GenderString() string { if s == nil { return "" }; return s.gender }
func (s *Subject) LocationString() string { if s == nil { return "" }; return s.location }
func (s *Subject) NounString() string { if s == nil { return "" }; return s.Noun }
func (s *Subject) FormalityString() string {
if s == nil {
return FormalityNeutral.String()
}
return s.formality.String()
}
func (s *Subject) IsFormal() bool { return s != nil && s.formality == FormalityFormal }
func (s *Subject) IsInformal() bool { return s != nil && s.formality == FormalityInformal }
func newTemplateData(s *Subject) templateData {
if s == nil {
return templateData{Count: 1}
}
return templateData{
Subject: s.String(),
Noun: s.Noun,
Count: s.count,
Gender: s.gender,
Location: s.location,
Formality: s.formality,
IsFormal: s.formality == FormalityFormal,
IsPlural: s.count != 1,
Value: s.Value,
}
}

70
context.go Normal file
View file

@ -0,0 +1,70 @@
package i18n
// TranslationContext provides disambiguation for translations.
//
// T("direction.right", C("navigation")) // "rechts" (German)
// T("status.right", C("correctness")) // "richtig" (German)
type TranslationContext struct {
Context string
Gender string
Formality Formality
Extra map[string]any
}
// C creates a TranslationContext.
func C(context string) *TranslationContext {
return &TranslationContext{Context: context}
}
func (c *TranslationContext) WithGender(gender string) *TranslationContext {
if c == nil { return nil }
c.Gender = gender
return c
}
func (c *TranslationContext) Formal() *TranslationContext {
if c == nil { return nil }
c.Formality = FormalityFormal
return c
}
func (c *TranslationContext) Informal() *TranslationContext {
if c == nil { return nil }
c.Formality = FormalityInformal
return c
}
func (c *TranslationContext) WithFormality(f Formality) *TranslationContext {
if c == nil { return nil }
c.Formality = f
return c
}
func (c *TranslationContext) Set(key string, value any) *TranslationContext {
if c == nil { return nil }
if c.Extra == nil {
c.Extra = make(map[string]any)
}
c.Extra[key] = value
return c
}
func (c *TranslationContext) Get(key string) any {
if c == nil || c.Extra == nil { return nil }
return c.Extra[key]
}
func (c *TranslationContext) ContextString() string {
if c == nil { return "" }
return c.Context
}
func (c *TranslationContext) GenderString() string {
if c == nil { return "" }
return c.Gender
}
func (c *TranslationContext) FormalityValue() Formality {
if c == nil { return FormalityNeutral }
return c.Formality
}

24
debug.go Normal file
View file

@ -0,0 +1,24 @@
package i18n
// SetDebug enables or disables debug mode on the default service.
func SetDebug(enabled bool) {
if svc := Default(); svc != nil {
svc.SetDebug(enabled)
}
}
func (s *Service) SetDebug(enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.debug = enabled
}
func (s *Service) Debug() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.debug
}
func debugFormat(key, text string) string {
return "[" + key + "] " + text
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module forge.lthn.ai/core/go-i18n
go 1.25.5
require golang.org/x/text v0.33.0

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=

391
grammar.go Normal file
View file

@ -0,0 +1,391 @@
package i18n
import (
"strings"
"text/template"
"unicode"
)
// GetGrammarData returns the grammar data for the specified language.
func GetGrammarData(lang string) *GrammarData {
grammarCacheMu.RLock()
defer grammarCacheMu.RUnlock()
return grammarCache[lang]
}
// SetGrammarData sets the grammar data for a language.
func SetGrammarData(lang string, data *GrammarData) {
grammarCacheMu.Lock()
defer grammarCacheMu.Unlock()
grammarCache[lang] = data
}
func getVerbForm(lang, verb, form string) string {
data := GetGrammarData(lang)
if data == nil || data.Verbs == nil {
return ""
}
verb = strings.ToLower(verb)
if forms, ok := data.Verbs[verb]; ok {
switch form {
case "past":
return forms.Past
case "gerund":
return forms.Gerund
}
}
return ""
}
func getWord(lang, word string) string {
data := GetGrammarData(lang)
if data == nil || data.Words == nil {
return ""
}
return data.Words[strings.ToLower(word)]
}
func getPunct(lang, rule, defaultVal string) string {
data := GetGrammarData(lang)
if data == nil {
return defaultVal
}
switch rule {
case "label":
if data.Punct.LabelSuffix != "" {
return data.Punct.LabelSuffix
}
case "progress":
if data.Punct.ProgressSuffix != "" {
return data.Punct.ProgressSuffix
}
}
return defaultVal
}
func getNounForm(lang, noun, form string) string {
data := GetGrammarData(lang)
if data == nil || data.Nouns == nil {
return ""
}
noun = strings.ToLower(noun)
if forms, ok := data.Nouns[noun]; ok {
switch form {
case "one":
return forms.One
case "other":
return forms.Other
case "gender":
return forms.Gender
}
}
return ""
}
func currentLangForGrammar() string {
if svc := Default(); svc != nil {
return svc.Language()
}
return "en"
}
// PastTense returns the past tense of a verb.
// 3-tier fallback: JSON locale -> irregular verbs -> regular rules.
//
// PastTense("delete") // "deleted"
// PastTense("run") // "ran"
// PastTense("copy") // "copied"
func PastTense(verb string) string {
verb = strings.ToLower(strings.TrimSpace(verb))
if verb == "" {
return ""
}
if form := getVerbForm(currentLangForGrammar(), verb, "past"); form != "" {
return form
}
if forms, ok := irregularVerbs[verb]; ok {
return forms.Past
}
return applyRegularPastTense(verb)
}
func applyRegularPastTense(verb string) string {
if strings.HasSuffix(verb, "ed") && len(verb) > 2 {
thirdFromEnd := verb[len(verb)-3]
if !isVowel(rune(thirdFromEnd)) && thirdFromEnd != 'e' {
return verb
}
}
if strings.HasSuffix(verb, "e") {
return verb + "d"
}
if strings.HasSuffix(verb, "y") && len(verb) > 1 {
prev := rune(verb[len(verb)-2])
if !isVowel(prev) {
return verb[:len(verb)-1] + "ied"
}
}
if len(verb) >= 2 && shouldDoubleConsonant(verb) {
return verb + string(verb[len(verb)-1]) + "ed"
}
return verb + "ed"
}
func shouldDoubleConsonant(verb string) bool {
if len(verb) < 3 {
return false
}
if noDoubleConsonant[verb] {
return false
}
lastChar := rune(verb[len(verb)-1])
secondLast := rune(verb[len(verb)-2])
if isVowel(lastChar) || lastChar == 'w' || lastChar == 'x' || lastChar == 'y' {
return false
}
if !isVowel(secondLast) {
return false
}
if len(verb) <= 4 {
thirdLast := rune(verb[len(verb)-3])
return !isVowel(thirdLast)
}
return false
}
// Gerund returns the present participle (-ing form) of a verb.
//
// Gerund("delete") // "deleting"
// Gerund("run") // "running"
// Gerund("die") // "dying"
func Gerund(verb string) string {
verb = strings.ToLower(strings.TrimSpace(verb))
if verb == "" {
return ""
}
if form := getVerbForm(currentLangForGrammar(), verb, "gerund"); form != "" {
return form
}
if forms, ok := irregularVerbs[verb]; ok {
return forms.Gerund
}
return applyRegularGerund(verb)
}
func applyRegularGerund(verb string) string {
if strings.HasSuffix(verb, "ie") {
return verb[:len(verb)-2] + "ying"
}
if strings.HasSuffix(verb, "e") && len(verb) > 1 {
secondLast := rune(verb[len(verb)-2])
if secondLast != 'e' && secondLast != 'y' && secondLast != 'o' {
return verb[:len(verb)-1] + "ing"
}
}
if shouldDoubleConsonant(verb) {
return verb + string(verb[len(verb)-1]) + "ing"
}
return verb + "ing"
}
// Pluralize returns the plural form of a noun based on count.
//
// Pluralize("file", 1) // "file"
// Pluralize("file", 5) // "files"
// Pluralize("child", 3) // "children"
func Pluralize(noun string, count int) string {
if count == 1 {
return noun
}
return PluralForm(noun)
}
// PluralForm returns the plural form of a noun.
func PluralForm(noun string) string {
noun = strings.TrimSpace(noun)
if noun == "" {
return ""
}
lower := strings.ToLower(noun)
if form := getNounForm(currentLangForGrammar(), lower, "other"); form != "" {
if unicode.IsUpper(rune(noun[0])) && len(form) > 0 {
return strings.ToUpper(string(form[0])) + form[1:]
}
return form
}
if plural, ok := irregularNouns[lower]; ok {
if unicode.IsUpper(rune(noun[0])) {
return strings.ToUpper(string(plural[0])) + plural[1:]
}
return plural
}
return applyRegularPlural(noun)
}
func applyRegularPlural(noun string) string {
lower := strings.ToLower(noun)
if strings.HasSuffix(lower, "s") ||
strings.HasSuffix(lower, "ss") ||
strings.HasSuffix(lower, "sh") ||
strings.HasSuffix(lower, "ch") ||
strings.HasSuffix(lower, "x") ||
strings.HasSuffix(lower, "z") {
return noun + "es"
}
if strings.HasSuffix(lower, "y") && len(noun) > 1 {
prev := rune(lower[len(lower)-2])
if !isVowel(prev) {
return noun[:len(noun)-1] + "ies"
}
}
if strings.HasSuffix(lower, "f") {
return noun[:len(noun)-1] + "ves"
}
if strings.HasSuffix(lower, "fe") {
return noun[:len(noun)-2] + "ves"
}
if strings.HasSuffix(lower, "o") && len(noun) > 1 {
prev := rune(lower[len(lower)-2])
if !isVowel(prev) {
if lower == "hero" || lower == "potato" || lower == "tomato" || lower == "echo" || lower == "veto" {
return noun + "es"
}
}
}
return noun + "s"
}
// Article returns the appropriate indefinite article ("a" or "an").
//
// Article("file") // "a"
// Article("error") // "an"
// Article("user") // "a" (sounds like "yoo-zer")
// Article("hour") // "an" (silent h)
func Article(word string) string {
if word == "" {
return ""
}
lower := strings.ToLower(strings.TrimSpace(word))
for key := range consonantSounds {
if strings.HasPrefix(lower, key) {
return "a"
}
}
for key := range vowelSounds {
if strings.HasPrefix(lower, key) {
return "an"
}
}
if len(lower) > 0 && isVowel(rune(lower[0])) {
return "an"
}
return "a"
}
func isVowel(r rune) bool {
switch unicode.ToLower(r) {
case 'a', 'e', 'i', 'o', 'u':
return true
}
return false
}
// Title capitalises the first letter of each word.
func Title(s string) string {
var b strings.Builder
b.Grow(len(s))
prev := ' '
for _, r := range s {
if !unicode.IsLetter(prev) && unicode.IsLetter(r) {
b.WriteRune(unicode.ToUpper(r))
} else {
b.WriteRune(r)
}
prev = r
}
return b.String()
}
// Quote wraps a string in double quotes.
func Quote(s string) string {
return `"` + s + `"`
}
// TemplateFuncs returns the template.FuncMap with all grammar functions.
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"title": Title,
"lower": strings.ToLower,
"upper": strings.ToUpper,
"past": PastTense,
"gerund": Gerund,
"plural": Pluralize,
"pluralForm": PluralForm,
"article": Article,
"quote": Quote,
}
}
// Progress returns a progress message: "Building..."
func Progress(verb string) string {
lang := currentLangForGrammar()
word := getWord(lang, verb)
if word == "" {
word = verb
}
g := Gerund(word)
if g == "" {
return ""
}
suffix := getPunct(lang, "progress", "...")
return Title(g) + suffix
}
// ProgressSubject returns a progress message with subject: "Building project..."
func ProgressSubject(verb, subject string) string {
lang := currentLangForGrammar()
word := getWord(lang, verb)
if word == "" {
word = verb
}
g := Gerund(word)
if g == "" {
return ""
}
suffix := getPunct(lang, "progress", "...")
return Title(g) + " " + subject + suffix
}
// ActionResult returns a completion message: "File deleted"
func ActionResult(verb, subject string) string {
p := PastTense(verb)
if p == "" || subject == "" {
return ""
}
return Title(subject) + " " + p
}
// ActionFailed returns a failure message: "Failed to delete file"
func ActionFailed(verb, subject string) string {
if verb == "" {
return ""
}
if subject == "" {
return "Failed to " + verb
}
return "Failed to " + verb + " " + subject
}
// Label returns a label with suffix: "Status:" (EN) or "Statut :" (FR)
func Label(word string) string {
if word == "" {
return ""
}
lang := currentLangForGrammar()
translated := getWord(lang, word)
if translated == "" {
translated = word
}
suffix := getPunct(lang, "label", ":")
return Title(translated) + suffix
}

357
grammar_test.go Normal file
View file

@ -0,0 +1,357 @@
package i18n
import "testing"
func TestPastTense(t *testing.T) {
// Ensure grammar data is loaded from embedded JSON
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
verb string
want string
}{
// Irregular verbs (from JSON)
{"be", "was"},
{"go", "went"},
{"run", "ran"},
{"write", "wrote"},
{"build", "built"},
{"find", "found"},
{"set", "set"},
{"put", "put"},
{"cut", "cut"},
{"commit", "committed"},
// Irregular verbs (from Go map only)
{"break", "broke"},
{"speak", "spoke"},
{"steal", "stole"},
{"freeze", "froze"},
// Regular verbs
{"delete", "deleted"},
{"update", "updated"},
{"push", "pushed"},
{"pull", "pulled"},
{"start", "started"},
{"copy", "copied"},
{"apply", "applied"},
// Edge cases
{"", ""},
{" delete ", "deleted"},
{"DELETE", "deleted"},
}
for _, tt := range tests {
t.Run(tt.verb, func(t *testing.T) {
got := PastTense(tt.verb)
if got != tt.want {
t.Errorf("PastTense(%q) = %q, want %q", tt.verb, got, tt.want)
}
})
}
}
func TestGerund(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
verb string
want string
}{
// Irregular verbs (from JSON)
{"be", "being"},
{"go", "going"},
{"run", "running"},
{"build", "building"},
{"write", "writing"},
{"commit", "committing"},
// Regular verbs
{"delete", "deleting"},
{"push", "pushing"},
{"pull", "pulling"},
{"start", "starting"},
{"die", "dying"},
// Edge cases
{"", ""},
}
for _, tt := range tests {
t.Run(tt.verb, func(t *testing.T) {
got := Gerund(tt.verb)
if got != tt.want {
t.Errorf("Gerund(%q) = %q, want %q", tt.verb, got, tt.want)
}
})
}
}
func TestPluralize(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
noun string
count int
want string
}{
// Singular (count=1 always returns original)
{"file", 1, "file"},
{"child", 1, "child"},
// From JSON grammar data
{"file", 5, "files"},
{"repo", 3, "repos"},
{"branch", 2, "branches"},
{"repository", 2, "repositories"},
{"vulnerability", 2, "vulnerabilities"},
{"person", 2, "people"},
{"child", 3, "children"},
// From irregular nouns map
{"mouse", 2, "mice"},
{"sheep", 5, "sheep"},
{"knife", 3, "knives"},
// Regular plurals
{"server", 2, "servers"},
{"box", 2, "boxes"},
// Count 0
{"file", 0, "files"},
}
for _, tt := range tests {
t.Run(tt.noun, func(t *testing.T) {
got := Pluralize(tt.noun, tt.count)
if got != tt.want {
t.Errorf("Pluralize(%q, %d) = %q, want %q", tt.noun, tt.count, got, tt.want)
}
})
}
}
func TestPluralForm(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
noun string
want string
}{
{"", ""},
// Capitalisation preserved
{"File", "Files"},
{"Child", "Children"},
// Regular rules
{"bus", "buses"},
{"fox", "foxes"},
{"city", "cities"},
{"key", "keys"},
}
for _, tt := range tests {
t.Run(tt.noun, func(t *testing.T) {
got := PluralForm(tt.noun)
if got != tt.want {
t.Errorf("PluralForm(%q) = %q, want %q", tt.noun, got, tt.want)
}
})
}
}
func TestArticle(t *testing.T) {
tests := []struct {
word string
want string
}{
{"file", "a"},
{"error", "an"},
{"apple", "an"},
{"user", "a"}, // Consonant sound: "yoo-zer"
{"hour", "an"}, // Vowel sound: silent h
{"honest", "an"}, // Vowel sound
{"university", "a"}, // Consonant sound
{"one", "a"}, // Consonant sound
{"", ""},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
got := Article(tt.word)
if got != tt.want {
t.Errorf("Article(%q) = %q, want %q", tt.word, got, tt.want)
}
})
}
}
func TestTitle(t *testing.T) {
tests := []struct {
input string
want string
}{
{"hello world", "Hello World"},
{"hello", "Hello"},
{"", ""},
{"HELLO", "HELLO"},
{"hello-world", "Hello-World"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := Title(tt.input)
if got != tt.want {
t.Errorf("Title(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestQuote(t *testing.T) {
if got := Quote("hello"); got != `"hello"` {
t.Errorf("Quote(%q) = %q, want %q", "hello", got, `"hello"`)
}
}
func TestLabel(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
word string
want string
}{
{"status", "Status:"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
got := Label(tt.word)
if got != tt.want {
t.Errorf("Label(%q) = %q, want %q", tt.word, got, tt.want)
}
})
}
}
func TestProgress(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
verb string
want string
}{
{"build", "Building..."},
{"delete", "Deleting..."},
{"scan", "Scanning..."},
}
for _, tt := range tests {
t.Run(tt.verb, func(t *testing.T) {
got := Progress(tt.verb)
if got != tt.want {
t.Errorf("Progress(%q) = %q, want %q", tt.verb, got, tt.want)
}
})
}
}
func TestProgressSubject(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
got := ProgressSubject("build", "project")
want := "Building project..."
if got != want {
t.Errorf("ProgressSubject(%q, %q) = %q, want %q", "build", "project", got, want)
}
}
func TestActionResult(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
verb, subject string
want string
}{
{"delete", "config.yaml", "Config.Yaml deleted"},
{"build", "project", "Project built"},
{"", "file", ""},
{"delete", "", ""},
}
for _, tt := range tests {
t.Run(tt.verb+"_"+tt.subject, func(t *testing.T) {
got := ActionResult(tt.verb, tt.subject)
if got != tt.want {
t.Errorf("ActionResult(%q, %q) = %q, want %q", tt.verb, tt.subject, got, tt.want)
}
})
}
}
func TestActionFailed(t *testing.T) {
tests := []struct {
verb, subject string
want string
}{
{"delete", "config.yaml", "Failed to delete config.yaml"},
{"push", "commits", "Failed to push commits"},
{"push", "", "Failed to push"},
{"", "", ""},
}
for _, tt := range tests {
t.Run(tt.verb+"_"+tt.subject, func(t *testing.T) {
got := ActionFailed(tt.verb, tt.subject)
if got != tt.want {
t.Errorf("ActionFailed(%q, %q) = %q, want %q", tt.verb, tt.subject, got, tt.want)
}
})
}
}
func TestTemplateFuncs(t *testing.T) {
funcs := TemplateFuncs()
expected := []string{"title", "lower", "upper", "past", "gerund", "plural", "pluralForm", "article", "quote"}
for _, name := range expected {
if _, ok := funcs[name]; !ok {
t.Errorf("TemplateFuncs() missing %q", name)
}
}
}

156
handler.go Normal file
View file

@ -0,0 +1,156 @@
package i18n
import (
"fmt"
"strings"
)
// LabelHandler handles i18n.label.{word} -> "Status:" patterns.
type LabelHandler struct{}
func (h LabelHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.label.")
}
func (h LabelHandler) Handle(key string, args []any, next func() string) string {
word := strings.TrimPrefix(key, "i18n.label.")
return Label(word)
}
// ProgressHandler handles i18n.progress.{verb} -> "Building..." patterns.
type ProgressHandler struct{}
func (h ProgressHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.progress.")
}
func (h ProgressHandler) Handle(key string, args []any, next func() string) string {
verb := strings.TrimPrefix(key, "i18n.progress.")
if len(args) > 0 {
if subj, ok := args[0].(string); ok {
return ProgressSubject(verb, subj)
}
}
return Progress(verb)
}
// CountHandler handles i18n.count.{noun} -> "5 files" patterns.
type CountHandler struct{}
func (h CountHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.count.")
}
func (h CountHandler) Handle(key string, args []any, next func() string) string {
noun := strings.TrimPrefix(key, "i18n.count.")
if len(args) > 0 {
count := toInt(args[0])
return fmt.Sprintf("%d %s", count, Pluralize(noun, count))
}
return noun
}
// DoneHandler handles i18n.done.{verb} -> "File deleted" patterns.
type DoneHandler struct{}
func (h DoneHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.done.")
}
func (h DoneHandler) Handle(key string, args []any, next func() string) string {
verb := strings.TrimPrefix(key, "i18n.done.")
if len(args) > 0 {
if subj, ok := args[0].(string); ok {
return ActionResult(verb, subj)
}
}
return Title(PastTense(verb))
}
// FailHandler handles i18n.fail.{verb} -> "Failed to delete file" patterns.
type FailHandler struct{}
func (h FailHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.fail.")
}
func (h FailHandler) Handle(key string, args []any, next func() string) string {
verb := strings.TrimPrefix(key, "i18n.fail.")
if len(args) > 0 {
if subj, ok := args[0].(string); ok {
return ActionFailed(verb, subj)
}
}
return ActionFailed(verb, "")
}
// NumericHandler handles i18n.numeric.{format} -> formatted numbers.
type NumericHandler struct{}
func (h NumericHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.numeric.")
}
func (h NumericHandler) Handle(key string, args []any, next func() string) string {
if len(args) == 0 {
return next()
}
format := strings.TrimPrefix(key, "i18n.numeric.")
switch format {
case "number", "int":
return FormatNumber(toInt64(args[0]))
case "decimal", "float":
return FormatDecimal(toFloat64(args[0]))
case "percent", "pct":
return FormatPercent(toFloat64(args[0]))
case "bytes", "size":
return FormatBytes(toInt64(args[0]))
case "ordinal", "ord":
return FormatOrdinal(toInt(args[0]))
case "ago":
if len(args) >= 2 {
if unit, ok := args[1].(string); ok {
return FormatAgo(toInt(args[0]), unit)
}
}
}
return next()
}
// DefaultHandlers returns the built-in i18n.* namespace handlers.
func DefaultHandlers() []KeyHandler {
return []KeyHandler{
LabelHandler{},
ProgressHandler{},
CountHandler{},
DoneHandler{},
FailHandler{},
NumericHandler{},
}
}
// RunHandlerChain executes a chain of handlers for a key.
func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback func() string) string {
for i, h := range handlers {
if h.Match(key) {
next := func() string {
remaining := handlers[i+1:]
if len(remaining) > 0 {
return RunHandlerChain(remaining, key, args, fallback)
}
return fallback()
}
return h.Handle(key, args, next)
}
}
return fallback()
}
var (
_ KeyHandler = LabelHandler{}
_ KeyHandler = ProgressHandler{}
_ KeyHandler = CountHandler{}
_ KeyHandler = DoneHandler{}
_ KeyHandler = FailHandler{}
_ KeyHandler = NumericHandler{}
)

193
handler_test.go Normal file
View file

@ -0,0 +1,193 @@
package i18n
import "testing"
func TestLabelHandler(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
h := LabelHandler{}
if !h.Match("i18n.label.status") {
t.Error("LabelHandler should match i18n.label.*")
}
if h.Match("other.key") {
t.Error("LabelHandler should not match other.key")
}
got := h.Handle("i18n.label.status", nil, nil)
if got != "Status:" {
t.Errorf("LabelHandler.Handle(status) = %q, want %q", got, "Status:")
}
}
func TestProgressHandler(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
h := ProgressHandler{}
if !h.Match("i18n.progress.build") {
t.Error("ProgressHandler should match i18n.progress.*")
}
// Without subject
got := h.Handle("i18n.progress.build", nil, nil)
if got != "Building..." {
t.Errorf("ProgressHandler.Handle(build) = %q, want %q", got, "Building...")
}
// With subject
got = h.Handle("i18n.progress.build", []any{"project"}, nil)
if got != "Building project..." {
t.Errorf("ProgressHandler.Handle(build, project) = %q, want %q", got, "Building project...")
}
}
func TestCountHandler(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
h := CountHandler{}
if !h.Match("i18n.count.file") {
t.Error("CountHandler should match i18n.count.*")
}
tests := []struct {
key string
args []any
want string
}{
{"i18n.count.file", []any{1}, "1 file"},
{"i18n.count.file", []any{5}, "5 files"},
{"i18n.count.file", []any{0}, "0 files"},
{"i18n.count.child", []any{3}, "3 children"},
{"i18n.count.file", nil, "file"},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
got := h.Handle(tt.key, tt.args, nil)
if got != tt.want {
t.Errorf("CountHandler.Handle(%q, %v) = %q, want %q", tt.key, tt.args, got, tt.want)
}
})
}
}
func TestDoneHandler(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
h := DoneHandler{}
if !h.Match("i18n.done.delete") {
t.Error("DoneHandler should match i18n.done.*")
}
// With subject
got := h.Handle("i18n.done.delete", []any{"config.yaml"}, nil)
if got != "Config.Yaml deleted" {
t.Errorf("DoneHandler.Handle(delete, config.yaml) = %q, want %q", got, "Config.Yaml deleted")
}
// Without subject — just past tense
got = h.Handle("i18n.done.delete", nil, nil)
if got != "Deleted" {
t.Errorf("DoneHandler.Handle(delete) = %q, want %q", got, "Deleted")
}
}
func TestFailHandler(t *testing.T) {
h := FailHandler{}
if !h.Match("i18n.fail.push") {
t.Error("FailHandler should match i18n.fail.*")
}
got := h.Handle("i18n.fail.push", []any{"commits"}, nil)
if got != "Failed to push commits" {
t.Errorf("FailHandler.Handle(push, commits) = %q, want %q", got, "Failed to push commits")
}
got = h.Handle("i18n.fail.push", nil, nil)
if got != "Failed to push" {
t.Errorf("FailHandler.Handle(push) = %q, want %q", got, "Failed to push")
}
}
func TestNumericHandler(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
h := NumericHandler{}
tests := []struct {
key string
args []any
want string
}{
{"i18n.numeric.number", []any{int64(1234567)}, "1,234,567"},
{"i18n.numeric.ordinal", []any{1}, "1st"},
{"i18n.numeric.ordinal", []any{2}, "2nd"},
{"i18n.numeric.ordinal", []any{3}, "3rd"},
{"i18n.numeric.ordinal", []any{11}, "11th"},
{"i18n.numeric.percent", []any{0.85}, "85%"},
{"i18n.numeric.bytes", []any{int64(1536000)}, "1.5 MB"},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
got := h.Handle(tt.key, tt.args, func() string { return "fallback" })
if got != tt.want {
t.Errorf("NumericHandler.Handle(%q, %v) = %q, want %q", tt.key, tt.args, got, tt.want)
}
})
}
// No args falls through to next
got := h.Handle("i18n.numeric.number", nil, func() string { return "fallback" })
if got != "fallback" {
t.Errorf("NumericHandler with no args should fallback, got %q", got)
}
}
func TestRunHandlerChain(t *testing.T) {
handlers := DefaultHandlers()
fallback := func() string { return "missed" }
// Label handler catches it
got := RunHandlerChain(handlers, "i18n.label.status", nil, fallback)
if got != "Status:" {
t.Errorf("chain label = %q, want %q", got, "Status:")
}
// Non-matching key falls through to fallback
got = RunHandlerChain(handlers, "some.other.key", nil, fallback)
if got != "missed" {
t.Errorf("chain miss = %q, want %q", got, "missed")
}
}
func TestDefaultHandlers(t *testing.T) {
handlers := DefaultHandlers()
if len(handlers) != 6 {
t.Errorf("DefaultHandlers() returned %d handlers, want 6", len(handlers))
}
}

72
hooks.go Normal file
View file

@ -0,0 +1,72 @@
package i18n
import (
"io/fs"
"runtime"
"sync"
"sync/atomic"
)
var missingKeyHandler atomic.Value
type localeRegistration struct {
fsys fs.FS
dir string
}
var (
registeredLocales []localeRegistration
registeredLocalesMu sync.Mutex
localesLoaded bool
)
// RegisterLocales registers a filesystem containing locale files.
// Call this in your package's init() to register translations.
//
// //go:embed locales/*.json
// var localeFS embed.FS
//
// func init() {
// i18n.RegisterLocales(localeFS, "locales")
// }
func RegisterLocales(fsys fs.FS, dir string) {
registeredLocalesMu.Lock()
defer registeredLocalesMu.Unlock()
registeredLocales = append(registeredLocales, localeRegistration{fsys: fsys, dir: dir})
if localesLoaded {
if svc := Default(); svc != nil {
_ = svc.LoadFS(fsys, dir)
}
}
}
func loadRegisteredLocales(svc *Service) {
registeredLocalesMu.Lock()
defer registeredLocalesMu.Unlock()
for _, reg := range registeredLocales {
_ = svc.LoadFS(reg.fsys, reg.dir)
}
localesLoaded = true
}
// OnMissingKey registers a handler for missing translation keys.
func OnMissingKey(h MissingKeyHandler) {
missingKeyHandler.Store(h)
}
func dispatchMissingKey(key string, args map[string]any) {
v := missingKeyHandler.Load()
if v == nil {
return
}
h, ok := v.(MissingKeyHandler)
if !ok || h == nil {
return
}
_, file, line, ok := runtime.Caller(2)
if !ok {
file = "unknown"
line = 0
}
h(MissingKey{Key: key, Args: args, CallerFile: file, CallerLine: line})
}

129
i18n.go Normal file
View file

@ -0,0 +1,129 @@
package i18n
import (
"bytes"
"errors"
"strings"
"text/template"
)
// T translates a message using the default service.
func T(messageID string, args ...any) string {
if svc := Default(); svc != nil {
return svc.T(messageID, args...)
}
return messageID
}
// Raw translates without i18n.* namespace magic.
func Raw(messageID string, args ...any) string {
if svc := Default(); svc != nil {
return svc.Raw(messageID, args...)
}
return messageID
}
// ErrServiceNotInitialized is returned when the service is not initialised.
var ErrServiceNotInitialized = errors.New("i18n: service not initialized")
// SetLanguage sets the language for the default service.
func SetLanguage(lang string) error {
svc := Default()
if svc == nil {
return ErrServiceNotInitialized
}
return svc.SetLanguage(lang)
}
// CurrentLanguage returns the current language code.
func CurrentLanguage() string {
if svc := Default(); svc != nil {
return svc.Language()
}
return "en"
}
// SetMode sets the translation mode for the default service.
func SetMode(m Mode) {
if svc := Default(); svc != nil {
svc.SetMode(m)
}
}
// CurrentMode returns the current translation mode.
func CurrentMode() Mode {
if svc := Default(); svc != nil {
return svc.Mode()
}
return ModeNormal
}
// N formats a number using the i18n.numeric.* namespace.
//
// N("number", 1234567) // "1,234,567"
// N("percent", 0.85) // "85%"
// N("bytes", 1536000) // "1.5 MB"
// N("ordinal", 1) // "1st"
func N(format string, value any) string {
return T("i18n.numeric."+format, value)
}
// AddHandler appends a handler to the default service's handler chain.
func AddHandler(h KeyHandler) {
if svc := Default(); svc != nil {
svc.AddHandler(h)
}
}
// PrependHandler inserts a handler at the start of the default service's handler chain.
func PrependHandler(h KeyHandler) {
if svc := Default(); svc != nil {
svc.PrependHandler(h)
}
}
func executeIntentTemplate(tmplStr string, data templateData) string {
if tmplStr == "" {
return ""
}
if cached, ok := templateCache.Load(tmplStr); ok {
var buf bytes.Buffer
if err := cached.(*template.Template).Execute(&buf, data); err != nil {
return tmplStr
}
return buf.String()
}
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(tmplStr)
if err != nil {
return tmplStr
}
templateCache.Store(tmplStr, tmpl)
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return tmplStr
}
return buf.String()
}
func applyTemplate(text string, data any) string {
if !strings.Contains(text, "{{") {
return text
}
if cached, ok := templateCache.Load(text); ok {
var buf bytes.Buffer
if err := cached.(*template.Template).Execute(&buf, data); err != nil {
return text
}
return buf.String()
}
tmpl, err := template.New("").Parse(text)
if err != nil {
return text
}
templateCache.Store(text, tmpl)
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return text
}
return buf.String()
}

85
language.go Normal file
View file

@ -0,0 +1,85 @@
package i18n
// GetPluralRule returns the plural rule for a language code.
func GetPluralRule(lang string) PluralRule {
if rule, ok := pluralRules[lang]; ok {
return rule
}
if len(lang) > 2 {
base := lang[:2]
if rule, ok := pluralRules[base]; ok {
return rule
}
}
return pluralRuleEnglish
}
// GetPluralCategory returns the plural category for a count in the given language.
func GetPluralCategory(lang string, n int) PluralCategory {
return GetPluralRule(lang)(n)
}
func pluralRuleEnglish(n int) PluralCategory {
if n == 1 {
return PluralOne
}
return PluralOther
}
func pluralRuleGerman(n int) PluralCategory { return pluralRuleEnglish(n) }
func pluralRuleSpanish(n int) PluralCategory { return pluralRuleEnglish(n) }
func pluralRuleFrench(n int) PluralCategory {
if n == 0 || n == 1 {
return PluralOne
}
return PluralOther
}
func pluralRuleRussian(n int) PluralCategory {
mod10 := n % 10
mod100 := n % 100
if mod10 == 1 && mod100 != 11 {
return PluralOne
}
if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
return PluralFew
}
return PluralMany
}
func pluralRulePolish(n int) PluralCategory {
if n == 1 {
return PluralOne
}
mod10 := n % 10
mod100 := n % 100
if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
return PluralFew
}
return PluralMany
}
func pluralRuleArabic(n int) PluralCategory {
if n == 0 {
return PluralZero
}
if n == 1 {
return PluralOne
}
if n == 2 {
return PluralTwo
}
mod100 := n % 100
if mod100 >= 3 && mod100 <= 10 {
return PluralFew
}
if mod100 >= 11 && mod100 <= 99 {
return PluralMany
}
return PluralOther
}
func pluralRuleChinese(n int) PluralCategory { return PluralOther }
func pluralRuleJapanese(n int) PluralCategory { return PluralOther }
func pluralRuleKorean(n int) PluralCategory { return PluralOther }

83
language_test.go Normal file
View file

@ -0,0 +1,83 @@
package i18n
import "testing"
func TestGetPluralCategory(t *testing.T) {
tests := []struct {
lang string
n int
want PluralCategory
}{
// English
{"en", 0, PluralOther},
{"en", 1, PluralOne},
{"en", 2, PluralOther},
// French (0 and 1 are singular)
{"fr", 0, PluralOne},
{"fr", 1, PluralOne},
{"fr", 2, PluralOther},
// Russian
{"ru", 1, PluralOne},
{"ru", 2, PluralFew},
{"ru", 5, PluralMany},
{"ru", 11, PluralMany},
{"ru", 21, PluralOne},
{"ru", 22, PluralFew},
// Polish
{"pl", 1, PluralOne},
{"pl", 2, PluralFew},
{"pl", 5, PluralMany},
// Arabic
{"ar", 0, PluralZero},
{"ar", 1, PluralOne},
{"ar", 2, PluralTwo},
{"ar", 5, PluralFew},
{"ar", 11, PluralMany},
{"ar", 100, PluralOther},
// Chinese (always other)
{"zh", 0, PluralOther},
{"zh", 1, PluralOther},
{"zh", 100, PluralOther},
// Fallback for unknown language uses English rules
{"xx", 1, PluralOne},
{"xx", 5, PluralOther},
}
for _, tt := range tests {
t.Run(tt.lang, func(t *testing.T) {
got := GetPluralCategory(tt.lang, tt.n)
if got != tt.want {
t.Errorf("GetPluralCategory(%q, %d) = %v, want %v", tt.lang, tt.n, got, tt.want)
}
})
}
}
func TestGetPluralRule(t *testing.T) {
// Known language
rule := GetPluralRule("en")
if rule == nil {
t.Fatal("GetPluralRule(en) returned nil")
}
if rule(1) != PluralOne {
t.Error("English rule(1) should be PluralOne")
}
// Base language extraction
rule = GetPluralRule("en-US")
if rule(1) != PluralOne {
t.Error("English-US rule(1) should be PluralOne")
}
// Unknown falls back to English
rule = GetPluralRule("xx-YY")
if rule(1) != PluralOne {
t.Error("Unknown rule(1) should fallback to English PluralOne")
}
}

241
loader.go Normal file
View file

@ -0,0 +1,241 @@
package i18n
import (
"encoding/json"
"fmt"
"io/fs"
"path"
"strings"
"sync"
)
// FSLoader loads translations from a filesystem (embedded or disk).
type FSLoader struct {
fsys fs.FS
dir string
languages []string
langOnce sync.Once
langErr error
}
// NewFSLoader creates a loader for the given filesystem and directory.
func NewFSLoader(fsys fs.FS, dir string) *FSLoader {
return &FSLoader{fsys: fsys, dir: dir}
}
// Load implements Loader.Load.
func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
variants := []string{
lang + ".json",
strings.ReplaceAll(lang, "-", "_") + ".json",
strings.ReplaceAll(lang, "_", "-") + ".json",
}
var data []byte
var err error
for _, filename := range variants {
filePath := path.Join(l.dir, filename)
data, err = fs.ReadFile(l.fsys, filePath)
if err == nil {
break
}
}
if err != nil {
return nil, nil, fmt.Errorf("locale %q not found: %w", lang, err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, nil, fmt.Errorf("invalid JSON in locale %q: %w", lang, err)
}
messages := make(map[string]Message)
grammar := &GrammarData{
Verbs: make(map[string]VerbForms),
Nouns: make(map[string]NounForms),
Words: make(map[string]string),
}
flattenWithGrammar("", raw, messages, grammar)
return messages, grammar, nil
}
// Languages implements Loader.Languages.
func (l *FSLoader) Languages() []string {
l.langOnce.Do(func() {
entries, err := fs.ReadDir(l.fsys, l.dir)
if err != nil {
l.langErr = fmt.Errorf("failed to read locale directory %q: %w", l.dir, err)
return
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
lang := strings.TrimSuffix(entry.Name(), ".json")
lang = strings.ReplaceAll(lang, "_", "-")
l.languages = append(l.languages, lang)
}
})
return l.languages
}
// LanguagesErr returns any error from the directory scan.
func (l *FSLoader) LanguagesErr() error {
l.Languages()
return l.langErr
}
var _ Loader = (*FSLoader)(nil)
// --- Flatten helpers ---
func flatten(prefix string, data map[string]any, out map[string]Message) {
flattenWithGrammar(prefix, data, out, nil)
}
func flattenWithGrammar(prefix string, data map[string]any, out map[string]Message, grammar *GrammarData) {
for key, value := range data {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
switch v := value.(type) {
case string:
if grammar != nil && strings.HasPrefix(fullKey, "gram.word.") {
wordKey := strings.TrimPrefix(fullKey, "gram.word.")
grammar.Words[strings.ToLower(wordKey)] = v
continue
}
out[fullKey] = Message{Text: v}
case map[string]any:
// Verb form object (has base/past/gerund keys)
if grammar != nil && isVerbFormObject(v) {
verbName := key
if strings.HasPrefix(fullKey, "gram.verb.") {
verbName = strings.TrimPrefix(fullKey, "gram.verb.")
}
forms := VerbForms{}
if past, ok := v["past"].(string); ok {
forms.Past = past
}
if gerund, ok := v["gerund"].(string); ok {
forms.Gerund = gerund
}
grammar.Verbs[strings.ToLower(verbName)] = forms
continue
}
// Noun form object (under gram.noun.* or has gender field)
if grammar != nil && (strings.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) {
nounName := key
if strings.HasPrefix(fullKey, "gram.noun.") {
nounName = strings.TrimPrefix(fullKey, "gram.noun.")
}
_, hasOne := v["one"]
_, hasOther := v["other"]
if hasOne && hasOther {
forms := NounForms{}
if one, ok := v["one"].(string); ok {
forms.One = one
}
if other, ok := v["other"].(string); ok {
forms.Other = other
}
if gender, ok := v["gender"].(string); ok {
forms.Gender = gender
}
grammar.Nouns[strings.ToLower(nounName)] = forms
continue
}
}
// Article configuration
if grammar != nil && fullKey == "gram.article" {
if indef, ok := v["indefinite"].(map[string]any); ok {
if def, ok := indef["default"].(string); ok {
grammar.Articles.IndefiniteDefault = def
}
if vowel, ok := indef["vowel"].(string); ok {
grammar.Articles.IndefiniteVowel = vowel
}
}
if def, ok := v["definite"].(string); ok {
grammar.Articles.Definite = def
}
continue
}
// Punctuation rules
if grammar != nil && fullKey == "gram.punct" {
if label, ok := v["label"].(string); ok {
grammar.Punct.LabelSuffix = label
}
if progress, ok := v["progress"].(string); ok {
grammar.Punct.ProgressSuffix = progress
}
continue
}
// CLDR plural object
if isPluralObject(v) {
msg := Message{}
if zero, ok := v["zero"].(string); ok {
msg.Zero = zero
}
if one, ok := v["one"].(string); ok {
msg.One = one
}
if two, ok := v["two"].(string); ok {
msg.Two = two
}
if few, ok := v["few"].(string); ok {
msg.Few = few
}
if many, ok := v["many"].(string); ok {
msg.Many = many
}
if other, ok := v["other"].(string); ok {
msg.Other = other
}
out[fullKey] = msg
} else {
flattenWithGrammar(fullKey, v, out, grammar)
}
}
}
}
func isVerbFormObject(m map[string]any) bool {
_, hasBase := m["base"]
_, hasPast := m["past"]
_, hasGerund := m["gerund"]
return (hasBase || hasPast || hasGerund) && !isPluralObject(m)
}
func isNounFormObject(m map[string]any) bool {
_, hasGender := m["gender"]
return hasGender
}
func isPluralObject(m map[string]any) bool {
_, hasZero := m["zero"]
_, hasOne := m["one"]
_, hasTwo := m["two"]
_, hasFew := m["few"]
_, hasMany := m["many"]
_, hasOther := m["other"]
if !hasZero && !hasOne && !hasTwo && !hasFew && !hasMany && !hasOther {
return false
}
for _, v := range m {
if _, isMap := v.(map[string]any); isMap {
return false
}
}
return true
}

263
loader_test.go Normal file
View file

@ -0,0 +1,263 @@
package i18n
import (
"testing"
"testing/fstest"
)
func TestFSLoaderLanguages(t *testing.T) {
loader := NewFSLoader(localeFS, "locales")
langs := loader.Languages()
if len(langs) == 0 {
t.Fatal("FSLoader.Languages() returned empty")
}
found := false
for _, l := range langs {
if l == "en" {
found = true
break
}
}
if !found {
t.Errorf("Languages() = %v, expected 'en' in list", langs)
}
}
func TestFSLoaderLoad(t *testing.T) {
loader := NewFSLoader(localeFS, "locales")
messages, grammar, err := loader.Load("en")
if err != nil {
t.Fatalf("Load(en) error: %v", err)
}
// Should have messages from the JSON
if len(messages) == 0 {
t.Error("Load(en) returned 0 messages")
}
// Grammar data should be extracted from nested JSON
if grammar == nil {
t.Fatal("Load(en) returned nil grammar")
}
// Verbs from gram.verb.*
if len(grammar.Verbs) == 0 {
t.Error("grammar has 0 verbs")
}
if v, ok := grammar.Verbs["build"]; !ok {
t.Error("grammar missing verb 'build'")
} else {
if v.Past != "built" {
t.Errorf("build.past = %q, want 'built'", v.Past)
}
if v.Gerund != "building" {
t.Errorf("build.gerund = %q, want 'building'", v.Gerund)
}
}
// Nouns from gram.noun.*
if len(grammar.Nouns) == 0 {
t.Error("grammar has 0 nouns")
}
if n, ok := grammar.Nouns["file"]; !ok {
t.Error("grammar missing noun 'file'")
} else {
if n.One != "file" {
t.Errorf("file.one = %q, want 'file'", n.One)
}
if n.Other != "files" {
t.Errorf("file.other = %q, want 'files'", n.Other)
}
}
// Articles from gram.article
if grammar.Articles.IndefiniteDefault != "a" {
t.Errorf("article.indefinite.default = %q, want 'a'", grammar.Articles.IndefiniteDefault)
}
if grammar.Articles.IndefiniteVowel != "an" {
t.Errorf("article.indefinite.vowel = %q, want 'an'", grammar.Articles.IndefiniteVowel)
}
if grammar.Articles.Definite != "the" {
t.Errorf("article.definite = %q, want 'the'", grammar.Articles.Definite)
}
// Punctuation from gram.punct
if grammar.Punct.LabelSuffix != ":" {
t.Errorf("punct.label = %q, want ':'", grammar.Punct.LabelSuffix)
}
if grammar.Punct.ProgressSuffix != "..." {
t.Errorf("punct.progress = %q, want '...'", grammar.Punct.ProgressSuffix)
}
// Words from gram.word.*
if len(grammar.Words) == 0 {
t.Error("grammar has 0 words")
}
if grammar.Words["url"] != "URL" {
t.Errorf("word.url = %q, want 'URL'", grammar.Words["url"])
}
if grammar.Words["api"] != "API" {
t.Errorf("word.api = %q, want 'API'", grammar.Words["api"])
}
}
func TestFSLoaderLoadMissing(t *testing.T) {
loader := NewFSLoader(localeFS, "locales")
_, _, err := loader.Load("xx")
if err == nil {
t.Error("Load(xx) should fail for non-existent locale")
}
}
func TestFlattenWithGrammar(t *testing.T) {
messages := make(map[string]Message)
grammar := &GrammarData{
Verbs: make(map[string]VerbForms),
Nouns: make(map[string]NounForms),
Words: make(map[string]string),
}
raw := map[string]any{
"gram": map[string]any{
"verb": map[string]any{
"test": map[string]any{
"base": "test",
"past": "tested",
"gerund": "testing",
},
},
"noun": map[string]any{
"widget": map[string]any{
"one": "widget",
"other": "widgets",
},
},
"word": map[string]any{
"api": "API",
},
"punct": map[string]any{
"label": ":",
"progress": "...",
},
"article": map[string]any{
"indefinite": map[string]any{
"default": "a",
"vowel": "an",
},
"definite": "the",
},
},
"prompt": map[string]any{
"yes": "y",
"no": "n",
},
}
flattenWithGrammar("", raw, messages, grammar)
// Verb extracted
if v, ok := grammar.Verbs["test"]; !ok {
t.Error("verb 'test' not extracted")
} else {
if v.Past != "tested" {
t.Errorf("test.past = %q, want 'tested'", v.Past)
}
}
// Noun extracted
if n, ok := grammar.Nouns["widget"]; !ok {
t.Error("noun 'widget' not extracted")
} else {
if n.Other != "widgets" {
t.Errorf("widget.other = %q, want 'widgets'", n.Other)
}
}
// Word extracted
if grammar.Words["api"] != "API" {
t.Errorf("word 'api' = %q, want 'API'", grammar.Words["api"])
}
// Punct extracted
if grammar.Punct.LabelSuffix != ":" {
t.Errorf("punct.label = %q, want ':'", grammar.Punct.LabelSuffix)
}
// Articles extracted
if grammar.Articles.IndefiniteDefault != "a" {
t.Errorf("article.indefinite.default = %q, want 'a'", grammar.Articles.IndefiniteDefault)
}
// Regular keys flattened
if msg, ok := messages["prompt.yes"]; !ok || msg.Text != "y" {
t.Errorf("prompt.yes not flattened correctly, got %+v", messages["prompt.yes"])
}
}
func TestFlattenPluralObject(t *testing.T) {
messages := make(map[string]Message)
raw := map[string]any{
"time": map[string]any{
"ago": map[string]any{
"second": map[string]any{
"one": "{{.Count}} second ago",
"other": "{{.Count}} seconds ago",
},
},
},
}
flattenWithGrammar("", raw, messages, nil)
msg, ok := messages["time.ago.second"]
if !ok {
t.Fatal("time.ago.second not found")
}
if !msg.IsPlural() {
t.Error("time.ago.second should be plural")
}
if msg.One != "{{.Count}} second ago" {
t.Errorf("time.ago.second.one = %q", msg.One)
}
if msg.Other != "{{.Count}} seconds ago" {
t.Errorf("time.ago.second.other = %q", msg.Other)
}
}
func TestCustomFSLoader(t *testing.T) {
fs := fstest.MapFS{
"locales/test.json": &fstest.MapFile{
Data: []byte(`{
"gram": {
"verb": {
"zap": { "base": "zap", "past": "zapped", "gerund": "zapping" }
},
"word": {
"hello": "Hello"
}
},
"greeting": "Hello, world!"
}`),
},
}
svc, err := NewWithFS(fs, "locales", WithFallback("test"))
if err != nil {
t.Fatalf("NewWithFS failed: %v", err)
}
got := svc.T("greeting")
if got != "Hello, world!" {
t.Errorf("T(greeting) = %q, want 'Hello, world!'", got)
}
// Grammar should be loaded
gd := GetGrammarData("test")
if gd == nil {
t.Fatal("grammar data not loaded for 'test'")
}
if v, ok := gd.Verbs["zap"]; !ok || v.Past != "zapped" {
t.Errorf("verb 'zap' not loaded correctly")
}
}

142
locales/en.json Normal file
View file

@ -0,0 +1,142 @@
{
"gram": {
"verb": {
"be": { "base": "be", "past": "was", "gerund": "being" },
"go": { "base": "go", "past": "went", "gerund": "going" },
"do": { "base": "do", "past": "did", "gerund": "doing" },
"have": { "base": "have", "past": "had", "gerund": "having" },
"make": { "base": "make", "past": "made", "gerund": "making" },
"get": { "base": "get", "past": "got", "gerund": "getting" },
"run": { "base": "run", "past": "ran", "gerund": "running" },
"write": { "base": "write", "past": "wrote", "gerund": "writing" },
"build": { "base": "build", "past": "built", "gerund": "building" },
"send": { "base": "send", "past": "sent", "gerund": "sending" },
"find": { "base": "find", "past": "found", "gerund": "finding" },
"take": { "base": "take", "past": "took", "gerund": "taking" },
"begin": { "base": "begin", "past": "began", "gerund": "beginning" },
"keep": { "base": "keep", "past": "kept", "gerund": "keeping" },
"hold": { "base": "hold", "past": "held", "gerund": "holding" },
"bring": { "base": "bring", "past": "brought", "gerund": "bringing" },
"think": { "base": "think", "past": "thought", "gerund": "thinking" },
"buy": { "base": "buy", "past": "bought", "gerund": "buying" },
"catch": { "base": "catch", "past": "caught", "gerund": "catching" },
"choose": { "base": "choose", "past": "chose", "gerund": "choosing" },
"lose": { "base": "lose", "past": "lost", "gerund": "losing" },
"win": { "base": "win", "past": "won", "gerund": "winning" },
"meet": { "base": "meet", "past": "met", "gerund": "meeting" },
"lead": { "base": "lead", "past": "led", "gerund": "leading" },
"leave": { "base": "leave", "past": "left", "gerund": "leaving" },
"spend": { "base": "spend", "past": "spent", "gerund": "spending" },
"pay": { "base": "pay", "past": "paid", "gerund": "paying" },
"sell": { "base": "sell", "past": "sold", "gerund": "selling" },
"commit": { "base": "commit", "past": "committed", "gerund": "committing" },
"stop": { "base": "stop", "past": "stopped", "gerund": "stopping" },
"scan": { "base": "scan", "past": "scanned", "gerund": "scanning" },
"format": { "base": "format", "past": "formatted", "gerund": "formatting" },
"set": { "base": "set", "past": "set", "gerund": "setting" },
"put": { "base": "put", "past": "put", "gerund": "putting" },
"cut": { "base": "cut", "past": "cut", "gerund": "cutting" },
"hit": { "base": "hit", "past": "hit", "gerund": "hitting" },
"sit": { "base": "sit", "past": "sat", "gerund": "sitting" },
"split": { "base": "split", "past": "split", "gerund": "splitting" },
"shut": { "base": "shut", "past": "shut", "gerund": "shutting" }
},
"noun": {
"file": { "one": "file", "other": "files" },
"repo": { "one": "repo", "other": "repos" },
"repository": { "one": "repository", "other": "repositories" },
"commit": { "one": "commit", "other": "commits" },
"branch": { "one": "branch", "other": "branches" },
"change": { "one": "change", "other": "changes" },
"item": { "one": "item", "other": "items" },
"issue": { "one": "issue", "other": "issues" },
"task": { "one": "task", "other": "tasks" },
"person": { "one": "person", "other": "people" },
"child": { "one": "child", "other": "children" },
"package": { "one": "package", "other": "packages" },
"artifact": { "one": "artifact", "other": "artifacts" },
"vulnerability": { "one": "vulnerability", "other": "vulnerabilities" },
"dependency": { "one": "dependency", "other": "dependencies" },
"directory": { "one": "directory", "other": "directories" },
"category": { "one": "category", "other": "categories" },
"query": { "one": "query", "other": "queries" },
"passed": { "one": "passed", "other": "passed" },
"failed": { "one": "failed", "other": "failed" },
"skipped": { "one": "skipped", "other": "skipped" },
"check": { "one": "check", "other": "checks" },
"test": { "one": "test", "other": "tests" }
},
"article": {
"indefinite": { "default": "a", "vowel": "an" },
"definite": "the"
},
"word": {
"url": "URL",
"id": "ID",
"ok": "OK",
"ci": "CI",
"qa": "QA",
"php": "PHP",
"sdk": "SDK",
"html": "HTML",
"cgo": "CGO",
"pid": "PID",
"cpus": "CPUs",
"ssh": "SSH",
"ssl": "SSL",
"api": "API",
"pr": "PR",
"vite": "Vite",
"pnpm": "pnpm",
"app_url": "app URL",
"blocked_by": "blocked by",
"claimed_by": "claimed by",
"related_files": "related files",
"up_to_date": "up to date",
"dry_run": "dry run",
"go_mod": "go.mod",
"test": "test",
"package": "package",
"filter": "filter",
"coverage": "coverage",
"passed": "passed",
"failed": "failed",
"skipped": "skipped"
},
"punct": {
"label": ":",
"progress": "..."
},
"number": {
"thousands": ",",
"decimal": ".",
"percent": "%s%%"
}
},
"prompt": {
"yes": "y",
"no": "n",
"continue": "Continue?",
"proceed": "Proceed?",
"confirm": "Are you sure?",
"overwrite": "Overwrite?",
"discard": "Discard changes?"
},
"time": {
"just_now": "just now",
"ago": {
"second": { "one": "{{.Count}} second ago", "other": "{{.Count}} seconds ago" },
"minute": { "one": "{{.Count}} minute ago", "other": "{{.Count}} minutes ago" },
"hour": { "one": "{{.Count}} hour ago", "other": "{{.Count}} hours ago" },
"day": { "one": "{{.Count}} day ago", "other": "{{.Count}} days ago" },
"week": { "one": "{{.Count}} week ago", "other": "{{.Count}} weeks ago" }
}
},
"lang": {
"de": "German",
"en": "English",
"es": "Spanish",
"fr": "French",
"zh": "Chinese"
}
}

113
localise.go Normal file
View file

@ -0,0 +1,113 @@
package i18n
import (
"os"
"strings"
"golang.org/x/text/language"
)
func (f Formality) String() string {
switch f {
case FormalityInformal:
return "informal"
case FormalityFormal:
return "formal"
default:
return "neutral"
}
}
func (d TextDirection) String() string {
if d == DirRTL {
return "rtl"
}
return "ltr"
}
func (p PluralCategory) String() string {
switch p {
case PluralZero:
return "zero"
case PluralOne:
return "one"
case PluralTwo:
return "two"
case PluralFew:
return "few"
case PluralMany:
return "many"
default:
return "other"
}
}
func (g GrammaticalGender) String() string {
switch g {
case GenderMasculine:
return "masculine"
case GenderFeminine:
return "feminine"
case GenderCommon:
return "common"
default:
return "neuter"
}
}
// IsRTLLanguage returns true if the language code uses right-to-left text.
func IsRTLLanguage(lang string) bool {
if rtlLanguages[lang] {
return true
}
if len(lang) > 2 {
return rtlLanguages[lang[:2]]
}
return false
}
// SetFormality sets the default formality level on the default service.
func SetFormality(f Formality) {
if svc := Default(); svc != nil {
svc.SetFormality(f)
}
}
// Direction returns the text direction for the current language.
func Direction() TextDirection {
if svc := Default(); svc != nil {
return svc.Direction()
}
return DirLTR
}
// IsRTL returns true if the current language uses right-to-left text.
func IsRTL() bool { return Direction() == DirRTL }
func detectLanguage(supported []language.Tag) string {
langEnv := os.Getenv("LANG")
if langEnv == "" {
langEnv = os.Getenv("LC_ALL")
if langEnv == "" {
langEnv = os.Getenv("LC_MESSAGES")
}
}
if langEnv == "" {
return ""
}
baseLang := strings.Split(langEnv, ".")[0]
baseLang = strings.ReplaceAll(baseLang, "_", "-")
parsedLang, err := language.Parse(baseLang)
if err != nil {
return ""
}
if len(supported) == 0 {
return ""
}
matcher := language.NewMatcher(supported)
bestMatch, _, confidence := matcher.Match(parsedLang)
if confidence >= language.Low {
return bestMatch.String()
}
return ""
}

162
numbers.go Normal file
View file

@ -0,0 +1,162 @@
package i18n
import (
"fmt"
"math"
"strconv"
"strings"
)
func getNumberFormat() NumberFormat {
lang := currentLangForGrammar()
if idx := strings.IndexAny(lang, "-_"); idx > 0 {
lang = lang[:idx]
}
if fmt, ok := numberFormats[lang]; ok {
return fmt
}
return numberFormats["en"]
}
// FormatNumber formats an integer with locale-specific thousands separators.
func FormatNumber(n int64) string {
return formatIntWithSep(n, getNumberFormat().ThousandsSep)
}
// FormatDecimal formats a float with locale-specific separators.
func FormatDecimal(f float64) string {
return FormatDecimalN(f, 2)
}
// FormatDecimalN formats a float with N decimal places.
func FormatDecimalN(f float64, decimals int) string {
nf := getNumberFormat()
intPart := int64(f)
fracPart := math.Abs(f - float64(intPart))
intStr := formatIntWithSep(intPart, nf.ThousandsSep)
if decimals <= 0 || fracPart == 0 {
return intStr
}
multiplier := math.Pow(10, float64(decimals))
fracInt := int64(math.Round(fracPart * multiplier))
if fracInt == 0 {
return intStr
}
fracStr := fmt.Sprintf("%0*d", decimals, fracInt)
fracStr = strings.TrimRight(fracStr, "0")
return intStr + nf.DecimalSep + fracStr
}
// FormatPercent formats a decimal as a percentage.
func FormatPercent(f float64) string {
nf := getNumberFormat()
pct := f * 100
var numStr string
if pct == float64(int64(pct)) {
numStr = strconv.FormatInt(int64(pct), 10)
} else {
numStr = FormatDecimalN(pct, 1)
}
return fmt.Sprintf(nf.PercentFmt, numStr)
}
// FormatBytes formats bytes as human-readable size.
func FormatBytes(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
TB = GB * 1024
)
nf := getNumberFormat()
var value float64
var unit string
switch {
case bytes >= TB:
value = float64(bytes) / TB
unit = "TB"
case bytes >= GB:
value = float64(bytes) / GB
unit = "GB"
case bytes >= MB:
value = float64(bytes) / MB
unit = "MB"
case bytes >= KB:
value = float64(bytes) / KB
unit = "KB"
default:
return fmt.Sprintf("%d B", bytes)
}
intPart := int64(value)
fracPart := value - float64(intPart)
if fracPart < 0.05 {
return fmt.Sprintf("%d %s", intPart, unit)
}
fracDigit := int(math.Round(fracPart * 10))
if fracDigit == 10 {
return fmt.Sprintf("%d %s", intPart+1, unit)
}
return fmt.Sprintf("%d%s%d %s", intPart, nf.DecimalSep, fracDigit, unit)
}
// FormatOrdinal formats a number as an ordinal.
func FormatOrdinal(n int) string {
lang := currentLangForGrammar()
if idx := strings.IndexAny(lang, "-_"); idx > 0 {
lang = lang[:idx]
}
switch lang {
case "en":
return formatEnglishOrdinal(n)
default:
return fmt.Sprintf("%d.", n)
}
}
func formatEnglishOrdinal(n int) string {
abs := n
if abs < 0 {
abs = -abs
}
if abs%100 >= 11 && abs%100 <= 13 {
return fmt.Sprintf("%dth", n)
}
switch abs % 10 {
case 1:
return fmt.Sprintf("%dst", n)
case 2:
return fmt.Sprintf("%dnd", n)
case 3:
return fmt.Sprintf("%drd", n)
default:
return fmt.Sprintf("%dth", n)
}
}
func formatIntWithSep(n int64, sep string) string {
if sep == "" {
return strconv.FormatInt(n, 10)
}
negative := n < 0
if negative {
n = -n
}
str := strconv.FormatInt(n, 10)
if len(str) <= 3 {
if negative {
return "-" + str
}
return str
}
var result strings.Builder
for i, c := range str {
if i > 0 && (len(str)-i)%3 == 0 {
result.WriteString(sep)
}
result.WriteRune(c)
}
if negative {
return "-" + result.String()
}
return result.String()
}

145
numbers_test.go Normal file
View file

@ -0,0 +1,145 @@
package i18n
import "testing"
func TestFormatNumber(t *testing.T) {
// Ensure service is initialised for English locale
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
n int64
want string
}{
{0, "0"},
{100, "100"},
{999, "999"},
{1000, "1,000"},
{1234567, "1,234,567"},
{-1234567, "-1,234,567"},
{1000000000, "1,000,000,000"},
}
for _, tt := range tests {
got := FormatNumber(tt.n)
if got != tt.want {
t.Errorf("FormatNumber(%d) = %q, want %q", tt.n, got, tt.want)
}
}
}
func TestFormatDecimal(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
f float64
want string
}{
{1.5, "1.5"},
{1.0, "1"},
{1234.56, "1,234.56"},
{0.1, "0.1"},
}
for _, tt := range tests {
got := FormatDecimal(tt.f)
if got != tt.want {
t.Errorf("FormatDecimal(%v) = %q, want %q", tt.f, got, tt.want)
}
}
}
func TestFormatPercent(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
f float64
want string
}{
{0.85, "85%"},
{1.0, "100%"},
{0.0, "0%"},
{0.333, "33.3%"},
}
for _, tt := range tests {
got := FormatPercent(tt.f)
if got != tt.want {
t.Errorf("FormatPercent(%v) = %q, want %q", tt.f, got, tt.want)
}
}
}
func TestFormatBytes(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
bytes int64
want string
}{
{0, "0 B"},
{512, "512 B"},
{1024, "1 KB"},
{1536, "1.5 KB"},
{1048576, "1 MB"},
{1536000, "1.5 MB"},
{1073741824, "1 GB"},
{1099511627776, "1 TB"},
}
for _, tt := range tests {
got := FormatBytes(tt.bytes)
if got != tt.want {
t.Errorf("FormatBytes(%d) = %q, want %q", tt.bytes, got, tt.want)
}
}
}
func TestFormatOrdinal(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
n int
want string
}{
{1, "1st"},
{2, "2nd"},
{3, "3rd"},
{4, "4th"},
{11, "11th"},
{12, "12th"},
{13, "13th"},
{21, "21st"},
{22, "22nd"},
{23, "23rd"},
{100, "100th"},
{101, "101st"},
{111, "111th"},
}
for _, tt := range tests {
got := FormatOrdinal(tt.n)
if got != tt.want {
t.Errorf("FormatOrdinal(%d) = %q, want %q", tt.n, got, tt.want)
}
}
}

466
service.go Normal file
View file

@ -0,0 +1,466 @@
package i18n
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"path"
"strings"
"sync"
"sync/atomic"
"golang.org/x/text/language"
)
// Service provides grammar-aware internationalisation.
type Service struct {
loader Loader
messages map[string]map[string]Message // lang -> key -> message
currentLang string
fallbackLang string
availableLangs []language.Tag
mode Mode
debug bool
formality Formality
handlers []KeyHandler
mu sync.RWMutex
}
// Option configures a Service during construction.
type Option func(*Service)
// WithFallback sets the fallback language for missing translations.
func WithFallback(lang string) Option {
return func(s *Service) { s.fallbackLang = lang }
}
// WithFormality sets the default formality level.
func WithFormality(f Formality) Option {
return func(s *Service) { s.formality = f }
}
// WithHandlers sets custom handlers (replaces default handlers).
func WithHandlers(handlers ...KeyHandler) Option {
return func(s *Service) { s.handlers = handlers }
}
// WithDefaultHandlers adds the default i18n.* namespace handlers.
func WithDefaultHandlers() Option {
return func(s *Service) { s.handlers = append(s.handlers, DefaultHandlers()...) }
}
// WithMode sets the translation mode.
func WithMode(m Mode) Option {
return func(s *Service) { s.mode = m }
}
// WithDebug enables or disables debug mode.
func WithDebug(enabled bool) Option {
return func(s *Service) { s.debug = enabled }
}
var (
defaultService atomic.Pointer[Service]
defaultOnce sync.Once
defaultErr error
)
//go:embed locales/*.json
var localeFS embed.FS
var _ Translator = (*Service)(nil)
// New creates a new i18n service with embedded locales.
func New(opts ...Option) (*Service, error) {
return NewWithLoader(NewFSLoader(localeFS, "locales"), opts...)
}
// NewWithFS creates a new i18n service loading locales from the given filesystem.
func NewWithFS(fsys fs.FS, dir string, opts ...Option) (*Service, error) {
return NewWithLoader(NewFSLoader(fsys, dir), opts...)
}
// NewWithLoader creates a new i18n service with a custom loader.
func NewWithLoader(loader Loader, opts ...Option) (*Service, error) {
s := &Service{
loader: loader,
messages: make(map[string]map[string]Message),
fallbackLang: "en",
handlers: DefaultHandlers(),
}
for _, opt := range opts {
opt(s)
}
langs := loader.Languages()
if len(langs) == 0 {
return nil, fmt.Errorf("no languages available from loader")
}
for _, lang := range langs {
messages, grammar, err := loader.Load(lang)
if err != nil {
return nil, fmt.Errorf("failed to load locale %q: %w", lang, err)
}
s.messages[lang] = messages
if grammar != nil && (len(grammar.Verbs) > 0 || len(grammar.Nouns) > 0 || len(grammar.Words) > 0) {
SetGrammarData(lang, grammar)
}
tag := language.Make(lang)
s.availableLangs = append(s.availableLangs, tag)
}
if detected := detectLanguage(s.availableLangs); detected != "" {
s.currentLang = detected
} else {
s.currentLang = s.fallbackLang
}
return s, nil
}
// Init initialises the default global service.
func Init() error {
defaultOnce.Do(func() {
svc, err := New()
if err == nil {
defaultService.Store(svc)
loadRegisteredLocales(svc)
}
defaultErr = err
})
return defaultErr
}
// Default returns the global i18n service, initialising if needed.
func Default() *Service {
_ = Init()
return defaultService.Load()
}
// SetDefault sets the global i18n service.
func SetDefault(s *Service) {
if s == nil {
panic("i18n: SetDefault called with nil service")
}
defaultService.Store(s)
}
func (s *Service) loadJSON(lang string, data []byte) error {
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
messages := make(map[string]Message)
grammarData := &GrammarData{
Verbs: make(map[string]VerbForms),
Nouns: make(map[string]NounForms),
Words: make(map[string]string),
}
flattenWithGrammar("", raw, messages, grammarData)
if existing, ok := s.messages[lang]; ok {
for key, msg := range messages {
existing[key] = msg
}
} else {
s.messages[lang] = messages
}
if len(grammarData.Verbs) > 0 || len(grammarData.Nouns) > 0 || len(grammarData.Words) > 0 {
SetGrammarData(lang, grammarData)
}
return nil
}
// SetLanguage sets the language for translations.
func (s *Service) SetLanguage(lang string) error {
s.mu.Lock()
defer s.mu.Unlock()
requestedLang, err := language.Parse(lang)
if err != nil {
return fmt.Errorf("invalid language tag %q: %w", lang, err)
}
if len(s.availableLangs) == 0 {
return fmt.Errorf("no languages available")
}
matcher := language.NewMatcher(s.availableLangs)
bestMatch, _, confidence := matcher.Match(requestedLang)
if confidence == language.No {
return fmt.Errorf("unsupported language: %q", lang)
}
s.currentLang = bestMatch.String()
return nil
}
func (s *Service) Language() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.currentLang
}
func (s *Service) AvailableLanguages() []string {
s.mu.RLock()
defer s.mu.RUnlock()
langs := make([]string, len(s.availableLangs))
for i, tag := range s.availableLangs {
langs[i] = tag.String()
}
return langs
}
func (s *Service) SetMode(m Mode) { s.mu.Lock(); s.mode = m; s.mu.Unlock() }
func (s *Service) Mode() Mode { s.mu.RLock(); defer s.mu.RUnlock(); return s.mode }
func (s *Service) SetFormality(f Formality) { s.mu.Lock(); s.formality = f; s.mu.Unlock() }
func (s *Service) Formality() Formality { s.mu.RLock(); defer s.mu.RUnlock(); return s.formality }
func (s *Service) Direction() TextDirection {
s.mu.RLock()
defer s.mu.RUnlock()
if IsRTLLanguage(s.currentLang) {
return DirRTL
}
return DirLTR
}
func (s *Service) IsRTL() bool { return s.Direction() == DirRTL }
func (s *Service) PluralCategory(n int) PluralCategory {
s.mu.RLock()
defer s.mu.RUnlock()
return GetPluralCategory(s.currentLang, n)
}
func (s *Service) AddHandler(h KeyHandler) {
s.mu.Lock()
defer s.mu.Unlock()
s.handlers = append(s.handlers, h)
}
func (s *Service) PrependHandler(h KeyHandler) {
s.mu.Lock()
defer s.mu.Unlock()
s.handlers = append([]KeyHandler{h}, s.handlers...)
}
func (s *Service) ClearHandlers() {
s.mu.Lock()
defer s.mu.Unlock()
s.handlers = nil
}
func (s *Service) Handlers() []KeyHandler {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]KeyHandler, len(s.handlers))
copy(result, s.handlers)
return result
}
// T translates a message by its ID with handler chain support.
//
// T("i18n.label.status") // "Status:"
// T("i18n.progress.build") // "Building..."
// T("i18n.count.file", 5) // "5 files"
// T("i18n.done.delete", "file") // "File deleted"
// T("i18n.fail.delete", "file") // "Failed to delete file"
func (s *Service) T(messageID string, args ...any) string {
s.mu.RLock()
defer s.mu.RUnlock()
result := RunHandlerChain(s.handlers, messageID, args, func() string {
var data any
if len(args) > 0 {
data = args[0]
}
text := s.resolveWithFallback(messageID, data)
if text == "" {
return s.handleMissingKey(messageID, args)
}
return text
})
if s.debug {
return debugFormat(messageID, result)
}
return result
}
func (s *Service) resolveWithFallback(messageID string, data any) string {
if text := s.tryResolve(s.currentLang, messageID, data); text != "" {
return text
}
if text := s.tryResolve(s.fallbackLang, messageID, data); text != "" {
return text
}
if strings.Contains(messageID, ".") {
parts := strings.Split(messageID, ".")
verb := parts[len(parts)-1]
commonKey := "common.action." + verb
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
return text
}
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
return text
}
commonKey = "common." + verb
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
return text
}
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
return text
}
}
return ""
}
func (s *Service) tryResolve(lang, key string, data any) string {
formality := s.getEffectiveFormality(data)
if formality != FormalityNeutral {
formalityKey := key + "._" + formality.String()
if text := s.resolveMessage(lang, formalityKey, data); text != "" {
return text
}
}
return s.resolveMessage(lang, key, data)
}
func (s *Service) resolveMessage(lang, key string, data any) string {
msg, ok := s.getMessage(lang, key)
if !ok {
return ""
}
text := msg.Text
if msg.IsPlural() {
count := getCount(data)
category := GetPluralCategory(lang, count)
text = msg.ForCategory(category)
}
if text == "" {
return ""
}
if data != nil {
text = applyTemplate(text, data)
}
return text
}
func (s *Service) getEffectiveFormality(data any) Formality {
if ctx, ok := data.(*TranslationContext); ok && ctx != nil {
if ctx.Formality != FormalityNeutral {
return ctx.Formality
}
}
if subj, ok := data.(*Subject); ok && subj != nil {
if subj.formality != FormalityNeutral {
return subj.formality
}
}
if m, ok := data.(map[string]any); ok {
switch f := m["Formality"].(type) {
case Formality:
if f != FormalityNeutral {
return f
}
case string:
switch strings.ToLower(f) {
case "formal":
return FormalityFormal
case "informal":
return FormalityInformal
}
}
}
return s.formality
}
func (s *Service) handleMissingKey(key string, args []any) string {
switch s.mode {
case ModeStrict:
panic(fmt.Sprintf("i18n: missing translation key %q", key))
case ModeCollect:
var argsMap map[string]any
if len(args) > 0 {
if m, ok := args[0].(map[string]any); ok {
argsMap = m
}
}
dispatchMissingKey(key, argsMap)
return "[" + key + "]"
default:
return key
}
}
// Raw translates without i18n.* namespace magic.
func (s *Service) Raw(messageID string, args ...any) string {
s.mu.RLock()
defer s.mu.RUnlock()
var data any
if len(args) > 0 {
data = args[0]
}
text := s.resolveWithFallback(messageID, data)
if text == "" {
return s.handleMissingKey(messageID, args)
}
if s.debug {
return debugFormat(messageID, text)
}
return text
}
func (s *Service) getMessage(lang, key string) (Message, bool) {
msgs, ok := s.messages[lang]
if !ok {
return Message{}, false
}
msg, ok := msgs[key]
return msg, ok
}
// AddMessages adds messages for a language at runtime.
func (s *Service) AddMessages(lang string, messages map[string]string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.messages[lang] == nil {
s.messages[lang] = make(map[string]Message)
}
for key, text := range messages {
s.messages[lang][key] = Message{Text: text}
}
}
// LoadFS loads additional locale files from a filesystem.
func (s *Service) LoadFS(fsys fs.FS, dir string) error {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := fs.ReadDir(fsys, dir)
if err != nil {
return fmt.Errorf("failed to read locales directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
filePath := path.Join(dir, entry.Name())
data, err := fs.ReadFile(fsys, filePath)
if err != nil {
return fmt.Errorf("failed to read locale %q: %w", entry.Name(), err)
}
lang := strings.TrimSuffix(entry.Name(), ".json")
lang = strings.ReplaceAll(lang, "_", "-")
if err := s.loadJSON(lang, data); err != nil {
return fmt.Errorf("failed to parse locale %q: %w", entry.Name(), err)
}
tag := language.Make(lang)
found := false
for _, existing := range s.availableLangs {
if existing == tag {
found = true
break
}
}
if !found {
s.availableLangs = append(s.availableLangs, tag)
}
}
return nil
}

305
service_test.go Normal file
View file

@ -0,0 +1,305 @@
package i18n
import (
"testing"
"testing/fstest"
)
func TestNewService(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
lang := svc.Language()
if lang == "" {
t.Error("Language() is empty")
}
// Language matcher may return canonical form with region (e.g. "en-u-rg-uszzzz")
// depending on LANG environment variable. Just check it starts with "en".
if lang[:2] != "en" {
t.Errorf("Language() = %q, expected to start with 'en'", lang)
}
langs := svc.AvailableLanguages()
if len(langs) == 0 {
t.Error("AvailableLanguages() is empty")
}
}
func TestServiceT(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
// Label handler
got := svc.T("i18n.label.status")
if got != "Status:" {
t.Errorf("T(i18n.label.status) = %q, want 'Status:'", got)
}
// Progress handler
got = svc.T("i18n.progress.build")
if got != "Building..." {
t.Errorf("T(i18n.progress.build) = %q, want 'Building...'", got)
}
// Count handler
got = svc.T("i18n.count.file", 5)
if got != "5 files" {
t.Errorf("T(i18n.count.file, 5) = %q, want '5 files'", got)
}
// Done handler
got = svc.T("i18n.done.delete", "config.yaml")
if got != "Config.Yaml deleted" {
t.Errorf("T(i18n.done.delete, config.yaml) = %q, want 'Config.Yaml deleted'", got)
}
// Fail handler
got = svc.T("i18n.fail.push", "commits")
if got != "Failed to push commits" {
t.Errorf("T(i18n.fail.push, commits) = %q, want 'Failed to push commits'", got)
}
}
func TestServiceTDirectKeys(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// Direct JSON keys
got := svc.T("prompt.yes")
if got != "y" {
t.Errorf("T(prompt.yes) = %q, want 'y'", got)
}
got = svc.T("prompt.confirm")
if got != "Are you sure?" {
t.Errorf("T(prompt.confirm) = %q, want 'Are you sure?'", got)
}
got = svc.T("lang.de")
if got != "German" {
t.Errorf("T(lang.de) = %q, want 'German'", got)
}
}
func TestServiceTPluralMessage(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// time.ago.second has one/other forms
got := svc.T("time.ago.second", map[string]any{"Count": 1})
if got != "1 second ago" {
t.Errorf("T(time.ago.second, 1) = %q, want '1 second ago'", got)
}
got = svc.T("time.ago.second", map[string]any{"Count": 5})
if got != "5 seconds ago" {
t.Errorf("T(time.ago.second, 5) = %q, want '5 seconds ago'", got)
}
}
func TestServiceRaw(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// Raw bypasses handlers
got := svc.Raw("prompt.yes")
if got != "y" {
t.Errorf("Raw(prompt.yes) = %q, want 'y'", got)
}
// Raw doesn't process i18n.* keys as handlers would
got = svc.Raw("i18n.label.status")
// Should return the key since it's not in the messages map
if got != "i18n.label.status" {
t.Errorf("Raw(i18n.label.status) = %q, want key returned", got)
}
}
func TestServiceModes(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// Default mode
if svc.Mode() != ModeNormal {
t.Errorf("default Mode() = %v, want ModeNormal", svc.Mode())
}
// Normal mode returns key for missing
got := svc.T("nonexistent.key")
if got != "nonexistent.key" {
t.Errorf("ModeNormal missing key = %q, want key", got)
}
// Collect mode returns [key] and dispatches event
svc.SetMode(ModeCollect)
var missing MissingKey
OnMissingKey(func(m MissingKey) { missing = m })
got = svc.T("nonexistent.key")
if got != "[nonexistent.key]" {
t.Errorf("ModeCollect missing key = %q, want '[nonexistent.key]'", got)
}
if missing.Key != "nonexistent.key" {
t.Errorf("MissingKey.Key = %q, want 'nonexistent.key'", missing.Key)
}
// Strict mode panics
svc.SetMode(ModeStrict)
defer func() {
r := recover()
if r == nil {
t.Error("ModeStrict should panic on missing key")
}
}()
svc.T("nonexistent.key")
}
func TestServiceDebug(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
svc.SetDebug(true)
got := svc.T("prompt.yes")
if got != "[prompt.yes] y" {
t.Errorf("debug T() = %q, want '[prompt.yes] y'", got)
}
svc.SetDebug(false)
got = svc.T("prompt.yes")
if got != "y" {
t.Errorf("non-debug T() = %q, want 'y'", got)
}
}
func TestServiceFormality(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
if svc.Formality() != FormalityNeutral {
t.Errorf("default Formality() = %v, want FormalityNeutral", svc.Formality())
}
svc.SetFormality(FormalityFormal)
if svc.Formality() != FormalityFormal {
t.Errorf("Formality() = %v, want FormalityFormal", svc.Formality())
}
}
func TestServiceDirection(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
if svc.Direction() != DirLTR {
t.Errorf("Direction() = %v, want DirLTR", svc.Direction())
}
if svc.IsRTL() {
t.Error("IsRTL() should be false for English")
}
}
func TestServiceAddMessages(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
svc.AddMessages("en", map[string]string{
"custom.greeting": "Hello!",
})
got := svc.T("custom.greeting")
if got != "Hello!" {
t.Errorf("T(custom.greeting) = %q, want 'Hello!'", got)
}
}
func TestServiceHandlers(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
initial := len(svc.Handlers())
if initial != 6 {
t.Errorf("default handlers = %d, want 6", initial)
}
svc.ClearHandlers()
if len(svc.Handlers()) != 0 {
t.Error("ClearHandlers() should remove all handlers")
}
}
func TestServiceWithOptions(t *testing.T) {
svc, err := New(
WithFallback("en"),
WithFormality(FormalityFormal),
WithMode(ModeCollect),
WithDebug(true),
)
if err != nil {
t.Fatalf("New() with options failed: %v", err)
}
if svc.Formality() != FormalityFormal {
t.Errorf("Formality = %v, want FormalityFormal", svc.Formality())
}
if svc.Mode() != ModeCollect {
t.Errorf("Mode = %v, want ModeCollect", svc.Mode())
}
if !svc.Debug() {
t.Error("Debug should be true")
}
}
func TestNewWithFS(t *testing.T) {
fs := fstest.MapFS{
"i18n/custom.json": &fstest.MapFile{
Data: []byte(`{"hello": "Hola!"}`),
},
}
svc, err := NewWithFS(fs, "i18n", WithFallback("custom"))
if err != nil {
t.Fatalf("NewWithFS failed: %v", err)
}
got := svc.T("hello")
if got != "Hola!" {
t.Errorf("T(hello) = %q, want 'Hola!'", got)
}
}
func TestServicePluralCategory(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
if svc.PluralCategory(1) != PluralOne {
t.Errorf("PluralCategory(1) = %v, want PluralOne", svc.PluralCategory(1))
}
if svc.PluralCategory(5) != PluralOther {
t.Errorf("PluralCategory(5) = %v, want PluralOther", svc.PluralCategory(5))
}
}

39
time.go Normal file
View file

@ -0,0 +1,39 @@
package i18n
import (
"fmt"
"time"
)
// TimeAgo returns a localised relative time string.
//
// TimeAgo(time.Now().Add(-5 * time.Minute)) // "5 minutes ago"
func TimeAgo(t time.Time) string {
duration := time.Since(t)
switch {
case duration < time.Minute:
return T("time.just_now")
case duration < time.Hour:
return FormatAgo(int(duration.Minutes()), "minute")
case duration < 24*time.Hour:
return FormatAgo(int(duration.Hours()), "hour")
case duration < 7*24*time.Hour:
return FormatAgo(int(duration.Hours()/24), "day")
default:
return FormatAgo(int(duration.Hours()/(24*7)), "week")
}
}
// FormatAgo formats "N unit ago" with proper pluralisation.
func FormatAgo(count int, unit string) string {
svc := Default()
if svc == nil {
return fmt.Sprintf("%d %ss ago", count, unit)
}
key := "time.ago." + unit
result := svc.T(key, map[string]any{"Count": count})
if result == key {
return fmt.Sprintf("%d %s ago", count, Pluralize(unit, count))
}
return result
}

117
transform.go Normal file
View file

@ -0,0 +1,117 @@
package i18n
func getCount(data any) int {
if data == nil {
return 0
}
switch d := data.(type) {
case map[string]any:
if c, ok := d["Count"]; ok {
return toInt(c)
}
case map[string]int:
if c, ok := d["Count"]; ok {
return c
}
}
return 0
}
func toInt(v any) int {
if v == nil {
return 0
}
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case int32:
return int(n)
case int16:
return int(n)
case int8:
return int(n)
case uint:
return int(n)
case uint64:
return int(n)
case uint32:
return int(n)
case uint16:
return int(n)
case uint8:
return int(n)
case float64:
return int(n)
case float32:
return int(n)
}
return 0
}
func toInt64(v any) int64 {
if v == nil {
return 0
}
switch n := v.(type) {
case int:
return int64(n)
case int64:
return n
case int32:
return int64(n)
case int16:
return int64(n)
case int8:
return int64(n)
case uint:
return int64(n)
case uint64:
return int64(n)
case uint32:
return int64(n)
case uint16:
return int64(n)
case uint8:
return int64(n)
case float64:
return int64(n)
case float32:
return int64(n)
}
return 0
}
func toFloat64(v any) float64 {
if v == nil {
return 0
}
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int64:
return float64(n)
case int32:
return float64(n)
case int16:
return float64(n)
case int8:
return float64(n)
case uint:
return float64(n)
case uint64:
return float64(n)
case uint32:
return float64(n)
case uint16:
return float64(n)
case uint8:
return float64(n)
}
return 0
}

423
types.go Normal file
View file

@ -0,0 +1,423 @@
// Package i18n provides grammar-aware internationalisation.
//
// Unlike flat key-value translation systems, this package composes
// grammatically correct output from verbs, nouns, and articles.
// The i18n.* namespace provides magic key handlers that auto-compose
// labels, progress messages, counts, and action results:
//
// T("i18n.label.status") // "Status:"
// T("i18n.progress.build") // "Building..."
// T("i18n.count.file", 5) // "5 files"
// T("i18n.done.delete", "config.yaml") // "Config.Yaml deleted"
// T("i18n.fail.push", "commits") // "Failed to push commits"
package i18n
import "sync"
// --- Core Types ---
// Mode determines how the service handles missing translation keys.
type Mode int
const (
ModeNormal Mode = iota // Returns key as-is (production)
ModeStrict // Panics on missing key (dev/CI)
ModeCollect // Dispatches MissingKey events, returns [key] (QA)
)
func (m Mode) String() string {
switch m {
case ModeNormal:
return "normal"
case ModeStrict:
return "strict"
case ModeCollect:
return "collect"
default:
return "unknown"
}
}
// Formality represents the level of formality in translations.
type Formality int
const (
FormalityNeutral Formality = iota // Context-appropriate (default)
FormalityInformal // du, tu, you
FormalityFormal // Sie, vous, usted
)
// TextDirection represents text directionality.
type TextDirection int
const (
DirLTR TextDirection = iota // Left-to-right
DirRTL // Right-to-left
)
// PluralCategory represents CLDR plural categories.
type PluralCategory int
const (
PluralOther PluralCategory = iota // Default/fallback
PluralZero // n=0 (Arabic, Latvian)
PluralOne // n=1 (most languages)
PluralTwo // n=2 (Arabic, Welsh)
PluralFew // Small numbers (Slavic: 2-4)
PluralMany // Larger numbers (Slavic: 5+)
)
// GrammaticalGender represents grammatical gender for nouns.
type GrammaticalGender int
const (
GenderNeuter GrammaticalGender = iota // das, it
GenderMasculine // der, le
GenderFeminine // die, la
GenderCommon // Swedish, Dutch
)
// --- Message Types ---
// Message represents a translation — either a simple string or plural forms.
type Message struct {
Text string // Simple string value (non-plural)
Zero string // count == 0 (Arabic, Latvian, Welsh)
One string // count == 1 (most languages)
Two string // count == 2 (Arabic, Welsh)
Few string // Small numbers (Slavic: 2-4, Arabic: 3-10)
Many string // Larger numbers (Slavic: 5+, Arabic: 11-99)
Other string // Default/fallback form
}
// ForCategory returns the appropriate text for a plural category.
func (m Message) ForCategory(cat PluralCategory) string {
switch cat {
case PluralZero:
if m.Zero != "" {
return m.Zero
}
case PluralOne:
if m.One != "" {
return m.One
}
case PluralTwo:
if m.Two != "" {
return m.Two
}
case PluralFew:
if m.Few != "" {
return m.Few
}
case PluralMany:
if m.Many != "" {
return m.Many
}
}
if m.Other != "" {
return m.Other
}
if m.One != "" {
return m.One
}
return m.Text
}
// IsPlural returns true if this message has any plural forms.
func (m Message) IsPlural() bool {
return m.Zero != "" || m.One != "" || m.Two != "" ||
m.Few != "" || m.Many != "" || m.Other != ""
}
// --- Subject Types ---
// Subject represents a typed subject with metadata for semantic translations.
type Subject struct {
Noun string // The noun type (e.g., "file", "repo")
Value any // The actual value (e.g., filename)
count int // Count for pluralisation (default 1)
gender string // Grammatical gender
location string // Location context (e.g., "in workspace")
formality Formality // Formality level override
}
// --- Intent Types ---
// IntentMeta defines the behaviour of an intent.
type IntentMeta struct {
Type string // "action", "question", "info"
Verb string // Reference to verb key
Dangerous bool // If true, requires confirmation
Default string // Default response: "yes" or "no"
Supports []string // Extra options
}
// Composed holds all output forms for an intent after template resolution.
type Composed struct {
Question string // "Delete config.yaml?"
Confirm string // "Really delete config.yaml?"
Success string // "config.yaml deleted"
Failure string // "Failed to delete config.yaml"
Meta IntentMeta // Intent metadata
}
// Intent defines a semantic intent with templates for all output forms.
type Intent struct {
Meta IntentMeta
Question string // Template for question form
Confirm string // Template for confirmation form
Success string // Template for success message
Failure string // Template for failure message
}
// templateData is passed to intent templates during execution.
type templateData struct {
Subject string
Noun string
Count int
Gender string
Location string
Formality Formality
IsFormal bool
IsPlural bool
Value any
}
// --- Grammar Types ---
// GrammarData holds language-specific grammar forms loaded from JSON.
type GrammarData struct {
Verbs map[string]VerbForms // verb -> forms
Nouns map[string]NounForms // noun -> forms
Articles ArticleForms // article configuration
Words map[string]string // base word translations
Punct PunctuationRules // language-specific punctuation
}
// VerbForms holds verb conjugations.
type VerbForms struct {
Past string // "deleted"
Gerund string // "deleting"
}
// NounForms holds plural and gender information for a noun.
type NounForms struct {
One string // Singular form
Other string // Plural form
Gender string // Grammatical gender
}
// ArticleForms holds article configuration for a language.
type ArticleForms struct {
IndefiniteDefault string // "a"
IndefiniteVowel string // "an"
Definite string // "the"
ByGender map[string]string // Gender-specific articles
}
// PunctuationRules holds language-specific punctuation patterns.
type PunctuationRules struct {
LabelSuffix string // ":" (French uses " :")
ProgressSuffix string // "..."
}
// --- Number Formatting ---
// NumberFormat defines locale-specific number formatting rules.
type NumberFormat struct {
ThousandsSep string // "," for en, "." for de
DecimalSep string // "." for en, "," for de
PercentFmt string // "%s%%" for en, "%s %%" for de
}
// --- Function Types ---
// PluralRule determines the plural category for a count.
type PluralRule func(n int) PluralCategory
// MissingKeyHandler receives missing key events.
type MissingKeyHandler func(missing MissingKey)
// MissingKey is dispatched when a translation key is not found in ModeCollect.
type MissingKey struct {
Key string
Args map[string]any
CallerFile string
CallerLine int
}
// --- Interfaces ---
// KeyHandler processes translation keys before standard lookup.
// Handlers form a chain; each can handle a key or delegate to the next.
type KeyHandler interface {
Match(key string) bool
Handle(key string, args []any, next func() string) string
}
// Loader provides translation data to the Service.
type Loader interface {
Load(lang string) (map[string]Message, *GrammarData, error)
Languages() []string
}
// Translator defines the interface for translation services.
type Translator interface {
T(messageID string, args ...any) string
SetLanguage(lang string) error
Language() string
SetMode(m Mode)
Mode() Mode
SetDebug(enabled bool)
Debug() bool
SetFormality(f Formality)
Formality() Formality
Direction() TextDirection
IsRTL() bool
PluralCategory(n int) PluralCategory
AvailableLanguages() []string
}
// --- Package Variables ---
var (
grammarCache = make(map[string]*GrammarData)
grammarCacheMu sync.RWMutex
)
var templateCache sync.Map
var numberFormats = map[string]NumberFormat{
"en": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"},
"de": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s %%"},
"fr": {ThousandsSep: " ", DecimalSep: ",", PercentFmt: "%s %%"},
"es": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s%%"},
"zh": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"},
}
var rtlLanguages = map[string]bool{
"ar": true, "ar-SA": true, "ar-EG": true,
"he": true, "he-IL": true,
"fa": true, "fa-IR": true,
"ur": true, "ur-PK": true,
"yi": true, "ps": true, "sd": true, "ug": true,
}
var pluralRules = map[string]PluralRule{
"en": pluralRuleEnglish, "en-GB": pluralRuleEnglish, "en-US": pluralRuleEnglish,
"de": pluralRuleGerman, "de-DE": pluralRuleGerman, "de-AT": pluralRuleGerman, "de-CH": pluralRuleGerman,
"fr": pluralRuleFrench, "fr-FR": pluralRuleFrench, "fr-CA": pluralRuleFrench,
"es": pluralRuleSpanish, "es-ES": pluralRuleSpanish, "es-MX": pluralRuleSpanish,
"ru": pluralRuleRussian, "ru-RU": pluralRuleRussian,
"pl": pluralRulePolish, "pl-PL": pluralRulePolish,
"ar": pluralRuleArabic, "ar-SA": pluralRuleArabic,
"zh": pluralRuleChinese, "zh-CN": pluralRuleChinese, "zh-TW": pluralRuleChinese,
"ja": pluralRuleJapanese, "ja-JP": pluralRuleJapanese,
"ko": pluralRuleKorean, "ko-KR": pluralRuleKorean,
}
// --- Irregular Forms ---
var irregularVerbs = map[string]VerbForms{
"be": {Past: "was", Gerund: "being"}, "have": {Past: "had", Gerund: "having"},
"do": {Past: "did", Gerund: "doing"}, "go": {Past: "went", Gerund: "going"},
"make": {Past: "made", Gerund: "making"}, "get": {Past: "got", Gerund: "getting"},
"run": {Past: "ran", Gerund: "running"}, "set": {Past: "set", Gerund: "setting"},
"put": {Past: "put", Gerund: "putting"}, "cut": {Past: "cut", Gerund: "cutting"},
"let": {Past: "let", Gerund: "letting"}, "hit": {Past: "hit", Gerund: "hitting"},
"shut": {Past: "shut", Gerund: "shutting"}, "split": {Past: "split", Gerund: "splitting"},
"spread": {Past: "spread", Gerund: "spreading"}, "read": {Past: "read", Gerund: "reading"},
"write": {Past: "wrote", Gerund: "writing"}, "send": {Past: "sent", Gerund: "sending"},
"build": {Past: "built", Gerund: "building"}, "begin": {Past: "began", Gerund: "beginning"},
"find": {Past: "found", Gerund: "finding"}, "take": {Past: "took", Gerund: "taking"},
"see": {Past: "saw", Gerund: "seeing"}, "keep": {Past: "kept", Gerund: "keeping"},
"hold": {Past: "held", Gerund: "holding"}, "tell": {Past: "told", Gerund: "telling"},
"bring": {Past: "brought", Gerund: "bringing"}, "think": {Past: "thought", Gerund: "thinking"},
"buy": {Past: "bought", Gerund: "buying"}, "catch": {Past: "caught", Gerund: "catching"},
"teach": {Past: "taught", Gerund: "teaching"}, "throw": {Past: "threw", Gerund: "throwing"},
"grow": {Past: "grew", Gerund: "growing"}, "know": {Past: "knew", Gerund: "knowing"},
"show": {Past: "showed", Gerund: "showing"}, "draw": {Past: "drew", Gerund: "drawing"},
"break": {Past: "broke", Gerund: "breaking"}, "speak": {Past: "spoke", Gerund: "speaking"},
"choose": {Past: "chose", Gerund: "choosing"}, "forget": {Past: "forgot", Gerund: "forgetting"},
"lose": {Past: "lost", Gerund: "losing"}, "win": {Past: "won", Gerund: "winning"},
"swim": {Past: "swam", Gerund: "swimming"}, "drive": {Past: "drove", Gerund: "driving"},
"rise": {Past: "rose", Gerund: "rising"}, "shine": {Past: "shone", Gerund: "shining"},
"sing": {Past: "sang", Gerund: "singing"}, "ring": {Past: "rang", Gerund: "ringing"},
"drink": {Past: "drank", Gerund: "drinking"}, "sink": {Past: "sank", Gerund: "sinking"},
"sit": {Past: "sat", Gerund: "sitting"}, "stand": {Past: "stood", Gerund: "standing"},
"hang": {Past: "hung", Gerund: "hanging"}, "dig": {Past: "dug", Gerund: "digging"},
"stick": {Past: "stuck", Gerund: "sticking"}, "bite": {Past: "bit", Gerund: "biting"},
"hide": {Past: "hid", Gerund: "hiding"}, "feed": {Past: "fed", Gerund: "feeding"},
"meet": {Past: "met", Gerund: "meeting"}, "lead": {Past: "led", Gerund: "leading"},
"sleep": {Past: "slept", Gerund: "sleeping"}, "feel": {Past: "felt", Gerund: "feeling"},
"leave": {Past: "left", Gerund: "leaving"}, "mean": {Past: "meant", Gerund: "meaning"},
"lend": {Past: "lent", Gerund: "lending"}, "spend": {Past: "spent", Gerund: "spending"},
"bend": {Past: "bent", Gerund: "bending"}, "deal": {Past: "dealt", Gerund: "dealing"},
"lay": {Past: "laid", Gerund: "laying"}, "pay": {Past: "paid", Gerund: "paying"},
"say": {Past: "said", Gerund: "saying"}, "sell": {Past: "sold", Gerund: "selling"},
"seek": {Past: "sought", Gerund: "seeking"}, "fight": {Past: "fought", Gerund: "fighting"},
"fly": {Past: "flew", Gerund: "flying"}, "wear": {Past: "wore", Gerund: "wearing"},
"tear": {Past: "tore", Gerund: "tearing"}, "bear": {Past: "bore", Gerund: "bearing"},
"swear": {Past: "swore", Gerund: "swearing"}, "wake": {Past: "woke", Gerund: "waking"},
"freeze": {Past: "froze", Gerund: "freezing"}, "steal": {Past: "stole", Gerund: "stealing"},
"overwrite": {Past: "overwritten", Gerund: "overwriting"}, "reset": {Past: "reset", Gerund: "resetting"},
"reboot": {Past: "rebooted", Gerund: "rebooting"},
"submit": {Past: "submitted", Gerund: "submitting"}, "permit": {Past: "permitted", Gerund: "permitting"},
"admit": {Past: "admitted", Gerund: "admitting"}, "omit": {Past: "omitted", Gerund: "omitting"},
"commit": {Past: "committed", Gerund: "committing"}, "transmit": {Past: "transmitted", Gerund: "transmitting"},
"prefer": {Past: "preferred", Gerund: "preferring"}, "refer": {Past: "referred", Gerund: "referring"},
"transfer": {Past: "transferred", Gerund: "transferring"}, "defer": {Past: "deferred", Gerund: "deferring"},
"confer": {Past: "conferred", Gerund: "conferring"}, "infer": {Past: "inferred", Gerund: "inferring"},
"occur": {Past: "occurred", Gerund: "occurring"}, "recur": {Past: "recurred", Gerund: "recurring"},
"incur": {Past: "incurred", Gerund: "incurring"}, "deter": {Past: "deterred", Gerund: "deterring"},
"control": {Past: "controlled", Gerund: "controlling"}, "patrol": {Past: "patrolled", Gerund: "patrolling"},
"compel": {Past: "compelled", Gerund: "compelling"}, "expel": {Past: "expelled", Gerund: "expelling"},
"propel": {Past: "propelled", Gerund: "propelling"}, "repel": {Past: "repelled", Gerund: "repelling"},
"rebel": {Past: "rebelled", Gerund: "rebelling"}, "excel": {Past: "excelled", Gerund: "excelling"},
"cancel": {Past: "cancelled", Gerund: "cancelling"}, "travel": {Past: "travelled", Gerund: "travelling"},
"label": {Past: "labelled", Gerund: "labelling"}, "model": {Past: "modelled", Gerund: "modelling"},
"level": {Past: "levelled", Gerund: "levelling"},
"format": {Past: "formatted", Gerund: "formatting"},
"analyse": {Past: "analysed", Gerund: "analysing"},
"organise": {Past: "organised", Gerund: "organising"},
"recognise": {Past: "recognised", Gerund: "recognising"},
"realise": {Past: "realised", Gerund: "realising"},
"customise": {Past: "customised", Gerund: "customising"},
"optimise": {Past: "optimised", Gerund: "optimising"},
"initialise": {Past: "initialised", Gerund: "initialising"},
"synchronise": {Past: "synchronised", Gerund: "synchronising"},
}
var noDoubleConsonant = map[string]bool{
"open": true, "listen": true, "happen": true, "enter": true, "offer": true,
"suffer": true, "differ": true, "cover": true, "deliver": true, "develop": true,
"visit": true, "limit": true, "edit": true, "credit": true, "orbit": true,
"total": true, "target": true, "budget": true, "market": true, "benefit": true, "focus": true,
}
var irregularNouns = map[string]string{
"child": "children", "person": "people", "man": "men", "woman": "women",
"foot": "feet", "tooth": "teeth", "mouse": "mice", "goose": "geese",
"ox": "oxen", "index": "indices", "appendix": "appendices", "matrix": "matrices",
"vertex": "vertices", "crisis": "crises", "analysis": "analyses", "diagnosis": "diagnoses",
"thesis": "theses", "hypothesis": "hypotheses", "parenthesis": "parentheses",
"datum": "data", "medium": "media", "bacterium": "bacteria", "criterion": "criteria",
"phenomenon": "phenomena", "curriculum": "curricula", "alumnus": "alumni",
"cactus": "cacti", "focus": "foci", "fungus": "fungi", "nucleus": "nuclei",
"radius": "radii", "stimulus": "stimuli", "syllabus": "syllabi",
"fish": "fish", "sheep": "sheep", "deer": "deer", "species": "species",
"series": "series", "aircraft": "aircraft",
"life": "lives", "wife": "wives", "knife": "knives", "leaf": "leaves",
"half": "halves", "self": "selves", "shelf": "shelves", "wolf": "wolves",
"calf": "calves", "loaf": "loaves", "thief": "thieves",
}
var vowelSounds = map[string]bool{
"hour": true, "honest": true, "honour": true, "honor": true, "heir": true, "herb": true,
}
var consonantSounds = map[string]bool{
"user": true, "union": true, "unique": true, "unit": true, "universe": true,
"university": true, "uniform": true, "usage": true, "usual": true, "utility": true,
"utensil": true, "one": true, "once": true, "euro": true, "eulogy": true, "euphemism": true,
}

253
types_test.go Normal file
View file

@ -0,0 +1,253 @@
package i18n
import "testing"
func TestMessageForCategory(t *testing.T) {
msg := Message{
One: "1 item",
Other: "{{.Count}} items",
Zero: "no items",
}
if got := msg.ForCategory(PluralOne); got != "1 item" {
t.Errorf("ForCategory(One) = %q, want '1 item'", got)
}
if got := msg.ForCategory(PluralOther); got != "{{.Count}} items" {
t.Errorf("ForCategory(Other) = %q, want template", got)
}
if got := msg.ForCategory(PluralZero); got != "no items" {
t.Errorf("ForCategory(Zero) = %q, want 'no items'", got)
}
// Falls back to Other when category not set
msg2 := Message{Other: "many"}
if got := msg2.ForCategory(PluralFew); got != "many" {
t.Errorf("ForCategory(Few) fallback = %q, want 'many'", got)
}
// Falls back to One if Other not set
msg3 := Message{One: "single"}
if got := msg3.ForCategory(PluralFew); got != "single" {
t.Errorf("ForCategory(Few) fallback to One = %q, want 'single'", got)
}
// Falls back to Text
msg4 := Message{Text: "default"}
if got := msg4.ForCategory(PluralOther); got != "default" {
t.Errorf("ForCategory fallback to Text = %q, want 'default'", got)
}
}
func TestMessageIsPlural(t *testing.T) {
if (Message{Text: "hello"}).IsPlural() {
t.Error("simple message should not be plural")
}
if !(Message{One: "a", Other: "b"}).IsPlural() {
t.Error("message with One+Other should be plural")
}
if !(Message{Zero: "none"}).IsPlural() {
t.Error("message with Zero should be plural")
}
}
func TestSubjectFluent(t *testing.T) {
s := S("file", "config.yaml")
if s.Noun != "file" {
t.Errorf("Noun = %q, want 'file'", s.Noun)
}
if s.String() != "config.yaml" {
t.Errorf("String() = %q, want 'config.yaml'", s.String())
}
if s.CountInt() != 1 {
t.Errorf("CountInt() = %d, want 1", s.CountInt())
}
if s.IsPlural() {
t.Error("count=1 should not be plural")
}
// Chain
s.Count(3).Gender("neuter").In("workspace").Formal()
if s.CountInt() != 3 {
t.Errorf("CountInt() = %d, want 3", s.CountInt())
}
if !s.IsPlural() {
t.Error("count=3 should be plural")
}
if s.GenderString() != "neuter" {
t.Errorf("GenderString() = %q, want 'neuter'", s.GenderString())
}
if s.LocationString() != "workspace" {
t.Errorf("LocationString() = %q, want 'workspace'", s.LocationString())
}
if !s.IsFormal() {
t.Error("should be formal")
}
}
func TestSubjectNil(t *testing.T) {
var s *Subject
if s.Count(3) != nil {
t.Error("nil.Count() should return nil")
}
if s.Gender("m") != nil {
t.Error("nil.Gender() should return nil")
}
if s.In("x") != nil {
t.Error("nil.In() should return nil")
}
if s.Formal() != nil {
t.Error("nil.Formal() should return nil")
}
if s.Informal() != nil {
t.Error("nil.Informal() should return nil")
}
if s.String() != "" {
t.Error("nil.String() should be empty")
}
if s.IsPlural() {
t.Error("nil.IsPlural() should be false")
}
}
func TestTranslationContext(t *testing.T) {
c := C("navigation")
if c.ContextString() != "navigation" {
t.Errorf("Context = %q, want 'navigation'", c.ContextString())
}
c.WithGender("masculine").Formal()
if c.GenderString() != "masculine" {
t.Errorf("Gender = %q, want 'masculine'", c.GenderString())
}
if c.FormalityValue() != FormalityFormal {
t.Error("should be formal")
}
c.Set("key", "value")
if c.Get("key") != "value" {
t.Errorf("Get(key) = %v, want 'value'", c.Get("key"))
}
}
func TestTranslationContextNil(t *testing.T) {
var c *TranslationContext
if c.WithGender("m") != nil {
t.Error("nil.WithGender() should return nil")
}
if c.Formal() != nil {
t.Error("nil.Formal() should return nil")
}
if c.Informal() != nil {
t.Error("nil.Informal() should return nil")
}
if c.WithFormality(FormalityFormal) != nil {
t.Error("nil.WithFormality() should return nil")
}
if c.Set("k", "v") != nil {
t.Error("nil.Set() should return nil")
}
if c.Get("k") != nil {
t.Error("nil.Get() should return nil")
}
if c.ContextString() != "" {
t.Error("nil.ContextString() should be empty")
}
}
func TestModeString(t *testing.T) {
tests := []struct {
m Mode
want string
}{
{ModeNormal, "normal"},
{ModeStrict, "strict"},
{ModeCollect, "collect"},
{Mode(99), "unknown"},
}
for _, tt := range tests {
if got := tt.m.String(); got != tt.want {
t.Errorf("Mode(%d).String() = %q, want %q", tt.m, got, tt.want)
}
}
}
func TestFormalityString(t *testing.T) {
tests := []struct {
f Formality
want string
}{
{FormalityNeutral, "neutral"},
{FormalityInformal, "informal"},
{FormalityFormal, "formal"},
}
for _, tt := range tests {
if got := tt.f.String(); got != tt.want {
t.Errorf("Formality(%d).String() = %q, want %q", tt.f, got, tt.want)
}
}
}
func TestTextDirectionString(t *testing.T) {
if DirLTR.String() != "ltr" {
t.Errorf("DirLTR.String() = %q", DirLTR.String())
}
if DirRTL.String() != "rtl" {
t.Errorf("DirRTL.String() = %q", DirRTL.String())
}
}
func TestPluralCategoryString(t *testing.T) {
tests := []struct {
p PluralCategory
want string
}{
{PluralOther, "other"},
{PluralZero, "zero"},
{PluralOne, "one"},
{PluralTwo, "two"},
{PluralFew, "few"},
{PluralMany, "many"},
}
for _, tt := range tests {
if got := tt.p.String(); got != tt.want {
t.Errorf("PluralCategory(%d).String() = %q, want %q", tt.p, got, tt.want)
}
}
}
func TestGrammaticalGenderString(t *testing.T) {
tests := []struct {
g GrammaticalGender
want string
}{
{GenderNeuter, "neuter"},
{GenderMasculine, "masculine"},
{GenderFeminine, "feminine"},
{GenderCommon, "common"},
}
for _, tt := range tests {
if got := tt.g.String(); got != tt.want {
t.Errorf("GrammaticalGender(%d).String() = %q, want %q", tt.g, got, tt.want)
}
}
}
func TestIsRTLLanguage(t *testing.T) {
tests := []struct {
lang string
want bool
}{
{"en", false},
{"de", false},
{"ar", true},
{"ar-SA", true},
{"he", true},
{"fa", true},
{"ur-PK", true},
}
for _, tt := range tests {
if got := IsRTLLanguage(tt.lang); got != tt.want {
t.Errorf("IsRTLLanguage(%q) = %v, want %v", tt.lang, got, tt.want)
}
}
}