refactor: remove pkg/i18n, use core/go-i18n module
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
2958527774
commit
84397a2e10
69 changed files with 6 additions and 46497 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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{}
|
||||
)
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
192
pkg/i18n/i18n.go
192
pkg/i18n/i18n.go
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
|
|
@ -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}} 成功完成"
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue