feat(i18n): implement semantic i18n system with grammar engine
Add semantic intent system for natural language CLI interactions:
- Mode system (Normal/Strict/Collect) for missing key handling
- Subject type with fluent builder for typed subjects
- Composed type with Question/Confirm/Success/Failure forms
- 30+ core.* intents (delete, create, commit, push, etc.)
- Grammar engine: verb conjugation, noun pluralization, articles
- Template functions: title, lower, upper, past, plural, article
- Enhanced CLI: Confirm with options, Question, Choose functions
- Collect mode handler for QA testing
Usage:
i18n.T("core.delete", i18n.S("file", "config.yaml"))
result := i18n.C("core.delete", subject)
cli.ConfirmIntent("core.delete", subject)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2a2dfcfe78
commit
f85064a954
11 changed files with 2600 additions and 8 deletions
|
|
@ -2,6 +2,7 @@ package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/framework"
|
"github.com/host-uk/core/pkg/framework"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
|
@ -11,12 +12,18 @@ import (
|
||||||
type I18nService struct {
|
type I18nService struct {
|
||||||
*framework.ServiceRuntime[I18nOptions]
|
*framework.ServiceRuntime[I18nOptions]
|
||||||
svc *i18n.Service
|
svc *i18n.Service
|
||||||
|
|
||||||
|
// Collect mode state
|
||||||
|
missingKeys []i18n.MissingKeyAction
|
||||||
|
missingKeysMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// I18nOptions configures the i18n service.
|
// I18nOptions configures the i18n service.
|
||||||
type I18nOptions struct {
|
type I18nOptions struct {
|
||||||
// Language overrides auto-detection (e.g., "en-GB", "de")
|
// Language overrides auto-detection (e.g., "en-GB", "de")
|
||||||
Language string
|
Language string
|
||||||
|
// Mode sets the translation mode (Normal, Strict, Collect)
|
||||||
|
Mode i18n.Mode
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewI18nService creates an i18n service factory.
|
// NewI18nService creates an i18n service factory.
|
||||||
|
|
@ -31,9 +38,13 @@ func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) {
|
||||||
svc.SetLanguage(opts.Language)
|
svc.SetLanguage(opts.Language)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set mode if specified
|
||||||
|
svc.SetMode(opts.Mode)
|
||||||
|
|
||||||
return &I18nService{
|
return &I18nService{
|
||||||
ServiceRuntime: framework.NewServiceRuntime(c, opts),
|
ServiceRuntime: framework.NewServiceRuntime(c, opts),
|
||||||
svc: svc,
|
svc: svc,
|
||||||
|
missingKeys: make([]i18n.MissingKeyAction, 0),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -41,9 +52,56 @@ func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) {
|
||||||
// OnStartup initialises the i18n service.
|
// OnStartup initialises the i18n service.
|
||||||
func (s *I18nService) OnStartup(ctx context.Context) error {
|
func (s *I18nService) OnStartup(ctx context.Context) error {
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
|
|
||||||
|
// Register action handler for collect mode
|
||||||
|
if s.svc.Mode() == i18n.ModeCollect {
|
||||||
|
i18n.SetActionHandler(s.handleMissingKey)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleMissingKey accumulates missing keys in collect mode.
|
||||||
|
func (s *I18nService) handleMissingKey(action i18n.MissingKeyAction) {
|
||||||
|
s.missingKeysMu.Lock()
|
||||||
|
defer s.missingKeysMu.Unlock()
|
||||||
|
s.missingKeys = append(s.missingKeys, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MissingKeys returns all missing keys collected in collect mode.
|
||||||
|
// Call this at the end of a QA session to report missing translations.
|
||||||
|
func (s *I18nService) MissingKeys() []i18n.MissingKeyAction {
|
||||||
|
s.missingKeysMu.Lock()
|
||||||
|
defer s.missingKeysMu.Unlock()
|
||||||
|
result := make([]i18n.MissingKeyAction, len(s.missingKeys))
|
||||||
|
copy(result, s.missingKeys)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearMissingKeys resets the collected missing keys.
|
||||||
|
func (s *I18nService) ClearMissingKeys() {
|
||||||
|
s.missingKeysMu.Lock()
|
||||||
|
defer s.missingKeysMu.Unlock()
|
||||||
|
s.missingKeys = s.missingKeys[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMode changes the translation mode.
|
||||||
|
func (s *I18nService) SetMode(mode i18n.Mode) {
|
||||||
|
s.svc.SetMode(mode)
|
||||||
|
|
||||||
|
// Update action handler registration
|
||||||
|
if mode == i18n.ModeCollect {
|
||||||
|
i18n.SetActionHandler(s.handleMissingKey)
|
||||||
|
} else {
|
||||||
|
i18n.SetActionHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode returns the current translation mode.
|
||||||
|
func (s *I18nService) Mode() i18n.Mode {
|
||||||
|
return s.svc.Mode()
|
||||||
|
}
|
||||||
|
|
||||||
// Queries for i18n service
|
// Queries for i18n service
|
||||||
|
|
||||||
// QueryTranslate requests a translation.
|
// QueryTranslate requests a translation.
|
||||||
|
|
|
||||||
290
pkg/cli/utils.go
290
pkg/cli/utils.go
|
|
@ -1,11 +1,15 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GhAuthenticated checks if the GitHub CLI is authenticated.
|
// GhAuthenticated checks if the GitHub CLI is authenticated.
|
||||||
|
|
@ -24,14 +28,290 @@ func Truncate(s string, max int) string {
|
||||||
return s[:max-3] + "..."
|
return s[:max-3] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfirmOption configures Confirm behaviour.
|
||||||
|
type ConfirmOption func(*confirmConfig)
|
||||||
|
|
||||||
|
type confirmConfig struct {
|
||||||
|
defaultYes bool
|
||||||
|
required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
|
||||||
|
func DefaultYes() ConfirmOption {
|
||||||
|
return func(c *confirmConfig) {
|
||||||
|
c.defaultYes = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required prevents empty responses; user must explicitly type y/n.
|
||||||
|
func Required() ConfirmOption {
|
||||||
|
return func(c *confirmConfig) {
|
||||||
|
c.required = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Confirm prompts the user for yes/no confirmation.
|
// Confirm prompts the user for yes/no confirmation.
|
||||||
// Returns true if the user enters "y" or "yes" (case-insensitive).
|
// Returns true if the user enters "y" or "yes" (case-insensitive).
|
||||||
func Confirm(prompt string) bool {
|
//
|
||||||
fmt.Printf("%s [y/N] ", prompt)
|
// Basic usage:
|
||||||
var response string
|
//
|
||||||
fmt.Scanln(&response)
|
// if Confirm("Delete file?") { ... }
|
||||||
|
//
|
||||||
|
// With options:
|
||||||
|
//
|
||||||
|
// if Confirm("Save changes?", DefaultYes()) { ... }
|
||||||
|
// if Confirm("Dangerous!", Required()) { ... }
|
||||||
|
func Confirm(prompt string, opts ...ConfirmOption) bool {
|
||||||
|
cfg := &confirmConfig{}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the prompt suffix
|
||||||
|
var suffix string
|
||||||
|
if cfg.required {
|
||||||
|
suffix = "[y/n] "
|
||||||
|
} else if cfg.defaultYes {
|
||||||
|
suffix = "[Y/n] "
|
||||||
|
} else {
|
||||||
|
suffix = "[y/N] "
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
|
for {
|
||||||
|
fmt.Printf("%s %s", prompt, suffix)
|
||||||
|
response, _ := reader.ReadString('\n')
|
||||||
response = strings.ToLower(strings.TrimSpace(response))
|
response = strings.ToLower(strings.TrimSpace(response))
|
||||||
return response == "y" || response == "yes"
|
|
||||||
|
// Handle empty response
|
||||||
|
if response == "" {
|
||||||
|
if cfg.required {
|
||||||
|
continue // Ask again
|
||||||
|
}
|
||||||
|
return cfg.defaultYes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for yes/no responses
|
||||||
|
if response == "y" || response == "yes" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if response == "n" || response == "no" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid response
|
||||||
|
if cfg.required {
|
||||||
|
fmt.Println("Please enter 'y' or 'n'")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-required: treat invalid as default
|
||||||
|
return cfg.defaultYes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmIntent prompts for confirmation using a semantic intent.
|
||||||
|
// The intent determines the question text, danger level, and default response.
|
||||||
|
//
|
||||||
|
// if ConfirmIntent("core.delete", i18n.S("file", "config.yaml")) { ... }
|
||||||
|
func ConfirmIntent(intent string, subject *i18n.Subject, opts ...ConfirmOption) bool {
|
||||||
|
result := i18n.C(intent, subject)
|
||||||
|
|
||||||
|
// Apply intent metadata to options
|
||||||
|
if result.Meta.Dangerous {
|
||||||
|
opts = append([]ConfirmOption{Required()}, opts...)
|
||||||
|
}
|
||||||
|
if result.Meta.Default == "yes" {
|
||||||
|
opts = append([]ConfirmOption{DefaultYes()}, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Confirm(result.Question, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmDangerous prompts for confirmation of a dangerous action.
|
||||||
|
// Shows both the question and a confirmation prompt, requiring explicit "yes".
|
||||||
|
//
|
||||||
|
// if ConfirmDangerous("core.delete", i18n.S("file", "config.yaml")) { ... }
|
||||||
|
func ConfirmDangerous(intent string, subject *i18n.Subject) bool {
|
||||||
|
result := i18n.C(intent, subject)
|
||||||
|
|
||||||
|
// Show initial question
|
||||||
|
if !Confirm(result.Question, Required()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// For dangerous actions, show confirmation prompt
|
||||||
|
if result.Meta.Dangerous && result.Confirm != "" {
|
||||||
|
return Confirm(result.Confirm, Required())
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuestionOption configures Question behaviour.
|
||||||
|
type QuestionOption func(*questionConfig)
|
||||||
|
|
||||||
|
type questionConfig struct {
|
||||||
|
defaultValue string
|
||||||
|
required bool
|
||||||
|
validator func(string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDefault sets the default value shown in brackets.
|
||||||
|
func WithDefault(value string) QuestionOption {
|
||||||
|
return func(c *questionConfig) {
|
||||||
|
c.defaultValue = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithValidator adds a validation function for the response.
|
||||||
|
func WithValidator(fn func(string) error) QuestionOption {
|
||||||
|
return func(c *questionConfig) {
|
||||||
|
c.validator = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequiredInput prevents empty responses.
|
||||||
|
func RequiredInput() QuestionOption {
|
||||||
|
return func(c *questionConfig) {
|
||||||
|
c.required = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Question prompts the user for text input.
|
||||||
|
//
|
||||||
|
// name := Question("Enter your name:")
|
||||||
|
// name := Question("Enter your name:", WithDefault("Anonymous"))
|
||||||
|
// name := Question("Enter your name:", RequiredInput())
|
||||||
|
func Question(prompt string, opts ...QuestionOption) string {
|
||||||
|
cfg := &questionConfig{}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Build prompt with default
|
||||||
|
if cfg.defaultValue != "" {
|
||||||
|
fmt.Printf("%s [%s] ", prompt, cfg.defaultValue)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s ", prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, _ := reader.ReadString('\n')
|
||||||
|
response = strings.TrimSpace(response)
|
||||||
|
|
||||||
|
// Handle empty response
|
||||||
|
if response == "" {
|
||||||
|
if cfg.required {
|
||||||
|
fmt.Println("Response required")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
response = cfg.defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate if validator provided
|
||||||
|
if cfg.validator != nil {
|
||||||
|
if err := cfg.validator(response); err != nil {
|
||||||
|
fmt.Printf("Invalid: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuestionIntent prompts for text input using a semantic intent.
|
||||||
|
//
|
||||||
|
// name := QuestionIntent("core.rename", i18n.S("file", "old.txt"))
|
||||||
|
func QuestionIntent(intent string, subject *i18n.Subject, opts ...QuestionOption) string {
|
||||||
|
result := i18n.C(intent, subject)
|
||||||
|
return Question(result.Question, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChooseOption configures Choose behaviour.
|
||||||
|
type ChooseOption[T any] func(*chooseConfig[T])
|
||||||
|
|
||||||
|
type chooseConfig[T any] struct {
|
||||||
|
displayFn func(T) string
|
||||||
|
defaultN int // 0-based index of default selection
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDisplay sets a custom display function for items.
|
||||||
|
func WithDisplay[T any](fn func(T) string) ChooseOption[T] {
|
||||||
|
return func(c *chooseConfig[T]) {
|
||||||
|
c.displayFn = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDefaultIndex sets the default selection index (0-based).
|
||||||
|
func WithDefaultIndex[T any](idx int) ChooseOption[T] {
|
||||||
|
return func(c *chooseConfig[T]) {
|
||||||
|
c.defaultN = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose prompts the user to select from a list of items.
|
||||||
|
// Returns the selected item. Uses simple numbered selection for terminal compatibility.
|
||||||
|
//
|
||||||
|
// choice := Choose("Select a file:", files)
|
||||||
|
// choice := Choose("Select a file:", files, WithDisplay(func(f File) string { return f.Name }))
|
||||||
|
func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
|
||||||
|
var zero T
|
||||||
|
if len(items) == 0 {
|
||||||
|
return zero
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &chooseConfig[T]{
|
||||||
|
displayFn: func(item T) string { return fmt.Sprint(item) },
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display options
|
||||||
|
fmt.Println(prompt)
|
||||||
|
for i, item := range items {
|
||||||
|
marker := " "
|
||||||
|
if i == cfg.defaultN {
|
||||||
|
marker = "*"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s%d. %s\n", marker, i+1, cfg.displayFn(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
|
for {
|
||||||
|
fmt.Printf("Enter number [1-%d]: ", len(items))
|
||||||
|
response, _ := reader.ReadString('\n')
|
||||||
|
response = strings.TrimSpace(response)
|
||||||
|
|
||||||
|
// Empty response uses default
|
||||||
|
if response == "" {
|
||||||
|
return items[cfg.defaultN]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse number
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscanf(response, "%d", &n); err == nil {
|
||||||
|
if n >= 1 && n <= len(items) {
|
||||||
|
return items[n-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Please enter a number between 1 and %d\n", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChooseIntent prompts for selection using a semantic intent.
|
||||||
|
//
|
||||||
|
// file := ChooseIntent("core.select", i18n.S("file", ""), files)
|
||||||
|
func ChooseIntent[T any](intent string, subject *i18n.Subject, items []T, opts ...ChooseOption[T]) T {
|
||||||
|
result := i18n.C(intent, subject)
|
||||||
|
return Choose(result.Question, items, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatAge formats a time as a human-readable age string.
|
// FormatAge formats a time as a human-readable age string.
|
||||||
|
|
|
||||||
162
pkg/i18n/compose.go
Normal file
162
pkg/i18n/compose.go
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
// Package i18n provides internationalization for the CLI.
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subject represents a typed subject with metadata for semantic translations.
|
||||||
|
// Use S() to create a Subject and chain methods for additional context.
|
||||||
|
//
|
||||||
|
// S("file", "config.yaml")
|
||||||
|
// S("repo", "core-php").Count(3)
|
||||||
|
// S("user", user).Gender("female")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
s.location = location
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCount returns the count value.
|
||||||
|
func (s *Subject) GetCount() int {
|
||||||
|
if s == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return s.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGender returns the grammatical gender.
|
||||||
|
func (s *Subject) GetGender() string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.gender
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocation returns the location context.
|
||||||
|
func (s *Subject) GetLocation() string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.location
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNoun returns the noun type.
|
||||||
|
func (s *Subject) GetNoun() string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.Noun
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntentMeta defines the behaviour and characteristics of an intent.
|
||||||
|
type IntentMeta struct {
|
||||||
|
Type string // "action", "question", "info"
|
||||||
|
Verb string // Reference to verb key (e.g., "delete", "save")
|
||||||
|
Dangerous bool // If true, requires extra confirmation
|
||||||
|
Default string // Default response: "yes" or "no"
|
||||||
|
Supports []string // Extra options supported by this intent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composed holds all output forms for an intent after template resolution.
|
||||||
|
// Each field is ready to display to the user.
|
||||||
|
type Composed struct {
|
||||||
|
Question string // Question form: "Delete config.yaml?"
|
||||||
|
Confirm string // Confirmation form: "Really delete config.yaml?"
|
||||||
|
Success string // Success message: "config.yaml deleted"
|
||||||
|
Failure string // Failure message: "Failed to delete config.yaml"
|
||||||
|
Meta IntentMeta // Intent metadata for UI decisions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intent defines a semantic intent with templates for all output forms.
|
||||||
|
// Templates use Go text/template syntax with Subject data available.
|
||||||
|
type Intent struct {
|
||||||
|
Meta IntentMeta // Intent behaviour and characteristics
|
||||||
|
Question string // Template for question form
|
||||||
|
Confirm string // Template for confirmation form
|
||||||
|
Success string // Template for success message
|
||||||
|
Failure string // Template for failure message
|
||||||
|
}
|
||||||
|
|
||||||
|
// templateData is passed to intent templates during execution.
|
||||||
|
type templateData struct {
|
||||||
|
Subject string // Display value of subject
|
||||||
|
Noun string // Noun type
|
||||||
|
Count int // Count for pluralization
|
||||||
|
Gender string // Grammatical gender
|
||||||
|
Location string // Location context
|
||||||
|
Value any // Raw value (for complex templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
Value: s.Value,
|
||||||
|
}
|
||||||
|
}
|
||||||
169
pkg/i18n/compose_test.go
Normal file
169
pkg/i18n/compose_test.go
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
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("with count", func(t *testing.T) {
|
||||||
|
s := S("file", "*.go").Count(5)
|
||||||
|
assert.Equal(t, 5, s.GetCount())
|
||||||
|
assert.True(t, s.IsPlural())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with gender", func(t *testing.T) {
|
||||||
|
s := S("user", "alice").Gender("female")
|
||||||
|
assert.Equal(t, "female", s.GetGender())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with location", func(t *testing.T) {
|
||||||
|
s := S("file", "config.yaml").In("workspace")
|
||||||
|
assert.Equal(t, "workspace", s.GetLocation())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("chained methods", func(t *testing.T) {
|
||||||
|
s := S("repo", "core-php").Count(3).Gender("neuter").In("organisation")
|
||||||
|
assert.Equal(t, "repo", s.GetNoun())
|
||||||
|
assert.Equal(t, 3, s.GetCount())
|
||||||
|
assert.Equal(t, "neuter", s.GetGender())
|
||||||
|
assert.Equal(t, "organisation", s.GetLocation())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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.GetNoun())
|
||||||
|
assert.Equal(t, 1, s.GetCount())
|
||||||
|
assert.Equal(t, "", s.GetGender())
|
||||||
|
assert.Equal(t, "", s.GetLocation())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
505
pkg/i18n/grammar.go
Normal file
505
pkg/i18n/grammar.go
Normal file
|
|
@ -0,0 +1,505 @@
|
||||||
|
// Package i18n provides internationalization for the CLI.
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerbForms holds irregular verb conjugations.
|
||||||
|
type VerbForms struct {
|
||||||
|
Past string // Past tense (e.g., "deleted")
|
||||||
|
Gerund string // Present participle (e.g., "deleting")
|
||||||
|
}
|
||||||
|
|
||||||
|
// irregularVerbs maps base verbs to their irregular forms.
|
||||||
|
var irregularVerbs = map[string]VerbForms{
|
||||||
|
"be": {Past: "was", Gerund: "being"},
|
||||||
|
"have": {Past: "had", Gerund: "having"},
|
||||||
|
"do": {Past: "did", Gerund: "doing"},
|
||||||
|
"go": {Past: "went", Gerund: "going"},
|
||||||
|
"make": {Past: "made", Gerund: "making"},
|
||||||
|
"get": {Past: "got", Gerund: "getting"},
|
||||||
|
"run": {Past: "ran", Gerund: "running"},
|
||||||
|
"set": {Past: "set", Gerund: "setting"},
|
||||||
|
"put": {Past: "put", Gerund: "putting"},
|
||||||
|
"cut": {Past: "cut", Gerund: "cutting"},
|
||||||
|
"let": {Past: "let", Gerund: "letting"},
|
||||||
|
"hit": {Past: "hit", Gerund: "hitting"},
|
||||||
|
"shut": {Past: "shut", Gerund: "shutting"},
|
||||||
|
"split": {Past: "split", Gerund: "splitting"},
|
||||||
|
"spread": {Past: "spread", Gerund: "spreading"},
|
||||||
|
"read": {Past: "read", Gerund: "reading"},
|
||||||
|
"write": {Past: "wrote", Gerund: "writing"},
|
||||||
|
"send": {Past: "sent", Gerund: "sending"},
|
||||||
|
"build": {Past: "built", Gerund: "building"},
|
||||||
|
"begin": {Past: "began", Gerund: "beginning"},
|
||||||
|
"find": {Past: "found", Gerund: "finding"},
|
||||||
|
"take": {Past: "took", Gerund: "taking"},
|
||||||
|
"see": {Past: "saw", Gerund: "seeing"},
|
||||||
|
"keep": {Past: "kept", Gerund: "keeping"},
|
||||||
|
"hold": {Past: "held", Gerund: "holding"},
|
||||||
|
"tell": {Past: "told", Gerund: "telling"},
|
||||||
|
"bring": {Past: "brought", Gerund: "bringing"},
|
||||||
|
"think": {Past: "thought", Gerund: "thinking"},
|
||||||
|
"buy": {Past: "bought", Gerund: "buying"},
|
||||||
|
"catch": {Past: "caught", Gerund: "catching"},
|
||||||
|
"teach": {Past: "taught", Gerund: "teaching"},
|
||||||
|
"throw": {Past: "threw", Gerund: "throwing"},
|
||||||
|
"grow": {Past: "grew", Gerund: "growing"},
|
||||||
|
"know": {Past: "knew", Gerund: "knowing"},
|
||||||
|
"show": {Past: "showed", Gerund: "showing"},
|
||||||
|
"draw": {Past: "drew", Gerund: "drawing"},
|
||||||
|
"break": {Past: "broke", Gerund: "breaking"},
|
||||||
|
"speak": {Past: "spoke", Gerund: "speaking"},
|
||||||
|
"choose": {Past: "chose", Gerund: "choosing"},
|
||||||
|
"forget": {Past: "forgot", Gerund: "forgetting"},
|
||||||
|
"lose": {Past: "lost", Gerund: "losing"},
|
||||||
|
"win": {Past: "won", Gerund: "winning"},
|
||||||
|
"swim": {Past: "swam", Gerund: "swimming"},
|
||||||
|
"drive": {Past: "drove", Gerund: "driving"},
|
||||||
|
"rise": {Past: "rose", Gerund: "rising"},
|
||||||
|
"shine": {Past: "shone", Gerund: "shining"},
|
||||||
|
"sing": {Past: "sang", Gerund: "singing"},
|
||||||
|
"ring": {Past: "rang", Gerund: "ringing"},
|
||||||
|
"drink": {Past: "drank", Gerund: "drinking"},
|
||||||
|
"sink": {Past: "sank", Gerund: "sinking"},
|
||||||
|
"sit": {Past: "sat", Gerund: "sitting"},
|
||||||
|
"stand": {Past: "stood", Gerund: "standing"},
|
||||||
|
"hang": {Past: "hung", Gerund: "hanging"},
|
||||||
|
"dig": {Past: "dug", Gerund: "digging"},
|
||||||
|
"stick": {Past: "stuck", Gerund: "sticking"},
|
||||||
|
"bite": {Past: "bit", Gerund: "biting"},
|
||||||
|
"hide": {Past: "hid", Gerund: "hiding"},
|
||||||
|
"feed": {Past: "fed", Gerund: "feeding"},
|
||||||
|
"meet": {Past: "met", Gerund: "meeting"},
|
||||||
|
"lead": {Past: "led", Gerund: "leading"},
|
||||||
|
"sleep": {Past: "slept", Gerund: "sleeping"},
|
||||||
|
"feel": {Past: "felt", Gerund: "feeling"},
|
||||||
|
"leave": {Past: "left", Gerund: "leaving"},
|
||||||
|
"mean": {Past: "meant", Gerund: "meaning"},
|
||||||
|
"lend": {Past: "lent", Gerund: "lending"},
|
||||||
|
"spend": {Past: "spent", Gerund: "spending"},
|
||||||
|
"bend": {Past: "bent", Gerund: "bending"},
|
||||||
|
"deal": {Past: "dealt", Gerund: "dealing"},
|
||||||
|
"lay": {Past: "laid", Gerund: "laying"},
|
||||||
|
"pay": {Past: "paid", Gerund: "paying"},
|
||||||
|
"say": {Past: "said", Gerund: "saying"},
|
||||||
|
"sell": {Past: "sold", Gerund: "selling"},
|
||||||
|
"seek": {Past: "sought", Gerund: "seeking"},
|
||||||
|
"fight": {Past: "fought", Gerund: "fighting"},
|
||||||
|
"fly": {Past: "flew", Gerund: "flying"},
|
||||||
|
"wear": {Past: "wore", Gerund: "wearing"},
|
||||||
|
"tear": {Past: "tore", Gerund: "tearing"},
|
||||||
|
"bear": {Past: "bore", Gerund: "bearing"},
|
||||||
|
"swear": {Past: "swore", Gerund: "swearing"},
|
||||||
|
"wake": {Past: "woke", Gerund: "waking"},
|
||||||
|
"freeze": {Past: "froze", Gerund: "freezing"},
|
||||||
|
"steal": {Past: "stole", Gerund: "stealing"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// PastTense returns the past tense of a verb.
|
||||||
|
// Handles irregular verbs and applies regular rules for others.
|
||||||
|
//
|
||||||
|
// PastTense("delete") // "deleted"
|
||||||
|
// PastTense("run") // "ran"
|
||||||
|
// PastTense("copy") // "copied"
|
||||||
|
func PastTense(verb string) string {
|
||||||
|
verb = strings.ToLower(strings.TrimSpace(verb))
|
||||||
|
if verb == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check irregular verbs first
|
||||||
|
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
|
||||||
|
if strings.HasSuffix(verb, "ed") {
|
||||||
|
return verb
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
"cancel": true,
|
||||||
|
"model": true,
|
||||||
|
"travel": true,
|
||||||
|
"label": true,
|
||||||
|
"level": true,
|
||||||
|
"total": true,
|
||||||
|
"target": true,
|
||||||
|
"budget": true,
|
||||||
|
"market": true,
|
||||||
|
"benefit": true,
|
||||||
|
"focus": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Gerund("delete") // "deleting"
|
||||||
|
// Gerund("run") // "running"
|
||||||
|
// Gerund("die") // "dying"
|
||||||
|
func Gerund(verb string) string {
|
||||||
|
verb = strings.ToLower(strings.TrimSpace(verb))
|
||||||
|
if verb == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check irregular verbs first
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// vowelSounds contains words that start with consonants but have vowel sounds.
|
||||||
|
// These take "an" instead of "a".
|
||||||
|
var vowelSounds = map[string]bool{
|
||||||
|
"hour": true,
|
||||||
|
"honest": true,
|
||||||
|
"honour": true,
|
||||||
|
"honor": true,
|
||||||
|
"heir": true,
|
||||||
|
"herb": true, // US pronunciation
|
||||||
|
}
|
||||||
|
|
||||||
|
// consonantSounds contains words that start with vowels but have consonant sounds.
|
||||||
|
// These take "a" instead of "an".
|
||||||
|
var consonantSounds = map[string]bool{
|
||||||
|
"user": true, // "yoo-zer"
|
||||||
|
"union": true, // "yoon-yon"
|
||||||
|
"unique": true,
|
||||||
|
"unit": true,
|
||||||
|
"universe": true,
|
||||||
|
"university": true,
|
||||||
|
"uniform": true,
|
||||||
|
"usage": true,
|
||||||
|
"usual": true,
|
||||||
|
"utility": true,
|
||||||
|
"utensil": true,
|
||||||
|
"one": true, // "wun"
|
||||||
|
"once": true,
|
||||||
|
"euro": true, // "yoo-ro"
|
||||||
|
"eulogy": true,
|
||||||
|
"euphemism": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 "a"
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
func Title(s string) string {
|
||||||
|
return strings.Title(s) //nolint:staticcheck // strings.Title is fine for our use case
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
303
pkg/i18n/grammar_test.go
Normal file
303
pkg/i18n/grammar_test.go
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
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
|
||||||
|
{"", "a"},
|
||||||
|
{" 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
156
pkg/i18n/i18n.go
156
pkg/i18n/i18n.go
|
|
@ -59,6 +59,7 @@ type Service struct {
|
||||||
currentLang string
|
currentLang string
|
||||||
fallbackLang string
|
fallbackLang string
|
||||||
availableLangs []language.Tag
|
availableLangs []language.Tag
|
||||||
|
mode Mode // Translation mode (Normal, Strict, Collect)
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,6 +242,10 @@ func SetDefault(s *Service) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// T translates a message using the default service.
|
// 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 {
|
func T(messageID string, args ...any) string {
|
||||||
if svc := Default(); svc != nil {
|
if svc := Default(); svc != nil {
|
||||||
return svc.T(messageID, args...)
|
return svc.T(messageID, args...)
|
||||||
|
|
@ -248,6 +253,32 @@ func T(messageID string, args ...any) string {
|
||||||
return messageID
|
return messageID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C composes a semantic intent using the default service.
|
||||||
|
// Returns all output forms (Question, Confirm, Success, Failure) for the intent.
|
||||||
|
//
|
||||||
|
// result := C("core.delete", S("file", "config.yaml"))
|
||||||
|
// fmt.Println(result.Question) // "Delete config.yaml?"
|
||||||
|
func C(intent string, subject *Subject) *Composed {
|
||||||
|
if svc := Default(); svc != nil {
|
||||||
|
return svc.C(intent, subject)
|
||||||
|
}
|
||||||
|
return &Composed{
|
||||||
|
Question: intent,
|
||||||
|
Confirm: intent,
|
||||||
|
Success: intent,
|
||||||
|
Failure: intent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// _ is the standard gettext-style translation helper.
|
||||||
|
// Alias for T() - use whichever you prefer.
|
||||||
|
//
|
||||||
|
// i18n._("cli.success")
|
||||||
|
// i18n._("cli.greeting", map[string]any{"Name": "World"})
|
||||||
|
func _(messageID string, args ...any) string {
|
||||||
|
return T(messageID, args...)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Service methods ---
|
// --- Service methods ---
|
||||||
|
|
||||||
// SetLanguage sets the language for translations.
|
// SetLanguage sets the language for translations.
|
||||||
|
|
@ -294,22 +325,51 @@ func (s *Service) AvailableLanguages() []string {
|
||||||
return langs
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// T translates a message by its ID.
|
// T translates a message by its ID.
|
||||||
// Optional template data can be passed for interpolation.
|
// Optional template data can be passed for interpolation.
|
||||||
//
|
//
|
||||||
// For plural messages, pass a map with "Count" to select the form:
|
// For plural messages, pass a map with "Count" to select the form:
|
||||||
//
|
//
|
||||||
// svc.T("cli.count.items", map[string]any{"Count": 5})
|
// svc.T("cli.count.items", map[string]any{"Count": 5})
|
||||||
|
//
|
||||||
|
// For semantic intents (core.* namespace), pass a Subject to get the Question form:
|
||||||
|
//
|
||||||
|
// svc.T("core.delete", S("file", "config.yaml")) // "Delete config.yaml?"
|
||||||
func (s *Service) T(messageID string, args ...any) string {
|
func (s *Service) T(messageID string, args ...any) string {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
// Check for semantic intent with Subject
|
||||||
|
if strings.HasPrefix(messageID, "core.") && len(args) > 0 {
|
||||||
|
if subject, ok := args[0].(*Subject); ok {
|
||||||
|
// Use C() to resolve the intent, return Question form
|
||||||
|
s.mu.RUnlock()
|
||||||
|
result := s.C(messageID, subject)
|
||||||
|
s.mu.RLock()
|
||||||
|
return result.Question
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try current language, then fallback
|
// Try current language, then fallback
|
||||||
msg, ok := s.getMessage(s.currentLang, messageID)
|
msg, ok := s.getMessage(s.currentLang, messageID)
|
||||||
if !ok {
|
if !ok {
|
||||||
msg, ok = s.getMessage(s.fallbackLang, messageID)
|
msg, ok = s.getMessage(s.fallbackLang, messageID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return messageID
|
return s.handleMissingKey(messageID, args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,7 +394,7 @@ func (s *Service) T(messageID string, args ...any) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return messageID
|
return s.handleMissingKey(messageID, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply template if we have data
|
// Apply template if we have data
|
||||||
|
|
@ -345,6 +405,98 @@ func (s *Service) T(messageID string, args ...any) string {
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleMissingKey handles a missing translation key based on the current mode.
|
||||||
|
// Must be called with s.mu.RLock held.
|
||||||
|
func (s *Service) handleMissingKey(key string, args []any) string {
|
||||||
|
switch s.mode {
|
||||||
|
case ModeStrict:
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C composes a semantic intent with a subject.
|
||||||
|
// Returns all output forms (Question, Confirm, Success, Failure) for the intent.
|
||||||
|
//
|
||||||
|
// result := svc.C("core.delete", S("file", "config.yaml"))
|
||||||
|
// fmt.Println(result.Question) // "Delete config.yaml?"
|
||||||
|
// fmt.Println(result.Success) // "Config.yaml deleted"
|
||||||
|
func (s *Service) C(intent string, subject *Subject) *Composed {
|
||||||
|
// Look up the intent definition
|
||||||
|
intentDef := getIntent(intent)
|
||||||
|
if intentDef == nil {
|
||||||
|
// Intent not found, handle as missing key
|
||||||
|
s.mu.RLock()
|
||||||
|
mode := s.mode
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case ModeStrict:
|
||||||
|
panic(fmt.Sprintf("i18n: missing intent %q", intent))
|
||||||
|
case ModeCollect:
|
||||||
|
dispatchMissingKey(intent, nil)
|
||||||
|
return &Composed{
|
||||||
|
Question: "[" + intent + "]",
|
||||||
|
Confirm: "[" + intent + "]",
|
||||||
|
Success: "[" + intent + "]",
|
||||||
|
Failure: "[" + intent + "]",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return &Composed{
|
||||||
|
Question: intent,
|
||||||
|
Confirm: intent,
|
||||||
|
Success: intent,
|
||||||
|
Failure: intent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create template data from subject
|
||||||
|
data := newTemplateData(subject)
|
||||||
|
|
||||||
|
return &Composed{
|
||||||
|
Question: executeIntentTemplate(intentDef.Question, data),
|
||||||
|
Confirm: executeIntentTemplate(intentDef.Confirm, data),
|
||||||
|
Success: executeIntentTemplate(intentDef.Success, data),
|
||||||
|
Failure: executeIntentTemplate(intentDef.Failure, data),
|
||||||
|
Meta: intentDef.Meta,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeIntentTemplate executes an intent template with the given data.
|
||||||
|
func executeIntentTemplate(tmplStr string, data templateData) string {
|
||||||
|
if tmplStr == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(tmplStr)
|
||||||
|
if err != nil {
|
||||||
|
return tmplStr
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return tmplStr
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// _ is the standard gettext-style translation helper. Alias for T().
|
||||||
|
func (s *Service) _(messageID string, args ...any) string {
|
||||||
|
return s.T(messageID, args...)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) getMessage(lang, key string) (Message, bool) {
|
func (s *Service) getMessage(lang, key string) (Message, bool) {
|
||||||
msgs, ok := s.messages[lang]
|
msgs, ok := s.messages[lang]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
463
pkg/i18n/intents.go
Normal file
463
pkg/i18n/intents.go
Normal file
|
|
@ -0,0 +1,463 @@
|
||||||
|
// Package i18n provides internationalization for the CLI.
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// getIntent retrieves an intent by its key from the core intents.
|
||||||
|
// Returns nil if the intent is not found.
|
||||||
|
func getIntent(key string) *Intent {
|
||||||
|
if intent, ok := coreIntents[key]; ok {
|
||||||
|
return &intent
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterIntent adds a custom intent to the core intents.
|
||||||
|
// Use this to extend the built-in intents with application-specific ones.
|
||||||
|
//
|
||||||
|
// 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) {
|
||||||
|
coreIntents[key] = intent
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntentKeys returns all registered intent keys.
|
||||||
|
func IntentKeys() []string {
|
||||||
|
keys := make([]string, 0, len(coreIntents))
|
||||||
|
for key := range coreIntents {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
230
pkg/i18n/intents_test.go
Normal file
230
pkg/i18n/intents_test.go
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetIntent(t *testing.T) {
|
||||||
|
t.Run("existing intent", func(t *testing.T) {
|
||||||
|
intent := getIntent("core.delete")
|
||||||
|
require.NotNil(t, intent)
|
||||||
|
assert.Equal(t, "action", intent.Meta.Type)
|
||||||
|
assert.Equal(t, "delete", intent.Meta.Verb)
|
||||||
|
assert.True(t, intent.Meta.Dangerous)
|
||||||
|
assert.Equal(t, "no", intent.Meta.Default)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-existent intent", func(t *testing.T) {
|
||||||
|
intent := getIntent("nonexistent.intent")
|
||||||
|
assert.Nil(t, intent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterIntent(t *testing.T) {
|
||||||
|
// Register a custom intent
|
||||||
|
RegisterIntent("test.custom", Intent{
|
||||||
|
Meta: IntentMeta{
|
||||||
|
Type: "action",
|
||||||
|
Verb: "custom",
|
||||||
|
Default: "yes",
|
||||||
|
},
|
||||||
|
Question: "Custom {{.Subject}}?",
|
||||||
|
Success: "{{.Subject | title}} customised",
|
||||||
|
Failure: "Failed to customise {{.Subject}}",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify it was registered
|
||||||
|
intent := getIntent("test.custom")
|
||||||
|
require.NotNil(t, intent)
|
||||||
|
assert.Equal(t, "custom", intent.Meta.Verb)
|
||||||
|
assert.Equal(t, "Custom {{.Subject}}?", intent.Question)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
delete(coreIntents, "test.custom")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntentKeys(t *testing.T) {
|
||||||
|
keys := IntentKeys()
|
||||||
|
|
||||||
|
// Should contain core intents
|
||||||
|
assert.Contains(t, keys, "core.delete")
|
||||||
|
assert.Contains(t, keys, "core.create")
|
||||||
|
assert.Contains(t, keys, "core.save")
|
||||||
|
assert.Contains(t, keys, "core.commit")
|
||||||
|
assert.Contains(t, keys, "core.push")
|
||||||
|
|
||||||
|
// Should have a reasonable number of intents
|
||||||
|
assert.GreaterOrEqual(t, len(keys), 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreIntents_Structure(t *testing.T) {
|
||||||
|
// Verify all core intents have required fields
|
||||||
|
for key, intent := range coreIntents {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
// Meta should be set
|
||||||
|
assert.NotEmpty(t, intent.Meta.Type, "intent %s missing Type", key)
|
||||||
|
assert.NotEmpty(t, intent.Meta.Verb, "intent %s missing Verb", key)
|
||||||
|
assert.NotEmpty(t, intent.Meta.Default, "intent %s missing Default", key)
|
||||||
|
|
||||||
|
// At least Question and one output should be set
|
||||||
|
assert.NotEmpty(t, intent.Question, "intent %s missing Question", key)
|
||||||
|
|
||||||
|
// Default should be valid
|
||||||
|
assert.Contains(t, []string{"yes", "no"}, intent.Meta.Default,
|
||||||
|
"intent %s has invalid Default: %s", key, intent.Meta.Default)
|
||||||
|
|
||||||
|
// Type should be valid
|
||||||
|
assert.Contains(t, []string{"action", "question", "info"}, intent.Meta.Type,
|
||||||
|
"intent %s has invalid Type: %s", key, intent.Meta.Type)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreIntents_Categories(t *testing.T) {
|
||||||
|
// Destructive actions should be dangerous
|
||||||
|
destructive := []string{"core.delete", "core.remove", "core.discard", "core.reset", "core.overwrite"}
|
||||||
|
for _, key := range destructive {
|
||||||
|
intent := getIntent(key)
|
||||||
|
require.NotNil(t, intent, "missing intent: %s", key)
|
||||||
|
assert.True(t, intent.Meta.Dangerous, "%s should be marked as dangerous", key)
|
||||||
|
assert.Equal(t, "no", intent.Meta.Default, "%s should default to no", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creation actions should not be dangerous
|
||||||
|
creation := []string{"core.create", "core.add", "core.clone", "core.copy"}
|
||||||
|
for _, key := range creation {
|
||||||
|
intent := getIntent(key)
|
||||||
|
require.NotNil(t, intent, "missing intent: %s", key)
|
||||||
|
assert.False(t, intent.Meta.Dangerous, "%s should not be marked as dangerous", key)
|
||||||
|
assert.Equal(t, "yes", intent.Meta.Default, "%s should default to yes", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreIntents_Templates(t *testing.T) {
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
intent string
|
||||||
|
subject *Subject
|
||||||
|
expectedQ string
|
||||||
|
expectedSuccess string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
intent: "core.delete",
|
||||||
|
subject: S("file", "config.yaml"),
|
||||||
|
expectedQ: "Delete config.yaml?",
|
||||||
|
expectedSuccess: "Config.Yaml deleted", // strings.Title capitalizes after dots
|
||||||
|
},
|
||||||
|
{
|
||||||
|
intent: "core.create",
|
||||||
|
subject: S("directory", "src"),
|
||||||
|
expectedQ: "Create src?",
|
||||||
|
expectedSuccess: "Src created",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
intent: "core.commit",
|
||||||
|
subject: S("changes", "3 files"),
|
||||||
|
expectedQ: "Commit 3 files?",
|
||||||
|
expectedSuccess: "3 Files committed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
intent: "core.push",
|
||||||
|
subject: S("commits", "5 commits"),
|
||||||
|
expectedQ: "Push 5 commits?",
|
||||||
|
expectedSuccess: "5 Commits pushed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.intent, func(t *testing.T) {
|
||||||
|
result := svc.C(tt.intent, tt.subject)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedQ, result.Question)
|
||||||
|
assert.Equal(t, tt.expectedSuccess, result.Success)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreIntents_TemplateErrors(t *testing.T) {
|
||||||
|
// Templates with invalid syntax should return the original template
|
||||||
|
RegisterIntent("test.invalid", Intent{
|
||||||
|
Meta: IntentMeta{Type: "action", Verb: "test", Default: "yes"},
|
||||||
|
Question: "{{.Invalid", // Invalid template syntax
|
||||||
|
Success: "Success",
|
||||||
|
Failure: "Failure",
|
||||||
|
})
|
||||||
|
defer delete(coreIntents, "test.invalid")
|
||||||
|
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result := svc.C("test.invalid", S("item", "test"))
|
||||||
|
// Should return the original invalid template
|
||||||
|
assert.Equal(t, "{{.Invalid", result.Question)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreIntents_TemplateFunctions(t *testing.T) {
|
||||||
|
// Register an intent that uses template functions
|
||||||
|
RegisterIntent("test.funcs", Intent{
|
||||||
|
Meta: IntentMeta{Type: "action", Verb: "test", Default: "yes"},
|
||||||
|
Question: "Process {{.Subject | quote}}?",
|
||||||
|
Success: "{{.Subject | upper}} processed",
|
||||||
|
Failure: "{{.Subject | lower}} failed",
|
||||||
|
})
|
||||||
|
defer delete(coreIntents, "test.funcs")
|
||||||
|
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result := svc.C("test.funcs", S("item", "Test"))
|
||||||
|
|
||||||
|
assert.Equal(t, `Process "Test"?`, result.Question)
|
||||||
|
assert.Equal(t, "TEST processed", result.Success)
|
||||||
|
assert.Equal(t, "test failed", result.Failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntentT_Integration(t *testing.T) {
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Using T with core.* prefix and Subject should return Question form
|
||||||
|
result := svc.T("core.delete", S("file", "config.yaml"))
|
||||||
|
assert.Equal(t, "Delete config.yaml?", result)
|
||||||
|
|
||||||
|
// Using T with regular key should work normally
|
||||||
|
result = svc.T("cli.success")
|
||||||
|
assert.Equal(t, "Success", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntent_EmptyTemplates(t *testing.T) {
|
||||||
|
RegisterIntent("test.empty", Intent{
|
||||||
|
Meta: IntentMeta{Type: "info", Verb: "info", Default: "yes"},
|
||||||
|
Question: "Question",
|
||||||
|
Confirm: "", // Empty confirm
|
||||||
|
Success: "", // Empty success
|
||||||
|
Failure: "", // Empty failure
|
||||||
|
})
|
||||||
|
defer delete(coreIntents, "test.empty")
|
||||||
|
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result := svc.C("test.empty", S("item", "test"))
|
||||||
|
|
||||||
|
assert.Equal(t, "Question", result.Question)
|
||||||
|
assert.Equal(t, "", result.Confirm)
|
||||||
|
assert.Equal(t, "", result.Success)
|
||||||
|
assert.Equal(t, "", result.Failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreIntents_AllKeysPrefixed(t *testing.T) {
|
||||||
|
for key := range coreIntents {
|
||||||
|
assert.True(t, strings.HasPrefix(key, "core."),
|
||||||
|
"intent key %q should be prefixed with 'core.'", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
pkg/i18n/mode.go
Normal file
74
pkg/i18n/mode.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Package i18n provides internationalization for the CLI.
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 a MissingKeyAction 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MissingKeyAction is dispatched when a translation key is not found in collect mode.
|
||||||
|
// It contains caller information for debugging and QA purposes.
|
||||||
|
type MissingKeyAction 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionHandler is a function that handles MissingKeyAction dispatches.
|
||||||
|
// Register handlers via SetActionHandler to receive missing key notifications.
|
||||||
|
type ActionHandler func(action MissingKeyAction)
|
||||||
|
|
||||||
|
var actionHandler ActionHandler
|
||||||
|
|
||||||
|
// SetActionHandler registers a handler for MissingKeyAction dispatches.
|
||||||
|
// Only one handler can be active at a time; subsequent calls replace the previous handler.
|
||||||
|
func SetActionHandler(h ActionHandler) {
|
||||||
|
actionHandler = h
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchMissingKey creates and dispatches a MissingKeyAction.
|
||||||
|
// Called internally when a key is missing in collect mode.
|
||||||
|
func dispatchMissingKey(key string, args map[string]any) {
|
||||||
|
if actionHandler == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, file, line, ok := runtime.Caller(2) // Skip dispatchMissingKey and handleMissingKey
|
||||||
|
if !ok {
|
||||||
|
file = "unknown"
|
||||||
|
line = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
actionHandler(MissingKeyAction{
|
||||||
|
Key: key,
|
||||||
|
Args: args,
|
||||||
|
CallerFile: file,
|
||||||
|
CallerLine: line,
|
||||||
|
})
|
||||||
|
}
|
||||||
196
pkg/i18n/mode_test.go
Normal file
196
pkg/i18n/mode_test.go
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
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 TestMissingKeyAction(t *testing.T) {
|
||||||
|
action := MissingKeyAction{
|
||||||
|
Key: "test.missing.key",
|
||||||
|
Args: map[string]any{"Name": "test"},
|
||||||
|
CallerFile: "/path/to/file.go",
|
||||||
|
CallerLine: 42,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "test.missing.key", action.Key)
|
||||||
|
assert.Equal(t, "test", action.Args["Name"])
|
||||||
|
assert.Equal(t, "/path/to/file.go", action.CallerFile)
|
||||||
|
assert.Equal(t, 42, action.CallerLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetActionHandler(t *testing.T) {
|
||||||
|
// Reset handler after test
|
||||||
|
defer SetActionHandler(nil)
|
||||||
|
|
||||||
|
t.Run("sets handler", func(t *testing.T) {
|
||||||
|
var received MissingKeyAction
|
||||||
|
SetActionHandler(func(action MissingKeyAction) {
|
||||||
|
received = action
|
||||||
|
})
|
||||||
|
|
||||||
|
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) {
|
||||||
|
SetActionHandler(nil)
|
||||||
|
// Should not panic
|
||||||
|
dispatchMissingKey("test.key", nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replaces previous handler", func(t *testing.T) {
|
||||||
|
called1 := false
|
||||||
|
called2 := false
|
||||||
|
|
||||||
|
SetActionHandler(func(action MissingKeyAction) {
|
||||||
|
called1 = true
|
||||||
|
})
|
||||||
|
SetActionHandler(func(action MissingKeyAction) {
|
||||||
|
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
|
||||||
|
defer func() {
|
||||||
|
defaultService = originalService
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Run("default mode is normal", func(t *testing.T) {
|
||||||
|
defaultService = 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 SetActionHandler(nil)
|
||||||
|
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc.SetMode(ModeCollect)
|
||||||
|
|
||||||
|
var received MissingKeyAction
|
||||||
|
SetActionHandler(func(action MissingKeyAction) {
|
||||||
|
received = action
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeCollect_MissingIntent(t *testing.T) {
|
||||||
|
// Reset handler after test
|
||||||
|
defer SetActionHandler(nil)
|
||||||
|
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc.SetMode(ModeCollect)
|
||||||
|
|
||||||
|
var received MissingKeyAction
|
||||||
|
SetActionHandler(func(action MissingKeyAction) {
|
||||||
|
received = action
|
||||||
|
})
|
||||||
|
|
||||||
|
// Missing intent should dispatch action and return [key]
|
||||||
|
result := svc.C("nonexistent.intent", S("file", "test.txt"))
|
||||||
|
|
||||||
|
assert.Equal(t, "[nonexistent.intent]", result.Question)
|
||||||
|
assert.Equal(t, "[nonexistent.intent]", result.Success)
|
||||||
|
assert.Equal(t, "[nonexistent.intent]", result.Failure)
|
||||||
|
assert.Equal(t, "nonexistent.intent", received.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeStrict_MissingIntent(t *testing.T) {
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc.SetMode(ModeStrict)
|
||||||
|
|
||||||
|
// Missing intent should panic
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
svc.C("nonexistent.intent", S("file", "test.txt"))
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue