From 46f6d4c5fe07f58c4bdca83fa4953e471b884217 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 12:55:41 +0000 Subject: [PATCH] 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 --- pkg/i18n/compose.go | 178 ++++++++++++++++++++--- pkg/i18n/compose_test.go | 96 +++++++++++++ pkg/i18n/i18n.go | 153 +++++++++++++++++--- pkg/i18n/i18n_test.go | 83 +++++++++++ pkg/i18n/language.go | 293 ++++++++++++++++++++++++++++++++++++++ pkg/i18n/language_test.go | 172 ++++++++++++++++++++++ 6 files changed, 939 insertions(+), 36 deletions(-) create mode 100644 pkg/i18n/language.go create mode 100644 pkg/i18n/language_test.go diff --git a/pkg/i18n/compose.go b/pkg/i18n/compose.go index d82f961..867150b 100644 --- a/pkg/i18n/compose.go +++ b/pkg/i18n/compose.go @@ -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 +} diff --git a/pkg/i18n/compose_test.go b/pkg/i18n/compose_test.go index 6909409..fa01c97 100644 --- a/pkg/i18n/compose_test.go +++ b/pkg/i18n/compose_test.go @@ -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()) + }) } diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go index e0857af..4e8a08e 100644 --- a/pkg/i18n/i18n.go +++ b/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 == "" { diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go index 98a9c7c..8a78a5c 100644 --- a/pkg/i18n/i18n_test.go +++ b/pkg/i18n/i18n_test.go @@ -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() diff --git a/pkg/i18n/language.go b/pkg/i18n/language.go new file mode 100644 index 0000000..5979ca4 --- /dev/null +++ b/pkg/i18n/language.go @@ -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) +} diff --git a/pkg/i18n/language_test.go b/pkg/i18n/language_test.go new file mode 100644 index 0000000..617b5e6 --- /dev/null +++ b/pkg/i18n/language_test.go @@ -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)) +}