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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-30 13:11:58 +00:00
parent 46f6d4c5fe
commit 829be45fcc
11 changed files with 1029 additions and 37 deletions

View file

@ -44,3 +44,8 @@ tasks:
cmds: cmds:
- task: cov - task: cov
- go tool cover -html=coverage.txt - go tool cover -html=coverage.txt
i18n:generate:
desc: "Regenerate i18n key constants from locale files"
cmds:
- go generate ./pkg/i18n/...

View file

@ -34,6 +34,7 @@ type ConfirmOption func(*confirmConfig)
type confirmConfig struct { type confirmConfig struct {
defaultYes bool defaultYes bool
required bool required bool
timeout time.Duration
} }
// DefaultYes sets the default response to "yes" (pressing Enter confirms). // 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. // Confirm prompts the user for yes/no confirmation.
// Returns true if the user enters "y" or "yes" (case-insensitive). // 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("Save changes?", DefaultYes()) { ... }
// if Confirm("Dangerous!", Required()) { ... } // if Confirm("Dangerous!", Required()) { ... }
// if Confirm("Auto-continue?", Timeout(30*time.Second)) { ... }
func Confirm(prompt string, opts ...ConfirmOption) bool { func Confirm(prompt string, opts ...ConfirmOption) bool {
cfg := &confirmConfig{} cfg := &confirmConfig{}
for _, opt := range opts { for _, opt := range opts {
@ -77,12 +90,37 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
suffix = "[y/N] " 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) reader := bufio.NewReader(os.Stdin)
for { for {
fmt.Printf("%s %s", prompt, suffix) 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 // Handle empty response
if response == "" { if response == "" {
@ -237,7 +275,9 @@ type ChooseOption[T any] func(*chooseConfig[T])
type chooseConfig[T any] struct { type chooseConfig[T any] struct {
displayFn func(T) string 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. // 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. // Choose prompts the user to select from a list of items.
// Returns the selected item. Uses simple numbered selection for terminal compatibility. // 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...) 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. // FormatAge formats a time as a human-readable age string.
// Examples: "5m ago", "2h ago", "3d ago", "1w ago", "2mo ago" // Examples: "5m ago", "2h ago", "3d ago", "1w ago", "2mo ago"
func FormatAge(t time.Time) string { func FormatAge(t time.Time) string {

View file

@ -3,10 +3,104 @@ package i18n
import ( import (
"strings" "strings"
"sync"
"text/template" "text/template"
"unicode" "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. // VerbForms holds irregular verb conjugations.
type VerbForms struct { type VerbForms struct {
Past string // Past tense (e.g., "deleted") Past string // Past tense (e.g., "deleted")
@ -100,7 +194,7 @@ var irregularVerbs = map[string]VerbForms{
} }
// PastTense returns the past tense of a verb. // 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("delete") // "deleted"
// PastTense("run") // "ran" // PastTense("run") // "ran"
@ -111,7 +205,12 @@ func PastTense(verb string) string {
return "" 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 { if forms, ok := irregularVerbs[verb]; ok {
return forms.Past return forms.Past
} }
@ -220,6 +319,7 @@ func shouldDoubleConsonant(verb string) bool {
} }
// Gerund returns the present participle (-ing form) of a verb. // 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("delete") // "deleting"
// Gerund("run") // "running" // Gerund("run") // "running"
@ -230,7 +330,12 @@ func Gerund(verb string) string {
return "" 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 { if forms, ok := irregularVerbs[verb]; ok {
return forms.Gerund return forms.Gerund
} }
@ -331,6 +436,7 @@ func Pluralize(noun string, count int) string {
} }
// PluralForm returns the plural form of a noun. // PluralForm returns the plural form of a noun.
// Checks JSON locale data first, then irregular nouns, then applies regular rules.
// //
// PluralForm("file") // "files" // PluralForm("file") // "files"
// PluralForm("child") // "children" // PluralForm("child") // "children"
@ -343,6 +449,15 @@ func PluralForm(noun string) string {
lower := strings.ToLower(noun) 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 // Check irregular nouns
if plural, ok := irregularNouns[lower]; ok { if plural, ok := irregularNouns[lower]; ok {
// Preserve original casing if title case // Preserve original casing if title case

View file

@ -168,6 +168,7 @@ func NewWithFS(fsys fs.FS, dir string) (*Service, error) {
} }
// loadJSON parses nested JSON and flattens to dot-notation keys. // 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 { func (s *Service) loadJSON(lang string, data []byte) error {
var raw map[string]any var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil { 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) 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 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 return nil
} }
// flatten recursively flattens nested maps into dot-notation keys. // flatten recursively flattens nested maps into dot-notation keys.
func flatten(prefix string, data map[string]any, out map[string]Message) { 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 { for key, value := range data {
fullKey := key fullKey := key
if prefix != "" { if prefix != "" {
@ -193,6 +210,62 @@ func flatten(prefix string, data map[string]any, out map[string]Message) {
out[fullKey] = Message{Text: v} out[fullKey] = Message{Text: v}
case map[string]any: case map[string]any:
// Check if this is a 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) // Check if this is a plural object (has CLDR plural category keys)
if isPluralObject(v) { if isPluralObject(v) {
msg := Message{} msg := Message{}
@ -217,12 +290,38 @@ func flatten(prefix string, data map[string]any, out map[string]Message) {
out[fullKey] = msg out[fullKey] = msg
} else { } else {
// Recurse into nested object // 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. // isPluralObject checks if a map represents plural forms.
// Recognizes all CLDR plural categories: zero, one, two, few, many, other. // Recognizes all CLDR plural categories: zero, one, two, few, many, other.
func isPluralObject(m map[string]any) bool { func isPluralObject(m map[string]any) bool {
@ -530,6 +629,14 @@ func (s *Service) PluralCategory(n int) PluralCategory {
// For semantic intents (core.* namespace), pass a Subject to get the Question form: // For semantic intents (core.* namespace), pass a Subject to get the Question form:
// //
// svc.T("core.delete", S("file", "config.yaml")) // "Delete config.yaml?" // 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 { func (s *Service) T(messageID string, args ...any) string {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() 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 // Get template data
var data any var data any
if len(args) > 0 { if len(args) > 0 {
data = args[0] data = args[0]
} }
// Get the appropriate text // Try fallback chain
text := msg.Text text := s.resolveWithFallback(messageID, data)
if msg.IsPlural() {
count := getCount(data)
// Use CLDR plural category for current language
category := GetPluralCategory(s.currentLang, count)
text = msg.ForCategory(category)
}
if text == "" { if text == "" {
return s.handleMissingKey(messageID, args) return s.handleMissingKey(messageID, args)
} }
// Apply template if we have data
if data != nil {
text = applyTemplate(text, data)
}
// Debug mode: prefix with key // Debug mode: prefix with key
if s.debug { if s.debug {
return "[" + messageID + "] " + text return "[" + messageID + "] " + text
@ -586,6 +672,74 @@ func (s *Service) T(messageID string, args ...any) string {
return text 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. // handleMissingKey handles a missing translation key based on the current mode.
// Must be called with s.mu.RLock held. // Must be called with s.mu.RLock held.
func (s *Service) handleMissingKey(key string, args []any) string { func (s *Service) handleMissingKey(key string, args []any) string {
@ -669,17 +823,36 @@ func (s *Service) C(intent string, subject *Subject) *Composed {
return result 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. // 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 { func executeIntentTemplate(tmplStr string, data templateData) string {
if tmplStr == "" { if tmplStr == "" {
return "" 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) tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(tmplStr)
if err != nil { if err != nil {
return tmplStr return tmplStr
} }
// Store in cache (safe even if another goroutine stored it first)
templateCache.Store(tmplStr, tmpl)
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil { if err := tmpl.Execute(&buf, data); err != nil {
return tmplStr return tmplStr

View file

@ -1,6 +1,10 @@
// Package i18n provides internationalization for the CLI. // Package i18n provides internationalization for the CLI.
package i18n package i18n
import (
"sync"
)
// coreIntents defines the built-in semantic intents for common operations. // coreIntents defines the built-in semantic intents for common operations.
// These are accessed via the "core.*" namespace in T() and C() calls. // 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. // Returns nil if the intent is not found.
func getIntent(key string) *Intent { 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 { if intent, ok := coreIntents[key]; ok {
return &intent return &intent
} }
return nil 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. // Use this to extend the built-in intents with application-specific ones.
// This function is thread-safe.
// //
// i18n.RegisterIntent("myapp.archive", i18n.Intent{ // i18n.RegisterIntent("myapp.archive", i18n.Intent{
// Meta: i18n.IntentMeta{Type: "action", Verb: "archive", Default: "yes"}, // Meta: i18n.IntentMeta{Type: "action", Verb: "archive", Default: "yes"},
@ -584,14 +606,74 @@ func getIntent(key string) *Intent {
// Failure: "Failed to archive {{.Subject}}", // Failure: "Failed to archive {{.Subject}}",
// }) // })
func RegisterIntent(key string, intent Intent) { 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 { 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 { for key := range coreIntents {
keys = append(keys, key) 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 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)
}

View file

@ -44,7 +44,80 @@ func TestRegisterIntent(t *testing.T) {
assert.Equal(t, "Custom {{.Subject}}?", intent.Question) assert.Equal(t, "Custom {{.Subject}}?", intent.Question)
// Clean up // 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) { func TestIntentKeys(t *testing.T) {

80
pkg/i18n/interface.go Normal file
View file

@ -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)

View file

@ -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)
}

126
pkg/i18n/keys.go Normal file
View file

@ -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"
)

View file

@ -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.success": "Erfolg",
"cli.error": "Fehler", "cli.error": "Fehler",
"cli.warning": "Warnung", "cli.warning": "Warnung",

View file

@ -1,5 +1,53 @@
{ {
"common": { "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": { "prompt": {
"yes": "y", "yes": "y",
"no": "n", "no": "n",