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:
parent
fa6d62e385
commit
46f6d4c5fe
6 changed files with 939 additions and 36 deletions
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
153
pkg/i18n/i18n.go
153
pkg/i18n/i18n.go
|
|
@ -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 == "" {
|
||||||
|
|
|
||||||
|
|
@ -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
293
pkg/i18n/language.go
Normal 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
172
pkg/i18n/language_test.go
Normal 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))
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue