Compare commits

..

1 commit

Author SHA1 Message Date
Claude
97f9c758d1
chore(ax): AX compliance sweep — banned imports, naming, Good/Bad/Ugly tests
- compose.go: remove fmt import, use local stringer interface + core.Sprint
- hooks.go: replace stdlib log with dappco.re/go/core/log
- localise.go: replace os.Getenv with core.Env
- loader.go: replace strings.CutPrefix with core.HasPrefix/TrimPrefix
- reversal/tokeniser.go: replace strings.Fields with local splitFields helper
- validate.go: rename sb → builder (AX naming)
- calibrate.go, classify.go: rename cfg → configuration (AX naming)
- numbers.go: rename local fmt variable → numberFormat
- All test files: add Good/Bad/Ugly triads per AX test naming convention

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 08:42:40 +01:00
58 changed files with 1390 additions and 10578 deletions

6
.gitignore vendored
View file

@ -4,9 +4,3 @@
# Knowledge base
KB/
.core/
# Local Go tooling caches
.cache/
.gocache/
.gomodcache/
.gopath/

View file

@ -1,30 +0,0 @@
.PHONY: build vet test cover tidy clean
override GOCACHE := $(CURDIR)/.cache/go-build
override GOPATH := $(CURDIR)/.cache/go
GO ?= go
GO_ENV = GOCACHE=$(GOCACHE) GOPATH=$(GOPATH)
build:
@mkdir -p $(GOCACHE) $(GOPATH)
@$(GO_ENV) $(GO) build ./...
vet:
@mkdir -p $(GOCACHE) $(GOPATH)
@$(GO_ENV) $(GO) vet ./...
test:
@mkdir -p $(GOCACHE) $(GOPATH)
@$(GO_ENV) $(GO) test ./... -count=1 -timeout 120s
cover:
@mkdir -p $(GOCACHE) $(GOPATH)
@$(GO_ENV) $(GO) test -cover ./...
tidy:
@mkdir -p $(GOCACHE) $(GOPATH)
@$(GO_ENV) $(GO) mod tidy
clean:
@rm -rf $(CURDIR)/.cache $(CURDIR)/.gocache $(CURDIR)/.gomodcache $(CURDIR)/.gopath

View file

@ -45,17 +45,6 @@ go test -bench=. ./...
go build ./...
```
For repeatable local runs in a clean workspace, the repo also ships a
`Makefile` with the standard workflow targets:
```bash
make build
make vet
make test
make cover
make tidy
```
## Licence
European Union Public Licence 1.2 — see [LICENCE](LICENCE) for details.

View file

@ -52,9 +52,9 @@ func CalibrateDomains(ctx context.Context, modelA, modelB inference.TextModel,
return nil, log.E("CalibrateDomains", "empty sample set", nil)
}
cfg := defaultClassifyConfig()
configuration := defaultClassifyConfig()
for _, o := range opts {
o(&cfg)
o(&configuration)
}
stats := &CalibrationStats{
@ -66,18 +66,18 @@ func CalibrateDomains(ctx context.Context, modelA, modelB inference.TextModel,
// Build classification prompts from sample texts.
prompts := make([]string, len(samples))
for i, s := range samples {
prompts[i] = core.Sprintf(cfg.promptTemplate, s.Text)
prompts[i] = core.Sprintf(configuration.promptTemplate, s.Text)
}
// Classify with model A.
domainsA, durA, err := classifyAll(ctx, modelA, prompts, cfg.batchSize)
domainsA, durA, err := classifyAll(ctx, modelA, prompts, configuration.batchSize)
if err != nil {
return nil, log.E("CalibrateDomains", "classify with model A", err)
}
stats.DurationA = durA
// Classify with model B.
domainsB, durB, err := classifyAll(ctx, modelB, prompts, cfg.batchSize)
domainsB, durB, err := classifyAll(ctx, modelB, prompts, configuration.batchSize)
if err != nil {
return nil, log.E("CalibrateDomains", "classify with model B", err)
}

View file

@ -194,6 +194,71 @@ func TestCalibrateDomains_EmptySamples(t *testing.T) {
}
}
// TestCalibrateDomains_Good verifies full agreement returns rate of 1.0.
//
// stats.AgreementRate == 1.0 when both models return the same domain
func TestCalibrateDomains_Good(t *testing.T) {
model := &mockModel{
classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) {
results := make([]inference.ClassifyResult, len(prompts))
for i := range prompts {
results[i] = inference.ClassifyResult{Token: inference.Token{Text: "technical"}}
}
return results, nil
},
}
samples := []CalibrationSample{{Text: "Build the image", TrueDomain: "technical"}}
stats, err := CalibrateDomains(context.Background(), model, model, samples)
if err != nil {
t.Fatalf("CalibrateDomains: %v", err)
}
if stats.AgreementRate != 1.0 {
t.Errorf("AgreementRate = %f, want 1.0", stats.AgreementRate)
}
}
// TestCalibrateDomains_Bad verifies nil samples return an error.
//
// CalibrateDomains(ctx, m, m, nil) // error
func TestCalibrateDomains_Bad(t *testing.T) {
model := &mockModel{
classifyFunc: func(_ context.Context, _ []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) {
return nil, nil
},
}
_, err := CalibrateDomains(context.Background(), model, model, nil)
if err == nil {
t.Error("expected error for nil samples")
}
}
// TestCalibrateDomains_Ugly verifies samples with no TrueDomain are still counted.
//
// stats.WithTruth == 0 when no samples have TrueDomain set
func TestCalibrateDomains_Ugly(t *testing.T) {
model := &mockModel{
classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) {
results := make([]inference.ClassifyResult, len(prompts))
for i := range prompts {
results[i] = inference.ClassifyResult{Token: inference.Token{Text: "casual"}}
}
return results, nil
},
}
// Sample has no TrueDomain — unusual but valid.
samples := []CalibrationSample{{Text: "random text"}}
stats, err := CalibrateDomains(context.Background(), model, model, samples)
if err != nil {
t.Fatalf("CalibrateDomains: %v", err)
}
if stats.WithTruth != 0 {
t.Errorf("WithTruth = %d, want 0 (no ground truth provided)", stats.WithTruth)
}
if stats.Total != 1 {
t.Errorf("Total = %d, want 1", stats.Total)
}
}
func TestCalibrateDomains_BatchBoundary(t *testing.T) {
// 7 samples with batch size 3: tests partial last batch.
model := &mockModel{

View file

@ -81,9 +81,9 @@ func mapTokenToDomain(token string) string {
func ClassifyCorpus(ctx context.Context, model inference.TextModel,
input io.Reader, output io.Writer, opts ...ClassifyOption) (*ClassifyStats, error) {
cfg := defaultClassifyConfig()
configuration := defaultClassifyConfig()
for _, o := range opts {
o(&cfg)
o(&configuration)
}
stats := &ClassifyStats{ByDomain: make(map[string]int)}
@ -105,19 +105,12 @@ func ClassifyCorpus(ctx context.Context, model inference.TextModel,
}
prompts := make([]string, len(batch))
for i, p := range batch {
prompts[i] = core.Sprintf(cfg.promptTemplate, p.prompt)
prompts[i] = core.Sprintf(configuration.promptTemplate, p.prompt)
}
results, err := model.Classify(ctx, prompts, inference.WithMaxTokens(1))
if err != nil {
return log.E("ClassifyCorpus", "classify batch", err)
}
if len(results) != len(batch) {
return log.E(
"ClassifyCorpus",
core.Sprintf("classify batch returned %d results for %d prompts", len(results), len(batch)),
nil,
)
}
for i, r := range results {
domain := mapTokenToDomain(r.Token.Text)
batch[i].record["domain_1b"] = domain
@ -141,7 +134,7 @@ func ClassifyCorpus(ctx context.Context, model inference.TextModel,
stats.Skipped++
continue
}
promptVal, ok := record[cfg.promptField]
promptVal, ok := record[configuration.promptField]
if !ok {
stats.Skipped++
continue
@ -153,7 +146,7 @@ func ClassifyCorpus(ctx context.Context, model inference.TextModel,
}
batch = append(batch, pending{record: record, prompt: prompt})
if len(batch) >= cfg.batchSize {
if len(batch) >= configuration.batchSize {
if err := flush(); err != nil {
return stats, err
}

View file

@ -67,11 +67,11 @@ func (m *mockModel) BatchGenerate(_ context.Context, _ []string, _ ...inference.
return nil, nil
}
func (m *mockModel) ModelType() string { return "mock" }
func (m *mockModel) Info() inference.ModelInfo { return inference.ModelInfo{} }
func (m *mockModel) ModelType() string { return "mock" }
func (m *mockModel) Info() inference.ModelInfo { return inference.ModelInfo{} }
func (m *mockModel) Metrics() inference.GenerateMetrics { return inference.GenerateMetrics{} }
func (m *mockModel) Err() error { return nil }
func (m *mockModel) Close() error { return nil }
func (m *mockModel) Err() error { return nil }
func (m *mockModel) Close() error { return nil }
func TestClassifyCorpus_Basic(t *testing.T) {
model := &mockModel{
@ -151,6 +151,34 @@ func TestClassifyCorpus_SkipsMalformed(t *testing.T) {
}
}
// TestMapTokenToDomain_Good verifies a known domain token maps correctly.
//
// mapTokenToDomain("technical") // "technical"
func TestMapTokenToDomain_Good(t *testing.T) {
if got := mapTokenToDomain("technical"); got != "technical" {
t.Errorf("mapTokenToDomain(technical) = %q, want %q", got, "technical")
}
}
// TestMapTokenToDomain_Bad verifies an empty token maps to unknown.
//
// mapTokenToDomain("") // "unknown"
func TestMapTokenToDomain_Bad(t *testing.T) {
if got := mapTokenToDomain(""); got != "unknown" {
t.Errorf("mapTokenToDomain(\"\") = %q, want %q", got, "unknown")
}
}
// TestMapTokenToDomain_Ugly verifies a word that starts with a domain name but is not a domain
// maps to unknown — prefix collision must not fire.
//
// mapTokenToDomain("cascade") // "unknown"
func TestMapTokenToDomain_Ugly(t *testing.T) {
if got := mapTokenToDomain("cascade"); got != "unknown" {
t.Errorf("mapTokenToDomain(cascade) = %q, want %q", got, "unknown")
}
}
func TestClassifyCorpus_DomainMapping(t *testing.T) {
model := &mockModel{
classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) {
@ -183,31 +211,3 @@ func TestClassifyCorpus_DomainMapping(t *testing.T) {
t.Errorf("ByDomain[ethical] = %d, want 1", stats.ByDomain["ethical"])
}
}
func TestClassifyCorpus_ResultCountMismatch(t *testing.T) {
model := &mockModel{
classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) {
if len(prompts) == 0 {
return nil, nil
}
return []inference.ClassifyResult{{Token: inference.Token{Text: "technical"}}}, nil
},
}
input := core.NewReader(
`{"prompt":"Delete the file now"}` + "\n" +
`{"prompt":"Create the repo"}` + "\n",
)
var output bytes.Buffer
stats, err := ClassifyCorpus(context.Background(), model, input, &output, WithBatchSize(16))
if err == nil {
t.Fatal("ClassifyCorpus returned nil error, want mismatch failure")
}
if stats.Total != 0 {
t.Errorf("Total = %d, want 0", stats.Total)
}
if output.Len() != 0 {
t.Errorf("output len = %d, want 0", output.Len())
}
}

View file

@ -1,10 +1,11 @@
package i18n
import (
"fmt"
import "dappco.re/go/core"
"dappco.re/go/core"
)
// stringer is a local interface for values that can describe themselves as a string.
type stringer interface {
String() string
}
// S creates a new Subject with the given noun and value.
//
@ -14,23 +15,6 @@ func S(noun string, value any) *Subject {
return &Subject{Noun: noun, Value: value, count: 1}
}
// ComposeIntent renders an intent's templates into concrete output.
func ComposeIntent(intent Intent, subject *Subject) Composed {
return intent.Compose(subject)
}
// Compose renders an intent's templates into concrete output.
func (i Intent) Compose(subject *Subject) Composed {
data := newTemplateData(subject)
return Composed{
Question: executeIntentTemplate(i.Question, data),
Confirm: executeIntentTemplate(i.Confirm, data),
Success: executeIntentTemplate(i.Success, data),
Failure: executeIntentTemplate(i.Failure, data),
Meta: i.Meta,
}
}
func (s *Subject) Count(n int) *Subject {
if s == nil {
return nil
@ -83,43 +67,18 @@ func (s *Subject) String() string {
if s == nil {
return ""
}
if stringer, ok := s.Value.(fmt.Stringer); ok {
return stringer.String()
if v, ok := s.Value.(stringer); ok {
return v.String()
}
return core.Sprintf("%v", s.Value)
return core.Sprint(s.Value)
}
func (s *Subject) IsPlural() bool { return s != nil && s.count != 1 }
func (s *Subject) CountInt() int {
if s == nil {
return 1
}
return s.count
}
func (s *Subject) CountString() string {
if s == nil {
return "1"
}
return FormatNumber(int64(s.count))
}
func (s *Subject) GenderString() string {
if s == nil {
return ""
}
return s.gender
}
func (s *Subject) LocationString() string {
if s == nil {
return ""
}
return s.location
}
func (s *Subject) NounString() string {
if s == nil {
return ""
}
return s.Noun
}
func (s *Subject) IsPlural() bool { return s != nil && s.count != 1 }
func (s *Subject) CountInt() int { if s == nil { return 1 }; return s.count }
func (s *Subject) CountString() string { if s == nil { return "1" }; return core.Sprint(s.count) }
func (s *Subject) GenderString() string { if s == nil { return "" }; return s.gender }
func (s *Subject) LocationString() string { if s == nil { return "" }; return s.location }
func (s *Subject) NounString() string { if s == nil { return "" }; return s.Noun }
func (s *Subject) FormalityString() string {
if s == nil {
return FormalityNeutral.String()

View file

@ -41,21 +41,6 @@ func TestSubject_Count_Good(t *testing.T) {
assert.True(t, subj.IsPlural())
}
func TestSubject_CountString_UsesLocaleFormatting(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
require.NoError(t, SetLanguage("fr"))
subj := S("file", "test.txt").Count(1234)
assert.Equal(t, "1 234", subj.CountString())
}
func TestSubject_Count_Bad_NilReceiver(t *testing.T) {
var s *Subject
result := s.Count(5)
@ -224,3 +209,32 @@ func TestSubject_FullChain_Good(t *testing.T) {
assert.True(t, subj.IsFormal())
assert.True(t, subj.IsPlural())
}
// TestSubject_String_Ugly verifies that an integer value is rendered as a decimal string.
//
// S("count", 42).String() // "42"
func TestSubject_String_Ugly(t *testing.T) {
subject := S("count", 42)
if got := subject.String(); got != "42" {
t.Errorf("S(count, 42).String() = %q, want %q", got, "42")
}
}
// TestSubject_Count_Ugly verifies a zero count makes the subject plural.
//
// S("item", "x").Count(0).IsPlural() // true
func TestSubject_Count_Ugly(t *testing.T) {
subject := S("item", "x").Count(0)
if !subject.IsPlural() {
t.Error("Count(0) should make IsPlural() true")
}
}
// TestS_Ugly verifies that a nil value does not panic.
//
// S("file", nil).String() // "<nil>" (implementation-defined)
func TestS_Ugly(t *testing.T) {
subject := S("file", nil)
got := subject.String()
_ = got // must not panic
}

View file

@ -7,72 +7,41 @@ package i18n
type TranslationContext struct {
Context string
Gender string
Location string
Formality Formality
count int
countSet bool
Extra map[string]any
}
// C creates a TranslationContext.
func C(context string) *TranslationContext {
return &TranslationContext{Context: context, count: 1}
return &TranslationContext{Context: context}
}
func (c *TranslationContext) WithGender(gender string) *TranslationContext {
if c == nil {
return nil
}
if c == nil { return nil }
c.Gender = gender
return c
}
func (c *TranslationContext) In(location string) *TranslationContext {
if c == nil {
return nil
}
c.Location = location
return c
}
func (c *TranslationContext) Formal() *TranslationContext {
if c == nil {
return nil
}
if c == nil { return nil }
c.Formality = FormalityFormal
return c
}
func (c *TranslationContext) Informal() *TranslationContext {
if c == nil {
return nil
}
if c == nil { return nil }
c.Formality = FormalityInformal
return c
}
func (c *TranslationContext) WithFormality(f Formality) *TranslationContext {
if c == nil {
return nil
}
if c == nil { return nil }
c.Formality = f
return c
}
// Count sets the count used for plural-sensitive translations.
func (c *TranslationContext) Count(n int) *TranslationContext {
if c == nil {
return nil
}
c.count = n
c.countSet = true
return c
}
func (c *TranslationContext) Set(key string, value any) *TranslationContext {
if c == nil {
return nil
}
if c == nil { return nil }
if c.Extra == nil {
c.Extra = make(map[string]any)
}
@ -81,71 +50,21 @@ func (c *TranslationContext) Set(key string, value any) *TranslationContext {
}
func (c *TranslationContext) Get(key string) any {
if c == nil || c.Extra == nil {
return nil
}
if c == nil || c.Extra == nil { return nil }
return c.Extra[key]
}
func (c *TranslationContext) ContextString() string {
if c == nil {
return ""
}
return c.Context
}
func (c *TranslationContext) String() string {
if c == nil {
return ""
}
if c == nil { return "" }
return c.Context
}
func (c *TranslationContext) GenderString() string {
if c == nil {
return ""
}
if c == nil { return "" }
return c.Gender
}
func (c *TranslationContext) LocationString() string {
if c == nil {
return ""
}
return c.Location
}
func (c *TranslationContext) FormalityValue() Formality {
if c == nil {
return FormalityNeutral
}
if c == nil { return FormalityNeutral }
return c.Formality
}
// CountInt returns the current count value.
func (c *TranslationContext) CountInt() int {
if c == nil {
return 1
}
return c.count
}
// CountString returns the current count value formatted as text.
func (c *TranslationContext) CountString() string {
if c == nil {
return "1"
}
return FormatNumber(int64(c.count))
}
// IsPlural reports whether the count is plural.
func (c *TranslationContext) IsPlural() bool {
return c != nil && c.count != 1
}
func (c *TranslationContext) countValue() (int, bool) {
if c == nil {
return 1, false
}
return c.count, c.countSet
}

View file

@ -1,83 +0,0 @@
package i18n
import "dappco.re/go/core"
func mapValueString(values any, key string) (string, bool) {
switch m := values.(type) {
case map[string]any:
raw, ok := m[key]
if !ok {
return "", false
}
text := core.Trim(core.Sprintf("%v", raw))
if text == "" {
return "", false
}
return text, true
case map[string]string:
text, ok := m[key]
if !ok {
return "", false
}
text = core.Trim(text)
if text == "" {
return "", false
}
return text, true
default:
return "", false
}
}
func contextMapValues(values any) map[string]any {
switch m := values.(type) {
case map[string]any:
return contextMapValuesAny(m)
case map[string]string:
return contextMapValuesString(m)
default:
return nil
}
}
func contextMapValuesAny(values map[string]any) map[string]any {
if len(values) == 0 {
return nil
}
extra := make(map[string]any, len(values))
for key, value := range values {
switch key {
case "Context", "Gender", "Location", "Formality", "Count", "IsPlural":
continue
case "Extra", "extra", "Extras", "extras":
mergeContextExtra(extra, value)
continue
default:
extra[key] = value
}
}
if len(extra) == 0 {
return nil
}
return extra
}
func contextMapValuesString(values map[string]string) map[string]any {
if len(values) == 0 {
return nil
}
extra := make(map[string]any, len(values))
for key, value := range values {
switch key {
case "Context", "Gender", "Location", "Formality", "Count", "IsPlural",
"Extra", "extra", "Extras", "extras":
continue
default:
extra[key] = value
}
}
if len(extra) == 0 {
return nil
}
return extra
}

View file

@ -14,10 +14,6 @@ func TestC_Good(t *testing.T) {
require.NotNil(t, ctx)
assert.Equal(t, "navigation", ctx.Context)
assert.Equal(t, "navigation", ctx.ContextString())
assert.Equal(t, "navigation", ctx.String())
assert.Equal(t, 1, ctx.CountInt())
assert.Equal(t, "1", ctx.CountString())
assert.False(t, ctx.IsPlural())
}
func TestC_Good_EmptyContext(t *testing.T) {
@ -31,9 +27,7 @@ func TestC_Good_EmptyContext(t *testing.T) {
func TestTranslationContext_NilReceiver_Good(t *testing.T) {
var ctx *TranslationContext
assert.Nil(t, ctx.Count(2))
assert.Nil(t, ctx.WithGender("masculine"))
assert.Nil(t, ctx.In("workspace"))
assert.Nil(t, ctx.Formal())
assert.Nil(t, ctx.Informal())
assert.Nil(t, ctx.WithFormality(FormalityFormal))
@ -41,11 +35,7 @@ func TestTranslationContext_NilReceiver_Good(t *testing.T) {
assert.Nil(t, ctx.Get("key"))
assert.Equal(t, "", ctx.ContextString())
assert.Equal(t, "", ctx.GenderString())
assert.Equal(t, "", ctx.LocationString())
assert.Equal(t, FormalityNeutral, ctx.FormalityValue())
assert.Equal(t, 1, ctx.CountInt())
assert.Equal(t, "1", ctx.CountString())
assert.False(t, ctx.IsPlural())
}
// --- WithGender ---
@ -56,17 +46,6 @@ func TestTranslationContext_WithGender_Good(t *testing.T) {
assert.Equal(t, "feminine", ctx.GenderString())
}
func TestTranslationContext_In_Good(t *testing.T) {
ctx := C("test").In("workspace")
assert.Equal(t, "workspace", ctx.Location)
assert.Equal(t, "workspace", ctx.LocationString())
}
func TestTranslationContext_In_Bad_NilReceiver(t *testing.T) {
var ctx *TranslationContext
assert.Nil(t, ctx.In("workspace"))
}
// --- Formal / Informal ---
func TestTranslationContext_Formal_Good(t *testing.T) {
@ -101,21 +80,6 @@ func TestTranslationContext_WithFormality_Good(t *testing.T) {
}
}
func TestTranslationContext_CountString_UsesLocaleFormatting(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
require.NoError(t, SetLanguage("fr"))
ctx := C("test").Count(1234)
assert.Equal(t, "1 234", ctx.CountString())
}
// --- Set / Get ---
func TestTranslationContext_SetGet_Good(t *testing.T) {
@ -141,18 +105,22 @@ func TestTranslationContext_Get_Bad_NilExtra(t *testing.T) {
func TestTranslationContext_FullChain_Good(t *testing.T) {
ctx := C("medical").
Count(3).
WithGender("feminine").
In("clinic").
Formal().
Set("speciality", "cardiology")
assert.Equal(t, "medical", ctx.ContextString())
assert.Equal(t, 3, ctx.CountInt())
assert.Equal(t, "3", ctx.CountString())
assert.True(t, ctx.IsPlural())
assert.Equal(t, "feminine", ctx.GenderString())
assert.Equal(t, "clinic", ctx.LocationString())
assert.Equal(t, FormalityFormal, ctx.FormalityValue())
assert.Equal(t, "cardiology", ctx.Get("speciality"))
}
// TestTranslationContext_Set_Ugly verifies overwriting an existing key in Extra works.
//
// C("x").Set("k", "a").Set("k", "b").Get("k") // "b"
func TestTranslationContext_Set_Ugly(t *testing.T) {
ctx := C("x").Set("k", "a").Set("k", "b")
if got := ctx.Get("k"); got != "b" {
t.Errorf("Set overwrite: Get(k) = %v, want %q", got, "b")
}
}

View file

@ -18,33 +18,14 @@ type CoreService struct {
missingKeys []MissingKey
missingKeysMu sync.Mutex
hookInstalled bool
}
var _ core.Startable = (*CoreService)(nil)
var _ core.Stoppable = (*CoreService)(nil)
func (s *CoreService) wrapped() *Service {
if s == nil {
return nil
}
return s.svc
}
// ServiceOptions configures the i18n Core service.
type ServiceOptions struct {
// Language overrides auto-detection (e.g., "en-GB", "de")
Language string
// Fallback sets the fallback language for missing translations.
Fallback string
// Formality sets the default formality level.
Formality Formality
// Location sets the default location context.
Location string
// Mode sets the translation mode (Normal, Strict, Collect)
Mode Mode
// Debug prefixes translated output with the message key.
Debug bool
// ExtraFS loads additional translation files on top of the embedded defaults.
// Each entry is an fs.FS + directory path within it.
ExtraFS []FSSource
@ -56,36 +37,6 @@ type FSSource struct {
Dir string
}
// String returns a compact summary of the filesystem source.
func (s FSSource) String() string {
if s.Dir == "" {
return core.Sprintf("FSSource{fs=%T}", s.FS)
}
return core.Sprintf("FSSource{fs=%T dir=%q}", s.FS, s.Dir)
}
// String returns a compact summary of the service options.
func (o ServiceOptions) String() string {
extraFS := "[]"
if len(o.ExtraFS) > 0 {
parts := make([]string, len(o.ExtraFS))
for i, src := range o.ExtraFS {
parts[i] = src.String()
}
extraFS = "[" + core.Join(", ", parts...) + "]"
}
return core.Sprintf(
"ServiceOptions{language=%q fallback=%q formality=%s location=%q mode=%s debug=%t extraFS=%s}",
o.Language,
o.Fallback,
o.Formality,
o.Location,
o.Mode,
o.Debug,
extraFS,
)
}
// NewCoreService creates an i18n Core service factory.
// Automatically loads locale filesystems from:
// 1. Embedded go-i18n base translations (grammar, verbs, nouns)
@ -105,28 +56,13 @@ func NewCoreService(opts ServiceOptions) func(*core.Core) (any, error) {
}
}
// Preserve the same init-time locale registration behaviour used by Init().
// Core bootstrap should not bypass packages that registered locale files
// before the service was constructed.
loadRegisteredLocales(svc)
if opts.Language != "" {
if langErr := svc.SetLanguage(opts.Language); langErr != nil {
return nil, langErr
return nil, core.Wrap(langErr, "NewCoreService", core.Sprintf("i18n: invalid language %q", opts.Language))
}
}
if opts.Fallback != "" {
svc.SetFallback(opts.Fallback)
}
if opts.Formality != FormalityNeutral {
svc.SetFormality(opts.Formality)
}
if opts.Location != "" {
svc.SetLocation(opts.Location)
}
svc.SetMode(opts.Mode)
svc.SetDebug(opts.Debug)
SetDefault(svc)
return &CoreService{
@ -138,54 +74,30 @@ func NewCoreService(opts ServiceOptions) func(*core.Core) (any, error) {
}
// OnStartup initialises the i18n service.
func (s *CoreService) OnStartup(_ context.Context) core.Result {
if svc := s.wrapped(); svc != nil && svc.Mode() == ModeCollect {
s.ensureMissingKeyCollector()
func (s *CoreService) OnStartup(_ context.Context) error {
if s.svc.Mode() == ModeCollect {
OnMissingKey(s.handleMissingKey)
}
return core.Result{OK: true}
}
// OnShutdown finalises the i18n service.
func (s *CoreService) OnShutdown(_ context.Context) core.Result {
return core.Result{OK: true}
}
func (s *CoreService) ensureMissingKeyCollector() {
if s == nil || s.svc == nil || s.hookInstalled {
return
}
AddMissingKeyHandler(s.handleMissingKey)
s.hookInstalled = true
return nil
}
func (s *CoreService) handleMissingKey(mk MissingKey) {
if s == nil {
return
}
s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock()
s.missingKeys = append(s.missingKeys, cloneMissingKey(mk))
s.missingKeys = append(s.missingKeys, mk)
}
// MissingKeys returns all missing keys collected in collect mode.
func (s *CoreService) MissingKeys() []MissingKey {
if s == nil {
return []MissingKey{}
}
s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock()
result := make([]MissingKey, len(s.missingKeys))
for i, mk := range s.missingKeys {
result[i] = cloneMissingKey(mk)
}
copy(result, s.missingKeys)
return result
}
// ClearMissingKeys resets the collected missing keys.
func (s *CoreService) ClearMissingKeys() {
if s == nil {
return
}
s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock()
s.missingKeys = s.missingKeys[:0]
@ -193,335 +105,15 @@ func (s *CoreService) ClearMissingKeys() {
// SetMode changes the translation mode.
func (s *CoreService) SetMode(mode Mode) {
if svc := s.wrapped(); svc != nil {
svc.SetMode(mode)
}
if s != nil && s.svc != nil && mode == ModeCollect {
s.ensureMissingKeyCollector()
s.svc.SetMode(mode)
if mode == ModeCollect {
OnMissingKey(s.handleMissingKey)
} else {
OnMissingKey(nil)
}
}
// Mode returns the current translation mode.
func (s *CoreService) Mode() Mode {
if svc := s.wrapped(); svc != nil {
return svc.Mode()
}
return ModeNormal
}
// CurrentMode returns the current translation mode.
func (s *CoreService) CurrentMode() Mode {
return s.Mode()
}
// T translates a message through the wrapped i18n service.
func (s *CoreService) T(messageID string, args ...any) string {
if svc := s.wrapped(); svc != nil {
return svc.T(messageID, args...)
}
return messageID
}
// Translate translates a message through the wrapped i18n service.
func (s *CoreService) Translate(messageID string, args ...any) core.Result {
if svc := s.wrapped(); svc != nil {
return svc.Translate(messageID, args...)
}
return core.Result{Value: messageID, OK: false}
}
// Raw translates without namespace handler magic.
func (s *CoreService) Raw(messageID string, args ...any) string {
if svc := s.wrapped(); svc != nil {
return svc.Raw(messageID, args...)
}
return messageID
}
// AddMessages adds message strings to the wrapped service.
func (s *CoreService) AddMessages(lang string, messages map[string]string) {
if svc := s.wrapped(); svc != nil {
svc.AddMessages(lang, messages)
}
}
// SetLanguage changes the wrapped service language.
func (s *CoreService) SetLanguage(lang string) error {
if svc := s.wrapped(); svc != nil {
return svc.SetLanguage(lang)
}
return ErrServiceNotInitialised
}
// Language returns the wrapped service language.
func (s *CoreService) Language() string {
if svc := s.wrapped(); svc != nil {
return svc.Language()
}
return "en"
}
// CurrentLanguage returns the wrapped service language.
func (s *CoreService) CurrentLanguage() string {
return s.Language()
}
// CurrentLang is a short alias for CurrentLanguage.
func (s *CoreService) CurrentLang() string {
return s.CurrentLanguage()
}
// Prompt translates a prompt key from the prompt namespace using the wrapped service.
func (s *CoreService) Prompt(key string) string {
if svc := s.wrapped(); svc != nil {
return svc.Prompt(key)
}
return namespaceLookupKey("prompt", key)
}
// CurrentPrompt is a short alias for Prompt.
func (s *CoreService) CurrentPrompt(key string) string {
return s.Prompt(key)
}
// Lang translates a language label from the lang namespace using the wrapped service.
func (s *CoreService) Lang(key string) string {
if svc := s.wrapped(); svc != nil {
return svc.Lang(key)
}
return namespaceLookupKey("lang", key)
}
// SetFallback changes the wrapped service fallback language.
func (s *CoreService) SetFallback(lang string) {
if svc := s.wrapped(); svc != nil {
svc.SetFallback(lang)
}
}
// Fallback returns the wrapped service fallback language.
func (s *CoreService) Fallback() string {
if svc := s.wrapped(); svc != nil {
return svc.Fallback()
}
return "en"
}
// CurrentFallback returns the wrapped service fallback language.
func (s *CoreService) CurrentFallback() string {
return s.Fallback()
}
// SetFormality changes the wrapped service default formality.
func (s *CoreService) SetFormality(f Formality) {
if svc := s.wrapped(); svc != nil {
svc.SetFormality(f)
}
}
// Formality returns the wrapped service default formality.
func (s *CoreService) Formality() Formality {
if svc := s.wrapped(); svc != nil {
return svc.Formality()
}
return FormalityNeutral
}
// CurrentFormality returns the wrapped service default formality.
func (s *CoreService) CurrentFormality() Formality {
return s.Formality()
}
// SetLocation changes the wrapped service default location.
func (s *CoreService) SetLocation(location string) {
if svc := s.wrapped(); svc != nil {
svc.SetLocation(location)
}
}
// Location returns the wrapped service default location.
func (s *CoreService) Location() string {
if svc := s.wrapped(); svc != nil {
return svc.Location()
}
return ""
}
// CurrentLocation returns the wrapped service default location.
func (s *CoreService) CurrentLocation() string {
return s.Location()
}
// SetDebug changes the wrapped service debug mode.
func (s *CoreService) SetDebug(enabled bool) {
if svc := s.wrapped(); svc != nil {
svc.SetDebug(enabled)
}
}
// Debug reports whether wrapped service debug mode is enabled.
func (s *CoreService) Debug() bool {
if svc := s.wrapped(); svc != nil {
return svc.Debug()
}
return false
}
// CurrentDebug reports whether wrapped service debug mode is enabled.
func (s *CoreService) CurrentDebug() bool {
return s.Debug()
}
// State returns a copy-safe snapshot of the wrapped service configuration.
func (s *CoreService) State() ServiceState {
if s == nil || s.svc == nil {
return defaultServiceStateSnapshot()
}
return s.svc.State()
}
// CurrentState is a more explicit alias for State.
func (s *CoreService) CurrentState() ServiceState {
return s.State()
}
// String returns a concise snapshot of the wrapped service state.
func (s *CoreService) String() string {
return s.State().String()
}
// AddHandler appends handlers to the wrapped service's chain.
func (s *CoreService) AddHandler(handlers ...KeyHandler) {
if svc := s.wrapped(); svc != nil {
svc.AddHandler(handlers...)
}
}
// SetHandlers replaces the wrapped service's handler chain.
func (s *CoreService) SetHandlers(handlers ...KeyHandler) {
if svc := s.wrapped(); svc != nil {
svc.SetHandlers(handlers...)
}
}
// PrependHandler inserts handlers at the front of the wrapped service's chain.
func (s *CoreService) PrependHandler(handlers ...KeyHandler) {
if svc := s.wrapped(); svc != nil {
svc.PrependHandler(handlers...)
}
}
// ClearHandlers removes all handlers from the wrapped service.
func (s *CoreService) ClearHandlers() {
if svc := s.wrapped(); svc != nil {
svc.ClearHandlers()
}
}
// ResetHandlers restores the wrapped service's default handler chain.
func (s *CoreService) ResetHandlers() {
if svc := s.wrapped(); svc != nil {
svc.ResetHandlers()
}
}
// Handlers returns a copy of the wrapped service's handler chain.
func (s *CoreService) Handlers() []KeyHandler {
if svc := s.wrapped(); svc != nil {
return svc.Handlers()
}
return []KeyHandler{}
}
// CurrentHandlers returns a copy of the wrapped service's handler chain.
func (s *CoreService) CurrentHandlers() []KeyHandler {
return s.Handlers()
}
// AddLoader loads extra locale data into the wrapped service.
func (s *CoreService) AddLoader(loader Loader) error {
if svc := s.wrapped(); svc != nil {
return svc.AddLoader(loader)
}
return ErrServiceNotInitialised
}
// LoadFS loads locale data from a filesystem into the wrapped service.
func (s *CoreService) LoadFS(fsys fs.FS, dir string) error {
if svc := s.wrapped(); svc != nil {
return svc.LoadFS(fsys, dir)
}
return ErrServiceNotInitialised
}
// AvailableLanguages returns the wrapped service languages.
func (s *CoreService) AvailableLanguages() []string {
if svc := s.wrapped(); svc != nil {
return svc.AvailableLanguages()
}
return []string{}
}
// CurrentAvailableLanguages returns the wrapped service languages.
func (s *CoreService) CurrentAvailableLanguages() []string {
return s.AvailableLanguages()
}
// Direction returns the wrapped service text direction.
func (s *CoreService) Direction() TextDirection {
if svc := s.wrapped(); svc != nil {
return svc.Direction()
}
return DirLTR
}
// CurrentDirection returns the wrapped service text direction.
func (s *CoreService) CurrentDirection() TextDirection {
return s.Direction()
}
// CurrentTextDirection is a more explicit alias for CurrentDirection.
func (s *CoreService) CurrentTextDirection() TextDirection {
return s.CurrentDirection()
}
// IsRTL reports whether the wrapped service language is right-to-left.
func (s *CoreService) IsRTL() bool {
if svc := s.wrapped(); svc != nil {
return svc.IsRTL()
}
return false
}
// RTL reports whether the wrapped service language is right-to-left.
func (s *CoreService) RTL() bool {
return s.IsRTL()
}
// CurrentIsRTL reports whether the wrapped service language is right-to-left.
func (s *CoreService) CurrentIsRTL() bool {
return s.IsRTL()
}
// CurrentRTL reports whether the wrapped service language is right-to-left.
func (s *CoreService) CurrentRTL() bool {
return s.CurrentIsRTL()
}
// PluralCategory returns the plural category for the wrapped service language.
func (s *CoreService) PluralCategory(n int) PluralCategory {
if svc := s.wrapped(); svc != nil {
return svc.PluralCategory(n)
}
return PluralOther
}
// CurrentPluralCategory returns the plural category for the wrapped service language.
func (s *CoreService) CurrentPluralCategory(n int) PluralCategory {
return s.PluralCategory(n)
}
// PluralCategoryOf is a short alias for CurrentPluralCategory.
func (s *CoreService) PluralCategoryOf(n int) PluralCategory {
return s.CurrentPluralCategory(n)
return s.svc.Mode()
}

View file

@ -1,105 +0,0 @@
package i18n
import (
"testing"
"testing/fstest"
"dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCoreServiceNilSafe(t *testing.T) {
var svc *CoreService
savedDefault := defaultService.Load()
t.Cleanup(func() {
defaultService.Store(savedDefault)
})
defaultService.Store(nil)
assert.NotPanics(t, func() {
assert.Equal(t, ModeNormal, svc.Mode())
assert.Equal(t, "en", svc.Language())
assert.Equal(t, "en", svc.Fallback())
assert.Equal(t, FormalityNeutral, svc.Formality())
assert.Equal(t, "", svc.Location())
assert.False(t, svc.Debug())
assert.Equal(t, DirLTR, svc.Direction())
assert.False(t, svc.IsRTL())
assert.Equal(t, PluralOther, svc.PluralCategory(3))
assert.Empty(t, svc.AvailableLanguages())
assert.Empty(t, svc.Handlers())
assert.Equal(t, "prompt.confirm", svc.Prompt("confirm"))
assert.Equal(t, "lang.fr", svc.Lang("fr"))
assert.Equal(t, "hello", svc.T("hello"))
assert.Equal(t, "hello", svc.Raw("hello"))
assert.Equal(t, core.Result{Value: "hello", OK: false}, svc.Translate("hello"))
assert.Equal(t, defaultServiceStateSnapshot(), svc.State())
assert.Equal(t, defaultServiceStateSnapshot(), svc.CurrentState())
assert.Equal(t, defaultServiceStateSnapshot().String(), svc.String())
})
assert.Nil(t, defaultService.Load())
assert.Equal(t, core.Result{OK: true}, svc.OnStartup(nil))
assert.Equal(t, core.Result{OK: true}, svc.OnShutdown(nil))
svc.SetMode(ModeCollect)
svc.SetFallback("fr")
svc.SetFormality(FormalityFormal)
svc.SetLocation("workspace")
svc.SetDebug(true)
svc.SetHandlers(nil)
svc.AddHandler(nil)
svc.PrependHandler(nil)
svc.ClearHandlers()
svc.ResetHandlers()
svc.AddMessages("en", nil)
require.ErrorIs(t, svc.SetLanguage("fr"), ErrServiceNotInitialised)
require.ErrorIs(t, svc.AddLoader(nil), ErrServiceNotInitialised)
require.ErrorIs(t, svc.LoadFS(nil, "locales"), ErrServiceNotInitialised)
}
func TestCoreServiceMissingKeysReturnsCopies(t *testing.T) {
svc, err := New()
require.NoError(t, err)
coreSvc := &CoreService{svc: svc}
coreSvc.SetMode(ModeCollect)
_ = svc.T("missing.copy.key", map[string]any{"foo": "bar"})
missing := coreSvc.MissingKeys()
require.Len(t, missing, 1)
require.Equal(t, "bar", missing[0].Args["foo"])
missing[0].Args["foo"] = "mutated"
again := coreSvc.MissingKeys()
require.Len(t, again, 1)
assert.Equal(t, "bar", again[0].Args["foo"])
}
func TestServiceOptionsAndFSSourceString(t *testing.T) {
opts := ServiceOptions{
Language: "en-GB",
Fallback: "en",
Formality: FormalityFormal,
Location: "workspace",
Mode: ModeCollect,
Debug: true,
ExtraFS: []FSSource{
{FS: fstest.MapFS{}, Dir: "locales"},
},
}
got := opts.String()
assert.Contains(t, got, `language="en-GB"`)
assert.Contains(t, got, `fallback="en"`)
assert.Contains(t, got, `formality=formal`)
assert.Contains(t, got, `location="workspace"`)
assert.Contains(t, got, `mode=collect`)
assert.Contains(t, got, `debug=true`)
assert.Contains(t, got, `FSSource{fs=fstest.MapFS dir="locales"}`)
src := FSSource{FS: fstest.MapFS{}, Dir: "translations"}
assert.Equal(t, `FSSource{fs=fstest.MapFS dir="translations"}`, src.String())
}

View file

@ -1,30 +1,24 @@
package i18n
import "dappco.re/go/core"
// SetDebug enables or disables debug mode on the default service.
func SetDebug(enabled bool) {
withDefaultService(func(svc *Service) { svc.SetDebug(enabled) })
if svc := Default(); svc != nil {
svc.SetDebug(enabled)
}
}
func (s *Service) SetDebug(enabled bool) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.debug = enabled
}
func (s *Service) Debug() bool {
if s == nil {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.debug
}
func debugFormat(key, text string) string {
return core.Sprintf("[%s] %s", key, text)
return "[" + key + "] " + text
}

View file

@ -33,20 +33,6 @@ func TestSetDebug_Good_ServiceLevel(t *testing.T) {
assert.False(t, svc.Debug())
}
func TestCurrentDebug_Good_PackageLevel(t *testing.T) {
svc, err := New()
require.NoError(t, err)
_ = Init()
SetDefault(svc)
SetDebug(true)
assert.True(t, CurrentDebug())
SetDebug(false)
assert.False(t, CurrentDebug())
}
func TestDebugFormat_Good(t *testing.T) {
tests := []struct {
name string
@ -84,19 +70,22 @@ func TestDebugMode_Good_Integration(t *testing.T) {
assert.Equal(t, "[prompt.yes] y", got)
}
func TestTranslate_DebugMode_PreservesOK(t *testing.T) {
// TestSetDebug_Bad verifies that calling SetDebug on a nil service does not panic.
//
// SetDefault(nil); SetDebug(true) // must not panic
func TestSetDebug_Bad(t *testing.T) {
SetDefault(nil)
SetDebug(true) // must not panic
// Restore a working default for subsequent tests.
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
svc.SetDebug(true)
defer svc.SetDebug(false)
translated := svc.Translate("prompt.yes")
assert.True(t, translated.OK)
assert.Equal(t, "[prompt.yes] y", translated.Value)
missing := svc.Translate("missing.translation.key")
assert.False(t, missing.OK)
assert.Equal(t, "[missing.translation.key] missing.translation.key", missing.Value)
}
// TestDebugFormat_Ugly verifies that both key and text empty produces "[] ".
//
// debugFormat("", "") // "[] "
func TestDebugFormat_Ugly(t *testing.T) {
got := debugFormat("", "")
assert.Equal(t, "[] ", got)
}

View file

@ -1,25 +0,0 @@
package i18n
// withDefaultService runs fn when the default service is available.
func withDefaultService(fn func(*Service)) {
if svc := Default(); svc != nil {
fn(svc)
}
}
// defaultServiceValue returns the value produced by fn when the default
// service exists, or fallback otherwise.
func defaultServiceValue[T any](fallback T, fn func(*Service) T) T {
if svc := Default(); svc != nil {
return fn(svc)
}
return fallback
}
// defaultServiceNamespaceValue resolves a namespace key against the default
// service when available, or returns the namespace-qualified key otherwise.
func defaultServiceNamespaceValue(namespace, key string, lookup func(*Service, string) string) string {
return defaultServiceValue(namespaceLookupKey(namespace, key), func(svc *Service) string {
return lookup(svc, key)
})
}

View file

@ -15,7 +15,6 @@ svc := i18n.Default()
// Option 2: Explicit creation with options
svc, err := i18n.New(
i18n.WithLanguage("en-GB"),
i18n.WithFallback("en"),
i18n.WithDefaultHandlers(),
)
@ -31,31 +30,11 @@ svc, err := i18n.NewWithFS(myFS, "locales")
The service automatically detects the system language from `LANG`, `LC_ALL`, or `LC_MESSAGES` environment variables using BCP 47 tag matching.
### Current-State Aliases
The API exposes `Current*` aliases for the most common service getters so call sites can choose between terse and explicit naming without changing behaviour:
- `CurrentLanguage()` / `CurrentLang()`
- `CurrentAvailableLanguages()`
- `CurrentMode()`
- `CurrentFallback()`
- `CurrentFormality()`
- `CurrentLocation()`
- `CurrentDirection()` / `CurrentTextDirection()`
- `CurrentIsRTL()` / `CurrentRTL()`
- `CurrentPluralCategory()` / `PluralCategoryOf()`
- `CurrentDebug()`
- `CurrentHandlers()`
- `CurrentPrompt()`
- `State()` / `CurrentState()` snapshot of the full service configuration
### Options
| Option | Effect |
|--------|--------|
| `WithFallback("en")` | Set fallback language for missing translations |
| `WithLanguage("fr")` | Set the initial language before the service starts serving |
| `WithLocation("workspace")` | Set the default location context |
| `WithDefaultHandlers()` | Register the six built-in `i18n.*` namespace handlers |
| `WithHandlers(h...)` | Replace handlers entirely |
| `WithMode(ModeStrict)` | Panic on missing keys (useful in CI) |
@ -215,7 +194,7 @@ T("i18n.count.person", 3) // "3 people"
Produces past-tense completion messages.
```go
T("i18n.done.delete", "config.yaml") // "Config.yaml deleted"
T("i18n.done.delete", "config.yaml") // "Config.Yaml deleted"
T("i18n.done.push", "commits") // "Commits pushed"
T("i18n.done.delete") // "Deleted"
```
@ -237,7 +216,7 @@ Locale-aware number formatting.
T("i18n.numeric.number", 1234567) // "1,234,567"
T("i18n.numeric.decimal", 3.14) // "3.14"
T("i18n.numeric.percent", 0.85) // "85%"
T("i18n.numeric.bytes", 1536000) // "1.46 MB"
T("i18n.numeric.bytes", 1536000) // "1.5 MB"
T("i18n.numeric.ordinal", 3) // "3rd"
T("i18n.numeric.ago", 5, "minutes") // "5 minutes ago"
```
@ -247,7 +226,7 @@ The shorthand `N()` function wraps this namespace:
```go
i18n.N("number", 1234567) // "1,234,567"
i18n.N("percent", 0.85) // "85%"
i18n.N("bytes", 1536000) // "1.46 MB"
i18n.N("bytes", 1536000) // "1.5 MB"
i18n.N("ordinal", 1) // "1st"
```
@ -359,4 +338,4 @@ All grammar functions are available as Go template functions via `TemplateFuncs(
template.New("").Funcs(i18n.TemplateFuncs())
```
Available functions: `title`, `lower`, `upper`, `past`, `gerund`, `plural`, `pluralForm`, `article`, `quote`, `label`, `progress`, `progressSubject`, `actionResult`, `actionFailed`, `timeAgo`, `formatAgo`.
Available functions: `title`, `lower`, `upper`, `past`, `gerund`, `plural`, `pluralForm`, `article`, `quote`.

View file

@ -201,7 +201,7 @@ The `irregularVerbs` and `irregularNouns` Go maps and the regular morphology rul
**French reversal**
French article handling now covers elision (`l'`), plural forms (`les`, `des`), and gendered articles in the reversal tokeniser. The forward composer also uses `by_gender` when available, so the French article path is no longer a known limitation.
Elision (`l'`) and plural articles (`les`, `des`) are not handled by the current `Article()` function or the reversal tokeniser. The `by_gender` article map supports gendered articles for composition, but the reversal tokeniser's `MatchArticle()` only checks `IndefiniteDefault`, `IndefiniteVowel`, and `Definite`. French reversal is therefore incomplete.
**Dual-class expansion candidates not yet measured**
@ -227,7 +227,7 @@ Measure imprint drift on the 88K seeds for the 20 candidate words listed above.
**French reversal**
If additional French article variants or locale-specific contractions are added later, update both the forward composer and `MatchArticle()` together so composition and reversal stay symmetric.
Extend `Article()` to handle elision (`l'` before vowel-initial nouns) and plural forms (`les`, `des`). Update `MatchArticle()` in the reversal tokeniser to recognise the full French article set including gendered and plural variants.
**88K seed corpus processing**

View file

@ -149,27 +149,6 @@ func (l *FallbackLoader) Load(lang string) (map[string]i18n.Message, *i18n.Gramm
}
```
### Locale Providers
Packages that want to contribute more than one locale source can implement `LocaleProvider` and register it once:
```go
type Provider struct{}
func (Provider) LocaleSources() []i18n.FSSource {
return []i18n.FSSource{
{FS: embedFS, Dir: "locales"},
{FS: sharedFS, Dir: "translations"},
}
}
func init() {
i18n.RegisterLocaleProvider(Provider{})
}
```
This is the preferred path when a package needs to contribute translations to the default service without manually sequencing multiple `RegisterLocales()` calls.
## Custom Handlers
Handlers process keys before standard lookup. Use for dynamic patterns.

View file

@ -214,9 +214,8 @@ i18n.Pluralize("file", 5) // "files"
i18n.Pluralize("child", 2) // "children" (irregular)
// Articles
i18n.Article("apple") // "an"
i18n.ArticlePhrase("apple") // "an apple"
i18n.ArticlePhrase("banana") // "a banana"
i18n.Article("apple") // "an apple"
i18n.Article("banana") // "a banana"
// Composed messages
i18n.Label("status") // "Status:"
@ -391,7 +390,6 @@ Use functional options when creating a service:
```go
svc, err := i18n.New(
i18n.WithLanguage("de-DE"), // Initial language
i18n.WithFallback("de-DE"), // Fallback language
i18n.WithFormality(i18n.FormalityFormal), // Default formality
i18n.WithMode(i18n.ModeStrict), // Missing key mode

2
go.mod
View file

@ -7,7 +7,7 @@ require golang.org/x/text v0.35.0
require (
dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/core/log v0.1.0
dappco.re/go/core/inference v0.1.4
forge.lthn.ai/core/go-inference v0.1.4
)
require github.com/kr/text v0.2.0 // indirect

View file

@ -2,7 +2,6 @@ package i18n
import (
"maps"
"strconv"
"text/template"
"unicode"
@ -11,227 +10,31 @@ import (
// GetGrammarData returns the grammar data for the specified language.
func GetGrammarData(lang string) *GrammarData {
lang = normalizeLanguageTag(lang)
if lang == "" {
return nil
}
grammarCacheMu.RLock()
defer grammarCacheMu.RUnlock()
if data, ok := grammarCache[lang]; ok && data != nil {
return data
}
if base := baseLanguageTag(lang); base != "" {
if data, ok := grammarCache[base]; ok && data != nil {
return data
}
}
return nil
return grammarCache[lang]
}
// SetGrammarData sets the grammar data for a language, replacing any existing data.
func SetGrammarData(lang string, data *GrammarData) {
lang = normalizeLanguageTag(lang)
if lang == "" {
return
}
grammarCacheMu.Lock()
defer grammarCacheMu.Unlock()
if data == nil {
delete(grammarCache, lang)
return
}
grammarCache[lang] = cloneGrammarData(data)
grammarCache[lang] = data
}
// MergeGrammarData merges grammar data into the existing data for a language.
// New entries are added; existing entries are overwritten per-key.
func MergeGrammarData(lang string, data *GrammarData) {
lang = normalizeLanguageTag(lang)
if lang == "" || data == nil {
return
}
grammarCacheMu.Lock()
defer grammarCacheMu.Unlock()
existing := grammarCache[lang]
if existing == nil {
grammarCache[lang] = cloneGrammarData(data)
grammarCache[lang] = data
return
}
if existing.Verbs == nil {
existing.Verbs = make(map[string]VerbForms, len(data.Verbs))
}
if existing.Nouns == nil {
existing.Nouns = make(map[string]NounForms, len(data.Nouns))
}
if existing.Words == nil {
existing.Words = make(map[string]string, len(data.Words))
}
maps.Copy(existing.Verbs, data.Verbs)
maps.Copy(existing.Nouns, data.Nouns)
maps.Copy(existing.Words, data.Words)
mergeArticleForms(&existing.Articles, data.Articles)
mergePunctuationRules(&existing.Punct, data.Punct)
mergeSignalData(&existing.Signals, data.Signals)
if data.Number.ThousandsSep != "" {
existing.Number.ThousandsSep = data.Number.ThousandsSep
}
if data.Number.DecimalSep != "" {
existing.Number.DecimalSep = data.Number.DecimalSep
}
if data.Number.PercentFmt != "" {
existing.Number.PercentFmt = data.Number.PercentFmt
}
}
func mergeArticleForms(dst *ArticleForms, src ArticleForms) {
if dst == nil {
return
}
if src.IndefiniteDefault != "" {
dst.IndefiniteDefault = src.IndefiniteDefault
}
if src.IndefiniteVowel != "" {
dst.IndefiniteVowel = src.IndefiniteVowel
}
if src.Definite != "" {
dst.Definite = src.Definite
}
if len(src.ByGender) == 0 {
return
}
if dst.ByGender == nil {
dst.ByGender = make(map[string]string, len(src.ByGender))
}
maps.Copy(dst.ByGender, src.ByGender)
}
func mergePunctuationRules(dst *PunctuationRules, src PunctuationRules) {
if dst == nil {
return
}
if src.LabelSuffix != "" {
dst.LabelSuffix = src.LabelSuffix
}
if src.ProgressSuffix != "" {
dst.ProgressSuffix = src.ProgressSuffix
}
}
func mergeSignalData(dst *SignalData, src SignalData) {
if dst == nil {
return
}
dst.NounDeterminers = appendUniqueStrings(dst.NounDeterminers, src.NounDeterminers...)
dst.VerbAuxiliaries = appendUniqueStrings(dst.VerbAuxiliaries, src.VerbAuxiliaries...)
dst.VerbInfinitive = appendUniqueStrings(dst.VerbInfinitive, src.VerbInfinitive...)
dst.VerbNegation = appendUniqueStrings(dst.VerbNegation, src.VerbNegation...)
if len(src.Priors) == 0 {
return
}
if dst.Priors == nil {
dst.Priors = make(map[string]map[string]float64, len(src.Priors))
}
for word, priors := range src.Priors {
if dst.Priors[word] == nil {
dst.Priors[word] = make(map[string]float64, len(priors))
}
maps.Copy(dst.Priors[word], priors)
}
}
func appendUniqueStrings(dst []string, values ...string) []string {
if len(values) == 0 {
return dst
}
seen := make(map[string]struct{}, len(dst))
for _, value := range dst {
seen[value] = struct{}{}
}
for _, value := range values {
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
dst = append(dst, value)
}
return dst
}
func grammarDataHasContent(data *GrammarData) bool {
if data == nil {
return false
}
if len(data.Verbs) > 0 || len(data.Nouns) > 0 || len(data.Words) > 0 {
return true
}
if data.Articles.IndefiniteDefault != "" ||
data.Articles.IndefiniteVowel != "" ||
data.Articles.Definite != "" ||
len(data.Articles.ByGender) > 0 {
return true
}
if data.Punct.LabelSuffix != "" || data.Punct.ProgressSuffix != "" {
return true
}
if len(data.Signals.NounDeterminers) > 0 ||
len(data.Signals.VerbAuxiliaries) > 0 ||
len(data.Signals.VerbInfinitive) > 0 ||
len(data.Signals.VerbNegation) > 0 ||
len(data.Signals.Priors) > 0 {
return true
}
return data.Number != (NumberFormat{})
}
func cloneGrammarData(data *GrammarData) *GrammarData {
if data == nil {
return nil
}
clone := &GrammarData{
Articles: ArticleForms{
IndefiniteDefault: data.Articles.IndefiniteDefault,
IndefiniteVowel: data.Articles.IndefiniteVowel,
Definite: data.Articles.Definite,
},
Punct: data.Punct,
Signals: SignalData{
NounDeterminers: append([]string(nil), data.Signals.NounDeterminers...),
VerbAuxiliaries: append([]string(nil), data.Signals.VerbAuxiliaries...),
VerbInfinitive: append([]string(nil), data.Signals.VerbInfinitive...),
VerbNegation: append([]string(nil), data.Signals.VerbNegation...),
Priors: make(map[string]map[string]float64, len(data.Signals.Priors)),
},
Number: data.Number,
}
if len(data.Verbs) > 0 {
clone.Verbs = make(map[string]VerbForms, len(data.Verbs))
maps.Copy(clone.Verbs, data.Verbs)
}
if len(data.Nouns) > 0 {
clone.Nouns = make(map[string]NounForms, len(data.Nouns))
maps.Copy(clone.Nouns, data.Nouns)
}
if len(data.Words) > 0 {
clone.Words = make(map[string]string, len(data.Words))
maps.Copy(clone.Words, data.Words)
}
if len(data.Articles.ByGender) > 0 {
clone.Articles.ByGender = make(map[string]string, len(data.Articles.ByGender))
maps.Copy(clone.Articles.ByGender, data.Articles.ByGender)
}
if len(data.Signals.Priors) > 0 {
for word, priors := range data.Signals.Priors {
if len(priors) == 0 {
continue
}
clone.Signals.Priors[word] = make(map[string]float64, len(priors))
maps.Copy(clone.Signals.Priors[word], priors)
}
}
return clone
}
// IrregularVerbs returns a copy of the irregular verb forms map.
@ -248,34 +51,8 @@ func IrregularNouns() map[string]string {
return result
}
// DualClassVerbs returns a copy of the additional regular verbs that also act
// as common nouns in dev/ops text.
func DualClassVerbs() map[string]VerbForms {
result := make(map[string]VerbForms, len(dualClassVerbs))
maps.Copy(result, dualClassVerbs)
return result
}
// DualClassNouns returns a copy of the additional regular nouns that also act
// as common verbs in dev/ops text.
func DualClassNouns() map[string]string {
result := make(map[string]string, len(dualClassNouns))
maps.Copy(result, dualClassNouns)
return result
}
// Lower returns the lowercase form of s.
func Lower(s string) string {
return core.Lower(s)
}
// Upper returns the uppercase form of s.
func Upper(s string) string {
return core.Upper(s)
}
func getVerbForm(lang, verb, form string) string {
data := grammarDataForLang(lang)
data := GetGrammarData(lang)
if data == nil || data.Verbs == nil {
return ""
}
@ -292,7 +69,7 @@ func getVerbForm(lang, verb, form string) string {
}
func getWord(lang, word string) string {
data := grammarDataForLang(lang)
data := GetGrammarData(lang)
if data == nil || data.Words == nil {
return ""
}
@ -300,7 +77,7 @@ func getWord(lang, word string) string {
}
func getPunct(lang, rule, defaultVal string) string {
data := grammarDataForLang(lang)
data := GetGrammarData(lang)
if data == nil {
return defaultVal
}
@ -318,7 +95,7 @@ func getPunct(lang, rule, defaultVal string) string {
}
func getNounForm(lang, noun, form string) string {
data := grammarDataForLang(lang)
data := GetGrammarData(lang)
if data == nil || data.Nouns == nil {
return ""
}
@ -379,9 +156,6 @@ func applyRegularPastTense(verb string) string {
return verb[:len(verb)-1] + "ied"
}
}
if core.HasSuffix(verb, "c") {
return verb + "ked"
}
if len(verb) >= 2 && shouldDoubleConsonant(verb) {
return verb + string(verb[len(verb)-1]) + "ed"
}
@ -439,9 +213,6 @@ func applyRegularGerund(verb string) string {
return verb[:len(verb)-1] + "ing"
}
}
if core.HasSuffix(verb, "c") {
return verb + "king"
}
if shouldDoubleConsonant(verb) {
return verb + string(verb[len(verb)-1]) + "ing"
}
@ -455,15 +226,6 @@ func applyRegularGerund(verb string) string {
// Pluralize("child", 3) // "children"
func Pluralize(noun string, count int) string {
if count == 1 {
// Honour locale-provided singular forms before falling back to the input.
noun = core.Trim(noun)
if noun == "" {
return ""
}
lower := core.Lower(noun)
if form := getNounForm(currentLangForGrammar(), lower, "one"); form != "" {
return preserveInitialCapitalization(noun, form)
}
return noun
}
return PluralForm(noun)
@ -477,10 +239,16 @@ func PluralForm(noun string) string {
}
lower := core.Lower(noun)
if form := getNounForm(currentLangForGrammar(), lower, "other"); form != "" {
return preserveInitialCapitalization(noun, form)
if unicode.IsUpper(rune(noun[0])) && len(form) > 0 {
return core.Upper(string(form[0])) + form[1:]
}
return form
}
if plural, ok := irregularNouns[lower]; ok {
return preserveInitialCapitalization(noun, plural)
if unicode.IsUpper(rune(noun[0])) {
return core.Upper(string(plural[0])) + plural[1:]
}
return plural
}
return applyRegularPlural(noun)
}
@ -518,31 +286,17 @@ func applyRegularPlural(noun string) string {
return noun + "s"
}
// Article returns the appropriate article token for the current language.
// English falls back to phonetic "a"/"an" heuristics. Locale grammar data
// can override this with language-specific article forms.
//
// Use ArticlePhrase when you want the noun phrase prefixed with the article.
// Article returns the appropriate indefinite article ("a" or "an").
//
// Article("file") // "a"
// Article("error") // "an"
// Article("user") // "a" (sounds like "yoo-zer")
// Article("hour") // "an" (silent h)
func Article(word string) string {
word = core.Trim(word)
if word == "" {
return ""
}
lower := core.Lower(word)
if article, ok := articleForCurrentLanguage(lower, word); ok {
return article
}
if isInitialism(word) {
if initialismUsesVowelSound(word) {
return "an"
}
return "a"
}
lower := core.Lower(core.Trim(word))
for key := range consonantSounds {
if core.HasPrefix(lower, key) {
return "a"
@ -559,239 +313,6 @@ func Article(word string) string {
return "a"
}
// ArticleToken is an explicit alias for Article.
func ArticleToken(word string) string {
return Article(word)
}
func articleForCurrentLanguage(lowerWord, originalWord string) (string, bool) {
lang := currentLangForGrammar()
data := grammarDataForLang(lang)
if data == nil {
return "", false
}
if article, ok := articleForPluralForm(data, lowerWord, lang); ok {
return article, true
}
if article, ok := articleForFrenchPluralGuess(data, lowerWord, originalWord, lang); ok {
return article, true
}
if article, ok := articleByGender(data, lowerWord, originalWord, lang); ok {
return article, true
}
if article, ok := articleFromGrammarForms(data, originalWord); ok {
return article, true
}
return "", false
}
func articleByGender(data *GrammarData, lowerWord, originalWord, lang string) (string, bool) {
if len(data.Articles.ByGender) == 0 {
return "", false
}
forms, ok := data.Nouns[lowerWord]
if !ok || forms.Gender == "" {
return "", false
}
article, ok := data.Articles.ByGender[forms.Gender]
if !ok || article == "" {
return "", false
}
return maybeElideArticle(article, originalWord, lang), true
}
func articleForPluralForm(data *GrammarData, lowerWord, lang string) (string, bool) {
if !isFrenchLanguage(lang) {
return "", false
}
if !isKnownPluralNoun(data, lowerWord) {
return "", false
}
return "les", true
}
func articleForFrenchPluralGuess(data *GrammarData, lowerWord, originalWord, lang string) (string, bool) {
if !isFrenchLanguage(lang) {
return "", false
}
if isKnownPluralNoun(data, lowerWord) {
return "", false
}
if !looksLikeFrenchPlural(originalWord) {
return "", false
}
return "des", true
}
func isKnownPluralNoun(data *GrammarData, lowerWord string) bool {
if data == nil || len(data.Nouns) == 0 {
return false
}
for _, forms := range data.Nouns {
if forms.Other == "" || core.Lower(forms.Other) != lowerWord {
continue
}
if forms.One != "" && core.Lower(forms.One) == lowerWord {
continue
}
return true
}
return false
}
func articleFromGrammarForms(data *GrammarData, word string) (string, bool) {
if data.Articles.IndefiniteDefault == "" && data.Articles.IndefiniteVowel == "" {
return "", false
}
if usesVowelSoundArticle(word) && data.Articles.IndefiniteVowel != "" {
return data.Articles.IndefiniteVowel, true
}
if data.Articles.IndefiniteDefault != "" {
return data.Articles.IndefiniteDefault, true
}
if data.Articles.IndefiniteVowel != "" {
return data.Articles.IndefiniteVowel, true
}
return "", false
}
func maybeElideArticle(article, word, lang string) string {
if !isFrenchLanguage(lang) {
return article
}
if !startsWithVowelSound(word) {
return article
}
switch core.Lower(article) {
case "le", "la", "de", "je", "me", "te", "se", "ne", "ce":
// French elision keeps the leading consonant and replaces the final
// vowel with an apostrophe: le/la -> l', de -> d', je -> j', etc.
return core.Lower(article[:1]) + "'"
case "que":
return "qu'"
}
return article
}
func usesVowelSoundArticle(word string) bool {
trimmed := core.Trim(word)
if trimmed == "" {
return false
}
if isInitialism(trimmed) {
return initialismUsesVowelSound(trimmed)
}
lower := core.Lower(trimmed)
for key := range consonantSounds {
if core.HasPrefix(lower, key) {
return false
}
}
for key := range vowelSounds {
if core.HasPrefix(lower, key) {
return true
}
}
for _, r := range lower {
return isVowel(r)
}
return false
}
func looksLikeFrenchPlural(word string) bool {
trimmed := core.Trim(word)
if trimmed == "" || core.Contains(trimmed, " ") || core.Contains(trimmed, "\t") || isInitialism(trimmed) {
return false
}
lower := core.Lower(trimmed)
if isFrenchAspiratedHWord(lower) {
return false
}
if core.HasSuffix(lower, "aux") || core.HasSuffix(lower, "eaux") {
return true
}
return core.HasSuffix(lower, "s") || core.HasSuffix(lower, "x")
}
func startsWithVowelSound(word string) bool {
trimmed := core.Trim(word)
lower := core.Lower(trimmed)
if lower == "" {
return false
}
if isFrenchAspiratedHWord(lower) {
return false
}
r := []rune(lower)
switch r[0] {
case 'a', 'e', 'i', 'o', 'u', 'y',
'à', 'â', 'ä', 'æ', 'é', 'è', 'ê', 'ë',
'î', 'ï', 'ô', 'ö', 'ù', 'û', 'ü', 'œ', 'h':
return true
}
return false
}
func isFrenchAspiratedHWord(word string) bool {
switch word {
case "haricot", "héron", "héros", "honte", "hache", "hasard", "hibou", "houx", "hurluberlu":
return true
default:
return false
}
}
func isFrenchLanguage(lang string) bool {
lang = core.Lower(lang)
return lang == "fr" || core.HasPrefix(lang, "fr-")
}
func isInitialism(word string) bool {
if len(word) < 2 {
return false
}
hasLetter := false
for _, r := range word {
if !unicode.IsLetter(r) {
return false
}
hasLetter = true
if unicode.IsLower(r) {
return false
}
}
return hasLetter
}
func preserveInitialCapitalization(original, form string) string {
if original == "" || form == "" {
return form
}
originalRunes := []rune(original)
formRunes := []rune(form)
if len(originalRunes) == 0 || len(formRunes) == 0 {
return form
}
if !unicode.IsUpper(originalRunes[0]) {
return form
}
formRunes[0] = unicode.ToUpper(formRunes[0])
return string(formRunes)
}
func initialismUsesVowelSound(word string) bool {
if word == "" {
return false
}
switch unicode.ToUpper([]rune(word)[0]) {
case 'A', 'E', 'F', 'H', 'I', 'L', 'M', 'N', 'O', 'R', 'S', 'X':
return true
default:
return false
}
}
func isVowel(r rune) bool {
switch unicode.ToLower(r) {
case 'a', 'e', 'i', 'o', 'u':
@ -800,229 +321,49 @@ func isVowel(r rune) bool {
return false
}
// Title capitalises the first letter of each word-like segment.
//
// Hyphens and whitespace start a new segment; punctuation inside identifiers
// such as dots and underscores is preserved so filenames stay readable.
// Title capitalises the first letter of each word.
func Title(s string) string {
b := core.NewBuilder()
b.Grow(len(s))
capNext := true
prev := ' '
for _, r := range s {
if unicode.IsLetter(r) && capNext {
if !unicode.IsLetter(prev) && unicode.IsLetter(r) {
b.WriteRune(unicode.ToUpper(r))
} else {
b.WriteRune(r)
}
switch r {
case ' ', '\t', '\n', '\r', '-':
capNext = true
default:
capNext = false
}
prev = r
}
return b.String()
}
func renderWord(lang, word string) string {
if translated := getWord(lang, word); translated != "" {
return translated
}
return word
}
func renderWordOrTitle(lang, word string) string {
if translated := getWord(lang, word); translated != "" {
return translated
}
return Title(word)
}
// Quote wraps a string in double quotes.
func Quote(s string) string {
return strconv.Quote(s)
}
// ArticlePhrase prefixes a noun phrase with the correct article.
func ArticlePhrase(word string) string {
word = core.Trim(word)
if word == "" {
return ""
}
lang := currentLangForGrammar()
word = renderWord(lang, word)
article := Article(word)
return prefixWithArticle(article, word)
}
// DefiniteArticle returns the language-specific definite article token for a word.
// For languages such as French, this respects gendered articles, plural forms,
// and elision rules when grammar data is available.
func DefiniteArticle(word string) string {
word = core.Trim(word)
if word == "" {
return ""
}
lower := core.Lower(word)
if article, ok := definiteArticleForCurrentLanguage(lower, word); ok {
return article
}
lang := currentLangForGrammar()
data := grammarDataForLang(lang)
if data != nil && data.Articles.Definite != "" {
return data.Articles.Definite
}
return "the"
}
// DefiniteToken is an explicit alias for DefiniteArticle.
func DefiniteToken(word string) string {
return DefiniteArticle(word)
}
// DefinitePhrase prefixes a noun phrase with the correct definite article.
func DefinitePhrase(word string) string {
word = core.Trim(word)
if word == "" {
return ""
}
lang := currentLangForGrammar()
word = renderWord(lang, word)
article := DefiniteArticle(word)
return prefixWithArticle(article, word)
}
func definiteArticleForCurrentLanguage(lowerWord, originalWord string) (string, bool) {
lang := currentLangForGrammar()
data := grammarDataForLang(lang)
if data == nil {
return "", false
}
if article, ok := articleByGender(data, lowerWord, originalWord, lang); ok {
return article, true
}
if article, ok := definiteArticleFromGrammarForms(data, lowerWord, originalWord, lang); ok {
return article, true
}
return "", false
}
func grammarDataForLang(lang string) *GrammarData {
if data := GetGrammarData(lang); data != nil {
return data
}
if base := baseLanguageTag(lang); base != "" {
return GetGrammarData(base)
}
return nil
}
func baseLanguageTag(lang string) string {
if idx := indexAny(lang, "-_"); idx > 0 {
return lang[:idx]
}
return ""
}
func definiteArticleFromGrammarForms(data *GrammarData, lowerWord, originalWord, lang string) (string, bool) {
if data == nil || data.Articles.Definite == "" {
return "", false
}
if isFrenchLanguage(lang) {
if isKnownPluralNoun(data, lowerWord) || looksLikeFrenchPlural(originalWord) {
return "les", true
}
return maybeElideArticle(data.Articles.Definite, originalWord, lang), true
}
return data.Articles.Definite, true
return `"` + s + `"`
}
// TemplateFuncs returns the template.FuncMap with all grammar functions.
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"title": Title,
"lower": Lower,
"upper": Upper,
"n": N,
"number": Number,
"int": Number,
"decimal": Decimal,
"float": Decimal,
"percent": Percent,
"pct": Percent,
"bytes": Bytes,
"size": Bytes,
"ordinal": Ordinal,
"ord": Ordinal,
"ago": Ago,
"past": PastTense,
"gerund": Gerund,
"plural": Pluralize,
"pluralForm": PluralForm,
"article": ArticlePhrase,
"articleToken": ArticleToken,
"articlePhrase": ArticlePhrase,
"definiteArticle": DefiniteArticle,
"definiteToken": DefiniteToken,
"definite": DefinitePhrase,
"definitePhrase": DefinitePhrase,
"quote": Quote,
"label": Label,
"progress": Progress,
"progressSubject": ProgressSubject,
"actionResult": ActionResult,
"actionFailed": ActionFailed,
"prompt": Prompt,
"lang": Lang,
"timeAgo": TimeAgo,
"formatAgo": FormatAgo,
"title": Title,
"lower": core.Lower,
"upper": core.Upper,
"past": PastTense,
"gerund": Gerund,
"plural": Pluralize,
"pluralForm": PluralForm,
"article": Article,
"quote": Quote,
}
}
// Number formats an integer using the current locale's number rules.
func Number(value any) string {
return FormatNumber(toInt64(value))
}
// Decimal formats a decimal using the current locale's number rules.
func Decimal(value any) string {
return FormatDecimal(toFloat64(value))
}
// Percent formats a percentage using the current locale's number rules.
func Percent(value any) string {
return FormatPercent(toFloat64(value))
}
// Bytes formats a byte count using the current locale's number rules.
func Bytes(value any) string {
return FormatBytes(toInt64(value))
}
// Ordinal formats a number as an ordinal using the current locale.
func Ordinal(value any) string {
return FormatOrdinal(toInt(value))
}
// Ago formats a relative time using the current locale's ago rules.
func Ago(count int, unit string) string {
return FormatAgo(count, unit)
}
func prefixWithArticle(article, word string) string {
if article == "" || word == "" {
return ""
}
if core.HasSuffix(article, "'") {
return article + word
}
return article + " " + word
}
// Progress returns a progress message: "Building..."
func Progress(verb string) string {
lang := currentLangForGrammar()
word := renderWord(lang, verb)
word := getWord(lang, verb)
if word == "" {
word = verb
}
g := Gerund(word)
if g == "" {
return ""
@ -1034,65 +375,48 @@ func Progress(verb string) string {
// ProgressSubject returns a progress message with subject: "Building project..."
func ProgressSubject(verb, subject string) string {
lang := currentLangForGrammar()
word := renderWord(lang, verb)
word := getWord(lang, verb)
if word == "" {
word = verb
}
g := Gerund(word)
if g == "" {
return ""
}
suffix := getPunct(lang, "progress", "...")
subject = core.Trim(subject)
if subject == "" {
return Title(g) + suffix
}
return Title(g) + " " + renderWord(lang, subject) + suffix
return Title(g) + " " + subject + suffix
}
// ActionResult returns a completion message: "File deleted"
func ActionResult(verb, subject string) string {
p := PastTense(verb)
if p == "" {
if p == "" || subject == "" {
return ""
}
subject = core.Trim(subject)
if subject == "" {
return Title(p)
}
return renderWordOrTitle(currentLangForGrammar(), subject) + " " + p
return Title(subject) + " " + p
}
// ActionFailed returns a failure message: "Failed to delete file"
func ActionFailed(verb, subject string) string {
verb = core.Trim(verb)
if verb == "" {
return ""
}
lang := currentLangForGrammar()
// Keep the failure verb in sentence case when no locale override exists.
verb = renderWord(lang, core.Lower(verb))
prefix := failedPrefix(lang)
subject = core.Trim(subject)
if subject == "" {
return prefix + " " + verb
return "Failed to " + verb
}
return prefix + " " + verb + " " + renderWord(lang, subject)
}
func failedPrefix(lang string) string {
prefix := renderWord(lang, "failed_to")
if prefix == "" || prefix == "failed_to" {
return "Failed to"
}
return prefix
return "Failed to " + verb + " " + subject
}
// Label returns a label with suffix: "Status:" (EN) or "Statut :" (FR)
func Label(word string) string {
word = core.Trim(word)
if word == "" {
return ""
}
lang := currentLangForGrammar()
translated := renderWordOrTitle(lang, word)
translated := getWord(lang, word)
if translated == "" {
translated = word
}
suffix := getPunct(lang, "label", ":")
return translated + suffix
return Title(translated) + suffix
}

View file

@ -1,21 +1,6 @@
package i18n
import (
"strings"
"testing"
"text/template"
"time"
)
type regionFallbackLoader struct{}
func (regionFallbackLoader) Languages() []string {
return []string{"en-GB"}
}
func (regionFallbackLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
return map[string]Message{}, nil, nil
}
import "testing"
func TestPastTense(t *testing.T) {
// Ensure grammar data is loaded from embedded JSON
@ -104,7 +89,6 @@ func TestPastTense(t *testing.T) {
{"push", "pushed"},
{"pull", "pulled"},
{"start", "started"},
{"panic", "panicked"},
{"copy", "copied"},
{"apply", "applied"},
@ -171,7 +155,6 @@ func TestGerund(t *testing.T) {
{"push", "pushing"},
{"pull", "pulling"},
{"start", "starting"},
{"panic", "panicking"},
{"die", "dying"},
// Edge cases
@ -236,62 +219,6 @@ func TestPluralize(t *testing.T) {
}
}
func TestPluralize_UsesLocaleSingularOverride(t *testing.T) {
const lang = "en-x-singular"
prev := Default()
t.Cleanup(func() {
SetDefault(prev)
SetGrammarData(lang, nil)
})
svc, err := NewWithLoader(pluralizeOverrideLoader{})
if err != nil {
t.Fatalf("NewWithLoader() failed: %v", err)
}
SetDefault(svc)
if err := SetLanguage(lang); err != nil {
t.Fatalf("SetLanguage(%s) failed: %v", lang, err)
}
if got, want := Pluralize("person", 1), "human"; got != want {
t.Fatalf("Pluralize(%q, 1) = %q, want %q", "person", got, want)
}
if got, want := Pluralize("Person", 1), "Human"; got != want {
t.Fatalf("Pluralize(%q, 1) = %q, want %q", "Person", got, want)
}
if got, want := Pluralize("person", 2), "people"; got != want {
t.Fatalf("Pluralize(%q, 2) = %q, want %q", "person", got, want)
}
}
func TestPluralize_PreservesUnicodeCapitalization(t *testing.T) {
prev := Default()
t.Cleanup(func() {
SetDefault(prev)
})
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
if err := SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
if got, want := Pluralize("Élément", 1), "Élément"; got != want {
t.Fatalf("Pluralize(%q, 1) = %q, want %q", "Élément", got, want)
}
if got, want := Pluralize("Élément", 2), "Éléments"; got != want {
t.Fatalf("Pluralize(%q, 2) = %q, want %q", "Élément", got, want)
}
if got, want := PluralForm("Élément"), "Éléments"; got != want {
t.Fatalf("PluralForm(%q) = %q, want %q", "Élément", got, want)
}
}
func TestPluralForm(t *testing.T) {
svc, err := New()
if err != nil {
@ -339,8 +266,6 @@ func TestArticle(t *testing.T) {
{"honest", "an"}, // Vowel sound
{"university", "a"}, // Consonant sound
{"one", "a"}, // Consonant sound
{"SSH", "an"}, // Initialism: "ess-ess-aitch"
{"URL", "a"}, // Initialism: "you-are-ell"
{"", ""},
}
@ -354,147 +279,6 @@ func TestArticle(t *testing.T) {
}
}
func TestArticleTokenAliases(t *testing.T) {
if got, want := ArticleToken("apple"), Article("apple"); got != want {
t.Fatalf("ArticleToken(apple) = %q, want %q", got, want)
}
if got, want := DefiniteToken("apple"), DefiniteArticle("apple"); got != want {
t.Fatalf("DefiniteToken(apple) = %q, want %q", got, want)
}
}
func TestArticleFrenchLocale(t *testing.T) {
prev := Default()
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
if err := SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
tests := []struct {
word string
want string
}{
{"branche", "la"},
{"branches", "les"},
{"amis", "des"},
{"enfant", "l'"},
{"fichier", "le"},
{"inconnu", "un"},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
got := Article(tt.word)
if got != tt.want {
t.Errorf("Article(%q) = %q, want %q", tt.word, got, tt.want)
}
})
}
}
func TestArticleFrenchElisionKeepsLeadingConsonant(t *testing.T) {
prevData := GetGrammarData("fr")
t.Cleanup(func() {
SetGrammarData("fr", prevData)
})
prev := Default()
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
if err := SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
SetGrammarData("fr", &GrammarData{
Nouns: map[string]NounForms{
"amie": {One: "amie", Other: "amies", Gender: "f"},
"accord": {One: "accord", Other: "accords", Gender: "d"},
"homme": {One: "homme", Other: "hommes", Gender: "m"},
"héros": {One: "héros", Other: "héros", Gender: "m"},
"idole": {One: "idole", Other: "idoles", Gender: "j"},
},
Articles: ArticleForms{
IndefiniteDefault: "un",
IndefiniteVowel: "un",
Definite: "le",
ByGender: map[string]string{
"d": "de",
"f": "la",
"j": "je",
"m": "le",
},
},
})
tests := []struct {
word string
want string
}{
{"homme", "l'"},
{"héros", "le"},
{"amie", "l'"},
{"accord", "d'"},
{"idole", "j'"},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
got := Article(tt.word)
if got != tt.want {
t.Errorf("Article(%q) = %q, want %q", tt.word, got, tt.want)
}
})
}
phraseTests := []struct {
word string
want string
}{
{"accord", "d'accord"},
{"idole", "j'idole"},
}
for _, tt := range phraseTests {
t.Run(tt.word+"_phrase", func(t *testing.T) {
got := ArticlePhrase(tt.word)
if got != tt.want {
t.Errorf("ArticlePhrase(%q) = %q, want %q", tt.word, got, tt.want)
}
})
}
}
type pluralizeOverrideLoader struct{}
func (pluralizeOverrideLoader) Languages() []string {
return []string{"en-x-singular"}
}
func (pluralizeOverrideLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
grammar := &GrammarData{
Nouns: map[string]NounForms{
"person": {One: "human", Other: "people"},
},
}
SetGrammarData(lang, grammar)
return map[string]Message{}, grammar, nil
}
func TestTitle(t *testing.T) {
tests := []struct {
input string
@ -505,7 +289,6 @@ func TestTitle(t *testing.T) {
{"", ""},
{"HELLO", "HELLO"},
{"hello-world", "Hello-World"},
{"config.yaml", "Config.yaml"},
}
for _, tt := range tests {
@ -522,191 +305,6 @@ func TestQuote(t *testing.T) {
if got := Quote("hello"); got != `"hello"` {
t.Errorf("Quote(%q) = %q, want %q", "hello", got, `"hello"`)
}
if got := Quote(`a "quoted" path\name`); got != `"a \"quoted\" path\\name"` {
t.Errorf("Quote(%q) = %q, want %q", `a "quoted" path\name`, got, `"a \"quoted\" path\\name"`)
}
}
func TestCaseHelpers(t *testing.T) {
if got := Lower("HELLO"); got != "hello" {
t.Fatalf("Lower(%q) = %q, want %q", "HELLO", got, "hello")
}
if got := Upper("hello"); got != "HELLO" {
t.Fatalf("Upper(%q) = %q, want %q", "hello", got, "HELLO")
}
}
func TestArticlePhrase(t *testing.T) {
tests := []struct {
word string
want string
}{
{"file", "a file"},
{"error", "an error"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
got := ArticlePhrase(tt.word)
if got != tt.want {
t.Errorf("ArticlePhrase(%q) = %q, want %q", tt.word, got, tt.want)
}
})
}
}
func TestArticlePhrase_RespectsWordMap(t *testing.T) {
prev := Default()
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
data := GetGrammarData("en")
if data == nil {
t.Fatal("GetGrammarData(\"en\") returned nil")
}
original, existed := data.Words["go_mod"]
data.Words["go_mod"] = "go.mod"
t.Cleanup(func() {
if existed {
data.Words["go_mod"] = original
return
}
delete(data.Words, "go_mod")
})
if got, want := ArticlePhrase("go_mod"), "a go.mod"; got != want {
t.Fatalf("ArticlePhrase(%q) = %q, want %q", "go_mod", got, want)
}
}
func TestArticlePhrase_UsesRenderedWordForArticleSelection(t *testing.T) {
prev := Default()
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
data := GetGrammarData("en")
if data == nil {
t.Fatal("GetGrammarData(\"en\") returned nil")
}
original, existed := data.Words["ssh"]
data.Words["ssh"] = "SSH"
t.Cleanup(func() {
if existed {
data.Words["ssh"] = original
return
}
delete(data.Words, "ssh")
})
if got, want := ArticlePhrase("ssh"), "an SSH"; got != want {
t.Fatalf("ArticlePhrase(%q) = %q, want %q", "ssh", got, want)
}
}
func TestArticlePhraseFrenchLocale(t *testing.T) {
prev := Default()
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
if err := SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
tests := []struct {
word string
want string
}{
{"branche", "la branche"},
{"branches", "les branches"},
{"amis", "des amis"},
{"enfant", "l'enfant"},
{"fichier", "le fichier"},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
got := ArticlePhrase(tt.word)
if got != tt.want {
t.Errorf("ArticlePhrase(%q) = %q, want %q", tt.word, got, tt.want)
}
})
}
}
func TestDefiniteArticle(t *testing.T) {
tests := []struct {
word string
want string
}{
{"file", "the"},
{"error", "the"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
got := DefiniteArticle(tt.word)
if got != tt.want {
t.Errorf("DefiniteArticle(%q) = %q, want %q", tt.word, got, tt.want)
}
})
}
}
func TestDefinitePhraseFrenchLocale(t *testing.T) {
prev := Default()
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
if err := SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
tests := []struct {
word string
want string
}{
{"branche", "la branche"},
{"branches", "les branches"},
{"amis", "les amis"},
{"enfant", "l'enfant"},
{"fichier", "le fichier"},
{"héros", "le héros"},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
got := DefinitePhrase(tt.word)
if got != tt.want {
t.Errorf("DefinitePhrase(%q) = %q, want %q", tt.word, got, tt.want)
}
})
}
}
func TestLabel(t *testing.T) {
@ -734,27 +332,6 @@ func TestLabel(t *testing.T) {
}
}
func TestCompositionHelpersTrimWhitespace(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
if got, want := Label(" status "), "Status:"; got != want {
t.Fatalf("Label(%q) = %q, want %q", " status ", got, want)
}
if got, want := Article(" error "), "an"; got != want {
t.Fatalf("Article(%q) = %q, want %q", " error ", got, want)
}
if got, want := ArticlePhrase(" go_mod "), "a go.mod"; got != want {
t.Fatalf("ArticlePhrase(%q) = %q, want %q", " go_mod ", got, want)
}
if got, want := ActionFailed(" delete ", " file "), "Failed to delete file"; got != want {
t.Fatalf("ActionFailed(%q, %q) = %q, want %q", " delete ", " file ", got, want)
}
}
func TestProgress(t *testing.T) {
svc, err := New()
if err != nil {
@ -806,10 +383,10 @@ func TestActionResult(t *testing.T) {
verb, subject string
want string
}{
{"delete", "config.yaml", "Config.yaml deleted"},
{"delete", "config.yaml", "Config.Yaml deleted"},
{"build", "project", "Project built"},
{"", "file", ""},
{"delete", "", "Deleted"},
{"delete", "", ""},
}
for _, tt := range tests {
@ -828,7 +405,6 @@ func TestActionFailed(t *testing.T) {
want string
}{
{"delete", "config.yaml", "Failed to delete config.yaml"},
{"Delete", "config.yaml", "Failed to delete config.yaml"},
{"push", "commits", "Failed to push commits"},
{"push", "", "Failed to push"},
{"", "", ""},
@ -844,56 +420,6 @@ func TestActionFailed(t *testing.T) {
}
}
func TestActionFailed_RespectsWordMap(t *testing.T) {
prev := Default()
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
data := GetGrammarData("en")
if data == nil {
t.Fatal("GetGrammarData(\"en\") returned nil")
}
original, existed := data.Words["push"]
data.Words["push"] = "submit"
t.Cleanup(func() {
if existed {
data.Words["push"] = original
return
}
delete(data.Words, "push")
})
if got, want := ActionFailed("push", "commits"), "Failed to submit commits"; got != want {
t.Fatalf("ActionFailed(%q, %q) = %q, want %q", "push", "commits", got, want)
}
}
func TestActionFailedFrenchLocale(t *testing.T) {
prev := Default()
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
if err := SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
if got, want := ActionFailed("supprimer", ""), "Impossible de supprimer"; got != want {
t.Fatalf("ActionFailed(%q, %q) = %q, want %q", "supprimer", "", got, want)
}
}
func TestGrammarData_Signals(t *testing.T) {
svc, err := New()
if err != nil {
@ -1056,96 +582,9 @@ func TestFrenchGrammarData(t *testing.T) {
}
}
func TestGrammarFallbackToBaseLanguageTag(t *testing.T) {
prevDefault := Default()
prevGrammar := GetGrammarData("en")
t.Cleanup(func() {
SetGrammarData("en", prevGrammar)
SetDefault(prevDefault)
})
SetGrammarData("en", &GrammarData{
Verbs: map[string]VerbForms{
"delete": {Past: "deleted", Gerund: "deleting"},
},
Nouns: map[string]NounForms{
"file": {One: "file", Other: "files"},
},
Articles: ArticleForms{
IndefiniteDefault: "a",
IndefiniteVowel: "an",
Definite: "the",
},
Punct: PunctuationRules{
LabelSuffix: ":",
ProgressSuffix: "...",
},
Words: map[string]string{
"status": "Status",
},
})
svc, err := NewWithLoader(regionFallbackLoader{})
if err != nil {
t.Fatalf("NewWithLoader() failed: %v", err)
}
SetDefault(svc)
if err := svc.SetLanguage("en-GB"); err != nil {
t.Fatalf("SetLanguage(en-GB) failed: %v", err)
}
if got := PastTense("delete"); got != "deleted" {
t.Fatalf("PastTense(delete) = %q, want %q", got, "deleted")
}
if got := Pluralize("file", 2); got != "files" {
t.Fatalf("Pluralize(file, 2) = %q, want %q", got, "files")
}
if got := Article("apple"); got != "an" {
t.Fatalf("Article(apple) = %q, want %q", got, "an")
}
if got := Label("status"); got != "Status:" {
t.Fatalf("Label(status) = %q, want %q", got, "Status:")
}
}
func TestTemplateFuncs(t *testing.T) {
funcs := TemplateFuncs()
expected := []string{
"title",
"lower",
"upper",
"n",
"number",
"int",
"decimal",
"float",
"percent",
"pct",
"bytes",
"size",
"ordinal",
"ord",
"ago",
"past",
"gerund",
"plural",
"pluralForm",
"article",
"articlePhrase",
"definiteArticle",
"definite",
"definitePhrase",
"quote",
"label",
"progress",
"progressSubject",
"actionResult",
"actionFailed",
"prompt",
"lang",
"timeAgo",
"formatAgo",
}
expected := []string{"title", "lower", "upper", "past", "gerund", "plural", "pluralForm", "article", "quote"}
for _, name := range expected {
if _, ok := funcs[name]; !ok {
t.Errorf("TemplateFuncs() missing %q", name)
@ -1153,215 +592,98 @@ func TestTemplateFuncs(t *testing.T) {
}
}
func TestTemplateFuncs_Article(t *testing.T) {
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(`{{article "apple"}}`)
if err != nil {
t.Fatalf("Parse() failed: %v", err)
}
// --- AX: Good/Bad/Ugly ---
var buf strings.Builder
if err := tmpl.Execute(&buf, nil); err != nil {
t.Fatalf("Execute() failed: %v", err)
}
if got, want := buf.String(), "an apple"; got != want {
t.Fatalf("template article = %q, want %q", got, want)
}
tmpl, err = template.New("").Funcs(TemplateFuncs()).Parse(`{{articleToken "apple"}}|{{articlePhrase "apple"}}|{{definiteToken "apple"}}|{{definitePhrase "apple"}}`)
if err != nil {
t.Fatalf("Parse() alias helpers failed: %v", err)
}
buf.Reset()
if err := tmpl.Execute(&buf, nil); err != nil {
t.Fatalf("Execute() alias helpers failed: %v", err)
}
if got, want := buf.String(), "an|an apple|the|the apple"; got != want {
t.Fatalf("template article aliases = %q, want %q", got, want)
}
tmpl, err = template.New("").Funcs(TemplateFuncs()).Parse(`{{definiteArticle "apple"}}`)
if err != nil {
t.Fatalf("Parse() definite article helper failed: %v", err)
}
buf.Reset()
if err := tmpl.Execute(&buf, nil); err != nil {
t.Fatalf("Execute() definite article helper failed: %v", err)
}
if got, want := buf.String(), "the"; got != want {
t.Fatalf("template definite article = %q, want %q", got, want)
// TestPastTense_Good verifies correct past tense for a known regular verb.
//
// PastTense("deploy") // "deployed"
func TestPastTense_Good(t *testing.T) {
svc, _ := New()
SetDefault(svc)
if got := PastTense("deploy"); got != "deployed" {
t.Errorf("PastTense(deploy) = %q, want %q", got, "deployed")
}
}
func TestTemplateFuncs_CompositeHelpers(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// TestPastTense_Bad verifies that an empty verb returns an empty string.
//
// PastTense("") // ""
func TestPastTense_Bad(t *testing.T) {
svc, _ := New()
SetDefault(svc)
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(
`{{label "status"}}|{{progress "build"}}|{{progressSubject "build" "project"}}|{{actionResult "delete" "file"}}|{{actionFailed "delete" "file"}}`,
)
if err != nil {
t.Fatalf("Parse() failed: %v", err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, nil); err != nil {
t.Fatalf("Execute() failed: %v", err)
}
want := "Status:|Building...|Building project...|File deleted|Failed to delete file"
if got := buf.String(); got != want {
t.Fatalf("template composite helpers = %q, want %q", got, want)
if got := PastTense(""); got != "" {
t.Errorf("PastTense(\"\") = %q, want empty", got)
}
}
func TestTemplateFuncs_PromptAndLang(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// TestPastTense_Ugly verifies that a whitespace-only verb is trimmed and returns empty.
//
// PastTense(" ") // ""
func TestPastTense_Ugly(t *testing.T) {
svc, _ := New()
SetDefault(svc)
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(
`{{prompt "confirm"}}|{{lang "de"}}`,
)
if err != nil {
t.Fatalf("Parse() failed: %v", err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, nil); err != nil {
t.Fatalf("Execute() failed: %v", err)
}
if got, want := buf.String(), "Are you sure?|German"; got != want {
t.Fatalf("template prompt/lang = %q, want %q", got, want)
if got := PastTense(" "); got != "" {
t.Errorf("PastTense(whitespace) = %q, want empty", got)
}
}
func TestTemplateFuncs_NumericAlias(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// TestGerund_Good verifies correct gerund for a known verb.
//
// Gerund("build") // "building"
func TestGerund_Good(t *testing.T) {
svc, _ := New()
SetDefault(svc)
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(
`{{n "number" 1234567}}|{{n "ago" 3 "hours"}}`,
)
if err != nil {
t.Fatalf("Parse() failed: %v", err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, nil); err != nil {
t.Fatalf("Execute() failed: %v", err)
}
got := buf.String()
if !strings.HasPrefix(got, "1,234,567|3 hours ago") {
t.Fatalf("template numeric alias = %q, want prefix %q", got, "1,234,567|3 hours ago")
if got := Gerund("build"); got != "building" {
t.Errorf("Gerund(build) = %q, want %q", got, "building")
}
}
func TestTemplateFuncs_NumericDirectAliases(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// TestGerund_Bad verifies that an empty verb returns empty.
//
// Gerund("") // ""
func TestGerund_Bad(t *testing.T) {
svc, _ := New()
SetDefault(svc)
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(
`{{int 1234567}}|{{float 3.14}}|{{pct 0.85}}|{{size 1536000}}|{{ord 3}}|{{ago 3 "hours"}}`,
)
if err != nil {
t.Fatalf("Parse() failed: %v", err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, nil); err != nil {
t.Fatalf("Execute() failed: %v", err)
}
got := buf.String()
if !strings.HasPrefix(got, "1,234,567|3.14|85%|1.46 MB|3rd|3 hours ago") {
t.Fatalf("template direct numeric aliases = %q, want prefix %q", got, "1,234,567|3.14|85%|1.46 MB|3rd|3 hours ago")
if got := Gerund(""); got != "" {
t.Errorf("Gerund(\"\") = %q, want empty", got)
}
}
func TestTemplateFuncs_TimeHelpers(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// TestGerund_Ugly verifies that a mixed-case verb is normalised before lookup.
//
// Gerund("BUILD") // "building"
func TestGerund_Ugly(t *testing.T) {
svc, _ := New()
SetDefault(svc)
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(
`{{formatAgo 3 "hour"}}|{{timeAgo .}}`,
)
if err != nil {
t.Fatalf("Parse() failed: %v", err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, time.Now().Add(-5*time.Minute)); err != nil {
t.Fatalf("Execute() failed: %v", err)
}
got := buf.String()
if !strings.HasPrefix(got, "3 hours ago|") {
t.Fatalf("template time helpers prefix = %q, want %q", got, "3 hours ago|")
}
if !strings.Contains(got, "minutes ago") && !strings.Contains(got, "just now") {
t.Fatalf("template time helpers suffix = %q, want relative time output", got)
if got := Gerund("BUILD"); got != "building" {
t.Errorf("Gerund(BUILD) = %q, want %q", got, "building")
}
}
func TestCompositeHelpersRespectWordMap(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
// TestArticle_Good verifies correct indefinite article for a known noun.
//
// Article("error") // "an"
func TestArticle_Good(t *testing.T) {
if got := Article("error"); got != "an" {
t.Errorf("Article(error) = %q, want %q", got, "an")
}
SetDefault(svc)
}
data := GetGrammarData("en")
if data == nil {
t.Fatal("GetGrammarData(\"en\") returned nil")
// TestArticle_Bad verifies that an empty string returns empty.
//
// Article("") // ""
func TestArticle_Bad(t *testing.T) {
if got := Article(""); got != "" {
t.Errorf("Article(\"\") = %q, want empty", got)
}
original, existed := data.Words["go_mod"]
data.Words["go_mod"] = "go.mod"
t.Cleanup(func() {
if existed {
data.Words["go_mod"] = original
return
}
delete(data.Words, "go_mod")
})
}
if got, want := Label("go_mod"), "go.mod:"; got != want {
t.Fatalf("Label(%q) = %q, want %q", "go_mod", got, want)
}
if got, want := ProgressSubject("build", "go_mod"), "Building go.mod..."; got != want {
t.Fatalf("ProgressSubject(%q, %q) = %q, want %q", "build", "go_mod", got, want)
}
if got, want := ProgressSubject("build", ""), "Building..."; got != want {
t.Fatalf("ProgressSubject(%q, %q) = %q, want %q", "build", "", got, want)
}
if got, want := ActionResult("delete", "go_mod"), "go.mod deleted"; got != want {
t.Fatalf("ActionResult(%q, %q) = %q, want %q", "delete", "go_mod", got, want)
}
if got, want := ActionResult("delete", ""), "Deleted"; got != want {
t.Fatalf("ActionResult(%q, %q) = %q, want %q", "delete", "", got, want)
}
if got, want := ActionFailed("delete", "go_mod"), "Failed to delete go.mod"; got != want {
t.Fatalf("ActionFailed(%q, %q) = %q, want %q", "delete", "go_mod", got, want)
// TestArticle_Ugly verifies the silent-h exception "hour" gets "an".
//
// Article("hour") // "an"
func TestArticle_Ugly(t *testing.T) {
if got := Article("hour"); got != "an" {
t.Errorf("Article(hour) = %q, want %q", got, "an")
}
}

View file

@ -1,9 +1,6 @@
package i18n
import (
"fmt"
"unicode"
"dappco.re/go/core"
)
@ -16,13 +13,7 @@ func (h LabelHandler) Match(key string) bool {
func (h LabelHandler) Handle(key string, args []any, next func() string) string {
word := core.TrimPrefix(key, "i18n.label.")
if got := Label(word); got != "" {
return got
}
if next != nil {
return next()
}
return ""
return Label(word)
}
// ProgressHandler handles i18n.progress.{verb} -> "Building..." patterns.
@ -35,19 +26,11 @@ func (h ProgressHandler) Match(key string) bool {
func (h ProgressHandler) Handle(key string, args []any, next func() string) string {
verb := core.TrimPrefix(key, "i18n.progress.")
if len(args) > 0 {
if subj := subjectArgText(args[0]); subj != "" {
if got := ProgressSubject(verb, subj); got != "" {
return got
}
if subj, ok := args[0].(string); ok {
return ProgressSubject(verb, subj)
}
}
if got := Progress(verb); got != "" {
return got
}
if next != nil {
return next()
}
return ""
return Progress(verb)
}
// CountHandler handles i18n.count.{noun} -> "5 files" patterns.
@ -59,18 +42,11 @@ func (h CountHandler) Match(key string) bool {
func (h CountHandler) Handle(key string, args []any, next func() string) string {
noun := core.TrimPrefix(key, "i18n.count.")
lang := currentLangForGrammar()
if core.Trim(noun) == "" {
if next != nil {
return next()
}
return ""
}
if len(args) > 0 {
count := getCount(args[0])
return core.Sprintf("%s %s", FormatNumber(int64(count)), countWordForm(lang, noun, count))
count := toInt(args[0])
return core.Sprintf("%d %s", count, Pluralize(noun, count))
}
return countWordForm(lang, noun, 1)
return noun
}
// DoneHandler handles i18n.done.{verb} -> "File deleted" patterns.
@ -83,19 +59,11 @@ func (h DoneHandler) Match(key string) bool {
func (h DoneHandler) Handle(key string, args []any, next func() string) string {
verb := core.TrimPrefix(key, "i18n.done.")
if len(args) > 0 {
if subj := subjectArgText(args[0]); subj != "" {
if got := ActionResult(verb, subj); got != "" {
return got
}
if subj, ok := args[0].(string); ok {
return ActionResult(verb, subj)
}
}
if got := Title(PastTense(verb)); got != "" {
return got
}
if next != nil {
return next()
}
return ""
return Title(PastTense(verb))
}
// FailHandler handles i18n.fail.{verb} -> "Failed to delete file" patterns.
@ -108,19 +76,11 @@ func (h FailHandler) Match(key string) bool {
func (h FailHandler) Handle(key string, args []any, next func() string) string {
verb := core.TrimPrefix(key, "i18n.fail.")
if len(args) > 0 {
if subj := subjectArgText(args[0]); subj != "" {
if got := ActionFailed(verb, subj); got != "" {
return got
}
if subj, ok := args[0].(string); ok {
return ActionFailed(verb, subj)
}
}
if got := ActionFailed(verb, ""); got != "" {
return got
}
if next != nil {
return next()
}
return ""
return ActionFailed(verb, "")
}
// NumericHandler handles i18n.numeric.{format} -> formatted numbers.
@ -132,10 +92,7 @@ func (h NumericHandler) Match(key string) bool {
func (h NumericHandler) Handle(key string, args []any, next func() string) string {
if len(args) == 0 {
if next != nil {
return next()
}
return ""
return next()
}
format := core.TrimPrefix(key, "i18n.numeric.")
switch format {
@ -156,15 +113,10 @@ func (h NumericHandler) Handle(key string, args []any, next func() string) strin
}
}
}
if next != nil {
return next()
}
return ""
return next()
}
// DefaultHandlers returns the built-in i18n.* namespace handlers.
//
// handlers := i18n.DefaultHandlers()
func DefaultHandlers() []KeyHandler {
return []KeyHandler{
LabelHandler{},
@ -176,135 +128,9 @@ func DefaultHandlers() []KeyHandler {
}
}
func countWordForm(lang, noun string, count int) string {
if hasGrammarCountForms(lang, noun) {
return Pluralize(noun, count)
}
display := renderWord(lang, noun)
if display == "" {
return Pluralize(noun, count)
}
if count == 1 {
return display
}
if !isPluralisableWordDisplay(display) {
return display
}
if isUpperAcronymPlural(display) {
return display
}
return Pluralize(display, count)
}
func hasGrammarCountForms(lang, noun string) bool {
data := GetGrammarData(lang)
if data == nil || len(data.Nouns) == 0 {
return false
}
forms, ok := data.Nouns[core.Lower(noun)]
if !ok {
return false
}
return forms.One != "" || forms.Other != ""
}
func isPluralisableWordDisplay(s string) bool {
hasLetter := false
for _, r := range s {
switch {
case unicode.IsLetter(r):
hasLetter = true
case unicode.IsSpace(r):
// Multi-word vocabulary entries should stay exact. The count handler
// prefixes the quantity, but does not invent a plural form for phrases.
return false
default:
return false
}
}
return hasLetter
}
func isUpperAcronymPlural(s string) bool {
if len(s) < 2 || !core.HasSuffix(s, "s") {
return false
}
hasLetter := false
for _, r := range s[:len(s)-1] {
if !unicode.IsLetter(r) {
continue
}
hasLetter = true
if !unicode.IsUpper(r) {
return false
}
}
return hasLetter
}
func isAllUpper(s string) bool {
hasLetter := false
for _, r := range s {
if !unicode.IsLetter(r) {
continue
}
hasLetter = true
if !unicode.IsUpper(r) {
return false
}
}
return hasLetter
}
func subjectArgText(arg any) string {
switch v := arg.(type) {
case string:
return v
case *Subject:
if v == nil {
return ""
}
return v.String()
case *TranslationContext:
if v == nil {
return ""
}
if text := core.Trim(v.String()); text != "" {
return text
}
if v.Extra != nil {
if text := contextArgText(v.Extra); text != "" {
return text
}
}
return ""
case map[string]any:
return contextArgText(v)
case map[string]string:
return contextArgText(v)
case fmt.Stringer:
return v.String()
default:
return ""
}
}
func contextArgText(values any) string {
for _, key := range []string{"Subject", "subject", "Value", "value", "Text", "text", "Context", "context", "Noun", "noun"} {
if text, ok := mapValueString(values, key); ok {
return text
}
}
return ""
}
// RunHandlerChain executes a chain of handlers for a key.
func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback func() string) string {
for i, h := range handlers {
if h == nil {
continue
}
if h.Match(key) {
next := func() string {
remaining := handlers[i+1:]
@ -319,22 +145,6 @@ func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback fun
return fallback()
}
func filterNilHandlers(handlers []KeyHandler) []KeyHandler {
if len(handlers) == 0 {
return nil
}
filtered := make([]KeyHandler, 0, len(handlers))
for _, h := range handlers {
if h != nil {
filtered = append(filtered, h)
}
}
if len(filtered) == 0 {
return nil
}
return filtered
}
var (
_ KeyHandler = LabelHandler{}
_ KeyHandler = ProgressHandler{}

View file

@ -22,11 +22,6 @@ func TestLabelHandler(t *testing.T) {
if got != "Status:" {
t.Errorf("LabelHandler.Handle(status) = %q, want %q", got, "Status:")
}
got = h.Handle("i18n.label.", nil, func() string { return "fallback" })
if got != "fallback" {
t.Errorf("LabelHandler.Handle(empty) = %q, want %q", got, "fallback")
}
}
func TestProgressHandler(t *testing.T) {
@ -53,31 +48,6 @@ func TestProgressHandler(t *testing.T) {
if got != "Building project..." {
t.Errorf("ProgressHandler.Handle(build, project) = %q, want %q", got, "Building project...")
}
got = h.Handle("i18n.progress.build", []any{S("project", "config.yaml")}, nil)
if got != "Building config.yaml..." {
t.Errorf("ProgressHandler.Handle(build, Subject) = %q, want %q", got, "Building config.yaml...")
}
got = h.Handle("i18n.progress.build", []any{C("project")}, nil)
if got != "Building project..." {
t.Errorf("ProgressHandler.Handle(build, TranslationContext) = %q, want %q", got, "Building project...")
}
got = h.Handle("i18n.progress.build", []any{map[string]any{"Subject": "project"}}, nil)
if got != "Building project..." {
t.Errorf("ProgressHandler.Handle(build, map[Subject:project]) = %q, want %q", got, "Building project...")
}
got = h.Handle("i18n.progress.build", []any{map[string]string{"Subject": "project"}}, nil)
if got != "Building project..." {
t.Errorf("ProgressHandler.Handle(build, map[string]string[Subject:project]) = %q, want %q", got, "Building project...")
}
got = h.Handle("i18n.progress.", nil, func() string { return "fallback" })
if got != "fallback" {
t.Errorf("ProgressHandler.Handle(empty) = %q, want %q", got, "fallback")
}
}
func TestCountHandler(t *testing.T) {
@ -102,11 +72,7 @@ func TestCountHandler(t *testing.T) {
{"i18n.count.file", []any{5}, "5 files"},
{"i18n.count.file", []any{0}, "0 files"},
{"i18n.count.child", []any{3}, "3 children"},
{"i18n.count.url", []any{2}, "2 URLs"},
{"i18n.count.api", []any{2}, "2 APIs"},
{"i18n.count.cpus", []any{2}, "2 CPUs"},
{"i18n.count.file", nil, "file"},
{"i18n.count.url", nil, "URL"},
}
for _, tt := range tests {
@ -117,31 +83,6 @@ func TestCountHandler(t *testing.T) {
}
})
}
got := h.Handle("i18n.count.file", []any{S("file", "config.yaml").Count(3)}, nil)
if got != "3 files" {
t.Errorf("CountHandler.Handle(file, Subject.Count(3)) = %q, want %q", got, "3 files")
}
got = h.Handle("i18n.count.file", []any{map[string]string{"Count": "3"}}, nil)
if got != "3 files" {
t.Errorf("CountHandler.Handle(file, map[string]string[Count:3]) = %q, want %q", got, "3 files")
}
got = h.Handle("i18n.count.file", []any{C("file").Set("Count", 3)}, nil)
if got != "3 files" {
t.Errorf("CountHandler.Handle(file, TranslationContext.Count=3) = %q, want %q", got, "3 files")
}
got = h.Handle("i18n.count.file", []any{C("file")}, nil)
if got != "1 file" {
t.Errorf("CountHandler.Handle(file, TranslationContext default count) = %q, want %q", got, "1 file")
}
got = h.Handle("i18n.count.", nil, func() string { return "fallback" })
if got != "fallback" {
t.Errorf("CountHandler.Handle(empty) = %q, want %q", got, "fallback")
}
}
func TestDoneHandler(t *testing.T) {
@ -159,28 +100,8 @@ func TestDoneHandler(t *testing.T) {
// With subject
got := h.Handle("i18n.done.delete", []any{"config.yaml"}, nil)
if got != "Config.yaml deleted" {
t.Errorf("DoneHandler.Handle(delete, config.yaml) = %q, want %q", got, "Config.yaml deleted")
}
got = h.Handle("i18n.done.delete", []any{S("file", "config.yaml")}, nil)
if got != "Config.yaml deleted" {
t.Errorf("DoneHandler.Handle(delete, Subject) = %q, want %q", got, "Config.yaml deleted")
}
got = h.Handle("i18n.done.delete", []any{C("config.yaml")}, nil)
if got != "Config.yaml deleted" {
t.Errorf("DoneHandler.Handle(delete, TranslationContext) = %q, want %q", got, "Config.yaml deleted")
}
got = h.Handle("i18n.done.delete", []any{map[string]any{"Subject": "config.yaml"}}, nil)
if got != "Config.yaml deleted" {
t.Errorf("DoneHandler.Handle(delete, map[Subject:config.yaml]) = %q, want %q", got, "Config.yaml deleted")
}
got = h.Handle("i18n.done.delete", []any{map[string]string{"Subject": "config.yaml"}}, nil)
if got != "Config.yaml deleted" {
t.Errorf("DoneHandler.Handle(delete, map[string]string[Subject:config.yaml]) = %q, want %q", got, "Config.yaml deleted")
if got != "Config.Yaml deleted" {
t.Errorf("DoneHandler.Handle(delete, config.yaml) = %q, want %q", got, "Config.Yaml deleted")
}
// Without subject — just past tense
@ -188,11 +109,6 @@ func TestDoneHandler(t *testing.T) {
if got != "Deleted" {
t.Errorf("DoneHandler.Handle(delete) = %q, want %q", got, "Deleted")
}
got = h.Handle("i18n.done.", nil, func() string { return "fallback" })
if got != "fallback" {
t.Errorf("DoneHandler.Handle(empty) = %q, want %q", got, "fallback")
}
}
func TestFailHandler(t *testing.T) {
@ -207,35 +123,10 @@ func TestFailHandler(t *testing.T) {
t.Errorf("FailHandler.Handle(push, commits) = %q, want %q", got, "Failed to push commits")
}
got = h.Handle("i18n.fail.push", []any{S("commit", "commits")}, nil)
if got != "Failed to push commits" {
t.Errorf("FailHandler.Handle(push, Subject) = %q, want %q", got, "Failed to push commits")
}
got = h.Handle("i18n.fail.push", []any{C("commits")}, nil)
if got != "Failed to push commits" {
t.Errorf("FailHandler.Handle(push, TranslationContext) = %q, want %q", got, "Failed to push commits")
}
got = h.Handle("i18n.fail.push", []any{map[string]any{"Subject": "commits"}}, nil)
if got != "Failed to push commits" {
t.Errorf("FailHandler.Handle(push, map[Subject:commits]) = %q, want %q", got, "Failed to push commits")
}
got = h.Handle("i18n.fail.push", []any{map[string]string{"Subject": "commits"}}, nil)
if got != "Failed to push commits" {
t.Errorf("FailHandler.Handle(push, map[string]string[Subject:commits]) = %q, want %q", got, "Failed to push commits")
}
got = h.Handle("i18n.fail.push", nil, nil)
if got != "Failed to push" {
t.Errorf("FailHandler.Handle(push) = %q, want %q", got, "Failed to push")
}
got = h.Handle("i18n.fail.", nil, func() string { return "fallback" })
if got != "fallback" {
t.Errorf("FailHandler.Handle(empty) = %q, want %q", got, "fallback")
}
}
func TestNumericHandler(t *testing.T) {
@ -258,9 +149,7 @@ func TestNumericHandler(t *testing.T) {
{"i18n.numeric.ordinal", []any{3}, "3rd"},
{"i18n.numeric.ordinal", []any{11}, "11th"},
{"i18n.numeric.percent", []any{0.85}, "85%"},
{"i18n.numeric.bytes", []any{int64(1536000)}, "1.46 MB"},
{"i18n.numeric.number", []any{"1234567"}, "1,234,567"},
{"i18n.numeric.ago", []any{5, "minutes"}, "5 minutes ago"},
{"i18n.numeric.bytes", []any{int64(1536000)}, "1.5 MB"},
}
for _, tt := range tests {
@ -277,113 +166,6 @@ func TestNumericHandler(t *testing.T) {
if got != "fallback" {
t.Errorf("NumericHandler with no args should fallback, got %q", got)
}
// No args and no fallback should not panic.
got = h.Handle("i18n.numeric.number", nil, nil)
if got != "" {
t.Errorf("NumericHandler with no args and no fallback = %q, want empty string", got)
}
}
func TestCountHandler_UsesLocaleNumberFormat(t *testing.T) {
prev := Default()
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
if err := SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
h := CountHandler{}
got := h.Handle("i18n.count.file", []any{1234}, nil)
want := "1 234 files"
if got != want {
t.Errorf("CountHandler.Handle(locale format) = %q, want %q", got, want)
}
}
func TestCountHandler_PreservesExactWordDisplay(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
data := GetGrammarData("en")
if data == nil {
t.Fatal("GetGrammarData(\"en\") returned nil")
}
original, existed := data.Words["go_mod"]
data.Words["go_mod"] = "go.mod"
t.Cleanup(func() {
if existed {
data.Words["go_mod"] = original
return
}
delete(data.Words, "go_mod")
})
h := CountHandler{}
got := h.Handle("i18n.count.go_mod", []any{2}, nil)
if got != "2 go.mod" {
t.Fatalf("CountHandler.Handle(go_mod, 2) = %q, want %q", got, "2 go.mod")
}
}
func TestCountHandler_PreservesPhraseDisplay(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
data := GetGrammarData("en")
if data == nil {
t.Fatal("GetGrammarData(\"en\") returned nil")
}
original, existed := data.Words["up_to_date"]
data.Words["up_to_date"] = "up to date"
t.Cleanup(func() {
if existed {
data.Words["up_to_date"] = original
return
}
delete(data.Words, "up_to_date")
})
h := CountHandler{}
got := h.Handle("i18n.count.up_to_date", []any{2}, nil)
if got != "2 up to date" {
t.Fatalf("CountHandler.Handle(up_to_date, 2) = %q, want %q", got, "2 up to date")
}
}
func TestCountHandler_PluralisesLocaleNounPhrases(t *testing.T) {
prev := Default()
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
if err := SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
h := CountHandler{}
got := h.Handle("i18n.count.mise à jour", []any{2}, nil)
if got != "2 mises à jour" {
t.Fatalf("CountHandler.Handle(mise à jour, 2) = %q, want %q", got, "2 mises à jour")
}
}
func TestRunHandlerChain(t *testing.T) {
@ -409,3 +191,72 @@ func TestDefaultHandlers(t *testing.T) {
t.Errorf("DefaultHandlers() returned %d handlers, want 6", len(handlers))
}
}
// TestLabelHandler_Good verifies a label key produces a colon-suffixed result.
//
// h.Handle("i18n.label.status", nil, nil) // "Status:"
func TestLabelHandler_Good(t *testing.T) {
svc, _ := New()
SetDefault(svc)
h := LabelHandler{}
if got := h.Handle("i18n.label.status", nil, nil); got != "Status:" {
t.Errorf("LabelHandler.Handle = %q, want %q", got, "Status:")
}
}
// TestLabelHandler_Bad verifies that a non-matching key is not handled.
//
// h.Match("other.key") // false
func TestLabelHandler_Bad(t *testing.T) {
h := LabelHandler{}
if h.Match("other.key") {
t.Error("LabelHandler should not match other.key")
}
}
// TestLabelHandler_Ugly verifies that an empty word suffix returns a colon.
//
// h.Handle("i18n.label.", nil, nil) // ":"
func TestLabelHandler_Ugly(t *testing.T) {
svc, _ := New()
SetDefault(svc)
h := LabelHandler{}
got := h.Handle("i18n.label.", nil, nil)
// An empty word suffix produces just the punctuation — should not panic.
_ = got
}
// TestProgressHandler_Good verifies a progress key produces a gerund message.
//
// h.Handle("i18n.progress.build", nil, nil) // "Building..."
func TestProgressHandler_Good(t *testing.T) {
svc, _ := New()
SetDefault(svc)
h := ProgressHandler{}
if got := h.Handle("i18n.progress.build", nil, nil); got != "Building..." {
t.Errorf("ProgressHandler.Handle = %q, want %q", got, "Building...")
}
}
// TestProgressHandler_Bad verifies that a non-matching key returns false from Match.
//
// h.Match("i18n.label.status") // false
func TestProgressHandler_Bad(t *testing.T) {
h := ProgressHandler{}
if h.Match("i18n.label.status") {
t.Error("ProgressHandler should not match i18n.label.* keys")
}
}
// TestProgressHandler_Ugly verifies that a subject string appended to the progress message works.
//
// h.Handle("i18n.progress.build", []any{"config.yaml"}, nil) // "Building config.yaml..."
func TestProgressHandler_Ugly(t *testing.T) {
svc, _ := New()
SetDefault(svc)
h := ProgressHandler{}
got := h.Handle("i18n.progress.build", []any{"config.yaml"}, nil)
if got != "Building config.yaml..." {
t.Errorf("ProgressHandler.Handle with subject = %q, want %q", got, "Building config.yaml...")
}
}

227
hooks.go
View file

@ -6,42 +6,20 @@ import (
"sync"
"sync/atomic"
"dappco.re/go/core"
log "dappco.re/go/core/log"
)
var missingKeyHandler atomic.Value
var missingKeyHandlerMu sync.Mutex
type missingKeyHandlersState struct {
handlers []MissingKeyHandler
}
type localeRegistration struct {
fsys fs.FS
dir string
id int
}
type localeProviderRegistration struct {
provider LocaleProvider
id int
}
// LocaleProvider supplies one or more locale filesystems to the default service.
//
// i18n.RegisterLocaleProvider(myProvider)
type LocaleProvider interface {
LocaleSources() []FSSource
}
var (
registeredLocales []localeRegistration
registeredLocaleProviders []localeProviderRegistration
registeredLocalesMu sync.Mutex
localesLoaded bool
nextLocaleRegistrationID int
nextLocaleProviderID int
registeredLocales []localeRegistration
registeredLocalesMu sync.Mutex
localesLoaded bool
)
// RegisterLocales registers a filesystem containing locale files.
@ -54,202 +32,47 @@ var (
// i18n.RegisterLocales(localeFS, "locales")
// }
func RegisterLocales(fsys fs.FS, dir string) {
reg := localeRegistration{fsys: fsys, dir: dir}
registeredLocalesMu.Lock()
nextLocaleRegistrationID++
reg.id = nextLocaleRegistrationID
registeredLocales = append(registeredLocales, reg)
svc := defaultService.Load()
registeredLocalesMu.Unlock()
if svc != nil {
if err := svc.LoadFS(fsys, dir); err != nil {
log.Error("i18n: RegisterLocales failed to load", "dir", dir, "err", err)
} else {
svc.markLocaleRegistrationLoaded(reg.id)
markLocalesLoaded()
defer registeredLocalesMu.Unlock()
registeredLocales = append(registeredLocales, localeRegistration{fsys: fsys, dir: dir})
if localesLoaded {
if svc := Default(); svc != nil {
if err := svc.LoadFS(fsys, dir); err != nil {
log.Error("i18n: RegisterLocales failed to load locale directory", "dir", dir, "err", err)
}
}
}
}
// RegisterLocaleProvider registers a provider that can contribute locale files.
// This is useful for packages that need to expose multiple locale sources as a
// single unit.
//
// i18n.RegisterLocaleProvider(myProvider)
func RegisterLocaleProvider(provider LocaleProvider) {
if provider == nil {
return
}
reg := localeProviderRegistration{provider: provider}
registeredLocalesMu.Lock()
nextLocaleProviderID++
reg.id = nextLocaleProviderID
registeredLocaleProviders = append(registeredLocaleProviders, reg)
svc := defaultService.Load()
registeredLocalesMu.Unlock()
if svc != nil {
loadLocaleProvider(svc, reg)
}
}
func loadRegisteredLocales(svc *Service) {
if svc == nil {
return
}
registeredLocalesMu.Lock()
locales := append([]localeRegistration(nil), registeredLocales...)
providers := append([]localeProviderRegistration(nil), registeredLocaleProviders...)
registeredLocalesMu.Unlock()
for _, reg := range locales {
if svc != nil && svc.hasLocaleRegistrationLoaded(reg.id) {
continue
}
defer registeredLocalesMu.Unlock()
for _, reg := range registeredLocales {
if err := svc.LoadFS(reg.fsys, reg.dir); err != nil {
log.Error("i18n: loadRegisteredLocales failed to load", "dir", reg.dir, "err", err)
continue
}
svc.markLocaleRegistrationLoaded(reg.id)
}
for _, provider := range providers {
if svc != nil && svc.hasLocaleProviderLoaded(provider.id) {
continue
}
loadLocaleProvider(svc, provider)
}
markLocalesLoaded()
}
func loadLocaleProvider(svc *Service, provider localeProviderRegistration) {
if svc == nil || provider.provider == nil {
return
}
for _, src := range provider.provider.LocaleSources() {
if err := svc.LoadFS(src.FS, src.Dir); err != nil {
log.Error("i18n: loadLocaleProvider failed to load", "dir", src.Dir, "err", err)
log.Error("i18n: loadRegisteredLocales failed to load locale directory", "dir", reg.dir, "err", err)
}
}
svc.markLocaleProviderLoaded(provider.id)
markLocalesLoaded()
}
func markLocalesLoaded() {
registeredLocalesMu.Lock()
localesLoaded = true
registeredLocalesMu.Unlock()
}
// OnMissingKey registers a handler for missing translation keys.
func OnMissingKey(h MissingKeyHandler) {
if h == nil {
ClearMissingKeyHandlers()
return
}
AddMissingKeyHandler(h)
}
// SetMissingKeyHandlers replaces the full missing-key handler chain.
func SetMissingKeyHandlers(handlers ...MissingKeyHandler) {
missingKeyHandlerMu.Lock()
defer missingKeyHandlerMu.Unlock()
handlers = filterNilMissingKeyHandlers(handlers)
if len(handlers) == 0 {
missingKeyHandler.Store(missingKeyHandlersState{})
return
}
missingKeyHandler.Store(missingKeyHandlersState{handlers: handlers})
}
// ClearMissingKeyHandlers removes all registered missing-key handlers.
func ClearMissingKeyHandlers() {
missingKeyHandlerMu.Lock()
defer missingKeyHandlerMu.Unlock()
missingKeyHandler.Store(missingKeyHandlersState{})
}
// AddMissingKeyHandler appends a missing-key handler without replacing any
// existing handlers.
func AddMissingKeyHandler(h MissingKeyHandler) {
if h == nil {
return
}
missingKeyHandlerMu.Lock()
defer missingKeyHandlerMu.Unlock()
current := missingKeyHandlers()
current.handlers = append(current.handlers, h)
missingKeyHandler.Store(current)
}
func filterNilMissingKeyHandlers(handlers []MissingKeyHandler) []MissingKeyHandler {
if len(handlers) == 0 {
return nil
}
filtered := make([]MissingKeyHandler, 0, len(handlers))
for _, h := range handlers {
if h != nil {
filtered = append(filtered, h)
}
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func missingKeyHandlers() missingKeyHandlersState {
v := missingKeyHandler.Load()
if v == nil {
return missingKeyHandlersState{}
}
state, ok := v.(missingKeyHandlersState)
if !ok {
return missingKeyHandlersState{}
}
return state
missingKeyHandler.Store(h)
}
func dispatchMissingKey(key string, args map[string]any) {
state := missingKeyHandlers()
if len(state.handlers) == 0 {
v := missingKeyHandler.Load()
if v == nil {
return
}
file, line := missingKeyCaller()
mk := cloneMissingKey(MissingKey{Key: key, Args: args, CallerFile: file, CallerLine: line})
for _, h := range state.handlers {
if h != nil {
h(mk)
}
h, ok := v.(MissingKeyHandler)
if !ok || h == nil {
return
}
}
func cloneMissingKey(mk MissingKey) MissingKey {
if len(mk.Args) == 0 {
mk.Args = nil
return mk
}
args := make(map[string]any, len(mk.Args))
for key, value := range mk.Args {
args[key] = value
}
mk.Args = args
return mk
}
func missingKeyCaller() (string, int) {
const packagePrefix = "dappco.re/go/core/i18n."
pcs := make([]uintptr, 16)
n := runtime.Callers(2, pcs)
frames := runtime.CallersFrames(pcs[:n])
for {
frame, more := frames.Next()
if !core.HasPrefix(frame.Function, packagePrefix) || core.HasSuffix(frame.File, "_test.go") {
return frame.File, frame.Line
}
if !more {
break
}
}
return "unknown", 0
_, file, line, ok := runtime.Caller(2)
if !ok {
file = "unknown"
line = 0
}
h(MissingKey{Key: key, Args: args, CallerFile: file, CallerLine: line})
}

View file

@ -1,8 +1,6 @@
package i18n
import (
"path/filepath"
"sync"
"testing"
"testing/fstest"
@ -10,28 +8,17 @@ import (
"github.com/stretchr/testify/require"
)
type testLocaleProvider struct {
sources []FSSource
}
func (p testLocaleProvider) LocaleSources() []FSSource {
return p.sources
}
func TestRegisterLocales_Good(t *testing.T) {
// Save and restore registered locales state
registeredLocalesMu.Lock()
savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded
registeredLocales = nil
registeredLocaleProviders = nil
localesLoaded = false
registeredLocalesMu.Unlock()
defer func() {
registeredLocalesMu.Lock()
registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded
registeredLocalesMu.Unlock()
}()
@ -50,41 +37,6 @@ func TestRegisterLocales_Good(t *testing.T) {
assert.Equal(t, 1, count, "should have 1 registered locale")
}
func TestRegisterLocaleProvider_Good(t *testing.T) {
registeredLocalesMu.Lock()
savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded
registeredLocales = nil
registeredLocaleProviders = nil
localesLoaded = false
registeredLocalesMu.Unlock()
defer func() {
registeredLocalesMu.Lock()
registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded
registeredLocalesMu.Unlock()
}()
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
fs := fstest.MapFS{
"locales/en.json": &fstest.MapFile{
Data: []byte(`{"provider.loaded": "loaded from provider"}`),
},
}
RegisterLocaleProvider(testLocaleProvider{
sources: []FSSource{{FS: fs, Dir: "locales"}},
})
got := svc.T("provider.loaded")
assert.Equal(t, "loaded from provider", got)
}
func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
// When localesLoaded is true, RegisterLocales should also call LoadFS immediately
svc, err := New()
@ -95,16 +47,13 @@ func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
// Save and restore state
registeredLocalesMu.Lock()
savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded
registeredLocales = nil
registeredLocaleProviders = nil
localesLoaded = true // Simulate already loaded
registeredLocalesMu.Unlock()
defer func() {
registeredLocalesMu.Lock()
registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded
registeredLocalesMu.Unlock()
}()
@ -123,363 +72,6 @@ func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
assert.Equal(t, "arrived late", got)
}
func TestRegisterLocales_Good_WithInitializedDefaultService(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
registeredLocalesMu.Lock()
savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded
registeredLocales = nil
registeredLocaleProviders = nil
localesLoaded = false
registeredLocalesMu.Unlock()
defer func() {
registeredLocalesMu.Lock()
registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded
registeredLocalesMu.Unlock()
}()
fs := fstest.MapFS{
"locales/en.json": &fstest.MapFile{
Data: []byte(`{"eager.registration": "loaded immediately"}`),
},
}
RegisterLocales(fs, "locales")
got := svc.T("eager.registration")
assert.Equal(t, "loaded immediately", got)
}
func TestSetDefault_Good_LoadsQueuedRegisteredLocales(t *testing.T) {
registeredLocalesMu.Lock()
savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded
registeredLocales = nil
registeredLocaleProviders = nil
localesLoaded = false
registeredLocalesMu.Unlock()
defer func() {
registeredLocalesMu.Lock()
registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded
registeredLocalesMu.Unlock()
}()
fs := fstest.MapFS{
"locales/en.json": &fstest.MapFile{
Data: []byte(`{"queued.registration": "loaded via setdefault"}`),
},
}
RegisterLocales(fs, "locales")
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
got := svc.T("queued.registration")
assert.Equal(t, "loaded via setdefault", got)
}
func TestSetDefault_Good_LoadsRegisteredLocalesIntoFreshService(t *testing.T) {
registeredLocalesMu.Lock()
savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded
registeredLocales = nil
registeredLocaleProviders = nil
localesLoaded = false
registeredLocalesMu.Unlock()
defer func() {
registeredLocalesMu.Lock()
registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded
registeredLocalesMu.Unlock()
}()
fs := fstest.MapFS{
"locales/en.json": &fstest.MapFile{
Data: []byte(`{"fresh.registration": "fresh value"}`),
},
}
RegisterLocales(fs, "locales")
first, err := New()
require.NoError(t, err)
SetDefault(first)
require.Equal(t, "fresh value", first.T("fresh.registration"))
second, err := New()
require.NoError(t, err)
SetDefault(second)
got := second.T("fresh.registration")
assert.Equal(t, "fresh value", got)
}
func TestInit_LoadsRegisteredLocales(t *testing.T) {
// Save and restore global service state.
registeredLocalesMu.Lock()
savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded
registeredLocales = nil
registeredLocaleProviders = nil
localesLoaded = false
registeredLocalesMu.Unlock()
defaultService.Store(nil)
defer func() {
registeredLocalesMu.Lock()
registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded
registeredLocalesMu.Unlock()
defaultService.Store(nil)
}()
fs := fstest.MapFS{
"locales/en.json": &fstest.MapFile{
Data: []byte(`{"init.registered": "loaded on init"}`),
},
}
RegisterLocales(fs, "locales")
require.NoError(t, Init())
svc := Default()
require.NotNil(t, svc)
got := svc.T("init.registered")
assert.Equal(t, "loaded on init", got)
}
func TestNewCoreService_LoadsRegisteredLocales(t *testing.T) {
registeredLocalesMu.Lock()
savedLocales := registeredLocales
savedLoaded := localesLoaded
registeredLocales = nil
localesLoaded = false
registeredLocalesMu.Unlock()
prev := defaultService.Load()
SetDefault(nil)
defer func() {
registeredLocalesMu.Lock()
registeredLocales = savedLocales
localesLoaded = savedLoaded
registeredLocalesMu.Unlock()
SetDefault(prev)
}()
fs := fstest.MapFS{
"locales/en.json": &fstest.MapFile{
Data: []byte(`{"core.registered": "loaded on core bootstrap"}`),
},
}
RegisterLocales(fs, "locales")
factory := NewCoreService(ServiceOptions{})
_, err := factory(nil)
require.NoError(t, err)
svc := Default()
require.NotNil(t, svc)
got := svc.T("core.registered")
assert.Equal(t, "loaded on core bootstrap", got)
}
func TestNewCoreService_InvalidLanguagePreservesSetLanguageError(t *testing.T) {
factory := NewCoreService(ServiceOptions{Language: "es"})
_, err := factory(nil)
require.Error(t, err)
msg := err.Error()
assert.Contains(t, msg, "unsupported language: es")
assert.Contains(t, msg, "available:")
assert.NotContains(t, msg, "invalid language")
}
func TestNewCoreService_AppliesOptions(t *testing.T) {
prev := Default()
SetDefault(nil)
t.Cleanup(func() {
SetDefault(prev)
})
factory := NewCoreService(ServiceOptions{
Language: "en",
Fallback: "fr",
Formality: FormalityFormal,
Location: "workspace",
Mode: ModeCollect,
Debug: true,
})
_, err := factory(nil)
require.NoError(t, err)
svc := Default()
require.NotNil(t, svc)
assert.Equal(t, "en", svc.Language())
assert.Equal(t, "fr", svc.Fallback())
assert.Equal(t, FormalityFormal, svc.Formality())
assert.Equal(t, "workspace", svc.Location())
assert.Equal(t, ModeCollect, svc.Mode())
assert.True(t, svc.Debug())
}
func TestCoreService_DelegatesToWrappedService(t *testing.T) {
svc, err := New()
require.NoError(t, err)
coreSvc := &CoreService{svc: svc}
assert.Equal(t, svc.T("i18n.label.status"), coreSvc.T("i18n.label.status"))
assert.Equal(t, svc.Raw("i18n.label.status"), coreSvc.Raw("i18n.label.status"))
assert.Equal(t, svc.Translate("i18n.label.status"), coreSvc.Translate("i18n.label.status"))
assert.Equal(t, svc.AvailableLanguages(), coreSvc.AvailableLanguages())
assert.Equal(t, svc.AvailableLanguages(), coreSvc.CurrentAvailableLanguages())
assert.Equal(t, svc.Direction(), coreSvc.Direction())
assert.Equal(t, svc.Direction(), coreSvc.CurrentDirection())
assert.Equal(t, svc.Direction(), coreSvc.CurrentTextDirection())
assert.Equal(t, svc.IsRTL(), coreSvc.IsRTL())
assert.Equal(t, svc.IsRTL(), coreSvc.CurrentIsRTL())
assert.Equal(t, svc.IsRTL(), coreSvc.RTL())
assert.Equal(t, svc.IsRTL(), coreSvc.CurrentRTL())
assert.Equal(t, svc.PluralCategory(2), coreSvc.PluralCategory(2))
assert.Equal(t, svc.PluralCategory(2), coreSvc.CurrentPluralCategory(2))
assert.Equal(t, svc.PluralCategory(2), coreSvc.PluralCategoryOf(2))
assert.Equal(t, svc.Mode(), coreSvc.CurrentMode())
assert.Equal(t, svc.Language(), coreSvc.CurrentLanguage())
assert.Equal(t, svc.Language(), coreSvc.CurrentLang())
assert.Equal(t, svc.Prompt("confirm"), coreSvc.Prompt("confirm"))
assert.Equal(t, svc.Prompt("confirm"), coreSvc.CurrentPrompt("confirm"))
assert.Equal(t, svc.Lang("fr"), coreSvc.Lang("fr"))
assert.Equal(t, svc.Fallback(), coreSvc.CurrentFallback())
assert.Equal(t, svc.Formality(), coreSvc.CurrentFormality())
assert.Equal(t, svc.Location(), coreSvc.CurrentLocation())
assert.Equal(t, svc.Debug(), coreSvc.CurrentDebug())
require.NoError(t, coreSvc.SetLanguage("en"))
assert.Equal(t, "en", coreSvc.Language())
coreSvc.SetFallback("fr")
assert.Equal(t, "fr", coreSvc.Fallback())
coreSvc.SetFormality(FormalityFormal)
assert.Equal(t, FormalityFormal, coreSvc.Formality())
coreSvc.SetLocation("workspace")
assert.Equal(t, "workspace", coreSvc.Location())
coreSvc.SetDebug(true)
assert.True(t, coreSvc.Debug())
coreSvc.SetDebug(false)
assert.False(t, coreSvc.Debug())
handlers := coreSvc.Handlers()
assert.Equal(t, svc.Handlers(), handlers)
assert.Equal(t, svc.Handlers(), coreSvc.CurrentHandlers())
coreSvc.SetHandlers(LabelHandler{})
require.Len(t, coreSvc.Handlers(), 1)
assert.IsType(t, LabelHandler{}, coreSvc.Handlers()[0])
coreSvc.AddHandler(ProgressHandler{})
require.Len(t, coreSvc.Handlers(), 2)
assert.IsType(t, ProgressHandler{}, coreSvc.Handlers()[1])
coreSvc.PrependHandler(CountHandler{})
require.Len(t, coreSvc.Handlers(), 3)
assert.IsType(t, CountHandler{}, coreSvc.Handlers()[0])
coreSvc.ClearHandlers()
assert.Empty(t, coreSvc.Handlers())
coreSvc.ResetHandlers()
require.NotEmpty(t, coreSvc.Handlers())
assert.IsType(t, LabelHandler{}, coreSvc.Handlers()[0])
require.NoError(t, coreSvc.AddLoader(NewFSLoader(fstest.MapFS{
"locales/en.json": &fstest.MapFile{Data: []byte(`{"core.service.loaded": "loaded"}`)},
}, "locales")))
assert.Equal(t, "loaded", coreSvc.T("core.service.loaded"))
require.NoError(t, coreSvc.LoadFS(fstest.MapFS{
"locales/en.json": &fstest.MapFile{Data: []byte(`{"core.service.loaded.fs": "loaded via fs"}`)},
}, "locales"))
assert.Equal(t, "loaded via fs", coreSvc.T("core.service.loaded.fs"))
coreSvc.AddMessages("en", map[string]string{
"core.service.add.messages": "loaded via add messages",
})
assert.Equal(t, "loaded via add messages", coreSvc.T("core.service.add.messages"))
}
func TestInit_ReDetectsRegisteredLocales(t *testing.T) {
t.Setenv("LANG", "de_DE.UTF-8")
registeredLocalesMu.Lock()
savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded
registeredLocales = nil
localesLoaded = false
registeredLocalesMu.Unlock()
defaultService.Store(nil)
defer func() {
registeredLocalesMu.Lock()
registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded
registeredLocalesMu.Unlock()
defaultService.Store(nil)
}()
fs := fstest.MapFS{
"locales/de.json": &fstest.MapFile{
Data: []byte(`{"hello": "hallo"}`),
},
}
RegisterLocales(fs, "locales")
require.NoError(t, Init())
svc := Default()
require.NotNil(t, svc)
assert.Contains(t, svc.Language(), "de")
assert.Equal(t, "hallo", svc.T("hello"))
}
func TestDefault_ReinitialisesAfterClear(t *testing.T) {
prev := Default()
t.Cleanup(func() {
SetDefault(prev)
})
SetDefault(nil)
require.NoError(t, Init())
svc := Default()
require.NotNil(t, svc)
assert.Equal(t, "y", svc.T("prompt.yes"))
}
func TestLoadRegisteredLocales_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
@ -487,7 +79,6 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
// Save and restore state
registeredLocalesMu.Lock()
savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded
registeredLocales = []localeRegistration{
{
@ -499,13 +90,11 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
dir: "loc",
},
}
registeredLocaleProviders = nil
localesLoaded = false
registeredLocalesMu.Unlock()
defer func() {
registeredLocalesMu.Lock()
registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded
registeredLocalesMu.Unlock()
}()
@ -524,11 +113,6 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
func TestOnMissingKey_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
svc.SetMode(ModeCollect)
var captured MissingKey
@ -536,285 +120,42 @@ func TestOnMissingKey_Good(t *testing.T) {
captured = m
})
_ = T("missing.test.key", map[string]any{"foo": "bar"})
_ = svc.T("missing.test.key", map[string]any{"foo": "bar"})
assert.Equal(t, "missing.test.key", captured.Key)
assert.Equal(t, "bar", captured.Args["foo"])
assert.Equal(t, "hooks_test.go", filepath.Base(captured.CallerFile))
}
func TestOnMissingKey_Good_AppendsHandlers(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
prevHandlers := missingKeyHandlers()
t.Cleanup(func() {
missingKeyHandler.Store(prevHandlers)
SetDefault(prev)
})
svc.SetMode(ModeCollect)
ClearMissingKeyHandlers()
t.Cleanup(func() {
ClearMissingKeyHandlers()
})
var first, second int
OnMissingKey(func(MissingKey) { first++ })
OnMissingKey(func(MissingKey) { second++ })
_ = T("missing.on.handler.append")
assert.Equal(t, 1, first)
assert.Equal(t, 1, second)
}
func TestAddMissingKeyHandler_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
prevHandlers := missingKeyHandlers()
t.Cleanup(func() {
missingKeyHandler.Store(prevHandlers)
SetDefault(prev)
})
svc.SetMode(ModeCollect)
ClearMissingKeyHandlers()
t.Cleanup(func() {
ClearMissingKeyHandlers()
})
var first, second int
AddMissingKeyHandler(func(MissingKey) {
first++
})
AddMissingKeyHandler(func(MissingKey) {
second++
})
_ = T("missing.multiple.handlers")
assert.Equal(t, 1, first)
assert.Equal(t, 1, second)
}
func TestSetMissingKeyHandlers_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
prevHandlers := missingKeyHandlers()
t.Cleanup(func() {
missingKeyHandler.Store(prevHandlers)
SetDefault(prev)
})
svc.SetMode(ModeCollect)
var first, second int
SetMissingKeyHandlers(
nil,
func(MissingKey) { first++ },
func(MissingKey) { second++ },
)
_ = T("missing.set.handlers")
assert.Equal(t, 1, first)
assert.Equal(t, 1, second)
assert.Len(t, missingKeyHandlers().handlers, 2)
}
func TestSetMissingKeyHandlers_Good_Clear(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
prevHandlers := missingKeyHandlers()
t.Cleanup(func() {
missingKeyHandler.Store(prevHandlers)
SetDefault(prev)
})
svc.SetMode(ModeCollect)
var called int
SetMissingKeyHandlers(func(MissingKey) { called++ })
SetMissingKeyHandlers(nil)
_ = T("missing.set.handlers.clear")
assert.Equal(t, 0, called)
assert.Empty(t, missingKeyHandlers().handlers)
}
func TestAddMissingKeyHandler_Good_Concurrent(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
prevHandlers := missingKeyHandlers()
t.Cleanup(func() {
missingKeyHandler.Store(prevHandlers)
SetDefault(prev)
})
svc.SetMode(ModeCollect)
ClearMissingKeyHandlers()
t.Cleanup(func() {
ClearMissingKeyHandlers()
})
const handlers = 32
var wg sync.WaitGroup
wg.Add(handlers)
for i := 0; i < handlers; i++ {
go func() {
defer wg.Done()
AddMissingKeyHandler(func(MissingKey) {})
}()
}
wg.Wait()
state := missingKeyHandlers()
assert.Len(t, state.handlers, handlers)
}
func TestClearMissingKeyHandlers_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
prevHandlers := missingKeyHandlers()
t.Cleanup(func() {
missingKeyHandler.Store(prevHandlers)
SetDefault(prev)
})
svc.SetMode(ModeCollect)
var called int
AddMissingKeyHandler(func(MissingKey) {
called++
})
ClearMissingKeyHandlers()
_ = T("missing.after.clear")
assert.Equal(t, 0, called)
}
func TestOnMissingKey_Good_SubjectArgs(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
svc.SetMode(ModeCollect)
var captured MissingKey
OnMissingKey(func(m MissingKey) {
captured = m
})
_ = T("missing.subject.key", S("file", "config.yaml").Count(3).In("workspace").Formal())
assert.Equal(t, "missing.subject.key", captured.Key)
assert.Equal(t, "config.yaml", captured.Args["Subject"])
assert.Equal(t, "file", captured.Args["Noun"])
assert.Equal(t, 3, captured.Args["Count"])
assert.Equal(t, "workspace", captured.Args["Location"])
assert.Equal(t, FormalityFormal, captured.Args["Formality"])
}
func TestOnMissingKey_Good_TranslationContextArgs(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
svc.SetMode(ModeCollect)
var captured MissingKey
OnMissingKey(func(m MissingKey) {
captured = m
})
_ = T("missing.context.key", C("navigation").WithGender("feminine").In("workspace").Formal())
assert.Equal(t, "missing.context.key", captured.Key)
assert.Equal(t, "navigation", captured.Args["Context"])
assert.Equal(t, "feminine", captured.Args["Gender"])
assert.Equal(t, "workspace", captured.Args["Location"])
assert.Equal(t, FormalityFormal, captured.Args["Formality"])
}
func TestOnMissingKey_Good_MergesAdditionalArgs(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
svc.SetMode(ModeCollect)
var captured MissingKey
OnMissingKey(func(m MissingKey) {
captured = m
})
_ = T("missing.extra.args", S("file", "config.yaml"), map[string]any{"trace": "abc123"})
assert.Equal(t, "missing.extra.args", captured.Key)
assert.Equal(t, "config.yaml", captured.Args["Subject"])
assert.Equal(t, "abc123", captured.Args["trace"])
assert.NotEmpty(t, captured.CallerFile)
}
func TestDispatchMissingKey_Good_NoHandler(t *testing.T) {
// Reset to the empty handler set.
OnMissingKey(nil)
// Store nil handler (using correct type)
missingKeyHandler.Store(MissingKeyHandler(nil))
// Should not panic when dispatching with nil handler
dispatchMissingKey("test.key", nil)
}
func TestCoreServiceSetMode_Good_PreservesMissingKeyHandlers(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := missingKeyHandlers()
t.Cleanup(func() {
missingKeyHandler.Store(prev)
})
var observed int
OnMissingKey(func(MissingKey) {
observed++
})
t.Cleanup(func() {
OnMissingKey(nil)
})
coreSvc := &CoreService{svc: svc}
coreSvc.SetMode(ModeCollect)
_ = svc.T("missing.core.service.key")
if observed != 1 {
t.Fatalf("custom missing key handler called %d times, want 1", observed)
}
missing := coreSvc.MissingKeys()
if len(missing) != 1 {
t.Fatalf("CoreService captured %d missing keys, want 1", len(missing))
}
if missing[0].Key != "missing.core.service.key" {
t.Fatalf("captured missing key = %q, want %q", missing[0].Key, "missing.core.service.key")
// TestOnMissingKey_Bad verifies that registering a nil handler clears any existing handler.
//
// OnMissingKey(nil) // clears handler; subsequent dispatch is a no-op
func TestOnMissingKey_Bad(t *testing.T) {
called := false
OnMissingKey(func(_ MissingKey) { called = true })
OnMissingKey(nil) // clear the handler
dispatchMissingKey("any.key", nil)
if called {
t.Error("handler should have been cleared by OnMissingKey(nil)")
}
}
// TestRegisterLocales_Ugly verifies re-registering the embedded locales is idempotent and safe.
//
// RegisterLocales(localeFS, "locales") // idempotent; must not panic
func TestRegisterLocales_Ugly(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("RegisterLocales panicked: %v", r)
}
}()
RegisterLocales(localeFS, "locales")
}

424
i18n.go
View file

@ -2,44 +2,25 @@ package i18n
import (
"bytes"
"io/fs"
"text/template"
"dappco.re/go/core"
log "dappco.re/go/core/log"
)
// T translates a message using the default service.
//
// Example:
//
// i18n.T("greeting")
func T(messageID string, args ...any) string {
return defaultServiceValue(messageID, func(svc *Service) string {
if svc := Default(); svc != nil {
return svc.T(messageID, args...)
})
}
// Translate translates a message using the default service and returns a Core result.
//
// Example:
//
// result := i18n.Translate("greeting")
func Translate(messageID string, args ...any) core.Result {
return defaultServiceValue(core.Result{Value: messageID, OK: false}, func(svc *Service) core.Result {
return svc.Translate(messageID, args...)
})
}
return messageID
}
// Raw translates without i18n.* namespace magic.
//
// Example:
//
// i18n.Raw("prompt.yes")
func Raw(messageID string, args ...any) string {
return defaultServiceValue(messageID, func(svc *Service) string {
if svc := Default(); svc != nil {
return svc.Raw(messageID, args...)
})
}
return messageID
}
// ErrServiceNotInitialised is returned when the service is not initialised.
@ -49,342 +30,59 @@ var ErrServiceNotInitialised = core.NewError("i18n: service not initialised")
var ErrServiceNotInitialized = ErrServiceNotInitialised
// SetLanguage sets the language for the default service.
//
// Example:
//
// _ = i18n.SetLanguage("fr")
func SetLanguage(lang string) error {
return defaultServiceValue(ErrServiceNotInitialised, func(svc *Service) error {
return svc.SetLanguage(lang)
})
svc := Default()
if svc == nil {
return ErrServiceNotInitialised
}
return svc.SetLanguage(lang)
}
// CurrentLanguage returns the current language code.
//
// Example:
//
// lang := i18n.CurrentLanguage()
func CurrentLanguage() string {
return Language()
}
// CurrentLang is a short alias for CurrentLanguage.
//
// Example:
//
// lang := i18n.CurrentLang()
func CurrentLang() string {
return CurrentLanguage()
}
// Language returns the current language code.
//
// Example:
//
// lang := i18n.Language()
func Language() string {
return defaultServiceValue("en", func(svc *Service) string {
if svc := Default(); svc != nil {
return svc.Language()
})
}
// AvailableLanguages returns the loaded language tags on the default service.
//
// Example:
//
// langs := i18n.AvailableLanguages()
func AvailableLanguages() []string {
return defaultServiceValue([]string{}, func(svc *Service) []string {
return svc.AvailableLanguages()
})
}
// CurrentAvailableLanguages returns the loaded language tags on the default
// service.
//
// Example:
//
// langs := i18n.CurrentAvailableLanguages()
func CurrentAvailableLanguages() []string {
return AvailableLanguages()
}
return "en"
}
// SetMode sets the translation mode for the default service.
//
// Example:
//
// i18n.SetMode(i18n.ModeCollect)
func SetMode(m Mode) {
withDefaultService(func(svc *Service) { svc.SetMode(m) })
}
// SetFallback sets the fallback language for the default service.
//
// Example:
//
// i18n.SetFallback("en")
func SetFallback(lang string) {
withDefaultService(func(svc *Service) { svc.SetFallback(lang) })
}
// Fallback returns the current fallback language.
//
// Example:
//
// fallback := i18n.Fallback()
func Fallback() string {
return defaultServiceValue("en", func(svc *Service) string {
return svc.Fallback()
})
if svc := Default(); svc != nil {
svc.SetMode(m)
}
}
// CurrentMode returns the current translation mode.
//
// Example:
//
// mode := i18n.CurrentMode()
func CurrentMode() Mode {
return defaultServiceValue(ModeNormal, func(svc *Service) Mode { return svc.Mode() })
if svc := Default(); svc != nil {
return svc.Mode()
}
return ModeNormal
}
// CurrentFallback returns the current fallback language.
//
// Example:
//
// fallback := i18n.CurrentFallback()
func CurrentFallback() string {
return Fallback()
}
// CurrentFormality returns the current default formality.
//
// Example:
//
// formality := i18n.CurrentFormality()
func CurrentFormality() Formality {
return defaultServiceValue(FormalityNeutral, func(svc *Service) Formality { return svc.Formality() })
}
// CurrentDebug reports whether debug mode is enabled on the default service.
//
// Example:
//
// debug := i18n.CurrentDebug()
func CurrentDebug() bool {
return Debug()
}
// State returns a copy-safe snapshot of the default service configuration.
//
// state := i18n.State()
func State() ServiceState {
return defaultServiceValue(defaultServiceStateSnapshot(), func(svc *Service) ServiceState {
return svc.State()
})
}
// CurrentState is a more explicit alias for State.
//
// state := i18n.CurrentState()
func CurrentState() ServiceState {
return State()
}
// Debug reports whether debug mode is enabled on the default service.
//
// Example:
//
// debug := i18n.Debug()
func Debug() bool {
return defaultServiceValue(false, func(svc *Service) bool {
return svc.Debug()
})
}
// N formats a value using the i18n.numeric.* namespace.
// N formats a number using the i18n.numeric.* namespace.
//
// N("number", 1234567) // "1,234,567"
// N("percent", 0.85) // "85%"
// N("bytes", 1536000) // "1.46 MB"
// N("bytes", 1536000) // "1.5 MB"
// N("ordinal", 1) // "1st"
//
// Multi-argument formats such as "ago" also pass through unchanged:
//
// N("ago", 5, "minutes") // "5 minutes ago"
func N(format string, value any, args ...any) string {
format = normalizeLookupKey(format)
switch format {
case "number", "int":
return FormatNumber(toInt64(value))
case "decimal", "float":
return FormatDecimal(toFloat64(value))
case "percent", "pct":
return FormatPercent(toFloat64(value))
case "bytes", "size":
return FormatBytes(toInt64(value))
case "ordinal", "ord":
return FormatOrdinal(toInt(value))
case "ago":
if len(args) > 0 {
if unit, ok := args[0].(string); ok {
return FormatAgo(toInt(value), unit)
}
}
func N(format string, value any) string {
return T("i18n.numeric."+format, value)
}
// AddHandler appends a handler to the default service's handler chain.
func AddHandler(h KeyHandler) {
if svc := Default(); svc != nil {
svc.AddHandler(h)
}
return T("i18n.numeric."+format, append([]any{value}, args...)...)
}
// Prompt translates a prompt key from the prompt namespace.
//
// Example:
//
// i18n.Prompt("confirm")
//
// Prompt("yes") // "y"
// Prompt("confirm") // "Are you sure?"
func Prompt(key string) string {
return defaultServiceNamespaceValue("prompt", key, func(svc *Service, resolved string) string {
return svc.Prompt(resolved)
})
}
// CurrentPrompt is a short alias for Prompt.
//
// Example:
//
// prompt := i18n.CurrentPrompt("confirm")
func CurrentPrompt(key string) string {
return Prompt(key)
}
// Lang translates a language label from the lang namespace.
//
// Example:
//
// i18n.Lang("de")
//
// Lang("de") // "German"
func Lang(key string) string {
return defaultServiceNamespaceValue("lang", key, func(svc *Service, resolved string) string {
return svc.Lang(resolved)
})
}
func normalizeLookupKey(key string) string {
return core.Lower(core.Trim(key))
}
func namespaceLookupKey(namespace, key string) string {
key = normalizeLookupKey(key)
namespace = normalizeLookupKey(namespace)
if key == "" {
return namespace
// PrependHandler inserts a handler at the start of the default service's handler chain.
func PrependHandler(h KeyHandler) {
if svc := Default(); svc != nil {
svc.PrependHandler(h)
}
if namespace != "" && key == namespace {
return key
}
if namespace != "" && core.HasPrefix(key, namespace+".") {
return key
}
if namespace == "" {
return key
}
return namespace + "." + key
}
// AddHandler appends one or more handlers to the default service's handler chain.
//
// Example:
//
// i18n.AddHandler(MyHandler{})
func AddHandler(handlers ...KeyHandler) {
withDefaultService(func(svc *Service) { svc.AddHandler(handlers...) })
}
// SetHandlers replaces the default service's handler chain.
//
// Example:
//
// i18n.SetHandlers(i18n.LabelHandler{}, i18n.ProgressHandler{})
func SetHandlers(handlers ...KeyHandler) {
withDefaultService(func(svc *Service) { svc.SetHandlers(handlers...) })
}
// LoadFS loads additional translations from an fs.FS into the default service.
//
// Example:
//
// i18n.LoadFS(os.DirFS("."), "locales")
//
// Call this from init() in packages that ship their own locale files:
//
// //go:embed locales/*.json
// var localeFS embed.FS
//
// func init() { i18n.LoadFS(localeFS, "locales") }
func LoadFS(fsys fs.FS, dir string) {
withDefaultService(func(svc *Service) {
if err := svc.AddLoader(NewFSLoader(fsys, dir)); err != nil {
log.Error("i18n: LoadFS failed", "dir", dir, "err", err)
}
})
}
// AddMessages adds message strings to the default service for a language.
//
// Example:
//
// i18n.AddMessages("en", map[string]string{"custom.greeting": "Hello!"})
func AddMessages(lang string, messages map[string]string) {
withDefaultService(func(svc *Service) { svc.AddMessages(lang, messages) })
}
// PrependHandler inserts one or more handlers at the start of the default service's handler chain.
//
// Example:
//
// i18n.PrependHandler(MyHandler{})
func PrependHandler(handlers ...KeyHandler) {
withDefaultService(func(svc *Service) { svc.PrependHandler(handlers...) })
}
// CurrentHandlers returns a copy of the default service's handler chain.
//
// Example:
//
// handlers := i18n.CurrentHandlers()
func CurrentHandlers() []KeyHandler {
return Handlers()
}
// Handlers returns a copy of the default service's handler chain.
//
// Example:
//
// handlers := i18n.Handlers()
func Handlers() []KeyHandler {
return defaultServiceValue([]KeyHandler{}, func(svc *Service) []KeyHandler {
return svc.Handlers()
})
}
// ClearHandlers removes all handlers from the default service.
//
// Example:
//
// i18n.ClearHandlers()
func ClearHandlers() {
withDefaultService(func(svc *Service) { svc.ClearHandlers() })
}
// ResetHandlers restores the built-in default handler chain on the default
// service.
//
// Example:
//
// i18n.ResetHandlers()
func ResetHandlers() {
withDefaultService(func(svc *Service) { svc.ResetHandlers() })
}
func executeIntentTemplate(tmplStr string, data templateData) string {
@ -414,7 +112,6 @@ func applyTemplate(text string, data any) string {
if !core.Contains(text, "{{") {
return text
}
data = templateDataForRendering(data)
if cached, ok := templateCache.Load(text); ok {
var buf bytes.Buffer
if err := cached.(*template.Template).Execute(&buf, data); err != nil {
@ -422,7 +119,7 @@ func applyTemplate(text string, data any) string {
}
return buf.String()
}
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(text)
tmpl, err := template.New("").Parse(text)
if err != nil {
return text
}
@ -433,52 +130,3 @@ func applyTemplate(text string, data any) string {
}
return buf.String()
}
func templateDataForRendering(data any) any {
switch v := data.(type) {
case *TranslationContext:
if v == nil {
return nil
}
count, explicit := v.countValue()
if !explicit && v.Extra != nil {
if c, ok := v.Extra["Count"]; ok {
count = toInt(c)
} else if c, ok := v.Extra["count"]; ok {
count = toInt(c)
}
}
rendered := map[string]any{
"Context": v.Context,
"Gender": v.Gender,
"Location": v.Location,
"Formality": v.Formality,
"Count": count,
"IsPlural": count != 1,
"Extra": v.Extra,
}
for key, value := range v.Extra {
if _, exists := rendered[key]; !exists {
rendered[key] = value
}
}
return rendered
case *Subject:
if v == nil {
return nil
}
return map[string]any{
"Subject": v.String(),
"Noun": v.Noun,
"Count": v.count,
"Gender": v.gender,
"Location": v.location,
"Formality": v.formality,
"IsFormal": v.formality == FormalityFormal,
"IsPlural": v.count != 1,
"Value": v.Value,
}
default:
return data
}
}

View file

@ -2,7 +2,6 @@ package i18n
import (
"testing"
"testing/fstest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -40,48 +39,6 @@ func TestT_Good_MissingKey(t *testing.T) {
assert.Equal(t, "nonexistent.key.test", got)
}
// --- Package-level Translate() ---
func TestTranslate_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
_ = Init()
SetDefault(svc)
result := Translate("prompt.yes")
require.True(t, result.OK)
assert.Equal(t, "y", result.Value)
}
func TestTranslate_Good_MissingKey(t *testing.T) {
svc, err := New()
require.NoError(t, err)
_ = Init()
SetDefault(svc)
result := Translate("nonexistent.translation.key")
require.False(t, result.OK)
assert.Equal(t, "nonexistent.translation.key", result.Value)
}
func TestTranslate_Good_SameTextAsKey(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
AddMessages("en", map[string]string{
"exact.same.key": "exact.same.key",
})
result := Translate("exact.same.key")
require.True(t, result.OK)
assert.Equal(t, "exact.same.key", result.Value)
}
// --- Package-level Raw() ---
func TestRaw_Good(t *testing.T) {
@ -105,44 +62,6 @@ func TestRaw_Good_BypassesHandlers(t *testing.T) {
assert.Equal(t, "i18n.label.status", got)
}
func TestLoadFS_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
fsys := fstest.MapFS{
"locales/en.json": &fstest.MapFile{
Data: []byte(`{"loadfs.key": "loaded via package helper"}`),
},
}
LoadFS(fsys, "locales")
got := T("loadfs.key")
assert.Equal(t, "loaded via package helper", got)
}
func TestAddMessages_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
AddMessages("en", map[string]string{
"add.messages.key": "loaded via package helper",
})
got := T("add.messages.key")
assert.Equal(t, "loaded via package helper", got)
}
// --- SetLanguage / CurrentLanguage ---
func TestSetLanguage_Good(t *testing.T) {
@ -156,27 +75,6 @@ func TestSetLanguage_Good(t *testing.T) {
assert.Contains(t, CurrentLanguage(), "en")
}
func TestSetLanguage_Good_UnderscoreTag(t *testing.T) {
svc, err := New()
require.NoError(t, err)
_ = Init()
SetDefault(svc)
err = SetLanguage("fr_CA")
assert.NoError(t, err)
assert.True(t, len(CurrentLanguage()) >= 2)
assert.Equal(t, "fr", CurrentLanguage()[:2])
}
func TestLanguage_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
_ = Init()
SetDefault(svc)
assert.Equal(t, CurrentLanguage(), Language())
}
func TestSetLanguage_Bad_Unsupported(t *testing.T) {
svc, err := New()
require.NoError(t, err)
@ -194,95 +92,6 @@ func TestCurrentLanguage_Good(t *testing.T) {
lang := CurrentLanguage()
assert.NotEmpty(t, lang)
assert.Equal(t, lang, CurrentLang())
}
func TestAvailableLanguages_Good(t *testing.T) {
prev := Default()
t.Cleanup(func() {
SetDefault(prev)
})
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
langs := AvailableLanguages()
require.NotEmpty(t, langs)
assert.Equal(t, svc.AvailableLanguages(), langs)
langs[0] = "zz"
assert.NotEqual(t, "zz", svc.AvailableLanguages()[0])
}
func TestCurrentAvailableLanguages_Good(t *testing.T) {
prev := Default()
t.Cleanup(func() {
SetDefault(prev)
})
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
langs := CurrentAvailableLanguages()
require.NotEmpty(t, langs)
assert.Equal(t, svc.AvailableLanguages(), langs)
}
func TestFallback_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.Equal(t, "en", Fallback())
SetFallback("fr")
assert.Equal(t, "fr", Fallback())
}
func TestDebug_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.False(t, Debug())
SetDebug(true)
assert.True(t, Debug())
}
func TestCurrentState_Good(t *testing.T) {
svc, err := NewWithLoader(messageBaseFallbackLoader{})
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
state := CurrentState()
assert.Equal(t, svc.Language(), state.Language)
assert.Equal(t, svc.AvailableLanguages(), state.AvailableLanguages)
assert.Equal(t, svc.Mode(), state.Mode)
assert.Equal(t, svc.Fallback(), state.Fallback)
assert.Equal(t, svc.Formality(), state.Formality)
assert.Equal(t, svc.Location(), state.Location)
assert.Equal(t, svc.Direction(), state.Direction)
assert.Equal(t, svc.IsRTL(), state.IsRTL)
assert.Equal(t, svc.Debug(), state.Debug)
assert.Len(t, state.Handlers, len(svc.Handlers()))
state.AvailableLanguages[0] = "zz"
assert.NotEqual(t, "zz", CurrentState().AvailableLanguages[0])
state.Handlers[0] = nil
assert.NotNil(t, CurrentState().Handlers[0])
}
func TestState_Good_WithoutDefaultService(t *testing.T) {
var svc *Service
state := svc.State()
assert.Equal(t, defaultServiceStateSnapshot(), state)
}
// --- SetMode / CurrentMode ---
@ -321,136 +130,21 @@ func TestN_Good(t *testing.T) {
name string
format string
value any
args []any
want string
}{
{"number", "number", int64(1234567), nil, "1,234,567"},
{"percent", "percent", 0.85, nil, "85%"},
{"bytes", "bytes", int64(1536000), nil, "1.46 MB"},
{"ordinal", "ordinal", 1, nil, "1st"},
{"ago", "ago", 5, []any{"minutes"}, "5 minutes ago"},
{"number", "number", int64(1234567), "1,234,567"},
{"percent", "percent", 0.85, "85%"},
{"bytes", "bytes", int64(1536000), "1.5 MB"},
{"ordinal", "ordinal", 1, "1st"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := N(tt.format, tt.value, tt.args...)
got := N(tt.format, tt.value)
assert.Equal(t, tt.want, got)
})
}
}
func TestN_Good_WithoutDefaultService(t *testing.T) {
prev := Default()
SetDefault(nil)
t.Cleanup(func() {
SetDefault(prev)
})
tests := []struct {
name string
format string
value any
args []any
want string
}{
{"number", "number", int64(1234567), nil, "1,234,567"},
{"percent", "percent", 0.85, nil, "85%"},
{"bytes", "bytes", int64(1536000), nil, "1.46 MB"},
{"ordinal", "ordinal", 1, nil, "1st"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := N(tt.format, tt.value, tt.args...)
assert.Equal(t, tt.want, got)
})
}
}
// --- Prompt() prompt shorthand ---
func TestPrompt_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
_ = Init()
SetDefault(svc)
tests := []struct {
name string
key string
want string
}{
{"yes", "yes", "y"},
{"yes_trimmed", " yes ", "y"},
{"yes_prefixed", "prompt.yes", "y"},
{"confirm", "confirm", "Are you sure?"},
{"confirm_prefixed", "prompt.confirm", "Are you sure?"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Prompt(tt.key)
assert.Equal(t, tt.want, got)
})
}
}
func TestCurrentPrompt_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
_ = Init()
SetDefault(svc)
assert.Equal(t, Prompt("confirm"), CurrentPrompt("confirm"))
}
// --- Lang() language label shorthand ---
func TestLang_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
_ = Init()
SetDefault(svc)
tests := []struct {
name string
key string
want string
}{
{"de", "de", "German"},
{"fr", "fr", "French"},
{"fr_ca", "fr_CA", "French"},
{"fr_prefixed", "lang.fr", "French"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Lang(tt.key)
assert.Equal(t, tt.want, got)
})
}
}
func TestLang_MissingKeyHandler_FiresOnce(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetMissingKeyHandlers()
SetMode(ModeNormal)
SetDefault(prev)
})
SetMode(ModeCollect)
calls := 0
SetMissingKeyHandlers(func(MissingKey) {
calls++
})
got := Lang("zz")
assert.Equal(t, "[lang.zz]", got)
assert.Equal(t, 1, calls)
}
// --- AddHandler / PrependHandler ---
func TestAddHandler_Good(t *testing.T) {
@ -465,46 +159,6 @@ func TestAddHandler_Good(t *testing.T) {
assert.Equal(t, initialCount+1, len(svc.Handlers()))
}
func TestAddHandler_Good_Variadic(t *testing.T) {
svc, err := New(WithHandlers())
require.NoError(t, err)
_ = Init()
SetDefault(svc)
AddHandler(LabelHandler{}, ProgressHandler{})
handlers := svc.Handlers()
assert.Equal(t, 2, len(handlers))
assert.IsType(t, LabelHandler{}, handlers[0])
assert.IsType(t, ProgressHandler{}, handlers[1])
}
func TestAddHandler_Good_SkipsNil(t *testing.T) {
svc, err := New(WithHandlers())
require.NoError(t, err)
_ = Init()
SetDefault(svc)
var nilHandler KeyHandler
AddHandler(nilHandler, LabelHandler{})
handlers := svc.Handlers()
require.Len(t, handlers, 1)
assert.IsType(t, LabelHandler{}, handlers[0])
}
func TestAddHandler_DoesNotMutateInputSlice(t *testing.T) {
svc, err := New(WithHandlers())
require.NoError(t, err)
_ = Init()
SetDefault(svc)
handlers := []KeyHandler{nil, LabelHandler{}}
AddHandler(handlers...)
assert.Nil(t, handlers[0])
assert.IsType(t, LabelHandler{}, handlers[1])
}
func TestPrependHandler_Good(t *testing.T) {
svc, err := New(WithHandlers()) // start with no handlers
require.NoError(t, err)
@ -520,132 +174,6 @@ func TestPrependHandler_Good(t *testing.T) {
assert.Equal(t, 2, len(handlers))
}
func TestPrependHandler_Good_Variadic(t *testing.T) {
svc, err := New(WithHandlers())
require.NoError(t, err)
_ = Init()
SetDefault(svc)
PrependHandler(LabelHandler{}, ProgressHandler{})
handlers := svc.Handlers()
assert.Equal(t, 2, len(handlers))
assert.IsType(t, LabelHandler{}, handlers[0])
assert.IsType(t, ProgressHandler{}, handlers[1])
}
func TestPrependHandler_Good_SkipsNil(t *testing.T) {
svc, err := New(WithHandlers())
require.NoError(t, err)
_ = Init()
SetDefault(svc)
var nilHandler KeyHandler
PrependHandler(nilHandler, LabelHandler{})
handlers := svc.Handlers()
require.Len(t, handlers, 1)
assert.IsType(t, LabelHandler{}, handlers[0])
}
func TestPrependHandler_DoesNotMutateInputSlice(t *testing.T) {
svc, err := New(WithHandlers())
require.NoError(t, err)
_ = Init()
SetDefault(svc)
handlers := []KeyHandler{nil, ProgressHandler{}}
PrependHandler(handlers...)
assert.Nil(t, handlers[0])
assert.IsType(t, ProgressHandler{}, handlers[1])
}
func TestClearHandlers_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
_ = Init()
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
AddHandler(LabelHandler{})
require.NotEmpty(t, svc.Handlers())
ClearHandlers()
assert.Empty(t, svc.Handlers())
}
func TestResetHandlers_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
_ = Init()
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
ClearHandlers()
require.Empty(t, svc.Handlers())
svc.ResetHandlers()
require.Len(t, svc.Handlers(), len(DefaultHandlers()))
assert.IsType(t, LabelHandler{}, svc.Handlers()[0])
ClearHandlers()
require.Empty(t, svc.Handlers())
ResetHandlers()
handlers := svc.Handlers()
require.Len(t, handlers, len(DefaultHandlers()))
assert.IsType(t, LabelHandler{}, handlers[0])
assert.Equal(t, "Status:", T("i18n.label.status"))
}
func TestSetHandlers_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
_ = Init()
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
SetHandlers(serviceStubHandler{})
handlers := CurrentHandlers()
require.Len(t, handlers, 1)
assert.IsType(t, serviceStubHandler{}, handlers[0])
assert.Equal(t, "stub", T("custom.stub"))
assert.Equal(t, "i18n.label.status", T("i18n.label.status"))
}
func TestHandlers_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
handlers := Handlers()
require.Len(t, handlers, len(svc.Handlers()))
assert.Equal(t, svc.Handlers(), handlers)
}
func TestNewWithHandlers_SkipsNil(t *testing.T) {
svc, err := New(WithHandlers(nil, LabelHandler{}))
require.NoError(t, err)
handlers := svc.Handlers()
require.Len(t, handlers, 1)
assert.IsType(t, LabelHandler{}, handlers[0])
}
// --- executeIntentTemplate ---
func TestExecuteIntentTemplate_Good(t *testing.T) {
@ -689,43 +217,6 @@ func TestExecuteIntentTemplate_Good_WithFuncs(t *testing.T) {
assert.Equal(t, "built!", got)
}
func TestComposeIntent_Good(t *testing.T) {
intent := Intent{
Meta: IntentMeta{
Type: "action",
Verb: "delete",
Dangerous: true,
Default: "no",
Supports: []string{"yes", "no"},
},
Question: "Delete {{.Subject}}?",
Confirm: "Really delete {{article .Subject}}?",
Success: "{{title .Subject}} deleted",
Failure: "Failed to delete {{lower .Subject}}",
}
got := ComposeIntent(intent, S("file", "config.yaml"))
assert.Equal(t, "Delete config.yaml?", got.Question)
assert.Equal(t, "Really delete a config.yaml?", got.Confirm)
assert.Equal(t, "Config.yaml deleted", got.Success)
assert.Equal(t, "Failed to delete config.yaml", got.Failure)
assert.Equal(t, intent.Meta, got.Meta)
}
func TestIntentCompose_Good_NilSubject(t *testing.T) {
intent := Intent{
Question: "Proceed?",
}
got := intent.Compose(nil)
assert.Equal(t, "Proceed?", got.Question)
assert.Empty(t, got.Confirm)
assert.Empty(t, got.Success)
assert.Empty(t, got.Failure)
}
// --- applyTemplate ---
func TestApplyTemplate_Good(t *testing.T) {
@ -770,3 +261,27 @@ func TestErrServiceNotInitialised_Good(t *testing.T) {
func TestErrServiceNotInitialized_DeprecatedAlias(t *testing.T) {
assert.Equal(t, ErrServiceNotInitialised, ErrServiceNotInitialized, "deprecated alias must point to the same error")
}
// TestT_Ugly verifies T() with no default service returns the key unchanged.
//
// SetDefault(nil); T("any.key") // "any.key"
func TestT_Ugly(t *testing.T) {
SetDefault(nil)
got := T("any.key")
assert.Equal(t, "any.key", got)
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
}
// TestSetLanguage_Ugly verifies SetLanguage returns ErrServiceNotInitialised with no default.
//
// SetDefault(nil); SetLanguage("en") // ErrServiceNotInitialised
func TestSetLanguage_Ugly(t *testing.T) {
SetDefault(nil)
err := SetLanguage("en")
assert.ErrorIs(t, err, ErrServiceNotInitialised)
svc, initErr := New()
require.NoError(t, initErr)
SetDefault(svc)
}

View file

@ -2,11 +2,11 @@ package i18n
// GetPluralRule returns the plural rule for a language code.
func GetPluralRule(lang string) PluralRule {
lang = normalizeLanguageTag(lang)
if rule, ok := pluralRules[lang]; ok {
return rule
}
if base := baseLanguageTag(lang); base != "" {
if len(lang) > 2 {
base := lang[:2]
if rule, ok := pluralRules[base]; ok {
return rule
}
@ -83,20 +83,3 @@ func pluralRuleArabic(n int) PluralCategory {
func pluralRuleChinese(n int) PluralCategory { return PluralOther }
func pluralRuleJapanese(n int) PluralCategory { return PluralOther }
func pluralRuleKorean(n int) PluralCategory { return PluralOther }
func pluralRuleWelsh(n int) PluralCategory {
switch n {
case 0:
return PluralZero
case 1:
return PluralOne
case 2:
return PluralTwo
case 3:
return PluralFew
case 6:
return PluralMany
default:
return PluralOther
}
}

View file

@ -12,13 +12,11 @@ func TestGetPluralCategory(t *testing.T) {
{"en", 0, PluralOther},
{"en", 1, PluralOne},
{"en", 2, PluralOther},
{"en_US", 1, PluralOne},
// French (0 and 1 are singular)
{"fr", 0, PluralOne},
{"fr", 1, PluralOne},
{"fr", 2, PluralOther},
{"fr_CA", 2, PluralOther},
// Russian
{"ru", 1, PluralOne},
@ -41,14 +39,6 @@ func TestGetPluralCategory(t *testing.T) {
{"ar", 11, PluralMany},
{"ar", 100, PluralOther},
// Welsh
{"cy", 0, PluralZero},
{"cy", 1, PluralOne},
{"cy", 2, PluralTwo},
{"cy", 3, PluralFew},
{"cy", 6, PluralMany},
{"cy", 7, PluralOther},
// Chinese (always other)
{"zh", 0, PluralOther},
{"zh", 1, PluralOther},
@ -85,24 +75,36 @@ func TestGetPluralRule(t *testing.T) {
t.Error("English-US rule(1) should be PluralOne")
}
rule = GetPluralRule("fr-Latn-CA")
if rule(0) != PluralOne {
t.Error("French multi-part tag rule(0) should be PluralOne")
}
rule = GetPluralRule("cy-GB")
if rule(2) != PluralTwo {
t.Error("Welsh-GB rule(2) should be PluralTwo")
}
rule = GetPluralRule("en_US")
if rule(1) != PluralOne {
t.Error("English_US rule(1) should be PluralOne")
}
// Unknown falls back to English
rule = GetPluralRule("xx-YY")
if rule(1) != PluralOne {
t.Error("Unknown rule(1) should fallback to English PluralOne")
}
}
// TestGetPluralCategory_Good verifies German singular follows English rules.
//
// GetPluralCategory("de", 1) // PluralOne
func TestGetPluralCategory_Good(t *testing.T) {
if got := GetPluralCategory("de", 1); got != PluralOne {
t.Errorf("GetPluralCategory(de, 1) = %v, want PluralOne", got)
}
}
// TestGetPluralCategory_Bad verifies an unknown language code uses English rules.
//
// GetPluralCategory("zz", 2) // PluralOther
func TestGetPluralCategory_Bad(t *testing.T) {
if got := GetPluralCategory("zz", 2); got != PluralOther {
t.Errorf("GetPluralCategory(zz, 2) = %v, want PluralOther", got)
}
}
// TestGetPluralCategory_Ugly verifies negative counts don't panic.
//
// GetPluralCategory("en", -1) // PluralOther (falls through)
func TestGetPluralCategory_Ugly(t *testing.T) {
// Negative counts are unusual but must not panic.
got := GetPluralCategory("en", -1)
_ = got // result is implementation-defined; just verify no panic
}

410
loader.go
View file

@ -1,11 +1,8 @@
package i18n
import (
"errors"
"io/fs"
"math"
"path"
"slices"
"sync"
"dappco.re/go/core"
@ -29,24 +26,22 @@ func NewFSLoader(fsys fs.FS, dir string) *FSLoader {
// Load implements Loader.Load.
func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
variants := localeFilenameCandidates(lang)
variants := []string{
lang + ".json",
core.Replace(lang, "-", "_") + ".json",
core.Replace(lang, "_", "-") + ".json",
}
var data []byte
var err error
var firstNonMissingErr error
for _, filename := range variants {
filePath := path.Join(l.dir, filename)
data, err = fs.ReadFile(l.fsys, filePath)
if err == nil {
break
}
if firstNonMissingErr == nil && !errors.Is(err, fs.ErrNotExist) {
firstNonMissingErr = err
}
}
if err != nil {
if firstNonMissingErr != nil {
err = firstNonMissingErr
}
return nil, nil, log.E("FSLoader.Load", "locale not found: "+lang, err)
}
@ -67,37 +62,6 @@ func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
return messages, grammar, nil
}
func localeFilenameCandidates(lang string) []string {
// Preserve the documented lookup order: exact tag first, then underscore /
// hyphen variants, then the base language tag.
variants := make([]string, 0, 4)
addVariant := func(candidate string) {
for _, existing := range variants {
if existing == candidate {
return
}
}
variants = append(variants, candidate)
}
canonical := normalizeLanguageTag(lang)
addTag := func(tag string) {
if tag == "" {
return
}
addVariant(tag + ".json")
addVariant(core.Replace(tag, "-", "_") + ".json")
addVariant(core.Replace(tag, "_", "-") + ".json")
}
addTag(lang)
if canonical != "" && canonical != lang {
addTag(canonical)
}
if base := baseLanguageTag(canonical); base != "" && base != canonical {
addTag(base)
}
return variants
}
// Languages implements Loader.Languages.
func (l *FSLoader) Languages() []string {
l.langOnce.Do(func() {
@ -106,25 +70,16 @@ func (l *FSLoader) Languages() []string {
l.langErr = log.E("FSLoader.Languages", "read locale directory: "+l.dir, err)
return
}
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") {
continue
}
lang := core.TrimSuffix(entry.Name(), ".json")
lang = normalizeLanguageTag(core.Replace(lang, "_", "-"))
if lang == "" {
continue
}
if _, ok := seen[lang]; ok {
continue
}
seen[lang] = struct{}{}
lang = core.Replace(lang, "_", "-")
l.languages = append(l.languages, lang)
}
slices.Sort(l.languages)
})
return append([]string(nil), l.languages...)
return l.languages
}
// LanguagesErr returns any error from the directory scan.
@ -150,33 +105,119 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa
switch v := value.(type) {
case string:
if grammar != nil && loadGrammarWord(fullKey, v, grammar) {
if grammar != nil && core.HasPrefix(fullKey, "gram.word.") {
wordKey := core.TrimPrefix(fullKey, "gram.word.")
grammar.Words[core.Lower(wordKey)] = v
continue
}
out[fullKey] = Message{Text: v}
case map[string]any:
if grammar != nil && loadGrammarVerb(fullKey, key, v, grammar) {
// Verb form object (has base/past/gerund keys)
if grammar != nil && isVerbFormObject(v) {
verbName := key
if core.HasPrefix(fullKey, "gram.verb.") {
verbName = core.TrimPrefix(fullKey, "gram.verb.")
}
forms := VerbForms{}
if past, ok := v["past"].(string); ok {
forms.Past = past
}
if gerund, ok := v["gerund"].(string); ok {
forms.Gerund = gerund
}
grammar.Verbs[core.Lower(verbName)] = forms
continue
}
if grammar != nil && loadGrammarNoun(fullKey, key, v, grammar) {
// Noun form object (under gram.noun.* or has gender field)
if grammar != nil && (core.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) {
nounName := key
if core.HasPrefix(fullKey, "gram.noun.") {
nounName = core.TrimPrefix(fullKey, "gram.noun.")
}
_, hasOne := v["one"]
_, hasOther := v["other"]
if hasOne && hasOther {
forms := NounForms{}
if one, ok := v["one"].(string); ok {
forms.One = one
}
if other, ok := v["other"].(string); ok {
forms.Other = other
}
if gender, ok := v["gender"].(string); ok {
forms.Gender = gender
}
grammar.Nouns[core.Lower(nounName)] = forms
continue
}
}
// Signal data for disambiguation
if grammar != nil && fullKey == "gram.signal" {
if nd, ok := v["noun_determiner"]; ok {
if arr, ok := nd.([]any); ok {
for _, item := range arr {
if s, ok := item.(string); ok {
grammar.Signals.NounDeterminers = append(grammar.Signals.NounDeterminers, core.Lower(s))
}
}
}
}
if va, ok := v["verb_auxiliary"]; ok {
if arr, ok := va.([]any); ok {
for _, item := range arr {
if s, ok := item.(string); ok {
grammar.Signals.VerbAuxiliaries = append(grammar.Signals.VerbAuxiliaries, core.Lower(s))
}
}
}
}
if vi, ok := v["verb_infinitive"]; ok {
if arr, ok := vi.([]any); ok {
for _, item := range arr {
if s, ok := item.(string); ok {
grammar.Signals.VerbInfinitive = append(grammar.Signals.VerbInfinitive, core.Lower(s))
}
}
}
}
continue
}
if grammar != nil && loadGrammarSignals(fullKey, v, grammar) {
// Article configuration
if grammar != nil && fullKey == "gram.article" {
if indef, ok := v["indefinite"].(map[string]any); ok {
if def, ok := indef["default"].(string); ok {
grammar.Articles.IndefiniteDefault = def
}
if vowel, ok := indef["vowel"].(string); ok {
grammar.Articles.IndefiniteVowel = vowel
}
}
if def, ok := v["definite"].(string); ok {
grammar.Articles.Definite = def
}
if bg, ok := v["by_gender"].(map[string]any); ok {
grammar.Articles.ByGender = make(map[string]string, len(bg))
for g, art := range bg {
if s, ok := art.(string); ok {
grammar.Articles.ByGender[g] = s
}
}
}
continue
}
if grammar != nil && loadGrammarArticle(fullKey, v, grammar) {
continue
}
if grammar != nil && loadGrammarPunctuation(fullKey, v, grammar) {
continue
}
if grammar != nil && loadGrammarNumber(fullKey, v, grammar) {
// Punctuation rules
if grammar != nil && fullKey == "gram.punct" {
if label, ok := v["label"].(string); ok {
grammar.Punct.LabelSuffix = label
}
if progress, ok := v["progress"].(string); ok {
grammar.Punct.ProgressSuffix = progress
}
continue
}
@ -209,170 +250,11 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa
}
}
func loadGrammarWord(fullKey, value string, grammar *GrammarData) bool {
if grammar == nil || !core.HasPrefix(fullKey, "gram.word.") {
return false
}
wordKey := core.TrimPrefix(fullKey, "gram.word.")
if shouldSkipDeprecatedEnglishGrammarEntry(fullKey) {
return true
}
grammar.Words[core.Lower(wordKey)] = value
return true
}
func loadGrammarVerb(fullKey, key string, v map[string]any, grammar *GrammarData) bool {
if grammar == nil || !isVerbFormObject(v) {
return false
}
verbName := key
if base, ok := v["base"].(string); ok && base != "" {
verbName = base
}
if core.HasPrefix(fullKey, "gram.verb.") {
after := core.TrimPrefix(fullKey, "gram.verb.")
if base, ok := v["base"].(string); !ok || base == "" {
verbName = after
}
}
forms := VerbForms{}
if past, ok := v["past"].(string); ok {
forms.Past = past
}
if gerund, ok := v["gerund"].(string); ok {
forms.Gerund = gerund
}
grammar.Verbs[core.Lower(verbName)] = forms
return true
}
func loadGrammarNoun(fullKey, key string, v map[string]any, grammar *GrammarData) bool {
if grammar == nil || !(core.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) {
return false
}
nounName := key
if core.HasPrefix(fullKey, "gram.noun.") {
nounName = core.TrimPrefix(fullKey, "gram.noun.")
}
if shouldSkipDeprecatedEnglishGrammarEntry(fullKey) {
return true
}
_, hasOne := v["one"]
_, hasOther := v["other"]
if !hasOne || !hasOther {
return false
}
forms := NounForms{}
if one, ok := v["one"].(string); ok {
forms.One = one
}
if other, ok := v["other"].(string); ok {
forms.Other = other
}
if gender, ok := v["gender"].(string); ok {
forms.Gender = gender
}
grammar.Nouns[core.Lower(nounName)] = forms
return true
}
func loadGrammarSignals(fullKey string, v map[string]any, grammar *GrammarData) bool {
if grammar == nil || (fullKey != "gram.signal" && fullKey != "gram.signals") {
return false
}
loadSignalStringList := func(dst *[]string, raw any) {
arr, ok := raw.([]any)
if !ok {
return
}
for _, item := range arr {
if s, ok := item.(string); ok {
*dst = append(*dst, core.Lower(s))
}
}
}
loadSignalStringList(&grammar.Signals.NounDeterminers, v["noun_determiner"])
loadSignalStringList(&grammar.Signals.VerbAuxiliaries, v["verb_auxiliary"])
loadSignalStringList(&grammar.Signals.VerbInfinitive, v["verb_infinitive"])
loadSignalStringList(&grammar.Signals.VerbNegation, v["verb_negation"])
if priors, ok := v["prior"].(map[string]any); ok {
loadSignalPriors(grammar, priors)
}
if priors, ok := v["priors"].(map[string]any); ok {
loadSignalPriors(grammar, priors)
}
return true
}
func loadGrammarArticle(fullKey string, v map[string]any, grammar *GrammarData) bool {
if grammar == nil || fullKey != "gram.article" {
return false
}
if indef, ok := v["indefinite"].(map[string]any); ok {
if def, ok := indef["default"].(string); ok {
grammar.Articles.IndefiniteDefault = def
}
if vowel, ok := indef["vowel"].(string); ok {
grammar.Articles.IndefiniteVowel = vowel
}
}
if def, ok := v["definite"].(string); ok {
grammar.Articles.Definite = def
}
if bg, ok := v["by_gender"].(map[string]any); ok {
grammar.Articles.ByGender = make(map[string]string, len(bg))
for g, art := range bg {
if s, ok := art.(string); ok {
grammar.Articles.ByGender[g] = s
}
}
}
if bg, ok := v["byGender"].(map[string]any); ok {
grammar.Articles.ByGender = make(map[string]string, len(bg))
for g, art := range bg {
if s, ok := art.(string); ok {
grammar.Articles.ByGender[g] = s
}
}
}
return true
}
func loadGrammarPunctuation(fullKey string, v map[string]any, grammar *GrammarData) bool {
if grammar == nil || fullKey != "gram.punct" {
return false
}
if label, ok := v["label"].(string); ok {
grammar.Punct.LabelSuffix = label
}
if progress, ok := v["progress"].(string); ok {
grammar.Punct.ProgressSuffix = progress
}
return true
}
func loadGrammarNumber(fullKey string, v map[string]any, grammar *GrammarData) bool {
if grammar == nil || fullKey != "gram.number" {
return false
}
if thousands, ok := v["thousands"].(string); ok {
grammar.Number.ThousandsSep = thousands
}
if decimal, ok := v["decimal"].(string); ok {
grammar.Number.DecimalSep = decimal
}
if percent, ok := v["percent"].(string); ok {
grammar.Number.PercentFmt = percent
}
return true
}
func isVerbFormObject(m map[string]any) bool {
_, hasBase := m["base"]
_, hasPast := m["past"]
_, hasGerund := m["gerund"]
// Verb objects are identified by their inflected forms. A bare "base"
// field is metadata, not enough to claim the object is a verb table.
return (hasPast || hasGerund) && !isPluralObject(m)
return (hasBase || hasPast || hasGerund) && !isPluralObject(m)
}
func isNounFormObject(m map[string]any) bool {
@ -397,77 +279,3 @@ func isPluralObject(m map[string]any) bool {
}
return true
}
func loadSignalPriors(grammar *GrammarData, priors map[string]any) {
if grammar == nil || len(priors) == 0 {
return
}
if grammar.Signals.Priors == nil {
grammar.Signals.Priors = make(map[string]map[string]float64, len(priors))
}
for word, raw := range priors {
bucket, ok := raw.(map[string]any)
if !ok || len(bucket) == 0 {
continue
}
key := core.Lower(word)
if grammar.Signals.Priors[key] == nil {
grammar.Signals.Priors[key] = make(map[string]float64, len(bucket))
}
for role, value := range bucket {
score, ok := float64Value(value)
if !ok || !validSignalPriorScore(score) {
continue
}
grammar.Signals.Priors[key][core.Lower(role)] = score
}
}
}
func validSignalPriorScore(score float64) bool {
return !math.IsNaN(score) && !math.IsInf(score, 0) && score >= 0
}
func float64Value(v any) (float64, bool) {
if v == nil {
return 0, false
}
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
case int32:
return float64(n), true
case int16:
return float64(n), true
case int8:
return float64(n), true
case uint:
return float64(n), true
case uint64:
return float64(n), true
case uint32:
return float64(n), true
case uint16:
return float64(n), true
case uint8:
return float64(n), true
default:
return 0, false
}
}
func shouldSkipDeprecatedEnglishGrammarEntry(fullKey string) bool {
switch fullKey {
case "gram.noun.passed", "gram.noun.failed", "gram.noun.skipped",
"gram.word.passed", "gram.word.failed", "gram.word.skipped":
return true
default:
return false
}
}

View file

@ -19,38 +19,6 @@ func TestFSLoaderLanguages(t *testing.T) {
}
}
func TestFSLoaderLanguagesCanonicalAndUnique(t *testing.T) {
fs := fstest.MapFS{
"locales/en.json": &fstest.MapFile{Data: []byte(`{}`)},
"locales/en_US.json": &fstest.MapFile{Data: []byte(`{}`)},
"locales/es-MX.json": &fstest.MapFile{Data: []byte(`{}`)},
"locales/fr.json": &fstest.MapFile{Data: []byte(`{}`)},
}
loader := NewFSLoader(fs, "locales")
langs := loader.Languages()
want := []string{"en", "en-US", "es-MX", "fr"}
if !slices.Equal(langs, want) {
t.Fatalf("Languages() = %v, want %v", langs, want)
}
}
func TestFSLoaderLanguagesReturnsCopy(t *testing.T) {
loader := NewFSLoader(localeFS, "locales")
langs := loader.Languages()
if len(langs) == 0 {
t.Fatal("Languages() returned empty")
}
langs[0] = "zz"
got := loader.Languages()
if got[0] == "zz" {
t.Fatalf("Languages() returned shared slice: %v", got)
}
}
func TestFSLoaderLoad(t *testing.T) {
loader := NewFSLoader(localeFS, "locales")
messages, grammar, err := loader.Load("en")
@ -117,17 +85,6 @@ func TestFSLoaderLoad(t *testing.T) {
t.Errorf("punct.progress = %q, want '...'", grammar.Punct.ProgressSuffix)
}
// Number formatting from gram.number
if grammar.Number.ThousandsSep != "," {
t.Errorf("number.thousands = %q, want ','", grammar.Number.ThousandsSep)
}
if grammar.Number.DecimalSep != "." {
t.Errorf("number.decimal = %q, want '.'", grammar.Number.DecimalSep)
}
if grammar.Number.PercentFmt != "%s%%" {
t.Errorf("number.percent = %q, want '%%s%%%%'", grammar.Number.PercentFmt)
}
// Words from gram.word.*
if len(grammar.Words) == 0 {
t.Error("grammar has 0 words")
@ -148,72 +105,6 @@ func TestFSLoaderLoadMissing(t *testing.T) {
}
}
func TestFSLoaderLoadFallsBackToBaseLanguage(t *testing.T) {
fs := fstest.MapFS{
"locales/en.json": &fstest.MapFile{
Data: []byte(`{
"greeting": "hello",
"gram": {
"article": {
"indefinite": { "default": "a", "vowel": "an" },
"definite": "the"
}
}
}`),
},
}
loader := NewFSLoader(fs, "locales")
messages, grammar, err := loader.Load("en-GB")
if err != nil {
t.Fatalf("Load(en-GB) error: %v", err)
}
if got := messages["greeting"].Text; got != "hello" {
t.Fatalf("Load(en-GB) greeting = %q, want %q", got, "hello")
}
if grammar == nil {
t.Fatal("Load(en-GB) returned nil grammar")
}
if grammar.Articles.Definite != "the" {
t.Fatalf("Load(en-GB) grammar article = %q, want %q", grammar.Articles.Definite, "the")
}
}
func TestLocaleFilenameCandidates(t *testing.T) {
got := localeFilenameCandidates("en-GB")
want := []string{"en-GB.json", "en_GB.json", "en.json"}
if !slices.Equal(got, want) {
t.Fatalf("localeFilenameCandidates(en-GB) = %v, want %v", got, want)
}
}
func TestLocaleFilenameCandidatesNormalisesCase(t *testing.T) {
got := localeFilenameCandidates("en-us")
want := []string{"en-us.json", "en_us.json", "en-US.json", "en_US.json", "en.json"}
if !slices.Equal(got, want) {
t.Fatalf("localeFilenameCandidates(en-us) = %v, want %v", got, want)
}
}
func TestFSLoaderLoadUsesCanonicalVariant(t *testing.T) {
fs := fstest.MapFS{
"locales/en-US.json": &fstest.MapFile{
Data: []byte(`{
"greeting": "hello"
}`),
},
}
loader := NewFSLoader(fs, "locales")
messages, _, err := loader.Load("en-us")
if err != nil {
t.Fatalf("Load(en-us) error: %v", err)
}
if got := messages["greeting"].Text; got != "hello" {
t.Fatalf("Load(en-us) greeting = %q, want %q", got, "hello")
}
}
func TestFlattenWithGrammar(t *testing.T) {
messages := make(map[string]Message)
grammar := &GrammarData{
@ -230,51 +121,20 @@ func TestFlattenWithGrammar(t *testing.T) {
"past": "tested",
"gerund": "testing",
},
"partial_past": map[string]any{
"past": "partialed",
},
"partial_gerund": map[string]any{
"gerund": "partialing",
},
"publish_draft": map[string]any{
"base": "publish",
"past": "published",
"gerund": "publishing",
},
},
"noun": map[string]any{
"widget": map[string]any{
"one": "widget",
"other": "widgets",
},
"passed": map[string]any{
"one": "passed",
"other": "passed",
},
},
"word": map[string]any{
"api": "API",
"failed": "failed",
"skipped": "skipped",
"api": "API",
},
"punct": map[string]any{
"label": ":",
"progress": "...",
},
"number": map[string]any{
"thousands": ",",
"decimal": ".",
"percent": "%s%%",
},
"signal": map[string]any{
"prior": map[string]any{
"commit": map[string]any{
"verb": 0.25,
"noun": 0.75,
},
},
"verb_negation": []any{"not", "never"},
},
"article": map[string]any{
"indefinite": map[string]any{
"default": "a",
@ -299,35 +159,6 @@ func TestFlattenWithGrammar(t *testing.T) {
t.Errorf("test.past = %q, want 'tested'", v.Past)
}
}
if v, ok := grammar.Verbs["publish"]; !ok {
t.Error("verb base override 'publish' not extracted")
} else {
if v.Past != "published" {
t.Errorf("publish.past = %q, want 'published'", v.Past)
}
if v.Gerund != "publishing" {
t.Errorf("publish.gerund = %q, want 'publishing'", v.Gerund)
}
}
if _, ok := grammar.Verbs["publish_draft"]; ok {
t.Error("verb should be stored under explicit base, not JSON key")
}
if v, ok := grammar.Verbs["partial_past"]; !ok {
t.Error("incomplete verb entry with only past should be extracted")
} else if v.Past != "partialed" || v.Gerund != "" {
t.Errorf("partial_past forms = %+v, want Past only", v)
}
if v, ok := grammar.Verbs["partial_gerund"]; !ok {
t.Error("incomplete verb entry with only gerund should be extracted")
} else if v.Past != "" || v.Gerund != "partialing" {
t.Errorf("partial_gerund forms = %+v, want Gerund only", v)
}
if _, ok := messages["gram.verb.partial_past"]; ok {
t.Error("gram.verb.partial_past should not be flattened into messages")
}
if _, ok := messages["gram.verb.partial_gerund"]; ok {
t.Error("gram.verb.partial_gerund should not be flattened into messages")
}
// Noun extracted
if n, ok := grammar.Nouns["widget"]; !ok {
@ -337,34 +168,17 @@ func TestFlattenWithGrammar(t *testing.T) {
t.Errorf("widget.other = %q, want 'widgets'", n.Other)
}
}
if _, ok := grammar.Nouns["passed"]; ok {
t.Error("deprecated noun 'passed' should be ignored")
}
// Word extracted
if grammar.Words["api"] != "API" {
t.Errorf("word 'api' = %q, want 'API'", grammar.Words["api"])
}
if _, ok := grammar.Words["failed"]; ok {
t.Error("deprecated word 'failed' should be ignored")
}
if _, ok := grammar.Words["skipped"]; ok {
t.Error("deprecated word 'skipped' should be ignored")
}
// Punct extracted
if grammar.Punct.LabelSuffix != ":" {
t.Errorf("punct.label = %q, want ':'", grammar.Punct.LabelSuffix)
}
// Number formatting extracted
if grammar.Number.ThousandsSep != "," {
t.Errorf("number.thousands = %q, want ','", grammar.Number.ThousandsSep)
}
if len(grammar.Signals.VerbNegation) != 2 || grammar.Signals.VerbNegation[0] != "not" || grammar.Signals.VerbNegation[1] != "never" {
t.Errorf("verb negation not extracted: %+v", grammar.Signals.VerbNegation)
}
// Articles extracted
if grammar.Articles.IndefiniteDefault != "a" {
t.Errorf("article.indefinite.default = %q, want 'a'", grammar.Articles.IndefiniteDefault)
@ -374,369 +188,6 @@ func TestFlattenWithGrammar(t *testing.T) {
if msg, ok := messages["prompt.yes"]; !ok || msg.Text != "y" {
t.Errorf("prompt.yes not flattened correctly, got %+v", messages["prompt.yes"])
}
if _, ok := messages["gram.number.thousands"]; ok {
t.Error("gram.number.thousands should not be flattened into messages")
}
}
func TestFlattenWithGrammar_DetectsSchemaObjectsOutsideGrammarPaths(t *testing.T) {
messages := make(map[string]Message)
grammar := &GrammarData{
Verbs: make(map[string]VerbForms),
Words: make(map[string]string),
}
raw := map[string]any{
"lexicon": map[string]any{
"base_only": map[string]any{
"base": "base",
},
},
"phrases": map[string]any{
"draft": map[string]any{
"past": "drafted",
"gerund": "drafting",
},
},
}
flattenWithGrammar("", raw, messages, grammar)
if _, ok := grammar.Verbs["draft"]; !ok {
t.Fatal("verb schema object outside gram.verb.* was not extracted")
}
if _, ok := messages["phrases.draft"]; ok {
t.Fatal("verb schema object should not be flattened into messages")
}
if _, ok := grammar.Verbs["base_only"]; ok {
t.Fatal("base-only object should not be detected as a verb table")
}
}
func TestMergeGrammarData(t *testing.T) {
const lang = "zz"
original := GetGrammarData(lang)
t.Cleanup(func() {
SetGrammarData(lang, original)
})
SetGrammarData(lang, &GrammarData{
Verbs: map[string]VerbForms{
"keep": {Past: "kept", Gerund: "keeping"},
},
Nouns: map[string]NounForms{
"file": {One: "file", Other: "files"},
},
Words: map[string]string{
"url": "URL",
},
Articles: ArticleForms{
IndefiniteDefault: "a",
IndefiniteVowel: "an",
Definite: "the",
ByGender: map[string]string{
"m": "le",
},
},
Punct: PunctuationRules{
LabelSuffix: ":",
ProgressSuffix: "...",
},
Signals: SignalData{
NounDeterminers: []string{"the"},
VerbAuxiliaries: []string{"will"},
VerbInfinitive: []string{"to"},
VerbNegation: []string{"not"},
Priors: map[string]map[string]float64{
"run": {
"verb": 0.7,
},
},
},
Number: NumberFormat{
ThousandsSep: ",",
DecimalSep: ".",
PercentFmt: "%s%%",
},
})
MergeGrammarData(lang, &GrammarData{
Verbs: map[string]VerbForms{
"add": {Past: "added", Gerund: "adding"},
},
Nouns: map[string]NounForms{
"repo": {One: "repo", Other: "repos"},
},
Words: map[string]string{
"api": "API",
},
Articles: ArticleForms{
ByGender: map[string]string{
"f": "la",
},
},
Punct: PunctuationRules{
LabelSuffix: " !",
},
Signals: SignalData{
NounDeterminers: []string{"a"},
VerbAuxiliaries: []string{"can"},
VerbInfinitive: []string{"go"},
VerbNegation: []string{"never"},
Priors: map[string]map[string]float64{
"run": {
"noun": 0.3,
},
},
},
Number: NumberFormat{
ThousandsSep: ".",
},
})
data := GetGrammarData(lang)
if data == nil {
t.Fatal("MergeGrammarData() cleared existing grammar data")
}
if _, ok := data.Verbs["keep"]; !ok {
t.Error("existing verb entry was lost")
}
if _, ok := data.Verbs["add"]; !ok {
t.Error("merged verb entry missing")
}
if _, ok := data.Nouns["file"]; !ok {
t.Error("existing noun entry was lost")
}
if _, ok := data.Nouns["repo"]; !ok {
t.Error("merged noun entry missing")
}
if data.Words["url"] != "URL" || data.Words["api"] != "API" {
t.Errorf("words not merged correctly: %+v", data.Words)
}
if data.Articles.IndefiniteDefault != "a" || data.Articles.IndefiniteVowel != "an" || data.Articles.Definite != "the" {
t.Errorf("article defaults changed unexpectedly: %+v", data.Articles)
}
if data.Articles.ByGender["m"] != "le" || data.Articles.ByGender["f"] != "la" {
t.Errorf("article by_gender not merged correctly: %+v", data.Articles.ByGender)
}
if data.Punct.LabelSuffix != " !" || data.Punct.ProgressSuffix != "..." {
t.Errorf("punctuation not merged correctly: %+v", data.Punct)
}
if len(data.Signals.NounDeterminers) != 2 || len(data.Signals.VerbAuxiliaries) != 2 || len(data.Signals.VerbInfinitive) != 2 || len(data.Signals.VerbNegation) != 2 {
t.Errorf("signal slices not merged correctly: %+v", data.Signals)
}
if got := data.Signals.Priors["run"]["verb"]; got != 0.7 {
t.Errorf("signal priors lost existing value: got %v", got)
}
if got := data.Signals.Priors["run"]["noun"]; got != 0.3 {
t.Errorf("signal priors missing merged value: got %v", got)
}
if data.Signals.VerbNegation[0] != "not" || data.Signals.VerbNegation[1] != "never" {
t.Errorf("signal negation not merged correctly: %+v", data.Signals.VerbNegation)
}
if data.Number.ThousandsSep != "." || data.Number.DecimalSep != "." || data.Number.PercentFmt != "%s%%" {
t.Errorf("number format not merged correctly: %+v", data.Number)
}
}
func TestMergeGrammarData_DeduplicatesSignals(t *testing.T) {
const lang = "zy"
original := GetGrammarData(lang)
t.Cleanup(func() {
SetGrammarData(lang, original)
})
SetGrammarData(lang, &GrammarData{
Signals: SignalData{
NounDeterminers: []string{"the", "a"},
VerbAuxiliaries: []string{"will"},
VerbInfinitive: []string{"to"},
VerbNegation: []string{"not"},
},
})
MergeGrammarData(lang, &GrammarData{
Signals: SignalData{
NounDeterminers: []string{"a", "some"},
VerbAuxiliaries: []string{"will", "can"},
VerbInfinitive: []string{"to", "de"},
VerbNegation: []string{"not", "never"},
},
})
data := GetGrammarData(lang)
if data == nil {
t.Fatal("GetGrammarData returned nil")
}
if got, want := data.Signals.NounDeterminers, []string{"the", "a", "some"}; !slices.Equal(got, want) {
t.Fatalf("NounDeterminers = %v, want %v", got, want)
}
if got, want := data.Signals.VerbAuxiliaries, []string{"will", "can"}; !slices.Equal(got, want) {
t.Fatalf("VerbAuxiliaries = %v, want %v", got, want)
}
if got, want := data.Signals.VerbInfinitive, []string{"to", "de"}; !slices.Equal(got, want) {
t.Fatalf("VerbInfinitive = %v, want %v", got, want)
}
if got, want := data.Signals.VerbNegation, []string{"not", "never"}; !slices.Equal(got, want) {
t.Fatalf("VerbNegation = %v, want %v", got, want)
}
}
func TestGrammarDataLanguageTagNormalisation(t *testing.T) {
const rawLang = "tl_PH"
const canonicalLang = "tl-PH"
originalRaw := GetGrammarData(rawLang)
originalCanonical := GetGrammarData(canonicalLang)
t.Cleanup(func() {
SetGrammarData(rawLang, originalRaw)
SetGrammarData(canonicalLang, originalCanonical)
})
SetGrammarData(rawLang, &GrammarData{
Words: map[string]string{
"demo": "Demo",
},
})
if got := GetGrammarData(rawLang); got == nil || got.Words["demo"] != "Demo" {
t.Fatalf("GetGrammarData(%q) = %+v, want demo word", rawLang, got)
}
if got := GetGrammarData(canonicalLang); got == nil || got.Words["demo"] != "Demo" {
t.Fatalf("GetGrammarData(%q) = %+v, want demo word", canonicalLang, got)
}
MergeGrammarData(canonicalLang, &GrammarData{
Words: map[string]string{
"api": "API",
},
})
data := GetGrammarData(rawLang)
if data == nil {
t.Fatalf("GetGrammarData(%q) returned nil after merge", rawLang)
}
if data.Words["api"] != "API" {
t.Fatalf("merged word normalisation failed: %+v", data.Words)
}
SetGrammarData(rawLang, nil)
if got := GetGrammarData(canonicalLang); got != nil {
t.Fatalf("SetGrammarData(%q, nil) did not clear entry: %+v", rawLang, got)
}
}
func TestNewWithLoader_LoadsGrammarOnlyLocale(t *testing.T) {
loaderFS := fstest.MapFS{
"fr.json": &fstest.MapFile{
Data: []byte(`{
"gram": {
"article": {
"indefinite": { "default": "el", "vowel": "l'" },
"definite": "el",
"by_gender": { "m": "el", "f": "la" }
},
"punct": { "label": " !", "progress": " ..." },
"signal": {
"noun_determiner": ["el"],
"verb_auxiliary": ["va"],
"verb_infinitive": ["a"],
"verb_negation": ["no", "nunca"]
},
"number": { "thousands": ".", "decimal": ",", "percent": "%s %%"}
}
}`),
},
}
svc, err := NewWithLoader(NewFSLoader(loaderFS, "."))
if err != nil {
t.Fatalf("NewWithLoader() failed: %v", err)
}
data := GetGrammarData("fr")
if data == nil {
t.Fatal("grammar-only locale was not loaded")
}
if data.Articles.ByGender["f"] != "la" {
t.Errorf("article by_gender[f] = %q, want %q", data.Articles.ByGender["f"], "la")
}
if data.Punct.LabelSuffix != " !" || data.Punct.ProgressSuffix != " ..." {
t.Errorf("punctuation not loaded: %+v", data.Punct)
}
if len(data.Signals.NounDeterminers) != 1 || data.Signals.NounDeterminers[0] != "el" {
t.Errorf("signals not loaded: %+v", data.Signals)
}
if len(data.Signals.VerbNegation) != 2 || data.Signals.VerbNegation[0] != "no" || data.Signals.VerbNegation[1] != "nunca" {
t.Errorf("negation signal not loaded: %+v", data.Signals.VerbNegation)
}
if data.Number.DecimalSep != "," || data.Number.ThousandsSep != "." {
t.Errorf("number format not loaded: %+v", data.Number)
}
if err := svc.SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
SetDefault(svc)
if got := Label("status"); got != "Status !" {
t.Errorf("Label(status) = %q, want %q", got, "Status !")
}
}
func TestNewWithLoader_AcceptsGrammarAliases(t *testing.T) {
loaderFS := fstest.MapFS{
"en.json": &fstest.MapFile{
Data: []byte(`{
"gram": {
"article": {
"byGender": { "f": "la", "m": "le" }
},
"noun": {
"user": { "one": "user", "other": "users", "gender": "f" }
},
"signals": {
"noun_determiner": ["the"],
"verb_auxiliary": ["will"],
"verb_infinitive": ["to"],
"verb_negation": ["never"]
}
}
}`),
},
}
svc, err := NewWithLoader(NewFSLoader(loaderFS, "."))
if err != nil {
t.Fatalf("NewWithLoader() failed: %v", err)
}
data := GetGrammarData("en")
if data == nil {
t.Fatal("alias grammar data was not loaded")
}
if data.Articles.ByGender["f"] != "la" || data.Articles.ByGender["m"] != "le" {
t.Fatalf("article byGender alias not loaded: %+v", data.Articles.ByGender)
}
if got, want := data.Signals.NounDeterminers, []string{"the"}; !slices.Equal(got, want) {
t.Fatalf("signal alias noun_determiner = %v, want %v", got, want)
}
if got, want := data.Signals.VerbAuxiliaries, []string{"will"}; !slices.Equal(got, want) {
t.Fatalf("signal alias verb_auxiliary = %v, want %v", got, want)
}
if got, want := data.Signals.VerbInfinitive, []string{"to"}; !slices.Equal(got, want) {
t.Fatalf("signal alias verb_infinitive = %v, want %v", got, want)
}
if got, want := data.Signals.VerbNegation, []string{"never"}; !slices.Equal(got, want) {
t.Fatalf("signal alias verb_negation = %v, want %v", got, want)
}
if err := svc.SetLanguage("en"); err != nil {
t.Fatalf("SetLanguage(en) failed: %v", err)
}
SetDefault(svc)
if got := DefinitePhrase("user"); got != "la user" && got != "la User" {
t.Fatalf("DefinitePhrase(user) = %q, want article from byGender alias", got)
}
}
func TestFlattenPluralObject(t *testing.T) {
@ -810,14 +261,8 @@ func TestCustomFSLoader(t *testing.T) {
Data: []byte(`{
"gram": {
"verb": {
"draft": { "base": "draft", "past": "drafted", "gerund": "drafting" },
"zap": { "base": "zap", "past": "zapped", "gerund": "zapping" }
},
"signal": {
"priors": {
"draft": { "verb": 0.6, "noun": 0.4 }
}
},
"word": {
"hello": "Hello"
}
@ -845,45 +290,28 @@ func TestCustomFSLoader(t *testing.T) {
if v, ok := gd.Verbs["zap"]; !ok || v.Past != "zapped" {
t.Errorf("verb 'zap' not loaded correctly")
}
if v, ok := gd.Verbs["draft"]; !ok || v.Past != "drafted" {
t.Errorf("verb base override 'draft' not loaded correctly")
}
if gd.Signals.Priors["draft"]["verb"] != 0.6 || gd.Signals.Priors["draft"]["noun"] != 0.4 {
t.Errorf("signal priors not loaded correctly: %+v", gd.Signals.Priors["draft"])
}
}
func TestCustomFSLoaderPreservesZeroSignalPriors(t *testing.T) {
fs := fstest.MapFS{
"locales/test.json": &fstest.MapFile{
Data: []byte(`{
"gram": {
"signal": {
"prior": {
"commit": { "verb": 0, "noun": 1 }
}
}
}
}`),
},
// TestFSLoader_Ugly verifies that loading a locale with only a gram block (no messages) works.
//
// loader.Load("en") // returns empty messages map but populated grammar
func TestFSLoader_Ugly(t *testing.T) {
gramOnly := `{"gram":{"verb":{"zap":{"past":"zapped","gerund":"zapping"}}}}`
fsys := fstest.MapFS{
"locales/eo.json": &fstest.MapFile{Data: []byte(gramOnly)},
}
loader := NewFSLoader(fs, "locales")
_, grammar, err := loader.Load("test")
loader := NewFSLoader(fsys, "locales")
messages, grammar, err := loader.Load("eo")
if err != nil {
t.Fatalf("Load(test) failed: %v", err)
t.Fatalf("Load: %v", err)
}
if len(messages) != 0 {
t.Errorf("messages = %d, want 0 for gram-only locale", len(messages))
}
if grammar == nil {
t.Fatal("expected grammar data")
t.Fatal("grammar should not be nil")
}
bucket, ok := grammar.Signals.Priors["commit"]
if !ok {
t.Fatal("signal priors for commit were not loaded")
}
if got := bucket["verb"]; got != 0 {
t.Fatalf("signal priors verb = %v, want 0", got)
}
if got := bucket["noun"]; got != 1 {
t.Fatalf("signal priors noun = %v, want 1", got)
if v, ok := grammar.Verbs["zap"]; !ok || v.Past != "zapped" {
t.Errorf("gram-only verb not loaded: %v", grammar.Verbs)
}
}

View file

@ -92,8 +92,7 @@
"ssh": "SSH",
"ssl": "SSL",
"pr": "PR",
"ci": "CI",
"failed_to": "Impossible de"
"ci": "CI"
},
"punct": {
"label": " :",
@ -145,9 +144,7 @@
"minute": { "one": "il y a {{.Count}} minute", "other": "il y a {{.Count}} minutes" },
"hour": { "one": "il y a {{.Count}} heure", "other": "il y a {{.Count}} heures" },
"day": { "one": "il y a {{.Count}} jour", "other": "il y a {{.Count}} jours" },
"week": { "one": "il y a {{.Count}} semaine", "other": "il y a {{.Count}} semaines" },
"month": { "one": "il y a {{.Count}} mois", "other": "il y a {{.Count}} mois" },
"year": { "one": "il y a {{.Count}} an", "other": "il y a {{.Count}} ans" }
"week": { "one": "il y a {{.Count}} semaine", "other": "il y a {{.Count}} semaines" }
}
},
"lang": {

View file

@ -1,8 +1,6 @@
package i18n
import (
"os"
"dappco.re/go/core"
"golang.org/x/text/language"
)
@ -57,7 +55,6 @@ func (g GrammaticalGender) String() string {
// IsRTLLanguage returns true if the language code uses right-to-left text.
func IsRTLLanguage(lang string) bool {
lang = normalizeLanguageTag(lang)
if rtlLanguages[lang] {
return true
}
@ -68,180 +65,47 @@ func IsRTLLanguage(lang string) bool {
}
// SetFormality sets the default formality level on the default service.
//
// Example:
//
// i18n.SetFormality(i18n.FormalityFormal)
func SetFormality(f Formality) {
withDefaultService(func(svc *Service) { svc.SetFormality(f) })
}
// SetLocation sets the default location context on the default service.
//
// Example:
//
// i18n.SetLocation("workspace")
func SetLocation(location string) {
withDefaultService(func(svc *Service) { svc.SetLocation(location) })
}
// CurrentLocation returns the current default location context.
//
// Example:
//
// location := i18n.CurrentLocation()
func CurrentLocation() string {
return Location()
}
// Location returns the current default location context.
//
// Example:
//
// location := i18n.Location()
func Location() string {
return defaultServiceValue("", func(svc *Service) string {
return svc.Location()
})
if svc := Default(); svc != nil {
svc.SetFormality(f)
}
}
// Direction returns the text direction for the current language.
//
// Example:
//
// dir := i18n.Direction()
func Direction() TextDirection {
return defaultServiceValue(DirLTR, func(svc *Service) TextDirection {
if svc := Default(); svc != nil {
return svc.Direction()
})
}
// CurrentDirection returns the current default text direction.
//
// Example:
//
// dir := i18n.CurrentDirection()
func CurrentDirection() TextDirection {
return Direction()
}
// CurrentTextDirection is a more explicit alias for CurrentDirection.
//
// Example:
//
// dir := i18n.CurrentTextDirection()
func CurrentTextDirection() TextDirection {
return CurrentDirection()
}
return DirLTR
}
// IsRTL returns true if the current language uses right-to-left text.
//
// Example:
//
// rtl := i18n.IsRTL()
func IsRTL() bool { return Direction() == DirRTL }
// RTL is a short alias for IsRTL.
//
// Example:
//
// rtl := i18n.RTL()
func RTL() bool { return IsRTL() }
// CurrentIsRTL returns true if the current default language uses
// right-to-left text.
//
// Example:
//
// rtl := i18n.CurrentIsRTL()
func CurrentIsRTL() bool {
return IsRTL()
}
// CurrentRTL is a short alias for CurrentIsRTL.
//
// Example:
//
// rtl := i18n.CurrentRTL()
func CurrentRTL() bool {
return CurrentIsRTL()
}
// CurrentPluralCategory returns the plural category for the current default language.
//
// Example:
//
// cat := i18n.CurrentPluralCategory(2)
func CurrentPluralCategory(n int) PluralCategory {
return defaultServiceValue(PluralOther, func(svc *Service) PluralCategory { return svc.PluralCategory(n) })
}
// PluralCategoryOf returns the plural category for the current default language.
//
// Example:
//
// cat := i18n.PluralCategoryOf(2)
func PluralCategoryOf(n int) PluralCategory {
return CurrentPluralCategory(n)
}
func detectLanguage(supported []language.Tag) string {
for _, langEnv := range []string{
os.Getenv("LC_ALL"),
firstLocaleFromList(os.Getenv("LANGUAGE")),
os.Getenv("LC_MESSAGES"),
os.Getenv("LANG"),
} {
langEnv := core.Env("LANG")
if langEnv == "" {
langEnv = core.Env("LC_ALL")
if langEnv == "" {
continue
}
if detected := detectLanguageFromEnv(langEnv, supported); detected != "" {
return detected
langEnv = core.Env("LC_MESSAGES")
}
}
return ""
}
func detectLanguageFromEnv(langEnv string, supported []language.Tag) string {
baseLang := normalizeLanguageTag(core.Split(langEnv, ".")[0])
if baseLang == "" || len(supported) == 0 {
if langEnv == "" {
return ""
}
baseLang := core.Split(langEnv, ".")[0]
baseLang = core.Replace(baseLang, "_", "-")
parsedLang, err := language.Parse(baseLang)
if err != nil {
return ""
}
if len(supported) == 0 {
return ""
}
matcher := language.NewMatcher(supported)
bestMatch, bestIndex, confidence := matcher.Match(parsedLang)
if confidence < language.Low {
return ""
}
if bestIndex >= 0 && bestIndex < len(supported) {
return supported[bestIndex].String()
}
return bestMatch.String()
}
func firstLocaleFromList(langList string) string {
if langList == "" {
return ""
}
for _, lang := range core.Split(langList, ":") {
if trimmed := core.Trim(lang); trimmed != "" {
return trimmed
}
bestMatch, _, confidence := matcher.Match(parsedLang)
if confidence >= language.Low {
return bestMatch.String()
}
return ""
}
func normalizeLanguageTag(lang string) string {
lang = core.Trim(lang)
if lang == "" {
return ""
}
lang = core.Replace(lang, "_", "-")
if tag, err := language.Parse(lang); err == nil {
return tag.String()
}
return lang
}

View file

@ -5,7 +5,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
)
// --- Formality.String() ---
@ -89,7 +88,6 @@ func TestIsRTLLanguage_Good(t *testing.T) {
}{
{"arabic", "ar", true},
{"arabic_sa", "ar-SA", true},
{"arabic_sa_underscore", "ar_EG", true},
{"hebrew", "he", true},
{"farsi", "fa", true},
{"urdu", "ur", true},
@ -97,7 +95,7 @@ func TestIsRTLLanguage_Good(t *testing.T) {
{"german", "de", false},
{"french", "fr", false},
{"unknown", "xx", false},
{"arabic_variant", "ar-EG-extra", true}, // len > 2 prefix check
{"arabic_variant", "ar-EG-extra", true}, // len > 2 prefix check
{"english_variant", "en-US-extra", false}, // len > 2, not RTL
}
for _, tt := range tests {
@ -121,86 +119,6 @@ func TestSetFormality_Good(t *testing.T) {
assert.Equal(t, FormalityNeutral, svc.Formality())
}
// --- Package-level SetFallback ---
func TestSetFallback_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
SetFallback("fr")
assert.Equal(t, "fr", svc.Fallback())
SetFallback("en")
assert.Equal(t, "en", svc.Fallback())
}
// --- Package-level CurrentFormality ---
func TestCurrentFormality_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.Equal(t, FormalityNeutral, CurrentFormality())
SetFormality(FormalityFormal)
assert.Equal(t, FormalityFormal, CurrentFormality())
}
// --- Package-level CurrentFallback ---
func TestCurrentFallback_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.Equal(t, "en", CurrentFallback())
SetFallback("fr")
assert.Equal(t, "fr", CurrentFallback())
}
// --- Package-level SetLocation ---
func TestSetLocation_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
SetLocation("workspace")
assert.Equal(t, "workspace", svc.Location())
SetLocation("")
assert.Equal(t, "", svc.Location())
}
// --- Package-level CurrentLocation ---
func TestCurrentLocation_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.Equal(t, "", CurrentLocation())
SetLocation("workspace")
assert.Equal(t, "workspace", CurrentLocation())
}
// --- Package-level Location ---
func TestLocation_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.Equal(t, CurrentLocation(), Location())
SetLocation("workspace")
assert.Equal(t, CurrentLocation(), Location())
}
// --- Package-level Direction ---
func TestDirection_Good(t *testing.T) {
@ -212,26 +130,6 @@ func TestDirection_Good(t *testing.T) {
assert.Equal(t, DirLTR, dir)
}
// --- Package-level CurrentDirection ---
func TestCurrentDirection_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.Equal(t, DirLTR, CurrentDirection())
}
// --- Package-level CurrentTextDirection ---
func TestCurrentTextDirection_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.Equal(t, CurrentDirection(), CurrentTextDirection())
}
// --- Package-level IsRTL ---
func TestIsRTL_Good(t *testing.T) {
@ -242,91 +140,6 @@ func TestIsRTL_Good(t *testing.T) {
assert.False(t, IsRTL(), "English should not be RTL")
}
// --- Package-level RTL ---
func TestRTL_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.Equal(t, IsRTL(), RTL())
}
// --- Package-level CurrentIsRTL ---
func TestCurrentIsRTL_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.False(t, CurrentIsRTL(), "English should not be RTL")
}
// --- Package-level CurrentRTL ---
func TestCurrentRTL_Good(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.Equal(t, CurrentIsRTL(), CurrentRTL())
}
// --- Package-level CurrentPluralCategory ---
func TestCurrentPluralCategory_Good(t *testing.T) {
prev := Default()
t.Cleanup(func() {
SetDefault(prev)
})
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.Equal(t, PluralOther, CurrentPluralCategory(0))
assert.Equal(t, PluralOne, CurrentPluralCategory(1))
assert.Equal(t, PluralOther, CurrentPluralCategory(2))
require.NoError(t, SetLanguage("fr"))
assert.Equal(t, PluralOne, CurrentPluralCategory(0))
assert.Equal(t, PluralOne, CurrentPluralCategory(1))
assert.Equal(t, PluralOther, CurrentPluralCategory(2))
}
// --- Package-level PluralCategoryOf ---
func TestPluralCategoryOf_Good(t *testing.T) {
prev := Default()
t.Cleanup(func() {
SetDefault(prev)
})
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
assert.Equal(t, PluralOther, PluralCategoryOf(0))
assert.Equal(t, PluralOne, PluralCategoryOf(1))
assert.Equal(t, PluralOther, PluralCategoryOf(2))
require.NoError(t, SetLanguage("fr"))
assert.Equal(t, PluralOne, PluralCategoryOf(0))
assert.Equal(t, PluralOne, PluralCategoryOf(1))
assert.Equal(t, PluralOther, PluralCategoryOf(2))
}
func TestCurrentPluralCategory_NoDefaultService(t *testing.T) {
prev := Default()
t.Cleanup(func() {
SetDefault(prev)
})
SetDefault(nil)
assert.Equal(t, PluralOther, CurrentPluralCategory(2))
}
// --- detectLanguage ---
func TestDetectLanguage_Good(t *testing.T) {
@ -336,49 +149,6 @@ func TestDetectLanguage_Good(t *testing.T) {
assert.Equal(t, "", result, "should return empty with no supported languages")
}
func TestDetectLanguage_PrefersLocaleOverrides(t *testing.T) {
t.Setenv("LANG", "en_US.UTF-8")
t.Setenv("LC_MESSAGES", "fr_FR.UTF-8")
t.Setenv("LC_ALL", "de_DE.UTF-8")
supported := []language.Tag{
language.AmericanEnglish,
language.French,
language.German,
}
result := detectLanguage(supported)
assert.Equal(t, "de", result, "LC_ALL should win over LANG and LC_MESSAGES")
}
func TestDetectLanguage_SkipsInvalidHigherPriorityLocale(t *testing.T) {
t.Setenv("LANG", "en_US.UTF-8")
t.Setenv("LC_MESSAGES", "fr_FR.UTF-8")
t.Setenv("LC_ALL", "not-a-locale")
supported := []language.Tag{
language.AmericanEnglish,
language.French,
}
result := detectLanguage(supported)
assert.Equal(t, "fr", result, "invalid LC_ALL should not block a valid lower-priority locale")
}
func TestDetectLanguage_PrefersLanguageList(t *testing.T) {
t.Setenv("LANGUAGE", "fr_FR.UTF-8:de_DE.UTF-8")
t.Setenv("LANG", "en_US.UTF-8")
supported := []language.Tag{
language.AmericanEnglish,
language.French,
language.German,
}
result := detectLanguage(supported)
assert.Equal(t, "fr", result, "LANGUAGE should be considered before LANG")
}
// --- Mode.String() ---
func TestMode_String_Good(t *testing.T) {
@ -398,3 +168,28 @@ func TestMode_String_Good(t *testing.T) {
})
}
}
// TestIsRTLLanguage_Bad verifies an unknown language code returns false.
//
// IsRTLLanguage("xx") // false
func TestIsRTLLanguage_Bad(t *testing.T) {
assert.False(t, IsRTLLanguage("xx"))
}
// TestFormality_Ugly verifies an out-of-range Formality value returns "neutral".
//
// Formality(99).String() // "neutral"
func TestFormality_Ugly(t *testing.T) {
assert.Equal(t, "neutral", Formality(99).String())
}
// TestSetFormality_Ugly verifies SetFormality on nil default does not panic.
//
// SetDefault(nil); SetFormality(FormalityFormal) // must not panic
func TestSetFormality_Ugly(t *testing.T) {
SetDefault(nil)
SetFormality(FormalityFormal) // must not panic
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
}

View file

@ -9,27 +9,15 @@ import (
func getNumberFormat() NumberFormat {
lang := currentLangForGrammar()
if fmt, ok := getLocaleNumberFormat(lang); ok {
return fmt
}
if idx := indexAny(lang, "-_"); idx > 0 {
if fmt, ok := getLocaleNumberFormat(lang[:idx]); ok {
return fmt
}
lang = lang[:idx]
}
if numberFormat, ok := numberFormats[lang]; ok {
return numberFormat
}
return numberFormats["en"]
}
func getLocaleNumberFormat(lang string) (NumberFormat, bool) {
if data := GetGrammarData(lang); data != nil && data.Number != (NumberFormat{}) {
return data.Number, true
}
if fmt, ok := numberFormats[lang]; ok {
return fmt, true
}
return NumberFormat{}, false
}
// FormatNumber formats an integer with locale-specific thousands separators.
func FormatNumber(n int64) string {
return formatIntWithSep(n, getNumberFormat().ThousandsSep)
@ -43,35 +31,19 @@ func FormatDecimal(f float64) string {
// FormatDecimalN formats a float with N decimal places.
func FormatDecimalN(f float64, decimals int) string {
nf := getNumberFormat()
negative := f < 0
absVal := math.Abs(f)
intPart := int64(absVal)
fracPart := absVal - float64(intPart)
intPart := int64(f)
fracPart := math.Abs(f - float64(intPart))
intStr := formatIntWithSep(intPart, nf.ThousandsSep)
if decimals <= 0 || fracPart == 0 {
if negative {
return "-" + intStr
}
return intStr
}
multiplier := math.Pow(10, float64(decimals))
fracInt := int64(math.Round(fracPart * multiplier))
if fracInt >= int64(multiplier) {
intPart++
intStr = formatIntWithSep(intPart, nf.ThousandsSep)
fracInt = 0
}
if fracInt == 0 {
if negative {
return "-" + intStr
}
return intStr
}
fracStr := core.Sprintf("%0*d", decimals, fracInt)
fracStr = trimRight(fracStr, "0")
if negative {
return "-" + intStr + nf.DecimalSep + fracStr
}
return intStr + nf.DecimalSep + fracStr
}
@ -96,6 +68,7 @@ func FormatBytes(bytes int64) string {
GB = MB * 1024
TB = GB * 1024
)
nf := getNumberFormat()
var value float64
var unit string
switch {
@ -114,7 +87,16 @@ func FormatBytes(bytes int64) string {
default:
return core.Sprintf("%d B", bytes)
}
return FormatDecimalN(value, 2) + " " + unit
intPart := int64(value)
fracPart := value - float64(intPart)
if fracPart < 0.05 {
return core.Sprintf("%d %s", intPart, unit)
}
fracDigit := int(math.Round(fracPart * 10))
if fracDigit == 10 {
return core.Sprintf("%d %s", intPart+1, unit)
}
return core.Sprintf("%d%s%d %s", intPart, nf.DecimalSep, fracDigit, unit)
}
// FormatOrdinal formats a number as an ordinal.
@ -124,8 +106,6 @@ func FormatOrdinal(n int) string {
lang = lang[:idx]
}
switch lang {
case "fr":
return formatFrenchOrdinal(n)
case "en":
return formatEnglishOrdinal(n)
default:
@ -133,13 +113,6 @@ func FormatOrdinal(n int) string {
}
}
func formatFrenchOrdinal(n int) string {
if n == 1 || n == -1 {
return core.Sprintf("%der", n)
}
return core.Sprintf("%de", n)
}
func formatEnglishOrdinal(n int) string {
abs := n
if abs < 0 {
@ -165,15 +138,10 @@ func formatIntWithSep(n int64, sep string) string {
return strconv.FormatInt(n, 10)
}
negative := n < 0
var abs uint64
if negative {
// Convert via n+1 to avoid overflowing on math.MinInt64.
abs = uint64(-(n + 1))
abs++
} else {
abs = uint64(n)
n = -n
}
str := strconv.FormatUint(abs, 10)
str := strconv.FormatInt(n, 10)
if len(str) <= 3 {
if negative {
return "-" + str

View file

@ -1,9 +1,6 @@
package i18n
import (
"math"
"testing"
)
import "testing"
func TestFormatNumber(t *testing.T) {
// Ensure service is initialised for English locale
@ -34,20 +31,6 @@ func TestFormatNumber(t *testing.T) {
}
}
func TestFormatNumber_MinInt64(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
got := FormatNumber(math.MinInt64)
want := "-9,223,372,036,854,775,808"
if got != want {
t.Fatalf("FormatNumber(math.MinInt64) = %q, want %q", got, want)
}
}
func TestFormatDecimal(t *testing.T) {
svc, err := New()
if err != nil {
@ -61,12 +44,8 @@ func TestFormatDecimal(t *testing.T) {
}{
{1.5, "1.5"},
{1.0, "1"},
{1.995, "2"},
{9.999, "10"},
{1234.56, "1,234.56"},
{0.1, "0.1"},
{-0.1, "-0.1"},
{-1234.56, "-1,234.56"},
}
for _, tt := range tests {
@ -77,31 +56,6 @@ func TestFormatDecimal(t *testing.T) {
}
}
func TestFormatDecimalN_RoundsCarry(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
tests := []struct {
f float64
decimals int
want string
}{
{1.995, 2, "2"},
{9.999, 2, "10"},
{999.999, 2, "1,000"},
}
for _, tt := range tests {
got := FormatDecimalN(tt.f, tt.decimals)
if got != tt.want {
t.Errorf("FormatDecimalN(%v, %d) = %q, want %q", tt.f, tt.decimals, got, tt.want)
}
}
}
func TestFormatPercent(t *testing.T) {
svc, err := New()
if err != nil {
@ -117,7 +71,6 @@ func TestFormatPercent(t *testing.T) {
{1.0, "100%"},
{0.0, "0%"},
{0.333, "33.3%"},
{-0.1, "-10%"},
}
for _, tt := range tests {
@ -144,7 +97,7 @@ func TestFormatBytes(t *testing.T) {
{1024, "1 KB"},
{1536, "1.5 KB"},
{1048576, "1 MB"},
{1536000, "1.46 MB"},
{1536000, "1.5 MB"},
{1073741824, "1 GB"},
{1099511627776, "1 TB"},
}
@ -191,60 +144,35 @@ func TestFormatOrdinal(t *testing.T) {
}
}
func TestFormatOrdinalFromLocale(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
prev := Default()
// TestFormatNumber_Good verifies thousands separator is applied for large numbers.
//
// FormatNumber(1000000) // "1,000,000"
func TestFormatNumber_Good(t *testing.T) {
svc, _ := New()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
if err := SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
tests := []struct {
n int
want string
}{
{1, "1er"},
{2, "2e"},
{3, "3e"},
{11, "11e"},
}
for _, tt := range tests {
got := FormatOrdinal(tt.n)
if got != tt.want {
t.Errorf("FormatOrdinal(fr, %d) = %q, want %q", tt.n, got, tt.want)
}
if got := FormatNumber(1_000_000); got != "1,000,000" {
t.Errorf("FormatNumber(1000000) = %q, want %q", got, "1,000,000")
}
}
func TestFormatNumberFromLocale(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// TestFormatNumber_Bad verifies negative numbers are formatted correctly.
//
// FormatNumber(-42) // "-42"
func TestFormatNumber_Bad(t *testing.T) {
svc, _ := New()
SetDefault(svc)
if err := SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
if got := FormatNumber(1234567); got != "1 234 567" {
t.Errorf("FormatNumber(fr) = %q, want %q", got, "1 234 567")
}
if got := FormatDecimal(1234.56); got != "1 234,56" {
t.Errorf("FormatDecimal(fr) = %q, want %q", got, "1 234,56")
}
if got := FormatPercent(0.85); got != "85 %" {
t.Errorf("FormatPercent(fr) = %q, want %q", got, "85 %")
}
if got := FormatDecimal(-0.1); got != "-0,1" {
t.Errorf("FormatDecimal(fr, negative) = %q, want %q", got, "-0,1")
if got := FormatNumber(-42); got != "-42" {
t.Errorf("FormatNumber(-42) = %q, want %q", got, "-42")
}
}
// TestFormatNumber_Ugly verifies zero is formatted without separators.
//
// FormatNumber(0) // "0"
func TestFormatNumber_Ugly(t *testing.T) {
svc, _ := New()
SetDefault(svc)
if got := FormatNumber(0); got != "0" {
t.Errorf("FormatNumber(0) = %q, want %q", got, "0")
}
}

View file

@ -167,3 +167,52 @@ func TestAnomalyStats_Rate(t *testing.T) {
t.Errorf("Rate = %.2f, want ~1.0 (all should be anomalies)", stats.Rate)
}
}
// TestDetectAnomalies_Good verifies that full agreement produces zero anomalies.
//
// rs.DetectAnomalies(tok, samples) // stats.Anomalies == 0
func TestDetectAnomalies_Good(t *testing.T) {
tok := initI18n(t)
refSamples := []ClassifiedText{
{Text: "Delete the file", Domain: "technical"},
{Text: "Build the project", Domain: "technical"},
}
rs, err := BuildReferences(tok, refSamples)
if err != nil {
t.Fatalf("BuildReferences: %v", err)
}
_, stats := rs.DetectAnomalies(tok, refSamples)
if stats.Anomalies != 0 {
t.Errorf("Anomalies = %d, want 0 for matching domain samples", stats.Anomalies)
}
}
// TestDetectAnomalies_Bad verifies that empty test samples produce zero anomalies.
//
// rs.DetectAnomalies(tok, nil) // stats.Total == 0
func TestDetectAnomalies_Bad(t *testing.T) {
tok := initI18n(t)
refSamples := []ClassifiedText{
{Text: "Delete the file", Domain: "technical"},
}
rs, _ := BuildReferences(tok, refSamples)
_, stats := rs.DetectAnomalies(tok, nil)
if stats.Total != 0 {
t.Errorf("Total = %d, want 0 for empty input", stats.Total)
}
}
// TestDetectAnomalies_Ugly verifies that samples with empty Domain are skipped.
//
// rs.DetectAnomalies(tok, []{{Text: "foo", Domain: ""}}) // stats.Total == 0
func TestDetectAnomalies_Ugly(t *testing.T) {
tok := initI18n(t)
refSamples := []ClassifiedText{
{Text: "Delete the file", Domain: "technical"},
}
rs, _ := BuildReferences(tok, refSamples)
_, stats := rs.DetectAnomalies(tok, []ClassifiedText{{Text: "anything", Domain: ""}})
if stats.Total != 0 {
t.Errorf("Total = %d, want 0 (empty Domain skipped)", stats.Total)
}
}

View file

@ -34,21 +34,6 @@ func TestNewImprint(t *testing.T) {
}
}
func TestNewImprint_WordPhrase(t *testing.T) {
svc, err := i18n.New()
if err != nil {
t.Fatalf("i18n.New() failed: %v", err)
}
i18n.SetDefault(svc)
tok := NewTokeniser()
imp := NewImprint(tok.Tokenise("up to date"))
if imp.DomainVocabulary["up_to_date"] != 1 {
t.Fatalf("DomainVocabulary[\"up_to_date\"] = %d, want 1", imp.DomainVocabulary["up_to_date"])
}
}
func TestNewImprint_Empty(t *testing.T) {
imp := NewImprint(nil)
if imp.TokenCount != 0 {
@ -172,3 +157,39 @@ func TestImprint_ConfidenceWeighting_BackwardsCompat(t *testing.T) {
t.Error("NounDistribution should contain 'file'")
}
}
// TestNewImprint_Good verifies a verb token contributes to VerbDistribution.
//
// NewImprint(tokens).VerbDistribution["delete"] > 0
func TestNewImprint_Good(t *testing.T) {
tok := initI18n(t)
tokens := tok.Tokenise("Delete the file")
imp := NewImprint(tokens)
if imp.VerbDistribution["delete"] == 0 {
t.Error("VerbDistribution should contain 'delete'")
}
}
// TestNewImprint_Bad verifies an empty token slice produces a zero-value imprint.
//
// NewImprint(nil).TokenCount == 0
func TestNewImprint_Bad(t *testing.T) {
imp := NewImprint(nil)
if imp.TokenCount != 0 {
t.Errorf("TokenCount = %d, want 0 for nil tokens", imp.TokenCount)
}
}
// TestImprint_Similar_Ugly verifies similarity between one empty and one non-empty imprint is 0.
//
// empty.Similar(nonEmpty) // 0.0
func TestImprint_Similar_Ugly(t *testing.T) {
tok := initI18n(t)
tokens := tok.Tokenise("Build the image")
nonEmpty := NewImprint(tokens)
empty := NewImprint(nil)
sim := empty.Similar(nonEmpty)
if sim != 0.0 {
t.Errorf("empty.Similar(nonEmpty) = %f, want 0.0", sim)
}
}

View file

@ -68,27 +68,39 @@ func (m *Multiplier) Expand(text string) []string {
// 2. Verb transforms: for each verb, produce past and gerund variants
for _, vi := range verbIndices {
addVariant(m.reconstructWithVerbTransform(tokens, vi, "past"))
addVariant(m.reconstructWithVerbTransform(tokens, vi, "gerund"))
addVariant(m.reconstructWithVerbTransform(tokens, vi, "base"))
pastTokens := m.applyVerbTransform(tokens, vi, "past")
addVariant(reconstruct(pastTokens))
gerundTokens := m.applyVerbTransform(tokens, vi, "gerund")
addVariant(reconstruct(gerundTokens))
baseTokens := m.applyVerbTransform(tokens, vi, "base")
addVariant(reconstruct(baseTokens))
}
// 3. Noun transforms: for each noun, toggle plural/singular
for _, ni := range nounIndices {
addVariant(m.reconstructWithNounTransform(tokens, ni))
pluralTokens := m.applyNounTransform(tokens, ni)
addVariant(reconstruct(pluralTokens))
}
// 4. Combinations: each verb transform + each noun transform
for _, vi := range verbIndices {
for _, ni := range nounIndices {
// past + noun toggle
addVariant(m.reconstructWithVerbAndNounTransform(tokens, vi, "past", ni))
pastTokens := m.applyVerbTransform(tokens, vi, "past")
pastPluralTokens := m.applyNounTransformOnTokens(pastTokens, ni)
addVariant(reconstruct(pastPluralTokens))
// gerund + noun toggle
addVariant(m.reconstructWithVerbAndNounTransform(tokens, vi, "gerund", ni))
gerundTokens := m.applyVerbTransform(tokens, vi, "gerund")
gerundPluralTokens := m.applyNounTransformOnTokens(gerundTokens, ni)
addVariant(reconstruct(gerundPluralTokens))
// base + noun toggle
addVariant(m.reconstructWithVerbAndNounTransform(tokens, vi, "base", ni))
baseTokens := m.applyVerbTransform(tokens, vi, "base")
basePluralTokens := m.applyNounTransformOnTokens(baseTokens, ni)
addVariant(reconstruct(basePluralTokens))
}
}
@ -192,81 +204,6 @@ func (m *Multiplier) applyNounTransformOnTokens(tokens []Token, ni int) []Token
return result
}
func (m *Multiplier) reconstructWithVerbTransform(tokens []Token, vi int, targetTense string) string {
return m.reconstructWithTransforms(tokens, vi, targetTense, -1)
}
func (m *Multiplier) reconstructWithNounTransform(tokens []Token, ni int) string {
return m.reconstructWithTransforms(tokens, -1, "", ni)
}
func (m *Multiplier) reconstructWithVerbAndNounTransform(tokens []Token, vi int, targetTense string, ni int) string {
return m.reconstructWithTransforms(tokens, vi, targetTense, ni)
}
func (m *Multiplier) reconstructWithTransforms(tokens []Token, vi int, targetTense string, ni int) string {
b := core.NewBuilder()
for i, tok := range tokens {
if i > 0 {
// Punctuation tokens should stay attached to the preceding token.
if tok.Type == TokenPunctuation {
b.WriteString(tok.Raw)
continue
}
b.WriteByte(' ')
}
switch {
case i == vi:
b.WriteString(transformedVerbRaw(tok, targetTense))
case i == ni:
b.WriteString(transformedNounRaw(tok))
default:
b.WriteString(tok.Raw)
}
}
return b.String()
}
func transformedVerbRaw(tok Token, targetTense string) string {
base := tok.VerbInfo.Base
currentTense := tok.VerbInfo.Tense
if currentTense == targetTense {
return tok.Raw
}
var newForm string
switch targetTense {
case "past":
newForm = i18n.PastTense(base)
case "gerund":
newForm = i18n.Gerund(base)
case "base":
newForm = base
}
if newForm == "" {
return tok.Raw
}
return preserveCase(tok.Raw, newForm)
}
func transformedNounRaw(tok Token) string {
base := tok.NounInfo.Base
if base == "" {
return tok.Raw
}
var newForm string
if tok.NounInfo.Plural {
newForm = base
} else {
newForm = i18n.PluralForm(base)
}
if newForm == "" {
return tok.Raw
}
return preserveCase(tok.Raw, newForm)
}
// reconstruct joins tokens back into a string, preserving spacing.
func reconstruct(tokens []Token) string {
b := core.NewBuilder()

View file

@ -107,3 +107,40 @@ func TestMultiplier_TransformedTokenConfidence(t *testing.T) {
t.Error("Noun-transformed token has zero Confidence, want 1.0")
}
}
// TestMultiplier_Good verifies that Expand produces at least the original text.
//
// m.Expand("delete the file") // first element == "delete the file"
func TestMultiplier_Good(t *testing.T) {
initI18n(t)
m := NewMultiplier()
variants := m.Expand("delete the file")
if len(variants) == 0 {
t.Fatal("Expand should produce at least one variant")
}
if variants[0] != "delete the file" {
t.Errorf("first variant = %q, want original text", variants[0])
}
}
// TestMultiplier_Bad verifies that an empty string produces no variants.
//
// m.Expand("") // nil
func TestMultiplier_Bad(t *testing.T) {
m := NewMultiplier()
variants := m.Expand("")
if variants != nil {
t.Errorf("Expand(\"\") = %v, want nil", variants)
}
}
// TestMultiplier_Ugly verifies that a whitespace-only input produces no variants.
//
// m.Expand(" ") // nil (trimmed to empty)
func TestMultiplier_Ugly(t *testing.T) {
m := NewMultiplier()
variants := m.Expand(" ")
if variants != nil {
t.Errorf("Expand(whitespace) = %v, want nil", variants)
}
}

View file

@ -122,6 +122,8 @@ func (rs *ReferenceSet) Classify(imprint GrammarImprint) ImprintClassification {
result.Domain = ranked[0].domain
if len(ranked) > 1 {
result.Confidence = ranked[0].sim - ranked[1].sim
} else {
result.Confidence = ranked[0].sim
}
}
return result

View file

@ -144,30 +144,6 @@ func TestReferenceSet_Classify(t *testing.T) {
}
}
func TestReferenceSet_Classify_SingleDomainConfidence(t *testing.T) {
tok := initI18n(t)
samples := []ClassifiedText{
{Text: "Delete the configuration file", Domain: "technical"},
{Text: "Build the project from source", Domain: "technical"},
}
rs, err := BuildReferences(tok, samples)
if err != nil {
t.Fatalf("BuildReferences: %v", err)
}
imp := NewImprint(tok.Tokenise("Run the tests before committing"))
cls := rs.Classify(imp)
if cls.Domain == "" {
t.Fatal("empty classification domain")
}
if cls.Confidence != 0 {
t.Errorf("Confidence = %f, want 0 when only one domain is available", cls.Confidence)
}
}
func TestReferenceSet_DomainNames(t *testing.T) {
tok := initI18n(t)
samples := []ClassifiedText{
@ -257,3 +233,48 @@ func TestComputeVariance_SingleSample(t *testing.T) {
t.Errorf("Single-sample variance should be nil, got %v", v)
}
}
// TestBuildReferences_Good verifies a non-empty sample set builds a valid ReferenceSet.
//
// BuildReferences(tok, samples).Domains["technical"] != nil
func TestBuildReferences_Good(t *testing.T) {
tok := initI18n(t)
samples := []ClassifiedText{
{Text: "Delete the file", Domain: "technical"},
}
rs, err := BuildReferences(tok, samples)
if err != nil {
t.Fatalf("BuildReferences: %v", err)
}
if rs.Domains["technical"] == nil {
t.Error("expected technical domain in reference set")
}
}
// TestBuildReferences_Bad verifies empty samples return an error.
//
// BuildReferences(tok, nil) // error
func TestBuildReferences_Bad(t *testing.T) {
tok := initI18n(t)
_, err := BuildReferences(tok, nil)
if err == nil {
t.Error("expected error for empty samples")
}
}
// TestReferenceSet_Classify_Ugly verifies classification when only one domain exists.
//
// rs.Classify(imp).Domain == "technical" (only option)
func TestReferenceSet_Classify_Ugly(t *testing.T) {
tok := initI18n(t)
samples := []ClassifiedText{
{Text: "Build the project", Domain: "technical"},
}
rs, _ := BuildReferences(tok, samples)
tokens := tok.Tokenise("Run the tests")
imp := NewImprint(tokens)
cls := rs.Classify(imp)
if cls.Domain != "technical" {
t.Errorf("Classify domain = %q, want %q (only domain)", cls.Domain, "technical")
}
}

File diff suppressed because it is too large Load diff

View file

@ -20,9 +20,9 @@ func TestTokeniser_MatchVerb_Irregular(t *testing.T) {
tok := NewTokeniser()
tests := []struct {
word string
wantOK bool
wantBase string
word string
wantOK bool
wantBase string
wantTense string
}{
// Irregular past tense
@ -159,7 +159,6 @@ func TestTokeniser_MatchWord(t *testing.T) {
{"url", "url", true},
{"ID", "id", true},
{"SSH", "ssh", true},
{"up to date", "up_to_date", true},
{"PHP", "php", true},
{"xyzzy", "", false},
}
@ -189,7 +188,6 @@ func TestTokeniser_MatchArticle(t *testing.T) {
{"a", "indefinite", true},
{"an", "indefinite", true},
{"the", "definite", true},
{"the.", "definite", true},
{"A", "indefinite", true},
{"The", "definite", true},
{"foo", "", false},
@ -208,516 +206,6 @@ func TestTokeniser_MatchArticle(t *testing.T) {
}
}
func TestTokeniser_MatchArticle_FrenchGendered(t *testing.T) {
setup(t)
tok := NewTokeniserForLang("fr")
tests := []struct {
word string
wantType string
wantOK bool
}{
{"le", "definite", true},
{"la", "definite", true},
{"le serveur", "definite", true},
{"le serveur.", "definite", true},
{"la branche", "definite", true},
{"les amis", "definite", true},
{"Le", "definite", true},
{"La", "definite", true},
{"Un enfant", "indefinite", true},
{"Une amie", "indefinite", true},
{"de la", "indefinite", true},
{"de le", "indefinite", true},
{"de les", "indefinite", true},
{"de l'", "indefinite", true},
{"de l", "indefinite", true},
{"du serveur", "indefinite", true},
{"des amis", "indefinite", true},
{"un", "indefinite", true},
{"une", "indefinite", true},
{"l'enfant", "definite", true},
{"lami", "definite", true},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
artType, ok := tok.MatchArticle(tt.word)
if ok != tt.wantOK {
t.Fatalf("MatchArticle(%q) ok=%v, want %v", tt.word, ok, tt.wantOK)
}
if ok && artType != tt.wantType {
t.Errorf("MatchArticle(%q) = %q, want %q", tt.word, artType, tt.wantType)
}
})
}
tokens := tok.Tokenise("la branche")
if len(tokens) == 0 || tokens[0].Type != TokenArticle {
t.Fatalf("Tokenise(%q)[0] should be TokenArticle, got %#v", "la branche", tokens)
}
tokens = tok.Tokenise("une branche")
if len(tokens) == 0 || tokens[0].Type != TokenArticle {
t.Fatalf("Tokenise(%q)[0] should be TokenArticle, got %#v", "une branche", tokens)
}
if tokens[0].ArtType != "indefinite" {
t.Fatalf("Tokenise(%q)[0].ArtType = %q, want %q", "une branche", tokens[0].ArtType, "indefinite")
}
}
func TestTokeniser_Tokenise_WordPhrase(t *testing.T) {
setup(t)
tok := NewTokeniser()
tokens := tok.Tokenise("up to date")
if len(tokens) != 1 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 1", "up to date", len(tokens))
}
if tokens[0].Type != TokenWord {
t.Fatalf("Tokenise(%q)[0].Type = %v, want TokenWord", "up to date", tokens[0].Type)
}
if tokens[0].WordCat != "up_to_date" {
t.Fatalf("Tokenise(%q)[0].WordCat = %q, want %q", "up to date", tokens[0].WordCat, "up_to_date")
}
}
func TestTokeniser_Tokenise_WordPhraseWithPunctuation(t *testing.T) {
setup(t)
tok := NewTokeniser()
tokens := tok.Tokenise("up to date.")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "up to date.", len(tokens))
}
if tokens[0].Type != TokenWord {
t.Fatalf("Tokenise(%q)[0].Type = %v, want TokenWord", "up to date.", tokens[0].Type)
}
if tokens[1].Type != TokenPunctuation {
t.Fatalf("Tokenise(%q)[1].Type = %v, want TokenPunctuation", "up to date.", tokens[1].Type)
}
}
func TestTokeniser_MatchArticle_FrenchExtended(t *testing.T) {
setup(t)
tok := NewTokeniserForLang("fr")
tests := []struct {
word string
wantType string
wantOK bool
}{
{"l'", "definite", true},
{"l", "definite", true},
{"lʼ", "definite", true},
{"L'", "definite", true},
{"L", "definite", true},
{"Lʼ", "definite", true},
{"les", "definite", true},
{"au", "definite", true},
{"aux", "definite", true},
{"du", "indefinite", true},
{"des", "indefinite", true},
{"l'enfant", "definite", true},
{"de l'enfant", "indefinite", true},
{"de lami", "indefinite", true},
{"De lenfant", "indefinite", true},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
artType, ok := tok.MatchArticle(tt.word)
if ok != tt.wantOK {
t.Fatalf("MatchArticle(%q) ok=%v, want %v", tt.word, ok, tt.wantOK)
}
if ok && artType != tt.wantType {
t.Errorf("MatchArticle(%q) = %q, want %q", tt.word, artType, tt.wantType)
}
})
}
}
func TestTokeniser_MatchArticle_FrenchUnderscoreTagFallback(t *testing.T) {
setup(t)
tok := NewTokeniserForLang("fr_CA")
tests := []struct {
word string
wantType string
wantOK bool
}{
{"le", "definite", true},
{"l'ami", "definite", true},
{"de l'ami", "indefinite", true},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
artType, ok := tok.MatchArticle(tt.word)
if ok != tt.wantOK {
t.Fatalf("MatchArticle(%q) ok=%v, want %v", tt.word, ok, tt.wantOK)
}
if ok && artType != tt.wantType {
t.Errorf("MatchArticle(%q) = %q, want %q", tt.word, artType, tt.wantType)
}
})
}
tokens := tok.Tokenise("l'ami")
if len(tokens) == 0 || tokens[0].Type != TokenArticle {
t.Fatalf("Tokenise(%q)[0] should be TokenArticle, got %#v", "l'ami", tokens)
}
}
func TestTokeniser_MatchArticle_ConfiguredPhrasePrefix(t *testing.T) {
setup(t)
const lang = "xx"
prev := i18n.GetGrammarData(lang)
t.Cleanup(func() {
i18n.SetGrammarData(lang, prev)
})
i18n.SetGrammarData(lang, &i18n.GrammarData{
Articles: i18n.ArticleForms{
IndefiniteDefault: "a",
IndefiniteVowel: "an",
Definite: "the",
},
})
tok := NewTokeniserForLang(lang)
tests := []struct {
word string
wantType string
wantOK bool
}{
{"the file", "definite", true},
{"a file", "indefinite", true},
{"an error", "indefinite", true},
{"file", "", false},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
artType, ok := tok.MatchArticle(tt.word)
if ok != tt.wantOK {
t.Fatalf("MatchArticle(%q) ok=%v, want %v", tt.word, ok, tt.wantOK)
}
if ok && artType != tt.wantType {
t.Errorf("MatchArticle(%q) = %q, want %q", tt.word, artType, tt.wantType)
}
})
}
}
func TestTokeniser_MatchArticle_ConfiguredElisionPrefix(t *testing.T) {
setup(t)
const lang = "xy"
prev := i18n.GetGrammarData(lang)
t.Cleanup(func() {
i18n.SetGrammarData(lang, prev)
})
i18n.SetGrammarData(lang, &i18n.GrammarData{
Articles: i18n.ArticleForms{
IndefiniteDefault: "a",
IndefiniteVowel: "an",
Definite: "l'",
ByGender: map[string]string{
"m": "le",
"f": "la",
},
},
Nouns: map[string]i18n.NounForms{
"ami": {One: "ami", Other: "amis", Gender: "m"},
},
})
tok := NewTokeniserForLang(lang)
tests := []struct {
word string
wantType string
wantOK bool
}{
{"l'ami", "definite", true},
{"lami", "definite", true},
{"lʼami", "definite", true},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
artType, ok := tok.MatchArticle(tt.word)
if ok != tt.wantOK {
t.Fatalf("MatchArticle(%q) ok=%v, want %v", tt.word, ok, tt.wantOK)
}
if ok && artType != tt.wantType {
t.Errorf("MatchArticle(%q) = %q, want %q", tt.word, artType, tt.wantType)
}
})
}
tokens := tok.Tokenise("l'ami")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "l'ami", len(tokens))
}
if tokens[0].Type != TokenArticle || tokens[0].ArtType != "definite" {
t.Fatalf("Tokenise(%q)[0] = %#v, want definite article", "l'ami", tokens[0])
}
if tokens[1].Type != TokenNoun || tokens[1].Lower != "ami" {
t.Fatalf("Tokenise(%q)[1] = %#v, want noun ami", "l'ami", tokens[1])
}
}
func TestTokeniser_Tokenise_FrenchElision(t *testing.T) {
setup(t)
tok := NewTokeniserForLang("fr")
tokens := tok.Tokenise("l'enfant")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "l'enfant", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].ArtType != "definite" {
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "definite")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
if tokens[1].Lower != "enfant" {
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
}
tokens = tok.Tokenise("de l'enfant")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de l'enfant", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].ArtType != "indefinite" {
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
}
if tokens[0].Lower != "de l'" {
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de l'")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
if tokens[1].Lower != "enfant" {
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
}
tokens = tok.Tokenise("de l' enfant")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de l' enfant", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].ArtType != "indefinite" {
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
}
if tokens[0].Lower != "de l'" {
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de l'")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
if tokens[1].Lower != "enfant" {
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
}
tokens = tok.Tokenise("De lenfant.")
if len(tokens) != 3 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 3", "De lenfant.", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].ArtType != "indefinite" {
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
if tokens[1].Lower != "enfant" {
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
}
if tokens[2].Type != TokenPunctuation {
t.Fatalf("tokens[2].Type = %v, want TokenPunctuation", tokens[2].Type)
}
if tokens[2].PunctType != "sentence_end" {
t.Fatalf("tokens[2].PunctType = %q, want %q", tokens[2].PunctType, "sentence_end")
}
tokens = tok.Tokenise("de le serveur")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de le serveur", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].ArtType != "indefinite" {
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
}
if tokens[0].Lower != "de le" {
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de le")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
if tokens[1].Lower != "serveur" {
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "serveur")
}
tokens = tok.Tokenise("de les amis")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de les amis", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].ArtType != "indefinite" {
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
}
if tokens[0].Lower != "de les" {
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de les")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
if tokens[1].Lower != "amis" {
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "amis")
}
tokens = tok.Tokenise("de l enfant")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de l enfant", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].Lower != "de l'" {
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de l'")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
if tokens[1].Lower != "enfant" {
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
}
tokens = tok.Tokenise("de lʼenfant")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de lʼenfant", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].Lower != "de l'" {
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de l'")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
if tokens[1].Lower != "enfant" {
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
}
tokens = tok.Tokenise("d'enfant")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "d'enfant", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].ArtType != "indefinite" {
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
tokens = tok.Tokenise("lenfant")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "lenfant", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].ArtType != "definite" {
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "definite")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
if tokens[1].Lower != "enfant" {
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
}
tokens = tok.Tokenise("au serveur")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "au serveur", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].ArtType != "definite" {
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "definite")
}
}
func TestTokeniser_Tokenise_FrenchPartitiveArticlePhrase(t *testing.T) {
setup(t)
tok := NewTokeniserForLang("fr")
tokens := tok.Tokenise("de la branche")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de la branche", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].Lower != "de la" {
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de la")
}
if tokens[0].ArtType != "indefinite" {
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
if tokens[1].Lower != "branche" {
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "branche")
}
tokens = tok.Tokenise("de les amis")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de les amis", len(tokens))
}
if tokens[0].Type != TokenArticle {
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
}
if tokens[0].Lower != "de les" {
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de les")
}
if tokens[0].ArtType != "indefinite" {
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
}
if tokens[1].Type != TokenNoun {
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
}
if tokens[1].Lower != "amis" {
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "amis")
}
}
func TestTokeniser_Tokenise(t *testing.T) {
setup(t)
tok := NewTokeniser()
@ -876,43 +364,6 @@ func TestTokeniser_WithSignals(t *testing.T) {
_ = tok // verify it compiles and accepts the option
}
func TestTokeniser_Tokenise_CorpusPriorBias(t *testing.T) {
const lang = "zz-prior"
original := i18n.GetGrammarData(lang)
t.Cleanup(func() {
i18n.SetGrammarData(lang, original)
})
i18n.SetGrammarData(lang, &i18n.GrammarData{
Verbs: map[string]i18n.VerbForms{
"commit": {Past: "committed", Gerund: "committing"},
},
Nouns: map[string]i18n.NounForms{
"commit": {One: "commit", Other: "commits"},
},
Signals: i18n.SignalData{
Priors: map[string]map[string]float64{
"commit": {
"verb": 0.2,
"noun": 0.8,
},
},
},
})
tok := NewTokeniserForLang(lang)
tokens := tok.Tokenise("please commit")
if len(tokens) != 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "please commit", len(tokens))
}
if tokens[1].Type != TokenNoun {
t.Fatalf("Tokenise(%q)[1].Type = %v, want TokenNoun", "please commit", tokens[1].Type)
}
if tokens[1].Confidence <= 0.5 {
t.Fatalf("Tokenise(%q)[1].Confidence = %f, want > 0.5", "please commit", tokens[1].Confidence)
}
}
func TestTokeniser_DualClassDetection(t *testing.T) {
setup(t)
tok := NewTokeniser()
@ -924,13 +375,7 @@ func TestTokeniser_DualClassDetection(t *testing.T) {
}
}
for _, word := range []string{"change", "export", "function", "handle", "host", "import", "link", "log", "merge", "patch", "process", "pull", "push", "queue", "release", "stream", "tag", "trigger", "update", "watch"} {
if !tok.IsDualClass(word) {
t.Errorf("%q should be dual-class after expansion", word)
}
}
notDual := []string{"delete", "go", "branch", "repo"}
notDual := []string{"delete", "go", "push", "branch", "repo"}
for _, word := range notDual {
if tok.IsDualClass(word) {
t.Errorf("%q should not be dual-class", word)
@ -938,78 +383,6 @@ func TestTokeniser_DualClassDetection(t *testing.T) {
}
}
func TestTokeniser_IgnoresDeprecatedGrammarEntries(t *testing.T) {
setup(t)
const lang = "zz-deprecated"
original := i18n.GetGrammarData(lang)
t.Cleanup(func() {
i18n.SetGrammarData(lang, original)
})
i18n.SetGrammarData(lang, &i18n.GrammarData{
Nouns: map[string]i18n.NounForms{
"passed": {One: "passed", Other: "passed"},
"failed": {One: "failed", Other: "failed"},
"skipped": {One: "skipped", Other: "skipped"},
"commit": {One: "commit", Other: "commits"},
},
Words: map[string]string{
"passed": "passed",
"failed": "failed",
"skipped": "skipped",
"url": "URL",
},
})
tok := NewTokeniserForLang(lang)
for _, word := range []string{"passed", "failed", "skipped"} {
if tok.IsDualClass(word) {
t.Fatalf("%q should not be treated as dual-class", word)
}
if cat, ok := tok.MatchWord(word); ok {
t.Fatalf("MatchWord(%q) = %q, %v; want not found", word, cat, ok)
}
if _, ok := tok.MatchNoun(word); ok {
t.Fatalf("MatchNoun(%q) should be ignored", word)
}
}
if cat, ok := tok.MatchWord("url"); !ok || cat != "url" {
t.Fatalf("MatchWord(%q) = %q, %v; want %q, true", "url", cat, ok, "url")
}
}
func TestTokeniser_DualClassExpansion_ClassifiesCommonDevOpsWords(t *testing.T) {
setup(t)
tok := NewTokeniser()
tests := []struct {
text string
wantType TokenType
wantLower string
}{
{"the merge", TokenNoun, "merge"},
{"please merge the file", TokenVerb, "merge"},
{"the process", TokenNoun, "process"},
{"please process the log", TokenVerb, "process"},
}
for _, tt := range tests {
t.Run(tt.text, func(t *testing.T) {
tokens := tok.Tokenise(tt.text)
if len(tokens) < 2 {
t.Fatalf("Tokenise(%q) returned %d tokens, want at least 2", tt.text, len(tokens))
}
if tokens[1].Lower != tt.wantLower {
t.Fatalf("Tokenise(%q)[1].Lower = %q, want %q", tt.text, tokens[1].Lower, tt.wantLower)
}
if tokens[1].Type != tt.wantType {
t.Fatalf("Tokenise(%q)[1].Type = %v, want %v", tt.text, tokens[1].Type, tt.wantType)
}
})
}
}
func TestToken_ConfidenceField(t *testing.T) {
setup(t)
tok := NewTokeniser()
@ -1130,46 +503,6 @@ func TestTokeniser_Disambiguate_ContractionAux(t *testing.T) {
}
}
func TestTokeniser_Disambiguate_ContractionAux_FallbackDefaults(t *testing.T) {
tok := NewTokeniserForLang("zz")
tokens := tok.Tokenise("don't run the tests")
// The hardcoded fallback auxiliaries should still recognise contractions
// even when no locale grammar data is loaded.
for _, token := range tokens {
if token.Lower == "run" && token.Type != TokenVerb {
t.Errorf("'run' after \"don't\": Type = %v, want TokenVerb", token.Type)
}
}
}
func TestTokeniser_Disambiguate_NegationSignal(t *testing.T) {
setup(t)
tok := NewTokeniser(WithSignals())
tokens := tok.Tokenise("no longer commit the changes")
if len(tokens) < 3 {
t.Fatalf("Tokenise(%q) returned %d tokens, want at least 3", "no longer commit the changes", len(tokens))
}
commitTok := tokens[2]
if commitTok.Type != TokenVerb {
t.Fatalf("'commit' after 'no longer': Type = %v, want TokenVerb", commitTok.Type)
}
if commitTok.Signals == nil {
t.Fatal("'commit' after 'no longer' should have signal breakdown")
}
foundNegation := false
for _, component := range commitTok.Signals.Components {
if component.Name == "verb_negation" {
foundNegation = true
break
}
}
if !foundNegation {
t.Error("verb_negation signal should have fired for 'no longer commit'")
}
}
func TestTokeniser_WithSignals_Breakdown(t *testing.T) {
setup(t)
tok := NewTokeniser(WithSignals())
@ -1217,7 +550,7 @@ func TestDisambiguationStats_WithAmbiguous(t *testing.T) {
setup(t)
tok := NewTokeniser()
tokens := tok.Tokenise("The commit passed the test")
stats := tok.DisambiguationStats(tokens)
stats := DisambiguationStatsFromTokens(tokens)
if stats.AmbiguousTokens == 0 {
t.Error("expected ambiguous tokens for dual-class words")
}
@ -1230,7 +563,7 @@ func TestDisambiguationStats_NoAmbiguous(t *testing.T) {
setup(t)
tok := NewTokeniser()
tokens := tok.Tokenise("Deleted the files")
stats := tok.DisambiguationStats(tokens)
stats := DisambiguationStatsFromTokens(tokens)
if stats.AmbiguousTokens != 0 {
t.Errorf("AmbiguousTokens = %d, want 0", stats.AmbiguousTokens)
}
@ -1239,7 +572,7 @@ func TestDisambiguationStats_NoAmbiguous(t *testing.T) {
func TestWithWeights_Override(t *testing.T) {
setup(t)
// Override noun_determiner to 0 — "The commit" should no longer resolve as noun
weights := map[string]float64{
tok := NewTokeniser(WithWeights(map[string]float64{
"noun_determiner": 0.0,
"verb_auxiliary": 0.25,
"following_class": 0.15,
@ -1247,8 +580,7 @@ func TestWithWeights_Override(t *testing.T) {
"verb_saturation": 0.10,
"inflection_echo": 0.03,
"default_prior": 0.02,
}
tok := NewTokeniser(WithWeights(weights))
}))
tokens := tok.Tokenise("The commit")
// With noun_determiner zeroed, default_prior (verb) should win
if tokens[1].Type != TokenVerb {
@ -1256,104 +588,44 @@ func TestWithWeights_Override(t *testing.T) {
}
}
func TestWithWeights_CopiesInputMap(t *testing.T) {
setup(t)
weights := map[string]float64{
"noun_determiner": 0.35,
"verb_auxiliary": 0.25,
"following_class": 0.15,
"sentence_position": 0.10,
"verb_saturation": 0.10,
"inflection_echo": 0.03,
"default_prior": 0.02,
}
tok := NewTokeniser(WithWeights(weights))
// --- AX: Good/Bad/Ugly ---
// Mutate the caller's map after construction; the tokeniser should keep
// using the original copied values.
weights["noun_determiner"] = 0
tokens := tok.Tokenise("The commit")
if tokens[1].Type != TokenNoun {
t.Fatalf("with copied weights, 'commit' Type = %v, want TokenNoun", tokens[1].Type)
}
}
func TestWithWeights_PartialOverrideKeepsDefaults(t *testing.T) {
setup(t)
tok := NewTokeniser(WithWeights(map[string]float64{
"verb_auxiliary": 0.25,
}))
tokens := tok.Tokenise("The commit")
if tokens[1].Type != TokenNoun {
t.Fatalf("with partial weights, 'commit' Type = %v, want TokenNoun", tokens[1].Type)
}
}
func TestDefaultWeights_ReturnsCopy(t *testing.T) {
first := DefaultWeights()
second := DefaultWeights()
if first["noun_determiner"] != 0.35 {
t.Fatalf("DefaultWeights()[noun_determiner] = %v, want 0.35", first["noun_determiner"])
}
first["noun_determiner"] = 0
if second["noun_determiner"] != 0.35 {
t.Fatalf("DefaultWeights() should return a fresh copy, got %v", second["noun_determiner"])
}
}
func TestTokeniserSignalWeights_ReturnsCopy(t *testing.T) {
setup(t)
tok := NewTokeniser(WithWeights(map[string]float64{
"noun_determiner": 0.5,
"default_prior": 0.1,
}))
weights := tok.SignalWeights()
if weights["noun_determiner"] != 0.5 {
t.Fatalf("SignalWeights()[noun_determiner] = %v, want 0.5", weights["noun_determiner"])
}
weights["noun_determiner"] = 0
if got := tok.SignalWeights()["noun_determiner"]; got != 0.5 {
t.Fatalf("SignalWeights() should return a fresh copy, got %v", got)
}
}
func TestLowInformationConfidenceConstants(t *testing.T) {
if LowInformationScoreThreshold != 0.10 {
t.Fatalf("LowInformationScoreThreshold = %v, want 0.10", LowInformationScoreThreshold)
}
if LowInformationVerbConfidence != 0.55 {
t.Fatalf("LowInformationVerbConfidence = %v, want 0.55", LowInformationVerbConfidence)
}
if LowInformationNounConfidence != 0.45 {
t.Fatalf("LowInformationNounConfidence = %v, want 0.45", LowInformationNounConfidence)
}
}
func TestTokeniser_LowInformationConfidenceFloor(t *testing.T) {
// TestTokeniser_MatchVerb_Good verifies that a known past-tense verb is matched.
//
// tok.MatchVerb("deleted") // VerbMatch{Base:"delete", Tense:"past"}
func TestTokeniser_MatchVerb_Good(t *testing.T) {
setup(t)
tok := NewTokeniser()
match, ok := tok.MatchVerb("deleted")
if !ok {
t.Fatal("MatchVerb(deleted) returned false, want true")
}
if match.Base != "delete" {
t.Errorf("VerbMatch.Base = %q, want %q", match.Base, "delete")
}
}
tokens := tok.Tokenise("maybe commit")
if len(tokens) != 2 {
t.Fatalf("Tokenise(maybe commit) produced %d tokens, want 2", len(tokens))
// TestTokeniser_MatchVerb_Bad verifies that a non-verb word is not matched.
//
// tok.MatchVerb("banana") // ok == false
func TestTokeniser_MatchVerb_Bad(t *testing.T) {
setup(t)
tok := NewTokeniser()
_, ok := tok.MatchVerb("banana")
if ok {
t.Error("MatchVerb(banana) returned true, want false")
}
if tokens[1].Type != TokenVerb {
t.Fatalf("Tokenise(maybe commit) Type = %v, want TokenVerb", tokens[1].Type)
}
if tokens[1].Confidence != 0.55 {
t.Fatalf("Tokenise(maybe commit) Confidence = %v, want 0.55", tokens[1].Confidence)
}
if tokens[1].AltType != TokenNoun {
t.Fatalf("Tokenise(maybe commit) AltType = %v, want TokenNoun", tokens[1].AltType)
}
if tokens[1].AltConf != 0.45 {
t.Fatalf("Tokenise(maybe commit) AltConf = %v, want 0.45", tokens[1].AltConf)
}
// TestTokeniser_Tokenise_Ugly verifies that tokenising whitespace-only text returns nil.
//
// tok.Tokenise(" ") // nil
func TestTokeniser_Tokenise_Ugly(t *testing.T) {
setup(t)
tok := NewTokeniser()
tokens := tok.Tokenise(" ")
if tokens != nil {
t.Errorf("Tokenise(whitespace) = %v, want nil", tokens)
}
}

1109
service.go

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

179
state.go
View file

@ -1,179 +0,0 @@
package i18n
import (
"dappco.re/go/core"
)
func newServiceStateSnapshot(
language string,
requestedLanguage string,
languageExplicit bool,
availableLanguages []string,
mode Mode,
fallback string,
formality Formality,
location string,
direction TextDirection,
debug bool,
handlers []KeyHandler,
) ServiceState {
return ServiceState{
Language: language,
RequestedLanguage: requestedLanguage,
LanguageExplicit: languageExplicit,
AvailableLanguages: availableLanguages,
Mode: mode,
Fallback: fallback,
Formality: formality,
Location: location,
Direction: direction,
IsRTL: direction == DirRTL,
Debug: debug,
Handlers: handlers,
}
}
func defaultServiceStateSnapshot() ServiceState {
// Keep the nil/default snapshot aligned with Service.State() so callers get
// the same shape regardless of whether a Service has been initialised.
return newServiceStateSnapshot(
"en",
"",
false,
[]string{},
ModeNormal,
"en",
FormalityNeutral,
"",
DirLTR,
false,
[]KeyHandler{},
)
}
// ServiceState captures the current configuration of a service in one
// copy-safe snapshot.
//
// state := i18n.CurrentState()
type ServiceState struct {
Language string
RequestedLanguage string
LanguageExplicit bool
AvailableLanguages []string
Mode Mode
Fallback string
Formality Formality
Location string
Direction TextDirection
IsRTL bool
Debug bool
Handlers []KeyHandler
}
// HandlerTypeNames returns the short type names of the snapshot's handlers.
//
// names := i18n.CurrentState().HandlerTypeNames()
//
// The returned slice is a fresh copy, so callers can inspect or mutate it
// without affecting the snapshot.
func (s ServiceState) HandlerTypeNames() []string {
if len(s.Handlers) == 0 {
return []string{}
}
names := make([]string, 0, len(s.Handlers))
for _, handler := range s.Handlers {
if handler == nil {
names = append(names, "<nil>")
continue
}
names = append(names, shortHandlerTypeName(handler))
}
return names
}
// String returns a concise, stable summary of the service snapshot.
//
// fmt.Println(i18n.CurrentState().String())
func (s ServiceState) String() string {
langs := "[]"
if len(s.AvailableLanguages) > 0 {
langs = "[" + core.Join(", ", s.AvailableLanguages...) + "]"
}
handlerNames := s.HandlerTypeNames()
handlers := "[]"
if len(handlerNames) > 0 {
handlers = "[" + core.Join(", ", handlerNames...) + "]"
}
return core.Sprintf(
"ServiceState{language=%q requested=%q explicit=%t fallback=%q mode=%s formality=%s location=%q direction=%s rtl=%t debug=%t available=%s handlers=%d types=%s}",
s.Language,
s.RequestedLanguage,
s.LanguageExplicit,
s.Fallback,
s.Mode,
s.Formality,
s.Location,
s.Direction,
s.IsRTL,
s.Debug,
langs,
len(s.Handlers),
handlers,
)
}
func shortHandlerTypeName(handler KeyHandler) string {
name := core.Sprintf("%T", handler)
parts := core.Split(name, ".")
if len(parts) > 0 {
name = parts[len(parts)-1]
}
return core.TrimPrefix(name, "*")
}
func (s *Service) State() ServiceState {
if s == nil {
return defaultServiceStateSnapshot()
}
s.mu.RLock()
defer s.mu.RUnlock()
langs := make([]string, len(s.availableLangs))
for i, tag := range s.availableLangs {
langs[i] = tag.String()
}
handlers := make([]KeyHandler, len(s.handlers))
copy(handlers, s.handlers)
dir := DirLTR
if IsRTLLanguage(s.currentLang) {
dir = DirRTL
}
return newServiceStateSnapshot(
s.currentLang,
s.requestedLang,
s.languageExplicit,
langs,
s.mode,
s.fallbackLang,
s.formality,
s.location,
dir,
s.debug,
handlers,
)
}
// String returns a concise snapshot of the service state.
func (s *Service) String() string {
return s.State().String()
}
// CurrentState is a more explicit alias for State.
//
// state := i18n.CurrentState()
func (s *Service) CurrentState() ServiceState {
return s.State()
}

53
time.go
View file

@ -8,78 +8,33 @@ import (
// TimeAgo returns a localised relative time string.
//
// TimeAgo(time.Now().Add(-4 * time.Second)) // "just now"
// TimeAgo(time.Now().Add(-5 * time.Minute)) // "5 minutes ago"
func TimeAgo(t time.Time) string {
duration := time.Since(t)
if duration < 0 {
duration = 0
}
switch {
case duration < 5*time.Second:
if text := T("time.just_now"); text != "time.just_now" {
return text
}
return "just now"
case duration < time.Minute:
return FormatAgo(int(duration/time.Second), "second")
return T("time.just_now")
case duration < time.Hour:
return FormatAgo(int(duration.Minutes()), "minute")
case duration < 24*time.Hour:
return FormatAgo(int(duration.Hours()), "hour")
case duration < 7*24*time.Hour:
return FormatAgo(int(duration.Hours()/24), "day")
case duration < 30*24*time.Hour:
return FormatAgo(int(duration.Hours()/(24*7)), "week")
case duration < 365*24*time.Hour:
return FormatAgo(int(duration.Hours()/(24*30)), "month")
default:
return FormatAgo(int(duration.Hours()/(24*365)), "year")
return FormatAgo(int(duration.Hours()/(24*7)), "week")
}
}
// FormatAgo formats "N unit ago" with proper pluralisation.
func FormatAgo(count int, unit string) string {
svc := Default()
unit = normalizeAgoUnit(unit)
if svc == nil {
return core.Sprintf("%d %s ago", count, Pluralize(unit, count))
return core.Sprintf("%d %ss ago", count, unit)
}
key := "time.ago." + unit
result := svc.T(key, map[string]any{"Count": count})
if result == key {
return core.Sprintf("%d %s ago", count, fallbackAgoUnit(unit, count))
return core.Sprintf("%d %s ago", count, Pluralize(unit, count))
}
return result
}
func fallbackAgoUnit(unit string, count int) string {
lang := currentLangForGrammar()
rendered := renderWord(lang, unit)
if rendered != unit {
return rendered
}
return Pluralize(unit, count)
}
func normalizeAgoUnit(unit string) string {
unit = core.Lower(core.Trim(unit))
switch unit {
case "seconds":
return "second"
case "minutes":
return "minute"
case "hours":
return "hour"
case "days":
return "day"
case "weeks":
return "week"
case "months":
return "month"
case "years":
return "year"
default:
return unit
}
}

View file

@ -2,7 +2,6 @@ package i18n
import (
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
@ -21,14 +20,11 @@ func TestTimeAgo_Good(t *testing.T) {
duration time.Duration
contains string
}{
{"just_now", 4 * time.Second, "just now"},
{"seconds_ago", 5 * time.Second, "5 seconds ago"},
{"just_now", 5 * time.Second, "just now"},
{"minutes_ago", 5 * time.Minute, "5 minutes ago"},
{"hours_ago", 3 * time.Hour, "3 hours ago"},
{"days_ago", 2 * 24 * time.Hour, "2 days ago"},
{"weeks_ago", 3 * 7 * 24 * time.Hour, "3 weeks ago"},
{"months_ago", 40 * 24 * time.Hour, "1 month ago"},
{"years_ago", 400 * 24 * time.Hour, "1 year ago"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -45,7 +41,7 @@ func TestTimeAgo_Good_EdgeCases(t *testing.T) {
// Just under 1 minute
got := TimeAgo(time.Now().Add(-59 * time.Second))
assert.Contains(t, got, "seconds ago")
assert.Contains(t, got, "just now")
// Exactly 1 minute
got = TimeAgo(time.Now().Add(-60 * time.Second))
@ -62,14 +58,6 @@ func TestTimeAgo_Good_EdgeCases(t *testing.T) {
// Just under 1 week
got = TimeAgo(time.Now().Add(-6 * 24 * time.Hour))
assert.Contains(t, got, "days ago")
// Just over 4 weeks
got = TimeAgo(time.Now().Add(-31 * 24 * time.Hour))
assert.Contains(t, got, "month ago")
// Well over a year
got = TimeAgo(time.Now().Add(-800 * 24 * time.Hour))
assert.Contains(t, got, "years ago")
}
func TestTimeAgo_Good_SingleUnits(t *testing.T) {
@ -94,24 +82,6 @@ func TestTimeAgo_Good_SingleUnits(t *testing.T) {
assert.Contains(t, got, "1 week ago")
}
func TestTimeAgo_Good_MissingJustNowKeyFallback(t *testing.T) {
svc, err := NewWithFS(fstest.MapFS{
"xx.json": &fstest.MapFile{
Data: []byte(`{}`),
},
}, ".")
require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
got := TimeAgo(time.Now().Add(-4 * time.Second))
assert.Equal(t, "just now", got)
}
// --- FormatAgo ---
func TestFormatAgo_Good(t *testing.T) {
@ -142,46 +112,6 @@ func TestFormatAgo_Good(t *testing.T) {
}
}
func TestFormatAgo_Good_PluralUnitAlias(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
got := FormatAgo(5, "minutes")
assert.Equal(t, "5 minutes ago", got)
}
func TestFormatAgo_Good_MorePluralUnitAliases(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
tests := []struct {
name string
count int
unit string
want string
}{
{"months", 3, "months", "3 months ago"},
{"year", 1, "years", "1 year ago"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatAgo(tt.count, tt.unit)
assert.Equal(t, tt.want, got)
})
}
}
func TestFormatAgo_Good_NormalisesUnitInput(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
got := FormatAgo(2, " Hours ")
assert.Equal(t, "2 hours ago", got)
}
func TestFormatAgo_Bad_UnknownUnit(t *testing.T) {
svc, err := New()
require.NoError(t, err)
@ -201,72 +131,13 @@ func TestFormatAgo_Good_SingularUnit(t *testing.T) {
assert.Equal(t, "1 fortnight ago", got)
}
func TestFormatAgo_Good_NoDefaultService(t *testing.T) {
prev := Default()
SetDefault(nil)
t.Cleanup(func() {
SetDefault(prev)
})
got := FormatAgo(1, "second")
assert.Equal(t, "1 second ago", got)
got = FormatAgo(5, "second")
assert.Equal(t, "5 seconds ago", got)
}
func TestFormatAgo_Good_FrenchRelativeTime(t *testing.T) {
prev := Default()
// TestTimeAgo_Ugly verifies that a future time (negative duration) does not panic.
//
// TimeAgo(time.Now().Add(10 * time.Minute)) // implementation-defined but no panic
func TestTimeAgo_Ugly(t *testing.T) {
svc, err := New()
require.NoError(t, err)
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
require.NoError(t, SetLanguage("fr"))
tests := []struct {
name string
count int
unit string
want string
}{
{"month", 1, "month", "il y a 1 mois"},
{"months", 3, "month", "il y a 3 mois"},
{"year", 1, "year", "il y a 1 an"},
{"years", 4, "year", "il y a 4 ans"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatAgo(tt.count, tt.unit)
assert.Equal(t, tt.want, got)
})
}
}
func TestFormatAgo_FallsBackToLocaleWordMap(t *testing.T) {
prev := Default()
svc, err := NewWithFS(fstest.MapFS{
"en.json": &fstest.MapFile{
Data: []byte(`{
"gram": {
"word": {
"month": "mois"
}
}
}`),
},
}, ".")
require.NoError(t, err)
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
require.NoError(t, SetLanguage("en"))
got := FormatAgo(2, "month")
assert.Equal(t, "2 mois ago", got)
got := TimeAgo(time.Now().Add(10 * time.Minute))
_ = got // must not panic
}

View file

@ -1,60 +1,20 @@
package i18n
import (
"strconv"
"dappco.re/go/core"
)
func getCount(data any) int {
if data == nil {
return 0
}
switch d := data.(type) {
case *Subject:
if d == nil {
return 0
}
return d.CountInt()
case *TranslationContext:
if d == nil {
return 0
}
if count, ok := d.countValue(); ok {
return count
}
if d.Extra != nil {
if c, ok := d.Extra["Count"]; ok {
return toInt(c)
}
if c, ok := d.Extra["count"]; ok {
return toInt(c)
}
}
return d.count
case map[string]any:
if c, ok := d["Count"]; ok {
return toInt(c)
}
if c, ok := d["count"]; ok {
return toInt(c)
}
case map[string]int:
if c, ok := d["Count"]; ok {
return c
}
if c, ok := d["count"]; ok {
return c
}
case map[string]string:
if c, ok := d["Count"]; ok {
return toInt(c)
}
if c, ok := d["count"]; ok {
return toInt(c)
}
}
return toInt(data)
return 0
}
func toInt(v any) int {
@ -86,13 +46,6 @@ func toInt(v any) int {
return int(n)
case float32:
return int(n)
case string:
if n == "" {
return 0
}
if parsed, err := strconv.Atoi(core.Trim(n)); err == nil {
return parsed
}
}
return 0
}
@ -126,13 +79,6 @@ func toInt64(v any) int64 {
return int64(n)
case float32:
return int64(n)
case string:
if n == "" {
return 0
}
if parsed, err := strconv.ParseInt(core.Trim(n), 10, 64); err == nil {
return parsed
}
}
return 0
}
@ -166,13 +112,6 @@ func toFloat64(v any) float64 {
return float64(n)
case uint8:
return float64(n)
case string:
if n == "" {
return 0
}
if parsed, err := strconv.ParseFloat(core.Trim(n), 64); err == nil {
return parsed
}
}
return 0
}

View file

@ -18,7 +18,6 @@ func TestGetCount_Good(t *testing.T) {
{"map_string_any", map[string]any{"Count": 5}, 5},
{"map_string_any_float", map[string]any{"Count": 3.7}, 3},
{"map_string_int", map[string]int{"Count": 42}, 42},
{"map_string_string", map[string]string{"Count": "9"}, 9},
{"no_count_key", map[string]any{"Name": "test"}, 0},
{"wrong_type", "a string", 0},
}
@ -30,16 +29,6 @@ func TestGetCount_Good(t *testing.T) {
}
}
func TestGetCount_Good_TranslationContextDefault(t *testing.T) {
ctx := C("test")
assert.Equal(t, 1, getCount(ctx))
}
func TestGetCount_Good_TranslationContextExtraCount(t *testing.T) {
ctx := C("test").Set("Count", 3)
assert.Equal(t, 3, getCount(ctx))
}
// --- toInt ---
func TestToInt_Good(t *testing.T) {
@ -61,7 +50,6 @@ func TestToInt_Good(t *testing.T) {
{"uint8", uint8(50), 50},
{"float64", float64(3.14), 3},
{"float32", float32(2.71), 2},
{"string_int", "123", 123},
{"string", "not a number", 0},
{"bool", true, 0},
}
@ -94,7 +82,6 @@ func TestToInt64_Good(t *testing.T) {
{"uint8", uint8(50), 50},
{"float64", float64(3.14), 3},
{"float32", float32(2.71), 2},
{"string_int64", "123", 123},
{"string", "not a number", 0},
{"bool", true, 0},
}
@ -127,7 +114,6 @@ func TestToFloat64_Good(t *testing.T) {
{"uint32", uint32(30), 30.0},
{"uint16", uint16(40), 40.0},
{"uint8", uint8(50), 50.0},
{"string_float", "3.5", 3.5},
{"string", "not a number", 0},
{"bool", true, 0},
}
@ -138,3 +124,25 @@ func TestToFloat64_Good(t *testing.T) {
})
}
}
// TestGetCount_Bad verifies a map without a Count key returns 0.
//
// getCount(map[string]any{"other": 5}) // 0
func TestGetCount_Bad(t *testing.T) {
data := map[string]any{"other": 5}
assert.Equal(t, 0, getCount(data))
}
// TestToInt_Ugly verifies that nil returns 0 without panic.
//
// toInt(nil) // 0
func TestToInt_Ugly(t *testing.T) {
assert.Equal(t, 0, toInt(nil))
}
// TestToFloat64_Ugly verifies that nil returns 0 without panic.
//
// toFloat64(nil) // 0
func TestToFloat64_Ugly(t *testing.T) {
assert.InDelta(t, 0.0, toFloat64(nil), 0.001)
}

108
types.go
View file

@ -8,7 +8,7 @@
// T("i18n.label.status") // "Status:"
// T("i18n.progress.build") // "Building..."
// T("i18n.count.file", 5) // "5 files"
// T("i18n.done.delete", "config.yaml") // "Config.yaml deleted"
// T("i18n.done.delete", "config.yaml") // "Config.Yaml deleted"
// T("i18n.fail.push", "commits") // "Failed to push commits"
package i18n
@ -17,8 +17,6 @@ import "sync"
// --- Core Types ---
// Mode determines how the service handles missing translation keys.
//
// i18n.SetMode(i18n.ModeStrict)
type Mode int
const (
@ -41,8 +39,6 @@ func (m Mode) String() string {
}
// Formality represents the level of formality in translations.
//
// i18n.S("user", "Alex").Formal()
type Formality int
const (
@ -52,18 +48,14 @@ const (
)
// TextDirection represents text directionality.
//
// if i18n.Direction() == i18n.DirRTL { /* ... */ }
type TextDirection int
const (
DirLTR TextDirection = iota // Left-to-right
DirRTL // Right-to-left
DirRTL // Right-to-left
)
// PluralCategory represents CLDR plural categories.
//
// cat := i18n.CurrentPluralCategory(2)
type PluralCategory int
const (
@ -76,8 +68,6 @@ const (
)
// GrammaticalGender represents grammatical gender for nouns.
//
// i18n.S("user", "Alex").Gender("feminine")
type GrammaticalGender int
const (
@ -90,8 +80,6 @@ const (
// --- Message Types ---
// Message represents a translation — either a simple string or plural forms.
//
// msg := i18n.Message{One: "{{.Count}} file", Other: "{{.Count}} files"}
type Message struct {
Text string // Simple string value (non-plural)
Zero string // count == 0 (Arabic, Latvian, Welsh)
@ -144,8 +132,6 @@ func (m Message) IsPlural() bool {
// --- Subject Types ---
// Subject represents a typed subject with metadata for semantic translations.
//
// subj := i18n.S("file", "config.yaml").Count(3).In("workspace")
type Subject struct {
Noun string // The noun type (e.g., "file", "repo")
Value any // The actual value (e.g., filename)
@ -158,8 +144,6 @@ type Subject struct {
// --- Intent Types ---
// IntentMeta defines the behaviour of an intent.
//
// intent := i18n.Intent{Meta: i18n.IntentMeta{Type: "action", Verb: "delete"}}
type IntentMeta struct {
Type string // "action", "question", "info"
Verb string // Reference to verb key
@ -169,8 +153,6 @@ type IntentMeta struct {
}
// Composed holds all output forms for an intent after template resolution.
//
// composed := i18n.ComposeIntent(i18n.Intent{Question: "Delete {{.Subject}}?"}, i18n.S("file", "config.yaml"))
type Composed struct {
Question string // "Delete config.yaml?"
Confirm string // "Really delete config.yaml?"
@ -180,8 +162,6 @@ type Composed struct {
}
// Intent defines a semantic intent with templates for all output forms.
//
// intent := i18n.Intent{Question: "Delete {{.Subject}}?"}
type Intent struct {
Meta IntentMeta
Question string // Template for question form
@ -206,8 +186,6 @@ type templateData struct {
// --- Grammar Types ---
// GrammarData holds language-specific grammar forms loaded from JSON.
//
// i18n.SetGrammarData("en", &i18n.GrammarData{Articles: i18n.ArticleForms{IndefiniteDefault: "a"}})
type GrammarData struct {
Verbs map[string]VerbForms // verb -> forms
Nouns map[string]NounForms // noun -> forms
@ -215,20 +193,15 @@ type GrammarData struct {
Words map[string]string // base word translations
Punct PunctuationRules // language-specific punctuation
Signals SignalData // disambiguation signal word lists
Number NumberFormat // locale-specific number formatting
}
// VerbForms holds verb conjugations.
//
// forms := i18n.VerbForms{Past: "deleted", Gerund: "deleting"}
type VerbForms struct {
Past string // "deleted"
Gerund string // "deleting"
}
// NounForms holds plural and gender information for a noun.
//
// forms := i18n.NounForms{One: "file", Other: "files"}
type NounForms struct {
One string // Singular form
Other string // Plural form
@ -236,8 +209,6 @@ type NounForms struct {
}
// ArticleForms holds article configuration for a language.
//
// articles := i18n.ArticleForms{IndefiniteDefault: "a", IndefiniteVowel: "an"}
type ArticleForms struct {
IndefiniteDefault string // "a"
IndefiniteVowel string // "an"
@ -246,29 +217,22 @@ type ArticleForms struct {
}
// PunctuationRules holds language-specific punctuation patterns.
//
// rules := i18n.PunctuationRules{LabelSuffix: ":", ProgressSuffix: "..."}
type PunctuationRules struct {
LabelSuffix string // ":" (French uses " :")
ProgressSuffix string // "..."
}
// SignalData holds word lists used for disambiguation signals.
//
// signals := i18n.SignalData{VerbAuxiliaries: []string{"is", "was"}}
type SignalData struct {
NounDeterminers []string // Words that precede nouns: "the", "a", "this", "my", ...
VerbAuxiliaries []string // Auxiliaries/modals before verbs: "is", "was", "will", ...
VerbInfinitive []string // Infinitive markers: "to"
VerbNegation []string // Negation cues that weakly signal a verb: "not", "never", ...
Priors map[string]map[string]float64 // Corpus-derived verb/noun priors for ambiguous words, consumed by the reversal tokeniser.
Priors map[string]map[string]float64 // Reserved for Phase 2: corpus-derived per-word priors. Not yet loaded.
}
// --- Number Formatting ---
// NumberFormat defines locale-specific number formatting rules.
//
// fmt := i18n.NumberFormat{ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"}
type NumberFormat struct {
ThousandsSep string // "," for en, "." for de
DecimalSep string // "." for en, "," for de
@ -278,18 +242,12 @@ type NumberFormat struct {
// --- Function Types ---
// PluralRule determines the plural category for a count.
//
// rule := i18n.GetPluralRule("en")
type PluralRule func(n int) PluralCategory
// MissingKeyHandler receives missing key events.
//
// i18n.OnMissingKey(func(m i18n.MissingKey) {})
type MissingKeyHandler func(missing MissingKey)
// MissingKey is dispatched when a translation key is not found in ModeCollect.
//
// func handle(m i18n.MissingKey) { _ = m.Key }
type MissingKey struct {
Key string
Args map[string]any
@ -301,24 +259,18 @@ type MissingKey struct {
// KeyHandler processes translation keys before standard lookup.
// Handlers form a chain; each can handle a key or delegate to the next.
//
// i18n.AddHandler(i18n.LabelHandler{})
type KeyHandler interface {
Match(key string) bool
Handle(key string, args []any, next func() string) string
}
// Loader provides translation data to the Service.
//
// svc, err := i18n.NewWithLoader(loader)
type Loader interface {
Load(lang string) (map[string]Message, *GrammarData, error)
Languages() []string
}
// Translator defines the interface for translation services.
//
// var t i18n.Translator = i18n.Default()
type Translator interface {
T(messageID string, args ...any) string
SetLanguage(lang string) error
@ -368,7 +320,6 @@ var pluralRules = map[string]PluralRule{
"ru": pluralRuleRussian, "ru-RU": pluralRuleRussian,
"pl": pluralRulePolish, "pl-PL": pluralRulePolish,
"ar": pluralRuleArabic, "ar-SA": pluralRuleArabic,
"cy": pluralRuleWelsh, "cy-GB": pluralRuleWelsh,
"zh": pluralRuleChinese, "zh-CN": pluralRuleChinese, "zh-TW": pluralRuleChinese,
"ja": pluralRuleJapanese, "ja-JP": pluralRuleJapanese,
"ko": pluralRuleKorean, "ko-KR": pluralRuleKorean,
@ -434,7 +385,7 @@ var irregularVerbs = map[string]VerbForms{
"rebel": {Past: "rebelled", Gerund: "rebelling"}, "excel": {Past: "excelled", Gerund: "excelling"},
"cancel": {Past: "cancelled", Gerund: "cancelling"}, "travel": {Past: "travelled", Gerund: "travelling"},
"label": {Past: "labelled", Gerund: "labelling"}, "model": {Past: "modelled", Gerund: "modelling"},
"level": {Past: "levelled", Gerund: "levelling"},
"level": {Past: "levelled", Gerund: "levelling"},
"format": {Past: "formatted", Gerund: "formatting"},
"analyse": {Past: "analysed", Gerund: "analysing"},
"organise": {Past: "organised", Gerund: "organising"},
@ -496,57 +447,6 @@ var irregularNouns = map[string]string{
"calf": "calves", "loaf": "loaves", "thief": "thieves",
}
// dualClassVerbs seeds additional regular verbs that are also common nouns in
// dev/ops text. The forms are regular, but listing them here makes the
// reversal tokeniser treat them as known bases for dual-class disambiguation.
var dualClassVerbs = map[string]VerbForms{
"change": {Past: "changed", Gerund: "changing"},
"export": {Past: "exported", Gerund: "exporting"},
"function": {Past: "functioned", Gerund: "functioning"},
"handle": {Past: "handled", Gerund: "handling"},
"host": {Past: "hosted", Gerund: "hosting"},
"import": {Past: "imported", Gerund: "importing"},
"link": {Past: "linked", Gerund: "linking"},
"log": {Past: "logged", Gerund: "logging"},
"merge": {Past: "merged", Gerund: "merging"},
"patch": {Past: "patched", Gerund: "patching"},
"process": {Past: "processed", Gerund: "processing"},
"queue": {Past: "queued", Gerund: "queuing"},
"release": {Past: "released", Gerund: "releasing"},
"pull": {Past: "pulled", Gerund: "pulling"},
"push": {Past: "pushed", Gerund: "pushing"},
"stream": {Past: "streamed", Gerund: "streaming"},
"tag": {Past: "tagged", Gerund: "tagging"},
"trigger": {Past: "triggered", Gerund: "triggering"},
"watch": {Past: "watched", Gerund: "watching"},
"update": {Past: "updated", Gerund: "updating"},
}
// dualClassNouns mirrors the same vocabulary as nouns so the tokeniser can
// classify the base forms as ambiguous when they appear without inflection.
var dualClassNouns = map[string]string{
"change": "changes",
"export": "exports",
"function": "functions",
"handle": "handles",
"host": "hosts",
"import": "imports",
"link": "links",
"log": "logs",
"merge": "merges",
"patch": "patches",
"process": "processes",
"queue": "queues",
"release": "releases",
"pull": "pulls",
"push": "pushes",
"stream": "streams",
"tag": "tags",
"trigger": "triggers",
"watch": "watches",
"update": "updates",
}
var vowelSounds = map[string]bool{
"hour": true, "honest": true, "honour": true, "honor": true, "heir": true, "herb": true,
}

View file

@ -251,3 +251,36 @@ func TestIsRTLLanguage(t *testing.T) {
}
}
}
// TestMessage_Good verifies a simple message returns its text.
//
// Message{Text: "hello"}.ForCategory(PluralOther) // "hello"
func TestMessage_Good(t *testing.T) {
m := Message{Text: "hello"}
if got := m.ForCategory(PluralOther); got != "hello" {
t.Errorf("Message.ForCategory = %q, want %q", got, "hello")
}
}
// TestMessage_Bad verifies an empty message returns empty string.
//
// Message{}.ForCategory(PluralOne) // ""
func TestMessage_Bad(t *testing.T) {
m := Message{}
if got := m.ForCategory(PluralOne); got != "" {
t.Errorf("empty Message.ForCategory = %q, want empty", got)
}
}
// TestMessage_Ugly verifies plural forms are selected by category when both Text and plural forms are set.
//
// Message{Text:"item", One:"item", Other:"items"}.ForCategory(PluralOther) // "items"
func TestMessage_Ugly(t *testing.T) {
m := Message{Text: "item", One: "item", Other: "items"}
if got := m.ForCategory(PluralOne); got != "item" {
t.Errorf("Message.ForCategory(PluralOne) = %q, want %q", got, "item")
}
if got := m.ForCategory(PluralOther); got != "items" {
t.Errorf("Message.ForCategory(PluralOther) = %q, want %q", got, "items")
}
}

View file

@ -44,17 +44,6 @@ type IrregularResult struct {
// articlePrompt builds a fill-in-the-blank prompt for article prediction.
func articlePrompt(noun string) string {
return articlePromptForLang(currentLangForGrammar(), noun)
}
func articlePromptForLang(lang, noun string) string {
noun = core.Trim(noun)
if isFrenchLanguage(lang) {
return core.Sprintf(
"Complete with the correct article (le/la/l'/les/du/au/aux/un/une/des): ___ %s. Answer with just the article:",
noun,
)
}
return core.Sprintf(
"Complete with the correct article (a/an/the): ___ %s. Answer with just the article:",
noun,
@ -71,14 +60,14 @@ func irregularPrompt(verb, tense string) string {
// collectGenerated runs a single-token generation and returns the trimmed, lowercased output.
func collectGenerated(ctx context.Context, m inference.TextModel, prompt string) (string, error) {
sb := core.NewBuilder()
builder := core.NewBuilder()
for tok := range m.Generate(ctx, prompt, inference.WithMaxTokens(1), inference.WithTemperature(0.05)) {
sb.WriteString(tok.Text)
builder.WriteString(tok.Text)
}
if err := m.Err(); err != nil {
return "", err
}
return core.Trim(core.Lower(sb.String())), nil
return core.Trim(core.Lower(builder.String())), nil
}
// ValidateArticle checks whether a given article usage is grammatically correct

View file

@ -327,30 +327,6 @@ func TestArticlePrompt(t *testing.T) {
}
}
func TestArticlePromptFrenchLocale(t *testing.T) {
prev := Default()
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
if err := SetLanguage("fr"); err != nil {
t.Fatalf("SetLanguage(fr) failed: %v", err)
}
prompt := articlePrompt("livre")
if !contains(prompt, "livre") {
t.Errorf("prompt should contain the noun: %q", prompt)
}
if !contains(prompt, "le/la/l'/les/du/au/aux/un/une/des") {
t.Errorf("prompt should mention French article options: %q", prompt)
}
}
func TestIrregularPrompt(t *testing.T) {
prompt := irregularPrompt("swim", "past participle")
if !contains(prompt, "'swim'") {
@ -360,3 +336,33 @@ func TestIrregularPrompt(t *testing.T) {
t.Errorf("prompt should contain the tense: %q", prompt)
}
}
// TestArticlePrompt_Good verifies the article prompt contains the noun.
//
// articlePrompt("error") // contains "error"
func TestArticlePrompt_Good(t *testing.T) {
prompt := articlePrompt("error")
if !contains(prompt, "error") {
t.Errorf("articlePrompt should contain noun %q, got %q", "error", prompt)
}
}
// TestArticlePrompt_Bad verifies an empty noun still produces a non-empty prompt.
//
// articlePrompt("") // non-empty
func TestArticlePrompt_Bad(t *testing.T) {
prompt := articlePrompt("")
if prompt == "" {
t.Error("articlePrompt(\"\") should not be empty")
}
}
// TestIrregularPrompt_Ugly verifies the prompt is not empty for an unusual tense string.
//
// irregularPrompt("run", "subjunctive") // non-empty
func TestIrregularPrompt_Ugly(t *testing.T) {
prompt := irregularPrompt("run", "subjunctive")
if prompt == "" {
t.Error("irregularPrompt should not be empty for unusual tense")
}
}