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("repo", "core-php").Count(3)
|
||||
// S("user", user).Gender("female")
|
||||
// S("user", user).Gender("feminine")
|
||||
// S("colleague", name).Formal()
|
||||
type Subject struct {
|
||||
Noun string // The noun type (e.g., "file", "repo", "user")
|
||||
Value any // The actual value (e.g., filename, struct, etc.)
|
||||
count int // Count for pluralization (default 1)
|
||||
gender string // Grammatical gender for languages that need it
|
||||
location string // Location context (e.g., "in workspace")
|
||||
Noun string // The noun type (e.g., "file", "repo", "user")
|
||||
Value any // The actual value (e.g., filename, struct, etc.)
|
||||
count int // Count for pluralization (default 1)
|
||||
gender string // Grammatical gender for languages that need it
|
||||
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.
|
||||
|
|
@ -66,6 +68,32 @@ func (s *Subject) In(location string) *Subject {
|
|||
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.
|
||||
func (s *Subject) String() string {
|
||||
if s == nil {
|
||||
|
|
@ -114,6 +142,25 @@ func (s *Subject) GetNoun() string {
|
|||
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.
|
||||
type IntentMeta struct {
|
||||
Type string // "action", "question", "info"
|
||||
|
|
@ -145,12 +192,15 @@ type Intent struct {
|
|||
|
||||
// templateData is passed to intent templates during execution.
|
||||
type templateData struct {
|
||||
Subject string // Display value of subject
|
||||
Noun string // Noun type
|
||||
Count int // Count for pluralization
|
||||
Gender string // Grammatical gender
|
||||
Location string // Location context
|
||||
Value any // Raw value (for complex templates)
|
||||
Subject string // Display value of subject
|
||||
Noun string // Noun type
|
||||
Count int // Count for pluralization
|
||||
Gender string // Grammatical gender
|
||||
Location string // Location context
|
||||
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.
|
||||
|
|
@ -159,11 +209,103 @@ func newTemplateData(s *Subject) templateData {
|
|||
return templateData{Count: 1}
|
||||
}
|
||||
return templateData{
|
||||
Subject: s.String(),
|
||||
Noun: s.Noun,
|
||||
Count: s.count,
|
||||
Gender: s.gender,
|
||||
Location: s.location,
|
||||
Value: s.Value,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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.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
|
||||
|
||||
// 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 {
|
||||
Text string // Simple string value
|
||||
One string // Singular form (count == 1)
|
||||
Other string // Plural form (count != 1)
|
||||
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
|
||||
}
|
||||
|
||||
// IsPlural returns true if this message has plural forms.
|
||||
// IsPlural returns true if this message has any plural forms.
|
||||
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.
|
||||
|
|
@ -59,8 +100,9 @@ type Service struct {
|
|||
currentLang string
|
||||
fallbackLang string
|
||||
availableLangs []language.Tag
|
||||
mode Mode // Translation mode (Normal, Strict, Collect)
|
||||
debug bool // Debug mode shows key prefixes
|
||||
mode Mode // Translation mode (Normal, Strict, Collect)
|
||||
debug bool // Debug mode shows key prefixes
|
||||
formality Formality // Default formality level for translations
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
|
|
@ -151,12 +193,24 @@ func flatten(prefix string, data map[string]any, out map[string]Message) {
|
|||
out[fullKey] = Message{Text: v}
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
|
@ -170,13 +224,20 @@ func flatten(prefix string, data map[string]any, out map[string]Message) {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
_, hasZero := m["zero"]
|
||||
_, hasOne := m["one"]
|
||||
_, hasTwo := m["two"]
|
||||
_, hasFew := m["few"]
|
||||
_, hasMany := m["many"]
|
||||
_, 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
|
||||
}
|
||||
// But not if it contains nested objects (those are namespace containers)
|
||||
for _, v := range m {
|
||||
if _, isMap := v.(map[string]any); isMap {
|
||||
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.
|
||||
// For semantic intents (core.* namespace), pass a Subject as the first argument.
|
||||
//
|
||||
|
|
@ -398,6 +481,45 @@ func (s *Service) Debug() bool {
|
|||
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.
|
||||
// Optional template data can be passed for interpolation.
|
||||
//
|
||||
|
|
@ -442,14 +564,9 @@ func (s *Service) T(messageID string, args ...any) string {
|
|||
text := msg.Text
|
||||
if msg.IsPlural() {
|
||||
count := getCount(data)
|
||||
if count == 1 {
|
||||
text = msg.One
|
||||
} else {
|
||||
text = msg.Other
|
||||
}
|
||||
if text == "" {
|
||||
text = msg.Other // Fallback to other
|
||||
}
|
||||
// Use CLDR plural category for current language
|
||||
category := GetPluralCategory(s.currentLang, count)
|
||||
text = msg.ForCategory(category)
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
|
|
|
|||
|
|
@ -165,6 +165,89 @@ func TestNestedKeys(t *testing.T) {
|
|||
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) {
|
||||
t.Run("default is disabled", func(t *testing.T) {
|
||||
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