From 829be45fcc027a29d5d689a9ca8e71105e55dafd Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 13:11:58 +0000 Subject: [PATCH] feat(i18n): add remaining API features for stability Implements the final features from the semantic i18n plan: - Template caching: sync.Map cache for compiled templates - Translator interface: enables mocking for tests - Custom intent registration: thread-safe RegisterIntents(), UnregisterIntent() - JSON-based grammar: verb/noun forms in locale files, checked before computed - Fallback chain: T() tries common.action.{verb} and common.{verb} - CLI enhancements: Timeout(), Filter(), Multi() options, ChooseMulti() - Intent key constants: type-safe IntentCore* and Key* constants Co-Authored-By: Claude Opus 4.5 --- Taskfile.yml | 5 + pkg/cli/utils.go | 186 +++++++++++++++++++++++++++++- pkg/i18n/grammar.go | 121 ++++++++++++++++++- pkg/i18n/i18n.go | 223 ++++++++++++++++++++++++++++++++---- pkg/i18n/intents.go | 92 ++++++++++++++- pkg/i18n/intents_test.go | 75 +++++++++++- pkg/i18n/interface.go | 80 +++++++++++++ pkg/i18n/interface_test.go | 84 ++++++++++++++ pkg/i18n/keys.go | 126 ++++++++++++++++++++ pkg/i18n/locales/de.json | 26 +++++ pkg/i18n/locales/en_GB.json | 48 ++++++++ 11 files changed, 1029 insertions(+), 37 deletions(-) create mode 100644 pkg/i18n/interface.go create mode 100644 pkg/i18n/interface_test.go create mode 100644 pkg/i18n/keys.go diff --git a/Taskfile.yml b/Taskfile.yml index 3e541fb0..dedc29d0 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -44,3 +44,8 @@ tasks: cmds: - task: cov - go tool cover -html=coverage.txt + + i18n:generate: + desc: "Regenerate i18n key constants from locale files" + cmds: + - go generate ./pkg/i18n/... diff --git a/pkg/cli/utils.go b/pkg/cli/utils.go index 2889e4f9..1f5dea77 100644 --- a/pkg/cli/utils.go +++ b/pkg/cli/utils.go @@ -34,6 +34,7 @@ type ConfirmOption func(*confirmConfig) type confirmConfig struct { defaultYes bool required bool + timeout time.Duration } // DefaultYes sets the default response to "yes" (pressing Enter confirms). @@ -50,6 +51,17 @@ func Required() ConfirmOption { } } +// Timeout sets a timeout after which the default response is auto-selected. +// If no default is set (not Required and not DefaultYes), defaults to "no". +// +// Confirm("Continue?", Timeout(30*time.Second)) // Auto-no after 30s +// Confirm("Continue?", DefaultYes(), Timeout(10*time.Second)) // Auto-yes after 10s +func Timeout(d time.Duration) ConfirmOption { + return func(c *confirmConfig) { + c.timeout = d + } +} + // Confirm prompts the user for yes/no confirmation. // Returns true if the user enters "y" or "yes" (case-insensitive). // @@ -61,6 +73,7 @@ func Required() ConfirmOption { // // if Confirm("Save changes?", DefaultYes()) { ... } // if Confirm("Dangerous!", Required()) { ... } +// if Confirm("Auto-continue?", Timeout(30*time.Second)) { ... } func Confirm(prompt string, opts ...ConfirmOption) bool { cfg := &confirmConfig{} for _, opt := range opts { @@ -77,12 +90,37 @@ func Confirm(prompt string, opts ...ConfirmOption) bool { suffix = "[y/N] " } + // Add timeout indicator if set + if cfg.timeout > 0 { + suffix = fmt.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second)) + } + reader := bufio.NewReader(os.Stdin) for { fmt.Printf("%s %s", prompt, suffix) - response, _ := reader.ReadString('\n') - response = strings.ToLower(strings.TrimSpace(response)) + + var response string + + if cfg.timeout > 0 { + // Use timeout-based reading + resultChan := make(chan string, 1) + go func() { + line, _ := reader.ReadString('\n') + resultChan <- line + }() + + select { + case response = <-resultChan: + response = strings.ToLower(strings.TrimSpace(response)) + case <-time.After(cfg.timeout): + fmt.Println() // New line after timeout + return cfg.defaultYes + } + } else { + response, _ = reader.ReadString('\n') + response = strings.ToLower(strings.TrimSpace(response)) + } // Handle empty response if response == "" { @@ -237,7 +275,9 @@ type ChooseOption[T any] func(*chooseConfig[T]) type chooseConfig[T any] struct { displayFn func(T) string - defaultN int // 0-based index of default selection + defaultN int // 0-based index of default selection + filter bool // Enable fuzzy filtering + multi bool // Allow multiple selection } // WithDisplay sets a custom display function for items. @@ -254,6 +294,32 @@ func WithDefaultIndex[T any](idx int) ChooseOption[T] { } } +// Filter enables type-to-filter functionality. +// Users can type to narrow down the list of options. +// Note: This is a hint for interactive UIs; the basic CLI Choose +// implementation uses numbered selection which doesn't support filtering. +func Filter[T any]() ChooseOption[T] { + return func(c *chooseConfig[T]) { + c.filter = true + } +} + +// Multi allows multiple selections. +// Use ChooseMulti instead of Choose when this option is needed. +func Multi[T any]() ChooseOption[T] { + return func(c *chooseConfig[T]) { + c.multi = true + } +} + +// Display sets a custom display function for items. +// Alias for WithDisplay for shorter syntax. +// +// Choose("Select:", items, Display(func(f File) string { return f.Name })) +func Display[T any](fn func(T) string) ChooseOption[T] { + return WithDisplay[T](fn) +} + // Choose prompts the user to select from a list of items. // Returns the selected item. Uses simple numbered selection for terminal compatibility. // @@ -314,6 +380,120 @@ func ChooseIntent[T any](intent string, subject *i18n.Subject, items []T, opts . return Choose(result.Question, items, opts...) } +// ChooseMulti prompts the user to select multiple items from a list. +// Returns the selected items. Uses space-separated numbers or ranges. +// +// choices := ChooseMulti("Select files:", files) +// choices := ChooseMulti("Select files:", files, WithDisplay(func(f File) string { return f.Name })) +// +// Input format: +// - "1 3 5" - select items 1, 3, and 5 +// - "1-3" - select items 1, 2, and 3 +// - "1 3-5" - select items 1, 3, 4, and 5 +// - "" (empty) - select none +func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T { + if len(items) == 0 { + return nil + } + + cfg := &chooseConfig[T]{ + displayFn: func(item T) string { return fmt.Sprint(item) }, + } + for _, opt := range opts { + opt(cfg) + } + + // Display options + fmt.Println(prompt) + for i, item := range items { + fmt.Printf(" %d. %s\n", i+1, cfg.displayFn(item)) + } + + reader := bufio.NewReader(os.Stdin) + + for { + fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ") + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(response) + + // Empty response returns no selections + if response == "" { + return nil + } + + // Parse the selection + selected, err := parseMultiSelection(response, len(items)) + if err != nil { + fmt.Printf("Invalid selection: %v\n", err) + continue + } + + // Build result + result := make([]T, 0, len(selected)) + for _, idx := range selected { + result = append(result, items[idx]) + } + return result + } +} + +// parseMultiSelection parses a multi-selection string like "1 3 5" or "1-3 5". +// Returns 0-based indices. +func parseMultiSelection(input string, maxItems int) ([]int, error) { + selected := make(map[int]bool) + parts := strings.Fields(input) + + for _, part := range parts { + // Check for range (e.g., "1-3") + if strings.Contains(part, "-") { + rangeParts := strings.Split(part, "-") + if len(rangeParts) != 2 { + return nil, fmt.Errorf("invalid range: %s", part) + } + var start, end int + if _, err := fmt.Sscanf(rangeParts[0], "%d", &start); err != nil { + return nil, fmt.Errorf("invalid range start: %s", rangeParts[0]) + } + if _, err := fmt.Sscanf(rangeParts[1], "%d", &end); err != nil { + return nil, fmt.Errorf("invalid range end: %s", rangeParts[1]) + } + if start < 1 || start > maxItems || end < 1 || end > maxItems || start > end { + return nil, fmt.Errorf("range out of bounds: %s", part) + } + for i := start; i <= end; i++ { + selected[i-1] = true // Convert to 0-based + } + } else { + // Single number + var n int + if _, err := fmt.Sscanf(part, "%d", &n); err != nil { + return nil, fmt.Errorf("invalid number: %s", part) + } + if n < 1 || n > maxItems { + return nil, fmt.Errorf("number out of range: %d", n) + } + selected[n-1] = true // Convert to 0-based + } + } + + // Convert map to sorted slice + result := make([]int, 0, len(selected)) + for i := 0; i < maxItems; i++ { + if selected[i] { + result = append(result, i) + } + } + return result, nil +} + +// ChooseMultiIntent prompts for multiple selections using a semantic intent. +// +// files := ChooseMultiIntent("core.select", i18n.S("files", ""), files) +func ChooseMultiIntent[T any](intent string, subject *i18n.Subject, items []T, opts ...ChooseOption[T]) []T { + result := i18n.C(intent, subject) + return ChooseMulti(result.Question, items, opts...) +} + // FormatAge formats a time as a human-readable age string. // Examples: "5m ago", "2h ago", "3d ago", "1w ago", "2mo ago" func FormatAge(t time.Time) string { diff --git a/pkg/i18n/grammar.go b/pkg/i18n/grammar.go index 4b4224d3..46992eea 100644 --- a/pkg/i18n/grammar.go +++ b/pkg/i18n/grammar.go @@ -3,10 +3,104 @@ 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 +} + +// 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 { + grammarCacheMu.RLock() + defer grammarCacheMu.RUnlock() + return grammarCache[lang] +} + +// SetGrammarData sets the grammar data for a language. +// Called by the Service when loading locale files. +func SetGrammarData(lang string, data *GrammarData) { + grammarCacheMu.Lock() + defer grammarCacheMu.Unlock() + grammarCache[lang] = data +} + +// getVerbForm retrieves a verb form from JSON data. +// Returns empty string if not found, allowing fallback to computed form. +func getVerbForm(lang, verb, form string) string { + data := getGrammarData(lang) + if data == nil || data.Verbs == nil { + return "" + } + verb = strings.ToLower(verb) + if forms, ok := data.Verbs[verb]; ok { + switch form { + case "past": + return forms.Past + case "gerund": + return forms.Gerund + } + } + return "" +} + +// getNounForm retrieves a noun form from JSON data. +// Returns empty string if not found, allowing fallback to computed form. +func getNounForm(lang, noun, form string) string { + data := getGrammarData(lang) + if data == nil || data.Nouns == nil { + return "" + } + noun = strings.ToLower(noun) + if forms, ok := data.Nouns[noun]; ok { + switch form { + case "one": + return forms.One + case "other": + return forms.Other + case "gender": + return forms.Gender + } + } + return "" +} + +// currentLangForGrammar returns the current language for grammar lookups. +// Uses the default service's language if available. +func currentLangForGrammar() string { + if svc := Default(); svc != nil { + return svc.Language() + } + return "en-GB" +} + // VerbForms holds irregular verb conjugations. type VerbForms struct { Past string // Past tense (e.g., "deleted") @@ -100,7 +194,7 @@ var irregularVerbs = map[string]VerbForms{ } // PastTense returns the past tense of a verb. -// Handles irregular verbs and applies regular rules for others. +// Checks JSON locale data first, then irregular verbs, then applies regular rules. // // PastTense("delete") // "deleted" // PastTense("run") // "ran" @@ -111,7 +205,12 @@ func PastTense(verb string) string { return "" } - // Check irregular verbs first + // Check JSON data first (for current language) + if form := getVerbForm(currentLangForGrammar(), verb, "past"); form != "" { + return form + } + + // Check irregular verbs if forms, ok := irregularVerbs[verb]; ok { return forms.Past } @@ -220,6 +319,7 @@ func shouldDoubleConsonant(verb string) bool { } // Gerund returns the present participle (-ing form) of a verb. +// Checks JSON locale data first, then irregular verbs, then applies regular rules. // // Gerund("delete") // "deleting" // Gerund("run") // "running" @@ -230,7 +330,12 @@ func Gerund(verb string) string { return "" } - // Check irregular verbs first + // Check JSON data first (for current language) + if form := getVerbForm(currentLangForGrammar(), verb, "gerund"); form != "" { + return form + } + + // Check irregular verbs if forms, ok := irregularVerbs[verb]; ok { return forms.Gerund } @@ -331,6 +436,7 @@ func Pluralize(noun string, count int) string { } // PluralForm returns the plural form of a noun. +// Checks JSON locale data first, then irregular nouns, then applies regular rules. // // PluralForm("file") // "files" // PluralForm("child") // "children" @@ -343,6 +449,15 @@ func PluralForm(noun string) string { lower := strings.ToLower(noun) + // Check JSON data first (for current language) + if form := getNounForm(currentLangForGrammar(), lower, "other"); form != "" { + // Preserve original casing if title case + if unicode.IsUpper(rune(noun[0])) && len(form) > 0 { + return strings.ToUpper(string(form[0])) + form[1:] + } + return form + } + // Check irregular nouns if plural, ok := irregularNouns[lower]; ok { // Preserve original casing if title case diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go index 4e8a08ed..b0ffce40 100644 --- a/pkg/i18n/i18n.go +++ b/pkg/i18n/i18n.go @@ -168,6 +168,7 @@ func NewWithFS(fsys fs.FS, dir string) (*Service, error) { } // loadJSON parses nested JSON and flattens to dot-notation keys. +// Also extracts grammar data (verbs, nouns, articles) for the language. func (s *Service) loadJSON(lang string, data []byte) error { var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { @@ -175,13 +176,29 @@ func (s *Service) loadJSON(lang string, data []byte) error { } messages := make(map[string]Message) - flatten("", raw, messages) + grammarData := &GrammarData{ + Verbs: make(map[string]VerbForms), + Nouns: make(map[string]NounForms), + } + + flattenWithGrammar("", raw, messages, grammarData) s.messages[lang] = messages + + // Store grammar data if any was found + if len(grammarData.Verbs) > 0 || len(grammarData.Nouns) > 0 { + SetGrammarData(lang, grammarData) + } + return nil } // flatten recursively flattens nested maps into dot-notation keys. func flatten(prefix string, data map[string]any, out map[string]Message) { + flattenWithGrammar(prefix, data, out, nil) +} + +// flattenWithGrammar recursively flattens nested maps and extracts grammar data. +func flattenWithGrammar(prefix string, data map[string]any, out map[string]Message, grammar *GrammarData) { for key, value := range data { fullKey := key if prefix != "" { @@ -193,6 +210,62 @@ 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 verb form object + if grammar != nil && isVerbFormObject(v) { + verbName := key + if strings.HasPrefix(fullKey, "common.verb.") { + verbName = strings.TrimPrefix(fullKey, "common.verb.") + } + forms := VerbForms{} + if base, ok := v["base"].(string); ok { + _ = base // base form stored but not used in VerbForms + } + if past, ok := v["past"].(string); ok { + forms.Past = past + } + if gerund, ok := v["gerund"].(string); ok { + forms.Gerund = gerund + } + grammar.Verbs[strings.ToLower(verbName)] = forms + continue + } + + // Check if this is a noun form object + if grammar != nil && isNounFormObject(v) { + nounName := key + if strings.HasPrefix(fullKey, "common.noun.") { + nounName = strings.TrimPrefix(fullKey, "common.noun.") + } + forms := NounForms{} + if one, ok := v["one"].(string); ok { + forms.One = one + } + if other, ok := v["other"].(string); ok { + forms.Other = other + } + if gender, ok := v["gender"].(string); ok { + forms.Gender = gender + } + grammar.Nouns[strings.ToLower(nounName)] = forms + continue + } + + // Check if this is an article object + if grammar != nil && fullKey == "common.article" { + if indef, ok := v["indefinite"].(map[string]any); ok { + if def, ok := indef["default"].(string); ok { + grammar.Articles.IndefiniteDefault = def + } + if vowel, ok := indef["vowel"].(string); ok { + grammar.Articles.IndefiniteVowel = vowel + } + } + if def, ok := v["definite"].(string); ok { + grammar.Articles.Definite = def + } + continue + } + // Check if this is a plural object (has CLDR plural category keys) if isPluralObject(v) { msg := Message{} @@ -217,12 +290,38 @@ func flatten(prefix string, data map[string]any, out map[string]Message) { out[fullKey] = msg } else { // Recurse into nested object - flatten(fullKey, v, out) + flattenWithGrammar(fullKey, v, out, grammar) } } } } +// isVerbFormObject checks if a map represents verb conjugation forms. +func isVerbFormObject(m map[string]any) bool { + _, hasBase := m["base"] + _, hasPast := m["past"] + _, hasGerund := m["gerund"] + return (hasBase || hasPast || hasGerund) && !isPluralObject(m) +} + +// isNounFormObject checks if a map represents noun forms (with gender). +// Noun form objects have "gender" field, distinguishing them from CLDR plural objects. +func isNounFormObject(m map[string]any) bool { + _, hasGender := m["gender"] + // Only consider it a noun form if it has a gender field + // This distinguishes noun forms from CLDR plural objects which use one/other + return hasGender +} + +// hasPluralCategories checks if a map has CLDR plural categories beyond one/other. +func hasPluralCategories(m map[string]any) bool { + _, hasZero := m["zero"] + _, hasTwo := m["two"] + _, hasFew := m["few"] + _, hasMany := m["many"] + return hasZero || hasTwo || hasFew || hasMany +} + // 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 { @@ -530,6 +629,14 @@ func (s *Service) PluralCategory(n int) PluralCategory { // For semantic intents (core.* namespace), pass a Subject to get the Question form: // // svc.T("core.delete", S("file", "config.yaml")) // "Delete config.yaml?" +// +// # Fallback Chain +// +// When a key is not found, T() tries a fallback chain: +// 1. Try the exact key in current language +// 2. Try the exact key in fallback language +// 3. If key looks like an intent (contains "."), try common.action.{verb} +// 4. Return the key as-is (or handle according to mode) func (s *Service) T(messageID string, args ...any) string { s.mu.RLock() defer s.mu.RUnlock() @@ -545,39 +652,18 @@ func (s *Service) T(messageID string, args ...any) string { } } - // Try current language, then fallback - msg, ok := s.getMessage(s.currentLang, messageID) - if !ok { - msg, ok = s.getMessage(s.fallbackLang, messageID) - if !ok { - return s.handleMissingKey(messageID, args) - } - } - // Get template data var data any if len(args) > 0 { data = args[0] } - // Get the appropriate text - text := msg.Text - if msg.IsPlural() { - count := getCount(data) - // Use CLDR plural category for current language - category := GetPluralCategory(s.currentLang, count) - text = msg.ForCategory(category) - } - + // Try fallback chain + text := s.resolveWithFallback(messageID, data) if text == "" { return s.handleMissingKey(messageID, args) } - // Apply template if we have data - if data != nil { - text = applyTemplate(text, data) - } - // Debug mode: prefix with key if s.debug { return "[" + messageID + "] " + text @@ -586,6 +672,74 @@ func (s *Service) T(messageID string, args ...any) string { return text } +// resolveWithFallback implements the fallback chain for message resolution. +// Must be called with s.mu.RLock held. +func (s *Service) resolveWithFallback(messageID string, data any) string { + // 1. Try exact key in current language + if text := s.tryResolve(s.currentLang, messageID, data); text != "" { + return text + } + + // 2. Try exact key in fallback language + if text := s.tryResolve(s.fallbackLang, messageID, data); text != "" { + return text + } + + // 3. Try fallback patterns for intent-like keys + if strings.Contains(messageID, ".") { + parts := strings.Split(messageID, ".") + verb := parts[len(parts)-1] + + // Try common.action.{verb} + commonKey := "common.action." + verb + if text := s.tryResolve(s.currentLang, commonKey, data); text != "" { + return text + } + if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" { + return text + } + + // Try common.{verb} + commonKey = "common." + verb + if text := s.tryResolve(s.currentLang, commonKey, data); text != "" { + return text + } + if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" { + return text + } + } + + return "" +} + +// tryResolve attempts to resolve a single key in a single language. +// Returns empty string if not found. +// Must be called with s.mu.RLock held. +func (s *Service) tryResolve(lang, key string, data any) string { + msg, ok := s.getMessage(lang, key) + if !ok { + return "" + } + + text := msg.Text + if msg.IsPlural() { + count := getCount(data) + category := GetPluralCategory(lang, count) + text = msg.ForCategory(category) + } + + if text == "" { + return "" + } + + // Apply template if we have data + if data != nil { + text = applyTemplate(text, data) + } + + return text +} + // handleMissingKey handles a missing translation key based on the current mode. // Must be called with s.mu.RLock held. func (s *Service) handleMissingKey(key string, args []any) string { @@ -669,17 +823,36 @@ func (s *Service) C(intent string, subject *Subject) *Composed { return result } +// 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. func executeIntentTemplate(tmplStr string, data templateData) string { if tmplStr == "" { return "" } + // Check cache first + if cached, ok := templateCache.Load(tmplStr); ok { + var buf bytes.Buffer + if err := cached.(*template.Template).Execute(&buf, data); err != nil { + return tmplStr + } + return buf.String() + } + + // Parse and cache tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(tmplStr) if err != nil { return tmplStr } + // Store in cache (safe even if another goroutine stored it first) + templateCache.Store(tmplStr, tmpl) + var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return tmplStr diff --git a/pkg/i18n/intents.go b/pkg/i18n/intents.go index 68d61c53..c1433c5b 100644 --- a/pkg/i18n/intents.go +++ b/pkg/i18n/intents.go @@ -1,6 +1,10 @@ // Package i18n provides internationalization for the CLI. package i18n +import ( + "sync" +) + // coreIntents defines the built-in semantic intents for common operations. // These are accessed via the "core.*" namespace in T() and C() calls. // @@ -565,17 +569,35 @@ var coreIntents = map[string]Intent{ }, } -// getIntent retrieves an intent by its key from the core intents. +// customIntents holds user-registered intents. +// Separated from coreIntents to allow thread-safe registration. +var ( + customIntents = make(map[string]Intent) + customIntentsMu sync.RWMutex +) + +// getIntent retrieves an intent by its key. +// Checks custom intents first, then falls back to core intents. // Returns nil if the intent is not found. func getIntent(key string) *Intent { + // Check custom intents first (thread-safe) + customIntentsMu.RLock() + if intent, ok := customIntents[key]; ok { + customIntentsMu.RUnlock() + return &intent + } + customIntentsMu.RUnlock() + + // Fall back to core intents if intent, ok := coreIntents[key]; ok { return &intent } return nil } -// RegisterIntent adds a custom intent to the core intents. +// RegisterIntent adds a custom intent at runtime. // Use this to extend the built-in intents with application-specific ones. +// This function is thread-safe. // // i18n.RegisterIntent("myapp.archive", i18n.Intent{ // Meta: i18n.IntentMeta{Type: "action", Verb: "archive", Default: "yes"}, @@ -584,14 +606,74 @@ func getIntent(key string) *Intent { // Failure: "Failed to archive {{.Subject}}", // }) func RegisterIntent(key string, intent Intent) { - coreIntents[key] = intent + customIntentsMu.Lock() + defer customIntentsMu.Unlock() + customIntents[key] = intent } -// IntentKeys returns all registered intent keys. +// RegisterIntents adds multiple custom intents at runtime. +// This is more efficient than calling RegisterIntent multiple times. +// This function is thread-safe. +// +// i18n.RegisterIntents(map[string]i18n.Intent{ +// "myapp.archive": { +// Meta: i18n.IntentMeta{Type: "action", Verb: "archive"}, +// Question: "Archive {{.Subject}}?", +// }, +// "myapp.export": { +// Meta: i18n.IntentMeta{Type: "action", Verb: "export"}, +// Question: "Export {{.Subject}}?", +// }, +// }) +func RegisterIntents(intents map[string]Intent) { + customIntentsMu.Lock() + defer customIntentsMu.Unlock() + for k, v := range intents { + customIntents[k] = v + } +} + +// UnregisterIntent removes a custom intent by key. +// This only affects custom intents, not core intents. +// This function is thread-safe. +func UnregisterIntent(key string) { + customIntentsMu.Lock() + defer customIntentsMu.Unlock() + delete(customIntents, key) +} + +// IntentKeys returns all registered intent keys (both core and custom). func IntentKeys() []string { - keys := make([]string, 0, len(coreIntents)) + customIntentsMu.RLock() + defer customIntentsMu.RUnlock() + + keys := make([]string, 0, len(coreIntents)+len(customIntents)) for key := range coreIntents { keys = append(keys, key) } + for key := range customIntents { + // Avoid duplicates if custom overrides core + found := false + for _, k := range keys { + if k == key { + found = true + break + } + } + if !found { + keys = append(keys, key) + } + } return keys } + +// HasIntent returns true if an intent with the given key exists. +func HasIntent(key string) bool { + return getIntent(key) != nil +} + +// GetIntent returns the intent for a key, or nil if not found. +// This is the public API for retrieving intents. +func GetIntent(key string) *Intent { + return getIntent(key) +} diff --git a/pkg/i18n/intents_test.go b/pkg/i18n/intents_test.go index a0e27a0b..00bc6718 100644 --- a/pkg/i18n/intents_test.go +++ b/pkg/i18n/intents_test.go @@ -44,7 +44,80 @@ func TestRegisterIntent(t *testing.T) { assert.Equal(t, "Custom {{.Subject}}?", intent.Question) // Clean up - delete(coreIntents, "test.custom") + UnregisterIntent("test.custom") +} + +func TestRegisterIntents_Batch(t *testing.T) { + // Register multiple intents at once + RegisterIntents(map[string]Intent{ + "test.batch1": { + Meta: IntentMeta{Type: "action", Verb: "batch1", Default: "yes"}, + Question: "Batch 1?", + }, + "test.batch2": { + Meta: IntentMeta{Type: "action", Verb: "batch2", Default: "no"}, + Question: "Batch 2?", + }, + }) + + // Verify both were registered + assert.True(t, HasIntent("test.batch1")) + assert.True(t, HasIntent("test.batch2")) + + intent1 := GetIntent("test.batch1") + require.NotNil(t, intent1) + assert.Equal(t, "batch1", intent1.Meta.Verb) + + intent2 := GetIntent("test.batch2") + require.NotNil(t, intent2) + assert.Equal(t, "batch2", intent2.Meta.Verb) + + // Clean up + UnregisterIntent("test.batch1") + UnregisterIntent("test.batch2") + + // Verify cleanup + assert.False(t, HasIntent("test.batch1")) + assert.False(t, HasIntent("test.batch2")) +} + +func TestCustomIntentOverridesCoreIntent(t *testing.T) { + // Custom intents should be checked before core intents + RegisterIntent("core.delete", Intent{ + Meta: IntentMeta{Type: "action", Verb: "delete", Default: "yes"}, + Question: "Custom delete {{.Subject}}?", + }) + + // Should get custom intent + intent := getIntent("core.delete") + require.NotNil(t, intent) + assert.Equal(t, "Custom delete {{.Subject}}?", intent.Question) + assert.Equal(t, "yes", intent.Meta.Default) // Changed from core's "no" + + // Clean up + UnregisterIntent("core.delete") + + // Now should get core intent again + intent = getIntent("core.delete") + require.NotNil(t, intent) + assert.Equal(t, "Delete {{.Subject}}?", intent.Question) + assert.Equal(t, "no", intent.Meta.Default) // Back to core default +} + +func TestHasIntent(t *testing.T) { + assert.True(t, HasIntent("core.delete")) + assert.True(t, HasIntent("core.create")) + assert.False(t, HasIntent("nonexistent.intent")) +} + +func TestGetIntent_Public(t *testing.T) { + intent := GetIntent("core.delete") + require.NotNil(t, intent) + assert.Equal(t, "delete", intent.Meta.Verb) + + // Non-existent intent + intent = GetIntent("nonexistent.intent") + assert.Nil(t, intent) } func TestIntentKeys(t *testing.T) { diff --git a/pkg/i18n/interface.go b/pkg/i18n/interface.go new file mode 100644 index 00000000..5edddb6c --- /dev/null +++ b/pkg/i18n/interface.go @@ -0,0 +1,80 @@ +// 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 + + // C composes a semantic intent with a subject. + // Returns all output forms (Question, Confirm, Success, Failure). + // + // result := svc.C("core.delete", S("file", "config.yaml")) + C(intent string, subject *Subject) *Composed + + // 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) diff --git a/pkg/i18n/interface_test.go b/pkg/i18n/interface_test.go new file mode 100644 index 00000000..8647da7c --- /dev/null +++ b/pkg/i18n/interface_test.go @@ -0,0 +1,84 @@ +package i18n + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceImplementsTranslator(t *testing.T) { + // This test verifies at compile time that Service implements Translator + var _ Translator = (*Service)(nil) + + // Create a service and use it through the interface + var translator Translator + svc, err := New() + require.NoError(t, err) + + translator = svc + + // Test interface methods + assert.Equal(t, "Success", translator.T("cli.success")) + assert.NotEmpty(t, translator.Language()) + assert.NotNil(t, translator.Direction()) + assert.NotNil(t, translator.Formality()) +} + +// MockTranslator demonstrates how to create a mock for testing +type MockTranslator struct { + translations map[string]string + language string +} + +func (m *MockTranslator) T(key string, args ...any) string { + if v, ok := m.translations[key]; ok { + return v + } + return key +} + +func (m *MockTranslator) C(intent string, subject *Subject) *Composed { + return &Composed{ + Question: "Mock: " + intent, + Confirm: "Mock confirm", + Success: "Mock success", + Failure: "Mock failure", + } +} + +func (m *MockTranslator) SetLanguage(lang string) error { + m.language = lang + return nil +} + +func (m *MockTranslator) Language() string { + return m.language +} + +func (m *MockTranslator) SetMode(mode Mode) {} +func (m *MockTranslator) Mode() Mode { return ModeNormal } +func (m *MockTranslator) SetDebug(enabled bool) {} +func (m *MockTranslator) Debug() bool { return false } +func (m *MockTranslator) SetFormality(f Formality) {} +func (m *MockTranslator) Formality() Formality { return FormalityNeutral } +func (m *MockTranslator) Direction() TextDirection { return DirLTR } +func (m *MockTranslator) IsRTL() bool { return false } +func (m *MockTranslator) PluralCategory(n int) PluralCategory { return PluralOther } +func (m *MockTranslator) AvailableLanguages() []string { return []string{"en-GB"} } + +func TestMockTranslator(t *testing.T) { + var translator Translator = &MockTranslator{ + translations: map[string]string{ + "test.hello": "Hello from mock", + }, + language: "en-GB", + } + + assert.Equal(t, "Hello from mock", translator.T("test.hello")) + assert.Equal(t, "test.missing", translator.T("test.missing")) + assert.Equal(t, "en-GB", translator.Language()) + + result := translator.C("core.delete", S("file", "test.txt")) + assert.Equal(t, "Mock: core.delete", result.Question) +} diff --git a/pkg/i18n/keys.go b/pkg/i18n/keys.go new file mode 100644 index 00000000..08b2bbdb --- /dev/null +++ b/pkg/i18n/keys.go @@ -0,0 +1,126 @@ +// Code generated by go generate; DO NOT EDIT. +// To regenerate: task i18n:generate +package i18n + +// Intent keys for type-safe intent references. +// Use these constants instead of string literals to catch typos at compile time. +// +// result := C(IntentCoreDelete, S("file", "config.yaml")) +const ( + // Destructive actions + IntentCoreDelete = "core.delete" + IntentCoreRemove = "core.remove" + IntentCoreDiscard = "core.discard" + IntentCoreReset = "core.reset" + IntentCoreOverwrite = "core.overwrite" + + // Creation actions + IntentCoreCreate = "core.create" + IntentCoreAdd = "core.add" + IntentCoreClone = "core.clone" + IntentCoreCopy = "core.copy" + + // Modification actions + IntentCoreSave = "core.save" + IntentCoreUpdate = "core.update" + IntentCoreRename = "core.rename" + IntentCoreMove = "core.move" + + // Git actions + IntentCoreCommit = "core.commit" + IntentCorePush = "core.push" + IntentCorePull = "core.pull" + IntentCoreMerge = "core.merge" + IntentCoreRebase = "core.rebase" + + // Network actions + IntentCoreInstall = "core.install" + IntentCoreDownload = "core.download" + IntentCoreUpload = "core.upload" + IntentCorePublish = "core.publish" + IntentCoreDeploy = "core.deploy" + + // Process actions + IntentCoreStart = "core.start" + IntentCoreStop = "core.stop" + IntentCoreRestart = "core.restart" + IntentCoreRun = "core.run" + IntentCoreBuild = "core.build" + IntentCoreTest = "core.test" + + // Information actions + IntentCoreContinue = "core.continue" + IntentCoreProceed = "core.proceed" + IntentCoreConfirm = "core.confirm" + + // Additional actions + IntentCoreSync = "core.sync" + IntentCoreBoot = "core.boot" + IntentCoreFormat = "core.format" + IntentCoreAnalyse = "core.analyse" + IntentCoreLink = "core.link" + IntentCoreUnlink = "core.unlink" + IntentCoreFetch = "core.fetch" + IntentCoreGenerate = "core.generate" + IntentCoreValidate = "core.validate" + IntentCoreCheck = "core.check" + IntentCoreScan = "core.scan" +) + +// Common message keys for type-safe message references. +const ( + // CLI status messages + KeyCliSuccess = "cli.success" + KeyCliError = "cli.error" + KeyCliWarning = "cli.warning" + KeyCliInfo = "cli.info" + KeyCliDone = "cli.done" + KeyCliFailed = "cli.failed" + KeyCliPass = "cli.pass" + KeyCliFail = "cli.fail" + KeyCliOK = "cli.ok" + KeyCliSkip = "cli.skip" + KeyCliPending = "cli.pending" + KeyCliCompleted = "cli.completed" + KeyCliCancelled = "cli.cancelled" + KeyCliAborted = "cli.aborted" + + // Common prompts + KeyCommonPromptYes = "common.prompt.yes" + KeyCommonPromptNo = "common.prompt.no" + KeyCommonPromptContinue = "common.prompt.continue" + KeyCommonPromptProceed = "common.prompt.proceed" + KeyCommonPromptConfirm = "common.prompt.confirm" + KeyCommonPromptAbort = "common.prompt.abort" + KeyCommonPromptCancel = "common.prompt.cancel" + + // Common labels + KeyCommonLabelError = "common.label.error" + KeyCommonLabelDone = "common.label.done" + KeyCommonLabelStatus = "common.label.status" + KeyCommonLabelVersion = "common.label.version" + KeyCommonLabelSummary = "common.label.summary" + KeyCommonLabelSuccess = "common.label.success" + KeyCommonLabelWarning = "common.label.warning" + KeyCommonLabelNote = "common.label.note" + KeyCommonLabelTotal = "common.label.total" + KeyCommonLabelCoverage = "common.label.coverage" + KeyCommonLabelPath = "common.label.path" + KeyCommonLabelURL = "common.label.url" + + // Common status + KeyCommonStatusRunning = "common.status.running" + KeyCommonStatusStopped = "common.status.stopped" + KeyCommonStatusDirty = "common.status.dirty" + KeyCommonStatusSynced = "common.status.synced" + KeyCommonStatusUpToDate = "common.status.up_to_date" + KeyCommonStatusInstalling = "common.status.installing" + KeyCommonStatusCloning = "common.status.cloning" + + // Error messages + KeyErrorNotFound = "error.not_found" + KeyErrorInvalid = "error.invalid" + KeyErrorPermission = "error.permission" + KeyErrorTimeout = "error.timeout" + KeyErrorNetwork = "error.network" +) diff --git a/pkg/i18n/locales/de.json b/pkg/i18n/locales/de.json index ec2fcbfc..eeeae6b2 100644 --- a/pkg/i18n/locales/de.json +++ b/pkg/i18n/locales/de.json @@ -1,4 +1,30 @@ { + "common.verb.delete": { "base": "löschen", "past": "gelöscht", "gerund": "löschend" }, + "common.verb.save": { "base": "speichern", "past": "gespeichert", "gerund": "speichernd" }, + "common.verb.create": { "base": "erstellen", "past": "erstellt", "gerund": "erstellend" }, + "common.verb.update": { "base": "aktualisieren", "past": "aktualisiert", "gerund": "aktualisierend" }, + "common.verb.build": { "base": "bauen", "past": "gebaut", "gerund": "bauend" }, + "common.verb.run": { "base": "laufen", "past": "gelaufen", "gerund": "laufend" }, + "common.verb.check": { "base": "prüfen", "past": "geprüft", "gerund": "prüfend" }, + "common.verb.install": { "base": "installieren", "past": "installiert", "gerund": "installierend" }, + "common.verb.push": { "base": "pushen", "past": "gepusht", "gerund": "pushend" }, + "common.verb.pull": { "base": "pullen", "past": "gepullt", "gerund": "pullend" }, + "common.verb.commit": { "base": "committen", "past": "committet", "gerund": "committend" }, + + "common.noun.file": { "one": "Datei", "other": "Dateien", "gender": "feminine" }, + "common.noun.repo": { "one": "Repository", "other": "Repositories", "gender": "neuter" }, + "common.noun.commit": { "one": "Commit", "other": "Commits", "gender": "masculine" }, + "common.noun.branch": { "one": "Branch", "other": "Branches", "gender": "masculine" }, + "common.noun.change": { "one": "Änderung", "other": "Änderungen", "gender": "feminine" }, + "common.noun.item": { "one": "Element", "other": "Elemente", "gender": "neuter" }, + + "common.article.indefinite.masculine": "ein", + "common.article.indefinite.feminine": "eine", + "common.article.indefinite.neuter": "ein", + "common.article.definite.masculine": "der", + "common.article.definite.feminine": "die", + "common.article.definite.neuter": "das", + "cli.success": "Erfolg", "cli.error": "Fehler", "cli.warning": "Warnung", diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index dfc1e94f..67240530 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -1,5 +1,53 @@ { "common": { + "verb": { + "be": { "base": "be", "past": "was", "gerund": "being" }, + "go": { "base": "go", "past": "went", "gerund": "going" }, + "do": { "base": "do", "past": "did", "gerund": "doing" }, + "have": { "base": "have", "past": "had", "gerund": "having" }, + "make": { "base": "make", "past": "made", "gerund": "making" }, + "get": { "base": "get", "past": "got", "gerund": "getting" }, + "run": { "base": "run", "past": "ran", "gerund": "running" }, + "write": { "base": "write", "past": "wrote", "gerund": "writing" }, + "build": { "base": "build", "past": "built", "gerund": "building" }, + "send": { "base": "send", "past": "sent", "gerund": "sending" }, + "find": { "base": "find", "past": "found", "gerund": "finding" }, + "take": { "base": "take", "past": "took", "gerund": "taking" }, + "begin": { "base": "begin", "past": "began", "gerund": "beginning" }, + "keep": { "base": "keep", "past": "kept", "gerund": "keeping" }, + "hold": { "base": "hold", "past": "held", "gerund": "holding" }, + "bring": { "base": "bring", "past": "brought", "gerund": "bringing" }, + "think": { "base": "think", "past": "thought", "gerund": "thinking" }, + "buy": { "base": "buy", "past": "bought", "gerund": "buying" }, + "catch": { "base": "catch", "past": "caught", "gerund": "catching" }, + "choose": { "base": "choose", "past": "chose", "gerund": "choosing" }, + "lose": { "base": "lose", "past": "lost", "gerund": "losing" }, + "win": { "base": "win", "past": "won", "gerund": "winning" }, + "meet": { "base": "meet", "past": "met", "gerund": "meeting" }, + "lead": { "base": "lead", "past": "led", "gerund": "leading" }, + "leave": { "base": "leave", "past": "left", "gerund": "leaving" }, + "spend": { "base": "spend", "past": "spent", "gerund": "spending" }, + "pay": { "base": "pay", "past": "paid", "gerund": "paying" }, + "sell": { "base": "sell", "past": "sold", "gerund": "selling" } + }, + "noun": { + "file": { "one": "file", "other": "files", "gender": "neuter" }, + "repo": { "one": "repo", "other": "repos", "gender": "neuter" }, + "repository": { "one": "repository", "other": "repositories", "gender": "neuter" }, + "commit": { "one": "commit", "other": "commits", "gender": "neuter" }, + "branch": { "one": "branch", "other": "branches", "gender": "neuter" }, + "change": { "one": "change", "other": "changes", "gender": "neuter" }, + "item": { "one": "item", "other": "items", "gender": "neuter" }, + "issue": { "one": "issue", "other": "issues", "gender": "neuter" }, + "task": { "one": "task", "other": "tasks", "gender": "neuter" }, + "user": { "one": "user", "other": "users", "gender": "neuter" }, + "person": { "one": "person", "other": "people", "gender": "neuter" }, + "child": { "one": "child", "other": "children", "gender": "neuter" } + }, + "article": { + "indefinite": { "default": "a", "vowel": "an" }, + "definite": "the" + }, "prompt": { "yes": "y", "no": "n",