diff --git a/pkg/i18n/checks.go b/pkg/i18n/checks.go deleted file mode 100644 index 9ea5b1e..0000000 --- a/pkg/i18n/checks.go +++ /dev/null @@ -1,57 +0,0 @@ -// Package i18n provides internationalization for the CLI. -package i18n - -// 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 { - _, hasZero := m["zero"] - _, hasOne := m["one"] - _, hasTwo := m["two"] - _, hasFew := m["few"] - _, hasMany := m["many"] - _, hasOther := m["other"] - - // It's a plural object if it has any plural category key - if !hasZero && !hasOne && !hasTwo && !hasFew && !hasMany && !hasOther { - return false - } - // But not if it contains nested objects (those are namespace containers) - for _, v := range m { - if _, isMap := v.(map[string]any); isMap { - return false - } - } - return true -} - -// IsPlural returns true if this message has any plural forms. -func (m Message) IsPlural() bool { - return m.Zero != "" || m.One != "" || m.Two != "" || - m.Few != "" || m.Many != "" || m.Other != "" -} diff --git a/pkg/i18n/checks_test.go b/pkg/i18n/checks_test.go deleted file mode 100644 index 19cdbc4..0000000 --- a/pkg/i18n/checks_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package i18n - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestIsVerbFormObject(t *testing.T) { - tests := []struct { - name string - input map[string]any - expected bool - }{ - { - name: "has base only", - input: map[string]any{"base": "run"}, - expected: true, - }, - { - name: "has past only", - input: map[string]any{"past": "ran"}, - expected: true, - }, - { - name: "has gerund only", - input: map[string]any{"gerund": "running"}, - expected: true, - }, - { - name: "has all verb forms", - input: map[string]any{"base": "run", "past": "ran", "gerund": "running"}, - expected: true, - }, - { - name: "empty map", - input: map[string]any{}, - expected: false, - }, - { - name: "plural object not verb", - input: map[string]any{"one": "item", "other": "items"}, - expected: false, - }, - { - name: "unrelated keys", - input: map[string]any{"foo": "bar", "baz": "qux"}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isVerbFormObject(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestIsNounFormObject(t *testing.T) { - tests := []struct { - name string - input map[string]any - expected bool - }{ - { - name: "has gender", - input: map[string]any{"gender": "masculine", "one": "file", "other": "files"}, - expected: true, - }, - { - name: "gender only", - input: map[string]any{"gender": "feminine"}, - expected: true, - }, - { - name: "no gender", - input: map[string]any{"one": "item", "other": "items"}, - expected: false, - }, - { - name: "empty map", - input: map[string]any{}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isNounFormObject(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestHasPluralCategories(t *testing.T) { - tests := []struct { - name string - input map[string]any - expected bool - }{ - { - name: "has zero", - input: map[string]any{"zero": "none", "one": "one", "other": "many"}, - expected: true, - }, - { - name: "has two", - input: map[string]any{"one": "one", "two": "two", "other": "many"}, - expected: true, - }, - { - name: "has few", - input: map[string]any{"one": "one", "few": "few", "other": "many"}, - expected: true, - }, - { - name: "has many", - input: map[string]any{"one": "one", "many": "many", "other": "other"}, - expected: true, - }, - { - name: "has all categories", - input: map[string]any{"zero": "0", "one": "1", "two": "2", "few": "few", "many": "many", "other": "other"}, - expected: true, - }, - { - name: "only one and other", - input: map[string]any{"one": "item", "other": "items"}, - expected: false, - }, - { - name: "empty map", - input: map[string]any{}, - expected: false, - }, - { - name: "unrelated keys", - input: map[string]any{"foo": "bar"}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := hasPluralCategories(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestIsPluralObject(t *testing.T) { - tests := []struct { - name string - input map[string]any - expected bool - }{ - { - name: "one and other", - input: map[string]any{"one": "item", "other": "items"}, - expected: true, - }, - { - name: "all CLDR categories", - input: map[string]any{"zero": "0", "one": "1", "two": "2", "few": "few", "many": "many", "other": "other"}, - expected: true, - }, - { - name: "only other", - input: map[string]any{"other": "items"}, - expected: true, - }, - { - name: "empty map", - input: map[string]any{}, - expected: false, - }, - { - name: "nested map is not plural", - input: map[string]any{"one": "item", "other": map[string]any{"nested": "value"}}, - expected: false, - }, - { - name: "unrelated keys", - input: map[string]any{"foo": "bar", "baz": "qux"}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isPluralObject(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestMessageIsPlural(t *testing.T) { - tests := []struct { - name string - msg Message - expected bool - }{ - { - name: "has zero", - msg: Message{Zero: "none"}, - expected: true, - }, - { - name: "has one", - msg: Message{One: "item"}, - expected: true, - }, - { - name: "has two", - msg: Message{Two: "items"}, - expected: true, - }, - { - name: "has few", - msg: Message{Few: "a few"}, - expected: true, - }, - { - name: "has many", - msg: Message{Many: "lots"}, - expected: true, - }, - { - name: "has other", - msg: Message{Other: "items"}, - expected: true, - }, - { - name: "has all", - msg: Message{Zero: "0", One: "1", Two: "2", Few: "few", Many: "many", Other: "other"}, - expected: true, - }, - { - name: "text only", - msg: Message{Text: "hello"}, - expected: false, - }, - { - name: "empty message", - msg: Message{}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.msg.IsPlural() - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/pkg/i18n/context.go b/pkg/i18n/context.go new file mode 100644 index 0000000..c20d7f5 --- /dev/null +++ b/pkg/i18n/context.go @@ -0,0 +1,106 @@ +// Package i18n provides internationalization for the CLI. +package i18n + +// TranslationContext provides disambiguation for translations. +// Use this when the same word translates differently in different contexts. +// +// Example: "right" can mean direction or correctness: +// +// T("direction.right", C("navigation")) // → "rechts" (German) +// T("status.right", C("correctness")) // → "richtig" (German) +type TranslationContext struct { + Context string // Semantic context (e.g., "navigation", "correctness") + Gender string // Grammatical gender hint (e.g., "masculine", "feminine") + Formality Formality // Formality level override + Extra map[string]any // Additional context-specific data +} + +// C creates a TranslationContext with the given context string. +// Chain methods to add more context: +// +// C("navigation").Gender("masculine").Formal() +func C(context string) *TranslationContext { + return &TranslationContext{ + Context: context, + } +} + +// WithGender sets the grammatical gender hint. +func (c *TranslationContext) WithGender(gender string) *TranslationContext { + if c == nil { + return nil + } + c.Gender = gender + return c +} + +// Formal sets the formality level to formal. +func (c *TranslationContext) Formal() *TranslationContext { + if c == nil { + return nil + } + c.Formality = FormalityFormal + return c +} + +// Informal sets the formality level to informal. +func (c *TranslationContext) Informal() *TranslationContext { + if c == nil { + return nil + } + c.Formality = FormalityInformal + return c +} + +// WithFormality sets an explicit formality level. +func (c *TranslationContext) WithFormality(f Formality) *TranslationContext { + if c == nil { + return nil + } + c.Formality = f + return c +} + +// Set adds a key-value pair to the extra context data. +func (c *TranslationContext) Set(key string, value any) *TranslationContext { + if c == nil { + return nil + } + if c.Extra == nil { + c.Extra = make(map[string]any) + } + c.Extra[key] = value + return c +} + +// Get retrieves a value from the extra context data. +func (c *TranslationContext) Get(key string) any { + if c == nil || c.Extra == nil { + return nil + } + return c.Extra[key] +} + +// ContextString returns the context string (nil-safe). +func (c *TranslationContext) ContextString() string { + if c == nil { + return "" + } + return c.Context +} + +// GenderString returns the gender hint (nil-safe). +func (c *TranslationContext) GenderString() string { + if c == nil { + return "" + } + return c.Gender +} + +// FormalityValue returns the formality level (nil-safe). +func (c *TranslationContext) FormalityValue() Formality { + if c == nil { + return FormalityNeutral + } + return c.Formality +} diff --git a/pkg/i18n/context_test.go b/pkg/i18n/context_test.go new file mode 100644 index 0000000..a81cf84 --- /dev/null +++ b/pkg/i18n/context_test.go @@ -0,0 +1,125 @@ +package i18n + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTranslationContext_C(t *testing.T) { + t.Run("creates context", func(t *testing.T) { + ctx := C("navigation") + assert.NotNil(t, ctx) + assert.Equal(t, "navigation", ctx.Context) + }) + + t.Run("empty context", func(t *testing.T) { + ctx := C("") + assert.NotNil(t, ctx) + assert.Empty(t, ctx.Context) + }) +} + +func TestTranslationContext_WithGender(t *testing.T) { + t.Run("sets gender", func(t *testing.T) { + ctx := C("context").WithGender("masculine") + assert.Equal(t, "masculine", ctx.Gender) + }) + + t.Run("nil safety", func(t *testing.T) { + var ctx *TranslationContext + result := ctx.WithGender("masculine") + assert.Nil(t, result) + }) +} + +func TestTranslationContext_Formality(t *testing.T) { + t.Run("Formal", func(t *testing.T) { + ctx := C("context").Formal() + assert.Equal(t, FormalityFormal, ctx.Formality) + }) + + t.Run("Informal", func(t *testing.T) { + ctx := C("context").Informal() + assert.Equal(t, FormalityInformal, ctx.Formality) + }) + + t.Run("WithFormality", func(t *testing.T) { + ctx := C("context").WithFormality(FormalityFormal) + assert.Equal(t, FormalityFormal, ctx.Formality) + }) + + t.Run("nil safety", func(t *testing.T) { + var ctx *TranslationContext + assert.Nil(t, ctx.Formal()) + assert.Nil(t, ctx.Informal()) + assert.Nil(t, ctx.WithFormality(FormalityFormal)) + }) +} + +func TestTranslationContext_Extra(t *testing.T) { + t.Run("Set and Get", func(t *testing.T) { + ctx := C("context").Set("key", "value") + assert.Equal(t, "value", ctx.Get("key")) + }) + + t.Run("Get missing key", func(t *testing.T) { + ctx := C("context") + assert.Nil(t, ctx.Get("missing")) + }) + + t.Run("nil safety Set", func(t *testing.T) { + var ctx *TranslationContext + result := ctx.Set("key", "value") + assert.Nil(t, result) + }) + + t.Run("nil safety Get", func(t *testing.T) { + var ctx *TranslationContext + assert.Nil(t, ctx.Get("key")) + }) +} + +func TestTranslationContext_Getters(t *testing.T) { + t.Run("ContextString", func(t *testing.T) { + ctx := C("navigation") + assert.Equal(t, "navigation", ctx.ContextString()) + }) + + t.Run("ContextString nil", func(t *testing.T) { + var ctx *TranslationContext + assert.Empty(t, ctx.ContextString()) + }) + + t.Run("GenderString", func(t *testing.T) { + ctx := C("context").WithGender("feminine") + assert.Equal(t, "feminine", ctx.GenderString()) + }) + + t.Run("GenderString nil", func(t *testing.T) { + var ctx *TranslationContext + assert.Empty(t, ctx.GenderString()) + }) + + t.Run("FormalityValue", func(t *testing.T) { + ctx := C("context").Formal() + assert.Equal(t, FormalityFormal, ctx.FormalityValue()) + }) + + t.Run("FormalityValue nil", func(t *testing.T) { + var ctx *TranslationContext + assert.Equal(t, FormalityNeutral, ctx.FormalityValue()) + }) +} + +func TestTranslationContext_Chaining(t *testing.T) { + ctx := C("navigation"). + WithGender("masculine"). + Formal(). + Set("locale", "de-DE") + + assert.Equal(t, "navigation", ctx.Context) + assert.Equal(t, "masculine", ctx.Gender) + assert.Equal(t, FormalityFormal, ctx.Formality) + assert.Equal(t, "de-DE", ctx.Get("locale")) +} diff --git a/pkg/i18n/handler.go b/pkg/i18n/handler.go new file mode 100644 index 0000000..d40df14 --- /dev/null +++ b/pkg/i18n/handler.go @@ -0,0 +1,166 @@ +// Package i18n provides internationalization for the CLI. +package i18n + +import ( + "fmt" + "strings" +) + +// --- Built-in Handlers --- + +// LabelHandler handles i18n.label.{word} → "Status:" patterns. +type LabelHandler struct{} + +func (h LabelHandler) Match(key string) bool { + return strings.HasPrefix(key, "i18n.label.") +} + +func (h LabelHandler) Handle(key string, args []any, next func() string) string { + word := strings.TrimPrefix(key, "i18n.label.") + return Label(word) +} + +// ProgressHandler handles i18n.progress.{verb} → "Building..." patterns. +type ProgressHandler struct{} + +func (h ProgressHandler) Match(key string) bool { + return strings.HasPrefix(key, "i18n.progress.") +} + +func (h ProgressHandler) Handle(key string, args []any, next func() string) string { + verb := strings.TrimPrefix(key, "i18n.progress.") + if len(args) > 0 { + if subj, ok := args[0].(string); ok { + return ProgressSubject(verb, subj) + } + } + return Progress(verb) +} + +// CountHandler handles i18n.count.{noun} → "5 files" patterns. +type CountHandler struct{} + +func (h CountHandler) Match(key string) bool { + return strings.HasPrefix(key, "i18n.count.") +} + +func (h CountHandler) Handle(key string, args []any, next func() string) string { + noun := strings.TrimPrefix(key, "i18n.count.") + if len(args) > 0 { + count := toInt(args[0]) + return fmt.Sprintf("%d %s", count, Pluralize(noun, count)) + } + return noun +} + +// DoneHandler handles i18n.done.{verb} → "File deleted" patterns. +type DoneHandler struct{} + +func (h DoneHandler) Match(key string) bool { + return strings.HasPrefix(key, "i18n.done.") +} + +func (h DoneHandler) Handle(key string, args []any, next func() string) string { + verb := strings.TrimPrefix(key, "i18n.done.") + if len(args) > 0 { + if subj, ok := args[0].(string); ok { + return ActionResult(verb, subj) + } + } + return Title(PastTense(verb)) +} + +// FailHandler handles i18n.fail.{verb} → "Failed to delete file" patterns. +type FailHandler struct{} + +func (h FailHandler) Match(key string) bool { + return strings.HasPrefix(key, "i18n.fail.") +} + +func (h FailHandler) Handle(key string, args []any, next func() string) string { + verb := strings.TrimPrefix(key, "i18n.fail.") + if len(args) > 0 { + if subj, ok := args[0].(string); ok { + return ActionFailed(verb, subj) + } + } + return ActionFailed(verb, "") +} + +// NumericHandler handles i18n.numeric.{format} → formatted numbers. +type NumericHandler struct{} + +func (h NumericHandler) Match(key string) bool { + return strings.HasPrefix(key, "i18n.numeric.") +} + +func (h NumericHandler) Handle(key string, args []any, next func() string) string { + if len(args) == 0 { + return next() + } + + format := strings.TrimPrefix(key, "i18n.numeric.") + switch format { + case "number", "int": + return FormatNumber(toInt64(args[0])) + case "decimal", "float": + return FormatDecimal(toFloat64(args[0])) + case "percent", "pct": + return FormatPercent(toFloat64(args[0])) + case "bytes", "size": + return FormatBytes(toInt64(args[0])) + case "ordinal", "ord": + return FormatOrdinal(toInt(args[0])) + case "ago": + if len(args) >= 2 { + if unit, ok := args[1].(string); ok { + return FormatAgo(toInt(args[0]), unit) + } + } + } + return next() +} + +// --- Handler Chain --- + +// DefaultHandlers returns the built-in i18n.* namespace handlers. +func DefaultHandlers() []KeyHandler { + return []KeyHandler{ + LabelHandler{}, + ProgressHandler{}, + CountHandler{}, + DoneHandler{}, + FailHandler{}, + NumericHandler{}, + } +} + +// RunHandlerChain executes a chain of handlers for a key. +// Returns empty string if no handler matched (caller should use standard lookup). +func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback func() string) string { + for i, h := range handlers { + if h.Match(key) { + // Create next function that tries remaining handlers + next := func() string { + remaining := handlers[i+1:] + if len(remaining) > 0 { + return RunHandlerChain(remaining, key, args, fallback) + } + return fallback() + } + return h.Handle(key, args, next) + } + } + return fallback() +} + +// --- Compile-time interface checks --- + +var ( + _ KeyHandler = LabelHandler{} + _ KeyHandler = ProgressHandler{} + _ KeyHandler = CountHandler{} + _ KeyHandler = DoneHandler{} + _ KeyHandler = FailHandler{} + _ KeyHandler = NumericHandler{} +) diff --git a/pkg/i18n/handler_test.go b/pkg/i18n/handler_test.go new file mode 100644 index 0000000..bdc56a0 --- /dev/null +++ b/pkg/i18n/handler_test.go @@ -0,0 +1,173 @@ +package i18n + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLabelHandler(t *testing.T) { + h := LabelHandler{} + + t.Run("matches i18n.label prefix", func(t *testing.T) { + assert.True(t, h.Match("i18n.label.status")) + assert.True(t, h.Match("i18n.label.version")) + assert.False(t, h.Match("i18n.progress.build")) + assert.False(t, h.Match("cli.label.status")) + }) + + t.Run("handles label", func(t *testing.T) { + result := h.Handle("i18n.label.status", nil, func() string { return "fallback" }) + assert.Equal(t, "Status:", result) + }) +} + +func TestProgressHandler(t *testing.T) { + h := ProgressHandler{} + + t.Run("matches i18n.progress prefix", func(t *testing.T) { + assert.True(t, h.Match("i18n.progress.build")) + assert.True(t, h.Match("i18n.progress.check")) + assert.False(t, h.Match("i18n.label.status")) + }) + + t.Run("handles progress without subject", func(t *testing.T) { + result := h.Handle("i18n.progress.build", nil, func() string { return "fallback" }) + assert.Equal(t, "Building...", result) + }) + + t.Run("handles progress with subject", func(t *testing.T) { + result := h.Handle("i18n.progress.check", []any{"config"}, func() string { return "fallback" }) + assert.Equal(t, "Checking config...", result) + }) +} + +func TestCountHandler(t *testing.T) { + h := CountHandler{} + + t.Run("matches i18n.count prefix", func(t *testing.T) { + assert.True(t, h.Match("i18n.count.file")) + assert.True(t, h.Match("i18n.count.repo")) + assert.False(t, h.Match("i18n.label.count")) + }) + + t.Run("handles count with number", func(t *testing.T) { + result := h.Handle("i18n.count.file", []any{5}, func() string { return "fallback" }) + assert.Equal(t, "5 files", result) + }) + + t.Run("handles singular count", func(t *testing.T) { + result := h.Handle("i18n.count.file", []any{1}, func() string { return "fallback" }) + assert.Equal(t, "1 file", result) + }) + + t.Run("handles no args", func(t *testing.T) { + result := h.Handle("i18n.count.file", nil, func() string { return "fallback" }) + assert.Equal(t, "file", result) + }) +} + +func TestDoneHandler(t *testing.T) { + h := DoneHandler{} + + t.Run("matches i18n.done prefix", func(t *testing.T) { + assert.True(t, h.Match("i18n.done.delete")) + assert.True(t, h.Match("i18n.done.save")) + assert.False(t, h.Match("i18n.fail.delete")) + }) + + t.Run("handles done with subject", func(t *testing.T) { + result := h.Handle("i18n.done.delete", []any{"config.yaml"}, func() string { return "fallback" }) + // ActionResult title-cases the subject + assert.Equal(t, "Config.Yaml deleted", result) + }) + + t.Run("handles done without subject", func(t *testing.T) { + result := h.Handle("i18n.done.delete", nil, func() string { return "fallback" }) + assert.Equal(t, "Deleted", result) + }) +} + +func TestFailHandler(t *testing.T) { + h := FailHandler{} + + t.Run("matches i18n.fail prefix", func(t *testing.T) { + assert.True(t, h.Match("i18n.fail.delete")) + assert.True(t, h.Match("i18n.fail.save")) + assert.False(t, h.Match("i18n.done.delete")) + }) + + t.Run("handles fail with subject", func(t *testing.T) { + result := h.Handle("i18n.fail.delete", []any{"config.yaml"}, func() string { return "fallback" }) + assert.Equal(t, "Failed to delete config.yaml", result) + }) + + t.Run("handles fail without subject", func(t *testing.T) { + result := h.Handle("i18n.fail.delete", nil, func() string { return "fallback" }) + assert.Contains(t, result, "Failed to delete") + }) +} + +func TestNumericHandler(t *testing.T) { + h := NumericHandler{} + + t.Run("matches i18n.numeric prefix", func(t *testing.T) { + assert.True(t, h.Match("i18n.numeric.number")) + assert.True(t, h.Match("i18n.numeric.bytes")) + assert.False(t, h.Match("i18n.count.file")) + }) + + t.Run("handles number format", func(t *testing.T) { + result := h.Handle("i18n.numeric.number", []any{1234567}, func() string { return "fallback" }) + assert.Equal(t, "1,234,567", result) + }) + + t.Run("handles bytes format", func(t *testing.T) { + result := h.Handle("i18n.numeric.bytes", []any{1024}, func() string { return "fallback" }) + assert.Equal(t, "1 KB", result) + }) + + t.Run("handles ordinal format", func(t *testing.T) { + result := h.Handle("i18n.numeric.ordinal", []any{3}, func() string { return "fallback" }) + assert.Equal(t, "3rd", result) + }) + + t.Run("falls through on no args", func(t *testing.T) { + result := h.Handle("i18n.numeric.number", nil, func() string { return "fallback" }) + assert.Equal(t, "fallback", result) + }) + + t.Run("falls through on unknown format", func(t *testing.T) { + result := h.Handle("i18n.numeric.unknown", []any{123}, func() string { return "fallback" }) + assert.Equal(t, "fallback", result) + }) +} + +func TestDefaultHandlers(t *testing.T) { + handlers := DefaultHandlers() + assert.Len(t, handlers, 6) +} + +func TestRunHandlerChain(t *testing.T) { + handlers := DefaultHandlers() + + t.Run("label handler matches", func(t *testing.T) { + result := RunHandlerChain(handlers, "i18n.label.status", nil, func() string { return "fallback" }) + assert.Equal(t, "Status:", result) + }) + + t.Run("progress handler matches", func(t *testing.T) { + result := RunHandlerChain(handlers, "i18n.progress.build", nil, func() string { return "fallback" }) + assert.Equal(t, "Building...", result) + }) + + t.Run("falls back for unknown key", func(t *testing.T) { + result := RunHandlerChain(handlers, "cli.unknown", nil, func() string { return "fallback" }) + assert.Equal(t, "fallback", result) + }) + + t.Run("empty handler chain uses fallback", func(t *testing.T) { + result := RunHandlerChain(nil, "any.key", nil, func() string { return "fallback" }) + assert.Equal(t, "fallback", result) + }) +} diff --git a/pkg/i18n/actions.go b/pkg/i18n/hooks.go similarity index 100% rename from pkg/i18n/actions.go rename to pkg/i18n/hooks.go diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go index 2ebab50..60959d1 100644 --- a/pkg/i18n/i18n.go +++ b/pkg/i18n/i18n.go @@ -110,6 +110,22 @@ func N(format string, value any) string { return T("i18n.numeric."+format, value) } +// AddHandler appends a handler to the default service's handler chain. +// Does nothing if the service is not initialized. +func AddHandler(h KeyHandler) { + if svc := Default(); svc != nil { + svc.AddHandler(h) + } +} + +// PrependHandler inserts a handler at the start of the default service's handler chain. +// Does nothing if the service is not initialized. +func PrependHandler(h KeyHandler) { + if svc := Default(); svc != nil { + svc.PrependHandler(h) + } +} + // --- Template helpers --- // executeIntentTemplate executes an intent template with the given data. diff --git a/pkg/i18n/interfaces.go b/pkg/i18n/interfaces.go deleted file mode 100644 index 774a3b2..0000000 --- a/pkg/i18n/interfaces.go +++ /dev/null @@ -1,650 +0,0 @@ -// Package i18n provides internationalization for the CLI. -package i18n - -import ( - "embed" - "sync" - - "golang.org/x/text/language" -) - -// Service provides internationalization and localization. -type Service struct { - messages map[string]map[string]Message // lang -> key -> message - currentLang string - fallbackLang string - availableLangs []language.Tag - mode Mode // Translation mode (Normal, Strict, Collect) - debug bool // Debug mode shows key prefixes - formality Formality // Default formality level for translations - mu sync.RWMutex -} - -// Default is the global i18n service instance. -var ( - defaultService *Service - defaultOnce sync.Once - defaultErr error -) - -// templateCache stores compiled templates for reuse. -// Key is the template string, value is the compiled template. -var templateCache sync.Map - -//go:embed locales/*.json -var localeFS embed.FS - -// Translator defines the interface for translation services. -// Implement this interface to provide custom translation backends -// or mock implementations for testing. -// -// Example usage in tests: -// -// type mockTranslator struct { -// translations map[string]string -// } -// -// func (m *mockTranslator) T(key string, args ...any) string { -// if v, ok := m.translations[key]; ok { -// return v -// } -// return key -// } -// -// func TestSomething(t *testing.T) { -// mock := &mockTranslator{translations: map[string]string{ -// "cli.success": "Test Success", -// }} -// // Use mock in your tests -// } -type Translator interface { - // T translates a message by its ID. - // Optional template data can be passed for interpolation. - // - // svc.T("cli.success") - // svc.T("cli.count.items", map[string]any{"Count": 5}) - T(messageID string, args ...any) string - - // SetLanguage sets the language for translations. - // Returns an error if the language is not supported. - SetLanguage(lang string) error - - // Language returns the current language code. - Language() string - - // SetMode sets the translation mode for missing key handling. - SetMode(m Mode) - - // Mode returns the current translation mode. - Mode() Mode - - // SetDebug enables or disables debug mode. - SetDebug(enabled bool) - - // Debug returns whether debug mode is enabled. - Debug() bool - - // SetFormality sets the default formality level for translations. - SetFormality(f Formality) - - // Formality returns the current formality level. - Formality() Formality - - // Direction returns the text direction for the current language. - Direction() TextDirection - - // IsRTL returns true if the current language uses RTL text. - IsRTL() bool - - // PluralCategory returns the plural category for a count. - PluralCategory(n int) PluralCategory - - // AvailableLanguages returns the list of available language codes. - AvailableLanguages() []string -} - -// Ensure Service implements Translator at compile time. -var _ Translator = (*Service)(nil) - -// NumberFormat defines locale-specific number formatting rules. -type NumberFormat struct { - ThousandsSep string // "," for en, "." for de - DecimalSep string // "." for en, "," for de - PercentFmt string // "%s%%" for en, "%s %%" for de (space before %) -} - -// Default number formats by language. -var numberFormats = map[string]NumberFormat{ - "en": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"}, - "de": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s %%"}, - "fr": {ThousandsSep: " ", DecimalSep: ",", PercentFmt: "%s %%"}, - "es": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s%%"}, - "zh": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"}, -} - -// Mode determines how the i18n service handles missing translation keys. -type Mode int - -const ( - // ModeNormal returns the key as-is when a translation is missing (production). - ModeNormal Mode = iota - // ModeStrict panics immediately when a translation is missing (dev/CI). - ModeStrict - // ModeCollect dispatches MissingKey actions and returns [key] (QA testing). - ModeCollect -) - -// Subject represents a typed subject with metadata for semantic translations. -// Use S() to create a Subject and chain methods for additional context. -// -// S("file", "config.yaml") -// S("repo", "core-php").Count(3) -// S("user", user).Gender("feminine") -// S("colleague", name).Formal() -type Subject struct { - Noun string // The noun type (e.g., "file", "repo", "user") - Value any // The actual value (e.g., filename, struct, etc.) - count int // Count for pluralization (default 1) - gender string // Grammatical gender for languages that need it - location string // Location context (e.g., "in workspace") - formality Formality // Formality level override (-1 = use service default) -} - -// IntentMeta defines the behaviour and characteristics of an intent. -type IntentMeta struct { - Type string // "action", "question", "info" - Verb string // Reference to verb key (e.g., "delete", "save") - Dangerous bool // If true, requires extra confirmation - Default string // Default response: "yes" or "no" - Supports []string // Extra options supported by this intent -} - -// Composed holds all output forms for an intent after template resolution. -// Each field is ready to display to the user. -type Composed struct { - Question string // Question form: "Delete config.yaml?" - Confirm string // Confirmation form: "Really delete config.yaml?" - Success string // Success message: "config.yaml deleted" - Failure string // Failure message: "Failed to delete config.yaml" - Meta IntentMeta // Intent metadata for UI decisions -} - -// Intent defines a semantic intent with templates for all output forms. -// Templates use Go text/template syntax with Subject data available. -type Intent struct { - Meta IntentMeta // Intent behaviour and characteristics - Question string // Template for question form - Confirm string // Template for confirmation form - Success string // Template for success message - Failure string // Template for failure message -} - -// templateData is passed to intent templates during execution. -type templateData struct { - Subject string // Display value of subject - Noun string // Noun type - Count int // Count for pluralization - Gender string // Grammatical gender - Location string // Location context - Formality Formality // Formality level - IsFormal bool // Convenience: formality == FormalityFormal - IsPlural bool // Convenience: count != 1 - Value any // Raw value (for complex templates) -} - -// GrammarData holds language-specific grammar forms loaded from JSON. -type GrammarData struct { - Verbs map[string]VerbForms // verb -> forms - Nouns map[string]NounForms // noun -> forms - Articles ArticleForms // article configuration - Words map[string]string // base word translations - Punct PunctuationRules // language-specific punctuation -} - -// PunctuationRules holds language-specific punctuation patterns. -// French uses " :" (space before colon), English uses ":" -type PunctuationRules struct { - LabelSuffix string // Suffix for labels (default ":") - ProgressSuffix string // Suffix for progress (default "...") -} - -// NounForms holds plural and gender information for a noun. -type NounForms struct { - One string // Singular form - Other string // Plural form - Gender string // Grammatical gender (masculine, feminine, neuter, common) -} - -// ArticleForms holds article configuration for a language. -type ArticleForms struct { - IndefiniteDefault string // Default indefinite article (e.g., "a") - IndefiniteVowel string // Indefinite article before vowel sounds (e.g., "an") - Definite string // Definite article (e.g., "the") - ByGender map[string]string // Gender-specific articles for gendered languages -} - -// grammarCache holds loaded grammar data per language. -var ( - grammarCache = make(map[string]*GrammarData) - grammarCacheMu sync.RWMutex -) - -// VerbForms holds irregular verb conjugations. -type VerbForms struct { - Past string // Past tense (e.g., "deleted") - Gerund string // Present participle (e.g., "deleting") -} - -// irregularVerbs maps base verbs to their irregular forms. -var irregularVerbs = map[string]VerbForms{ - "be": {Past: "was", Gerund: "being"}, - "have": {Past: "had", Gerund: "having"}, - "do": {Past: "did", Gerund: "doing"}, - "go": {Past: "went", Gerund: "going"}, - "make": {Past: "made", Gerund: "making"}, - "get": {Past: "got", Gerund: "getting"}, - "run": {Past: "ran", Gerund: "running"}, - "set": {Past: "set", Gerund: "setting"}, - "put": {Past: "put", Gerund: "putting"}, - "cut": {Past: "cut", Gerund: "cutting"}, - "let": {Past: "let", Gerund: "letting"}, - "hit": {Past: "hit", Gerund: "hitting"}, - "shut": {Past: "shut", Gerund: "shutting"}, - "split": {Past: "split", Gerund: "splitting"}, - "spread": {Past: "spread", Gerund: "spreading"}, - "read": {Past: "read", Gerund: "reading"}, - "write": {Past: "wrote", Gerund: "writing"}, - "send": {Past: "sent", Gerund: "sending"}, - "build": {Past: "built", Gerund: "building"}, - "begin": {Past: "began", Gerund: "beginning"}, - "find": {Past: "found", Gerund: "finding"}, - "take": {Past: "took", Gerund: "taking"}, - "see": {Past: "saw", Gerund: "seeing"}, - "keep": {Past: "kept", Gerund: "keeping"}, - "hold": {Past: "held", Gerund: "holding"}, - "tell": {Past: "told", Gerund: "telling"}, - "bring": {Past: "brought", Gerund: "bringing"}, - "think": {Past: "thought", Gerund: "thinking"}, - "buy": {Past: "bought", Gerund: "buying"}, - "catch": {Past: "caught", Gerund: "catching"}, - "teach": {Past: "taught", Gerund: "teaching"}, - "throw": {Past: "threw", Gerund: "throwing"}, - "grow": {Past: "grew", Gerund: "growing"}, - "know": {Past: "knew", Gerund: "knowing"}, - "show": {Past: "showed", Gerund: "showing"}, - "draw": {Past: "drew", Gerund: "drawing"}, - "break": {Past: "broke", Gerund: "breaking"}, - "speak": {Past: "spoke", Gerund: "speaking"}, - "choose": {Past: "chose", Gerund: "choosing"}, - "forget": {Past: "forgot", Gerund: "forgetting"}, - "lose": {Past: "lost", Gerund: "losing"}, - "win": {Past: "won", Gerund: "winning"}, - "swim": {Past: "swam", Gerund: "swimming"}, - "drive": {Past: "drove", Gerund: "driving"}, - "rise": {Past: "rose", Gerund: "rising"}, - "shine": {Past: "shone", Gerund: "shining"}, - "sing": {Past: "sang", Gerund: "singing"}, - "ring": {Past: "rang", Gerund: "ringing"}, - "drink": {Past: "drank", Gerund: "drinking"}, - "sink": {Past: "sank", Gerund: "sinking"}, - "sit": {Past: "sat", Gerund: "sitting"}, - "stand": {Past: "stood", Gerund: "standing"}, - "hang": {Past: "hung", Gerund: "hanging"}, - "dig": {Past: "dug", Gerund: "digging"}, - "stick": {Past: "stuck", Gerund: "sticking"}, - "bite": {Past: "bit", Gerund: "biting"}, - "hide": {Past: "hid", Gerund: "hiding"}, - "feed": {Past: "fed", Gerund: "feeding"}, - "meet": {Past: "met", Gerund: "meeting"}, - "lead": {Past: "led", Gerund: "leading"}, - "sleep": {Past: "slept", Gerund: "sleeping"}, - "feel": {Past: "felt", Gerund: "feeling"}, - "leave": {Past: "left", Gerund: "leaving"}, - "mean": {Past: "meant", Gerund: "meaning"}, - "lend": {Past: "lent", Gerund: "lending"}, - "spend": {Past: "spent", Gerund: "spending"}, - "bend": {Past: "bent", Gerund: "bending"}, - "deal": {Past: "dealt", Gerund: "dealing"}, - "lay": {Past: "laid", Gerund: "laying"}, - "pay": {Past: "paid", Gerund: "paying"}, - "say": {Past: "said", Gerund: "saying"}, - "sell": {Past: "sold", Gerund: "selling"}, - "seek": {Past: "sought", Gerund: "seeking"}, - "fight": {Past: "fought", Gerund: "fighting"}, - "fly": {Past: "flew", Gerund: "flying"}, - "wear": {Past: "wore", Gerund: "wearing"}, - "tear": {Past: "tore", Gerund: "tearing"}, - "bear": {Past: "bore", Gerund: "bearing"}, - "swear": {Past: "swore", Gerund: "swearing"}, - "wake": {Past: "woke", Gerund: "waking"}, - "freeze": {Past: "froze", Gerund: "freezing"}, - "steal": {Past: "stole", Gerund: "stealing"}, - "overwrite": {Past: "overwritten", Gerund: "overwriting"}, - "reset": {Past: "reset", Gerund: "resetting"}, - "reboot": {Past: "rebooted", Gerund: "rebooting"}, - - // Multi-syllable verbs with stressed final syllables (double consonant) - "submit": {Past: "submitted", Gerund: "submitting"}, - "permit": {Past: "permitted", Gerund: "permitting"}, - "admit": {Past: "admitted", Gerund: "admitting"}, - "omit": {Past: "omitted", Gerund: "omitting"}, - "commit": {Past: "committed", Gerund: "committing"}, - "transmit": {Past: "transmitted", Gerund: "transmitting"}, - "prefer": {Past: "preferred", Gerund: "preferring"}, - "refer": {Past: "referred", Gerund: "referring"}, - "transfer": {Past: "transferred", Gerund: "transferring"}, - "defer": {Past: "deferred", Gerund: "deferring"}, - "confer": {Past: "conferred", Gerund: "conferring"}, - "infer": {Past: "inferred", Gerund: "inferring"}, - "occur": {Past: "occurred", Gerund: "occurring"}, - "recur": {Past: "recurred", Gerund: "recurring"}, - "incur": {Past: "incurred", Gerund: "incurring"}, - "deter": {Past: "deterred", Gerund: "deterring"}, - "control": {Past: "controlled", Gerund: "controlling"}, - "patrol": {Past: "patrolled", Gerund: "patrolling"}, - "compel": {Past: "compelled", Gerund: "compelling"}, - "expel": {Past: "expelled", Gerund: "expelling"}, - "propel": {Past: "propelled", Gerund: "propelling"}, - "repel": {Past: "repelled", Gerund: "repelling"}, - "rebel": {Past: "rebelled", Gerund: "rebelling"}, - "excel": {Past: "excelled", Gerund: "excelling"}, - "cancel": {Past: "cancelled", Gerund: "cancelling"}, // UK spelling - "travel": {Past: "travelled", Gerund: "travelling"}, // UK spelling - "label": {Past: "labelled", Gerund: "labelling"}, // UK spelling - "model": {Past: "modelled", Gerund: "modelling"}, // UK spelling - "level": {Past: "levelled", Gerund: "levelling"}, // UK spelling -} - -// noDoubleConsonant contains multi-syllable verbs that don't double the final consonant. -// Note: UK English doubles -l (travelled, cancelled) - those are in irregularVerbs. -var noDoubleConsonant = map[string]bool{ - "open": true, - "listen": true, - "happen": true, - "enter": true, - "offer": true, - "suffer": true, - "differ": true, - "cover": true, - "deliver": true, - "develop": true, - "visit": true, - "limit": true, - "edit": true, - "credit": true, - "orbit": true, - "total": true, - "target": true, - "budget": true, - "market": true, - "benefit": true, - "focus": true, -} - -// irregularNouns maps singular nouns to their irregular plural forms. -var irregularNouns = map[string]string{ - "child": "children", - "person": "people", - "man": "men", - "woman": "women", - "foot": "feet", - "tooth": "teeth", - "mouse": "mice", - "goose": "geese", - "ox": "oxen", - "index": "indices", - "appendix": "appendices", - "matrix": "matrices", - "vertex": "vertices", - "crisis": "crises", - "analysis": "analyses", - "diagnosis": "diagnoses", - "thesis": "theses", - "hypothesis": "hypotheses", - "parenthesis": "parentheses", - "datum": "data", - "medium": "media", - "bacterium": "bacteria", - "criterion": "criteria", - "phenomenon": "phenomena", - "curriculum": "curricula", - "alumnus": "alumni", - "cactus": "cacti", - "focus": "foci", - "fungus": "fungi", - "nucleus": "nuclei", - "radius": "radii", - "stimulus": "stimuli", - "syllabus": "syllabi", - "fish": "fish", - "sheep": "sheep", - "deer": "deer", - "species": "species", - "series": "series", - "aircraft": "aircraft", - "life": "lives", - "wife": "wives", - "knife": "knives", - "leaf": "leaves", - "half": "halves", - "self": "selves", - "shelf": "shelves", - "wolf": "wolves", - "calf": "calves", - "loaf": "loaves", - "thief": "thieves", -} - -// vowelSounds contains words that start with consonants but have vowel sounds. -// These take "an" instead of "a". -var vowelSounds = map[string]bool{ - "hour": true, - "honest": true, - "honour": true, - "honor": true, - "heir": true, - "herb": true, // US pronunciation -} - -// consonantSounds contains words that start with vowels but have consonant sounds. -// These take "a" instead of "an". -var consonantSounds = map[string]bool{ - "user": true, // "yoo-zer" - "union": true, // "yoon-yon" - "unique": true, - "unit": true, - "universe": true, - "university": true, - "uniform": true, - "usage": true, - "usual": true, - "utility": true, - "utensil": true, - "one": true, // "wun" - "once": true, - "euro": true, // "yoo-ro" - "eulogy": true, - "euphemism": true, -} - -// --- Function type interfaces --- - -// MissingKeyHandler receives missing key events for analysis. -// Used in ModeCollect to capture translation keys that need to be added. -// -// i18n.OnMissingKey(func(m i18n.MissingKey) { -// log.Printf("MISSING: %s at %s:%d", m.Key, m.CallerFile, m.CallerLine) -// }) -type MissingKeyHandler func(missing MissingKey) - -// MissingKey is dispatched when a translation key is not found in ModeCollect. -// Used by QA tools to collect and report missing translations. -type MissingKey struct { - Key string // The missing translation key - Args map[string]any // Arguments passed to the translation - CallerFile string // Source file where T() was called - CallerLine int // Line number where T() was called -} - -// PluralRule is a function that determines the plural category for a count. -// Each language has its own plural rule based on CLDR data. -// -// rule := i18n.GetPluralRule("ru") -// category := rule(5) // Returns PluralMany for Russian -type PluralRule func(n int) PluralCategory - -// PluralCategory represents CLDR plural categories. -// Different languages use different subsets of these categories. -// -// Examples: -// - English: one, other -// - Russian: one, few, many, other -// - Arabic: zero, one, two, few, many, other -// - Welsh: zero, one, two, few, many, other -type PluralCategory int - -const ( - // PluralOther is the default/fallback category - PluralOther PluralCategory = iota - // PluralZero is used when count == 0 (Arabic, Latvian, etc.) - PluralZero - // PluralOne is used when count == 1 (most languages) - PluralOne - // PluralTwo is used when count == 2 (Arabic, Welsh, etc.) - PluralTwo - // PluralFew is used for small numbers (Slavic: 2-4, Arabic: 3-10, etc.) - PluralFew - // PluralMany is used for larger numbers (Slavic: 5+, Arabic: 11-99, etc.) - PluralMany -) - -// GrammaticalGender represents grammatical gender for nouns. -type GrammaticalGender int - -const ( - // GenderNeuter is used for neuter nouns (das in German, it in English) - GenderNeuter GrammaticalGender = iota - // GenderMasculine is used for masculine nouns (der in German, le in French) - GenderMasculine - // GenderFeminine is used for feminine nouns (die in German, la in French) - GenderFeminine - // GenderCommon is used in languages with common gender (Swedish, Dutch) - GenderCommon -) - -// rtlLanguages contains language codes that use right-to-left text direction. -var rtlLanguages = map[string]bool{ - "ar": true, // Arabic - "ar-SA": true, - "ar-EG": true, - "he": true, // Hebrew - "he-IL": true, - "fa": true, // Persian/Farsi - "fa-IR": true, - "ur": true, // Urdu - "ur-PK": true, - "yi": true, // Yiddish - "ps": true, // Pashto - "sd": true, // Sindhi - "ug": true, // Uyghur -} - -// pluralRules contains CLDR plural rules for supported languages. -var pluralRules = map[string]PluralRule{ - "en": pluralRuleEnglish, - "en-GB": pluralRuleEnglish, - "en-US": pluralRuleEnglish, - "de": pluralRuleGerman, - "de-DE": pluralRuleGerman, - "de-AT": pluralRuleGerman, - "de-CH": pluralRuleGerman, - "fr": pluralRuleFrench, - "fr-FR": pluralRuleFrench, - "fr-CA": pluralRuleFrench, - "es": pluralRuleSpanish, - "es-ES": pluralRuleSpanish, - "es-MX": pluralRuleSpanish, - "ru": pluralRuleRussian, - "ru-RU": pluralRuleRussian, - "pl": pluralRulePolish, - "pl-PL": pluralRulePolish, - "ar": pluralRuleArabic, - "ar-SA": pluralRuleArabic, - "zh": pluralRuleChinese, - "zh-CN": pluralRuleChinese, - "zh-TW": pluralRuleChinese, - "ja": pluralRuleJapanese, - "ja-JP": pluralRuleJapanese, - "ko": pluralRuleKorean, - "ko-KR": pluralRuleKorean, -} - -// Formality represents the level of formality in translations. -// Used for languages that distinguish formal/informal address (Sie/du, vous/tu). -type Formality int - -const ( - // FormalityNeutral uses context-appropriate formality (default) - FormalityNeutral Formality = iota - // FormalityInformal uses informal address (du, tu, you) - FormalityInformal - // FormalityFormal uses formal address (Sie, vous, usted) - FormalityFormal -) - -// TextDirection represents text directionality. -type TextDirection int - -const ( - // DirLTR is left-to-right text direction (English, German, etc.) - DirLTR TextDirection = iota - // DirRTL is right-to-left text direction (Arabic, Hebrew, etc.) - DirRTL -) - -// Message represents a translation - either a simple string or plural forms. -// Supports full CLDR plural categories for languages with complex plural rules. -type Message struct { - Text string // Simple string value (non-plural) - Zero string // count == 0 (Arabic, Latvian, Welsh) - One string // count == 1 (most languages) - Two string // count == 2 (Arabic, Welsh) - Few string // Small numbers (Slavic: 2-4, Arabic: 3-10) - Many string // Larger numbers (Slavic: 5+, Arabic: 11-99) - Other string // Default/fallback form -} - -// ForCategory returns the appropriate text for a plural category. -// Falls back through the category hierarchy to find a non-empty string. -func (m Message) ForCategory(cat PluralCategory) string { - switch cat { - case PluralZero: - if m.Zero != "" { - return m.Zero - } - case PluralOne: - if m.One != "" { - return m.One - } - case PluralTwo: - if m.Two != "" { - return m.Two - } - case PluralFew: - if m.Few != "" { - return m.Few - } - case PluralMany: - if m.Many != "" { - return m.Many - } - } - // Fallback to Other, then One, then Text - if m.Other != "" { - return m.Other - } - if m.One != "" { - return m.One - } - return m.Text -} diff --git a/pkg/i18n/loader.go b/pkg/i18n/loader.go new file mode 100644 index 0000000..3d51973 --- /dev/null +++ b/pkg/i18n/loader.go @@ -0,0 +1,270 @@ +// Package i18n provides internationalization for the CLI. +package i18n + +import ( + "encoding/json" + "fmt" + "io/fs" + "path/filepath" + "strings" +) + +// FSLoader loads translations from a filesystem (embedded or disk). +type FSLoader struct { + fsys fs.FS + dir string + + // Cache of available languages (populated on first Languages() call) + languages []string +} + +// NewFSLoader creates a loader for the given filesystem and directory. +func NewFSLoader(fsys fs.FS, dir string) *FSLoader { + return &FSLoader{ + fsys: fsys, + dir: dir, + } +} + +// Load implements Loader.Load - loads messages and grammar for a language. +func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) { + // Try both hyphen and underscore variants + variants := []string{ + lang + ".json", + strings.ReplaceAll(lang, "-", "_") + ".json", + strings.ReplaceAll(lang, "_", "-") + ".json", + } + + var data []byte + var err error + for _, filename := range variants { + filePath := filepath.Join(l.dir, filename) + data, err = fs.ReadFile(l.fsys, filePath) + if err == nil { + break + } + } + if err != nil { + return nil, nil, fmt.Errorf("locale %q not found: %w", lang, err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, nil, fmt.Errorf("invalid JSON in locale %q: %w", lang, err) + } + + messages := make(map[string]Message) + grammar := &GrammarData{ + Verbs: make(map[string]VerbForms), + Nouns: make(map[string]NounForms), + Words: make(map[string]string), + } + + flattenWithGrammar("", raw, messages, grammar) + + return messages, grammar, nil +} + +// Languages implements Loader.Languages - returns available language codes. +func (l *FSLoader) Languages() []string { + if l.languages != nil { + return l.languages + } + + entries, err := fs.ReadDir(l.fsys, l.dir) + if err != nil { + return nil + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + lang := strings.TrimSuffix(entry.Name(), ".json") + // Normalise underscore to hyphen (en_GB -> en-GB) + lang = strings.ReplaceAll(lang, "_", "-") + l.languages = append(l.languages, lang) + } + + return l.languages +} + +// Ensure FSLoader implements Loader at compile time. +var _ Loader = (*FSLoader)(nil) + +// --- Flatten helpers --- + +// 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 != "" { + fullKey = prefix + "." + key + } + + switch v := value.(type) { + case string: + out[fullKey] = Message{Text: v} + + case map[string]any: + // Check if this is a verb form object + // Grammar data lives under "gram.*" (a nod to Gram - grandmother) + if grammar != nil && isVerbFormObject(v) { + verbName := key + if strings.HasPrefix(fullKey, "gram.verb.") { + verbName = strings.TrimPrefix(fullKey, "gram.verb.") + } + forms := 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, "gram.noun.") { + nounName = strings.TrimPrefix(fullKey, "gram.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 == "gram.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 punctuation rules object + if grammar != nil && fullKey == "gram.punct" { + if label, ok := v["label"].(string); ok { + grammar.Punct.LabelSuffix = label + } + if progress, ok := v["progress"].(string); ok { + grammar.Punct.ProgressSuffix = progress + } + continue + } + + // Check if this is a base word in gram.word.* + if grammar != nil && strings.HasPrefix(fullKey, "gram.word.") { + wordKey := strings.TrimPrefix(fullKey, "gram.word.") + // v could be a string or a nested object + if str, ok := value.(string); ok { + if grammar.Words == nil { + grammar.Words = make(map[string]string) + } + grammar.Words[strings.ToLower(wordKey)] = str + } + continue + } + + // Check if this is a plural object (has CLDR plural category keys) + if isPluralObject(v) { + msg := Message{} + if zero, ok := v["zero"].(string); ok { + msg.Zero = zero + } + if one, ok := v["one"].(string); ok { + msg.One = one + } + if two, ok := v["two"].(string); ok { + msg.Two = two + } + if few, ok := v["few"].(string); ok { + msg.Few = few + } + if many, ok := v["many"].(string); ok { + msg.Many = many + } + if other, ok := v["other"].(string); ok { + msg.Other = other + } + out[fullKey] = msg + } else { + // Recurse into nested object + flattenWithGrammar(fullKey, v, out, grammar) + } + } + } +} + +// --- Check helpers --- + +// 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"] + 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 { + _, hasZero := m["zero"] + _, hasOne := m["one"] + _, hasTwo := m["two"] + _, hasFew := m["few"] + _, hasMany := m["many"] + _, hasOther := m["other"] + + // It's a plural object if it has any plural category key + if !hasZero && !hasOne && !hasTwo && !hasFew && !hasMany && !hasOther { + return false + } + // But not if it contains nested objects (those are namespace containers) + for _, v := range m { + if _, isMap := v.(map[string]any); isMap { + return false + } + } + return true +} diff --git a/pkg/i18n/loader_test.go b/pkg/i18n/loader_test.go new file mode 100644 index 0000000..0af3573 --- /dev/null +++ b/pkg/i18n/loader_test.go @@ -0,0 +1,589 @@ +package i18n + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFSLoader_Load(t *testing.T) { + t.Run("loads simple messages", func(t *testing.T) { + fsys := fstest.MapFS{ + "locales/en.json": &fstest.MapFile{ + Data: []byte(`{"hello": "world", "nested": {"key": "value"}}`), + }, + } + loader := NewFSLoader(fsys, "locales") + messages, grammar, err := loader.Load("en") + require.NoError(t, err) + assert.NotNil(t, grammar) + assert.Equal(t, "world", messages["hello"].Text) + assert.Equal(t, "value", messages["nested.key"].Text) + }) + + t.Run("handles underscore/hyphen variants", func(t *testing.T) { + fsys := fstest.MapFS{ + "locales/en_GB.json": &fstest.MapFile{ + Data: []byte(`{"greeting": "Hello"}`), + }, + } + loader := NewFSLoader(fsys, "locales") + messages, _, err := loader.Load("en-GB") + require.NoError(t, err) + assert.Equal(t, "Hello", messages["greeting"].Text) + }) + + t.Run("returns error for missing language", func(t *testing.T) { + fsys := fstest.MapFS{} + loader := NewFSLoader(fsys, "locales") + _, _, err := loader.Load("fr") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("extracts grammar data", func(t *testing.T) { + fsys := fstest.MapFS{ + "locales/en.json": &fstest.MapFile{ + Data: []byte(`{ + "gram": { + "verb": { + "run": {"past": "ran", "gerund": "running"} + }, + "noun": { + "file": {"one": "file", "other": "files", "gender": "neuter"} + } + } + }`), + }, + } + loader := NewFSLoader(fsys, "locales") + _, grammar, err := loader.Load("en") + require.NoError(t, err) + assert.Equal(t, "ran", grammar.Verbs["run"].Past) + assert.Equal(t, "running", grammar.Verbs["run"].Gerund) + assert.Equal(t, "files", grammar.Nouns["file"].Other) + }) +} + +func TestFSLoader_Languages(t *testing.T) { + t.Run("lists available languages", func(t *testing.T) { + fsys := fstest.MapFS{ + "locales/en.json": &fstest.MapFile{Data: []byte(`{}`)}, + "locales/de.json": &fstest.MapFile{Data: []byte(`{}`)}, + "locales/fr_FR.json": &fstest.MapFile{Data: []byte(`{}`)}, + } + loader := NewFSLoader(fsys, "locales") + langs := loader.Languages() + assert.Contains(t, langs, "en") + assert.Contains(t, langs, "de") + assert.Contains(t, langs, "fr-FR") // normalised + }) + + t.Run("caches result", func(t *testing.T) { + fsys := fstest.MapFS{ + "locales/en.json": &fstest.MapFile{Data: []byte(`{}`)}, + } + loader := NewFSLoader(fsys, "locales") + langs1 := loader.Languages() + langs2 := loader.Languages() + assert.Equal(t, langs1, langs2) + }) + + t.Run("empty directory", func(t *testing.T) { + fsys := fstest.MapFS{} + loader := NewFSLoader(fsys, "locales") + langs := loader.Languages() + assert.Empty(t, langs) + }) +} + +func TestFlatten(t *testing.T) { + tests := []struct { + name string + prefix string + data map[string]any + expected map[string]Message + }{ + { + name: "simple string", + prefix: "", + data: map[string]any{"hello": "world"}, + expected: map[string]Message{ + "hello": {Text: "world"}, + }, + }, + { + name: "nested object", + prefix: "", + data: map[string]any{ + "cli": map[string]any{ + "success": "Done", + "error": "Failed", + }, + }, + expected: map[string]Message{ + "cli.success": {Text: "Done"}, + "cli.error": {Text: "Failed"}, + }, + }, + { + name: "with prefix", + prefix: "app", + data: map[string]any{"key": "value"}, + expected: map[string]Message{ + "app.key": {Text: "value"}, + }, + }, + { + name: "deeply nested", + prefix: "", + data: map[string]any{ + "a": map[string]any{ + "b": map[string]any{ + "c": "deep value", + }, + }, + }, + expected: map[string]Message{ + "a.b.c": {Text: "deep value"}, + }, + }, + { + name: "plural object", + prefix: "", + data: map[string]any{ + "items": map[string]any{ + "one": "{{.Count}} item", + "other": "{{.Count}} items", + }, + }, + expected: map[string]Message{ + "items": {One: "{{.Count}} item", Other: "{{.Count}} items"}, + }, + }, + { + name: "full CLDR plural", + prefix: "", + data: map[string]any{ + "files": map[string]any{ + "zero": "no files", + "one": "one file", + "two": "two files", + "few": "a few files", + "many": "many files", + "other": "{{.Count}} files", + }, + }, + expected: map[string]Message{ + "files": { + Zero: "no files", + One: "one file", + Two: "two files", + Few: "a few files", + Many: "many files", + Other: "{{.Count}} files", + }, + }, + }, + { + name: "mixed content", + prefix: "", + data: map[string]any{ + "simple": "text", + "plural": map[string]any{ + "one": "singular", + "other": "plural", + }, + "nested": map[string]any{ + "child": "nested value", + }, + }, + expected: map[string]Message{ + "simple": {Text: "text"}, + "plural": {One: "singular", Other: "plural"}, + "nested.child": {Text: "nested value"}, + }, + }, + { + name: "empty data", + prefix: "", + data: map[string]any{}, + expected: map[string]Message{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := make(map[string]Message) + flatten(tt.prefix, tt.data, out) + assert.Equal(t, tt.expected, out) + }) + } +} + +func TestFlattenWithGrammar(t *testing.T) { + t.Run("extracts verb forms", func(t *testing.T) { + data := map[string]any{ + "gram": map[string]any{ + "verb": map[string]any{ + "run": map[string]any{ + "base": "run", + "past": "ran", + "gerund": "running", + }, + }, + }, + } + out := make(map[string]Message) + grammar := &GrammarData{ + Verbs: make(map[string]VerbForms), + Nouns: make(map[string]NounForms), + } + flattenWithGrammar("", data, out, grammar) + + assert.Contains(t, grammar.Verbs, "run") + assert.Equal(t, "ran", grammar.Verbs["run"].Past) + assert.Equal(t, "running", grammar.Verbs["run"].Gerund) + }) + + t.Run("extracts noun forms", func(t *testing.T) { + data := map[string]any{ + "gram": map[string]any{ + "noun": map[string]any{ + "file": map[string]any{ + "one": "file", + "other": "files", + "gender": "neuter", + }, + }, + }, + } + out := make(map[string]Message) + grammar := &GrammarData{ + Verbs: make(map[string]VerbForms), + Nouns: make(map[string]NounForms), + } + flattenWithGrammar("", data, out, grammar) + + assert.Contains(t, grammar.Nouns, "file") + assert.Equal(t, "file", grammar.Nouns["file"].One) + assert.Equal(t, "files", grammar.Nouns["file"].Other) + assert.Equal(t, "neuter", grammar.Nouns["file"].Gender) + }) + + t.Run("extracts articles", func(t *testing.T) { + data := map[string]any{ + "gram": map[string]any{ + "article": map[string]any{ + "indefinite": map[string]any{ + "default": "a", + "vowel": "an", + }, + "definite": "the", + }, + }, + } + out := make(map[string]Message) + grammar := &GrammarData{ + Verbs: make(map[string]VerbForms), + Nouns: make(map[string]NounForms), + } + flattenWithGrammar("", data, out, grammar) + + assert.Equal(t, "a", grammar.Articles.IndefiniteDefault) + assert.Equal(t, "an", grammar.Articles.IndefiniteVowel) + assert.Equal(t, "the", grammar.Articles.Definite) + }) + + t.Run("extracts punctuation rules", func(t *testing.T) { + data := map[string]any{ + "gram": map[string]any{ + "punct": map[string]any{ + "label": ":", + "progress": "...", + }, + }, + } + out := make(map[string]Message) + grammar := &GrammarData{ + Verbs: make(map[string]VerbForms), + Nouns: make(map[string]NounForms), + } + flattenWithGrammar("", data, out, grammar) + + assert.Equal(t, ":", grammar.Punct.LabelSuffix) + assert.Equal(t, "...", grammar.Punct.ProgressSuffix) + }) + + t.Run("nil grammar skips extraction", func(t *testing.T) { + data := map[string]any{ + "gram": map[string]any{ + "verb": map[string]any{ + "run": map[string]any{ + "past": "ran", + "gerund": "running", + }, + }, + }, + "simple": "text", + } + out := make(map[string]Message) + flattenWithGrammar("", data, out, nil) + + // Without grammar, verb forms are recursively processed as nested objects + assert.Contains(t, out, "simple") + assert.Equal(t, "text", out["simple"].Text) + }) +} + +func TestIsVerbFormObject(t *testing.T) { + tests := []struct { + name string + input map[string]any + expected bool + }{ + { + name: "has base only", + input: map[string]any{"base": "run"}, + expected: true, + }, + { + name: "has past only", + input: map[string]any{"past": "ran"}, + expected: true, + }, + { + name: "has gerund only", + input: map[string]any{"gerund": "running"}, + expected: true, + }, + { + name: "has all verb forms", + input: map[string]any{"base": "run", "past": "ran", "gerund": "running"}, + expected: true, + }, + { + name: "empty map", + input: map[string]any{}, + expected: false, + }, + { + name: "plural object not verb", + input: map[string]any{"one": "item", "other": "items"}, + expected: false, + }, + { + name: "unrelated keys", + input: map[string]any{"foo": "bar", "baz": "qux"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isVerbFormObject(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsNounFormObject(t *testing.T) { + tests := []struct { + name string + input map[string]any + expected bool + }{ + { + name: "has gender", + input: map[string]any{"gender": "masculine", "one": "file", "other": "files"}, + expected: true, + }, + { + name: "gender only", + input: map[string]any{"gender": "feminine"}, + expected: true, + }, + { + name: "no gender", + input: map[string]any{"one": "item", "other": "items"}, + expected: false, + }, + { + name: "empty map", + input: map[string]any{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isNounFormObject(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasPluralCategories(t *testing.T) { + tests := []struct { + name string + input map[string]any + expected bool + }{ + { + name: "has zero", + input: map[string]any{"zero": "none", "one": "one", "other": "many"}, + expected: true, + }, + { + name: "has two", + input: map[string]any{"one": "one", "two": "two", "other": "many"}, + expected: true, + }, + { + name: "has few", + input: map[string]any{"one": "one", "few": "few", "other": "many"}, + expected: true, + }, + { + name: "has many", + input: map[string]any{"one": "one", "many": "many", "other": "other"}, + expected: true, + }, + { + name: "has all categories", + input: map[string]any{"zero": "0", "one": "1", "two": "2", "few": "few", "many": "many", "other": "other"}, + expected: true, + }, + { + name: "only one and other", + input: map[string]any{"one": "item", "other": "items"}, + expected: false, + }, + { + name: "empty map", + input: map[string]any{}, + expected: false, + }, + { + name: "unrelated keys", + input: map[string]any{"foo": "bar"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasPluralCategories(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsPluralObject(t *testing.T) { + tests := []struct { + name string + input map[string]any + expected bool + }{ + { + name: "one and other", + input: map[string]any{"one": "item", "other": "items"}, + expected: true, + }, + { + name: "all CLDR categories", + input: map[string]any{"zero": "0", "one": "1", "two": "2", "few": "few", "many": "many", "other": "other"}, + expected: true, + }, + { + name: "only other", + input: map[string]any{"other": "items"}, + expected: true, + }, + { + name: "empty map", + input: map[string]any{}, + expected: false, + }, + { + name: "nested map is not plural", + input: map[string]any{"one": "item", "other": map[string]any{"nested": "value"}}, + expected: false, + }, + { + name: "unrelated keys", + input: map[string]any{"foo": "bar", "baz": "qux"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isPluralObject(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMessageIsPlural(t *testing.T) { + tests := []struct { + name string + msg Message + expected bool + }{ + { + name: "has zero", + msg: Message{Zero: "none"}, + expected: true, + }, + { + name: "has one", + msg: Message{One: "item"}, + expected: true, + }, + { + name: "has two", + msg: Message{Two: "items"}, + expected: true, + }, + { + name: "has few", + msg: Message{Few: "a few"}, + expected: true, + }, + { + name: "has many", + msg: Message{Many: "lots"}, + expected: true, + }, + { + name: "has other", + msg: Message{Other: "items"}, + expected: true, + }, + { + name: "has all", + msg: Message{Zero: "0", One: "1", Two: "2", Few: "few", Many: "many", Other: "other"}, + expected: true, + }, + { + name: "text only", + msg: Message{Text: "hello"}, + expected: false, + }, + { + name: "empty message", + msg: Message{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.msg.IsPlural() + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/i18n/mode.go b/pkg/i18n/mode.go deleted file mode 100644 index ea3be8c..0000000 --- a/pkg/i18n/mode.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package i18n provides internationalization for the CLI. -package i18n - -// String returns the string representation of the Mode. -func (m Mode) String() string { - switch m { - case ModeNormal: - return "normal" - case ModeStrict: - return "strict" - case ModeCollect: - return "collect" - default: - return "unknown" - } -} diff --git a/pkg/i18n/mutate.go b/pkg/i18n/mutate.go deleted file mode 100644 index 1f36520..0000000 --- a/pkg/i18n/mutate.go +++ /dev/null @@ -1,133 +0,0 @@ -// Package i18n provides internationalization for the CLI. -package i18n - -import "strings" - -// 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 != "" { - fullKey = prefix + "." + key - } - - switch v := value.(type) { - case string: - out[fullKey] = Message{Text: v} - - case map[string]any: - // Check if this is a verb form object - // Grammar data lives under "gram.*" (a nod to Gram - grandmother) - if grammar != nil && isVerbFormObject(v) { - verbName := key - if strings.HasPrefix(fullKey, "gram.verb.") { - verbName = strings.TrimPrefix(fullKey, "gram.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, "gram.noun.") { - nounName = strings.TrimPrefix(fullKey, "gram.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 == "gram.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 punctuation rules object - if grammar != nil && fullKey == "gram.punct" { - if label, ok := v["label"].(string); ok { - grammar.Punct.LabelSuffix = label - } - if progress, ok := v["progress"].(string); ok { - grammar.Punct.ProgressSuffix = progress - } - continue - } - - // Check if this is a base word in gram.word.* - if grammar != nil && strings.HasPrefix(fullKey, "gram.word.") { - wordKey := strings.TrimPrefix(fullKey, "gram.word.") - // v could be a string or a nested object - if str, ok := value.(string); ok { - if grammar.Words == nil { - grammar.Words = make(map[string]string) - } - grammar.Words[strings.ToLower(wordKey)] = str - } - continue - } - - // Check if this is a plural object (has CLDR plural category keys) - if isPluralObject(v) { - msg := Message{} - if zero, ok := v["zero"].(string); ok { - msg.Zero = zero - } - if one, ok := v["one"].(string); ok { - msg.One = one - } - if two, ok := v["two"].(string); ok { - msg.Two = two - } - if few, ok := v["few"].(string); ok { - msg.Few = few - } - if many, ok := v["many"].(string); ok { - msg.Many = many - } - if other, ok := v["other"].(string); ok { - msg.Other = other - } - out[fullKey] = msg - } else { - // Recurse into nested object - flattenWithGrammar(fullKey, v, out, grammar) - } - } - } -} diff --git a/pkg/i18n/mutate_test.go b/pkg/i18n/mutate_test.go deleted file mode 100644 index 14009f7..0000000 --- a/pkg/i18n/mutate_test.go +++ /dev/null @@ -1,246 +0,0 @@ -package i18n - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFlatten(t *testing.T) { - tests := []struct { - name string - prefix string - data map[string]any - expected map[string]Message - }{ - { - name: "simple string", - prefix: "", - data: map[string]any{"hello": "world"}, - expected: map[string]Message{ - "hello": {Text: "world"}, - }, - }, - { - name: "nested object", - prefix: "", - data: map[string]any{ - "cli": map[string]any{ - "success": "Done", - "error": "Failed", - }, - }, - expected: map[string]Message{ - "cli.success": {Text: "Done"}, - "cli.error": {Text: "Failed"}, - }, - }, - { - name: "with prefix", - prefix: "app", - data: map[string]any{"key": "value"}, - expected: map[string]Message{ - "app.key": {Text: "value"}, - }, - }, - { - name: "deeply nested", - prefix: "", - data: map[string]any{ - "a": map[string]any{ - "b": map[string]any{ - "c": "deep value", - }, - }, - }, - expected: map[string]Message{ - "a.b.c": {Text: "deep value"}, - }, - }, - { - name: "plural object", - prefix: "", - data: map[string]any{ - "items": map[string]any{ - "one": "{{.Count}} item", - "other": "{{.Count}} items", - }, - }, - expected: map[string]Message{ - "items": {One: "{{.Count}} item", Other: "{{.Count}} items"}, - }, - }, - { - name: "full CLDR plural", - prefix: "", - data: map[string]any{ - "files": map[string]any{ - "zero": "no files", - "one": "one file", - "two": "two files", - "few": "a few files", - "many": "many files", - "other": "{{.Count}} files", - }, - }, - expected: map[string]Message{ - "files": { - Zero: "no files", - One: "one file", - Two: "two files", - Few: "a few files", - Many: "many files", - Other: "{{.Count}} files", - }, - }, - }, - { - name: "mixed content", - prefix: "", - data: map[string]any{ - "simple": "text", - "plural": map[string]any{ - "one": "singular", - "other": "plural", - }, - "nested": map[string]any{ - "child": "nested value", - }, - }, - expected: map[string]Message{ - "simple": {Text: "text"}, - "plural": {One: "singular", Other: "plural"}, - "nested.child": {Text: "nested value"}, - }, - }, - { - name: "empty data", - prefix: "", - data: map[string]any{}, - expected: map[string]Message{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - out := make(map[string]Message) - flatten(tt.prefix, tt.data, out) - assert.Equal(t, tt.expected, out) - }) - } -} - -func TestFlattenWithGrammar(t *testing.T) { - t.Run("extracts verb forms", func(t *testing.T) { - data := map[string]any{ - "gram": map[string]any{ - "verb": map[string]any{ - "run": map[string]any{ - "base": "run", - "past": "ran", - "gerund": "running", - }, - }, - }, - } - out := make(map[string]Message) - grammar := &GrammarData{ - Verbs: make(map[string]VerbForms), - Nouns: make(map[string]NounForms), - } - flattenWithGrammar("", data, out, grammar) - - assert.Contains(t, grammar.Verbs, "run") - assert.Equal(t, "ran", grammar.Verbs["run"].Past) - assert.Equal(t, "running", grammar.Verbs["run"].Gerund) - }) - - t.Run("extracts noun forms", func(t *testing.T) { - data := map[string]any{ - "gram": map[string]any{ - "noun": map[string]any{ - "file": map[string]any{ - "one": "file", - "other": "files", - "gender": "neuter", - }, - }, - }, - } - out := make(map[string]Message) - grammar := &GrammarData{ - Verbs: make(map[string]VerbForms), - Nouns: make(map[string]NounForms), - } - flattenWithGrammar("", data, out, grammar) - - assert.Contains(t, grammar.Nouns, "file") - assert.Equal(t, "file", grammar.Nouns["file"].One) - assert.Equal(t, "files", grammar.Nouns["file"].Other) - assert.Equal(t, "neuter", grammar.Nouns["file"].Gender) - }) - - t.Run("extracts articles", func(t *testing.T) { - data := map[string]any{ - "gram": map[string]any{ - "article": map[string]any{ - "indefinite": map[string]any{ - "default": "a", - "vowel": "an", - }, - "definite": "the", - }, - }, - } - out := make(map[string]Message) - grammar := &GrammarData{ - Verbs: make(map[string]VerbForms), - Nouns: make(map[string]NounForms), - } - flattenWithGrammar("", data, out, grammar) - - assert.Equal(t, "a", grammar.Articles.IndefiniteDefault) - assert.Equal(t, "an", grammar.Articles.IndefiniteVowel) - assert.Equal(t, "the", grammar.Articles.Definite) - }) - - t.Run("extracts punctuation rules", func(t *testing.T) { - data := map[string]any{ - "gram": map[string]any{ - "punct": map[string]any{ - "label": ":", - "progress": "...", - }, - }, - } - out := make(map[string]Message) - grammar := &GrammarData{ - Verbs: make(map[string]VerbForms), - Nouns: make(map[string]NounForms), - } - flattenWithGrammar("", data, out, grammar) - - assert.Equal(t, ":", grammar.Punct.LabelSuffix) - assert.Equal(t, "...", grammar.Punct.ProgressSuffix) - }) - - t.Run("nil grammar skips extraction", func(t *testing.T) { - data := map[string]any{ - "gram": map[string]any{ - "verb": map[string]any{ - "run": map[string]any{ - "past": "ran", - "gerund": "running", - }, - }, - }, - "simple": "text", - } - out := make(map[string]Message) - flattenWithGrammar("", data, out, nil) - - // Without grammar, verb forms are recursively processed as nested objects - assert.Contains(t, out, "simple") - assert.Equal(t, "text", out["simple"].Text) - }) -} diff --git a/pkg/i18n/service.go b/pkg/i18n/service.go index bb5f9f4..531f92b 100644 --- a/pkg/i18n/service.go +++ b/pkg/i18n/service.go @@ -2,15 +2,43 @@ package i18n import ( + "embed" "encoding/json" "fmt" "io/fs" "path/filepath" "strings" + "sync" "golang.org/x/text/language" ) +// Service provides internationalization and localization. +type Service struct { + messages map[string]map[string]Message // lang -> key -> message + currentLang string + fallbackLang string + availableLangs []language.Tag + mode Mode // Translation mode (Normal, Strict, Collect) + debug bool // Debug mode shows key prefixes + formality Formality // Default formality level for translations + handlers []KeyHandler // Handler chain for dynamic key patterns + mu sync.RWMutex +} + +// Default is the global i18n service instance. +var ( + defaultService *Service + defaultOnce sync.Once + defaultErr error +) + +//go:embed locales/*.json +var localeFS embed.FS + +// Ensure Service implements Translator at compile time. +var _ Translator = (*Service)(nil) + // New creates a new i18n service with embedded locales. func New() (*Service, error) { return NewWithFS(localeFS, "locales") @@ -21,6 +49,7 @@ func NewWithFS(fsys fs.FS, dir string) (*Service, error) { s := &Service{ messages: make(map[string]map[string]Message), fallbackLang: "en-GB", + handlers: DefaultHandlers(), } entries, err := fs.ReadDir(fsys, dir) @@ -209,7 +238,40 @@ func (s *Service) PluralCategory(n int) PluralCategory { return GetPluralCategory(s.currentLang, n) } -// T translates a message by its ID with smart i18n.* namespace handling. +// AddHandler appends a handler to the end of the handler chain. +// Later handlers have lower priority (run if earlier handlers don't match). +func (s *Service) AddHandler(h KeyHandler) { + s.mu.Lock() + defer s.mu.Unlock() + s.handlers = append(s.handlers, h) +} + +// PrependHandler inserts a handler at the start of the handler chain. +// Prepended handlers have highest priority (run first). +func (s *Service) PrependHandler(h KeyHandler) { + s.mu.Lock() + defer s.mu.Unlock() + s.handlers = append([]KeyHandler{h}, s.handlers...) +} + +// ClearHandlers removes all handlers from the chain. +// Useful for testing or disabling all i18n.* magic. +func (s *Service) ClearHandlers() { + s.mu.Lock() + defer s.mu.Unlock() + s.handlers = nil +} + +// Handlers returns a copy of the current handler chain. +func (s *Service) Handlers() []KeyHandler { + s.mu.RLock() + defer s.mu.RUnlock() + result := make([]KeyHandler, len(s.handlers)) + copy(result, s.handlers) + return result +} + +// T translates a message by its ID with handler chain support. // // # i18n Namespace Magic // @@ -226,118 +288,31 @@ func (s *Service) PluralCategory(n int) PluralCategory { // // T("core.delete", S("file", "config.yaml")) // → "Delete config.yaml?" // -// Use _() for raw key lookup without i18n.* magic. +// Use Raw() for direct key lookup without handler chain processing. func (s *Service) T(messageID string, args ...any) string { s.mu.RLock() defer s.mu.RUnlock() - // Handle i18n.* namespace magic - if strings.HasPrefix(messageID, "i18n.") { - if result := s.handleI18nNamespace(messageID, args); result != "" { - if s.debug { - return debugFormat(messageID, result) - } - return result + // Run handler chain - handlers can intercept and process keys + result := RunHandlerChain(s.handlers, messageID, args, func() string { + // Fallback: standard message lookup + var data any + if len(args) > 0 { + data = args[0] } - } - - // Get template data - var data any - if len(args) > 0 { - data = args[0] - } - - // Try fallback chain - text := s.resolveWithFallback(messageID, data) - if text == "" { - return s.handleMissingKey(messageID, args) - } + text := s.resolveWithFallback(messageID, data) + if text == "" { + return s.handleMissingKey(messageID, args) + } + return text + }) // Debug mode: prefix with key if s.debug { - return debugFormat(messageID, text) + return debugFormat(messageID, result) } - return text -} - -// handleI18nNamespace processes i18n.* namespace patterns. -// Returns empty string if pattern not recognized. -// Must be called with s.mu.RLock held. -func (s *Service) handleI18nNamespace(key string, args []any) string { - // i18n.label.{word} → Label(word) - if strings.HasPrefix(key, "i18n.label.") { - word := strings.TrimPrefix(key, "i18n.label.") - return Label(word) - } - - // i18n.progress.{verb} → Progress(verb) or ProgressSubject(verb, subj) - if strings.HasPrefix(key, "i18n.progress.") { - verb := strings.TrimPrefix(key, "i18n.progress.") - if len(args) > 0 { - if subj, ok := args[0].(string); ok { - return ProgressSubject(verb, subj) - } - } - return Progress(verb) - } - - // i18n.count.{noun} → "N noun(s)" - if strings.HasPrefix(key, "i18n.count.") { - noun := strings.TrimPrefix(key, "i18n.count.") - if len(args) > 0 { - count := toInt(args[0]) - return fmt.Sprintf("%d %s", count, Pluralize(noun, count)) - } - return noun - } - - // i18n.done.{verb} → ActionResult(verb, subj) - if strings.HasPrefix(key, "i18n.done.") { - verb := strings.TrimPrefix(key, "i18n.done.") - if len(args) > 0 { - if subj, ok := args[0].(string); ok { - return ActionResult(verb, subj) - } - } - return Title(PastTense(verb)) - } - - // i18n.fail.{verb} → ActionFailed(verb, subj) - if strings.HasPrefix(key, "i18n.fail.") { - verb := strings.TrimPrefix(key, "i18n.fail.") - if len(args) > 0 { - if subj, ok := args[0].(string); ok { - return ActionFailed(verb, subj) - } - } - return ActionFailed(verb, "") - } - - // i18n.numeric.* namespace (for N() helper) - if strings.HasPrefix(key, "i18n.numeric.") && len(args) > 0 { - format := strings.TrimPrefix(key, "i18n.numeric.") - switch format { - case "number", "int": - return FormatNumber(toInt64(args[0])) - case "decimal", "float": - return FormatDecimal(toFloat64(args[0])) - case "percent", "pct": - return FormatPercent(toFloat64(args[0])) - case "bytes", "size": - return FormatBytes(toInt64(args[0])) - case "ordinal", "ord": - return FormatOrdinal(toInt(args[0])) - case "ago": - if len(args) >= 2 { - if unit, ok := args[1].(string); ok { - return FormatAgo(toInt(args[0]), unit) - } - } - } - } - - return "" + return result } // resolveWithFallback implements the fallback chain for message resolution. diff --git a/pkg/i18n/types.go b/pkg/i18n/types.go new file mode 100644 index 0000000..ac17aaa --- /dev/null +++ b/pkg/i18n/types.go @@ -0,0 +1,449 @@ +// Package i18n provides internationalization for the CLI. +package i18n + +import "sync" + +// --- Core Types --- + +// Mode determines how the i18n service handles missing translation keys. +type Mode int + +const ( + // ModeNormal returns the key as-is when a translation is missing (production). + ModeNormal Mode = iota + // ModeStrict panics immediately when a translation is missing (dev/CI). + ModeStrict + // ModeCollect dispatches MissingKey actions and returns [key] (QA testing). + ModeCollect +) + +// String returns the string representation of the Mode. +func (m Mode) String() string { + switch m { + case ModeNormal: + return "normal" + case ModeStrict: + return "strict" + case ModeCollect: + return "collect" + default: + return "unknown" + } +} + +// Formality represents the level of formality in translations. +// Used for languages that distinguish formal/informal address (Sie/du, vous/tu). +type Formality int + +const ( + // FormalityNeutral uses context-appropriate formality (default) + FormalityNeutral Formality = iota + // FormalityInformal uses informal address (du, tu, you) + FormalityInformal + // FormalityFormal uses formal address (Sie, vous, usted) + FormalityFormal +) + +// TextDirection represents text directionality. +type TextDirection int + +const ( + // DirLTR is left-to-right text direction (English, German, etc.) + DirLTR TextDirection = iota + // DirRTL is right-to-left text direction (Arabic, Hebrew, etc.) + DirRTL +) + +// PluralCategory represents CLDR plural categories. +// Different languages use different subsets of these categories. +type PluralCategory int + +const ( + // PluralOther is the default/fallback category + PluralOther PluralCategory = iota + // PluralZero is used when count == 0 (Arabic, Latvian, etc.) + PluralZero + // PluralOne is used when count == 1 (most languages) + PluralOne + // PluralTwo is used when count == 2 (Arabic, Welsh, etc.) + PluralTwo + // PluralFew is used for small numbers (Slavic: 2-4, Arabic: 3-10, etc.) + PluralFew + // PluralMany is used for larger numbers (Slavic: 5+, Arabic: 11-99, etc.) + PluralMany +) + +// GrammaticalGender represents grammatical gender for nouns. +type GrammaticalGender int + +const ( + // GenderNeuter is used for neuter nouns (das in German, it in English) + GenderNeuter GrammaticalGender = iota + // GenderMasculine is used for masculine nouns (der in German, le in French) + GenderMasculine + // GenderFeminine is used for feminine nouns (die in German, la in French) + GenderFeminine + // GenderCommon is used in languages with common gender (Swedish, Dutch) + GenderCommon +) + +// --- Message Types --- + +// Message represents a translation - either a simple string or plural forms. +// Supports full CLDR plural categories for languages with complex plural rules. +type Message struct { + Text string // Simple string value (non-plural) + Zero string // count == 0 (Arabic, Latvian, Welsh) + One string // count == 1 (most languages) + Two string // count == 2 (Arabic, Welsh) + Few string // Small numbers (Slavic: 2-4, Arabic: 3-10) + Many string // Larger numbers (Slavic: 5+, Arabic: 11-99) + Other string // Default/fallback form +} + +// ForCategory returns the appropriate text for a plural category. +// Falls back through the category hierarchy to find a non-empty string. +func (m Message) ForCategory(cat PluralCategory) string { + switch cat { + case PluralZero: + if m.Zero != "" { + return m.Zero + } + case PluralOne: + if m.One != "" { + return m.One + } + case PluralTwo: + if m.Two != "" { + return m.Two + } + case PluralFew: + if m.Few != "" { + return m.Few + } + case PluralMany: + if m.Many != "" { + return m.Many + } + } + // Fallback to Other, then One, then Text + if m.Other != "" { + return m.Other + } + if m.One != "" { + return m.One + } + return m.Text +} + +// IsPlural returns true if this message has any plural forms. +func (m Message) IsPlural() bool { + return m.Zero != "" || m.One != "" || m.Two != "" || + m.Few != "" || m.Many != "" || m.Other != "" +} + +// --- Subject Types --- + +// Subject represents a typed subject with metadata for semantic translations. +// Use S() to create a Subject and chain methods for additional context. +type Subject struct { + Noun string // The noun type (e.g., "file", "repo", "user") + Value any // The actual value (e.g., filename, struct, etc.) + count int // Count for pluralization (default 1) + gender string // Grammatical gender for languages that need it + location string // Location context (e.g., "in workspace") + formality Formality // Formality level override +} + +// --- Intent Types --- + +// IntentMeta defines the behaviour and characteristics of an intent. +type IntentMeta struct { + Type string // "action", "question", "info" + Verb string // Reference to verb key (e.g., "delete", "save") + Dangerous bool // If true, requires extra confirmation + Default string // Default response: "yes" or "no" + Supports []string // Extra options supported by this intent +} + +// Composed holds all output forms for an intent after template resolution. +type Composed struct { + Question string // Question form: "Delete config.yaml?" + Confirm string // Confirmation form: "Really delete config.yaml?" + Success string // Success message: "config.yaml deleted" + Failure string // Failure message: "Failed to delete config.yaml" + Meta IntentMeta // Intent metadata for UI decisions +} + +// Intent defines a semantic intent with templates for all output forms. +type Intent struct { + Meta IntentMeta // Intent behaviour and characteristics + Question string // Template for question form + Confirm string // Template for confirmation form + Success string // Template for success message + Failure string // Template for failure message +} + +// templateData is passed to intent templates during execution. +type templateData struct { + Subject string // Display value of subject + Noun string // Noun type + Count int // Count for pluralization + Gender string // Grammatical gender + Location string // Location context + Formality Formality // Formality level + IsFormal bool // Convenience: formality == FormalityFormal + IsPlural bool // Convenience: count != 1 + Value any // Raw value (for complex templates) +} + +// --- Grammar Types --- + +// GrammarData holds language-specific grammar forms loaded from JSON. +type GrammarData struct { + Verbs map[string]VerbForms // verb -> forms + Nouns map[string]NounForms // noun -> forms + Articles ArticleForms // article configuration + Words map[string]string // base word translations + Punct PunctuationRules // language-specific punctuation +} + +// VerbForms holds irregular verb conjugations. +type VerbForms struct { + Past string // Past tense (e.g., "deleted") + Gerund string // Present participle (e.g., "deleting") +} + +// 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 +} + +// PunctuationRules holds language-specific punctuation patterns. +type PunctuationRules struct { + LabelSuffix string // Suffix for labels (default ":") + ProgressSuffix string // Suffix for progress (default "...") +} + +// --- Number Formatting --- + +// NumberFormat defines locale-specific number formatting rules. +type NumberFormat struct { + ThousandsSep string // "," for en, "." for de + DecimalSep string // "." for en, "," for de + PercentFmt string // "%s%%" for en, "%s %%" for de (space before %) +} + +// --- Function Types --- + +// PluralRule is a function that determines the plural category for a count. +type PluralRule func(n int) PluralCategory + +// MissingKeyHandler receives missing key events for analysis. +type MissingKeyHandler func(missing MissingKey) + +// MissingKey is dispatched when a translation key is not found in ModeCollect. +type MissingKey struct { + Key string // The missing translation key + Args map[string]any // Arguments passed to the translation + CallerFile string // Source file where T() was called + CallerLine int // Line number where T() was called +} + +// --- Interfaces --- + +// KeyHandler processes translation keys before standard lookup. +// Handlers form a chain; each can handle a key or delegate to the next handler. +// Use this to implement dynamic key patterns like i18n.label.*, i18n.progress.*, etc. +type KeyHandler interface { + // Match returns true if this handler should process the key. + Match(key string) bool + + // Handle processes the key and returns the result. + // Call next() to delegate to the next handler in the chain. + Handle(key string, args []any, next func() string) string +} + +// Loader provides translation data to the Service. +// Implement this interface to support custom storage backends (database, remote API, etc.). +type Loader interface { + // Load returns messages and grammar data for a language. + // Returns an error if the language cannot be loaded. + Load(lang string) (map[string]Message, *GrammarData, error) + + // Languages returns all available language codes. + Languages() []string +} + +// Translator defines the interface for translation services. +type Translator interface { + T(messageID string, args ...any) string + SetLanguage(lang string) error + Language() string + SetMode(m Mode) + Mode() Mode + SetDebug(enabled bool) + Debug() bool + SetFormality(f Formality) + Formality() Formality + Direction() TextDirection + IsRTL() bool + PluralCategory(n int) PluralCategory + AvailableLanguages() []string +} + +// --- Package Variables --- + +// grammarCache holds loaded grammar data per language. +var ( + grammarCache = make(map[string]*GrammarData) + grammarCacheMu sync.RWMutex +) + +// templateCache stores compiled templates for reuse. +var templateCache sync.Map + +// numberFormats contains default number formats by language. +var numberFormats = map[string]NumberFormat{ + "en": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"}, + "de": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s %%"}, + "fr": {ThousandsSep: " ", DecimalSep: ",", PercentFmt: "%s %%"}, + "es": {ThousandsSep: ".", DecimalSep: ",", PercentFmt: "%s%%"}, + "zh": {ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"}, +} + +// rtlLanguages contains language codes that use right-to-left text direction. +var rtlLanguages = map[string]bool{ + "ar": true, "ar-SA": true, "ar-EG": true, + "he": true, "he-IL": true, + "fa": true, "fa-IR": true, + "ur": true, "ur-PK": true, + "yi": true, "ps": true, "sd": true, "ug": true, +} + +// pluralRules contains CLDR plural rules for supported languages. +var pluralRules = map[string]PluralRule{ + "en": pluralRuleEnglish, "en-GB": pluralRuleEnglish, "en-US": pluralRuleEnglish, + "de": pluralRuleGerman, "de-DE": pluralRuleGerman, "de-AT": pluralRuleGerman, "de-CH": pluralRuleGerman, + "fr": pluralRuleFrench, "fr-FR": pluralRuleFrench, "fr-CA": pluralRuleFrench, + "es": pluralRuleSpanish, "es-ES": pluralRuleSpanish, "es-MX": pluralRuleSpanish, + "ru": pluralRuleRussian, "ru-RU": pluralRuleRussian, + "pl": pluralRulePolish, "pl-PL": pluralRulePolish, + "ar": pluralRuleArabic, "ar-SA": pluralRuleArabic, + "zh": pluralRuleChinese, "zh-CN": pluralRuleChinese, "zh-TW": pluralRuleChinese, + "ja": pluralRuleJapanese, "ja-JP": pluralRuleJapanese, + "ko": pluralRuleKorean, "ko-KR": pluralRuleKorean, +} + +// --- Irregular Forms --- + +// irregularVerbs maps base verbs to their irregular forms. +var irregularVerbs = map[string]VerbForms{ + "be": {Past: "was", Gerund: "being"}, "have": {Past: "had", Gerund: "having"}, + "do": {Past: "did", Gerund: "doing"}, "go": {Past: "went", Gerund: "going"}, + "make": {Past: "made", Gerund: "making"}, "get": {Past: "got", Gerund: "getting"}, + "run": {Past: "ran", Gerund: "running"}, "set": {Past: "set", Gerund: "setting"}, + "put": {Past: "put", Gerund: "putting"}, "cut": {Past: "cut", Gerund: "cutting"}, + "let": {Past: "let", Gerund: "letting"}, "hit": {Past: "hit", Gerund: "hitting"}, + "shut": {Past: "shut", Gerund: "shutting"}, "split": {Past: "split", Gerund: "splitting"}, + "spread": {Past: "spread", Gerund: "spreading"}, "read": {Past: "read", Gerund: "reading"}, + "write": {Past: "wrote", Gerund: "writing"}, "send": {Past: "sent", Gerund: "sending"}, + "build": {Past: "built", Gerund: "building"}, "begin": {Past: "began", Gerund: "beginning"}, + "find": {Past: "found", Gerund: "finding"}, "take": {Past: "took", Gerund: "taking"}, + "see": {Past: "saw", Gerund: "seeing"}, "keep": {Past: "kept", Gerund: "keeping"}, + "hold": {Past: "held", Gerund: "holding"}, "tell": {Past: "told", Gerund: "telling"}, + "bring": {Past: "brought", Gerund: "bringing"}, "think": {Past: "thought", Gerund: "thinking"}, + "buy": {Past: "bought", Gerund: "buying"}, "catch": {Past: "caught", Gerund: "catching"}, + "teach": {Past: "taught", Gerund: "teaching"}, "throw": {Past: "threw", Gerund: "throwing"}, + "grow": {Past: "grew", Gerund: "growing"}, "know": {Past: "knew", Gerund: "knowing"}, + "show": {Past: "showed", Gerund: "showing"}, "draw": {Past: "drew", Gerund: "drawing"}, + "break": {Past: "broke", Gerund: "breaking"}, "speak": {Past: "spoke", Gerund: "speaking"}, + "choose": {Past: "chose", Gerund: "choosing"}, "forget": {Past: "forgot", Gerund: "forgetting"}, + "lose": {Past: "lost", Gerund: "losing"}, "win": {Past: "won", Gerund: "winning"}, + "swim": {Past: "swam", Gerund: "swimming"}, "drive": {Past: "drove", Gerund: "driving"}, + "rise": {Past: "rose", Gerund: "rising"}, "shine": {Past: "shone", Gerund: "shining"}, + "sing": {Past: "sang", Gerund: "singing"}, "ring": {Past: "rang", Gerund: "ringing"}, + "drink": {Past: "drank", Gerund: "drinking"}, "sink": {Past: "sank", Gerund: "sinking"}, + "sit": {Past: "sat", Gerund: "sitting"}, "stand": {Past: "stood", Gerund: "standing"}, + "hang": {Past: "hung", Gerund: "hanging"}, "dig": {Past: "dug", Gerund: "digging"}, + "stick": {Past: "stuck", Gerund: "sticking"}, "bite": {Past: "bit", Gerund: "biting"}, + "hide": {Past: "hid", Gerund: "hiding"}, "feed": {Past: "fed", Gerund: "feeding"}, + "meet": {Past: "met", Gerund: "meeting"}, "lead": {Past: "led", Gerund: "leading"}, + "sleep": {Past: "slept", Gerund: "sleeping"}, "feel": {Past: "felt", Gerund: "feeling"}, + "leave": {Past: "left", Gerund: "leaving"}, "mean": {Past: "meant", Gerund: "meaning"}, + "lend": {Past: "lent", Gerund: "lending"}, "spend": {Past: "spent", Gerund: "spending"}, + "bend": {Past: "bent", Gerund: "bending"}, "deal": {Past: "dealt", Gerund: "dealing"}, + "lay": {Past: "laid", Gerund: "laying"}, "pay": {Past: "paid", Gerund: "paying"}, + "say": {Past: "said", Gerund: "saying"}, "sell": {Past: "sold", Gerund: "selling"}, + "seek": {Past: "sought", Gerund: "seeking"}, "fight": {Past: "fought", Gerund: "fighting"}, + "fly": {Past: "flew", Gerund: "flying"}, "wear": {Past: "wore", Gerund: "wearing"}, + "tear": {Past: "tore", Gerund: "tearing"}, "bear": {Past: "bore", Gerund: "bearing"}, + "swear": {Past: "swore", Gerund: "swearing"}, "wake": {Past: "woke", Gerund: "waking"}, + "freeze": {Past: "froze", Gerund: "freezing"}, "steal": {Past: "stole", Gerund: "stealing"}, + "overwrite": {Past: "overwritten", Gerund: "overwriting"}, "reset": {Past: "reset", Gerund: "resetting"}, + "reboot": {Past: "rebooted", Gerund: "rebooting"}, + // Multi-syllable verbs with stressed final syllables (double consonant) + "submit": {Past: "submitted", Gerund: "submitting"}, "permit": {Past: "permitted", Gerund: "permitting"}, + "admit": {Past: "admitted", Gerund: "admitting"}, "omit": {Past: "omitted", Gerund: "omitting"}, + "commit": {Past: "committed", Gerund: "committing"}, "transmit": {Past: "transmitted", Gerund: "transmitting"}, + "prefer": {Past: "preferred", Gerund: "preferring"}, "refer": {Past: "referred", Gerund: "referring"}, + "transfer": {Past: "transferred", Gerund: "transferring"}, "defer": {Past: "deferred", Gerund: "deferring"}, + "confer": {Past: "conferred", Gerund: "conferring"}, "infer": {Past: "inferred", Gerund: "inferring"}, + "occur": {Past: "occurred", Gerund: "occurring"}, "recur": {Past: "recurred", Gerund: "recurring"}, + "incur": {Past: "incurred", Gerund: "incurring"}, "deter": {Past: "deterred", Gerund: "deterring"}, + "control": {Past: "controlled", Gerund: "controlling"}, "patrol": {Past: "patrolled", Gerund: "patrolling"}, + "compel": {Past: "compelled", Gerund: "compelling"}, "expel": {Past: "expelled", Gerund: "expelling"}, + "propel": {Past: "propelled", Gerund: "propelling"}, "repel": {Past: "repelled", Gerund: "repelling"}, + "rebel": {Past: "rebelled", Gerund: "rebelling"}, "excel": {Past: "excelled", Gerund: "excelling"}, + "cancel": {Past: "cancelled", Gerund: "cancelling"}, "travel": {Past: "travelled", Gerund: "travelling"}, + "label": {Past: "labelled", Gerund: "labelling"}, "model": {Past: "modelled", Gerund: "modelling"}, + "level": {Past: "levelled", Gerund: "levelling"}, +} + +// noDoubleConsonant contains multi-syllable verbs that don't double the final consonant. +var noDoubleConsonant = map[string]bool{ + "open": true, "listen": true, "happen": true, "enter": true, "offer": true, + "suffer": true, "differ": true, "cover": true, "deliver": true, "develop": true, + "visit": true, "limit": true, "edit": true, "credit": true, "orbit": true, + "total": true, "target": true, "budget": true, "market": true, "benefit": true, "focus": true, +} + +// irregularNouns maps singular nouns to their irregular plural forms. +var irregularNouns = map[string]string{ + "child": "children", "person": "people", "man": "men", "woman": "women", + "foot": "feet", "tooth": "teeth", "mouse": "mice", "goose": "geese", + "ox": "oxen", "index": "indices", "appendix": "appendices", "matrix": "matrices", + "vertex": "vertices", "crisis": "crises", "analysis": "analyses", "diagnosis": "diagnoses", + "thesis": "theses", "hypothesis": "hypotheses", "parenthesis": "parentheses", + "datum": "data", "medium": "media", "bacterium": "bacteria", "criterion": "criteria", + "phenomenon": "phenomena", "curriculum": "curricula", "alumnus": "alumni", + "cactus": "cacti", "focus": "foci", "fungus": "fungi", "nucleus": "nuclei", + "radius": "radii", "stimulus": "stimuli", "syllabus": "syllabi", + "fish": "fish", "sheep": "sheep", "deer": "deer", "species": "species", + "series": "series", "aircraft": "aircraft", + "life": "lives", "wife": "wives", "knife": "knives", "leaf": "leaves", + "half": "halves", "self": "selves", "shelf": "shelves", "wolf": "wolves", + "calf": "calves", "loaf": "loaves", "thief": "thieves", +} + +// vowelSounds contains words that start with consonants but have vowel sounds. +var vowelSounds = map[string]bool{ + "hour": true, "honest": true, "honour": true, "honor": true, "heir": true, "herb": true, +} + +// consonantSounds contains words that start with vowels but have consonant sounds. +var consonantSounds = map[string]bool{ + "user": true, "union": true, "unique": true, "unit": true, "universe": true, + "university": true, "uniform": true, "usage": true, "usual": true, "utility": true, + "utensil": true, "one": true, "once": true, "euro": true, "eulogy": true, "euphemism": true, +}