refactor(i18n): consolidate types into interfaces.go
Move all exported types to interfaces.go for consistent organisation. Rename interface.go → interfaces.go. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a7e404c9d0
commit
650c7b1c5a
9 changed files with 654 additions and 652 deletions
|
|
@ -5,22 +5,6 @@ import (
|
|||
"fmt"
|
||||
)
|
||||
|
||||
// Subject represents a typed subject with metadata for semantic translations.
|
||||
// Use S() to create a Subject and chain methods for additional context.
|
||||
//
|
||||
// S("file", "config.yaml")
|
||||
// S("repo", "core-php").Count(3)
|
||||
// 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")
|
||||
formality Formality // Formality level override (-1 = use service default)
|
||||
}
|
||||
|
||||
// S creates a new Subject with the given noun and value.
|
||||
// The noun is used for grammar rules, the value for display.
|
||||
//
|
||||
|
|
@ -161,48 +145,6 @@ 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"
|
||||
Verb string // Reference to verb key (e.g., "delete", "save")
|
||||
Dangerous bool // If true, requires extra confirmation
|
||||
Default string // Default response: "yes" or "no"
|
||||
Supports []string // Extra options supported by this intent
|
||||
}
|
||||
|
||||
// Composed holds all output forms for an intent after template resolution.
|
||||
// Each field is ready to display to the user.
|
||||
type Composed struct {
|
||||
Question string // Question form: "Delete config.yaml?"
|
||||
Confirm string // Confirmation form: "Really delete config.yaml?"
|
||||
Success string // Success message: "config.yaml deleted"
|
||||
Failure string // Failure message: "Failed to delete config.yaml"
|
||||
Meta IntentMeta // Intent metadata for UI decisions
|
||||
}
|
||||
|
||||
// Intent defines a semantic intent with templates for all output forms.
|
||||
// Templates use Go text/template syntax with Subject data available.
|
||||
type Intent struct {
|
||||
Meta IntentMeta // Intent behaviour and characteristics
|
||||
Question string // Template for question form
|
||||
Confirm string // Template for confirmation form
|
||||
Success string // Template for success message
|
||||
Failure string // Template for failure message
|
||||
}
|
||||
|
||||
// templateData is passed to intent templates during execution.
|
||||
type templateData struct {
|
||||
Subject string // 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.
|
||||
func newTemplateData(s *Subject) templateData {
|
||||
if s == nil {
|
||||
|
|
@ -220,4 +162,3 @@ func newTemplateData(s *Subject) templateData {
|
|||
Value: s.Value,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,48 +3,10 @@ package i18n
|
|||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// GrammarData holds language-specific grammar forms loaded from JSON.
|
||||
type GrammarData struct {
|
||||
Verbs map[string]VerbForms // verb -> forms
|
||||
Nouns map[string]NounForms // noun -> forms
|
||||
Articles ArticleForms // article configuration
|
||||
Words map[string]string // base word translations
|
||||
Punct PunctuationRules // language-specific punctuation
|
||||
}
|
||||
|
||||
// PunctuationRules holds language-specific punctuation patterns.
|
||||
// French uses " :" (space before colon), English uses ":"
|
||||
type PunctuationRules struct {
|
||||
LabelSuffix string // Suffix for labels (default ":")
|
||||
ProgressSuffix string // Suffix for progress (default "...")
|
||||
}
|
||||
|
||||
// NounForms holds plural and gender information for a noun.
|
||||
type NounForms struct {
|
||||
One string // Singular form
|
||||
Other string // Plural form
|
||||
Gender string // Grammatical gender (masculine, feminine, neuter, common)
|
||||
}
|
||||
|
||||
// ArticleForms holds article configuration for a language.
|
||||
type ArticleForms struct {
|
||||
IndefiniteDefault string // Default indefinite article (e.g., "a")
|
||||
IndefiniteVowel string // Indefinite article before vowel sounds (e.g., "an")
|
||||
Definite string // Definite article (e.g., "the")
|
||||
ByGender map[string]string // Gender-specific articles for gendered languages
|
||||
}
|
||||
|
||||
// grammarCache holds loaded grammar data per language.
|
||||
var (
|
||||
grammarCache = make(map[string]*GrammarData)
|
||||
grammarCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getGrammarData returns the grammar data for the current language.
|
||||
// Returns nil if no grammar data is loaded for the language.
|
||||
func getGrammarData(lang string) *GrammarData {
|
||||
|
|
@ -140,132 +102,6 @@ func currentLangForGrammar() string {
|
|||
return "en-GB"
|
||||
}
|
||||
|
||||
// VerbForms holds irregular verb conjugations.
|
||||
type VerbForms struct {
|
||||
Past string // Past tense (e.g., "deleted")
|
||||
Gerund string // Present participle (e.g., "deleting")
|
||||
}
|
||||
|
||||
// irregularVerbs maps base verbs to their irregular forms.
|
||||
var irregularVerbs = map[string]VerbForms{
|
||||
"be": {Past: "was", Gerund: "being"},
|
||||
"have": {Past: "had", Gerund: "having"},
|
||||
"do": {Past: "did", Gerund: "doing"},
|
||||
"go": {Past: "went", Gerund: "going"},
|
||||
"make": {Past: "made", Gerund: "making"},
|
||||
"get": {Past: "got", Gerund: "getting"},
|
||||
"run": {Past: "ran", Gerund: "running"},
|
||||
"set": {Past: "set", Gerund: "setting"},
|
||||
"put": {Past: "put", Gerund: "putting"},
|
||||
"cut": {Past: "cut", Gerund: "cutting"},
|
||||
"let": {Past: "let", Gerund: "letting"},
|
||||
"hit": {Past: "hit", Gerund: "hitting"},
|
||||
"shut": {Past: "shut", Gerund: "shutting"},
|
||||
"split": {Past: "split", Gerund: "splitting"},
|
||||
"spread": {Past: "spread", Gerund: "spreading"},
|
||||
"read": {Past: "read", Gerund: "reading"},
|
||||
"write": {Past: "wrote", Gerund: "writing"},
|
||||
"send": {Past: "sent", Gerund: "sending"},
|
||||
"build": {Past: "built", Gerund: "building"},
|
||||
"begin": {Past: "began", Gerund: "beginning"},
|
||||
"find": {Past: "found", Gerund: "finding"},
|
||||
"take": {Past: "took", Gerund: "taking"},
|
||||
"see": {Past: "saw", Gerund: "seeing"},
|
||||
"keep": {Past: "kept", Gerund: "keeping"},
|
||||
"hold": {Past: "held", Gerund: "holding"},
|
||||
"tell": {Past: "told", Gerund: "telling"},
|
||||
"bring": {Past: "brought", Gerund: "bringing"},
|
||||
"think": {Past: "thought", Gerund: "thinking"},
|
||||
"buy": {Past: "bought", Gerund: "buying"},
|
||||
"catch": {Past: "caught", Gerund: "catching"},
|
||||
"teach": {Past: "taught", Gerund: "teaching"},
|
||||
"throw": {Past: "threw", Gerund: "throwing"},
|
||||
"grow": {Past: "grew", Gerund: "growing"},
|
||||
"know": {Past: "knew", Gerund: "knowing"},
|
||||
"show": {Past: "showed", Gerund: "showing"},
|
||||
"draw": {Past: "drew", Gerund: "drawing"},
|
||||
"break": {Past: "broke", Gerund: "breaking"},
|
||||
"speak": {Past: "spoke", Gerund: "speaking"},
|
||||
"choose": {Past: "chose", Gerund: "choosing"},
|
||||
"forget": {Past: "forgot", Gerund: "forgetting"},
|
||||
"lose": {Past: "lost", Gerund: "losing"},
|
||||
"win": {Past: "won", Gerund: "winning"},
|
||||
"swim": {Past: "swam", Gerund: "swimming"},
|
||||
"drive": {Past: "drove", Gerund: "driving"},
|
||||
"rise": {Past: "rose", Gerund: "rising"},
|
||||
"shine": {Past: "shone", Gerund: "shining"},
|
||||
"sing": {Past: "sang", Gerund: "singing"},
|
||||
"ring": {Past: "rang", Gerund: "ringing"},
|
||||
"drink": {Past: "drank", Gerund: "drinking"},
|
||||
"sink": {Past: "sank", Gerund: "sinking"},
|
||||
"sit": {Past: "sat", Gerund: "sitting"},
|
||||
"stand": {Past: "stood", Gerund: "standing"},
|
||||
"hang": {Past: "hung", Gerund: "hanging"},
|
||||
"dig": {Past: "dug", Gerund: "digging"},
|
||||
"stick": {Past: "stuck", Gerund: "sticking"},
|
||||
"bite": {Past: "bit", Gerund: "biting"},
|
||||
"hide": {Past: "hid", Gerund: "hiding"},
|
||||
"feed": {Past: "fed", Gerund: "feeding"},
|
||||
"meet": {Past: "met", Gerund: "meeting"},
|
||||
"lead": {Past: "led", Gerund: "leading"},
|
||||
"sleep": {Past: "slept", Gerund: "sleeping"},
|
||||
"feel": {Past: "felt", Gerund: "feeling"},
|
||||
"leave": {Past: "left", Gerund: "leaving"},
|
||||
"mean": {Past: "meant", Gerund: "meaning"},
|
||||
"lend": {Past: "lent", Gerund: "lending"},
|
||||
"spend": {Past: "spent", Gerund: "spending"},
|
||||
"bend": {Past: "bent", Gerund: "bending"},
|
||||
"deal": {Past: "dealt", Gerund: "dealing"},
|
||||
"lay": {Past: "laid", Gerund: "laying"},
|
||||
"pay": {Past: "paid", Gerund: "paying"},
|
||||
"say": {Past: "said", Gerund: "saying"},
|
||||
"sell": {Past: "sold", Gerund: "selling"},
|
||||
"seek": {Past: "sought", Gerund: "seeking"},
|
||||
"fight": {Past: "fought", Gerund: "fighting"},
|
||||
"fly": {Past: "flew", Gerund: "flying"},
|
||||
"wear": {Past: "wore", Gerund: "wearing"},
|
||||
"tear": {Past: "tore", Gerund: "tearing"},
|
||||
"bear": {Past: "bore", Gerund: "bearing"},
|
||||
"swear": {Past: "swore", Gerund: "swearing"},
|
||||
"wake": {Past: "woke", Gerund: "waking"},
|
||||
"freeze": {Past: "froze", Gerund: "freezing"},
|
||||
"steal": {Past: "stole", Gerund: "stealing"},
|
||||
"overwrite": {Past: "overwritten", Gerund: "overwriting"},
|
||||
"reset": {Past: "reset", Gerund: "resetting"},
|
||||
"reboot": {Past: "rebooted", Gerund: "rebooting"},
|
||||
|
||||
// Multi-syllable verbs with stressed final syllables (double consonant)
|
||||
"submit": {Past: "submitted", Gerund: "submitting"},
|
||||
"permit": {Past: "permitted", Gerund: "permitting"},
|
||||
"admit": {Past: "admitted", Gerund: "admitting"},
|
||||
"omit": {Past: "omitted", Gerund: "omitting"},
|
||||
"commit": {Past: "committed", Gerund: "committing"},
|
||||
"transmit": {Past: "transmitted", Gerund: "transmitting"},
|
||||
"prefer": {Past: "preferred", Gerund: "preferring"},
|
||||
"refer": {Past: "referred", Gerund: "referring"},
|
||||
"transfer": {Past: "transferred", Gerund: "transferring"},
|
||||
"defer": {Past: "deferred", Gerund: "deferring"},
|
||||
"confer": {Past: "conferred", Gerund: "conferring"},
|
||||
"infer": {Past: "inferred", Gerund: "inferring"},
|
||||
"occur": {Past: "occurred", Gerund: "occurring"},
|
||||
"recur": {Past: "recurred", Gerund: "recurring"},
|
||||
"incur": {Past: "incurred", Gerund: "incurring"},
|
||||
"deter": {Past: "deterred", Gerund: "deterring"},
|
||||
"control": {Past: "controlled", Gerund: "controlling"},
|
||||
"patrol": {Past: "patrolled", Gerund: "patrolling"},
|
||||
"compel": {Past: "compelled", Gerund: "compelling"},
|
||||
"expel": {Past: "expelled", Gerund: "expelling"},
|
||||
"propel": {Past: "propelled", Gerund: "propelling"},
|
||||
"repel": {Past: "repelled", Gerund: "repelling"},
|
||||
"rebel": {Past: "rebelled", Gerund: "rebelling"},
|
||||
"excel": {Past: "excelled", Gerund: "excelling"},
|
||||
"cancel": {Past: "cancelled", Gerund: "cancelling"}, // UK spelling
|
||||
"travel": {Past: "travelled", Gerund: "travelling"}, // UK spelling
|
||||
"label": {Past: "labelled", Gerund: "labelling"}, // UK spelling
|
||||
"model": {Past: "modelled", Gerund: "modelling"}, // UK spelling
|
||||
"level": {Past: "levelled", Gerund: "levelling"}, // UK spelling
|
||||
}
|
||||
|
||||
// PastTense returns the past tense of a verb.
|
||||
// Checks JSON locale data first, then irregular verbs, then applies regular rules.
|
||||
//
|
||||
|
|
@ -328,32 +164,6 @@ func applyRegularPastTense(verb string) string {
|
|||
return verb + "ed"
|
||||
}
|
||||
|
||||
// noDoubleConsonant contains multi-syllable verbs that don't double the final consonant.
|
||||
// Note: UK English doubles -l (travelled, cancelled) - those are in irregularVerbs.
|
||||
var noDoubleConsonant = map[string]bool{
|
||||
"open": true,
|
||||
"listen": true,
|
||||
"happen": true,
|
||||
"enter": true,
|
||||
"offer": true,
|
||||
"suffer": true,
|
||||
"differ": true,
|
||||
"cover": true,
|
||||
"deliver": true,
|
||||
"develop": true,
|
||||
"visit": true,
|
||||
"limit": true,
|
||||
"edit": true,
|
||||
"credit": true,
|
||||
"orbit": true,
|
||||
"total": true,
|
||||
"target": true,
|
||||
"budget": true,
|
||||
"market": true,
|
||||
"benefit": true,
|
||||
"focus": true,
|
||||
}
|
||||
|
||||
// shouldDoubleConsonant checks if the final consonant should be doubled.
|
||||
// Applies to CVC (consonant-vowel-consonant) endings in single-syllable words
|
||||
// and stressed final syllables in multi-syllable words.
|
||||
|
|
@ -444,60 +254,6 @@ func applyRegularGerund(verb string) string {
|
|||
return verb + "ing"
|
||||
}
|
||||
|
||||
// irregularNouns maps singular nouns to their irregular plural forms.
|
||||
var irregularNouns = map[string]string{
|
||||
"child": "children",
|
||||
"person": "people",
|
||||
"man": "men",
|
||||
"woman": "women",
|
||||
"foot": "feet",
|
||||
"tooth": "teeth",
|
||||
"mouse": "mice",
|
||||
"goose": "geese",
|
||||
"ox": "oxen",
|
||||
"index": "indices",
|
||||
"appendix": "appendices",
|
||||
"matrix": "matrices",
|
||||
"vertex": "vertices",
|
||||
"crisis": "crises",
|
||||
"analysis": "analyses",
|
||||
"diagnosis": "diagnoses",
|
||||
"thesis": "theses",
|
||||
"hypothesis": "hypotheses",
|
||||
"parenthesis":"parentheses",
|
||||
"datum": "data",
|
||||
"medium": "media",
|
||||
"bacterium": "bacteria",
|
||||
"criterion": "criteria",
|
||||
"phenomenon": "phenomena",
|
||||
"curriculum": "curricula",
|
||||
"alumnus": "alumni",
|
||||
"cactus": "cacti",
|
||||
"focus": "foci",
|
||||
"fungus": "fungi",
|
||||
"nucleus": "nuclei",
|
||||
"radius": "radii",
|
||||
"stimulus": "stimuli",
|
||||
"syllabus": "syllabi",
|
||||
"fish": "fish",
|
||||
"sheep": "sheep",
|
||||
"deer": "deer",
|
||||
"species": "species",
|
||||
"series": "series",
|
||||
"aircraft": "aircraft",
|
||||
"life": "lives",
|
||||
"wife": "wives",
|
||||
"knife": "knives",
|
||||
"leaf": "leaves",
|
||||
"half": "halves",
|
||||
"self": "selves",
|
||||
"shelf": "shelves",
|
||||
"wolf": "wolves",
|
||||
"calf": "calves",
|
||||
"loaf": "loaves",
|
||||
"thief": "thieves",
|
||||
}
|
||||
|
||||
// Pluralize returns the plural form of a noun based on count.
|
||||
// If count is 1, returns the singular form unchanged.
|
||||
//
|
||||
|
|
@ -592,38 +348,6 @@ func applyRegularPlural(noun string) string {
|
|||
return noun + "s"
|
||||
}
|
||||
|
||||
// vowelSounds contains words that start with consonants but have vowel sounds.
|
||||
// These take "an" instead of "a".
|
||||
var vowelSounds = map[string]bool{
|
||||
"hour": true,
|
||||
"honest": true,
|
||||
"honour": true,
|
||||
"honor": true,
|
||||
"heir": true,
|
||||
"herb": true, // US pronunciation
|
||||
}
|
||||
|
||||
// consonantSounds contains words that start with vowels but have consonant sounds.
|
||||
// These take "a" instead of "an".
|
||||
var consonantSounds = map[string]bool{
|
||||
"user": true, // "yoo-zer"
|
||||
"union": true, // "yoon-yon"
|
||||
"unique": true,
|
||||
"unit": true,
|
||||
"universe": true,
|
||||
"university": true,
|
||||
"uniform": true,
|
||||
"usage": true,
|
||||
"usual": true,
|
||||
"utility": true,
|
||||
"utensil": true,
|
||||
"one": true, // "wun"
|
||||
"once": true,
|
||||
"euro": true, // "yoo-ro"
|
||||
"eulogy": true,
|
||||
"euphemism": true,
|
||||
}
|
||||
|
||||
// Article returns the appropriate indefinite article ("a" or "an") for a word.
|
||||
//
|
||||
// Article("file") // "a"
|
||||
|
|
|
|||
|
|
@ -25,15 +25,10 @@ package i18n
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//go:embed locales/*.json
|
||||
var localeFS embed.FS
|
||||
|
||||
// --- Global convenience functions ---
|
||||
|
||||
// T translates a message using the default service.
|
||||
|
|
@ -63,10 +58,6 @@ func _(messageID string, args ...any) string {
|
|||
|
||||
// --- Template helpers ---
|
||||
|
||||
// templateCache stores compiled templates for reuse.
|
||||
// Key is the template string, value is the compiled template.
|
||||
var templateCache sync.Map
|
||||
|
||||
// executeIntentTemplate executes an intent template with the given data.
|
||||
// Templates are cached for performance - repeated calls with the same template
|
||||
// string will reuse the compiled template.
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
// Package i18n provides internationalization for the CLI.
|
||||
package i18n
|
||||
|
||||
// Translator defines the interface for translation services.
|
||||
// Implement this interface to provide custom translation backends
|
||||
// or mock implementations for testing.
|
||||
//
|
||||
// Example usage in tests:
|
||||
//
|
||||
// type mockTranslator struct {
|
||||
// translations map[string]string
|
||||
// }
|
||||
//
|
||||
// func (m *mockTranslator) T(key string, args ...any) string {
|
||||
// if v, ok := m.translations[key]; ok {
|
||||
// return v
|
||||
// }
|
||||
// return key
|
||||
// }
|
||||
//
|
||||
// func TestSomething(t *testing.T) {
|
||||
// mock := &mockTranslator{translations: map[string]string{
|
||||
// "cli.success": "Test Success",
|
||||
// }}
|
||||
// // Use mock in your tests
|
||||
// }
|
||||
type Translator interface {
|
||||
// T translates a message by its ID.
|
||||
// Optional template data can be passed for interpolation.
|
||||
//
|
||||
// svc.T("cli.success")
|
||||
// svc.T("cli.count.items", map[string]any{"Count": 5})
|
||||
T(messageID string, args ...any) string
|
||||
|
||||
// SetLanguage sets the language for translations.
|
||||
// Returns an error if the language is not supported.
|
||||
SetLanguage(lang string) error
|
||||
|
||||
// Language returns the current language code.
|
||||
Language() string
|
||||
|
||||
// SetMode sets the translation mode for missing key handling.
|
||||
SetMode(m Mode)
|
||||
|
||||
// Mode returns the current translation mode.
|
||||
Mode() Mode
|
||||
|
||||
// SetDebug enables or disables debug mode.
|
||||
SetDebug(enabled bool)
|
||||
|
||||
// Debug returns whether debug mode is enabled.
|
||||
Debug() bool
|
||||
|
||||
// SetFormality sets the default formality level for translations.
|
||||
SetFormality(f Formality)
|
||||
|
||||
// Formality returns the current formality level.
|
||||
Formality() Formality
|
||||
|
||||
// Direction returns the text direction for the current language.
|
||||
Direction() TextDirection
|
||||
|
||||
// IsRTL returns true if the current language uses RTL text.
|
||||
IsRTL() bool
|
||||
|
||||
// PluralCategory returns the plural category for a count.
|
||||
PluralCategory(n int) PluralCategory
|
||||
|
||||
// AvailableLanguages returns the list of available language codes.
|
||||
AvailableLanguages() []string
|
||||
}
|
||||
|
||||
// Ensure Service implements Translator at compile time.
|
||||
var _ Translator = (*Service)(nil)
|
||||
|
||||
// --- Function type interfaces ---
|
||||
|
||||
// MissingKeyHandler receives missing key events for analysis.
|
||||
// Used in ModeCollect to capture translation keys that need to be added.
|
||||
//
|
||||
// i18n.OnMissingKey(func(m i18n.MissingKey) {
|
||||
// log.Printf("MISSING: %s at %s:%d", m.Key, m.CallerFile, m.CallerLine)
|
||||
// })
|
||||
type MissingKeyHandler func(missing MissingKey)
|
||||
|
||||
// MissingKey is dispatched when a translation key is not found in ModeCollect.
|
||||
// Used by QA tools to collect and report missing translations.
|
||||
type MissingKey struct {
|
||||
Key string // The missing translation key
|
||||
Args map[string]any // Arguments passed to the translation
|
||||
CallerFile string // Source file where T() was called
|
||||
CallerLine int // Line number where T() was called
|
||||
}
|
||||
|
||||
// MissingKeyAction is an alias for backwards compatibility.
|
||||
// Deprecated: Use MissingKey instead.
|
||||
type MissingKeyAction = MissingKey
|
||||
|
||||
// PluralRule is a function that determines the plural category for a count.
|
||||
// Each language has its own plural rule based on CLDR data.
|
||||
//
|
||||
// rule := i18n.GetPluralRule("ru")
|
||||
// category := rule(5) // Returns PluralMany for Russian
|
||||
type PluralRule func(n int) PluralCategory
|
||||
|
||||
// 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 (non-plural)
|
||||
Zero string // count == 0 (Arabic, Latvian, Welsh)
|
||||
One string // count == 1 (most languages)
|
||||
Two string // count == 2 (Arabic, Welsh)
|
||||
Few string // Small numbers (Slavic: 2-4, Arabic: 3-10)
|
||||
Many string // Larger numbers (Slavic: 5+, Arabic: 11-99)
|
||||
Other string // Default/fallback form
|
||||
}
|
||||
|
||||
// ForCategory returns the appropriate text for a plural category.
|
||||
// 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
|
||||
}
|
||||
654
pkg/i18n/interfaces.go
Normal file
654
pkg/i18n/interfaces.go
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
// Package i18n provides internationalization for the CLI.
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Service provides internationalization and localization.
|
||||
type Service struct {
|
||||
messages map[string]map[string]Message // lang -> key -> message
|
||||
currentLang string
|
||||
fallbackLang string
|
||||
availableLangs []language.Tag
|
||||
mode Mode // Translation mode (Normal, Strict, Collect)
|
||||
debug bool // Debug mode shows key prefixes
|
||||
formality Formality // Default formality level for translations
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Default is the global i18n service instance.
|
||||
var (
|
||||
defaultService *Service
|
||||
defaultOnce sync.Once
|
||||
defaultErr error
|
||||
)
|
||||
|
||||
// templateCache stores compiled templates for reuse.
|
||||
// Key is the template string, value is the compiled template.
|
||||
var templateCache sync.Map
|
||||
|
||||
//go:embed locales/*.json
|
||||
var localeFS embed.FS
|
||||
|
||||
// Translator defines the interface for translation services.
|
||||
// Implement this interface to provide custom translation backends
|
||||
// or mock implementations for testing.
|
||||
//
|
||||
// Example usage in tests:
|
||||
//
|
||||
// type mockTranslator struct {
|
||||
// translations map[string]string
|
||||
// }
|
||||
//
|
||||
// func (m *mockTranslator) T(key string, args ...any) string {
|
||||
// if v, ok := m.translations[key]; ok {
|
||||
// return v
|
||||
// }
|
||||
// return key
|
||||
// }
|
||||
//
|
||||
// func TestSomething(t *testing.T) {
|
||||
// mock := &mockTranslator{translations: map[string]string{
|
||||
// "cli.success": "Test Success",
|
||||
// }}
|
||||
// // Use mock in your tests
|
||||
// }
|
||||
type Translator interface {
|
||||
// T translates a message by its ID.
|
||||
// Optional template data can be passed for interpolation.
|
||||
//
|
||||
// svc.T("cli.success")
|
||||
// svc.T("cli.count.items", map[string]any{"Count": 5})
|
||||
T(messageID string, args ...any) string
|
||||
|
||||
// SetLanguage sets the language for translations.
|
||||
// Returns an error if the language is not supported.
|
||||
SetLanguage(lang string) error
|
||||
|
||||
// Language returns the current language code.
|
||||
Language() string
|
||||
|
||||
// SetMode sets the translation mode for missing key handling.
|
||||
SetMode(m Mode)
|
||||
|
||||
// Mode returns the current translation mode.
|
||||
Mode() Mode
|
||||
|
||||
// SetDebug enables or disables debug mode.
|
||||
SetDebug(enabled bool)
|
||||
|
||||
// Debug returns whether debug mode is enabled.
|
||||
Debug() bool
|
||||
|
||||
// SetFormality sets the default formality level for translations.
|
||||
SetFormality(f Formality)
|
||||
|
||||
// Formality returns the current formality level.
|
||||
Formality() Formality
|
||||
|
||||
// Direction returns the text direction for the current language.
|
||||
Direction() TextDirection
|
||||
|
||||
// IsRTL returns true if the current language uses RTL text.
|
||||
IsRTL() bool
|
||||
|
||||
// PluralCategory returns the plural category for a count.
|
||||
PluralCategory(n int) PluralCategory
|
||||
|
||||
// AvailableLanguages returns the list of available language codes.
|
||||
AvailableLanguages() []string
|
||||
}
|
||||
|
||||
// Ensure Service implements Translator at compile time.
|
||||
var _ Translator = (*Service)(nil)
|
||||
|
||||
// NumberFormat defines locale-specific number formatting rules.
|
||||
type NumberFormat struct {
|
||||
ThousandsSep string // "," for en, "." for de
|
||||
DecimalSep string // "." for en, "," for de
|
||||
PercentFmt string // "%s%%" for en, "%s %%" for de (space before %)
|
||||
}
|
||||
|
||||
// Default number formats by language.
|
||||
var numberFormats = map[string]NumberFormat{
|
||||
"en": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"},
|
||||
"de": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s %%"},
|
||||
"fr": {ThousandsSep: " ", DecimalSep: ",", PercentFmt: "%s %%"},
|
||||
"es": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s%%"},
|
||||
"zh": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"},
|
||||
}
|
||||
|
||||
// Mode determines how the i18n service handles missing translation keys.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
// ModeNormal returns the key as-is when a translation is missing (production).
|
||||
ModeNormal Mode = iota
|
||||
// ModeStrict panics immediately when a translation is missing (dev/CI).
|
||||
ModeStrict
|
||||
// ModeCollect dispatches MissingKey actions and returns [key] (QA testing).
|
||||
ModeCollect
|
||||
)
|
||||
|
||||
// Subject represents a typed subject with metadata for semantic translations.
|
||||
// Use S() to create a Subject and chain methods for additional context.
|
||||
//
|
||||
// S("file", "config.yaml")
|
||||
// S("repo", "core-php").Count(3)
|
||||
// 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")
|
||||
formality Formality // Formality level override (-1 = use service default)
|
||||
}
|
||||
|
||||
// IntentMeta defines the behaviour and characteristics of an intent.
|
||||
type IntentMeta struct {
|
||||
Type string // "action", "question", "info"
|
||||
Verb string // Reference to verb key (e.g., "delete", "save")
|
||||
Dangerous bool // If true, requires extra confirmation
|
||||
Default string // Default response: "yes" or "no"
|
||||
Supports []string // Extra options supported by this intent
|
||||
}
|
||||
|
||||
// Composed holds all output forms for an intent after template resolution.
|
||||
// Each field is ready to display to the user.
|
||||
type Composed struct {
|
||||
Question string // Question form: "Delete config.yaml?"
|
||||
Confirm string // Confirmation form: "Really delete config.yaml?"
|
||||
Success string // Success message: "config.yaml deleted"
|
||||
Failure string // Failure message: "Failed to delete config.yaml"
|
||||
Meta IntentMeta // Intent metadata for UI decisions
|
||||
}
|
||||
|
||||
// Intent defines a semantic intent with templates for all output forms.
|
||||
// Templates use Go text/template syntax with Subject data available.
|
||||
type Intent struct {
|
||||
Meta IntentMeta // Intent behaviour and characteristics
|
||||
Question string // Template for question form
|
||||
Confirm string // Template for confirmation form
|
||||
Success string // Template for success message
|
||||
Failure string // Template for failure message
|
||||
}
|
||||
|
||||
// templateData is passed to intent templates during execution.
|
||||
type templateData struct {
|
||||
Subject string // 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)
|
||||
}
|
||||
|
||||
// GrammarData holds language-specific grammar forms loaded from JSON.
|
||||
type GrammarData struct {
|
||||
Verbs map[string]VerbForms // verb -> forms
|
||||
Nouns map[string]NounForms // noun -> forms
|
||||
Articles ArticleForms // article configuration
|
||||
Words map[string]string // base word translations
|
||||
Punct PunctuationRules // language-specific punctuation
|
||||
}
|
||||
|
||||
// PunctuationRules holds language-specific punctuation patterns.
|
||||
// French uses " :" (space before colon), English uses ":"
|
||||
type PunctuationRules struct {
|
||||
LabelSuffix string // Suffix for labels (default ":")
|
||||
ProgressSuffix string // Suffix for progress (default "...")
|
||||
}
|
||||
|
||||
// NounForms holds plural and gender information for a noun.
|
||||
type NounForms struct {
|
||||
One string // Singular form
|
||||
Other string // Plural form
|
||||
Gender string // Grammatical gender (masculine, feminine, neuter, common)
|
||||
}
|
||||
|
||||
// ArticleForms holds article configuration for a language.
|
||||
type ArticleForms struct {
|
||||
IndefiniteDefault string // Default indefinite article (e.g., "a")
|
||||
IndefiniteVowel string // Indefinite article before vowel sounds (e.g., "an")
|
||||
Definite string // Definite article (e.g., "the")
|
||||
ByGender map[string]string // Gender-specific articles for gendered languages
|
||||
}
|
||||
|
||||
// grammarCache holds loaded grammar data per language.
|
||||
var (
|
||||
grammarCache = make(map[string]*GrammarData)
|
||||
grammarCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// VerbForms holds irregular verb conjugations.
|
||||
type VerbForms struct {
|
||||
Past string // Past tense (e.g., "deleted")
|
||||
Gerund string // Present participle (e.g., "deleting")
|
||||
}
|
||||
|
||||
// irregularVerbs maps base verbs to their irregular forms.
|
||||
var irregularVerbs = map[string]VerbForms{
|
||||
"be": {Past: "was", Gerund: "being"},
|
||||
"have": {Past: "had", Gerund: "having"},
|
||||
"do": {Past: "did", Gerund: "doing"},
|
||||
"go": {Past: "went", Gerund: "going"},
|
||||
"make": {Past: "made", Gerund: "making"},
|
||||
"get": {Past: "got", Gerund: "getting"},
|
||||
"run": {Past: "ran", Gerund: "running"},
|
||||
"set": {Past: "set", Gerund: "setting"},
|
||||
"put": {Past: "put", Gerund: "putting"},
|
||||
"cut": {Past: "cut", Gerund: "cutting"},
|
||||
"let": {Past: "let", Gerund: "letting"},
|
||||
"hit": {Past: "hit", Gerund: "hitting"},
|
||||
"shut": {Past: "shut", Gerund: "shutting"},
|
||||
"split": {Past: "split", Gerund: "splitting"},
|
||||
"spread": {Past: "spread", Gerund: "spreading"},
|
||||
"read": {Past: "read", Gerund: "reading"},
|
||||
"write": {Past: "wrote", Gerund: "writing"},
|
||||
"send": {Past: "sent", Gerund: "sending"},
|
||||
"build": {Past: "built", Gerund: "building"},
|
||||
"begin": {Past: "began", Gerund: "beginning"},
|
||||
"find": {Past: "found", Gerund: "finding"},
|
||||
"take": {Past: "took", Gerund: "taking"},
|
||||
"see": {Past: "saw", Gerund: "seeing"},
|
||||
"keep": {Past: "kept", Gerund: "keeping"},
|
||||
"hold": {Past: "held", Gerund: "holding"},
|
||||
"tell": {Past: "told", Gerund: "telling"},
|
||||
"bring": {Past: "brought", Gerund: "bringing"},
|
||||
"think": {Past: "thought", Gerund: "thinking"},
|
||||
"buy": {Past: "bought", Gerund: "buying"},
|
||||
"catch": {Past: "caught", Gerund: "catching"},
|
||||
"teach": {Past: "taught", Gerund: "teaching"},
|
||||
"throw": {Past: "threw", Gerund: "throwing"},
|
||||
"grow": {Past: "grew", Gerund: "growing"},
|
||||
"know": {Past: "knew", Gerund: "knowing"},
|
||||
"show": {Past: "showed", Gerund: "showing"},
|
||||
"draw": {Past: "drew", Gerund: "drawing"},
|
||||
"break": {Past: "broke", Gerund: "breaking"},
|
||||
"speak": {Past: "spoke", Gerund: "speaking"},
|
||||
"choose": {Past: "chose", Gerund: "choosing"},
|
||||
"forget": {Past: "forgot", Gerund: "forgetting"},
|
||||
"lose": {Past: "lost", Gerund: "losing"},
|
||||
"win": {Past: "won", Gerund: "winning"},
|
||||
"swim": {Past: "swam", Gerund: "swimming"},
|
||||
"drive": {Past: "drove", Gerund: "driving"},
|
||||
"rise": {Past: "rose", Gerund: "rising"},
|
||||
"shine": {Past: "shone", Gerund: "shining"},
|
||||
"sing": {Past: "sang", Gerund: "singing"},
|
||||
"ring": {Past: "rang", Gerund: "ringing"},
|
||||
"drink": {Past: "drank", Gerund: "drinking"},
|
||||
"sink": {Past: "sank", Gerund: "sinking"},
|
||||
"sit": {Past: "sat", Gerund: "sitting"},
|
||||
"stand": {Past: "stood", Gerund: "standing"},
|
||||
"hang": {Past: "hung", Gerund: "hanging"},
|
||||
"dig": {Past: "dug", Gerund: "digging"},
|
||||
"stick": {Past: "stuck", Gerund: "sticking"},
|
||||
"bite": {Past: "bit", Gerund: "biting"},
|
||||
"hide": {Past: "hid", Gerund: "hiding"},
|
||||
"feed": {Past: "fed", Gerund: "feeding"},
|
||||
"meet": {Past: "met", Gerund: "meeting"},
|
||||
"lead": {Past: "led", Gerund: "leading"},
|
||||
"sleep": {Past: "slept", Gerund: "sleeping"},
|
||||
"feel": {Past: "felt", Gerund: "feeling"},
|
||||
"leave": {Past: "left", Gerund: "leaving"},
|
||||
"mean": {Past: "meant", Gerund: "meaning"},
|
||||
"lend": {Past: "lent", Gerund: "lending"},
|
||||
"spend": {Past: "spent", Gerund: "spending"},
|
||||
"bend": {Past: "bent", Gerund: "bending"},
|
||||
"deal": {Past: "dealt", Gerund: "dealing"},
|
||||
"lay": {Past: "laid", Gerund: "laying"},
|
||||
"pay": {Past: "paid", Gerund: "paying"},
|
||||
"say": {Past: "said", Gerund: "saying"},
|
||||
"sell": {Past: "sold", Gerund: "selling"},
|
||||
"seek": {Past: "sought", Gerund: "seeking"},
|
||||
"fight": {Past: "fought", Gerund: "fighting"},
|
||||
"fly": {Past: "flew", Gerund: "flying"},
|
||||
"wear": {Past: "wore", Gerund: "wearing"},
|
||||
"tear": {Past: "tore", Gerund: "tearing"},
|
||||
"bear": {Past: "bore", Gerund: "bearing"},
|
||||
"swear": {Past: "swore", Gerund: "swearing"},
|
||||
"wake": {Past: "woke", Gerund: "waking"},
|
||||
"freeze": {Past: "froze", Gerund: "freezing"},
|
||||
"steal": {Past: "stole", Gerund: "stealing"},
|
||||
"overwrite": {Past: "overwritten", Gerund: "overwriting"},
|
||||
"reset": {Past: "reset", Gerund: "resetting"},
|
||||
"reboot": {Past: "rebooted", Gerund: "rebooting"},
|
||||
|
||||
// Multi-syllable verbs with stressed final syllables (double consonant)
|
||||
"submit": {Past: "submitted", Gerund: "submitting"},
|
||||
"permit": {Past: "permitted", Gerund: "permitting"},
|
||||
"admit": {Past: "admitted", Gerund: "admitting"},
|
||||
"omit": {Past: "omitted", Gerund: "omitting"},
|
||||
"commit": {Past: "committed", Gerund: "committing"},
|
||||
"transmit": {Past: "transmitted", Gerund: "transmitting"},
|
||||
"prefer": {Past: "preferred", Gerund: "preferring"},
|
||||
"refer": {Past: "referred", Gerund: "referring"},
|
||||
"transfer": {Past: "transferred", Gerund: "transferring"},
|
||||
"defer": {Past: "deferred", Gerund: "deferring"},
|
||||
"confer": {Past: "conferred", Gerund: "conferring"},
|
||||
"infer": {Past: "inferred", Gerund: "inferring"},
|
||||
"occur": {Past: "occurred", Gerund: "occurring"},
|
||||
"recur": {Past: "recurred", Gerund: "recurring"},
|
||||
"incur": {Past: "incurred", Gerund: "incurring"},
|
||||
"deter": {Past: "deterred", Gerund: "deterring"},
|
||||
"control": {Past: "controlled", Gerund: "controlling"},
|
||||
"patrol": {Past: "patrolled", Gerund: "patrolling"},
|
||||
"compel": {Past: "compelled", Gerund: "compelling"},
|
||||
"expel": {Past: "expelled", Gerund: "expelling"},
|
||||
"propel": {Past: "propelled", Gerund: "propelling"},
|
||||
"repel": {Past: "repelled", Gerund: "repelling"},
|
||||
"rebel": {Past: "rebelled", Gerund: "rebelling"},
|
||||
"excel": {Past: "excelled", Gerund: "excelling"},
|
||||
"cancel": {Past: "cancelled", Gerund: "cancelling"}, // UK spelling
|
||||
"travel": {Past: "travelled", Gerund: "travelling"}, // UK spelling
|
||||
"label": {Past: "labelled", Gerund: "labelling"}, // UK spelling
|
||||
"model": {Past: "modelled", Gerund: "modelling"}, // UK spelling
|
||||
"level": {Past: "levelled", Gerund: "levelling"}, // UK spelling
|
||||
}
|
||||
|
||||
// noDoubleConsonant contains multi-syllable verbs that don't double the final consonant.
|
||||
// Note: UK English doubles -l (travelled, cancelled) - those are in irregularVerbs.
|
||||
var noDoubleConsonant = map[string]bool{
|
||||
"open": true,
|
||||
"listen": true,
|
||||
"happen": true,
|
||||
"enter": true,
|
||||
"offer": true,
|
||||
"suffer": true,
|
||||
"differ": true,
|
||||
"cover": true,
|
||||
"deliver": true,
|
||||
"develop": true,
|
||||
"visit": true,
|
||||
"limit": true,
|
||||
"edit": true,
|
||||
"credit": true,
|
||||
"orbit": true,
|
||||
"total": true,
|
||||
"target": true,
|
||||
"budget": true,
|
||||
"market": true,
|
||||
"benefit": true,
|
||||
"focus": true,
|
||||
}
|
||||
|
||||
// irregularNouns maps singular nouns to their irregular plural forms.
|
||||
var irregularNouns = map[string]string{
|
||||
"child": "children",
|
||||
"person": "people",
|
||||
"man": "men",
|
||||
"woman": "women",
|
||||
"foot": "feet",
|
||||
"tooth": "teeth",
|
||||
"mouse": "mice",
|
||||
"goose": "geese",
|
||||
"ox": "oxen",
|
||||
"index": "indices",
|
||||
"appendix": "appendices",
|
||||
"matrix": "matrices",
|
||||
"vertex": "vertices",
|
||||
"crisis": "crises",
|
||||
"analysis": "analyses",
|
||||
"diagnosis": "diagnoses",
|
||||
"thesis": "theses",
|
||||
"hypothesis": "hypotheses",
|
||||
"parenthesis": "parentheses",
|
||||
"datum": "data",
|
||||
"medium": "media",
|
||||
"bacterium": "bacteria",
|
||||
"criterion": "criteria",
|
||||
"phenomenon": "phenomena",
|
||||
"curriculum": "curricula",
|
||||
"alumnus": "alumni",
|
||||
"cactus": "cacti",
|
||||
"focus": "foci",
|
||||
"fungus": "fungi",
|
||||
"nucleus": "nuclei",
|
||||
"radius": "radii",
|
||||
"stimulus": "stimuli",
|
||||
"syllabus": "syllabi",
|
||||
"fish": "fish",
|
||||
"sheep": "sheep",
|
||||
"deer": "deer",
|
||||
"species": "species",
|
||||
"series": "series",
|
||||
"aircraft": "aircraft",
|
||||
"life": "lives",
|
||||
"wife": "wives",
|
||||
"knife": "knives",
|
||||
"leaf": "leaves",
|
||||
"half": "halves",
|
||||
"self": "selves",
|
||||
"shelf": "shelves",
|
||||
"wolf": "wolves",
|
||||
"calf": "calves",
|
||||
"loaf": "loaves",
|
||||
"thief": "thieves",
|
||||
}
|
||||
|
||||
// vowelSounds contains words that start with consonants but have vowel sounds.
|
||||
// These take "an" instead of "a".
|
||||
var vowelSounds = map[string]bool{
|
||||
"hour": true,
|
||||
"honest": true,
|
||||
"honour": true,
|
||||
"honor": true,
|
||||
"heir": true,
|
||||
"herb": true, // US pronunciation
|
||||
}
|
||||
|
||||
// consonantSounds contains words that start with vowels but have consonant sounds.
|
||||
// These take "a" instead of "an".
|
||||
var consonantSounds = map[string]bool{
|
||||
"user": true, // "yoo-zer"
|
||||
"union": true, // "yoon-yon"
|
||||
"unique": true,
|
||||
"unit": true,
|
||||
"universe": true,
|
||||
"university": true,
|
||||
"uniform": true,
|
||||
"usage": true,
|
||||
"usual": true,
|
||||
"utility": true,
|
||||
"utensil": true,
|
||||
"one": true, // "wun"
|
||||
"once": true,
|
||||
"euro": true, // "yoo-ro"
|
||||
"eulogy": true,
|
||||
"euphemism": true,
|
||||
}
|
||||
|
||||
// --- Function type interfaces ---
|
||||
|
||||
// MissingKeyHandler receives missing key events for analysis.
|
||||
// Used in ModeCollect to capture translation keys that need to be added.
|
||||
//
|
||||
// i18n.OnMissingKey(func(m i18n.MissingKey) {
|
||||
// log.Printf("MISSING: %s at %s:%d", m.Key, m.CallerFile, m.CallerLine)
|
||||
// })
|
||||
type MissingKeyHandler func(missing MissingKey)
|
||||
|
||||
// MissingKey is dispatched when a translation key is not found in ModeCollect.
|
||||
// Used by QA tools to collect and report missing translations.
|
||||
type MissingKey struct {
|
||||
Key string // The missing translation key
|
||||
Args map[string]any // Arguments passed to the translation
|
||||
CallerFile string // Source file where T() was called
|
||||
CallerLine int // Line number where T() was called
|
||||
}
|
||||
|
||||
// MissingKeyAction is an alias for backwards compatibility.
|
||||
// Deprecated: Use MissingKey instead.
|
||||
type MissingKeyAction = MissingKey
|
||||
|
||||
// PluralRule is a function that determines the plural category for a count.
|
||||
// Each language has its own plural rule based on CLDR data.
|
||||
//
|
||||
// rule := i18n.GetPluralRule("ru")
|
||||
// category := rule(5) // Returns PluralMany for Russian
|
||||
type PluralRule func(n int) PluralCategory
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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 (non-plural)
|
||||
Zero string // count == 0 (Arabic, Latvian, Welsh)
|
||||
One string // count == 1 (most languages)
|
||||
Two string // count == 2 (Arabic, Welsh)
|
||||
Few string // Small numbers (Slavic: 2-4, Arabic: 3-10)
|
||||
Many string // Larger numbers (Slavic: 5+, Arabic: 11-99)
|
||||
Other string // Default/fallback form
|
||||
}
|
||||
|
||||
// ForCategory returns the appropriate text for a plural category.
|
||||
// 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
|
||||
}
|
||||
|
|
@ -1,19 +1,6 @@
|
|||
// 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 {
|
||||
|
|
@ -26,16 +13,6 @@ func (f Formality) String() string {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -44,31 +21,6 @@ func (d TextDirection) String() string {
|
|||
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 {
|
||||
|
|
@ -87,20 +39,6 @@ func (p PluralCategory) String() string {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -115,23 +53,6 @@ func (g GrammaticalGender) String() string {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -146,36 +67,6 @@ func IsRTLLanguage(lang string) bool {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,6 @@
|
|||
// Package i18n provides internationalization for the CLI.
|
||||
package i18n
|
||||
|
||||
// Mode determines how the i18n service handles missing translation keys.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
// ModeNormal returns the key as-is when a translation is missing (production).
|
||||
ModeNormal Mode = iota
|
||||
// ModeStrict panics immediately when a translation is missing (dev/CI).
|
||||
ModeStrict
|
||||
// ModeCollect dispatches MissingKey actions and returns [key] (QA testing).
|
||||
ModeCollect
|
||||
)
|
||||
|
||||
// String returns the string representation of the mode.
|
||||
func (m Mode) String() string {
|
||||
switch m {
|
||||
|
|
|
|||
|
|
@ -8,22 +8,6 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// NumberFormat defines locale-specific number formatting rules.
|
||||
type NumberFormat struct {
|
||||
ThousandsSep string // "," for en, "." for de
|
||||
DecimalSep string // "." for en, "," for de
|
||||
PercentFmt string // "%s%%" for en, "%s %%" for de (space before %)
|
||||
}
|
||||
|
||||
// Default number formats by language.
|
||||
var numberFormats = map[string]NumberFormat{
|
||||
"en": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"},
|
||||
"de": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s %%"},
|
||||
"fr": {ThousandsSep: " ", DecimalSep: ",", PercentFmt: "%s %%"},
|
||||
"es": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s%%"},
|
||||
"zh": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"},
|
||||
}
|
||||
|
||||
// getNumberFormat returns the number format for the current language.
|
||||
func getNumberFormat() NumberFormat {
|
||||
lang := currentLangForGrammar()
|
||||
|
|
|
|||
|
|
@ -7,30 +7,10 @@ import (
|
|||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Service provides internationalization and localization.
|
||||
type Service struct {
|
||||
messages map[string]map[string]Message // lang -> key -> message
|
||||
currentLang string
|
||||
fallbackLang string
|
||||
availableLangs []language.Tag
|
||||
mode Mode // Translation mode (Normal, Strict, Collect)
|
||||
debug bool // Debug mode shows key prefixes
|
||||
formality Formality // Default formality level for translations
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Default is the global i18n service instance.
|
||||
var (
|
||||
defaultService *Service
|
||||
defaultOnce sync.Once
|
||||
defaultErr error
|
||||
)
|
||||
|
||||
// New creates a new i18n service with embedded locales.
|
||||
func New() (*Service, error) {
|
||||
return NewWithFS(localeFS, "locales")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue