Compare commits

..

2 commits
dev ... main

Author SHA1 Message Date
c2a16f4282 Merge pull request 'chore: update dependencies to dappco.re tagged versions' (#10) from dev into main
All checks were successful
Security Scan / security (push) Successful in 11s
Test / test (push) Successful in 38s
Reviewed-on: #10
2026-03-23 20:36:42 +00:00
Claude
cf9ea9b4b7
chore: update dependencies to dappco.re tagged versions
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 1m24s
Security Scan / security (pull_request) Successful in 6s
Test / test (pull_request) Successful in 32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 01:10:23 +00:00
58 changed files with 787 additions and 10904 deletions

6
.gitignore vendored
View file

@ -4,9 +4,3 @@
# Knowledge base # Knowledge base
KB/ KB/
.core/ .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 ./... 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 ## Licence
European Union Public Licence 1.2 — see [LICENCE](LICENCE) for details. European Union Public Licence 1.2 — see [LICENCE](LICENCE) for details.

View file

@ -2,9 +2,9 @@ package i18n
import ( import (
"context" "context"
"fmt"
"time" "time"
"dappco.re/go/core"
log "dappco.re/go/core/log" log "dappco.re/go/core/log"
"forge.lthn.ai/core/go-inference" "forge.lthn.ai/core/go-inference"
) )
@ -66,7 +66,7 @@ func CalibrateDomains(ctx context.Context, modelA, modelB inference.TextModel,
// Build classification prompts from sample texts. // Build classification prompts from sample texts.
prompts := make([]string, len(samples)) prompts := make([]string, len(samples))
for i, s := range samples { for i, s := range samples {
prompts[i] = core.Sprintf(cfg.promptTemplate, s.Text) prompts[i] = fmt.Sprintf(cfg.promptTemplate, s.Text)
} }
// Classify with model A. // Classify with model A.
@ -93,7 +93,7 @@ func CalibrateDomains(ctx context.Context, modelA, modelB inference.TextModel,
if agree { if agree {
stats.Agreed++ stats.Agreed++
} else { } else {
key := core.Sprintf("%s->%s", a, b) key := fmt.Sprintf("%s->%s", a, b)
stats.ConfusionPairs[key]++ stats.ConfusionPairs[key]++
} }
stats.ByDomainA[a]++ stats.ByDomainA[a]++
@ -140,7 +140,7 @@ func classifyAll(ctx context.Context, model inference.TextModel, prompts []strin
results, err := model.Classify(ctx, batch, inference.WithMaxTokens(1)) results, err := model.Classify(ctx, batch, inference.WithMaxTokens(1))
if err != nil { if err != nil {
return nil, 0, log.E("classifyAll", core.Sprintf("classify batch [%d:%d]", i, end), err) return nil, 0, log.E("classifyAll", fmt.Sprintf("classify batch [%d:%d]", i, end), err)
} }
for j, r := range results { for j, r := range results {

View file

@ -3,10 +3,12 @@ package i18n
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/json"
"fmt"
"io" "io"
"strings"
"time" "time"
"dappco.re/go/core"
log "dappco.re/go/core/log" log "dappco.re/go/core/log"
"forge.lthn.ai/core/go-inference" "forge.lthn.ai/core/go-inference"
) )
@ -61,7 +63,7 @@ func mapTokenToDomain(token string) string {
if len(token) == 0 { if len(token) == 0 {
return "unknown" return "unknown"
} }
lower := core.Lower(token) lower := strings.ToLower(token)
switch { switch {
case lower == "technical" || lower == "tech": case lower == "technical" || lower == "tech":
return "technical" return "technical"
@ -105,31 +107,25 @@ func ClassifyCorpus(ctx context.Context, model inference.TextModel,
} }
prompts := make([]string, len(batch)) prompts := make([]string, len(batch))
for i, p := range batch { for i, p := range batch {
prompts[i] = core.Sprintf(cfg.promptTemplate, p.prompt) prompts[i] = fmt.Sprintf(cfg.promptTemplate, p.prompt)
} }
results, err := model.Classify(ctx, prompts, inference.WithMaxTokens(1)) results, err := model.Classify(ctx, prompts, inference.WithMaxTokens(1))
if err != nil { if err != nil {
return log.E("ClassifyCorpus", "classify batch", err) 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 { for i, r := range results {
domain := mapTokenToDomain(r.Token.Text) domain := mapTokenToDomain(r.Token.Text)
batch[i].record["domain_1b"] = domain batch[i].record["domain_1b"] = domain
stats.ByDomain[domain]++ stats.ByDomain[domain]++
stats.Total++ stats.Total++
mr := core.JSONMarshal(batch[i].record) line, err := json.Marshal(batch[i].record)
if !mr.OK { if err != nil {
return log.E("ClassifyCorpus", "marshal output", mr.Value.(error)) return log.E("ClassifyCorpus", "marshal output", err)
}
if _, err := fmt.Fprintf(output, "%s\n", line); err != nil {
return log.E("ClassifyCorpus", "write output", err)
} }
line := mr.Value.([]byte)
core.Print(output, "%s", line)
} }
batch = batch[:0] batch = batch[:0]
return nil return nil
@ -137,7 +133,7 @@ func ClassifyCorpus(ctx context.Context, model inference.TextModel,
for scanner.Scan() { for scanner.Scan() {
var record map[string]any var record map[string]any
if r := core.JSONUnmarshal(scanner.Bytes(), &record); !r.OK { if err := json.Unmarshal(scanner.Bytes(), &record); err != nil {
stats.Skipped++ stats.Skipped++
continue continue
} }

View file

@ -3,10 +3,11 @@ package i18n
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"iter" "iter"
"strings"
"testing" "testing"
"dappco.re/go/core"
"forge.lthn.ai/core/go-inference" "forge.lthn.ai/core/go-inference"
) )
@ -67,11 +68,11 @@ func (m *mockModel) BatchGenerate(_ context.Context, _ []string, _ ...inference.
return nil, nil return nil, nil
} }
func (m *mockModel) ModelType() string { return "mock" } func (m *mockModel) ModelType() string { return "mock" }
func (m *mockModel) Info() inference.ModelInfo { return inference.ModelInfo{} } func (m *mockModel) Info() inference.ModelInfo { return inference.ModelInfo{} }
func (m *mockModel) Metrics() inference.GenerateMetrics { return inference.GenerateMetrics{} } func (m *mockModel) Metrics() inference.GenerateMetrics { return inference.GenerateMetrics{} }
func (m *mockModel) Err() error { return nil } func (m *mockModel) Err() error { return nil }
func (m *mockModel) Close() error { return nil } func (m *mockModel) Close() error { return nil }
func TestClassifyCorpus_Basic(t *testing.T) { func TestClassifyCorpus_Basic(t *testing.T) {
model := &mockModel{ model := &mockModel{
@ -84,7 +85,7 @@ func TestClassifyCorpus_Basic(t *testing.T) {
}, },
} }
input := core.NewReader( input := strings.NewReader(
`{"seed_id":"1","domain":"general","prompt":"Delete the file"}` + "\n" + `{"seed_id":"1","domain":"general","prompt":"Delete the file"}` + "\n" +
`{"seed_id":"2","domain":"science","prompt":"Explain gravity"}` + "\n", `{"seed_id":"2","domain":"science","prompt":"Explain gravity"}` + "\n",
) )
@ -101,15 +102,15 @@ func TestClassifyCorpus_Basic(t *testing.T) {
t.Errorf("Skipped = %d, want 0", stats.Skipped) t.Errorf("Skipped = %d, want 0", stats.Skipped)
} }
lines := core.Split(core.Trim(output.String()), "\n") lines := strings.Split(strings.TrimSpace(output.String()), "\n")
if len(lines) != 2 { if len(lines) != 2 {
t.Fatalf("output lines = %d, want 2", len(lines)) t.Fatalf("output lines = %d, want 2", len(lines))
} }
for i, line := range lines { for i, line := range lines {
var record map[string]any var record map[string]any
if r := core.JSONUnmarshal([]byte(line), &record); !r.OK { if err := json.Unmarshal([]byte(line), &record); err != nil {
t.Fatalf("line %d: unmarshal: %v", i, r.Value) t.Fatalf("line %d: unmarshal: %v", i, err)
} }
if record["domain_1b"] != "technical" { if record["domain_1b"] != "technical" {
t.Errorf("line %d: domain_1b = %v, want %q", i, record["domain_1b"], "technical") t.Errorf("line %d: domain_1b = %v, want %q", i, record["domain_1b"], "technical")
@ -132,7 +133,7 @@ func TestClassifyCorpus_SkipsMalformed(t *testing.T) {
}, },
} }
input := core.NewReader( input := strings.NewReader(
"not valid json\n" + "not valid json\n" +
`{"seed_id":"1","domain":"general","prompt":"Hello world"}` + "\n" + `{"seed_id":"1","domain":"general","prompt":"Hello world"}` + "\n" +
`{"seed_id":"2","domain":"general"}` + "\n", `{"seed_id":"2","domain":"general"}` + "\n",
@ -156,7 +157,7 @@ func TestClassifyCorpus_DomainMapping(t *testing.T) {
classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) { classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) {
results := make([]inference.ClassifyResult, len(prompts)) results := make([]inference.ClassifyResult, len(prompts))
for i, p := range prompts { for i, p := range prompts {
if core.Contains(p, "Delete") { if strings.Contains(p, "Delete") {
results[i] = inference.ClassifyResult{Token: inference.Token{Text: "technical"}} results[i] = inference.ClassifyResult{Token: inference.Token{Text: "technical"}}
} else { } else {
results[i] = inference.ClassifyResult{Token: inference.Token{Text: "ethical"}} results[i] = inference.ClassifyResult{Token: inference.Token{Text: "ethical"}}
@ -166,7 +167,7 @@ func TestClassifyCorpus_DomainMapping(t *testing.T) {
}, },
} }
input := core.NewReader( input := strings.NewReader(
`{"prompt":"Delete the file now"}` + "\n" + `{"prompt":"Delete the file now"}` + "\n" +
`{"prompt":"Is it right to lie?"}` + "\n", `{"prompt":"Is it right to lie?"}` + "\n",
) )
@ -183,31 +184,3 @@ func TestClassifyCorpus_DomainMapping(t *testing.T) {
t.Errorf("ByDomain[ethical] = %d, want 1", stats.ByDomain["ethical"]) 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,6 @@
package i18n package i18n
import ( import "fmt"
"fmt"
"dappco.re/go/core"
)
// S creates a new Subject with the given noun and value. // S creates a new Subject with the given noun and value.
// //
@ -14,23 +10,6 @@ func S(noun string, value any) *Subject {
return &Subject{Noun: noun, Value: value, count: 1} 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 { func (s *Subject) Count(n int) *Subject {
if s == nil { if s == nil {
return nil return nil
@ -86,40 +65,15 @@ func (s *Subject) String() string {
if stringer, ok := s.Value.(fmt.Stringer); ok { if stringer, ok := s.Value.(fmt.Stringer); ok {
return stringer.String() return stringer.String()
} }
return core.Sprintf("%v", s.Value) return fmt.Sprint(s.Value)
} }
func (s *Subject) IsPlural() bool { return s != nil && s.count != 1 } func (s *Subject) IsPlural() bool { return s != nil && s.count != 1 }
func (s *Subject) CountInt() int { func (s *Subject) CountInt() int { if s == nil { return 1 }; return s.count }
if s == nil { func (s *Subject) CountString() string { if s == nil { return "1" }; return fmt.Sprint(s.count) }
return 1 func (s *Subject) GenderString() string { if s == nil { return "" }; return s.gender }
} func (s *Subject) LocationString() string { if s == nil { return "" }; return s.location }
return s.count func (s *Subject) NounString() string { if s == nil { return "" }; return s.Noun }
}
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) FormalityString() string { func (s *Subject) FormalityString() string {
if s == nil { if s == nil {
return FormalityNeutral.String() return FormalityNeutral.String()

View file

@ -1,9 +1,9 @@
package i18n package i18n
import ( import (
"fmt"
"testing" "testing"
"dappco.re/go/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -41,21 +41,6 @@ func TestSubject_Count_Good(t *testing.T) {
assert.True(t, subj.IsPlural()) 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) { func TestSubject_Count_Bad_NilReceiver(t *testing.T) {
var s *Subject var s *Subject
result := s.Count(5) result := s.Count(5)
@ -138,7 +123,7 @@ func TestSubject_String_Good(t *testing.T) {
func TestSubject_String_Good_Stringer(t *testing.T) { func TestSubject_String_Good_Stringer(t *testing.T) {
// Use a type that implements fmt.Stringer // Use a type that implements fmt.Stringer
subj := S("error", core.NewError("something broke")) subj := S("error", fmt.Errorf("something broke"))
assert.Equal(t, "something broke", subj.String()) assert.Equal(t, "something broke", subj.String())
} }

View file

@ -7,72 +7,41 @@ package i18n
type TranslationContext struct { type TranslationContext struct {
Context string Context string
Gender string Gender string
Location string
Formality Formality Formality Formality
count int
countSet bool
Extra map[string]any Extra map[string]any
} }
// C creates a TranslationContext. // C creates a TranslationContext.
func C(context string) *TranslationContext { func C(context string) *TranslationContext {
return &TranslationContext{Context: context, count: 1} return &TranslationContext{Context: context}
} }
func (c *TranslationContext) WithGender(gender string) *TranslationContext { func (c *TranslationContext) WithGender(gender string) *TranslationContext {
if c == nil { if c == nil { return nil }
return nil
}
c.Gender = gender c.Gender = gender
return c return c
} }
func (c *TranslationContext) In(location string) *TranslationContext {
if c == nil {
return nil
}
c.Location = location
return c
}
func (c *TranslationContext) Formal() *TranslationContext { func (c *TranslationContext) Formal() *TranslationContext {
if c == nil { if c == nil { return nil }
return nil
}
c.Formality = FormalityFormal c.Formality = FormalityFormal
return c return c
} }
func (c *TranslationContext) Informal() *TranslationContext { func (c *TranslationContext) Informal() *TranslationContext {
if c == nil { if c == nil { return nil }
return nil
}
c.Formality = FormalityInformal c.Formality = FormalityInformal
return c return c
} }
func (c *TranslationContext) WithFormality(f Formality) *TranslationContext { func (c *TranslationContext) WithFormality(f Formality) *TranslationContext {
if c == nil { if c == nil { return nil }
return nil
}
c.Formality = f c.Formality = f
return c 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 { func (c *TranslationContext) Set(key string, value any) *TranslationContext {
if c == nil { if c == nil { return nil }
return nil
}
if c.Extra == nil { if c.Extra == nil {
c.Extra = make(map[string]any) 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 { func (c *TranslationContext) Get(key string) any {
if c == nil || c.Extra == nil { if c == nil || c.Extra == nil { return nil }
return nil
}
return c.Extra[key] return c.Extra[key]
} }
func (c *TranslationContext) ContextString() string { func (c *TranslationContext) ContextString() string {
if c == nil { if c == nil { return "" }
return ""
}
return c.Context
}
func (c *TranslationContext) String() string {
if c == nil {
return ""
}
return c.Context return c.Context
} }
func (c *TranslationContext) GenderString() string { func (c *TranslationContext) GenderString() string {
if c == nil { if c == nil { return "" }
return ""
}
return c.Gender return c.Gender
} }
func (c *TranslationContext) LocationString() string {
if c == nil {
return ""
}
return c.Location
}
func (c *TranslationContext) FormalityValue() Formality { func (c *TranslationContext) FormalityValue() Formality {
if c == nil { if c == nil { return FormalityNeutral }
return FormalityNeutral
}
return c.Formality 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) require.NotNil(t, ctx)
assert.Equal(t, "navigation", ctx.Context) assert.Equal(t, "navigation", ctx.Context)
assert.Equal(t, "navigation", ctx.ContextString()) 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) { 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) { func TestTranslationContext_NilReceiver_Good(t *testing.T) {
var ctx *TranslationContext var ctx *TranslationContext
assert.Nil(t, ctx.Count(2))
assert.Nil(t, ctx.WithGender("masculine")) assert.Nil(t, ctx.WithGender("masculine"))
assert.Nil(t, ctx.In("workspace"))
assert.Nil(t, ctx.Formal()) assert.Nil(t, ctx.Formal())
assert.Nil(t, ctx.Informal()) assert.Nil(t, ctx.Informal())
assert.Nil(t, ctx.WithFormality(FormalityFormal)) assert.Nil(t, ctx.WithFormality(FormalityFormal))
@ -41,11 +35,7 @@ func TestTranslationContext_NilReceiver_Good(t *testing.T) {
assert.Nil(t, ctx.Get("key")) assert.Nil(t, ctx.Get("key"))
assert.Equal(t, "", ctx.ContextString()) assert.Equal(t, "", ctx.ContextString())
assert.Equal(t, "", ctx.GenderString()) assert.Equal(t, "", ctx.GenderString())
assert.Equal(t, "", ctx.LocationString())
assert.Equal(t, FormalityNeutral, ctx.FormalityValue()) assert.Equal(t, FormalityNeutral, ctx.FormalityValue())
assert.Equal(t, 1, ctx.CountInt())
assert.Equal(t, "1", ctx.CountString())
assert.False(t, ctx.IsPlural())
} }
// --- WithGender --- // --- WithGender ---
@ -56,17 +46,6 @@ func TestTranslationContext_WithGender_Good(t *testing.T) {
assert.Equal(t, "feminine", ctx.GenderString()) 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 --- // --- Formal / Informal ---
func TestTranslationContext_Formal_Good(t *testing.T) { 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 --- // --- Set / Get ---
func TestTranslationContext_SetGet_Good(t *testing.T) { func TestTranslationContext_SetGet_Good(t *testing.T) {
@ -141,18 +105,12 @@ func TestTranslationContext_Get_Bad_NilExtra(t *testing.T) {
func TestTranslationContext_FullChain_Good(t *testing.T) { func TestTranslationContext_FullChain_Good(t *testing.T) {
ctx := C("medical"). ctx := C("medical").
Count(3).
WithGender("feminine"). WithGender("feminine").
In("clinic").
Formal(). Formal().
Set("speciality", "cardiology") Set("speciality", "cardiology")
assert.Equal(t, "medical", ctx.ContextString()) 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, "feminine", ctx.GenderString())
assert.Equal(t, "clinic", ctx.LocationString())
assert.Equal(t, FormalityFormal, ctx.FormalityValue()) assert.Equal(t, FormalityFormal, ctx.FormalityValue())
assert.Equal(t, "cardiology", ctx.Get("speciality")) assert.Equal(t, "cardiology", ctx.Get("speciality"))
} }

View file

@ -18,33 +18,14 @@ type CoreService struct {
missingKeys []MissingKey missingKeys []MissingKey
missingKeysMu sync.Mutex 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. // ServiceOptions configures the i18n Core service.
type ServiceOptions struct { type ServiceOptions struct {
// Language overrides auto-detection (e.g., "en-GB", "de") // Language overrides auto-detection (e.g., "en-GB", "de")
Language string 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 sets the translation mode (Normal, Strict, Collect)
Mode Mode Mode Mode
// Debug prefixes translated output with the message key.
Debug bool
// ExtraFS loads additional translation files on top of the embedded defaults. // ExtraFS loads additional translation files on top of the embedded defaults.
// Each entry is an fs.FS + directory path within it. // Each entry is an fs.FS + directory path within it.
ExtraFS []FSSource ExtraFS []FSSource
@ -56,36 +37,6 @@ type FSSource struct {
Dir string 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. // NewCoreService creates an i18n Core service factory.
// Automatically loads locale filesystems from: // Automatically loads locale filesystems from:
// 1. Embedded go-i18n base translations (grammar, verbs, nouns) // 1. Embedded go-i18n base translations (grammar, verbs, nouns)
@ -105,28 +56,11 @@ 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 opts.Language != "" {
if langErr := svc.SetLanguage(opts.Language); langErr != nil { _ = svc.SetLanguage(opts.Language)
return nil, langErr
}
}
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.SetMode(opts.Mode)
svc.SetDebug(opts.Debug)
SetDefault(svc) SetDefault(svc)
return &CoreService{ return &CoreService{
@ -138,54 +72,30 @@ func NewCoreService(opts ServiceOptions) func(*core.Core) (any, error) {
} }
// OnStartup initialises the i18n service. // OnStartup initialises the i18n service.
func (s *CoreService) OnStartup(_ context.Context) core.Result { func (s *CoreService) OnStartup(_ context.Context) error {
if svc := s.wrapped(); svc != nil && svc.Mode() == ModeCollect { if s.svc.Mode() == ModeCollect {
s.ensureMissingKeyCollector() OnMissingKey(s.handleMissingKey)
} }
return core.Result{OK: true} return nil
}
// 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
} }
func (s *CoreService) handleMissingKey(mk MissingKey) { func (s *CoreService) handleMissingKey(mk MissingKey) {
if s == nil {
return
}
s.missingKeysMu.Lock() s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock() 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. // MissingKeys returns all missing keys collected in collect mode.
func (s *CoreService) MissingKeys() []MissingKey { func (s *CoreService) MissingKeys() []MissingKey {
if s == nil {
return []MissingKey{}
}
s.missingKeysMu.Lock() s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock() defer s.missingKeysMu.Unlock()
result := make([]MissingKey, len(s.missingKeys)) result := make([]MissingKey, len(s.missingKeys))
for i, mk := range s.missingKeys { copy(result, s.missingKeys)
result[i] = cloneMissingKey(mk)
}
return result return result
} }
// ClearMissingKeys resets the collected missing keys. // ClearMissingKeys resets the collected missing keys.
func (s *CoreService) ClearMissingKeys() { func (s *CoreService) ClearMissingKeys() {
if s == nil {
return
}
s.missingKeysMu.Lock() s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock() defer s.missingKeysMu.Unlock()
s.missingKeys = s.missingKeys[:0] s.missingKeys = s.missingKeys[:0]
@ -193,335 +103,15 @@ func (s *CoreService) ClearMissingKeys() {
// SetMode changes the translation mode. // SetMode changes the translation mode.
func (s *CoreService) SetMode(mode Mode) { func (s *CoreService) SetMode(mode Mode) {
if svc := s.wrapped(); svc != nil { s.svc.SetMode(mode)
svc.SetMode(mode) if mode == ModeCollect {
} OnMissingKey(s.handleMissingKey)
if s != nil && s.svc != nil && mode == ModeCollect { } else {
s.ensureMissingKeyCollector() OnMissingKey(nil)
} }
} }
// Mode returns the current translation mode. // Mode returns the current translation mode.
func (s *CoreService) Mode() Mode { func (s *CoreService) Mode() Mode {
if svc := s.wrapped(); svc != nil { return s.svc.Mode()
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)
} }

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 package i18n
import "dappco.re/go/core"
// SetDebug enables or disables debug mode on the default service. // SetDebug enables or disables debug mode on the default service.
func SetDebug(enabled bool) { 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) { func (s *Service) SetDebug(enabled bool) {
if s == nil {
return
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.debug = enabled s.debug = enabled
} }
func (s *Service) Debug() bool { func (s *Service) Debug() bool {
if s == nil {
return false
}
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
return s.debug return s.debug
} }
func debugFormat(key, text string) string { 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()) 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) { func TestDebugFormat_Good(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -83,20 +69,3 @@ func TestDebugMode_Good_Integration(t *testing.T) {
got = svc.Raw("prompt.yes") got = svc.Raw("prompt.yes")
assert.Equal(t, "[prompt.yes] y", got) assert.Equal(t, "[prompt.yes] y", got)
} }
func TestTranslate_DebugMode_PreservesOK(t *testing.T) {
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)
}

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 // Option 2: Explicit creation with options
svc, err := i18n.New( svc, err := i18n.New(
i18n.WithLanguage("en-GB"),
i18n.WithFallback("en"), i18n.WithFallback("en"),
i18n.WithDefaultHandlers(), 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. 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 ### Options
| Option | Effect | | Option | Effect |
|--------|--------| |--------|--------|
| `WithFallback("en")` | Set fallback language for missing translations | | `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 | | `WithDefaultHandlers()` | Register the six built-in `i18n.*` namespace handlers |
| `WithHandlers(h...)` | Replace handlers entirely | | `WithHandlers(h...)` | Replace handlers entirely |
| `WithMode(ModeStrict)` | Panic on missing keys (useful in CI) | | `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. Produces past-tense completion messages.
```go ```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.push", "commits") // "Commits pushed"
T("i18n.done.delete") // "Deleted" T("i18n.done.delete") // "Deleted"
``` ```
@ -237,7 +216,7 @@ Locale-aware number formatting.
T("i18n.numeric.number", 1234567) // "1,234,567" T("i18n.numeric.number", 1234567) // "1,234,567"
T("i18n.numeric.decimal", 3.14) // "3.14" T("i18n.numeric.decimal", 3.14) // "3.14"
T("i18n.numeric.percent", 0.85) // "85%" 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.ordinal", 3) // "3rd"
T("i18n.numeric.ago", 5, "minutes") // "5 minutes ago" T("i18n.numeric.ago", 5, "minutes") // "5 minutes ago"
``` ```
@ -247,7 +226,7 @@ The shorthand `N()` function wraps this namespace:
```go ```go
i18n.N("number", 1234567) // "1,234,567" i18n.N("number", 1234567) // "1,234,567"
i18n.N("percent", 0.85) // "85%" 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" 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()) 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 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** **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** **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** **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 ## Custom Handlers
Handlers process keys before standard lookup. Use for dynamic patterns. 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) i18n.Pluralize("child", 2) // "children" (irregular)
// Articles // Articles
i18n.Article("apple") // "an" i18n.Article("apple") // "an apple"
i18n.ArticlePhrase("apple") // "an apple" i18n.Article("banana") // "a banana"
i18n.ArticlePhrase("banana") // "a banana"
// Composed messages // Composed messages
i18n.Label("status") // "Status:" i18n.Label("status") // "Status:"
@ -391,7 +390,6 @@ Use functional options when creating a service:
```go ```go
svc, err := i18n.New( svc, err := i18n.New(
i18n.WithLanguage("de-DE"), // Initial language
i18n.WithFallback("de-DE"), // Fallback language i18n.WithFallback("de-DE"), // Fallback language
i18n.WithFormality(i18n.FormalityFormal), // Default formality i18n.WithFormality(i18n.FormalityFormal), // Default formality
i18n.WithMode(i18n.ModeStrict), // Missing key mode i18n.WithMode(i18n.ModeStrict), // Missing key mode

9
go.mod
View file

@ -5,12 +5,15 @@ go 1.26.0
require golang.org/x/text v0.35.0 require golang.org/x/text v0.35.0
require ( require (
dappco.re/go/core v0.8.0-alpha.1 dappco.re/go/core v0.5.0
dappco.re/go/core/log v0.1.0 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 require (
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/kr/text v0.2.0 // indirect
)
require ( require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect

4
go.sum
View file

@ -1,5 +1,5 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0= forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,6 @@
package i18n package i18n
import ( import "testing"
"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
}
func TestPastTense(t *testing.T) { func TestPastTense(t *testing.T) {
// Ensure grammar data is loaded from embedded JSON // Ensure grammar data is loaded from embedded JSON
@ -104,7 +89,6 @@ func TestPastTense(t *testing.T) {
{"push", "pushed"}, {"push", "pushed"},
{"pull", "pulled"}, {"pull", "pulled"},
{"start", "started"}, {"start", "started"},
{"panic", "panicked"},
{"copy", "copied"}, {"copy", "copied"},
{"apply", "applied"}, {"apply", "applied"},
@ -171,7 +155,6 @@ func TestGerund(t *testing.T) {
{"push", "pushing"}, {"push", "pushing"},
{"pull", "pulling"}, {"pull", "pulling"},
{"start", "starting"}, {"start", "starting"},
{"panic", "panicking"},
{"die", "dying"}, {"die", "dying"},
// Edge cases // 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) { func TestPluralForm(t *testing.T) {
svc, err := New() svc, err := New()
if err != nil { if err != nil {
@ -339,8 +266,6 @@ func TestArticle(t *testing.T) {
{"honest", "an"}, // Vowel sound {"honest", "an"}, // Vowel sound
{"university", "a"}, // Consonant sound {"university", "a"}, // Consonant sound
{"one", "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) { func TestTitle(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
@ -505,7 +289,6 @@ func TestTitle(t *testing.T) {
{"", ""}, {"", ""},
{"HELLO", "HELLO"}, {"HELLO", "HELLO"},
{"hello-world", "Hello-World"}, {"hello-world", "Hello-World"},
{"config.yaml", "Config.yaml"},
} }
for _, tt := range tests { for _, tt := range tests {
@ -522,191 +305,6 @@ func TestQuote(t *testing.T) {
if got := Quote("hello"); got != `"hello"` { if got := Quote("hello"); got != `"hello"` {
t.Errorf("Quote(%q) = %q, want %q", "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) { 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) { func TestProgress(t *testing.T) {
svc, err := New() svc, err := New()
if err != nil { if err != nil {
@ -806,10 +383,10 @@ func TestActionResult(t *testing.T) {
verb, subject string verb, subject string
want string want string
}{ }{
{"delete", "config.yaml", "Config.yaml deleted"}, {"delete", "config.yaml", "Config.Yaml deleted"},
{"build", "project", "Project built"}, {"build", "project", "Project built"},
{"", "file", ""}, {"", "file", ""},
{"delete", "", "Deleted"}, {"delete", "", ""},
} }
for _, tt := range tests { for _, tt := range tests {
@ -828,7 +405,6 @@ func TestActionFailed(t *testing.T) {
want string want string
}{ }{
{"delete", "config.yaml", "Failed to delete config.yaml"}, {"delete", "config.yaml", "Failed to delete config.yaml"},
{"Delete", "config.yaml", "Failed to delete config.yaml"},
{"push", "commits", "Failed to push commits"}, {"push", "commits", "Failed to push commits"},
{"push", "", "Failed to push"}, {"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) { func TestGrammarData_Signals(t *testing.T) {
svc, err := New() svc, err := New()
if err != nil { 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) { func TestTemplateFuncs(t *testing.T) {
funcs := TemplateFuncs() funcs := TemplateFuncs()
expected := []string{ expected := []string{"title", "lower", "upper", "past", "gerund", "plural", "pluralForm", "article", "quote"}
"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",
}
for _, name := range expected { for _, name := range expected {
if _, ok := funcs[name]; !ok { if _, ok := funcs[name]; !ok {
t.Errorf("TemplateFuncs() missing %q", name) t.Errorf("TemplateFuncs() missing %q", name)
@ -1153,218 +592,6 @@ 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)
}
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)
}
}
func TestTemplateFuncs_CompositeHelpers(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
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)
}
}
func TestTemplateFuncs_PromptAndLang(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
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)
}
}
func TestTemplateFuncs_NumericAlias(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
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")
}
}
func TestTemplateFuncs_NumericDirectAliases(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
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")
}
}
func TestTemplateFuncs_TimeHelpers(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
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)
}
}
func TestCompositeHelpersRespectWordMap(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")
})
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)
}
}
// --- Benchmarks --- // --- Benchmarks ---
func BenchmarkPastTense_Irregular(b *testing.B) { func BenchmarkPastTense_Irregular(b *testing.B) {

View file

@ -2,142 +2,100 @@ package i18n
import ( import (
"fmt" "fmt"
"unicode" "strings"
"dappco.re/go/core"
) )
// LabelHandler handles i18n.label.{word} -> "Status:" patterns. // LabelHandler handles i18n.label.{word} -> "Status:" patterns.
type LabelHandler struct{} type LabelHandler struct{}
func (h LabelHandler) Match(key string) bool { func (h LabelHandler) Match(key string) bool {
return core.HasPrefix(key, "i18n.label.") return strings.HasPrefix(key, "i18n.label.")
} }
func (h LabelHandler) Handle(key string, args []any, next func() string) string { func (h LabelHandler) Handle(key string, args []any, next func() string) string {
word := core.TrimPrefix(key, "i18n.label.") word := strings.TrimPrefix(key, "i18n.label.")
if got := Label(word); got != "" { return Label(word)
return got
}
if next != nil {
return next()
}
return ""
} }
// ProgressHandler handles i18n.progress.{verb} -> "Building..." patterns. // ProgressHandler handles i18n.progress.{verb} -> "Building..." patterns.
type ProgressHandler struct{} type ProgressHandler struct{}
func (h ProgressHandler) Match(key string) bool { func (h ProgressHandler) Match(key string) bool {
return core.HasPrefix(key, "i18n.progress.") return strings.HasPrefix(key, "i18n.progress.")
} }
func (h ProgressHandler) Handle(key string, args []any, next func() string) string { func (h ProgressHandler) Handle(key string, args []any, next func() string) string {
verb := core.TrimPrefix(key, "i18n.progress.") verb := strings.TrimPrefix(key, "i18n.progress.")
if len(args) > 0 { if len(args) > 0 {
if subj := subjectArgText(args[0]); subj != "" { if subj, ok := args[0].(string); ok {
if got := ProgressSubject(verb, subj); got != "" { return ProgressSubject(verb, subj)
return got
}
} }
} }
if got := Progress(verb); got != "" { return Progress(verb)
return got
}
if next != nil {
return next()
}
return ""
} }
// CountHandler handles i18n.count.{noun} -> "5 files" patterns. // CountHandler handles i18n.count.{noun} -> "5 files" patterns.
type CountHandler struct{} type CountHandler struct{}
func (h CountHandler) Match(key string) bool { func (h CountHandler) Match(key string) bool {
return core.HasPrefix(key, "i18n.count.") return strings.HasPrefix(key, "i18n.count.")
} }
func (h CountHandler) Handle(key string, args []any, next func() string) string { func (h CountHandler) Handle(key string, args []any, next func() string) string {
noun := core.TrimPrefix(key, "i18n.count.") noun := strings.TrimPrefix(key, "i18n.count.")
lang := currentLangForGrammar()
if core.Trim(noun) == "" {
if next != nil {
return next()
}
return ""
}
if len(args) > 0 { if len(args) > 0 {
count := getCount(args[0]) count := toInt(args[0])
return core.Sprintf("%s %s", FormatNumber(int64(count)), countWordForm(lang, noun, count)) return fmt.Sprintf("%d %s", count, Pluralize(noun, count))
} }
return countWordForm(lang, noun, 1) return noun
} }
// DoneHandler handles i18n.done.{verb} -> "File deleted" patterns. // DoneHandler handles i18n.done.{verb} -> "File deleted" patterns.
type DoneHandler struct{} type DoneHandler struct{}
func (h DoneHandler) Match(key string) bool { func (h DoneHandler) Match(key string) bool {
return core.HasPrefix(key, "i18n.done.") return strings.HasPrefix(key, "i18n.done.")
} }
func (h DoneHandler) Handle(key string, args []any, next func() string) string { func (h DoneHandler) Handle(key string, args []any, next func() string) string {
verb := core.TrimPrefix(key, "i18n.done.") verb := strings.TrimPrefix(key, "i18n.done.")
if len(args) > 0 { if len(args) > 0 {
if subj := subjectArgText(args[0]); subj != "" { if subj, ok := args[0].(string); ok {
if got := ActionResult(verb, subj); got != "" { return ActionResult(verb, subj)
return got
}
} }
} }
if got := Title(PastTense(verb)); got != "" { return Title(PastTense(verb))
return got
}
if next != nil {
return next()
}
return ""
} }
// FailHandler handles i18n.fail.{verb} -> "Failed to delete file" patterns. // FailHandler handles i18n.fail.{verb} -> "Failed to delete file" patterns.
type FailHandler struct{} type FailHandler struct{}
func (h FailHandler) Match(key string) bool { func (h FailHandler) Match(key string) bool {
return core.HasPrefix(key, "i18n.fail.") return strings.HasPrefix(key, "i18n.fail.")
} }
func (h FailHandler) Handle(key string, args []any, next func() string) string { func (h FailHandler) Handle(key string, args []any, next func() string) string {
verb := core.TrimPrefix(key, "i18n.fail.") verb := strings.TrimPrefix(key, "i18n.fail.")
if len(args) > 0 { if len(args) > 0 {
if subj := subjectArgText(args[0]); subj != "" { if subj, ok := args[0].(string); ok {
if got := ActionFailed(verb, subj); got != "" { return ActionFailed(verb, subj)
return got
}
} }
} }
if got := ActionFailed(verb, ""); got != "" { return ActionFailed(verb, "")
return got
}
if next != nil {
return next()
}
return ""
} }
// NumericHandler handles i18n.numeric.{format} -> formatted numbers. // NumericHandler handles i18n.numeric.{format} -> formatted numbers.
type NumericHandler struct{} type NumericHandler struct{}
func (h NumericHandler) Match(key string) bool { func (h NumericHandler) Match(key string) bool {
return core.HasPrefix(key, "i18n.numeric.") return strings.HasPrefix(key, "i18n.numeric.")
} }
func (h NumericHandler) Handle(key string, args []any, next func() string) string { func (h NumericHandler) Handle(key string, args []any, next func() string) string {
if len(args) == 0 { if len(args) == 0 {
if next != nil { return next()
return next()
}
return ""
} }
format := core.TrimPrefix(key, "i18n.numeric.") format := strings.TrimPrefix(key, "i18n.numeric.")
switch format { switch format {
case "number", "int": case "number", "int":
return FormatNumber(toInt64(args[0])) return FormatNumber(toInt64(args[0]))
@ -156,15 +114,10 @@ func (h NumericHandler) Handle(key string, args []any, next func() string) strin
} }
} }
} }
if next != nil { return next()
return next()
}
return ""
} }
// DefaultHandlers returns the built-in i18n.* namespace handlers. // DefaultHandlers returns the built-in i18n.* namespace handlers.
//
// handlers := i18n.DefaultHandlers()
func DefaultHandlers() []KeyHandler { func DefaultHandlers() []KeyHandler {
return []KeyHandler{ return []KeyHandler{
LabelHandler{}, LabelHandler{},
@ -176,135 +129,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. // RunHandlerChain executes a chain of handlers for a key.
func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback func() string) string { func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback func() string) string {
for i, h := range handlers { for i, h := range handlers {
if h == nil {
continue
}
if h.Match(key) { if h.Match(key) {
next := func() string { next := func() string {
remaining := handlers[i+1:] remaining := handlers[i+1:]
@ -319,22 +146,6 @@ func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback fun
return fallback() 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 ( var (
_ KeyHandler = LabelHandler{} _ KeyHandler = LabelHandler{}
_ KeyHandler = ProgressHandler{} _ KeyHandler = ProgressHandler{}

View file

@ -22,11 +22,6 @@ func TestLabelHandler(t *testing.T) {
if got != "Status:" { if got != "Status:" {
t.Errorf("LabelHandler.Handle(status) = %q, want %q", 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) { func TestProgressHandler(t *testing.T) {
@ -53,31 +48,6 @@ func TestProgressHandler(t *testing.T) {
if got != "Building project..." { if got != "Building project..." {
t.Errorf("ProgressHandler.Handle(build, project) = %q, want %q", 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) { 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{5}, "5 files"},
{"i18n.count.file", []any{0}, "0 files"}, {"i18n.count.file", []any{0}, "0 files"},
{"i18n.count.child", []any{3}, "3 children"}, {"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.file", nil, "file"},
{"i18n.count.url", nil, "URL"},
} }
for _, tt := range tests { 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) { func TestDoneHandler(t *testing.T) {
@ -159,28 +100,8 @@ func TestDoneHandler(t *testing.T) {
// With subject // With subject
got := h.Handle("i18n.done.delete", []any{"config.yaml"}, nil) got := h.Handle("i18n.done.delete", []any{"config.yaml"}, nil)
if got != "Config.yaml deleted" { if got != "Config.Yaml deleted" {
t.Errorf("DoneHandler.Handle(delete, config.yaml) = %q, want %q", 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")
} }
// Without subject — just past tense // Without subject — just past tense
@ -188,11 +109,6 @@ func TestDoneHandler(t *testing.T) {
if got != "Deleted" { if got != "Deleted" {
t.Errorf("DoneHandler.Handle(delete) = %q, want %q", 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) { 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") 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) got = h.Handle("i18n.fail.push", nil, nil)
if got != "Failed to push" { if got != "Failed to push" {
t.Errorf("FailHandler.Handle(push) = %q, want %q", 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) { func TestNumericHandler(t *testing.T) {
@ -258,9 +149,7 @@ func TestNumericHandler(t *testing.T) {
{"i18n.numeric.ordinal", []any{3}, "3rd"}, {"i18n.numeric.ordinal", []any{3}, "3rd"},
{"i18n.numeric.ordinal", []any{11}, "11th"}, {"i18n.numeric.ordinal", []any{11}, "11th"},
{"i18n.numeric.percent", []any{0.85}, "85%"}, {"i18n.numeric.percent", []any{0.85}, "85%"},
{"i18n.numeric.bytes", []any{int64(1536000)}, "1.46 MB"}, {"i18n.numeric.bytes", []any{int64(1536000)}, "1.5 MB"},
{"i18n.numeric.number", []any{"1234567"}, "1,234,567"},
{"i18n.numeric.ago", []any{5, "minutes"}, "5 minutes ago"},
} }
for _, tt := range tests { for _, tt := range tests {
@ -277,113 +166,6 @@ func TestNumericHandler(t *testing.T) {
if got != "fallback" { if got != "fallback" {
t.Errorf("NumericHandler with no args should fallback, got %q", got) 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) { func TestRunHandlerChain(t *testing.T) {

230
hooks.go
View file

@ -2,46 +2,23 @@ package i18n
import ( import (
"io/fs" "io/fs"
"log"
"runtime" "runtime"
"sync" "sync"
"sync/atomic" "sync/atomic"
"dappco.re/go/core"
log "dappco.re/go/core/log"
) )
var missingKeyHandler atomic.Value var missingKeyHandler atomic.Value
var missingKeyHandlerMu sync.Mutex
type missingKeyHandlersState struct {
handlers []MissingKeyHandler
}
type localeRegistration struct { type localeRegistration struct {
fsys fs.FS fsys fs.FS
dir string 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 ( var (
registeredLocales []localeRegistration registeredLocales []localeRegistration
registeredLocaleProviders []localeProviderRegistration registeredLocalesMu sync.Mutex
registeredLocalesMu sync.Mutex localesLoaded bool
localesLoaded bool
nextLocaleRegistrationID int
nextLocaleProviderID int
) )
// RegisterLocales registers a filesystem containing locale files. // RegisterLocales registers a filesystem containing locale files.
@ -54,202 +31,47 @@ var (
// i18n.RegisterLocales(localeFS, "locales") // i18n.RegisterLocales(localeFS, "locales")
// } // }
func RegisterLocales(fsys fs.FS, dir string) { func RegisterLocales(fsys fs.FS, dir string) {
reg := localeRegistration{fsys: fsys, dir: dir}
registeredLocalesMu.Lock() registeredLocalesMu.Lock()
nextLocaleRegistrationID++ defer registeredLocalesMu.Unlock()
reg.id = nextLocaleRegistrationID registeredLocales = append(registeredLocales, localeRegistration{fsys: fsys, dir: dir})
registeredLocales = append(registeredLocales, reg) if localesLoaded {
svc := defaultService.Load() if svc := Default(); svc != nil {
registeredLocalesMu.Unlock() if err := svc.LoadFS(fsys, dir); err != nil {
if svc != nil { log.Printf("i18n: RegisterLocales failed to load %q: %v", dir, err)
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()
} }
} }
} }
// 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) { func loadRegisteredLocales(svc *Service) {
if svc == nil {
return
}
registeredLocalesMu.Lock() registeredLocalesMu.Lock()
locales := append([]localeRegistration(nil), registeredLocales...) defer registeredLocalesMu.Unlock()
providers := append([]localeProviderRegistration(nil), registeredLocaleProviders...) for _, reg := range registeredLocales {
registeredLocalesMu.Unlock()
for _, reg := range locales {
if svc != nil && svc.hasLocaleRegistrationLoaded(reg.id) {
continue
}
if err := svc.LoadFS(reg.fsys, reg.dir); err != nil { if err := svc.LoadFS(reg.fsys, reg.dir); err != nil {
log.Error("i18n: loadRegisteredLocales failed to load", "dir", reg.dir, "err", err) log.Printf("i18n: loadRegisteredLocales failed to load %q: %v", reg.dir, 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)
} }
} }
svc.markLocaleProviderLoaded(provider.id)
markLocalesLoaded()
}
func markLocalesLoaded() {
registeredLocalesMu.Lock()
localesLoaded = true localesLoaded = true
registeredLocalesMu.Unlock()
} }
// OnMissingKey registers a handler for missing translation keys. // OnMissingKey registers a handler for missing translation keys.
func OnMissingKey(h MissingKeyHandler) { func OnMissingKey(h MissingKeyHandler) {
if h == nil { missingKeyHandler.Store(h)
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
} }
func dispatchMissingKey(key string, args map[string]any) { func dispatchMissingKey(key string, args map[string]any) {
state := missingKeyHandlers() v := missingKeyHandler.Load()
if len(state.handlers) == 0 { if v == nil {
return return
} }
file, line := missingKeyCaller() h, ok := v.(MissingKeyHandler)
mk := cloneMissingKey(MissingKey{Key: key, Args: args, CallerFile: file, CallerLine: line}) if !ok || h == nil {
for _, h := range state.handlers { return
if h != nil {
h(mk)
}
} }
} _, file, line, ok := runtime.Caller(2)
if !ok {
func cloneMissingKey(mk MissingKey) MissingKey { file = "unknown"
if len(mk.Args) == 0 { line = 0
mk.Args = nil }
return mk h(MissingKey{Key: key, Args: args, CallerFile: file, CallerLine: line})
}
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
} }

View file

@ -1,8 +1,6 @@
package i18n package i18n
import ( import (
"path/filepath"
"sync"
"testing" "testing"
"testing/fstest" "testing/fstest"
@ -10,28 +8,17 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
type testLocaleProvider struct {
sources []FSSource
}
func (p testLocaleProvider) LocaleSources() []FSSource {
return p.sources
}
func TestRegisterLocales_Good(t *testing.T) { func TestRegisterLocales_Good(t *testing.T) {
// Save and restore registered locales state // Save and restore registered locales state
registeredLocalesMu.Lock() registeredLocalesMu.Lock()
savedLocales := registeredLocales savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded savedLoaded := localesLoaded
registeredLocales = nil registeredLocales = nil
registeredLocaleProviders = nil
localesLoaded = false localesLoaded = false
registeredLocalesMu.Unlock() registeredLocalesMu.Unlock()
defer func() { defer func() {
registeredLocalesMu.Lock() registeredLocalesMu.Lock()
registeredLocales = savedLocales registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded localesLoaded = savedLoaded
registeredLocalesMu.Unlock() registeredLocalesMu.Unlock()
}() }()
@ -50,41 +37,6 @@ func TestRegisterLocales_Good(t *testing.T) {
assert.Equal(t, 1, count, "should have 1 registered locale") 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) { func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
// When localesLoaded is true, RegisterLocales should also call LoadFS immediately // When localesLoaded is true, RegisterLocales should also call LoadFS immediately
svc, err := New() svc, err := New()
@ -95,16 +47,13 @@ func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
// Save and restore state // Save and restore state
registeredLocalesMu.Lock() registeredLocalesMu.Lock()
savedLocales := registeredLocales savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded savedLoaded := localesLoaded
registeredLocales = nil registeredLocales = nil
registeredLocaleProviders = nil
localesLoaded = true // Simulate already loaded localesLoaded = true // Simulate already loaded
registeredLocalesMu.Unlock() registeredLocalesMu.Unlock()
defer func() { defer func() {
registeredLocalesMu.Lock() registeredLocalesMu.Lock()
registeredLocales = savedLocales registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded localesLoaded = savedLoaded
registeredLocalesMu.Unlock() registeredLocalesMu.Unlock()
}() }()
@ -123,363 +72,6 @@ func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
assert.Equal(t, "arrived late", got) 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) { func TestLoadRegisteredLocales_Good(t *testing.T) {
svc, err := New() svc, err := New()
require.NoError(t, err) require.NoError(t, err)
@ -487,7 +79,6 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
// Save and restore state // Save and restore state
registeredLocalesMu.Lock() registeredLocalesMu.Lock()
savedLocales := registeredLocales savedLocales := registeredLocales
savedProviders := registeredLocaleProviders
savedLoaded := localesLoaded savedLoaded := localesLoaded
registeredLocales = []localeRegistration{ registeredLocales = []localeRegistration{
{ {
@ -499,13 +90,11 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
dir: "loc", dir: "loc",
}, },
} }
registeredLocaleProviders = nil
localesLoaded = false localesLoaded = false
registeredLocalesMu.Unlock() registeredLocalesMu.Unlock()
defer func() { defer func() {
registeredLocalesMu.Lock() registeredLocalesMu.Lock()
registeredLocales = savedLocales registeredLocales = savedLocales
registeredLocaleProviders = savedProviders
localesLoaded = savedLoaded localesLoaded = savedLoaded
registeredLocalesMu.Unlock() registeredLocalesMu.Unlock()
}() }()
@ -524,11 +113,6 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
func TestOnMissingKey_Good(t *testing.T) { func TestOnMissingKey_Good(t *testing.T) {
svc, err := New() svc, err := New()
require.NoError(t, err) require.NoError(t, err)
prev := Default()
SetDefault(svc)
t.Cleanup(func() {
SetDefault(prev)
})
svc.SetMode(ModeCollect) svc.SetMode(ModeCollect)
var captured MissingKey var captured MissingKey
@ -536,285 +120,17 @@ func TestOnMissingKey_Good(t *testing.T) {
captured = m 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, "missing.test.key", captured.Key)
assert.Equal(t, "bar", captured.Args["foo"]) assert.Equal(t, "bar", captured.Args["foo"])
assert.Equal(t, "hooks_test.go", filepath.Base(captured.CallerFile)) assert.NotEmpty(t, 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"])
} }
func TestDispatchMissingKey_Good_NoHandler(t *testing.T) { func TestDispatchMissingKey_Good_NoHandler(t *testing.T) {
// Reset to the empty handler set. // Store nil handler (using correct type)
OnMissingKey(nil) missingKeyHandler.Store(MissingKeyHandler(nil))
// Should not panic when dispatching with nil handler // Should not panic when dispatching with nil handler
dispatchMissingKey("test.key", nil) 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")
}
}

432
i18n.go
View file

@ -2,389 +2,87 @@ package i18n
import ( import (
"bytes" "bytes"
"io/fs" "errors"
"strings"
"text/template" "text/template"
"dappco.re/go/core"
log "dappco.re/go/core/log"
) )
// T translates a message using the default service. // T translates a message using the default service.
//
// Example:
//
// i18n.T("greeting")
func T(messageID string, args ...any) string { func T(messageID string, args ...any) string {
return defaultServiceValue(messageID, func(svc *Service) string { if svc := Default(); svc != nil {
return svc.T(messageID, args...) return svc.T(messageID, args...)
}) }
} return messageID
// 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...)
})
} }
// Raw translates without i18n.* namespace magic. // Raw translates without i18n.* namespace magic.
//
// Example:
//
// i18n.Raw("prompt.yes")
func Raw(messageID string, args ...any) string { 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 svc.Raw(messageID, args...)
}) }
return messageID
} }
// ErrServiceNotInitialised is returned when the service is not initialised. // ErrServiceNotInitialised is returned when the service is not initialised.
var ErrServiceNotInitialised = core.NewError("i18n: service not initialised") var ErrServiceNotInitialised = errors.New("i18n: service not initialised")
// ErrServiceNotInitialized is deprecated: use ErrServiceNotInitialised. // ErrServiceNotInitialized is deprecated: use ErrServiceNotInitialised.
var ErrServiceNotInitialized = ErrServiceNotInitialised var ErrServiceNotInitialized = ErrServiceNotInitialised
// SetLanguage sets the language for the default service. // SetLanguage sets the language for the default service.
//
// Example:
//
// _ = i18n.SetLanguage("fr")
func SetLanguage(lang string) error { func SetLanguage(lang string) error {
return defaultServiceValue(ErrServiceNotInitialised, func(svc *Service) error { svc := Default()
return svc.SetLanguage(lang) if svc == nil {
}) return ErrServiceNotInitialised
}
return svc.SetLanguage(lang)
} }
// CurrentLanguage returns the current language code. // CurrentLanguage returns the current language code.
//
// Example:
//
// lang := i18n.CurrentLanguage()
func CurrentLanguage() string { func CurrentLanguage() string {
return Language() if svc := Default(); svc != nil {
}
// 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 {
return svc.Language() return svc.Language()
}) }
} return "en"
// 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()
} }
// SetMode sets the translation mode for the default service. // SetMode sets the translation mode for the default service.
//
// Example:
//
// i18n.SetMode(i18n.ModeCollect)
func SetMode(m Mode) { func SetMode(m Mode) {
withDefaultService(func(svc *Service) { svc.SetMode(m) }) if svc := Default(); svc != nil {
} 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()
})
} }
// CurrentMode returns the current translation mode. // CurrentMode returns the current translation mode.
//
// Example:
//
// mode := i18n.CurrentMode()
func CurrentMode() Mode { 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. // N formats a number using the i18n.numeric.* namespace.
//
// 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("number", 1234567) // "1,234,567" // N("number", 1234567) // "1,234,567"
// N("percent", 0.85) // "85%" // N("percent", 0.85) // "85%"
// N("bytes", 1536000) // "1.46 MB" // N("bytes", 1536000) // "1.5 MB"
// N("ordinal", 1) // "1st" // N("ordinal", 1) // "1st"
// func N(format string, value any) string {
// Multi-argument formats such as "ago" also pass through unchanged: return T("i18n.numeric."+format, value)
// }
// N("ago", 5, "minutes") // "5 minutes ago"
func N(format string, value any, args ...any) string { // AddHandler appends a handler to the default service's handler chain.
format = normalizeLookupKey(format) func AddHandler(h KeyHandler) {
switch format { if svc := Default(); svc != nil {
case "number", "int": svc.AddHandler(h)
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)
}
}
} }
return T("i18n.numeric."+format, append([]any{value}, args...)...)
} }
// Prompt translates a prompt key from the prompt namespace. // PrependHandler inserts a handler at the start of the default service's handler chain.
// func PrependHandler(h KeyHandler) {
// Example: if svc := Default(); svc != nil {
// svc.PrependHandler(h)
// 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
} }
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 { func executeIntentTemplate(tmplStr string, data templateData) string {
@ -411,10 +109,9 @@ func executeIntentTemplate(tmplStr string, data templateData) string {
} }
func applyTemplate(text string, data any) string { func applyTemplate(text string, data any) string {
if !core.Contains(text, "{{") { if !strings.Contains(text, "{{") {
return text return text
} }
data = templateDataForRendering(data)
if cached, ok := templateCache.Load(text); ok { if cached, ok := templateCache.Load(text); ok {
var buf bytes.Buffer var buf bytes.Buffer
if err := cached.(*template.Template).Execute(&buf, data); err != nil { if err := cached.(*template.Template).Execute(&buf, data); err != nil {
@ -422,7 +119,7 @@ func applyTemplate(text string, data any) string {
} }
return buf.String() return buf.String()
} }
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(text) tmpl, err := template.New("").Parse(text)
if err != nil { if err != nil {
return text return text
} }
@ -433,52 +130,3 @@ func applyTemplate(text string, data any) string {
} }
return buf.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 ( import (
"testing" "testing"
"testing/fstest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -40,48 +39,6 @@ func TestT_Good_MissingKey(t *testing.T) {
assert.Equal(t, "nonexistent.key.test", got) 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() --- // --- Package-level Raw() ---
func TestRaw_Good(t *testing.T) { func TestRaw_Good(t *testing.T) {
@ -105,44 +62,6 @@ func TestRaw_Good_BypassesHandlers(t *testing.T) {
assert.Equal(t, "i18n.label.status", got) 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 --- // --- SetLanguage / CurrentLanguage ---
func TestSetLanguage_Good(t *testing.T) { func TestSetLanguage_Good(t *testing.T) {
@ -156,27 +75,6 @@ func TestSetLanguage_Good(t *testing.T) {
assert.Contains(t, CurrentLanguage(), "en") 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) { func TestSetLanguage_Bad_Unsupported(t *testing.T) {
svc, err := New() svc, err := New()
require.NoError(t, err) require.NoError(t, err)
@ -194,95 +92,6 @@ func TestCurrentLanguage_Good(t *testing.T) {
lang := CurrentLanguage() lang := CurrentLanguage()
assert.NotEmpty(t, lang) 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 --- // --- SetMode / CurrentMode ---
@ -321,136 +130,21 @@ func TestN_Good(t *testing.T) {
name string name string
format string format string
value any value any
args []any
want string want string
}{ }{
{"number", "number", int64(1234567), nil, "1,234,567"}, {"number", "number", int64(1234567), "1,234,567"},
{"percent", "percent", 0.85, nil, "85%"}, {"percent", "percent", 0.85, "85%"},
{"bytes", "bytes", int64(1536000), nil, "1.46 MB"}, {"bytes", "bytes", int64(1536000), "1.5 MB"},
{"ordinal", "ordinal", 1, nil, "1st"}, {"ordinal", "ordinal", 1, "1st"},
{"ago", "ago", 5, []any{"minutes"}, "5 minutes ago"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) 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 --- // --- AddHandler / PrependHandler ---
func TestAddHandler_Good(t *testing.T) { func TestAddHandler_Good(t *testing.T) {
@ -465,46 +159,6 @@ func TestAddHandler_Good(t *testing.T) {
assert.Equal(t, initialCount+1, len(svc.Handlers())) 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) { func TestPrependHandler_Good(t *testing.T) {
svc, err := New(WithHandlers()) // start with no handlers svc, err := New(WithHandlers()) // start with no handlers
require.NoError(t, err) require.NoError(t, err)
@ -520,132 +174,6 @@ func TestPrependHandler_Good(t *testing.T) {
assert.Equal(t, 2, len(handlers)) 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 --- // --- executeIntentTemplate ---
func TestExecuteIntentTemplate_Good(t *testing.T) { func TestExecuteIntentTemplate_Good(t *testing.T) {
@ -689,43 +217,6 @@ func TestExecuteIntentTemplate_Good_WithFuncs(t *testing.T) {
assert.Equal(t, "built!", got) 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 --- // --- applyTemplate ---
func TestApplyTemplate_Good(t *testing.T) { func TestApplyTemplate_Good(t *testing.T) {

View file

@ -2,10 +2,10 @@ package integration
import ( import (
"context" "context"
"fmt"
"sort" "sort"
"testing" "testing"
"dappco.re/go/core"
i18n "dappco.re/go/core/i18n" i18n "dappco.re/go/core/i18n"
"forge.lthn.ai/core/go-inference" "forge.lthn.ai/core/go-inference"
_ "forge.lthn.ai/core/go-mlx" // registers Metal backend _ "forge.lthn.ai/core/go-mlx" // registers Metal backend
@ -549,7 +549,7 @@ func TestCalibrateDomains_1Bvs27B(t *testing.T) {
disagreements++ disagreements++
truth := "" truth := ""
if r.TrueDomain != "" { if r.TrueDomain != "" {
truth = core.Sprintf(" [truth=%s]", r.TrueDomain) truth = fmt.Sprintf(" [truth=%s]", r.TrueDomain)
} }
t.Logf(" DISAGREE: 1B=%s 27B=%s%s | %.60s", r.DomainA, r.DomainB, truth, r.Text) t.Logf(" DISAGREE: 1B=%s 27B=%s%s | %.60s", r.DomainA, r.DomainB, truth, r.Text)
if disagreements >= 50 { if disagreements >= 50 {

View file

@ -3,10 +3,12 @@ package integration
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt"
"strings"
"testing" "testing"
"time" "time"
"dappco.re/go/core"
i18n "dappco.re/go/core/i18n" i18n "dappco.re/go/core/i18n"
"forge.lthn.ai/core/go-inference" "forge.lthn.ai/core/go-inference"
_ "forge.lthn.ai/core/go-mlx" // registers Metal backend _ "forge.lthn.ai/core/go-mlx" // registers Metal backend
@ -22,9 +24,9 @@ func TestClassifyCorpus_Integration(t *testing.T) {
// Build 50 technical prompts for throughput measurement // Build 50 technical prompts for throughput measurement
var lines []string var lines []string
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
lines = append(lines, core.Sprintf(`{"id":%d,"prompt":"Delete the configuration file and rebuild the project"}`, i)) lines = append(lines, fmt.Sprintf(`{"id":%d,"prompt":"Delete the configuration file and rebuild the project"}`, i))
} }
input := core.NewReader(core.Join("\n", lines...) + "\n") input := strings.NewReader(strings.Join(lines, "\n") + "\n")
var output bytes.Buffer var output bytes.Buffer
start := time.Now() start := time.Now()
@ -56,10 +58,10 @@ func TestClassifyCorpus_Integration(t *testing.T) {
} }
// Also inspect the output JSONL for misclassified entries // Also inspect the output JSONL for misclassified entries
outLines := core.Split(core.Trim(output.String()), "\n") outLines := strings.Split(strings.TrimSpace(output.String()), "\n")
for _, line := range outLines { for _, line := range outLines {
var record map[string]any var record map[string]any
if r := core.JSONUnmarshal([]byte(line), &record); r.OK { if err := json.Unmarshal([]byte(line), &record); err == nil {
if record["domain_1b"] != "technical" { if record["domain_1b"] != "technical" {
t.Logf(" misclassified: id=%v domain_1b=%v", record["id"], record["domain_1b"]) t.Logf(" misclassified: id=%v domain_1b=%v", record["id"], record["domain_1b"])
} }

View file

@ -2,11 +2,11 @@ package i18n
// GetPluralRule returns the plural rule for a language code. // GetPluralRule returns the plural rule for a language code.
func GetPluralRule(lang string) PluralRule { func GetPluralRule(lang string) PluralRule {
lang = normalizeLanguageTag(lang)
if rule, ok := pluralRules[lang]; ok { if rule, ok := pluralRules[lang]; ok {
return rule return rule
} }
if base := baseLanguageTag(lang); base != "" { if len(lang) > 2 {
base := lang[:2]
if rule, ok := pluralRules[base]; ok { if rule, ok := pluralRules[base]; ok {
return rule return rule
} }
@ -83,20 +83,3 @@ func pluralRuleArabic(n int) PluralCategory {
func pluralRuleChinese(n int) PluralCategory { return PluralOther } func pluralRuleChinese(n int) PluralCategory { return PluralOther }
func pluralRuleJapanese(n int) PluralCategory { return PluralOther } func pluralRuleJapanese(n int) PluralCategory { return PluralOther }
func pluralRuleKorean(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", 0, PluralOther},
{"en", 1, PluralOne}, {"en", 1, PluralOne},
{"en", 2, PluralOther}, {"en", 2, PluralOther},
{"en_US", 1, PluralOne},
// French (0 and 1 are singular) // French (0 and 1 are singular)
{"fr", 0, PluralOne}, {"fr", 0, PluralOne},
{"fr", 1, PluralOne}, {"fr", 1, PluralOne},
{"fr", 2, PluralOther}, {"fr", 2, PluralOther},
{"fr_CA", 2, PluralOther},
// Russian // Russian
{"ru", 1, PluralOne}, {"ru", 1, PluralOne},
@ -41,14 +39,6 @@ func TestGetPluralCategory(t *testing.T) {
{"ar", 11, PluralMany}, {"ar", 11, PluralMany},
{"ar", 100, PluralOther}, {"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) // Chinese (always other)
{"zh", 0, PluralOther}, {"zh", 0, PluralOther},
{"zh", 1, PluralOther}, {"zh", 1, PluralOther},
@ -85,21 +75,6 @@ func TestGetPluralRule(t *testing.T) {
t.Error("English-US rule(1) should be PluralOne") 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 // Unknown falls back to English
rule = GetPluralRule("xx-YY") rule = GetPluralRule("xx-YY")
if rule(1) != PluralOne { if rule(1) != PluralOne {

421
loader.go
View file

@ -1,14 +1,12 @@
package i18n package i18n
import ( import (
"errors" "encoding/json"
"io/fs" "io/fs"
"math"
"path" "path"
"slices" "strings"
"sync" "sync"
"dappco.re/go/core"
log "dappco.re/go/core/log" log "dappco.re/go/core/log"
) )
@ -29,30 +27,28 @@ func NewFSLoader(fsys fs.FS, dir string) *FSLoader {
// Load implements Loader.Load. // Load implements Loader.Load.
func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) { func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
variants := localeFilenameCandidates(lang) variants := []string{
lang + ".json",
strings.ReplaceAll(lang, "-", "_") + ".json",
strings.ReplaceAll(lang, "_", "-") + ".json",
}
var data []byte var data []byte
var err error var err error
var firstNonMissingErr error
for _, filename := range variants { for _, filename := range variants {
filePath := path.Join(l.dir, filename) filePath := path.Join(l.dir, filename)
data, err = fs.ReadFile(l.fsys, filePath) data, err = fs.ReadFile(l.fsys, filePath)
if err == nil { if err == nil {
break break
} }
if firstNonMissingErr == nil && !errors.Is(err, fs.ErrNotExist) {
firstNonMissingErr = err
}
} }
if err != nil { if err != nil {
if firstNonMissingErr != nil {
err = firstNonMissingErr
}
return nil, nil, log.E("FSLoader.Load", "locale not found: "+lang, err) return nil, nil, log.E("FSLoader.Load", "locale not found: "+lang, err)
} }
var raw map[string]any var raw map[string]any
if r := core.JSONUnmarshal(data, &raw); !r.OK { if err := json.Unmarshal(data, &raw); err != nil {
return nil, nil, log.E("FSLoader.Load", "invalid JSON in locale: "+lang, r.Value.(error)) return nil, nil, log.E("FSLoader.Load", "invalid JSON in locale: "+lang, err)
} }
messages := make(map[string]Message) messages := make(map[string]Message)
@ -67,37 +63,6 @@ func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
return messages, grammar, nil 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. // Languages implements Loader.Languages.
func (l *FSLoader) Languages() []string { func (l *FSLoader) Languages() []string {
l.langOnce.Do(func() { l.langOnce.Do(func() {
@ -106,25 +71,16 @@ func (l *FSLoader) Languages() []string {
l.langErr = log.E("FSLoader.Languages", "read locale directory: "+l.dir, err) l.langErr = log.E("FSLoader.Languages", "read locale directory: "+l.dir, err)
return return
} }
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue continue
} }
lang := core.TrimSuffix(entry.Name(), ".json") lang := strings.TrimSuffix(entry.Name(), ".json")
lang = normalizeLanguageTag(core.Replace(lang, "_", "-")) lang = strings.ReplaceAll(lang, "_", "-")
if lang == "" {
continue
}
if _, ok := seen[lang]; ok {
continue
}
seen[lang] = struct{}{}
l.languages = append(l.languages, 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. // LanguagesErr returns any error from the directory scan.
@ -150,33 +106,119 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa
switch v := value.(type) { switch v := value.(type) {
case string: case string:
if grammar != nil && loadGrammarWord(fullKey, v, grammar) { if grammar != nil && strings.HasPrefix(fullKey, "gram.word.") {
wordKey := strings.TrimPrefix(fullKey, "gram.word.")
grammar.Words[strings.ToLower(wordKey)] = v
continue continue
} }
out[fullKey] = Message{Text: v} out[fullKey] = Message{Text: v}
case map[string]any: 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 after, ok := strings.CutPrefix(fullKey, "gram.verb."); ok {
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[strings.ToLower(verbName)] = forms
continue continue
} }
if grammar != nil && loadGrammarNoun(fullKey, key, v, grammar) { // Noun form object (under gram.noun.* or has gender field)
if grammar != nil && (strings.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) {
nounName := key
if after, ok := strings.CutPrefix(fullKey, "gram.noun."); ok {
nounName = after
}
_, hasOne := v["one"]
_, hasOther := v["other"]
if hasOne && hasOther {
forms := NounForms{}
if one, ok := v["one"].(string); ok {
forms.One = one
}
if other, ok := v["other"].(string); ok {
forms.Other = other
}
if gender, ok := v["gender"].(string); ok {
forms.Gender = gender
}
grammar.Nouns[strings.ToLower(nounName)] = forms
continue
}
}
// 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, strings.ToLower(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, strings.ToLower(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, strings.ToLower(s))
}
}
}
}
continue 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 continue
} }
if grammar != nil && loadGrammarArticle(fullKey, v, grammar) { // Punctuation rules
continue if grammar != nil && fullKey == "gram.punct" {
} if label, ok := v["label"].(string); ok {
grammar.Punct.LabelSuffix = label
if grammar != nil && loadGrammarPunctuation(fullKey, v, grammar) { }
continue if progress, ok := v["progress"].(string); ok {
} grammar.Punct.ProgressSuffix = progress
}
if grammar != nil && loadGrammarNumber(fullKey, v, grammar) {
continue continue
} }
@ -209,170 +251,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 { func isVerbFormObject(m map[string]any) bool {
_, hasBase := m["base"]
_, hasPast := m["past"] _, hasPast := m["past"]
_, hasGerund := m["gerund"] _, hasGerund := m["gerund"]
// Verb objects are identified by their inflected forms. A bare "base" return (hasBase || hasPast || hasGerund) && !isPluralObject(m)
// field is metadata, not enough to claim the object is a verb table.
return (hasPast || hasGerund) && !isPluralObject(m)
} }
func isNounFormObject(m map[string]any) bool { func isNounFormObject(m map[string]any) bool {
@ -397,77 +280,3 @@ func isPluralObject(m map[string]any) bool {
} }
return true 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) { func TestFSLoaderLoad(t *testing.T) {
loader := NewFSLoader(localeFS, "locales") loader := NewFSLoader(localeFS, "locales")
messages, grammar, err := loader.Load("en") messages, grammar, err := loader.Load("en")
@ -117,17 +85,6 @@ func TestFSLoaderLoad(t *testing.T) {
t.Errorf("punct.progress = %q, want '...'", grammar.Punct.ProgressSuffix) 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.* // Words from gram.word.*
if len(grammar.Words) == 0 { if len(grammar.Words) == 0 {
t.Error("grammar has 0 words") 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) { func TestFlattenWithGrammar(t *testing.T) {
messages := make(map[string]Message) messages := make(map[string]Message)
grammar := &GrammarData{ grammar := &GrammarData{
@ -230,51 +121,20 @@ func TestFlattenWithGrammar(t *testing.T) {
"past": "tested", "past": "tested",
"gerund": "testing", "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{ "noun": map[string]any{
"widget": map[string]any{ "widget": map[string]any{
"one": "widget", "one": "widget",
"other": "widgets", "other": "widgets",
}, },
"passed": map[string]any{
"one": "passed",
"other": "passed",
},
}, },
"word": map[string]any{ "word": map[string]any{
"api": "API", "api": "API",
"failed": "failed",
"skipped": "skipped",
}, },
"punct": map[string]any{ "punct": map[string]any{
"label": ":", "label": ":",
"progress": "...", "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{ "article": map[string]any{
"indefinite": map[string]any{ "indefinite": map[string]any{
"default": "a", "default": "a",
@ -299,35 +159,6 @@ func TestFlattenWithGrammar(t *testing.T) {
t.Errorf("test.past = %q, want 'tested'", v.Past) 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 // Noun extracted
if n, ok := grammar.Nouns["widget"]; !ok { 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) 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 // Word extracted
if grammar.Words["api"] != "API" { if grammar.Words["api"] != "API" {
t.Errorf("word 'api' = %q, want 'API'", grammar.Words["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 // Punct extracted
if grammar.Punct.LabelSuffix != ":" { if grammar.Punct.LabelSuffix != ":" {
t.Errorf("punct.label = %q, want ':'", 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 // Articles extracted
if grammar.Articles.IndefiniteDefault != "a" { if grammar.Articles.IndefiniteDefault != "a" {
t.Errorf("article.indefinite.default = %q, want 'a'", grammar.Articles.IndefiniteDefault) 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" { if msg, ok := messages["prompt.yes"]; !ok || msg.Text != "y" {
t.Errorf("prompt.yes not flattened correctly, got %+v", messages["prompt.yes"]) 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) { func TestFlattenPluralObject(t *testing.T) {
@ -810,14 +261,8 @@ func TestCustomFSLoader(t *testing.T) {
Data: []byte(`{ Data: []byte(`{
"gram": { "gram": {
"verb": { "verb": {
"draft": { "base": "draft", "past": "drafted", "gerund": "drafting" },
"zap": { "base": "zap", "past": "zapped", "gerund": "zapping" } "zap": { "base": "zap", "past": "zapped", "gerund": "zapping" }
}, },
"signal": {
"priors": {
"draft": { "verb": 0.6, "noun": 0.4 }
}
},
"word": { "word": {
"hello": "Hello" "hello": "Hello"
} }
@ -845,45 +290,4 @@ func TestCustomFSLoader(t *testing.T) {
if v, ok := gd.Verbs["zap"]; !ok || v.Past != "zapped" { if v, ok := gd.Verbs["zap"]; !ok || v.Past != "zapped" {
t.Errorf("verb 'zap' not loaded correctly") 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 }
}
}
}
}`),
},
}
loader := NewFSLoader(fs, "locales")
_, grammar, err := loader.Load("test")
if err != nil {
t.Fatalf("Load(test) failed: %v", err)
}
if grammar == nil {
t.Fatal("expected grammar data")
}
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)
}
} }

View file

@ -92,8 +92,7 @@
"ssh": "SSH", "ssh": "SSH",
"ssl": "SSL", "ssl": "SSL",
"pr": "PR", "pr": "PR",
"ci": "CI", "ci": "CI"
"failed_to": "Impossible de"
}, },
"punct": { "punct": {
"label": " :", "label": " :",
@ -145,9 +144,7 @@
"minute": { "one": "il y a {{.Count}} minute", "other": "il y a {{.Count}} minutes" }, "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" }, "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" }, "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" }, "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" }
} }
}, },
"lang": { "lang": {

View file

@ -2,8 +2,8 @@ package i18n
import ( import (
"os" "os"
"strings"
"dappco.re/go/core"
"golang.org/x/text/language" "golang.org/x/text/language"
) )
@ -57,7 +57,6 @@ func (g GrammaticalGender) String() string {
// IsRTLLanguage returns true if the language code uses right-to-left text. // IsRTLLanguage returns true if the language code uses right-to-left text.
func IsRTLLanguage(lang string) bool { func IsRTLLanguage(lang string) bool {
lang = normalizeLanguageTag(lang)
if rtlLanguages[lang] { if rtlLanguages[lang] {
return true return true
} }
@ -68,180 +67,47 @@ func IsRTLLanguage(lang string) bool {
} }
// SetFormality sets the default formality level on the default service. // SetFormality sets the default formality level on the default service.
//
// Example:
//
// i18n.SetFormality(i18n.FormalityFormal)
func SetFormality(f Formality) { func SetFormality(f Formality) {
withDefaultService(func(svc *Service) { svc.SetFormality(f) }) if svc := Default(); svc != nil {
} 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()
})
} }
// Direction returns the text direction for the current language. // Direction returns the text direction for the current language.
//
// Example:
//
// dir := i18n.Direction()
func Direction() TextDirection { func Direction() TextDirection {
return defaultServiceValue(DirLTR, func(svc *Service) TextDirection { if svc := Default(); svc != nil {
return svc.Direction() return svc.Direction()
}) }
} return DirLTR
// 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()
} }
// IsRTL returns true if the current language uses right-to-left text. // IsRTL returns true if the current language uses right-to-left text.
//
// Example:
//
// rtl := i18n.IsRTL()
func IsRTL() bool { return Direction() == DirRTL } 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 { func detectLanguage(supported []language.Tag) string {
for _, langEnv := range []string{ langEnv := os.Getenv("LANG")
os.Getenv("LC_ALL"), if langEnv == "" {
firstLocaleFromList(os.Getenv("LANGUAGE")), langEnv = os.Getenv("LC_ALL")
os.Getenv("LC_MESSAGES"),
os.Getenv("LANG"),
} {
if langEnv == "" { if langEnv == "" {
continue langEnv = os.Getenv("LC_MESSAGES")
}
if detected := detectLanguageFromEnv(langEnv, supported); detected != "" {
return detected
} }
} }
return "" if langEnv == "" {
}
func detectLanguageFromEnv(langEnv string, supported []language.Tag) string {
baseLang := normalizeLanguageTag(core.Split(langEnv, ".")[0])
if baseLang == "" || len(supported) == 0 {
return "" return ""
} }
baseLang := strings.Split(langEnv, ".")[0]
baseLang = strings.ReplaceAll(baseLang, "_", "-")
parsedLang, err := language.Parse(baseLang) parsedLang, err := language.Parse(baseLang)
if err != nil { if err != nil {
return "" return ""
} }
if len(supported) == 0 {
return ""
}
matcher := language.NewMatcher(supported) matcher := language.NewMatcher(supported)
bestMatch, bestIndex, confidence := matcher.Match(parsedLang) bestMatch, _, confidence := matcher.Match(parsedLang)
if confidence < language.Low { if confidence >= language.Low {
return "" return bestMatch.String()
}
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
}
} }
return "" 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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/text/language"
) )
// --- Formality.String() --- // --- Formality.String() ---
@ -89,7 +88,6 @@ func TestIsRTLLanguage_Good(t *testing.T) {
}{ }{
{"arabic", "ar", true}, {"arabic", "ar", true},
{"arabic_sa", "ar-SA", true}, {"arabic_sa", "ar-SA", true},
{"arabic_sa_underscore", "ar_EG", true},
{"hebrew", "he", true}, {"hebrew", "he", true},
{"farsi", "fa", true}, {"farsi", "fa", true},
{"urdu", "ur", true}, {"urdu", "ur", true},
@ -97,7 +95,7 @@ func TestIsRTLLanguage_Good(t *testing.T) {
{"german", "de", false}, {"german", "de", false},
{"french", "fr", false}, {"french", "fr", false},
{"unknown", "xx", 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 {"english_variant", "en-US-extra", false}, // len > 2, not RTL
} }
for _, tt := range tests { for _, tt := range tests {
@ -121,86 +119,6 @@ func TestSetFormality_Good(t *testing.T) {
assert.Equal(t, FormalityNeutral, svc.Formality()) 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 --- // --- Package-level Direction ---
func TestDirection_Good(t *testing.T) { func TestDirection_Good(t *testing.T) {
@ -212,26 +130,6 @@ func TestDirection_Good(t *testing.T) {
assert.Equal(t, DirLTR, dir) 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 --- // --- Package-level IsRTL ---
func TestIsRTL_Good(t *testing.T) { 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") 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 --- // --- detectLanguage ---
func TestDetectLanguage_Good(t *testing.T) { 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") 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() --- // --- Mode.String() ---
func TestMode_String_Good(t *testing.T) { func TestMode_String_Good(t *testing.T) {

View file

@ -1,33 +1,21 @@
package i18n package i18n
import ( import (
"fmt"
"math" "math"
"strconv" "strconv"
"strings"
"dappco.re/go/core"
) )
func getNumberFormat() NumberFormat { func getNumberFormat() NumberFormat {
lang := currentLangForGrammar() lang := currentLangForGrammar()
if fmt, ok := getLocaleNumberFormat(lang); ok { if idx := strings.IndexAny(lang, "-_"); idx > 0 {
return fmt lang = lang[:idx]
}
if idx := indexAny(lang, "-_"); idx > 0 {
if fmt, ok := getLocaleNumberFormat(lang[:idx]); ok {
return fmt
}
}
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 { if fmt, ok := numberFormats[lang]; ok {
return fmt, true return fmt
} }
return NumberFormat{}, false return numberFormats["en"]
} }
// FormatNumber formats an integer with locale-specific thousands separators. // FormatNumber formats an integer with locale-specific thousands separators.
@ -43,35 +31,19 @@ func FormatDecimal(f float64) string {
// FormatDecimalN formats a float with N decimal places. // FormatDecimalN formats a float with N decimal places.
func FormatDecimalN(f float64, decimals int) string { func FormatDecimalN(f float64, decimals int) string {
nf := getNumberFormat() nf := getNumberFormat()
negative := f < 0 intPart := int64(f)
absVal := math.Abs(f) fracPart := math.Abs(f - float64(intPart))
intPart := int64(absVal)
fracPart := absVal - float64(intPart)
intStr := formatIntWithSep(intPart, nf.ThousandsSep) intStr := formatIntWithSep(intPart, nf.ThousandsSep)
if decimals <= 0 || fracPart == 0 { if decimals <= 0 || fracPart == 0 {
if negative {
return "-" + intStr
}
return intStr return intStr
} }
multiplier := math.Pow(10, float64(decimals)) multiplier := math.Pow(10, float64(decimals))
fracInt := int64(math.Round(fracPart * multiplier)) fracInt := int64(math.Round(fracPart * multiplier))
if fracInt >= int64(multiplier) {
intPart++
intStr = formatIntWithSep(intPart, nf.ThousandsSep)
fracInt = 0
}
if fracInt == 0 { if fracInt == 0 {
if negative {
return "-" + intStr
}
return intStr return intStr
} }
fracStr := core.Sprintf("%0*d", decimals, fracInt) fracStr := fmt.Sprintf("%0*d", decimals, fracInt)
fracStr = trimRight(fracStr, "0") fracStr = strings.TrimRight(fracStr, "0")
if negative {
return "-" + intStr + nf.DecimalSep + fracStr
}
return intStr + nf.DecimalSep + fracStr return intStr + nf.DecimalSep + fracStr
} }
@ -85,7 +57,7 @@ func FormatPercent(f float64) string {
} else { } else {
numStr = FormatDecimalN(pct, 1) numStr = FormatDecimalN(pct, 1)
} }
return core.Sprintf(nf.PercentFmt, numStr) return fmt.Sprintf(nf.PercentFmt, numStr)
} }
// FormatBytes formats bytes as human-readable size. // FormatBytes formats bytes as human-readable size.
@ -96,6 +68,7 @@ func FormatBytes(bytes int64) string {
GB = MB * 1024 GB = MB * 1024
TB = GB * 1024 TB = GB * 1024
) )
nf := getNumberFormat()
var value float64 var value float64
var unit string var unit string
switch { switch {
@ -112,51 +85,51 @@ func FormatBytes(bytes int64) string {
value = float64(bytes) / KB value = float64(bytes) / KB
unit = "KB" unit = "KB"
default: default:
return core.Sprintf("%d B", bytes) return fmt.Sprintf("%d B", bytes)
} }
return FormatDecimalN(value, 2) + " " + unit intPart := int64(value)
fracPart := value - float64(intPart)
if fracPart < 0.05 {
return fmt.Sprintf("%d %s", intPart, unit)
}
fracDigit := int(math.Round(fracPart * 10))
if fracDigit == 10 {
return fmt.Sprintf("%d %s", intPart+1, unit)
}
return fmt.Sprintf("%d%s%d %s", intPart, nf.DecimalSep, fracDigit, unit)
} }
// FormatOrdinal formats a number as an ordinal. // FormatOrdinal formats a number as an ordinal.
func FormatOrdinal(n int) string { func FormatOrdinal(n int) string {
lang := currentLangForGrammar() lang := currentLangForGrammar()
if idx := indexAny(lang, "-_"); idx > 0 { if idx := strings.IndexAny(lang, "-_"); idx > 0 {
lang = lang[:idx] lang = lang[:idx]
} }
switch lang { switch lang {
case "fr":
return formatFrenchOrdinal(n)
case "en": case "en":
return formatEnglishOrdinal(n) return formatEnglishOrdinal(n)
default: default:
return core.Sprintf("%d.", n) return fmt.Sprintf("%d.", n)
} }
} }
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 { func formatEnglishOrdinal(n int) string {
abs := n abs := n
if abs < 0 { if abs < 0 {
abs = -abs abs = -abs
} }
if abs%100 >= 11 && abs%100 <= 13 { if abs%100 >= 11 && abs%100 <= 13 {
return core.Sprintf("%dth", n) return fmt.Sprintf("%dth", n)
} }
switch abs % 10 { switch abs % 10 {
case 1: case 1:
return core.Sprintf("%dst", n) return fmt.Sprintf("%dst", n)
case 2: case 2:
return core.Sprintf("%dnd", n) return fmt.Sprintf("%dnd", n)
case 3: case 3:
return core.Sprintf("%drd", n) return fmt.Sprintf("%drd", n)
default: default:
return core.Sprintf("%dth", n) return fmt.Sprintf("%dth", n)
} }
} }
@ -165,22 +138,17 @@ func formatIntWithSep(n int64, sep string) string {
return strconv.FormatInt(n, 10) return strconv.FormatInt(n, 10)
} }
negative := n < 0 negative := n < 0
var abs uint64
if negative { if negative {
// Convert via n+1 to avoid overflowing on math.MinInt64. n = -n
abs = uint64(-(n + 1))
abs++
} else {
abs = uint64(n)
} }
str := strconv.FormatUint(abs, 10) str := strconv.FormatInt(n, 10)
if len(str) <= 3 { if len(str) <= 3 {
if negative { if negative {
return "-" + str return "-" + str
} }
return str return str
} }
result := core.NewBuilder() var result strings.Builder
for i, c := range str { for i, c := range str {
if i > 0 && (len(str)-i)%3 == 0 { if i > 0 && (len(str)-i)%3 == 0 {
result.WriteString(sep) result.WriteString(sep)
@ -192,34 +160,3 @@ func formatIntWithSep(n int64, sep string) string {
} }
return result.String() return result.String()
} }
// indexAny returns the index of the first occurrence of any char in chars, or -1.
func indexAny(s, chars string) int {
for i, c := range s {
for _, ch := range chars {
if c == ch {
return i
}
}
}
return -1
}
// trimRight returns s with all trailing occurrences of cutset removed.
func trimRight(s, cutset string) string {
for len(s) > 0 {
found := false
r := rune(s[len(s)-1])
for _, c := range cutset {
if r == c {
found = true
break
}
}
if !found {
break
}
s = s[:len(s)-1]
}
return s
}

View file

@ -1,9 +1,6 @@
package i18n package i18n
import ( import "testing"
"math"
"testing"
)
func TestFormatNumber(t *testing.T) { func TestFormatNumber(t *testing.T) {
// Ensure service is initialised for English locale // 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) { func TestFormatDecimal(t *testing.T) {
svc, err := New() svc, err := New()
if err != nil { if err != nil {
@ -61,12 +44,8 @@ func TestFormatDecimal(t *testing.T) {
}{ }{
{1.5, "1.5"}, {1.5, "1.5"},
{1.0, "1"}, {1.0, "1"},
{1.995, "2"},
{9.999, "10"},
{1234.56, "1,234.56"}, {1234.56, "1,234.56"},
{0.1, "0.1"}, {0.1, "0.1"},
{-0.1, "-0.1"},
{-1234.56, "-1,234.56"},
} }
for _, tt := range tests { 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) { func TestFormatPercent(t *testing.T) {
svc, err := New() svc, err := New()
if err != nil { if err != nil {
@ -117,7 +71,6 @@ func TestFormatPercent(t *testing.T) {
{1.0, "100%"}, {1.0, "100%"},
{0.0, "0%"}, {0.0, "0%"},
{0.333, "33.3%"}, {0.333, "33.3%"},
{-0.1, "-10%"},
} }
for _, tt := range tests { for _, tt := range tests {
@ -144,7 +97,7 @@ func TestFormatBytes(t *testing.T) {
{1024, "1 KB"}, {1024, "1 KB"},
{1536, "1.5 KB"}, {1536, "1.5 KB"},
{1048576, "1 MB"}, {1048576, "1 MB"},
{1536000, "1.46 MB"}, {1536000, "1.5 MB"},
{1073741824, "1 GB"}, {1073741824, "1 GB"},
{1099511627776, "1 TB"}, {1099511627776, "1 TB"},
} }
@ -190,61 +143,3 @@ func TestFormatOrdinal(t *testing.T) {
} }
} }
} }
func TestFormatOrdinalFromLocale(t *testing.T) {
svc, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
prev := Default()
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)
}
}
}
func TestFormatNumberFromLocale(t *testing.T) {
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 := 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")
}
}

View file

@ -1,10 +1,10 @@
package reversal package reversal
import ( import (
"fmt"
"sort" "sort"
"strings"
"testing" "testing"
"dappco.re/go/core"
) )
// Domain categories for classification ground truth. // Domain categories for classification ground truth.
@ -418,17 +418,17 @@ func TestClassification_LeaveOneOut(t *testing.T) {
// Print confusion matrix // Print confusion matrix
t.Log("\nConfusion matrix (rows=actual, cols=predicted):") t.Log("\nConfusion matrix (rows=actual, cols=predicted):")
header := core.NewBuilder() var header strings.Builder
header.WriteString(core.Sprintf(" %-10s", "")) header.WriteString(fmt.Sprintf(" %-10s", ""))
for _, d := range domains { for _, d := range domains {
header.WriteString(core.Sprintf(" %10s", d[:4])) header.WriteString(fmt.Sprintf(" %10s", d[:4]))
} }
t.Log(header.String()) t.Log(header.String())
for _, actual := range domains { for _, actual := range domains {
row := core.NewBuilder() var row strings.Builder
row.WriteString(core.Sprintf(" %-10s", actual[:4])) row.WriteString(fmt.Sprintf(" %-10s", actual[:4]))
for _, predicted := range domains { for _, predicted := range domains {
row.WriteString(core.Sprintf(" %10d", confusion[actual][predicted])) row.WriteString(fmt.Sprintf(" %10d", confusion[actual][predicted]))
} }
t.Log(row.String()) t.Log(row.String())
} }
@ -506,14 +506,14 @@ func TestClassification_TenseProfile(t *testing.T) {
} }
} }
parts := core.NewBuilder() var parts strings.Builder
parts.WriteString(core.Sprintf("%-10s verbs=%d", d, totalVerbs)) parts.WriteString(fmt.Sprintf("%-10s verbs=%d", d, totalVerbs))
for _, tense := range tenses { for _, tense := range tenses {
pct := 0.0 pct := 0.0
if totalVerbs > 0 { if totalVerbs > 0 {
pct = float64(tenseCounts[tense]) / float64(totalVerbs) * 100 pct = float64(tenseCounts[tense]) / float64(totalVerbs) * 100
} }
parts.WriteString(core.Sprintf(" %s=%.0f%%", tense, pct)) parts.WriteString(fmt.Sprintf(" %s=%.0f%%", tense, pct))
} }
t.Log(parts.String()) t.Log(parts.String())
} }
@ -550,12 +550,12 @@ func TestClassification_TopVerbs(t *testing.T) {
sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count }) sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count })
top := min(len(sorted), 8) top := min(len(sorted), 8)
verbs := core.NewBuilder() var verbs strings.Builder
for i := 0; i < top; i++ { for i := 0; i < top; i++ {
if i > 0 { if i > 0 {
verbs.WriteString(", ") verbs.WriteString(", ")
} }
verbs.WriteString(core.Sprintf("%s(%d)", sorted[i].verb, sorted[i].count)) verbs.WriteString(fmt.Sprintf("%s(%d)", sorted[i].verb, sorted[i].count))
} }
t.Logf("%-10s unique=%d top: %s", d, len(verbCounts), verbs.String()) t.Logf("%-10s unique=%d top: %s", d, len(verbCounts), verbs.String())
} }

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) { func TestNewImprint_Empty(t *testing.T) {
imp := NewImprint(nil) imp := NewImprint(nil)
if imp.TokenCount != 0 { if imp.TokenCount != 0 {

View file

@ -1,9 +1,9 @@
package reversal package reversal
import ( import (
"strings"
"unicode" "unicode"
"dappco.re/go/core"
i18n "dappco.re/go/core/i18n" i18n "dappco.re/go/core/i18n"
) )
@ -26,7 +26,7 @@ func NewMultiplierForLang(lang string) *Multiplier {
// Expand produces: original + tense flips (past, gerund) + number flips (plural toggle) + combinations. // Expand produces: original + tense flips (past, gerund) + number flips (plural toggle) + combinations.
// All output is deterministic and grammatically correct. // All output is deterministic and grammatically correct.
func (m *Multiplier) Expand(text string) []string { func (m *Multiplier) Expand(text string) []string {
text = core.Trim(text) text = strings.TrimSpace(text)
if text == "" { if text == "" {
return nil return nil
} }
@ -68,27 +68,39 @@ func (m *Multiplier) Expand(text string) []string {
// 2. Verb transforms: for each verb, produce past and gerund variants // 2. Verb transforms: for each verb, produce past and gerund variants
for _, vi := range verbIndices { for _, vi := range verbIndices {
addVariant(m.reconstructWithVerbTransform(tokens, vi, "past")) pastTokens := m.applyVerbTransform(tokens, vi, "past")
addVariant(m.reconstructWithVerbTransform(tokens, vi, "gerund")) addVariant(reconstruct(pastTokens))
addVariant(m.reconstructWithVerbTransform(tokens, vi, "base"))
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 // 3. Noun transforms: for each noun, toggle plural/singular
for _, ni := range nounIndices { 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 // 4. Combinations: each verb transform + each noun transform
for _, vi := range verbIndices { for _, vi := range verbIndices {
for _, ni := range nounIndices { for _, ni := range nounIndices {
// past + noun toggle // 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 // 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 // base + noun toggle
addVariant(m.reconstructWithVerbAndNounTransform(tokens, vi, "base", ni)) baseTokens := m.applyVerbTransform(tokens, vi, "base")
basePluralTokens := m.applyNounTransformOnTokens(baseTokens, ni)
addVariant(reconstruct(basePluralTokens))
} }
} }
@ -128,7 +140,7 @@ func (m *Multiplier) applyVerbTransform(tokens []Token, vi int, targetTense stri
result[vi] = Token{ result[vi] = Token{
Raw: newForm, Raw: newForm,
Lower: core.Lower(newForm), Lower: strings.ToLower(newForm),
Type: TokenVerb, Type: TokenVerb,
Confidence: 1.0, Confidence: 1.0,
VerbInfo: VerbMatch{ VerbInfo: VerbMatch{
@ -179,7 +191,7 @@ func (m *Multiplier) applyNounTransformOnTokens(tokens []Token, ni int) []Token
result[ni] = Token{ result[ni] = Token{
Raw: newForm, Raw: newForm,
Lower: core.Lower(newForm), Lower: strings.ToLower(newForm),
Type: TokenNoun, Type: TokenNoun,
Confidence: 1.0, Confidence: 1.0,
NounInfo: NounMatch{ NounInfo: NounMatch{
@ -192,84 +204,9 @@ func (m *Multiplier) applyNounTransformOnTokens(tokens []Token, ni int) []Token
return result 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. // reconstruct joins tokens back into a string, preserving spacing.
func reconstruct(tokens []Token) string { func reconstruct(tokens []Token) string {
b := core.NewBuilder() var b strings.Builder
for i, tok := range tokens { for i, tok := range tokens {
if i > 0 { if i > 0 {
// Punctuation tokens that were split from the previous word // Punctuation tokens that were split from the previous word
@ -298,7 +235,7 @@ func preserveCase(original, replacement string) string {
// If the original is all uppercase (like "DELETE"), make replacement all uppercase. // If the original is all uppercase (like "DELETE"), make replacement all uppercase.
if isAllUpper(original) && len(original) > 1 { if isAllUpper(original) && len(original) > 1 {
return core.Upper(replacement) return strings.ToUpper(replacement)
} }
// If the first character of the original is uppercase, capitalise the replacement. // If the first character of the original is uppercase, capitalise the replacement.

View file

@ -122,6 +122,8 @@ func (rs *ReferenceSet) Classify(imprint GrammarImprint) ImprintClassification {
result.Domain = ranked[0].domain result.Domain = ranked[0].domain
if len(ranked) > 1 { if len(ranked) > 1 {
result.Confidence = ranked[0].sim - ranked[1].sim result.Confidence = ranked[0].sim - ranked[1].sim
} else {
result.Confidence = ranked[0].sim
} }
} }
return result 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) { func TestReferenceSet_DomainNames(t *testing.T) {
tok := initI18n(t) tok := initI18n(t)
samples := []ClassifiedText{ samples := []ClassifiedText{

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() tok := NewTokeniser()
tests := []struct { tests := []struct {
word string word string
wantOK bool wantOK bool
wantBase string wantBase string
wantTense string wantTense string
}{ }{
// Irregular past tense // Irregular past tense
@ -159,7 +159,6 @@ func TestTokeniser_MatchWord(t *testing.T) {
{"url", "url", true}, {"url", "url", true},
{"ID", "id", true}, {"ID", "id", true},
{"SSH", "ssh", true}, {"SSH", "ssh", true},
{"up to date", "up_to_date", true},
{"PHP", "php", true}, {"PHP", "php", true},
{"xyzzy", "", false}, {"xyzzy", "", false},
} }
@ -189,7 +188,6 @@ func TestTokeniser_MatchArticle(t *testing.T) {
{"a", "indefinite", true}, {"a", "indefinite", true},
{"an", "indefinite", true}, {"an", "indefinite", true},
{"the", "definite", true}, {"the", "definite", true},
{"the.", "definite", true},
{"A", "indefinite", true}, {"A", "indefinite", true},
{"The", "definite", true}, {"The", "definite", true},
{"foo", "", false}, {"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) { func TestTokeniser_Tokenise(t *testing.T) {
setup(t) setup(t)
tok := NewTokeniser() tok := NewTokeniser()
@ -771,55 +259,6 @@ func TestTokeniser_Tokenise_Punctuation(t *testing.T) {
} }
} }
func TestTokeniser_Tokenise_ClauseBoundarySentence(t *testing.T) {
setup(t)
tok := NewTokeniser()
tokens := tok.Tokenise("run tests. commit")
hasSentenceEnd := false
for _, token := range tokens {
if token.Raw == "run" && token.Type != TokenVerb {
t.Errorf("'run' should remain TokenVerb, got %v", token.Type)
}
if token.Type == TokenPunctuation && token.PunctType == "sentence_end" {
hasSentenceEnd = true
}
if token.Lower == "commit" {
// Without sentence-end boundary support, this can be demoted by verb saturation.
// With boundary detection, it should still classify as a verb.
if token.Type != TokenVerb {
t.Errorf("'commit' after period should be TokenVerb, got %v", token.Type)
}
}
}
if !hasSentenceEnd {
t.Error("did not detect sentence-end punctuation in \"run tests. commit\"")
}
}
func TestTokeniser_Tokenise_ClauseBoundaryStandalonePunctuation(t *testing.T) {
setup(t)
tok := NewTokeniser()
tokens := tok.Tokenise("run tests . commit")
hasSentenceEnd := false
for _, token := range tokens {
if token.Type == TokenPunctuation && token.PunctType == "sentence_end" {
hasSentenceEnd = true
}
if token.Lower == "commit" && token.Type != TokenVerb {
t.Errorf("'commit' after standalone period should be TokenVerb, got %v", token.Type)
}
}
if !hasSentenceEnd {
t.Error("did not detect standalone sentence-end punctuation in \"run tests . commit\"")
}
}
func TestTokeniser_Tokenise_Empty(t *testing.T) { func TestTokeniser_Tokenise_Empty(t *testing.T) {
setup(t) setup(t)
tok := NewTokeniser() tok := NewTokeniser()
@ -876,43 +315,6 @@ func TestTokeniser_WithSignals(t *testing.T) {
_ = tok // verify it compiles and accepts the option _ = 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) { func TestTokeniser_DualClassDetection(t *testing.T) {
setup(t) setup(t)
tok := NewTokeniser() tok := NewTokeniser()
@ -924,13 +326,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"} { notDual := []string{"delete", "go", "push", "branch", "repo"}
if !tok.IsDualClass(word) {
t.Errorf("%q should be dual-class after expansion", word)
}
}
notDual := []string{"delete", "go", "branch", "repo"}
for _, word := range notDual { for _, word := range notDual {
if tok.IsDualClass(word) { if tok.IsDualClass(word) {
t.Errorf("%q should not be dual-class", word) t.Errorf("%q should not be dual-class", word)
@ -938,78 +334,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) { func TestToken_ConfidenceField(t *testing.T) {
setup(t) setup(t)
tok := NewTokeniser() tok := NewTokeniser()
@ -1130,46 +454,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) { func TestTokeniser_WithSignals_Breakdown(t *testing.T) {
setup(t) setup(t)
tok := NewTokeniser(WithSignals()) tok := NewTokeniser(WithSignals())
@ -1217,7 +501,7 @@ func TestDisambiguationStats_WithAmbiguous(t *testing.T) {
setup(t) setup(t)
tok := NewTokeniser() tok := NewTokeniser()
tokens := tok.Tokenise("The commit passed the test") tokens := tok.Tokenise("The commit passed the test")
stats := tok.DisambiguationStats(tokens) stats := DisambiguationStatsFromTokens(tokens)
if stats.AmbiguousTokens == 0 { if stats.AmbiguousTokens == 0 {
t.Error("expected ambiguous tokens for dual-class words") t.Error("expected ambiguous tokens for dual-class words")
} }
@ -1230,7 +514,7 @@ func TestDisambiguationStats_NoAmbiguous(t *testing.T) {
setup(t) setup(t)
tok := NewTokeniser() tok := NewTokeniser()
tokens := tok.Tokenise("Deleted the files") tokens := tok.Tokenise("Deleted the files")
stats := tok.DisambiguationStats(tokens) stats := DisambiguationStatsFromTokens(tokens)
if stats.AmbiguousTokens != 0 { if stats.AmbiguousTokens != 0 {
t.Errorf("AmbiguousTokens = %d, want 0", stats.AmbiguousTokens) t.Errorf("AmbiguousTokens = %d, want 0", stats.AmbiguousTokens)
} }
@ -1239,7 +523,7 @@ func TestDisambiguationStats_NoAmbiguous(t *testing.T) {
func TestWithWeights_Override(t *testing.T) { func TestWithWeights_Override(t *testing.T) {
setup(t) setup(t)
// Override noun_determiner to 0 — "The commit" should no longer resolve as noun // 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, "noun_determiner": 0.0,
"verb_auxiliary": 0.25, "verb_auxiliary": 0.25,
"following_class": 0.15, "following_class": 0.15,
@ -1247,8 +531,7 @@ func TestWithWeights_Override(t *testing.T) {
"verb_saturation": 0.10, "verb_saturation": 0.10,
"inflection_echo": 0.03, "inflection_echo": 0.03,
"default_prior": 0.02, "default_prior": 0.02,
} }))
tok := NewTokeniser(WithWeights(weights))
tokens := tok.Tokenise("The commit") tokens := tok.Tokenise("The commit")
// With noun_determiner zeroed, default_prior (verb) should win // With noun_determiner zeroed, default_prior (verb) should win
if tokens[1].Type != TokenVerb { if tokens[1].Type != TokenVerb {
@ -1256,107 +539,6 @@ 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))
// 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) {
setup(t)
tok := NewTokeniser()
tokens := tok.Tokenise("maybe commit")
if len(tokens) != 2 {
t.Fatalf("Tokenise(maybe commit) produced %d tokens, want 2", len(tokens))
}
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)
}
}
// --- Benchmarks --- // --- Benchmarks ---
func benchSetup(b *testing.B) { func benchSetup(b *testing.B) {

1122
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()
}

56
time.go
View file

@ -1,85 +1,39 @@
package i18n package i18n
import ( import (
"fmt"
"time" "time"
"dappco.re/go/core"
) )
// TimeAgo returns a localised relative time string. // 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" // TimeAgo(time.Now().Add(-5 * time.Minute)) // "5 minutes ago"
func TimeAgo(t time.Time) string { func TimeAgo(t time.Time) string {
duration := time.Since(t) duration := time.Since(t)
if duration < 0 {
duration = 0
}
switch { 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: case duration < time.Minute:
return FormatAgo(int(duration/time.Second), "second") return T("time.just_now")
case duration < time.Hour: case duration < time.Hour:
return FormatAgo(int(duration.Minutes()), "minute") return FormatAgo(int(duration.Minutes()), "minute")
case duration < 24*time.Hour: case duration < 24*time.Hour:
return FormatAgo(int(duration.Hours()), "hour") return FormatAgo(int(duration.Hours()), "hour")
case duration < 7*24*time.Hour: case duration < 7*24*time.Hour:
return FormatAgo(int(duration.Hours()/24), "day") 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: 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. // FormatAgo formats "N unit ago" with proper pluralisation.
func FormatAgo(count int, unit string) string { func FormatAgo(count int, unit string) string {
svc := Default() svc := Default()
unit = normalizeAgoUnit(unit)
if svc == nil { if svc == nil {
return core.Sprintf("%d %s ago", count, Pluralize(unit, count)) return fmt.Sprintf("%d %ss ago", count, unit)
} }
key := "time.ago." + unit key := "time.ago." + unit
result := svc.T(key, map[string]any{"Count": count}) result := svc.T(key, map[string]any{"Count": count})
if result == key { if result == key {
return core.Sprintf("%d %s ago", count, fallbackAgoUnit(unit, count)) return fmt.Sprintf("%d %s ago", count, Pluralize(unit, count))
} }
return result 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 ( import (
"testing" "testing"
"testing/fstest"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -21,14 +20,11 @@ func TestTimeAgo_Good(t *testing.T) {
duration time.Duration duration time.Duration
contains string contains string
}{ }{
{"just_now", 4 * time.Second, "just now"}, {"just_now", 5 * time.Second, "just now"},
{"seconds_ago", 5 * time.Second, "5 seconds ago"},
{"minutes_ago", 5 * time.Minute, "5 minutes ago"}, {"minutes_ago", 5 * time.Minute, "5 minutes ago"},
{"hours_ago", 3 * time.Hour, "3 hours ago"}, {"hours_ago", 3 * time.Hour, "3 hours ago"},
{"days_ago", 2 * 24 * time.Hour, "2 days ago"}, {"days_ago", 2 * 24 * time.Hour, "2 days ago"},
{"weeks_ago", 3 * 7 * 24 * time.Hour, "3 weeks 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -45,7 +41,7 @@ func TestTimeAgo_Good_EdgeCases(t *testing.T) {
// Just under 1 minute // Just under 1 minute
got := TimeAgo(time.Now().Add(-59 * time.Second)) got := TimeAgo(time.Now().Add(-59 * time.Second))
assert.Contains(t, got, "seconds ago") assert.Contains(t, got, "just now")
// Exactly 1 minute // Exactly 1 minute
got = TimeAgo(time.Now().Add(-60 * time.Second)) got = TimeAgo(time.Now().Add(-60 * time.Second))
@ -62,14 +58,6 @@ func TestTimeAgo_Good_EdgeCases(t *testing.T) {
// Just under 1 week // Just under 1 week
got = TimeAgo(time.Now().Add(-6 * 24 * time.Hour)) got = TimeAgo(time.Now().Add(-6 * 24 * time.Hour))
assert.Contains(t, got, "days ago") 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) { 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") 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 --- // --- FormatAgo ---
func TestFormatAgo_Good(t *testing.T) { 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) { func TestFormatAgo_Bad_UnknownUnit(t *testing.T) {
svc, err := New() svc, err := New()
require.NoError(t, err) require.NoError(t, err)
@ -200,73 +130,3 @@ func TestFormatAgo_Good_SingularUnit(t *testing.T) {
got := FormatAgo(1, "fortnight") got := FormatAgo(1, "fortnight")
assert.Equal(t, "1 fortnight ago", got) 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()
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)
}

View file

@ -1,60 +1,20 @@
package i18n package i18n
import (
"strconv"
"dappco.re/go/core"
)
func getCount(data any) int { func getCount(data any) int {
if data == nil { if data == nil {
return 0 return 0
} }
switch d := data.(type) { 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: case map[string]any:
if c, ok := d["Count"]; ok { if c, ok := d["Count"]; ok {
return toInt(c) return toInt(c)
} }
if c, ok := d["count"]; ok {
return toInt(c)
}
case map[string]int: case map[string]int:
if c, ok := d["Count"]; ok { if c, ok := d["Count"]; ok {
return c 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 { func toInt(v any) int {
@ -86,13 +46,6 @@ func toInt(v any) int {
return int(n) return int(n)
case float32: case float32:
return int(n) return int(n)
case string:
if n == "" {
return 0
}
if parsed, err := strconv.Atoi(core.Trim(n)); err == nil {
return parsed
}
} }
return 0 return 0
} }
@ -126,13 +79,6 @@ func toInt64(v any) int64 {
return int64(n) return int64(n)
case float32: case float32:
return int64(n) 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 return 0
} }
@ -166,13 +112,6 @@ func toFloat64(v any) float64 {
return float64(n) return float64(n)
case uint8: case uint8:
return float64(n) return float64(n)
case string:
if n == "" {
return 0
}
if parsed, err := strconv.ParseFloat(core.Trim(n), 64); err == nil {
return parsed
}
} }
return 0 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", map[string]any{"Count": 5}, 5},
{"map_string_any_float", map[string]any{"Count": 3.7}, 3}, {"map_string_any_float", map[string]any{"Count": 3.7}, 3},
{"map_string_int", map[string]int{"Count": 42}, 42}, {"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}, {"no_count_key", map[string]any{"Name": "test"}, 0},
{"wrong_type", "a string", 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 --- // --- toInt ---
func TestToInt_Good(t *testing.T) { func TestToInt_Good(t *testing.T) {
@ -61,7 +50,6 @@ func TestToInt_Good(t *testing.T) {
{"uint8", uint8(50), 50}, {"uint8", uint8(50), 50},
{"float64", float64(3.14), 3}, {"float64", float64(3.14), 3},
{"float32", float32(2.71), 2}, {"float32", float32(2.71), 2},
{"string_int", "123", 123},
{"string", "not a number", 0}, {"string", "not a number", 0},
{"bool", true, 0}, {"bool", true, 0},
} }
@ -94,7 +82,6 @@ func TestToInt64_Good(t *testing.T) {
{"uint8", uint8(50), 50}, {"uint8", uint8(50), 50},
{"float64", float64(3.14), 3}, {"float64", float64(3.14), 3},
{"float32", float32(2.71), 2}, {"float32", float32(2.71), 2},
{"string_int64", "123", 123},
{"string", "not a number", 0}, {"string", "not a number", 0},
{"bool", true, 0}, {"bool", true, 0},
} }
@ -127,7 +114,6 @@ func TestToFloat64_Good(t *testing.T) {
{"uint32", uint32(30), 30.0}, {"uint32", uint32(30), 30.0},
{"uint16", uint16(40), 40.0}, {"uint16", uint16(40), 40.0},
{"uint8", uint8(50), 50.0}, {"uint8", uint8(50), 50.0},
{"string_float", "3.5", 3.5},
{"string", "not a number", 0}, {"string", "not a number", 0},
{"bool", true, 0}, {"bool", true, 0},
} }

108
types.go
View file

@ -8,7 +8,7 @@
// T("i18n.label.status") // "Status:" // T("i18n.label.status") // "Status:"
// T("i18n.progress.build") // "Building..." // T("i18n.progress.build") // "Building..."
// T("i18n.count.file", 5) // "5 files" // 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" // T("i18n.fail.push", "commits") // "Failed to push commits"
package i18n package i18n
@ -17,8 +17,6 @@ import "sync"
// --- Core Types --- // --- Core Types ---
// Mode determines how the service handles missing translation keys. // Mode determines how the service handles missing translation keys.
//
// i18n.SetMode(i18n.ModeStrict)
type Mode int type Mode int
const ( const (
@ -41,8 +39,6 @@ func (m Mode) String() string {
} }
// Formality represents the level of formality in translations. // Formality represents the level of formality in translations.
//
// i18n.S("user", "Alex").Formal()
type Formality int type Formality int
const ( const (
@ -52,18 +48,14 @@ const (
) )
// TextDirection represents text directionality. // TextDirection represents text directionality.
//
// if i18n.Direction() == i18n.DirRTL { /* ... */ }
type TextDirection int type TextDirection int
const ( const (
DirLTR TextDirection = iota // Left-to-right DirLTR TextDirection = iota // Left-to-right
DirRTL // Right-to-left DirRTL // Right-to-left
) )
// PluralCategory represents CLDR plural categories. // PluralCategory represents CLDR plural categories.
//
// cat := i18n.CurrentPluralCategory(2)
type PluralCategory int type PluralCategory int
const ( const (
@ -76,8 +68,6 @@ const (
) )
// GrammaticalGender represents grammatical gender for nouns. // GrammaticalGender represents grammatical gender for nouns.
//
// i18n.S("user", "Alex").Gender("feminine")
type GrammaticalGender int type GrammaticalGender int
const ( const (
@ -90,8 +80,6 @@ const (
// --- Message Types --- // --- Message Types ---
// Message represents a translation — either a simple string or plural forms. // Message represents a translation — either a simple string or plural forms.
//
// msg := i18n.Message{One: "{{.Count}} file", Other: "{{.Count}} files"}
type Message struct { type Message struct {
Text string // Simple string value (non-plural) Text string // Simple string value (non-plural)
Zero string // count == 0 (Arabic, Latvian, Welsh) Zero string // count == 0 (Arabic, Latvian, Welsh)
@ -144,8 +132,6 @@ func (m Message) IsPlural() bool {
// --- Subject Types --- // --- Subject Types ---
// Subject represents a typed subject with metadata for semantic translations. // Subject represents a typed subject with metadata for semantic translations.
//
// subj := i18n.S("file", "config.yaml").Count(3).In("workspace")
type Subject struct { type Subject struct {
Noun string // The noun type (e.g., "file", "repo") Noun string // The noun type (e.g., "file", "repo")
Value any // The actual value (e.g., filename) Value any // The actual value (e.g., filename)
@ -158,8 +144,6 @@ type Subject struct {
// --- Intent Types --- // --- Intent Types ---
// IntentMeta defines the behaviour of an intent. // IntentMeta defines the behaviour of an intent.
//
// intent := i18n.Intent{Meta: i18n.IntentMeta{Type: "action", Verb: "delete"}}
type IntentMeta struct { type IntentMeta struct {
Type string // "action", "question", "info" Type string // "action", "question", "info"
Verb string // Reference to verb key 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 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 { type Composed struct {
Question string // "Delete config.yaml?" Question string // "Delete config.yaml?"
Confirm string // "Really 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 defines a semantic intent with templates for all output forms.
//
// intent := i18n.Intent{Question: "Delete {{.Subject}}?"}
type Intent struct { type Intent struct {
Meta IntentMeta Meta IntentMeta
Question string // Template for question form Question string // Template for question form
@ -206,8 +186,6 @@ type templateData struct {
// --- Grammar Types --- // --- Grammar Types ---
// GrammarData holds language-specific grammar forms loaded from JSON. // GrammarData holds language-specific grammar forms loaded from JSON.
//
// i18n.SetGrammarData("en", &i18n.GrammarData{Articles: i18n.ArticleForms{IndefiniteDefault: "a"}})
type GrammarData struct { type GrammarData struct {
Verbs map[string]VerbForms // verb -> forms Verbs map[string]VerbForms // verb -> forms
Nouns map[string]NounForms // noun -> forms Nouns map[string]NounForms // noun -> forms
@ -215,20 +193,15 @@ type GrammarData struct {
Words map[string]string // base word translations Words map[string]string // base word translations
Punct PunctuationRules // language-specific punctuation Punct PunctuationRules // language-specific punctuation
Signals SignalData // disambiguation signal word lists Signals SignalData // disambiguation signal word lists
Number NumberFormat // locale-specific number formatting
} }
// VerbForms holds verb conjugations. // VerbForms holds verb conjugations.
//
// forms := i18n.VerbForms{Past: "deleted", Gerund: "deleting"}
type VerbForms struct { type VerbForms struct {
Past string // "deleted" Past string // "deleted"
Gerund string // "deleting" Gerund string // "deleting"
} }
// NounForms holds plural and gender information for a noun. // NounForms holds plural and gender information for a noun.
//
// forms := i18n.NounForms{One: "file", Other: "files"}
type NounForms struct { type NounForms struct {
One string // Singular form One string // Singular form
Other string // Plural form Other string // Plural form
@ -236,8 +209,6 @@ type NounForms struct {
} }
// ArticleForms holds article configuration for a language. // ArticleForms holds article configuration for a language.
//
// articles := i18n.ArticleForms{IndefiniteDefault: "a", IndefiniteVowel: "an"}
type ArticleForms struct { type ArticleForms struct {
IndefiniteDefault string // "a" IndefiniteDefault string // "a"
IndefiniteVowel string // "an" IndefiniteVowel string // "an"
@ -246,29 +217,22 @@ type ArticleForms struct {
} }
// PunctuationRules holds language-specific punctuation patterns. // PunctuationRules holds language-specific punctuation patterns.
//
// rules := i18n.PunctuationRules{LabelSuffix: ":", ProgressSuffix: "..."}
type PunctuationRules struct { type PunctuationRules struct {
LabelSuffix string // ":" (French uses " :") LabelSuffix string // ":" (French uses " :")
ProgressSuffix string // "..." ProgressSuffix string // "..."
} }
// SignalData holds word lists used for disambiguation signals. // SignalData holds word lists used for disambiguation signals.
//
// signals := i18n.SignalData{VerbAuxiliaries: []string{"is", "was"}}
type SignalData struct { type SignalData struct {
NounDeterminers []string // Words that precede nouns: "the", "a", "this", "my", ... NounDeterminers []string // Words that precede nouns: "the", "a", "this", "my", ...
VerbAuxiliaries []string // Auxiliaries/modals before verbs: "is", "was", "will", ... VerbAuxiliaries []string // Auxiliaries/modals before verbs: "is", "was", "will", ...
VerbInfinitive []string // Infinitive markers: "to" VerbInfinitive []string // Infinitive markers: "to"
VerbNegation []string // Negation cues that weakly signal a verb: "not", "never", ... Priors map[string]map[string]float64 // Reserved for Phase 2: corpus-derived per-word priors. Not yet loaded.
Priors map[string]map[string]float64 // Corpus-derived verb/noun priors for ambiguous words, consumed by the reversal tokeniser.
} }
// --- Number Formatting --- // --- Number Formatting ---
// NumberFormat defines locale-specific number formatting rules. // NumberFormat defines locale-specific number formatting rules.
//
// fmt := i18n.NumberFormat{ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"}
type NumberFormat struct { type NumberFormat struct {
ThousandsSep string // "," for en, "." for de ThousandsSep string // "," for en, "." for de
DecimalSep string // "." for en, "," for de DecimalSep string // "." for en, "," for de
@ -278,18 +242,12 @@ type NumberFormat struct {
// --- Function Types --- // --- Function Types ---
// PluralRule determines the plural category for a count. // PluralRule determines the plural category for a count.
//
// rule := i18n.GetPluralRule("en")
type PluralRule func(n int) PluralCategory type PluralRule func(n int) PluralCategory
// MissingKeyHandler receives missing key events. // MissingKeyHandler receives missing key events.
//
// i18n.OnMissingKey(func(m i18n.MissingKey) {})
type MissingKeyHandler func(missing MissingKey) type MissingKeyHandler func(missing MissingKey)
// MissingKey is dispatched when a translation key is not found in ModeCollect. // MissingKey is dispatched when a translation key is not found in ModeCollect.
//
// func handle(m i18n.MissingKey) { _ = m.Key }
type MissingKey struct { type MissingKey struct {
Key string Key string
Args map[string]any Args map[string]any
@ -301,24 +259,18 @@ type MissingKey struct {
// KeyHandler processes translation keys before standard lookup. // KeyHandler processes translation keys before standard lookup.
// Handlers form a chain; each can handle a key or delegate to the next. // Handlers form a chain; each can handle a key or delegate to the next.
//
// i18n.AddHandler(i18n.LabelHandler{})
type KeyHandler interface { type KeyHandler interface {
Match(key string) bool Match(key string) bool
Handle(key string, args []any, next func() string) string Handle(key string, args []any, next func() string) string
} }
// Loader provides translation data to the Service. // Loader provides translation data to the Service.
//
// svc, err := i18n.NewWithLoader(loader)
type Loader interface { type Loader interface {
Load(lang string) (map[string]Message, *GrammarData, error) Load(lang string) (map[string]Message, *GrammarData, error)
Languages() []string Languages() []string
} }
// Translator defines the interface for translation services. // Translator defines the interface for translation services.
//
// var t i18n.Translator = i18n.Default()
type Translator interface { type Translator interface {
T(messageID string, args ...any) string T(messageID string, args ...any) string
SetLanguage(lang string) error SetLanguage(lang string) error
@ -368,7 +320,6 @@ var pluralRules = map[string]PluralRule{
"ru": pluralRuleRussian, "ru-RU": pluralRuleRussian, "ru": pluralRuleRussian, "ru-RU": pluralRuleRussian,
"pl": pluralRulePolish, "pl-PL": pluralRulePolish, "pl": pluralRulePolish, "pl-PL": pluralRulePolish,
"ar": pluralRuleArabic, "ar-SA": pluralRuleArabic, "ar": pluralRuleArabic, "ar-SA": pluralRuleArabic,
"cy": pluralRuleWelsh, "cy-GB": pluralRuleWelsh,
"zh": pluralRuleChinese, "zh-CN": pluralRuleChinese, "zh-TW": pluralRuleChinese, "zh": pluralRuleChinese, "zh-CN": pluralRuleChinese, "zh-TW": pluralRuleChinese,
"ja": pluralRuleJapanese, "ja-JP": pluralRuleJapanese, "ja": pluralRuleJapanese, "ja-JP": pluralRuleJapanese,
"ko": pluralRuleKorean, "ko-KR": pluralRuleKorean, "ko": pluralRuleKorean, "ko-KR": pluralRuleKorean,
@ -434,7 +385,7 @@ var irregularVerbs = map[string]VerbForms{
"rebel": {Past: "rebelled", Gerund: "rebelling"}, "excel": {Past: "excelled", Gerund: "excelling"}, "rebel": {Past: "rebelled", Gerund: "rebelling"}, "excel": {Past: "excelled", Gerund: "excelling"},
"cancel": {Past: "cancelled", Gerund: "cancelling"}, "travel": {Past: "travelled", Gerund: "travelling"}, "cancel": {Past: "cancelled", Gerund: "cancelling"}, "travel": {Past: "travelled", Gerund: "travelling"},
"label": {Past: "labelled", Gerund: "labelling"}, "model": {Past: "modelled", Gerund: "modelling"}, "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"}, "format": {Past: "formatted", Gerund: "formatting"},
"analyse": {Past: "analysed", Gerund: "analysing"}, "analyse": {Past: "analysed", Gerund: "analysing"},
"organise": {Past: "organised", Gerund: "organising"}, "organise": {Past: "organised", Gerund: "organising"},
@ -496,57 +447,6 @@ var irregularNouns = map[string]string{
"calf": "calves", "loaf": "loaves", "thief": "thieves", "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{ var vowelSounds = map[string]bool{
"hour": true, "honest": true, "honour": true, "honor": true, "heir": true, "herb": true, "hour": true, "honest": true, "honour": true, "honor": true, "heir": true, "herb": true,
} }

View file

@ -4,8 +4,9 @@ package i18n
import ( import (
"context" "context"
"fmt"
"strings"
"dappco.re/go/core"
log "dappco.re/go/core/log" log "dappco.re/go/core/log"
"forge.lthn.ai/core/go-inference" "forge.lthn.ai/core/go-inference"
) )
@ -44,18 +45,7 @@ type IrregularResult struct {
// articlePrompt builds a fill-in-the-blank prompt for article prediction. // articlePrompt builds a fill-in-the-blank prompt for article prediction.
func articlePrompt(noun string) string { func articlePrompt(noun string) string {
return articlePromptForLang(currentLangForGrammar(), noun) return fmt.Sprintf(
}
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:", "Complete with the correct article (a/an/the): ___ %s. Answer with just the article:",
noun, noun,
) )
@ -63,7 +53,7 @@ func articlePromptForLang(lang, noun string) string {
// irregularPrompt builds a fill-in-the-blank prompt for irregular verb prediction. // irregularPrompt builds a fill-in-the-blank prompt for irregular verb prediction.
func irregularPrompt(verb, tense string) string { func irregularPrompt(verb, tense string) string {
return core.Sprintf( return fmt.Sprintf(
"What is the %s form of the verb '%s'? Answer with just the word:", "What is the %s form of the verb '%s'? Answer with just the word:",
tense, verb, tense, verb,
) )
@ -71,14 +61,14 @@ func irregularPrompt(verb, tense string) string {
// collectGenerated runs a single-token generation and returns the trimmed, lowercased output. // collectGenerated runs a single-token generation and returns the trimmed, lowercased output.
func collectGenerated(ctx context.Context, m inference.TextModel, prompt string) (string, error) { func collectGenerated(ctx context.Context, m inference.TextModel, prompt string) (string, error) {
sb := core.NewBuilder() var sb strings.Builder
for tok := range m.Generate(ctx, prompt, inference.WithMaxTokens(1), inference.WithTemperature(0.05)) { for tok := range m.Generate(ctx, prompt, inference.WithMaxTokens(1), inference.WithTemperature(0.05)) {
sb.WriteString(tok.Text) sb.WriteString(tok.Text)
} }
if err := m.Err(); err != nil { if err := m.Err(); err != nil {
return "", err return "", err
} }
return core.Trim(core.Lower(sb.String())), nil return strings.TrimSpace(strings.ToLower(sb.String())), nil
} }
// ValidateArticle checks whether a given article usage is grammatically correct // ValidateArticle checks whether a given article usage is grammatically correct
@ -90,7 +80,7 @@ func ValidateArticle(ctx context.Context, m inference.TextModel, noun string, ar
if err != nil { if err != nil {
return ArticleResult{}, log.E("ValidateArticle", "validate: "+noun, err) return ArticleResult{}, log.E("ValidateArticle", "validate: "+noun, err)
} }
given := core.Trim(core.Lower(article)) given := strings.TrimSpace(strings.ToLower(article))
return ArticleResult{ return ArticleResult{
Noun: noun, Noun: noun,
Given: given, Given: given,
@ -109,7 +99,7 @@ func ValidateIrregular(ctx context.Context, m inference.TextModel, verb string,
if err != nil { if err != nil {
return IrregularResult{}, log.E("ValidateIrregular", "validate: "+verb+" ("+tense+")", err) return IrregularResult{}, log.E("ValidateIrregular", "validate: "+verb+" ("+tense+")", err)
} }
given := core.Trim(core.Lower(form)) given := strings.TrimSpace(strings.ToLower(form))
return IrregularResult{ return IrregularResult{
Verb: verb, Verb: verb,
Tense: tense, Tense: tense,

View file

@ -4,10 +4,10 @@ package i18n
import ( import (
"context" "context"
"fmt"
"iter" "iter"
"testing" "testing"
"dappco.re/go/core"
"forge.lthn.ai/core/go-inference" "forge.lthn.ai/core/go-inference"
) )
@ -73,7 +73,7 @@ func newMockIrregularModel(forms map[string]string) *mockGenerateModel {
// containsVerb checks if the prompt contains the verb in the expected format. // containsVerb checks if the prompt contains the verb in the expected format.
func containsVerb(prompt, verb string) bool { func containsVerb(prompt, verb string) bool {
return len(prompt) > 0 && len(verb) > 0 && return len(prompt) > 0 && len(verb) > 0 &&
contains(prompt, core.Sprintf("'%s'", verb)) contains(prompt, fmt.Sprintf("'%s'", verb))
} }
// contains is a simple substring check (avoids importing strings in test). // contains is a simple substring check (avoids importing strings in test).
@ -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) { func TestIrregularPrompt(t *testing.T) {
prompt := irregularPrompt("swim", "past participle") prompt := irregularPrompt("swim", "past participle")
if !contains(prompt, "'swim'") { if !contains(prompt, "'swim'") {