diff --git a/pkg/i18n/grammar.go b/pkg/i18n/grammar.go index cc1b3f26..91859d72 100644 --- a/pkg/i18n/grammar.go +++ b/pkg/i18n/grammar.go @@ -393,8 +393,21 @@ func isVowel(r rune) bool { } // Title capitalizes the first letter of each word. +// Uses unicode-aware casing for proper internationalization. +// Word boundaries are defined as any non-letter character (matching strings.Title behavior). func Title(s string) string { - return strings.Title(s) //nolint:staticcheck // strings.Title is fine for our use case + var b strings.Builder + b.Grow(len(s)) + prev := ' ' // Treat start of string as word boundary + for _, r := range s { + if !unicode.IsLetter(prev) && unicode.IsLetter(r) { + b.WriteRune(unicode.ToUpper(r)) + } else { + b.WriteRune(r) + } + prev = r + } + return b.String() } // Quote wraps a string in double quotes. diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go index a3b22a63..a02bbac7 100644 --- a/pkg/i18n/i18n_test.go +++ b/pkg/i18n/i18n_test.go @@ -495,3 +495,83 @@ func TestNewWithFS(t *testing.T) { assert.True(t, svc.Debug()) }) } + +func TestConcurrentTranslation(t *testing.T) { + svc, err := New() + require.NoError(t, err) + + t.Run("concurrent T calls", func(t *testing.T) { + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + result := svc.T("cmd.dev.short") + assert.Equal(t, "Multi-repo development workflow", result) + }() + } + wg.Wait() + }) + + t.Run("concurrent T with args", func(t *testing.T) { + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + result := svc.T("i18n.count.file", n) + if n == 1 { + assert.Equal(t, "1 file", result) + } else { + assert.Contains(t, result, "files") + } + }(i) + } + wg.Wait() + }) + + t.Run("concurrent read and write", func(t *testing.T) { + var wg sync.WaitGroup + + // Readers + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = svc.T("cmd.dev.short") + _ = svc.Language() + _ = svc.Formality() + }() + } + + // Writers + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + svc.SetFormality(FormalityNeutral) + svc.SetDebug(false) + }() + } + + wg.Wait() + }) +} + +func TestConcurrentDefault(t *testing.T) { + // Reset for test + defaultService.Store(nil) + defaultOnce = sync.Once{} + defaultErr = nil + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + svc := Default() + assert.NotNil(t, svc) + }() + } + wg.Wait() +} diff --git a/pkg/i18n/loader.go b/pkg/i18n/loader.go index 56ca5da2..b7678610 100644 --- a/pkg/i18n/loader.go +++ b/pkg/i18n/loader.go @@ -5,7 +5,7 @@ import ( "encoding/json" "fmt" "io/fs" - "path/filepath" + "path" "strings" "sync" ) @@ -18,6 +18,7 @@ type FSLoader struct { // Cache of available languages (populated on first Languages() call) languages []string langOnce sync.Once + langErr error // Error from directory scan, if any } // NewFSLoader creates a loader for the given filesystem and directory. @@ -40,7 +41,7 @@ func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) { var data []byte var err error for _, filename := range variants { - filePath := filepath.Join(l.dir, filename) + filePath := path.Join(l.dir, filename) // Use path.Join for fs.FS (forward slashes) data, err = fs.ReadFile(l.fsys, filePath) if err == nil { break @@ -69,10 +70,12 @@ func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) { // Languages implements Loader.Languages - returns available language codes. // Thread-safe: uses sync.Once to ensure the directory is scanned only once. +// Returns nil if the directory scan failed (check LanguagesErr for details). func (l *FSLoader) Languages() []string { l.langOnce.Do(func() { entries, err := fs.ReadDir(l.fsys, l.dir) if err != nil { + l.langErr = fmt.Errorf("failed to read locale directory %q: %w", l.dir, err) return } @@ -90,6 +93,13 @@ func (l *FSLoader) Languages() []string { return l.languages } +// LanguagesErr returns any error that occurred during Languages() scan. +// Returns nil if the scan succeeded. +func (l *FSLoader) LanguagesErr() error { + l.Languages() // Ensure scan has been attempted + return l.langErr +} + // Ensure FSLoader implements Loader at compile time. var _ Loader = (*FSLoader)(nil) diff --git a/pkg/i18n/service.go b/pkg/i18n/service.go index 692ea1cb..0df51246 100644 --- a/pkg/i18n/service.go +++ b/pkg/i18n/service.go @@ -161,15 +161,17 @@ func Init() error { // Default returns the global i18n service, initializing if needed. // Thread-safe: can be called concurrently. func Default() *Service { - if defaultService.Load() == nil { - _ = Init() - } + _ = Init() // sync.Once handles idempotency return defaultService.Load() } // SetDefault sets the global i18n service. // Thread-safe: can be called concurrently with Default(). +// Panics if s is nil. func SetDefault(s *Service) { + if s == nil { + panic("i18n: SetDefault called with nil service") + } defaultService.Store(s) } @@ -490,9 +492,14 @@ func (s *Service) getEffectiveFormality(data any) Formality { // handleMissingKey handles a missing translation key based on the current mode. // Must be called with s.mu.RLock held. +// +// In ModeStrict, this panics - use only in development/CI to catch missing keys. +// In ModeCollect, this dispatches to OnMissingKey handler for logging/collection. +// In ModeNormal (default), this returns the key as-is. func (s *Service) handleMissingKey(key string, args []any) string { switch s.mode { case ModeStrict: + // WARNING: Panics! Use ModeStrict only in development/CI environments. panic(fmt.Sprintf("i18n: missing translation key %q", key)) case ModeCollect: // Convert args to map for the action diff --git a/pkg/i18n/transform.go b/pkg/i18n/transform.go index 095cd168..3421db2a 100644 --- a/pkg/i18n/transform.go +++ b/pkg/i18n/transform.go @@ -31,6 +31,20 @@ func toInt(v any) int { return int(n) case int32: return int(n) + case int16: + return int(n) + case int8: + return int(n) + case uint: + return int(n) + case uint64: + return int(n) + case uint32: + return int(n) + case uint16: + return int(n) + case uint8: + return int(n) case float64: return int(n) case float32: @@ -51,6 +65,20 @@ func toInt64(v any) int64 { return n case int32: return int64(n) + case int16: + return int64(n) + case int8: + return int64(n) + case uint: + return int64(n) + case uint64: + return int64(n) + case uint32: + return int64(n) + case uint16: + return int64(n) + case uint8: + return int64(n) case float64: return int64(n) case float32: @@ -75,6 +103,20 @@ func toFloat64(v any) float64 { return float64(n) case int32: return float64(n) + case int16: + return float64(n) + case int8: + return float64(n) + case uint: + return float64(n) + case uint64: + return float64(n) + case uint32: + return float64(n) + case uint16: + return float64(n) + case uint8: + return float64(n) } return 0 }