refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes: - Add KeyHandler interface for pluggable namespace handlers - Add Loader interface for format-agnostic translation loading - Add TranslationContext for translation disambiguation - Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric) - Update T() to use handler chain instead of hardcoded logic - Add handler management methods (AddHandler, PrependHandler, ClearHandlers) File reorganisation: - types.go: all type definitions - loader.go: Loader interface + FSLoader (from mutate.go, checks.go) - handler.go: KeyHandler interface + built-in handlers - context.go: TranslationContext + C() builder - hooks.go: renamed from actions.go - service.go: merged interfaces.go content Deleted: interfaces.go, mode.go, mutate.go, checks.go Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c47c2905a8
commit
67e7e552f3
16 changed files with 1972 additions and 1462 deletions
|
|
@ -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 != ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
106
pkg/i18n/context.go
Normal file
106
pkg/i18n/context.go
Normal file
|
|
@ -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
|
||||
}
|
||||
125
pkg/i18n/context_test.go
Normal file
125
pkg/i18n/context_test.go
Normal file
|
|
@ -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"))
|
||||
}
|
||||
166
pkg/i18n/handler.go
Normal file
166
pkg/i18n/handler.go
Normal file
|
|
@ -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{}
|
||||
)
|
||||
173
pkg/i18n/handler_test.go
Normal file
173
pkg/i18n/handler_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
270
pkg/i18n/loader.go
Normal file
270
pkg/i18n/loader.go
Normal file
|
|
@ -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
|
||||
}
|
||||
589
pkg/i18n/loader_test.go
Normal file
589
pkg/i18n/loader_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
449
pkg/i18n/types.go
Normal file
449
pkg/i18n/types.go
Normal file
|
|
@ -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,
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue