Compare commits
335 commits
ax/review-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c97e321948 | ||
|
|
0e914911fa | ||
|
|
d3a12bfe74 | ||
|
|
dd9d0832af | ||
|
|
7332f5bd1c | ||
|
|
f487e427e9 | ||
|
|
cc1dd6b898 | ||
|
|
bd5e299575 | ||
|
|
cc66b3d049 | ||
|
|
495e977a6f | ||
|
|
06a5fe9ac8 | ||
|
|
afe7b6e9de | ||
|
|
ec9dc0666b | ||
|
|
72b2e822d2 | ||
|
|
1ae029cfdb | ||
|
|
5020fbef42 | ||
|
|
30ed8ff6ad | ||
|
|
6ca01b37a5 | ||
|
|
0cea872363 | ||
|
|
0c26ef0caa | ||
|
|
87f582f43f | ||
|
|
b051d71684 | ||
|
|
b8c5b77d88 | ||
|
|
0e01e9f894 | ||
|
|
4608de8def | ||
|
|
31d9672a46 | ||
|
|
99412a64ea | ||
|
|
963ea7999d | ||
|
|
1fe523b921 | ||
|
|
fd97bc7048 | ||
|
|
62d08d7bc3 | ||
|
|
43cf530b9d | ||
|
|
fdb151a0af | ||
|
|
56b6db8a8d | ||
|
|
5dc9847eec | ||
|
|
6e202e8230 | ||
|
|
82164940d7 | ||
|
|
d868f21ab2 | ||
|
|
730b6166a1 | ||
|
|
852b11fda5 | ||
|
|
d8355d241f | ||
|
|
cdc396269c | ||
|
|
9d4af96d3d | ||
|
|
158f71443c | ||
|
|
4d1650addd | ||
|
|
24eaadda8a | ||
|
|
e1f496f296 | ||
|
|
177912d4e6 | ||
|
|
0db1db2d8c | ||
|
|
1ae9ada1fd | ||
|
|
56099f5f07 | ||
|
|
b716661b7d | ||
|
|
12bbe48970 | ||
|
|
849428ab10 | ||
|
|
8551af74be | ||
|
|
8f6be63def | ||
|
|
a69868f18b | ||
|
|
e9f06342a7 | ||
|
|
e7bbae8d18 | ||
|
|
2abe15c2b1 | ||
|
|
7f09ee550f | ||
|
|
a14fb38579 | ||
|
|
4c80cd16fe | ||
|
|
53bf8f39fd | ||
|
|
fabb907ea1 | ||
|
|
9aec3010fc | ||
|
|
b7029eeb64 | ||
|
|
045a732191 | ||
|
|
097c964a3b | ||
|
|
9617878f5a | ||
| 2d7425141f | |||
|
|
01a6ba4378 | ||
| 55c81b42e8 | |||
|
|
520a5188ba | ||
| f9544bb605 | |||
|
|
2e4a6e5e11 | ||
| 9348deb6cd | |||
|
|
38d2142c67 | ||
| a72fa80daa | |||
|
|
2195d42413 | ||
| 4d856155d9 | |||
|
|
1af9f42be3 | ||
| a58b8c6677 | |||
|
|
149bed3698 | ||
| 51151410ae | |||
|
|
9d1f266be7 | ||
| 536a2a7ac8 | |||
|
|
de5bd091be | ||
| 4564816b16 | |||
|
|
174d78b37d | ||
| faddbb8ed6 | |||
|
|
0918f6cf72 | ||
| 8c7e28a09f | |||
|
|
9aca9b443f | ||
| 5811923813 | |||
|
|
0c7486ce28 | ||
| a8584f5e53 | |||
|
|
4d8b288a9c | ||
| 57cbf2e237 | |||
|
|
8f2fdddff5 | ||
| d4cd3b4e87 | |||
|
|
0bb3d59ca9 | ||
| 5ea5bf2f40 | |||
|
|
02339893ee | ||
| bd5556bb7e | |||
|
|
58924e1710 | ||
| 567fc62d56 | |||
|
|
1fcfc33d5c | ||
| 83c8f4af55 | |||
|
|
04692a185c | ||
| 61551bf0a0 | |||
|
|
0f62be1abd | ||
| 022f628db5 | |||
|
|
d74550c605 | ||
| fa92407d7f | |||
|
|
c3374948cb | ||
| 99df9d343b | |||
|
|
3927860245 | ||
| 1757fad8d3 | |||
|
|
63f7e3e6cc | ||
| 13bb3de4a9 | |||
|
|
2345b401cb | ||
| 29c1f24d8b | |||
|
|
4b04dfed2a | ||
| 2635a29d8b | |||
|
|
c0dafc927d | ||
| 39c46ca166 | |||
|
|
4cd6216fb7 | ||
| 7cd76a95fa | |||
|
|
9e49d133ec | ||
| 95e8ebd1d2 | |||
|
|
1970bb04c4 | ||
| fe7c57775d | |||
|
|
2ad5b66272 | ||
| 70cb160a57 | |||
|
|
8e39a7fe5d | ||
| 05f2debf10 | |||
|
|
e28a86d705 | ||
| 97d5e7871c | |||
|
|
8a23f26a47 | ||
| b0ccee4daa | |||
|
|
cdb06e2eab | ||
| bc16e4edc8 | |||
|
|
58d24cb597 | ||
| 5e6df966d1 | |||
|
|
92b2f2ce94 | ||
| c7c1a5e537 | |||
|
|
9a846ae05b | ||
| 43787e6b91 | |||
|
|
98a0748143 | ||
| 3bad2edce5 | |||
|
|
1c45f39d36 | ||
| 0921d97ace | |||
|
|
f13446fb73 | ||
| 7758ec8b2e | |||
|
|
f2d2f2e5fd | ||
| 84fb453641 | |||
|
|
92add102a8 | ||
| a5ee1ce74e | |||
|
|
996fb4c849 | ||
| 22d18ed609 | |||
|
|
e934c4f02c | ||
| 8222f314ea | |||
|
|
487d897450 | ||
| 31fec846d6 | |||
|
|
a3167d4cc9 | ||
| ca73fc9f0a | |||
|
|
440f96fc16 | ||
| 58f26e7f8c | |||
|
|
79f23264ef | ||
| ed143476ed | |||
|
|
65dea1d4c9 | ||
| f8fa793784 | |||
|
|
0aa4bc334e | ||
| 9feabeea59 | |||
|
|
d2017eb6e3 | ||
| 63fd02cf91 | |||
|
|
69bf91b1b9 | ||
| 4400227d18 | |||
|
|
ac8645466f | ||
| caf2d8de0a | |||
|
|
ff42b06ae4 | ||
| b17b35908c | |||
|
|
f091340da3 | ||
| 639fdfecc8 | |||
|
|
d95218917e | ||
| df66fd00be | |||
|
|
7b36000b7d | ||
| 120e4b4ff9 | |||
|
|
dd42265f90 | ||
| 990e68acfc | |||
|
|
379cab296c | ||
| 9fb170bac9 | |||
|
|
8f8252cfc5 | ||
| af1890a4b3 | |||
|
|
6810b352c5 | ||
| 819fa29d1e | |||
|
|
78637dc14f | ||
| 044d41db9c | |||
|
|
50dcc47836 | ||
|
|
94e6c2d5d7 | ||
|
|
c4b91d6585 | ||
|
|
4614931ae1 | ||
|
|
956e90aa45 | ||
|
|
7e0f6cb13a | ||
|
|
3c32b7a7ef | ||
|
|
3f6c06add5 | ||
|
|
885933626d | ||
|
|
5b139b06f1 | ||
|
|
b3fe050512 | ||
|
|
c60a5e0474 | ||
|
|
f2dd3473b3 | ||
|
|
bae8b1b788 | ||
|
|
459876c8b7 | ||
|
|
1a0cb1b7f9 | ||
|
|
31d0b2db22 | ||
|
|
9f76b3ee3d | ||
|
|
5449026041 | ||
|
|
c7e5ee8512 | ||
|
|
2f43e2731f | ||
|
|
7f6240caf3 | ||
|
|
4d672f8fb6 | ||
|
|
7e931ed08f | ||
|
|
297fceff2e | ||
|
|
d5060197f5 | ||
|
|
1c9cf442b6 | ||
|
|
c5fbf6ee0c | ||
|
|
9eca049323 | ||
|
|
4d8d075189 | ||
|
|
60bb0e91e8 | ||
|
|
a2e7f01cbb | ||
|
|
9a58b60f0e | ||
|
|
9de96a76d0 | ||
|
|
edbfde8c6f | ||
|
|
a8ea6532f9 | ||
|
|
632681c0df | ||
|
|
c21afd4263 | ||
|
|
5b5c5ab9ff | ||
|
|
74e0820911 | ||
|
|
5d81842930 | ||
|
|
971d6c3f8a | ||
|
|
10e95cfdd2 | ||
|
|
ea5443f955 | ||
|
|
7c269e42e8 | ||
|
|
2c5c2b715e | ||
|
|
e62aac03cf | ||
|
|
eeffe92da0 | ||
|
|
6758585c36 | ||
|
|
61df3fd304 | ||
|
|
53a6353e19 | ||
|
|
94b336247e | ||
|
|
c5a56a8606 | ||
|
|
bfada30290 | ||
|
|
b8444b1780 | ||
|
|
faaa03bac4 | ||
|
|
3cc9d50764 | ||
|
|
01bb75947c | ||
|
|
18d7ba1f2c | ||
|
|
40e00d13f1 | ||
|
|
2957268629 | ||
|
|
0baf2d3e7f | ||
|
|
521471e5ec | ||
|
|
b2f7157cb9 | ||
|
|
77f12f6441 | ||
|
|
a9ca135ad5 | ||
|
|
568ded9031 | ||
|
|
3b2f6ec2ec | ||
|
|
4b4f30cdb3 | ||
|
|
1906208025 | ||
|
|
a70238f84a | ||
|
|
8fee185c60 | ||
|
|
1e3b86ffdf | ||
|
|
7c502f3da0 | ||
|
|
07d22a2bd6 | ||
|
|
38bda00c92 | ||
|
|
22d4295cd8 | ||
|
|
8fec2bc49f | ||
|
|
be1cf9bba8 | ||
|
|
a1a7b2d6fe | ||
|
|
43c8a872ca | ||
|
|
ed3094a7a7 | ||
|
|
e8353f68f1 | ||
|
|
eb006ab546 | ||
|
|
3d03213587 | ||
|
|
eba973c28e | ||
|
|
9502bef6da | ||
|
|
7e827ab3ce | ||
|
|
6bf02fd0bd | ||
|
|
2f0f4c3f75 | ||
|
|
5511aba529 | ||
|
|
9a2abd98fd | ||
|
|
0178ded8fa | ||
|
|
524e7eac8b | ||
|
|
d90dd083f2 | ||
|
|
08149135c7 | ||
|
|
d0e1312abf | ||
|
|
e6c1b1f384 | ||
|
|
2c2ab77009 | ||
|
|
3eb508de82 | ||
|
|
a35c406024 | ||
|
|
460d9e8dd6 | ||
|
|
277445cc5d | ||
|
|
91a1b2ece2 | ||
|
|
5b32ed3e33 | ||
|
|
891953d445 | ||
|
|
691663bca4 | ||
|
|
bc0ec5129b | ||
|
|
97cffe8dbd | ||
|
|
bf47180828 | ||
|
|
945070c0da | ||
|
|
742def991d | ||
|
|
793f5f902c | ||
|
|
2c7175b892 | ||
|
|
e71280189b | ||
|
|
18cc20ff7b | ||
|
|
81e99c67ea | ||
|
|
514bc6bd83 | ||
|
|
71e0049622 | ||
|
|
87557b66e4 | ||
|
|
02d9811918 | ||
|
|
c0ebc16d97 | ||
|
|
efbea846b6 | ||
|
|
caec3ad026 | ||
|
|
aeed714693 | ||
|
|
c83329b90a | ||
|
|
5f24e55c23 | ||
|
|
a70f1e474a | ||
|
|
5a95df9947 | ||
|
|
eb45f9bfb1 | ||
|
|
50c528ccf9 | ||
|
|
00b512063a | ||
|
|
a499643f33 | ||
|
|
d373e55b8d | ||
|
|
12c3070bab | ||
|
|
b6a1ed0cc0 |
53 changed files with 10633 additions and 623 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -4,3 +4,9 @@
|
|||
# Knowledge base
|
||||
KB/
|
||||
.core/
|
||||
|
||||
# Local Go tooling caches
|
||||
.cache/
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
.gopath/
|
||||
|
|
|
|||
30
Makefile
Normal file
30
Makefile
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
.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
|
||||
11
README.md
11
README.md
|
|
@ -45,6 +45,17 @@ go test -bench=. ./...
|
|||
go build ./...
|
||||
```
|
||||
|
||||
For repeatable local runs in a clean workspace, the repo also ships a
|
||||
`Makefile` with the standard workflow targets:
|
||||
|
||||
```bash
|
||||
make build
|
||||
make vet
|
||||
make test
|
||||
make cover
|
||||
make tidy
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
European Union Public Licence 1.2 — see [LICENCE](LICENCE) for details.
|
||||
|
|
|
|||
|
|
@ -111,6 +111,13 @@ func ClassifyCorpus(ctx context.Context, model inference.TextModel,
|
|||
if err != nil {
|
||||
return log.E("ClassifyCorpus", "classify batch", err)
|
||||
}
|
||||
if len(results) != len(batch) {
|
||||
return log.E(
|
||||
"ClassifyCorpus",
|
||||
core.Sprintf("classify batch returned %d results for %d prompts", len(results), len(batch)),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
for i, r := range results {
|
||||
domain := mapTokenToDomain(r.Token.Text)
|
||||
batch[i].record["domain_1b"] = domain
|
||||
|
|
|
|||
|
|
@ -67,11 +67,11 @@ func (m *mockModel) BatchGenerate(_ context.Context, _ []string, _ ...inference.
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockModel) ModelType() string { return "mock" }
|
||||
func (m *mockModel) Info() inference.ModelInfo { return inference.ModelInfo{} }
|
||||
func (m *mockModel) ModelType() string { return "mock" }
|
||||
func (m *mockModel) Info() inference.ModelInfo { return inference.ModelInfo{} }
|
||||
func (m *mockModel) Metrics() inference.GenerateMetrics { return inference.GenerateMetrics{} }
|
||||
func (m *mockModel) Err() error { return nil }
|
||||
func (m *mockModel) Close() error { return nil }
|
||||
func (m *mockModel) Err() error { return nil }
|
||||
func (m *mockModel) Close() error { return nil }
|
||||
|
||||
func TestClassifyCorpus_Basic(t *testing.T) {
|
||||
model := &mockModel{
|
||||
|
|
@ -183,3 +183,31 @@ func TestClassifyCorpus_DomainMapping(t *testing.T) {
|
|||
t.Errorf("ByDomain[ethical] = %d, want 1", stats.ByDomain["ethical"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyCorpus_ResultCountMismatch(t *testing.T) {
|
||||
model := &mockModel{
|
||||
classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) {
|
||||
if len(prompts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []inference.ClassifyResult{{Token: inference.Token{Text: "technical"}}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
input := core.NewReader(
|
||||
`{"prompt":"Delete the file now"}` + "\n" +
|
||||
`{"prompt":"Create the repo"}` + "\n",
|
||||
)
|
||||
|
||||
var output bytes.Buffer
|
||||
stats, err := ClassifyCorpus(context.Background(), model, input, &output, WithBatchSize(16))
|
||||
if err == nil {
|
||||
t.Fatal("ClassifyCorpus returned nil error, want mismatch failure")
|
||||
}
|
||||
if stats.Total != 0 {
|
||||
t.Errorf("Total = %d, want 0", stats.Total)
|
||||
}
|
||||
if output.Len() != 0 {
|
||||
t.Errorf("output len = %d, want 0", output.Len())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
compose.go
62
compose.go
|
|
@ -1,6 +1,10 @@
|
|||
package i18n
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// S creates a new Subject with the given noun and value.
|
||||
//
|
||||
|
|
@ -10,6 +14,23 @@ func S(noun string, value any) *Subject {
|
|||
return &Subject{Noun: noun, Value: value, count: 1}
|
||||
}
|
||||
|
||||
// ComposeIntent renders an intent's templates into concrete output.
|
||||
func ComposeIntent(intent Intent, subject *Subject) Composed {
|
||||
return intent.Compose(subject)
|
||||
}
|
||||
|
||||
// Compose renders an intent's templates into concrete output.
|
||||
func (i Intent) Compose(subject *Subject) Composed {
|
||||
data := newTemplateData(subject)
|
||||
return Composed{
|
||||
Question: executeIntentTemplate(i.Question, data),
|
||||
Confirm: executeIntentTemplate(i.Confirm, data),
|
||||
Success: executeIntentTemplate(i.Success, data),
|
||||
Failure: executeIntentTemplate(i.Failure, data),
|
||||
Meta: i.Meta,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Subject) Count(n int) *Subject {
|
||||
if s == nil {
|
||||
return nil
|
||||
|
|
@ -65,15 +86,40 @@ func (s *Subject) String() string {
|
|||
if stringer, ok := s.Value.(fmt.Stringer); ok {
|
||||
return stringer.String()
|
||||
}
|
||||
return fmt.Sprint(s.Value)
|
||||
return core.Sprintf("%v", s.Value)
|
||||
}
|
||||
|
||||
func (s *Subject) IsPlural() bool { return s != nil && s.count != 1 }
|
||||
func (s *Subject) CountInt() int { if s == nil { return 1 }; return s.count }
|
||||
func (s *Subject) CountString() string { if s == nil { return "1" }; return fmt.Sprint(s.count) }
|
||||
func (s *Subject) GenderString() string { if s == nil { return "" }; return s.gender }
|
||||
func (s *Subject) LocationString() string { if s == nil { return "" }; return s.location }
|
||||
func (s *Subject) NounString() string { if s == nil { return "" }; return s.Noun }
|
||||
func (s *Subject) IsPlural() bool { return s != nil && s.count != 1 }
|
||||
func (s *Subject) CountInt() int {
|
||||
if s == nil {
|
||||
return 1
|
||||
}
|
||||
return s.count
|
||||
}
|
||||
func (s *Subject) CountString() string {
|
||||
if s == nil {
|
||||
return "1"
|
||||
}
|
||||
return FormatNumber(int64(s.count))
|
||||
}
|
||||
func (s *Subject) GenderString() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.gender
|
||||
}
|
||||
func (s *Subject) LocationString() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.location
|
||||
}
|
||||
func (s *Subject) NounString() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.Noun
|
||||
}
|
||||
func (s *Subject) FormalityString() string {
|
||||
if s == nil {
|
||||
return FormalityNeutral.String()
|
||||
|
|
|
|||
|
|
@ -41,6 +41,21 @@ func TestSubject_Count_Good(t *testing.T) {
|
|||
assert.True(t, subj.IsPlural())
|
||||
}
|
||||
|
||||
func TestSubject_CountString_UsesLocaleFormatting(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
require.NoError(t, SetLanguage("fr"))
|
||||
|
||||
subj := S("file", "test.txt").Count(1234)
|
||||
assert.Equal(t, "1 234", subj.CountString())
|
||||
}
|
||||
|
||||
func TestSubject_Count_Bad_NilReceiver(t *testing.T) {
|
||||
var s *Subject
|
||||
result := s.Count(5)
|
||||
|
|
|
|||
101
context.go
101
context.go
|
|
@ -7,41 +7,72 @@ package i18n
|
|||
type TranslationContext struct {
|
||||
Context string
|
||||
Gender string
|
||||
Location string
|
||||
Formality Formality
|
||||
count int
|
||||
countSet bool
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
// C creates a TranslationContext.
|
||||
func C(context string) *TranslationContext {
|
||||
return &TranslationContext{Context: context}
|
||||
return &TranslationContext{Context: context, count: 1}
|
||||
}
|
||||
|
||||
func (c *TranslationContext) WithGender(gender string) *TranslationContext {
|
||||
if c == nil { return nil }
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.Gender = gender
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) In(location string) *TranslationContext {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.Location = location
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) Formal() *TranslationContext {
|
||||
if c == nil { return nil }
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.Formality = FormalityFormal
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) Informal() *TranslationContext {
|
||||
if c == nil { return nil }
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.Formality = FormalityInformal
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) WithFormality(f Formality) *TranslationContext {
|
||||
if c == nil { return nil }
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.Formality = f
|
||||
return c
|
||||
}
|
||||
|
||||
// Count sets the count used for plural-sensitive translations.
|
||||
func (c *TranslationContext) Count(n int) *TranslationContext {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.count = n
|
||||
c.countSet = true
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) Set(key string, value any) *TranslationContext {
|
||||
if c == nil { return nil }
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if c.Extra == nil {
|
||||
c.Extra = make(map[string]any)
|
||||
}
|
||||
|
|
@ -50,21 +81,71 @@ func (c *TranslationContext) Set(key string, value any) *TranslationContext {
|
|||
}
|
||||
|
||||
func (c *TranslationContext) Get(key string) any {
|
||||
if c == nil || c.Extra == nil { return nil }
|
||||
if c == nil || c.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
return c.Extra[key]
|
||||
}
|
||||
|
||||
func (c *TranslationContext) ContextString() string {
|
||||
if c == nil { return "" }
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.Context
|
||||
}
|
||||
|
||||
func (c *TranslationContext) String() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.Context
|
||||
}
|
||||
|
||||
func (c *TranslationContext) GenderString() string {
|
||||
if c == nil { return "" }
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.Gender
|
||||
}
|
||||
|
||||
func (c *TranslationContext) LocationString() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.Location
|
||||
}
|
||||
|
||||
func (c *TranslationContext) FormalityValue() Formality {
|
||||
if c == nil { return FormalityNeutral }
|
||||
if c == nil {
|
||||
return FormalityNeutral
|
||||
}
|
||||
return c.Formality
|
||||
}
|
||||
|
||||
// CountInt returns the current count value.
|
||||
func (c *TranslationContext) CountInt() int {
|
||||
if c == nil {
|
||||
return 1
|
||||
}
|
||||
return c.count
|
||||
}
|
||||
|
||||
// CountString returns the current count value formatted as text.
|
||||
func (c *TranslationContext) CountString() string {
|
||||
if c == nil {
|
||||
return "1"
|
||||
}
|
||||
return FormatNumber(int64(c.count))
|
||||
}
|
||||
|
||||
// IsPlural reports whether the count is plural.
|
||||
func (c *TranslationContext) IsPlural() bool {
|
||||
return c != nil && c.count != 1
|
||||
}
|
||||
|
||||
func (c *TranslationContext) countValue() (int, bool) {
|
||||
if c == nil {
|
||||
return 1, false
|
||||
}
|
||||
return c.count, c.countSet
|
||||
}
|
||||
|
|
|
|||
83
context_map.go
Normal file
83
context_map.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -14,6 +14,10 @@ func TestC_Good(t *testing.T) {
|
|||
require.NotNil(t, ctx)
|
||||
assert.Equal(t, "navigation", ctx.Context)
|
||||
assert.Equal(t, "navigation", ctx.ContextString())
|
||||
assert.Equal(t, "navigation", ctx.String())
|
||||
assert.Equal(t, 1, ctx.CountInt())
|
||||
assert.Equal(t, "1", ctx.CountString())
|
||||
assert.False(t, ctx.IsPlural())
|
||||
}
|
||||
|
||||
func TestC_Good_EmptyContext(t *testing.T) {
|
||||
|
|
@ -27,7 +31,9 @@ func TestC_Good_EmptyContext(t *testing.T) {
|
|||
func TestTranslationContext_NilReceiver_Good(t *testing.T) {
|
||||
var ctx *TranslationContext
|
||||
|
||||
assert.Nil(t, ctx.Count(2))
|
||||
assert.Nil(t, ctx.WithGender("masculine"))
|
||||
assert.Nil(t, ctx.In("workspace"))
|
||||
assert.Nil(t, ctx.Formal())
|
||||
assert.Nil(t, ctx.Informal())
|
||||
assert.Nil(t, ctx.WithFormality(FormalityFormal))
|
||||
|
|
@ -35,7 +41,11 @@ func TestTranslationContext_NilReceiver_Good(t *testing.T) {
|
|||
assert.Nil(t, ctx.Get("key"))
|
||||
assert.Equal(t, "", ctx.ContextString())
|
||||
assert.Equal(t, "", ctx.GenderString())
|
||||
assert.Equal(t, "", ctx.LocationString())
|
||||
assert.Equal(t, FormalityNeutral, ctx.FormalityValue())
|
||||
assert.Equal(t, 1, ctx.CountInt())
|
||||
assert.Equal(t, "1", ctx.CountString())
|
||||
assert.False(t, ctx.IsPlural())
|
||||
}
|
||||
|
||||
// --- WithGender ---
|
||||
|
|
@ -46,6 +56,17 @@ func TestTranslationContext_WithGender_Good(t *testing.T) {
|
|||
assert.Equal(t, "feminine", ctx.GenderString())
|
||||
}
|
||||
|
||||
func TestTranslationContext_In_Good(t *testing.T) {
|
||||
ctx := C("test").In("workspace")
|
||||
assert.Equal(t, "workspace", ctx.Location)
|
||||
assert.Equal(t, "workspace", ctx.LocationString())
|
||||
}
|
||||
|
||||
func TestTranslationContext_In_Bad_NilReceiver(t *testing.T) {
|
||||
var ctx *TranslationContext
|
||||
assert.Nil(t, ctx.In("workspace"))
|
||||
}
|
||||
|
||||
// --- Formal / Informal ---
|
||||
|
||||
func TestTranslationContext_Formal_Good(t *testing.T) {
|
||||
|
|
@ -80,6 +101,21 @@ func TestTranslationContext_WithFormality_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTranslationContext_CountString_UsesLocaleFormatting(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
require.NoError(t, SetLanguage("fr"))
|
||||
|
||||
ctx := C("test").Count(1234)
|
||||
assert.Equal(t, "1 234", ctx.CountString())
|
||||
}
|
||||
|
||||
// --- Set / Get ---
|
||||
|
||||
func TestTranslationContext_SetGet_Good(t *testing.T) {
|
||||
|
|
@ -105,12 +141,18 @@ func TestTranslationContext_Get_Bad_NilExtra(t *testing.T) {
|
|||
|
||||
func TestTranslationContext_FullChain_Good(t *testing.T) {
|
||||
ctx := C("medical").
|
||||
Count(3).
|
||||
WithGender("feminine").
|
||||
In("clinic").
|
||||
Formal().
|
||||
Set("speciality", "cardiology")
|
||||
|
||||
assert.Equal(t, "medical", ctx.ContextString())
|
||||
assert.Equal(t, 3, ctx.CountInt())
|
||||
assert.Equal(t, "3", ctx.CountString())
|
||||
assert.True(t, ctx.IsPlural())
|
||||
assert.Equal(t, "feminine", ctx.GenderString())
|
||||
assert.Equal(t, "clinic", ctx.LocationString())
|
||||
assert.Equal(t, FormalityFormal, ctx.FormalityValue())
|
||||
assert.Equal(t, "cardiology", ctx.Get("speciality"))
|
||||
}
|
||||
|
|
|
|||
434
core_service.go
434
core_service.go
|
|
@ -18,14 +18,33 @@ type CoreService struct {
|
|||
|
||||
missingKeys []MissingKey
|
||||
missingKeysMu sync.Mutex
|
||||
hookInstalled bool
|
||||
}
|
||||
|
||||
var _ core.Startable = (*CoreService)(nil)
|
||||
var _ core.Stoppable = (*CoreService)(nil)
|
||||
|
||||
func (s *CoreService) wrapped() *Service {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.svc
|
||||
}
|
||||
|
||||
// ServiceOptions configures the i18n Core service.
|
||||
type ServiceOptions struct {
|
||||
// Language overrides auto-detection (e.g., "en-GB", "de")
|
||||
Language string
|
||||
// Fallback sets the fallback language for missing translations.
|
||||
Fallback string
|
||||
// Formality sets the default formality level.
|
||||
Formality Formality
|
||||
// Location sets the default location context.
|
||||
Location string
|
||||
// Mode sets the translation mode (Normal, Strict, Collect)
|
||||
Mode Mode
|
||||
// Debug prefixes translated output with the message key.
|
||||
Debug bool
|
||||
// ExtraFS loads additional translation files on top of the embedded defaults.
|
||||
// Each entry is an fs.FS + directory path within it.
|
||||
ExtraFS []FSSource
|
||||
|
|
@ -37,6 +56,36 @@ type FSSource struct {
|
|||
Dir string
|
||||
}
|
||||
|
||||
// String returns a compact summary of the filesystem source.
|
||||
func (s FSSource) String() string {
|
||||
if s.Dir == "" {
|
||||
return core.Sprintf("FSSource{fs=%T}", s.FS)
|
||||
}
|
||||
return core.Sprintf("FSSource{fs=%T dir=%q}", s.FS, s.Dir)
|
||||
}
|
||||
|
||||
// String returns a compact summary of the service options.
|
||||
func (o ServiceOptions) String() string {
|
||||
extraFS := "[]"
|
||||
if len(o.ExtraFS) > 0 {
|
||||
parts := make([]string, len(o.ExtraFS))
|
||||
for i, src := range o.ExtraFS {
|
||||
parts[i] = src.String()
|
||||
}
|
||||
extraFS = "[" + core.Join(", ", parts...) + "]"
|
||||
}
|
||||
return core.Sprintf(
|
||||
"ServiceOptions{language=%q fallback=%q formality=%s location=%q mode=%s debug=%t extraFS=%s}",
|
||||
o.Language,
|
||||
o.Fallback,
|
||||
o.Formality,
|
||||
o.Location,
|
||||
o.Mode,
|
||||
o.Debug,
|
||||
extraFS,
|
||||
)
|
||||
}
|
||||
|
||||
// NewCoreService creates an i18n Core service factory.
|
||||
// Automatically loads locale filesystems from:
|
||||
// 1. Embedded go-i18n base translations (grammar, verbs, nouns)
|
||||
|
|
@ -56,13 +105,28 @@ func NewCoreService(opts ServiceOptions) func(*core.Core) (any, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Preserve the same init-time locale registration behaviour used by Init().
|
||||
// Core bootstrap should not bypass packages that registered locale files
|
||||
// before the service was constructed.
|
||||
loadRegisteredLocales(svc)
|
||||
|
||||
if opts.Language != "" {
|
||||
if langErr := svc.SetLanguage(opts.Language); langErr != nil {
|
||||
return nil, core.Wrap(langErr, "NewCoreService", core.Sprintf("i18n: invalid language %q", 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.SetDebug(opts.Debug)
|
||||
SetDefault(svc)
|
||||
|
||||
return &CoreService{
|
||||
|
|
@ -74,30 +138,54 @@ func NewCoreService(opts ServiceOptions) func(*core.Core) (any, error) {
|
|||
}
|
||||
|
||||
// OnStartup initialises the i18n service.
|
||||
func (s *CoreService) OnStartup(_ context.Context) error {
|
||||
if s.svc.Mode() == ModeCollect {
|
||||
OnMissingKey(s.handleMissingKey)
|
||||
func (s *CoreService) OnStartup(_ context.Context) core.Result {
|
||||
if svc := s.wrapped(); svc != nil && svc.Mode() == ModeCollect {
|
||||
s.ensureMissingKeyCollector()
|
||||
}
|
||||
return nil
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
// OnShutdown finalises the i18n service.
|
||||
func (s *CoreService) OnShutdown(_ context.Context) core.Result {
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
func (s *CoreService) ensureMissingKeyCollector() {
|
||||
if s == nil || s.svc == nil || s.hookInstalled {
|
||||
return
|
||||
}
|
||||
AddMissingKeyHandler(s.handleMissingKey)
|
||||
s.hookInstalled = true
|
||||
}
|
||||
|
||||
func (s *CoreService) handleMissingKey(mk MissingKey) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
s.missingKeys = append(s.missingKeys, mk)
|
||||
s.missingKeys = append(s.missingKeys, cloneMissingKey(mk))
|
||||
}
|
||||
|
||||
// MissingKeys returns all missing keys collected in collect mode.
|
||||
func (s *CoreService) MissingKeys() []MissingKey {
|
||||
if s == nil {
|
||||
return []MissingKey{}
|
||||
}
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
result := make([]MissingKey, len(s.missingKeys))
|
||||
copy(result, s.missingKeys)
|
||||
for i, mk := range s.missingKeys {
|
||||
result[i] = cloneMissingKey(mk)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearMissingKeys resets the collected missing keys.
|
||||
func (s *CoreService) ClearMissingKeys() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
s.missingKeys = s.missingKeys[:0]
|
||||
|
|
@ -105,15 +193,335 @@ func (s *CoreService) ClearMissingKeys() {
|
|||
|
||||
// SetMode changes the translation mode.
|
||||
func (s *CoreService) SetMode(mode Mode) {
|
||||
s.svc.SetMode(mode)
|
||||
if mode == ModeCollect {
|
||||
OnMissingKey(s.handleMissingKey)
|
||||
} else {
|
||||
OnMissingKey(nil)
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetMode(mode)
|
||||
}
|
||||
if s != nil && s.svc != nil && mode == ModeCollect {
|
||||
s.ensureMissingKeyCollector()
|
||||
}
|
||||
}
|
||||
|
||||
// Mode returns the current translation mode.
|
||||
func (s *CoreService) Mode() Mode {
|
||||
return s.svc.Mode()
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Mode()
|
||||
}
|
||||
return ModeNormal
|
||||
}
|
||||
|
||||
// CurrentMode returns the current translation mode.
|
||||
func (s *CoreService) CurrentMode() Mode {
|
||||
return s.Mode()
|
||||
}
|
||||
|
||||
// T translates a message through the wrapped i18n service.
|
||||
func (s *CoreService) T(messageID string, args ...any) string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.T(messageID, args...)
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
// Translate translates a message through the wrapped i18n service.
|
||||
func (s *CoreService) Translate(messageID string, args ...any) core.Result {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Translate(messageID, args...)
|
||||
}
|
||||
return core.Result{Value: messageID, OK: false}
|
||||
}
|
||||
|
||||
// Raw translates without namespace handler magic.
|
||||
func (s *CoreService) Raw(messageID string, args ...any) string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Raw(messageID, args...)
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
// AddMessages adds message strings to the wrapped service.
|
||||
func (s *CoreService) AddMessages(lang string, messages map[string]string) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.AddMessages(lang, messages)
|
||||
}
|
||||
}
|
||||
|
||||
// SetLanguage changes the wrapped service language.
|
||||
func (s *CoreService) SetLanguage(lang string) error {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.SetLanguage(lang)
|
||||
}
|
||||
return ErrServiceNotInitialised
|
||||
}
|
||||
|
||||
// Language returns the wrapped service language.
|
||||
func (s *CoreService) Language() string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Language()
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// CurrentLanguage returns the wrapped service language.
|
||||
func (s *CoreService) CurrentLanguage() string {
|
||||
return s.Language()
|
||||
}
|
||||
|
||||
// CurrentLang is a short alias for CurrentLanguage.
|
||||
func (s *CoreService) CurrentLang() string {
|
||||
return s.CurrentLanguage()
|
||||
}
|
||||
|
||||
// Prompt translates a prompt key from the prompt namespace using the wrapped service.
|
||||
func (s *CoreService) Prompt(key string) string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Prompt(key)
|
||||
}
|
||||
return namespaceLookupKey("prompt", key)
|
||||
}
|
||||
|
||||
// CurrentPrompt is a short alias for Prompt.
|
||||
func (s *CoreService) CurrentPrompt(key string) string {
|
||||
return s.Prompt(key)
|
||||
}
|
||||
|
||||
// Lang translates a language label from the lang namespace using the wrapped service.
|
||||
func (s *CoreService) Lang(key string) string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Lang(key)
|
||||
}
|
||||
return namespaceLookupKey("lang", key)
|
||||
}
|
||||
|
||||
// SetFallback changes the wrapped service fallback language.
|
||||
func (s *CoreService) SetFallback(lang string) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetFallback(lang)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback returns the wrapped service fallback language.
|
||||
func (s *CoreService) Fallback() string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Fallback()
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// CurrentFallback returns the wrapped service fallback language.
|
||||
func (s *CoreService) CurrentFallback() string {
|
||||
return s.Fallback()
|
||||
}
|
||||
|
||||
// SetFormality changes the wrapped service default formality.
|
||||
func (s *CoreService) SetFormality(f Formality) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetFormality(f)
|
||||
}
|
||||
}
|
||||
|
||||
// Formality returns the wrapped service default formality.
|
||||
func (s *CoreService) Formality() Formality {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Formality()
|
||||
}
|
||||
return FormalityNeutral
|
||||
}
|
||||
|
||||
// CurrentFormality returns the wrapped service default formality.
|
||||
func (s *CoreService) CurrentFormality() Formality {
|
||||
return s.Formality()
|
||||
}
|
||||
|
||||
// SetLocation changes the wrapped service default location.
|
||||
func (s *CoreService) SetLocation(location string) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
// Location returns the wrapped service default location.
|
||||
func (s *CoreService) Location() string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Location()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CurrentLocation returns the wrapped service default location.
|
||||
func (s *CoreService) CurrentLocation() string {
|
||||
return s.Location()
|
||||
}
|
||||
|
||||
// SetDebug changes the wrapped service debug mode.
|
||||
func (s *CoreService) SetDebug(enabled bool) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetDebug(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Debug reports whether wrapped service debug mode is enabled.
|
||||
func (s *CoreService) Debug() bool {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Debug()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CurrentDebug reports whether wrapped service debug mode is enabled.
|
||||
func (s *CoreService) CurrentDebug() bool {
|
||||
return s.Debug()
|
||||
}
|
||||
|
||||
// State returns a copy-safe snapshot of the wrapped service configuration.
|
||||
func (s *CoreService) State() ServiceState {
|
||||
if s == nil || s.svc == nil {
|
||||
return defaultServiceStateSnapshot()
|
||||
}
|
||||
return s.svc.State()
|
||||
}
|
||||
|
||||
// CurrentState is a more explicit alias for State.
|
||||
func (s *CoreService) CurrentState() ServiceState {
|
||||
return s.State()
|
||||
}
|
||||
|
||||
// String returns a concise snapshot of the wrapped service state.
|
||||
func (s *CoreService) String() string {
|
||||
return s.State().String()
|
||||
}
|
||||
|
||||
// AddHandler appends handlers to the wrapped service's chain.
|
||||
func (s *CoreService) AddHandler(handlers ...KeyHandler) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.AddHandler(handlers...)
|
||||
}
|
||||
}
|
||||
|
||||
// SetHandlers replaces the wrapped service's handler chain.
|
||||
func (s *CoreService) SetHandlers(handlers ...KeyHandler) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetHandlers(handlers...)
|
||||
}
|
||||
}
|
||||
|
||||
// PrependHandler inserts handlers at the front of the wrapped service's chain.
|
||||
func (s *CoreService) PrependHandler(handlers ...KeyHandler) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.PrependHandler(handlers...)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearHandlers removes all handlers from the wrapped service.
|
||||
func (s *CoreService) ClearHandlers() {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.ClearHandlers()
|
||||
}
|
||||
}
|
||||
|
||||
// ResetHandlers restores the wrapped service's default handler chain.
|
||||
func (s *CoreService) ResetHandlers() {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.ResetHandlers()
|
||||
}
|
||||
}
|
||||
|
||||
// Handlers returns a copy of the wrapped service's handler chain.
|
||||
func (s *CoreService) Handlers() []KeyHandler {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Handlers()
|
||||
}
|
||||
return []KeyHandler{}
|
||||
}
|
||||
|
||||
// CurrentHandlers returns a copy of the wrapped service's handler chain.
|
||||
func (s *CoreService) CurrentHandlers() []KeyHandler {
|
||||
return s.Handlers()
|
||||
}
|
||||
|
||||
// AddLoader loads extra locale data into the wrapped service.
|
||||
func (s *CoreService) AddLoader(loader Loader) error {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.AddLoader(loader)
|
||||
}
|
||||
return ErrServiceNotInitialised
|
||||
}
|
||||
|
||||
// LoadFS loads locale data from a filesystem into the wrapped service.
|
||||
func (s *CoreService) LoadFS(fsys fs.FS, dir string) error {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.LoadFS(fsys, dir)
|
||||
}
|
||||
return ErrServiceNotInitialised
|
||||
}
|
||||
|
||||
// AvailableLanguages returns the wrapped service languages.
|
||||
func (s *CoreService) AvailableLanguages() []string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.AvailableLanguages()
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// CurrentAvailableLanguages returns the wrapped service languages.
|
||||
func (s *CoreService) CurrentAvailableLanguages() []string {
|
||||
return s.AvailableLanguages()
|
||||
}
|
||||
|
||||
// Direction returns the wrapped service text direction.
|
||||
func (s *CoreService) Direction() TextDirection {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Direction()
|
||||
}
|
||||
return DirLTR
|
||||
}
|
||||
|
||||
// CurrentDirection returns the wrapped service text direction.
|
||||
func (s *CoreService) CurrentDirection() TextDirection {
|
||||
return s.Direction()
|
||||
}
|
||||
|
||||
// CurrentTextDirection is a more explicit alias for CurrentDirection.
|
||||
func (s *CoreService) CurrentTextDirection() TextDirection {
|
||||
return s.CurrentDirection()
|
||||
}
|
||||
|
||||
// IsRTL reports whether the wrapped service language is right-to-left.
|
||||
func (s *CoreService) IsRTL() bool {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.IsRTL()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RTL reports whether the wrapped service language is right-to-left.
|
||||
func (s *CoreService) RTL() bool {
|
||||
return s.IsRTL()
|
||||
}
|
||||
|
||||
// CurrentIsRTL reports whether the wrapped service language is right-to-left.
|
||||
func (s *CoreService) CurrentIsRTL() bool {
|
||||
return s.IsRTL()
|
||||
}
|
||||
|
||||
// CurrentRTL reports whether the wrapped service language is right-to-left.
|
||||
func (s *CoreService) CurrentRTL() bool {
|
||||
return s.CurrentIsRTL()
|
||||
}
|
||||
|
||||
// PluralCategory returns the plural category for the wrapped service language.
|
||||
func (s *CoreService) PluralCategory(n int) PluralCategory {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.PluralCategory(n)
|
||||
}
|
||||
return PluralOther
|
||||
}
|
||||
|
||||
// CurrentPluralCategory returns the plural category for the wrapped service language.
|
||||
func (s *CoreService) CurrentPluralCategory(n int) PluralCategory {
|
||||
return s.PluralCategory(n)
|
||||
}
|
||||
|
||||
// PluralCategoryOf is a short alias for CurrentPluralCategory.
|
||||
func (s *CoreService) PluralCategoryOf(n int) PluralCategory {
|
||||
return s.CurrentPluralCategory(n)
|
||||
}
|
||||
|
|
|
|||
105
core_service_test.go
Normal file
105
core_service_test.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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())
|
||||
}
|
||||
14
debug.go
14
debug.go
|
|
@ -1,24 +1,30 @@
|
|||
package i18n
|
||||
|
||||
import "dappco.re/go/core"
|
||||
|
||||
// SetDebug enables or disables debug mode on the default service.
|
||||
func SetDebug(enabled bool) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.SetDebug(enabled)
|
||||
}
|
||||
withDefaultService(func(svc *Service) { svc.SetDebug(enabled) })
|
||||
}
|
||||
|
||||
func (s *Service) SetDebug(enabled bool) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.debug = enabled
|
||||
}
|
||||
|
||||
func (s *Service) Debug() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.debug
|
||||
}
|
||||
|
||||
func debugFormat(key, text string) string {
|
||||
return "[" + key + "] " + text
|
||||
return core.Sprintf("[%s] %s", key, text)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,20 @@ func TestSetDebug_Good_ServiceLevel(t *testing.T) {
|
|||
assert.False(t, svc.Debug())
|
||||
}
|
||||
|
||||
func TestCurrentDebug_Good_PackageLevel(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
SetDebug(true)
|
||||
assert.True(t, CurrentDebug())
|
||||
|
||||
SetDebug(false)
|
||||
assert.False(t, CurrentDebug())
|
||||
}
|
||||
|
||||
func TestDebugFormat_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -69,3 +83,20 @@ func TestDebugMode_Good_Integration(t *testing.T) {
|
|||
got = svc.Raw("prompt.yes")
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
25
default_service.go
Normal file
25
default_service.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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)
|
||||
})
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ svc := i18n.Default()
|
|||
|
||||
// Option 2: Explicit creation with options
|
||||
svc, err := i18n.New(
|
||||
i18n.WithLanguage("en-GB"),
|
||||
i18n.WithFallback("en"),
|
||||
i18n.WithDefaultHandlers(),
|
||||
)
|
||||
|
|
@ -30,11 +31,31 @@ svc, err := i18n.NewWithFS(myFS, "locales")
|
|||
|
||||
The service automatically detects the system language from `LANG`, `LC_ALL`, or `LC_MESSAGES` environment variables using BCP 47 tag matching.
|
||||
|
||||
### Current-State Aliases
|
||||
|
||||
The API exposes `Current*` aliases for the most common service getters so call sites can choose between terse and explicit naming without changing behaviour:
|
||||
|
||||
- `CurrentLanguage()` / `CurrentLang()`
|
||||
- `CurrentAvailableLanguages()`
|
||||
- `CurrentMode()`
|
||||
- `CurrentFallback()`
|
||||
- `CurrentFormality()`
|
||||
- `CurrentLocation()`
|
||||
- `CurrentDirection()` / `CurrentTextDirection()`
|
||||
- `CurrentIsRTL()` / `CurrentRTL()`
|
||||
- `CurrentPluralCategory()` / `PluralCategoryOf()`
|
||||
- `CurrentDebug()`
|
||||
- `CurrentHandlers()`
|
||||
- `CurrentPrompt()`
|
||||
- `State()` / `CurrentState()` snapshot of the full service configuration
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| `WithFallback("en")` | Set fallback language for missing translations |
|
||||
| `WithLanguage("fr")` | Set the initial language before the service starts serving |
|
||||
| `WithLocation("workspace")` | Set the default location context |
|
||||
| `WithDefaultHandlers()` | Register the six built-in `i18n.*` namespace handlers |
|
||||
| `WithHandlers(h...)` | Replace handlers entirely |
|
||||
| `WithMode(ModeStrict)` | Panic on missing keys (useful in CI) |
|
||||
|
|
@ -194,7 +215,7 @@ T("i18n.count.person", 3) // "3 people"
|
|||
Produces past-tense completion messages.
|
||||
|
||||
```go
|
||||
T("i18n.done.delete", "config.yaml") // "Config.Yaml deleted"
|
||||
T("i18n.done.delete", "config.yaml") // "Config.yaml deleted"
|
||||
T("i18n.done.push", "commits") // "Commits pushed"
|
||||
T("i18n.done.delete") // "Deleted"
|
||||
```
|
||||
|
|
@ -216,7 +237,7 @@ Locale-aware number formatting.
|
|||
T("i18n.numeric.number", 1234567) // "1,234,567"
|
||||
T("i18n.numeric.decimal", 3.14) // "3.14"
|
||||
T("i18n.numeric.percent", 0.85) // "85%"
|
||||
T("i18n.numeric.bytes", 1536000) // "1.5 MB"
|
||||
T("i18n.numeric.bytes", 1536000) // "1.46 MB"
|
||||
T("i18n.numeric.ordinal", 3) // "3rd"
|
||||
T("i18n.numeric.ago", 5, "minutes") // "5 minutes ago"
|
||||
```
|
||||
|
|
@ -226,7 +247,7 @@ The shorthand `N()` function wraps this namespace:
|
|||
```go
|
||||
i18n.N("number", 1234567) // "1,234,567"
|
||||
i18n.N("percent", 0.85) // "85%"
|
||||
i18n.N("bytes", 1536000) // "1.5 MB"
|
||||
i18n.N("bytes", 1536000) // "1.46 MB"
|
||||
i18n.N("ordinal", 1) // "1st"
|
||||
```
|
||||
|
||||
|
|
@ -338,4 +359,4 @@ All grammar functions are available as Go template functions via `TemplateFuncs(
|
|||
template.New("").Funcs(i18n.TemplateFuncs())
|
||||
```
|
||||
|
||||
Available functions: `title`, `lower`, `upper`, `past`, `gerund`, `plural`, `pluralForm`, `article`, `quote`.
|
||||
Available functions: `title`, `lower`, `upper`, `past`, `gerund`, `plural`, `pluralForm`, `article`, `quote`, `label`, `progress`, `progressSubject`, `actionResult`, `actionFailed`, `timeAgo`, `formatAgo`.
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ The `irregularVerbs` and `irregularNouns` Go maps and the regular morphology rul
|
|||
|
||||
**French reversal**
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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**
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**88K seed corpus processing**
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,27 @@ func (l *FallbackLoader) Load(lang string) (map[string]i18n.Message, *i18n.Gramm
|
|||
}
|
||||
```
|
||||
|
||||
### Locale Providers
|
||||
|
||||
Packages that want to contribute more than one locale source can implement `LocaleProvider` and register it once:
|
||||
|
||||
```go
|
||||
type Provider struct{}
|
||||
|
||||
func (Provider) LocaleSources() []i18n.FSSource {
|
||||
return []i18n.FSSource{
|
||||
{FS: embedFS, Dir: "locales"},
|
||||
{FS: sharedFS, Dir: "translations"},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
i18n.RegisterLocaleProvider(Provider{})
|
||||
}
|
||||
```
|
||||
|
||||
This is the preferred path when a package needs to contribute translations to the default service without manually sequencing multiple `RegisterLocales()` calls.
|
||||
|
||||
## Custom Handlers
|
||||
|
||||
Handlers process keys before standard lookup. Use for dynamic patterns.
|
||||
|
|
|
|||
|
|
@ -214,8 +214,9 @@ i18n.Pluralize("file", 5) // "files"
|
|||
i18n.Pluralize("child", 2) // "children" (irregular)
|
||||
|
||||
// Articles
|
||||
i18n.Article("apple") // "an apple"
|
||||
i18n.Article("banana") // "a banana"
|
||||
i18n.Article("apple") // "an"
|
||||
i18n.ArticlePhrase("apple") // "an apple"
|
||||
i18n.ArticlePhrase("banana") // "a banana"
|
||||
|
||||
// Composed messages
|
||||
i18n.Label("status") // "Status:"
|
||||
|
|
@ -390,6 +391,7 @@ Use functional options when creating a service:
|
|||
|
||||
```go
|
||||
svc, err := i18n.New(
|
||||
i18n.WithLanguage("de-DE"), // Initial language
|
||||
i18n.WithFallback("de-DE"), // Fallback language
|
||||
i18n.WithFormality(i18n.FormalityFormal), // Default formality
|
||||
i18n.WithMode(i18n.ModeStrict), // Missing key mode
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -7,7 +7,7 @@ require golang.org/x/text v0.35.0
|
|||
require (
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/core/log v0.1.0
|
||||
forge.lthn.ai/core/go-inference v0.1.4
|
||||
dappco.re/go/core/inference v0.1.4
|
||||
)
|
||||
|
||||
require github.com/kr/text v0.2.0 // indirect
|
||||
|
|
|
|||
774
grammar.go
774
grammar.go
|
|
@ -2,6 +2,7 @@ package i18n
|
|||
|
||||
import (
|
||||
"maps"
|
||||
"strconv"
|
||||
"text/template"
|
||||
"unicode"
|
||||
|
||||
|
|
@ -10,31 +11,227 @@ import (
|
|||
|
||||
// GetGrammarData returns the grammar data for the specified language.
|
||||
func GetGrammarData(lang string) *GrammarData {
|
||||
lang = normalizeLanguageTag(lang)
|
||||
if lang == "" {
|
||||
return nil
|
||||
}
|
||||
grammarCacheMu.RLock()
|
||||
defer grammarCacheMu.RUnlock()
|
||||
return grammarCache[lang]
|
||||
if data, ok := grammarCache[lang]; ok && data != nil {
|
||||
return data
|
||||
}
|
||||
if base := baseLanguageTag(lang); base != "" {
|
||||
if data, ok := grammarCache[base]; ok && data != nil {
|
||||
return data
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetGrammarData sets the grammar data for a language, replacing any existing data.
|
||||
func SetGrammarData(lang string, data *GrammarData) {
|
||||
lang = normalizeLanguageTag(lang)
|
||||
if lang == "" {
|
||||
return
|
||||
}
|
||||
grammarCacheMu.Lock()
|
||||
defer grammarCacheMu.Unlock()
|
||||
grammarCache[lang] = data
|
||||
if data == nil {
|
||||
delete(grammarCache, lang)
|
||||
return
|
||||
}
|
||||
grammarCache[lang] = cloneGrammarData(data)
|
||||
}
|
||||
|
||||
// MergeGrammarData merges grammar data into the existing data for a language.
|
||||
// New entries are added; existing entries are overwritten per-key.
|
||||
func MergeGrammarData(lang string, data *GrammarData) {
|
||||
lang = normalizeLanguageTag(lang)
|
||||
if lang == "" || data == nil {
|
||||
return
|
||||
}
|
||||
grammarCacheMu.Lock()
|
||||
defer grammarCacheMu.Unlock()
|
||||
existing := grammarCache[lang]
|
||||
if existing == nil {
|
||||
grammarCache[lang] = data
|
||||
grammarCache[lang] = cloneGrammarData(data)
|
||||
return
|
||||
}
|
||||
if existing.Verbs == nil {
|
||||
existing.Verbs = make(map[string]VerbForms, len(data.Verbs))
|
||||
}
|
||||
if existing.Nouns == nil {
|
||||
existing.Nouns = make(map[string]NounForms, len(data.Nouns))
|
||||
}
|
||||
if existing.Words == nil {
|
||||
existing.Words = make(map[string]string, len(data.Words))
|
||||
}
|
||||
maps.Copy(existing.Verbs, data.Verbs)
|
||||
maps.Copy(existing.Nouns, data.Nouns)
|
||||
maps.Copy(existing.Words, data.Words)
|
||||
mergeArticleForms(&existing.Articles, data.Articles)
|
||||
mergePunctuationRules(&existing.Punct, data.Punct)
|
||||
mergeSignalData(&existing.Signals, data.Signals)
|
||||
if data.Number.ThousandsSep != "" {
|
||||
existing.Number.ThousandsSep = data.Number.ThousandsSep
|
||||
}
|
||||
if data.Number.DecimalSep != "" {
|
||||
existing.Number.DecimalSep = data.Number.DecimalSep
|
||||
}
|
||||
if data.Number.PercentFmt != "" {
|
||||
existing.Number.PercentFmt = data.Number.PercentFmt
|
||||
}
|
||||
}
|
||||
|
||||
func mergeArticleForms(dst *ArticleForms, src ArticleForms) {
|
||||
if dst == nil {
|
||||
return
|
||||
}
|
||||
if src.IndefiniteDefault != "" {
|
||||
dst.IndefiniteDefault = src.IndefiniteDefault
|
||||
}
|
||||
if src.IndefiniteVowel != "" {
|
||||
dst.IndefiniteVowel = src.IndefiniteVowel
|
||||
}
|
||||
if src.Definite != "" {
|
||||
dst.Definite = src.Definite
|
||||
}
|
||||
if len(src.ByGender) == 0 {
|
||||
return
|
||||
}
|
||||
if dst.ByGender == nil {
|
||||
dst.ByGender = make(map[string]string, len(src.ByGender))
|
||||
}
|
||||
maps.Copy(dst.ByGender, src.ByGender)
|
||||
}
|
||||
|
||||
func mergePunctuationRules(dst *PunctuationRules, src PunctuationRules) {
|
||||
if dst == nil {
|
||||
return
|
||||
}
|
||||
if src.LabelSuffix != "" {
|
||||
dst.LabelSuffix = src.LabelSuffix
|
||||
}
|
||||
if src.ProgressSuffix != "" {
|
||||
dst.ProgressSuffix = src.ProgressSuffix
|
||||
}
|
||||
}
|
||||
|
||||
func mergeSignalData(dst *SignalData, src SignalData) {
|
||||
if dst == nil {
|
||||
return
|
||||
}
|
||||
dst.NounDeterminers = appendUniqueStrings(dst.NounDeterminers, src.NounDeterminers...)
|
||||
dst.VerbAuxiliaries = appendUniqueStrings(dst.VerbAuxiliaries, src.VerbAuxiliaries...)
|
||||
dst.VerbInfinitive = appendUniqueStrings(dst.VerbInfinitive, src.VerbInfinitive...)
|
||||
dst.VerbNegation = appendUniqueStrings(dst.VerbNegation, src.VerbNegation...)
|
||||
if len(src.Priors) == 0 {
|
||||
return
|
||||
}
|
||||
if dst.Priors == nil {
|
||||
dst.Priors = make(map[string]map[string]float64, len(src.Priors))
|
||||
}
|
||||
for word, priors := range src.Priors {
|
||||
if dst.Priors[word] == nil {
|
||||
dst.Priors[word] = make(map[string]float64, len(priors))
|
||||
}
|
||||
maps.Copy(dst.Priors[word], priors)
|
||||
}
|
||||
}
|
||||
|
||||
func appendUniqueStrings(dst []string, values ...string) []string {
|
||||
if len(values) == 0 {
|
||||
return dst
|
||||
}
|
||||
seen := make(map[string]struct{}, len(dst))
|
||||
for _, value := range dst {
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
for _, value := range values {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
dst = append(dst, value)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func grammarDataHasContent(data *GrammarData) bool {
|
||||
if data == nil {
|
||||
return false
|
||||
}
|
||||
if len(data.Verbs) > 0 || len(data.Nouns) > 0 || len(data.Words) > 0 {
|
||||
return true
|
||||
}
|
||||
if data.Articles.IndefiniteDefault != "" ||
|
||||
data.Articles.IndefiniteVowel != "" ||
|
||||
data.Articles.Definite != "" ||
|
||||
len(data.Articles.ByGender) > 0 {
|
||||
return true
|
||||
}
|
||||
if data.Punct.LabelSuffix != "" || data.Punct.ProgressSuffix != "" {
|
||||
return true
|
||||
}
|
||||
if len(data.Signals.NounDeterminers) > 0 ||
|
||||
len(data.Signals.VerbAuxiliaries) > 0 ||
|
||||
len(data.Signals.VerbInfinitive) > 0 ||
|
||||
len(data.Signals.VerbNegation) > 0 ||
|
||||
len(data.Signals.Priors) > 0 {
|
||||
return true
|
||||
}
|
||||
return data.Number != (NumberFormat{})
|
||||
}
|
||||
|
||||
func cloneGrammarData(data *GrammarData) *GrammarData {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
clone := &GrammarData{
|
||||
Articles: ArticleForms{
|
||||
IndefiniteDefault: data.Articles.IndefiniteDefault,
|
||||
IndefiniteVowel: data.Articles.IndefiniteVowel,
|
||||
Definite: data.Articles.Definite,
|
||||
},
|
||||
Punct: data.Punct,
|
||||
Signals: SignalData{
|
||||
NounDeterminers: append([]string(nil), data.Signals.NounDeterminers...),
|
||||
VerbAuxiliaries: append([]string(nil), data.Signals.VerbAuxiliaries...),
|
||||
VerbInfinitive: append([]string(nil), data.Signals.VerbInfinitive...),
|
||||
VerbNegation: append([]string(nil), data.Signals.VerbNegation...),
|
||||
Priors: make(map[string]map[string]float64, len(data.Signals.Priors)),
|
||||
},
|
||||
Number: data.Number,
|
||||
}
|
||||
if len(data.Verbs) > 0 {
|
||||
clone.Verbs = make(map[string]VerbForms, len(data.Verbs))
|
||||
maps.Copy(clone.Verbs, data.Verbs)
|
||||
}
|
||||
if len(data.Nouns) > 0 {
|
||||
clone.Nouns = make(map[string]NounForms, len(data.Nouns))
|
||||
maps.Copy(clone.Nouns, data.Nouns)
|
||||
}
|
||||
if len(data.Words) > 0 {
|
||||
clone.Words = make(map[string]string, len(data.Words))
|
||||
maps.Copy(clone.Words, data.Words)
|
||||
}
|
||||
if len(data.Articles.ByGender) > 0 {
|
||||
clone.Articles.ByGender = make(map[string]string, len(data.Articles.ByGender))
|
||||
maps.Copy(clone.Articles.ByGender, data.Articles.ByGender)
|
||||
}
|
||||
if len(data.Signals.Priors) > 0 {
|
||||
for word, priors := range data.Signals.Priors {
|
||||
if len(priors) == 0 {
|
||||
continue
|
||||
}
|
||||
clone.Signals.Priors[word] = make(map[string]float64, len(priors))
|
||||
maps.Copy(clone.Signals.Priors[word], priors)
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// IrregularVerbs returns a copy of the irregular verb forms map.
|
||||
|
|
@ -51,8 +248,34 @@ func IrregularNouns() map[string]string {
|
|||
return result
|
||||
}
|
||||
|
||||
// DualClassVerbs returns a copy of the additional regular verbs that also act
|
||||
// as common nouns in dev/ops text.
|
||||
func DualClassVerbs() map[string]VerbForms {
|
||||
result := make(map[string]VerbForms, len(dualClassVerbs))
|
||||
maps.Copy(result, dualClassVerbs)
|
||||
return result
|
||||
}
|
||||
|
||||
// DualClassNouns returns a copy of the additional regular nouns that also act
|
||||
// as common verbs in dev/ops text.
|
||||
func DualClassNouns() map[string]string {
|
||||
result := make(map[string]string, len(dualClassNouns))
|
||||
maps.Copy(result, dualClassNouns)
|
||||
return result
|
||||
}
|
||||
|
||||
// Lower returns the lowercase form of s.
|
||||
func Lower(s string) string {
|
||||
return core.Lower(s)
|
||||
}
|
||||
|
||||
// Upper returns the uppercase form of s.
|
||||
func Upper(s string) string {
|
||||
return core.Upper(s)
|
||||
}
|
||||
|
||||
func getVerbForm(lang, verb, form string) string {
|
||||
data := GetGrammarData(lang)
|
||||
data := grammarDataForLang(lang)
|
||||
if data == nil || data.Verbs == nil {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -69,7 +292,7 @@ func getVerbForm(lang, verb, form string) string {
|
|||
}
|
||||
|
||||
func getWord(lang, word string) string {
|
||||
data := GetGrammarData(lang)
|
||||
data := grammarDataForLang(lang)
|
||||
if data == nil || data.Words == nil {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -77,7 +300,7 @@ func getWord(lang, word string) string {
|
|||
}
|
||||
|
||||
func getPunct(lang, rule, defaultVal string) string {
|
||||
data := GetGrammarData(lang)
|
||||
data := grammarDataForLang(lang)
|
||||
if data == nil {
|
||||
return defaultVal
|
||||
}
|
||||
|
|
@ -95,7 +318,7 @@ func getPunct(lang, rule, defaultVal string) string {
|
|||
}
|
||||
|
||||
func getNounForm(lang, noun, form string) string {
|
||||
data := GetGrammarData(lang)
|
||||
data := grammarDataForLang(lang)
|
||||
if data == nil || data.Nouns == nil {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -156,6 +379,9 @@ func applyRegularPastTense(verb string) string {
|
|||
return verb[:len(verb)-1] + "ied"
|
||||
}
|
||||
}
|
||||
if core.HasSuffix(verb, "c") {
|
||||
return verb + "ked"
|
||||
}
|
||||
if len(verb) >= 2 && shouldDoubleConsonant(verb) {
|
||||
return verb + string(verb[len(verb)-1]) + "ed"
|
||||
}
|
||||
|
|
@ -213,6 +439,9 @@ func applyRegularGerund(verb string) string {
|
|||
return verb[:len(verb)-1] + "ing"
|
||||
}
|
||||
}
|
||||
if core.HasSuffix(verb, "c") {
|
||||
return verb + "king"
|
||||
}
|
||||
if shouldDoubleConsonant(verb) {
|
||||
return verb + string(verb[len(verb)-1]) + "ing"
|
||||
}
|
||||
|
|
@ -226,6 +455,15 @@ func applyRegularGerund(verb string) string {
|
|||
// Pluralize("child", 3) // "children"
|
||||
func Pluralize(noun string, count int) string {
|
||||
if count == 1 {
|
||||
// Honour locale-provided singular forms before falling back to the input.
|
||||
noun = core.Trim(noun)
|
||||
if noun == "" {
|
||||
return ""
|
||||
}
|
||||
lower := core.Lower(noun)
|
||||
if form := getNounForm(currentLangForGrammar(), lower, "one"); form != "" {
|
||||
return preserveInitialCapitalization(noun, form)
|
||||
}
|
||||
return noun
|
||||
}
|
||||
return PluralForm(noun)
|
||||
|
|
@ -239,16 +477,10 @@ func PluralForm(noun string) string {
|
|||
}
|
||||
lower := core.Lower(noun)
|
||||
if form := getNounForm(currentLangForGrammar(), lower, "other"); form != "" {
|
||||
if unicode.IsUpper(rune(noun[0])) && len(form) > 0 {
|
||||
return core.Upper(string(form[0])) + form[1:]
|
||||
}
|
||||
return form
|
||||
return preserveInitialCapitalization(noun, form)
|
||||
}
|
||||
if plural, ok := irregularNouns[lower]; ok {
|
||||
if unicode.IsUpper(rune(noun[0])) {
|
||||
return core.Upper(string(plural[0])) + plural[1:]
|
||||
}
|
||||
return plural
|
||||
return preserveInitialCapitalization(noun, plural)
|
||||
}
|
||||
return applyRegularPlural(noun)
|
||||
}
|
||||
|
|
@ -286,17 +518,31 @@ func applyRegularPlural(noun string) string {
|
|||
return noun + "s"
|
||||
}
|
||||
|
||||
// Article returns the appropriate indefinite article ("a" or "an").
|
||||
// Article returns the appropriate article token for the current language.
|
||||
// English falls back to phonetic "a"/"an" heuristics. Locale grammar data
|
||||
// can override this with language-specific article forms.
|
||||
//
|
||||
// Use ArticlePhrase when you want the noun phrase prefixed with the article.
|
||||
//
|
||||
// Article("file") // "a"
|
||||
// Article("error") // "an"
|
||||
// Article("user") // "a" (sounds like "yoo-zer")
|
||||
// Article("hour") // "an" (silent h)
|
||||
func Article(word string) string {
|
||||
word = core.Trim(word)
|
||||
if word == "" {
|
||||
return ""
|
||||
}
|
||||
lower := core.Lower(core.Trim(word))
|
||||
lower := core.Lower(word)
|
||||
if article, ok := articleForCurrentLanguage(lower, word); ok {
|
||||
return article
|
||||
}
|
||||
if isInitialism(word) {
|
||||
if initialismUsesVowelSound(word) {
|
||||
return "an"
|
||||
}
|
||||
return "a"
|
||||
}
|
||||
for key := range consonantSounds {
|
||||
if core.HasPrefix(lower, key) {
|
||||
return "a"
|
||||
|
|
@ -313,6 +559,239 @@ func Article(word string) string {
|
|||
return "a"
|
||||
}
|
||||
|
||||
// ArticleToken is an explicit alias for Article.
|
||||
func ArticleToken(word string) string {
|
||||
return Article(word)
|
||||
}
|
||||
|
||||
func articleForCurrentLanguage(lowerWord, originalWord string) (string, bool) {
|
||||
lang := currentLangForGrammar()
|
||||
data := grammarDataForLang(lang)
|
||||
if data == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if article, ok := articleForPluralForm(data, lowerWord, lang); ok {
|
||||
return article, true
|
||||
}
|
||||
if article, ok := articleForFrenchPluralGuess(data, lowerWord, originalWord, lang); ok {
|
||||
return article, true
|
||||
}
|
||||
if article, ok := articleByGender(data, lowerWord, originalWord, lang); ok {
|
||||
return article, true
|
||||
}
|
||||
if article, ok := articleFromGrammarForms(data, originalWord); ok {
|
||||
return article, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func articleByGender(data *GrammarData, lowerWord, originalWord, lang string) (string, bool) {
|
||||
if len(data.Articles.ByGender) == 0 {
|
||||
return "", false
|
||||
}
|
||||
forms, ok := data.Nouns[lowerWord]
|
||||
if !ok || forms.Gender == "" {
|
||||
return "", false
|
||||
}
|
||||
article, ok := data.Articles.ByGender[forms.Gender]
|
||||
if !ok || article == "" {
|
||||
return "", false
|
||||
}
|
||||
return maybeElideArticle(article, originalWord, lang), true
|
||||
}
|
||||
|
||||
func articleForPluralForm(data *GrammarData, lowerWord, lang string) (string, bool) {
|
||||
if !isFrenchLanguage(lang) {
|
||||
return "", false
|
||||
}
|
||||
if !isKnownPluralNoun(data, lowerWord) {
|
||||
return "", false
|
||||
}
|
||||
return "les", true
|
||||
}
|
||||
|
||||
func articleForFrenchPluralGuess(data *GrammarData, lowerWord, originalWord, lang string) (string, bool) {
|
||||
if !isFrenchLanguage(lang) {
|
||||
return "", false
|
||||
}
|
||||
if isKnownPluralNoun(data, lowerWord) {
|
||||
return "", false
|
||||
}
|
||||
if !looksLikeFrenchPlural(originalWord) {
|
||||
return "", false
|
||||
}
|
||||
return "des", true
|
||||
}
|
||||
|
||||
func isKnownPluralNoun(data *GrammarData, lowerWord string) bool {
|
||||
if data == nil || len(data.Nouns) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, forms := range data.Nouns {
|
||||
if forms.Other == "" || core.Lower(forms.Other) != lowerWord {
|
||||
continue
|
||||
}
|
||||
if forms.One != "" && core.Lower(forms.One) == lowerWord {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func articleFromGrammarForms(data *GrammarData, word string) (string, bool) {
|
||||
if data.Articles.IndefiniteDefault == "" && data.Articles.IndefiniteVowel == "" {
|
||||
return "", false
|
||||
}
|
||||
if usesVowelSoundArticle(word) && data.Articles.IndefiniteVowel != "" {
|
||||
return data.Articles.IndefiniteVowel, true
|
||||
}
|
||||
if data.Articles.IndefiniteDefault != "" {
|
||||
return data.Articles.IndefiniteDefault, true
|
||||
}
|
||||
if data.Articles.IndefiniteVowel != "" {
|
||||
return data.Articles.IndefiniteVowel, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func maybeElideArticle(article, word, lang string) string {
|
||||
if !isFrenchLanguage(lang) {
|
||||
return article
|
||||
}
|
||||
if !startsWithVowelSound(word) {
|
||||
return article
|
||||
}
|
||||
switch core.Lower(article) {
|
||||
case "le", "la", "de", "je", "me", "te", "se", "ne", "ce":
|
||||
// French elision keeps the leading consonant and replaces the final
|
||||
// vowel with an apostrophe: le/la -> l', de -> d', je -> j', etc.
|
||||
return core.Lower(article[:1]) + "'"
|
||||
case "que":
|
||||
return "qu'"
|
||||
}
|
||||
return article
|
||||
}
|
||||
|
||||
func usesVowelSoundArticle(word string) bool {
|
||||
trimmed := core.Trim(word)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if isInitialism(trimmed) {
|
||||
return initialismUsesVowelSound(trimmed)
|
||||
}
|
||||
lower := core.Lower(trimmed)
|
||||
for key := range consonantSounds {
|
||||
if core.HasPrefix(lower, key) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for key := range vowelSounds {
|
||||
if core.HasPrefix(lower, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, r := range lower {
|
||||
return isVowel(r)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func looksLikeFrenchPlural(word string) bool {
|
||||
trimmed := core.Trim(word)
|
||||
if trimmed == "" || core.Contains(trimmed, " ") || core.Contains(trimmed, "\t") || isInitialism(trimmed) {
|
||||
return false
|
||||
}
|
||||
lower := core.Lower(trimmed)
|
||||
if isFrenchAspiratedHWord(lower) {
|
||||
return false
|
||||
}
|
||||
if core.HasSuffix(lower, "aux") || core.HasSuffix(lower, "eaux") {
|
||||
return true
|
||||
}
|
||||
return core.HasSuffix(lower, "s") || core.HasSuffix(lower, "x")
|
||||
}
|
||||
|
||||
func startsWithVowelSound(word string) bool {
|
||||
trimmed := core.Trim(word)
|
||||
lower := core.Lower(trimmed)
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
if isFrenchAspiratedHWord(lower) {
|
||||
return false
|
||||
}
|
||||
r := []rune(lower)
|
||||
switch r[0] {
|
||||
case 'a', 'e', 'i', 'o', 'u', 'y',
|
||||
'à', 'â', 'ä', 'æ', 'é', 'è', 'ê', 'ë',
|
||||
'î', 'ï', 'ô', 'ö', 'ù', 'û', 'ü', 'œ', 'h':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isFrenchAspiratedHWord(word string) bool {
|
||||
switch word {
|
||||
case "haricot", "héron", "héros", "honte", "hache", "hasard", "hibou", "houx", "hurluberlu":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isFrenchLanguage(lang string) bool {
|
||||
lang = core.Lower(lang)
|
||||
return lang == "fr" || core.HasPrefix(lang, "fr-")
|
||||
}
|
||||
|
||||
func isInitialism(word string) bool {
|
||||
if len(word) < 2 {
|
||||
return false
|
||||
}
|
||||
hasLetter := false
|
||||
for _, r := range word {
|
||||
if !unicode.IsLetter(r) {
|
||||
return false
|
||||
}
|
||||
hasLetter = true
|
||||
if unicode.IsLower(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter
|
||||
}
|
||||
|
||||
func preserveInitialCapitalization(original, form string) string {
|
||||
if original == "" || form == "" {
|
||||
return form
|
||||
}
|
||||
originalRunes := []rune(original)
|
||||
formRunes := []rune(form)
|
||||
if len(originalRunes) == 0 || len(formRunes) == 0 {
|
||||
return form
|
||||
}
|
||||
if !unicode.IsUpper(originalRunes[0]) {
|
||||
return form
|
||||
}
|
||||
formRunes[0] = unicode.ToUpper(formRunes[0])
|
||||
return string(formRunes)
|
||||
}
|
||||
|
||||
func initialismUsesVowelSound(word string) bool {
|
||||
if word == "" {
|
||||
return false
|
||||
}
|
||||
switch unicode.ToUpper([]rune(word)[0]) {
|
||||
case 'A', 'E', 'F', 'H', 'I', 'L', 'M', 'N', 'O', 'R', 'S', 'X':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isVowel(r rune) bool {
|
||||
switch unicode.ToLower(r) {
|
||||
case 'a', 'e', 'i', 'o', 'u':
|
||||
|
|
@ -321,49 +800,229 @@ func isVowel(r rune) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// Title capitalises the first letter of each word.
|
||||
// Title capitalises the first letter of each word-like segment.
|
||||
//
|
||||
// Hyphens and whitespace start a new segment; punctuation inside identifiers
|
||||
// such as dots and underscores is preserved so filenames stay readable.
|
||||
func Title(s string) string {
|
||||
b := core.NewBuilder()
|
||||
b.Grow(len(s))
|
||||
prev := ' '
|
||||
capNext := true
|
||||
for _, r := range s {
|
||||
if !unicode.IsLetter(prev) && unicode.IsLetter(r) {
|
||||
if unicode.IsLetter(r) && capNext {
|
||||
b.WriteRune(unicode.ToUpper(r))
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
prev = r
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r', '-':
|
||||
capNext = true
|
||||
default:
|
||||
capNext = false
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderWord(lang, word string) string {
|
||||
if translated := getWord(lang, word); translated != "" {
|
||||
return translated
|
||||
}
|
||||
return word
|
||||
}
|
||||
|
||||
func renderWordOrTitle(lang, word string) string {
|
||||
if translated := getWord(lang, word); translated != "" {
|
||||
return translated
|
||||
}
|
||||
return Title(word)
|
||||
}
|
||||
|
||||
// Quote wraps a string in double quotes.
|
||||
func Quote(s string) string {
|
||||
return `"` + s + `"`
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
|
||||
// ArticlePhrase prefixes a noun phrase with the correct article.
|
||||
func ArticlePhrase(word string) string {
|
||||
word = core.Trim(word)
|
||||
if word == "" {
|
||||
return ""
|
||||
}
|
||||
lang := currentLangForGrammar()
|
||||
word = renderWord(lang, word)
|
||||
article := Article(word)
|
||||
return prefixWithArticle(article, word)
|
||||
}
|
||||
|
||||
// DefiniteArticle returns the language-specific definite article token for a word.
|
||||
// For languages such as French, this respects gendered articles, plural forms,
|
||||
// and elision rules when grammar data is available.
|
||||
func DefiniteArticle(word string) string {
|
||||
word = core.Trim(word)
|
||||
if word == "" {
|
||||
return ""
|
||||
}
|
||||
lower := core.Lower(word)
|
||||
if article, ok := definiteArticleForCurrentLanguage(lower, word); ok {
|
||||
return article
|
||||
}
|
||||
lang := currentLangForGrammar()
|
||||
data := grammarDataForLang(lang)
|
||||
if data != nil && data.Articles.Definite != "" {
|
||||
return data.Articles.Definite
|
||||
}
|
||||
return "the"
|
||||
}
|
||||
|
||||
// DefiniteToken is an explicit alias for DefiniteArticle.
|
||||
func DefiniteToken(word string) string {
|
||||
return DefiniteArticle(word)
|
||||
}
|
||||
|
||||
// DefinitePhrase prefixes a noun phrase with the correct definite article.
|
||||
func DefinitePhrase(word string) string {
|
||||
word = core.Trim(word)
|
||||
if word == "" {
|
||||
return ""
|
||||
}
|
||||
lang := currentLangForGrammar()
|
||||
word = renderWord(lang, word)
|
||||
article := DefiniteArticle(word)
|
||||
return prefixWithArticle(article, word)
|
||||
}
|
||||
|
||||
func definiteArticleForCurrentLanguage(lowerWord, originalWord string) (string, bool) {
|
||||
lang := currentLangForGrammar()
|
||||
data := grammarDataForLang(lang)
|
||||
if data == nil {
|
||||
return "", false
|
||||
}
|
||||
if article, ok := articleByGender(data, lowerWord, originalWord, lang); ok {
|
||||
return article, true
|
||||
}
|
||||
if article, ok := definiteArticleFromGrammarForms(data, lowerWord, originalWord, lang); ok {
|
||||
return article, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func grammarDataForLang(lang string) *GrammarData {
|
||||
if data := GetGrammarData(lang); data != nil {
|
||||
return data
|
||||
}
|
||||
if base := baseLanguageTag(lang); base != "" {
|
||||
return GetGrammarData(base)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func baseLanguageTag(lang string) string {
|
||||
if idx := indexAny(lang, "-_"); idx > 0 {
|
||||
return lang[:idx]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func definiteArticleFromGrammarForms(data *GrammarData, lowerWord, originalWord, lang string) (string, bool) {
|
||||
if data == nil || data.Articles.Definite == "" {
|
||||
return "", false
|
||||
}
|
||||
if isFrenchLanguage(lang) {
|
||||
if isKnownPluralNoun(data, lowerWord) || looksLikeFrenchPlural(originalWord) {
|
||||
return "les", true
|
||||
}
|
||||
return maybeElideArticle(data.Articles.Definite, originalWord, lang), true
|
||||
}
|
||||
return data.Articles.Definite, true
|
||||
}
|
||||
|
||||
// TemplateFuncs returns the template.FuncMap with all grammar functions.
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"title": Title,
|
||||
"lower": core.Lower,
|
||||
"upper": core.Upper,
|
||||
"past": PastTense,
|
||||
"gerund": Gerund,
|
||||
"plural": Pluralize,
|
||||
"pluralForm": PluralForm,
|
||||
"article": Article,
|
||||
"quote": Quote,
|
||||
"title": Title,
|
||||
"lower": Lower,
|
||||
"upper": Upper,
|
||||
"n": N,
|
||||
"number": Number,
|
||||
"int": Number,
|
||||
"decimal": Decimal,
|
||||
"float": Decimal,
|
||||
"percent": Percent,
|
||||
"pct": Percent,
|
||||
"bytes": Bytes,
|
||||
"size": Bytes,
|
||||
"ordinal": Ordinal,
|
||||
"ord": Ordinal,
|
||||
"ago": Ago,
|
||||
"past": PastTense,
|
||||
"gerund": Gerund,
|
||||
"plural": Pluralize,
|
||||
"pluralForm": PluralForm,
|
||||
"article": ArticlePhrase,
|
||||
"articleToken": ArticleToken,
|
||||
"articlePhrase": ArticlePhrase,
|
||||
"definiteArticle": DefiniteArticle,
|
||||
"definiteToken": DefiniteToken,
|
||||
"definite": DefinitePhrase,
|
||||
"definitePhrase": DefinitePhrase,
|
||||
"quote": Quote,
|
||||
"label": Label,
|
||||
"progress": Progress,
|
||||
"progressSubject": ProgressSubject,
|
||||
"actionResult": ActionResult,
|
||||
"actionFailed": ActionFailed,
|
||||
"prompt": Prompt,
|
||||
"lang": Lang,
|
||||
"timeAgo": TimeAgo,
|
||||
"formatAgo": FormatAgo,
|
||||
}
|
||||
}
|
||||
|
||||
// Number formats an integer using the current locale's number rules.
|
||||
func Number(value any) string {
|
||||
return FormatNumber(toInt64(value))
|
||||
}
|
||||
|
||||
// Decimal formats a decimal using the current locale's number rules.
|
||||
func Decimal(value any) string {
|
||||
return FormatDecimal(toFloat64(value))
|
||||
}
|
||||
|
||||
// Percent formats a percentage using the current locale's number rules.
|
||||
func Percent(value any) string {
|
||||
return FormatPercent(toFloat64(value))
|
||||
}
|
||||
|
||||
// Bytes formats a byte count using the current locale's number rules.
|
||||
func Bytes(value any) string {
|
||||
return FormatBytes(toInt64(value))
|
||||
}
|
||||
|
||||
// Ordinal formats a number as an ordinal using the current locale.
|
||||
func Ordinal(value any) string {
|
||||
return FormatOrdinal(toInt(value))
|
||||
}
|
||||
|
||||
// Ago formats a relative time using the current locale's ago rules.
|
||||
func Ago(count int, unit string) string {
|
||||
return FormatAgo(count, unit)
|
||||
}
|
||||
|
||||
func prefixWithArticle(article, word string) string {
|
||||
if article == "" || word == "" {
|
||||
return ""
|
||||
}
|
||||
if core.HasSuffix(article, "'") {
|
||||
return article + word
|
||||
}
|
||||
return article + " " + word
|
||||
}
|
||||
|
||||
// Progress returns a progress message: "Building..."
|
||||
func Progress(verb string) string {
|
||||
lang := currentLangForGrammar()
|
||||
word := getWord(lang, verb)
|
||||
if word == "" {
|
||||
word = verb
|
||||
}
|
||||
word := renderWord(lang, verb)
|
||||
g := Gerund(word)
|
||||
if g == "" {
|
||||
return ""
|
||||
|
|
@ -375,48 +1034,65 @@ func Progress(verb string) string {
|
|||
// ProgressSubject returns a progress message with subject: "Building project..."
|
||||
func ProgressSubject(verb, subject string) string {
|
||||
lang := currentLangForGrammar()
|
||||
word := getWord(lang, verb)
|
||||
if word == "" {
|
||||
word = verb
|
||||
}
|
||||
word := renderWord(lang, verb)
|
||||
g := Gerund(word)
|
||||
if g == "" {
|
||||
return ""
|
||||
}
|
||||
suffix := getPunct(lang, "progress", "...")
|
||||
return Title(g) + " " + subject + suffix
|
||||
subject = core.Trim(subject)
|
||||
if subject == "" {
|
||||
return Title(g) + suffix
|
||||
}
|
||||
return Title(g) + " " + renderWord(lang, subject) + suffix
|
||||
}
|
||||
|
||||
// ActionResult returns a completion message: "File deleted"
|
||||
func ActionResult(verb, subject string) string {
|
||||
p := PastTense(verb)
|
||||
if p == "" || subject == "" {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
return Title(subject) + " " + p
|
||||
subject = core.Trim(subject)
|
||||
if subject == "" {
|
||||
return Title(p)
|
||||
}
|
||||
return renderWordOrTitle(currentLangForGrammar(), subject) + " " + p
|
||||
}
|
||||
|
||||
// ActionFailed returns a failure message: "Failed to delete file"
|
||||
func ActionFailed(verb, subject string) string {
|
||||
verb = core.Trim(verb)
|
||||
if verb == "" {
|
||||
return ""
|
||||
}
|
||||
lang := currentLangForGrammar()
|
||||
// Keep the failure verb in sentence case when no locale override exists.
|
||||
verb = renderWord(lang, core.Lower(verb))
|
||||
prefix := failedPrefix(lang)
|
||||
subject = core.Trim(subject)
|
||||
if subject == "" {
|
||||
return "Failed to " + verb
|
||||
return prefix + " " + verb
|
||||
}
|
||||
return "Failed to " + verb + " " + subject
|
||||
return prefix + " " + verb + " " + renderWord(lang, subject)
|
||||
}
|
||||
|
||||
func failedPrefix(lang string) string {
|
||||
prefix := renderWord(lang, "failed_to")
|
||||
if prefix == "" || prefix == "failed_to" {
|
||||
return "Failed to"
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
// Label returns a label with suffix: "Status:" (EN) or "Statut :" (FR)
|
||||
func Label(word string) string {
|
||||
word = core.Trim(word)
|
||||
if word == "" {
|
||||
return ""
|
||||
}
|
||||
lang := currentLangForGrammar()
|
||||
translated := getWord(lang, word)
|
||||
if translated == "" {
|
||||
translated = word
|
||||
}
|
||||
translated := renderWordOrTitle(lang, word)
|
||||
suffix := getPunct(lang, "label", ":")
|
||||
return Title(translated) + suffix
|
||||
return translated + suffix
|
||||
}
|
||||
|
|
|
|||
781
grammar_test.go
781
grammar_test.go
|
|
@ -1,6 +1,21 @@
|
|||
package i18n
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
type regionFallbackLoader struct{}
|
||||
|
||||
func (regionFallbackLoader) Languages() []string {
|
||||
return []string{"en-GB"}
|
||||
}
|
||||
|
||||
func (regionFallbackLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
|
||||
return map[string]Message{}, nil, nil
|
||||
}
|
||||
|
||||
func TestPastTense(t *testing.T) {
|
||||
// Ensure grammar data is loaded from embedded JSON
|
||||
|
|
@ -89,6 +104,7 @@ func TestPastTense(t *testing.T) {
|
|||
{"push", "pushed"},
|
||||
{"pull", "pulled"},
|
||||
{"start", "started"},
|
||||
{"panic", "panicked"},
|
||||
{"copy", "copied"},
|
||||
{"apply", "applied"},
|
||||
|
||||
|
|
@ -155,6 +171,7 @@ func TestGerund(t *testing.T) {
|
|||
{"push", "pushing"},
|
||||
{"pull", "pulling"},
|
||||
{"start", "starting"},
|
||||
{"panic", "panicking"},
|
||||
{"die", "dying"},
|
||||
|
||||
// Edge cases
|
||||
|
|
@ -219,6 +236,62 @@ func TestPluralize(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPluralize_UsesLocaleSingularOverride(t *testing.T) {
|
||||
const lang = "en-x-singular"
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
SetGrammarData(lang, nil)
|
||||
})
|
||||
|
||||
svc, err := NewWithLoader(pluralizeOverrideLoader{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithLoader() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
if err := SetLanguage(lang); err != nil {
|
||||
t.Fatalf("SetLanguage(%s) failed: %v", lang, err)
|
||||
}
|
||||
|
||||
if got, want := Pluralize("person", 1), "human"; got != want {
|
||||
t.Fatalf("Pluralize(%q, 1) = %q, want %q", "person", got, want)
|
||||
}
|
||||
if got, want := Pluralize("Person", 1), "Human"; got != want {
|
||||
t.Fatalf("Pluralize(%q, 1) = %q, want %q", "Person", got, want)
|
||||
}
|
||||
if got, want := Pluralize("person", 2), "people"; got != want {
|
||||
t.Fatalf("Pluralize(%q, 2) = %q, want %q", "person", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralize_PreservesUnicodeCapitalization(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
if got, want := Pluralize("Élément", 1), "Élément"; got != want {
|
||||
t.Fatalf("Pluralize(%q, 1) = %q, want %q", "Élément", got, want)
|
||||
}
|
||||
if got, want := Pluralize("Élément", 2), "Éléments"; got != want {
|
||||
t.Fatalf("Pluralize(%q, 2) = %q, want %q", "Élément", got, want)
|
||||
}
|
||||
if got, want := PluralForm("Élément"), "Éléments"; got != want {
|
||||
t.Fatalf("PluralForm(%q) = %q, want %q", "Élément", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralForm(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
|
|
@ -266,6 +339,8 @@ func TestArticle(t *testing.T) {
|
|||
{"honest", "an"}, // Vowel sound
|
||||
{"university", "a"}, // Consonant sound
|
||||
{"one", "a"}, // Consonant sound
|
||||
{"SSH", "an"}, // Initialism: "ess-ess-aitch"
|
||||
{"URL", "a"}, // Initialism: "you-are-ell"
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
|
|
@ -279,6 +354,147 @@ func TestArticle(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestArticleTokenAliases(t *testing.T) {
|
||||
if got, want := ArticleToken("apple"), Article("apple"); got != want {
|
||||
t.Fatalf("ArticleToken(apple) = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := DefiniteToken("apple"), DefiniteArticle("apple"); got != want {
|
||||
t.Fatalf("DefiniteToken(apple) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleFrenchLocale(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"branche", "la"},
|
||||
{"branches", "les"},
|
||||
{"amis", "des"},
|
||||
{"enfant", "l'"},
|
||||
{"fichier", "le"},
|
||||
{"inconnu", "un"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := Article(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("Article(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleFrenchElisionKeepsLeadingConsonant(t *testing.T) {
|
||||
prevData := GetGrammarData("fr")
|
||||
t.Cleanup(func() {
|
||||
SetGrammarData("fr", prevData)
|
||||
})
|
||||
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
SetGrammarData("fr", &GrammarData{
|
||||
Nouns: map[string]NounForms{
|
||||
"amie": {One: "amie", Other: "amies", Gender: "f"},
|
||||
"accord": {One: "accord", Other: "accords", Gender: "d"},
|
||||
"homme": {One: "homme", Other: "hommes", Gender: "m"},
|
||||
"héros": {One: "héros", Other: "héros", Gender: "m"},
|
||||
"idole": {One: "idole", Other: "idoles", Gender: "j"},
|
||||
},
|
||||
Articles: ArticleForms{
|
||||
IndefiniteDefault: "un",
|
||||
IndefiniteVowel: "un",
|
||||
Definite: "le",
|
||||
ByGender: map[string]string{
|
||||
"d": "de",
|
||||
"f": "la",
|
||||
"j": "je",
|
||||
"m": "le",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"homme", "l'"},
|
||||
{"héros", "le"},
|
||||
{"amie", "l'"},
|
||||
{"accord", "d'"},
|
||||
{"idole", "j'"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := Article(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("Article(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
phraseTests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"accord", "d'accord"},
|
||||
{"idole", "j'idole"},
|
||||
}
|
||||
|
||||
for _, tt := range phraseTests {
|
||||
t.Run(tt.word+"_phrase", func(t *testing.T) {
|
||||
got := ArticlePhrase(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("ArticlePhrase(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type pluralizeOverrideLoader struct{}
|
||||
|
||||
func (pluralizeOverrideLoader) Languages() []string {
|
||||
return []string{"en-x-singular"}
|
||||
}
|
||||
|
||||
func (pluralizeOverrideLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
|
||||
grammar := &GrammarData{
|
||||
Nouns: map[string]NounForms{
|
||||
"person": {One: "human", Other: "people"},
|
||||
},
|
||||
}
|
||||
SetGrammarData(lang, grammar)
|
||||
return map[string]Message{}, grammar, nil
|
||||
}
|
||||
|
||||
func TestTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
|
@ -289,6 +505,7 @@ func TestTitle(t *testing.T) {
|
|||
{"", ""},
|
||||
{"HELLO", "HELLO"},
|
||||
{"hello-world", "Hello-World"},
|
||||
{"config.yaml", "Config.yaml"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -305,6 +522,191 @@ func TestQuote(t *testing.T) {
|
|||
if got := Quote("hello"); got != `"hello"` {
|
||||
t.Errorf("Quote(%q) = %q, want %q", "hello", got, `"hello"`)
|
||||
}
|
||||
if got := Quote(`a "quoted" path\name`); got != `"a \"quoted\" path\\name"` {
|
||||
t.Errorf("Quote(%q) = %q, want %q", `a "quoted" path\name`, got, `"a \"quoted\" path\\name"`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseHelpers(t *testing.T) {
|
||||
if got := Lower("HELLO"); got != "hello" {
|
||||
t.Fatalf("Lower(%q) = %q, want %q", "HELLO", got, "hello")
|
||||
}
|
||||
if got := Upper("hello"); got != "HELLO" {
|
||||
t.Fatalf("Upper(%q) = %q, want %q", "hello", got, "HELLO")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticlePhrase(t *testing.T) {
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"file", "a file"},
|
||||
{"error", "an error"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := ArticlePhrase(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("ArticlePhrase(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticlePhrase_RespectsWordMap(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData(\"en\") returned nil")
|
||||
}
|
||||
original, existed := data.Words["go_mod"]
|
||||
data.Words["go_mod"] = "go.mod"
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
data.Words["go_mod"] = original
|
||||
return
|
||||
}
|
||||
delete(data.Words, "go_mod")
|
||||
})
|
||||
|
||||
if got, want := ArticlePhrase("go_mod"), "a go.mod"; got != want {
|
||||
t.Fatalf("ArticlePhrase(%q) = %q, want %q", "go_mod", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticlePhrase_UsesRenderedWordForArticleSelection(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData(\"en\") returned nil")
|
||||
}
|
||||
original, existed := data.Words["ssh"]
|
||||
data.Words["ssh"] = "SSH"
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
data.Words["ssh"] = original
|
||||
return
|
||||
}
|
||||
delete(data.Words, "ssh")
|
||||
})
|
||||
|
||||
if got, want := ArticlePhrase("ssh"), "an SSH"; got != want {
|
||||
t.Fatalf("ArticlePhrase(%q) = %q, want %q", "ssh", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticlePhraseFrenchLocale(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"branche", "la branche"},
|
||||
{"branches", "les branches"},
|
||||
{"amis", "des amis"},
|
||||
{"enfant", "l'enfant"},
|
||||
{"fichier", "le fichier"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := ArticlePhrase(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("ArticlePhrase(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefiniteArticle(t *testing.T) {
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"file", "the"},
|
||||
{"error", "the"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := DefiniteArticle(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("DefiniteArticle(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefinitePhraseFrenchLocale(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"branche", "la branche"},
|
||||
{"branches", "les branches"},
|
||||
{"amis", "les amis"},
|
||||
{"enfant", "l'enfant"},
|
||||
{"fichier", "le fichier"},
|
||||
{"héros", "le héros"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := DefinitePhrase(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("DefinitePhrase(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabel(t *testing.T) {
|
||||
|
|
@ -332,6 +734,27 @@ func TestLabel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCompositionHelpersTrimWhitespace(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
if got, want := Label(" status "), "Status:"; got != want {
|
||||
t.Fatalf("Label(%q) = %q, want %q", " status ", got, want)
|
||||
}
|
||||
if got, want := Article(" error "), "an"; got != want {
|
||||
t.Fatalf("Article(%q) = %q, want %q", " error ", got, want)
|
||||
}
|
||||
if got, want := ArticlePhrase(" go_mod "), "a go.mod"; got != want {
|
||||
t.Fatalf("ArticlePhrase(%q) = %q, want %q", " go_mod ", got, want)
|
||||
}
|
||||
if got, want := ActionFailed(" delete ", " file "), "Failed to delete file"; got != want {
|
||||
t.Fatalf("ActionFailed(%q, %q) = %q, want %q", " delete ", " file ", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgress(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
|
|
@ -383,10 +806,10 @@ func TestActionResult(t *testing.T) {
|
|||
verb, subject string
|
||||
want string
|
||||
}{
|
||||
{"delete", "config.yaml", "Config.Yaml deleted"},
|
||||
{"delete", "config.yaml", "Config.yaml deleted"},
|
||||
{"build", "project", "Project built"},
|
||||
{"", "file", ""},
|
||||
{"delete", "", ""},
|
||||
{"delete", "", "Deleted"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -405,6 +828,7 @@ func TestActionFailed(t *testing.T) {
|
|||
want string
|
||||
}{
|
||||
{"delete", "config.yaml", "Failed to delete config.yaml"},
|
||||
{"Delete", "config.yaml", "Failed to delete config.yaml"},
|
||||
{"push", "commits", "Failed to push commits"},
|
||||
{"push", "", "Failed to push"},
|
||||
{"", "", ""},
|
||||
|
|
@ -420,6 +844,56 @@ func TestActionFailed(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestActionFailed_RespectsWordMap(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData(\"en\") returned nil")
|
||||
}
|
||||
original, existed := data.Words["push"]
|
||||
data.Words["push"] = "submit"
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
data.Words["push"] = original
|
||||
return
|
||||
}
|
||||
delete(data.Words, "push")
|
||||
})
|
||||
|
||||
if got, want := ActionFailed("push", "commits"), "Failed to submit commits"; got != want {
|
||||
t.Fatalf("ActionFailed(%q, %q) = %q, want %q", "push", "commits", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionFailedFrenchLocale(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
if got, want := ActionFailed("supprimer", ""), "Impossible de supprimer"; got != want {
|
||||
t.Fatalf("ActionFailed(%q, %q) = %q, want %q", "supprimer", "", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrammarData_Signals(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
|
|
@ -582,9 +1056,96 @@ func TestFrenchGrammarData(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGrammarFallbackToBaseLanguageTag(t *testing.T) {
|
||||
prevDefault := Default()
|
||||
prevGrammar := GetGrammarData("en")
|
||||
t.Cleanup(func() {
|
||||
SetGrammarData("en", prevGrammar)
|
||||
SetDefault(prevDefault)
|
||||
})
|
||||
|
||||
SetGrammarData("en", &GrammarData{
|
||||
Verbs: map[string]VerbForms{
|
||||
"delete": {Past: "deleted", Gerund: "deleting"},
|
||||
},
|
||||
Nouns: map[string]NounForms{
|
||||
"file": {One: "file", Other: "files"},
|
||||
},
|
||||
Articles: ArticleForms{
|
||||
IndefiniteDefault: "a",
|
||||
IndefiniteVowel: "an",
|
||||
Definite: "the",
|
||||
},
|
||||
Punct: PunctuationRules{
|
||||
LabelSuffix: ":",
|
||||
ProgressSuffix: "...",
|
||||
},
|
||||
Words: map[string]string{
|
||||
"status": "Status",
|
||||
},
|
||||
})
|
||||
|
||||
svc, err := NewWithLoader(regionFallbackLoader{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithLoader() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
if err := svc.SetLanguage("en-GB"); err != nil {
|
||||
t.Fatalf("SetLanguage(en-GB) failed: %v", err)
|
||||
}
|
||||
|
||||
if got := PastTense("delete"); got != "deleted" {
|
||||
t.Fatalf("PastTense(delete) = %q, want %q", got, "deleted")
|
||||
}
|
||||
if got := Pluralize("file", 2); got != "files" {
|
||||
t.Fatalf("Pluralize(file, 2) = %q, want %q", got, "files")
|
||||
}
|
||||
if got := Article("apple"); got != "an" {
|
||||
t.Fatalf("Article(apple) = %q, want %q", got, "an")
|
||||
}
|
||||
if got := Label("status"); got != "Status:" {
|
||||
t.Fatalf("Label(status) = %q, want %q", got, "Status:")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateFuncs(t *testing.T) {
|
||||
funcs := TemplateFuncs()
|
||||
expected := []string{"title", "lower", "upper", "past", "gerund", "plural", "pluralForm", "article", "quote"}
|
||||
expected := []string{
|
||||
"title",
|
||||
"lower",
|
||||
"upper",
|
||||
"n",
|
||||
"number",
|
||||
"int",
|
||||
"decimal",
|
||||
"float",
|
||||
"percent",
|
||||
"pct",
|
||||
"bytes",
|
||||
"size",
|
||||
"ordinal",
|
||||
"ord",
|
||||
"ago",
|
||||
"past",
|
||||
"gerund",
|
||||
"plural",
|
||||
"pluralForm",
|
||||
"article",
|
||||
"articlePhrase",
|
||||
"definiteArticle",
|
||||
"definite",
|
||||
"definitePhrase",
|
||||
"quote",
|
||||
"label",
|
||||
"progress",
|
||||
"progressSubject",
|
||||
"actionResult",
|
||||
"actionFailed",
|
||||
"prompt",
|
||||
"lang",
|
||||
"timeAgo",
|
||||
"formatAgo",
|
||||
}
|
||||
for _, name := range expected {
|
||||
if _, ok := funcs[name]; !ok {
|
||||
t.Errorf("TemplateFuncs() missing %q", name)
|
||||
|
|
@ -592,6 +1153,218 @@ 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 ---
|
||||
|
||||
func BenchmarkPastTense_Irregular(b *testing.B) {
|
||||
|
|
|
|||
222
handler.go
222
handler.go
|
|
@ -1,6 +1,9 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unicode"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
|
|
@ -13,7 +16,13 @@ func (h LabelHandler) Match(key string) bool {
|
|||
|
||||
func (h LabelHandler) Handle(key string, args []any, next func() string) string {
|
||||
word := core.TrimPrefix(key, "i18n.label.")
|
||||
return Label(word)
|
||||
if got := Label(word); got != "" {
|
||||
return got
|
||||
}
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ProgressHandler handles i18n.progress.{verb} -> "Building..." patterns.
|
||||
|
|
@ -26,11 +35,19 @@ func (h ProgressHandler) Match(key string) bool {
|
|||
func (h ProgressHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := core.TrimPrefix(key, "i18n.progress.")
|
||||
if len(args) > 0 {
|
||||
if subj, ok := args[0].(string); ok {
|
||||
return ProgressSubject(verb, subj)
|
||||
if subj := subjectArgText(args[0]); subj != "" {
|
||||
if got := ProgressSubject(verb, subj); got != "" {
|
||||
return got
|
||||
}
|
||||
}
|
||||
}
|
||||
return Progress(verb)
|
||||
if got := Progress(verb); got != "" {
|
||||
return got
|
||||
}
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CountHandler handles i18n.count.{noun} -> "5 files" patterns.
|
||||
|
|
@ -42,11 +59,18 @@ func (h CountHandler) Match(key string) bool {
|
|||
|
||||
func (h CountHandler) Handle(key string, args []any, next func() string) string {
|
||||
noun := core.TrimPrefix(key, "i18n.count.")
|
||||
if len(args) > 0 {
|
||||
count := toInt(args[0])
|
||||
return core.Sprintf("%d %s", count, Pluralize(noun, count))
|
||||
lang := currentLangForGrammar()
|
||||
if core.Trim(noun) == "" {
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return noun
|
||||
if len(args) > 0 {
|
||||
count := getCount(args[0])
|
||||
return core.Sprintf("%s %s", FormatNumber(int64(count)), countWordForm(lang, noun, count))
|
||||
}
|
||||
return countWordForm(lang, noun, 1)
|
||||
}
|
||||
|
||||
// DoneHandler handles i18n.done.{verb} -> "File deleted" patterns.
|
||||
|
|
@ -59,11 +83,19 @@ func (h DoneHandler) Match(key string) bool {
|
|||
func (h DoneHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := core.TrimPrefix(key, "i18n.done.")
|
||||
if len(args) > 0 {
|
||||
if subj, ok := args[0].(string); ok {
|
||||
return ActionResult(verb, subj)
|
||||
if subj := subjectArgText(args[0]); subj != "" {
|
||||
if got := ActionResult(verb, subj); got != "" {
|
||||
return got
|
||||
}
|
||||
}
|
||||
}
|
||||
return Title(PastTense(verb))
|
||||
if got := Title(PastTense(verb)); got != "" {
|
||||
return got
|
||||
}
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FailHandler handles i18n.fail.{verb} -> "Failed to delete file" patterns.
|
||||
|
|
@ -76,11 +108,19 @@ func (h FailHandler) Match(key string) bool {
|
|||
func (h FailHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := core.TrimPrefix(key, "i18n.fail.")
|
||||
if len(args) > 0 {
|
||||
if subj, ok := args[0].(string); ok {
|
||||
return ActionFailed(verb, subj)
|
||||
if subj := subjectArgText(args[0]); subj != "" {
|
||||
if got := ActionFailed(verb, subj); got != "" {
|
||||
return got
|
||||
}
|
||||
}
|
||||
}
|
||||
return ActionFailed(verb, "")
|
||||
if got := ActionFailed(verb, ""); got != "" {
|
||||
return got
|
||||
}
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// NumericHandler handles i18n.numeric.{format} -> formatted numbers.
|
||||
|
|
@ -92,7 +132,10 @@ func (h NumericHandler) Match(key string) bool {
|
|||
|
||||
func (h NumericHandler) Handle(key string, args []any, next func() string) string {
|
||||
if len(args) == 0 {
|
||||
return next()
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
format := core.TrimPrefix(key, "i18n.numeric.")
|
||||
switch format {
|
||||
|
|
@ -113,10 +156,15 @@ func (h NumericHandler) Handle(key string, args []any, next func() string) strin
|
|||
}
|
||||
}
|
||||
}
|
||||
return next()
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DefaultHandlers returns the built-in i18n.* namespace handlers.
|
||||
//
|
||||
// handlers := i18n.DefaultHandlers()
|
||||
func DefaultHandlers() []KeyHandler {
|
||||
return []KeyHandler{
|
||||
LabelHandler{},
|
||||
|
|
@ -128,9 +176,135 @@ func DefaultHandlers() []KeyHandler {
|
|||
}
|
||||
}
|
||||
|
||||
func countWordForm(lang, noun string, count int) string {
|
||||
if hasGrammarCountForms(lang, noun) {
|
||||
return Pluralize(noun, count)
|
||||
}
|
||||
|
||||
display := renderWord(lang, noun)
|
||||
if display == "" {
|
||||
return Pluralize(noun, count)
|
||||
}
|
||||
if count == 1 {
|
||||
return display
|
||||
}
|
||||
if !isPluralisableWordDisplay(display) {
|
||||
return display
|
||||
}
|
||||
if isUpperAcronymPlural(display) {
|
||||
return display
|
||||
}
|
||||
return Pluralize(display, count)
|
||||
}
|
||||
|
||||
func hasGrammarCountForms(lang, noun string) bool {
|
||||
data := GetGrammarData(lang)
|
||||
if data == nil || len(data.Nouns) == 0 {
|
||||
return false
|
||||
}
|
||||
forms, ok := data.Nouns[core.Lower(noun)]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return forms.One != "" || forms.Other != ""
|
||||
}
|
||||
|
||||
func isPluralisableWordDisplay(s string) bool {
|
||||
hasLetter := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case unicode.IsLetter(r):
|
||||
hasLetter = true
|
||||
case unicode.IsSpace(r):
|
||||
// Multi-word vocabulary entries should stay exact. The count handler
|
||||
// prefixes the quantity, but does not invent a plural form for phrases.
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter
|
||||
}
|
||||
|
||||
func isUpperAcronymPlural(s string) bool {
|
||||
if len(s) < 2 || !core.HasSuffix(s, "s") {
|
||||
return false
|
||||
}
|
||||
hasLetter := false
|
||||
for _, r := range s[:len(s)-1] {
|
||||
if !unicode.IsLetter(r) {
|
||||
continue
|
||||
}
|
||||
hasLetter = true
|
||||
if !unicode.IsUpper(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter
|
||||
}
|
||||
|
||||
func isAllUpper(s string) bool {
|
||||
hasLetter := false
|
||||
for _, r := range s {
|
||||
if !unicode.IsLetter(r) {
|
||||
continue
|
||||
}
|
||||
hasLetter = true
|
||||
if !unicode.IsUpper(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter
|
||||
}
|
||||
|
||||
func subjectArgText(arg any) string {
|
||||
switch v := arg.(type) {
|
||||
case string:
|
||||
return v
|
||||
case *Subject:
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.String()
|
||||
case *TranslationContext:
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if text := core.Trim(v.String()); text != "" {
|
||||
return text
|
||||
}
|
||||
if v.Extra != nil {
|
||||
if text := contextArgText(v.Extra); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
case map[string]any:
|
||||
return contextArgText(v)
|
||||
case map[string]string:
|
||||
return contextArgText(v)
|
||||
case fmt.Stringer:
|
||||
return v.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func contextArgText(values any) string {
|
||||
for _, key := range []string{"Subject", "subject", "Value", "value", "Text", "text", "Context", "context", "Noun", "noun"} {
|
||||
if text, ok := mapValueString(values, key); ok {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RunHandlerChain executes a chain of handlers for a key.
|
||||
func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback func() string) string {
|
||||
for i, h := range handlers {
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
if h.Match(key) {
|
||||
next := func() string {
|
||||
remaining := handlers[i+1:]
|
||||
|
|
@ -145,6 +319,22 @@ func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback fun
|
|||
return fallback()
|
||||
}
|
||||
|
||||
func filterNilHandlers(handlers []KeyHandler) []KeyHandler {
|
||||
if len(handlers) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]KeyHandler, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
if h != nil {
|
||||
filtered = append(filtered, h)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
var (
|
||||
_ KeyHandler = LabelHandler{}
|
||||
_ KeyHandler = ProgressHandler{}
|
||||
|
|
|
|||
224
handler_test.go
224
handler_test.go
|
|
@ -22,6 +22,11 @@ func TestLabelHandler(t *testing.T) {
|
|||
if got != "Status:" {
|
||||
t.Errorf("LabelHandler.Handle(status) = %q, want %q", got, "Status:")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.label.", nil, func() string { return "fallback" })
|
||||
if got != "fallback" {
|
||||
t.Errorf("LabelHandler.Handle(empty) = %q, want %q", got, "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressHandler(t *testing.T) {
|
||||
|
|
@ -48,6 +53,31 @@ func TestProgressHandler(t *testing.T) {
|
|||
if got != "Building project..." {
|
||||
t.Errorf("ProgressHandler.Handle(build, project) = %q, want %q", got, "Building project...")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.progress.build", []any{S("project", "config.yaml")}, nil)
|
||||
if got != "Building config.yaml..." {
|
||||
t.Errorf("ProgressHandler.Handle(build, Subject) = %q, want %q", got, "Building config.yaml...")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.progress.build", []any{C("project")}, nil)
|
||||
if got != "Building project..." {
|
||||
t.Errorf("ProgressHandler.Handle(build, TranslationContext) = %q, want %q", got, "Building project...")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.progress.build", []any{map[string]any{"Subject": "project"}}, nil)
|
||||
if got != "Building project..." {
|
||||
t.Errorf("ProgressHandler.Handle(build, map[Subject:project]) = %q, want %q", got, "Building project...")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.progress.build", []any{map[string]string{"Subject": "project"}}, nil)
|
||||
if got != "Building project..." {
|
||||
t.Errorf("ProgressHandler.Handle(build, map[string]string[Subject:project]) = %q, want %q", got, "Building project...")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.progress.", nil, func() string { return "fallback" })
|
||||
if got != "fallback" {
|
||||
t.Errorf("ProgressHandler.Handle(empty) = %q, want %q", got, "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHandler(t *testing.T) {
|
||||
|
|
@ -72,7 +102,11 @@ func TestCountHandler(t *testing.T) {
|
|||
{"i18n.count.file", []any{5}, "5 files"},
|
||||
{"i18n.count.file", []any{0}, "0 files"},
|
||||
{"i18n.count.child", []any{3}, "3 children"},
|
||||
{"i18n.count.url", []any{2}, "2 URLs"},
|
||||
{"i18n.count.api", []any{2}, "2 APIs"},
|
||||
{"i18n.count.cpus", []any{2}, "2 CPUs"},
|
||||
{"i18n.count.file", nil, "file"},
|
||||
{"i18n.count.url", nil, "URL"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -83,6 +117,31 @@ 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) {
|
||||
|
|
@ -100,8 +159,28 @@ func TestDoneHandler(t *testing.T) {
|
|||
|
||||
// With subject
|
||||
got := h.Handle("i18n.done.delete", []any{"config.yaml"}, nil)
|
||||
if got != "Config.Yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, config.yaml) = %q, want %q", got, "Config.Yaml deleted")
|
||||
if got != "Config.yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, config.yaml) = %q, want %q", got, "Config.yaml deleted")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.done.delete", []any{S("file", "config.yaml")}, nil)
|
||||
if got != "Config.yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, Subject) = %q, want %q", got, "Config.yaml deleted")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.done.delete", []any{C("config.yaml")}, nil)
|
||||
if got != "Config.yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, TranslationContext) = %q, want %q", got, "Config.yaml deleted")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.done.delete", []any{map[string]any{"Subject": "config.yaml"}}, nil)
|
||||
if got != "Config.yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, map[Subject:config.yaml]) = %q, want %q", got, "Config.yaml deleted")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.done.delete", []any{map[string]string{"Subject": "config.yaml"}}, nil)
|
||||
if got != "Config.yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, map[string]string[Subject:config.yaml]) = %q, want %q", got, "Config.yaml deleted")
|
||||
}
|
||||
|
||||
// Without subject — just past tense
|
||||
|
|
@ -109,6 +188,11 @@ func TestDoneHandler(t *testing.T) {
|
|||
if got != "Deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete) = %q, want %q", got, "Deleted")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.done.", nil, func() string { return "fallback" })
|
||||
if got != "fallback" {
|
||||
t.Errorf("DoneHandler.Handle(empty) = %q, want %q", got, "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailHandler(t *testing.T) {
|
||||
|
|
@ -123,10 +207,35 @@ func TestFailHandler(t *testing.T) {
|
|||
t.Errorf("FailHandler.Handle(push, commits) = %q, want %q", got, "Failed to push commits")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.push", []any{S("commit", "commits")}, nil)
|
||||
if got != "Failed to push commits" {
|
||||
t.Errorf("FailHandler.Handle(push, Subject) = %q, want %q", got, "Failed to push commits")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.push", []any{C("commits")}, nil)
|
||||
if got != "Failed to push commits" {
|
||||
t.Errorf("FailHandler.Handle(push, TranslationContext) = %q, want %q", got, "Failed to push commits")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.push", []any{map[string]any{"Subject": "commits"}}, nil)
|
||||
if got != "Failed to push commits" {
|
||||
t.Errorf("FailHandler.Handle(push, map[Subject:commits]) = %q, want %q", got, "Failed to push commits")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.push", []any{map[string]string{"Subject": "commits"}}, nil)
|
||||
if got != "Failed to push commits" {
|
||||
t.Errorf("FailHandler.Handle(push, map[string]string[Subject:commits]) = %q, want %q", got, "Failed to push commits")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.push", nil, nil)
|
||||
if got != "Failed to push" {
|
||||
t.Errorf("FailHandler.Handle(push) = %q, want %q", got, "Failed to push")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.", nil, func() string { return "fallback" })
|
||||
if got != "fallback" {
|
||||
t.Errorf("FailHandler.Handle(empty) = %q, want %q", got, "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericHandler(t *testing.T) {
|
||||
|
|
@ -149,7 +258,9 @@ func TestNumericHandler(t *testing.T) {
|
|||
{"i18n.numeric.ordinal", []any{3}, "3rd"},
|
||||
{"i18n.numeric.ordinal", []any{11}, "11th"},
|
||||
{"i18n.numeric.percent", []any{0.85}, "85%"},
|
||||
{"i18n.numeric.bytes", []any{int64(1536000)}, "1.5 MB"},
|
||||
{"i18n.numeric.bytes", []any{int64(1536000)}, "1.46 MB"},
|
||||
{"i18n.numeric.number", []any{"1234567"}, "1,234,567"},
|
||||
{"i18n.numeric.ago", []any{5, "minutes"}, "5 minutes ago"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -166,6 +277,113 @@ func TestNumericHandler(t *testing.T) {
|
|||
if got != "fallback" {
|
||||
t.Errorf("NumericHandler with no args should fallback, got %q", got)
|
||||
}
|
||||
|
||||
// No args and no fallback should not panic.
|
||||
got = h.Handle("i18n.numeric.number", nil, nil)
|
||||
if got != "" {
|
||||
t.Errorf("NumericHandler with no args and no fallback = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHandler_UsesLocaleNumberFormat(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
h := CountHandler{}
|
||||
got := h.Handle("i18n.count.file", []any{1234}, nil)
|
||||
want := "1 234 files"
|
||||
if got != want {
|
||||
t.Errorf("CountHandler.Handle(locale format) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHandler_PreservesExactWordDisplay(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData(\"en\") returned nil")
|
||||
}
|
||||
original, existed := data.Words["go_mod"]
|
||||
data.Words["go_mod"] = "go.mod"
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
data.Words["go_mod"] = original
|
||||
return
|
||||
}
|
||||
delete(data.Words, "go_mod")
|
||||
})
|
||||
|
||||
h := CountHandler{}
|
||||
got := h.Handle("i18n.count.go_mod", []any{2}, nil)
|
||||
if got != "2 go.mod" {
|
||||
t.Fatalf("CountHandler.Handle(go_mod, 2) = %q, want %q", got, "2 go.mod")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHandler_PreservesPhraseDisplay(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData(\"en\") returned nil")
|
||||
}
|
||||
original, existed := data.Words["up_to_date"]
|
||||
data.Words["up_to_date"] = "up to date"
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
data.Words["up_to_date"] = original
|
||||
return
|
||||
}
|
||||
delete(data.Words, "up_to_date")
|
||||
})
|
||||
|
||||
h := CountHandler{}
|
||||
got := h.Handle("i18n.count.up_to_date", []any{2}, nil)
|
||||
if got != "2 up to date" {
|
||||
t.Fatalf("CountHandler.Handle(up_to_date, 2) = %q, want %q", got, "2 up to date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHandler_PluralisesLocaleNounPhrases(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
h := CountHandler{}
|
||||
got := h.Handle("i18n.count.mise à jour", []any{2}, nil)
|
||||
if got != "2 mises à jour" {
|
||||
t.Fatalf("CountHandler.Handle(mise à jour, 2) = %q, want %q", got, "2 mises à jour")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHandlerChain(t *testing.T) {
|
||||
|
|
|
|||
230
hooks.go
230
hooks.go
|
|
@ -2,23 +2,46 @@ package i18n
|
|||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"dappco.re/go/core"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
var missingKeyHandler atomic.Value
|
||||
var missingKeyHandlerMu sync.Mutex
|
||||
|
||||
type missingKeyHandlersState struct {
|
||||
handlers []MissingKeyHandler
|
||||
}
|
||||
|
||||
type localeRegistration struct {
|
||||
fsys fs.FS
|
||||
dir string
|
||||
id int
|
||||
}
|
||||
|
||||
type localeProviderRegistration struct {
|
||||
provider LocaleProvider
|
||||
id int
|
||||
}
|
||||
|
||||
// LocaleProvider supplies one or more locale filesystems to the default service.
|
||||
//
|
||||
// i18n.RegisterLocaleProvider(myProvider)
|
||||
type LocaleProvider interface {
|
||||
LocaleSources() []FSSource
|
||||
}
|
||||
|
||||
var (
|
||||
registeredLocales []localeRegistration
|
||||
registeredLocalesMu sync.Mutex
|
||||
localesLoaded bool
|
||||
registeredLocales []localeRegistration
|
||||
registeredLocaleProviders []localeProviderRegistration
|
||||
registeredLocalesMu sync.Mutex
|
||||
localesLoaded bool
|
||||
nextLocaleRegistrationID int
|
||||
nextLocaleProviderID int
|
||||
)
|
||||
|
||||
// RegisterLocales registers a filesystem containing locale files.
|
||||
|
|
@ -31,47 +54,202 @@ var (
|
|||
// i18n.RegisterLocales(localeFS, "locales")
|
||||
// }
|
||||
func RegisterLocales(fsys fs.FS, dir string) {
|
||||
reg := localeRegistration{fsys: fsys, dir: dir}
|
||||
registeredLocalesMu.Lock()
|
||||
defer registeredLocalesMu.Unlock()
|
||||
registeredLocales = append(registeredLocales, localeRegistration{fsys: fsys, dir: dir})
|
||||
if localesLoaded {
|
||||
if svc := Default(); svc != nil {
|
||||
if err := svc.LoadFS(fsys, dir); err != nil {
|
||||
log.Printf("i18n: RegisterLocales failed to load %q: %v", dir, err)
|
||||
}
|
||||
nextLocaleRegistrationID++
|
||||
reg.id = nextLocaleRegistrationID
|
||||
registeredLocales = append(registeredLocales, reg)
|
||||
svc := defaultService.Load()
|
||||
registeredLocalesMu.Unlock()
|
||||
if svc != nil {
|
||||
if err := svc.LoadFS(fsys, dir); err != nil {
|
||||
log.Error("i18n: RegisterLocales failed to load", "dir", dir, "err", err)
|
||||
} else {
|
||||
svc.markLocaleRegistrationLoaded(reg.id)
|
||||
markLocalesLoaded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterLocaleProvider registers a provider that can contribute locale files.
|
||||
// This is useful for packages that need to expose multiple locale sources as a
|
||||
// single unit.
|
||||
//
|
||||
// i18n.RegisterLocaleProvider(myProvider)
|
||||
func RegisterLocaleProvider(provider LocaleProvider) {
|
||||
if provider == nil {
|
||||
return
|
||||
}
|
||||
reg := localeProviderRegistration{provider: provider}
|
||||
registeredLocalesMu.Lock()
|
||||
nextLocaleProviderID++
|
||||
reg.id = nextLocaleProviderID
|
||||
registeredLocaleProviders = append(registeredLocaleProviders, reg)
|
||||
svc := defaultService.Load()
|
||||
registeredLocalesMu.Unlock()
|
||||
if svc != nil {
|
||||
loadLocaleProvider(svc, reg)
|
||||
}
|
||||
}
|
||||
|
||||
func loadRegisteredLocales(svc *Service) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
registeredLocalesMu.Lock()
|
||||
defer registeredLocalesMu.Unlock()
|
||||
for _, reg := range registeredLocales {
|
||||
locales := append([]localeRegistration(nil), registeredLocales...)
|
||||
providers := append([]localeProviderRegistration(nil), registeredLocaleProviders...)
|
||||
registeredLocalesMu.Unlock()
|
||||
|
||||
for _, reg := range locales {
|
||||
if svc != nil && svc.hasLocaleRegistrationLoaded(reg.id) {
|
||||
continue
|
||||
}
|
||||
if err := svc.LoadFS(reg.fsys, reg.dir); err != nil {
|
||||
log.Printf("i18n: loadRegisteredLocales failed to load %q: %v", reg.dir, err)
|
||||
log.Error("i18n: loadRegisteredLocales failed to load", "dir", reg.dir, "err", err)
|
||||
continue
|
||||
}
|
||||
svc.markLocaleRegistrationLoaded(reg.id)
|
||||
}
|
||||
for _, provider := range providers {
|
||||
if svc != nil && svc.hasLocaleProviderLoaded(provider.id) {
|
||||
continue
|
||||
}
|
||||
loadLocaleProvider(svc, provider)
|
||||
}
|
||||
|
||||
markLocalesLoaded()
|
||||
}
|
||||
|
||||
func loadLocaleProvider(svc *Service, provider localeProviderRegistration) {
|
||||
if svc == nil || provider.provider == nil {
|
||||
return
|
||||
}
|
||||
for _, src := range provider.provider.LocaleSources() {
|
||||
if err := svc.LoadFS(src.FS, src.Dir); err != nil {
|
||||
log.Error("i18n: loadLocaleProvider failed to load", "dir", src.Dir, "err", err)
|
||||
}
|
||||
}
|
||||
svc.markLocaleProviderLoaded(provider.id)
|
||||
markLocalesLoaded()
|
||||
}
|
||||
|
||||
func markLocalesLoaded() {
|
||||
registeredLocalesMu.Lock()
|
||||
localesLoaded = true
|
||||
registeredLocalesMu.Unlock()
|
||||
}
|
||||
|
||||
// OnMissingKey registers a handler for missing translation keys.
|
||||
func OnMissingKey(h MissingKeyHandler) {
|
||||
missingKeyHandler.Store(h)
|
||||
if h == nil {
|
||||
ClearMissingKeyHandlers()
|
||||
return
|
||||
}
|
||||
AddMissingKeyHandler(h)
|
||||
}
|
||||
|
||||
// SetMissingKeyHandlers replaces the full missing-key handler chain.
|
||||
func SetMissingKeyHandlers(handlers ...MissingKeyHandler) {
|
||||
missingKeyHandlerMu.Lock()
|
||||
defer missingKeyHandlerMu.Unlock()
|
||||
handlers = filterNilMissingKeyHandlers(handlers)
|
||||
if len(handlers) == 0 {
|
||||
missingKeyHandler.Store(missingKeyHandlersState{})
|
||||
return
|
||||
}
|
||||
missingKeyHandler.Store(missingKeyHandlersState{handlers: handlers})
|
||||
}
|
||||
|
||||
// ClearMissingKeyHandlers removes all registered missing-key handlers.
|
||||
func ClearMissingKeyHandlers() {
|
||||
missingKeyHandlerMu.Lock()
|
||||
defer missingKeyHandlerMu.Unlock()
|
||||
missingKeyHandler.Store(missingKeyHandlersState{})
|
||||
}
|
||||
|
||||
// AddMissingKeyHandler appends a missing-key handler without replacing any
|
||||
// existing handlers.
|
||||
func AddMissingKeyHandler(h MissingKeyHandler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
missingKeyHandlerMu.Lock()
|
||||
defer missingKeyHandlerMu.Unlock()
|
||||
current := missingKeyHandlers()
|
||||
current.handlers = append(current.handlers, h)
|
||||
missingKeyHandler.Store(current)
|
||||
}
|
||||
|
||||
func filterNilMissingKeyHandlers(handlers []MissingKeyHandler) []MissingKeyHandler {
|
||||
if len(handlers) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]MissingKeyHandler, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
if h != nil {
|
||||
filtered = append(filtered, h)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func missingKeyHandlers() missingKeyHandlersState {
|
||||
v := missingKeyHandler.Load()
|
||||
if v == nil {
|
||||
return missingKeyHandlersState{}
|
||||
}
|
||||
state, ok := v.(missingKeyHandlersState)
|
||||
if !ok {
|
||||
return missingKeyHandlersState{}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func dispatchMissingKey(key string, args map[string]any) {
|
||||
v := missingKeyHandler.Load()
|
||||
if v == nil {
|
||||
state := missingKeyHandlers()
|
||||
if len(state.handlers) == 0 {
|
||||
return
|
||||
}
|
||||
h, ok := v.(MissingKeyHandler)
|
||||
if !ok || h == nil {
|
||||
return
|
||||
file, line := missingKeyCaller()
|
||||
mk := cloneMissingKey(MissingKey{Key: key, Args: args, CallerFile: file, CallerLine: line})
|
||||
for _, h := range state.handlers {
|
||||
if h != nil {
|
||||
h(mk)
|
||||
}
|
||||
}
|
||||
_, file, line, ok := runtime.Caller(2)
|
||||
if !ok {
|
||||
file = "unknown"
|
||||
line = 0
|
||||
}
|
||||
h(MissingKey{Key: key, Args: args, CallerFile: file, CallerLine: line})
|
||||
}
|
||||
|
||||
func cloneMissingKey(mk MissingKey) MissingKey {
|
||||
if len(mk.Args) == 0 {
|
||||
mk.Args = nil
|
||||
return mk
|
||||
}
|
||||
args := make(map[string]any, len(mk.Args))
|
||||
for key, value := range mk.Args {
|
||||
args[key] = value
|
||||
}
|
||||
mk.Args = args
|
||||
return mk
|
||||
}
|
||||
|
||||
func missingKeyCaller() (string, int) {
|
||||
const packagePrefix = "dappco.re/go/core/i18n."
|
||||
|
||||
pcs := make([]uintptr, 16)
|
||||
n := runtime.Callers(2, pcs)
|
||||
frames := runtime.CallersFrames(pcs[:n])
|
||||
for {
|
||||
frame, more := frames.Next()
|
||||
if !core.HasPrefix(frame.Function, packagePrefix) || core.HasSuffix(frame.File, "_test.go") {
|
||||
return frame.File, frame.Line
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
return "unknown", 0
|
||||
}
|
||||
|
|
|
|||
692
hooks_test.go
692
hooks_test.go
|
|
@ -1,6 +1,8 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
|
|
@ -8,17 +10,28 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testLocaleProvider struct {
|
||||
sources []FSSource
|
||||
}
|
||||
|
||||
func (p testLocaleProvider) LocaleSources() []FSSource {
|
||||
return p.sources
|
||||
}
|
||||
|
||||
func TestRegisterLocales_Good(t *testing.T) {
|
||||
// Save and restore registered locales state
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
|
@ -37,6 +50,41 @@ func TestRegisterLocales_Good(t *testing.T) {
|
|||
assert.Equal(t, 1, count, "should have 1 registered locale")
|
||||
}
|
||||
|
||||
func TestRegisterLocaleProvider_Good(t *testing.T) {
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"provider.loaded": "loaded from provider"}`),
|
||||
},
|
||||
}
|
||||
|
||||
RegisterLocaleProvider(testLocaleProvider{
|
||||
sources: []FSSource{{FS: fs, Dir: "locales"}},
|
||||
})
|
||||
|
||||
got := svc.T("provider.loaded")
|
||||
assert.Equal(t, "loaded from provider", got)
|
||||
}
|
||||
|
||||
func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
|
||||
// When localesLoaded is true, RegisterLocales should also call LoadFS immediately
|
||||
svc, err := New()
|
||||
|
|
@ -47,13 +95,16 @@ func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
|
|||
// Save and restore state
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = true // Simulate already loaded
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
|
@ -72,6 +123,363 @@ func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
|
|||
assert.Equal(t, "arrived late", got)
|
||||
}
|
||||
|
||||
func TestRegisterLocales_Good_WithInitializedDefaultService(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"eager.registration": "loaded immediately"}`),
|
||||
},
|
||||
}
|
||||
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
got := svc.T("eager.registration")
|
||||
assert.Equal(t, "loaded immediately", got)
|
||||
}
|
||||
|
||||
func TestSetDefault_Good_LoadsQueuedRegisteredLocales(t *testing.T) {
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"queued.registration": "loaded via setdefault"}`),
|
||||
},
|
||||
}
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
got := svc.T("queued.registration")
|
||||
assert.Equal(t, "loaded via setdefault", got)
|
||||
}
|
||||
|
||||
func TestSetDefault_Good_LoadsRegisteredLocalesIntoFreshService(t *testing.T) {
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"fresh.registration": "fresh value"}`),
|
||||
},
|
||||
}
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
first, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(first)
|
||||
require.Equal(t, "fresh value", first.T("fresh.registration"))
|
||||
|
||||
second, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(second)
|
||||
|
||||
got := second.T("fresh.registration")
|
||||
assert.Equal(t, "fresh value", got)
|
||||
}
|
||||
|
||||
func TestInit_LoadsRegisteredLocales(t *testing.T) {
|
||||
// Save and restore global service state.
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
|
||||
defaultService.Store(nil)
|
||||
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
defaultService.Store(nil)
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"init.registered": "loaded on init"}`),
|
||||
},
|
||||
}
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
require.NoError(t, Init())
|
||||
|
||||
svc := Default()
|
||||
require.NotNil(t, svc)
|
||||
|
||||
got := svc.T("init.registered")
|
||||
assert.Equal(t, "loaded on init", got)
|
||||
}
|
||||
|
||||
func TestNewCoreService_LoadsRegisteredLocales(t *testing.T) {
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
|
||||
prev := defaultService.Load()
|
||||
SetDefault(nil)
|
||||
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
SetDefault(prev)
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"core.registered": "loaded on core bootstrap"}`),
|
||||
},
|
||||
}
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
factory := NewCoreService(ServiceOptions{})
|
||||
_, err := factory(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := Default()
|
||||
require.NotNil(t, svc)
|
||||
got := svc.T("core.registered")
|
||||
assert.Equal(t, "loaded on core bootstrap", got)
|
||||
}
|
||||
|
||||
func TestNewCoreService_InvalidLanguagePreservesSetLanguageError(t *testing.T) {
|
||||
factory := NewCoreService(ServiceOptions{Language: "es"})
|
||||
|
||||
_, err := factory(nil)
|
||||
require.Error(t, err)
|
||||
|
||||
msg := err.Error()
|
||||
assert.Contains(t, msg, "unsupported language: es")
|
||||
assert.Contains(t, msg, "available:")
|
||||
assert.NotContains(t, msg, "invalid language")
|
||||
}
|
||||
|
||||
func TestNewCoreService_AppliesOptions(t *testing.T) {
|
||||
prev := Default()
|
||||
SetDefault(nil)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
factory := NewCoreService(ServiceOptions{
|
||||
Language: "en",
|
||||
Fallback: "fr",
|
||||
Formality: FormalityFormal,
|
||||
Location: "workspace",
|
||||
Mode: ModeCollect,
|
||||
Debug: true,
|
||||
})
|
||||
|
||||
_, err := factory(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := Default()
|
||||
require.NotNil(t, svc)
|
||||
assert.Equal(t, "en", svc.Language())
|
||||
assert.Equal(t, "fr", svc.Fallback())
|
||||
assert.Equal(t, FormalityFormal, svc.Formality())
|
||||
assert.Equal(t, "workspace", svc.Location())
|
||||
assert.Equal(t, ModeCollect, svc.Mode())
|
||||
assert.True(t, svc.Debug())
|
||||
}
|
||||
|
||||
func TestCoreService_DelegatesToWrappedService(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
coreSvc := &CoreService{svc: svc}
|
||||
|
||||
assert.Equal(t, svc.T("i18n.label.status"), coreSvc.T("i18n.label.status"))
|
||||
assert.Equal(t, svc.Raw("i18n.label.status"), coreSvc.Raw("i18n.label.status"))
|
||||
assert.Equal(t, svc.Translate("i18n.label.status"), coreSvc.Translate("i18n.label.status"))
|
||||
assert.Equal(t, svc.AvailableLanguages(), coreSvc.AvailableLanguages())
|
||||
assert.Equal(t, svc.AvailableLanguages(), coreSvc.CurrentAvailableLanguages())
|
||||
assert.Equal(t, svc.Direction(), coreSvc.Direction())
|
||||
assert.Equal(t, svc.Direction(), coreSvc.CurrentDirection())
|
||||
assert.Equal(t, svc.Direction(), coreSvc.CurrentTextDirection())
|
||||
assert.Equal(t, svc.IsRTL(), coreSvc.IsRTL())
|
||||
assert.Equal(t, svc.IsRTL(), coreSvc.CurrentIsRTL())
|
||||
assert.Equal(t, svc.IsRTL(), coreSvc.RTL())
|
||||
assert.Equal(t, svc.IsRTL(), coreSvc.CurrentRTL())
|
||||
assert.Equal(t, svc.PluralCategory(2), coreSvc.PluralCategory(2))
|
||||
assert.Equal(t, svc.PluralCategory(2), coreSvc.CurrentPluralCategory(2))
|
||||
assert.Equal(t, svc.PluralCategory(2), coreSvc.PluralCategoryOf(2))
|
||||
assert.Equal(t, svc.Mode(), coreSvc.CurrentMode())
|
||||
assert.Equal(t, svc.Language(), coreSvc.CurrentLanguage())
|
||||
assert.Equal(t, svc.Language(), coreSvc.CurrentLang())
|
||||
assert.Equal(t, svc.Prompt("confirm"), coreSvc.Prompt("confirm"))
|
||||
assert.Equal(t, svc.Prompt("confirm"), coreSvc.CurrentPrompt("confirm"))
|
||||
assert.Equal(t, svc.Lang("fr"), coreSvc.Lang("fr"))
|
||||
assert.Equal(t, svc.Fallback(), coreSvc.CurrentFallback())
|
||||
assert.Equal(t, svc.Formality(), coreSvc.CurrentFormality())
|
||||
assert.Equal(t, svc.Location(), coreSvc.CurrentLocation())
|
||||
assert.Equal(t, svc.Debug(), coreSvc.CurrentDebug())
|
||||
|
||||
require.NoError(t, coreSvc.SetLanguage("en"))
|
||||
assert.Equal(t, "en", coreSvc.Language())
|
||||
|
||||
coreSvc.SetFallback("fr")
|
||||
assert.Equal(t, "fr", coreSvc.Fallback())
|
||||
|
||||
coreSvc.SetFormality(FormalityFormal)
|
||||
assert.Equal(t, FormalityFormal, coreSvc.Formality())
|
||||
|
||||
coreSvc.SetLocation("workspace")
|
||||
assert.Equal(t, "workspace", coreSvc.Location())
|
||||
|
||||
coreSvc.SetDebug(true)
|
||||
assert.True(t, coreSvc.Debug())
|
||||
coreSvc.SetDebug(false)
|
||||
assert.False(t, coreSvc.Debug())
|
||||
|
||||
handlers := coreSvc.Handlers()
|
||||
assert.Equal(t, svc.Handlers(), handlers)
|
||||
assert.Equal(t, svc.Handlers(), coreSvc.CurrentHandlers())
|
||||
|
||||
coreSvc.SetHandlers(LabelHandler{})
|
||||
require.Len(t, coreSvc.Handlers(), 1)
|
||||
assert.IsType(t, LabelHandler{}, coreSvc.Handlers()[0])
|
||||
|
||||
coreSvc.AddHandler(ProgressHandler{})
|
||||
require.Len(t, coreSvc.Handlers(), 2)
|
||||
assert.IsType(t, ProgressHandler{}, coreSvc.Handlers()[1])
|
||||
|
||||
coreSvc.PrependHandler(CountHandler{})
|
||||
require.Len(t, coreSvc.Handlers(), 3)
|
||||
assert.IsType(t, CountHandler{}, coreSvc.Handlers()[0])
|
||||
|
||||
coreSvc.ClearHandlers()
|
||||
assert.Empty(t, coreSvc.Handlers())
|
||||
|
||||
coreSvc.ResetHandlers()
|
||||
require.NotEmpty(t, coreSvc.Handlers())
|
||||
assert.IsType(t, LabelHandler{}, coreSvc.Handlers()[0])
|
||||
|
||||
require.NoError(t, coreSvc.AddLoader(NewFSLoader(fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{Data: []byte(`{"core.service.loaded": "loaded"}`)},
|
||||
}, "locales")))
|
||||
assert.Equal(t, "loaded", coreSvc.T("core.service.loaded"))
|
||||
|
||||
require.NoError(t, coreSvc.LoadFS(fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{Data: []byte(`{"core.service.loaded.fs": "loaded via fs"}`)},
|
||||
}, "locales"))
|
||||
assert.Equal(t, "loaded via fs", coreSvc.T("core.service.loaded.fs"))
|
||||
|
||||
coreSvc.AddMessages("en", map[string]string{
|
||||
"core.service.add.messages": "loaded via add messages",
|
||||
})
|
||||
assert.Equal(t, "loaded via add messages", coreSvc.T("core.service.add.messages"))
|
||||
}
|
||||
|
||||
func TestInit_ReDetectsRegisteredLocales(t *testing.T) {
|
||||
t.Setenv("LANG", "de_DE.UTF-8")
|
||||
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
|
||||
defaultService.Store(nil)
|
||||
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
defaultService.Store(nil)
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/de.json": &fstest.MapFile{
|
||||
Data: []byte(`{"hello": "hallo"}`),
|
||||
},
|
||||
}
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
require.NoError(t, Init())
|
||||
|
||||
svc := Default()
|
||||
require.NotNil(t, svc)
|
||||
assert.Contains(t, svc.Language(), "de")
|
||||
assert.Equal(t, "hallo", svc.T("hello"))
|
||||
}
|
||||
|
||||
func TestDefault_ReinitialisesAfterClear(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
SetDefault(nil)
|
||||
|
||||
require.NoError(t, Init())
|
||||
|
||||
svc := Default()
|
||||
require.NotNil(t, svc)
|
||||
assert.Equal(t, "y", svc.T("prompt.yes"))
|
||||
}
|
||||
|
||||
func TestLoadRegisteredLocales_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -79,6 +487,7 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
|
|||
// Save and restore state
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = []localeRegistration{
|
||||
{
|
||||
|
|
@ -90,11 +499,13 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
|
|||
dir: "loc",
|
||||
},
|
||||
}
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
|
@ -113,6 +524,11 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
|
|||
func TestOnMissingKey_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var captured MissingKey
|
||||
|
|
@ -120,17 +536,285 @@ func TestOnMissingKey_Good(t *testing.T) {
|
|||
captured = m
|
||||
})
|
||||
|
||||
_ = svc.T("missing.test.key", map[string]any{"foo": "bar"})
|
||||
_ = T("missing.test.key", map[string]any{"foo": "bar"})
|
||||
|
||||
assert.Equal(t, "missing.test.key", captured.Key)
|
||||
assert.Equal(t, "bar", captured.Args["foo"])
|
||||
assert.NotEmpty(t, captured.CallerFile)
|
||||
assert.Equal(t, "hooks_test.go", filepath.Base(captured.CallerFile))
|
||||
}
|
||||
|
||||
func TestOnMissingKey_Good_AppendsHandlers(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc.SetMode(ModeCollect)
|
||||
ClearMissingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
ClearMissingKeyHandlers()
|
||||
})
|
||||
|
||||
var first, second int
|
||||
OnMissingKey(func(MissingKey) { first++ })
|
||||
OnMissingKey(func(MissingKey) { second++ })
|
||||
|
||||
_ = T("missing.on.handler.append")
|
||||
|
||||
assert.Equal(t, 1, first)
|
||||
assert.Equal(t, 1, second)
|
||||
}
|
||||
|
||||
func TestAddMissingKeyHandler_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
ClearMissingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
ClearMissingKeyHandlers()
|
||||
})
|
||||
|
||||
var first, second int
|
||||
AddMissingKeyHandler(func(MissingKey) {
|
||||
first++
|
||||
})
|
||||
AddMissingKeyHandler(func(MissingKey) {
|
||||
second++
|
||||
})
|
||||
|
||||
_ = T("missing.multiple.handlers")
|
||||
|
||||
assert.Equal(t, 1, first)
|
||||
assert.Equal(t, 1, second)
|
||||
}
|
||||
|
||||
func TestSetMissingKeyHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var first, second int
|
||||
SetMissingKeyHandlers(
|
||||
nil,
|
||||
func(MissingKey) { first++ },
|
||||
func(MissingKey) { second++ },
|
||||
)
|
||||
|
||||
_ = T("missing.set.handlers")
|
||||
|
||||
assert.Equal(t, 1, first)
|
||||
assert.Equal(t, 1, second)
|
||||
assert.Len(t, missingKeyHandlers().handlers, 2)
|
||||
}
|
||||
|
||||
func TestSetMissingKeyHandlers_Good_Clear(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var called int
|
||||
SetMissingKeyHandlers(func(MissingKey) { called++ })
|
||||
SetMissingKeyHandlers(nil)
|
||||
|
||||
_ = T("missing.set.handlers.clear")
|
||||
|
||||
assert.Equal(t, 0, called)
|
||||
assert.Empty(t, missingKeyHandlers().handlers)
|
||||
}
|
||||
|
||||
func TestAddMissingKeyHandler_Good_Concurrent(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
ClearMissingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
ClearMissingKeyHandlers()
|
||||
})
|
||||
|
||||
const handlers = 32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(handlers)
|
||||
for i := 0; i < handlers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
AddMissingKeyHandler(func(MissingKey) {})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
state := missingKeyHandlers()
|
||||
assert.Len(t, state.handlers, handlers)
|
||||
}
|
||||
|
||||
func TestClearMissingKeyHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var called int
|
||||
AddMissingKeyHandler(func(MissingKey) {
|
||||
called++
|
||||
})
|
||||
|
||||
ClearMissingKeyHandlers()
|
||||
|
||||
_ = T("missing.after.clear")
|
||||
|
||||
assert.Equal(t, 0, called)
|
||||
}
|
||||
|
||||
func TestOnMissingKey_Good_SubjectArgs(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var captured MissingKey
|
||||
OnMissingKey(func(m MissingKey) {
|
||||
captured = m
|
||||
})
|
||||
|
||||
_ = T("missing.subject.key", S("file", "config.yaml").Count(3).In("workspace").Formal())
|
||||
|
||||
assert.Equal(t, "missing.subject.key", captured.Key)
|
||||
assert.Equal(t, "config.yaml", captured.Args["Subject"])
|
||||
assert.Equal(t, "file", captured.Args["Noun"])
|
||||
assert.Equal(t, 3, captured.Args["Count"])
|
||||
assert.Equal(t, "workspace", captured.Args["Location"])
|
||||
assert.Equal(t, FormalityFormal, captured.Args["Formality"])
|
||||
}
|
||||
|
||||
func TestOnMissingKey_Good_TranslationContextArgs(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var captured MissingKey
|
||||
OnMissingKey(func(m MissingKey) {
|
||||
captured = m
|
||||
})
|
||||
|
||||
_ = T("missing.context.key", C("navigation").WithGender("feminine").In("workspace").Formal())
|
||||
|
||||
assert.Equal(t, "missing.context.key", captured.Key)
|
||||
assert.Equal(t, "navigation", captured.Args["Context"])
|
||||
assert.Equal(t, "feminine", captured.Args["Gender"])
|
||||
assert.Equal(t, "workspace", captured.Args["Location"])
|
||||
assert.Equal(t, FormalityFormal, captured.Args["Formality"])
|
||||
}
|
||||
|
||||
func TestOnMissingKey_Good_MergesAdditionalArgs(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var captured MissingKey
|
||||
OnMissingKey(func(m MissingKey) {
|
||||
captured = m
|
||||
})
|
||||
|
||||
_ = T("missing.extra.args", S("file", "config.yaml"), map[string]any{"trace": "abc123"})
|
||||
|
||||
assert.Equal(t, "missing.extra.args", captured.Key)
|
||||
assert.Equal(t, "config.yaml", captured.Args["Subject"])
|
||||
assert.Equal(t, "abc123", captured.Args["trace"])
|
||||
}
|
||||
|
||||
func TestDispatchMissingKey_Good_NoHandler(t *testing.T) {
|
||||
// Store nil handler (using correct type)
|
||||
missingKeyHandler.Store(MissingKeyHandler(nil))
|
||||
// Reset to the empty handler set.
|
||||
OnMissingKey(nil)
|
||||
|
||||
// Should not panic when dispatching with nil handler
|
||||
dispatchMissingKey("test.key", nil)
|
||||
}
|
||||
|
||||
func TestCoreServiceSetMode_Good_PreservesMissingKeyHandlers(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
prev := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prev)
|
||||
})
|
||||
|
||||
var observed int
|
||||
OnMissingKey(func(MissingKey) {
|
||||
observed++
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
OnMissingKey(nil)
|
||||
})
|
||||
|
||||
coreSvc := &CoreService{svc: svc}
|
||||
coreSvc.SetMode(ModeCollect)
|
||||
|
||||
_ = svc.T("missing.core.service.key")
|
||||
|
||||
if observed != 1 {
|
||||
t.Fatalf("custom missing key handler called %d times, want 1", observed)
|
||||
}
|
||||
|
||||
missing := coreSvc.MissingKeys()
|
||||
if len(missing) != 1 {
|
||||
t.Fatalf("CoreService captured %d missing keys, want 1", len(missing))
|
||||
}
|
||||
if missing[0].Key != "missing.core.service.key" {
|
||||
t.Fatalf("captured missing key = %q, want %q", missing[0].Key, "missing.core.service.key")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
422
i18n.go
422
i18n.go
|
|
@ -2,25 +2,44 @@ package i18n
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"text/template"
|
||||
|
||||
"dappco.re/go/core"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// T translates a message using the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.T("greeting")
|
||||
func T(messageID string, args ...any) string {
|
||||
if svc := Default(); svc != nil {
|
||||
return defaultServiceValue(messageID, func(svc *Service) string {
|
||||
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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.Raw("prompt.yes")
|
||||
func Raw(messageID string, args ...any) string {
|
||||
if svc := Default(); svc != nil {
|
||||
return defaultServiceValue(messageID, func(svc *Service) string {
|
||||
return svc.Raw(messageID, args...)
|
||||
}
|
||||
return messageID
|
||||
})
|
||||
}
|
||||
|
||||
// ErrServiceNotInitialised is returned when the service is not initialised.
|
||||
|
|
@ -30,59 +49,342 @@ var ErrServiceNotInitialised = core.NewError("i18n: service not initialised")
|
|||
var ErrServiceNotInitialized = ErrServiceNotInitialised
|
||||
|
||||
// SetLanguage sets the language for the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// _ = i18n.SetLanguage("fr")
|
||||
func SetLanguage(lang string) error {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return ErrServiceNotInitialised
|
||||
}
|
||||
return svc.SetLanguage(lang)
|
||||
return defaultServiceValue(ErrServiceNotInitialised, func(svc *Service) error {
|
||||
return svc.SetLanguage(lang)
|
||||
})
|
||||
}
|
||||
|
||||
// CurrentLanguage returns the current language code.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// lang := i18n.CurrentLanguage()
|
||||
func CurrentLanguage() string {
|
||||
if svc := Default(); svc != nil {
|
||||
return Language()
|
||||
}
|
||||
|
||||
// CurrentLang is a short alias for CurrentLanguage.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// lang := i18n.CurrentLang()
|
||||
func CurrentLang() string {
|
||||
return CurrentLanguage()
|
||||
}
|
||||
|
||||
// Language returns the current language code.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// lang := i18n.Language()
|
||||
func Language() string {
|
||||
return defaultServiceValue("en", func(svc *Service) string {
|
||||
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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.SetMode(i18n.ModeCollect)
|
||||
func SetMode(m Mode) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.SetMode(m)
|
||||
}
|
||||
withDefaultService(func(svc *Service) { svc.SetMode(m) })
|
||||
}
|
||||
|
||||
// SetFallback sets the fallback language for the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.SetFallback("en")
|
||||
func SetFallback(lang string) {
|
||||
withDefaultService(func(svc *Service) { svc.SetFallback(lang) })
|
||||
}
|
||||
|
||||
// Fallback returns the current fallback language.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// fallback := i18n.Fallback()
|
||||
func Fallback() string {
|
||||
return defaultServiceValue("en", func(svc *Service) string {
|
||||
return svc.Fallback()
|
||||
})
|
||||
}
|
||||
|
||||
// CurrentMode returns the current translation mode.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// mode := i18n.CurrentMode()
|
||||
func CurrentMode() Mode {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.Mode()
|
||||
}
|
||||
return ModeNormal
|
||||
return defaultServiceValue(ModeNormal, func(svc *Service) Mode { return svc.Mode() })
|
||||
}
|
||||
|
||||
// N formats a number using the i18n.numeric.* namespace.
|
||||
// CurrentFallback returns the current fallback language.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// fallback := i18n.CurrentFallback()
|
||||
func CurrentFallback() string {
|
||||
return Fallback()
|
||||
}
|
||||
|
||||
// CurrentFormality returns the current default formality.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// formality := i18n.CurrentFormality()
|
||||
func CurrentFormality() Formality {
|
||||
return defaultServiceValue(FormalityNeutral, func(svc *Service) Formality { return svc.Formality() })
|
||||
}
|
||||
|
||||
// CurrentDebug reports whether debug mode is enabled on the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// debug := i18n.CurrentDebug()
|
||||
func CurrentDebug() bool {
|
||||
return Debug()
|
||||
}
|
||||
|
||||
// State returns a copy-safe snapshot of the default service configuration.
|
||||
//
|
||||
// state := i18n.State()
|
||||
func State() ServiceState {
|
||||
return defaultServiceValue(defaultServiceStateSnapshot(), func(svc *Service) ServiceState {
|
||||
return svc.State()
|
||||
})
|
||||
}
|
||||
|
||||
// CurrentState is a more explicit alias for State.
|
||||
//
|
||||
// state := i18n.CurrentState()
|
||||
func CurrentState() ServiceState {
|
||||
return State()
|
||||
}
|
||||
|
||||
// Debug reports whether debug mode is enabled on the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// debug := i18n.Debug()
|
||||
func Debug() bool {
|
||||
return defaultServiceValue(false, func(svc *Service) bool {
|
||||
return svc.Debug()
|
||||
})
|
||||
}
|
||||
|
||||
// N formats a value using the i18n.numeric.* namespace.
|
||||
//
|
||||
// N("number", 1234567) // "1,234,567"
|
||||
// N("percent", 0.85) // "85%"
|
||||
// N("bytes", 1536000) // "1.5 MB"
|
||||
// N("bytes", 1536000) // "1.46 MB"
|
||||
// N("ordinal", 1) // "1st"
|
||||
func N(format string, value any) string {
|
||||
return T("i18n.numeric."+format, value)
|
||||
//
|
||||
// Multi-argument formats such as "ago" also pass through unchanged:
|
||||
//
|
||||
// N("ago", 5, "minutes") // "5 minutes ago"
|
||||
func N(format string, value any, args ...any) string {
|
||||
format = normalizeLookupKey(format)
|
||||
switch format {
|
||||
case "number", "int":
|
||||
return FormatNumber(toInt64(value))
|
||||
case "decimal", "float":
|
||||
return FormatDecimal(toFloat64(value))
|
||||
case "percent", "pct":
|
||||
return FormatPercent(toFloat64(value))
|
||||
case "bytes", "size":
|
||||
return FormatBytes(toInt64(value))
|
||||
case "ordinal", "ord":
|
||||
return FormatOrdinal(toInt(value))
|
||||
case "ago":
|
||||
if len(args) > 0 {
|
||||
if unit, ok := args[0].(string); ok {
|
||||
return FormatAgo(toInt(value), unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
return T("i18n.numeric."+format, append([]any{value}, args...)...)
|
||||
}
|
||||
|
||||
// AddHandler appends a handler to the default service's handler chain.
|
||||
func AddHandler(h KeyHandler) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.AddHandler(h)
|
||||
}
|
||||
// Prompt translates a prompt key from the prompt namespace.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.Prompt("confirm")
|
||||
//
|
||||
// Prompt("yes") // "y"
|
||||
// Prompt("confirm") // "Are you sure?"
|
||||
func Prompt(key string) string {
|
||||
return defaultServiceNamespaceValue("prompt", key, func(svc *Service, resolved string) string {
|
||||
return svc.Prompt(resolved)
|
||||
})
|
||||
}
|
||||
|
||||
// PrependHandler inserts a handler at the start of the default service's handler chain.
|
||||
func PrependHandler(h KeyHandler) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.PrependHandler(h)
|
||||
// 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 {
|
||||
|
|
@ -112,6 +414,7 @@ func applyTemplate(text string, data any) string {
|
|||
if !core.Contains(text, "{{") {
|
||||
return text
|
||||
}
|
||||
data = templateDataForRendering(data)
|
||||
if cached, ok := templateCache.Load(text); ok {
|
||||
var buf bytes.Buffer
|
||||
if err := cached.(*template.Template).Execute(&buf, data); err != nil {
|
||||
|
|
@ -119,7 +422,7 @@ func applyTemplate(text string, data any) string {
|
|||
}
|
||||
return buf.String()
|
||||
}
|
||||
tmpl, err := template.New("").Parse(text)
|
||||
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(text)
|
||||
if err != nil {
|
||||
return text
|
||||
}
|
||||
|
|
@ -130,3 +433,52 @@ func applyTemplate(text string, data any) string {
|
|||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func templateDataForRendering(data any) any {
|
||||
switch v := data.(type) {
|
||||
case *TranslationContext:
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
count, explicit := v.countValue()
|
||||
if !explicit && v.Extra != nil {
|
||||
if c, ok := v.Extra["Count"]; ok {
|
||||
count = toInt(c)
|
||||
} else if c, ok := v.Extra["count"]; ok {
|
||||
count = toInt(c)
|
||||
}
|
||||
}
|
||||
rendered := map[string]any{
|
||||
"Context": v.Context,
|
||||
"Gender": v.Gender,
|
||||
"Location": v.Location,
|
||||
"Formality": v.Formality,
|
||||
"Count": count,
|
||||
"IsPlural": count != 1,
|
||||
"Extra": v.Extra,
|
||||
}
|
||||
for key, value := range v.Extra {
|
||||
if _, exists := rendered[key]; !exists {
|
||||
rendered[key] = value
|
||||
}
|
||||
}
|
||||
return rendered
|
||||
case *Subject:
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"Subject": v.String(),
|
||||
"Noun": v.Noun,
|
||||
"Count": v.count,
|
||||
"Gender": v.gender,
|
||||
"Location": v.location,
|
||||
"Formality": v.formality,
|
||||
"IsFormal": v.formality == FormalityFormal,
|
||||
"IsPlural": v.count != 1,
|
||||
"Value": v.Value,
|
||||
}
|
||||
default:
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
|
|
|||
519
i18n_test.go
519
i18n_test.go
|
|
@ -2,6 +2,7 @@ package i18n
|
|||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -39,6 +40,48 @@ func TestT_Good_MissingKey(t *testing.T) {
|
|||
assert.Equal(t, "nonexistent.key.test", got)
|
||||
}
|
||||
|
||||
// --- Package-level Translate() ---
|
||||
|
||||
func TestTranslate_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
result := Translate("prompt.yes")
|
||||
require.True(t, result.OK)
|
||||
assert.Equal(t, "y", result.Value)
|
||||
}
|
||||
|
||||
func TestTranslate_Good_MissingKey(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
result := Translate("nonexistent.translation.key")
|
||||
require.False(t, result.OK)
|
||||
assert.Equal(t, "nonexistent.translation.key", result.Value)
|
||||
}
|
||||
|
||||
func TestTranslate_Good_SameTextAsKey(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
AddMessages("en", map[string]string{
|
||||
"exact.same.key": "exact.same.key",
|
||||
})
|
||||
|
||||
result := Translate("exact.same.key")
|
||||
require.True(t, result.OK)
|
||||
assert.Equal(t, "exact.same.key", result.Value)
|
||||
}
|
||||
|
||||
// --- Package-level Raw() ---
|
||||
|
||||
func TestRaw_Good(t *testing.T) {
|
||||
|
|
@ -62,6 +105,44 @@ func TestRaw_Good_BypassesHandlers(t *testing.T) {
|
|||
assert.Equal(t, "i18n.label.status", got)
|
||||
}
|
||||
|
||||
func TestLoadFS_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"loadfs.key": "loaded via package helper"}`),
|
||||
},
|
||||
}
|
||||
|
||||
LoadFS(fsys, "locales")
|
||||
|
||||
got := T("loadfs.key")
|
||||
assert.Equal(t, "loaded via package helper", got)
|
||||
}
|
||||
|
||||
func TestAddMessages_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
AddMessages("en", map[string]string{
|
||||
"add.messages.key": "loaded via package helper",
|
||||
})
|
||||
|
||||
got := T("add.messages.key")
|
||||
assert.Equal(t, "loaded via package helper", got)
|
||||
}
|
||||
|
||||
// --- SetLanguage / CurrentLanguage ---
|
||||
|
||||
func TestSetLanguage_Good(t *testing.T) {
|
||||
|
|
@ -75,6 +156,27 @@ func TestSetLanguage_Good(t *testing.T) {
|
|||
assert.Contains(t, CurrentLanguage(), "en")
|
||||
}
|
||||
|
||||
func TestSetLanguage_Good_UnderscoreTag(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
err = SetLanguage("fr_CA")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, len(CurrentLanguage()) >= 2)
|
||||
assert.Equal(t, "fr", CurrentLanguage()[:2])
|
||||
}
|
||||
|
||||
func TestLanguage_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, CurrentLanguage(), Language())
|
||||
}
|
||||
|
||||
func TestSetLanguage_Bad_Unsupported(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -92,6 +194,95 @@ func TestCurrentLanguage_Good(t *testing.T) {
|
|||
|
||||
lang := CurrentLanguage()
|
||||
assert.NotEmpty(t, lang)
|
||||
assert.Equal(t, lang, CurrentLang())
|
||||
}
|
||||
|
||||
func TestAvailableLanguages_Good(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
langs := AvailableLanguages()
|
||||
require.NotEmpty(t, langs)
|
||||
assert.Equal(t, svc.AvailableLanguages(), langs)
|
||||
|
||||
langs[0] = "zz"
|
||||
assert.NotEqual(t, "zz", svc.AvailableLanguages()[0])
|
||||
}
|
||||
|
||||
func TestCurrentAvailableLanguages_Good(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
langs := CurrentAvailableLanguages()
|
||||
require.NotEmpty(t, langs)
|
||||
assert.Equal(t, svc.AvailableLanguages(), langs)
|
||||
}
|
||||
|
||||
func TestFallback_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, "en", Fallback())
|
||||
|
||||
SetFallback("fr")
|
||||
assert.Equal(t, "fr", Fallback())
|
||||
}
|
||||
|
||||
func TestDebug_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.False(t, Debug())
|
||||
|
||||
SetDebug(true)
|
||||
assert.True(t, Debug())
|
||||
}
|
||||
|
||||
func TestCurrentState_Good(t *testing.T) {
|
||||
svc, err := NewWithLoader(messageBaseFallbackLoader{})
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
state := CurrentState()
|
||||
assert.Equal(t, svc.Language(), state.Language)
|
||||
assert.Equal(t, svc.AvailableLanguages(), state.AvailableLanguages)
|
||||
assert.Equal(t, svc.Mode(), state.Mode)
|
||||
assert.Equal(t, svc.Fallback(), state.Fallback)
|
||||
assert.Equal(t, svc.Formality(), state.Formality)
|
||||
assert.Equal(t, svc.Location(), state.Location)
|
||||
assert.Equal(t, svc.Direction(), state.Direction)
|
||||
assert.Equal(t, svc.IsRTL(), state.IsRTL)
|
||||
assert.Equal(t, svc.Debug(), state.Debug)
|
||||
assert.Len(t, state.Handlers, len(svc.Handlers()))
|
||||
|
||||
state.AvailableLanguages[0] = "zz"
|
||||
assert.NotEqual(t, "zz", CurrentState().AvailableLanguages[0])
|
||||
state.Handlers[0] = nil
|
||||
assert.NotNil(t, CurrentState().Handlers[0])
|
||||
}
|
||||
|
||||
func TestState_Good_WithoutDefaultService(t *testing.T) {
|
||||
var svc *Service
|
||||
state := svc.State()
|
||||
assert.Equal(t, defaultServiceStateSnapshot(), state)
|
||||
}
|
||||
|
||||
// --- SetMode / CurrentMode ---
|
||||
|
|
@ -130,21 +321,136 @@ func TestN_Good(t *testing.T) {
|
|||
name string
|
||||
format string
|
||||
value any
|
||||
args []any
|
||||
want string
|
||||
}{
|
||||
{"number", "number", int64(1234567), "1,234,567"},
|
||||
{"percent", "percent", 0.85, "85%"},
|
||||
{"bytes", "bytes", int64(1536000), "1.5 MB"},
|
||||
{"ordinal", "ordinal", 1, "1st"},
|
||||
{"number", "number", int64(1234567), nil, "1,234,567"},
|
||||
{"percent", "percent", 0.85, nil, "85%"},
|
||||
{"bytes", "bytes", int64(1536000), nil, "1.46 MB"},
|
||||
{"ordinal", "ordinal", 1, nil, "1st"},
|
||||
{"ago", "ago", 5, []any{"minutes"}, "5 minutes ago"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := N(tt.format, tt.value)
|
||||
got := N(tt.format, tt.value, tt.args...)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestN_Good_WithoutDefaultService(t *testing.T) {
|
||||
prev := Default()
|
||||
SetDefault(nil)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
value any
|
||||
args []any
|
||||
want string
|
||||
}{
|
||||
{"number", "number", int64(1234567), nil, "1,234,567"},
|
||||
{"percent", "percent", 0.85, nil, "85%"},
|
||||
{"bytes", "bytes", int64(1536000), nil, "1.46 MB"},
|
||||
{"ordinal", "ordinal", 1, nil, "1st"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := N(tt.format, tt.value, tt.args...)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prompt() prompt shorthand ---
|
||||
|
||||
func TestPrompt_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"yes", "yes", "y"},
|
||||
{"yes_trimmed", " yes ", "y"},
|
||||
{"yes_prefixed", "prompt.yes", "y"},
|
||||
{"confirm", "confirm", "Are you sure?"},
|
||||
{"confirm_prefixed", "prompt.confirm", "Are you sure?"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Prompt(tt.key)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentPrompt_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, Prompt("confirm"), CurrentPrompt("confirm"))
|
||||
}
|
||||
|
||||
// --- Lang() language label shorthand ---
|
||||
|
||||
func TestLang_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"de", "de", "German"},
|
||||
{"fr", "fr", "French"},
|
||||
{"fr_ca", "fr_CA", "French"},
|
||||
{"fr_prefixed", "lang.fr", "French"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Lang(tt.key)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLang_MissingKeyHandler_FiresOnce(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetMissingKeyHandlers()
|
||||
SetMode(ModeNormal)
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
SetMode(ModeCollect)
|
||||
calls := 0
|
||||
SetMissingKeyHandlers(func(MissingKey) {
|
||||
calls++
|
||||
})
|
||||
|
||||
got := Lang("zz")
|
||||
assert.Equal(t, "[lang.zz]", got)
|
||||
assert.Equal(t, 1, calls)
|
||||
}
|
||||
|
||||
// --- AddHandler / PrependHandler ---
|
||||
|
||||
func TestAddHandler_Good(t *testing.T) {
|
||||
|
|
@ -159,6 +465,46 @@ func TestAddHandler_Good(t *testing.T) {
|
|||
assert.Equal(t, initialCount+1, len(svc.Handlers()))
|
||||
}
|
||||
|
||||
func TestAddHandler_Good_Variadic(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
AddHandler(LabelHandler{}, ProgressHandler{})
|
||||
handlers := svc.Handlers()
|
||||
assert.Equal(t, 2, len(handlers))
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
assert.IsType(t, ProgressHandler{}, handlers[1])
|
||||
}
|
||||
|
||||
func TestAddHandler_Good_SkipsNil(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
var nilHandler KeyHandler
|
||||
AddHandler(nilHandler, LabelHandler{})
|
||||
|
||||
handlers := svc.Handlers()
|
||||
require.Len(t, handlers, 1)
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
}
|
||||
|
||||
func TestAddHandler_DoesNotMutateInputSlice(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
handlers := []KeyHandler{nil, LabelHandler{}}
|
||||
AddHandler(handlers...)
|
||||
|
||||
assert.Nil(t, handlers[0])
|
||||
assert.IsType(t, LabelHandler{}, handlers[1])
|
||||
}
|
||||
|
||||
func TestPrependHandler_Good(t *testing.T) {
|
||||
svc, err := New(WithHandlers()) // start with no handlers
|
||||
require.NoError(t, err)
|
||||
|
|
@ -174,6 +520,132 @@ func TestPrependHandler_Good(t *testing.T) {
|
|||
assert.Equal(t, 2, len(handlers))
|
||||
}
|
||||
|
||||
func TestPrependHandler_Good_Variadic(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
PrependHandler(LabelHandler{}, ProgressHandler{})
|
||||
handlers := svc.Handlers()
|
||||
assert.Equal(t, 2, len(handlers))
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
assert.IsType(t, ProgressHandler{}, handlers[1])
|
||||
}
|
||||
|
||||
func TestPrependHandler_Good_SkipsNil(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
var nilHandler KeyHandler
|
||||
PrependHandler(nilHandler, LabelHandler{})
|
||||
|
||||
handlers := svc.Handlers()
|
||||
require.Len(t, handlers, 1)
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
}
|
||||
|
||||
func TestPrependHandler_DoesNotMutateInputSlice(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
handlers := []KeyHandler{nil, ProgressHandler{}}
|
||||
PrependHandler(handlers...)
|
||||
|
||||
assert.Nil(t, handlers[0])
|
||||
assert.IsType(t, ProgressHandler{}, handlers[1])
|
||||
}
|
||||
|
||||
func TestClearHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
AddHandler(LabelHandler{})
|
||||
require.NotEmpty(t, svc.Handlers())
|
||||
|
||||
ClearHandlers()
|
||||
assert.Empty(t, svc.Handlers())
|
||||
}
|
||||
|
||||
func TestResetHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
ClearHandlers()
|
||||
require.Empty(t, svc.Handlers())
|
||||
|
||||
svc.ResetHandlers()
|
||||
require.Len(t, svc.Handlers(), len(DefaultHandlers()))
|
||||
assert.IsType(t, LabelHandler{}, svc.Handlers()[0])
|
||||
|
||||
ClearHandlers()
|
||||
require.Empty(t, svc.Handlers())
|
||||
|
||||
ResetHandlers()
|
||||
handlers := svc.Handlers()
|
||||
require.Len(t, handlers, len(DefaultHandlers()))
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
assert.Equal(t, "Status:", T("i18n.label.status"))
|
||||
}
|
||||
|
||||
func TestSetHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
SetHandlers(serviceStubHandler{})
|
||||
|
||||
handlers := CurrentHandlers()
|
||||
require.Len(t, handlers, 1)
|
||||
assert.IsType(t, serviceStubHandler{}, handlers[0])
|
||||
assert.Equal(t, "stub", T("custom.stub"))
|
||||
assert.Equal(t, "i18n.label.status", T("i18n.label.status"))
|
||||
}
|
||||
|
||||
func TestHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
handlers := Handlers()
|
||||
require.Len(t, handlers, len(svc.Handlers()))
|
||||
assert.Equal(t, svc.Handlers(), handlers)
|
||||
}
|
||||
|
||||
func TestNewWithHandlers_SkipsNil(t *testing.T) {
|
||||
svc, err := New(WithHandlers(nil, LabelHandler{}))
|
||||
require.NoError(t, err)
|
||||
|
||||
handlers := svc.Handlers()
|
||||
require.Len(t, handlers, 1)
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
}
|
||||
|
||||
// --- executeIntentTemplate ---
|
||||
|
||||
func TestExecuteIntentTemplate_Good(t *testing.T) {
|
||||
|
|
@ -217,6 +689,43 @@ func TestExecuteIntentTemplate_Good_WithFuncs(t *testing.T) {
|
|||
assert.Equal(t, "built!", got)
|
||||
}
|
||||
|
||||
func TestComposeIntent_Good(t *testing.T) {
|
||||
intent := Intent{
|
||||
Meta: IntentMeta{
|
||||
Type: "action",
|
||||
Verb: "delete",
|
||||
Dangerous: true,
|
||||
Default: "no",
|
||||
Supports: []string{"yes", "no"},
|
||||
},
|
||||
Question: "Delete {{.Subject}}?",
|
||||
Confirm: "Really delete {{article .Subject}}?",
|
||||
Success: "{{title .Subject}} deleted",
|
||||
Failure: "Failed to delete {{lower .Subject}}",
|
||||
}
|
||||
|
||||
got := ComposeIntent(intent, S("file", "config.yaml"))
|
||||
|
||||
assert.Equal(t, "Delete config.yaml?", got.Question)
|
||||
assert.Equal(t, "Really delete a config.yaml?", got.Confirm)
|
||||
assert.Equal(t, "Config.yaml deleted", got.Success)
|
||||
assert.Equal(t, "Failed to delete config.yaml", got.Failure)
|
||||
assert.Equal(t, intent.Meta, got.Meta)
|
||||
}
|
||||
|
||||
func TestIntentCompose_Good_NilSubject(t *testing.T) {
|
||||
intent := Intent{
|
||||
Question: "Proceed?",
|
||||
}
|
||||
|
||||
got := intent.Compose(nil)
|
||||
|
||||
assert.Equal(t, "Proceed?", got.Question)
|
||||
assert.Empty(t, got.Confirm)
|
||||
assert.Empty(t, got.Success)
|
||||
assert.Empty(t, got.Failure)
|
||||
}
|
||||
|
||||
// --- applyTemplate ---
|
||||
|
||||
func TestApplyTemplate_Good(t *testing.T) {
|
||||
|
|
|
|||
21
language.go
21
language.go
|
|
@ -2,11 +2,11 @@ package i18n
|
|||
|
||||
// GetPluralRule returns the plural rule for a language code.
|
||||
func GetPluralRule(lang string) PluralRule {
|
||||
lang = normalizeLanguageTag(lang)
|
||||
if rule, ok := pluralRules[lang]; ok {
|
||||
return rule
|
||||
}
|
||||
if len(lang) > 2 {
|
||||
base := lang[:2]
|
||||
if base := baseLanguageTag(lang); base != "" {
|
||||
if rule, ok := pluralRules[base]; ok {
|
||||
return rule
|
||||
}
|
||||
|
|
@ -83,3 +83,20 @@ func pluralRuleArabic(n int) PluralCategory {
|
|||
func pluralRuleChinese(n int) PluralCategory { return PluralOther }
|
||||
func pluralRuleJapanese(n int) PluralCategory { return PluralOther }
|
||||
func pluralRuleKorean(n int) PluralCategory { return PluralOther }
|
||||
|
||||
func pluralRuleWelsh(n int) PluralCategory {
|
||||
switch n {
|
||||
case 0:
|
||||
return PluralZero
|
||||
case 1:
|
||||
return PluralOne
|
||||
case 2:
|
||||
return PluralTwo
|
||||
case 3:
|
||||
return PluralFew
|
||||
case 6:
|
||||
return PluralMany
|
||||
default:
|
||||
return PluralOther
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ func TestGetPluralCategory(t *testing.T) {
|
|||
{"en", 0, PluralOther},
|
||||
{"en", 1, PluralOne},
|
||||
{"en", 2, PluralOther},
|
||||
{"en_US", 1, PluralOne},
|
||||
|
||||
// French (0 and 1 are singular)
|
||||
{"fr", 0, PluralOne},
|
||||
{"fr", 1, PluralOne},
|
||||
{"fr", 2, PluralOther},
|
||||
{"fr_CA", 2, PluralOther},
|
||||
|
||||
// Russian
|
||||
{"ru", 1, PluralOne},
|
||||
|
|
@ -39,6 +41,14 @@ func TestGetPluralCategory(t *testing.T) {
|
|||
{"ar", 11, PluralMany},
|
||||
{"ar", 100, PluralOther},
|
||||
|
||||
// Welsh
|
||||
{"cy", 0, PluralZero},
|
||||
{"cy", 1, PluralOne},
|
||||
{"cy", 2, PluralTwo},
|
||||
{"cy", 3, PluralFew},
|
||||
{"cy", 6, PluralMany},
|
||||
{"cy", 7, PluralOther},
|
||||
|
||||
// Chinese (always other)
|
||||
{"zh", 0, PluralOther},
|
||||
{"zh", 1, PluralOther},
|
||||
|
|
@ -75,6 +85,21 @@ func TestGetPluralRule(t *testing.T) {
|
|||
t.Error("English-US rule(1) should be PluralOne")
|
||||
}
|
||||
|
||||
rule = GetPluralRule("fr-Latn-CA")
|
||||
if rule(0) != PluralOne {
|
||||
t.Error("French multi-part tag rule(0) should be PluralOne")
|
||||
}
|
||||
|
||||
rule = GetPluralRule("cy-GB")
|
||||
if rule(2) != PluralTwo {
|
||||
t.Error("Welsh-GB rule(2) should be PluralTwo")
|
||||
}
|
||||
|
||||
rule = GetPluralRule("en_US")
|
||||
if rule(1) != PluralOne {
|
||||
t.Error("English_US rule(1) should be PluralOne")
|
||||
}
|
||||
|
||||
// Unknown falls back to English
|
||||
rule = GetPluralRule("xx-YY")
|
||||
if rule(1) != PluralOne {
|
||||
|
|
|
|||
411
loader.go
411
loader.go
|
|
@ -1,9 +1,11 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"math"
|
||||
"path"
|
||||
"strings"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"dappco.re/go/core"
|
||||
|
|
@ -27,22 +29,24 @@ func NewFSLoader(fsys fs.FS, dir string) *FSLoader {
|
|||
|
||||
// Load implements Loader.Load.
|
||||
func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
|
||||
variants := []string{
|
||||
lang + ".json",
|
||||
core.Replace(lang, "-", "_") + ".json",
|
||||
core.Replace(lang, "_", "-") + ".json",
|
||||
}
|
||||
|
||||
variants := localeFilenameCandidates(lang)
|
||||
var data []byte
|
||||
var err error
|
||||
var firstNonMissingErr error
|
||||
for _, filename := range variants {
|
||||
filePath := path.Join(l.dir, filename)
|
||||
data, err = fs.ReadFile(l.fsys, filePath)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if firstNonMissingErr == nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
firstNonMissingErr = err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if firstNonMissingErr != nil {
|
||||
err = firstNonMissingErr
|
||||
}
|
||||
return nil, nil, log.E("FSLoader.Load", "locale not found: "+lang, err)
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +67,37 @@ func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
|
|||
return messages, grammar, nil
|
||||
}
|
||||
|
||||
func localeFilenameCandidates(lang string) []string {
|
||||
// Preserve the documented lookup order: exact tag first, then underscore /
|
||||
// hyphen variants, then the base language tag.
|
||||
variants := make([]string, 0, 4)
|
||||
addVariant := func(candidate string) {
|
||||
for _, existing := range variants {
|
||||
if existing == candidate {
|
||||
return
|
||||
}
|
||||
}
|
||||
variants = append(variants, candidate)
|
||||
}
|
||||
canonical := normalizeLanguageTag(lang)
|
||||
addTag := func(tag string) {
|
||||
if tag == "" {
|
||||
return
|
||||
}
|
||||
addVariant(tag + ".json")
|
||||
addVariant(core.Replace(tag, "-", "_") + ".json")
|
||||
addVariant(core.Replace(tag, "_", "-") + ".json")
|
||||
}
|
||||
addTag(lang)
|
||||
if canonical != "" && canonical != lang {
|
||||
addTag(canonical)
|
||||
}
|
||||
if base := baseLanguageTag(canonical); base != "" && base != canonical {
|
||||
addTag(base)
|
||||
}
|
||||
return variants
|
||||
}
|
||||
|
||||
// Languages implements Loader.Languages.
|
||||
func (l *FSLoader) Languages() []string {
|
||||
l.langOnce.Do(func() {
|
||||
|
|
@ -71,16 +106,25 @@ func (l *FSLoader) Languages() []string {
|
|||
l.langErr = log.E("FSLoader.Languages", "read locale directory: "+l.dir, err)
|
||||
return
|
||||
}
|
||||
seen := make(map[string]struct{}, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
lang := core.TrimSuffix(entry.Name(), ".json")
|
||||
lang = core.Replace(lang, "_", "-")
|
||||
lang = normalizeLanguageTag(core.Replace(lang, "_", "-"))
|
||||
if lang == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[lang]; ok {
|
||||
continue
|
||||
}
|
||||
seen[lang] = struct{}{}
|
||||
l.languages = append(l.languages, lang)
|
||||
}
|
||||
slices.Sort(l.languages)
|
||||
})
|
||||
return l.languages
|
||||
return append([]string(nil), l.languages...)
|
||||
}
|
||||
|
||||
// LanguagesErr returns any error from the directory scan.
|
||||
|
|
@ -106,119 +150,33 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa
|
|||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if grammar != nil && core.HasPrefix(fullKey, "gram.word.") {
|
||||
wordKey := core.TrimPrefix(fullKey, "gram.word.")
|
||||
grammar.Words[core.Lower(wordKey)] = v
|
||||
if grammar != nil && loadGrammarWord(fullKey, v, grammar) {
|
||||
continue
|
||||
}
|
||||
out[fullKey] = Message{Text: v}
|
||||
|
||||
case map[string]any:
|
||||
// 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[core.Lower(verbName)] = forms
|
||||
if grammar != nil && loadGrammarVerb(fullKey, key, v, grammar) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Noun form object (under gram.noun.* or has gender field)
|
||||
if grammar != nil && (core.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[core.Lower(nounName)] = forms
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Signal data for disambiguation
|
||||
if grammar != nil && fullKey == "gram.signal" {
|
||||
if nd, ok := v["noun_determiner"]; ok {
|
||||
if arr, ok := nd.([]any); ok {
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
grammar.Signals.NounDeterminers = append(grammar.Signals.NounDeterminers, core.Lower(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if va, ok := v["verb_auxiliary"]; ok {
|
||||
if arr, ok := va.([]any); ok {
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
grammar.Signals.VerbAuxiliaries = append(grammar.Signals.VerbAuxiliaries, core.Lower(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if vi, ok := v["verb_infinitive"]; ok {
|
||||
if arr, ok := vi.([]any); ok {
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
grammar.Signals.VerbInfinitive = append(grammar.Signals.VerbInfinitive, core.Lower(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if grammar != nil && loadGrammarNoun(fullKey, key, v, grammar) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
if grammar != nil && loadGrammarSignals(fullKey, v, grammar) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Punctuation rules
|
||||
if grammar != nil && fullKey == "gram.punct" {
|
||||
if label, ok := v["label"].(string); ok {
|
||||
grammar.Punct.LabelSuffix = label
|
||||
}
|
||||
if progress, ok := v["progress"].(string); ok {
|
||||
grammar.Punct.ProgressSuffix = progress
|
||||
}
|
||||
if grammar != nil && loadGrammarArticle(fullKey, v, grammar) {
|
||||
continue
|
||||
}
|
||||
|
||||
if grammar != nil && loadGrammarPunctuation(fullKey, v, grammar) {
|
||||
continue
|
||||
}
|
||||
|
||||
if grammar != nil && loadGrammarNumber(fullKey, v, grammar) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -251,11 +209,170 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa
|
|||
}
|
||||
}
|
||||
|
||||
func loadGrammarWord(fullKey, value string, grammar *GrammarData) bool {
|
||||
if grammar == nil || !core.HasPrefix(fullKey, "gram.word.") {
|
||||
return false
|
||||
}
|
||||
wordKey := core.TrimPrefix(fullKey, "gram.word.")
|
||||
if shouldSkipDeprecatedEnglishGrammarEntry(fullKey) {
|
||||
return true
|
||||
}
|
||||
grammar.Words[core.Lower(wordKey)] = value
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarVerb(fullKey, key string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || !isVerbFormObject(v) {
|
||||
return false
|
||||
}
|
||||
verbName := key
|
||||
if base, ok := v["base"].(string); ok && base != "" {
|
||||
verbName = base
|
||||
}
|
||||
if core.HasPrefix(fullKey, "gram.verb.") {
|
||||
after := core.TrimPrefix(fullKey, "gram.verb.")
|
||||
if base, ok := v["base"].(string); !ok || base == "" {
|
||||
verbName = after
|
||||
}
|
||||
}
|
||||
forms := VerbForms{}
|
||||
if past, ok := v["past"].(string); ok {
|
||||
forms.Past = past
|
||||
}
|
||||
if gerund, ok := v["gerund"].(string); ok {
|
||||
forms.Gerund = gerund
|
||||
}
|
||||
grammar.Verbs[core.Lower(verbName)] = forms
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarNoun(fullKey, key string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || !(core.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) {
|
||||
return false
|
||||
}
|
||||
nounName := key
|
||||
if core.HasPrefix(fullKey, "gram.noun.") {
|
||||
nounName = core.TrimPrefix(fullKey, "gram.noun.")
|
||||
}
|
||||
if shouldSkipDeprecatedEnglishGrammarEntry(fullKey) {
|
||||
return true
|
||||
}
|
||||
_, hasOne := v["one"]
|
||||
_, hasOther := v["other"]
|
||||
if !hasOne || !hasOther {
|
||||
return false
|
||||
}
|
||||
forms := NounForms{}
|
||||
if one, ok := v["one"].(string); ok {
|
||||
forms.One = one
|
||||
}
|
||||
if other, ok := v["other"].(string); ok {
|
||||
forms.Other = other
|
||||
}
|
||||
if gender, ok := v["gender"].(string); ok {
|
||||
forms.Gender = gender
|
||||
}
|
||||
grammar.Nouns[core.Lower(nounName)] = forms
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarSignals(fullKey string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || (fullKey != "gram.signal" && fullKey != "gram.signals") {
|
||||
return false
|
||||
}
|
||||
loadSignalStringList := func(dst *[]string, raw any) {
|
||||
arr, ok := raw.([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
*dst = append(*dst, core.Lower(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
loadSignalStringList(&grammar.Signals.NounDeterminers, v["noun_determiner"])
|
||||
loadSignalStringList(&grammar.Signals.VerbAuxiliaries, v["verb_auxiliary"])
|
||||
loadSignalStringList(&grammar.Signals.VerbInfinitive, v["verb_infinitive"])
|
||||
loadSignalStringList(&grammar.Signals.VerbNegation, v["verb_negation"])
|
||||
if priors, ok := v["prior"].(map[string]any); ok {
|
||||
loadSignalPriors(grammar, priors)
|
||||
}
|
||||
if priors, ok := v["priors"].(map[string]any); ok {
|
||||
loadSignalPriors(grammar, priors)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarArticle(fullKey string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || fullKey != "gram.article" {
|
||||
return false
|
||||
}
|
||||
if indef, ok := v["indefinite"].(map[string]any); ok {
|
||||
if def, ok := indef["default"].(string); ok {
|
||||
grammar.Articles.IndefiniteDefault = def
|
||||
}
|
||||
if vowel, ok := indef["vowel"].(string); ok {
|
||||
grammar.Articles.IndefiniteVowel = vowel
|
||||
}
|
||||
}
|
||||
if def, ok := v["definite"].(string); ok {
|
||||
grammar.Articles.Definite = def
|
||||
}
|
||||
if bg, ok := v["by_gender"].(map[string]any); ok {
|
||||
grammar.Articles.ByGender = make(map[string]string, len(bg))
|
||||
for g, art := range bg {
|
||||
if s, ok := art.(string); ok {
|
||||
grammar.Articles.ByGender[g] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if bg, ok := v["byGender"].(map[string]any); ok {
|
||||
grammar.Articles.ByGender = make(map[string]string, len(bg))
|
||||
for g, art := range bg {
|
||||
if s, ok := art.(string); ok {
|
||||
grammar.Articles.ByGender[g] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarPunctuation(fullKey string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || fullKey != "gram.punct" {
|
||||
return false
|
||||
}
|
||||
if label, ok := v["label"].(string); ok {
|
||||
grammar.Punct.LabelSuffix = label
|
||||
}
|
||||
if progress, ok := v["progress"].(string); ok {
|
||||
grammar.Punct.ProgressSuffix = progress
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarNumber(fullKey string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || fullKey != "gram.number" {
|
||||
return false
|
||||
}
|
||||
if thousands, ok := v["thousands"].(string); ok {
|
||||
grammar.Number.ThousandsSep = thousands
|
||||
}
|
||||
if decimal, ok := v["decimal"].(string); ok {
|
||||
grammar.Number.DecimalSep = decimal
|
||||
}
|
||||
if percent, ok := v["percent"].(string); ok {
|
||||
grammar.Number.PercentFmt = percent
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isVerbFormObject(m map[string]any) bool {
|
||||
_, hasBase := m["base"]
|
||||
_, hasPast := m["past"]
|
||||
_, hasGerund := m["gerund"]
|
||||
return (hasBase || hasPast || hasGerund) && !isPluralObject(m)
|
||||
// Verb objects are identified by their inflected forms. A bare "base"
|
||||
// field is metadata, not enough to claim the object is a verb table.
|
||||
return (hasPast || hasGerund) && !isPluralObject(m)
|
||||
}
|
||||
|
||||
func isNounFormObject(m map[string]any) bool {
|
||||
|
|
@ -280,3 +397,77 @@ func isPluralObject(m map[string]any) bool {
|
|||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func loadSignalPriors(grammar *GrammarData, priors map[string]any) {
|
||||
if grammar == nil || len(priors) == 0 {
|
||||
return
|
||||
}
|
||||
if grammar.Signals.Priors == nil {
|
||||
grammar.Signals.Priors = make(map[string]map[string]float64, len(priors))
|
||||
}
|
||||
for word, raw := range priors {
|
||||
bucket, ok := raw.(map[string]any)
|
||||
if !ok || len(bucket) == 0 {
|
||||
continue
|
||||
}
|
||||
key := core.Lower(word)
|
||||
if grammar.Signals.Priors[key] == nil {
|
||||
grammar.Signals.Priors[key] = make(map[string]float64, len(bucket))
|
||||
}
|
||||
for role, value := range bucket {
|
||||
score, ok := float64Value(value)
|
||||
if !ok || !validSignalPriorScore(score) {
|
||||
continue
|
||||
}
|
||||
grammar.Signals.Priors[key][core.Lower(role)] = score
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validSignalPriorScore(score float64) bool {
|
||||
return !math.IsNaN(score) && !math.IsInf(score, 0) && score >= 0
|
||||
}
|
||||
|
||||
func float64Value(v any) (float64, bool) {
|
||||
if v == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case float32:
|
||||
return float64(n), true
|
||||
case int:
|
||||
return float64(n), true
|
||||
case int64:
|
||||
return float64(n), true
|
||||
case int32:
|
||||
return float64(n), true
|
||||
case int16:
|
||||
return float64(n), true
|
||||
case int8:
|
||||
return float64(n), true
|
||||
case uint:
|
||||
return float64(n), true
|
||||
case uint64:
|
||||
return float64(n), true
|
||||
case uint32:
|
||||
return float64(n), true
|
||||
case uint16:
|
||||
return float64(n), true
|
||||
case uint8:
|
||||
return float64(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipDeprecatedEnglishGrammarEntry(fullKey string) bool {
|
||||
switch fullKey {
|
||||
case "gram.noun.passed", "gram.noun.failed", "gram.noun.skipped",
|
||||
"gram.word.passed", "gram.word.failed", "gram.word.skipped":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
598
loader_test.go
598
loader_test.go
|
|
@ -19,6 +19,38 @@ func TestFSLoaderLanguages(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLanguagesCanonicalAndUnique(t *testing.T) {
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{Data: []byte(`{}`)},
|
||||
"locales/en_US.json": &fstest.MapFile{Data: []byte(`{}`)},
|
||||
"locales/es-MX.json": &fstest.MapFile{Data: []byte(`{}`)},
|
||||
"locales/fr.json": &fstest.MapFile{Data: []byte(`{}`)},
|
||||
}
|
||||
|
||||
loader := NewFSLoader(fs, "locales")
|
||||
langs := loader.Languages()
|
||||
want := []string{"en", "en-US", "es-MX", "fr"}
|
||||
if !slices.Equal(langs, want) {
|
||||
t.Fatalf("Languages() = %v, want %v", langs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLanguagesReturnsCopy(t *testing.T) {
|
||||
loader := NewFSLoader(localeFS, "locales")
|
||||
|
||||
langs := loader.Languages()
|
||||
if len(langs) == 0 {
|
||||
t.Fatal("Languages() returned empty")
|
||||
}
|
||||
|
||||
langs[0] = "zz"
|
||||
|
||||
got := loader.Languages()
|
||||
if got[0] == "zz" {
|
||||
t.Fatalf("Languages() returned shared slice: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLoad(t *testing.T) {
|
||||
loader := NewFSLoader(localeFS, "locales")
|
||||
messages, grammar, err := loader.Load("en")
|
||||
|
|
@ -85,6 +117,17 @@ func TestFSLoaderLoad(t *testing.T) {
|
|||
t.Errorf("punct.progress = %q, want '...'", grammar.Punct.ProgressSuffix)
|
||||
}
|
||||
|
||||
// Number formatting from gram.number
|
||||
if grammar.Number.ThousandsSep != "," {
|
||||
t.Errorf("number.thousands = %q, want ','", grammar.Number.ThousandsSep)
|
||||
}
|
||||
if grammar.Number.DecimalSep != "." {
|
||||
t.Errorf("number.decimal = %q, want '.'", grammar.Number.DecimalSep)
|
||||
}
|
||||
if grammar.Number.PercentFmt != "%s%%" {
|
||||
t.Errorf("number.percent = %q, want '%%s%%%%'", grammar.Number.PercentFmt)
|
||||
}
|
||||
|
||||
// Words from gram.word.*
|
||||
if len(grammar.Words) == 0 {
|
||||
t.Error("grammar has 0 words")
|
||||
|
|
@ -105,6 +148,72 @@ func TestFSLoaderLoadMissing(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLoadFallsBackToBaseLanguage(t *testing.T) {
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"greeting": "hello",
|
||||
"gram": {
|
||||
"article": {
|
||||
"indefinite": { "default": "a", "vowel": "an" },
|
||||
"definite": "the"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
loader := NewFSLoader(fs, "locales")
|
||||
messages, grammar, err := loader.Load("en-GB")
|
||||
if err != nil {
|
||||
t.Fatalf("Load(en-GB) error: %v", err)
|
||||
}
|
||||
if got := messages["greeting"].Text; got != "hello" {
|
||||
t.Fatalf("Load(en-GB) greeting = %q, want %q", got, "hello")
|
||||
}
|
||||
if grammar == nil {
|
||||
t.Fatal("Load(en-GB) returned nil grammar")
|
||||
}
|
||||
if grammar.Articles.Definite != "the" {
|
||||
t.Fatalf("Load(en-GB) grammar article = %q, want %q", grammar.Articles.Definite, "the")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocaleFilenameCandidates(t *testing.T) {
|
||||
got := localeFilenameCandidates("en-GB")
|
||||
want := []string{"en-GB.json", "en_GB.json", "en.json"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("localeFilenameCandidates(en-GB) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocaleFilenameCandidatesNormalisesCase(t *testing.T) {
|
||||
got := localeFilenameCandidates("en-us")
|
||||
want := []string{"en-us.json", "en_us.json", "en-US.json", "en_US.json", "en.json"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("localeFilenameCandidates(en-us) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLoadUsesCanonicalVariant(t *testing.T) {
|
||||
fs := fstest.MapFS{
|
||||
"locales/en-US.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"greeting": "hello"
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
loader := NewFSLoader(fs, "locales")
|
||||
messages, _, err := loader.Load("en-us")
|
||||
if err != nil {
|
||||
t.Fatalf("Load(en-us) error: %v", err)
|
||||
}
|
||||
if got := messages["greeting"].Text; got != "hello" {
|
||||
t.Fatalf("Load(en-us) greeting = %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenWithGrammar(t *testing.T) {
|
||||
messages := make(map[string]Message)
|
||||
grammar := &GrammarData{
|
||||
|
|
@ -121,20 +230,51 @@ func TestFlattenWithGrammar(t *testing.T) {
|
|||
"past": "tested",
|
||||
"gerund": "testing",
|
||||
},
|
||||
"partial_past": map[string]any{
|
||||
"past": "partialed",
|
||||
},
|
||||
"partial_gerund": map[string]any{
|
||||
"gerund": "partialing",
|
||||
},
|
||||
"publish_draft": map[string]any{
|
||||
"base": "publish",
|
||||
"past": "published",
|
||||
"gerund": "publishing",
|
||||
},
|
||||
},
|
||||
"noun": map[string]any{
|
||||
"widget": map[string]any{
|
||||
"one": "widget",
|
||||
"other": "widgets",
|
||||
},
|
||||
"passed": map[string]any{
|
||||
"one": "passed",
|
||||
"other": "passed",
|
||||
},
|
||||
},
|
||||
"word": map[string]any{
|
||||
"api": "API",
|
||||
"api": "API",
|
||||
"failed": "failed",
|
||||
"skipped": "skipped",
|
||||
},
|
||||
"punct": map[string]any{
|
||||
"label": ":",
|
||||
"progress": "...",
|
||||
},
|
||||
"number": map[string]any{
|
||||
"thousands": ",",
|
||||
"decimal": ".",
|
||||
"percent": "%s%%",
|
||||
},
|
||||
"signal": map[string]any{
|
||||
"prior": map[string]any{
|
||||
"commit": map[string]any{
|
||||
"verb": 0.25,
|
||||
"noun": 0.75,
|
||||
},
|
||||
},
|
||||
"verb_negation": []any{"not", "never"},
|
||||
},
|
||||
"article": map[string]any{
|
||||
"indefinite": map[string]any{
|
||||
"default": "a",
|
||||
|
|
@ -159,6 +299,35 @@ func TestFlattenWithGrammar(t *testing.T) {
|
|||
t.Errorf("test.past = %q, want 'tested'", v.Past)
|
||||
}
|
||||
}
|
||||
if v, ok := grammar.Verbs["publish"]; !ok {
|
||||
t.Error("verb base override 'publish' not extracted")
|
||||
} else {
|
||||
if v.Past != "published" {
|
||||
t.Errorf("publish.past = %q, want 'published'", v.Past)
|
||||
}
|
||||
if v.Gerund != "publishing" {
|
||||
t.Errorf("publish.gerund = %q, want 'publishing'", v.Gerund)
|
||||
}
|
||||
}
|
||||
if _, ok := grammar.Verbs["publish_draft"]; ok {
|
||||
t.Error("verb should be stored under explicit base, not JSON key")
|
||||
}
|
||||
if v, ok := grammar.Verbs["partial_past"]; !ok {
|
||||
t.Error("incomplete verb entry with only past should be extracted")
|
||||
} else if v.Past != "partialed" || v.Gerund != "" {
|
||||
t.Errorf("partial_past forms = %+v, want Past only", v)
|
||||
}
|
||||
if v, ok := grammar.Verbs["partial_gerund"]; !ok {
|
||||
t.Error("incomplete verb entry with only gerund should be extracted")
|
||||
} else if v.Past != "" || v.Gerund != "partialing" {
|
||||
t.Errorf("partial_gerund forms = %+v, want Gerund only", v)
|
||||
}
|
||||
if _, ok := messages["gram.verb.partial_past"]; ok {
|
||||
t.Error("gram.verb.partial_past should not be flattened into messages")
|
||||
}
|
||||
if _, ok := messages["gram.verb.partial_gerund"]; ok {
|
||||
t.Error("gram.verb.partial_gerund should not be flattened into messages")
|
||||
}
|
||||
|
||||
// Noun extracted
|
||||
if n, ok := grammar.Nouns["widget"]; !ok {
|
||||
|
|
@ -168,17 +337,34 @@ func TestFlattenWithGrammar(t *testing.T) {
|
|||
t.Errorf("widget.other = %q, want 'widgets'", n.Other)
|
||||
}
|
||||
}
|
||||
if _, ok := grammar.Nouns["passed"]; ok {
|
||||
t.Error("deprecated noun 'passed' should be ignored")
|
||||
}
|
||||
|
||||
// Word extracted
|
||||
if grammar.Words["api"] != "API" {
|
||||
t.Errorf("word 'api' = %q, want 'API'", grammar.Words["api"])
|
||||
}
|
||||
if _, ok := grammar.Words["failed"]; ok {
|
||||
t.Error("deprecated word 'failed' should be ignored")
|
||||
}
|
||||
if _, ok := grammar.Words["skipped"]; ok {
|
||||
t.Error("deprecated word 'skipped' should be ignored")
|
||||
}
|
||||
|
||||
// Punct extracted
|
||||
if grammar.Punct.LabelSuffix != ":" {
|
||||
t.Errorf("punct.label = %q, want ':'", grammar.Punct.LabelSuffix)
|
||||
}
|
||||
|
||||
// Number formatting extracted
|
||||
if grammar.Number.ThousandsSep != "," {
|
||||
t.Errorf("number.thousands = %q, want ','", grammar.Number.ThousandsSep)
|
||||
}
|
||||
if len(grammar.Signals.VerbNegation) != 2 || grammar.Signals.VerbNegation[0] != "not" || grammar.Signals.VerbNegation[1] != "never" {
|
||||
t.Errorf("verb negation not extracted: %+v", grammar.Signals.VerbNegation)
|
||||
}
|
||||
|
||||
// Articles extracted
|
||||
if grammar.Articles.IndefiniteDefault != "a" {
|
||||
t.Errorf("article.indefinite.default = %q, want 'a'", grammar.Articles.IndefiniteDefault)
|
||||
|
|
@ -188,6 +374,369 @@ func TestFlattenWithGrammar(t *testing.T) {
|
|||
if msg, ok := messages["prompt.yes"]; !ok || msg.Text != "y" {
|
||||
t.Errorf("prompt.yes not flattened correctly, got %+v", messages["prompt.yes"])
|
||||
}
|
||||
if _, ok := messages["gram.number.thousands"]; ok {
|
||||
t.Error("gram.number.thousands should not be flattened into messages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenWithGrammar_DetectsSchemaObjectsOutsideGrammarPaths(t *testing.T) {
|
||||
messages := make(map[string]Message)
|
||||
grammar := &GrammarData{
|
||||
Verbs: make(map[string]VerbForms),
|
||||
Words: make(map[string]string),
|
||||
}
|
||||
|
||||
raw := map[string]any{
|
||||
"lexicon": map[string]any{
|
||||
"base_only": map[string]any{
|
||||
"base": "base",
|
||||
},
|
||||
},
|
||||
"phrases": map[string]any{
|
||||
"draft": map[string]any{
|
||||
"past": "drafted",
|
||||
"gerund": "drafting",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flattenWithGrammar("", raw, messages, grammar)
|
||||
|
||||
if _, ok := grammar.Verbs["draft"]; !ok {
|
||||
t.Fatal("verb schema object outside gram.verb.* was not extracted")
|
||||
}
|
||||
if _, ok := messages["phrases.draft"]; ok {
|
||||
t.Fatal("verb schema object should not be flattened into messages")
|
||||
}
|
||||
if _, ok := grammar.Verbs["base_only"]; ok {
|
||||
t.Fatal("base-only object should not be detected as a verb table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGrammarData(t *testing.T) {
|
||||
const lang = "zz"
|
||||
original := GetGrammarData(lang)
|
||||
t.Cleanup(func() {
|
||||
SetGrammarData(lang, original)
|
||||
})
|
||||
|
||||
SetGrammarData(lang, &GrammarData{
|
||||
Verbs: map[string]VerbForms{
|
||||
"keep": {Past: "kept", Gerund: "keeping"},
|
||||
},
|
||||
Nouns: map[string]NounForms{
|
||||
"file": {One: "file", Other: "files"},
|
||||
},
|
||||
Words: map[string]string{
|
||||
"url": "URL",
|
||||
},
|
||||
Articles: ArticleForms{
|
||||
IndefiniteDefault: "a",
|
||||
IndefiniteVowel: "an",
|
||||
Definite: "the",
|
||||
ByGender: map[string]string{
|
||||
"m": "le",
|
||||
},
|
||||
},
|
||||
Punct: PunctuationRules{
|
||||
LabelSuffix: ":",
|
||||
ProgressSuffix: "...",
|
||||
},
|
||||
Signals: SignalData{
|
||||
NounDeterminers: []string{"the"},
|
||||
VerbAuxiliaries: []string{"will"},
|
||||
VerbInfinitive: []string{"to"},
|
||||
VerbNegation: []string{"not"},
|
||||
Priors: map[string]map[string]float64{
|
||||
"run": {
|
||||
"verb": 0.7,
|
||||
},
|
||||
},
|
||||
},
|
||||
Number: NumberFormat{
|
||||
ThousandsSep: ",",
|
||||
DecimalSep: ".",
|
||||
PercentFmt: "%s%%",
|
||||
},
|
||||
})
|
||||
|
||||
MergeGrammarData(lang, &GrammarData{
|
||||
Verbs: map[string]VerbForms{
|
||||
"add": {Past: "added", Gerund: "adding"},
|
||||
},
|
||||
Nouns: map[string]NounForms{
|
||||
"repo": {One: "repo", Other: "repos"},
|
||||
},
|
||||
Words: map[string]string{
|
||||
"api": "API",
|
||||
},
|
||||
Articles: ArticleForms{
|
||||
ByGender: map[string]string{
|
||||
"f": "la",
|
||||
},
|
||||
},
|
||||
Punct: PunctuationRules{
|
||||
LabelSuffix: " !",
|
||||
},
|
||||
Signals: SignalData{
|
||||
NounDeterminers: []string{"a"},
|
||||
VerbAuxiliaries: []string{"can"},
|
||||
VerbInfinitive: []string{"go"},
|
||||
VerbNegation: []string{"never"},
|
||||
Priors: map[string]map[string]float64{
|
||||
"run": {
|
||||
"noun": 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
Number: NumberFormat{
|
||||
ThousandsSep: ".",
|
||||
},
|
||||
})
|
||||
|
||||
data := GetGrammarData(lang)
|
||||
if data == nil {
|
||||
t.Fatal("MergeGrammarData() cleared existing grammar data")
|
||||
}
|
||||
if _, ok := data.Verbs["keep"]; !ok {
|
||||
t.Error("existing verb entry was lost")
|
||||
}
|
||||
if _, ok := data.Verbs["add"]; !ok {
|
||||
t.Error("merged verb entry missing")
|
||||
}
|
||||
if _, ok := data.Nouns["file"]; !ok {
|
||||
t.Error("existing noun entry was lost")
|
||||
}
|
||||
if _, ok := data.Nouns["repo"]; !ok {
|
||||
t.Error("merged noun entry missing")
|
||||
}
|
||||
if data.Words["url"] != "URL" || data.Words["api"] != "API" {
|
||||
t.Errorf("words not merged correctly: %+v", data.Words)
|
||||
}
|
||||
if data.Articles.IndefiniteDefault != "a" || data.Articles.IndefiniteVowel != "an" || data.Articles.Definite != "the" {
|
||||
t.Errorf("article defaults changed unexpectedly: %+v", data.Articles)
|
||||
}
|
||||
if data.Articles.ByGender["m"] != "le" || data.Articles.ByGender["f"] != "la" {
|
||||
t.Errorf("article by_gender not merged correctly: %+v", data.Articles.ByGender)
|
||||
}
|
||||
if data.Punct.LabelSuffix != " !" || data.Punct.ProgressSuffix != "..." {
|
||||
t.Errorf("punctuation not merged correctly: %+v", data.Punct)
|
||||
}
|
||||
if len(data.Signals.NounDeterminers) != 2 || len(data.Signals.VerbAuxiliaries) != 2 || len(data.Signals.VerbInfinitive) != 2 || len(data.Signals.VerbNegation) != 2 {
|
||||
t.Errorf("signal slices not merged correctly: %+v", data.Signals)
|
||||
}
|
||||
if got := data.Signals.Priors["run"]["verb"]; got != 0.7 {
|
||||
t.Errorf("signal priors lost existing value: got %v", got)
|
||||
}
|
||||
if got := data.Signals.Priors["run"]["noun"]; got != 0.3 {
|
||||
t.Errorf("signal priors missing merged value: got %v", got)
|
||||
}
|
||||
if data.Signals.VerbNegation[0] != "not" || data.Signals.VerbNegation[1] != "never" {
|
||||
t.Errorf("signal negation not merged correctly: %+v", data.Signals.VerbNegation)
|
||||
}
|
||||
if data.Number.ThousandsSep != "." || data.Number.DecimalSep != "." || data.Number.PercentFmt != "%s%%" {
|
||||
t.Errorf("number format not merged correctly: %+v", data.Number)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGrammarData_DeduplicatesSignals(t *testing.T) {
|
||||
const lang = "zy"
|
||||
original := GetGrammarData(lang)
|
||||
t.Cleanup(func() {
|
||||
SetGrammarData(lang, original)
|
||||
})
|
||||
|
||||
SetGrammarData(lang, &GrammarData{
|
||||
Signals: SignalData{
|
||||
NounDeterminers: []string{"the", "a"},
|
||||
VerbAuxiliaries: []string{"will"},
|
||||
VerbInfinitive: []string{"to"},
|
||||
VerbNegation: []string{"not"},
|
||||
},
|
||||
})
|
||||
|
||||
MergeGrammarData(lang, &GrammarData{
|
||||
Signals: SignalData{
|
||||
NounDeterminers: []string{"a", "some"},
|
||||
VerbAuxiliaries: []string{"will", "can"},
|
||||
VerbInfinitive: []string{"to", "de"},
|
||||
VerbNegation: []string{"not", "never"},
|
||||
},
|
||||
})
|
||||
|
||||
data := GetGrammarData(lang)
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData returned nil")
|
||||
}
|
||||
if got, want := data.Signals.NounDeterminers, []string{"the", "a", "some"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("NounDeterminers = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbAuxiliaries, []string{"will", "can"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("VerbAuxiliaries = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbInfinitive, []string{"to", "de"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("VerbInfinitive = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbNegation, []string{"not", "never"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("VerbNegation = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrammarDataLanguageTagNormalisation(t *testing.T) {
|
||||
const rawLang = "tl_PH"
|
||||
const canonicalLang = "tl-PH"
|
||||
|
||||
originalRaw := GetGrammarData(rawLang)
|
||||
originalCanonical := GetGrammarData(canonicalLang)
|
||||
t.Cleanup(func() {
|
||||
SetGrammarData(rawLang, originalRaw)
|
||||
SetGrammarData(canonicalLang, originalCanonical)
|
||||
})
|
||||
|
||||
SetGrammarData(rawLang, &GrammarData{
|
||||
Words: map[string]string{
|
||||
"demo": "Demo",
|
||||
},
|
||||
})
|
||||
|
||||
if got := GetGrammarData(rawLang); got == nil || got.Words["demo"] != "Demo" {
|
||||
t.Fatalf("GetGrammarData(%q) = %+v, want demo word", rawLang, got)
|
||||
}
|
||||
if got := GetGrammarData(canonicalLang); got == nil || got.Words["demo"] != "Demo" {
|
||||
t.Fatalf("GetGrammarData(%q) = %+v, want demo word", canonicalLang, got)
|
||||
}
|
||||
|
||||
MergeGrammarData(canonicalLang, &GrammarData{
|
||||
Words: map[string]string{
|
||||
"api": "API",
|
||||
},
|
||||
})
|
||||
|
||||
data := GetGrammarData(rawLang)
|
||||
if data == nil {
|
||||
t.Fatalf("GetGrammarData(%q) returned nil after merge", rawLang)
|
||||
}
|
||||
if data.Words["api"] != "API" {
|
||||
t.Fatalf("merged word normalisation failed: %+v", data.Words)
|
||||
}
|
||||
|
||||
SetGrammarData(rawLang, nil)
|
||||
if got := GetGrammarData(canonicalLang); got != nil {
|
||||
t.Fatalf("SetGrammarData(%q, nil) did not clear entry: %+v", rawLang, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithLoader_LoadsGrammarOnlyLocale(t *testing.T) {
|
||||
loaderFS := fstest.MapFS{
|
||||
"fr.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"gram": {
|
||||
"article": {
|
||||
"indefinite": { "default": "el", "vowel": "l'" },
|
||||
"definite": "el",
|
||||
"by_gender": { "m": "el", "f": "la" }
|
||||
},
|
||||
"punct": { "label": " !", "progress": " ..." },
|
||||
"signal": {
|
||||
"noun_determiner": ["el"],
|
||||
"verb_auxiliary": ["va"],
|
||||
"verb_infinitive": ["a"],
|
||||
"verb_negation": ["no", "nunca"]
|
||||
},
|
||||
"number": { "thousands": ".", "decimal": ",", "percent": "%s %%"}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
svc, err := NewWithLoader(NewFSLoader(loaderFS, "."))
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithLoader() failed: %v", err)
|
||||
}
|
||||
|
||||
data := GetGrammarData("fr")
|
||||
if data == nil {
|
||||
t.Fatal("grammar-only locale was not loaded")
|
||||
}
|
||||
if data.Articles.ByGender["f"] != "la" {
|
||||
t.Errorf("article by_gender[f] = %q, want %q", data.Articles.ByGender["f"], "la")
|
||||
}
|
||||
if data.Punct.LabelSuffix != " !" || data.Punct.ProgressSuffix != " ..." {
|
||||
t.Errorf("punctuation not loaded: %+v", data.Punct)
|
||||
}
|
||||
if len(data.Signals.NounDeterminers) != 1 || data.Signals.NounDeterminers[0] != "el" {
|
||||
t.Errorf("signals not loaded: %+v", data.Signals)
|
||||
}
|
||||
if len(data.Signals.VerbNegation) != 2 || data.Signals.VerbNegation[0] != "no" || data.Signals.VerbNegation[1] != "nunca" {
|
||||
t.Errorf("negation signal not loaded: %+v", data.Signals.VerbNegation)
|
||||
}
|
||||
if data.Number.DecimalSep != "," || data.Number.ThousandsSep != "." {
|
||||
t.Errorf("number format not loaded: %+v", data.Number)
|
||||
}
|
||||
|
||||
if err := svc.SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
if got := Label("status"); got != "Status !" {
|
||||
t.Errorf("Label(status) = %q, want %q", got, "Status !")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithLoader_AcceptsGrammarAliases(t *testing.T) {
|
||||
loaderFS := fstest.MapFS{
|
||||
"en.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"gram": {
|
||||
"article": {
|
||||
"byGender": { "f": "la", "m": "le" }
|
||||
},
|
||||
"noun": {
|
||||
"user": { "one": "user", "other": "users", "gender": "f" }
|
||||
},
|
||||
"signals": {
|
||||
"noun_determiner": ["the"],
|
||||
"verb_auxiliary": ["will"],
|
||||
"verb_infinitive": ["to"],
|
||||
"verb_negation": ["never"]
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
svc, err := NewWithLoader(NewFSLoader(loaderFS, "."))
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithLoader() failed: %v", err)
|
||||
}
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("alias grammar data was not loaded")
|
||||
}
|
||||
if data.Articles.ByGender["f"] != "la" || data.Articles.ByGender["m"] != "le" {
|
||||
t.Fatalf("article byGender alias not loaded: %+v", data.Articles.ByGender)
|
||||
}
|
||||
if got, want := data.Signals.NounDeterminers, []string{"the"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("signal alias noun_determiner = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbAuxiliaries, []string{"will"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("signal alias verb_auxiliary = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbInfinitive, []string{"to"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("signal alias verb_infinitive = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbNegation, []string{"never"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("signal alias verb_negation = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
if err := svc.SetLanguage("en"); err != nil {
|
||||
t.Fatalf("SetLanguage(en) failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
if got := DefinitePhrase("user"); got != "la user" && got != "la User" {
|
||||
t.Fatalf("DefinitePhrase(user) = %q, want article from byGender alias", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenPluralObject(t *testing.T) {
|
||||
|
|
@ -261,8 +810,14 @@ func TestCustomFSLoader(t *testing.T) {
|
|||
Data: []byte(`{
|
||||
"gram": {
|
||||
"verb": {
|
||||
"draft": { "base": "draft", "past": "drafted", "gerund": "drafting" },
|
||||
"zap": { "base": "zap", "past": "zapped", "gerund": "zapping" }
|
||||
},
|
||||
"signal": {
|
||||
"priors": {
|
||||
"draft": { "verb": 0.6, "noun": 0.4 }
|
||||
}
|
||||
},
|
||||
"word": {
|
||||
"hello": "Hello"
|
||||
}
|
||||
|
|
@ -290,4 +845,45 @@ func TestCustomFSLoader(t *testing.T) {
|
|||
if v, ok := gd.Verbs["zap"]; !ok || v.Past != "zapped" {
|
||||
t.Errorf("verb 'zap' not loaded correctly")
|
||||
}
|
||||
if v, ok := gd.Verbs["draft"]; !ok || v.Past != "drafted" {
|
||||
t.Errorf("verb base override 'draft' not loaded correctly")
|
||||
}
|
||||
if gd.Signals.Priors["draft"]["verb"] != 0.6 || gd.Signals.Priors["draft"]["noun"] != 0.4 {
|
||||
t.Errorf("signal priors not loaded correctly: %+v", gd.Signals.Priors["draft"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomFSLoaderPreservesZeroSignalPriors(t *testing.T) {
|
||||
fs := fstest.MapFS{
|
||||
"locales/test.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"gram": {
|
||||
"signal": {
|
||||
"prior": {
|
||||
"commit": { "verb": 0, "noun": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@
|
|||
"ssh": "SSH",
|
||||
"ssl": "SSL",
|
||||
"pr": "PR",
|
||||
"ci": "CI"
|
||||
"ci": "CI",
|
||||
"failed_to": "Impossible de"
|
||||
},
|
||||
"punct": {
|
||||
"label": " :",
|
||||
|
|
@ -144,7 +145,9 @@
|
|||
"minute": { "one": "il y a {{.Count}} minute", "other": "il y a {{.Count}} minutes" },
|
||||
"hour": { "one": "il y a {{.Count}} heure", "other": "il y a {{.Count}} heures" },
|
||||
"day": { "one": "il y a {{.Count}} jour", "other": "il y a {{.Count}} jours" },
|
||||
"week": { "one": "il y a {{.Count}} semaine", "other": "il y a {{.Count}} semaines" }
|
||||
"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": {
|
||||
|
|
|
|||
170
localise.go
170
localise.go
|
|
@ -57,6 +57,7 @@ func (g GrammaticalGender) String() string {
|
|||
|
||||
// IsRTLLanguage returns true if the language code uses right-to-left text.
|
||||
func IsRTLLanguage(lang string) bool {
|
||||
lang = normalizeLanguageTag(lang)
|
||||
if rtlLanguages[lang] {
|
||||
return true
|
||||
}
|
||||
|
|
@ -67,47 +68,180 @@ func IsRTLLanguage(lang string) bool {
|
|||
}
|
||||
|
||||
// SetFormality sets the default formality level on the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.SetFormality(i18n.FormalityFormal)
|
||||
func SetFormality(f Formality) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.SetFormality(f)
|
||||
}
|
||||
withDefaultService(func(svc *Service) { svc.SetFormality(f) })
|
||||
}
|
||||
|
||||
// SetLocation sets the default location context on the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.SetLocation("workspace")
|
||||
func SetLocation(location string) {
|
||||
withDefaultService(func(svc *Service) { svc.SetLocation(location) })
|
||||
}
|
||||
|
||||
// CurrentLocation returns the current default location context.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// location := i18n.CurrentLocation()
|
||||
func CurrentLocation() string {
|
||||
return Location()
|
||||
}
|
||||
|
||||
// Location returns the current default location context.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// location := i18n.Location()
|
||||
func Location() string {
|
||||
return defaultServiceValue("", func(svc *Service) string {
|
||||
return svc.Location()
|
||||
})
|
||||
}
|
||||
|
||||
// Direction returns the text direction for the current language.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dir := i18n.Direction()
|
||||
func Direction() TextDirection {
|
||||
if svc := Default(); svc != nil {
|
||||
return defaultServiceValue(DirLTR, func(svc *Service) TextDirection {
|
||||
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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// rtl := i18n.IsRTL()
|
||||
func IsRTL() bool { return Direction() == DirRTL }
|
||||
|
||||
// RTL is a short alias for IsRTL.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// rtl := i18n.RTL()
|
||||
func RTL() bool { return IsRTL() }
|
||||
|
||||
// CurrentIsRTL returns true if the current default language uses
|
||||
// right-to-left text.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// rtl := i18n.CurrentIsRTL()
|
||||
func CurrentIsRTL() bool {
|
||||
return IsRTL()
|
||||
}
|
||||
|
||||
// CurrentRTL is a short alias for CurrentIsRTL.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// rtl := i18n.CurrentRTL()
|
||||
func CurrentRTL() bool {
|
||||
return CurrentIsRTL()
|
||||
}
|
||||
|
||||
// CurrentPluralCategory returns the plural category for the current default language.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// cat := i18n.CurrentPluralCategory(2)
|
||||
func CurrentPluralCategory(n int) PluralCategory {
|
||||
return defaultServiceValue(PluralOther, func(svc *Service) PluralCategory { return svc.PluralCategory(n) })
|
||||
}
|
||||
|
||||
// PluralCategoryOf returns the plural category for the current default language.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// cat := i18n.PluralCategoryOf(2)
|
||||
func PluralCategoryOf(n int) PluralCategory {
|
||||
return CurrentPluralCategory(n)
|
||||
}
|
||||
|
||||
func detectLanguage(supported []language.Tag) string {
|
||||
langEnv := os.Getenv("LANG")
|
||||
if langEnv == "" {
|
||||
langEnv = os.Getenv("LC_ALL")
|
||||
for _, langEnv := range []string{
|
||||
os.Getenv("LC_ALL"),
|
||||
firstLocaleFromList(os.Getenv("LANGUAGE")),
|
||||
os.Getenv("LC_MESSAGES"),
|
||||
os.Getenv("LANG"),
|
||||
} {
|
||||
if langEnv == "" {
|
||||
langEnv = os.Getenv("LC_MESSAGES")
|
||||
continue
|
||||
}
|
||||
if detected := detectLanguageFromEnv(langEnv, supported); detected != "" {
|
||||
return detected
|
||||
}
|
||||
}
|
||||
if langEnv == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectLanguageFromEnv(langEnv string, supported []language.Tag) string {
|
||||
baseLang := normalizeLanguageTag(core.Split(langEnv, ".")[0])
|
||||
if baseLang == "" || len(supported) == 0 {
|
||||
return ""
|
||||
}
|
||||
baseLang := core.Split(langEnv, ".")[0]
|
||||
baseLang = core.Replace(baseLang, "_", "-")
|
||||
parsedLang, err := language.Parse(baseLang)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(supported) == 0 {
|
||||
matcher := language.NewMatcher(supported)
|
||||
bestMatch, bestIndex, confidence := matcher.Match(parsedLang)
|
||||
if confidence < language.Low {
|
||||
return ""
|
||||
}
|
||||
matcher := language.NewMatcher(supported)
|
||||
bestMatch, _, confidence := matcher.Match(parsedLang)
|
||||
if confidence >= language.Low {
|
||||
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 ""
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
232
localise_test.go
232
localise_test.go
|
|
@ -5,6 +5,7 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// --- Formality.String() ---
|
||||
|
|
@ -88,6 +89,7 @@ func TestIsRTLLanguage_Good(t *testing.T) {
|
|||
}{
|
||||
{"arabic", "ar", true},
|
||||
{"arabic_sa", "ar-SA", true},
|
||||
{"arabic_sa_underscore", "ar_EG", true},
|
||||
{"hebrew", "he", true},
|
||||
{"farsi", "fa", true},
|
||||
{"urdu", "ur", true},
|
||||
|
|
@ -95,7 +97,7 @@ func TestIsRTLLanguage_Good(t *testing.T) {
|
|||
{"german", "de", false},
|
||||
{"french", "fr", false},
|
||||
{"unknown", "xx", false},
|
||||
{"arabic_variant", "ar-EG-extra", true}, // len > 2 prefix check
|
||||
{"arabic_variant", "ar-EG-extra", true}, // len > 2 prefix check
|
||||
{"english_variant", "en-US-extra", false}, // len > 2, not RTL
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
|
@ -119,6 +121,86 @@ func TestSetFormality_Good(t *testing.T) {
|
|||
assert.Equal(t, FormalityNeutral, svc.Formality())
|
||||
}
|
||||
|
||||
// --- Package-level SetFallback ---
|
||||
|
||||
func TestSetFallback_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
SetFallback("fr")
|
||||
assert.Equal(t, "fr", svc.Fallback())
|
||||
|
||||
SetFallback("en")
|
||||
assert.Equal(t, "en", svc.Fallback())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentFormality ---
|
||||
|
||||
func TestCurrentFormality_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, FormalityNeutral, CurrentFormality())
|
||||
|
||||
SetFormality(FormalityFormal)
|
||||
assert.Equal(t, FormalityFormal, CurrentFormality())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentFallback ---
|
||||
|
||||
func TestCurrentFallback_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, "en", CurrentFallback())
|
||||
|
||||
SetFallback("fr")
|
||||
assert.Equal(t, "fr", CurrentFallback())
|
||||
}
|
||||
|
||||
// --- Package-level SetLocation ---
|
||||
|
||||
func TestSetLocation_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
SetLocation("workspace")
|
||||
assert.Equal(t, "workspace", svc.Location())
|
||||
|
||||
SetLocation("")
|
||||
assert.Equal(t, "", svc.Location())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentLocation ---
|
||||
|
||||
func TestCurrentLocation_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, "", CurrentLocation())
|
||||
|
||||
SetLocation("workspace")
|
||||
assert.Equal(t, "workspace", CurrentLocation())
|
||||
}
|
||||
|
||||
// --- Package-level Location ---
|
||||
|
||||
func TestLocation_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, CurrentLocation(), Location())
|
||||
|
||||
SetLocation("workspace")
|
||||
assert.Equal(t, CurrentLocation(), Location())
|
||||
}
|
||||
|
||||
// --- Package-level Direction ---
|
||||
|
||||
func TestDirection_Good(t *testing.T) {
|
||||
|
|
@ -130,6 +212,26 @@ func TestDirection_Good(t *testing.T) {
|
|||
assert.Equal(t, DirLTR, dir)
|
||||
}
|
||||
|
||||
// --- Package-level CurrentDirection ---
|
||||
|
||||
func TestCurrentDirection_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, DirLTR, CurrentDirection())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentTextDirection ---
|
||||
|
||||
func TestCurrentTextDirection_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, CurrentDirection(), CurrentTextDirection())
|
||||
}
|
||||
|
||||
// --- Package-level IsRTL ---
|
||||
|
||||
func TestIsRTL_Good(t *testing.T) {
|
||||
|
|
@ -140,6 +242,91 @@ func TestIsRTL_Good(t *testing.T) {
|
|||
assert.False(t, IsRTL(), "English should not be RTL")
|
||||
}
|
||||
|
||||
// --- Package-level RTL ---
|
||||
|
||||
func TestRTL_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, IsRTL(), RTL())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentIsRTL ---
|
||||
|
||||
func TestCurrentIsRTL_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.False(t, CurrentIsRTL(), "English should not be RTL")
|
||||
}
|
||||
|
||||
// --- Package-level CurrentRTL ---
|
||||
|
||||
func TestCurrentRTL_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, CurrentIsRTL(), CurrentRTL())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentPluralCategory ---
|
||||
|
||||
func TestCurrentPluralCategory_Good(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, PluralOther, CurrentPluralCategory(0))
|
||||
assert.Equal(t, PluralOne, CurrentPluralCategory(1))
|
||||
assert.Equal(t, PluralOther, CurrentPluralCategory(2))
|
||||
|
||||
require.NoError(t, SetLanguage("fr"))
|
||||
assert.Equal(t, PluralOne, CurrentPluralCategory(0))
|
||||
assert.Equal(t, PluralOne, CurrentPluralCategory(1))
|
||||
assert.Equal(t, PluralOther, CurrentPluralCategory(2))
|
||||
}
|
||||
|
||||
// --- Package-level PluralCategoryOf ---
|
||||
|
||||
func TestPluralCategoryOf_Good(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, PluralOther, PluralCategoryOf(0))
|
||||
assert.Equal(t, PluralOne, PluralCategoryOf(1))
|
||||
assert.Equal(t, PluralOther, PluralCategoryOf(2))
|
||||
|
||||
require.NoError(t, SetLanguage("fr"))
|
||||
assert.Equal(t, PluralOne, PluralCategoryOf(0))
|
||||
assert.Equal(t, PluralOne, PluralCategoryOf(1))
|
||||
assert.Equal(t, PluralOther, PluralCategoryOf(2))
|
||||
}
|
||||
|
||||
func TestCurrentPluralCategory_NoDefaultService(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
SetDefault(nil)
|
||||
|
||||
assert.Equal(t, PluralOther, CurrentPluralCategory(2))
|
||||
}
|
||||
|
||||
// --- detectLanguage ---
|
||||
|
||||
func TestDetectLanguage_Good(t *testing.T) {
|
||||
|
|
@ -149,6 +336,49 @@ func TestDetectLanguage_Good(t *testing.T) {
|
|||
assert.Equal(t, "", result, "should return empty with no supported languages")
|
||||
}
|
||||
|
||||
func TestDetectLanguage_PrefersLocaleOverrides(t *testing.T) {
|
||||
t.Setenv("LANG", "en_US.UTF-8")
|
||||
t.Setenv("LC_MESSAGES", "fr_FR.UTF-8")
|
||||
t.Setenv("LC_ALL", "de_DE.UTF-8")
|
||||
|
||||
supported := []language.Tag{
|
||||
language.AmericanEnglish,
|
||||
language.French,
|
||||
language.German,
|
||||
}
|
||||
|
||||
result := detectLanguage(supported)
|
||||
assert.Equal(t, "de", result, "LC_ALL should win over LANG and LC_MESSAGES")
|
||||
}
|
||||
|
||||
func TestDetectLanguage_SkipsInvalidHigherPriorityLocale(t *testing.T) {
|
||||
t.Setenv("LANG", "en_US.UTF-8")
|
||||
t.Setenv("LC_MESSAGES", "fr_FR.UTF-8")
|
||||
t.Setenv("LC_ALL", "not-a-locale")
|
||||
|
||||
supported := []language.Tag{
|
||||
language.AmericanEnglish,
|
||||
language.French,
|
||||
}
|
||||
|
||||
result := detectLanguage(supported)
|
||||
assert.Equal(t, "fr", result, "invalid LC_ALL should not block a valid lower-priority locale")
|
||||
}
|
||||
|
||||
func TestDetectLanguage_PrefersLanguageList(t *testing.T) {
|
||||
t.Setenv("LANGUAGE", "fr_FR.UTF-8:de_DE.UTF-8")
|
||||
t.Setenv("LANG", "en_US.UTF-8")
|
||||
|
||||
supported := []language.Tag{
|
||||
language.AmericanEnglish,
|
||||
language.French,
|
||||
language.German,
|
||||
}
|
||||
|
||||
result := detectLanguage(supported)
|
||||
assert.Equal(t, "fr", result, "LANGUAGE should be considered before LANG")
|
||||
}
|
||||
|
||||
// --- Mode.String() ---
|
||||
|
||||
func TestMode_String_Good(t *testing.T) {
|
||||
|
|
|
|||
70
numbers.go
70
numbers.go
|
|
@ -9,15 +9,27 @@ import (
|
|||
|
||||
func getNumberFormat() NumberFormat {
|
||||
lang := currentLangForGrammar()
|
||||
if idx := indexAny(lang, "-_"); idx > 0 {
|
||||
lang = lang[:idx]
|
||||
}
|
||||
if fmt, ok := numberFormats[lang]; ok {
|
||||
if fmt, ok := getLocaleNumberFormat(lang); ok {
|
||||
return fmt
|
||||
}
|
||||
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 {
|
||||
return fmt, true
|
||||
}
|
||||
return NumberFormat{}, false
|
||||
}
|
||||
|
||||
// FormatNumber formats an integer with locale-specific thousands separators.
|
||||
func FormatNumber(n int64) string {
|
||||
return formatIntWithSep(n, getNumberFormat().ThousandsSep)
|
||||
|
|
@ -31,19 +43,35 @@ func FormatDecimal(f float64) string {
|
|||
// FormatDecimalN formats a float with N decimal places.
|
||||
func FormatDecimalN(f float64, decimals int) string {
|
||||
nf := getNumberFormat()
|
||||
intPart := int64(f)
|
||||
fracPart := math.Abs(f - float64(intPart))
|
||||
negative := f < 0
|
||||
absVal := math.Abs(f)
|
||||
intPart := int64(absVal)
|
||||
fracPart := absVal - float64(intPart)
|
||||
intStr := formatIntWithSep(intPart, nf.ThousandsSep)
|
||||
if decimals <= 0 || fracPart == 0 {
|
||||
if negative {
|
||||
return "-" + intStr
|
||||
}
|
||||
return intStr
|
||||
}
|
||||
multiplier := math.Pow(10, float64(decimals))
|
||||
fracInt := int64(math.Round(fracPart * multiplier))
|
||||
if fracInt >= int64(multiplier) {
|
||||
intPart++
|
||||
intStr = formatIntWithSep(intPart, nf.ThousandsSep)
|
||||
fracInt = 0
|
||||
}
|
||||
if fracInt == 0 {
|
||||
if negative {
|
||||
return "-" + intStr
|
||||
}
|
||||
return intStr
|
||||
}
|
||||
fracStr := core.Sprintf("%0*d", decimals, fracInt)
|
||||
fracStr = trimRight(fracStr, "0")
|
||||
if negative {
|
||||
return "-" + intStr + nf.DecimalSep + fracStr
|
||||
}
|
||||
return intStr + nf.DecimalSep + fracStr
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +96,6 @@ func FormatBytes(bytes int64) string {
|
|||
GB = MB * 1024
|
||||
TB = GB * 1024
|
||||
)
|
||||
nf := getNumberFormat()
|
||||
var value float64
|
||||
var unit string
|
||||
switch {
|
||||
|
|
@ -87,16 +114,7 @@ func FormatBytes(bytes int64) string {
|
|||
default:
|
||||
return core.Sprintf("%d B", bytes)
|
||||
}
|
||||
intPart := int64(value)
|
||||
fracPart := value - float64(intPart)
|
||||
if fracPart < 0.05 {
|
||||
return core.Sprintf("%d %s", intPart, unit)
|
||||
}
|
||||
fracDigit := int(math.Round(fracPart * 10))
|
||||
if fracDigit == 10 {
|
||||
return core.Sprintf("%d %s", intPart+1, unit)
|
||||
}
|
||||
return core.Sprintf("%d%s%d %s", intPart, nf.DecimalSep, fracDigit, unit)
|
||||
return FormatDecimalN(value, 2) + " " + unit
|
||||
}
|
||||
|
||||
// FormatOrdinal formats a number as an ordinal.
|
||||
|
|
@ -106,6 +124,8 @@ func FormatOrdinal(n int) string {
|
|||
lang = lang[:idx]
|
||||
}
|
||||
switch lang {
|
||||
case "fr":
|
||||
return formatFrenchOrdinal(n)
|
||||
case "en":
|
||||
return formatEnglishOrdinal(n)
|
||||
default:
|
||||
|
|
@ -113,6 +133,13 @@ func FormatOrdinal(n int) string {
|
|||
}
|
||||
}
|
||||
|
||||
func formatFrenchOrdinal(n int) string {
|
||||
if n == 1 || n == -1 {
|
||||
return core.Sprintf("%der", n)
|
||||
}
|
||||
return core.Sprintf("%de", n)
|
||||
}
|
||||
|
||||
func formatEnglishOrdinal(n int) string {
|
||||
abs := n
|
||||
if abs < 0 {
|
||||
|
|
@ -138,10 +165,15 @@ func formatIntWithSep(n int64, sep string) string {
|
|||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
negative := n < 0
|
||||
var abs uint64
|
||||
if negative {
|
||||
n = -n
|
||||
// Convert via n+1 to avoid overflowing on math.MinInt64.
|
||||
abs = uint64(-(n + 1))
|
||||
abs++
|
||||
} else {
|
||||
abs = uint64(n)
|
||||
}
|
||||
str := strconv.FormatInt(n, 10)
|
||||
str := strconv.FormatUint(abs, 10)
|
||||
if len(str) <= 3 {
|
||||
if negative {
|
||||
return "-" + str
|
||||
|
|
|
|||
109
numbers_test.go
109
numbers_test.go
|
|
@ -1,6 +1,9 @@
|
|||
package i18n
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatNumber(t *testing.T) {
|
||||
// Ensure service is initialised for English locale
|
||||
|
|
@ -31,6 +34,20 @@ func TestFormatNumber(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFormatNumber_MinInt64(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
got := FormatNumber(math.MinInt64)
|
||||
want := "-9,223,372,036,854,775,808"
|
||||
if got != want {
|
||||
t.Fatalf("FormatNumber(math.MinInt64) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDecimal(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
|
|
@ -44,8 +61,12 @@ func TestFormatDecimal(t *testing.T) {
|
|||
}{
|
||||
{1.5, "1.5"},
|
||||
{1.0, "1"},
|
||||
{1.995, "2"},
|
||||
{9.999, "10"},
|
||||
{1234.56, "1,234.56"},
|
||||
{0.1, "0.1"},
|
||||
{-0.1, "-0.1"},
|
||||
{-1234.56, "-1,234.56"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -56,6 +77,31 @@ func TestFormatDecimal(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFormatDecimalN_RoundsCarry(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
f float64
|
||||
decimals int
|
||||
want string
|
||||
}{
|
||||
{1.995, 2, "2"},
|
||||
{9.999, 2, "10"},
|
||||
{999.999, 2, "1,000"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := FormatDecimalN(tt.f, tt.decimals)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatDecimalN(%v, %d) = %q, want %q", tt.f, tt.decimals, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPercent(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
|
|
@ -71,6 +117,7 @@ func TestFormatPercent(t *testing.T) {
|
|||
{1.0, "100%"},
|
||||
{0.0, "0%"},
|
||||
{0.333, "33.3%"},
|
||||
{-0.1, "-10%"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -97,7 +144,7 @@ func TestFormatBytes(t *testing.T) {
|
|||
{1024, "1 KB"},
|
||||
{1536, "1.5 KB"},
|
||||
{1048576, "1 MB"},
|
||||
{1536000, "1.5 MB"},
|
||||
{1536000, "1.46 MB"},
|
||||
{1073741824, "1 GB"},
|
||||
{1099511627776, "1 TB"},
|
||||
}
|
||||
|
|
@ -143,3 +190,61 @@ 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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,21 @@ func TestNewImprint(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNewImprint_WordPhrase(t *testing.T) {
|
||||
svc, err := i18n.New()
|
||||
if err != nil {
|
||||
t.Fatalf("i18n.New() failed: %v", err)
|
||||
}
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
tok := NewTokeniser()
|
||||
imp := NewImprint(tok.Tokenise("up to date"))
|
||||
|
||||
if imp.DomainVocabulary["up_to_date"] != 1 {
|
||||
t.Fatalf("DomainVocabulary[\"up_to_date\"] = %d, want 1", imp.DomainVocabulary["up_to_date"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewImprint_Empty(t *testing.T) {
|
||||
imp := NewImprint(nil)
|
||||
if imp.TokenCount != 0 {
|
||||
|
|
|
|||
|
|
@ -68,39 +68,27 @@ func (m *Multiplier) Expand(text string) []string {
|
|||
|
||||
// 2. Verb transforms: for each verb, produce past and gerund variants
|
||||
for _, vi := range verbIndices {
|
||||
pastTokens := m.applyVerbTransform(tokens, vi, "past")
|
||||
addVariant(reconstruct(pastTokens))
|
||||
|
||||
gerundTokens := m.applyVerbTransform(tokens, vi, "gerund")
|
||||
addVariant(reconstruct(gerundTokens))
|
||||
|
||||
baseTokens := m.applyVerbTransform(tokens, vi, "base")
|
||||
addVariant(reconstruct(baseTokens))
|
||||
addVariant(m.reconstructWithVerbTransform(tokens, vi, "past"))
|
||||
addVariant(m.reconstructWithVerbTransform(tokens, vi, "gerund"))
|
||||
addVariant(m.reconstructWithVerbTransform(tokens, vi, "base"))
|
||||
}
|
||||
|
||||
// 3. Noun transforms: for each noun, toggle plural/singular
|
||||
for _, ni := range nounIndices {
|
||||
pluralTokens := m.applyNounTransform(tokens, ni)
|
||||
addVariant(reconstruct(pluralTokens))
|
||||
addVariant(m.reconstructWithNounTransform(tokens, ni))
|
||||
}
|
||||
|
||||
// 4. Combinations: each verb transform + each noun transform
|
||||
for _, vi := range verbIndices {
|
||||
for _, ni := range nounIndices {
|
||||
// past + noun toggle
|
||||
pastTokens := m.applyVerbTransform(tokens, vi, "past")
|
||||
pastPluralTokens := m.applyNounTransformOnTokens(pastTokens, ni)
|
||||
addVariant(reconstruct(pastPluralTokens))
|
||||
addVariant(m.reconstructWithVerbAndNounTransform(tokens, vi, "past", ni))
|
||||
|
||||
// gerund + noun toggle
|
||||
gerundTokens := m.applyVerbTransform(tokens, vi, "gerund")
|
||||
gerundPluralTokens := m.applyNounTransformOnTokens(gerundTokens, ni)
|
||||
addVariant(reconstruct(gerundPluralTokens))
|
||||
addVariant(m.reconstructWithVerbAndNounTransform(tokens, vi, "gerund", ni))
|
||||
|
||||
// base + noun toggle
|
||||
baseTokens := m.applyVerbTransform(tokens, vi, "base")
|
||||
basePluralTokens := m.applyNounTransformOnTokens(baseTokens, ni)
|
||||
addVariant(reconstruct(basePluralTokens))
|
||||
addVariant(m.reconstructWithVerbAndNounTransform(tokens, vi, "base", ni))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -204,6 +192,81 @@ func (m *Multiplier) applyNounTransformOnTokens(tokens []Token, ni int) []Token
|
|||
return result
|
||||
}
|
||||
|
||||
func (m *Multiplier) reconstructWithVerbTransform(tokens []Token, vi int, targetTense string) string {
|
||||
return m.reconstructWithTransforms(tokens, vi, targetTense, -1)
|
||||
}
|
||||
|
||||
func (m *Multiplier) reconstructWithNounTransform(tokens []Token, ni int) string {
|
||||
return m.reconstructWithTransforms(tokens, -1, "", ni)
|
||||
}
|
||||
|
||||
func (m *Multiplier) reconstructWithVerbAndNounTransform(tokens []Token, vi int, targetTense string, ni int) string {
|
||||
return m.reconstructWithTransforms(tokens, vi, targetTense, ni)
|
||||
}
|
||||
|
||||
func (m *Multiplier) reconstructWithTransforms(tokens []Token, vi int, targetTense string, ni int) string {
|
||||
b := core.NewBuilder()
|
||||
for i, tok := range tokens {
|
||||
if i > 0 {
|
||||
// Punctuation tokens should stay attached to the preceding token.
|
||||
if tok.Type == TokenPunctuation {
|
||||
b.WriteString(tok.Raw)
|
||||
continue
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
switch {
|
||||
case i == vi:
|
||||
b.WriteString(transformedVerbRaw(tok, targetTense))
|
||||
case i == ni:
|
||||
b.WriteString(transformedNounRaw(tok))
|
||||
default:
|
||||
b.WriteString(tok.Raw)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func transformedVerbRaw(tok Token, targetTense string) string {
|
||||
base := tok.VerbInfo.Base
|
||||
currentTense := tok.VerbInfo.Tense
|
||||
if currentTense == targetTense {
|
||||
return tok.Raw
|
||||
}
|
||||
|
||||
var newForm string
|
||||
switch targetTense {
|
||||
case "past":
|
||||
newForm = i18n.PastTense(base)
|
||||
case "gerund":
|
||||
newForm = i18n.Gerund(base)
|
||||
case "base":
|
||||
newForm = base
|
||||
}
|
||||
if newForm == "" {
|
||||
return tok.Raw
|
||||
}
|
||||
return preserveCase(tok.Raw, newForm)
|
||||
}
|
||||
|
||||
func transformedNounRaw(tok Token) string {
|
||||
base := tok.NounInfo.Base
|
||||
if base == "" {
|
||||
return tok.Raw
|
||||
}
|
||||
|
||||
var newForm string
|
||||
if tok.NounInfo.Plural {
|
||||
newForm = base
|
||||
} else {
|
||||
newForm = i18n.PluralForm(base)
|
||||
}
|
||||
if newForm == "" {
|
||||
return tok.Raw
|
||||
}
|
||||
return preserveCase(tok.Raw, newForm)
|
||||
}
|
||||
|
||||
// reconstruct joins tokens back into a string, preserving spacing.
|
||||
func reconstruct(tokens []Token) string {
|
||||
b := core.NewBuilder()
|
||||
|
|
|
|||
|
|
@ -122,8 +122,6 @@ func (rs *ReferenceSet) Classify(imprint GrammarImprint) ImprintClassification {
|
|||
result.Domain = ranked[0].domain
|
||||
if len(ranked) > 1 {
|
||||
result.Confidence = ranked[0].sim - ranked[1].sim
|
||||
} else {
|
||||
result.Confidence = ranked[0].sim
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -144,6 +144,30 @@ func TestReferenceSet_Classify(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestReferenceSet_Classify_SingleDomainConfidence(t *testing.T) {
|
||||
tok := initI18n(t)
|
||||
|
||||
samples := []ClassifiedText{
|
||||
{Text: "Delete the configuration file", Domain: "technical"},
|
||||
{Text: "Build the project from source", Domain: "technical"},
|
||||
}
|
||||
|
||||
rs, err := BuildReferences(tok, samples)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildReferences: %v", err)
|
||||
}
|
||||
|
||||
imp := NewImprint(tok.Tokenise("Run the tests before committing"))
|
||||
cls := rs.Classify(imp)
|
||||
|
||||
if cls.Domain == "" {
|
||||
t.Fatal("empty classification domain")
|
||||
}
|
||||
if cls.Confidence != 0 {
|
||||
t.Errorf("Confidence = %f, want 0 when only one domain is available", cls.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceSet_DomainNames(t *testing.T) {
|
||||
tok := initI18n(t)
|
||||
samples := []ClassifiedText{
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,9 +20,9 @@ func TestTokeniser_MatchVerb_Irregular(t *testing.T) {
|
|||
tok := NewTokeniser()
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
wantOK bool
|
||||
wantBase string
|
||||
word string
|
||||
wantOK bool
|
||||
wantBase string
|
||||
wantTense string
|
||||
}{
|
||||
// Irregular past tense
|
||||
|
|
@ -159,6 +159,7 @@ func TestTokeniser_MatchWord(t *testing.T) {
|
|||
{"url", "url", true},
|
||||
{"ID", "id", true},
|
||||
{"SSH", "ssh", true},
|
||||
{"up to date", "up_to_date", true},
|
||||
{"PHP", "php", true},
|
||||
{"xyzzy", "", false},
|
||||
}
|
||||
|
|
@ -188,6 +189,7 @@ func TestTokeniser_MatchArticle(t *testing.T) {
|
|||
{"a", "indefinite", true},
|
||||
{"an", "indefinite", true},
|
||||
{"the", "definite", true},
|
||||
{"the.", "definite", true},
|
||||
{"A", "indefinite", true},
|
||||
{"The", "definite", true},
|
||||
{"foo", "", false},
|
||||
|
|
@ -206,6 +208,516 @@ 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},
|
||||
{"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("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 l’ami", "indefinite", true},
|
||||
{"De l’enfant", "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},
|
||||
{"l’ami", "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 l’enfant.")
|
||||
if len(tokens) != 3 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 3", "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[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("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("au serveur")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "au serveur", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].ArtType != "definite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "definite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise_FrenchPartitiveArticlePhrase(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniserForLang("fr")
|
||||
|
||||
tokens := tok.Tokenise("de la branche")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de la branche", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].Lower != "de la" {
|
||||
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de la")
|
||||
}
|
||||
if tokens[0].ArtType != "indefinite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "branche" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "branche")
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("de les amis")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de les amis", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].Lower != "de les" {
|
||||
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de les")
|
||||
}
|
||||
if tokens[0].ArtType != "indefinite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "amis" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "amis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
|
@ -364,6 +876,43 @@ func TestTokeniser_WithSignals(t *testing.T) {
|
|||
_ = tok // verify it compiles and accepts the option
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise_CorpusPriorBias(t *testing.T) {
|
||||
const lang = "zz-prior"
|
||||
original := i18n.GetGrammarData(lang)
|
||||
t.Cleanup(func() {
|
||||
i18n.SetGrammarData(lang, original)
|
||||
})
|
||||
|
||||
i18n.SetGrammarData(lang, &i18n.GrammarData{
|
||||
Verbs: map[string]i18n.VerbForms{
|
||||
"commit": {Past: "committed", Gerund: "committing"},
|
||||
},
|
||||
Nouns: map[string]i18n.NounForms{
|
||||
"commit": {One: "commit", Other: "commits"},
|
||||
},
|
||||
Signals: i18n.SignalData{
|
||||
Priors: map[string]map[string]float64{
|
||||
"commit": {
|
||||
"verb": 0.2,
|
||||
"noun": 0.8,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tok := NewTokeniserForLang(lang)
|
||||
tokens := tok.Tokenise("please commit")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "please commit", len(tokens))
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("Tokenise(%q)[1].Type = %v, want TokenNoun", "please commit", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Confidence <= 0.5 {
|
||||
t.Fatalf("Tokenise(%q)[1].Confidence = %f, want > 0.5", "please commit", tokens[1].Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_DualClassDetection(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
|
@ -375,7 +924,13 @@ func TestTokeniser_DualClassDetection(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
notDual := []string{"delete", "go", "push", "branch", "repo"}
|
||||
for _, word := range []string{"change", "export", "function", "handle", "host", "import", "link", "log", "merge", "patch", "process", "pull", "push", "queue", "release", "stream", "tag", "trigger", "update", "watch"} {
|
||||
if !tok.IsDualClass(word) {
|
||||
t.Errorf("%q should be dual-class after expansion", word)
|
||||
}
|
||||
}
|
||||
|
||||
notDual := []string{"delete", "go", "branch", "repo"}
|
||||
for _, word := range notDual {
|
||||
if tok.IsDualClass(word) {
|
||||
t.Errorf("%q should not be dual-class", word)
|
||||
|
|
@ -383,6 +938,78 @@ func TestTokeniser_DualClassDetection(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_IgnoresDeprecatedGrammarEntries(t *testing.T) {
|
||||
setup(t)
|
||||
|
||||
const lang = "zz-deprecated"
|
||||
original := i18n.GetGrammarData(lang)
|
||||
t.Cleanup(func() {
|
||||
i18n.SetGrammarData(lang, original)
|
||||
})
|
||||
|
||||
i18n.SetGrammarData(lang, &i18n.GrammarData{
|
||||
Nouns: map[string]i18n.NounForms{
|
||||
"passed": {One: "passed", Other: "passed"},
|
||||
"failed": {One: "failed", Other: "failed"},
|
||||
"skipped": {One: "skipped", Other: "skipped"},
|
||||
"commit": {One: "commit", Other: "commits"},
|
||||
},
|
||||
Words: map[string]string{
|
||||
"passed": "passed",
|
||||
"failed": "failed",
|
||||
"skipped": "skipped",
|
||||
"url": "URL",
|
||||
},
|
||||
})
|
||||
|
||||
tok := NewTokeniserForLang(lang)
|
||||
for _, word := range []string{"passed", "failed", "skipped"} {
|
||||
if tok.IsDualClass(word) {
|
||||
t.Fatalf("%q should not be treated as dual-class", word)
|
||||
}
|
||||
if cat, ok := tok.MatchWord(word); ok {
|
||||
t.Fatalf("MatchWord(%q) = %q, %v; want not found", word, cat, ok)
|
||||
}
|
||||
if _, ok := tok.MatchNoun(word); ok {
|
||||
t.Fatalf("MatchNoun(%q) should be ignored", word)
|
||||
}
|
||||
}
|
||||
if cat, ok := tok.MatchWord("url"); !ok || cat != "url" {
|
||||
t.Fatalf("MatchWord(%q) = %q, %v; want %q, true", "url", cat, ok, "url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_DualClassExpansion_ClassifiesCommonDevOpsWords(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
||||
tests := []struct {
|
||||
text string
|
||||
wantType TokenType
|
||||
wantLower string
|
||||
}{
|
||||
{"the merge", TokenNoun, "merge"},
|
||||
{"please merge the file", TokenVerb, "merge"},
|
||||
{"the process", TokenNoun, "process"},
|
||||
{"please process the log", TokenVerb, "process"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.text, func(t *testing.T) {
|
||||
tokens := tok.Tokenise(tt.text)
|
||||
if len(tokens) < 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want at least 2", tt.text, len(tokens))
|
||||
}
|
||||
if tokens[1].Lower != tt.wantLower {
|
||||
t.Fatalf("Tokenise(%q)[1].Lower = %q, want %q", tt.text, tokens[1].Lower, tt.wantLower)
|
||||
}
|
||||
if tokens[1].Type != tt.wantType {
|
||||
t.Fatalf("Tokenise(%q)[1].Type = %v, want %v", tt.text, tokens[1].Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToken_ConfidenceField(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
|
@ -503,6 +1130,46 @@ func TestTokeniser_Disambiguate_ContractionAux(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Disambiguate_ContractionAux_FallbackDefaults(t *testing.T) {
|
||||
tok := NewTokeniserForLang("zz")
|
||||
tokens := tok.Tokenise("don't run the tests")
|
||||
// The hardcoded fallback auxiliaries should still recognise contractions
|
||||
// even when no locale grammar data is loaded.
|
||||
for _, token := range tokens {
|
||||
if token.Lower == "run" && token.Type != TokenVerb {
|
||||
t.Errorf("'run' after \"don't\": Type = %v, want TokenVerb", token.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Disambiguate_NegationSignal(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser(WithSignals())
|
||||
|
||||
tokens := tok.Tokenise("no longer commit the changes")
|
||||
if len(tokens) < 3 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want at least 3", "no longer commit the changes", len(tokens))
|
||||
}
|
||||
|
||||
commitTok := tokens[2]
|
||||
if commitTok.Type != TokenVerb {
|
||||
t.Fatalf("'commit' after 'no longer': Type = %v, want TokenVerb", commitTok.Type)
|
||||
}
|
||||
if commitTok.Signals == nil {
|
||||
t.Fatal("'commit' after 'no longer' should have signal breakdown")
|
||||
}
|
||||
foundNegation := false
|
||||
for _, component := range commitTok.Signals.Components {
|
||||
if component.Name == "verb_negation" {
|
||||
foundNegation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundNegation {
|
||||
t.Error("verb_negation signal should have fired for 'no longer commit'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_WithSignals_Breakdown(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser(WithSignals())
|
||||
|
|
@ -550,7 +1217,7 @@ func TestDisambiguationStats_WithAmbiguous(t *testing.T) {
|
|||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
tokens := tok.Tokenise("The commit passed the test")
|
||||
stats := DisambiguationStatsFromTokens(tokens)
|
||||
stats := tok.DisambiguationStats(tokens)
|
||||
if stats.AmbiguousTokens == 0 {
|
||||
t.Error("expected ambiguous tokens for dual-class words")
|
||||
}
|
||||
|
|
@ -563,7 +1230,7 @@ func TestDisambiguationStats_NoAmbiguous(t *testing.T) {
|
|||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
tokens := tok.Tokenise("Deleted the files")
|
||||
stats := DisambiguationStatsFromTokens(tokens)
|
||||
stats := tok.DisambiguationStats(tokens)
|
||||
if stats.AmbiguousTokens != 0 {
|
||||
t.Errorf("AmbiguousTokens = %d, want 0", stats.AmbiguousTokens)
|
||||
}
|
||||
|
|
@ -572,7 +1239,7 @@ func TestDisambiguationStats_NoAmbiguous(t *testing.T) {
|
|||
func TestWithWeights_Override(t *testing.T) {
|
||||
setup(t)
|
||||
// Override noun_determiner to 0 — "The commit" should no longer resolve as noun
|
||||
tok := NewTokeniser(WithWeights(map[string]float64{
|
||||
weights := map[string]float64{
|
||||
"noun_determiner": 0.0,
|
||||
"verb_auxiliary": 0.25,
|
||||
"following_class": 0.15,
|
||||
|
|
@ -580,7 +1247,8 @@ func TestWithWeights_Override(t *testing.T) {
|
|||
"verb_saturation": 0.10,
|
||||
"inflection_echo": 0.03,
|
||||
"default_prior": 0.02,
|
||||
}))
|
||||
}
|
||||
tok := NewTokeniser(WithWeights(weights))
|
||||
tokens := tok.Tokenise("The commit")
|
||||
// With noun_determiner zeroed, default_prior (verb) should win
|
||||
if tokens[1].Type != TokenVerb {
|
||||
|
|
@ -588,6 +1256,107 @@ 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 ---
|
||||
|
||||
func benchSetup(b *testing.B) {
|
||||
|
|
|
|||
1115
service.go
1115
service.go
File diff suppressed because it is too large
Load diff
1127
service_test.go
1127
service_test.go
File diff suppressed because it is too large
Load diff
179
state.go
Normal file
179
state.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
func newServiceStateSnapshot(
|
||||
language string,
|
||||
requestedLanguage string,
|
||||
languageExplicit bool,
|
||||
availableLanguages []string,
|
||||
mode Mode,
|
||||
fallback string,
|
||||
formality Formality,
|
||||
location string,
|
||||
direction TextDirection,
|
||||
debug bool,
|
||||
handlers []KeyHandler,
|
||||
) ServiceState {
|
||||
return ServiceState{
|
||||
Language: language,
|
||||
RequestedLanguage: requestedLanguage,
|
||||
LanguageExplicit: languageExplicit,
|
||||
AvailableLanguages: availableLanguages,
|
||||
Mode: mode,
|
||||
Fallback: fallback,
|
||||
Formality: formality,
|
||||
Location: location,
|
||||
Direction: direction,
|
||||
IsRTL: direction == DirRTL,
|
||||
Debug: debug,
|
||||
Handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultServiceStateSnapshot() ServiceState {
|
||||
// Keep the nil/default snapshot aligned with Service.State() so callers get
|
||||
// the same shape regardless of whether a Service has been initialised.
|
||||
return newServiceStateSnapshot(
|
||||
"en",
|
||||
"",
|
||||
false,
|
||||
[]string{},
|
||||
ModeNormal,
|
||||
"en",
|
||||
FormalityNeutral,
|
||||
"",
|
||||
DirLTR,
|
||||
false,
|
||||
[]KeyHandler{},
|
||||
)
|
||||
}
|
||||
|
||||
// ServiceState captures the current configuration of a service in one
|
||||
// copy-safe snapshot.
|
||||
//
|
||||
// state := i18n.CurrentState()
|
||||
type ServiceState struct {
|
||||
Language string
|
||||
RequestedLanguage string
|
||||
LanguageExplicit bool
|
||||
AvailableLanguages []string
|
||||
Mode Mode
|
||||
Fallback string
|
||||
Formality Formality
|
||||
Location string
|
||||
Direction TextDirection
|
||||
IsRTL bool
|
||||
Debug bool
|
||||
Handlers []KeyHandler
|
||||
}
|
||||
|
||||
// HandlerTypeNames returns the short type names of the snapshot's handlers.
|
||||
//
|
||||
// names := i18n.CurrentState().HandlerTypeNames()
|
||||
//
|
||||
// The returned slice is a fresh copy, so callers can inspect or mutate it
|
||||
// without affecting the snapshot.
|
||||
func (s ServiceState) HandlerTypeNames() []string {
|
||||
if len(s.Handlers) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
names := make([]string, 0, len(s.Handlers))
|
||||
for _, handler := range s.Handlers {
|
||||
if handler == nil {
|
||||
names = append(names, "<nil>")
|
||||
continue
|
||||
}
|
||||
names = append(names, shortHandlerTypeName(handler))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// String returns a concise, stable summary of the service snapshot.
|
||||
//
|
||||
// fmt.Println(i18n.CurrentState().String())
|
||||
func (s ServiceState) String() string {
|
||||
langs := "[]"
|
||||
if len(s.AvailableLanguages) > 0 {
|
||||
langs = "[" + core.Join(", ", s.AvailableLanguages...) + "]"
|
||||
}
|
||||
handlerNames := s.HandlerTypeNames()
|
||||
handlers := "[]"
|
||||
if len(handlerNames) > 0 {
|
||||
handlers = "[" + core.Join(", ", handlerNames...) + "]"
|
||||
}
|
||||
return core.Sprintf(
|
||||
"ServiceState{language=%q requested=%q explicit=%t fallback=%q mode=%s formality=%s location=%q direction=%s rtl=%t debug=%t available=%s handlers=%d types=%s}",
|
||||
s.Language,
|
||||
s.RequestedLanguage,
|
||||
s.LanguageExplicit,
|
||||
s.Fallback,
|
||||
s.Mode,
|
||||
s.Formality,
|
||||
s.Location,
|
||||
s.Direction,
|
||||
s.IsRTL,
|
||||
s.Debug,
|
||||
langs,
|
||||
len(s.Handlers),
|
||||
handlers,
|
||||
)
|
||||
}
|
||||
|
||||
func shortHandlerTypeName(handler KeyHandler) string {
|
||||
name := core.Sprintf("%T", handler)
|
||||
parts := core.Split(name, ".")
|
||||
if len(parts) > 0 {
|
||||
name = parts[len(parts)-1]
|
||||
}
|
||||
return core.TrimPrefix(name, "*")
|
||||
}
|
||||
|
||||
func (s *Service) State() ServiceState {
|
||||
if s == nil {
|
||||
return defaultServiceStateSnapshot()
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
langs := make([]string, len(s.availableLangs))
|
||||
for i, tag := range s.availableLangs {
|
||||
langs[i] = tag.String()
|
||||
}
|
||||
|
||||
handlers := make([]KeyHandler, len(s.handlers))
|
||||
copy(handlers, s.handlers)
|
||||
|
||||
dir := DirLTR
|
||||
if IsRTLLanguage(s.currentLang) {
|
||||
dir = DirRTL
|
||||
}
|
||||
|
||||
return newServiceStateSnapshot(
|
||||
s.currentLang,
|
||||
s.requestedLang,
|
||||
s.languageExplicit,
|
||||
langs,
|
||||
s.mode,
|
||||
s.fallbackLang,
|
||||
s.formality,
|
||||
s.location,
|
||||
dir,
|
||||
s.debug,
|
||||
handlers,
|
||||
)
|
||||
}
|
||||
|
||||
// String returns a concise snapshot of the service state.
|
||||
func (s *Service) String() string {
|
||||
return s.State().String()
|
||||
}
|
||||
|
||||
// CurrentState is a more explicit alias for State.
|
||||
//
|
||||
// state := i18n.CurrentState()
|
||||
func (s *Service) CurrentState() ServiceState {
|
||||
return s.State()
|
||||
}
|
||||
53
time.go
53
time.go
|
|
@ -8,33 +8,78 @@ import (
|
|||
|
||||
// TimeAgo returns a localised relative time string.
|
||||
//
|
||||
// TimeAgo(time.Now().Add(-4 * time.Second)) // "just now"
|
||||
// TimeAgo(time.Now().Add(-5 * time.Minute)) // "5 minutes ago"
|
||||
func TimeAgo(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
switch {
|
||||
case duration < 5*time.Second:
|
||||
if text := T("time.just_now"); text != "time.just_now" {
|
||||
return text
|
||||
}
|
||||
return "just now"
|
||||
case duration < time.Minute:
|
||||
return T("time.just_now")
|
||||
return FormatAgo(int(duration/time.Second), "second")
|
||||
case duration < time.Hour:
|
||||
return FormatAgo(int(duration.Minutes()), "minute")
|
||||
case duration < 24*time.Hour:
|
||||
return FormatAgo(int(duration.Hours()), "hour")
|
||||
case duration < 7*24*time.Hour:
|
||||
return FormatAgo(int(duration.Hours()/24), "day")
|
||||
default:
|
||||
case duration < 30*24*time.Hour:
|
||||
return FormatAgo(int(duration.Hours()/(24*7)), "week")
|
||||
case duration < 365*24*time.Hour:
|
||||
return FormatAgo(int(duration.Hours()/(24*30)), "month")
|
||||
default:
|
||||
return FormatAgo(int(duration.Hours()/(24*365)), "year")
|
||||
}
|
||||
}
|
||||
|
||||
// FormatAgo formats "N unit ago" with proper pluralisation.
|
||||
func FormatAgo(count int, unit string) string {
|
||||
svc := Default()
|
||||
unit = normalizeAgoUnit(unit)
|
||||
if svc == nil {
|
||||
return core.Sprintf("%d %ss ago", count, unit)
|
||||
return core.Sprintf("%d %s ago", count, Pluralize(unit, count))
|
||||
}
|
||||
key := "time.ago." + unit
|
||||
result := svc.T(key, map[string]any{"Count": count})
|
||||
if result == key {
|
||||
return core.Sprintf("%d %s ago", count, Pluralize(unit, count))
|
||||
return core.Sprintf("%d %s ago", count, fallbackAgoUnit(unit, count))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fallbackAgoUnit(unit string, count int) string {
|
||||
lang := currentLangForGrammar()
|
||||
rendered := renderWord(lang, unit)
|
||||
if rendered != unit {
|
||||
return rendered
|
||||
}
|
||||
return Pluralize(unit, count)
|
||||
}
|
||||
|
||||
func normalizeAgoUnit(unit string) string {
|
||||
unit = core.Lower(core.Trim(unit))
|
||||
switch unit {
|
||||
case "seconds":
|
||||
return "second"
|
||||
case "minutes":
|
||||
return "minute"
|
||||
case "hours":
|
||||
return "hour"
|
||||
case "days":
|
||||
return "day"
|
||||
case "weeks":
|
||||
return "week"
|
||||
case "months":
|
||||
return "month"
|
||||
case "years":
|
||||
return "year"
|
||||
default:
|
||||
return unit
|
||||
}
|
||||
}
|
||||
|
|
|
|||
144
time_test.go
144
time_test.go
|
|
@ -2,6 +2,7 @@ package i18n
|
|||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -20,11 +21,14 @@ func TestTimeAgo_Good(t *testing.T) {
|
|||
duration time.Duration
|
||||
contains string
|
||||
}{
|
||||
{"just_now", 5 * time.Second, "just now"},
|
||||
{"just_now", 4 * time.Second, "just now"},
|
||||
{"seconds_ago", 5 * time.Second, "5 seconds ago"},
|
||||
{"minutes_ago", 5 * time.Minute, "5 minutes ago"},
|
||||
{"hours_ago", 3 * time.Hour, "3 hours ago"},
|
||||
{"days_ago", 2 * 24 * time.Hour, "2 days ago"},
|
||||
{"weeks_ago", 3 * 7 * 24 * time.Hour, "3 weeks ago"},
|
||||
{"months_ago", 40 * 24 * time.Hour, "1 month ago"},
|
||||
{"years_ago", 400 * 24 * time.Hour, "1 year ago"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -41,7 +45,7 @@ func TestTimeAgo_Good_EdgeCases(t *testing.T) {
|
|||
|
||||
// Just under 1 minute
|
||||
got := TimeAgo(time.Now().Add(-59 * time.Second))
|
||||
assert.Contains(t, got, "just now")
|
||||
assert.Contains(t, got, "seconds ago")
|
||||
|
||||
// Exactly 1 minute
|
||||
got = TimeAgo(time.Now().Add(-60 * time.Second))
|
||||
|
|
@ -58,6 +62,14 @@ func TestTimeAgo_Good_EdgeCases(t *testing.T) {
|
|||
// Just under 1 week
|
||||
got = TimeAgo(time.Now().Add(-6 * 24 * time.Hour))
|
||||
assert.Contains(t, got, "days ago")
|
||||
|
||||
// Just over 4 weeks
|
||||
got = TimeAgo(time.Now().Add(-31 * 24 * time.Hour))
|
||||
assert.Contains(t, got, "month ago")
|
||||
|
||||
// Well over a year
|
||||
got = TimeAgo(time.Now().Add(-800 * 24 * time.Hour))
|
||||
assert.Contains(t, got, "years ago")
|
||||
}
|
||||
|
||||
func TestTimeAgo_Good_SingleUnits(t *testing.T) {
|
||||
|
|
@ -82,6 +94,24 @@ func TestTimeAgo_Good_SingleUnits(t *testing.T) {
|
|||
assert.Contains(t, got, "1 week ago")
|
||||
}
|
||||
|
||||
func TestTimeAgo_Good_MissingJustNowKeyFallback(t *testing.T) {
|
||||
svc, err := NewWithFS(fstest.MapFS{
|
||||
"xx.json": &fstest.MapFile{
|
||||
Data: []byte(`{}`),
|
||||
},
|
||||
}, ".")
|
||||
require.NoError(t, err)
|
||||
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
got := TimeAgo(time.Now().Add(-4 * time.Second))
|
||||
assert.Equal(t, "just now", got)
|
||||
}
|
||||
|
||||
// --- FormatAgo ---
|
||||
|
||||
func TestFormatAgo_Good(t *testing.T) {
|
||||
|
|
@ -112,6 +142,46 @@ func TestFormatAgo_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFormatAgo_Good_PluralUnitAlias(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
got := FormatAgo(5, "minutes")
|
||||
assert.Equal(t, "5 minutes ago", got)
|
||||
}
|
||||
|
||||
func TestFormatAgo_Good_MorePluralUnitAliases(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
count int
|
||||
unit string
|
||||
want string
|
||||
}{
|
||||
{"months", 3, "months", "3 months ago"},
|
||||
{"year", 1, "years", "1 year ago"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatAgo(tt.count, tt.unit)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAgo_Good_NormalisesUnitInput(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
got := FormatAgo(2, " Hours ")
|
||||
assert.Equal(t, "2 hours ago", got)
|
||||
}
|
||||
|
||||
func TestFormatAgo_Bad_UnknownUnit(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -130,3 +200,73 @@ func TestFormatAgo_Good_SingularUnit(t *testing.T) {
|
|||
got := FormatAgo(1, "fortnight")
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
63
transform.go
63
transform.go
|
|
@ -1,20 +1,60 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
func getCount(data any) int {
|
||||
if data == nil {
|
||||
return 0
|
||||
}
|
||||
switch d := data.(type) {
|
||||
case *Subject:
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
return d.CountInt()
|
||||
case *TranslationContext:
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
if count, ok := d.countValue(); ok {
|
||||
return count
|
||||
}
|
||||
if d.Extra != nil {
|
||||
if c, ok := d.Extra["Count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
if c, ok := d.Extra["count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
}
|
||||
return d.count
|
||||
case map[string]any:
|
||||
if c, ok := d["Count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
if c, ok := d["count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
case map[string]int:
|
||||
if c, ok := d["Count"]; ok {
|
||||
return c
|
||||
}
|
||||
if c, ok := d["count"]; ok {
|
||||
return c
|
||||
}
|
||||
case map[string]string:
|
||||
if c, ok := d["Count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
if c, ok := d["count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
return toInt(data)
|
||||
}
|
||||
|
||||
func toInt(v any) int {
|
||||
|
|
@ -46,6 +86,13 @@ func toInt(v any) int {
|
|||
return int(n)
|
||||
case float32:
|
||||
return int(n)
|
||||
case string:
|
||||
if n == "" {
|
||||
return 0
|
||||
}
|
||||
if parsed, err := strconv.Atoi(core.Trim(n)); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -79,6 +126,13 @@ func toInt64(v any) int64 {
|
|||
return int64(n)
|
||||
case float32:
|
||||
return int64(n)
|
||||
case string:
|
||||
if n == "" {
|
||||
return 0
|
||||
}
|
||||
if parsed, err := strconv.ParseInt(core.Trim(n), 10, 64); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -112,6 +166,13 @@ func toFloat64(v any) float64 {
|
|||
return float64(n)
|
||||
case uint8:
|
||||
return float64(n)
|
||||
case string:
|
||||
if n == "" {
|
||||
return 0
|
||||
}
|
||||
if parsed, err := strconv.ParseFloat(core.Trim(n), 64); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ func TestGetCount_Good(t *testing.T) {
|
|||
{"map_string_any", map[string]any{"Count": 5}, 5},
|
||||
{"map_string_any_float", map[string]any{"Count": 3.7}, 3},
|
||||
{"map_string_int", map[string]int{"Count": 42}, 42},
|
||||
{"map_string_string", map[string]string{"Count": "9"}, 9},
|
||||
{"no_count_key", map[string]any{"Name": "test"}, 0},
|
||||
{"wrong_type", "a string", 0},
|
||||
}
|
||||
|
|
@ -29,6 +30,16 @@ func TestGetCount_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetCount_Good_TranslationContextDefault(t *testing.T) {
|
||||
ctx := C("test")
|
||||
assert.Equal(t, 1, getCount(ctx))
|
||||
}
|
||||
|
||||
func TestGetCount_Good_TranslationContextExtraCount(t *testing.T) {
|
||||
ctx := C("test").Set("Count", 3)
|
||||
assert.Equal(t, 3, getCount(ctx))
|
||||
}
|
||||
|
||||
// --- toInt ---
|
||||
|
||||
func TestToInt_Good(t *testing.T) {
|
||||
|
|
@ -50,6 +61,7 @@ func TestToInt_Good(t *testing.T) {
|
|||
{"uint8", uint8(50), 50},
|
||||
{"float64", float64(3.14), 3},
|
||||
{"float32", float32(2.71), 2},
|
||||
{"string_int", "123", 123},
|
||||
{"string", "not a number", 0},
|
||||
{"bool", true, 0},
|
||||
}
|
||||
|
|
@ -82,6 +94,7 @@ func TestToInt64_Good(t *testing.T) {
|
|||
{"uint8", uint8(50), 50},
|
||||
{"float64", float64(3.14), 3},
|
||||
{"float32", float32(2.71), 2},
|
||||
{"string_int64", "123", 123},
|
||||
{"string", "not a number", 0},
|
||||
{"bool", true, 0},
|
||||
}
|
||||
|
|
@ -114,6 +127,7 @@ func TestToFloat64_Good(t *testing.T) {
|
|||
{"uint32", uint32(30), 30.0},
|
||||
{"uint16", uint16(40), 40.0},
|
||||
{"uint8", uint8(50), 50.0},
|
||||
{"string_float", "3.5", 3.5},
|
||||
{"string", "not a number", 0},
|
||||
{"bool", true, 0},
|
||||
}
|
||||
|
|
|
|||
108
types.go
108
types.go
|
|
@ -8,7 +8,7 @@
|
|||
// T("i18n.label.status") // "Status:"
|
||||
// T("i18n.progress.build") // "Building..."
|
||||
// T("i18n.count.file", 5) // "5 files"
|
||||
// T("i18n.done.delete", "config.yaml") // "Config.Yaml deleted"
|
||||
// T("i18n.done.delete", "config.yaml") // "Config.yaml deleted"
|
||||
// T("i18n.fail.push", "commits") // "Failed to push commits"
|
||||
package i18n
|
||||
|
||||
|
|
@ -17,6 +17,8 @@ import "sync"
|
|||
// --- Core Types ---
|
||||
|
||||
// Mode determines how the service handles missing translation keys.
|
||||
//
|
||||
// i18n.SetMode(i18n.ModeStrict)
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
|
|
@ -39,6 +41,8 @@ func (m Mode) String() string {
|
|||
}
|
||||
|
||||
// Formality represents the level of formality in translations.
|
||||
//
|
||||
// i18n.S("user", "Alex").Formal()
|
||||
type Formality int
|
||||
|
||||
const (
|
||||
|
|
@ -48,14 +52,18 @@ const (
|
|||
)
|
||||
|
||||
// TextDirection represents text directionality.
|
||||
//
|
||||
// if i18n.Direction() == i18n.DirRTL { /* ... */ }
|
||||
type TextDirection int
|
||||
|
||||
const (
|
||||
DirLTR TextDirection = iota // Left-to-right
|
||||
DirRTL // Right-to-left
|
||||
DirRTL // Right-to-left
|
||||
)
|
||||
|
||||
// PluralCategory represents CLDR plural categories.
|
||||
//
|
||||
// cat := i18n.CurrentPluralCategory(2)
|
||||
type PluralCategory int
|
||||
|
||||
const (
|
||||
|
|
@ -68,6 +76,8 @@ const (
|
|||
)
|
||||
|
||||
// GrammaticalGender represents grammatical gender for nouns.
|
||||
//
|
||||
// i18n.S("user", "Alex").Gender("feminine")
|
||||
type GrammaticalGender int
|
||||
|
||||
const (
|
||||
|
|
@ -80,6 +90,8 @@ const (
|
|||
// --- Message Types ---
|
||||
|
||||
// Message represents a translation — either a simple string or plural forms.
|
||||
//
|
||||
// msg := i18n.Message{One: "{{.Count}} file", Other: "{{.Count}} files"}
|
||||
type Message struct {
|
||||
Text string // Simple string value (non-plural)
|
||||
Zero string // count == 0 (Arabic, Latvian, Welsh)
|
||||
|
|
@ -132,6 +144,8 @@ func (m Message) IsPlural() bool {
|
|||
// --- Subject Types ---
|
||||
|
||||
// Subject represents a typed subject with metadata for semantic translations.
|
||||
//
|
||||
// subj := i18n.S("file", "config.yaml").Count(3).In("workspace")
|
||||
type Subject struct {
|
||||
Noun string // The noun type (e.g., "file", "repo")
|
||||
Value any // The actual value (e.g., filename)
|
||||
|
|
@ -144,6 +158,8 @@ type Subject struct {
|
|||
// --- Intent Types ---
|
||||
|
||||
// IntentMeta defines the behaviour of an intent.
|
||||
//
|
||||
// intent := i18n.Intent{Meta: i18n.IntentMeta{Type: "action", Verb: "delete"}}
|
||||
type IntentMeta struct {
|
||||
Type string // "action", "question", "info"
|
||||
Verb string // Reference to verb key
|
||||
|
|
@ -153,6 +169,8 @@ type IntentMeta struct {
|
|||
}
|
||||
|
||||
// Composed holds all output forms for an intent after template resolution.
|
||||
//
|
||||
// composed := i18n.ComposeIntent(i18n.Intent{Question: "Delete {{.Subject}}?"}, i18n.S("file", "config.yaml"))
|
||||
type Composed struct {
|
||||
Question string // "Delete config.yaml?"
|
||||
Confirm string // "Really delete config.yaml?"
|
||||
|
|
@ -162,6 +180,8 @@ type Composed struct {
|
|||
}
|
||||
|
||||
// Intent defines a semantic intent with templates for all output forms.
|
||||
//
|
||||
// intent := i18n.Intent{Question: "Delete {{.Subject}}?"}
|
||||
type Intent struct {
|
||||
Meta IntentMeta
|
||||
Question string // Template for question form
|
||||
|
|
@ -186,6 +206,8 @@ type templateData struct {
|
|||
// --- Grammar Types ---
|
||||
|
||||
// GrammarData holds language-specific grammar forms loaded from JSON.
|
||||
//
|
||||
// i18n.SetGrammarData("en", &i18n.GrammarData{Articles: i18n.ArticleForms{IndefiniteDefault: "a"}})
|
||||
type GrammarData struct {
|
||||
Verbs map[string]VerbForms // verb -> forms
|
||||
Nouns map[string]NounForms // noun -> forms
|
||||
|
|
@ -193,15 +215,20 @@ type GrammarData struct {
|
|||
Words map[string]string // base word translations
|
||||
Punct PunctuationRules // language-specific punctuation
|
||||
Signals SignalData // disambiguation signal word lists
|
||||
Number NumberFormat // locale-specific number formatting
|
||||
}
|
||||
|
||||
// VerbForms holds verb conjugations.
|
||||
//
|
||||
// forms := i18n.VerbForms{Past: "deleted", Gerund: "deleting"}
|
||||
type VerbForms struct {
|
||||
Past string // "deleted"
|
||||
Gerund string // "deleting"
|
||||
}
|
||||
|
||||
// NounForms holds plural and gender information for a noun.
|
||||
//
|
||||
// forms := i18n.NounForms{One: "file", Other: "files"}
|
||||
type NounForms struct {
|
||||
One string // Singular form
|
||||
Other string // Plural form
|
||||
|
|
@ -209,6 +236,8 @@ type NounForms struct {
|
|||
}
|
||||
|
||||
// ArticleForms holds article configuration for a language.
|
||||
//
|
||||
// articles := i18n.ArticleForms{IndefiniteDefault: "a", IndefiniteVowel: "an"}
|
||||
type ArticleForms struct {
|
||||
IndefiniteDefault string // "a"
|
||||
IndefiniteVowel string // "an"
|
||||
|
|
@ -217,22 +246,29 @@ type ArticleForms struct {
|
|||
}
|
||||
|
||||
// PunctuationRules holds language-specific punctuation patterns.
|
||||
//
|
||||
// rules := i18n.PunctuationRules{LabelSuffix: ":", ProgressSuffix: "..."}
|
||||
type PunctuationRules struct {
|
||||
LabelSuffix string // ":" (French uses " :")
|
||||
ProgressSuffix string // "..."
|
||||
}
|
||||
|
||||
// SignalData holds word lists used for disambiguation signals.
|
||||
//
|
||||
// signals := i18n.SignalData{VerbAuxiliaries: []string{"is", "was"}}
|
||||
type SignalData struct {
|
||||
NounDeterminers []string // Words that precede nouns: "the", "a", "this", "my", ...
|
||||
VerbAuxiliaries []string // Auxiliaries/modals before verbs: "is", "was", "will", ...
|
||||
VerbInfinitive []string // Infinitive markers: "to"
|
||||
Priors map[string]map[string]float64 // Reserved for Phase 2: corpus-derived per-word priors. Not yet loaded.
|
||||
VerbNegation []string // Negation cues that weakly signal a verb: "not", "never", ...
|
||||
Priors map[string]map[string]float64 // Corpus-derived verb/noun priors for ambiguous words, consumed by the reversal tokeniser.
|
||||
}
|
||||
|
||||
// --- Number Formatting ---
|
||||
|
||||
// NumberFormat defines locale-specific number formatting rules.
|
||||
//
|
||||
// fmt := i18n.NumberFormat{ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"}
|
||||
type NumberFormat struct {
|
||||
ThousandsSep string // "," for en, "." for de
|
||||
DecimalSep string // "." for en, "," for de
|
||||
|
|
@ -242,12 +278,18 @@ type NumberFormat struct {
|
|||
// --- Function Types ---
|
||||
|
||||
// PluralRule determines the plural category for a count.
|
||||
//
|
||||
// rule := i18n.GetPluralRule("en")
|
||||
type PluralRule func(n int) PluralCategory
|
||||
|
||||
// MissingKeyHandler receives missing key events.
|
||||
//
|
||||
// i18n.OnMissingKey(func(m i18n.MissingKey) {})
|
||||
type MissingKeyHandler func(missing MissingKey)
|
||||
|
||||
// MissingKey is dispatched when a translation key is not found in ModeCollect.
|
||||
//
|
||||
// func handle(m i18n.MissingKey) { _ = m.Key }
|
||||
type MissingKey struct {
|
||||
Key string
|
||||
Args map[string]any
|
||||
|
|
@ -259,18 +301,24 @@ type MissingKey struct {
|
|||
|
||||
// KeyHandler processes translation keys before standard lookup.
|
||||
// Handlers form a chain; each can handle a key or delegate to the next.
|
||||
//
|
||||
// i18n.AddHandler(i18n.LabelHandler{})
|
||||
type KeyHandler interface {
|
||||
Match(key string) bool
|
||||
Handle(key string, args []any, next func() string) string
|
||||
}
|
||||
|
||||
// Loader provides translation data to the Service.
|
||||
//
|
||||
// svc, err := i18n.NewWithLoader(loader)
|
||||
type Loader interface {
|
||||
Load(lang string) (map[string]Message, *GrammarData, error)
|
||||
Languages() []string
|
||||
}
|
||||
|
||||
// Translator defines the interface for translation services.
|
||||
//
|
||||
// var t i18n.Translator = i18n.Default()
|
||||
type Translator interface {
|
||||
T(messageID string, args ...any) string
|
||||
SetLanguage(lang string) error
|
||||
|
|
@ -320,6 +368,7 @@ var pluralRules = map[string]PluralRule{
|
|||
"ru": pluralRuleRussian, "ru-RU": pluralRuleRussian,
|
||||
"pl": pluralRulePolish, "pl-PL": pluralRulePolish,
|
||||
"ar": pluralRuleArabic, "ar-SA": pluralRuleArabic,
|
||||
"cy": pluralRuleWelsh, "cy-GB": pluralRuleWelsh,
|
||||
"zh": pluralRuleChinese, "zh-CN": pluralRuleChinese, "zh-TW": pluralRuleChinese,
|
||||
"ja": pluralRuleJapanese, "ja-JP": pluralRuleJapanese,
|
||||
"ko": pluralRuleKorean, "ko-KR": pluralRuleKorean,
|
||||
|
|
@ -385,7 +434,7 @@ var irregularVerbs = map[string]VerbForms{
|
|||
"rebel": {Past: "rebelled", Gerund: "rebelling"}, "excel": {Past: "excelled", Gerund: "excelling"},
|
||||
"cancel": {Past: "cancelled", Gerund: "cancelling"}, "travel": {Past: "travelled", Gerund: "travelling"},
|
||||
"label": {Past: "labelled", Gerund: "labelling"}, "model": {Past: "modelled", Gerund: "modelling"},
|
||||
"level": {Past: "levelled", Gerund: "levelling"},
|
||||
"level": {Past: "levelled", Gerund: "levelling"},
|
||||
"format": {Past: "formatted", Gerund: "formatting"},
|
||||
"analyse": {Past: "analysed", Gerund: "analysing"},
|
||||
"organise": {Past: "organised", Gerund: "organising"},
|
||||
|
|
@ -447,6 +496,57 @@ var irregularNouns = map[string]string{
|
|||
"calf": "calves", "loaf": "loaves", "thief": "thieves",
|
||||
}
|
||||
|
||||
// dualClassVerbs seeds additional regular verbs that are also common nouns in
|
||||
// dev/ops text. The forms are regular, but listing them here makes the
|
||||
// reversal tokeniser treat them as known bases for dual-class disambiguation.
|
||||
var dualClassVerbs = map[string]VerbForms{
|
||||
"change": {Past: "changed", Gerund: "changing"},
|
||||
"export": {Past: "exported", Gerund: "exporting"},
|
||||
"function": {Past: "functioned", Gerund: "functioning"},
|
||||
"handle": {Past: "handled", Gerund: "handling"},
|
||||
"host": {Past: "hosted", Gerund: "hosting"},
|
||||
"import": {Past: "imported", Gerund: "importing"},
|
||||
"link": {Past: "linked", Gerund: "linking"},
|
||||
"log": {Past: "logged", Gerund: "logging"},
|
||||
"merge": {Past: "merged", Gerund: "merging"},
|
||||
"patch": {Past: "patched", Gerund: "patching"},
|
||||
"process": {Past: "processed", Gerund: "processing"},
|
||||
"queue": {Past: "queued", Gerund: "queuing"},
|
||||
"release": {Past: "released", Gerund: "releasing"},
|
||||
"pull": {Past: "pulled", Gerund: "pulling"},
|
||||
"push": {Past: "pushed", Gerund: "pushing"},
|
||||
"stream": {Past: "streamed", Gerund: "streaming"},
|
||||
"tag": {Past: "tagged", Gerund: "tagging"},
|
||||
"trigger": {Past: "triggered", Gerund: "triggering"},
|
||||
"watch": {Past: "watched", Gerund: "watching"},
|
||||
"update": {Past: "updated", Gerund: "updating"},
|
||||
}
|
||||
|
||||
// dualClassNouns mirrors the same vocabulary as nouns so the tokeniser can
|
||||
// classify the base forms as ambiguous when they appear without inflection.
|
||||
var dualClassNouns = map[string]string{
|
||||
"change": "changes",
|
||||
"export": "exports",
|
||||
"function": "functions",
|
||||
"handle": "handles",
|
||||
"host": "hosts",
|
||||
"import": "imports",
|
||||
"link": "links",
|
||||
"log": "logs",
|
||||
"merge": "merges",
|
||||
"patch": "patches",
|
||||
"process": "processes",
|
||||
"queue": "queues",
|
||||
"release": "releases",
|
||||
"pull": "pulls",
|
||||
"push": "pushes",
|
||||
"stream": "streams",
|
||||
"tag": "tags",
|
||||
"trigger": "triggers",
|
||||
"watch": "watches",
|
||||
"update": "updates",
|
||||
}
|
||||
|
||||
var vowelSounds = map[string]bool{
|
||||
"hour": true, "honest": true, "honour": true, "honor": true, "heir": true, "herb": true,
|
||||
}
|
||||
|
|
|
|||
11
validate.go
11
validate.go
|
|
@ -44,6 +44,17 @@ type IrregularResult struct {
|
|||
|
||||
// articlePrompt builds a fill-in-the-blank prompt for article prediction.
|
||||
func articlePrompt(noun string) string {
|
||||
return articlePromptForLang(currentLangForGrammar(), noun)
|
||||
}
|
||||
|
||||
func articlePromptForLang(lang, noun string) string {
|
||||
noun = core.Trim(noun)
|
||||
if isFrenchLanguage(lang) {
|
||||
return core.Sprintf(
|
||||
"Complete with the correct article (le/la/l'/les/du/au/aux/un/une/des): ___ %s. Answer with just the article:",
|
||||
noun,
|
||||
)
|
||||
}
|
||||
return core.Sprintf(
|
||||
"Complete with the correct article (a/an/the): ___ %s. Answer with just the article:",
|
||||
noun,
|
||||
|
|
|
|||
|
|
@ -327,6 +327,30 @@ func TestArticlePrompt(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestArticlePromptFrenchLocale(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
prompt := articlePrompt("livre")
|
||||
if !contains(prompt, "livre") {
|
||||
t.Errorf("prompt should contain the noun: %q", prompt)
|
||||
}
|
||||
if !contains(prompt, "le/la/l'/les/du/au/aux/un/une/des") {
|
||||
t.Errorf("prompt should mention French article options: %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIrregularPrompt(t *testing.T) {
|
||||
prompt := irregularPrompt("swim", "past participle")
|
||||
if !contains(prompt, "'swim'") {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue