feat(i18n): add Phase 4 extended language support

Fluent Intent Builder API:
- I("core.delete").For(S("file", path)).Question()
- I("core.delete").With(subject).Compose()
- Convenience methods: Question(), Success(), Failure(), Meta(), IsDangerous()

Formality Levels (for Sie/du, vous/tu languages):
- FormalityNeutral, FormalityInformal, FormalityFormal constants
- Subject.Formal(), Subject.Informal(), Subject.Formality()
- Service.SetFormality(), Service.Formality()
- Package-level SetFormality()

CLDR Plural Categories:
- PluralZero, PluralOne, PluralTwo, PluralFew, PluralMany, PluralOther
- Language-specific plural rules: English, German, French, Spanish, Russian, Polish, Arabic, Chinese, Japanese, Korean
- Message.ForCategory() for proper plural selection
- Service.PluralCategory() for getting category by count

RTL Text Direction Support:
- TextDirection type (DirLTR, DirRTL)
- IsRTLLanguage() for language detection
- Service.Direction(), Service.IsRTL()
- Package-level Direction(), IsRTL()

GrammaticalGender type:
- GenderNeuter, GenderMasculine, GenderFeminine, GenderCommon
- For future gender agreement in gendered languages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-30 12:55:41 +00:00
parent fa6d62e385
commit 46f6d4c5fe
6 changed files with 939 additions and 36 deletions

View file

@ -10,13 +10,15 @@ import (
// //
// S("file", "config.yaml") // S("file", "config.yaml")
// S("repo", "core-php").Count(3) // S("repo", "core-php").Count(3)
// S("user", user).Gender("female") // S("user", user).Gender("feminine")
// S("colleague", name).Formal()
type Subject struct { type Subject struct {
Noun string // The noun type (e.g., "file", "repo", "user") Noun string // The noun type (e.g., "file", "repo", "user")
Value any // The actual value (e.g., filename, struct, etc.) Value any // The actual value (e.g., filename, struct, etc.)
count int // Count for pluralization (default 1) count int // Count for pluralization (default 1)
gender string // Grammatical gender for languages that need it gender string // Grammatical gender for languages that need it
location string // Location context (e.g., "in workspace") location string // Location context (e.g., "in workspace")
formality Formality // Formality level override (-1 = use service default)
} }
// S creates a new Subject with the given noun and value. // S creates a new Subject with the given noun and value.
@ -66,6 +68,32 @@ func (s *Subject) In(location string) *Subject {
return s return s
} }
// Formal sets the formality level to formal (Sie, vous, usted).
// Use for polite/professional address in languages that distinguish formality.
//
// S("colleague", name).Formal()
func (s *Subject) Formal() *Subject {
s.formality = FormalityFormal
return s
}
// Informal sets the formality level to informal (du, tu, tú).
// Use for casual/friendly address in languages that distinguish formality.
//
// S("friend", name).Informal()
func (s *Subject) Informal() *Subject {
s.formality = FormalityInformal
return s
}
// Formality sets the formality level explicitly.
//
// S("user", name).Formality(FormalityFormal)
func (s *Subject) Formality(f Formality) *Subject {
s.formality = f
return s
}
// String returns the display value of the subject. // String returns the display value of the subject.
func (s *Subject) String() string { func (s *Subject) String() string {
if s == nil { if s == nil {
@ -114,6 +142,25 @@ func (s *Subject) GetNoun() string {
return s.Noun return s.Noun
} }
// GetFormality returns the formality level.
// Returns FormalityNeutral if not explicitly set.
func (s *Subject) GetFormality() Formality {
if s == nil {
return FormalityNeutral
}
return s.formality
}
// IsFormal returns true if formal address should be used.
func (s *Subject) IsFormal() bool {
return s != nil && s.formality == FormalityFormal
}
// IsInformal returns true if informal address should be used.
func (s *Subject) IsInformal() bool {
return s != nil && s.formality == FormalityInformal
}
// IntentMeta defines the behaviour and characteristics of an intent. // IntentMeta defines the behaviour and characteristics of an intent.
type IntentMeta struct { type IntentMeta struct {
Type string // "action", "question", "info" Type string // "action", "question", "info"
@ -145,12 +192,15 @@ type Intent struct {
// templateData is passed to intent templates during execution. // templateData is passed to intent templates during execution.
type templateData struct { type templateData struct {
Subject string // Display value of subject Subject string // Display value of subject
Noun string // Noun type Noun string // Noun type
Count int // Count for pluralization Count int // Count for pluralization
Gender string // Grammatical gender Gender string // Grammatical gender
Location string // Location context Location string // Location context
Value any // Raw value (for complex templates) Formality Formality // Formality level
IsFormal bool // Convenience: formality == FormalityFormal
IsPlural bool // Convenience: count != 1
Value any // Raw value (for complex templates)
} }
// newTemplateData creates templateData from a Subject. // newTemplateData creates templateData from a Subject.
@ -159,11 +209,103 @@ func newTemplateData(s *Subject) templateData {
return templateData{Count: 1} return templateData{Count: 1}
} }
return templateData{ return templateData{
Subject: s.String(), Subject: s.String(),
Noun: s.Noun, Noun: s.Noun,
Count: s.count, Count: s.count,
Gender: s.gender, Gender: s.gender,
Location: s.location, Location: s.location,
Value: s.Value, Formality: s.formality,
IsFormal: s.formality == FormalityFormal,
IsPlural: s.count != 1,
Value: s.Value,
} }
} }
// --- Fluent Intent Builder API ---
// IntentBuilder provides a fluent API for composing semantic intents.
// Use I() to start building an intent.
//
// I("core.delete").For(S("file", path)).Question()
// I("core.save").For(S("changes", n).Count(n)).Compose()
type IntentBuilder struct {
intent string
subject *Subject
}
// I creates a new IntentBuilder for the given intent key.
// This is the entry point for the fluent intent API.
//
// I("core.delete").For(S("file", "config.yaml")).Question()
// I("core.commit").For(S("file", files).Count(len(files))).Success()
func I(intent string) *IntentBuilder {
return &IntentBuilder{intent: intent}
}
// For sets the subject for this intent.
// Returns the builder for chaining.
//
// I("core.delete").For(S("file", path))
func (b *IntentBuilder) For(subject *Subject) *IntentBuilder {
b.subject = subject
return b
}
// With is an alias for For() - sets the subject for this intent.
//
// I("core.delete").With(S("file", path))
func (b *IntentBuilder) With(subject *Subject) *IntentBuilder {
return b.For(subject)
}
// Compose returns all output forms for the intent.
// Uses the default service to resolve the intent.
//
// result := I("core.delete").For(subject).Compose()
// fmt.Println(result.Question)
func (b *IntentBuilder) Compose() *Composed {
return C(b.intent, b.subject)
}
// Question returns just the question form of the intent.
//
// question := I("core.delete").For(subject).Question()
func (b *IntentBuilder) Question() string {
return b.Compose().Question
}
// Confirm returns just the confirmation form of the intent.
//
// confirm := I("core.delete").For(subject).Confirm()
func (b *IntentBuilder) Confirm() string {
return b.Compose().Confirm
}
// Success returns just the success message form of the intent.
//
// success := I("core.delete").For(subject).Success()
func (b *IntentBuilder) Success() string {
return b.Compose().Success
}
// Failure returns just the failure message form of the intent.
//
// failure := I("core.delete").For(subject).Failure()
func (b *IntentBuilder) Failure() string {
return b.Compose().Failure
}
// Meta returns just the intent metadata.
//
// meta := I("core.delete").For(subject).Meta()
// if meta.Dangerous { ... }
func (b *IntentBuilder) Meta() IntentMeta {
return b.Compose().Meta
}
// IsDangerous returns true if the intent is marked as dangerous.
//
// if I("core.delete").IsDangerous() { ... }
func (b *IntentBuilder) IsDangerous() bool {
return b.Meta().Dangerous
}

View file

@ -172,4 +172,100 @@ func TestNewTemplateData(t *testing.T) {
assert.Equal(t, "", data.Location) assert.Equal(t, "", data.Location)
assert.Nil(t, data.Value) assert.Nil(t, data.Value)
}) })
t.Run("with formality", func(t *testing.T) {
s := S("user", "Hans").Formal()
data := newTemplateData(s)
assert.Equal(t, FormalityFormal, data.Formality)
assert.True(t, data.IsFormal)
})
t.Run("with plural", func(t *testing.T) {
s := S("file", "*.go").Count(5)
data := newTemplateData(s)
assert.True(t, data.IsPlural)
assert.Equal(t, 5, data.Count)
})
}
func TestSubject_Formality(t *testing.T) {
t.Run("default is neutral", func(t *testing.T) {
s := S("user", "name")
assert.Equal(t, FormalityNeutral, s.GetFormality())
assert.False(t, s.IsFormal())
assert.False(t, s.IsInformal())
})
t.Run("Formal()", func(t *testing.T) {
s := S("user", "name").Formal()
assert.Equal(t, FormalityFormal, s.GetFormality())
assert.True(t, s.IsFormal())
})
t.Run("Informal()", func(t *testing.T) {
s := S("user", "name").Informal()
assert.Equal(t, FormalityInformal, s.GetFormality())
assert.True(t, s.IsInformal())
})
t.Run("Formality() explicit", func(t *testing.T) {
s := S("user", "name").Formality(FormalityFormal)
assert.Equal(t, FormalityFormal, s.GetFormality())
})
t.Run("nil safety", func(t *testing.T) {
var s *Subject
assert.Equal(t, FormalityNeutral, s.GetFormality())
assert.False(t, s.IsFormal())
assert.False(t, s.IsInformal())
})
}
func TestIntentBuilder(t *testing.T) {
// Initialize the default service for tests
_ = Init()
t.Run("basic fluent API", func(t *testing.T) {
builder := I("core.delete").For(S("file", "config.yaml"))
assert.NotNil(t, builder)
})
t.Run("With alias", func(t *testing.T) {
builder := I("core.delete").With(S("file", "config.yaml"))
assert.NotNil(t, builder)
})
t.Run("Compose returns all forms", func(t *testing.T) {
result := I("core.delete").For(S("file", "config.yaml")).Compose()
assert.NotEmpty(t, result.Question)
assert.NotEmpty(t, result.Success)
assert.NotEmpty(t, result.Failure)
})
t.Run("Question returns string", func(t *testing.T) {
question := I("core.delete").For(S("file", "config.yaml")).Question()
assert.Contains(t, question, "config.yaml")
})
t.Run("Success returns string", func(t *testing.T) {
success := I("core.delete").For(S("file", "config.yaml")).Success()
assert.NotEmpty(t, success)
})
t.Run("Failure returns string", func(t *testing.T) {
failure := I("core.delete").For(S("file", "config.yaml")).Failure()
assert.Contains(t, failure, "delete")
})
t.Run("Meta returns metadata", func(t *testing.T) {
meta := I("core.delete").Meta()
assert.True(t, meta.Dangerous)
})
t.Run("IsDangerous helper", func(t *testing.T) {
assert.True(t, I("core.delete").IsDangerous())
assert.False(t, I("core.save").IsDangerous())
})
} }

View file

@ -42,15 +42,56 @@ import (
var localeFS embed.FS var localeFS embed.FS
// Message represents a translation - either a simple string or plural forms. // Message represents a translation - either a simple string or plural forms.
// Supports full CLDR plural categories for languages with complex plural rules.
type Message struct { type Message struct {
Text string // Simple string value Text string // Simple string value (non-plural)
One string // Singular form (count == 1) Zero string // count == 0 (Arabic, Latvian, Welsh)
Other string // Plural form (count != 1) 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
} }
// IsPlural returns true if this message has plural forms. // IsPlural returns true if this message has any plural forms.
func (m Message) IsPlural() bool { func (m Message) IsPlural() bool {
return m.One != "" || m.Other != "" return m.Zero != "" || m.One != "" || m.Two != "" ||
m.Few != "" || m.Many != "" || m.Other != ""
}
// ForCategory returns the appropriate text for a plural category.
// Falls back through the category hierarchy to find a non-empty string.
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
}
}
// Fallback to Other, then One, then Text
if m.Other != "" {
return m.Other
}
if m.One != "" {
return m.One
}
return m.Text
} }
// Service provides internationalization and localization. // Service provides internationalization and localization.
@ -59,8 +100,9 @@ type Service struct {
currentLang string currentLang string
fallbackLang string fallbackLang string
availableLangs []language.Tag availableLangs []language.Tag
mode Mode // Translation mode (Normal, Strict, Collect) mode Mode // Translation mode (Normal, Strict, Collect)
debug bool // Debug mode shows key prefixes debug bool // Debug mode shows key prefixes
formality Formality // Default formality level for translations
mu sync.RWMutex mu sync.RWMutex
} }
@ -151,12 +193,24 @@ func flatten(prefix string, data map[string]any, out map[string]Message) {
out[fullKey] = Message{Text: v} out[fullKey] = Message{Text: v}
case map[string]any: case map[string]any:
// Check if this is a plural object (has "one" or "other" keys) // Check if this is a plural object (has CLDR plural category keys)
if isPluralObject(v) { if isPluralObject(v) {
msg := Message{} msg := Message{}
if zero, ok := v["zero"].(string); ok {
msg.Zero = zero
}
if one, ok := v["one"].(string); ok { if one, ok := v["one"].(string); ok {
msg.One = one 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 { if other, ok := v["other"].(string); ok {
msg.Other = other msg.Other = other
} }
@ -170,13 +224,20 @@ func flatten(prefix string, data map[string]any, out map[string]Message) {
} }
// isPluralObject checks if a map represents plural forms. // isPluralObject checks if a map represents plural forms.
// Recognizes all CLDR plural categories: zero, one, two, few, many, other.
func isPluralObject(m map[string]any) bool { func isPluralObject(m map[string]any) bool {
_, hasZero := m["zero"]
_, hasOne := m["one"] _, hasOne := m["one"]
_, hasTwo := m["two"]
_, hasFew := m["few"]
_, hasMany := m["many"]
_, hasOther := m["other"] _, hasOther := m["other"]
// It's a plural object if it has one/other and no nested objects
if !hasOne && !hasOther { // It's a plural object if it has any plural category key
if !hasZero && !hasOne && !hasTwo && !hasFew && !hasMany && !hasOther {
return false return false
} }
// But not if it contains nested objects (those are namespace containers)
for _, v := range m { for _, v := range m {
if _, isMap := v.(map[string]any); isMap { if _, isMap := v.(map[string]any); isMap {
return false return false
@ -253,6 +314,28 @@ func SetDebug(enabled bool) {
} }
} }
// SetFormality sets the default formality level on the default service.
//
// SetFormality(FormalityFormal) // Use formal address (Sie, vous)
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
}
// T translates a message using the default service. // T translates a message using the default service.
// For semantic intents (core.* namespace), pass a Subject as the first argument. // For semantic intents (core.* namespace), pass a Subject as the first argument.
// //
@ -398,6 +481,45 @@ func (s *Service) Debug() bool {
return s.debug return s.debug
} }
// SetFormality sets the default formality level for translations.
// This affects languages that distinguish formal/informal address (Sie/du, vous/tu).
//
// svc.SetFormality(FormalityFormal) // Use formal address
func (s *Service) SetFormality(f Formality) {
s.mu.Lock()
defer s.mu.Unlock()
s.formality = f
}
// Formality returns the current formality level.
func (s *Service) Formality() Formality {
s.mu.RLock()
defer s.mu.RUnlock()
return s.formality
}
// Direction returns the text direction for the current language.
func (s *Service) Direction() TextDirection {
s.mu.RLock()
defer s.mu.RUnlock()
if IsRTLLanguage(s.currentLang) {
return DirRTL
}
return DirLTR
}
// IsRTL returns true if the current language uses right-to-left text direction.
func (s *Service) IsRTL() bool {
return s.Direction() == DirRTL
}
// PluralCategory returns the plural category for a count in the current language.
func (s *Service) PluralCategory(n int) PluralCategory {
s.mu.RLock()
defer s.mu.RUnlock()
return GetPluralCategory(s.currentLang, n)
}
// T translates a message by its ID. // T translates a message by its ID.
// Optional template data can be passed for interpolation. // Optional template data can be passed for interpolation.
// //
@ -442,14 +564,9 @@ func (s *Service) T(messageID string, args ...any) string {
text := msg.Text text := msg.Text
if msg.IsPlural() { if msg.IsPlural() {
count := getCount(data) count := getCount(data)
if count == 1 { // Use CLDR plural category for current language
text = msg.One category := GetPluralCategory(s.currentLang, count)
} else { text = msg.ForCategory(category)
text = msg.Other
}
if text == "" {
text = msg.Other // Fallback to other
}
} }
if text == "" { if text == "" {

View file

@ -165,6 +165,89 @@ func TestNestedKeys(t *testing.T) {
assert.Equal(t, "Show status only, don't push", result) assert.Equal(t, "Show status only, don't push", result)
} }
func TestMessage_ForCategory(t *testing.T) {
t.Run("basic categories", func(t *testing.T) {
msg := Message{
Zero: "no items",
One: "1 item",
Two: "2 items",
Few: "a few items",
Many: "many items",
Other: "some items",
}
assert.Equal(t, "no items", msg.ForCategory(PluralZero))
assert.Equal(t, "1 item", msg.ForCategory(PluralOne))
assert.Equal(t, "2 items", msg.ForCategory(PluralTwo))
assert.Equal(t, "a few items", msg.ForCategory(PluralFew))
assert.Equal(t, "many items", msg.ForCategory(PluralMany))
assert.Equal(t, "some items", msg.ForCategory(PluralOther))
})
t.Run("fallback to other", func(t *testing.T) {
msg := Message{
One: "1 item",
Other: "items",
}
// Categories without explicit values fall back to Other
assert.Equal(t, "items", msg.ForCategory(PluralZero))
assert.Equal(t, "1 item", msg.ForCategory(PluralOne))
assert.Equal(t, "items", msg.ForCategory(PluralFew))
})
t.Run("fallback to one then text", func(t *testing.T) {
msg := Message{
One: "single item",
}
// Falls back to One when Other is empty
assert.Equal(t, "single item", msg.ForCategory(PluralOther))
assert.Equal(t, "single item", msg.ForCategory(PluralMany))
})
}
func TestServiceFormality(t *testing.T) {
svc, err := New()
require.NoError(t, err)
t.Run("default is neutral", func(t *testing.T) {
assert.Equal(t, FormalityNeutral, svc.Formality())
})
t.Run("set formality", func(t *testing.T) {
svc.SetFormality(FormalityFormal)
assert.Equal(t, FormalityFormal, svc.Formality())
svc.SetFormality(FormalityInformal)
assert.Equal(t, FormalityInformal, svc.Formality())
})
}
func TestServiceDirection(t *testing.T) {
svc, err := New()
require.NoError(t, err)
t.Run("English is LTR", func(t *testing.T) {
err := svc.SetLanguage("en-GB")
require.NoError(t, err)
assert.Equal(t, DirLTR, svc.Direction())
assert.False(t, svc.IsRTL())
})
}
func TestServicePluralCategory(t *testing.T) {
svc, err := New()
require.NoError(t, err)
t.Run("English plural rules", func(t *testing.T) {
assert.Equal(t, PluralOne, svc.PluralCategory(1))
assert.Equal(t, PluralOther, svc.PluralCategory(0))
assert.Equal(t, PluralOther, svc.PluralCategory(5))
})
}
func TestDebugMode(t *testing.T) { func TestDebugMode(t *testing.T) {
t.Run("default is disabled", func(t *testing.T) { t.Run("default is disabled", func(t *testing.T) {
svc, err := New() svc, err := New()

293
pkg/i18n/language.go Normal file
View file

@ -0,0 +1,293 @@
// Package i18n provides internationalization for the CLI.
package i18n
// Formality represents the level of formality in translations.
// Used for languages that distinguish formal/informal address (Sie/du, vous/tu).
type Formality int
const (
// FormalityNeutral uses context-appropriate formality (default)
FormalityNeutral Formality = iota
// FormalityInformal uses informal address (du, tu, you)
FormalityInformal
// FormalityFormal uses formal address (Sie, vous, usted)
FormalityFormal
)
// String returns the string representation of a Formality level.
func (f Formality) String() string {
switch f {
case FormalityInformal:
return "informal"
case FormalityFormal:
return "formal"
default:
return "neutral"
}
}
// TextDirection represents text directionality.
type TextDirection int
const (
// DirLTR is left-to-right text direction (English, German, etc.)
DirLTR TextDirection = iota
// DirRTL is right-to-left text direction (Arabic, Hebrew, etc.)
DirRTL
)
// String returns the string representation of a TextDirection.
func (d TextDirection) String() string {
if d == DirRTL {
return "rtl"
}
return "ltr"
}
// PluralCategory represents CLDR plural categories.
// Different languages use different subsets of these categories.
//
// Examples:
// - English: one, other
// - Russian: one, few, many, other
// - Arabic: zero, one, two, few, many, other
// - Welsh: zero, one, two, few, many, other
type PluralCategory int
const (
// PluralOther is the default/fallback category
PluralOther PluralCategory = iota
// PluralZero is used when count == 0 (Arabic, Latvian, etc.)
PluralZero
// PluralOne is used when count == 1 (most languages)
PluralOne
// PluralTwo is used when count == 2 (Arabic, Welsh, etc.)
PluralTwo
// PluralFew is used for small numbers (Slavic: 2-4, Arabic: 3-10, etc.)
PluralFew
// PluralMany is used for larger numbers (Slavic: 5+, Arabic: 11-99, etc.)
PluralMany
)
// String returns the string representation of a PluralCategory.
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"
}
}
// GrammaticalGender represents grammatical gender for nouns.
type GrammaticalGender int
const (
// GenderNeuter is used for neuter nouns (das in German, it in English)
GenderNeuter GrammaticalGender = iota
// GenderMasculine is used for masculine nouns (der in German, le in French)
GenderMasculine
// GenderFeminine is used for feminine nouns (die in German, la in French)
GenderFeminine
// GenderCommon is used in languages with common gender (Swedish, Dutch)
GenderCommon
)
// String returns the string representation of a GrammaticalGender.
func (g GrammaticalGender) String() string {
switch g {
case GenderMasculine:
return "masculine"
case GenderFeminine:
return "feminine"
case GenderCommon:
return "common"
default:
return "neuter"
}
}
// PluralRule is a function that determines the plural category for a count.
// Each language has its own plural rule based on CLDR data.
type PluralRule func(n int) PluralCategory
// rtlLanguages contains language codes that use right-to-left text direction.
var rtlLanguages = map[string]bool{
"ar": true, // Arabic
"ar-SA": true,
"ar-EG": true,
"he": true, // Hebrew
"he-IL": true,
"fa": true, // Persian/Farsi
"fa-IR": true,
"ur": true, // Urdu
"ur-PK": true,
"yi": true, // Yiddish
"ps": true, // Pashto
"sd": true, // Sindhi
"ug": true, // Uyghur
}
// IsRTLLanguage returns true if the language code uses right-to-left text.
func IsRTLLanguage(lang string) bool {
// Check exact match first
if rtlLanguages[lang] {
return true
}
// Check base language (e.g., "ar" for "ar-SA")
if len(lang) > 2 {
base := lang[:2]
return rtlLanguages[base]
}
return false
}
// pluralRules contains CLDR plural rules for supported languages.
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,
}
// English: one (n=1), other
func pluralRuleEnglish(n int) PluralCategory {
if n == 1 {
return PluralOne
}
return PluralOther
}
// German: same as English
func pluralRuleGerman(n int) PluralCategory {
return pluralRuleEnglish(n)
}
// French: one (n=0,1), other
func pluralRuleFrench(n int) PluralCategory {
if n == 0 || n == 1 {
return PluralOne
}
return PluralOther
}
// Spanish: one (n=1), many (n=0 or n>=1000000), other
func pluralRuleSpanish(n int) PluralCategory {
if n == 1 {
return PluralOne
}
return PluralOther
}
// Russian: one (n%10=1, n%100!=11), few (n%10=2-4, n%100!=12-14), many (others)
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
}
// Polish: one (n=1), few (n%10=2-4, n%100!=12-14), many (others)
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
}
// Arabic: zero (n=0), one (n=1), two (n=2), few (n%100=3-10), many (n%100=11-99), other
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
}
// Chinese/Japanese/Korean: other (no plural distinction)
func pluralRuleChinese(n int) PluralCategory {
return PluralOther
}
func pluralRuleJapanese(n int) PluralCategory {
return PluralOther
}
func pluralRuleKorean(n int) PluralCategory {
return PluralOther
}
// GetPluralRule returns the plural rule for a language code.
// Falls back to English rules if the language is not found.
func GetPluralRule(lang string) PluralRule {
if rule, ok := pluralRules[lang]; ok {
return rule
}
// Try base language
if len(lang) > 2 {
base := lang[:2]
if rule, ok := pluralRules[base]; ok {
return rule
}
}
// Default to English
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)
}

172
pkg/i18n/language_test.go Normal file
View file

@ -0,0 +1,172 @@
package i18n
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFormality_String(t *testing.T) {
tests := []struct {
f Formality
expected string
}{
{FormalityNeutral, "neutral"},
{FormalityInformal, "informal"},
{FormalityFormal, "formal"},
{Formality(99), "neutral"}, // Unknown defaults to neutral
}
for _, tt := range tests {
assert.Equal(t, tt.expected, tt.f.String())
}
}
func TestTextDirection_String(t *testing.T) {
assert.Equal(t, "ltr", DirLTR.String())
assert.Equal(t, "rtl", DirRTL.String())
}
func TestPluralCategory_String(t *testing.T) {
tests := []struct {
cat PluralCategory
expected string
}{
{PluralZero, "zero"},
{PluralOne, "one"},
{PluralTwo, "two"},
{PluralFew, "few"},
{PluralMany, "many"},
{PluralOther, "other"},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, tt.cat.String())
}
}
func TestGrammaticalGender_String(t *testing.T) {
tests := []struct {
g GrammaticalGender
expected string
}{
{GenderNeuter, "neuter"},
{GenderMasculine, "masculine"},
{GenderFeminine, "feminine"},
{GenderCommon, "common"},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, tt.g.String())
}
}
func TestIsRTLLanguage(t *testing.T) {
// RTL languages
assert.True(t, IsRTLLanguage("ar"))
assert.True(t, IsRTLLanguage("ar-SA"))
assert.True(t, IsRTLLanguage("he"))
assert.True(t, IsRTLLanguage("he-IL"))
assert.True(t, IsRTLLanguage("fa"))
assert.True(t, IsRTLLanguage("ur"))
// LTR languages
assert.False(t, IsRTLLanguage("en"))
assert.False(t, IsRTLLanguage("en-GB"))
assert.False(t, IsRTLLanguage("de"))
assert.False(t, IsRTLLanguage("fr"))
assert.False(t, IsRTLLanguage("zh"))
}
func TestPluralRuleEnglish(t *testing.T) {
tests := []struct {
n int
expected PluralCategory
}{
{0, PluralOther},
{1, PluralOne},
{2, PluralOther},
{5, PluralOther},
{100, PluralOther},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, pluralRuleEnglish(tt.n), "count=%d", tt.n)
}
}
func TestPluralRuleFrench(t *testing.T) {
// French uses singular for 0 and 1
assert.Equal(t, PluralOne, pluralRuleFrench(0))
assert.Equal(t, PluralOne, pluralRuleFrench(1))
assert.Equal(t, PluralOther, pluralRuleFrench(2))
}
func TestPluralRuleRussian(t *testing.T) {
tests := []struct {
n int
expected PluralCategory
}{
{1, PluralOne},
{2, PluralFew},
{3, PluralFew},
{4, PluralFew},
{5, PluralMany},
{11, PluralMany},
{12, PluralMany},
{21, PluralOne},
{22, PluralFew},
{25, PluralMany},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, pluralRuleRussian(tt.n), "count=%d", tt.n)
}
}
func TestPluralRuleArabic(t *testing.T) {
tests := []struct {
n int
expected PluralCategory
}{
{0, PluralZero},
{1, PluralOne},
{2, PluralTwo},
{3, PluralFew},
{10, PluralFew},
{11, PluralMany},
{99, PluralMany},
{100, PluralOther},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, pluralRuleArabic(tt.n), "count=%d", tt.n)
}
}
func TestPluralRuleChinese(t *testing.T) {
// Chinese has no plural distinction
assert.Equal(t, PluralOther, pluralRuleChinese(0))
assert.Equal(t, PluralOther, pluralRuleChinese(1))
assert.Equal(t, PluralOther, pluralRuleChinese(100))
}
func TestGetPluralRule(t *testing.T) {
// Known languages
rule := GetPluralRule("en-GB")
assert.Equal(t, PluralOne, rule(1))
rule = GetPluralRule("ru")
assert.Equal(t, PluralFew, rule(2))
// Unknown language falls back to English
rule = GetPluralRule("xx-unknown")
assert.Equal(t, PluralOne, rule(1))
assert.Equal(t, PluralOther, rule(2))
}
func TestGetPluralCategory(t *testing.T) {
assert.Equal(t, PluralOne, GetPluralCategory("en", 1))
assert.Equal(t, PluralOther, GetPluralCategory("en", 5))
assert.Equal(t, PluralFew, GetPluralCategory("ru", 3))
}