refactor: remove pkg/i18n, use core/go-i18n module

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-06 09:09:02 +00:00
parent 2958527774
commit 84397a2e10
69 changed files with 6 additions and 46497 deletions

View file

@ -8,7 +8,7 @@ import (
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go-i18n"
)
var (

View file

@ -10,7 +10,7 @@ import (
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go-i18n"
)
var (

View file

@ -5,7 +5,7 @@ package gocmd
import (
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go-i18n"
)
// Style aliases for shared styles

View file

@ -13,7 +13,7 @@ import (
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go-i18n"
)
var (

View file

@ -12,7 +12,7 @@ import (
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-devops/cmd/qa"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go-i18n"
)
// QA command flags - comprehensive options for all agents

View file

@ -7,7 +7,7 @@ import (
"path/filepath"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go-i18n"
)
var (

View file

@ -1,116 +0,0 @@
package i18n
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestTranslationCompleteness_Good verifies every T() key in the source code
// has a translation in en_GB.json. Catches missing keys at test time instead
// of showing raw keys like "cmd.collect.short" in the CLI.
func TestTranslationCompleteness_Good(t *testing.T) {
svc, err := New(WithMode(ModeStrict))
require.NoError(t, err)
// Find repo root (walk up from pkg/i18n/ to find go.mod)
root := findRepoRoot(t)
// Extract all T("key") calls from Go source
keys := extractTranslationKeys(t, root)
if len(keys) == 0 {
t.Skip("no i18n.T() calls found in source — CLI not yet wired to i18n")
}
var missing []string
for _, key := range keys {
// ModeStrict panics on missing — use recover to collect them all
func() {
defer func() {
if r := recover(); r != nil {
missing = append(missing, key)
}
}()
svc.T(key)
}()
}
if len(missing) > 0 {
sort.Strings(missing)
t.Errorf("found %d missing translation keys in en_GB.json:\n %s",
len(missing), strings.Join(missing, "\n "))
}
}
// findRepoRoot walks up from the test directory to find the repo root (containing go.mod).
func findRepoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
require.NoError(t, err)
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("could not find repo root (no go.mod found)")
}
dir = parent
}
}
// tCallRegex matches i18n.T("key"), T("key"), and cli.T("key") patterns.
var tCallRegex = regexp.MustCompile(`(?:i18n|cli)\.T\("([^"]+)"`)
// extractTranslationKeys scans all .go files (excluding tests and vendors)
// for T() calls and returns the unique set of translation keys.
func extractTranslationKeys(t *testing.T, root string) []string {
t.Helper()
seen := make(map[string]bool)
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip errors
}
// Skip vendor, .git, and test files
if info.IsDir() {
base := info.Name()
if base == "vendor" || base == ".git" || base == "node_modules" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
matches := tCallRegex.FindAllSubmatch(data, -1)
for _, m := range matches {
key := string(m[1])
// Only track cmd.* and common.* keys (skip dynamic/template keys)
if strings.HasPrefix(key, "cmd.") || strings.HasPrefix(key, "common.") {
seen[key] = true
}
}
return nil
})
require.NoError(t, err)
keys := make([]string, 0, len(seen))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View file

@ -1,184 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
import (
"fmt"
)
// S creates a new Subject with the given noun and value.
// The noun is used for grammar rules, the value for display.
//
// S("file", "config.yaml") // "config.yaml"
// S("repo", repo) // Uses repo.String() or fmt.Sprint()
// S("file", path).Count(3).In("workspace")
func S(noun string, value any) *Subject {
return &Subject{
Noun: noun,
Value: value,
count: 1, // Default to singular
}
}
// Count sets the count for pluralization.
// Used to determine singular/plural forms in templates.
//
// S("file", files).Count(len(files))
func (s *Subject) Count(n int) *Subject {
if s == nil {
return nil
}
s.count = n
return s
}
// Gender sets the grammatical gender for languages that require it.
// Common values: "masculine", "feminine", "neuter"
//
// S("user", user).Gender("female")
func (s *Subject) Gender(g string) *Subject {
if s == nil {
return nil
}
s.gender = g
return s
}
// In sets the location context for the subject.
// Used in templates to provide spatial context.
//
// S("file", "config.yaml").In("workspace")
func (s *Subject) In(location string) *Subject {
if s == nil {
return nil
}
s.location = location
return s
}
// Formal sets the formality level to formal (Sie, vous, usted).
// Use for polite/professional address in languages that distinguish formality.
//
// S("colleague", name).Formal()
func (s *Subject) Formal() *Subject {
if s == nil {
return nil
}
s.formality = FormalityFormal
return s
}
// Informal sets the formality level to informal (du, tu, tú).
// Use for casual/friendly address in languages that distinguish formality.
//
// S("friend", name).Informal()
func (s *Subject) Informal() *Subject {
if s == nil {
return nil
}
s.formality = FormalityInformal
return s
}
// Formality sets the formality level explicitly.
//
// S("user", name).Formality(FormalityFormal)
func (s *Subject) Formality(f Formality) *Subject {
if s == nil {
return nil
}
s.formality = f
return s
}
// String returns the display value of the subject.
func (s *Subject) String() string {
if s == nil {
return ""
}
if stringer, ok := s.Value.(fmt.Stringer); ok {
return stringer.String()
}
return fmt.Sprint(s.Value)
}
// IsPlural returns true if count != 1.
func (s *Subject) IsPlural() bool {
return s != nil && s.count != 1
}
// CountInt returns the count value.
func (s *Subject) CountInt() int {
if s == nil {
return 1
}
return s.count
}
// CountString returns the count as a string.
func (s *Subject) CountString() string {
if s == nil {
return "1"
}
return fmt.Sprint(s.count)
}
// GenderString returns the grammatical gender.
func (s *Subject) GenderString() string {
if s == nil {
return ""
}
return s.gender
}
// LocationString returns the location context.
func (s *Subject) LocationString() string {
if s == nil {
return ""
}
return s.location
}
// NounString returns the noun type.
func (s *Subject) NounString() string {
if s == nil {
return ""
}
return s.Noun
}
// FormalityString returns the formality level as a string.
// Returns "neutral" if not explicitly set.
func (s *Subject) FormalityString() string {
if s == nil {
return FormalityNeutral.String()
}
return s.formality.String()
}
// IsFormal returns true if formal address should be used.
func (s *Subject) IsFormal() bool {
return s != nil && s.formality == FormalityFormal
}
// IsInformal returns true if informal address should be used.
func (s *Subject) IsInformal() bool {
return s != nil && s.formality == FormalityInformal
}
// newTemplateData creates templateData from a Subject.
func newTemplateData(s *Subject) templateData {
if s == nil {
return templateData{Count: 1}
}
return templateData{
Subject: s.String(),
Noun: s.Noun,
Count: s.count,
Gender: s.gender,
Location: s.location,
Formality: s.formality,
IsFormal: s.formality == FormalityFormal,
IsPlural: s.count != 1,
Value: s.Value,
}
}

View file

@ -1,679 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
import (
"sync"
)
// coreIntents defines the built-in semantic intents for common operations.
// These are accessed via the "core.*" namespace in T() and C() calls.
//
// Each intent provides templates for all output forms:
// - Question: Initial prompt to the user
// - Confirm: Secondary confirmation (for dangerous actions)
// - Success: Message shown on successful completion
// - Failure: Message shown on failure
//
// Templates use Go text/template syntax with the following data available:
// - .Subject: Display value of the subject
// - .Noun: The noun type (e.g., "file", "repo")
// - .Count: Count for pluralization
// - .Location: Location context
//
// Template functions available:
// - title, lower, upper: Case transformations
// - past, gerund: Verb conjugations
// - plural, pluralForm: Noun pluralization
// - article: Indefinite article selection (a/an)
// - quote: Wrap in double quotes
var coreIntents = map[string]Intent{
// --- Destructive Actions ---
"core.delete": {
Meta: IntentMeta{
Type: "action",
Verb: "delete",
Dangerous: true,
Default: "no",
},
Question: "Delete {{.Subject}}?",
Confirm: "Really delete {{.Subject}}? This cannot be undone.",
Success: "{{.Subject | title}} deleted",
Failure: "Failed to delete {{.Subject}}",
},
"core.remove": {
Meta: IntentMeta{
Type: "action",
Verb: "remove",
Dangerous: true,
Default: "no",
},
Question: "Remove {{.Subject}}?",
Confirm: "Really remove {{.Subject}}?",
Success: "{{.Subject | title}} removed",
Failure: "Failed to remove {{.Subject}}",
},
"core.discard": {
Meta: IntentMeta{
Type: "action",
Verb: "discard",
Dangerous: true,
Default: "no",
},
Question: "Discard {{.Subject}}?",
Confirm: "Really discard {{.Subject}}? All changes will be lost.",
Success: "{{.Subject | title}} discarded",
Failure: "Failed to discard {{.Subject}}",
},
"core.reset": {
Meta: IntentMeta{
Type: "action",
Verb: "reset",
Dangerous: true,
Default: "no",
},
Question: "Reset {{.Subject}}?",
Confirm: "Really reset {{.Subject}}? This cannot be undone.",
Success: "{{.Subject | title}} reset",
Failure: "Failed to reset {{.Subject}}",
},
"core.overwrite": {
Meta: IntentMeta{
Type: "action",
Verb: "overwrite",
Dangerous: true,
Default: "no",
},
Question: "Overwrite {{.Subject}}?",
Confirm: "Really overwrite {{.Subject}}? Existing content will be lost.",
Success: "{{.Subject | title}} overwritten",
Failure: "Failed to overwrite {{.Subject}}",
},
// --- Creation Actions ---
"core.create": {
Meta: IntentMeta{
Type: "action",
Verb: "create",
Default: "yes",
},
Question: "Create {{.Subject}}?",
Confirm: "Create {{.Subject}}?",
Success: "{{.Subject | title}} created",
Failure: "Failed to create {{.Subject}}",
},
"core.add": {
Meta: IntentMeta{
Type: "action",
Verb: "add",
Default: "yes",
},
Question: "Add {{.Subject}}?",
Confirm: "Add {{.Subject}}?",
Success: "{{.Subject | title}} added",
Failure: "Failed to add {{.Subject}}",
},
"core.clone": {
Meta: IntentMeta{
Type: "action",
Verb: "clone",
Default: "yes",
},
Question: "Clone {{.Subject}}?",
Confirm: "Clone {{.Subject}}?",
Success: "{{.Subject | title}} cloned",
Failure: "Failed to clone {{.Subject}}",
},
"core.copy": {
Meta: IntentMeta{
Type: "action",
Verb: "copy",
Default: "yes",
},
Question: "Copy {{.Subject}}?",
Confirm: "Copy {{.Subject}}?",
Success: "{{.Subject | title}} copied",
Failure: "Failed to copy {{.Subject}}",
},
// --- Modification Actions ---
"core.save": {
Meta: IntentMeta{
Type: "action",
Verb: "save",
Default: "yes",
},
Question: "Save {{.Subject}}?",
Confirm: "Save {{.Subject}}?",
Success: "{{.Subject | title}} saved",
Failure: "Failed to save {{.Subject}}",
},
"core.update": {
Meta: IntentMeta{
Type: "action",
Verb: "update",
Default: "yes",
},
Question: "Update {{.Subject}}?",
Confirm: "Update {{.Subject}}?",
Success: "{{.Subject | title}} updated",
Failure: "Failed to update {{.Subject}}",
},
"core.rename": {
Meta: IntentMeta{
Type: "action",
Verb: "rename",
Default: "yes",
},
Question: "Rename {{.Subject}}?",
Confirm: "Rename {{.Subject}}?",
Success: "{{.Subject | title}} renamed",
Failure: "Failed to rename {{.Subject}}",
},
"core.move": {
Meta: IntentMeta{
Type: "action",
Verb: "move",
Default: "yes",
},
Question: "Move {{.Subject}}?",
Confirm: "Move {{.Subject}}?",
Success: "{{.Subject | title}} moved",
Failure: "Failed to move {{.Subject}}",
},
// --- Git Actions ---
"core.commit": {
Meta: IntentMeta{
Type: "action",
Verb: "commit",
Default: "yes",
},
Question: "Commit {{.Subject}}?",
Confirm: "Commit {{.Subject}}?",
Success: "{{.Subject | title}} committed",
Failure: "Failed to commit {{.Subject}}",
},
"core.push": {
Meta: IntentMeta{
Type: "action",
Verb: "push",
Default: "yes",
},
Question: "Push {{.Subject}}?",
Confirm: "Push {{.Subject}}?",
Success: "{{.Subject | title}} pushed",
Failure: "Failed to push {{.Subject}}",
},
"core.pull": {
Meta: IntentMeta{
Type: "action",
Verb: "pull",
Default: "yes",
},
Question: "Pull {{.Subject}}?",
Confirm: "Pull {{.Subject}}?",
Success: "{{.Subject | title}} pulled",
Failure: "Failed to pull {{.Subject}}",
},
"core.merge": {
Meta: IntentMeta{
Type: "action",
Verb: "merge",
Dangerous: true,
Default: "no",
},
Question: "Merge {{.Subject}}?",
Confirm: "Really merge {{.Subject}}?",
Success: "{{.Subject | title}} merged",
Failure: "Failed to merge {{.Subject}}",
},
"core.rebase": {
Meta: IntentMeta{
Type: "action",
Verb: "rebase",
Dangerous: true,
Default: "no",
},
Question: "Rebase {{.Subject}}?",
Confirm: "Really rebase {{.Subject}}? This rewrites history.",
Success: "{{.Subject | title}} rebased",
Failure: "Failed to rebase {{.Subject}}",
},
// --- Network Actions ---
"core.install": {
Meta: IntentMeta{
Type: "action",
Verb: "install",
Default: "yes",
},
Question: "Install {{.Subject}}?",
Confirm: "Install {{.Subject}}?",
Success: "{{.Subject | title}} installed",
Failure: "Failed to install {{.Subject}}",
},
"core.download": {
Meta: IntentMeta{
Type: "action",
Verb: "download",
Default: "yes",
},
Question: "Download {{.Subject}}?",
Confirm: "Download {{.Subject}}?",
Success: "{{.Subject | title}} downloaded",
Failure: "Failed to download {{.Subject}}",
},
"core.upload": {
Meta: IntentMeta{
Type: "action",
Verb: "upload",
Default: "yes",
},
Question: "Upload {{.Subject}}?",
Confirm: "Upload {{.Subject}}?",
Success: "{{.Subject | title}} uploaded",
Failure: "Failed to upload {{.Subject}}",
},
"core.publish": {
Meta: IntentMeta{
Type: "action",
Verb: "publish",
Dangerous: true,
Default: "no",
},
Question: "Publish {{.Subject}}?",
Confirm: "Really publish {{.Subject}}? This will be publicly visible.",
Success: "{{.Subject | title}} published",
Failure: "Failed to publish {{.Subject}}",
},
"core.deploy": {
Meta: IntentMeta{
Type: "action",
Verb: "deploy",
Dangerous: true,
Default: "no",
},
Question: "Deploy {{.Subject}}?",
Confirm: "Really deploy {{.Subject}}?",
Success: "{{.Subject | title}} deployed",
Failure: "Failed to deploy {{.Subject}}",
},
// --- Process Actions ---
"core.start": {
Meta: IntentMeta{
Type: "action",
Verb: "start",
Default: "yes",
},
Question: "Start {{.Subject}}?",
Confirm: "Start {{.Subject}}?",
Success: "{{.Subject | title}} started",
Failure: "Failed to start {{.Subject}}",
},
"core.stop": {
Meta: IntentMeta{
Type: "action",
Verb: "stop",
Default: "yes",
},
Question: "Stop {{.Subject}}?",
Confirm: "Stop {{.Subject}}?",
Success: "{{.Subject | title}} stopped",
Failure: "Failed to stop {{.Subject}}",
},
"core.restart": {
Meta: IntentMeta{
Type: "action",
Verb: "restart",
Default: "yes",
},
Question: "Restart {{.Subject}}?",
Confirm: "Restart {{.Subject}}?",
Success: "{{.Subject | title}} restarted",
Failure: "Failed to restart {{.Subject}}",
},
"core.run": {
Meta: IntentMeta{
Type: "action",
Verb: "run",
Default: "yes",
},
Question: "Run {{.Subject}}?",
Confirm: "Run {{.Subject}}?",
Success: "{{.Subject | title}} completed",
Failure: "Failed to run {{.Subject}}",
},
"core.build": {
Meta: IntentMeta{
Type: "action",
Verb: "build",
Default: "yes",
},
Question: "Build {{.Subject}}?",
Confirm: "Build {{.Subject}}?",
Success: "{{.Subject | title}} built",
Failure: "Failed to build {{.Subject}}",
},
"core.test": {
Meta: IntentMeta{
Type: "action",
Verb: "test",
Default: "yes",
},
Question: "Test {{.Subject}}?",
Confirm: "Test {{.Subject}}?",
Success: "{{.Subject | title}} passed",
Failure: "{{.Subject | title}} failed",
},
// --- Information Actions ---
"core.continue": {
Meta: IntentMeta{
Type: "question",
Verb: "continue",
Default: "yes",
},
Question: "Continue?",
Confirm: "Continue?",
Success: "Continuing",
Failure: "Aborted",
},
"core.proceed": {
Meta: IntentMeta{
Type: "question",
Verb: "proceed",
Default: "yes",
},
Question: "Proceed?",
Confirm: "Proceed?",
Success: "Proceeding",
Failure: "Aborted",
},
"core.confirm": {
Meta: IntentMeta{
Type: "question",
Verb: "confirm",
Default: "no",
},
Question: "Are you sure?",
Confirm: "Are you sure?",
Success: "Confirmed",
Failure: "Cancelled",
},
// --- Additional Actions ---
"core.sync": {
Meta: IntentMeta{
Type: "action",
Verb: "sync",
Default: "yes",
},
Question: "Sync {{.Subject}}?",
Confirm: "Sync {{.Subject}}?",
Success: "{{.Subject | title}} synced",
Failure: "Failed to sync {{.Subject}}",
},
"core.boot": {
Meta: IntentMeta{
Type: "action",
Verb: "boot",
Default: "yes",
},
Question: "Boot {{.Subject}}?",
Confirm: "Boot {{.Subject}}?",
Success: "{{.Subject | title}} booted",
Failure: "Failed to boot {{.Subject}}",
},
"core.format": {
Meta: IntentMeta{
Type: "action",
Verb: "format",
Default: "yes",
},
Question: "Format {{.Subject}}?",
Confirm: "Format {{.Subject}}?",
Success: "{{.Subject | title}} formatted",
Failure: "Failed to format {{.Subject}}",
},
"core.analyse": {
Meta: IntentMeta{
Type: "action",
Verb: "analyse",
Default: "yes",
},
Question: "Analyse {{.Subject}}?",
Confirm: "Analyse {{.Subject}}?",
Success: "{{.Subject | title}} analysed",
Failure: "Failed to analyse {{.Subject}}",
},
"core.link": {
Meta: IntentMeta{
Type: "action",
Verb: "link",
Default: "yes",
},
Question: "Link {{.Subject}}?",
Confirm: "Link {{.Subject}}?",
Success: "{{.Subject | title}} linked",
Failure: "Failed to link {{.Subject}}",
},
"core.unlink": {
Meta: IntentMeta{
Type: "action",
Verb: "unlink",
Default: "yes",
},
Question: "Unlink {{.Subject}}?",
Confirm: "Unlink {{.Subject}}?",
Success: "{{.Subject | title}} unlinked",
Failure: "Failed to unlink {{.Subject}}",
},
"core.fetch": {
Meta: IntentMeta{
Type: "action",
Verb: "fetch",
Default: "yes",
},
Question: "Fetch {{.Subject}}?",
Confirm: "Fetch {{.Subject}}?",
Success: "{{.Subject | title}} fetched",
Failure: "Failed to fetch {{.Subject}}",
},
"core.generate": {
Meta: IntentMeta{
Type: "action",
Verb: "generate",
Default: "yes",
},
Question: "Generate {{.Subject}}?",
Confirm: "Generate {{.Subject}}?",
Success: "{{.Subject | title}} generated",
Failure: "Failed to generate {{.Subject}}",
},
"core.validate": {
Meta: IntentMeta{
Type: "action",
Verb: "validate",
Default: "yes",
},
Question: "Validate {{.Subject}}?",
Confirm: "Validate {{.Subject}}?",
Success: "{{.Subject | title}} valid",
Failure: "{{.Subject | title}} invalid",
},
"core.check": {
Meta: IntentMeta{
Type: "action",
Verb: "check",
Default: "yes",
},
Question: "Check {{.Subject}}?",
Confirm: "Check {{.Subject}}?",
Success: "{{.Subject | title}} OK",
Failure: "{{.Subject | title}} failed",
},
"core.scan": {
Meta: IntentMeta{
Type: "action",
Verb: "scan",
Default: "yes",
},
Question: "Scan {{.Subject}}?",
Confirm: "Scan {{.Subject}}?",
Success: "{{.Subject | title}} scanned",
Failure: "Failed to scan {{.Subject}}",
},
}
// customIntents holds user-registered intents.
// Separated from coreIntents to allow thread-safe registration.
var (
customIntents = make(map[string]Intent)
customIntentsMu sync.RWMutex
)
// getIntent retrieves an intent by its key.
// Checks custom intents first, then falls back to core intents.
// Returns nil if the intent is not found.
func getIntent(key string) *Intent {
// Check custom intents first (thread-safe)
customIntentsMu.RLock()
if intent, ok := customIntents[key]; ok {
customIntentsMu.RUnlock()
return &intent
}
customIntentsMu.RUnlock()
// Fall back to core intents
if intent, ok := coreIntents[key]; ok {
return &intent
}
return nil
}
// RegisterIntent adds a custom intent at runtime.
// Use this to extend the built-in intents with application-specific ones.
// This function is thread-safe.
//
// i18n.RegisterIntent("myapp.archive", i18n.Intent{
// Meta: i18n.IntentMeta{Type: "action", Verb: "archive", Default: "yes"},
// Question: "Archive {{.Subject}}?",
// Success: "{{.Subject | title}} archived",
// Failure: "Failed to archive {{.Subject}}",
// })
func RegisterIntent(key string, intent Intent) {
customIntentsMu.Lock()
defer customIntentsMu.Unlock()
customIntents[key] = intent
}
// RegisterIntents adds multiple custom intents at runtime.
// This is more efficient than calling RegisterIntent multiple times.
// This function is thread-safe.
//
// i18n.RegisterIntents(map[string]i18n.Intent{
// "myapp.archive": {
// Meta: i18n.IntentMeta{Type: "action", Verb: "archive"},
// Question: "Archive {{.Subject}}?",
// },
// "myapp.export": {
// Meta: i18n.IntentMeta{Type: "action", Verb: "export"},
// Question: "Export {{.Subject}}?",
// },
// })
func RegisterIntents(intents map[string]Intent) {
customIntentsMu.Lock()
defer customIntentsMu.Unlock()
for k, v := range intents {
customIntents[k] = v
}
}
// UnregisterIntent removes a custom intent by key.
// This only affects custom intents, not core intents.
// This function is thread-safe.
func UnregisterIntent(key string) {
customIntentsMu.Lock()
defer customIntentsMu.Unlock()
delete(customIntents, key)
}
// IntentKeys returns all registered intent keys (both core and custom).
func IntentKeys() []string {
customIntentsMu.RLock()
defer customIntentsMu.RUnlock()
keys := make([]string, 0, len(coreIntents)+len(customIntents))
for key := range coreIntents {
keys = append(keys, key)
}
for key := range customIntents {
// Avoid duplicates if custom overrides core
found := false
for _, k := range keys {
if k == key {
found = true
break
}
}
if !found {
keys = append(keys, key)
}
}
return keys
}
// HasIntent returns true if an intent with the given key exists.
func HasIntent(key string) bool {
return getIntent(key) != nil
}
// GetIntent returns the intent for a key, or nil if not found.
// This is the public API for retrieving intents.
func GetIntent(key string) *Intent {
return getIntent(key)
}

View file

@ -1,814 +0,0 @@
package i18n
import (
"testing"
"github.com/stretchr/testify/assert"
)
// stringerValue is a test helper that implements fmt.Stringer
type stringerValue struct {
value string
}
func (s stringerValue) String() string {
return s.value
}
func TestSubject_Good(t *testing.T) {
t.Run("basic creation", func(t *testing.T) {
s := S("file", "config.yaml")
assert.Equal(t, "file", s.Noun)
assert.Equal(t, "config.yaml", s.Value)
assert.Equal(t, 1, s.count)
assert.Equal(t, "", s.gender)
assert.Equal(t, "", s.location)
})
t.Run("S with different value types", func(t *testing.T) {
s := S("repo", "core-php")
assert.Equal(t, "repo", s.Noun)
assert.Equal(t, "core-php", s.Value)
})
t.Run("with count", func(t *testing.T) {
s := S("file", "*.go").Count(5)
assert.Equal(t, 5, s.CountInt())
assert.True(t, s.IsPlural())
})
t.Run("with gender", func(t *testing.T) {
s := S("user", "alice").Gender("female")
assert.Equal(t, "female", s.GenderString())
})
t.Run("with location", func(t *testing.T) {
s := S("file", "config.yaml").In("workspace")
assert.Equal(t, "workspace", s.LocationString())
})
t.Run("chained methods", func(t *testing.T) {
s := S("repo", "core-php").Count(3).Gender("neuter").In("organisation")
assert.Equal(t, "repo", s.NounString())
assert.Equal(t, 3, s.CountInt())
assert.Equal(t, "neuter", s.GenderString())
assert.Equal(t, "organisation", s.LocationString())
})
}
func TestSubject_String(t *testing.T) {
t.Run("string value", func(t *testing.T) {
s := S("file", "config.yaml")
assert.Equal(t, "config.yaml", s.String())
})
t.Run("stringer interface", func(t *testing.T) {
// Using a struct that implements Stringer via embedded method
s := S("item", stringerValue{"test"})
assert.Equal(t, "test", s.String())
})
t.Run("nil subject", func(t *testing.T) {
var s *Subject
assert.Equal(t, "", s.String())
})
t.Run("int value", func(t *testing.T) {
s := S("count", 42)
assert.Equal(t, "42", s.String())
})
}
func TestSubject_IsPlural(t *testing.T) {
t.Run("singular (count 1)", func(t *testing.T) {
s := S("file", "test.go")
assert.False(t, s.IsPlural())
})
t.Run("plural (count 0)", func(t *testing.T) {
s := S("file", "*.go").Count(0)
assert.True(t, s.IsPlural())
})
t.Run("plural (count > 1)", func(t *testing.T) {
s := S("file", "*.go").Count(5)
assert.True(t, s.IsPlural())
})
t.Run("nil subject", func(t *testing.T) {
var s *Subject
assert.False(t, s.IsPlural())
})
}
func TestSubject_Getters(t *testing.T) {
t.Run("nil safety", func(t *testing.T) {
var s *Subject
assert.Equal(t, "", s.NounString())
assert.Equal(t, 1, s.CountInt())
assert.Equal(t, "1", s.CountString())
assert.Equal(t, "", s.GenderString())
assert.Equal(t, "", s.LocationString())
})
t.Run("CountString", func(t *testing.T) {
s := S("file", "test.go").Count(42)
assert.Equal(t, "42", s.CountString())
})
}
func TestIntentMeta(t *testing.T) {
meta := IntentMeta{
Type: "action",
Verb: "delete",
Dangerous: true,
Default: "no",
Supports: []string{"force", "recursive"},
}
assert.Equal(t, "action", meta.Type)
assert.Equal(t, "delete", meta.Verb)
assert.True(t, meta.Dangerous)
assert.Equal(t, "no", meta.Default)
assert.Contains(t, meta.Supports, "force")
assert.Contains(t, meta.Supports, "recursive")
}
func TestComposed(t *testing.T) {
composed := Composed{
Question: "Delete config.yaml?",
Confirm: "Really delete config.yaml?",
Success: "Config.yaml deleted",
Failure: "Failed to delete config.yaml",
Meta: IntentMeta{
Type: "action",
Verb: "delete",
Dangerous: true,
Default: "no",
},
}
assert.Equal(t, "Delete config.yaml?", composed.Question)
assert.Equal(t, "Really delete config.yaml?", composed.Confirm)
assert.Equal(t, "Config.yaml deleted", composed.Success)
assert.Equal(t, "Failed to delete config.yaml", composed.Failure)
assert.True(t, composed.Meta.Dangerous)
}
func TestNewTemplateData(t *testing.T) {
t.Run("from subject", func(t *testing.T) {
s := S("file", "config.yaml").Count(3).Gender("neuter").In("workspace")
data := newTemplateData(s)
assert.Equal(t, "config.yaml", data.Subject)
assert.Equal(t, "file", data.Noun)
assert.Equal(t, 3, data.Count)
assert.Equal(t, "neuter", data.Gender)
assert.Equal(t, "workspace", data.Location)
assert.Equal(t, "config.yaml", data.Value)
})
t.Run("from nil subject", func(t *testing.T) {
data := newTemplateData(nil)
assert.Equal(t, "", data.Subject)
assert.Equal(t, "", data.Noun)
assert.Equal(t, 1, data.Count)
assert.Equal(t, "", data.Gender)
assert.Equal(t, "", data.Location)
assert.Nil(t, data.Value)
})
t.Run("with formality", func(t *testing.T) {
s := S("user", "Hans").Formal()
data := newTemplateData(s)
assert.Equal(t, FormalityFormal, data.Formality)
assert.True(t, data.IsFormal)
})
t.Run("with plural", func(t *testing.T) {
s := S("file", "*.go").Count(5)
data := newTemplateData(s)
assert.True(t, data.IsPlural)
assert.Equal(t, 5, data.Count)
})
}
func TestSubject_Formality(t *testing.T) {
t.Run("default is neutral", func(t *testing.T) {
s := S("user", "name")
assert.Equal(t, "neutral", s.FormalityString())
assert.False(t, s.IsFormal())
assert.False(t, s.IsInformal())
})
t.Run("Formal()", func(t *testing.T) {
s := S("user", "name").Formal()
assert.Equal(t, "formal", s.FormalityString())
assert.True(t, s.IsFormal())
})
t.Run("Informal()", func(t *testing.T) {
s := S("user", "name").Informal()
assert.Equal(t, "informal", s.FormalityString())
assert.True(t, s.IsInformal())
})
t.Run("Formality() explicit", func(t *testing.T) {
s := S("user", "name").Formality(FormalityFormal)
assert.Equal(t, "formal", s.FormalityString())
})
t.Run("nil safety", func(t *testing.T) {
var s *Subject
assert.Equal(t, "neutral", s.FormalityString())
assert.False(t, s.IsFormal())
assert.False(t, s.IsInformal())
})
}
// --- Grammar composition tests using intent data ---
// composeIntent executes intent templates with a subject for testing.
// This is a test helper that replicates what C() used to do.
func composeIntent(intent Intent, subject *Subject) *Composed {
data := newTemplateData(subject)
return &Composed{
Question: executeIntentTemplate(intent.Question, data),
Confirm: executeIntentTemplate(intent.Confirm, data),
Success: executeIntentTemplate(intent.Success, data),
Failure: executeIntentTemplate(intent.Failure, data),
Meta: intent.Meta,
}
}
// TestGrammarComposition_MatchesIntents verifies that the grammar engine
// can compose the same strings as the intent templates.
// This turns the intents definitions into a comprehensive test suite.
func TestGrammarComposition_MatchesIntents(t *testing.T) {
// Clear locale env vars to ensure British English fallback (en-GB)
t.Setenv("LANG", "")
t.Setenv("LC_ALL", "")
t.Setenv("LC_MESSAGES", "")
// Test subjects for validation
subjects := []struct {
noun string
value string
}{
{"file", "config.yaml"},
{"directory", "src"},
{"repo", "core-php"},
{"branch", "feature/auth"},
{"commit", "abc1234"},
{"changes", "5 files"},
{"package", "laravel/framework"},
}
// Test each core intent's composition
for key, intent := range coreIntents {
t.Run(key, func(t *testing.T) {
for _, subj := range subjects {
subject := S(subj.noun, subj.value)
// Compose using intent templates directly
composed := composeIntent(intent, subject)
// Verify Success output matches ActionResult
if intent.Success != "" && intent.Meta.Verb != "" {
// Standard success pattern: "{{.Subject | title}} verbed"
expectedSuccess := ActionResult(intent.Meta.Verb, subj.value)
// Some intents have non-standard success messages
switch key {
case "core.run":
// "completed" instead of "ran"
expectedSuccess = Title(subj.value) + " completed"
case "core.test":
// "passed" instead of "tested"
expectedSuccess = Title(subj.value) + " passed"
case "core.validate":
// "valid" instead of "validated"
expectedSuccess = Title(subj.value) + " valid"
case "core.check":
// "OK" instead of "checked"
expectedSuccess = Title(subj.value) + " OK"
case "core.continue", "core.proceed":
// No subject in success
continue
case "core.confirm":
// No subject in success
continue
}
assert.Equal(t, expectedSuccess, composed.Success,
"%s: Success mismatch for subject %s", key, subj.value)
}
// Verify Failure output matches ActionFailed
if intent.Failure != "" && intent.Meta.Verb != "" {
// Standard failure pattern: "Failed to verb subject"
expectedFailure := ActionFailed(intent.Meta.Verb, subj.value)
// Some intents have non-standard failure messages
switch key {
case "core.test":
// "failed" instead of "Failed to test"
expectedFailure = Title(subj.value) + " failed"
case "core.validate":
// "invalid" instead of "Failed to validate"
expectedFailure = Title(subj.value) + " invalid"
case "core.check":
// "failed" instead of "Failed to check"
expectedFailure = Title(subj.value) + " failed"
case "core.continue", "core.proceed", "core.confirm":
// Non-standard failures
continue
}
assert.Equal(t, expectedFailure, composed.Failure,
"%s: Failure mismatch for subject %s", key, subj.value)
}
}
})
}
}
// TestActionResult_AllIntentVerbs tests that ActionResult handles
// all verbs used in the core intents.
func TestActionResult_AllIntentVerbs(t *testing.T) {
// Extract all unique verbs from intents
verbs := make(map[string]bool)
for _, intent := range coreIntents {
if intent.Meta.Verb != "" {
verbs[intent.Meta.Verb] = true
}
}
subject := "test item"
for verb := range verbs {
t.Run(verb, func(t *testing.T) {
result := ActionResult(verb, subject)
// Should produce non-empty result
assert.NotEmpty(t, result, "ActionResult(%q, %q) should not be empty", verb, subject)
// Should start with title-cased subject
assert.Contains(t, result, Title(subject),
"ActionResult should contain title-cased subject")
// Should contain past tense of verb
past := PastTense(verb)
assert.Contains(t, result, past,
"ActionResult(%q) should contain past tense %q", verb, past)
})
}
}
// TestActionFailed_AllIntentVerbs tests that ActionFailed handles
// all verbs used in the core intents.
func TestActionFailed_AllIntentVerbs(t *testing.T) {
verbs := make(map[string]bool)
for _, intent := range coreIntents {
if intent.Meta.Verb != "" {
verbs[intent.Meta.Verb] = true
}
}
subject := "test item"
for verb := range verbs {
t.Run(verb, func(t *testing.T) {
result := ActionFailed(verb, subject)
// Should produce non-empty result
assert.NotEmpty(t, result, "ActionFailed(%q, %q) should not be empty", verb, subject)
// Should start with "Failed to"
assert.Contains(t, result, "Failed to",
"ActionFailed should contain 'Failed to'")
// Should contain the verb
assert.Contains(t, result, verb,
"ActionFailed should contain the verb")
// Should contain the subject
assert.Contains(t, result, subject,
"ActionFailed should contain the subject")
})
}
}
// TestProgress_AllIntentVerbs tests that Progress handles
// all verbs used in the core intents.
func TestProgress_AllIntentVerbs(t *testing.T) {
verbs := make(map[string]bool)
for _, intent := range coreIntents {
if intent.Meta.Verb != "" {
verbs[intent.Meta.Verb] = true
}
}
for verb := range verbs {
t.Run(verb, func(t *testing.T) {
result := Progress(verb)
// Should produce non-empty result
assert.NotEmpty(t, result, "Progress(%q) should not be empty", verb)
// Should end with "..."
assert.Contains(t, result, "...",
"Progress should contain '...'")
// Should contain gerund form
gerund := Gerund(verb)
assert.Contains(t, result, Title(gerund),
"Progress(%q) should contain gerund %q", verb, gerund)
})
}
}
// TestPastTense_AllIntentVerbs ensures PastTense works for all intent verbs.
func TestPastTense_AllIntentVerbs(t *testing.T) {
// Clear locale env vars to ensure British English fallback (en-GB)
t.Setenv("LANG", "")
t.Setenv("LC_ALL", "")
t.Setenv("LC_MESSAGES", "")
expected := map[string]string{
// Destructive
"delete": "deleted",
"remove": "removed",
"discard": "discarded",
"reset": "reset",
"overwrite": "overwritten",
// Creation
"create": "created",
"add": "added",
"clone": "cloned",
"copy": "copied",
// Modification
"save": "saved",
"update": "updated",
"rename": "renamed",
"move": "moved",
// Git
"commit": "committed",
"push": "pushed",
"pull": "pulled",
"merge": "merged",
"rebase": "rebased",
// Network
"install": "installed",
"download": "downloaded",
"upload": "uploaded",
"publish": "published",
"deploy": "deployed",
// Process
"start": "started",
"stop": "stopped",
"restart": "restarted",
"run": "ran",
"build": "built",
"test": "tested",
// Info - these are regular verbs ending in consonant, -ed suffix
"continue": "continued",
"proceed": "proceeded",
"confirm": "confirmed",
// Additional
"sync": "synced",
"boot": "booted",
"format": "formatted",
"analyse": "analysed",
"link": "linked",
"unlink": "unlinked",
"fetch": "fetched",
"generate": "generated",
"validate": "validated",
"check": "checked",
"scan": "scanned",
}
for verb, want := range expected {
t.Run(verb, func(t *testing.T) {
got := PastTense(verb)
assert.Equal(t, want, got, "PastTense(%q)", verb)
})
}
}
// TestGerund_AllIntentVerbs ensures Gerund works for all intent verbs.
func TestGerund_AllIntentVerbs(t *testing.T) {
// Clear locale env vars to ensure British English fallback (en-GB)
t.Setenv("LANG", "")
t.Setenv("LC_ALL", "")
t.Setenv("LC_MESSAGES", "")
expected := map[string]string{
// Destructive
"delete": "deleting",
"remove": "removing",
"discard": "discarding",
"reset": "resetting",
"overwrite": "overwriting",
// Creation
"create": "creating",
"add": "adding",
"clone": "cloning",
"copy": "copying",
// Modification
"save": "saving",
"update": "updating",
"rename": "renaming",
"move": "moving",
// Git
"commit": "committing",
"push": "pushing",
"pull": "pulling",
"merge": "merging",
"rebase": "rebasing",
// Network
"install": "installing",
"download": "downloading",
"upload": "uploading",
"publish": "publishing",
"deploy": "deploying",
// Process
"start": "starting",
"stop": "stopping",
"restart": "restarting",
"run": "running",
"build": "building",
"test": "testing",
// Info
"continue": "continuing",
"proceed": "proceeding",
"confirm": "confirming",
// Additional
"sync": "syncing",
"boot": "booting",
"format": "formatting",
"analyse": "analysing",
"link": "linking",
"unlink": "unlinking",
"fetch": "fetching",
"generate": "generating",
"validate": "validating",
"check": "checking",
"scan": "scanning",
}
for verb, want := range expected {
t.Run(verb, func(t *testing.T) {
got := Gerund(verb)
assert.Equal(t, want, got, "Gerund(%q)", verb)
})
}
}
// TestQuestionFormat verifies that standard question format
// can be composed from verb and subject.
func TestQuestionFormat(t *testing.T) {
tests := []struct {
verb string
subject string
expected string
}{
{"delete", "config.yaml", "Delete config.yaml?"},
{"create", "src", "Create src?"},
{"commit", "changes", "Commit changes?"},
{"push", "5 commits", "Push 5 commits?"},
{"install", "package", "Install package?"},
}
for _, tt := range tests {
t.Run(tt.verb, func(t *testing.T) {
// Standard question format: "Verb subject?"
result := Title(tt.verb) + " " + tt.subject + "?"
assert.Equal(t, tt.expected, result)
})
}
}
// TestConfirmFormat verifies dangerous action confirm messages.
func TestConfirmFormat(t *testing.T) {
// Dangerous actions have "Really verb subject?" confirm
dangerous := []string{"delete", "remove", "discard", "reset", "overwrite", "merge", "rebase", "publish", "deploy"}
for _, verb := range dangerous {
t.Run(verb, func(t *testing.T) {
subject := "test item"
// Basic confirm format
result := "Really " + verb + " " + subject + "?"
assert.Contains(t, result, "Really",
"Dangerous action confirm should start with 'Really'")
assert.Contains(t, result, verb)
assert.Contains(t, result, subject)
assert.Contains(t, result, "?")
})
}
}
// TestIntentConsistency verifies patterns across all intents.
func TestIntentConsistency(t *testing.T) {
// These intents have non-standard question formats
specialQuestions := map[string]bool{
"core.continue": true, // "Continue?" (no subject)
"core.proceed": true, // "Proceed?" (no subject)
"core.confirm": true, // "Are you sure?" (different format)
}
for key, intent := range coreIntents {
t.Run(key, func(t *testing.T) {
verb := intent.Meta.Verb
// Verify verb is set
assert.NotEmpty(t, verb, "intent should have a verb")
// Verify Question contains the verb (unless special case)
if !specialQuestions[key] {
assert.Contains(t, intent.Question, Title(verb)+" ",
"Question should contain '%s '", Title(verb))
}
// Verify dangerous intents default to "no"
if intent.Meta.Dangerous {
assert.Equal(t, "no", intent.Meta.Default,
"Dangerous intent should default to 'no'")
}
// Verify non-dangerous intents default to "yes"
if !intent.Meta.Dangerous && intent.Meta.Type == "action" {
assert.Equal(t, "yes", intent.Meta.Default,
"Safe action intent should default to 'yes'")
}
})
}
}
// TestComposedVsManual compares template output with manual grammar composition.
func TestComposedVsManual(t *testing.T) {
tests := []struct {
intentKey string
noun string
value string
}{
{"core.delete", "file", "config.yaml"},
{"core.create", "directory", "src"},
{"core.save", "changes", "data"},
{"core.commit", "repo", "core-php"},
{"core.push", "branch", "feature/test"},
{"core.install", "package", "express"},
}
for _, tt := range tests {
t.Run(tt.intentKey, func(t *testing.T) {
subject := S(tt.noun, tt.value)
intent := coreIntents[tt.intentKey]
// Compose using intent templates
composed := composeIntent(intent, subject)
// Manual composition using grammar functions
manualSuccess := ActionResult(intent.Meta.Verb, tt.value)
manualFailure := ActionFailed(intent.Meta.Verb, tt.value)
assert.Equal(t, manualSuccess, composed.Success,
"Template Success should match ActionResult()")
assert.Equal(t, manualFailure, composed.Failure,
"Template Failure should match ActionFailed()")
})
}
}
// TestGrammarCanReplaceIntents demonstrates that the grammar engine
// can compose all the standard output forms without hardcoded templates.
// This proves the i18n system can work with just verb definitions.
func TestGrammarCanReplaceIntents(t *testing.T) {
tests := []struct {
verb string
subject string
// Expected outputs that grammar should produce
wantQuestion string
wantSuccess string
wantFailure string
wantProgress string
}{
{
verb: "delete",
subject: "config.yaml",
wantQuestion: "Delete config.yaml?",
wantSuccess: "Config.Yaml deleted",
wantFailure: "Failed to delete config.yaml",
wantProgress: "Deleting...",
},
{
verb: "create",
subject: "project",
wantQuestion: "Create project?",
wantSuccess: "Project created",
wantFailure: "Failed to create project",
wantProgress: "Creating...",
},
{
verb: "build",
subject: "app",
wantQuestion: "Build app?",
wantSuccess: "App built",
wantFailure: "Failed to build app",
wantProgress: "Building...",
},
{
verb: "run",
subject: "tests",
wantQuestion: "Run tests?",
wantSuccess: "Tests ran",
wantFailure: "Failed to run tests",
wantProgress: "Running...",
},
{
verb: "commit",
subject: "changes",
wantQuestion: "Commit changes?",
wantSuccess: "Changes committed",
wantFailure: "Failed to commit changes",
wantProgress: "Committing...",
},
{
verb: "overwrite",
subject: "file",
wantQuestion: "Overwrite file?",
wantSuccess: "File overwritten",
wantFailure: "Failed to overwrite file",
wantProgress: "Overwriting...",
},
{
verb: "reset",
subject: "state",
wantQuestion: "Reset state?",
wantSuccess: "State reset",
wantFailure: "Failed to reset state",
wantProgress: "Resetting...",
},
}
for _, tt := range tests {
t.Run(tt.verb, func(t *testing.T) {
// Compose using grammar functions only (no templates)
question := Title(tt.verb) + " " + tt.subject + "?"
success := ActionResult(tt.verb, tt.subject)
failure := ActionFailed(tt.verb, tt.subject)
progress := Progress(tt.verb)
assert.Equal(t, tt.wantQuestion, question, "Question")
assert.Equal(t, tt.wantSuccess, success, "Success")
assert.Equal(t, tt.wantFailure, failure, "Failure")
assert.Equal(t, tt.wantProgress, progress, "Progress")
})
}
}
// TestProgressSubjectMatchesExpected tests ProgressSubject for all intent verbs.
func TestProgressSubjectMatchesExpected(t *testing.T) {
tests := []struct {
verb string
subject string
want string
}{
{"delete", "config.yaml", "Deleting config.yaml..."},
{"create", "project", "Creating project..."},
{"build", "app", "Building app..."},
{"install", "package", "Installing package..."},
{"commit", "changes", "Committing changes..."},
{"push", "commits", "Pushing commits..."},
{"pull", "updates", "Pulling updates..."},
{"sync", "files", "Syncing files..."},
{"fetch", "data", "Fetching data..."},
{"check", "status", "Checking status..."},
}
for _, tt := range tests {
t.Run(tt.verb, func(t *testing.T) {
result := ProgressSubject(tt.verb, tt.subject)
assert.Equal(t, tt.want, result)
})
}
}

View file

@ -1,106 +0,0 @@
// 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
}

View file

@ -1,125 +0,0 @@
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"))
}

View file

@ -1,49 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
// Debug mode provides visibility into i18n key resolution for development.
// When enabled, translations are prefixed with their key: [cli.success] Success
//
// Usage:
//
// i18n.SetDebug(true)
// fmt.Println(i18n.T("cli.success")) // "[cli.success] Success"
//
// This helps identify which keys are being used in the UI, making it easier
// to find and update translations during development.
// SetDebug enables or disables debug mode on the default service.
// Does nothing if the service is not initialized.
// In debug mode, translations show their keys: [key] translation
//
// SetDebug(true)
// T("cli.success") // "[cli.success] Success"
func SetDebug(enabled bool) {
if svc := Default(); svc != nil {
svc.SetDebug(enabled)
}
}
// SetDebug enables or disables debug mode.
// In debug mode, translations are prefixed with their key:
//
// [cli.success] Success
// [core.delete] Delete config.yaml?
func (s *Service) SetDebug(enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.debug = enabled
}
// Debug returns whether debug mode is enabled.
func (s *Service) Debug() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.debug
}
// debugFormat formats a translation with its key prefix for debug mode.
// Returns "[key] text" format.
func debugFormat(key, text string) string {
return "[" + key + "] " + text
}

View file

@ -1,532 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
import (
"strings"
"text/template"
"unicode"
)
// GetGrammarData returns the grammar data for the specified language.
// Returns nil if no grammar data is loaded for the language.
func GetGrammarData(lang string) *GrammarData {
grammarCacheMu.RLock()
defer grammarCacheMu.RUnlock()
return grammarCache[lang]
}
// SetGrammarData sets the grammar data for a language.
// Called by the Service when loading locale files.
func SetGrammarData(lang string, data *GrammarData) {
grammarCacheMu.Lock()
defer grammarCacheMu.Unlock()
grammarCache[lang] = data
}
// getVerbForm retrieves a verb form from JSON data.
// Returns empty string if not found, allowing fallback to computed form.
func getVerbForm(lang, verb, form string) string {
data := GetGrammarData(lang)
if data == nil || data.Verbs == nil {
return ""
}
verb = strings.ToLower(verb)
if forms, ok := data.Verbs[verb]; ok {
switch form {
case "past":
return forms.Past
case "gerund":
return forms.Gerund
}
}
return ""
}
// getWord retrieves a base word translation from JSON data.
// Returns empty string if not found, allowing fallback to the key itself.
func getWord(lang, word string) string {
data := GetGrammarData(lang)
if data == nil || data.Words == nil {
return ""
}
return data.Words[strings.ToLower(word)]
}
// getPunct retrieves a punctuation rule for the language.
// Returns the default if not found.
func getPunct(lang, rule, defaultVal string) string {
data := GetGrammarData(lang)
if data == nil {
return defaultVal
}
switch rule {
case "label":
if data.Punct.LabelSuffix != "" {
return data.Punct.LabelSuffix
}
case "progress":
if data.Punct.ProgressSuffix != "" {
return data.Punct.ProgressSuffix
}
}
return defaultVal
}
// getNounForm retrieves a noun form from JSON data.
// Returns empty string if not found, allowing fallback to computed form.
func getNounForm(lang, noun, form string) string {
data := GetGrammarData(lang)
if data == nil || data.Nouns == nil {
return ""
}
noun = strings.ToLower(noun)
if forms, ok := data.Nouns[noun]; ok {
switch form {
case "one":
return forms.One
case "other":
return forms.Other
case "gender":
return forms.Gender
}
}
return ""
}
// currentLangForGrammar returns the current language for grammar lookups.
// Uses the default service's language if available.
func currentLangForGrammar() string {
if svc := Default(); svc != nil {
return svc.Language()
}
return "en-GB"
}
// PastTense returns the past tense of a verb.
// Checks JSON locale data first, then irregular verbs, then applies regular rules.
//
// PastTense("delete") // "deleted"
// PastTense("run") // "ran"
// PastTense("copy") // "copied"
func PastTense(verb string) string {
verb = strings.ToLower(strings.TrimSpace(verb))
if verb == "" {
return ""
}
// Check JSON data first (for current language)
if form := getVerbForm(currentLangForGrammar(), verb, "past"); form != "" {
return form
}
// Check irregular verbs
if forms, ok := irregularVerbs[verb]; ok {
return forms.Past
}
return applyRegularPastTense(verb)
}
// applyRegularPastTense applies regular past tense rules.
func applyRegularPastTense(verb string) string {
// Already ends in -ed (but not -eed, -ied which need different handling)
// Words like "proceed", "succeed", "exceed" end in -eed and are NOT past tense
if strings.HasSuffix(verb, "ed") && len(verb) > 2 {
// Check if it's actually a past tense suffix (consonant + ed)
// vs a word root ending (e.g., "proceed" = proc + eed, "feed" = feed)
thirdFromEnd := verb[len(verb)-3]
if !isVowel(rune(thirdFromEnd)) && thirdFromEnd != 'e' {
// Consonant before -ed means it's likely already past tense
return verb
}
// Words ending in vowel + ed (like "proceed") need -ed added
}
// Ends in -e: just add -d
if strings.HasSuffix(verb, "e") {
return verb + "d"
}
// Ends in consonant + y: change y to ied
if strings.HasSuffix(verb, "y") && len(verb) > 1 {
prev := rune(verb[len(verb)-2])
if !isVowel(prev) {
return verb[:len(verb)-1] + "ied"
}
}
// Ends in single vowel + single consonant (CVC pattern): double consonant
if len(verb) >= 2 && shouldDoubleConsonant(verb) {
return verb + string(verb[len(verb)-1]) + "ed"
}
// Default: add -ed
return verb + "ed"
}
// shouldDoubleConsonant checks if the final consonant should be doubled.
// Applies to CVC (consonant-vowel-consonant) endings in single-syllable words
// and stressed final syllables in multi-syllable words.
func shouldDoubleConsonant(verb string) bool {
if len(verb) < 3 {
return false
}
// Check explicit exceptions
if noDoubleConsonant[verb] {
return false
}
lastChar := rune(verb[len(verb)-1])
secondLast := rune(verb[len(verb)-2])
// Last char must be consonant (not w, x, y)
if isVowel(lastChar) || lastChar == 'w' || lastChar == 'x' || lastChar == 'y' {
return false
}
// Second to last must be a single vowel
if !isVowel(secondLast) {
return false
}
// For short words (3-4 chars), always double if CVC pattern
if len(verb) <= 4 {
thirdLast := rune(verb[len(verb)-3])
return !isVowel(thirdLast)
}
// For longer words, only double if the pattern is strongly CVC
// (stressed final syllable). This is a simplification - in practice,
// most common multi-syllable verbs either:
// 1. End in a doubled consonant already (e.g., "submit" -> "submitted")
// 2. Don't double (e.g., "open" -> "opened")
// We err on the side of not doubling for longer words
return false
}
// Gerund returns the present participle (-ing form) of a verb.
// Checks JSON locale data first, then irregular verbs, then applies regular rules.
//
// Gerund("delete") // "deleting"
// Gerund("run") // "running"
// Gerund("die") // "dying"
func Gerund(verb string) string {
verb = strings.ToLower(strings.TrimSpace(verb))
if verb == "" {
return ""
}
// Check JSON data first (for current language)
if form := getVerbForm(currentLangForGrammar(), verb, "gerund"); form != "" {
return form
}
// Check irregular verbs
if forms, ok := irregularVerbs[verb]; ok {
return forms.Gerund
}
return applyRegularGerund(verb)
}
// applyRegularGerund applies regular gerund rules.
func applyRegularGerund(verb string) string {
// Ends in -ie: change to -ying
if strings.HasSuffix(verb, "ie") {
return verb[:len(verb)-2] + "ying"
}
// Ends in -e (but not -ee, -ye, -oe): drop e, add -ing
if strings.HasSuffix(verb, "e") && len(verb) > 1 {
secondLast := rune(verb[len(verb)-2])
if secondLast != 'e' && secondLast != 'y' && secondLast != 'o' {
return verb[:len(verb)-1] + "ing"
}
}
// CVC pattern: double final consonant
if shouldDoubleConsonant(verb) {
return verb + string(verb[len(verb)-1]) + "ing"
}
// Default: add -ing
return verb + "ing"
}
// Pluralize returns the plural form of a noun based on count.
// If count is 1, returns the singular form unchanged.
//
// Pluralize("file", 1) // "file"
// Pluralize("file", 5) // "files"
// Pluralize("child", 3) // "children"
// Pluralize("box", 2) // "boxes"
func Pluralize(noun string, count int) string {
if count == 1 {
return noun
}
return PluralForm(noun)
}
// PluralForm returns the plural form of a noun.
// Checks JSON locale data first, then irregular nouns, then applies regular rules.
//
// PluralForm("file") // "files"
// PluralForm("child") // "children"
// PluralForm("box") // "boxes"
func PluralForm(noun string) string {
noun = strings.TrimSpace(noun)
if noun == "" {
return ""
}
lower := strings.ToLower(noun)
// Check JSON data first (for current language)
if form := getNounForm(currentLangForGrammar(), lower, "other"); form != "" {
// Preserve original casing if title case
if unicode.IsUpper(rune(noun[0])) && len(form) > 0 {
return strings.ToUpper(string(form[0])) + form[1:]
}
return form
}
// Check irregular nouns
if plural, ok := irregularNouns[lower]; ok {
// Preserve original casing if title case
if unicode.IsUpper(rune(noun[0])) {
return strings.ToUpper(string(plural[0])) + plural[1:]
}
return plural
}
return applyRegularPlural(noun)
}
// applyRegularPlural applies regular plural rules.
func applyRegularPlural(noun string) string {
lower := strings.ToLower(noun)
// Words ending in -s, -ss, -sh, -ch, -x, -z: add -es
if strings.HasSuffix(lower, "s") ||
strings.HasSuffix(lower, "ss") ||
strings.HasSuffix(lower, "sh") ||
strings.HasSuffix(lower, "ch") ||
strings.HasSuffix(lower, "x") ||
strings.HasSuffix(lower, "z") {
return noun + "es"
}
// Words ending in consonant + y: change y to ies
if strings.HasSuffix(lower, "y") && len(noun) > 1 {
prev := rune(lower[len(lower)-2])
if !isVowel(prev) {
return noun[:len(noun)-1] + "ies"
}
}
// Words ending in -f or -fe: change to -ves (some exceptions already in irregulars)
if strings.HasSuffix(lower, "f") {
return noun[:len(noun)-1] + "ves"
}
if strings.HasSuffix(lower, "fe") {
return noun[:len(noun)-2] + "ves"
}
// Words ending in -o preceded by consonant: add -es
if strings.HasSuffix(lower, "o") && len(noun) > 1 {
prev := rune(lower[len(lower)-2])
if !isVowel(prev) {
// Many exceptions (photos, pianos) - but common tech terms add -es
if lower == "hero" || lower == "potato" || lower == "tomato" || lower == "echo" || lower == "veto" {
return noun + "es"
}
}
}
// Default: add -s
return noun + "s"
}
// Article returns the appropriate indefinite article ("a" or "an") for a word.
//
// Article("file") // "a"
// Article("error") // "an"
// Article("user") // "a" (sounds like "yoo-zer")
// Article("hour") // "an" (silent h)
func Article(word string) string {
if word == "" {
return ""
}
lower := strings.ToLower(strings.TrimSpace(word))
// Check for consonant sounds (words starting with vowels but sounding like consonants)
for key := range consonantSounds {
if strings.HasPrefix(lower, key) {
return "a"
}
}
// Check for vowel sounds (words starting with consonants but sounding like vowels)
for key := range vowelSounds {
if strings.HasPrefix(lower, key) {
return "an"
}
}
// Check first letter
if len(lower) > 0 && isVowel(rune(lower[0])) {
return "an"
}
return "a"
}
// isVowel returns true if the rune is a vowel (a, e, i, o, u).
func isVowel(r rune) bool {
switch unicode.ToLower(r) {
case 'a', 'e', 'i', 'o', 'u':
return true
}
return false
}
// Title capitalizes the first letter of each word.
// Uses unicode-aware casing for proper internationalization.
// Word boundaries are defined as any non-letter character (matching strings.Title behavior).
func Title(s string) string {
var b strings.Builder
b.Grow(len(s))
prev := ' ' // Treat start of string as word boundary
for _, r := range s {
if !unicode.IsLetter(prev) && unicode.IsLetter(r) {
b.WriteRune(unicode.ToUpper(r))
} else {
b.WriteRune(r)
}
prev = r
}
return b.String()
}
// Quote wraps a string in double quotes.
func Quote(s string) string {
return `"` + s + `"`
}
// TemplateFuncs returns the template.FuncMap with all grammar functions.
// Use this to add grammar helpers to your templates.
//
// tmpl := template.New("").Funcs(i18n.TemplateFuncs())
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"title": Title,
"lower": strings.ToLower,
"upper": strings.ToUpper,
"past": PastTense,
"gerund": Gerund,
"plural": Pluralize,
"pluralForm": PluralForm,
"article": Article,
"quote": Quote,
}
}
// Progress returns a progress message for a verb.
// Generates "Verbing..." form using language-specific punctuation.
//
// Progress("build") // "Building..."
// Progress("check") // "Checking..."
// Progress("fetch") // "Fetching..."
func Progress(verb string) string {
lang := currentLangForGrammar()
// Try translated word first
word := getWord(lang, verb)
if word == "" {
word = verb
}
g := Gerund(word)
if g == "" {
return ""
}
suffix := getPunct(lang, "progress", "...")
return Title(g) + suffix
}
// ProgressSubject returns a progress message with a subject.
// Generates "Verbing subject..." form using language-specific punctuation.
//
// ProgressSubject("build", "project") // "Building project..."
// ProgressSubject("check", "config.yaml") // "Checking config.yaml..."
func ProgressSubject(verb, subject string) string {
lang := currentLangForGrammar()
// Try translated word first
word := getWord(lang, verb)
if word == "" {
word = verb
}
g := Gerund(word)
if g == "" {
return ""
}
suffix := getPunct(lang, "progress", "...")
return Title(g) + " " + subject + suffix
}
// ActionResult returns a result message for a completed action.
// Generates "Subject verbed" form.
//
// ActionResult("delete", "file") // "File deleted"
// ActionResult("commit", "changes") // "Changes committed"
func ActionResult(verb, subject string) string {
p := PastTense(verb)
if p == "" || subject == "" {
return ""
}
return Title(subject) + " " + p
}
// ActionFailed returns a failure message for an action.
// Generates "Failed to verb subject" form.
//
// ActionFailed("delete", "file") // "Failed to delete file"
// ActionFailed("push", "commits") // "Failed to push commits"
func ActionFailed(verb, subject string) string {
if verb == "" {
return ""
}
if subject == "" {
return "Failed to " + verb
}
return "Failed to " + verb + " " + subject
}
// Label returns a label with a colon suffix.
// Generates "Word:" form using language-specific punctuation.
// French uses " :" (space before colon), English uses ":".
//
// Label("status") // EN: "Status:" FR: "Statut :"
// Label("version") // EN: "Version:" FR: "Version :"
func Label(word string) string {
if word == "" {
return ""
}
lang := currentLangForGrammar()
// Try translated word first
translated := getWord(lang, word)
if translated == "" {
translated = word
}
suffix := getPunct(lang, "label", ":")
return Title(translated) + suffix
}

View file

@ -1,303 +0,0 @@
package i18n
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPastTense(t *testing.T) {
tests := []struct {
verb string
expected string
}{
// Irregular verbs
{"be", "was"},
{"have", "had"},
{"do", "did"},
{"go", "went"},
{"make", "made"},
{"get", "got"},
{"run", "ran"},
{"write", "wrote"},
{"build", "built"},
{"find", "found"},
{"keep", "kept"},
{"think", "thought"},
// Regular verbs - ends in -e
{"delete", "deleted"},
{"save", "saved"},
{"create", "created"},
{"update", "updated"},
{"remove", "removed"},
// Regular verbs - consonant + y -> ied
{"copy", "copied"},
{"carry", "carried"},
{"try", "tried"},
// Regular verbs - vowel + y -> yed
{"play", "played"},
{"stay", "stayed"},
{"enjoy", "enjoyed"},
// Regular verbs - CVC doubling
{"stop", "stopped"},
{"drop", "dropped"},
{"plan", "planned"},
// Regular verbs - no doubling
{"install", "installed"},
{"open", "opened"},
{"start", "started"},
// Edge cases
{"", ""},
{" delete ", "deleted"},
}
for _, tt := range tests {
t.Run(tt.verb, func(t *testing.T) {
result := PastTense(tt.verb)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGerund(t *testing.T) {
tests := []struct {
verb string
expected string
}{
// Irregular verbs
{"be", "being"},
{"have", "having"},
{"run", "running"},
{"write", "writing"},
// Regular verbs - drop -e
{"delete", "deleting"},
{"save", "saving"},
{"create", "creating"},
{"update", "updating"},
// Regular verbs - ie -> ying
{"die", "dying"},
{"lie", "lying"},
{"tie", "tying"},
// Regular verbs - CVC doubling
{"stop", "stopping"},
{"run", "running"},
{"plan", "planning"},
// Regular verbs - no doubling
{"install", "installing"},
{"open", "opening"},
{"start", "starting"},
{"play", "playing"},
// Edge cases
{"", ""},
}
for _, tt := range tests {
t.Run(tt.verb, func(t *testing.T) {
result := Gerund(tt.verb)
assert.Equal(t, tt.expected, result)
})
}
}
func TestPluralize(t *testing.T) {
tests := []struct {
noun string
count int
expected string
}{
// Singular (count = 1)
{"file", 1, "file"},
{"repo", 1, "repo"},
// Regular plurals
{"file", 2, "files"},
{"repo", 5, "repos"},
{"user", 0, "users"},
// -s, -ss, -sh, -ch, -x, -z -> -es
{"bus", 2, "buses"},
{"class", 3, "classes"},
{"bush", 2, "bushes"},
{"match", 2, "matches"},
{"box", 2, "boxes"},
// consonant + y -> -ies
{"city", 2, "cities"},
{"repository", 3, "repositories"},
{"copy", 2, "copies"},
// vowel + y -> -ys
{"key", 2, "keys"},
{"day", 2, "days"},
{"toy", 2, "toys"},
// Irregular nouns
{"child", 2, "children"},
{"person", 3, "people"},
{"man", 2, "men"},
{"woman", 2, "women"},
{"foot", 2, "feet"},
{"tooth", 2, "teeth"},
{"mouse", 2, "mice"},
{"index", 2, "indices"},
// Unchanged plurals
{"fish", 2, "fish"},
{"sheep", 2, "sheep"},
{"deer", 2, "deer"},
{"species", 2, "species"},
}
for _, tt := range tests {
t.Run(tt.noun, func(t *testing.T) {
result := Pluralize(tt.noun, tt.count)
assert.Equal(t, tt.expected, result)
})
}
}
func TestPluralForm(t *testing.T) {
tests := []struct {
noun string
expected string
}{
// Regular
{"file", "files"},
{"repo", "repos"},
// -es endings
{"box", "boxes"},
{"class", "classes"},
{"bush", "bushes"},
{"match", "matches"},
// -ies endings
{"city", "cities"},
{"copy", "copies"},
// Irregular
{"child", "children"},
{"person", "people"},
// Title case preservation
{"Child", "Children"},
{"Person", "People"},
// Empty
{"", ""},
}
for _, tt := range tests {
t.Run(tt.noun, func(t *testing.T) {
result := PluralForm(tt.noun)
assert.Equal(t, tt.expected, result)
})
}
}
func TestArticle(t *testing.T) {
tests := []struct {
word string
expected string
}{
// Regular vowels -> "an"
{"error", "an"},
{"apple", "an"},
{"issue", "an"},
{"update", "an"},
{"item", "an"},
{"object", "an"},
// Regular consonants -> "a"
{"file", "a"},
{"repo", "a"},
{"commit", "a"},
{"branch", "a"},
{"test", "a"},
// Consonant sounds despite vowel start -> "a"
{"user", "a"},
{"union", "a"},
{"unique", "a"},
{"unit", "a"},
{"universe", "a"},
{"one", "a"},
{"once", "a"},
{"euro", "a"},
// Vowel sounds despite consonant start -> "an"
{"hour", "an"},
{"honest", "an"},
{"honour", "an"},
{"heir", "an"},
// Edge cases
{"", ""},
{" error ", "an"},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
result := Article(tt.word)
assert.Equal(t, tt.expected, result)
})
}
}
func TestTitle(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"hello world", "Hello World"},
{"file deleted", "File Deleted"},
{"ALREADY CAPS", "ALREADY CAPS"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := Title(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestQuote(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"file.txt", `"file.txt"`},
{"", `""`},
{"hello world", `"hello world"`},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := Quote(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestTemplateFuncs(t *testing.T) {
funcs := TemplateFuncs()
// Check all expected functions are present
expectedFuncs := []string{"title", "lower", "upper", "past", "gerund", "plural", "pluralForm", "article", "quote"}
for _, name := range expectedFuncs {
assert.Contains(t, funcs, name, "TemplateFuncs should contain %s", name)
}
}

View file

@ -1,178 +0,0 @@
// 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{}
// Match returns true for keys starting with "i18n.label.".
func (h LabelHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.label.")
}
// Handle transforms label keys into formatted labels with colons.
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{}
// Match returns true for keys starting with "i18n.progress.".
func (h ProgressHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.progress.")
}
// Handle transforms progress keys into gerund phrases like "Building...".
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{}
// Match returns true for keys starting with "i18n.count.".
func (h CountHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.count.")
}
// Handle transforms count keys into pluralized phrases like "5 files".
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{}
// Match returns true for keys starting with "i18n.done.".
func (h DoneHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.done.")
}
// Handle transforms done keys into past-tense completion messages.
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{}
// Match returns true for keys starting with "i18n.fail.".
func (h FailHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.fail.")
}
// Handle transforms fail keys into failure messages like "Failed to delete".
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{}
// Match returns true for keys starting with "i18n.numeric.".
func (h NumericHandler) Match(key string) bool {
return strings.HasPrefix(key, "i18n.numeric.")
}
// Handle transforms numeric keys into locale-formatted numbers.
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{}
)

View file

@ -1,173 +0,0 @@
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)
})
}

View file

@ -1,96 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
import (
"io/fs"
"runtime"
"sync"
"sync/atomic"
)
var missingKeyHandler atomic.Value // stores MissingKeyHandler
// localeRegistration holds a filesystem and directory for locale loading.
type localeRegistration struct {
fsys fs.FS
dir string
}
var (
registeredLocales []localeRegistration
registeredLocalesMu sync.Mutex
localesLoaded bool
)
// RegisterLocales registers a filesystem containing locale files to be loaded.
// Call this in your package's init() to register translations.
// Locales are loaded when the i18n service initialises.
//
// //go:embed locales/*.json
// var localeFS embed.FS
//
// func init() {
// i18n.RegisterLocales(localeFS, "locales")
// }
func RegisterLocales(fsys fs.FS, dir string) {
registeredLocalesMu.Lock()
defer registeredLocalesMu.Unlock()
registeredLocales = append(registeredLocales, localeRegistration{fsys: fsys, dir: dir})
// If locales already loaded (service already running), load immediately
if localesLoaded {
if svc := Default(); svc != nil {
_ = svc.LoadFS(fsys, dir)
}
}
}
// loadRegisteredLocales loads all registered locale filesystems into the service.
// Called by the service during initialisation.
func loadRegisteredLocales(svc *Service) {
registeredLocalesMu.Lock()
defer registeredLocalesMu.Unlock()
for _, reg := range registeredLocales {
_ = svc.LoadFS(reg.fsys, reg.dir)
}
localesLoaded = true
}
// OnMissingKey registers a handler for missing translation keys.
// Called when T() can't find a key in ModeCollect.
// Thread-safe: can be called concurrently with translations.
//
// i18n.SetMode(i18n.ModeCollect)
// i18n.OnMissingKey(func(m i18n.MissingKey) {
// log.Printf("MISSING: %s at %s:%d", m.Key, m.CallerFile, m.CallerLine)
// })
func OnMissingKey(h MissingKeyHandler) {
missingKeyHandler.Store(h)
}
// dispatchMissingKey creates and dispatches a MissingKey event.
// Called internally when a key is missing in ModeCollect.
func dispatchMissingKey(key string, args map[string]any) {
v := missingKeyHandler.Load()
if v == nil {
return
}
h, ok := v.(MissingKeyHandler)
if !ok || h == nil {
return
}
_, file, line, ok := runtime.Caller(2) // Skip dispatchMissingKey and handleMissingKey
if !ok {
file = "unknown"
line = 0
}
h(MissingKey{
Key: key,
Args: args,
CallerFile: file,
CallerLine: line,
})
}

View file

@ -1,192 +0,0 @@
// Package i18n provides internationalization for the CLI.
//
// Locale files use nested JSON for compatibility with translation tools:
//
// {
// "cli": {
// "success": "Operation completed",
// "count": {
// "items": {
// "one": "{{.Count}} item",
// "other": "{{.Count}} items"
// }
// }
// }
// }
//
// Keys are accessed with dot notation: T("cli.success"), T("cli.count.items")
//
// # Getting Started
//
// svc, err := i18n.New()
// fmt.Println(svc.T("cli.success"))
// fmt.Println(svc.T("cli.count.items", map[string]any{"Count": 5}))
package i18n
import (
"bytes"
"errors"
"strings"
"text/template"
)
// --- Global convenience functions ---
// T translates a message using the default service.
// For semantic intents (core.* namespace), pass a Subject as the first argument.
//
// T("cli.success") // Simple translation
// T("core.delete", S("file", "config.yaml")) // Semantic intent
func T(messageID string, args ...any) string {
if svc := Default(); svc != nil {
return svc.T(messageID, args...)
}
return messageID
}
// Raw is the raw translation helper without i18n.* namespace magic.
// Unlike T(), this does NOT handle i18n.* namespace patterns.
// Use this for direct key lookups without auto-composition.
//
// Raw("cli.success") // Direct lookup
// T("i18n.label.status") // Smart: returns "Status:"
func Raw(messageID string, args ...any) string {
if svc := Default(); svc != nil {
return svc.Raw(messageID, args...)
}
return messageID
}
// ErrServiceNotInitialized is returned when the i18n service is not initialized.
var ErrServiceNotInitialized = errors.New("i18n: service not initialized")
// SetLanguage sets the language for the default service.
// Returns ErrServiceNotInitialized if the service has not been initialized,
// or an error if the language tag is invalid or unsupported.
//
// Unlike other Set* functions, this returns an error because it validates
// the language tag against available locales.
func SetLanguage(lang string) error {
svc := Default()
if svc == nil {
return ErrServiceNotInitialized
}
return svc.SetLanguage(lang)
}
// CurrentLanguage returns the current language code from the default service.
// Returns "en-GB" (the fallback language) if the service is not initialized.
func CurrentLanguage() string {
if svc := Default(); svc != nil {
return svc.Language()
}
return "en-GB"
}
// SetMode sets the translation mode for the default service.
// Does nothing if the service is not initialized.
func SetMode(m Mode) {
if svc := Default(); svc != nil {
svc.SetMode(m)
}
}
// CurrentMode returns the current translation mode from the default service.
func CurrentMode() Mode {
if svc := Default(); svc != nil {
return svc.Mode()
}
return ModeNormal
}
// N formats a number using the i18n.numeric.* namespace.
// Wrapper for T("i18n.numeric.{format}", value).
//
// N("number", 1234567) // T("i18n.numeric.number", 1234567)
// N("percent", 0.85) // T("i18n.numeric.percent", 0.85)
// N("bytes", 1536000) // T("i18n.numeric.bytes", 1536000)
// N("ordinal", 1) // T("i18n.numeric.ordinal", 1)
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.
// Templates are cached for performance - repeated calls with the same template
// string will reuse the compiled template.
func executeIntentTemplate(tmplStr string, data templateData) string {
if tmplStr == "" {
return ""
}
// Check cache first
if cached, ok := templateCache.Load(tmplStr); ok {
var buf bytes.Buffer
if err := cached.(*template.Template).Execute(&buf, data); err != nil {
return tmplStr
}
return buf.String()
}
// Parse and cache
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(tmplStr)
if err != nil {
return tmplStr
}
// Store in cache (safe even if another goroutine stored it first)
templateCache.Store(tmplStr, tmpl)
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return tmplStr
}
return buf.String()
}
func applyTemplate(text string, data any) string {
// Quick check for template syntax
if !strings.Contains(text, "{{") {
return text
}
// Check cache first
if cached, ok := templateCache.Load(text); ok {
var buf bytes.Buffer
if err := cached.(*template.Template).Execute(&buf, data); err != nil {
return text
}
return buf.String()
}
// Parse and cache
tmpl, err := template.New("").Parse(text)
if err != nil {
return text
}
templateCache.Store(text, tmpl)
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return text
}
return buf.String()
}

View file

@ -1,582 +0,0 @@
package i18n
import (
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
svc, err := New()
require.NoError(t, err)
require.NotNil(t, svc)
// Should have English available
langs := svc.AvailableLanguages()
assert.Contains(t, langs, "en-GB")
}
func TestTranslate(t *testing.T) {
svc, err := New()
require.NoError(t, err)
// Basic translation
result := svc.T("cmd.dev.short")
assert.Equal(t, "Multi-repo development workflow", result)
// Missing key returns the key
result = svc.T("nonexistent.key")
assert.Equal(t, "nonexistent.key", result)
}
func TestTranslateWithArgs(t *testing.T) {
svc, err := New()
require.NoError(t, err)
// Translation with template data
result := svc.T("error.repo_not_found", map[string]string{"Name": "config.yaml"})
assert.Equal(t, "Repository 'config.yaml' not found", result)
result = svc.T("cmd.ai.task_pr.branch_error", map[string]string{"Branch": "main"})
assert.Equal(t, "cannot create PR from main branch; create a feature branch first", result)
}
func TestSetLanguage(t *testing.T) {
// Clear locale env vars to ensure fallback to en-GB
t.Setenv("LANG", "")
t.Setenv("LC_ALL", "")
t.Setenv("LC_MESSAGES", "")
svc, err := New()
require.NoError(t, err)
// Default is en-GB (when no system locale detected)
assert.Equal(t, "en-GB", svc.Language())
// Setting invalid language should error
err = svc.SetLanguage("xx-invalid")
assert.Error(t, err)
// Language should still be en-GB
assert.Equal(t, "en-GB", svc.Language())
}
func TestDefaultService(t *testing.T) {
// Reset default for test
defaultService.Store(nil)
defaultOnce = sync.Once{}
defaultErr = nil
err := Init()
require.NoError(t, err)
svc := Default()
require.NotNil(t, svc)
// Global T function should work
result := T("cmd.dev.short")
assert.Equal(t, "Multi-repo development workflow", result)
}
func TestAddMessages(t *testing.T) {
svc, err := New()
require.NoError(t, err)
// Add custom messages
svc.AddMessages("en-GB", map[string]string{
"custom.greeting": "Hello, {{.Name}}!",
})
result := svc.T("custom.greeting", map[string]string{"Name": "World"})
assert.Equal(t, "Hello, World!", result)
}
func TestAvailableLanguages(t *testing.T) {
svc, err := New()
require.NoError(t, err)
langs := svc.AvailableLanguages()
assert.NotEmpty(t, langs)
assert.Contains(t, langs, "en-GB")
}
func TestDetectLanguage(t *testing.T) {
tests := []struct {
name string
langEnv string
expected string
}{
{
name: "English exact",
langEnv: "en-GB",
expected: "en-GB",
},
{
name: "English with encoding",
langEnv: "en_GB.UTF-8",
expected: "en-GB",
},
{
name: "Empty LANG",
langEnv: "",
expected: "",
},
}
svc, err := New()
require.NoError(t, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LANG", tt.langEnv)
t.Setenv("LC_ALL", "")
t.Setenv("LC_MESSAGES", "")
result := detectLanguage(svc.availableLangs)
assert.Equal(t, tt.expected, result)
})
}
}
func TestPluralization(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
// Singular - uses i18n.count.* magic
result := svc.T("i18n.count.item", 1)
assert.Equal(t, "1 item", result)
// Plural
result = svc.T("i18n.count.item", 5)
assert.Equal(t, "5 items", result)
// Zero uses plural
result = svc.T("i18n.count.item", 0)
assert.Equal(t, "0 items", result)
}
func TestNestedKeys(t *testing.T) {
svc, err := New()
require.NoError(t, err)
// Nested key
result := svc.T("cmd.dev.short")
assert.Equal(t, "Multi-repo development workflow", result)
// Deeper nested key (flat key with dots)
result = svc.T("cmd.dev.push.short")
assert.Equal(t, "Push commits across all repos", result)
}
func TestMessage_ForCategory(t *testing.T) {
t.Run("basic categories", func(t *testing.T) {
msg := Message{
Zero: "no items",
One: "1 item",
Two: "2 items",
Few: "a few items",
Many: "many items",
Other: "some items",
}
assert.Equal(t, "no items", msg.ForCategory(PluralZero))
assert.Equal(t, "1 item", msg.ForCategory(PluralOne))
assert.Equal(t, "2 items", msg.ForCategory(PluralTwo))
assert.Equal(t, "a few items", msg.ForCategory(PluralFew))
assert.Equal(t, "many items", msg.ForCategory(PluralMany))
assert.Equal(t, "some items", msg.ForCategory(PluralOther))
})
t.Run("fallback to other", func(t *testing.T) {
msg := Message{
One: "1 item",
Other: "items",
}
// Categories without explicit values fall back to Other
assert.Equal(t, "items", msg.ForCategory(PluralZero))
assert.Equal(t, "1 item", msg.ForCategory(PluralOne))
assert.Equal(t, "items", msg.ForCategory(PluralFew))
})
t.Run("fallback to one then text", func(t *testing.T) {
msg := Message{
One: "single item",
}
// Falls back to One when Other is empty
assert.Equal(t, "single item", msg.ForCategory(PluralOther))
assert.Equal(t, "single item", msg.ForCategory(PluralMany))
})
}
func TestServiceFormality(t *testing.T) {
svc, err := New()
require.NoError(t, err)
t.Run("default is neutral", func(t *testing.T) {
assert.Equal(t, FormalityNeutral, svc.Formality())
})
t.Run("set formality", func(t *testing.T) {
svc.SetFormality(FormalityFormal)
assert.Equal(t, FormalityFormal, svc.Formality())
svc.SetFormality(FormalityInformal)
assert.Equal(t, FormalityInformal, svc.Formality())
})
}
func TestServiceDirection(t *testing.T) {
svc, err := New()
require.NoError(t, err)
t.Run("English is LTR", func(t *testing.T) {
err := svc.SetLanguage("en-GB")
require.NoError(t, err)
assert.Equal(t, DirLTR, svc.Direction())
assert.False(t, svc.IsRTL())
})
}
func TestServicePluralCategory(t *testing.T) {
svc, err := New()
require.NoError(t, err)
t.Run("English plural rules", func(t *testing.T) {
assert.Equal(t, PluralOne, svc.PluralCategory(1))
assert.Equal(t, PluralOther, svc.PluralCategory(0))
assert.Equal(t, PluralOther, svc.PluralCategory(5))
})
}
func TestDebugMode(t *testing.T) {
t.Run("default is disabled", func(t *testing.T) {
svc, err := New()
require.NoError(t, err)
assert.False(t, svc.Debug())
})
t.Run("T with debug mode", func(t *testing.T) {
svc, err := New()
require.NoError(t, err)
// Without debug
result := svc.T("cmd.dev.short")
assert.Equal(t, "Multi-repo development workflow", result)
// Enable debug
svc.SetDebug(true)
assert.True(t, svc.Debug())
// With debug - shows key prefix
result = svc.T("cmd.dev.short")
assert.Equal(t, "[cmd.dev.short] Multi-repo development workflow", result)
// Disable debug
svc.SetDebug(false)
result = svc.T("cmd.dev.short")
assert.Equal(t, "Multi-repo development workflow", result)
})
t.Run("package-level SetDebug", func(t *testing.T) {
// Reset default
defaultService.Store(nil)
defaultOnce = sync.Once{}
defaultErr = nil
err := Init()
require.NoError(t, err)
// Enable debug via package function
SetDebug(true)
assert.True(t, Default().Debug())
// Translate
result := T("cmd.dev.short")
assert.Equal(t, "[cmd.dev.short] Multi-repo development workflow", result)
// Cleanup
SetDebug(false)
})
}
func TestI18nNamespaceMagic(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
tests := []struct {
name string
key string
args []any
expected string
}{
{"label", "i18n.label.status", nil, "Status:"},
{"label version", "i18n.label.version", nil, "Version:"},
{"progress", "i18n.progress.build", nil, "Building..."},
{"progress check", "i18n.progress.check", nil, "Checking..."},
{"progress with subject", "i18n.progress.check", []any{"config"}, "Checking config..."},
{"count singular", "i18n.count.file", []any{1}, "1 file"},
{"count plural", "i18n.count.file", []any{5}, "5 files"},
{"done", "i18n.done.delete", []any{"file"}, "File deleted"},
{"done build", "i18n.done.build", []any{"project"}, "Project built"},
{"fail", "i18n.fail.delete", []any{"file"}, "Failed to delete file"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := svc.T(tt.key, tt.args...)
assert.Equal(t, tt.expected, result)
})
}
}
func TestRawBypassesI18nNamespace(t *testing.T) {
svc, err := New()
require.NoError(t, err)
// Raw() should return key as-is since i18n.label.status isn't in JSON
result := svc.Raw("i18n.label.status")
assert.Equal(t, "i18n.label.status", result)
// T() should compose it
result = svc.T("i18n.label.status")
assert.Equal(t, "Status:", result)
}
func TestFormalityMessageSelection(t *testing.T) {
svc, err := New()
require.NoError(t, err)
// Add test messages with formality variants
svc.AddMessages("en-GB", map[string]string{
"greeting": "Hello",
"greeting._formal": "Good morning, sir",
"greeting._informal": "Hey there",
"farewell": "Goodbye",
"farewell._formal": "Farewell",
})
t.Run("neutral formality uses base key", func(t *testing.T) {
svc.SetFormality(FormalityNeutral)
assert.Equal(t, "Hello", svc.T("greeting"))
assert.Equal(t, "Goodbye", svc.T("farewell"))
})
t.Run("formal uses ._formal variant", func(t *testing.T) {
svc.SetFormality(FormalityFormal)
assert.Equal(t, "Good morning, sir", svc.T("greeting"))
assert.Equal(t, "Farewell", svc.T("farewell"))
})
t.Run("informal uses ._informal variant", func(t *testing.T) {
svc.SetFormality(FormalityInformal)
assert.Equal(t, "Hey there", svc.T("greeting"))
// No informal variant for farewell, falls back to base
assert.Equal(t, "Goodbye", svc.T("farewell"))
})
t.Run("subject formality overrides service formality", func(t *testing.T) {
svc.SetFormality(FormalityNeutral)
// Subject with formal overrides neutral service
result := svc.T("greeting", S("user", "test").Formal())
assert.Equal(t, "Good morning, sir", result)
// Subject with informal overrides neutral service
result = svc.T("greeting", S("user", "test").Informal())
assert.Equal(t, "Hey there", result)
})
t.Run("subject formality overrides service formal", func(t *testing.T) {
svc.SetFormality(FormalityFormal)
// Subject with informal overrides formal service
result := svc.T("greeting", S("user", "test").Informal())
assert.Equal(t, "Hey there", result)
})
t.Run("context formality overrides service formality", func(t *testing.T) {
svc.SetFormality(FormalityNeutral)
// TranslationContext with formal overrides neutral service
result := svc.T("greeting", C("user greeting").Formal())
assert.Equal(t, "Good morning, sir", result)
// TranslationContext with informal overrides neutral service
result = svc.T("greeting", C("user greeting").Informal())
assert.Equal(t, "Hey there", result)
})
t.Run("context formality overrides service formal", func(t *testing.T) {
svc.SetFormality(FormalityFormal)
// TranslationContext with informal overrides formal service
result := svc.T("greeting", C("user greeting").Informal())
assert.Equal(t, "Hey there", result)
})
}
func TestNewWithOptions(t *testing.T) {
t.Run("WithFallback", func(t *testing.T) {
svc, err := New(WithFallback("de-DE"))
require.NoError(t, err)
assert.Equal(t, "de-DE", svc.fallbackLang)
})
t.Run("WithFormality", func(t *testing.T) {
svc, err := New(WithFormality(FormalityFormal))
require.NoError(t, err)
assert.Equal(t, FormalityFormal, svc.Formality())
})
t.Run("WithMode", func(t *testing.T) {
svc, err := New(WithMode(ModeStrict))
require.NoError(t, err)
assert.Equal(t, ModeStrict, svc.Mode())
})
t.Run("WithDebug", func(t *testing.T) {
svc, err := New(WithDebug(true))
require.NoError(t, err)
assert.True(t, svc.Debug())
})
t.Run("WithHandlers replaces defaults", func(t *testing.T) {
customHandler := LabelHandler{}
svc, err := New(WithHandlers(customHandler))
require.NoError(t, err)
assert.Len(t, svc.Handlers(), 1)
})
t.Run("WithDefaultHandlers adds back defaults", func(t *testing.T) {
svc, err := New(WithHandlers(), WithDefaultHandlers())
require.NoError(t, err)
assert.Len(t, svc.Handlers(), 6) // 6 default handlers
})
t.Run("multiple options", func(t *testing.T) {
svc, err := New(
WithFallback("fr-FR"),
WithFormality(FormalityInformal),
WithMode(ModeCollect),
WithDebug(true),
)
require.NoError(t, err)
assert.Equal(t, "fr-FR", svc.fallbackLang)
assert.Equal(t, FormalityInformal, svc.Formality())
assert.Equal(t, ModeCollect, svc.Mode())
assert.True(t, svc.Debug())
})
}
func TestNewWithLoader(t *testing.T) {
t.Run("uses custom loader", func(t *testing.T) {
loader := NewFSLoader(localeFS, "locales")
svc, err := NewWithLoader(loader)
require.NoError(t, err)
assert.NotNil(t, svc.loader)
assert.Contains(t, svc.AvailableLanguages(), "en-GB")
})
t.Run("with options", func(t *testing.T) {
loader := NewFSLoader(localeFS, "locales")
svc, err := NewWithLoader(loader, WithFallback("de-DE"), WithFormality(FormalityFormal))
require.NoError(t, err)
assert.Equal(t, "de-DE", svc.fallbackLang)
assert.Equal(t, FormalityFormal, svc.Formality())
})
}
func TestNewWithFS(t *testing.T) {
t.Run("with options", func(t *testing.T) {
svc, err := NewWithFS(localeFS, "locales", WithDebug(true))
require.NoError(t, err)
assert.True(t, svc.Debug())
})
}
func TestConcurrentTranslation(t *testing.T) {
svc, err := New()
require.NoError(t, err)
t.Run("concurrent T calls", func(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result := svc.T("cmd.dev.short")
assert.Equal(t, "Multi-repo development workflow", result)
}()
}
wg.Wait()
})
t.Run("concurrent T with args", func(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
result := svc.T("i18n.count.file", n)
if n == 1 {
assert.Equal(t, "1 file", result)
} else {
assert.Contains(t, result, "files")
}
}(i)
}
wg.Wait()
})
t.Run("concurrent read and write", func(t *testing.T) {
var wg sync.WaitGroup
// Readers
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = svc.T("cmd.dev.short")
_ = svc.Language()
_ = svc.Formality()
}()
}
// Writers
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
svc.SetFormality(FormalityNeutral)
svc.SetDebug(false)
}()
}
wg.Wait()
})
}
func TestConcurrentDefault(t *testing.T) {
// Reset for test
defaultService.Store(nil)
defaultOnce = sync.Once{}
defaultErr = nil
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
svc := Default()
assert.NotNil(t, svc)
}()
}
wg.Wait()
}

View file

@ -1,74 +0,0 @@
package i18n
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestServiceImplementsTranslator(t *testing.T) {
// This test verifies at compile time that Service implements Translator
var _ Translator = (*Service)(nil)
// Create a service and use it through the interface
var translator Translator
svc, err := New()
require.NoError(t, err)
translator = svc
// Test interface methods
assert.Equal(t, "Multi-repo development workflow", translator.T("cmd.dev.short"))
assert.NotEmpty(t, translator.Language())
assert.NotNil(t, translator.Direction())
assert.NotNil(t, translator.Formality())
}
// MockTranslator demonstrates how to create a mock for testing
type MockTranslator struct {
translations map[string]string
language string
}
func (m *MockTranslator) T(key string, args ...any) string {
if v, ok := m.translations[key]; ok {
return v
}
return key
}
func (m *MockTranslator) SetLanguage(lang string) error {
m.language = lang
return nil
}
func (m *MockTranslator) Language() string {
return m.language
}
func (m *MockTranslator) SetMode(mode Mode) {}
func (m *MockTranslator) Mode() Mode { return ModeNormal }
func (m *MockTranslator) SetDebug(enabled bool) {}
func (m *MockTranslator) Debug() bool { return false }
func (m *MockTranslator) SetFormality(f Formality) {}
func (m *MockTranslator) Formality() Formality { return FormalityNeutral }
func (m *MockTranslator) Direction() TextDirection { return DirLTR }
func (m *MockTranslator) IsRTL() bool { return false }
func (m *MockTranslator) PluralCategory(n int) PluralCategory {
return PluralOther
}
func (m *MockTranslator) AvailableLanguages() []string { return []string{"en-GB"} }
func TestMockTranslator(t *testing.T) {
var translator Translator = &MockTranslator{
translations: map[string]string{
"test.hello": "Hello from mock",
},
language: "en-GB",
}
assert.Equal(t, "Hello from mock", translator.T("test.hello"))
assert.Equal(t, "test.missing", translator.T("test.missing"))
assert.Equal(t, "en-GB", translator.Language())
}

View file

@ -1,527 +0,0 @@
// Command i18n-validate scans Go source files for i18n key usage and validates
// them against the locale JSON files.
//
// Usage:
//
// go run ./cmd/i18n-validate ./...
// go run ./cmd/i18n-validate ./pkg/cli ./cmd/dev
//
// The validator checks:
// - T("key") calls - validates key exists in locale files
// - C("intent", ...) calls - validates intent exists in registered intents
// - i18n.T("key") and i18n.C("intent", ...) qualified calls
//
// Exit codes:
// - 0: All keys valid
// - 1: Missing keys found
// - 2: Error during validation
package main
import (
"cmp"
"encoding/json"
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"slices"
"strings"
)
// KeyUsage records where a key is used in the source code.
type KeyUsage struct {
Key string
File string
Line int
Function string // "T" or "C"
}
// ValidationResult holds the results of validation.
type ValidationResult struct {
TotalKeys int
ValidKeys int
MissingKeys []KeyUsage
IntentKeys int
MessageKeys int
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: i18n-validate <packages...>")
fmt.Fprintln(os.Stderr, "Example: i18n-validate ./...")
os.Exit(2)
}
// Find the project root (where locales are)
root, err := findProjectRoot()
if err != nil {
fmt.Fprintf(os.Stderr, "Error finding project root: %v\n", err)
os.Exit(2)
}
// Load valid keys from locale files
validKeys, err := loadValidKeys(filepath.Join(root, "pkg/i18n/locales"))
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading locale files: %v\n", err)
os.Exit(2)
}
// Load valid intents
validIntents := loadValidIntents()
// Scan source files
usages, err := scanPackages(os.Args[1:])
if err != nil {
fmt.Fprintf(os.Stderr, "Error scanning packages: %v\n", err)
os.Exit(2)
}
// Validate
result := validate(usages, validKeys, validIntents)
// Report
printReport(result)
if len(result.MissingKeys) > 0 {
os.Exit(1)
}
}
// findProjectRoot finds the project root by looking for go.mod.
func findProjectRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", errors.New("could not find go.mod in any parent directory")
}
dir = parent
}
}
// loadValidKeys loads all valid keys from locale JSON files.
func loadValidKeys(localesDir string) (map[string]bool, error) {
keys := make(map[string]bool)
entries, err := os.ReadDir(localesDir)
if err != nil {
return nil, fmt.Errorf("reading locales dir: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(localesDir, entry.Name()))
if err != nil {
return nil, fmt.Errorf("reading %s: %w", entry.Name(), err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err)
}
extractKeys("", raw, keys)
}
return keys, nil
}
// extractKeys recursively extracts flattened keys from nested JSON.
func extractKeys(prefix string, data map[string]any, out map[string]bool) {
for key, value := range data {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
switch v := value.(type) {
case string:
out[fullKey] = true
case map[string]any:
// Check if it's a plural/verb/noun object (has specific keys)
if isPluralOrGrammarObject(v) {
out[fullKey] = true
} else {
extractKeys(fullKey, v, out)
}
}
}
}
// isPluralOrGrammarObject checks if a map is a leaf object (plural forms, verb forms, etc).
func isPluralOrGrammarObject(m map[string]any) bool {
// CLDR plural keys
_, hasOne := m["one"]
_, hasOther := m["other"]
_, hasZero := m["zero"]
_, hasTwo := m["two"]
_, hasFew := m["few"]
_, hasMany := m["many"]
// Grammar keys
_, hasPast := m["past"]
_, hasGerund := m["gerund"]
_, hasGender := m["gender"]
_, hasBase := m["base"]
// Article keys
_, hasDefault := m["default"]
_, hasVowel := m["vowel"]
if hasOne || hasOther || hasZero || hasTwo || hasFew || hasMany {
return true
}
if hasPast || hasGerund || hasGender || hasBase {
return true
}
if hasDefault || hasVowel {
return true
}
return false
}
// loadValidIntents returns the set of valid intent keys.
func loadValidIntents() map[string]bool {
// Core intents - these match what's defined in intents.go
return map[string]bool{
// Destructive
"core.delete": true,
"core.remove": true,
"core.discard": true,
"core.reset": true,
"core.overwrite": true,
// Creation
"core.create": true,
"core.add": true,
"core.clone": true,
"core.copy": true,
// Modification
"core.save": true,
"core.update": true,
"core.rename": true,
"core.move": true,
// Git
"core.commit": true,
"core.push": true,
"core.pull": true,
"core.merge": true,
"core.rebase": true,
// Network
"core.install": true,
"core.download": true,
"core.upload": true,
"core.publish": true,
"core.deploy": true,
// Process
"core.start": true,
"core.stop": true,
"core.restart": true,
"core.run": true,
"core.build": true,
"core.test": true,
// Information
"core.continue": true,
"core.proceed": true,
"core.confirm": true,
// Additional
"core.sync": true,
"core.boot": true,
"core.format": true,
"core.analyse": true,
"core.link": true,
"core.unlink": true,
"core.fetch": true,
"core.generate": true,
"core.validate": true,
"core.check": true,
"core.scan": true,
}
}
// scanPackages scans Go packages for i18n key usage.
func scanPackages(patterns []string) ([]KeyUsage, error) {
var usages []KeyUsage
for _, pattern := range patterns {
// Expand pattern
matches, err := expandPattern(pattern)
if err != nil {
return nil, fmt.Errorf("expanding pattern %q: %w", pattern, err)
}
for _, dir := range matches {
dirUsages, err := scanDirectory(dir)
if err != nil {
return nil, fmt.Errorf("scanning %s: %w", dir, err)
}
usages = append(usages, dirUsages...)
}
}
return usages, nil
}
// expandPattern expands a Go package pattern to directories.
func expandPattern(pattern string) ([]string, error) {
// Handle ./... or ... pattern
if strings.HasSuffix(pattern, "...") {
base := strings.TrimSuffix(pattern, "...")
base = strings.TrimSuffix(base, "/")
if base == "" || base == "." {
base = "."
}
return findAllGoDirs(base)
}
// Single directory
return []string{pattern}, nil
}
// findAllGoDirs finds all directories containing .go files.
func findAllGoDirs(root string) ([]string, error) {
var dirs []string
seen := make(map[string]bool)
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Continue walking even on error
}
if info == nil {
return nil
}
// Skip vendor, testdata, and hidden directories (but not . itself)
if info.IsDir() {
name := info.Name()
if name == "vendor" || name == "testdata" || (strings.HasPrefix(name, ".") && name != ".") {
return filepath.SkipDir
}
return nil
}
// Check for .go files
if strings.HasSuffix(path, ".go") {
dir := filepath.Dir(path)
if !seen[dir] {
seen[dir] = true
dirs = append(dirs, dir)
}
}
return nil
})
return dirs, err
}
// scanDirectory scans a directory for i18n key usage.
func scanDirectory(dir string) ([]KeyUsage, error) {
var usages []KeyUsage
fset := token.NewFileSet()
// Parse all .go files except those ending exactly in _test.go
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
name := fi.Name()
// Only exclude files that are actual test files (ending in _test.go)
// Files like "go_test_cmd.go" should be included
return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go")
}, 0)
if err != nil {
return nil, err
}
for _, pkg := range pkgs {
for filename, file := range pkg.Files {
fileUsages := scanFile(fset, filename, file)
usages = append(usages, fileUsages...)
}
}
return usages, nil
}
// scanFile scans a single file for i18n key usage.
func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage {
var usages []KeyUsage
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
funcName := getFuncName(call)
if funcName == "" {
return true
}
// Check for T(), C(), i18n.T(), i18n.C()
switch funcName {
case "T", "i18n.T", "_", "i18n._":
if key := extractStringArg(call, 0); key != "" {
pos := fset.Position(call.Pos())
usages = append(usages, KeyUsage{
Key: key,
File: filename,
Line: pos.Line,
Function: "T",
})
}
case "C", "i18n.C":
if key := extractStringArg(call, 0); key != "" {
pos := fset.Position(call.Pos())
usages = append(usages, KeyUsage{
Key: key,
File: filename,
Line: pos.Line,
Function: "C",
})
}
case "I", "i18n.I":
if key := extractStringArg(call, 0); key != "" {
pos := fset.Position(call.Pos())
usages = append(usages, KeyUsage{
Key: key,
File: filename,
Line: pos.Line,
Function: "C", // I() is an intent builder
})
}
}
return true
})
return usages
}
// getFuncName extracts the function name from a call expression.
func getFuncName(call *ast.CallExpr) string {
switch fn := call.Fun.(type) {
case *ast.Ident:
return fn.Name
case *ast.SelectorExpr:
if ident, ok := fn.X.(*ast.Ident); ok {
return ident.Name + "." + fn.Sel.Name
}
}
return ""
}
// extractStringArg extracts a string literal from a call argument.
func extractStringArg(call *ast.CallExpr, index int) string {
if index >= len(call.Args) {
return ""
}
arg := call.Args[index]
// Direct string literal
if lit, ok := arg.(*ast.BasicLit); ok && lit.Kind == token.STRING {
// Remove quotes
s := lit.Value
if len(s) >= 2 {
return s[1 : len(s)-1]
}
}
// Identifier (constant reference) - we skip these as they're type-safe
if _, ok := arg.(*ast.Ident); ok {
return "" // Skip constants like IntentCoreDelete
}
// Selector (like i18n.IntentCoreDelete) - skip these too
if _, ok := arg.(*ast.SelectorExpr); ok {
return ""
}
return ""
}
// validate validates key usages against valid keys and intents.
func validate(usages []KeyUsage, validKeys, validIntents map[string]bool) ValidationResult {
result := ValidationResult{
TotalKeys: len(usages),
}
for _, usage := range usages {
if usage.Function == "C" {
result.IntentKeys++
// Check intent keys
if validIntents[usage.Key] {
result.ValidKeys++
} else {
// Also allow custom intents (non-core.* prefix)
if !strings.HasPrefix(usage.Key, "core.") {
result.ValidKeys++ // Assume custom intents are valid
} else {
result.MissingKeys = append(result.MissingKeys, usage)
}
}
} else {
result.MessageKeys++
// Check message keys
if validKeys[usage.Key] {
result.ValidKeys++
} else if strings.HasPrefix(usage.Key, "core.") {
// core.* keys used with T() are intent keys
if validIntents[usage.Key] {
result.ValidKeys++
} else {
result.MissingKeys = append(result.MissingKeys, usage)
}
} else {
result.MissingKeys = append(result.MissingKeys, usage)
}
}
}
return result
}
// printReport prints the validation report.
func printReport(result ValidationResult) {
fmt.Printf("i18n Validation Report\n")
fmt.Printf("======================\n\n")
fmt.Printf("Total keys scanned: %d\n", result.TotalKeys)
fmt.Printf(" Message keys (T): %d\n", result.MessageKeys)
fmt.Printf(" Intent keys (C): %d\n", result.IntentKeys)
fmt.Printf("Valid keys: %d\n", result.ValidKeys)
fmt.Printf("Missing keys: %d\n", len(result.MissingKeys))
if len(result.MissingKeys) > 0 {
fmt.Printf("\nMissing Keys:\n")
fmt.Printf("-------------\n")
// Sort by file then line
slices.SortFunc(result.MissingKeys, func(a, b KeyUsage) int {
if a.File != b.File {
return cmp.Compare(a.File, b.File)
}
return cmp.Compare(a.Line, b.Line)
})
for _, usage := range result.MissingKeys {
fmt.Printf(" %s:%d: %s(%q)\n", usage.File, usage.Line, usage.Function, usage.Key)
}
fmt.Printf("\nAdd these keys to pkg/i18n/locales/en_GB.json or use constants from pkg/i18n/keys.go\n")
} else {
fmt.Printf("\nAll keys are valid!\n")
}
}

View file

@ -1,192 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
// String returns the string representation of the Formality.
func (f Formality) String() string {
switch f {
case FormalityInformal:
return "informal"
case FormalityFormal:
return "formal"
default:
return "neutral"
}
}
// String returns the string representation of the TextDirection.
func (d TextDirection) String() string {
if d == DirRTL {
return "rtl"
}
return "ltr"
}
// String returns the string representation of the PluralCategory.
func (p PluralCategory) String() string {
switch p {
case PluralZero:
return "zero"
case PluralOne:
return "one"
case PluralTwo:
return "two"
case PluralFew:
return "few"
case PluralMany:
return "many"
default:
return "other"
}
}
// String returns the string representation of the GrammaticalGender.
func (g GrammaticalGender) String() string {
switch g {
case GenderMasculine:
return "masculine"
case GenderFeminine:
return "feminine"
case GenderCommon:
return "common"
default:
return "neuter"
}
}
// IsRTLLanguage returns true if the language code uses right-to-left text.
func IsRTLLanguage(lang string) bool {
// Check exact match first
if rtlLanguages[lang] {
return true
}
// Check base language (e.g., "ar" for "ar-SA")
if len(lang) > 2 {
base := lang[:2]
return rtlLanguages[base]
}
return false
}
// pluralRuleEnglish returns the plural category for English.
// Categories: one (n=1), other.
func pluralRuleEnglish(n int) PluralCategory {
if n == 1 {
return PluralOne
}
return PluralOther
}
// pluralRuleGerman returns the plural category for German.
// Categories: same as English.
func pluralRuleGerman(n int) PluralCategory {
return pluralRuleEnglish(n)
}
// pluralRuleFrench returns the plural category for French.
// Categories: one (n=0,1), other.
func pluralRuleFrench(n int) PluralCategory {
if n == 0 || n == 1 {
return PluralOne
}
return PluralOther
}
// pluralRuleSpanish returns the plural category for Spanish.
// Categories: one (n=1), other.
func pluralRuleSpanish(n int) PluralCategory {
if n == 1 {
return PluralOne
}
return PluralOther
}
// pluralRuleRussian returns the plural category for Russian.
// Categories: one (n%10=1, n%100!=11), few (n%10=2-4, n%100!=12-14), many (others).
func pluralRuleRussian(n int) PluralCategory {
mod10 := n % 10
mod100 := n % 100
if mod10 == 1 && mod100 != 11 {
return PluralOne
}
if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
return PluralFew
}
return PluralMany
}
// pluralRulePolish returns the plural category for Polish.
// Categories: one (n=1), few (n%10=2-4, n%100!=12-14), many (others).
func pluralRulePolish(n int) PluralCategory {
if n == 1 {
return PluralOne
}
mod10 := n % 10
mod100 := n % 100
if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
return PluralFew
}
return PluralMany
}
// pluralRuleArabic returns the plural category for Arabic.
// Categories: zero (n=0), one (n=1), two (n=2), few (n%100=3-10), many (n%100=11-99), other.
func pluralRuleArabic(n int) PluralCategory {
if n == 0 {
return PluralZero
}
if n == 1 {
return PluralOne
}
if n == 2 {
return PluralTwo
}
mod100 := n % 100
if mod100 >= 3 && mod100 <= 10 {
return PluralFew
}
if mod100 >= 11 && mod100 <= 99 {
return PluralMany
}
return PluralOther
}
// pluralRuleChinese returns the plural category for Chinese.
// Categories: other (no plural distinction).
func pluralRuleChinese(n int) PluralCategory {
return PluralOther
}
// pluralRuleJapanese returns the plural category for Japanese.
// Categories: other (no plural distinction).
func pluralRuleJapanese(n int) PluralCategory {
return PluralOther
}
// pluralRuleKorean returns the plural category for Korean.
// Categories: other (no plural distinction).
func pluralRuleKorean(n int) PluralCategory {
return PluralOther
}
// GetPluralRule returns the plural rule for a language code.
// Falls back to English rules if the language is not found.
func GetPluralRule(lang string) PluralRule {
if rule, ok := pluralRules[lang]; ok {
return rule
}
// Try base language
if len(lang) > 2 {
base := lang[:2]
if rule, ok := pluralRules[base]; ok {
return rule
}
}
// Default to English
return pluralRuleEnglish
}
// GetPluralCategory returns the plural category for a count in the given language.
func GetPluralCategory(lang string, n int) PluralCategory {
return GetPluralRule(lang)(n)
}

View file

@ -1,172 +0,0 @@
package i18n
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFormality_String(t *testing.T) {
tests := []struct {
f Formality
expected string
}{
{FormalityNeutral, "neutral"},
{FormalityInformal, "informal"},
{FormalityFormal, "formal"},
{Formality(99), "neutral"}, // Unknown defaults to neutral
}
for _, tt := range tests {
assert.Equal(t, tt.expected, tt.f.String())
}
}
func TestTextDirection_String(t *testing.T) {
assert.Equal(t, "ltr", DirLTR.String())
assert.Equal(t, "rtl", DirRTL.String())
}
func TestPluralCategory_String(t *testing.T) {
tests := []struct {
cat PluralCategory
expected string
}{
{PluralZero, "zero"},
{PluralOne, "one"},
{PluralTwo, "two"},
{PluralFew, "few"},
{PluralMany, "many"},
{PluralOther, "other"},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, tt.cat.String())
}
}
func TestGrammaticalGender_String(t *testing.T) {
tests := []struct {
g GrammaticalGender
expected string
}{
{GenderNeuter, "neuter"},
{GenderMasculine, "masculine"},
{GenderFeminine, "feminine"},
{GenderCommon, "common"},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, tt.g.String())
}
}
func TestIsRTLLanguage(t *testing.T) {
// RTL languages
assert.True(t, IsRTLLanguage("ar"))
assert.True(t, IsRTLLanguage("ar-SA"))
assert.True(t, IsRTLLanguage("he"))
assert.True(t, IsRTLLanguage("he-IL"))
assert.True(t, IsRTLLanguage("fa"))
assert.True(t, IsRTLLanguage("ur"))
// LTR languages
assert.False(t, IsRTLLanguage("en"))
assert.False(t, IsRTLLanguage("en-GB"))
assert.False(t, IsRTLLanguage("de"))
assert.False(t, IsRTLLanguage("fr"))
assert.False(t, IsRTLLanguage("zh"))
}
func TestPluralRuleEnglish(t *testing.T) {
tests := []struct {
n int
expected PluralCategory
}{
{0, PluralOther},
{1, PluralOne},
{2, PluralOther},
{5, PluralOther},
{100, PluralOther},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, pluralRuleEnglish(tt.n), "count=%d", tt.n)
}
}
func TestPluralRuleFrench(t *testing.T) {
// French uses singular for 0 and 1
assert.Equal(t, PluralOne, pluralRuleFrench(0))
assert.Equal(t, PluralOne, pluralRuleFrench(1))
assert.Equal(t, PluralOther, pluralRuleFrench(2))
}
func TestPluralRuleRussian(t *testing.T) {
tests := []struct {
n int
expected PluralCategory
}{
{1, PluralOne},
{2, PluralFew},
{3, PluralFew},
{4, PluralFew},
{5, PluralMany},
{11, PluralMany},
{12, PluralMany},
{21, PluralOne},
{22, PluralFew},
{25, PluralMany},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, pluralRuleRussian(tt.n), "count=%d", tt.n)
}
}
func TestPluralRuleArabic(t *testing.T) {
tests := []struct {
n int
expected PluralCategory
}{
{0, PluralZero},
{1, PluralOne},
{2, PluralTwo},
{3, PluralFew},
{10, PluralFew},
{11, PluralMany},
{99, PluralMany},
{100, PluralOther},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, pluralRuleArabic(tt.n), "count=%d", tt.n)
}
}
func TestPluralRuleChinese(t *testing.T) {
// Chinese has no plural distinction
assert.Equal(t, PluralOther, pluralRuleChinese(0))
assert.Equal(t, PluralOther, pluralRuleChinese(1))
assert.Equal(t, PluralOther, pluralRuleChinese(100))
}
func TestGetPluralRule(t *testing.T) {
// Known languages
rule := GetPluralRule("en-GB")
assert.Equal(t, PluralOne, rule(1))
rule = GetPluralRule("ru")
assert.Equal(t, PluralFew, rule(2))
// Unknown language falls back to English
rule = GetPluralRule("xx-unknown")
assert.Equal(t, PluralOne, rule(1))
assert.Equal(t, PluralOther, rule(2))
}
func TestGetPluralCategory(t *testing.T) {
assert.Equal(t, PluralOne, GetPluralCategory("en", 1))
assert.Equal(t, PluralOther, GetPluralCategory("en", 5))
assert.Equal(t, PluralFew, GetPluralCategory("ru", 3))
}

View file

@ -1,279 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
import (
"encoding/json"
"fmt"
"io/fs"
"path"
"strings"
"sync"
)
// 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
langOnce sync.Once
langErr error // Error from directory scan, if any
}
// 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 := path.Join(l.dir, filename) // Use path.Join for fs.FS (forward slashes)
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.
// Thread-safe: uses sync.Once to ensure the directory is scanned only once.
// Returns nil if the directory scan failed (check LanguagesErr for details).
func (l *FSLoader) Languages() []string {
l.langOnce.Do(func() {
entries, err := fs.ReadDir(l.fsys, l.dir)
if err != nil {
l.langErr = fmt.Errorf("failed to read locale directory %q: %w", l.dir, err)
return
}
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
}
// LanguagesErr returns any error that occurred during Languages() scan.
// Returns nil if the scan succeeded.
func (l *FSLoader) LanguagesErr() error {
l.Languages() // Ensure scan has been attempted
return l.langErr
}
// Ensure FSLoader implements Loader at compile time.
var _ Loader = (*FSLoader)(nil)
// --- 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:
// Check if this is a word in gram.word.*
if grammar != nil && strings.HasPrefix(fullKey, "gram.word.") {
wordKey := strings.TrimPrefix(fullKey, "gram.word.")
grammar.Words[strings.ToLower(wordKey)] = v
continue
}
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 (under gram.noun.* path, or has gender field)
if grammar != nil && (strings.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) {
nounName := key
if strings.HasPrefix(fullKey, "gram.noun.") {
nounName = strings.TrimPrefix(fullKey, "gram.noun.")
}
// Only process if it has one/other structure (noun pluralization)
_, hasOne := v["one"]
_, hasOther := v["other"]
if hasOne && hasOther {
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 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
}

View file

@ -1,589 +0,0 @@
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)
})
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,150 +0,0 @@
{
"gram": {
"verb": {
"be": { "base": "быть", "past": "был", "gerund": "бытие" },
"go": { "base": "идти", "past": "пошёл", "gerund": "переход" },
"do": { "base": "делать", "past": "сделал", "gerund": "выполнение" },
"have": { "base": "иметь", "past": "имел", "gerund": "наличие" },
"make": { "base": "создать", "past": "создал", "gerund": "создание" },
"get": { "base": "получить", "past": "получил", "gerund": "получение" },
"run": { "base": "запустить", "past": "запустил", "gerund": "запуск" },
"write": { "base": "записать", "past": "записал", "gerund": "запись" },
"build": { "base": "собрать", "past": "собрал", "gerund": "сборка" },
"send": { "base": "отправить", "past": "отправил", "gerund": "отправка" },
"find": { "base": "найти", "past": "нашёл", "gerund": "поиск" },
"take": { "base": "взять", "past": "взял", "gerund": "получение" },
"begin": { "base": "начать", "past": "начал", "gerund": "начало" },
"keep": { "base": "сохранить", "past": "сохранил", "gerund": "сохранение" },
"hold": { "base": "удерживать", "past": "удержал", "gerund": "удержание" },
"bring": { "base": "принести", "past": "принёс", "gerund": "доставка" },
"think": { "base": "думать", "past": "думал", "gerund": "обдумывание" },
"choose": { "base": "выбрать", "past": "выбрал", "gerund": "выбор" },
"lose": { "base": "потерять", "past": "потерял", "gerund": "потеря" },
"win": { "base": "победить", "past": "победил", "gerund": "победа" },
"meet": { "base": "встретить", "past": "встретил", "gerund": "встреча" },
"lead": { "base": "вести", "past": "вёл", "gerund": "ведение" },
"leave": { "base": "покинуть", "past": "покинул", "gerund": "выход" },
"spend": { "base": "потратить", "past": "потратил", "gerund": "расход" },
"pay": { "base": "оплатить", "past": "оплатил", "gerund": "оплата" },
"commit": { "base": "коммитить", "past": "закоммитил", "gerund": "коммит" },
"stop": { "base": "остановить", "past": "остановил", "gerund": "остановка" },
"scan": { "base": "сканировать", "past": "просканировал", "gerund": "сканирование" },
"format": { "base": "форматировать", "past": "отформатировал", "gerund": "форматирование" },
"set": { "base": "установить", "past": "установил", "gerund": "установка" },
"check": { "base": "проверить", "past": "проверил", "gerund": "проверка" },
"create": { "base": "создать", "past": "создал", "gerund": "создание" },
"delete": { "base": "удалить", "past": "удалил", "gerund": "удаление" },
"install": { "base": "установить", "past": "установил", "gerund": "установка" },
"update": { "base": "обновить", "past": "обновил", "gerund": "обновление" },
"pull": { "base": "загрузить", "past": "загрузил", "gerund": "загрузка" },
"push": { "base": "отправить", "past": "отправил", "gerund": "отправка" },
"save": { "base": "сохранить", "past": "сохранил", "gerund": "сохранение" },
"analyse": { "base": "анализировать", "past": "проанализировал", "gerund": "анализ" },
"organise": { "base": "организовать", "past": "организовал", "gerund": "организация" },
"test": { "base": "тестировать", "past": "протестировал", "gerund": "тестирование" },
"deploy": { "base": "развернуть", "past": "развернул", "gerund": "развёртывание" },
"clone": { "base": "клонировать", "past": "клонировал", "gerund": "клонирование" },
"compile": { "base": "компилировать", "past": "скомпилировал", "gerund": "компиляция" },
"download": { "base": "скачать", "past": "скачал", "gerund": "загрузка" },
"upload": { "base": "загрузить", "past": "загрузил", "gerund": "выгрузка" }
},
"noun": {
"file": { "one": "файл", "other": "файлы", "gender": "masculine" },
"repo": { "one": "репозиторий", "other": "репозитории", "gender": "masculine" },
"repository": { "one": "репозиторий", "other": "репозитории", "gender": "masculine" },
"commit": { "one": "коммит", "other": "коммиты", "gender": "masculine" },
"branch": { "one": "ветка", "other": "ветки", "gender": "feminine" },
"change": { "one": "изменение", "other": "изменения", "gender": "neuter" },
"item": { "one": "элемент", "other": "элементы", "gender": "masculine" },
"issue": { "one": "проблема", "other": "проблемы", "gender": "feminine" },
"task": { "one": "задача", "other": "задачи", "gender": "feminine" },
"person": { "one": "человек", "other": "люди", "gender": "masculine" },
"child": { "one": "дочерний", "other": "дочерние", "gender": "masculine" },
"package": { "one": "пакет", "other": "пакеты", "gender": "masculine" },
"artifact": { "one": "артефакт", "other": "артефакты", "gender": "masculine" },
"vulnerability": { "one": "уязвимость", "other": "уязвимости", "gender": "feminine" },
"dependency": { "one": "зависимость", "other": "зависимости", "gender": "feminine" },
"directory": { "one": "директория", "other": "директории", "gender": "feminine" },
"category": { "one": "категория", "other": "категории", "gender": "feminine" },
"query": { "one": "запрос", "other": "запросы", "gender": "masculine" },
"check": { "one": "проверка", "other": "проверки", "gender": "feminine" },
"test": { "one": "тест", "other": "тесты", "gender": "masculine" },
"error": { "one": "ошибка", "other": "ошибки", "gender": "feminine" },
"warning": { "one": "предупреждение", "other": "предупреждения", "gender": "neuter" },
"service": { "one": "сервис", "other": "сервисы", "gender": "masculine" },
"config": { "one": "конфигурация", "other": "конфигурации", "gender": "feminine" },
"workflow": { "one": "процесс", "other": "процессы", "gender": "masculine" }
},
"article": {
"indefinite": { "default": "", "vowel": "" },
"definite": ""
},
"word": {
"url": "URL", "id": "ID", "ok": "OK", "ci": "CI", "qa": "QA",
"php": "PHP", "sdk": "SDK", "html": "HTML", "cgo": "CGO", "pid": "PID",
"cpus": "ЦПУ", "ssh": "SSH", "ssl": "SSL", "api": "API", "pr": "PR",
"vite": "Vite", "pnpm": "pnpm",
"app_url": "URL приложения", "blocked_by": "заблокировано",
"claimed_by": "назначено", "related_files": "связанные файлы",
"up_to_date": "актуально", "dry_run": "пробный запуск",
"go_mod": "go.mod", "coverage": "покрытие", "failed": "не пройдено",
"filter": "фильтр", "package": "пакет", "passed": "пройдено",
"skipped": "пропущено", "test": "тест"
},
"punct": {
"label": ":",
"progress": "..."
},
"number": {
"thousands": " ",
"decimal": ",",
"percent": "%s%%"
}
},
"cli.aborted": "Прервано.",
"cli.fail": "ОШИБКА",
"cli.pass": "ОК",
"lang": {
"de": "Немецкий", "en": "Английский", "es": "Испанский",
"fr": "Французский", "ru": "Русский", "zh": "Китайский"
},
"prompt": {
"yes": "д", "no": "н",
"continue": "Продолжить?", "proceed": "Выполнить?",
"confirm": "Вы уверены?", "overwrite": "Перезаписать?",
"discard": "Отменить изменения?"
},
"time": {
"just_now": "только что",
"ago": {
"second": { "one": "{{.Count}} секунду назад", "few": "{{.Count}} секунды назад", "many": "{{.Count}} секунд назад", "other": "{{.Count}} секунд назад" },
"minute": { "one": "{{.Count}} минуту назад", "few": "{{.Count}} минуты назад", "many": "{{.Count}} минут назад", "other": "{{.Count}} минут назад" },
"hour": { "one": "{{.Count}} час назад", "few": "{{.Count}} часа назад", "many": "{{.Count}} часов назад", "other": "{{.Count}} часов назад" },
"day": { "one": "{{.Count}} день назад", "few": "{{.Count}} дня назад", "many": "{{.Count}} дней назад", "other": "{{.Count}} дней назад" },
"week": { "one": "{{.Count}} неделю назад", "few": "{{.Count}} недели назад", "many": "{{.Count}} недель назад", "other": "{{.Count}} недель назад" }
}
},
"error.gh_not_found": "CLI 'gh' не найден. Установите: https://cli.github.com/",
"error.registry_not_found": "Файл repos.yaml не найден",
"error.repo_not_found": "Репозиторий '{{.Name}}' не найден",
"common.label.done": "Готово",
"common.label.error": "Ошибка",
"common.label.info": "Инфо",
"common.label.success": "Успешно",
"common.label.warning": "Внимание",
"common.status.clean": "чисто",
"common.status.dirty": "изменено",
"common.status.running": "Работает",
"common.status.stopped": "Остановлено",
"common.status.up_to_date": "актуально",
"common.result.all_passed": "Все тесты пройдены",
"common.result.no_issues": "Проблем не найдено",
"common.prompt.abort": "Прервано.",
"common.success.completed": "{{.Action}} выполнено успешно"
}

File diff suppressed because it is too large Load diff

View file

@ -1 +0,0 @@
{}

View file

@ -1 +0,0 @@
{}

View file

@ -1 +0,0 @@
{}

View file

@ -1 +0,0 @@
{}

View file

@ -1,148 +0,0 @@
{
"gram": {
"verb": {
"be": { "base": "是", "past": "是", "gerund": "状态" },
"go": { "base": "前往", "past": "前往", "gerund": "前往" },
"do": { "base": "执行", "past": "执行", "gerund": "执行" },
"have": { "base": "拥有", "past": "拥有", "gerund": "拥有" },
"make": { "base": "创建", "past": "创建", "gerund": "创建" },
"get": { "base": "获取", "past": "获取", "gerund": "获取" },
"run": { "base": "运行", "past": "运行", "gerund": "运行" },
"write": { "base": "写入", "past": "写入", "gerund": "写入" },
"build": { "base": "构建", "past": "构建", "gerund": "构建" },
"send": { "base": "发送", "past": "发送", "gerund": "发送" },
"find": { "base": "查找", "past": "查找", "gerund": "查找" },
"take": { "base": "获取", "past": "获取", "gerund": "获取" },
"begin": { "base": "开始", "past": "开始", "gerund": "开始" },
"keep": { "base": "保持", "past": "保持", "gerund": "保持" },
"hold": { "base": "持有", "past": "持有", "gerund": "持有" },
"bring": { "base": "带来", "past": "带来", "gerund": "带来" },
"think": { "base": "思考", "past": "思考", "gerund": "思考" },
"choose": { "base": "选择", "past": "选择", "gerund": "选择" },
"lose": { "base": "丢失", "past": "丢失", "gerund": "丢失" },
"win": { "base": "成功", "past": "成功", "gerund": "成功" },
"meet": { "base": "匹配", "past": "匹配", "gerund": "匹配" },
"lead": { "base": "引导", "past": "引导", "gerund": "引导" },
"leave": { "base": "离开", "past": "离开", "gerund": "离开" },
"commit": { "base": "提交", "past": "提交", "gerund": "提交" },
"stop": { "base": "停止", "past": "停止", "gerund": "停止" },
"scan": { "base": "扫描", "past": "扫描", "gerund": "扫描" },
"format": { "base": "格式化", "past": "格式化", "gerund": "格式化" },
"set": { "base": "设置", "past": "设置", "gerund": "设置" },
"check": { "base": "检查", "past": "检查", "gerund": "检查" },
"create": { "base": "创建", "past": "创建", "gerund": "创建" },
"delete": { "base": "删除", "past": "删除", "gerund": "删除" },
"install": { "base": "安装", "past": "安装", "gerund": "安装" },
"update": { "base": "更新", "past": "更新", "gerund": "更新" },
"pull": { "base": "拉取", "past": "拉取", "gerund": "拉取" },
"push": { "base": "推送", "past": "推送", "gerund": "推送" },
"save": { "base": "保存", "past": "保存", "gerund": "保存" },
"analyse": { "base": "分析", "past": "分析", "gerund": "分析" },
"organise": { "base": "整理", "past": "整理", "gerund": "整理" },
"test": { "base": "测试", "past": "测试", "gerund": "测试" },
"deploy": { "base": "部署", "past": "部署", "gerund": "部署" },
"clone": { "base": "克隆", "past": "克隆", "gerund": "克隆" },
"compile": { "base": "编译", "past": "编译", "gerund": "编译" },
"download": { "base": "下载", "past": "下载", "gerund": "下载" },
"upload": { "base": "上传", "past": "上传", "gerund": "上传" }
},
"noun": {
"file": { "one": "文件", "other": "文件" },
"repo": { "one": "仓库", "other": "仓库" },
"repository": { "one": "仓库", "other": "仓库" },
"commit": { "one": "提交", "other": "提交" },
"branch": { "one": "分支", "other": "分支" },
"change": { "one": "更改", "other": "更改" },
"item": { "one": "项", "other": "项" },
"issue": { "one": "问题", "other": "问题" },
"task": { "one": "任务", "other": "任务" },
"person": { "one": "人", "other": "人" },
"child": { "one": "子项", "other": "子项" },
"package": { "one": "包", "other": "包" },
"artifact": { "one": "构件", "other": "构件" },
"vulnerability": { "one": "漏洞", "other": "漏洞" },
"dependency": { "one": "依赖", "other": "依赖" },
"directory": { "one": "目录", "other": "目录" },
"category": { "one": "分类", "other": "分类" },
"query": { "one": "查询", "other": "查询" },
"check": { "one": "检查", "other": "检查" },
"test": { "one": "测试", "other": "测试" },
"error": { "one": "错误", "other": "错误" },
"warning": { "one": "警告", "other": "警告" },
"service": { "one": "服务", "other": "服务" },
"config": { "one": "配置", "other": "配置" },
"workflow": { "one": "工作流", "other": "工作流" }
},
"article": {
"indefinite": { "default": "", "vowel": "" },
"definite": ""
},
"word": {
"url": "URL", "id": "ID", "ok": "OK", "ci": "CI", "qa": "QA",
"php": "PHP", "sdk": "SDK", "html": "HTML", "cgo": "CGO", "pid": "PID",
"cpus": "CPU", "ssh": "SSH", "ssl": "SSL", "api": "API", "pr": "PR",
"vite": "Vite", "pnpm": "pnpm",
"app_url": "应用 URL", "blocked_by": "被阻塞",
"claimed_by": "已认领", "related_files": "相关文件",
"up_to_date": "已是最新", "dry_run": "模拟运行",
"go_mod": "go.mod", "coverage": "覆盖率", "failed": "失败",
"filter": "过滤器", "package": "包", "passed": "通过",
"skipped": "跳过", "test": "测试"
},
"punct": {
"label": "",
"progress": "..."
},
"number": {
"thousands": ",",
"decimal": ".",
"percent": "%s%%"
}
},
"cli.aborted": "已中止。",
"cli.fail": "失败",
"cli.pass": "通过",
"lang": {
"de": "德语", "en": "英语", "es": "西班牙语",
"fr": "法语", "ru": "俄语", "zh": "中文"
},
"prompt": {
"yes": "是", "no": "否",
"continue": "继续?", "proceed": "执行?",
"confirm": "确定吗?", "overwrite": "覆盖?",
"discard": "放弃更改?"
},
"time": {
"just_now": "刚刚",
"ago": {
"second": { "other": "{{.Count}} 秒前" },
"minute": { "other": "{{.Count}} 分钟前" },
"hour": { "other": "{{.Count}} 小时前" },
"day": { "other": "{{.Count}} 天前" },
"week": { "other": "{{.Count}} 周前" }
}
},
"error.gh_not_found": "未找到 'gh' CLI 工具。请安装https://cli.github.com/",
"error.registry_not_found": "未找到 repos.yaml",
"error.repo_not_found": "未找到仓库 '{{.Name}}'",
"common.label.done": "完成",
"common.label.error": "错误",
"common.label.info": "信息",
"common.label.success": "成功",
"common.label.warning": "警告",
"common.status.clean": "干净",
"common.status.dirty": "已修改",
"common.status.running": "运行中",
"common.status.stopped": "已停止",
"common.status.up_to_date": "已是最新",
"common.result.all_passed": "所有测试通过",
"common.result.no_issues": "未发现问题",
"common.prompt.abort": "已中止。",
"common.success.completed": "{{.Action}} 成功完成"
}

View file

@ -1 +0,0 @@
{}

View file

@ -1,66 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
import (
"os"
"strings"
"golang.org/x/text/language"
)
// SetFormality sets the default formality level on the default service.
// Does nothing if the service is not initialized.
//
// SetFormality(FormalityFormal) // Use formal address (Sie, vous)
func SetFormality(f Formality) {
if svc := Default(); svc != nil {
svc.SetFormality(f)
}
}
// Direction returns the text direction for the current language.
func Direction() TextDirection {
if svc := Default(); svc != nil {
return svc.Direction()
}
return DirLTR
}
// IsRTL returns true if the current language uses right-to-left text.
func IsRTL() bool {
return Direction() == DirRTL
}
func detectLanguage(supported []language.Tag) string {
langEnv := os.Getenv("LANG")
if langEnv == "" {
langEnv = os.Getenv("LC_ALL")
if langEnv == "" {
langEnv = os.Getenv("LC_MESSAGES")
}
}
if langEnv == "" {
return ""
}
// Parse LANG format: en_GB.UTF-8 -> en-GB
baseLang := strings.Split(langEnv, ".")[0]
baseLang = strings.ReplaceAll(baseLang, "_", "-")
parsedLang, err := language.Parse(baseLang)
if err != nil {
return ""
}
if len(supported) == 0 {
return ""
}
matcher := language.NewMatcher(supported)
bestMatch, _, confidence := matcher.Match(parsedLang)
if confidence >= language.Low {
return bestMatch.String()
}
return ""
}

View file

@ -1,161 +0,0 @@
package i18n
import (
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMode_String(t *testing.T) {
tests := []struct {
mode Mode
expected string
}{
{ModeNormal, "normal"},
{ModeStrict, "strict"},
{ModeCollect, "collect"},
{Mode(99), "unknown"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.mode.String())
})
}
}
func TestMissingKey(t *testing.T) {
mk := MissingKey{
Key: "test.missing.key",
Args: map[string]any{"Name": "test"},
CallerFile: "/path/to/file.go",
CallerLine: 42,
}
assert.Equal(t, "test.missing.key", mk.Key)
assert.Equal(t, "test", mk.Args["Name"])
assert.Equal(t, "/path/to/file.go", mk.CallerFile)
assert.Equal(t, 42, mk.CallerLine)
}
func TestOnMissingKey(t *testing.T) {
// Reset handler after test
defer OnMissingKey(nil)
t.Run("sets handler", func(t *testing.T) {
var received MissingKey
OnMissingKey(func(mk MissingKey) {
received = mk
})
dispatchMissingKey("test.key", map[string]any{"foo": "bar"})
assert.Equal(t, "test.key", received.Key)
assert.Equal(t, "bar", received.Args["foo"])
})
t.Run("nil handler", func(t *testing.T) {
OnMissingKey(nil)
// Should not panic
dispatchMissingKey("test.key", nil)
})
t.Run("replaces previous handler", func(t *testing.T) {
called1 := false
called2 := false
OnMissingKey(func(mk MissingKey) {
called1 = true
})
OnMissingKey(func(mk MissingKey) {
called2 = true
})
dispatchMissingKey("test.key", nil)
assert.False(t, called1)
assert.True(t, called2)
})
}
func TestServiceMode(t *testing.T) {
// Reset default service after tests
originalService := defaultService.Load()
defer func() {
defaultService.Store(originalService)
}()
t.Run("default mode is normal", func(t *testing.T) {
defaultService.Store(nil)
defaultOnce = sync.Once{}
defaultErr = nil
svc, err := New()
require.NoError(t, err)
assert.Equal(t, ModeNormal, svc.Mode())
})
t.Run("set mode", func(t *testing.T) {
svc, err := New()
require.NoError(t, err)
svc.SetMode(ModeStrict)
assert.Equal(t, ModeStrict, svc.Mode())
svc.SetMode(ModeCollect)
assert.Equal(t, ModeCollect, svc.Mode())
svc.SetMode(ModeNormal)
assert.Equal(t, ModeNormal, svc.Mode())
})
}
func TestModeNormal_MissingKey(t *testing.T) {
svc, err := New()
require.NoError(t, err)
svc.SetMode(ModeNormal)
// Missing key should return the key itself
result := svc.T("nonexistent.key")
assert.Equal(t, "nonexistent.key", result)
}
func TestModeStrict_MissingKey(t *testing.T) {
svc, err := New()
require.NoError(t, err)
svc.SetMode(ModeStrict)
// Missing key should panic
assert.Panics(t, func() {
svc.T("nonexistent.key")
})
}
func TestModeCollect_MissingKey(t *testing.T) {
// Reset handler after test
defer OnMissingKey(nil)
svc, err := New()
require.NoError(t, err)
svc.SetMode(ModeCollect)
var received MissingKey
OnMissingKey(func(mk MissingKey) {
received = mk
})
// Missing key should dispatch action and return [key]
result := svc.T("nonexistent.key", map[string]any{"arg": "value"})
assert.Equal(t, "[nonexistent.key]", result)
assert.Equal(t, "nonexistent.key", received.Key)
assert.Equal(t, "value", received.Args["arg"])
assert.NotEmpty(t, received.CallerFile)
assert.Greater(t, received.CallerLine, 0)
}

View file

@ -1,223 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
import (
"fmt"
"math"
"strconv"
"strings"
)
// getNumberFormat returns the number format for the current language.
func getNumberFormat() NumberFormat {
lang := currentLangForGrammar()
// Extract base language (en-GB → en)
if idx := strings.IndexAny(lang, "-_"); idx > 0 {
lang = lang[:idx]
}
if fmt, ok := numberFormats[lang]; ok {
return fmt
}
return numberFormats["en"] // fallback
}
// FormatNumber formats an integer with locale-specific thousands separators.
//
// FormatNumber(1234567) // "1,234,567" (en) or "1.234.567" (de)
func FormatNumber(n int64) string {
nf := getNumberFormat()
return formatIntWithSep(n, nf.ThousandsSep)
}
// FormatDecimal formats a float with locale-specific separators.
// Uses up to 2 decimal places, trimming trailing zeros.
//
// FormatDecimal(1234.5) // "1,234.5" (en) or "1.234,5" (de)
// FormatDecimal(1234.00) // "1,234" (en) or "1.234" (de)
func FormatDecimal(f float64) string {
return FormatDecimalN(f, 2)
}
// FormatDecimalN formats a float with N decimal places.
//
// FormatDecimalN(1234.5678, 3) // "1,234.568" (en)
func FormatDecimalN(f float64, decimals int) string {
nf := getNumberFormat()
// Split into integer and fractional parts
intPart := int64(f)
fracPart := math.Abs(f - float64(intPart))
// Format integer part with thousands separator
intStr := formatIntWithSep(intPart, nf.ThousandsSep)
// Format fractional part
if decimals <= 0 || fracPart == 0 {
return intStr
}
// Round and format fractional part
multiplier := math.Pow(10, float64(decimals))
fracInt := int64(math.Round(fracPart * multiplier))
if fracInt == 0 {
return intStr
}
// Format with leading zeros, then trim trailing zeros
fracStr := fmt.Sprintf("%0*d", decimals, fracInt)
fracStr = strings.TrimRight(fracStr, "0")
return intStr + nf.DecimalSep + fracStr
}
// FormatPercent formats a decimal as a percentage.
//
// FormatPercent(0.85) // "85%" (en) or "85 %" (de)
// FormatPercent(0.333) // "33.3%" (en)
// FormatPercent(1.5) // "150%" (en)
func FormatPercent(f float64) string {
nf := getNumberFormat()
pct := f * 100
// Format the number part
var numStr string
if pct == float64(int64(pct)) {
numStr = strconv.FormatInt(int64(pct), 10)
} else {
numStr = FormatDecimalN(pct, 1)
}
return fmt.Sprintf(nf.PercentFmt, numStr)
}
// FormatBytes formats bytes as human-readable size.
//
// FormatBytes(1536) // "1.5 KB"
// FormatBytes(1536000) // "1.5 MB"
// FormatBytes(1536000000) // "1.4 GB"
func FormatBytes(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
TB = GB * 1024
)
nf := getNumberFormat()
var value float64
var unit string
switch {
case bytes >= TB:
value = float64(bytes) / TB
unit = "TB"
case bytes >= GB:
value = float64(bytes) / GB
unit = "GB"
case bytes >= MB:
value = float64(bytes) / MB
unit = "MB"
case bytes >= KB:
value = float64(bytes) / KB
unit = "KB"
default:
return fmt.Sprintf("%d B", bytes)
}
// Format with 1 decimal place, trim .0
intPart := int64(value)
fracPart := value - float64(intPart)
if fracPart < 0.05 {
return fmt.Sprintf("%d %s", intPart, unit)
}
fracDigit := int(math.Round(fracPart * 10))
if fracDigit == 10 {
return fmt.Sprintf("%d %s", intPart+1, unit)
}
return fmt.Sprintf("%d%s%d %s", intPart, nf.DecimalSep, fracDigit, unit)
}
// FormatOrdinal formats a number as an ordinal.
//
// FormatOrdinal(1) // "1st" (en) or "1." (de)
// FormatOrdinal(2) // "2nd" (en) or "2." (de)
// FormatOrdinal(3) // "3rd" (en) or "3." (de)
// FormatOrdinal(11) // "11th" (en) or "11." (de)
func FormatOrdinal(n int) string {
lang := currentLangForGrammar()
// Extract base language
if idx := strings.IndexAny(lang, "-_"); idx > 0 {
lang = lang[:idx]
}
// Most languages just use number + period
switch lang {
case "en":
return formatEnglishOrdinal(n)
default:
return fmt.Sprintf("%d.", n)
}
}
// formatEnglishOrdinal returns English ordinal suffix.
func formatEnglishOrdinal(n int) string {
abs := n
if abs < 0 {
abs = -abs
}
// Special cases for 11, 12, 13
if abs%100 >= 11 && abs%100 <= 13 {
return fmt.Sprintf("%dth", n)
}
switch abs % 10 {
case 1:
return fmt.Sprintf("%dst", n)
case 2:
return fmt.Sprintf("%dnd", n)
case 3:
return fmt.Sprintf("%drd", n)
default:
return fmt.Sprintf("%dth", n)
}
}
// formatIntWithSep formats an integer with thousands separator.
func formatIntWithSep(n int64, sep string) string {
if sep == "" {
return strconv.FormatInt(n, 10)
}
negative := n < 0
if negative {
n = -n
}
str := strconv.FormatInt(n, 10)
if len(str) <= 3 {
if negative {
return "-" + str
}
return str
}
// Insert separators from right to left
var result strings.Builder
for i, c := range str {
if i > 0 && (len(str)-i)%3 == 0 {
result.WriteString(sep)
}
result.WriteRune(c)
}
if negative {
return "-" + result.String()
}
return result.String()
}

View file

@ -1,173 +0,0 @@
package i18n
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFormatNumber(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
tests := []struct {
name string
input int64
expected string
}{
{"zero", 0, "0"},
{"small", 123, "123"},
{"thousands", 1234, "1,234"},
{"millions", 1234567, "1,234,567"},
{"negative", -1234567, "-1,234,567"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatNumber(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestFormatDecimal(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
tests := []struct {
name string
input float64
expected string
}{
{"integer", 1234.0, "1,234"},
{"one decimal", 1234.5, "1,234.5"},
{"two decimals", 1234.56, "1,234.56"},
{"trailing zeros", 1234.50, "1,234.5"},
{"small", 0.5, "0.5"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatDecimal(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestFormatPercent(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
tests := []struct {
name string
input float64
expected string
}{
{"whole", 0.85, "85%"},
{"decimal", 0.333, "33.3%"},
{"over 100", 1.5, "150%"},
{"zero", 0.0, "0%"},
{"one", 1.0, "100%"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatPercent(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestFormatBytes(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
tests := []struct {
name string
input int64
expected string
}{
{"bytes", 500, "500 B"},
{"KB", 1536, "1.5 KB"},
{"MB", 1572864, "1.5 MB"},
{"GB", 1610612736, "1.5 GB"},
{"exact KB", 1024, "1 KB"},
{"exact MB", 1048576, "1 MB"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatBytes(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestFormatOrdinal(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
tests := []struct {
name string
input int
expected string
}{
{"1st", 1, "1st"},
{"2nd", 2, "2nd"},
{"3rd", 3, "3rd"},
{"4th", 4, "4th"},
{"11th", 11, "11th"},
{"12th", 12, "12th"},
{"13th", 13, "13th"},
{"21st", 21, "21st"},
{"22nd", 22, "22nd"},
{"23rd", 23, "23rd"},
{"100th", 100, "100th"},
{"101st", 101, "101st"},
{"111th", 111, "111th"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatOrdinal(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestI18nNumberNamespace(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
t.Run("i18n.numeric.number", func(t *testing.T) {
result := svc.T("i18n.numeric.number", 1234567)
assert.Equal(t, "1,234,567", result)
})
t.Run("i18n.numeric.decimal", func(t *testing.T) {
result := svc.T("i18n.numeric.decimal", 1234.56)
assert.Equal(t, "1,234.56", result)
})
t.Run("i18n.numeric.percent", func(t *testing.T) {
result := svc.T("i18n.numeric.percent", 0.85)
assert.Equal(t, "85%", result)
})
t.Run("i18n.numeric.bytes", func(t *testing.T) {
result := svc.T("i18n.numeric.bytes", 1572864)
assert.Equal(t, "1.5 MB", result)
})
t.Run("i18n.numeric.ordinal", func(t *testing.T) {
result := svc.T("i18n.numeric.ordinal", 3)
assert.Equal(t, "3rd", result)
})
}

View file

@ -1,636 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
import (
"embed"
"encoding/json"
"errors"
"fmt"
"io/fs"
"path"
"strings"
"sync"
"sync/atomic"
"golang.org/x/text/language"
)
// Service provides internationalization and localization.
type Service struct {
loader Loader // Source for loading translations
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
}
// Option configures a Service during construction.
type Option func(*Service)
// WithFallback sets the fallback language for missing translations.
func WithFallback(lang string) Option {
return func(s *Service) {
s.fallbackLang = lang
}
}
// WithFormality sets the default formality level.
func WithFormality(f Formality) Option {
return func(s *Service) {
s.formality = f
}
}
// WithHandlers sets custom handlers (replaces default handlers).
func WithHandlers(handlers ...KeyHandler) Option {
return func(s *Service) {
s.handlers = handlers
}
}
// WithDefaultHandlers adds the default i18n.* namespace handlers.
// Use this after WithHandlers to add defaults back, or to ensure defaults are present.
func WithDefaultHandlers() Option {
return func(s *Service) {
s.handlers = append(s.handlers, DefaultHandlers()...)
}
}
// WithMode sets the translation mode.
func WithMode(m Mode) Option {
return func(s *Service) {
s.mode = m
}
}
// WithDebug enables or disables debug mode.
func WithDebug(enabled bool) Option {
return func(s *Service) {
s.debug = enabled
}
}
// Default is the global i18n service instance.
var (
defaultService atomic.Pointer[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 and default options.
func New(opts ...Option) (*Service, error) {
return NewWithLoader(NewFSLoader(localeFS, "locales"), opts...)
}
// NewWithFS creates a new i18n service loading locales from the given filesystem.
func NewWithFS(fsys fs.FS, dir string, opts ...Option) (*Service, error) {
return NewWithLoader(NewFSLoader(fsys, dir), opts...)
}
// NewWithLoader creates a new i18n service with a custom loader.
// Use this for custom storage backends (database, remote API, etc.).
//
// loader := NewFSLoader(customFS, "translations")
// svc, err := NewWithLoader(loader, WithFallback("de-DE"))
func NewWithLoader(loader Loader, opts ...Option) (*Service, error) {
s := &Service{
loader: loader,
messages: make(map[string]map[string]Message),
fallbackLang: "en-GB",
handlers: DefaultHandlers(),
}
// Apply options
for _, opt := range opts {
opt(s)
}
// Load all available languages
langs := loader.Languages()
if len(langs) == 0 {
return nil, errors.New("no languages available from loader")
}
for _, lang := range langs {
messages, grammar, err := loader.Load(lang)
if err != nil {
return nil, fmt.Errorf("failed to load locale %q: %w", lang, err)
}
s.messages[lang] = messages
if grammar != nil && (len(grammar.Verbs) > 0 || len(grammar.Nouns) > 0 || len(grammar.Words) > 0) {
SetGrammarData(lang, grammar)
}
tag := language.Make(lang)
s.availableLangs = append(s.availableLangs, tag)
}
// Try to detect system language
if detected := detectLanguage(s.availableLangs); detected != "" {
s.currentLang = detected
} else {
s.currentLang = s.fallbackLang
}
return s, nil
}
// Init initializes the default global service.
func Init() error {
defaultOnce.Do(func() {
svc, err := New()
if err == nil {
defaultService.Store(svc)
// Load any locales registered by packages before Init was called
loadRegisteredLocales(svc)
}
defaultErr = err
})
return defaultErr
}
// Default returns the global i18n service, initializing if needed.
// Thread-safe: can be called concurrently.
func Default() *Service {
_ = Init() // sync.Once handles idempotency
return defaultService.Load()
}
// SetDefault sets the global i18n service.
// Thread-safe: can be called concurrently with Default().
// Panics if s is nil.
func SetDefault(s *Service) {
if s == nil {
panic("i18n: SetDefault called with nil service")
}
defaultService.Store(s)
}
// loadJSON parses nested JSON and flattens to dot-notation keys.
// Also extracts grammar data (verbs, nouns, articles) for the language.
// If messages already exist for the language, new messages are merged in.
func (s *Service) loadJSON(lang string, data []byte) error {
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
messages := make(map[string]Message)
grammarData := &GrammarData{
Verbs: make(map[string]VerbForms),
Nouns: make(map[string]NounForms),
Words: make(map[string]string),
}
flattenWithGrammar("", raw, messages, grammarData)
// Merge new messages into existing (or create new map)
if existing, ok := s.messages[lang]; ok {
for key, msg := range messages {
existing[key] = msg
}
} else {
s.messages[lang] = messages
}
// Store grammar data if any was found
if len(grammarData.Verbs) > 0 || len(grammarData.Nouns) > 0 || len(grammarData.Words) > 0 {
SetGrammarData(lang, grammarData)
}
return nil
}
// SetLanguage sets the language for translations.
func (s *Service) SetLanguage(lang string) error {
s.mu.Lock()
defer s.mu.Unlock()
requestedLang, err := language.Parse(lang)
if err != nil {
return fmt.Errorf("invalid language tag %q: %w", lang, err)
}
if len(s.availableLangs) == 0 {
return errors.New("no languages available")
}
matcher := language.NewMatcher(s.availableLangs)
bestMatch, _, confidence := matcher.Match(requestedLang)
if confidence == language.No {
return fmt.Errorf("unsupported language: %q", lang)
}
s.currentLang = bestMatch.String()
return nil
}
// Language returns the current language code.
func (s *Service) Language() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.currentLang
}
// AvailableLanguages returns the list of available language codes.
func (s *Service) AvailableLanguages() []string {
s.mu.RLock()
defer s.mu.RUnlock()
langs := make([]string, len(s.availableLangs))
for i, tag := range s.availableLangs {
langs[i] = tag.String()
}
return langs
}
// SetMode sets the translation mode for missing key handling.
func (s *Service) SetMode(m Mode) {
s.mu.Lock()
defer s.mu.Unlock()
s.mode = m
}
// Mode returns the current translation mode.
func (s *Service) Mode() Mode {
s.mu.RLock()
defer s.mu.RUnlock()
return s.mode
}
// SetFormality sets the default formality level for translations.
// This affects languages that distinguish formal/informal address (Sie/du, vous/tu).
//
// svc.SetFormality(FormalityFormal) // Use formal address
func (s *Service) SetFormality(f Formality) {
s.mu.Lock()
defer s.mu.Unlock()
s.formality = f
}
// Formality returns the current formality level.
func (s *Service) Formality() Formality {
s.mu.RLock()
defer s.mu.RUnlock()
return s.formality
}
// Direction returns the text direction for the current language.
func (s *Service) Direction() TextDirection {
s.mu.RLock()
defer s.mu.RUnlock()
if IsRTLLanguage(s.currentLang) {
return DirRTL
}
return DirLTR
}
// IsRTL returns true if the current language uses right-to-left text direction.
func (s *Service) IsRTL() bool {
return s.Direction() == DirRTL
}
// PluralCategory returns the plural category for a count in the current language.
func (s *Service) PluralCategory(n int) PluralCategory {
s.mu.RLock()
defer s.mu.RUnlock()
return GetPluralCategory(s.currentLang, n)
}
// AddHandler appends a handler to the end of the handler chain.
// Later handlers have lower priority (run if earlier handlers don't match).
//
// Note: Handlers are executed during T() while holding a read lock.
// Handlers should not call back into the same Service instance to avoid
// contention. Grammar functions like PastTense() use currentLangForGrammar()
// which safely calls Default().Language().
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
//
// The i18n.* namespace provides auto-composed grammar shortcuts:
//
// T("i18n.label.status") // → "Status:"
// T("i18n.progress.build") // → "Building..."
// T("i18n.progress.check", "config") // → "Checking config..."
// T("i18n.count.file", 5) // → "5 files"
// T("i18n.done.delete", "file") // → "File deleted"
// T("i18n.fail.delete", "file") // → "Failed to delete file"
//
// For semantic intents, pass a Subject:
//
// T("core.delete", S("file", "config.yaml")) // → "Delete config.yaml?"
//
// 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()
// 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]
}
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, result)
}
return result
}
// resolveWithFallback implements the fallback chain for message resolution.
// Must be called with s.mu.RLock held.
func (s *Service) resolveWithFallback(messageID string, data any) string {
// 1. Try exact key in current language
if text := s.tryResolve(s.currentLang, messageID, data); text != "" {
return text
}
// 2. Try exact key in fallback language
if text := s.tryResolve(s.fallbackLang, messageID, data); text != "" {
return text
}
// 3. Try fallback patterns for intent-like keys
if strings.Contains(messageID, ".") {
parts := strings.Split(messageID, ".")
verb := parts[len(parts)-1]
// Try common.action.{verb}
commonKey := "common.action." + verb
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
return text
}
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
return text
}
// Try common.{verb}
commonKey = "common." + verb
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
return text
}
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
return text
}
}
return ""
}
// tryResolve attempts to resolve a single key in a single language.
// Returns empty string if not found.
// Must be called with s.mu.RLock held.
func (s *Service) tryResolve(lang, key string, data any) string {
// Determine effective formality
formality := s.getEffectiveFormality(data)
// Try formality-specific key first (key._formal or key._informal)
if formality != FormalityNeutral {
formalityKey := key + "._" + formality.String()
if text := s.resolveMessage(lang, formalityKey, data); text != "" {
return text
}
}
// Fall back to base key
return s.resolveMessage(lang, key, data)
}
// resolveMessage resolves a single message key without formality fallback.
// Must be called with s.mu.RLock held.
func (s *Service) resolveMessage(lang, key string, data any) string {
msg, ok := s.getMessage(lang, key)
if !ok {
return ""
}
text := msg.Text
if msg.IsPlural() {
count := getCount(data)
category := GetPluralCategory(lang, count)
text = msg.ForCategory(category)
}
if text == "" {
return ""
}
// Apply template if we have data
if data != nil {
text = applyTemplate(text, data)
}
return text
}
// getEffectiveFormality returns the formality to use for translation.
// Priority: TranslationContext > Subject > map["Formality"] > Service.formality
// Must be called with s.mu.RLock held.
func (s *Service) getEffectiveFormality(data any) Formality {
// Check if data is a TranslationContext with explicit formality
if ctx, ok := data.(*TranslationContext); ok && ctx != nil {
if ctx.Formality != FormalityNeutral {
return ctx.Formality
}
}
// Check if data is a Subject with explicit formality
if subj, ok := data.(*Subject); ok && subj != nil {
if subj.formality != FormalityNeutral {
return subj.formality
}
}
// Check if data is a map with Formality field
if m, ok := data.(map[string]any); ok {
switch f := m["Formality"].(type) {
case Formality:
if f != FormalityNeutral {
return f
}
case string:
// Support string values for convenience
switch strings.ToLower(f) {
case "formal":
return FormalityFormal
case "informal":
return FormalityInformal
}
}
}
// Fall back to service default
return s.formality
}
// handleMissingKey handles a missing translation key based on the current mode.
// Must be called with s.mu.RLock held.
//
// In ModeStrict, this panics - use only in development/CI to catch missing keys.
// In ModeCollect, this dispatches to OnMissingKey handler for logging/collection.
// In ModeNormal (default), this returns the key as-is.
func (s *Service) handleMissingKey(key string, args []any) string {
switch s.mode {
case ModeStrict:
// WARNING: Panics! Use ModeStrict only in development/CI environments.
panic(fmt.Sprintf("i18n: missing translation key %q", key))
case ModeCollect:
// Convert args to map for the action
var argsMap map[string]any
if len(args) > 0 {
if m, ok := args[0].(map[string]any); ok {
argsMap = m
}
}
dispatchMissingKey(key, argsMap)
return "[" + key + "]"
default:
return key
}
}
// Raw is the raw translation helper without i18n.* namespace magic.
// Use T() for smart i18n.* handling, Raw() for direct key lookup.
func (s *Service) Raw(messageID string, args ...any) string {
s.mu.RLock()
defer s.mu.RUnlock()
var data any
if len(args) > 0 {
data = args[0]
}
text := s.resolveWithFallback(messageID, data)
if text == "" {
return s.handleMissingKey(messageID, args)
}
if s.debug {
return debugFormat(messageID, text)
}
return text
}
// getMessage retrieves a message by language and key.
// Returns the message and true if found, or empty Message and false if not.
func (s *Service) getMessage(lang, key string) (Message, bool) {
msgs, ok := s.messages[lang]
if !ok {
return Message{}, false
}
msg, ok := msgs[key]
return msg, ok
}
// AddMessages adds messages for a language at runtime.
func (s *Service) AddMessages(lang string, messages map[string]string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.messages[lang] == nil {
s.messages[lang] = make(map[string]Message)
}
for key, text := range messages {
s.messages[lang][key] = Message{Text: text}
}
}
// LoadFS loads additional locale files from a filesystem.
func (s *Service) LoadFS(fsys fs.FS, dir string) error {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := fs.ReadDir(fsys, dir)
if err != nil {
return fmt.Errorf("failed to read locales directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
filePath := path.Join(dir, entry.Name()) // Use path.Join for fs.FS (forward slashes)
data, err := fs.ReadFile(fsys, filePath)
if err != nil {
return fmt.Errorf("failed to read locale %q: %w", entry.Name(), err)
}
lang := strings.TrimSuffix(entry.Name(), ".json")
lang = strings.ReplaceAll(lang, "_", "-")
if err := s.loadJSON(lang, data); err != nil {
return fmt.Errorf("failed to parse locale %q: %w", entry.Name(), err)
}
// Add to available languages if new
tag := language.Make(lang)
found := false
for _, existing := range s.availableLangs {
if existing == tag {
found = true
break
}
}
if !found {
s.availableLangs = append(s.availableLangs, tag)
}
}
return nil
}

View file

@ -1,55 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
import (
"fmt"
"time"
)
// TimeAgo returns a localized relative time string.
//
// TimeAgo(time.Now().Add(-5 * time.Minute)) // "5 minutes ago"
// TimeAgo(time.Now().Add(-1 * time.Hour)) // "1 hour ago"
func TimeAgo(t time.Time) string {
duration := time.Since(t)
switch {
case duration < time.Minute:
return T("time.just_now")
case duration < time.Hour:
mins := int(duration.Minutes())
return FormatAgo(mins, "minute")
case duration < 24*time.Hour:
hours := int(duration.Hours())
return FormatAgo(hours, "hour")
case duration < 7*24*time.Hour:
days := int(duration.Hours() / 24)
return FormatAgo(days, "day")
default:
weeks := int(duration.Hours() / (24 * 7))
return FormatAgo(weeks, "week")
}
}
// FormatAgo formats "N unit ago" with proper pluralization.
// Uses locale-specific patterns from time.ago.{unit}.
//
// FormatAgo(5, "minute") // "5 minutes ago"
// FormatAgo(1, "hour") // "1 hour ago"
func FormatAgo(count int, unit string) string {
svc := Default()
if svc == nil {
return fmt.Sprintf("%d %ss ago", count, unit)
}
// Try locale-specific pattern: time.ago.{unit}
key := "time.ago." + unit
result := svc.T(key, map[string]any{"Count": count})
// If key was returned as-is (not found), compose fallback
if result == key {
return fmt.Sprintf("%d %s ago", count, Pluralize(unit, count))
}
return result
}

View file

@ -1,85 +0,0 @@
package i18n
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFormatAgo(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
tests := []struct {
name string
count int
unit string
expected string
}{
{"1 second", 1, "second", "1 second ago"},
{"5 seconds", 5, "second", "5 seconds ago"},
{"1 minute", 1, "minute", "1 minute ago"},
{"30 minutes", 30, "minute", "30 minutes ago"},
{"1 hour", 1, "hour", "1 hour ago"},
{"3 hours", 3, "hour", "3 hours ago"},
{"1 day", 1, "day", "1 day ago"},
{"7 days", 7, "day", "7 days ago"},
{"1 week", 1, "week", "1 week ago"},
{"2 weeks", 2, "week", "2 weeks ago"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatAgo(tt.count, tt.unit)
assert.Equal(t, tt.expected, result)
})
}
}
func TestTimeAgo(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
tests := []struct {
name string
ago time.Duration
expected string
}{
{"just now", 30 * time.Second, "just now"},
{"1 minute", 1 * time.Minute, "1 minute ago"},
{"5 minutes", 5 * time.Minute, "5 minutes ago"},
{"1 hour", 1 * time.Hour, "1 hour ago"},
{"3 hours", 3 * time.Hour, "3 hours ago"},
{"1 day", 24 * time.Hour, "1 day ago"},
{"3 days", 3 * 24 * time.Hour, "3 days ago"},
{"1 week", 7 * 24 * time.Hour, "1 week ago"},
{"2 weeks", 14 * 24 * time.Hour, "2 weeks ago"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TimeAgo(time.Now().Add(-tt.ago))
assert.Equal(t, tt.expected, result)
})
}
}
func TestI18nAgoNamespace(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
t.Run("i18n.numeric.ago pattern", func(t *testing.T) {
result := T("i18n.numeric.ago", 5, "minute")
assert.Equal(t, "5 minutes ago", result)
})
t.Run("i18n.numeric.ago singular", func(t *testing.T) {
result := T("i18n.numeric.ago", 1, "hour")
assert.Equal(t, "1 hour ago", result)
})
}

View file

@ -1,122 +0,0 @@
// Package i18n provides internationalization for the CLI.
package i18n
// getCount extracts a Count value from template data.
func getCount(data any) int {
if data == nil {
return 0
}
switch d := data.(type) {
case map[string]any:
if c, ok := d["Count"]; ok {
return toInt(c)
}
case map[string]int:
if c, ok := d["Count"]; ok {
return c
}
}
return 0
}
// toInt converts any numeric type to int.
func toInt(v any) int {
if v == nil {
return 0
}
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case int32:
return int(n)
case int16:
return int(n)
case int8:
return int(n)
case uint:
return int(n)
case uint64:
return int(n)
case uint32:
return int(n)
case uint16:
return int(n)
case uint8:
return int(n)
case float64:
return int(n)
case float32:
return int(n)
}
return 0
}
// toInt64 converts any numeric type to int64.
func toInt64(v any) int64 {
if v == nil {
return 0
}
switch n := v.(type) {
case int:
return int64(n)
case int64:
return n
case int32:
return int64(n)
case int16:
return int64(n)
case int8:
return int64(n)
case uint:
return int64(n)
case uint64:
return int64(n)
case uint32:
return int64(n)
case uint16:
return int64(n)
case uint8:
return int64(n)
case float64:
return int64(n)
case float32:
return int64(n)
}
return 0
}
// toFloat64 converts any numeric type to float64.
func toFloat64(v any) float64 {
if v == nil {
return 0
}
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int64:
return float64(n)
case int32:
return float64(n)
case int16:
return float64(n)
case int8:
return float64(n)
case uint:
return float64(n)
case uint64:
return float64(n)
case uint32:
return float64(n)
case uint16:
return float64(n)
case uint8:
return float64(n)
}
return 0
}

View file

@ -1,459 +0,0 @@
// 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"},
// British English spellings
"format": {Past: "formatted", Gerund: "formatting"},
"analyse": {Past: "analysed", Gerund: "analysing"},
"organise": {Past: "organised", Gerund: "organising"},
"recognise": {Past: "recognised", Gerund: "recognising"},
"realise": {Past: "realised", Gerund: "realising"},
"customise": {Past: "customised", Gerund: "customising"},
"optimise": {Past: "optimised", Gerund: "optimising"},
"initialise": {Past: "initialised", Gerund: "initialising"},
"synchronise": {Past: "synchronised", Gerund: "synchronising"},
}
// 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,
}