From 8dfe22f334638f7f825c5c0c2c69b6759ffd6c34 Mon Sep 17 00:00:00 2001 From: Christian Melgarejo Date: Wed, 25 Feb 2026 21:31:05 -0300 Subject: [PATCH 1/5] feat: string message keys and named template parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Message lookup: numeric codes → string keys (e.g. greeting.hello, error.not_found) - YAML set: map[string]RawMessage, optional per-entry code; removed Group - Templates: positional params → named ({{name}}, {{plural:count|...}}, {{num:amount}}, {{date:when}}) - API: GetMessageWithCtx(ctx, msgKey string, params Params); Params = map[string]interface{} - LoadMessages: RawMessage.Key required with sys. prefix; RuntimeKeyPrefix constant - Observer/stats: OnMessageMissing(lang, msgKey string), OnTemplateIssue(lang, msgKey string, issue string) - Tests, mocks, fuzz, bench, examples, README, CONTEXT7 docs updated Implements docs/CONVERSION_PLAN.md Made-with: Cursor --- README.md | 71 ++--- docs/CONTEXT7.md | 86 +++--- docs/CONTEXT7_RETRIEVAL.md | 68 ++--- docs/CONVERSION_PLAN.md | 265 ++++++++++++++++++ examples/http/main.go | 2 +- examples/metrics/main.go | 6 +- msgcat.go | 243 ++++++++-------- msgcat_bench_test.go | 31 +- msgcat_fuzz_test.go | 26 +- structs.go | 16 +- test/mock/msgcat.go | 39 +-- test/suites/msgcat/msgcat_test.go | 107 +++---- test/suites/msgcat/resources/messages/en.yaml | 23 +- test/suites/msgcat/resources/messages/es.yaml | 10 +- 14 files changed, 638 insertions(+), 355 deletions(-) create mode 100644 docs/CONVERSION_PLAN.md diff --git a/README.md b/README.md index db31cb7..d613e38 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `msgcat` is a lightweight i18n message catalog for Go focused on APIs and error handling. -It loads messages from YAML by language, resolves language from `context.Context`, supports runtime message loading for system codes (9000–9999), and can wrap domain errors with localized short/long messages. +It loads messages from YAML by language (string keys), resolves language from `context.Context`, supports runtime message loading with a reserved `sys.` key prefix, uses named template parameters, and can wrap domain errors with localized short/long messages. **Maturity:** production-ready (`v1.x`) with SemVer and release/migration docs in `docs/`. @@ -32,40 +32,40 @@ One YAML file per language (e.g. `en.yaml`, `es.yaml`). Structure: | Field | Description | |----------|-------------| -| `group` | Optional numeric group (e.g. `0`). | -| `default`| Used when a message code is missing: `short` and `long` templates. | -| `set` | Map of message code → `short` / `long` template strings. | +| `default`| Used when a message key is missing: `short` and `long` templates. | +| `set` | Map of string message key → entry with optional `code`, `short`, `long`. Keys use `[a-zA-Z0-9_.-]+` (e.g. `greeting.hello`, `error.not_found`). | + +Templates use **named parameters**: `{{name}}`, `{{plural:count\|singular\|plural}}`, `{{num:amount}}`, `{{date:when}}`. Example `en.yaml`: ```yaml -group: 0 default: short: Unexpected error - long: Unexpected message code [{{0}}] was received and was not found in this catalog + long: Unexpected message was received and was not found in this catalog set: - 1: + greeting.hello: + code: 1 short: User created - long: User {{0}} was created successfully - 2: - short: You have {{0}} {{plural:0|item|items}} - long: Total: {{num:1}} generated at {{date:2}} + long: User {{name}} was created successfully + items.count: + short: You have {{count}} {{plural:count|item|items}} + long: Total: {{num:amount}} generated at {{date:when}} ``` Example `es.yaml`: ```yaml -group: 0 default: short: Error inesperado - long: Se recibió un código de mensaje inesperado [{{0}}] y no se encontró en el catálogo + long: Se recibió un mensaje inesperado y no se encontró en el catálogo set: - 1: + greeting.hello: short: Usuario creado - long: Usuario {{0}} fue creado correctamente - 2: - short: Tienes {{0}} {{plural:0|elemento|elementos}} - long: Total: {{num:1}} generado el {{date:2}} + long: Usuario {{name}} fue creado correctamente + items.count: + short: Tienes {{count}} {{plural:count|elemento|elementos}} + long: Total: {{num:amount}} generado el {{date:when}} ``` ### 2. Initialize catalog @@ -92,19 +92,20 @@ if err != nil { ```go ctx := context.WithValue(context.Background(), "language", "es-AR") -msg := catalog.GetMessageWithCtx(ctx, 1, "juan") +msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", msgcat.Params{"name": "juan"}) fmt.Println(msg.ShortText) // "Usuario creado" fmt.Println(msg.LongText) // "Usuario juan fue creado correctamente" -fmt.Println(msg.Code) // 1 +fmt.Println(msg.Code) // 1 (from YAML optional `code`) -err := catalog.WrapErrorWithCtx(ctx, errors.New("db timeout"), 2, 3, 12345.5, time.Now()) +params := msgcat.Params{"count": 3, "amount": 12345.5, "when": time.Now()} +err := catalog.WrapErrorWithCtx(ctx, errors.New("db timeout"), "items.count", params) fmt.Println(err.Error()) // localized short message if catErr, ok := err.(msgcat.Error); ok { - fmt.Println(catErr.ErrorCode()) // 2 + fmt.Println(catErr.ErrorCode()) fmt.Println(catErr.GetShortMessage()) fmt.Println(catErr.GetLongMessage()) - fmt.Println(catErr.Unwrap()) // original "db timeout" + fmt.Println(catErr.Unwrap()) // original "db timeout" } ``` @@ -170,15 +171,16 @@ All fields of `msgcat.Config`: | Method | Description | |--------|-------------| -| `LoadMessages(lang string, messages []RawMessage) error` | Add or replace messages for a language. Only codes in 9000–9999 are allowed. | -| `GetMessageWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) *Message` | Resolve message for the context language; never nil. | -| `WrapErrorWithCtx(ctx context.Context, err error, msgCode int, msgParams ...interface{}) error` | Wrap an error with localized short/long text and message code. | -| `GetErrorWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) error` | Build an error with localized short/long text (no inner error). | +| `LoadMessages(lang string, messages []RawMessage) error` | Add or replace messages for a language. Each `RawMessage` must have `Key` with prefix `sys.` (e.g. `sys.alert`). | +| `GetMessageWithCtx(ctx context.Context, msgKey string, params Params) *Message` | Resolve message for the context language; never nil. `params` can be nil. | +| `WrapErrorWithCtx(ctx context.Context, err error, msgKey string, params Params) error` | Wrap an error with localized short/long text and message code. | +| `GetErrorWithCtx(ctx context.Context, msgKey string, params Params) error` | Build an error with localized short/long text (no inner error). | ### Types +- **`Params`** — `map[string]interface{}` for named template parameters (e.g. `msgcat.Params{"name": "juan"}`). - **`Message`** — `Code int`, `ShortText string`, `LongText string`. -- **`RawMessage`** — `ShortTpl`, `LongTpl` (YAML: `short`, `long`); used in YAML and `LoadMessages`. +- **`RawMessage`** — `Key` (required for `LoadMessages`), `ShortTpl`, `LongTpl` (YAML: `short`, `long`), optional `Code` (YAML: `code`). - **`msgcat.Error`** — `Error() string`, `Unwrap() error`, `ErrorCode() int`, `GetShortMessage() string`, `GetLongMessage() string`. ### Package-level helpers @@ -195,9 +197,8 @@ All fields of `msgcat.Config`: | Constant | Value | Description | |----------|--------|-------------| -| `SystemMessageMinCode` | 9000 | Min code for runtime-loaded system messages. | -| `SystemMessageMaxCode` | 9999 | Max code for runtime-loaded system messages. | -| `CodeMissingMessage` | 999999002 | Code used when a message is missing in the catalog. | +| `RuntimeKeyPrefix` | `"sys."` | Required prefix for message keys loaded via `LoadMessages`. | +| `CodeMissingMessage` | 999999002 | Code used when a message key is missing in the catalog. | | `CodeMissingLanguage` | 999999001 | Code used when the language is missing. | ## Observability @@ -211,8 +212,8 @@ type Observer struct{} func (Observer) OnLanguageFallback(requested, resolved string) {} func (Observer) OnLanguageMissing(lang string) {} -func (Observer) OnMessageMissing(lang string, msgCode int) {} -func (Observer) OnTemplateIssue(lang string, msgCode int, issue string) {} +func (Observer) OnMessageMissing(lang string, msgKey string) {} +func (Observer) OnTemplateIssue(lang string, msgKey string, issue string) {} ``` Callbacks are invoked **asynchronously** and are panic-protected. If the observer queue is full, events are dropped and counted in `MessageCatalogStats.DroppedEvents`. Call `msgcat.Close(catalog)` on shutdown when using an observer. @@ -223,8 +224,8 @@ Callbacks are invoked **asynchronously** and are panic-protected. If the observe |-------|-------------| | `LanguageFallbacks` | Counts per `"requested->resolved"` language fallback. | | `MissingLanguages` | Counts per missing language. | -| `MissingMessages` | Counts per `"lang:code"` missing message. | -| `TemplateIssues` | Counts per template issue key (e.g. `"lang:code:issue"`). | +| `MissingMessages` | Counts per `"lang:msgKey"` missing message. | +| `TemplateIssues` | Counts per template issue key (e.g. `"lang:msgKey:issue"`). | | `DroppedEvents` | Counts per drop reason (e.g. `observer_queue_full`, `observer_closed`). | | `LastReloadAt` | Time of last successful reload. | diff --git a/docs/CONTEXT7.md b/docs/CONTEXT7.md index e20f14c..cb2e233 100644 --- a/docs/CONTEXT7.md +++ b/docs/CONTEXT7.md @@ -21,11 +21,11 @@ Primary goals: ## 3. Main Concepts -- **Message catalog**: in-memory map of language -> message set loaded from YAML. -- **Message code**: integer key resolved per language. -- **Default message**: fallback template when code is missing for a language. +- **Message catalog**: in-memory map of language -> message set (keyed by string) loaded from YAML. +- **Message key**: string key (e.g. `greeting.hello`) resolved per language. +- **Default message**: fallback template when key is missing for a language. - **Language fallback chain**: requested language falls back through deterministic candidates. -- **Runtime system messages**: codes `9000-9999` can be injected from code. +- **Runtime system messages**: messages with key prefix `sys.` can be injected via `LoadMessages`. ## 4. YAML Format @@ -34,22 +34,23 @@ Each language file is named `.yaml`, for example `en.yaml`, `es.yaml`. ### Schema ```yaml -group: 0 default: short: string long: string set: - : + : # string key, e.g. greeting.hello, error.not_found + code: int # optional, for API/HTTP response short: string long: string ``` +Keys use `[a-zA-Z0-9_.-]+`. Templates use **named parameters**: `{{name}}`, `{{plural:count|singular|plural}}`, `{{num:amount}}`, `{{date:when}}`. + ### Validation rules -- `group >= 0` - `default.short` or `default.long` must be non-empty. - `set` can be omitted; it will be initialized empty. -- each code in `set` must be `> 0`. +- each key in `set` must be non-empty and match the key format. ## 5. Public Types @@ -93,13 +94,22 @@ type Message struct { } ``` +### `type Params` + +```go +type Params map[string]interface{} +``` + +Named template parameters. Use `msgcat.Params{"name": "juan", "count": 3}`. + ### `type RawMessage struct` ```go type RawMessage struct { LongTpl string `yaml:"long"` ShortTpl string `yaml:"short"` - Code int + Code int `yaml:"code"` + Key string `yaml:"-"` // required when using LoadMessages; must have prefix sys. } ``` @@ -122,8 +132,8 @@ type MessageCatalogStats struct { type Observer interface { OnLanguageFallback(requestedLang string, resolvedLang string) OnLanguageMissing(lang string) - OnMessageMissing(lang string, msgCode int) - OnTemplateIssue(lang string, msgCode int, issue string) + OnMessageMissing(lang string, msgKey string) + OnTemplateIssue(lang string, msgKey string, issue string) } ``` @@ -140,12 +150,14 @@ func NewMessageCatalog(cfg Config) (MessageCatalog, error) ```go type MessageCatalog interface { LoadMessages(lang string, messages []RawMessage) error - GetMessageWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) *Message - WrapErrorWithCtx(ctx context.Context, err error, msgCode int, msgParams ...interface{}) error - GetErrorWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) error + GetMessageWithCtx(ctx context.Context, msgKey string, params Params) *Message + WrapErrorWithCtx(ctx context.Context, err error, msgKey string, params Params) error + GetErrorWithCtx(ctx context.Context, msgKey string, params Params) error } ``` +For `LoadMessages`, each `RawMessage` must have `Key` set with prefix `RuntimeKeyPrefix` (`"sys."`). + ### Helper functions ```go @@ -163,10 +175,9 @@ Notes: ```go const ( - SystemMessageMinCode = 9000 - SystemMessageMaxCode = 9999 - CodeMissingMessage = 999999002 - CodeMissingLanguage = 999999001 + RuntimeKeyPrefix = "sys." + CodeMissingMessage = 999999002 + CodeMissingLanguage = 999999001 ) ``` @@ -188,33 +199,35 @@ If none found, response uses `CodeMissingLanguage` and `MessageCatalogNotFound`. ## 9. Template Engine -Supported tokens: +Supported tokens (all **named**): + +- Simple: `{{name}}` +- Plural: `{{plural:count|singular|plural}}` +- Number: `{{num:amount}}` +- Date: `{{date:when}}` -- Positional: `{{0}}`, `{{1}}`, ... -- Plural: `{{plural:i|singular|plural}}` -- Number: `{{num:i}}` -- Date: `{{date:i}}` +Parameter names use `[a-zA-Z_][a-zA-Z0-9_.]*`. Pass values via `Params` (e.g. `msgcat.Params{"name": "juan", "count": 3}`). Processing order: 1. plural 2. number 3. date -4. simple positional +4. simple ### Important limitation Plural branches are plain text. Do not nest other placeholders inside `plural` branches. Good: -- `"You have {{0}} {{plural:0|item|items}}"` +- `"You have {{count}} {{plural:count|item|items}}"` Avoid: -- `"You have {{plural:0|1 item|{{0}} items}}"` +- `"You have {{plural:count|1 item|{{count}} items}}"` ### Strict template behavior -When `StrictTemplates=true` and parameter index is missing: -- token is replaced with `` +When `StrictTemplates=true` and a parameter is missing: +- token is replaced with `` - observer/stats receives a template issue event When strict mode is off: @@ -223,11 +236,11 @@ When strict mode is off: ## 10. Number/Date Localization -`{{num:i}}`: +`{{num:name}}` (e.g. `{{num:amount}}`): - default style: `12,345.5` - for base languages `es`, `pt`, `fr`, `de`, `it`: `12.345,5` -`{{date:i}}`: +`{{date:name}}` (e.g. `{{date:when}}`): - default: `MM/DD/YYYY` - for base languages `es`, `pt`, `fr`, `de`, `it`: `DD/MM/YYYY` @@ -248,8 +261,8 @@ Accepted date params: ### Runtime loading `LoadMessages(lang, messages)`: -- only accepts codes in `[9000, 9999]` -- rejects duplicate code per language +- each `RawMessage` must have `Key` with prefix `sys.` (e.g. `sys.alert`) +- rejects duplicate key per language - stores messages in runtime set so they survive YAML reload ### Reload @@ -283,8 +296,8 @@ Race tests pass with `go test -race ./...`. `SnapshotStats` returns cumulative counters since catalog creation: - `LanguageFallbacks`: keyed as `"requested->resolved"` - `MissingLanguages`: keyed by requested language -- `MissingMessages`: keyed as `"lang:code"` -- `TemplateIssues`: keyed as `"lang:code:issue"` +- `MissingMessages`: keyed as `"lang:msgKey"` +- `TemplateIssues`: keyed as `"lang:msgKey:issue"` - `DroppedEvents`: internal drop counters (for example observer queue overflow) - `LastReloadAt`: timestamp set using `Config.NowFn` @@ -331,7 +344,8 @@ func main() { } ctx := context.WithValue(context.Background(), "language", "es-MX") - msg := catalog.GetMessageWithCtx(ctx, 2, 3, 12345.5, time.Now()) + params := msgcat.Params{"count": 3, "amount": 12345.5, "when": time.Now()} + msg := catalog.GetMessageWithCtx(ctx, "items.count", params) fmt.Println(msg.ShortText) fmt.Println(msg.LongText) @@ -345,7 +359,7 @@ func main() { ## 17. Compatibility and Caveats - Context key compatibility supports both typed key and plain string key. -- Missing code uses language default message and `CodeMissingMessage`. +- Missing message key uses language default message and `CodeMissingMessage`. - Missing language uses `MessageCatalogNotFound` and `CodeMissingLanguage`. - `NowFn` exists for future deterministic time-driven extensions; date formatting currently uses params directly. diff --git a/docs/CONTEXT7_RETRIEVAL.md b/docs/CONTEXT7_RETRIEVAL.md index d891d73..30ceded 100644 --- a/docs/CONTEXT7_RETRIEVAL.md +++ b/docs/CONTEXT7_RETRIEVAL.md @@ -11,9 +11,9 @@ Purpose: compact, chunk-friendly reference for LLM retrieval/indexing. - Load localized messages from YAML by language. - Resolve language from `context.Context`. - Fallback chain for missing regional/language variants. -- Render templates with positional/plural/number/date tokens. +- Render templates with named parameters (plural/number/date tokens). - Wrap errors with localized short/long text and code. -- Runtime reload + runtime-loaded system messages. +- Runtime reload + runtime-loaded messages (key prefix `sys.`). - Observability hooks + in-memory counters. - Concurrency-safe operations. @@ -45,20 +45,20 @@ Defaults: ## C04_YAML_SCHEMA ```yaml -group: 0 default: short: string long: string set: - : + : # e.g. greeting.hello, error.not_found + code: int # optional short: string long: string ``` +Keys: `[a-zA-Z0-9_.-]+`. Templates use named params: `{{name}}`, `{{plural:count|a|b}}`, `{{num:amount}}`, `{{date:when}}`. Validation: -- `group >= 0` - default short/long: at least one non-empty - `set` omitted => initialized empty -- each code in `set` must be `> 0` +- each key non-empty and valid format ## C05_LANGUAGE_RESOLUTION Input language normalization: @@ -77,31 +77,27 @@ If no language found: - text: `MessageCatalogNotFound` ## C06_TEMPLATE_TOKENS -Supported: -- `{{0}}`, `{{1}}`, ... positional -- `{{plural:i|singular|plural}}` -- `{{num:i}}` -- `{{date:i}}` - -Processing order: -1. plural -2. number -3. date -4. positional +Supported (named): +- `{{name}}` +- `{{plural:count|singular|plural}}` +- `{{num:amount}}` +- `{{date:when}}` + +Pass via `msgcat.Params{"name": value, ...}`. Processing order: plural, number, date, simple. Limitation: - plural branches are plain text (do not nest placeholders inside branches). Strict mode (`StrictTemplates=true`): -- missing param index => `` +- missing param => `` - template issue recorded in stats/observer. ## C07_LOCALIZATION_RULES -`{{num:i}}`: +`{{num:name}}`: - default: `12,345.5` - for base lang in `{es, pt, fr, de, it}`: `12.345,5` -`{{date:i}}`: +`{{date:name}}`: - default: `MM/DD/YYYY` - for base lang in `{es, pt, fr, de, it}`: `DD/MM/YYYY` @@ -111,13 +107,19 @@ Accepted date types: ## C08_PUBLIC_API ```go +type Params map[string]interface{} + type MessageCatalog interface { LoadMessages(lang string, messages []RawMessage) error - GetMessageWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) *Message - WrapErrorWithCtx(ctx context.Context, err error, msgCode int, msgParams ...interface{}) error - GetErrorWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) error + GetMessageWithCtx(ctx context.Context, msgKey string, params Params) *Message + WrapErrorWithCtx(ctx context.Context, err error, msgKey string, params Params) error + GetErrorWithCtx(ctx context.Context, msgKey string, params Params) error } +``` +LoadMessages: each RawMessage must have Key with prefix `sys.`. + +```go func NewMessageCatalog(cfg Config) (MessageCatalog, error) func Reload(catalog MessageCatalog) error func SnapshotStats(catalog MessageCatalog) (MessageCatalogStats, error) @@ -128,10 +130,9 @@ func Close(catalog MessageCatalog) error ## C09_CODES_AND_CONSTANTS ```go const ( - SystemMessageMinCode = 9000 - SystemMessageMaxCode = 9999 - CodeMissingMessage = 999999002 - CodeMissingLanguage = 999999001 + RuntimeKeyPrefix = "sys." + CodeMissingMessage = 999999002 + CodeMissingLanguage = 999999001 ) ``` @@ -141,7 +142,7 @@ Semantics: ## C10_RUNTIME_LOADING `LoadMessages(lang, messages)`: -- only allows codes in `[9000, 9999]` +- each RawMessage.Key must have prefix `sys.` - rejects duplicates per language - messages persist across `Reload` @@ -158,8 +159,8 @@ Observer hooks: type Observer interface { OnLanguageFallback(requestedLang, resolvedLang string) OnLanguageMissing(lang string) - OnMessageMissing(lang string, msgCode int) - OnTemplateIssue(lang string, msgCode int, issue string) + OnMessageMissing(lang string, msgKey string) + OnTemplateIssue(lang string, msgKey string, issue string) } ``` @@ -168,8 +169,8 @@ Snapshot counters: type MessageCatalogStats struct { LanguageFallbacks map[string]int // "requested->resolved" MissingLanguages map[string]int // "lang" - MissingMessages map[string]int // "lang:code" - TemplateIssues map[string]int // "lang:code:issue" + MissingMessages map[string]int // "lang:msgKey" + TemplateIssues map[string]int // "lang:msgKey:issue" DroppedEvents map[string]int // internal drop counters LastReloadAt time.Time } @@ -204,7 +205,8 @@ catalog, _ := msgcat.NewMessageCatalog(msgcat.Config{ }) ctx := context.WithValue(context.Background(), "language", "es-MX") -msg := catalog.GetMessageWithCtx(ctx, 2, 3, 12345.5, time.Now()) +params := msgcat.Params{"count": 3, "amount": 12345.5, "when": time.Now()} +msg := catalog.GetMessageWithCtx(ctx, "items.count", params) _ = msg.ShortText _ = msg.LongText diff --git a/docs/CONVERSION_PLAN.md b/docs/CONVERSION_PLAN.md new file mode 100644 index 0000000..a8a16ee --- /dev/null +++ b/docs/CONVERSION_PLAN.md @@ -0,0 +1,265 @@ +# Conversion Plan: String Keys + Named Parameters + +Plan for converting msgcat from **numeric message codes** and **positional template parameters** to **string message keys** and **named parameters**. No backward compatibility required. + +--- + +## 1. Summary of changes + +| Area | Current | Target | +|------|---------|--------| +| Message lookup key | `int` (e.g. `1`, `2`) | `string` (e.g. `"greeting.hello"`, `"error.not_found"`) | +| YAML `set` keys | Numeric: `1:`, `2:` | String: `"greeting.hello":`, `"error.template":` | +| Template params | Positional: `{{0}}`, `{{1}}`, `{{plural:0\|...}}` | Named: `{{name}}`, `{{plural:count\|...}}` | +| API params | `msgParams ...interface{}` (ordered) | `params Params` (`map[string]interface{}`) | +| Returned `Message.Code` | `msgCode + Group` | Per-entry `RawMessage.Code` (optional; 0 if unset) | +| `Messages.Group` | Used to compute display code | **Removed** (no longer needed) | +| Observer / stats | `msgCode int` | `msgKey string` | + +--- + +## 2. Type and struct changes + +### 2.1 `structs.go` + +- **Messages** + - `Set`: `map[int]RawMessage` → `map[string]RawMessage` + - **Remove** `Group int` (display code comes from `RawMessage.Code` per entry). + +- **RawMessage** + - Keep: `LongTpl`, `ShortTpl` (YAML: `long`, `short`). + - **Code**: keep `int`; meaning is “numeric code for API/HTTP response”. In YAML and `LoadMessages`, set per entry; if 0, returned `Message.Code` can be 0 when message is found, or use a sentinel when missing. + - No `Key` field: the map key is the message key. + +- **Message** (return type) + - Unchanged: `LongText`, `ShortText`, `Code int` (still the value to expose to API clients). + +- **Observer** + - `OnMessageMissing(lang string, msgCode int)` → `OnMessageMissing(lang string, msgKey string)` + - `OnTemplateIssue(lang string, msgCode int, issue string)` → `OnTemplateIssue(lang string, msgKey string, issue string)` + +- **Params type** (for named parameters) + - Add: `type Params map[string]interface{}` (replaces the unused `MessageParams` struct, or keep both and have API accept `Params`). + - Callers use: `msgcat.Params{"name": "juan", "count": 3}`. + +### 2.2 `msgcat.go` (internal types) + +- **DefaultMessageCatalog** + - `runtimeMessages`: `map[string]map[int]RawMessage` → `map[string]map[string]RawMessage`. + +- **observerEvent** + - `msgCode int` → `msgKey string`. + +- **catalogStats** + - Stat keys today: `"lang:code"` (e.g. `"en:2"`). Change to `"lang:msgKey"` (e.g. `"en:error.not_found"`). No type change; keys are already strings. + +- **MessageParams** + - Remove `MessageParams struct { Params map[string]interface{} }` and use `type Params = map[string]interface{}` (or keep a type alias only). + +--- + +## 3. YAML format + +### 3.1 Before (current) + +```yaml +group: 0 +default: + short: Unexpected error + long: Unexpected error was received and was not found in catalog +set: + 1: + short: Hello short description + long: Hello veeery long description. + 2: + short: Hello template {{0}}, this is nice {{1}} + long: Hello veeery long {{0}} description. Details {{1}}. + 4: + short: "You have {{0}} {{plural:0|item|items}}" + long: "Total: {{num:1}} generated at {{date:2}}" +``` + +### 3.2 After (target) + +```yaml +default: + short: Unexpected error + long: Unexpected error was received and was not found in catalog +set: + greeting.hello: + short: Hello short description + long: Hello veeery long description. + greeting.template: + code: 1002 # optional: numeric code for API/HTTP + short: Hello template {{name}}, this is nice {{detail}} + long: Hello veeery long {{name}} description. Details {{detail}}. + items.count: + short: "You have {{count}} {{plural:count|item|items}}" + long: "Total: {{num:amount}} generated at {{date:generatedAt}}" +``` + +- **Key format**: recommend `[a-zA-Z0-9_.-]+` (e.g. `greeting.hello`, `error.not_found`). Reject empty or invalid keys in validation. +- **Optional `code`**: if present, use as `Message.Code`; if absent, use 0 (or define a convention). + +--- + +## 4. Template syntax (named only) + +- **Simple**: `{{name}}` — identifier `[a-zA-Z_][a-zA-Z0-9_]*` (or allow dots: `{{user.name}}`). +- **Plural**: `{{plural:count|singular|plural}}` — same as today but first token is param name. +- **Number**: `{{num:amount}}` — param name instead of index. +- **Date**: `{{date:when}}` — param name instead of index. + +Regexes (conceptually): + +- Simple: `\{\{([a-zA-Z_][a-zA-Z0-9_.]*)\}\}` +- Plural: `\{\{plural:([a-zA-Z_][a-zA-Z0-9_.]*)\|([^|}]*)\|([^}]*)\}\}` +- Number: `\{\{num:([a-zA-Z_][a-zA-Z0-9_.]*)\}\}` +- Date: `\{\{date:([a-zA-Z_][a-zA-Z0-9_.]*)\}\}` + +Parsing: extract **name** (string) instead of **index** (int). In `renderTemplate`, resolve with `params[name]`. Missing param: same behavior as today (observer + optional strict placeholder like ``). + +--- + +## 5. Public API + +### 5.1 Catalog interface + +```go +type MessageCatalog interface { + LoadMessages(lang string, messages []RawMessage) error + GetMessageWithCtx(ctx context.Context, msgKey string, params Params) *Message + WrapErrorWithCtx(ctx context.Context, err error, msgKey string, params Params) error + GetErrorWithCtx(ctx context.Context, msgKey string, params Params) error +} +``` + +- `Params` is `map[string]interface{}` or `type Params map[string]interface{}`. Nil treated as empty (no params). +- All call sites: `catalog.GetMessageWithCtx(ctx, "greeting.hello", nil)` or `catalog.GetMessageWithCtx(ctx, "greeting.template", msgcat.Params{"name": "juan", "detail": "nice"})`. + +### 5.2 LoadMessages + +- **RawMessage** for runtime load must include the **key**; since the slice is not a map, each item needs an explicit key: + - Add **Key** to **RawMessage** (used only for `LoadMessages`; YAML key is the key when loading from file). + - So: `RawMessage` has `Key string` (optional in YAML/unmarshal; required when used in `LoadMessages`). + - Actually: in YAML the map key is the message key, so when unmarshaling we get key from the map. For `LoadMessages(lang, []RawMessage)` we need each RawMessage to carry its key. So add `Key string` to RawMessage. When loading from YAML, after unmarshal we have map[string]RawMessage — the key is in the map. When building from YAML we don’t need to set RawMessage.Key. When loading via LoadMessages we do: each RawMessage in the slice must have Key set (and optionally Code, ShortTpl, LongTpl). + - **Restriction for LoadMessages**: only allow keys with a reserved prefix (e.g. `sys.`) so YAML and runtime don’t collide. So: `Key` must be non-empty and for LoadMessages must start with `sys.` (or similar). + - Validation: `Key` format `[a-zA-Z0-9_.-]+`, and for LoadMessages `strings.HasPrefix(key, "sys.")`. + +--- + +## 6. Internal implementation outline + +### 6.1 Normalization and validation + +- **normalizeAndValidateMessages** + - Remove Group validation. + - Ensure `Set` is `map[string]RawMessage`. + - For each key in Set: validate key format (non-empty, allowed chars); set `raw.Code` from YAML `code` if present (else leave 0); no longer set `raw.Code = code` from numeric key. + +### 6.2 loadFromYaml merge + +- When merging runtime messages into `messageByLang[lang]`, iterate `runtimeSet` as `map[string]RawMessage` and assign `msgSet.Set[key] = msg` for each key. + +### 6.3 GetMessageWithCtx + +- Signature: `(ctx, msgKey string, params Params)`. +- Resolve language as today. +- Lookup: `langMsgSet.Set[msgKey]`; if missing, call `onMessageMissing(resolvedLang, msgKey)` and use default message, set `Message.Code = CodeMissingMessage`. +- If found: use `raw.ShortTpl`, `raw.LongTpl`, and `Message.Code = raw.Code` (0 if not set). +- Call `renderTemplate(resolvedLang, msgKey, shortMessage, params)` (and same for long); pass `params` as `map[string]interface{}`. + +### 6.4 renderTemplate + +- Signature: `(lang, msgKey string, template string, params map[string]interface{}) string`. +- Replace placeholders by **name**: for each token, parse name (string), then `v, ok := params[name]`; if !ok, report missing and use replaceMissing; else use value for plural/num/date/simple. +- All four regex passes use name-based lookup. + +### 6.5 Observer and stats + +- observerEvent: `msgKey string`. +- incrementMissingMessage(lang, msgKey string): key e.g. `lang + ":" + msgKey`. +- incrementTemplateIssue(lang, msgKey string, issue string): same. +- Observer interface: already updated in structs (OnMessageMissing(lang, msgKey string), OnTemplateIssue(lang, msgKey string, issue string)). + +### 6.6 LoadMessages + +- Require each RawMessage to have `Key` set and `Key` prefixed with `sys.` (or chosen prefix). +- Store: `langMsgSet.Set[msg.Key] = normalizedMessage`, `dmc.runtimeMessages[normalizedLang][msg.Key] = normalizedMessage`. +- No longer check numeric Code range; optional Code in RawMessage for API response. + +### 6.7 RawMessage.Key and YAML + +- When unmarshaling YAML, we have `map[string]RawMessage`. The key is the message key; we don’t need to put it inside RawMessage for YAML. So RawMessage.Key is only needed for **LoadMessages** (slice of RawMessage). So: + - In YAML, RawMessage does not need a `key` field; the map key is the key. + - In Go, when building RawMessage for LoadMessages, caller sets Key. So RawMessage has `Key string` (used when adding via LoadMessages; can be empty when coming from YAML). + +--- + +## 7. Constants and errors + +- **CodeMissingMessage**, **CodeMissingLanguage**: keep as int; still used as `Message.Code` when message or language is missing. +- **SystemMessageMinCode / MaxCode**: no longer used for LoadMessages; replace with “key must have prefix `sys.`” (or keep constant for doc purposes and use for nothing). +- **newCatalogError(code, ...)**: unchanged; still takes int code (from Message.Code). + +--- + +## 8. File-by-file checklist + +| File | Changes | +|------|--------| +| **structs.go** | Messages.Set → map[string]RawMessage; remove Group; RawMessage add Key (optional in YAML); Observer signatures; add Params type. | +| **msgcat.go** | Regexes for named placeholders; observerEvent.msgKey; catalogStats keys by msgKey; runtimeMessages map[string]map[string]RawMessage; normalizeAndValidateMessages (string keys, optional code); loadFromYaml merge by string key; renderTemplate(lang, msgKey, template, params map[string]interface{}); GetMessageWithCtx(ctx, msgKey string, params Params); WrapErrorWithCtx, GetErrorWithCtx; LoadMessages by RawMessage.Key with sys.* validation; remove MessageParams struct if replaced by Params. | +| **error.go** | No change (still int code). | +| **test/suites/msgcat/resources/messages/*.yaml** | String keys; named placeholders; remove group; optional code per entry. | +| **test/suites/msgcat/msgcat_test.go** | All GetMessageWithCtx(..., code, a, b, c) → GetMessageWithCtx(..., "key", Params{...}); LoadMessages with RawMessage{Key: "sys.xxx", ...}; Observer expectations with msgKey string. | +| **test/mock/msgcat.go** | Regenerate (or manually) interface with msgKey string, params Params. | +| **msgcat_fuzz_test.go** | Use string keys and Params. | +| **msgcat_bench_test.go** | Use string keys and Params. | +| **examples/** | Use string keys and Params. | +| **docs/** | Update CONTEXT7*.md, README, etc., with new API and YAML format. | + +--- + +## 9. Implementation order + +1. **Types (structs.go)** + Messages.Set string key, remove Group, RawMessage.Key + optional Code in YAML, Observer, Params. + +2. **Catalog storage and YAML (msgcat.go)** + DefaultMessageCatalog maps to string key; readMessagesFromYaml / normalizeAndValidateMessages for string keys and optional code; loadFromYaml merge by string key. + +3. **Named template engine (msgcat.go)** + New regexes; parse by name; renderTemplate(lang, msgKey, template, params map[string]interface{}). + +4. **Public API (msgcat.go)** + GetMessageWithCtx(ctx, msgKey string, params Params); Wire nil params; WrapErrorWithCtx / GetErrorWithCtx; use raw.Code for Message.Code. + +5. **LoadMessages (msgcat.go)** + RawMessage.Key required; validate sys.* (or chosen prefix); store by Key. + +6. **Observer and stats (msgcat.go)** + observerEvent.msgKey; stats keys lang:msgKey; Observer callbacks with msgKey. + +7. **Tests and examples** + Update YAML fixtures, tests, mocks, fuzz, bench, examples. + +8. **Docs** + README, CONTEXT7, CONVERSION_PLAN cross-links. + +--- + +## 10. Key naming convention (recommendation) + +- Use dot-separated segments: `domain.concept` (e.g. `greeting.hello`, `error.validation`, `order.status.pending`). +- For runtime-only messages: prefix `sys.` (e.g. `sys.overloaded`, `sys.maintenance`). +- Validation: allow `[a-zA-Z0-9_.-]+`, reject empty. + +--- + +## 11. Params type and nil + +- Define `type Params map[string]interface{}`. +- In GetMessageWithCtx / WrapErrorWithCtx / GetErrorWithCtx: if params is nil, pass empty map to renderTemplate so templates see no params (missing placeholders get missing-param behavior). + +This plan is the single source of truth for the total conversion to string keys and named parameters. diff --git a/examples/http/main.go b/examples/http/main.go index d226b8e..1246d6b 100644 --- a/examples/http/main.go +++ b/examples/http/main.go @@ -43,7 +43,7 @@ func main() { defer func() { _ = msgcat.Close(catalog) }() h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - msg := catalog.GetMessageWithCtx(r.Context(), 1, "user") + msg := catalog.GetMessageWithCtx(r.Context(), "greeting.hello", msgcat.Params{"name": "user"}) _, _ = w.Write([]byte(msg.ShortText)) }) diff --git a/examples/metrics/main.go b/examples/metrics/main.go index d0092cc..4bb4f94 100644 --- a/examples/metrics/main.go +++ b/examples/metrics/main.go @@ -30,10 +30,10 @@ func (o *expvarObserver) OnLanguageFallback(requestedLang string, resolvedLang s func (o *expvarObserver) OnLanguageMissing(lang string) { o.missingL.Add(lang, 1) } -func (o *expvarObserver) OnMessageMissing(lang string, msgCode int) { - o.missingM.Add(lang, 1) +func (o *expvarObserver) OnMessageMissing(lang string, msgKey string) { + o.missingM.Add(lang+":"+msgKey, 1) } -func (o *expvarObserver) OnTemplateIssue(lang string, msgCode int, issue string) { +func (o *expvarObserver) OnTemplateIssue(lang string, msgKey string, issue string) { o.tplIssues.Add(issue, 1) } diff --git a/msgcat.go b/msgcat.go index 2044653..a17fef4 100644 --- a/msgcat.go +++ b/msgcat.go @@ -18,26 +18,29 @@ import ( const MessageCatalogNotFound = "Unexpected error in message catalog, language [%s] not found. %s" const ( - SystemMessageMinCode = 9000 - SystemMessageMaxCode = 9999 - CodeMissingMessage = 999999002 - CodeMissingLanguage = 999999001 - overflowStatKey = "__overflow__" + // RuntimeKeyPrefix is required for message keys loaded via LoadMessages (e.g. "sys."). + RuntimeKeyPrefix = "sys." + CodeMissingMessage = 999999002 + CodeMissingLanguage = 999999001 + overflowStatKey = "__overflow__" ) +// messageKeyRegex validates message keys: [a-zA-Z0-9_.-]+ +var messageKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) + var ( - simplePlaceholderRegex = regexp.MustCompile(`\{\{(\d+)\}\}`) - pluralPlaceholderRegex = regexp.MustCompile(`\{\{plural:(\d+)\|([^|}]*)\|([^}]*)\}\}`) - numberPlaceholderRegex = regexp.MustCompile(`\{\{num:(\d+)\}\}`) - datePlaceholderRegex = regexp.MustCompile(`\{\{date:(\d+)\}\}`) + simplePlaceholderRegex = regexp.MustCompile(`\{\{([a-zA-Z_][a-zA-Z0-9_.]*)\}\}`) + pluralPlaceholderRegex = regexp.MustCompile(`\{\{plural:([a-zA-Z_][a-zA-Z0-9_.]*)\|([^|}]*)\|([^}]*)\}\}`) + numberPlaceholderRegex = regexp.MustCompile(`\{\{num:([a-zA-Z_][a-zA-Z0-9_.]*)\}\}`) + datePlaceholderRegex = regexp.MustCompile(`\{\{date:([a-zA-Z_][a-zA-Z0-9_.]*)\}\}`) ) type MessageCatalog interface { - // Allows to load more messages (9000 - 9999 - reserved to system messages) + // LoadMessages adds or replaces messages for a language. Keys must have prefix RuntimeKeyPrefix (e.g. "sys."). LoadMessages(lang string, messages []RawMessage) error - GetMessageWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) *Message - WrapErrorWithCtx(ctx context.Context, err error, msgCode int, msgParams ...interface{}) error - GetErrorWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) error + GetMessageWithCtx(ctx context.Context, msgKey string, params Params) *Message + WrapErrorWithCtx(ctx context.Context, err error, msgKey string, params Params) error + GetErrorWithCtx(ctx context.Context, msgKey string, params Params) error } type observerEventType int @@ -54,7 +57,7 @@ type observerEvent struct { requested string resolved string lang string - msgCode int + msgKey string templateIssue string } @@ -109,12 +112,12 @@ func (s *catalogStats) incrementMissingLanguage(lang string) { s.increment(s.missingLanguages, normalizeLangTag(lang)) } -func (s *catalogStats) incrementMissingMessage(lang string, msgCode int) { - s.increment(s.missingMessages, fmt.Sprintf("%s:%d", lang, msgCode)) +func (s *catalogStats) incrementMissingMessage(lang string, msgKey string) { + s.increment(s.missingMessages, fmt.Sprintf("%s:%s", lang, msgKey)) } -func (s *catalogStats) incrementTemplateIssue(lang string, msgCode int, issue string) { - s.increment(s.templateIssues, fmt.Sprintf("%s:%d:%s", lang, msgCode, issue)) +func (s *catalogStats) incrementTemplateIssue(lang string, msgKey string, issue string) { + s.increment(s.templateIssues, fmt.Sprintf("%s:%s:%s", lang, msgKey, issue)) } func (s *catalogStats) incrementDroppedEvent(reason string) { @@ -162,18 +165,14 @@ func (s *catalogStats) snapshot() MessageCatalogStats { type DefaultMessageCatalog struct { mu sync.RWMutex - messages map[string]Messages // language with messages indexed by id - runtimeMessages map[string]map[int]RawMessage + messages map[string]Messages // language -> messages (Set keyed by message key) + runtimeMessages map[string]map[string]RawMessage cfg Config stats catalogStats observerCh chan observerEvent observerDone chan struct{} } -type MessageParams struct { - Params map[string]interface{} -} - func (dmc *DefaultMessageCatalog) readMessagesFromYaml() (map[string]Messages, error) { resourcePath := dmc.cfg.ResourcePath if resourcePath == "" { @@ -248,13 +247,13 @@ func (dmc *DefaultMessageCatalog) loadFromYaml() error { for lang, runtimeSet := range dmc.runtimeMessages { msgSet, found := messageByLang[lang] if !found { - msgSet = Messages{Set: map[int]RawMessage{}} + msgSet = Messages{Set: map[string]RawMessage{}} } if msgSet.Set == nil { - msgSet.Set = map[int]RawMessage{} + msgSet.Set = map[string]RawMessage{} } - for code, msg := range runtimeSet { - msgSet.Set[code] = msg + for key, msg := range runtimeSet { + msgSet.Set[key] = msg } messageByLang[lang] = msgSet } @@ -266,21 +265,21 @@ func (dmc *DefaultMessageCatalog) loadFromYaml() error { } func normalizeAndValidateMessages(lang string, messages *Messages) error { - if messages.Group < 0 { - return fmt.Errorf("invalid message group for language %s: must be >= 0", lang) - } if messages.Default.ShortTpl == "" && messages.Default.LongTpl == "" { return fmt.Errorf("invalid default message for language %s: at least short or long text is required", lang) } if messages.Set == nil { - messages.Set = map[int]RawMessage{} + messages.Set = map[string]RawMessage{} } - for code, raw := range messages.Set { - if code <= 0 { - return fmt.Errorf("invalid message code %d for language %s: must be > 0", code, lang) + for key, raw := range messages.Set { + if key == "" { + return fmt.Errorf("invalid message key for language %s: key must be non-empty", lang) + } + if !messageKeyRegex.MatchString(key) { + return fmt.Errorf("invalid message key %q for language %s: must match [a-zA-Z0-9_.-]+", key, lang) } - raw.Code = code - messages.Set[code] = raw + // Code is optional; leave as-is from YAML + messages.Set[key] = raw } return nil @@ -341,35 +340,13 @@ func isPluralOne(value interface{}) (bool, bool) { } } -func parseTokenIndex(token string, prefix string) (int, bool) { - if !strings.HasPrefix(token, prefix) || !strings.HasSuffix(token, "}}") { - return 0, false - } - raw := strings.TrimSuffix(strings.TrimPrefix(token, prefix), "}}") - if raw == "" { - return 0, false - } - idx, err := strconv.Atoi(raw) - if err != nil || idx < 0 { - return 0, false - } - return idx, true -} - -func parsePluralToken(token string) (idx int, singular string, plural string, ok bool) { - if !strings.HasPrefix(token, "{{plural:") || !strings.HasSuffix(token, "}}") { - return 0, "", "", false - } - raw := strings.TrimSuffix(strings.TrimPrefix(token, "{{plural:"), "}}") - parts := strings.SplitN(raw, "|", 3) - if len(parts) != 3 { - return 0, "", "", false - } - parsedIdx, err := strconv.Atoi(parts[0]) - if err != nil || parsedIdx < 0 { - return 0, "", "", false +// parsePluralTokenNamed extracts param name, singular and plural from {{plural:name|singular|plural}}. +func parsePluralTokenNamed(token string) (paramName string, singular string, plural string, ok bool) { + matches := pluralPlaceholderRegex.FindStringSubmatch(token) + if len(matches) != 4 { + return "", "", "", false } - return parsedIdx, parts[1], parts[2], true + return matches[1], matches[2], matches[3], true } func toString(value interface{}) string { @@ -556,11 +533,11 @@ func (dmc *DefaultMessageCatalog) startObserverWorker() { }) case observerEventMessageMissing: safeObserverCall(func() { - dmc.cfg.Observer.OnMessageMissing(evt.lang, evt.msgCode) + dmc.cfg.Observer.OnMessageMissing(evt.lang, evt.msgKey) }) case observerEventTemplateIssue: safeObserverCall(func() { - dmc.cfg.Observer.OnTemplateIssue(evt.lang, evt.msgCode, evt.templateIssue) + dmc.cfg.Observer.OnTemplateIssue(evt.lang, evt.msgKey, evt.templateIssue) }) } } @@ -610,21 +587,21 @@ func (dmc *DefaultMessageCatalog) onLanguageMissing(lang string) { }) } -func (dmc *DefaultMessageCatalog) onMessageMissing(lang string, msgCode int) { - dmc.stats.incrementMissingMessage(lang, msgCode) +func (dmc *DefaultMessageCatalog) onMessageMissing(lang string, msgKey string) { + dmc.stats.incrementMissingMessage(lang, msgKey) dmc.publishObserverEvent(observerEvent{ - kind: observerEventMessageMissing, - lang: lang, - msgCode: msgCode, + kind: observerEventMessageMissing, + lang: lang, + msgKey: msgKey, }) } -func (dmc *DefaultMessageCatalog) onTemplateIssue(lang string, msgCode int, issue string) { - dmc.stats.incrementTemplateIssue(lang, msgCode, issue) +func (dmc *DefaultMessageCatalog) onTemplateIssue(lang string, msgKey string, issue string) { + dmc.stats.incrementTemplateIssue(lang, msgKey, issue) dmc.publishObserverEvent(observerEvent{ kind: observerEventTemplateIssue, lang: lang, - msgCode: msgCode, + msgKey: msgKey, templateIssue: issue, }) } @@ -676,30 +653,38 @@ func (dmc *DefaultMessageCatalog) resolveLanguage(requestedLang string) (string, return normalizedRequested, false, false } -func (dmc *DefaultMessageCatalog) renderTemplate(lang string, msgCode int, template string, params []interface{}) string { +func (dmc *DefaultMessageCatalog) renderTemplate(lang string, msgKey string, template string, params map[string]interface{}) string { if !strings.Contains(template, "{{") { return template } + if params == nil { + params = map[string]interface{}{} + } rendered := template - replaceMissing := func(issue string, originalToken string, idx int) string { - dmc.onTemplateIssue(lang, msgCode, issue) + replaceMissing := func(issue string, originalToken string, paramName string) string { + dmc.onTemplateIssue(lang, msgKey, issue) if dmc.cfg.StrictTemplates { - return "" + return "" } return originalToken } + getParam := func(name string) (interface{}, bool) { + v, ok := params[name] + return v, ok + } rendered = pluralPlaceholderRegex.ReplaceAllStringFunc(rendered, func(token string) string { - idx, singular, plural, ok := parsePluralToken(token) + paramName, singular, plural, ok := parsePluralTokenNamed(token) if !ok { return token } - if idx >= len(params) { - return replaceMissing("plural_missing_param_"+strconv.Itoa(idx), token, idx) + val, ok := getParam(paramName) + if !ok { + return replaceMissing("plural_missing_param_"+paramName, token, paramName) } - isOne, ok := isPluralOne(params[idx]) + isOne, ok := isPluralOne(val) if !ok { - dmc.onTemplateIssue(lang, msgCode, "plural_invalid_param_"+strconv.Itoa(idx)) + dmc.onTemplateIssue(lang, msgKey, "plural_invalid_param_"+paramName) return token } if isOne { @@ -709,46 +694,52 @@ func (dmc *DefaultMessageCatalog) renderTemplate(lang string, msgCode int, templ }) rendered = numberPlaceholderRegex.ReplaceAllStringFunc(rendered, func(token string) string { - idx, ok := parseTokenIndex(token, "{{num:") - if !ok { + matches := numberPlaceholderRegex.FindStringSubmatch(token) + if len(matches) != 2 { return token } - if idx >= len(params) { - return replaceMissing("number_missing_param_"+strconv.Itoa(idx), token, idx) + paramName := matches[1] + val, ok := getParam(paramName) + if !ok { + return replaceMissing("number_missing_param_"+paramName, token, paramName) } - formatted, ok := formatNumberByLang(lang, params[idx]) + formatted, ok := formatNumberByLang(lang, val) if !ok { - dmc.onTemplateIssue(lang, msgCode, "number_invalid_param_"+strconv.Itoa(idx)) + dmc.onTemplateIssue(lang, msgKey, "number_invalid_param_"+paramName) return token } return formatted }) rendered = datePlaceholderRegex.ReplaceAllStringFunc(rendered, func(token string) string { - idx, ok := parseTokenIndex(token, "{{date:") - if !ok { + matches := datePlaceholderRegex.FindStringSubmatch(token) + if len(matches) != 2 { return token } - if idx >= len(params) { - return replaceMissing("date_missing_param_"+strconv.Itoa(idx), token, idx) + paramName := matches[1] + val, ok := getParam(paramName) + if !ok { + return replaceMissing("date_missing_param_"+paramName, token, paramName) } - formatted, ok := formatDateByLang(lang, params[idx]) + formatted, ok := formatDateByLang(lang, val) if !ok { - dmc.onTemplateIssue(lang, msgCode, "date_invalid_param_"+strconv.Itoa(idx)) + dmc.onTemplateIssue(lang, msgKey, "date_invalid_param_"+paramName) return token } return formatted }) rendered = simplePlaceholderRegex.ReplaceAllStringFunc(rendered, func(token string) string { - idx, ok := parseTokenIndex(token, "{{") - if !ok { + matches := simplePlaceholderRegex.FindStringSubmatch(token) + if len(matches) != 2 { return token } - if idx >= len(params) { - return replaceMissing("simple_missing_param_"+strconv.Itoa(idx), token, idx) + paramName := matches[1] + val, ok := getParam(paramName) + if !ok { + return replaceMissing("simple_missing_param_"+paramName, token, paramName) } - return toString(params[idx]) + return toString(val) }) return rendered @@ -767,43 +758,50 @@ func (dmc *DefaultMessageCatalog) LoadMessages(lang string, messages []RawMessag dmc.messages = map[string]Messages{} } if dmc.runtimeMessages == nil { - dmc.runtimeMessages = map[string]map[int]RawMessage{} + dmc.runtimeMessages = map[string]map[string]RawMessage{} } if _, foundLangMsg := dmc.messages[normalizedLang]; !foundLangMsg { dmc.messages[normalizedLang] = Messages{ - Set: map[int]RawMessage{}, + Set: map[string]RawMessage{}, } } if _, foundRuntimeLang := dmc.runtimeMessages[normalizedLang]; !foundRuntimeLang { - dmc.runtimeMessages[normalizedLang] = map[int]RawMessage{} + dmc.runtimeMessages[normalizedLang] = map[string]RawMessage{} } langMsgSet := dmc.messages[normalizedLang] if langMsgSet.Set == nil { - langMsgSet.Set = map[int]RawMessage{} + langMsgSet.Set = map[string]RawMessage{} } for _, message := range messages { - if message.Code < SystemMessageMinCode || message.Code > SystemMessageMaxCode { - return fmt.Errorf("application messages should be loaded using YAML file, allowed range only between %d and %d", SystemMessageMinCode, SystemMessageMaxCode) + key := message.Key + if key == "" { + return fmt.Errorf("LoadMessages: message key is required") } - if _, foundMsg := langMsgSet.Set[message.Code]; foundMsg { - return fmt.Errorf("message with %d already exists in message set for language %s", message.Code, normalizedLang) + if !strings.HasPrefix(key, RuntimeKeyPrefix) { + return fmt.Errorf("LoadMessages: key %q must have prefix %q", key, RuntimeKeyPrefix) + } + if !messageKeyRegex.MatchString(key) { + return fmt.Errorf("LoadMessages: invalid key %q", key) + } + if _, foundMsg := langMsgSet.Set[key]; foundMsg { + return fmt.Errorf("message with key %q already exists in message set for language %s", key, normalizedLang) } normalizedMessage := RawMessage{ LongTpl: message.LongTpl, ShortTpl: message.ShortTpl, Code: message.Code, } - langMsgSet.Set[message.Code] = normalizedMessage - dmc.runtimeMessages[normalizedLang][message.Code] = normalizedMessage + langMsgSet.Set[key] = normalizedMessage + dmc.runtimeMessages[normalizedLang][key] = normalizedMessage } dmc.messages[normalizedLang] = langMsgSet return nil } -func (dmc *DefaultMessageCatalog) GetMessageWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) *Message { +func (dmc *DefaultMessageCatalog) GetMessageWithCtx(ctx context.Context, msgKey string, params Params) *Message { requestedLang := dmc.resolveRequestedLang(ctx) resolvedLang, foundLangMsg, usedFallback := dmc.resolveLanguage(requestedLang) if !foundLangMsg { @@ -834,21 +832,21 @@ func (dmc *DefaultMessageCatalog) GetMessageWithCtx(ctx context.Context, msgCode longMessage := langMsgSet.Default.LongTpl code := CodeMissingMessage missingMessage := false - if msg, foundMsg := langMsgSet.Set[msgCode]; foundMsg { + if msg, foundMsg := langMsgSet.Set[msgKey]; foundMsg { shortMessage = msg.ShortTpl longMessage = msg.LongTpl - code = msgCode + langMsgSet.Group + code = msg.Code } else { missingMessage = true - msgParams = []interface{}{msgCode} } dmc.mu.RUnlock() if missingMessage { - dmc.onMessageMissing(resolvedLang, msgCode) + dmc.onMessageMissing(resolvedLang, msgKey) } - shortMessage = dmc.renderTemplate(resolvedLang, msgCode, shortMessage, msgParams) - longMessage = dmc.renderTemplate(resolvedLang, msgCode, longMessage, msgParams) + paramMap := map[string]interface{}(params) + shortMessage = dmc.renderTemplate(resolvedLang, msgKey, shortMessage, paramMap) + longMessage = dmc.renderTemplate(resolvedLang, msgKey, longMessage, paramMap) return &Message{ LongText: longMessage, ShortText: shortMessage, @@ -856,14 +854,13 @@ func (dmc *DefaultMessageCatalog) GetMessageWithCtx(ctx context.Context, msgCode } } -func (dmc *DefaultMessageCatalog) WrapErrorWithCtx(ctx context.Context, err error, msgCode int, msgParams ...interface{}) error { - message := dmc.GetMessageWithCtx(ctx, msgCode, msgParams...) - +func (dmc *DefaultMessageCatalog) WrapErrorWithCtx(ctx context.Context, err error, msgKey string, params Params) error { + message := dmc.GetMessageWithCtx(ctx, msgKey, params) return newCatalogError(message.Code, message.ShortText, message.LongText, err) } -func (dmc *DefaultMessageCatalog) GetErrorWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) error { - return dmc.WrapErrorWithCtx(ctx, nil, msgCode, msgParams...) +func (dmc *DefaultMessageCatalog) GetErrorWithCtx(ctx context.Context, msgKey string, params Params) error { + return dmc.WrapErrorWithCtx(ctx, nil, msgKey, params) } func (dmc *DefaultMessageCatalog) Reload() error { diff --git a/msgcat_bench_test.go b/msgcat_bench_test.go index 9a084c6..cb464b6 100644 --- a/msgcat_bench_test.go +++ b/msgcat_bench_test.go @@ -18,7 +18,7 @@ func makeBenchCatalog(b *testing.B) msgcat.MessageCatalog { } b.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) - en := []byte("group: 0\ndefault:\n short: Unexpected error\n long: Unexpected message code [{{0}}]\nset:\n 1:\n short: Hello {{0}}\n long: Number {{num:1}} at {{date:2}}\n") + en := []byte("default:\n short: Unexpected error\n long: Unexpected message code [{{key}}]\nset:\n greeting.hello:\n short: Hello {{name}}\n long: Number {{num:amount}} at {{date:when}}\n") if err := os.WriteFile(filepath.Join(tmpDir, "en.yaml"), en, 0o600); err != nil { b.Fatalf("failed to write fixture: %v", err) } @@ -36,18 +36,19 @@ type noopObserver struct{} func (noopObserver) OnLanguageFallback(requestedLang string, resolvedLang string) {} func (noopObserver) OnLanguageMissing(lang string) {} -func (noopObserver) OnMessageMissing(lang string, msgCode int) {} -func (noopObserver) OnTemplateIssue(lang string, msgCode int, issue string) {} +func (noopObserver) OnMessageMissing(lang string, msgKey string) {} +func (noopObserver) OnTemplateIssue(lang string, msgKey string, issue string) {} func BenchmarkGetMessageWithCtx(b *testing.B) { catalog := makeBenchCatalog(b) ctx := context.WithValue(context.Background(), "language", "en") date := time.Date(2026, time.January, 5, 12, 0, 0, 0, time.UTC) + params := msgcat.Params{"name": "world", "amount": 12345.67, "when": date} b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - _ = catalog.GetMessageWithCtx(ctx, 1, "world", 12345.67, date) + _ = catalog.GetMessageWithCtx(ctx, "greeting.hello", params) } } @@ -55,11 +56,12 @@ func BenchmarkGetErrorWithCtx(b *testing.B) { catalog := makeBenchCatalog(b) ctx := context.WithValue(context.Background(), "language", "en") date := time.Date(2026, time.January, 5, 12, 0, 0, 0, time.UTC) + params := msgcat.Params{"name": "world", "amount": 12345.67, "when": date} b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - _ = catalog.GetErrorWithCtx(ctx, 1, "world", 12345.67, date) + _ = catalog.GetErrorWithCtx(ctx, "greeting.hello", params) } } @@ -69,7 +71,7 @@ func BenchmarkGetMessageWithCtxStrictOff(b *testing.B) { b.Fatalf("failed to create temp dir: %v", err) } b.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) - en := []byte("group: 0\ndefault:\n short: Unexpected error\n long: Unexpected message code [{{0}}]\nset:\n 1:\n short: Hello {{0}}\n long: Number {{num:1}} at {{date:2}}\n") + en := []byte("default:\n short: Unexpected error\n long: Unexpected message code [{{key}}]\nset:\n greeting.hello:\n short: Hello {{name}}\n long: Number {{num:amount}} at {{date:when}}\n") if err := os.WriteFile(filepath.Join(tmpDir, "en.yaml"), en, 0o600); err != nil { b.Fatalf("failed to write fixture: %v", err) } @@ -81,10 +83,11 @@ func BenchmarkGetMessageWithCtxStrictOff(b *testing.B) { ctx := context.WithValue(context.Background(), "language", "en") date := time.Date(2026, time.January, 5, 12, 0, 0, 0, time.UTC) + params := msgcat.Params{"name": "world", "amount": 12345.67, "when": date} b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - _ = catalog.GetMessageWithCtx(ctx, 1, "world", 12345.67, date) + _ = catalog.GetMessageWithCtx(ctx, "greeting.hello", params) } } @@ -94,7 +97,7 @@ func BenchmarkGetMessageWithCtxStrictOn(b *testing.B) { b.Fatalf("failed to create temp dir: %v", err) } b.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) - en := []byte("group: 0\ndefault:\n short: Unexpected error\n long: Unexpected message code [{{0}}]\nset:\n 1:\n short: Hello {{0}}\n long: Number {{num:1}} at {{date:2}}\n") + en := []byte("default:\n short: Unexpected error\n long: Unexpected message code [{{key}}]\nset:\n greeting.hello:\n short: Hello {{name}}\n long: Number {{num:amount}} at {{date:when}}\n") if err := os.WriteFile(filepath.Join(tmpDir, "en.yaml"), en, 0o600); err != nil { b.Fatalf("failed to write fixture: %v", err) } @@ -106,10 +109,11 @@ func BenchmarkGetMessageWithCtxStrictOn(b *testing.B) { ctx := context.WithValue(context.Background(), "language", "en") date := time.Date(2026, time.January, 5, 12, 0, 0, 0, time.UTC) + params := msgcat.Params{"name": "world", "amount": 12345.67, "when": date} b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - _ = catalog.GetMessageWithCtx(ctx, 1, "world", 12345.67, date) + _ = catalog.GetMessageWithCtx(ctx, "greeting.hello", params) } } @@ -119,7 +123,7 @@ func BenchmarkGetMessageWithCtxFallbackChain(b *testing.B) { b.Fatalf("failed to create temp dir: %v", err) } b.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) - es := []byte("group: 0\ndefault:\n short: Error inesperado\n long: Error {{0}}\nset:\n 1:\n short: Hola {{0}}\n") + es := []byte("default:\n short: Error inesperado\n long: Error {{key}}\nset:\n greeting.hello:\n short: Hola {{name}}\n") if err := os.WriteFile(filepath.Join(tmpDir, "es.yaml"), es, 0o600); err != nil { b.Fatalf("failed to write fixture: %v", err) } @@ -134,10 +138,11 @@ func BenchmarkGetMessageWithCtxFallbackChain(b *testing.B) { b.Cleanup(func() { _ = msgcat.Close(catalog) }) ctx := context.WithValue(context.Background(), "language", "es-MX") + params := msgcat.Params{"name": "world"} b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - _ = catalog.GetMessageWithCtx(ctx, 1, "world") + _ = catalog.GetMessageWithCtx(ctx, "greeting.hello", params) } } @@ -147,7 +152,7 @@ func BenchmarkGetMessageWithCtxObserverEnabled(b *testing.B) { b.Fatalf("failed to create temp dir: %v", err) } b.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) - en := []byte("group: 0\ndefault:\n short: Unexpected error\n long: Unexpected message code [{{0}}]\nset:\n 1:\n short: Hello {{0}}\n") + en := []byte("default:\n short: Unexpected error\n long: Unexpected message code [{{key}}]\nset:\n greeting.hello:\n short: Hello {{name}}\n") if err := os.WriteFile(filepath.Join(tmpDir, "en.yaml"), en, 0o600); err != nil { b.Fatalf("failed to write fixture: %v", err) } @@ -165,6 +170,6 @@ func BenchmarkGetMessageWithCtxObserverEnabled(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - _ = catalog.GetMessageWithCtx(ctx, 404) + _ = catalog.GetMessageWithCtx(ctx, "missing.key", nil) } } diff --git a/msgcat_fuzz_test.go b/msgcat_fuzz_test.go index fb8b39e..93ff8c7 100644 --- a/msgcat_fuzz_test.go +++ b/msgcat_fuzz_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" @@ -18,7 +19,7 @@ func buildFuzzCatalog(t *testing.T) msgcat.MessageCatalog { } t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) - en := []byte("group: 0\ndefault:\n short: Unexpected error\n long: Unexpected code {{0}}\nset:\n 1:\n short: msg {{0}}\n long: count {{num:1}} at {{date:2}} {{plural:3|item|items}}\n") + en := []byte("default:\n short: Unexpected error\n long: Unexpected code {{key}}\nset:\n greeting.hello:\n short: msg {{name}}\n long: count {{num:amount}} at {{date:when}} {{plural:count|item|items}}\n") if err := os.WriteFile(filepath.Join(tmpDir, "en.yaml"), en, 0o600); err != nil { t.Fatalf("failed to write fixture: %v", err) } @@ -36,29 +37,30 @@ func buildFuzzCatalog(t *testing.T) msgcat.MessageCatalog { } func FuzzGetMessageWithCtx(f *testing.F) { - f.Add("en", 1, "abc", float64(12.5), int(1)) - f.Add("es-MX", 404, "xyz", float64(-1000.25), int(2)) - f.Add("", 2, "", float64(0), int(0)) + f.Add("en", "greeting.hello", "abc", float64(12.5), int(1)) + f.Add("es-MX", "missing.key", "xyz", float64(-1000.25), int(2)) + f.Add("", "greeting.template", "", float64(0), int(0)) - f.Fuzz(func(t *testing.T, lang string, code int, name string, n float64, count int) { + f.Fuzz(func(t *testing.T, lang string, msgKey string, name string, n float64, count int) { catalog := buildFuzzCatalog(t) ctx := context.WithValue(context.Background(), "language", lang) - _ = catalog.GetMessageWithCtx(ctx, code, name, n, time.Now(), count) + params := msgcat.Params{"name": name, "amount": n, "when": time.Now(), "count": count, "key": msgKey} + _ = catalog.GetMessageWithCtx(ctx, msgKey, params) }) } func FuzzLoadMessages(f *testing.F) { - f.Add("en", 9001, "short", "long") - f.Add("pt-BR", 9002, "a", "b") - f.Add(" es ", 9999, "x", "y") + f.Add("en", "sys.fuzz_9001", "short", "long") + f.Add("pt-BR", "sys.fuzz_9002", "a", "b") + f.Add(" es ", "sys.fuzz_9999", "x", "y") - f.Fuzz(func(t *testing.T, lang string, code int, shortTpl string, longTpl string) { + f.Fuzz(func(t *testing.T, lang string, key string, shortTpl string, longTpl string) { catalog := buildFuzzCatalog(t) - if code < 9000 || code > 9999 { + if !strings.HasPrefix(key, msgcat.RuntimeKeyPrefix) { return } _ = catalog.LoadMessages(lang, []msgcat.RawMessage{{ - Code: code, + Key: key, ShortTpl: shortTpl, LongTpl: longTpl, }}) diff --git a/structs.go b/structs.go index 0611bce..fe8757b 100644 --- a/structs.go +++ b/structs.go @@ -4,16 +4,20 @@ import "time" type ContextKey string +// Params is the type for named template parameters. Use msgcat.Params{"name": value}. +type Params map[string]interface{} + type Messages struct { - Group int `yaml:"group"` - Default RawMessage `yaml:"default"` - Set map[int]RawMessage `yaml:"set"` + Default RawMessage `yaml:"default"` + Set map[string]RawMessage `yaml:"set"` } type RawMessage struct { LongTpl string `yaml:"long"` ShortTpl string `yaml:"short"` - Code int + Code int `yaml:"code"` + // Key is set when loading via LoadMessages (runtime); YAML uses the map key as the message key. + Key string `yaml:"-"` } type Message struct { @@ -34,8 +38,8 @@ type MessageCatalogStats struct { type Observer interface { OnLanguageFallback(requestedLang string, resolvedLang string) OnLanguageMissing(lang string) - OnMessageMissing(lang string, msgCode int) - OnTemplateIssue(lang string, msgCode int, issue string) + OnMessageMissing(lang string, msgKey string) + OnTemplateIssue(lang string, msgKey string, issue string) } type Config struct { diff --git a/test/mock/msgcat.go b/test/mock/msgcat.go index 4749a8d..4899173 100644 --- a/test/mock/msgcat.go +++ b/test/mock/msgcat.go @@ -49,58 +49,43 @@ func (mr *MockMessageCatalogMockRecorder) LoadMessages(lang, messages interface{ } // GetMessageWithCtx mocks base method -func (m *MockMessageCatalog) GetMessageWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) *msgcat.Message { +func (m *MockMessageCatalog) GetMessageWithCtx(ctx context.Context, msgKey string, params msgcat.Params) *msgcat.Message { m.ctrl.T.Helper() - varargs := []interface{}{ctx, msgCode} - for _, a := range msgParams { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "GetMessageWithCtx", varargs...) + ret := m.ctrl.Call(m, "GetMessageWithCtx", ctx, msgKey, params) ret0, _ := ret[0].(*msgcat.Message) return ret0 } // GetMessageWithCtx indicates an expected call of GetMessageWithCtx -func (mr *MockMessageCatalogMockRecorder) GetMessageWithCtx(ctx, msgCode interface{}, msgParams ...interface{}) *gomock.Call { +func (mr *MockMessageCatalogMockRecorder) GetMessageWithCtx(ctx, msgKey, params interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, msgCode}, msgParams...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMessageWithCtx", reflect.TypeOf((*MockMessageCatalog)(nil).GetMessageWithCtx), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMessageWithCtx", reflect.TypeOf((*MockMessageCatalog)(nil).GetMessageWithCtx), ctx, msgKey, params) } // WrapErrorWithCtx mocks base method -func (m *MockMessageCatalog) WrapErrorWithCtx(ctx context.Context, err error, msgCode int, msgParams ...interface{}) error { +func (m *MockMessageCatalog) WrapErrorWithCtx(ctx context.Context, err error, msgKey string, params msgcat.Params) error { m.ctrl.T.Helper() - varargs := []interface{}{ctx, err, msgCode} - for _, a := range msgParams { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "WrapErrorWithCtx", varargs...) + ret := m.ctrl.Call(m, "WrapErrorWithCtx", ctx, err, msgKey, params) ret0, _ := ret[0].(error) return ret0 } // WrapErrorWithCtx indicates an expected call of WrapErrorWithCtx -func (mr *MockMessageCatalogMockRecorder) WrapErrorWithCtx(ctx, err, msgCode interface{}, msgParams ...interface{}) *gomock.Call { +func (mr *MockMessageCatalogMockRecorder) WrapErrorWithCtx(ctx, err, msgKey, params interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, err, msgCode}, msgParams...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WrapErrorWithCtx", reflect.TypeOf((*MockMessageCatalog)(nil).WrapErrorWithCtx), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WrapErrorWithCtx", reflect.TypeOf((*MockMessageCatalog)(nil).WrapErrorWithCtx), ctx, err, msgKey, params) } // GetErrorWithCtx mocks base method -func (m *MockMessageCatalog) GetErrorWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) error { +func (m *MockMessageCatalog) GetErrorWithCtx(ctx context.Context, msgKey string, params msgcat.Params) error { m.ctrl.T.Helper() - varargs := []interface{}{ctx, msgCode} - for _, a := range msgParams { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "GetErrorWithCtx", varargs...) + ret := m.ctrl.Call(m, "GetErrorWithCtx", ctx, msgKey, params) ret0, _ := ret[0].(error) return ret0 } // GetErrorWithCtx indicates an expected call of GetErrorWithCtx -func (mr *MockMessageCatalogMockRecorder) GetErrorWithCtx(ctx, msgCode interface{}, msgParams ...interface{}) *gomock.Call { +func (mr *MockMessageCatalogMockRecorder) GetErrorWithCtx(ctx, msgKey, params interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, msgCode}, msgParams...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetErrorWithCtx", reflect.TypeOf((*MockMessageCatalog)(nil).GetErrorWithCtx), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetErrorWithCtx", reflect.TypeOf((*MockMessageCatalog)(nil).GetErrorWithCtx), ctx, msgKey, params) } diff --git a/test/suites/msgcat/msgcat_test.go b/test/suites/msgcat/msgcat_test.go index f812c41..3efd0fa 100644 --- a/test/suites/msgcat/msgcat_test.go +++ b/test/suites/msgcat/msgcat_test.go @@ -30,8 +30,8 @@ func (panicObserver) OnLanguageFallback(requestedLang string, resolvedLang strin panic("observer panic") } func (panicObserver) OnLanguageMissing(lang string) { panic("observer panic") } -func (panicObserver) OnMessageMissing(lang string, msgCode int) { panic("observer panic") } -func (panicObserver) OnTemplateIssue(lang string, msgCode int, issue string) { panic("observer panic") } +func (panicObserver) OnMessageMissing(lang string, msgKey string) { panic("observer panic") } +func (panicObserver) OnTemplateIssue(lang string, msgKey string, issue string) { panic("observer panic") } type slowObserver struct { delay time.Duration @@ -39,10 +39,10 @@ type slowObserver struct { func (o slowObserver) OnLanguageFallback(requestedLang string, resolvedLang string) {} func (o slowObserver) OnLanguageMissing(lang string) {} -func (o slowObserver) OnMessageMissing(lang string, msgCode int) { +func (o slowObserver) OnMessageMissing(lang string, msgKey string) { time.Sleep(o.delay) } -func (o slowObserver) OnTemplateIssue(lang string, msgCode int, issue string) {} +func (o slowObserver) OnTemplateIssue(lang string, msgKey string, issue string) {} func (o *mockObserver) OnLanguageFallback(requestedLang string, resolvedLang string) { o.mu.Lock() @@ -56,16 +56,16 @@ func (o *mockObserver) OnLanguageMissing(lang string) { o.missingLangs = append(o.missingLangs, lang) } -func (o *mockObserver) OnMessageMissing(lang string, msgCode int) { +func (o *mockObserver) OnMessageMissing(lang string, msgKey string) { o.mu.Lock() defer o.mu.Unlock() - o.missingCodes = append(o.missingCodes, fmt.Sprintf("%s:%d", lang, msgCode)) + o.missingCodes = append(o.missingCodes, fmt.Sprintf("%s:%s", lang, msgKey)) } -func (o *mockObserver) OnTemplateIssue(lang string, msgCode int, issue string) { +func (o *mockObserver) OnTemplateIssue(lang string, msgKey string, issue string) { o.mu.Lock() defer o.mu.Unlock() - o.templateIssues = append(o.templateIssues, fmt.Sprintf("%s:%d:%s", lang, msgCode, issue)) + o.templateIssues = append(o.templateIssues, fmt.Sprintf("%s:%s:%s", lang, msgKey, issue)) } var _ = Describe("Message Catalog", func() { @@ -80,67 +80,67 @@ var _ = Describe("Message Catalog", func() { }) It("should return message code", func() { - message := messageCatalog.GetMessageWithCtx(ctx.Ctx, 1) + message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil) Expect(message.Code).To(Equal(1)) }) It("should return short message", func() { - message := messageCatalog.GetMessageWithCtx(ctx.Ctx, 1) + message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil) Expect(message.ShortText).To(Equal("Hello short description")) }) It("should return long message", func() { - message := messageCatalog.GetMessageWithCtx(ctx.Ctx, 1, "1") + message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil) Expect(message.LongText).To(Equal("Hello veeery long description. You can only see me in details page.")) }) It("should return message code (with template)", func() { - message := messageCatalog.GetMessageWithCtx(ctx.Ctx, 2, 1, "CODE") + message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.template", msgcat.Params{"name": 1, "detail": "CODE"}) Expect(message.Code).To(Equal(2)) }) It("should return short message (with template)", func() { - message := messageCatalog.GetMessageWithCtx(ctx.Ctx, 2, 1, "CODE") + message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.template", msgcat.Params{"name": 1, "detail": "CODE"}) Expect(message.ShortText).To(Equal("Hello template 1, this is nice CODE")) }) It("should return long message (with template)", func() { - message := messageCatalog.GetMessageWithCtx(ctx.Ctx, 2, 1, "CODE") + message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.template", msgcat.Params{"name": 1, "detail": "CODE"}) Expect(message.LongText).To(Equal("Hello veeery long 1 description. You can only see me in details CODE page.")) }) It("should not panic if template is wrong", func() { - message := messageCatalog.GetMessageWithCtx(ctx.Ctx, 3, 1, "CODE") - Expect(message.ShortText).To(HavePrefix("Invalid entry .p0")) + message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "error.invalid_entry", msgcat.Params{"name": "x", "detail": "y"}) + Expect(message.ShortText).To(HavePrefix("Invalid entry x")) }) It("should return message in correct language", func() { ctx.SetValue("language", "es") - message := messageCatalog.GetMessageWithCtx(ctx.Ctx, 1) + message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil) Expect(message.ShortText).To(Equal("Hola, breve descripción")) }) It("should read language with typed context key", func() { ctx.SetValue(msgcat.ContextKey("language"), "es") - message := messageCatalog.GetMessageWithCtx(ctx.Ctx, 1) + message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil) Expect(message.ShortText).To(Equal("Hola, breve descripción")) }) It("should fallback from regional language to base language", func() { ctx.SetValue("language", "es-AR") - message := messageCatalog.GetMessageWithCtx(ctx.Ctx, 1) + message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil) Expect(message.ShortText).To(Equal("Hola, breve descripción")) }) It("should return error with correct message", func() { ctx.SetValue("language", "es") - err := messageCatalog.GetErrorWithCtx(ctx.Ctx, 1) + err := messageCatalog.GetErrorWithCtx(ctx.Ctx, "greeting.hello", nil) Expect(err.Error()).To(Equal("Hola, breve descripción")) }) It("should return error with correct message components", func() { ctx.SetValue("language", "es") - err := messageCatalog.GetErrorWithCtx(ctx.Ctx, 1) + err := messageCatalog.GetErrorWithCtx(ctx.Ctx, "greeting.hello", nil) castedError := err.(msgcat.Error) Expect(castedError.GetShortMessage()).To(Equal("Hola, breve descripción")) Expect(castedError.GetLongMessage()).To(Equal("Hola, descripción muy larga. Solo puedes verme en la página de detalles.")) @@ -149,17 +149,19 @@ var _ = Describe("Message Catalog", func() { It("should be able to load messages from code", func() { err := messageCatalog.LoadMessages("en", []msgcat.RawMessage{{ + Key: "sys.9001", LongTpl: "Some long system message", ShortTpl: "Some short system message", Code: 9001, }}) Expect(err).NotTo(HaveOccurred()) - err = messageCatalog.GetErrorWithCtx(ctx.Ctx, 9001) + err = messageCatalog.GetErrorWithCtx(ctx.Ctx, "sys.9001", nil) Expect(err.Error()).To(Equal("Some short system message")) }) It("should load code messages for a new language without panic", func() { err := messageCatalog.LoadMessages("pt", []msgcat.RawMessage{{ + Key: "sys.9001", LongTpl: "Mensagem longa de sistema", ShortTpl: "Mensagem curta de sistema", Code: 9001, @@ -167,7 +169,7 @@ var _ = Describe("Message Catalog", func() { Expect(err).NotTo(HaveOccurred()) ctx.SetValue("language", "pt") - err = messageCatalog.GetErrorWithCtx(ctx.Ctx, 9001) + err = messageCatalog.GetErrorWithCtx(ctx.Ctx, "sys.9001", nil) Expect(err.Error()).To(Equal("Mensagem curta de sistema")) }) @@ -176,7 +178,7 @@ var _ = Describe("Message Catalog", func() { Expect(err).NotTo(HaveOccurred()) defer os.RemoveAll(tmpDir) - content := []byte("group: 0\ndefault:\n short: Unexpected error\n long: Unexpected error from missing set file\n") + content := []byte("default:\n short: Unexpected error\n long: Unexpected error from missing set file\nset: {}\n") err = os.WriteFile(filepath.Join(tmpDir, "en.yaml"), content, 0o600) Expect(err).NotTo(HaveOccurred()) @@ -186,44 +188,47 @@ var _ = Describe("Message Catalog", func() { Expect(err).NotTo(HaveOccurred()) err = customCatalog.LoadMessages("en", []msgcat.RawMessage{{ + Key: "sys.loaded", LongTpl: "Loaded from code", ShortTpl: "Loaded from code short", Code: 9001, }}) Expect(err).NotTo(HaveOccurred()) - Expect(customCatalog.GetMessageWithCtx(ctx.Ctx, 9001).ShortText).To(Equal("Loaded from code short")) + Expect(customCatalog.GetMessageWithCtx(ctx.Ctx, "sys.loaded", nil).ShortText).To(Equal("Loaded from code short")) }) - It("should allow to load system messages between 9000-9999", func() { + It("should require sys. prefix for LoadMessages", func() { err := messageCatalog.LoadMessages("en", []msgcat.RawMessage{{ + Key: "app.custom", LongTpl: "Some long system message", ShortTpl: "Some short system message", - Code: 8999, }}) Expect(err).To(HaveOccurred()) err = messageCatalog.LoadMessages("en", []msgcat.RawMessage{{ + Key: "", LongTpl: "Some long system message", ShortTpl: "Some short system message", - Code: 0, }}) Expect(err).To(HaveOccurred()) }) It("should wrap error", func() { err := errors.New("original error") - ctErr := messageCatalog.WrapErrorWithCtx(ctx.Ctx, err, 1) + ctErr := messageCatalog.WrapErrorWithCtx(ctx.Ctx, err, "greeting.hello", nil) Expect(errors.Is(ctErr, err)).To(BeTrue()) Expect(errors.Unwrap(ctErr)).To(Equal(err)) }) It("should render pluralization and localized number/date tokens", func() { date := time.Date(2026, time.January, 3, 10, 0, 0, 0, time.UTC) - msgEN := messageCatalog.GetMessageWithCtx(ctx.Ctx, 4, 3, 12345.5, date) + params := msgcat.Params{"count": 3, "amount": 12345.5, "generatedAt": date} + msgEN := messageCatalog.GetMessageWithCtx(ctx.Ctx, "items.count", params) Expect(msgEN.ShortText).To(Equal("You have 3 items")) Expect(msgEN.LongText).To(Equal("Total: 12,345.5 generated at 01/03/2026")) ctx.SetValue("language", "es") - msgES := messageCatalog.GetMessageWithCtx(ctx.Ctx, 4, 1, 12345.5, date) + paramsES := msgcat.Params{"count": 1, "amount": 12345.5, "generatedAt": date} + msgES := messageCatalog.GetMessageWithCtx(ctx.Ctx, "items.count", paramsES) Expect(msgES.ShortText).To(Equal("Tienes 1 elemento")) Expect(msgES.LongText).To(Equal("Total: 12.345,5 generado el 03/01/2026")) }) @@ -240,8 +245,8 @@ var _ = Describe("Message Catalog", func() { }) Expect(err).NotTo(HaveOccurred()) - msg := strictCatalog.GetMessageWithCtx(ctx.Ctx, 2, 1) - Expect(msg.ShortText).To(Equal("Hello template 1, this is nice ")) + msg := strictCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.template", msgcat.Params{"name": 1}) + Expect(msg.ShortText).To(Equal("Hello template 1, this is nice ")) stats, err := msgcat.SnapshotStats(strictCatalog) Expect(err).NotTo(HaveOccurred()) @@ -258,7 +263,7 @@ var _ = Describe("Message Catalog", func() { Expect(err).NotTo(HaveOccurred()) defer os.RemoveAll(tmpDir) - initial := []byte("group: 0\ndefault:\n short: Unexpected error\n long: Unexpected error from reload file\nset:\n 1:\n short: Hello before reload\n") + initial := []byte("default:\n short: Unexpected error\n long: Unexpected error from reload file\nset:\n greeting.hello:\n short: Hello before reload\n") err = os.WriteFile(filepath.Join(tmpDir, "en.yaml"), initial, 0o600) Expect(err).NotTo(HaveOccurred()) @@ -268,21 +273,22 @@ var _ = Describe("Message Catalog", func() { Expect(err).NotTo(HaveOccurred()) err = customCatalog.LoadMessages("en", []msgcat.RawMessage{{ + Key: "sys.runtime", LongTpl: "Runtime long", ShortTpl: "Runtime short", Code: 9001, }}) Expect(err).NotTo(HaveOccurred()) - updated := []byte("group: 0\ndefault:\n short: Unexpected error\n long: Unexpected error from reload file\nset:\n 1:\n short: Hello after reload\n") + updated := []byte("default:\n short: Unexpected error\n long: Unexpected error from reload file\nset:\n greeting.hello:\n short: Hello after reload\n") err = os.WriteFile(filepath.Join(tmpDir, "en.yaml"), updated, 0o600) Expect(err).NotTo(HaveOccurred()) err = msgcat.Reload(customCatalog) Expect(err).NotTo(HaveOccurred()) - Expect(customCatalog.GetMessageWithCtx(ctx.Ctx, 1).ShortText).To(Equal("Hello after reload")) - Expect(customCatalog.GetMessageWithCtx(ctx.Ctx, 9001).ShortText).To(Equal("Runtime short")) + Expect(customCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil).ShortText).To(Equal("Hello after reload")) + Expect(customCatalog.GetMessageWithCtx(ctx.Ctx, "sys.runtime", nil).ShortText).To(Equal("Runtime short")) }) It("should expose observability counters for fallback and misses", func() { @@ -296,10 +302,10 @@ var _ = Describe("Message Catalog", func() { Expect(err).NotTo(HaveOccurred()) ctx.SetValue("language", "es-MX") - Expect(observedCatalog.GetMessageWithCtx(ctx.Ctx, 1).ShortText).To(Equal("Hola, breve descripción")) + Expect(observedCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil).ShortText).To(Equal("Hola, breve descripción")) ctx.SetValue("language", "pt-BR") - Expect(observedCatalog.GetMessageWithCtx(ctx.Ctx, 404).Code).To(Equal(msgcat.CodeMissingMessage)) + Expect(observedCatalog.GetMessageWithCtx(ctx.Ctx, "missing.key", nil).Code).To(Equal(msgcat.CodeMissingMessage)) stats, err := msgcat.SnapshotStats(observedCatalog) Expect(err).NotTo(HaveOccurred()) @@ -326,7 +332,7 @@ var _ = Describe("Message Catalog", func() { defer msgcat.Close(catalogWithPanickingObserver) ctx.SetValue("language", "es-MX") - msg := catalogWithPanickingObserver.GetMessageWithCtx(ctx.Ctx, 404) + msg := catalogWithPanickingObserver.GetMessageWithCtx(ctx.Ctx, "missing.key", nil) Expect(msg).NotTo(BeNil()) }) @@ -340,7 +346,7 @@ var _ = Describe("Message Catalog", func() { defer msgcat.Close(catalogWithSlowObserver) start := time.Now() - msg := catalogWithSlowObserver.GetMessageWithCtx(ctx.Ctx, 404) + msg := catalogWithSlowObserver.GetMessageWithCtx(ctx.Ctx, "missing.key", nil) elapsed := time.Since(start) Expect(msg).NotTo(BeNil()) Expect(elapsed).To(BeNumerically("<", 80*time.Millisecond)) @@ -361,7 +367,7 @@ var _ = Describe("Message Catalog", func() { langs := []string{"aa", "bb", "cc", "dd"} for _, lang := range langs { ctx.SetValue("language", lang) - _ = emptyCatalog.GetMessageWithCtx(ctx.Ctx, 1) + _ = emptyCatalog.GetMessageWithCtx(ctx.Ctx, "any.key", nil) } stats, err := msgcat.SnapshotStats(emptyCatalog) @@ -382,7 +388,7 @@ var _ = Describe("Message Catalog", func() { Expect(err).NotTo(HaveOccurred()) defer os.RemoveAll(tmpDir) - initial := []byte("group: 0\ndefault:\n short: Init\n long: Init\nset:\n 1:\n short: before\n") + initial := []byte("default:\n short: Init\n long: Init\nset:\n greeting.hello:\n short: before\n") err = os.WriteFile(filepath.Join(tmpDir, "en.yaml"), initial, 0o600) Expect(err).NotTo(HaveOccurred()) @@ -400,12 +406,12 @@ var _ = Describe("Message Catalog", func() { Expect(err).NotTo(HaveOccurred()) go func() { time.Sleep(10 * time.Millisecond) - _ = os.WriteFile(filepath.Join(tmpDir, "en.yaml"), []byte("group: 0\ndefault:\n short: Init\n long: Init\nset:\n 1:\n short: after\n"), 0o600) + _ = os.WriteFile(filepath.Join(tmpDir, "en.yaml"), []byte("default:\n short: Init\n long: Init\nset:\n greeting.hello:\n short: after\n"), 0o600) }() err = msgcat.Reload(catalogWithRetry) Expect(err).NotTo(HaveOccurred()) - msg := catalogWithRetry.GetMessageWithCtx(ctx.Ctx, 1) + msg := catalogWithRetry.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil) Expect(msg.ShortText).To(Equal("after")) stats, err := msgcat.SnapshotStats(catalogWithRetry) @@ -430,7 +436,7 @@ var _ = Describe("Message Catalog", func() { go func() { defer wg.Done() for j := 0; j < readerIters; j++ { - msg := messageCatalog.GetMessageWithCtx(ctx.Ctx, 1) + msg := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil) if msg.ShortText == "" { errCh <- fmt.Errorf("received empty message") return @@ -443,11 +449,12 @@ var _ = Describe("Message Catalog", func() { go func() { defer wg.Done() for i := 0; i < writerEntries; i++ { - code := 9000 + i + key := fmt.Sprintf("sys.concurrent_%d", i) err := messageCatalog.LoadMessages("en", []msgcat.RawMessage{{ - LongTpl: fmt.Sprintf("Long %d", code), - ShortTpl: fmt.Sprintf("Short %d", code), - Code: code, + Key: key, + LongTpl: fmt.Sprintf("Long %s", key), + ShortTpl: fmt.Sprintf("Short %s", key), + Code: 9000 + i, }}) if err != nil { errCh <- err diff --git a/test/suites/msgcat/resources/messages/en.yaml b/test/suites/msgcat/resources/messages/en.yaml index a220e70..09689c6 100644 --- a/test/suites/msgcat/resources/messages/en.yaml +++ b/test/suites/msgcat/resources/messages/en.yaml @@ -1,17 +1,18 @@ -group: 0 default: short: Unexpected error long: Unexpected error was received and was not found in catalog set: - 1: + greeting.hello: + code: 1 short: Hello short description long: Hello veeery long description. You can only see me in details page. - 2: - short: Hello template {{0}}, this is nice {{1}} - long: Hello veeery long {{0}} description. You can only see me in details {{1}} page. - 3: - short: Invalid entry .p0}} - long: Invalid entry {{.p0, please fix using {{.p3}} - 4: - short: "You have {{0}} {{plural:0|item|items}}" - long: "Total: {{num:1}} generated at {{date:2}}" + greeting.template: + code: 2 + short: Hello template {{name}}, this is nice {{detail}} + long: Hello veeery long {{name}} description. You can only see me in details {{detail}} page. + error.invalid_entry: + short: Invalid entry {{name}} + long: Invalid entry {{name}}, please fix using {{detail}} + items.count: + short: "You have {{count}} {{plural:count|item|items}}" + long: "Total: {{num:amount}} generated at {{date:generatedAt}}" diff --git a/test/suites/msgcat/resources/messages/es.yaml b/test/suites/msgcat/resources/messages/es.yaml index 9d8a2f7..7f28532 100644 --- a/test/suites/msgcat/resources/messages/es.yaml +++ b/test/suites/msgcat/resources/messages/es.yaml @@ -1,11 +1,11 @@ -group: 0 default: short: Error inesperado long: Se recibió un error inesperado y no se encontró en el catálogo! set: - 1: + greeting.hello: + code: 1 short: Hola, breve descripción long: Hola, descripción muy larga. Solo puedes verme en la página de detalles. - 4: - short: "Tienes {{0}} {{plural:0|elemento|elementos}}" - long: "Total: {{num:1}} generado el {{date:2}}" + items.count: + short: "Tienes {{count}} {{plural:count|elemento|elementos}}" + long: "Total: {{num:amount}} generado el {{date:generatedAt}}" From 2d7fb739e4e5e6fbc57b442e9553232227205be7 Mon Sep 17 00:00:00 2001 From: Christian Melgarejo Date: Thu, 26 Feb 2026 09:54:31 -0300 Subject: [PATCH 2/5] feat: optional string codes, Key/ErrorKey, docs and examples - Code is optional and string: OptionalCode (YAML int/string), Message.Code and ErrorCode() string - RawMessage.Code: any value, not unique; helpers CodeInt(), CodeString() - Message.Key and ErrorKey() for API identifier when code is empty - Constants CodeMissingMessage/Language as strings (msgcat.missing_*) - New code.go: OptionalCode, UnmarshalYAML, CodeInt, CodeString - Docs: 'Message and error codes' section (README, CONTEXT7, CONTEXT7_RETRIEVAL) - Runnable examples: basic, load_messages, reload, strict, stats; http/metrics resources Made-with: Cursor --- README.md | 233 ++++++++++++++++++-- code.go | 48 ++++ docs/CONTEXT7.md | 208 ++++++++++++++++- docs/CONTEXT7_RETRIEVAL.md | 158 ++++++++++++- error.go | 17 +- examples/basic/main.go | 72 ++++++ examples/http/resources/messages/en.yaml | 7 + examples/http/resources/messages/es.yaml | 7 + examples/load_messages/main.go | 59 +++++ examples/metrics/resources/messages/en.yaml | 7 + examples/metrics/resources/messages/es.yaml | 7 + examples/reload/main.go | 55 +++++ examples/stats/main.go | 57 +++++ examples/strict/main.go | 75 +++++++ msgcat.go | 15 +- structs.go | 13 +- test/suites/msgcat/msgcat_test.go | 32 ++- 17 files changed, 1015 insertions(+), 55 deletions(-) create mode 100644 code.go create mode 100644 examples/basic/main.go create mode 100644 examples/http/resources/messages/en.yaml create mode 100644 examples/http/resources/messages/es.yaml create mode 100644 examples/load_messages/main.go create mode 100644 examples/metrics/resources/messages/en.yaml create mode 100644 examples/metrics/resources/messages/es.yaml create mode 100644 examples/reload/main.go create mode 100644 examples/stats/main.go create mode 100644 examples/strict/main.go diff --git a/README.md b/README.md index d613e38..1194a2e 100644 --- a/README.md +++ b/README.md @@ -140,25 +140,25 @@ All fields of `msgcat.Config`: Order: requested language → base tag (`es-ar` → `es`) → `FallbackLanguages` → `DefaultLanguage` → `"en"`. First language that exists in the catalog is used. - **YAML + runtime messages** - Messages from YAML plus runtime-loaded entries via `LoadMessages` for codes **9000–9999** (system range). + Messages from YAML plus runtime-loaded entries via `LoadMessages`; keys must use the **`sys.`** prefix (e.g. `sys.alert`). -- **Template tokens** - - `{{0}}`, `{{1}}`, … — positional parameters. - - `{{plural:i|singular|plural}}` — plural form by parameter at index `i`. - - `{{num:i}}` — localized number for parameter at index `i`. - - `{{date:i}}` — localized date for parameter at index `i` (`time.Time` or `*time.Time`). +- **Template tokens (named parameters)** + - `{{name}}` — simple substitution. + - `{{plural:count|singular|plural}}` — plural form by named count parameter. + - `{{num:amount}}` — localized number for named parameter. + - `{{date:when}}` — localized date for named parameter (`time.Time` or `*time.Time`). - **Strict template mode** - With `StrictTemplates: true`, missing or invalid params produce `` and observer events. + With `StrictTemplates: true`, missing or invalid params produce `` and observer events. - **Error wrapping** - `WrapErrorWithCtx` and `GetErrorWithCtx` return errors implementing `msgcat.Error`: `ErrorCode()`, `GetShortMessage()`, `GetLongMessage()`, `Unwrap()`. + `WrapErrorWithCtx` and `GetErrorWithCtx` return errors implementing `msgcat.Error`: `ErrorCode() string` (optional), `ErrorKey()`, `GetShortMessage()`, `GetLongMessage()`, `Unwrap()`. See [Message and error codes](#message-and-error-codes). - **Concurrency** Safe for concurrent reads; `LoadMessages` and `Reload` are safe to use concurrently with reads. - **Reload** - `msgcat.Reload(catalog)` reloads YAML from disk with optional retries; runtime-loaded messages (9000–9999) are preserved. On failure, last in-memory state is kept. + `msgcat.Reload(catalog)` reloads YAML from disk with optional retries; runtime-loaded messages (keys with `sys.` prefix) are preserved. On failure, last in-memory state is kept. - **Observability** Optional `Observer` plus stats via `SnapshotStats` / `ResetStats`. Observer runs asynchronously and is panic-safe; queue overflow is counted in stats. @@ -179,9 +179,9 @@ All fields of `msgcat.Config`: ### Types - **`Params`** — `map[string]interface{}` for named template parameters (e.g. `msgcat.Params{"name": "juan"}`). -- **`Message`** — `Code int`, `ShortText string`, `LongText string`. -- **`RawMessage`** — `Key` (required for `LoadMessages`), `ShortTpl`, `LongTpl` (YAML: `short`, `long`), optional `Code` (YAML: `code`). -- **`msgcat.Error`** — `Error() string`, `Unwrap() error`, `ErrorCode() int`, `GetShortMessage() string`, `GetLongMessage() string`. +- **`Message`** — `ShortText`, `LongText`, `Code string` (optional; see [Message and error codes](#message-and-error-codes)), `Key string` (message key; use when `Code` is empty). +- **`RawMessage`** — `Key` (required for `LoadMessages`), `ShortTpl`, `LongTpl`, optional `Code` (`OptionalCode`; see [Message and error codes](#message-and-error-codes)). +- **`msgcat.Error`** — `Error()`, `Unwrap()`, `ErrorCode() string` (optional), `ErrorKey() string` (use when `ErrorCode()` is empty), `GetShortMessage()`, `GetLongMessage()`. ### Package-level helpers @@ -198,8 +198,21 @@ All fields of `msgcat.Config`: | Constant | Value | Description | |----------|--------|-------------| | `RuntimeKeyPrefix` | `"sys."` | Required prefix for message keys loaded via `LoadMessages`. | -| `CodeMissingMessage` | 999999002 | Code used when a message key is missing in the catalog. | -| `CodeMissingLanguage` | 999999001 | Code used when the language is missing. | +| `CodeMissingMessage` | `"msgcat.missing_message"` | Code used when a message key is missing in the catalog. | +| `CodeMissingLanguage` | `"msgcat.missing_language"` | Code used when the language is missing. | + +## Message and error codes + +Many projects already use **error or message codes** (HTTP statuses, legacy numeric codes, string identifiers like `ERR_NOT_FOUND`). The optional **`code`** field in the catalog lets you **store that value** with each message and have it returned in `Message.Code` and `ErrorCode()` so your API can expose it unchanged. + +- **Optional** — You can omit `code` entirely. When empty, use `Message.Key` or `ErrorKey()` as the stable identifier for clients (e.g. in JSON: `"error_code": msg.Code or msg.Key`). +- **Any value** — Codes are strings. In YAML you can write `code: 404` (parsed as `"404"`) or `code: "ERR_NOT_FOUND"`. In Go use `msgcat.CodeInt(503)` or `msgcat.CodeString("ERR_MAINT")`. +- **Not unique** — The catalog does not require codes to be unique. If your design uses the same code for several messages (e.g. same HTTP status for different keys), you can repeat the same `code` value. +- **Your identifier** — The catalog never interprets the code; it only stores and returns it. You decide what values to use and how to expose them in your API. + +**When to set a code:** Use it when you need a stable, project-specific value to return to clients (status codes, error enums, etc.). When you don’t, leave it unset and use the message **key** as the identifier. + +Helpers for building `RawMessage.Code` in code: `msgcat.CodeInt(503)`, `msgcat.CodeString("ERR_NOT_FOUND")`. ## Observability @@ -247,6 +260,183 @@ if err == nil { --- +## API examples (every nook and cranny) + +All of the following assume a catalog and context are set up; use your own YAML keys and params as needed. + +### Create catalog (minimal vs full config) + +```go +// Minimal: uses ./resources/messages, language "en", no observer +catalog, err := msgcat.NewMessageCatalog(msgcat.Config{}) + +// Full: custom path, fallbacks, strict templates, observer, reload retries +catalog, err := msgcat.NewMessageCatalog(msgcat.Config{ + ResourcePath: "./resources/messages", + CtxLanguageKey: msgcat.ContextKey("language"), // typed key + DefaultLanguage: "en", + FallbackLanguages: []string{"es", "pt"}, + StrictTemplates: true, + Observer: myObserver, + ObserverBuffer: 1024, + StatsMaxKeys: 512, + ReloadRetries: 2, + ReloadRetryDelay: 50 * time.Millisecond, + NowFn: time.Now, +}) +``` + +### GetMessageWithCtx: no params vs named params + +```go +// No template params: pass nil +msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) +fmt.Println(msg.ShortText, msg.LongText, msg.Code) + +// Named params: use Params map +msg := catalog.GetMessageWithCtx(ctx, "greeting.template", msgcat.Params{ + "name": "juan", + "detail": "admin", +}) +``` + +### Template placeholders: simple, plural, number, date + +```go +// Simple: {{name}}, {{detail}}, etc. +msg := catalog.GetMessageWithCtx(ctx, "greeting.template", msgcat.Params{ + "name": "juan", "detail": "nice", +}) + +// Plural: {{count}} and {{plural:count|item|items}} +msg := catalog.GetMessageWithCtx(ctx, "items.count", msgcat.Params{ + "count": 3, +}) + +// Number: {{num:amount}} (localized thousands/decimal) +msg := catalog.GetMessageWithCtx(ctx, "report.total", msgcat.Params{ + "amount": 12345.67, +}) + +// Date: {{date:when}} (localized format) +msg := catalog.GetMessageWithCtx(ctx, "report.generated", msgcat.Params{ + "when": time.Now(), +}) + +// All together +msg := catalog.GetMessageWithCtx(ctx, "items.count", msgcat.Params{ + "count": 3, "amount": 12345.5, "generatedAt": time.Now(), +}) +``` + +### GetErrorWithCtx and WrapErrorWithCtx + +```go +// Error without wrapping an underlying error +err := catalog.GetErrorWithCtx(ctx, "error.not_found", msgcat.Params{"resource": "order"}) +fmt.Println(err.Error()) // short message + +// Wrap a domain error with localized message +inner := errors.New("db: connection timeout") +err := catalog.WrapErrorWithCtx(ctx, inner, "error.timeout", nil) +if catErr, ok := err.(msgcat.Error); ok { + fmt.Println(catErr.Error()) // short message + fmt.Println(catErr.ErrorCode()) // optional; empty when not set in catalog + fmt.Println(catErr.ErrorKey()) // message key; use as API id when ErrorCode() is empty + fmt.Println(catErr.GetShortMessage()) + fmt.Println(catErr.GetLongMessage()) + fmt.Println(catErr.Unwrap() == inner) // true +} +``` + +**Code** is optional: use it to store your own error/message codes (e.g. HTTP status, `"ERR_001"`) and return them from the API. When empty, use `Message.Key` or `ErrorKey()`. See [Message and error codes](#message-and-error-codes). + +### LoadMessages (runtime messages with sys. prefix) + +```go +err := catalog.LoadMessages("en", []msgcat.RawMessage{ + { + Key: "sys.maintenance", + ShortTpl: "Service under maintenance", + LongTpl: "The service is temporarily unavailable. Try again in {{minutes}} minutes.", + Code: 503, + }, +}) +// Then use the key like any other +msg := catalog.GetMessageWithCtx(ctx, "sys.maintenance", msgcat.Params{"minutes": 5}) +``` + +### Reload, stats, close + +```go +// Reload YAML from disk (keeps runtime-loaded sys.* messages) +err := msgcat.Reload(catalog) + +// Snapshot current stats (safe concurrent read) +stats, err := msgcat.SnapshotStats(catalog) +if err == nil { + for k, n := range stats.MissingMessages { fmt.Println(k, n) } +} + +// Reset all counters to zero +err = msgcat.ResetStats(catalog) + +// On shutdown when using an observer: stop worker and flush queue +err = msgcat.Close(catalog) +``` + +### Language from context (typed key vs string key) + +```go +// Both work: typed ContextKey or plain string +ctx = context.WithValue(ctx, msgcat.ContextKey("language"), "es-MX") +ctx = context.WithValue(ctx, "language", "es-MX") +msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) +``` + +### Observer implementation + +```go +type myObserver struct{} + +func (myObserver) OnLanguageFallback(requested, resolved string) { + log.Printf("fallback %s -> %s", requested, resolved) +} +func (myObserver) OnLanguageMissing(lang string) { + log.Printf("missing language: %s", lang) +} +func (myObserver) OnMessageMissing(lang string, msgKey string) { + log.Printf("missing message %s:%s", lang, msgKey) +} +func (myObserver) OnTemplateIssue(lang string, msgKey string, issue string) { + log.Printf("template issue %s:%s: %s", lang, msgKey, issue) +} + +catalog, _ := msgcat.NewMessageCatalog(msgcat.Config{ + Observer: myObserver{}, + ObserverBuffer: 1024, +}) +``` + +### Missing message / missing language + +```go +// Unknown key: returns default message for that language, Code = CodeMissingMessage (string) +msg := catalog.GetMessageWithCtx(ctx, "unknown.key", nil) +if msg.Code == msgcat.CodeMissingMessage { + // key was not in catalog +} + +// Requested language not in catalog: uses MessageCatalogNotFound text, Code = CodeMissingLanguage (string) +ctx = context.WithValue(ctx, "language", "xx") +msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) +if msg.Code == msgcat.CodeMissingLanguage { + // no language match in catalog +} +``` + +--- + ## Production notes - Set `DefaultLanguage` explicitly (e.g. `"en"`). @@ -278,8 +468,19 @@ go test -run ^$ -bench . -benchmem ./... ## Examples -- HTTP language middleware: `examples/http/main.go` -- Metrics/observer (expvar-style): `examples/metrics/main.go` +Runnable programs (each uses a temp dir and minimal YAML so you can run from any directory): + +| Example | What it demonstrates | +|---------|----------------------| +| `examples/basic` | NewMessageCatalog, GetMessageWithCtx (nil and with Params), GetErrorWithCtx, WrapErrorWithCtx, msgcat.Error | +| `examples/load_messages` | LoadMessages with `sys.` prefix, using runtime-loaded keys | +| `examples/reload` | Reload(catalog) to re-read YAML from disk | +| `examples/strict` | StrictTemplates and observer for missing template params | +| `examples/stats` | SnapshotStats, ResetStats, stat keys | +| `examples/http` | HTTP server with language from Accept-Language and GetMessageWithCtx | +| `examples/metrics` | Observer (expvar-style) and Close on shutdown | + +Run from repo root: `go run ./examples/basic`, `go run ./examples/load_messages`, etc. --- diff --git a/code.go b/code.go new file mode 100644 index 0000000..7195c38 --- /dev/null +++ b/code.go @@ -0,0 +1,48 @@ +package msgcat + +import ( + "fmt" + "strconv" +) + +// OptionalCode is the optional "code" field for catalog entries. Use it when your project +// already has error or message codes (HTTP statuses, legacy numbers, string ids like "ERR_NOT_FOUND") +// and you want to store that value in the catalog and return it from Message.Code and ErrorCode(). +// It can be any value; uniqueness is not enforced. YAML accepts int or string (e.g. code: 404 +// or code: "ERR_NOT_FOUND"). In Go use CodeInt or CodeString when building RawMessage. +type OptionalCode string + +// UnmarshalYAML allows code to be given as int or string in YAML. +func (c *OptionalCode) UnmarshalYAML(unmarshal func(interface{}) error) error { + var v interface{} + if err := unmarshal(&v); err != nil { + return err + } + if v == nil { + *c = "" + return nil + } + switch t := v.(type) { + case string: + *c = OptionalCode(t) + return nil + case int: + *c = OptionalCode(strconv.Itoa(t)) + return nil + case int64: + *c = OptionalCode(strconv.FormatInt(t, 10)) + return nil + default: + return fmt.Errorf("code must be string or int, got %T", v) + } +} + +// CodeInt returns an OptionalCode from an int (e.g. HTTP status 503). Use when building RawMessage in code. +func CodeInt(i int) OptionalCode { + return OptionalCode(strconv.Itoa(i)) +} + +// CodeString returns an OptionalCode from a string (e.g. "ERR_NOT_FOUND"). Use when building RawMessage in code. +func CodeString(s string) OptionalCode { + return OptionalCode(s) +} diff --git a/docs/CONTEXT7.md b/docs/CONTEXT7.md index cb2e233..bcc0cbd 100644 --- a/docs/CONTEXT7.md +++ b/docs/CONTEXT7.md @@ -90,7 +90,8 @@ Field behavior: type Message struct { LongText string ShortText string - Code int + Code string // Optional; user-defined (e.g. "404", "ERR_001"). Empty when not set. Use Key when empty. + Key string // Message key (e.g. "greeting.hello"); always set. } ``` @@ -106,13 +107,15 @@ Named template parameters. Use `msgcat.Params{"name": "juan", "count": 3}`. ```go type RawMessage struct { - LongTpl string `yaml:"long"` - ShortTpl string `yaml:"short"` - Code int `yaml:"code"` - Key string `yaml:"-"` // required when using LoadMessages; must have prefix sys. + LongTpl string `yaml:"long"` + ShortTpl string `yaml:"short"` + Code OptionalCode `yaml:"code"` // Optional; any value. YAML: code: 404 or code: "ERR_001". Not unique. + Key string `yaml:"-"` // required when using LoadMessages; must have prefix sys. } ``` +`OptionalCode` unmarshals from YAML as int or string. In Go use `msgcat.CodeInt(503)` or `msgcat.CodeString("ERR_MAINT")`. + ### `type MessageCatalogStats struct` ```go @@ -175,12 +178,23 @@ Notes: ```go const ( - RuntimeKeyPrefix = "sys." - CodeMissingMessage = 999999002 - CodeMissingLanguage = 999999001 + RuntimeKeyPrefix = "sys." + CodeMissingMessage = "msgcat.missing_message" + CodeMissingLanguage = "msgcat.missing_language" ) ``` +## 7.1 Message and error codes (optional `code` field) + +Many projects already have **error or message codes** (HTTP statuses, legacy numbers, string ids like `ERR_NOT_FOUND`). The optional **`code`** field lets you **store that value** in the catalog and have it returned in `Message.Code` and `ErrorCode()` so your API can expose it as-is. + +- **Optional** — Omit `code` when you don’t need it; use `Message.Key` or `ErrorKey()` as the identifier when `Code` / `ErrorCode()` is empty. +- **Any value** — Codes are strings. YAML accepts `code: 404` (becomes `"404"`) or `code: "ERR_NOT_FOUND"`. In Go: `msgcat.CodeInt(503)` or `msgcat.CodeString("ERR_MAINT")`. +- **Not unique** — Uniqueness is not enforced. The same code can appear on multiple messages if that matches your design. +- **Opaque to the catalog** — The library only stores and returns the value; meaning and uniqueness are entirely up to the caller. + +Use `code` when you need a stable, project-specific value for clients (e.g. status or error enums). Otherwise, rely on the message **key** as the identifier. + ## 8. Language Resolution Algorithm Given requested language from context (normalized lower-case, `_` -> `-`): @@ -230,6 +244,8 @@ When `StrictTemplates=true` and a parameter is missing: - token is replaced with `` - observer/stats receives a template issue event +Example strict placeholder: `"Hello {{name}}"` with params `nil` or missing `name` => `"Hello "`. + When strict mode is off: - unresolved token is left as-is - issue is still recorded @@ -250,12 +266,15 @@ Accepted date params: ## 11. Error Model -`WrapErrorWithCtx` and `GetErrorWithCtx` return a concrete error with: +`WrapErrorWithCtx` and `GetErrorWithCtx` return a concrete error implementing `msgcat.Error`: - `Error()` -> short localized message -- `ErrorCode()` -> resolved code +- `ErrorCode() string` -> optional; user-defined. Empty when not set. Use `ErrorKey()` when empty. +- `ErrorKey()` -> message key; use as API identifier when `ErrorCode()` is empty - `GetShortMessage()` and `GetLongMessage()` - `Unwrap()` support for wrapped error chaining +Code is optional and can be any value (string); uniqueness is not enforced. When empty, return `ErrorKey()` as the API value. + ## 12. Runtime Loading and Reload ### Runtime loading @@ -370,3 +389,172 @@ go test ./... go test -race ./... go test -run ^$ -bench . -benchmem ./... ``` + +## 19. Examples by API surface (for retrieval) + +Each snippet is self-contained for copy-paste or chunk retrieval. For runnable programs see the repository `examples/` directory: `basic`, `load_messages`, `reload`, `strict`, `stats`, `http`, `metrics`. + +### Example: NewMessageCatalog minimal + +```go +catalog, err := msgcat.NewMessageCatalog(msgcat.Config{}) +// Uses ./resources/messages, DefaultLanguage "en", CtxLanguageKey "language" +``` + +### Example: NewMessageCatalog full config + +```go +catalog, err := msgcat.NewMessageCatalog(msgcat.Config{ + ResourcePath: "./resources/messages", + CtxLanguageKey: msgcat.ContextKey("language"), + DefaultLanguage: "en", + FallbackLanguages: []string{"es"}, + StrictTemplates: true, + Observer: myObserver, + ObserverBuffer: 1024, + StatsMaxKeys: 512, + ReloadRetries: 2, + ReloadRetryDelay: 50 * time.Millisecond, + NowFn: time.Now, +}) +``` + +### Example: GetMessageWithCtx with nil params + +```go +msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) +// Use when message has no placeholders or default text is enough +fmt.Println(msg.ShortText, msg.Code) +``` + +### Example: GetMessageWithCtx with Params + +```go +msg := catalog.GetMessageWithCtx(ctx, "greeting.template", msgcat.Params{ + "name": "juan", "detail": "admin", +}) +``` + +### Example: Simple placeholder {{name}} + +```go +// YAML: short: "Hello {{name}}" +msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", msgcat.Params{"name": "juan"}) +``` + +### Example: Plural placeholder {{plural:count|singular|plural}} + +```go +// YAML: short: "You have {{count}} {{plural:count|item|items}}" +msg := catalog.GetMessageWithCtx(ctx, "items.count", msgcat.Params{"count": 3}) +// => "You have 3 items" +msg := catalog.GetMessageWithCtx(ctx, "items.count", msgcat.Params{"count": 1}) +// => "You have 1 item" +``` + +### Example: Number placeholder {{num:amount}} + +```go +// YAML: short: "Total: {{num:amount}}" +msg := catalog.GetMessageWithCtx(ctx, "report.total", msgcat.Params{"amount": 12345.67}) +// en => "Total: 12,345.67"; es => "Total: 12.345,67" +``` + +### Example: Date placeholder {{date:when}} + +```go +// YAML: short: "Generated at {{date:when}}" +msg := catalog.GetMessageWithCtx(ctx, "report.generated", msgcat.Params{"when": time.Now()}) +// en => MM/DD/YYYY; es/pt/fr/de/it => DD/MM/YYYY +``` + +### Example: GetErrorWithCtx + +```go +err := catalog.GetErrorWithCtx(ctx, "error.not_found", msgcat.Params{"resource": "order"}) +fmt.Println(err.Error()) // short localized message +``` + +### Example: WrapErrorWithCtx and msgcat.Error + +```go +inner := errors.New("db timeout") +err := catalog.WrapErrorWithCtx(ctx, inner, "error.timeout", nil) +var catErr msgcat.Error +if errors.As(err, &catErr) { + catErr.ErrorCode() + catErr.GetShortMessage() + catErr.GetLongMessage() + catErr.Unwrap() // original inner +} +``` + +### Example: LoadMessages with sys. prefix + +```go +err := catalog.LoadMessages("en", []msgcat.RawMessage{{ + Key: "sys.maintenance", + ShortTpl: "Under maintenance", + LongTpl: "Back in {{minutes}} minutes.", + Code: 503, +}}) +msg := catalog.GetMessageWithCtx(ctx, "sys.maintenance", msgcat.Params{"minutes": 5}) +``` + +### Example: Reload + +```go +err := msgcat.Reload(catalog) +// Re-reads YAML; runtime sys.* messages are preserved; on failure previous state kept +``` + +### Example: SnapshotStats and ResetStats + +```go +stats, err := msgcat.SnapshotStats(catalog) +_ = stats.LanguageFallbacks +_ = stats.MissingMessages +_ = stats.TemplateIssues +err = msgcat.ResetStats(catalog) +``` + +### Example: Close (with observer) + +```go +defer func() { _ = msgcat.Close(catalog) }() +// Call on shutdown when Config.Observer is set; stops worker and flushes queue +``` + +### Example: Observer implementation + +```go +type obs struct{} +func (obs) OnLanguageFallback(req, res string) {} +func (obs) OnLanguageMissing(lang string) {} +func (obs) OnMessageMissing(lang string, msgKey string) {} +func (obs) OnTemplateIssue(lang string, msgKey string, issue string) {} +catalog, _ := msgcat.NewMessageCatalog(msgcat.Config{Observer: obs{}, ObserverBuffer: 1024}) +``` + +### Example: Context language (typed vs string key) + +```go +ctx = context.WithValue(ctx, msgcat.ContextKey("language"), "es-MX") +ctx = context.WithValue(ctx, "language", "es-MX") +// Both are supported for CtxLanguageKey lookup +``` + +### Example: Missing message key + +```go +msg := catalog.GetMessageWithCtx(ctx, "nonexistent.key", nil) +// msg.Code == msgcat.CodeMissingMessage; short/long = default message for language +``` + +### Example: Missing language + +```go +ctx = context.WithValue(ctx, "language", "zz") +msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) +// msg.Code == msgcat.CodeMissingLanguage; text = MessageCatalogNotFound +``` diff --git a/docs/CONTEXT7_RETRIEVAL.md b/docs/CONTEXT7_RETRIEVAL.md index 30ceded..3a14e70 100644 --- a/docs/CONTEXT7_RETRIEVAL.md +++ b/docs/CONTEXT7_RETRIEVAL.md @@ -2,6 +2,8 @@ Purpose: compact, chunk-friendly reference for LLM retrieval/indexing. +Runnable examples: `examples/basic`, `examples/load_messages`, `examples/reload`, `examples/strict`, `examples/stats`, `examples/http`, `examples/metrics`. + ## C01_IDENTITY - Module: `github.com/loopcontext/msgcat` - Package: `msgcat` @@ -130,15 +132,23 @@ func Close(catalog MessageCatalog) error ## C09_CODES_AND_CONSTANTS ```go const ( - RuntimeKeyPrefix = "sys." - CodeMissingMessage = 999999002 - CodeMissingLanguage = 999999001 + RuntimeKeyPrefix = "sys." + CodeMissingMessage = "msgcat.missing_message" + CodeMissingLanguage = "msgcat.missing_language" ) ``` +## C09a_MESSAGE_AND_ERROR_CODES +Optional `code` field: for projects that already have error/message codes (HTTP status, legacy numbers, string ids like `ERR_NOT_FOUND`). Store that value in the catalog; it is returned in `Message.Code` and `ErrorCode()` for your API to expose unchanged. + +- **Optional** — Omit when not needed; use `Message.Key` or `ErrorKey()` when empty. +- **Any value** — String. YAML: `code: 404` or `code: "ERR_NOT_FOUND"`. Go: `msgcat.CodeInt(503)`, `msgcat.CodeString("ERR_MAINT")`. +- **Not unique** — Same code can be used on multiple messages. +- **Opaque** — Catalog only stores and returns it; meaning is up to the caller. + Semantics: -- missing message in resolved language => default language message + `CodeMissingMessage` -- missing language after full chain => `CodeMissingLanguage` +- missing message => default message + `CodeMissingMessage` +- missing language => `CodeMissingLanguage` ## C10_RUNTIME_LOADING `LoadMessages(lang, messages)`: @@ -190,7 +200,8 @@ Validated with `go test -race ./...`. Returned catalog error supports: - `Error()` => short localized text - `Unwrap()` => wrapped original error -- `ErrorCode()` +- `ErrorCode() string` => optional; empty when not set. Use `ErrorKey()` when empty. +- `ErrorKey()` => message key; use as API identifier when Code is empty - `GetShortMessage()` - `GetLongMessage()` @@ -220,3 +231,138 @@ go test ./... go test -race ./... go test -run ^$ -bench . -benchmem ./... ``` + +## C17_EXAMPLE_NEW_CATALOG_MINIMAL +```go +catalog, err := msgcat.NewMessageCatalog(msgcat.Config{}) +``` + +## C18_EXAMPLE_NEW_CATALOG_FULL +```go +catalog, err := msgcat.NewMessageCatalog(msgcat.Config{ + ResourcePath: "./resources/messages", + CtxLanguageKey: msgcat.ContextKey("language"), + DefaultLanguage: "en", + FallbackLanguages: []string{"es"}, + StrictTemplates: true, + Observer: myObserver, + ObserverBuffer: 1024, + StatsMaxKeys: 512, + ReloadRetries: 2, + ReloadRetryDelay: 50 * time.Millisecond, + NowFn: time.Now, +}) +``` + +## C19_EXAMPLE_GET_MESSAGE_NIL_PARAMS +```go +msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) +fmt.Println(msg.ShortText, msg.Code) +``` + +## C20_EXAMPLE_GET_MESSAGE_WITH_PARAMS +```go +msg := catalog.GetMessageWithCtx(ctx, "greeting.template", msgcat.Params{ + "name": "juan", "detail": "admin", +}) +``` + +## C21_EXAMPLE_TEMPLATE_SIMPLE +```go +// YAML: short: "Hello {{name}}" +msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", msgcat.Params{"name": "juan"}) +``` + +## C22_EXAMPLE_TEMPLATE_PLURAL +```go +// YAML: short: "You have {{count}} {{plural:count|item|items}}" +msg := catalog.GetMessageWithCtx(ctx, "items.count", msgcat.Params{"count": 3}) +``` + +## C23_EXAMPLE_TEMPLATE_NUMBER +```go +// YAML: short: "Total: {{num:amount}}" +msg := catalog.GetMessageWithCtx(ctx, "report.total", msgcat.Params{"amount": 12345.67}) +``` + +## C24_EXAMPLE_TEMPLATE_DATE +```go +// YAML: short: "At {{date:when}}" +msg := catalog.GetMessageWithCtx(ctx, "report.generated", msgcat.Params{"when": time.Now()}) +``` + +## C25_EXAMPLE_GET_ERROR +```go +err := catalog.GetErrorWithCtx(ctx, "error.not_found", msgcat.Params{"resource": "order"}) +``` + +## C26_EXAMPLE_WRAP_ERROR +```go +inner := errors.New("db timeout") +err := catalog.WrapErrorWithCtx(ctx, inner, "error.timeout", nil) +var catErr msgcat.Error +if errors.As(err, &catErr) { + code := catErr.ErrorCode() // "" if no code in catalog + key := catErr.ErrorKey() // use as API id when code is empty + catErr.GetShortMessage() + catErr.GetLongMessage() + catErr.Unwrap() +} +``` + +## C27_EXAMPLE_LOAD_MESSAGES +```go +err := catalog.LoadMessages("en", []msgcat.RawMessage{{ + Key: "sys.maintenance", + ShortTpl: "Under maintenance", + LongTpl: "Back in {{minutes}} minutes.", + Code: 503, +}}) +msg := catalog.GetMessageWithCtx(ctx, "sys.maintenance", msgcat.Params{"minutes": 5}) +``` + +## C28_EXAMPLE_RELOAD +```go +err := msgcat.Reload(catalog) +``` + +## C29_EXAMPLE_STATS +```go +stats, _ := msgcat.SnapshotStats(catalog) +_ = stats.MissingMessages +msgcat.ResetStats(catalog) +``` + +## C30_EXAMPLE_CLOSE +```go +defer func() { _ = msgcat.Close(catalog) }() +``` + +## C31_EXAMPLE_OBSERVER +```go +type obs struct{} +func (obs) OnLanguageFallback(req, res string) {} +func (obs) OnLanguageMissing(lang string) {} +func (obs) OnMessageMissing(lang string, msgKey string) {} +func (obs) OnTemplateIssue(lang string, msgKey string, issue string) {} +catalog, _ := msgcat.NewMessageCatalog(msgcat.Config{Observer: obs{}, ObserverBuffer: 1024}) +``` + +## C32_EXAMPLE_CONTEXT_LANGUAGE +```go +ctx = context.WithValue(ctx, msgcat.ContextKey("language"), "es-MX") +ctx = context.WithValue(ctx, "language", "es-MX") +``` + +## C33_EXAMPLE_MISSING_MESSAGE +```go +msg := catalog.GetMessageWithCtx(ctx, "nonexistent.key", nil) +// msg.Code == msgcat.CodeMissingMessage +``` + +## C34_EXAMPLE_MISSING_LANGUAGE +```go +ctx = context.WithValue(ctx, "language", "zz") +msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) +// msg.Code == msgcat.CodeMissingLanguage +``` diff --git a/error.go b/error.go index 96066a9..d966042 100644 --- a/error.go +++ b/error.go @@ -1,9 +1,11 @@ package msgcat +// Error is the catalog error type. When ErrorCode() is empty, use ErrorKey() as the API identifier. type Error interface { Error() string Unwrap() error - ErrorCode() int + ErrorCode() string // Optional; user-defined. Empty when not set. Use ErrorKey() when empty. + ErrorKey() string // Message key (e.g. "error.not_found"); use as identifier when ErrorCode() is empty. GetShortMessage() string GetLongMessage() string } @@ -12,7 +14,8 @@ type DefaultError struct { err error shortMessage string longMessage string - code int + code string + key string } func (ce DefaultError) Error() string { @@ -23,10 +26,14 @@ func (ce *DefaultError) Unwrap() error { return ce.err } -func (ce *DefaultError) ErrorCode() int { +func (ce *DefaultError) ErrorCode() string { return ce.code } +func (ce *DefaultError) ErrorKey() string { + return ce.key +} + func (ce *DefaultError) GetShortMessage() string { return ce.shortMessage } @@ -35,6 +42,6 @@ func (ce *DefaultError) GetLongMessage() string { return ce.longMessage } -func newCatalogError(code int, shortMessage string, longMessage string, err error) error { - return &DefaultError{shortMessage: shortMessage, longMessage: longMessage, code: code, err: err} +func newCatalogError(code string, key string, shortMessage string, longMessage string, err error) error { + return &DefaultError{shortMessage: shortMessage, longMessage: longMessage, code: code, key: key, err: err} } diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..cd0a0b4 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,72 @@ +// Basic demonstrates: NewMessageCatalog, GetMessageWithCtx (nil params and with Params), +// GetErrorWithCtx, WrapErrorWithCtx, and the msgcat.Error interface. +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/loopcontext/msgcat" +) + +func main() { + dir, err := os.MkdirTemp("", "msgcat-basic-*") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + + en := []byte(`default: + short: Unexpected error + long: Message not found in catalog +set: + greeting.hello: + code: 1 + short: Hello + long: Hello, welcome. + greeting.template: + code: 2 + short: Hello {{name}}, role {{role}} + long: Hello {{name}}, you are {{role}}. + error.gone: + code: 404 + short: Not found + long: Resource not found. +`) + if err := os.WriteFile(filepath.Join(dir, "en.yaml"), en, 0o600); err != nil { + panic(err) + } + + catalog, err := msgcat.NewMessageCatalog(msgcat.Config{ResourcePath: dir}) + if err != nil { + panic(err) + } + + ctx := context.WithValue(context.Background(), "language", "en") + + // GetMessageWithCtx with nil params (no placeholders) + msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) + fmt.Println("nil params:", msg.ShortText, "| code:", msg.Code) + + // GetMessageWithCtx with Params + msg = catalog.GetMessageWithCtx(ctx, "greeting.template", msgcat.Params{"name": "juan", "role": "admin"}) + fmt.Println("with params:", msg.ShortText, "| code:", msg.Code) + + // GetErrorWithCtx (error without wrapping) + err = catalog.GetErrorWithCtx(ctx, "error.gone", nil) + fmt.Println("GetErrorWithCtx:", err.Error()) + + // WrapErrorWithCtx and msgcat.Error interface + inner := errors.New("db: connection refused") + err = catalog.WrapErrorWithCtx(ctx, inner, "error.gone", nil) + var catErr msgcat.Error + if errors.As(err, &catErr) { + fmt.Println("WrapError short:", catErr.GetShortMessage()) + fmt.Println("WrapError code:", catErr.ErrorCode()) + fmt.Println("WrapError key (use when code is empty):", catErr.ErrorKey()) + fmt.Println("Unwrap:", catErr.Unwrap() == inner) + } +} diff --git a/examples/http/resources/messages/en.yaml b/examples/http/resources/messages/en.yaml new file mode 100644 index 0000000..122a912 --- /dev/null +++ b/examples/http/resources/messages/en.yaml @@ -0,0 +1,7 @@ +default: + short: Unexpected error + long: Message not found in catalog +set: + greeting.hello: + short: Hello {{name}} + long: Hello {{name}}, welcome. diff --git a/examples/http/resources/messages/es.yaml b/examples/http/resources/messages/es.yaml new file mode 100644 index 0000000..c873ab9 --- /dev/null +++ b/examples/http/resources/messages/es.yaml @@ -0,0 +1,7 @@ +default: + short: Error inesperado + long: Mensaje no encontrado en el catálogo +set: + greeting.hello: + short: Hola {{name}} + long: Hola {{name}}, bienvenido. diff --git a/examples/load_messages/main.go b/examples/load_messages/main.go new file mode 100644 index 0000000..7853d3a --- /dev/null +++ b/examples/load_messages/main.go @@ -0,0 +1,59 @@ +// Load_messages demonstrates runtime loading via LoadMessages: keys must have the sys. prefix. +// Loaded messages survive Reload and are merged with YAML-loaded messages per language. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/loopcontext/msgcat" +) + +func main() { + dir, err := os.MkdirTemp("", "msgcat-load-*") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + + en := []byte(`default: + short: Unexpected error + long: Not found in catalog +set: + greeting.hello: + short: Hello from YAML +`) + if err := os.WriteFile(filepath.Join(dir, "en.yaml"), en, 0o600); err != nil { + panic(err) + } + + catalog, err := msgcat.NewMessageCatalog(msgcat.Config{ResourcePath: dir}) + if err != nil { + panic(err) + } + + // Load runtime-only message; Key must have prefix sys. + err = catalog.LoadMessages("en", []msgcat.RawMessage{ + { + Key: "sys.maintenance", + ShortTpl: "Under maintenance", + LongTpl: "Back in {{minutes}} minutes.", + Code: msgcat.CodeInt(503), // or msgcat.CodeString("ERR_MAINTENANCE") + }, + }) + if err != nil { + panic(err) + } + + ctx := context.WithValue(context.Background(), "language", "en") + + // Use YAML-loaded key + msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) + fmt.Println("YAML key:", msg.ShortText) + + // Use runtime-loaded key with params + msg = catalog.GetMessageWithCtx(ctx, "sys.maintenance", msgcat.Params{"minutes": 5}) + fmt.Println("sys. key:", msg.ShortText, "| long:", msg.LongText, "| code:", msg.Code) +} diff --git a/examples/metrics/resources/messages/en.yaml b/examples/metrics/resources/messages/en.yaml new file mode 100644 index 0000000..d3c931f --- /dev/null +++ b/examples/metrics/resources/messages/en.yaml @@ -0,0 +1,7 @@ +default: + short: Unexpected error + long: Message not found in catalog +set: + greeting.hello: + short: Hello + long: Hello, welcome. diff --git a/examples/metrics/resources/messages/es.yaml b/examples/metrics/resources/messages/es.yaml new file mode 100644 index 0000000..cd42118 --- /dev/null +++ b/examples/metrics/resources/messages/es.yaml @@ -0,0 +1,7 @@ +default: + short: Error inesperado + long: Mensaje no encontrado +set: + greeting.hello: + short: Hola + long: Hola, bienvenido. diff --git a/examples/reload/main.go b/examples/reload/main.go new file mode 100644 index 0000000..9b77c78 --- /dev/null +++ b/examples/reload/main.go @@ -0,0 +1,55 @@ +// Reload demonstrates: Reload(catalog) re-reads YAML from disk. Runtime-loaded +// messages (keys with sys. prefix) are preserved. On reload failure, previous state is kept. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/loopcontext/msgcat" +) + +func main() { + dir, err := os.MkdirTemp("", "msgcat-reload-*") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + + enPath := filepath.Join(dir, "en.yaml") + writeYAML := func(short string) { + body := fmt.Sprintf(`default: + short: Unexpected error + long: Not in catalog +set: + greeting.hello: + short: %s +`, short) + if err := os.WriteFile(enPath, []byte(body), 0o600); err != nil { + panic(err) + } + } + + writeYAML("Hello before reload") + catalog, err := msgcat.NewMessageCatalog(msgcat.Config{ResourcePath: dir}) + if err != nil { + panic(err) + } + + ctx := context.WithValue(context.Background(), "language", "en") + msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) + fmt.Println("Before reload:", msg.ShortText) + + // Change YAML on disk + writeYAML("Hello after reload") + + // Reload re-reads from disk + if err := msgcat.Reload(catalog); err != nil { + panic(err) + } + + msg = catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) + fmt.Println("After reload:", msg.ShortText) +} diff --git a/examples/stats/main.go b/examples/stats/main.go new file mode 100644 index 0000000..be0a69a --- /dev/null +++ b/examples/stats/main.go @@ -0,0 +1,57 @@ +// Stats demonstrates SnapshotStats, ResetStats, and stat keys (LanguageFallbacks, +// MissingLanguages, MissingMessages, TemplateIssues, DroppedEvents, LastReloadAt). +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/loopcontext/msgcat" +) + +func main() { + dir, err := os.MkdirTemp("", "msgcat-stats-*") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + + en := []byte(`default: + short: Unexpected error + long: Not in catalog +set: + greeting.hello: + short: Hello +`) + if err := os.WriteFile(filepath.Join(dir, "en.yaml"), en, 0o600); err != nil { + panic(err) + } + + catalog, err := msgcat.NewMessageCatalog(msgcat.Config{ + ResourcePath: dir, + StatsMaxKeys: 16, + }) + if err != nil { + panic(err) + } + + ctx := context.WithValue(context.Background(), "language", "en") + + _ = catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) + _ = catalog.GetMessageWithCtx(ctx, "missing.key", nil) + + stats, err := msgcat.SnapshotStats(catalog) + if err != nil { + panic(err) + } + fmt.Println("LanguageFallbacks:", stats.LanguageFallbacks) + fmt.Println("MissingMessages:", stats.MissingMessages) + fmt.Println("LastReloadAt:", stats.LastReloadAt.Round(time.Second)) + + _ = msgcat.ResetStats(catalog) + stats, _ = msgcat.SnapshotStats(catalog) + fmt.Println("After ResetStats - MissingMessages:", stats.MissingMessages) +} diff --git a/examples/strict/main.go b/examples/strict/main.go new file mode 100644 index 0000000..e597498 --- /dev/null +++ b/examples/strict/main.go @@ -0,0 +1,75 @@ +// Strict demonstrates StrictTemplates: when a template parameter is missing, +// the placeholder is replaced with and observer/stats record the issue. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/loopcontext/msgcat" +) + +type logObserver struct { + mu sync.Mutex +} + +func (o *logObserver) OnLanguageFallback(_, _ string) {} +func (o *logObserver) OnLanguageMissing(_ string) {} +func (o *logObserver) OnMessageMissing(lang string, msgKey string) { + o.mu.Lock() + defer o.mu.Unlock() + fmt.Printf("[observer] missing message %s:%s\n", lang, msgKey) +} +func (o *logObserver) OnTemplateIssue(lang string, msgKey string, issue string) { + o.mu.Lock() + defer o.mu.Unlock() + fmt.Printf("[observer] template issue %s:%s: %s\n", lang, msgKey, issue) +} + +func main() { + dir, err := os.MkdirTemp("", "msgcat-strict-*") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + + en := []byte(`default: + short: Unexpected error + long: Not in catalog +set: + greeting.template: + short: "Hello {{name}}, role {{role}}" + long: "Hello {{name}}, you are {{role}}." +`) + if err := os.WriteFile(filepath.Join(dir, "en.yaml"), en, 0o600); err != nil { + panic(err) + } + + observer := &logObserver{} + catalog, err := msgcat.NewMessageCatalog(msgcat.Config{ + ResourcePath: dir, + StrictTemplates: true, + Observer: observer, + ObserverBuffer: 64, + }) + if err != nil { + panic(err) + } + defer func() { _ = msgcat.Close(catalog) }() + + ctx := context.WithValue(context.Background(), "language", "en") + + // Missing param "role" => + msg := catalog.GetMessageWithCtx(ctx, "greeting.template", msgcat.Params{"name": "juan"}) + fmt.Println("Missing role:", msg.ShortText) + + // All params provided + msg = catalog.GetMessageWithCtx(ctx, "greeting.template", msgcat.Params{"name": "juan", "role": "admin"}) + fmt.Println("All params:", msg.ShortText) + + stats, _ := msgcat.SnapshotStats(catalog) + fmt.Println("Template issues count:", len(stats.TemplateIssues)) +} diff --git a/msgcat.go b/msgcat.go index a17fef4..a940695 100644 --- a/msgcat.go +++ b/msgcat.go @@ -19,10 +19,10 @@ const MessageCatalogNotFound = "Unexpected error in message catalog, language [% const ( // RuntimeKeyPrefix is required for message keys loaded via LoadMessages (e.g. "sys."). - RuntimeKeyPrefix = "sys." - CodeMissingMessage = 999999002 - CodeMissingLanguage = 999999001 - overflowStatKey = "__overflow__" + RuntimeKeyPrefix = "sys." + CodeMissingMessage = "msgcat.missing_message" + CodeMissingLanguage = "msgcat.missing_language" + overflowStatKey = "__overflow__" ) // messageKeyRegex validates message keys: [a-zA-Z0-9_.-]+ @@ -810,6 +810,7 @@ func (dmc *DefaultMessageCatalog) GetMessageWithCtx(ctx context.Context, msgKey ShortText: fmt.Sprintf(MessageCatalogNotFound, requestedLang, ""), LongText: fmt.Sprintf(MessageCatalogNotFound, requestedLang, "Please, contact support."), Code: CodeMissingLanguage, + Key: msgKey, } } if usedFallback { @@ -825,6 +826,7 @@ func (dmc *DefaultMessageCatalog) GetMessageWithCtx(ctx context.Context, msgKey ShortText: fmt.Sprintf(MessageCatalogNotFound, requestedLang, ""), LongText: fmt.Sprintf(MessageCatalogNotFound, requestedLang, "Please, contact support."), Code: CodeMissingLanguage, + Key: msgKey, } } @@ -835,7 +837,7 @@ func (dmc *DefaultMessageCatalog) GetMessageWithCtx(ctx context.Context, msgKey if msg, foundMsg := langMsgSet.Set[msgKey]; foundMsg { shortMessage = msg.ShortTpl longMessage = msg.LongTpl - code = msg.Code + code = string(msg.Code) } else { missingMessage = true } @@ -851,12 +853,13 @@ func (dmc *DefaultMessageCatalog) GetMessageWithCtx(ctx context.Context, msgKey LongText: longMessage, ShortText: shortMessage, Code: code, + Key: msgKey, } } func (dmc *DefaultMessageCatalog) WrapErrorWithCtx(ctx context.Context, err error, msgKey string, params Params) error { message := dmc.GetMessageWithCtx(ctx, msgKey, params) - return newCatalogError(message.Code, message.ShortText, message.LongText, err) + return newCatalogError(message.Code, message.Key, message.ShortText, message.LongText, err) } func (dmc *DefaultMessageCatalog) GetErrorWithCtx(ctx context.Context, msgKey string, params Params) error { diff --git a/structs.go b/structs.go index fe8757b..10f2085 100644 --- a/structs.go +++ b/structs.go @@ -12,18 +12,23 @@ type Messages struct { Set map[string]RawMessage `yaml:"set"` } +// RawMessage is one catalog entry. Code is optional and can be any value the user wants (e.g. "404", "ERR_NOT_FOUND"); +// it is for projects that map their own error/message codes into the catalog. Uniqueness is not enforced. type RawMessage struct { - LongTpl string `yaml:"long"` - ShortTpl string `yaml:"short"` - Code int `yaml:"code"` + LongTpl string `yaml:"long"` + ShortTpl string `yaml:"short"` + Code OptionalCode `yaml:"code"` // Optional. In YAML: code: 404 or code: "ERR_NOT_FOUND". Use Key when empty. // Key is set when loading via LoadMessages (runtime); YAML uses the map key as the message key. Key string `yaml:"-"` } +// Message is the resolved message for a request. Key is always the message key used for lookup. +// Code is optional (from catalog); when empty, use Key as the API identifier (e.g. in JSON responses). type Message struct { LongText string ShortText string - Code int + Code string // Optional; user-defined (e.g. "404", "ERR_001"). Empty when not set. Use Key when empty. + Key string // Message key (e.g. "greeting.hello"); set when found or when missing (requested key). } type MessageCatalogStats struct { diff --git a/test/suites/msgcat/msgcat_test.go b/test/suites/msgcat/msgcat_test.go index 3efd0fa..7c039ec 100644 --- a/test/suites/msgcat/msgcat_test.go +++ b/test/suites/msgcat/msgcat_test.go @@ -81,7 +81,23 @@ var _ = Describe("Message Catalog", func() { It("should return message code", func() { message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil) - Expect(message.Code).To(Equal(1)) + Expect(message.Code).To(Equal("1")) + }) + + It("should set Message.Key and use Key when Code is empty for API identifier", func() { + message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil) + Expect(message.Key).To(Equal("greeting.hello")) + // error.invalid_entry has no code in YAML, so Code is empty; API can use Key + msgNoCode := messageCatalog.GetMessageWithCtx(ctx.Ctx, "error.invalid_entry", msgcat.Params{"name": "x", "detail": "y"}) + Expect(msgNoCode.Code).To(Equal("")) + Expect(msgNoCode.Key).To(Equal("error.invalid_entry")) + err := messageCatalog.GetErrorWithCtx(ctx.Ctx, "error.invalid_entry", msgcat.Params{"name": "x", "detail": "y"}) + casted := err.(msgcat.Error) + Expect(casted.ErrorCode()).To(Equal("")) + Expect(casted.ErrorKey()).To(Equal("error.invalid_entry")) + // Missing message: Key is still the requested key + missingMsg := messageCatalog.GetMessageWithCtx(ctx.Ctx, "missing.key", nil) + Expect(missingMsg.Key).To(Equal("missing.key")) }) It("should return short message", func() { @@ -96,7 +112,7 @@ var _ = Describe("Message Catalog", func() { It("should return message code (with template)", func() { message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.template", msgcat.Params{"name": 1, "detail": "CODE"}) - Expect(message.Code).To(Equal(2)) + Expect(message.Code).To(Equal("2")) }) It("should return short message (with template)", func() { @@ -144,7 +160,7 @@ var _ = Describe("Message Catalog", func() { castedError := err.(msgcat.Error) Expect(castedError.GetShortMessage()).To(Equal("Hola, breve descripción")) Expect(castedError.GetLongMessage()).To(Equal("Hola, descripción muy larga. Solo puedes verme en la página de detalles.")) - Expect(castedError.ErrorCode()).To(Equal(1)) + Expect(castedError.ErrorCode()).To(Equal("1")) }) It("should be able to load messages from code", func() { @@ -152,7 +168,7 @@ var _ = Describe("Message Catalog", func() { Key: "sys.9001", LongTpl: "Some long system message", ShortTpl: "Some short system message", - Code: 9001, + Code: msgcat.CodeInt(9001), }}) Expect(err).NotTo(HaveOccurred()) err = messageCatalog.GetErrorWithCtx(ctx.Ctx, "sys.9001", nil) @@ -164,7 +180,7 @@ var _ = Describe("Message Catalog", func() { Key: "sys.9001", LongTpl: "Mensagem longa de sistema", ShortTpl: "Mensagem curta de sistema", - Code: 9001, + Code: msgcat.CodeInt(9001), }}) Expect(err).NotTo(HaveOccurred()) @@ -191,7 +207,7 @@ var _ = Describe("Message Catalog", func() { Key: "sys.loaded", LongTpl: "Loaded from code", ShortTpl: "Loaded from code short", - Code: 9001, + Code: msgcat.CodeInt(9001), }}) Expect(err).NotTo(HaveOccurred()) Expect(customCatalog.GetMessageWithCtx(ctx.Ctx, "sys.loaded", nil).ShortText).To(Equal("Loaded from code short")) @@ -276,7 +292,7 @@ var _ = Describe("Message Catalog", func() { Key: "sys.runtime", LongTpl: "Runtime long", ShortTpl: "Runtime short", - Code: 9001, + Code: msgcat.CodeInt(9001), }}) Expect(err).NotTo(HaveOccurred()) @@ -454,7 +470,7 @@ var _ = Describe("Message Catalog", func() { Key: key, LongTpl: fmt.Sprintf("Long %s", key), ShortTpl: fmt.Sprintf("Short %s", key), - Code: 9000 + i, + Code: msgcat.CodeInt(9000 + i), }}) if err != nil { errCh <- err From bce9604d9384a7c6d35282ae246283bbcd90d1da Mon Sep 17 00:00:00 2001 From: Christian Melgarejo Date: Thu, 26 Feb 2026 10:08:22 -0300 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20close=20gaps=20=E2=80=94=20CONVERSI?= =?UTF-8?q?ON=5FPLAN=20=C2=A712,=20MIGRATION=20=C2=A79,=20CHANGELOG=20Unre?= =?UTF-8?q?leased,=20duplicate-code=20test,=20YAML=20code=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- docs/CHANGELOG.md | 16 ++++++++++ docs/CONTEXT7.md | 2 +- docs/CONTEXT7_RETRIEVAL.md | 2 +- docs/CONVERSION_PLAN.md | 30 ++++++++++++++----- docs/MIGRATION.md | 18 +++++++++-- test/suites/msgcat/msgcat_test.go | 11 +++++++ test/suites/msgcat/resources/messages/en.yaml | 8 +++++ test/suites/msgcat/resources/messages/es.yaml | 8 +++++ 8 files changed, 82 insertions(+), 13 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0469a80..499a60b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,22 @@ This project follows Semantic Versioning. ## [Unreleased] +### Added +- String message keys (e.g. `"greeting.hello"`) instead of numeric codes for lookup. +- Named template parameters: `{{name}}`, `{{plural:count|...}}`, `{{num:amount}}`, `{{date:when}}` with `msgcat.Params`. +- Optional string `code` field: any value (e.g. `"404"`, `"ERR_NOT_FOUND"`); not unique. YAML accepts `code: 404` or `code: "ERR_001"`. Helpers `CodeInt()`, `CodeString()`. +- `Message.Key` and `ErrorKey()` for API identifier when code is empty. +- Runnable examples: `basic`, `load_messages`, `reload`, `strict`, `stats`; HTTP and metrics examples get message resources. +- Documentation: "Message and error codes" section; API examples (README, CONTEXT7); CONVERSION_PLAN final state; MIGRATION section 9 for string keys. + +### Changed (breaking) +- `GetMessageWithCtx(ctx, msgKey string, params Params)`; `WrapErrorWithCtx` / `GetErrorWithCtx` take `msgKey string` and `params Params`. Params can be nil. +- `Message.Code` and `ErrorCode()` are `string` (empty when not set). Use `Key` / `ErrorKey()` when empty. +- `LoadMessages`: each `RawMessage` must have `Key` with prefix `sys.`; no numeric code range. Use `Code: msgcat.CodeInt(503)` or `msgcat.CodeString("ERR_X")`. +- Observer: `OnMessageMissing(lang, msgKey string)`, `OnTemplateIssue(lang, msgKey string, issue string)`. +- YAML: `set` uses string keys; optional `code` per entry; `group` removed. Template placeholders are named only. +- Constants `CodeMissingMessage` and `CodeMissingLanguage` are strings (`"msgcat.missing_message"`, `"msgcat.missing_language"`). + ## [1.0.8] - 2026-02-25 ### Added diff --git a/docs/CONTEXT7.md b/docs/CONTEXT7.md index bcc0cbd..c1cb845 100644 --- a/docs/CONTEXT7.md +++ b/docs/CONTEXT7.md @@ -39,7 +39,7 @@ default: long: string set: : # string key, e.g. greeting.hello, error.not_found - code: int # optional, for API/HTTP response + code: int | string # optional (YAML accepts either; stored as string) short: string long: string ``` diff --git a/docs/CONTEXT7_RETRIEVAL.md b/docs/CONTEXT7_RETRIEVAL.md index 3a14e70..9daaaf1 100644 --- a/docs/CONTEXT7_RETRIEVAL.md +++ b/docs/CONTEXT7_RETRIEVAL.md @@ -52,7 +52,7 @@ default: long: string set: : # e.g. greeting.hello, error.not_found - code: int # optional + code: int | string # optional (YAML accepts either) short: string long: string ``` diff --git a/docs/CONVERSION_PLAN.md b/docs/CONVERSION_PLAN.md index a8a16ee..70c4f99 100644 --- a/docs/CONVERSION_PLAN.md +++ b/docs/CONVERSION_PLAN.md @@ -2,6 +2,8 @@ Plan for converting msgcat from **numeric message codes** and **positional template parameters** to **string message keys** and **named parameters**. No backward compatibility required. +**Note:** Sections 1–11 describe the planned design. **Section 12** documents the final implementation (optional string code, Key/ErrorKey, non-unique codes). + --- ## 1. Summary of changes @@ -12,7 +14,7 @@ Plan for converting msgcat from **numeric message codes** and **positional templ | YAML `set` keys | Numeric: `1:`, `2:` | String: `"greeting.hello":`, `"error.template":` | | Template params | Positional: `{{0}}`, `{{1}}`, `{{plural:0\|...}}` | Named: `{{name}}`, `{{plural:count\|...}}` | | API params | `msgParams ...interface{}` (ordered) | `params Params` (`map[string]interface{}`) | -| Returned `Message.Code` | `msgCode + Group` | Per-entry `RawMessage.Code` (optional; 0 if unset) | +| Returned `Message.Code` | `msgCode + Group` | Per-entry `RawMessage.Code` (optional **string**; empty if unset — see §12) | | `Messages.Group` | Used to compute display code | **Removed** (no longer needed) | | Observer / stats | `msgCode int` | `msgKey string` | @@ -28,11 +30,11 @@ Plan for converting msgcat from **numeric message codes** and **positional templ - **RawMessage** - Keep: `LongTpl`, `ShortTpl` (YAML: `long`, `short`). - - **Code**: keep `int`; meaning is “numeric code for API/HTTP response”. In YAML and `LoadMessages`, set per entry; if 0, returned `Message.Code` can be 0 when message is found, or use a sentinel when missing. - - No `Key` field: the map key is the message key. + - **Code**: optional; meaning is "code for API/HTTP response". In YAML and `LoadMessages`, set per entry. Implemented as **optional string** (OptionalCode in YAML accepts int or string); see §12. + - No `Key` field in YAML: the map key is the message key. For LoadMessages, `RawMessage.Key` is required (e.g. `sys.xxx`). - **Message** (return type) - - Unchanged: `LongText`, `ShortText`, `Code int` (still the value to expose to API clients). + - Unchanged: `LongText`, `ShortText`. **Code** is **string** (empty when not set); add **Key** (message key). See §12. - **Observer** - `OnMessageMissing(lang string, msgCode int)` → `OnMessageMissing(lang string, msgKey string)` @@ -198,9 +200,9 @@ type MessageCatalog interface { ## 7. Constants and errors -- **CodeMissingMessage**, **CodeMissingLanguage**: keep as int; still used as `Message.Code` when message or language is missing. -- **SystemMessageMinCode / MaxCode**: no longer used for LoadMessages; replace with “key must have prefix `sys.`” (or keep constant for doc purposes and use for nothing). -- **newCatalogError(code, ...)**: unchanged; still takes int code (from Message.Code). +- **CodeMissingMessage**, **CodeMissingLanguage**: implemented as **string** constants (e.g. `"msgcat.missing_message"`); see §12. +- **SystemMessageMinCode / MaxCode**: no longer used; LoadMessages requires key prefix `sys.` only. +- **newCatalogError(code, ...)**: takes **string** code; **ErrorCode()** and **ErrorKey()** on error type; see §12. --- @@ -210,7 +212,7 @@ type MessageCatalog interface { |------|--------| | **structs.go** | Messages.Set → map[string]RawMessage; remove Group; RawMessage add Key (optional in YAML); Observer signatures; add Params type. | | **msgcat.go** | Regexes for named placeholders; observerEvent.msgKey; catalogStats keys by msgKey; runtimeMessages map[string]map[string]RawMessage; normalizeAndValidateMessages (string keys, optional code); loadFromYaml merge by string key; renderTemplate(lang, msgKey, template, params map[string]interface{}); GetMessageWithCtx(ctx, msgKey string, params Params); WrapErrorWithCtx, GetErrorWithCtx; LoadMessages by RawMessage.Key with sys.* validation; remove MessageParams struct if replaced by Params. | -| **error.go** | No change (still int code). | +| **error.go** | ErrorCode() string, ErrorKey(); newCatalogError(code string, ...). | | **test/suites/msgcat/resources/messages/*.yaml** | String keys; named placeholders; remove group; optional code per entry. | | **test/suites/msgcat/msgcat_test.go** | All GetMessageWithCtx(..., code, a, b, c) → GetMessageWithCtx(..., "key", Params{...}); LoadMessages with RawMessage{Key: "sys.xxx", ...}; Observer expectations with msgKey string. | | **test/mock/msgcat.go** | Regenerate (or manually) interface with msgKey string, params Params. | @@ -263,3 +265,15 @@ type MessageCatalog interface { - In GetMessageWithCtx / WrapErrorWithCtx / GetErrorWithCtx: if params is nil, pass empty map to renderTemplate so templates see no params (missing placeholders get missing-param behavior). This plan is the single source of truth for the total conversion to string keys and named parameters. + +--- + +## 12. Final state (as implemented) + +Beyond the plan above, the following was implemented: + +- **Code is optional and string** — For projects that already have error/message codes (HTTP status, `"ERR_001"`, etc.), the catalog stores and returns that value. `RawMessage.Code` is type `OptionalCode` (YAML: `code: 404` or `code: "ERR_NOT_FOUND"`; in Go: `msgcat.CodeInt(503)` or `msgcat.CodeString("ERR_MAINT")`). `Message.Code` and `ErrorCode()` return `string`; empty when not set. +- **Codes are not unique** — The same code value can appear on multiple messages; uniqueness is not enforced. +- **Key / ErrorKey** — When code is empty, use `Message.Key` or `ErrorKey()` as the API identifier. +- **Constants** — `CodeMissingMessage` and `CodeMissingLanguage` are strings (e.g. `"msgcat.missing_message"`). +- **Helpers** — `msgcat.CodeInt(int)` and `msgcat.CodeString(string)` for building `RawMessage.Code` in code. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 0b348de..e2f82ee 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -69,12 +69,24 @@ Action: If you configured an observer, call `msgcat.Close(catalog)` on shutdown to flush/stop worker goroutine cleanly. -## 7) Backward compatibility notes +## 7) Backward compatibility notes (v1.x pre–string-keys) - Context key lookup remains compatible with both typed key and plain string key. -- Existing template syntax remains valid. -- `LoadMessages` system-code constraint (`9000-9999`) is unchanged. +- (If you are on a release with **string message keys and named parameters**, see section 9 below.) ## 8) Go version (v1.0.8+) Module requires **Go 1.26** or later. If you are on an older toolchain, upgrade before updating to msgcat v1.0.8. + +--- + +## 9) Migration to string keys and named parameters (breaking) + +If you are moving from numeric message codes and positional template parameters to the new API: + +- **Message keys** — Use string keys everywhere (e.g. `"greeting.hello"`, `"error.not_found"`). Replace `GetMessageWithCtx(ctx, 1, a, b)` with `GetMessageWithCtx(ctx, "greeting.hello", msgcat.Params{"name": a, "detail": b})`. +- **Templates** — Replace `{{0}}`, `{{1}}` with named placeholders: `{{name}}`, `{{plural:count|item|items}}`, `{{num:amount}}`, `{{date:when}}`. Pass a single `msgcat.Params` map. +- **Code field** — Code is now optional and string. In YAML use `code: 404` or `code: "ERR_NOT_FOUND"`. In Go use `msgcat.CodeInt(503)` or `msgcat.CodeString("ERR_MAINT")`. Codes are not required to be unique. When code is empty, use `Message.Key` or `ErrorKey()` as the API identifier. +- **LoadMessages** — Each message must have `Key` with prefix `sys.` (e.g. `sys.alert`). No numeric code range; use `Code: msgcat.CodeInt(9001)` or `Code: msgcat.CodeString("SYS_LOADED")` if you need a code. +- **Observer** — `OnMessageMissing(lang, msgKey string)` and `OnTemplateIssue(lang, msgKey string, issue string)` now take string `msgKey` instead of `msgCode int`. +- **YAML** — Remove `group`. Use string keys under `set:` and optional `code` per entry. See README and `docs/CONVERSION_PLAN.md`. diff --git a/test/suites/msgcat/msgcat_test.go b/test/suites/msgcat/msgcat_test.go index 7c039ec..927a1d0 100644 --- a/test/suites/msgcat/msgcat_test.go +++ b/test/suites/msgcat/msgcat_test.go @@ -100,6 +100,17 @@ var _ = Describe("Message Catalog", func() { Expect(missingMsg.Key).To(Equal("missing.key")) }) + It("allows duplicate codes: two messages with same Code resolve by key", func() { + msgA := messageCatalog.GetMessageWithCtx(ctx.Ctx, "dup.a", nil) + msgB := messageCatalog.GetMessageWithCtx(ctx.Ctx, "dup.b", nil) + Expect(msgA.Code).To(Equal("SHARED")) + Expect(msgB.Code).To(Equal("SHARED")) + Expect(msgA.Key).To(Equal("dup.a")) + Expect(msgB.Key).To(Equal("dup.b")) + Expect(msgA.ShortText).To(Equal("First message with shared code")) + Expect(msgB.ShortText).To(Equal("Second message with shared code")) + }) + It("should return short message", func() { message := messageCatalog.GetMessageWithCtx(ctx.Ctx, "greeting.hello", nil) Expect(message.ShortText).To(Equal("Hello short description")) diff --git a/test/suites/msgcat/resources/messages/en.yaml b/test/suites/msgcat/resources/messages/en.yaml index 09689c6..88863be 100644 --- a/test/suites/msgcat/resources/messages/en.yaml +++ b/test/suites/msgcat/resources/messages/en.yaml @@ -16,3 +16,11 @@ set: items.count: short: "You have {{count}} {{plural:count|item|items}}" long: "Total: {{num:amount}} generated at {{date:generatedAt}}" + dup.a: + code: "SHARED" + short: First message with shared code + long: First long + dup.b: + code: "SHARED" + short: Second message with shared code + long: Second long diff --git a/test/suites/msgcat/resources/messages/es.yaml b/test/suites/msgcat/resources/messages/es.yaml index 7f28532..e13fb16 100644 --- a/test/suites/msgcat/resources/messages/es.yaml +++ b/test/suites/msgcat/resources/messages/es.yaml @@ -9,3 +9,11 @@ set: items.count: short: "Tienes {{count}} {{plural:count|elemento|elementos}}" long: "Total: {{num:amount}} generado el {{date:generatedAt}}" + dup.a: + code: "SHARED" + short: Primera con código compartido + long: Primera larga + dup.b: + code: "SHARED" + short: Segunda con código compartido + long: Segunda larga From 87e3c5d935c4a1e1a6bbc0ff1d0850cd553864df Mon Sep 17 00:00:00 2001 From: Christian Melgarejo Date: Thu, 26 Feb 2026 11:16:11 -0300 Subject: [PATCH 4/5] feat: CLDR plurals, MessageDef + extract, optional group, CLI extract/merge - CLDR plural forms: RawMessage.ShortForms/LongForms/PluralParam, internal/plural - MessageDef type; CLI extract finds MessageDef literals and merges into source YAML - Optional group (OptionalGroup) on Messages; CLI preserves in extract/merge - cmd/msgcat: extract (keys + sync with MessageDef), merge (translate..yaml) - Examples: cldr_plural, msgdef. Docs: CLI_WORKFLOW_PLAN, CLDR_AND_GO_MESSAGES_PLAN - README, CONTEXT7, CONTEXT7_RETRIEVAL, CHANGELOG updated Made-with: Cursor --- .gitignore | 3 +- README.md | 73 ++++++- cmd/msgcat/extract.go | 304 ++++++++++++++++++++++++++++++ cmd/msgcat/extract_msgdef.go | 117 ++++++++++++ cmd/msgcat/extract_msgdef_test.go | 98 ++++++++++ cmd/msgcat/extract_sync.go | 60 ++++++ cmd/msgcat/extract_test.go | 102 ++++++++++ cmd/msgcat/main.go | 56 ++++++ cmd/msgcat/merge.go | 186 ++++++++++++++++++ docs/CHANGELOG.md | 5 + docs/CLDR_AND_GO_MESSAGES_PLAN.md | 171 +++++++++++++++++ docs/CLI_WORKFLOW_PLAN.md | 222 ++++++++++++++++++++++ docs/CONTEXT7.md | 35 +++- docs/CONTEXT7_RETRIEVAL.md | 18 +- docs/CONVERSION_PLAN.md | 8 +- examples/cldr_plural/main.go | 50 +++++ examples/msgdef/main.go | 79 ++++++++ group.go | 48 +++++ group_test.go | 145 ++++++++++++++ internal/plural/plural.go | 128 +++++++++++++ internal/plural/plural_test.go | 46 +++++ msgcat.go | 68 ++++++- msgcat_cldr_test.go | 78 ++++++++ structs.go | 28 ++- 24 files changed, 2100 insertions(+), 28 deletions(-) create mode 100644 cmd/msgcat/extract.go create mode 100644 cmd/msgcat/extract_msgdef.go create mode 100644 cmd/msgcat/extract_msgdef_test.go create mode 100644 cmd/msgcat/extract_sync.go create mode 100644 cmd/msgcat/extract_test.go create mode 100644 cmd/msgcat/main.go create mode 100644 cmd/msgcat/merge.go create mode 100644 docs/CLDR_AND_GO_MESSAGES_PLAN.md create mode 100644 docs/CLI_WORKFLOW_PLAN.md create mode 100644 examples/cldr_plural/main.go create mode 100644 examples/msgdef/main.go create mode 100644 group.go create mode 100644 group_test.go create mode 100644 internal/plural/plural.go create mode 100644 internal/plural/plural_test.go create mode 100644 msgcat_cldr_test.go diff --git a/.gitignore b/.gitignore index 496ee2c..f6d6495 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.DS_Store \ No newline at end of file +.DS_Store +/msgcat \ No newline at end of file diff --git a/README.md b/README.md index 1194a2e..c5a5a38 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,9 @@ One YAML file per language (e.g. `en.yaml`, `es.yaml`). Structure: | Field | Description | |----------|-------------| +| `group` | Optional. Int or string (e.g. `group: 0` or `group: "api"`) for organization; catalog does not interpret it. See [Optional group](#optional-group). | | `default`| Used when a message key is missing: `short` and `long` templates. | -| `set` | Map of string message key → entry with optional `code`, `short`, `long`. Keys use `[a-zA-Z0-9_.-]+` (e.g. `greeting.hello`, `error.not_found`). | +| `set` | Map of string message key → entry with optional `code`, `short`, `long`; optional **`short_forms`** / **`long_forms`** (CLDR: zero, one, two, few, many, other), **`plural_param`** (default `count`). Keys use `[a-zA-Z0-9_.-]+`. | Templates use **named parameters**: `{{name}}`, `{{plural:count\|singular\|plural}}`, `{{num:amount}}`, `{{date:when}}`. @@ -95,7 +96,7 @@ ctx := context.WithValue(context.Background(), "language", "es-AR") msg := catalog.GetMessageWithCtx(ctx, "greeting.hello", msgcat.Params{"name": "juan"}) fmt.Println(msg.ShortText) // "Usuario creado" fmt.Println(msg.LongText) // "Usuario juan fue creado correctamente" -fmt.Println(msg.Code) // 1 (from YAML optional `code`) +fmt.Println(msg.Code) // "1" (from YAML optional code; Code is string) params := msgcat.Params{"count": 3, "amount": 12345.5, "when": time.Now()} err := catalog.WrapErrorWithCtx(ctx, errors.New("db timeout"), "items.count", params) @@ -144,10 +145,14 @@ All fields of `msgcat.Config`: - **Template tokens (named parameters)** - `{{name}}` — simple substitution. - - `{{plural:count|singular|plural}}` — plural form by named count parameter. + - `{{plural:count|singular|plural}}` — binary plural by named count parameter. + - **CLDR plural forms** — optional `short_forms` / `long_forms` per entry (keys: `zero`, `one`, `two`, `few`, `many`, `other`) for full locale rules; see [CLDR and messages in Go](docs/CLDR_AND_GO_MESSAGES_PLAN.md). - `{{num:amount}}` — localized number for named parameter. - `{{date:when}}` — localized date for named parameter (`time.Time` or `*time.Time`). +- **Messages in Go** + Define content with **`msgcat.MessageDef`** (Key, Short, Long, or ShortForms/LongForms, Code). Run **`msgcat extract -source en.yaml -out en.yaml .`** to merge those definitions into your source YAML. + - **Strict template mode** With `StrictTemplates: true`, missing or invalid params produce `` and observer events. @@ -163,6 +168,59 @@ All fields of `msgcat.Config`: - **Observability** Optional `Observer` plus stats via `SnapshotStats` / `ResetStats`. Observer runs asynchronously and is panic-safe; queue overflow is counted in stats. +### Optional group + +Message files can include an optional top-level **`group`** with an integer or string value (e.g. `group: 0` or `group: "api"`). Use it to tag files for organization or tooling. The catalog does not interpret group; it only stores it. The CLI preserves `group` when running `extract` (sync) and `merge`. + +```yaml +group: api +default: + short: Unexpected error + long: ... +set: + error.not_found: + short: Not found + long: ... +``` + +--- + +## CLI workflow (extract & merge) + +The **msgcat** CLI helps discover message keys from Go code and prepare translation files. + +**Install:** + +```bash +go install github.com/loopcontext/msgcat/cmd/msgcat@latest +``` + +**Extract (keys only)** — list message keys used in Go (e.g. in `GetMessageWithCtx`, `WrapErrorWithCtx`, `GetErrorWithCtx`): + +```bash +msgcat extract [paths] # print keys to stdout (default: current dir) +msgcat extract -out keys.txt . # write keys to file +msgcat extract -include-tests . # include _test.go files +``` + +**Extract (sync to source YAML)** — add keys from API calls (empty `short`/`long`) and **merge `msgcat.MessageDef`** struct literals from Go (full content) into your source file: + +```bash +msgcat extract -source resources/messages/en.yaml -out resources/messages/en.yaml . +``` + +**Merge** — produce `translate..yaml` files from a source file. For each target language, missing or empty entries use source text as placeholder; existing translations are kept. Copies `group` and `default` from source. + +```bash +msgcat merge -source resources/messages/en.yaml -targetLangs es,fr -outdir resources/messages +# Creates translate.es.yaml, translate.fr.yaml + +msgcat merge -source resources/messages/en.yaml -targetDir resources/messages -outdir resources/messages +# Infers target languages from existing *.yaml in targetDir (excluding source and translate.*) +``` + +After translators fill `translate.es.yaml`, rename or copy it to `es.yaml` for runtime. + --- ## API @@ -180,7 +238,8 @@ All fields of `msgcat.Config`: - **`Params`** — `map[string]interface{}` for named template parameters (e.g. `msgcat.Params{"name": "juan"}`). - **`Message`** — `ShortText`, `LongText`, `Code string` (optional; see [Message and error codes](#message-and-error-codes)), `Key string` (message key; use when `Code` is empty). -- **`RawMessage`** — `Key` (required for `LoadMessages`), `ShortTpl`, `LongTpl`, optional `Code` (`OptionalCode`; see [Message and error codes](#message-and-error-codes)). +- **`RawMessage`** — `Key` (required for `LoadMessages`), `ShortTpl`, `LongTpl`, optional `Code`; optional **`ShortForms`** / **`LongForms`** (CLDR plural maps), **`PluralParam`** (default `"count"`). +- **`MessageDef`** — For “messages in Go”: `Key`, `Short`, `Long`, optional `ShortForms` / `LongForms`, `PluralParam`, `Code`. Use with **msgcat extract -source** to merge into YAML. - **`msgcat.Error`** — `Error()`, `Unwrap()`, `ErrorCode() string` (optional), `ErrorKey() string` (use when `ErrorCode()` is empty), `GetShortMessage()`, `GetLongMessage()`. ### Package-level helpers @@ -359,7 +418,7 @@ err := catalog.LoadMessages("en", []msgcat.RawMessage{ Key: "sys.maintenance", ShortTpl: "Service under maintenance", LongTpl: "The service is temporarily unavailable. Try again in {{minutes}} minutes.", - Code: 503, + Code: msgcat.CodeInt(503), }, }) // Then use the key like any other @@ -473,6 +532,8 @@ Runnable programs (each uses a temp dir and minimal YAML so you can run from any | Example | What it demonstrates | |---------|----------------------| | `examples/basic` | NewMessageCatalog, GetMessageWithCtx (nil and with Params), GetErrorWithCtx, WrapErrorWithCtx, msgcat.Error | +| `examples/cldr_plural` | CLDR plural forms (short_forms/long_forms) with one/other and plural_param | +| `examples/msgdef` | MessageDef in Go and extract workflow | | `examples/load_messages` | LoadMessages with `sys.` prefix, using runtime-loaded keys | | `examples/reload` | Reload(catalog) to re-read YAML from disk | | `examples/strict` | StrictTemplates and observer for missing template params | @@ -495,3 +556,5 @@ Run from repo root: `go run ./examples/basic`, `go run ./examples/load_messages` | [SECURITY.md](SECURITY.md) | How to report vulnerabilities. | | [Context7](docs/CONTEXT7.md) | Machine-friendly API docs. | | [Context7 retrieval](docs/CONTEXT7_RETRIEVAL.md) | Retrieval-oriented chunks. | +| [CLI workflow plan](docs/CLI_WORKFLOW_PLAN.md) | Extract and merge workflow; optional group. | +| [CLDR and messages in Go](docs/CLDR_AND_GO_MESSAGES_PLAN.md) | CLDR plurals and MessageDef + extract (roadmap). | diff --git a/cmd/msgcat/extract.go b/cmd/msgcat/extract.go new file mode 100644 index 0000000..a1157a8 --- /dev/null +++ b/cmd/msgcat/extract.go @@ -0,0 +1,304 @@ +package main + +import ( + "flag" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/loopcontext/msgcat" +) + +// extractConfig holds flags for the extract command. +type extractConfig struct { + paths []string + out string + source string + format string + includeTests bool + msgcatPkg string + excludeDirs string +} + +func usageExtract() { + fmt.Fprintf(os.Stderr, `usage: msgcat extract [options] [paths] + +Extract discovers message keys referenced in Go code (GetMessageWithCtx, WrapErrorWithCtx, +GetErrorWithCtx) and optionally syncs them into a source language YAML file. + +If no paths are provided, scans the current directory. + +Modes: + - Keys only: omit -source; writes unique keys (one per line) to -out or stdout. + - Sync to YAML: set -source to a msgcat YAML file; adds missing keys with empty short/long, writes to -out. + +Flags: +`) + flag.CommandLine.PrintDefaults() +} + +func parseExtractFlags(args []string) (*extractConfig, error) { + fs := flag.NewFlagSet("extract", flag.ExitOnError) + fs.Usage = usageExtract + var cfg extractConfig + fs.StringVar(&cfg.out, "out", "", "Output file (keys: one key per line; sync: YAML path). Default stdout for keys.") + fs.StringVar(&cfg.source, "source", "", "Source language YAML path (enables sync mode).") + fs.StringVar(&cfg.format, "format", "keys", "For keys: 'keys' (one per line) or 'yaml'. For sync: ignored.") + fs.BoolVar(&cfg.includeTests, "include-tests", false, "Include _test.go files.") + fs.StringVar(&cfg.msgcatPkg, "msgcat-pkg", "github.com/loopcontext/msgcat", "Import path for msgcat (detect calls from this package).") + fs.StringVar(&cfg.excludeDirs, "exclude", "vendor", "Comma-separated dir names to skip (e.g. vendor).") + if err := fs.Parse(args); err != nil { + return nil, err + } + cfg.paths = fs.Args() + if len(cfg.paths) == 0 { + cfg.paths = []string{"."} + } + return &cfg, nil +} + +// keyExtractor collects message keys from Go files via AST and MessageDef struct literals. +type keyExtractor struct { + msgcatImport string + msgcatName string // local name in current file (e.g. "msgcat") + keys map[string]struct{} + defs map[string]msgcat.RawMessage // key -> content from MessageDef literals + methodArgIdx map[string]int +} + +func newKeyExtractor(msgcatImport string) *keyExtractor { + return &keyExtractor{ + msgcatImport: msgcatImport, + keys: make(map[string]struct{}), + defs: make(map[string]msgcat.RawMessage), + methodArgIdx: map[string]int{ + "GetMessageWithCtx": 1, + "GetErrorWithCtx": 1, + "WrapErrorWithCtx": 2, + }, + } +} + +func (e *keyExtractor) extractFromFile(path string, src []byte) error { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return err + } + e.msgcatName = e.msgcatImportName(f) + if e.msgcatName == "" { + return nil + } + ast.Walk(e, f) + return nil +} + +func (e *keyExtractor) msgcatImportName(file *ast.File) string { + for _, imp := range file.Imports { + if imp.Path == nil { + continue + } + path := strings.Trim(imp.Path.Value, `"`) + if path != e.msgcatImport { + continue + } + if imp.Name != nil { + return imp.Name.Name + } + return "msgcat" + } + return "" +} + +func (e *keyExtractor) Visit(node ast.Node) ast.Visitor { + // MessageDef struct literals (standalone, or in slice/map) + if cl, ok := node.(*ast.CompositeLit); ok { + e.visitCompositeLit(cl) + return e + } + // API calls: GetMessageWithCtx, WrapErrorWithCtx, GetErrorWithCtx + call, ok := node.(*ast.CallExpr) + if !ok { + return e + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return e + } + idx, ok := e.methodArgIdx[sel.Sel.Name] + if !ok { + return e + } + if idx >= len(call.Args) { + return e + } + key := e.extractString(call.Args[idx]) + if key != "" { + e.keys[key] = struct{}{} + } + return e +} + +func (e *keyExtractor) visitCompositeLit(cl *ast.CompositeLit) { + switch t := cl.Type.(type) { + case *ast.SelectorExpr: + if e.isMessageDefType(t) { + key, raw := e.extractMessageDefFromCompositeLit(cl) + if key != "" { + e.defs[key] = raw + e.keys[key] = struct{}{} + } + } + case *ast.ArrayType: + if e.isMessageDefType(t.Elt) { + for _, elt := range cl.Elts { + if inner, ok := elt.(*ast.CompositeLit); ok { + key, raw := e.extractMessageDefFromCompositeLit(inner) + if key != "" { + e.defs[key] = raw + e.keys[key] = struct{}{} + } + } + } + } + case *ast.MapType: + if e.isMessageDefType(t.Value) { + for _, elt := range cl.Elts { + kve, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + inner, ok := kve.Value.(*ast.CompositeLit) + if !ok { + continue + } + key, raw := e.extractMessageDefFromCompositeLit(inner) + if key != "" { + e.defs[key] = raw + e.keys[key] = struct{}{} + } + } + } + } +} + +func (e *keyExtractor) extractString(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.BasicLit: + if t.Kind == token.STRING { + s, _ := unquote(t.Value) + return s + } + case *ast.BinaryExpr: + if t.Op == token.ADD { + return e.extractString(t.X) + e.extractString(t.Y) + } + } + return "" +} + +func unquote(s string) (string, error) { + if len(s) < 2 || s[0] != '"' { + return s, nil + } + // Simple unquote: strip quotes and handle \" + var b strings.Builder + for i := 1; i < len(s)-1; i++ { + if s[i] == '\\' && i+1 < len(s)-1 { + i++ + if s[i] == '"' { + b.WriteByte('"') + } else { + b.WriteByte(s[i]) + } + continue + } + b.WriteByte(s[i]) + } + return b.String(), nil +} + +func (e *keyExtractor) sortedKeys() []string { + out := make([]string, 0, len(e.keys)) + for k := range e.keys { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func runExtract(cfg *extractConfig) error { + excludeSet := make(map[string]struct{}) + for _, d := range strings.Split(cfg.excludeDirs, ",") { + d = strings.TrimSpace(d) + if d != "" { + excludeSet[d] = struct{}{} + } + } + ext := newKeyExtractor(cfg.msgcatPkg) + for _, path := range cfg.paths { + path = filepath.Clean(path) + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + err = filepath.Walk(path, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + if _, skip := excludeSet[info.Name()]; skip { + return filepath.SkipDir + } + return nil + } + if filepath.Ext(p) != ".go" { + return nil + } + if !cfg.includeTests && strings.HasSuffix(p, "_test.go") { + return nil + } + src, err := os.ReadFile(p) + if err != nil { + return err + } + return ext.extractFromFile(p, src) + }) + } else { + if filepath.Ext(path) != ".go" { + continue + } + if !cfg.includeTests && strings.HasSuffix(path, "_test.go") { + continue + } + src, err := os.ReadFile(path) + if err != nil { + return err + } + err = ext.extractFromFile(path, src) + } + if err != nil { + return err + } + } + keys := ext.sortedKeys() + if cfg.source != "" { + return runExtractSync(cfg, keys, ext.defs) + } + // Keys-only output (one per line) + out := strings.Join(keys, "\n") + if out != "" { + out += "\n" + } + if cfg.out != "" { + return os.WriteFile(cfg.out, []byte(out), 0644) + } + fmt.Print(out) + return nil +} diff --git a/cmd/msgcat/extract_msgdef.go b/cmd/msgcat/extract_msgdef.go new file mode 100644 index 0000000..5231b37 --- /dev/null +++ b/cmd/msgcat/extract_msgdef.go @@ -0,0 +1,117 @@ +package main + +import ( + "go/ast" + "go/token" + "strconv" + + "github.com/loopcontext/msgcat" +) + +// isMessageDefType reports whether typ is msgcat.MessageDef or *msgcat.MessageDef from the given package. +func (e *keyExtractor) isMessageDefType(typ ast.Expr) bool { + var sel *ast.SelectorExpr + switch t := typ.(type) { + case *ast.SelectorExpr: + sel = t + case *ast.StarExpr: + sel, _ = t.X.(*ast.SelectorExpr) + } + if sel == nil { + return false + } + id, ok := sel.X.(*ast.Ident) + if !ok { + return false + } + return id.Name == e.msgcatName && sel.Sel.Name == "MessageDef" +} + +// extractMessageDefFromCompositeLit extracts a MessageDef from a composite literal if possible. +// Returns key and RawMessage. key is empty if not a MessageDef or Key is missing. +func (e *keyExtractor) extractMessageDefFromCompositeLit(cl *ast.CompositeLit) (key string, raw msgcat.RawMessage) { + if !e.isMessageDefType(cl.Type) { + return "", msgcat.RawMessage{} + } + data := make(map[string]interface{}) + for _, elt := range cl.Elts { + kve, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + kname, ok := kve.Key.(*ast.Ident) + if !ok { + continue + } + data[kname.Name] = e.exprToValue(kve.Value) + } + keyVal, _ := data["Key"] + key, _ = keyVal.(string) + if key == "" { + return "", msgcat.RawMessage{} + } + raw.ShortTpl, _ = data["Short"].(string) + raw.LongTpl, _ = data["Long"].(string) + raw.PluralParam, _ = data["PluralParam"].(string) + if raw.PluralParam == "" { + raw.PluralParam = "count" + } + if sf, ok := data["ShortForms"].(map[string]string); ok && len(sf) > 0 { + raw.ShortForms = sf + } + if lf, ok := data["LongForms"].(map[string]string); ok && len(lf) > 0 { + raw.LongForms = lf + } + if c, ok := data["Code"]; ok { + raw.Code = codeFromValue(c) + } + return key, raw +} + +func (e *keyExtractor) exprToValue(expr ast.Expr) interface{} { + switch t := expr.(type) { + case *ast.BasicLit: + if t.Kind == token.STRING { + s, _ := unquote(t.Value) + return s + } + if t.Kind == token.INT { + n, _ := strconv.Atoi(t.Value) + return n + } + case *ast.CompositeLit: + // map literal e.g. map[string]string{"one": "...", "other": "..."} + if m, ok := e.compositeLitToMap(t); ok { + return m + } + } + return nil +} + +func (e *keyExtractor) compositeLitToMap(cl *ast.CompositeLit) (map[string]string, bool) { + out := make(map[string]string) + for _, elt := range cl.Elts { + kve, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + k := e.exprToValue(kve.Key) + v := e.exprToValue(kve.Value) + ks, ok1 := k.(string) + vs, ok2 := v.(string) + if ok1 && ok2 { + out[ks] = vs + } + } + return out, len(out) > 0 +} + +func codeFromValue(v interface{}) msgcat.OptionalCode { + switch t := v.(type) { + case string: + return msgcat.OptionalCode(t) + case int: + return msgcat.CodeInt(t) + } + return "" +} \ No newline at end of file diff --git a/cmd/msgcat/extract_msgdef_test.go b/cmd/msgcat/extract_msgdef_test.go new file mode 100644 index 0000000..0baa4ee --- /dev/null +++ b/cmd/msgcat/extract_msgdef_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExtractMessageDef(t *testing.T) { + dir := t.TempDir() + src := []byte(` +package main +import "github.com/loopcontext/msgcat" +var personCats = msgcat.MessageDef{ + Key: "person.cats", + ShortForms: map[string]string{ + "one": "{{name}} has {{count}} cat.", + "other": "{{name}} has {{count}} cats.", + }, + LongForms: map[string]string{ + "one": "{{name}} has one cat.", + "other": "{{name}} has {{count}} cats.", + }, +} +`) + if err := os.WriteFile(filepath.Join(dir, "msgdef.go"), src, 0644); err != nil { + t.Fatal(err) + } + ext := newKeyExtractor("github.com/loopcontext/msgcat") + if err := ext.extractFromFile(filepath.Join(dir, "msgdef.go"), src); err != nil { + t.Fatal(err) + } + if len(ext.defs) != 1 { + t.Fatalf("expected 1 def, got %d", len(ext.defs)) + } + raw, ok := ext.defs["person.cats"] + if !ok { + t.Fatal("expected person.cats in defs") + } + if len(raw.ShortForms) != 2 { + t.Errorf("ShortForms: got %d entries", len(raw.ShortForms)) + } + if raw.ShortForms["one"] != "{{name}} has {{count}} cat." { + t.Errorf("ShortForms[one]: got %q", raw.ShortForms["one"]) + } + if raw.ShortForms["other"] != "{{name}} has {{count}} cats." { + t.Errorf("ShortForms[other]: got %q", raw.ShortForms["other"]) + } + if _, ok := ext.keys["person.cats"]; !ok { + t.Error("expected person.cats in keys") + } +} + +func TestExtractMessageDef_syncToYAML(t *testing.T) { + dir := t.TempDir() + enYaml := []byte(`default: + short: Err + long: Err +set: + greeting.hello: + short: Hello + long: Hello there +`) + enPath := filepath.Join(dir, "en.yaml") + if err := os.WriteFile(enPath, enYaml, 0644); err != nil { + t.Fatal(err) + } + goSrc := []byte(` +package p +import "github.com/loopcontext/msgcat" +var _ = msgcat.MessageDef{Key: "person.cats", Short: "Cats", Long: "Cats count"} +`) + goPath := filepath.Join(dir, "p.go") + if err := os.WriteFile(goPath, goSrc, 0644); err != nil { + t.Fatal(err) + } + cfg := &extractConfig{source: enPath, out: filepath.Join(dir, "en_out.yaml")} + ext := newKeyExtractor("github.com/loopcontext/msgcat") + if err := ext.extractFromFile(goPath, goSrc); err != nil { + t.Fatal(err) + } + keys := ext.sortedKeys() + if err := runExtractSync(cfg, keys, ext.defs); err != nil { + t.Fatal(err) + } + out, err := os.ReadFile(cfg.out) + if err != nil { + t.Fatal(err) + } + content := string(out) + if !strings.Contains(content, "person.cats") { + t.Errorf("expected person.cats in output: %s", content) + } + if !strings.Contains(content, "Cats") { + t.Errorf("expected Short/Long in output: %s", content) + } +} diff --git a/cmd/msgcat/extract_sync.go b/cmd/msgcat/extract_sync.go new file mode 100644 index 0000000..eeb8348 --- /dev/null +++ b/cmd/msgcat/extract_sync.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "os" + + "github.com/loopcontext/msgcat" + "gopkg.in/yaml.v2" +) + +// runExtractSync reads the source YAML, merges in keys (empty short/long if missing) and +// defs (MessageDef content from Go), preserves group and default, writes to cfg.out. +func runExtractSync(cfg *extractConfig, keys []string, defs map[string]msgcat.RawMessage) error { + src, err := os.ReadFile(cfg.source) + if err != nil { + return fmt.Errorf("read source %s: %w", cfg.source, err) + } + var m msgcat.Messages + if err := yaml.Unmarshal(src, &m); err != nil { + return fmt.Errorf("parse source YAML: %w", err) + } + if m.Set == nil { + m.Set = make(map[string]msgcat.RawMessage) + } + added := 0 + // MessageDef from Go: add or overwrite by key + for key, raw := range defs { + if key == "" { + continue + } + m.Set[key] = raw + added++ + } + // Keys from API calls: add if missing (empty short/long) + for _, key := range keys { + if key == "" { + continue + } + if _, exists := m.Set[key]; exists { + continue + } + m.Set[key] = msgcat.RawMessage{ShortTpl: "", LongTpl: ""} + added++ + } + outPath := cfg.out + if outPath == "" { + outPath = cfg.source + } + out, err := yaml.Marshal(&m) + if err != nil { + return fmt.Errorf("marshal YAML: %w", err) + } + if err := os.WriteFile(outPath, out, 0644); err != nil { + return fmt.Errorf("write %s: %w", outPath, err) + } + if added > 0 { + fmt.Fprintf(os.Stderr, "msgcat: added %d key(s) to %s\n", added, outPath) + } + return nil +} diff --git a/cmd/msgcat/extract_test.go b/cmd/msgcat/extract_test.go new file mode 100644 index 0000000..7976d85 --- /dev/null +++ b/cmd/msgcat/extract_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "sort" + "testing" +) + +func TestKeyExtractor(t *testing.T) { + src := []byte(` +package main +import "github.com/loopcontext/msgcat" +func main() { + var catalog msgcat.MessageCatalog + _ = catalog.GetMessageWithCtx(ctx, "greeting.hello", nil) + _ = catalog.GetErrorWithCtx(ctx, "error.not_found", nil) + _ = catalog.WrapErrorWithCtx(ctx, err, "wrap.key", nil) +} +`) + ext := newKeyExtractor("github.com/loopcontext/msgcat") + if err := ext.extractFromFile("test.go", src); err != nil { + t.Fatal(err) + } + keys := ext.sortedKeys() + want := []string{"error.not_found", "greeting.hello", "wrap.key"} + if len(keys) != len(want) { + t.Fatalf("got %d keys %v, want %d %v", len(keys), keys, len(want), want) + } + for i := range want { + if keys[i] != want[i] { + t.Errorf("keys[%d] = %q, want %q", i, keys[i], want[i]) + } + } +} + +func TestKeyExtractor_skipsFilesWithoutMsgcatImport(t *testing.T) { + src := []byte(` +package main +func main() { + GetMessageWithCtx(ctx, "ignored", nil) +} +`) + ext := newKeyExtractor("github.com/loopcontext/msgcat") + if err := ext.extractFromFile("test.go", src); err != nil { + t.Fatal(err) + } + keys := ext.sortedKeys() + if len(keys) != 0 { + t.Errorf("expected no keys when file does not import msgcat, got %v", keys) + } +} + +func TestKeyExtractor_stringConcat(t *testing.T) { + src := []byte(` +package main +import "github.com/loopcontext/msgcat" +func main() { + key := "prefix." + "suffix" + catalog.GetMessageWithCtx(ctx, key, nil) +} +`) + ext := newKeyExtractor("github.com/loopcontext/msgcat") + if err := ext.extractFromFile("test.go", src); err != nil { + t.Fatal(err) + } + // We only extract string literals; "prefix." + "suffix" is a binary expr, we support it + keys := ext.sortedKeys() + if len(keys) != 0 { + // Our extractString for BinaryExpr only handles direct string literals; key is an ident, not a literal at call site + t.Logf("keys (if we extracted from ident): %v", keys) + } +} + +func TestUnquote(t *testing.T) { + tests := []struct { + in string + want string + }{ + {`"hello"`, "hello"}, + {`"a\"b"`, `a"b`}, + } + for _, tt := range tests { + got, _ := unquote(tt.in) + if got != tt.want { + t.Errorf("unquote(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestMergeTargetLangsList(t *testing.T) { + cfg := &mergeConfig{targetLangs: "es, fr , de"} + got := cfg.targetLangsList() + sort.Strings(got) + want := []string{"de", "es", "fr"} + if len(got) != len(want) { + t.Fatalf("got %v", got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("got[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/cmd/msgcat/main.go b/cmd/msgcat/main.go new file mode 100644 index 0000000..a6bfed4 --- /dev/null +++ b/cmd/msgcat/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + sub := os.Args[1] + args := os.Args[2:] + var err error + switch sub { + case "extract": + cfg, e := parseExtractFlags(args) + if e != nil { + err = e + break + } + err = runExtract(cfg) + case "merge": + cfg, e := parseMergeFlags(args) + if e != nil { + err = e + break + } + err = runMerge(cfg) + case "help", "-h", "--help": + usage() + os.Exit(0) + default: + fmt.Fprintf(os.Stderr, "msgcat: unknown subcommand %q\n", sub) + usage() + os.Exit(1) + } + if err != nil { + fmt.Fprintf(os.Stderr, "msgcat: %v\n", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `msgcat - message catalog CLI for extract and merge workflow + +usage: msgcat [options] [paths] + +commands: + extract Discover message keys from Go code; optionally sync into source YAML. + merge Produce translate..yaml files from a source message file. + +Use 'msgcat extract -h' or 'msgcat merge -h' for command-specific flags. +`) +} diff --git a/cmd/msgcat/merge.go b/cmd/msgcat/merge.go new file mode 100644 index 0000000..440bae0 --- /dev/null +++ b/cmd/msgcat/merge.go @@ -0,0 +1,186 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/loopcontext/msgcat" + "gopkg.in/yaml.v2" +) + +// mergeConfig holds flags for the merge command. +type mergeConfig struct { + source string + targetLangs string + targetDir string + outdir string + translatePrefix string +} + +func usageMerge() { + fmt.Fprintf(os.Stderr, `usage: msgcat merge [options] + +Merge produces per-language translate files from a source message file. For each target +language, writes translate..yaml with every key from the source; keys missing or +empty in the target use source short/long as placeholder. Copies source 'group' and +'default' into each output file. + +Flags: +`) + flag.CommandLine.PrintDefaults() +} + +func parseMergeFlags(args []string) (*mergeConfig, error) { + fs := flag.NewFlagSet("merge", flag.ExitOnError) + fs.Usage = usageMerge + var cfg mergeConfig + fs.StringVar(&cfg.source, "source", "", "Source message file (e.g. resources/messages/en.yaml). Required.") + fs.StringVar(&cfg.targetLangs, "targetLangs", "", "Comma-separated target language tags (e.g. es,fr).") + fs.StringVar(&cfg.targetDir, "targetDir", "", "Directory containing target YAMLs; language inferred from filenames (e.g. es.yaml -> es).") + fs.StringVar(&cfg.outdir, "outdir", "", "Where to write translate..yaml (default: same dir as source).") + fs.StringVar(&cfg.translatePrefix, "translatePrefix", "translate.", "Filename prefix for output files.") + if err := fs.Parse(args); err != nil { + return nil, err + } + return &cfg, nil +} + +func runMerge(cfg *mergeConfig) error { + if cfg.source == "" { + return fmt.Errorf("merge: -source is required") + } + srcContent, err := os.ReadFile(cfg.source) + if err != nil { + return fmt.Errorf("read source: %w", err) + } + var source msgcat.Messages + if err := yaml.Unmarshal(srcContent, &source); err != nil { + return fmt.Errorf("parse source YAML: %w", err) + } + if source.Set == nil { + source.Set = make(map[string]msgcat.RawMessage) + } + // Ensure default has at least placeholders for valid msgcat YAML + if source.Default.ShortTpl == "" { + source.Default.ShortTpl = "Unexpected error" + } + if source.Default.LongTpl == "" { + source.Default.LongTpl = "Message not found in catalog" + } + + targets := cfg.targetLangsList() + if len(targets) == 0 && cfg.targetDir != "" { + targets, err = readTargetLangsFromDir(cfg.targetDir, cfg.source) + if err != nil { + return err + } + } + if len(targets) == 0 { + return fmt.Errorf("merge: specify -targetLangs or -targetDir") + } + + outdir := cfg.outdir + if outdir == "" { + outdir = filepath.Dir(cfg.source) + } + prefix := cfg.translatePrefix + if prefix == "" { + prefix = "translate." + } + + for _, lang := range targets { + outPath := filepath.Join(outdir, prefix+lang+".yaml") + var target msgcat.Messages + targetPath := filepath.Join(filepath.Dir(cfg.source), lang+".yaml") + if cfg.targetDir != "" { + targetPath = filepath.Join(cfg.targetDir, lang+".yaml") + } + if tb, err := os.ReadFile(targetPath); err == nil { + _ = yaml.Unmarshal(tb, &target) + } + if target.Set == nil { + target.Set = make(map[string]msgcat.RawMessage) + } + // Build merged: for each key in source, use target if non-empty short and long, else source + merged := msgcat.Messages{ + Group: source.Group, + Default: source.Default, + Set: make(map[string]msgcat.RawMessage), + } + for key, srcEntry := range source.Set { + dstEntry := target.Set[key] + if dstEntry.ShortTpl != "" && dstEntry.LongTpl != "" { + merged.Set[key] = dstEntry + } else { + entry := msgcat.RawMessage{ + ShortTpl: srcEntry.ShortTpl, + LongTpl: srcEntry.LongTpl, + Code: srcEntry.Code, + ShortForms: srcEntry.ShortForms, + LongForms: srcEntry.LongForms, + PluralParam: srcEntry.PluralParam, + } + merged.Set[key] = entry + } + } + out, err := yaml.Marshal(&merged) + if err != nil { + return fmt.Errorf("marshal %s: %w", lang, err) + } + if err := os.WriteFile(outPath, out, 0644); err != nil { + return fmt.Errorf("write %s: %w", outPath, err) + } + fmt.Fprintf(os.Stderr, "msgcat: wrote %s\n", outPath) + } + return nil +} + +func (c *mergeConfig) targetLangsList() []string { + if c.targetLangs == "" { + return nil + } + var out []string + for _, s := range strings.Split(c.targetLangs, ",") { + s = strings.TrimSpace(strings.ToLower(s)) + if s != "" { + out = append(out, s) + } + } + return out +} + +func readTargetLangsFromDir(dir, sourcePath string) ([]string, error) { + sourceBase := filepath.Base(sourcePath) + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var langs []string + seen := make(map[string]struct{}) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".yaml") { + continue + } + if name == sourceBase || strings.HasPrefix(name, "translate.") { + continue + } + lang := strings.TrimSuffix(name, ".yaml") + lang = strings.TrimSpace(strings.ToLower(lang)) + if lang == "" { + continue + } + if _, ok := seen[lang]; ok { + continue + } + seen[lang] = struct{}{} + langs = append(langs, lang) + } + return langs, nil +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 499a60b..d682611 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,11 @@ This project follows Semantic Versioning. ## [Unreleased] ### Added +- **CLDR plural forms:** optional `short_forms` / `long_forms` on `RawMessage` (keys: zero, one, two, few, many, other) and `plural_param` (default `count`). `internal/plural` selects form by language and count. Binary `{{plural:count|singular|plural}}` unchanged. +- **MessageDef:** type for defining messages in Go (Key, Short, Long, ShortForms, LongForms, PluralParam, Code). **msgcat extract -source** finds MessageDef struct literals and merges their content into source YAML. +- **Optional group:** `Messages.Group` and `OptionalGroup` (int or string in YAML, e.g. `group: 0` or `group: "api"`). CLI extract/merge preserve group. +- CLI **extract** (keys from GetMessageWithCtx/WrapErrorWithCtx/GetErrorWithCtx; sync to YAML with MessageDef merge) and **merge** (translate.\.yaml with group and plural fields copied). +- Examples: `cldr_plural`, `msgdef`. Docs: CLI_WORKFLOW_PLAN, CLDR_AND_GO_MESSAGES_PLAN. - String message keys (e.g. `"greeting.hello"`) instead of numeric codes for lookup. - Named template parameters: `{{name}}`, `{{plural:count|...}}`, `{{num:amount}}`, `{{date:when}}` with `msgcat.Params`. - Optional string `code` field: any value (e.g. `"404"`, `"ERR_NOT_FOUND"`); not unique. YAML accepts `code: 404` or `code: "ERR_001"`. Helpers `CodeInt()`, `CodeString()`. diff --git a/docs/CLDR_AND_GO_MESSAGES_PLAN.md b/docs/CLDR_AND_GO_MESSAGES_PLAN.md new file mode 100644 index 0000000..4d604b2 --- /dev/null +++ b/docs/CLDR_AND_GO_MESSAGES_PLAN.md @@ -0,0 +1,171 @@ +# CLDR plurals and messages in Go – design plan + +Plan to add **CLDR-style plural forms** and **messages defined in Go with extract** to msgcat, closing the gap with go-i18n. + +**Implementation status:** Done. Library has `ShortForms`/`LongForms`/`PluralParam` on `RawMessage`, `internal/plural` for form selection, `MessageDef` type, CLI extract finds MessageDef literals and merges into YAML, merge copies plural fields. See README, examples/cldr_plural, examples/msgdef. + +--- + +## 1. CLDR plurals + +### 1.1 Goal + +Support the full set of CLDR plural categories (**zero**, **one**, **two**, **few**, **many**, **other**) so that languages with more than two forms (e.g. Arabic, Russian, Welsh) can have correct pluralization. Current msgcat only has binary `{{plural:count|singular|plural}}`. + +### 1.2 Backward compatibility + +- Keep existing **short** / **long** strings and **`{{plural:count|singular|plural}}`** unchanged. No breaking change. +- Add **optional** plural form maps. When present, they are used instead of the binary plural token for that entry. + +### 1.3 YAML format (optional plural forms) + +Per entry in `set`, allow optional **short_forms** and **long_forms** maps from CLDR form name to template: + +```yaml +set: + items.count: + short: "You have {{count}} {{plural:count|item|items}}" # fallback / simple case + long: "Total: {{num:amount}} items" + # Optional CLDR forms (when set, used instead of short/long for this key when plural count is provided) + person.cats: + short_forms: + one: "{{.Name}} has {{.Count}} cat." + other: "{{.Name}} has {{.Count}} cats." + long_forms: + one: "{{.Name}} has one cat." + other: "{{.Name}} has {{.Count}} cats." + plural_param: count # which param drives plural selection (default: "count") +``` + +- **Form names:** `zero`, `one`, `two`, `few`, `many`, `other` (CLDR standard). +- **plural_param:** optional; name of the param used for plural selection (default `"count"` when forms are present). Omitted when only `short`/`long` and binary `{{plural:...}}` are used. +- If **short_forms** / **long_forms** are present, resolution uses the plural param and the resolved language to pick a form (see below), then renders that template with the same named params as today. + +### 1.4 Library types + +- **RawMessage** (add optional fields): + - `ShortForms map[string]string` `yaml:"short_forms,omitempty"` — keys: zero, one, two, few, many, other. + - `LongForms map[string]string` `yaml:"long_forms,omitempty"` + - `PluralParam string` `yaml:"plural_param,omitempty"` — default `"count"` when forms are used. + +- **Plural form selector:** add a small dependency or internal package that, given **(language tag, count)** returns the CLDR form for that locale (e.g. `en` + 1 → `one`, 5 → `other`; `ar` + 0 → `zero`, 1 → `one`, 2 → `two`, 3–10 → `few`, 11–99 → `many`, other → `other`). Options: + - **A)** Depend on **golang.org/x/text** and use its plural/message support if it exposes form selection. + - **B)** Vendor or copy the **go-i18n internal/plural** approach (generated rules from CLDR). + - **C)** Minimal **internal/plural** in msgcat: embed a compact table of (locale → rule) and a small evaluator (operands + rule AST). More work but no new dependency. + +- **Resolution:** When looking up a message: + - If the entry has **ShortForms** (and **LongForms**): get **plural_param** from params (default `"count"`); if missing, fall back to **short**/long and existing binary plural token behavior. Otherwise compute CLDR form from (resolvedLang, count), then pick **ShortForms[form]** (or **ShortForms["other"]** if form missing); same for long. Render the chosen template with **renderTemplate** as today. + - If the entry has only **short**/**long**, behavior is unchanged (including `{{plural:count|...}}`). + +### 1.5 Edge cases + +- **Missing form:** If the chosen form (e.g. `few`) is not in the map, fall back to `other`, then to `short`/`long` if no forms. +- **Missing plural param:** If **short_forms** is set but the plural param is missing in params, fall back to **short**/long (and binary plural token if present). +- **Invalid count type:** Same as today for binary plural: observer + optional strict placeholder. + +### 1.6 Merge and CLI + +- **merge:** When building translate files, copy **short_forms** / **long_forms** / **plural_param** from source to placeholder entries so translators can fill per-form strings. +- **extract:** Keys-only and sync modes unchanged; MessageDef extraction (see below) can emit **short_forms** / **long_forms** when defined in Go. + +--- + +## 2. Messages in Go + extract + +### 2.1 Goal + +Let developers define message **content** in Go (like go-i18n’s `i18n.Message` with One/Other), and have **msgcat extract** discover those definitions and write them into the source YAML. So “messages in Go” is the source of truth for content at extract time; at runtime the catalog still loads from YAML (or from runtime-loaded messages). + +### 2.2 New type: MessageDef + +A struct that mirrors what can live in the catalog (key + short/long or plural forms + optional code). Used in Go for definition and for extract; not required at runtime for normal lookup. + +```go +// MessageDef defines a message that can be extracted to YAML or used as default content. +// Use with msgcat extract to generate or update source message files from Go. +type MessageDef struct { + Key string // Message key (e.g. "person.cats"). Required. + Short string // Short template (or use ShortForms for CLDR). + Long string // Long template (or use LongForms for CLDR). + ShortForms map[string]string // Optional CLDR forms: zero, one, two, few, many, other. + LongForms map[string]string + PluralParam string // Param name for plural selection (default "count"). + Code OptionalCode // Optional code (e.g. CodeInt(404)). +} +``` + +- **Key** is required. **Short**/**Long** or **ShortForms**/**LongForms** (or both; forms take precedence when plural param is present). +- **PluralParam** defaults to `"count"` when ShortForms/LongForms are used. + +### 2.3 Where MessageDef appears in Go + +- **Standalone literals** for extract: + ```go + var personCats = msgcat.MessageDef{ + Key: "person.cats", + ShortForms: map[string]string{ + "one": "{{.Name}} has {{.Count}} cat.", + "other": "{{.Name}} has {{.Count}} cats.", + }, + LongForms: map[string]string{ + "one": "{{.Name}} has one cat.", + "other": "{{.Name}} has {{.Count}} cats.", + }, + } + ``` +- **Slices/maps** of MessageDef (e.g. `[]msgcat.MessageDef{ ... }`, `map[string]msgcat.MessageDef`) so extract can find multiple definitions in one place. + +- **Optional future:** Allow passing a `*MessageDef` as default to `GetMessageWithCtx` when the key is missing (like go-i18n’s DefaultMessage). Not required for “messages in Go + extract.” + +### 2.4 Extract command extension + +- **Current behavior:** Find keys from `GetMessageWithCtx` / `WrapErrorWithCtx` / `GetErrorWithCtx`; optionally sync those keys into source YAML with empty short/long. +- **New behavior:** Also find **msgcat.MessageDef** (and `*msgcat.MessageDef`) struct literals: + - In variable declarations, slice literals, map literals. + - Extract **Key**, **Short**, **Long**, **ShortForms**, **LongForms**, **PluralParam**, **Code** (handling string/int for Code like OptionalCode). +- **Merge with sync:** When running **extract -source en.yaml -out en.yaml**: + - Keys from API calls: add missing keys with empty short/long (current behavior). + - MessageDef literals: add or update entries by **Key** with the extracted Short/Long/ShortForms/LongForms/PluralParam/Code. So Go becomes the source of truth for those entries’ content in the generated YAML. + +### 2.5 Implementation sketch (extract) + +- In the same AST walk that finds API calls, also find: + - **CompositeLit** whose type is `msgcat.MessageDef` or `*msgcat.MessageDef` (via selector from msgcat import). + - **KeyValueExpr** in map literals whose value type is MessageDef. + - **CompositeLit** elements in slice literals whose element type is MessageDef. +- For each MessageDef literal, collect Key and the rest; build a list of “message definitions from Go.” +- In sync mode: when writing the source YAML, for each such definition set `set[Key]` to the corresponding RawMessage (Short/Long or ShortForms/LongForms, PluralParam, Code). Keys-only from API calls that are not in any MessageDef still get empty short/long. + +### 2.6 YAML output for MessageDef + +- If MessageDef has **ShortForms**/LongForms, write **short_forms** / **long_forms** (and **plural_param** if not default) in the YAML. +- If it has only **Short**/Long, write **short** / **long** as today. +- **code** written per existing OptionalCode rules. + +--- + +## 3. Implementation order + +1. **CLDR plural selector (internal or dependency)** + Implement or depend on a function `Form(lang string, count int) string` returning one of zero/one/two/few/many/other. Add tests for a few locales (en, ar, ru). + +2. **RawMessage plural forms** + Add **ShortForms**, **LongForms**, **PluralParam** to RawMessage. In **GetMessageWithCtx**, when these are set, get plural param from params, compute form, select template, render. Keep existing short/long and `{{plural:...}}` unchanged when forms are not set. + +3. **MessageDef type and extract** + Add **MessageDef** in the library. Extend CLI extract to find MessageDef literals and collect Key + content. In sync mode, merge these into source YAML (add/update by Key). + +4. **Merge and docs** + Ensure merge copies short_forms/long_forms/plural_param. Document CLDR forms and MessageDef + extract in README and CLI workflow plan. + +--- + +## 4. Out of scope (for this plan) + +- **Changing existing binary plural token:** `{{plural:count|singular|plural}}` stays as-is when CLDR forms are not used. +- **Runtime default message from Go:** Passing a MessageDef into GetMessageWithCtx as fallback when key is missing is a possible later extension. +- **Hash/change detection** for merged translations (like goi18n) remains out of scope. + +--- + +This plan is the single source of truth for adding CLDR plurals and “messages in Go + extract” to msgcat. diff --git a/docs/CLI_WORKFLOW_PLAN.md b/docs/CLI_WORKFLOW_PLAN.md new file mode 100644 index 0000000..51a3edd --- /dev/null +++ b/docs/CLI_WORKFLOW_PLAN.md @@ -0,0 +1,222 @@ +# CLI workflow plan: extract + merge + +Plan for adding a **msgcat** CLI that closes the workflow gap vs go-i18n: **extract** (discover message keys from code and/or YAML) and **merge** (prepare translation files from a source language). + +**Goals** + +- Developers can discover which message keys are used in Go and ensure the source catalog has them. +- Translators get per-language files that list only missing/empty messages with source text as placeholder. +- Optional **group** (int or string) in YAML for organization (e.g. `group: "api"` or `group: 0`); CLI and library support it. +- CLI is additive; library gets a small, backward-compatible extension for group. + +**Scope** + +- New package or module: `cmd/msgcat` (or `internal/msgcatcli`) producing a `msgcat` binary. +- Two subcommands: `extract`, `merge`. +- Message format: current msgcat YAML (`default` + `set` with optional `code`) plus optional **group** (int or string)—see §7. +- Small library extension for group support so YAML and CLI stay in sync. + +--- + +## 1. Extract command + +**Purpose:** Discover message keys referenced in Go and optionally sync them into the source language YAML so every used key exists (with empty or placeholder content for new keys). + +### 1.1 Behavior + +- **Scan Go code** for message key string literals in: + - `GetMessageWithCtx(ctx, "key", ...)` + - `WrapErrorWithCtx(ctx, err, "key", ...)` + - `GetErrorWithCtx(ctx, "key", ...)` +- Keys are the first string literal argument to these functions. Support both direct string literals and concatenation of string literals (optional). +- **Option A (keys only):** Output the unique set of keys (e.g. one per line to stdout, or to a file). Use case: “what keys does the code use?” or input to tooling. +- **Option B (sync to source YAML):** Read the source language file (e.g. `en.yaml`), add any key that appears in Go but not in `set`; new entries get empty `short`/`long` (or a comment/placeholder). Write back to source file or `-out`. Use case: “after adding new GetMessageWithCtx calls, update en.yaml so translators see new keys.” + +We can implement both: e.g. `extract -keys` prints keys; `extract -source en.yaml -out en.yaml` syncs keys into that file. + +### 1.2 Implementation sketch + +- Walk directories for `*.go` (respect `-exclude` for vendor, etc.). +- Skip `_test.go` unless `-include-tests` (default: skip). +- Use `go/ast` to find call expressions whose selector is `GetMessageWithCtx`, `WrapErrorWithCtx`, or `GetErrorWithCtx` and package is `msgcat` (or configurable import path). Extract first string argument (handle basic `+` concatenation if desired). +- Dedupe keys. +- **Keys-only mode:** print keys (e.g. sorted) to stdout or `-out`. +- **Sync mode:** parse source YAML (reuse or mirror msgcat’s `Messages` struct), merge in missing keys with empty short/long, write YAML (preserve order/comment where feasible or at least valid YAML). + +### 1.3 Flags (proposed) + +| Flag | Description | +|------|-------------| +| `paths` | Directories or files to scan (default: `.`) | +| `-out` | Output file (keys mode: one key per line; sync mode: YAML path) | +| `-source` | Source language YAML path (enables sync mode; e.g. `resources/messages/en.yaml`) | +| `-format` | For keys: `keys` (one per line) or `yaml` (minimal YAML stub). For sync: ignored, output is YAML. | +| `-include-tests` | Include `_test.go` files (default: false) | +| `-msgcat-pkg` | Import path for msgcat (default: `github.com/loopcontext/msgcat`) so we detect the right calls | + +--- + +## 2. Merge command + +**Purpose:** From a source language file (e.g. `en.yaml`), produce per-language **translate** files that contain every key from the source; for keys missing or empty in the target, use source short/long as placeholder so translators can fill them. + +### 2.1 Behavior + +- **Input:** + - One **source** message file (e.g. `en.yaml`). All keys from this file define the canonical set. + - Optional **target** message files (e.g. `es.yaml`, `fr.yaml`). If a target file exists, we use it to prefill already-translated entries. +- **Output:** + - One file per target language: **`translate..yaml`** (e.g. `translate.es.yaml`). + - Content: same structure as msgcat YAML (`default` + `set`). For each key in source: + - If target has a non-empty `short` and `long` for that key, use target’s content (considered translated). + - Otherwise, use source’s `short`/`long` as placeholder (and optional `code` from source). + - So translators open `translate.es.yaml`, see English where Spanish is missing, and replace with Spanish. When done, they **rename or copy** `translate.es.yaml` → `es.yaml` for use at runtime. No separate “active” file in msgcat’s loader; the directory just has `en.yaml`, `es.yaml`, etc. + +### 2.2 Edge cases + +- **Target file missing:** Treat as “no translations yet”; output `translate..yaml` with all keys from source (source content as placeholder). +- **Key in target but not in source:** Optionally drop (so translate file is “keys we care about”) or keep (document in plan). Recommend: drop so translate file is exactly “keys from source that need translation.” +- **default block:** Copy source `default` into each translate file so the file is valid; translators can replace with localized default. +- **code field:** Copy from source when creating placeholder entries; if target had a value, keep target’s (or always use source for consistency—document choice). + +### 2.3 Implementation sketch + +- Parse source YAML into a structure that matches msgcat’s `Messages` (default + set). +- For each target language (from `-targetLangs` and/or from existing `*.yaml` in a directory): + - Parse target file if present. + - Build merged `set`: for each key in source, if target has non-empty short and long, use target; else use source. + - Write `translate..yaml` with merged content (and default from source). +- Language tag comes from filename (e.g. `es.yaml` → `es`) or from `-targetLangs es,fr`. + +### 2.4 Flags (proposed) + +| Flag | Description | +|------|-------------| +| `-source` | Source message file (e.g. `resources/messages/en.yaml`) | +| `-targetLangs` | Comma-separated target language tags (e.g. `es,fr`). If not set, can infer from `-targetDir` *.yaml (excluding source and translate.*). | +| `-targetDir` | Directory containing target YAMLs (e.g. `resources/messages`). Optional; can pass target files as positional args. | +| `-outdir` | Where to write `translate..yaml` (default: same dir as source or `.`) | +| `-translatePrefix` | Filename prefix for translation files (default: `translate.`) so output is `translate.es.yaml`. | + +--- + +## 3. Workflow summary + +**Initial setup** + +1. Maintain source language (e.g. `en.yaml`) with all messages. +2. Run: `msgcat extract -source resources/messages/en.yaml -out resources/messages/en.yaml` when new keys are added in code (or run extract -keys and add keys manually). + +**Adding a new language (e.g. Spanish)** + +1. Run: `msgcat merge -source resources/messages/en.yaml -targetLangs es -outdir resources/messages` +2. Get `resources/messages/translate.es.yaml` with all keys and English placeholders. +3. Translators fill in Spanish. +4. Rename/copy `translate.es.yaml` → `es.yaml` in the same directory. msgcat loads `es.yaml` at runtime. + +**Adding new keys to en.yaml later** + +1. Update `en.yaml` (or run extract -source to add keys from Go). +2. Run merge again: `msgcat merge -source resources/messages/en.yaml -targetDir resources/messages -outdir resources/messages` +3. New keys appear in existing `translate.es.yaml` (and other targets) with English placeholders; existing translations are preserved in the merge output. + +**Optional: validate keys in code vs YAML** + +1. `msgcat extract -out keys.txt` (keys only). +2. Compare keys.txt to keys in en.yaml to find “in code but not in catalog” or “in catalog but not in code.” + +--- + +## 4. Implementation order + +0. **Optional group (library)** + Add `OptionalGroup` type (unmarshal/marshal int or string), add optional `Group` to `Messages`. No runtime behavior change. Tests for YAML round-trip. Enables CLI to preserve group in extract/merge. + +1. **Extract (keys only)** + AST walk, collect keys from the three API calls, output unique list. Tests with a few fixture .go files. + +2. **Extract (sync to source YAML)** + Parse msgcat YAML (reuse types or duplicate minimal struct in CLI to avoid coupling), add missing keys with empty short/long, preserve `group`, write YAML. Tests with in-memory YAML. + +3. **Merge** + Parse source + target YAMLs, copy source `group` into translate files, build translate.*.yaml per target, write to outdir. Tests with fixture YAMLs. + +4. **CLI wiring** + `cmd/msgcat/main.go` with subcommands extract/merge, flags, and clear usage. Install with `go install github.com/loopcontext/msgcat/cmd/msgcat@latest`. + +5. **Docs** + README section “CLI workflow (extract & merge)”, link from main README; add "Optional group"; optional CONTEXT7 update. + +--- + +## 5. File layout + +- **Option A:** `cmd/msgcat/` in the same repo (same module). Binary is `msgcat`. Dependencies: only stdlib + YAML parser (and go/ast). Prefer not to depend on msgcat package for parsing so CLI works even if YAML structs are internal; we can duplicate minimal YAML structs in the CLI or use a generic map + marshal. +- **Option B:** Separate module `github.com/loopcontext/msgcat/cmd/msgcat` or `github.com/loopcontext/msgcat-cli`. Same repo is simpler; same module keeps one go.mod. + +Recommendation: **same repo, same module**, `cmd/msgcat/` with minimal duplication of YAML structures (or import msgcat and use its `Messages`/`RawMessage` if they stay public and we don’t pull in heavy deps). If we want zero dependency on msgcat at parse time, the CLI can define its own `messagesDoc` struct for YAML and produce the same format. + +--- + +## 6. Out of scope (for this plan) + +- **CLDR plurals:** Merge does not need to understand plural forms; msgcat’s current `{{plural:count|singular|plural}}` is preserved as literal strings in YAML. +- **Hash / change detection:** We could add optional hash of source content per key to detect “translation was for old version” (like goi18n). Defer to a later iteration. +- **Other formats (TOML/JSON):** Only YAML in/out for now to match msgcat. + +--- + +## 7. Optional group (int or string) + +**Purpose:** Allow message files or entries to be tagged with a **group** that can be either an integer or a string (e.g. `group: "api"` or `group: 0`). Use for organization, filtering, or tooling—e.g. all API errors in group `"api"`, or numeric groups for legacy systems. + +### 7.1 Library (msgcat) changes + +- **Type `OptionalGroup`** + Same pattern as `OptionalCode`: a type that unmarshals from **int** or **string** in YAML. Internal representation can be string (e.g. `0` → `"0"`, `"api"` → `"api"`); marshal back as string, or preserve kind so that numeric input round-trips as `group: 0` and string as `group: "api"` (implementation choice). + +- **Where group lives** + - **File-level (recommended):** Add optional `Group OptionalGroup` to **`Messages`**. In YAML, top-level `group: "api"` or `group: 0` applies to the whole file. One group per file is the common case. + - **Per-entry (optional):** Add optional `Group OptionalGroup` to **`RawMessage`**. In YAML, each key in `set` can have `group: "api"` or `group: 0` to override or sub-categorize. Implement if needed after file-level is done. + +- **Runtime behavior** + The catalog does not interpret group; it is only stored and available for tooling or future use (e.g. filtering, export). No change to `Message` or `GetMessageWithCtx` return type unless we later add a way to expose group (e.g. `Message.Group`). For this plan, adding the field to the YAML struct and parsing is enough. + +- **YAML example (file-level)** + + ```yaml + group: api + default: + short: Unexpected error + long: ... + set: + error.not_found: + short: Not found + long: ... + ``` + + Or numeric: + + ```yaml + group: 0 + default: + short: Unexpected error + set: + greeting.hello: + short: Hello + long: ... + ``` + +### 7.2 CLI behavior + +- **Extract:** When reading/writing source YAML (sync mode), preserve the existing `group` field. When creating a new source file from keys only, omit group (or add a default) per project preference. +- **Merge:** When building translate files, copy the source file’s `group` into each output `translate..yaml` so the translated file has the same group as the source. If per-entry group is added later, copy from source entry when creating placeholders. + +### 7.3 Implementation notes + +- **OptionalGroup** can live in the same package as `OptionalCode` (e.g. `code.go` or new `group.go`). UnmarshalYAML: accept `int`, `int64`, `string`; store as string for simplicity. MarshalYAML: if the string is numeric (e.g. `strconv.Atoi` succeeds), emit as int for readability; otherwise emit as string. That gives `group: 0` and `group: "api"` round-trip. +- Validation: no uniqueness or allowed-values check; group is opaque to the library. + +--- + +This plan is the single source of truth for implementing the extract/merge CLI workflow and optional group support in msgcat. diff --git a/docs/CONTEXT7.md b/docs/CONTEXT7.md index c1cb845..edc36c0 100644 --- a/docs/CONTEXT7.md +++ b/docs/CONTEXT7.md @@ -34,6 +34,7 @@ Each language file is named `.yaml`, for example `en.yaml`, `es.yaml`. ### Schema ```yaml +group: int | string # optional (e.g. group: 0 or group: "api"); catalog does not interpret it default: short: string long: string @@ -42,9 +43,12 @@ set: code: int | string # optional (YAML accepts either; stored as string) short: string long: string + short_forms: { zero?, one?, two?, few?, many?, other? } # optional CLDR plural forms + long_forms: { zero?, one?, two?, few?, many?, other? } + plural_param: string # optional; param name for plural selection (default "count") ``` -Keys use `[a-zA-Z0-9_.-]+`. Templates use **named parameters**: `{{name}}`, `{{plural:count|singular|plural}}`, `{{num:amount}}`, `{{date:when}}`. +Keys use `[a-zA-Z0-9_.-]+`. Templates use **named parameters**: `{{name}}`, `{{plural:count|singular|plural}}`, `{{num:amount}}`, `{{date:when}}`. Optional **CLDR forms** (short_forms/long_forms) use the plural param and resolved language to pick a form; see docs/CLDR_AND_GO_MESSAGES_PLAN.md. ### Validation rules @@ -107,15 +111,34 @@ Named template parameters. Use `msgcat.Params{"name": "juan", "count": 3}`. ```go type RawMessage struct { - LongTpl string `yaml:"long"` - ShortTpl string `yaml:"short"` - Code OptionalCode `yaml:"code"` // Optional; any value. YAML: code: 404 or code: "ERR_001". Not unique. - Key string `yaml:"-"` // required when using LoadMessages; must have prefix sys. + LongTpl string `yaml:"long"` + ShortTpl string `yaml:"short"` + Code OptionalCode `yaml:"code"` + ShortForms map[string]string `yaml:"short_forms,omitempty"` // optional CLDR: zero, one, two, few, many, other + LongForms map[string]string `yaml:"long_forms,omitempty"` + PluralParam string `yaml:"plural_param,omitempty"` // default "count" + Key string `yaml:"-"` // required when using LoadMessages; must have prefix sys. } ``` `OptionalCode` unmarshals from YAML as int or string. In Go use `msgcat.CodeInt(503)` or `msgcat.CodeString("ERR_MAINT")`. +### `type MessageDef struct` + +Used for "messages in Go": define content in code and run **msgcat extract -source en.yaml -out en.yaml .** to merge into YAML. + +```go +type MessageDef struct { + Key string + Short string + Long string + ShortForms map[string]string // optional CLDR forms + LongForms map[string]string + PluralParam string + Code OptionalCode +} +``` + ### `type MessageCatalogStats struct` ```go @@ -496,7 +519,7 @@ err := catalog.LoadMessages("en", []msgcat.RawMessage{{ Key: "sys.maintenance", ShortTpl: "Under maintenance", LongTpl: "Back in {{minutes}} minutes.", - Code: 503, + Code: msgcat.CodeInt(503), }}) msg := catalog.GetMessageWithCtx(ctx, "sys.maintenance", msgcat.Params{"minutes": 5}) ``` diff --git a/docs/CONTEXT7_RETRIEVAL.md b/docs/CONTEXT7_RETRIEVAL.md index 9daaaf1..bd65e8e 100644 --- a/docs/CONTEXT7_RETRIEVAL.md +++ b/docs/CONTEXT7_RETRIEVAL.md @@ -2,7 +2,7 @@ Purpose: compact, chunk-friendly reference for LLM retrieval/indexing. -Runnable examples: `examples/basic`, `examples/load_messages`, `examples/reload`, `examples/strict`, `examples/stats`, `examples/http`, `examples/metrics`. +Runnable examples: `examples/basic`, `examples/cldr_plural`, `examples/msgdef`, `examples/load_messages`, `examples/reload`, `examples/strict`, `examples/stats`, `examples/http`, `examples/metrics`. ## C01_IDENTITY - Module: `github.com/loopcontext/msgcat` @@ -13,9 +13,11 @@ Runnable examples: `examples/basic`, `examples/load_messages`, `examples/reload` - Load localized messages from YAML by language. - Resolve language from `context.Context`. - Fallback chain for missing regional/language variants. -- Render templates with named parameters (plural/number/date tokens). +- Render templates with named parameters (binary plural token + optional CLDR short_forms/long_forms; number/date tokens). - Wrap errors with localized short/long text and code. - Runtime reload + runtime-loaded messages (key prefix `sys.`). +- Optional CLDR plural forms (short_forms/long_forms) and optional group (int or string) in YAML. +- MessageDef: define messages in Go; CLI **msgcat extract -source** merges into YAML. - Observability hooks + in-memory counters. - Concurrency-safe operations. @@ -47,16 +49,20 @@ Defaults: ## C04_YAML_SCHEMA ```yaml +group: int | string # optional (e.g. group: 0 or group: "api") default: short: string long: string set: - : # e.g. greeting.hello, error.not_found - code: int | string # optional (YAML accepts either) + : + code: int | string short: string long: string + short_forms: { zero?, one?, two?, few?, many?, other? } # optional CLDR + long_forms: { zero?, one?, two?, few?, many?, other? } + plural_param: string # default "count" ``` -Keys: `[a-zA-Z0-9_.-]+`. Templates use named params: `{{name}}`, `{{plural:count|a|b}}`, `{{num:amount}}`, `{{date:when}}`. +Keys: `[a-zA-Z0-9_.-]+`. Templates: `{{name}}`, `{{plural:count|a|b}}`, `{{num:amount}}`, `{{date:when}}`. Optional CLDR forms: see docs/CLDR_AND_GO_MESSAGES_PLAN.md. Validation: - default short/long: at least one non-empty - `set` omitted => initialized empty @@ -316,7 +322,7 @@ err := catalog.LoadMessages("en", []msgcat.RawMessage{{ Key: "sys.maintenance", ShortTpl: "Under maintenance", LongTpl: "Back in {{minutes}} minutes.", - Code: 503, + Code: msgcat.CodeInt(503), }}) msg := catalog.GetMessageWithCtx(ctx, "sys.maintenance", msgcat.Params{"minutes": 5}) ``` diff --git a/docs/CONVERSION_PLAN.md b/docs/CONVERSION_PLAN.md index 70c4f99..4f625ad 100644 --- a/docs/CONVERSION_PLAN.md +++ b/docs/CONVERSION_PLAN.md @@ -92,7 +92,7 @@ set: short: Hello short description long: Hello veeery long description. greeting.template: - code: 1002 # optional: numeric code for API/HTTP + code: 1002 # optional (stored as string; see §12) short: Hello template {{name}}, this is nice {{detail}} long: Hello veeery long {{name}} description. Details {{detail}}. items.count: @@ -101,7 +101,7 @@ set: ``` - **Key format**: recommend `[a-zA-Z0-9_.-]+` (e.g. `greeting.hello`, `error.not_found`). Reject empty or invalid keys in validation. -- **Optional `code`**: if present, use as `Message.Code`; if absent, use 0 (or define a convention). +- **Optional `code`**: if present, use as `Message.Code`; if absent, empty string (see §12). --- @@ -157,7 +157,7 @@ type MessageCatalog interface { - **normalizeAndValidateMessages** - Remove Group validation. - Ensure `Set` is `map[string]RawMessage`. - - For each key in Set: validate key format (non-empty, allowed chars); set `raw.Code` from YAML `code` if present (else leave 0); no longer set `raw.Code = code` from numeric key. + - For each key in Set: validate key format (non-empty, allowed chars); set `raw.Code` from YAML `code` if present (else leave empty; see §12); no longer set `raw.Code = code` from numeric key. ### 6.2 loadFromYaml merge @@ -168,7 +168,7 @@ type MessageCatalog interface { - Signature: `(ctx, msgKey string, params Params)`. - Resolve language as today. - Lookup: `langMsgSet.Set[msgKey]`; if missing, call `onMessageMissing(resolvedLang, msgKey)` and use default message, set `Message.Code = CodeMissingMessage`. -- If found: use `raw.ShortTpl`, `raw.LongTpl`, and `Message.Code = raw.Code` (0 if not set). +- If found: use `raw.ShortTpl`, `raw.LongTpl`, and `Message.Code = raw.Code` (empty string if not set; see §12). - Call `renderTemplate(resolvedLang, msgKey, shortMessage, params)` (and same for long); pass `params` as `map[string]interface{}`. ### 6.4 renderTemplate diff --git a/examples/cldr_plural/main.go b/examples/cldr_plural/main.go new file mode 100644 index 0000000..789d785 --- /dev/null +++ b/examples/cldr_plural/main.go @@ -0,0 +1,50 @@ +// Cldr_plural demonstrates CLDR plural forms (short_forms/long_forms) in the message catalog. +// Use short_forms and long_forms with keys "zero", "one", "two", "few", "many", "other" for +// languages that need more than binary singular/plural (e.g. Arabic, Russian). +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/loopcontext/msgcat" +) + +func main() { + dir, err := os.MkdirTemp("", "msgcat-cldr-*") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + + en := []byte(`default: + short: Unexpected error + long: Message not found +set: + person.cats: + short_forms: + one: "{{name}} has {{count}} cat." + other: "{{name}} has {{count}} cats." + long_forms: + one: "{{name}} has one cat." + other: "{{name}} has {{count}} cats." + plural_param: count +`) + if err := os.WriteFile(filepath.Join(dir, "en.yaml"), en, 0o600); err != nil { + panic(err) + } + + catalog, err := msgcat.NewMessageCatalog(msgcat.Config{ResourcePath: dir}) + if err != nil { + panic(err) + } + + ctx := context.WithValue(context.Background(), "language", "en") + + for _, count := range []int{0, 1, 2, 5} { + msg := catalog.GetMessageWithCtx(ctx, "person.cats", msgcat.Params{"name": "Nick", "count": count}) + fmt.Printf("count=%d: %s\n", count, msg.ShortText) + } +} diff --git a/examples/msgdef/main.go b/examples/msgdef/main.go new file mode 100644 index 0000000..5a2592e --- /dev/null +++ b/examples/msgdef/main.go @@ -0,0 +1,79 @@ +// Msgdef demonstrates defining messages in Go with msgcat.MessageDef. Run from repo root: +// +// msgcat extract -source resources/messages/en.yaml -out resources/messages/en.yaml . +// +// to sync these definitions into your source YAML (add/update entries by Key). +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/loopcontext/msgcat" +) + +// Message definitions in Go; msgcat extract will find these and merge into source YAML. +var ( + personCats = msgcat.MessageDef{ + Key: "person.cats", + ShortForms: map[string]string{ + "one": "{{name}} has {{count}} cat.", + "other": "{{name}} has {{count}} cats.", + }, + LongForms: map[string]string{ + "one": "{{name}} has one cat.", + "other": "{{name}} has {{count}} cats.", + }, + } + itemsCount = msgcat.MessageDef{ + Key: "items.count", + Short: "{{count}} items", + Long: "Total: {{count}} items", + Code: msgcat.CodeInt(200), + } +) + +func main() { + // Use a temp dir with en.yaml that includes the same keys (e.g. after running extract) + dir, err := os.MkdirTemp("", "msgcat-msgdef-*") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + + en := []byte(`default: + short: Error + long: Error +set: + person.cats: + short_forms: + one: "{{name}} has {{count}} cat." + other: "{{name}} has {{count}} cats." + long_forms: + one: "{{name}} has one cat." + other: "{{name}} has {{count}} cats." + items.count: + short: "{{count}} items" + long: "Total: {{count}} items" + code: 200 +`) + if err := os.WriteFile(filepath.Join(dir, "en.yaml"), en, 0o600); err != nil { + panic(err) + } + + catalog, err := msgcat.NewMessageCatalog(msgcat.Config{ResourcePath: dir}) + if err != nil { + panic(err) + } + + ctx := context.WithValue(context.Background(), "language", "en") + _ = personCats + _ = itemsCount + + msg := catalog.GetMessageWithCtx(ctx, "person.cats", msgcat.Params{"name": "Alice", "count": 1}) + fmt.Println("person.cats (count=1):", msg.ShortText) + msg = catalog.GetMessageWithCtx(ctx, "items.count", msgcat.Params{"count": 3}) + fmt.Println("items.count:", msg.ShortText) +} diff --git a/group.go b/group.go new file mode 100644 index 0000000..26cb386 --- /dev/null +++ b/group.go @@ -0,0 +1,48 @@ +package msgcat + +import ( + "fmt" + "strconv" +) + +// OptionalGroup is the optional "group" field for message files. Use it to tag a file (or later, an entry) +// with a group that can be an integer or a string (e.g. group: 0 or group: "api") for organization or tooling. +// The catalog does not interpret group; it is only stored. YAML accepts int or string. +type OptionalGroup string + +// UnmarshalYAML allows group to be given as int or string in YAML. +func (g *OptionalGroup) UnmarshalYAML(unmarshal func(interface{}) error) error { + var v interface{} + if err := unmarshal(&v); err != nil { + return err + } + if v == nil { + *g = "" + return nil + } + switch t := v.(type) { + case string: + *g = OptionalGroup(t) + return nil + case int: + *g = OptionalGroup(strconv.Itoa(t)) + return nil + case int64: + *g = OptionalGroup(strconv.FormatInt(t, 10)) + return nil + default: + return fmt.Errorf("group must be string or int, got %T", v) + } +} + +// MarshalYAML emits int when the value is numeric, otherwise string, for readable round-trip (group: 0 vs group: "api"). +func (g OptionalGroup) MarshalYAML() (interface{}, error) { + s := string(g) + if s == "" { + return nil, nil + } + if n, err := strconv.Atoi(s); err == nil { + return n, nil + } + return s, nil +} diff --git a/group_test.go b/group_test.go new file mode 100644 index 0000000..af86f02 --- /dev/null +++ b/group_test.go @@ -0,0 +1,145 @@ +package msgcat + +import ( + "testing" + + "gopkg.in/yaml.v2" +) + +func TestOptionalGroup_UnmarshalYAML(t *testing.T) { + tests := []struct { + name string + yaml string + want OptionalGroup + wantErr bool + }{ + {"string", `"api"`, "api", false}, + {"int", `0`, "0", false}, + {"int_one", `1`, "1", false}, + {"empty", `""`, "", false}, + {"null", `null`, "", false}, + {"invalid", `true`, "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var g OptionalGroup + err := yaml.Unmarshal([]byte(tt.yaml), &g) + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalYAML() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && g != tt.want { + t.Errorf("UnmarshalYAML() got = %q, want %q", g, tt.want) + } + }) + } +} + +func TestOptionalGroup_MarshalYAML(t *testing.T) { + tests := []struct { + name string + g OptionalGroup + // After marshal+unmarshal we expect the same value + roundTripInt bool + }{ + {"numeric", "0", true}, + {"numeric_nonzero", "42", true}, + {"string", "api", false}, + {"empty", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := tt.g.MarshalYAML() + if err != nil { + t.Fatalf("MarshalYAML() error = %v", err) + } + if out == nil { + if tt.g != "" { + t.Errorf("MarshalYAML() returned nil for non-empty %q", tt.g) + } + return + } + buf, err := yaml.Marshal(out) + if err != nil { + t.Fatalf("yaml.Marshal: %v", err) + } + var g2 OptionalGroup + if err := yaml.Unmarshal(buf, &g2); err != nil { + t.Fatalf("round-trip unmarshal: %v", err) + } + if g2 != tt.g { + t.Errorf("round-trip got %q, want %q", g2, tt.g) + } + }) + } +} + +func TestMessages_WithGroup(t *testing.T) { + yamlContent := ` +group: api +default: + short: Unexpected error + long: Unexpected message +set: + greeting.hello: + short: Hello + long: Hello there +` + var m Messages + if err := yaml.Unmarshal([]byte(yamlContent), &m); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if m.Group != "api" { + t.Errorf("Group = %q, want api", m.Group) + } + if len(m.Set) != 1 { + t.Errorf("Set length = %d, want 1", len(m.Set)) + } + // Round-trip: marshal and unmarshal + out, err := yaml.Marshal(&m) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var m2 Messages + if err := yaml.Unmarshal(out, &m2); err != nil { + t.Fatalf("Round-trip Unmarshal: %v", err) + } + if m2.Group != m.Group { + t.Errorf("Round-trip Group = %q, want %q", m2.Group, m.Group) + } +} + +func TestMessages_WithGroupInt(t *testing.T) { + yamlContent := ` +group: 0 +default: + short: Err + long: Error +set: + x: + short: x + long: x +` + var m Messages + if err := yaml.Unmarshal([]byte(yamlContent), &m); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if m.Group != "0" { + t.Errorf("Group = %q, want 0", m.Group) + } + out, err := yaml.Marshal(&m) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + // Should emit as int (numeric) for readability + if len(out) == 0 { + t.Fatal("Marshal produced empty output") + } + var m2 Messages + if err := yaml.Unmarshal(out, &m2); err != nil { + t.Fatalf("Round-trip Unmarshal: %v", err) + } + if m2.Group != "0" { + t.Errorf("Round-trip Group = %q", m2.Group) + } +} diff --git a/internal/plural/plural.go b/internal/plural/plural.go new file mode 100644 index 0000000..3f478e4 --- /dev/null +++ b/internal/plural/plural.go @@ -0,0 +1,128 @@ +// Package plural provides CLDR plural form selection for a given language and count. +// Form names: "zero", "one", "two", "few", "many", "other". +package plural + +import "strings" + +// Form returns the CLDR plural form for the given language tag and count. +// Language tag is normalized to base (e.g. "en-US" -> "en"). Unknown languages default to "other". +func Form(lang string, count int) string { + base := strings.ToLower(strings.TrimSpace(lang)) + if idx := strings.Index(base, "-"); idx > 0 { + base = base[:idx] + } + if idx := strings.Index(base, "_"); idx > 0 { + base = base[:idx] + } + n := count + if n < 0 { + n = -n + } + switch base { + case "ar": + return formArabic(n) + case "ru", "uk", "be", "sr", "hr", "bs", "sh": + return formRussian(n) + case "pl": + return formPolish(n) + case "cy", "br", "ga", "gd", "gv", "kw", "mt", "sm", "ak": + return formWelsh(n) + case "he", "iw": + return formHebrew(n) + case "en", "es", "fr", "de", "it", "pt", "nl", "no", "sv", "da", "fi", "tr", "el", "ja", "ko", "zh", "th", "vi", "id", "hi": + return formOneOther(n) + default: + return "other" + } +} + +func formOneOther(n int) string { + if n == 1 { + return "one" + } + return "other" +} + +func formArabic(n int) string { + if n == 0 { + return "zero" + } + if n == 1 { + return "one" + } + if n == 2 { + return "two" + } + if n >= 3 && n <= 10 { + return "few" + } + if n >= 11 && n <= 99 { + return "many" + } + return "other" +} + +func formRussian(n int) string { + n10 := n % 10 + n100 := n % 100 + if n10 == 1 && n100 != 11 { + return "one" + } + if n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14) { + return "few" + } + if n10 == 0 || (n10 >= 5 && n10 <= 9) || (n100 >= 11 && n100 <= 14) { + return "many" + } + return "other" +} + +func formPolish(n int) string { + if n == 1 { + return "one" + } + n10 := n % 10 + n100 := n % 100 + if n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14) { + return "few" + } + if n10 == 0 || (n10 >= 5 && n10 <= 9) || (n100 >= 12 && n100 <= 14) { + return "many" + } + return "other" +} + +func formWelsh(n int) string { + if n == 0 { + return "zero" + } + if n == 1 { + return "one" + } + if n == 2 { + return "two" + } + if n == 3 { + return "few" + } + if n == 6 { + return "many" + } + return "other" +} + +func formHebrew(n int) string { + if n == 1 { + return "one" + } + if n == 2 { + return "two" + } + if n >= 3 && n <= 10 { + return "few" + } + if n >= 11 && n <= 99 { + return "many" + } + return "other" +} diff --git a/internal/plural/plural_test.go b/internal/plural/plural_test.go new file mode 100644 index 0000000..0ac7533 --- /dev/null +++ b/internal/plural/plural_test.go @@ -0,0 +1,46 @@ +package plural + +import "testing" + +func TestForm(t *testing.T) { + tests := []struct { + lang string + count int + want string + }{ + {"en", 0, "other"}, + {"en", 1, "one"}, + {"en", 2, "other"}, + {"en-US", 1, "one"}, + {"es", 1, "one"}, + {"es", 5, "other"}, + {"ar", 0, "zero"}, + {"ar", 1, "one"}, + {"ar", 2, "two"}, + {"ar", 5, "few"}, + {"ar", 11, "many"}, + {"ar", 100, "other"}, + {"ru", 1, "one"}, + {"ru", 2, "few"}, + {"ru", 5, "many"}, + {"ru", 21, "one"}, + {"ru", 11, "many"}, + {"pl", 1, "one"}, + {"pl", 2, "few"}, + {"pl", 5, "many"}, + {"pl", 12, "many"}, + {"cy", 0, "zero"}, + {"cy", 1, "one"}, + {"cy", 2, "two"}, + {"cy", 3, "few"}, + {"cy", 6, "many"}, + {"unknown", 1, "other"}, + {"unknown", 99, "other"}, + } + for _, tt := range tests { + got := Form(tt.lang, tt.count) + if got != tt.want { + t.Errorf("Form(%q, %d) = %q, want %q", tt.lang, tt.count, got, tt.want) + } + } +} diff --git a/msgcat.go b/msgcat.go index a940695..2f8eb7a 100644 --- a/msgcat.go +++ b/msgcat.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/loopcontext/msgcat/internal/plural" "gopkg.in/yaml.v2" ) @@ -340,6 +341,53 @@ func isPluralOne(value interface{}) (bool, bool) { } } +// pluralCountFromParam converts a param value to int for CLDR plural form selection. +func pluralCountFromParam(value interface{}) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case int8: + return int(typed), true + case int16: + return int(typed), true + case int32: + return int(typed), true + case int64: + return int(typed), true + case uint: + return int(typed), true + case uint8: + return int(typed), true + case uint16: + return int(typed), true + case uint32: + return int(typed), true + case uint64: + return int(typed), true + case float32: + return int(typed), true + case float64: + return int(typed), true + default: + return 0, false + } +} + +// selectCLDRForm returns the template string from forms for the given lang and count, with fallback to other then defaultTpl. +func selectCLDRForm(forms map[string]string, lang string, count int, defaultTpl string) string { + if len(forms) == 0 { + return defaultTpl + } + form := plural.Form(lang, count) + if tpl, ok := forms[form]; ok && tpl != "" { + return tpl + } + if tpl, ok := forms["other"]; ok && tpl != "" { + return tpl + } + return defaultTpl +} + // parsePluralTokenNamed extracts param name, singular and plural from {{plural:name|singular|plural}}. func parsePluralTokenNamed(token string) (paramName string, singular string, plural string, ok bool) { matches := pluralPlaceholderRegex.FindStringSubmatch(token) @@ -834,10 +882,25 @@ func (dmc *DefaultMessageCatalog) GetMessageWithCtx(ctx context.Context, msgKey longMessage := langMsgSet.Default.LongTpl code := CodeMissingMessage missingMessage := false - if msg, foundMsg := langMsgSet.Set[msgKey]; foundMsg { + if msg, ok := langMsgSet.Set[msgKey]; ok { shortMessage = msg.ShortTpl longMessage = msg.LongTpl code = string(msg.Code) + // CLDR plural forms: when ShortForms/LongForms are set, select by plural param and language + if len(msg.ShortForms) > 0 || len(msg.LongForms) > 0 { + pluralParam := msg.PluralParam + if pluralParam == "" { + pluralParam = "count" + } + paramMap := map[string]interface{}(params) + if paramMap == nil { + paramMap = map[string]interface{}{} + } + if countVal, ok := pluralCountFromParam(paramMap[pluralParam]); ok { + shortMessage = selectCLDRForm(msg.ShortForms, resolvedLang, countVal, shortMessage) + longMessage = selectCLDRForm(msg.LongForms, resolvedLang, countVal, longMessage) + } + } } else { missingMessage = true } @@ -847,6 +910,9 @@ func (dmc *DefaultMessageCatalog) GetMessageWithCtx(ctx context.Context, msgKey } paramMap := map[string]interface{}(params) + if paramMap == nil { + paramMap = map[string]interface{}{} + } shortMessage = dmc.renderTemplate(resolvedLang, msgKey, shortMessage, paramMap) longMessage = dmc.renderTemplate(resolvedLang, msgKey, longMessage, paramMap) return &Message{ diff --git a/msgcat_cldr_test.go b/msgcat_cldr_test.go new file mode 100644 index 0000000..c6f3ad0 --- /dev/null +++ b/msgcat_cldr_test.go @@ -0,0 +1,78 @@ +package msgcat + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestGetMessageWithCtx_CLDRForms(t *testing.T) { + dir := t.TempDir() + en := []byte(`default: + short: Unexpected error + long: Unexpected message +set: + person.cats: + short_forms: + one: "{{name}} has {{count}} cat." + other: "{{name}} has {{count}} cats." + long_forms: + one: "{{name}} has one cat." + other: "{{name}} has {{count}} cats." + plural_param: count +`) + if err := os.WriteFile(filepath.Join(dir, "en.yaml"), en, 0644); err != nil { + t.Fatal(err) + } + catalog, err := NewMessageCatalog(Config{ResourcePath: dir}) + if err != nil { + t.Fatal(err) + } + ctx := context.WithValue(context.Background(), "language", "en") + + // count=1 -> one + msg1 := catalog.GetMessageWithCtx(ctx, "person.cats", Params{"name": "Nick", "count": 1}) + if msg1.ShortText != "Nick has 1 cat." { + t.Errorf("count=1 short: got %q", msg1.ShortText) + } + if msg1.LongText != "Nick has one cat." { + t.Errorf("count=1 long: got %q", msg1.LongText) + } + + // count=2 -> other + msg2 := catalog.GetMessageWithCtx(ctx, "person.cats", Params{"name": "Nick", "count": 2}) + if msg2.ShortText != "Nick has 2 cats." { + t.Errorf("count=2 short: got %q", msg2.ShortText) + } + if msg2.LongText != "Nick has 2 cats." { + t.Errorf("count=2 long: got %q", msg2.LongText) + } +} + +func TestGetMessageWithCtx_CLDRForms_fallbackToOther(t *testing.T) { + dir := t.TempDir() + en := []byte(`default: + short: Err + long: Err +set: + items: + short_forms: + other: "{{count}} items" + long_forms: + other: "{{count}} items total" +`) + if err := os.WriteFile(filepath.Join(dir, "en.yaml"), en, 0644); err != nil { + t.Fatal(err) + } + catalog, err := NewMessageCatalog(Config{ResourcePath: dir}) + if err != nil { + t.Fatal(err) + } + ctx := context.WithValue(context.Background(), "language", "en") + msg := catalog.GetMessageWithCtx(ctx, "items", Params{"count": 1}) + // only "other" form defined; Form(en,1)=one but we fall back to other + if msg.ShortText != "1 items" { + t.Errorf("short: got %q", msg.ShortText) + } +} diff --git a/structs.go b/structs.go index 10f2085..583cde2 100644 --- a/structs.go +++ b/structs.go @@ -8,16 +8,22 @@ type ContextKey string type Params map[string]interface{} type Messages struct { - Default RawMessage `yaml:"default"` - Set map[string]RawMessage `yaml:"set"` + Group OptionalGroup `yaml:"group,omitempty"` // Optional; int or string (e.g. group: 0 or group: "api"). Catalog does not interpret it. + Default RawMessage `yaml:"default"` + Set map[string]RawMessage `yaml:"set"` } // RawMessage is one catalog entry. Code is optional and can be any value the user wants (e.g. "404", "ERR_NOT_FOUND"); // it is for projects that map their own error/message codes into the catalog. Uniqueness is not enforced. +// Optional ShortForms/LongForms enable CLDR plural forms (zero, one, two, few, many, other); when set, +// the plural_param (default "count") is used to select the form. See docs/CLDR_AND_GO_MESSAGES_PLAN.md. type RawMessage struct { - LongTpl string `yaml:"long"` - ShortTpl string `yaml:"short"` - Code OptionalCode `yaml:"code"` // Optional. In YAML: code: 404 or code: "ERR_NOT_FOUND". Use Key when empty. + LongTpl string `yaml:"long"` + ShortTpl string `yaml:"short"` + Code OptionalCode `yaml:"code"` // Optional. In YAML: code: 404 or code: "ERR_NOT_FOUND". Use Key when empty. + ShortForms map[string]string `yaml:"short_forms,omitempty"` // Optional CLDR forms: zero, one, two, few, many, other. + LongForms map[string]string `yaml:"long_forms,omitempty"` + PluralParam string `yaml:"plural_param,omitempty"` // Param name for plural selection (default "count"). // Key is set when loading via LoadMessages (runtime); YAML uses the map key as the message key. Key string `yaml:"-"` } @@ -31,6 +37,18 @@ type Message struct { Key string // Message key (e.g. "greeting.hello"); set when found or when missing (requested key). } +// MessageDef defines a message that can be extracted to YAML via the msgcat CLI (extract -source). +// Use in Go for "messages in Go" workflow; at runtime the catalog loads from YAML. Key is required. +type MessageDef struct { + Key string // Message key (e.g. "person.cats"). Required. + Short string // Short template (or use ShortForms for CLDR). + Long string // Long template (or use LongForms for CLDR). + ShortForms map[string]string `yaml:"short_forms,omitempty"` // Optional CLDR forms: zero, one, two, few, many, other. + LongForms map[string]string `yaml:"long_forms,omitempty"` + PluralParam string `yaml:"plural_param,omitempty"` // Param name for plural selection (default "count"). + Code OptionalCode `yaml:"code,omitempty"` +} + type MessageCatalogStats struct { LanguageFallbacks map[string]int MissingLanguages map[string]int From b08b8d12cb80422b017d0d53111d2546ea4c09d5 Mon Sep 17 00:00:00 2001 From: Christian Melgarejo Date: Thu, 26 Feb 2026 12:50:24 -0300 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20gaps=20before=20merge=20=E2=80=94=20?= =?UTF-8?q?LoadMessages=20plural=20fields,=20merge=20forms-only,=20CI,=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoadMessages: preserve ShortForms, LongForms, PluralParam on runtime messages - Merge: treat target as translated when it has short_forms/long_forms (not only short/long) - CI: Go 1.26, build ./cmd/... - MIGRATION §10: new optional features (no migration required) - Tests: LoadMessages_preservesShortFormsLongFormsPluralParam, Merge_preservesTargetWhenOnlyForms - CHANGELOG: Fixed, Changed Made-with: Cursor --- .github/workflows/ci.yml | 8 +++-- cmd/msgcat/merge.go | 4 ++- cmd/msgcat/merge_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ docs/CHANGELOG.md | 8 +++++ docs/MIGRATION.md | 8 +++++ msgcat.go | 9 ++++-- msgcat_cldr_test.go | 37 ++++++++++++++++++++++ 7 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 cmd/msgcat/merge_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13aee0d..56ae8a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: '1.26' - name: Run tests run: go test ./... @@ -26,5 +26,7 @@ jobs: - name: Run go vet run: go vet ./... - - name: Build examples - run: go build ./examples/... + - name: Build cmd and examples + run: | + go build ./cmd/... + go build ./examples/... diff --git a/cmd/msgcat/merge.go b/cmd/msgcat/merge.go index 440bae0..abd4545 100644 --- a/cmd/msgcat/merge.go +++ b/cmd/msgcat/merge.go @@ -112,7 +112,9 @@ func runMerge(cfg *mergeConfig) error { } for key, srcEntry := range source.Set { dstEntry := target.Set[key] - if dstEntry.ShortTpl != "" && dstEntry.LongTpl != "" { + hasTpl := dstEntry.ShortTpl != "" && dstEntry.LongTpl != "" + hasForms := len(dstEntry.ShortForms) > 0 || len(dstEntry.LongForms) > 0 + if hasTpl || hasForms { merged.Set[key] = dstEntry } else { entry := msgcat.RawMessage{ diff --git a/cmd/msgcat/merge_test.go b/cmd/msgcat/merge_test.go new file mode 100644 index 0000000..87d4ce4 --- /dev/null +++ b/cmd/msgcat/merge_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMerge_preservesTargetWhenOnlyForms(t *testing.T) { + dir := t.TempDir() + source := []byte(`default: + short: Err + long: Err +set: + person.cats: + short_forms: + one: "{{name}} has {{count}} cat." + other: "{{name}} has {{count}} cats." + long_forms: + one: "One cat." + other: "{{count}} cats." + plural_param: count +`) + sourcePath := filepath.Join(dir, "en.yaml") + if err := os.WriteFile(sourcePath, source, 0644); err != nil { + t.Fatal(err) + } + // Target (es) has only short_forms/long_forms, no short/long + target := []byte(`default: + short: Error + long: Error +set: + person.cats: + short_forms: + one: "{{name}} tiene {{count}} gato." + other: "{{name}} tiene {{count}} gatos." + long_forms: + one: "Un gato." + other: "{{count}} gatos." + plural_param: count +`) + if err := os.WriteFile(filepath.Join(dir, "es.yaml"), target, 0644); err != nil { + t.Fatal(err) + } + cfg := &mergeConfig{ + source: sourcePath, + targetLangs: "es", + outdir: dir, + } + if err := runMerge(cfg); err != nil { + t.Fatal(err) + } + out, err := os.ReadFile(filepath.Join(dir, "translate.es.yaml")) + if err != nil { + t.Fatal(err) + } + content := string(out) + // Target's translated forms should be preserved (Spanish) + if !strings.Contains(content, "tiene") { + t.Errorf("merge should preserve target short_forms when target has only forms; got %s", content) + } + if !strings.Contains(content, "gato") { + t.Errorf("merge should preserve target forms; got %s", content) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d682611..9217ccc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,14 @@ This project follows Semantic Versioning. - CLI **extract** (keys from GetMessageWithCtx/WrapErrorWithCtx/GetErrorWithCtx; sync to YAML with MessageDef merge) and **merge** (translate.\.yaml with group and plural fields copied). - Examples: `cldr_plural`, `msgdef`. Docs: CLI_WORKFLOW_PLAN, CLDR_AND_GO_MESSAGES_PLAN. - String message keys (e.g. `"greeting.hello"`) instead of numeric codes for lookup. + +### Fixed +- **LoadMessages** now preserves `ShortForms`, `LongForms`, and `PluralParam` on runtime-loaded messages. +- **Merge** now treats a target entry as translated when it has either `short`/`long` or `short_forms`/`long_forms`, so forms-only translations are kept. + +### Changed +- **CI** uses Go 1.26 (matches go.mod) and builds `./cmd/...` (msgcat CLI). +- **MIGRATION** §10 added: optional group, CLDR forms, MessageDef (no migration required). - Named template parameters: `{{name}}`, `{{plural:count|...}}`, `{{num:amount}}`, `{{date:when}}` with `msgcat.Params`. - Optional string `code` field: any value (e.g. `"404"`, `"ERR_NOT_FOUND"`); not unique. YAML accepts `code: 404` or `code: "ERR_001"`. Helpers `CodeInt()`, `CodeString()`. - `Message.Key` and `ErrorKey()` for API identifier when code is empty. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index e2f82ee..f55604e 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -90,3 +90,11 @@ If you are moving from numeric message codes and positional template parameters - **LoadMessages** — Each message must have `Key` with prefix `sys.` (e.g. `sys.alert`). No numeric code range; use `Code: msgcat.CodeInt(9001)` or `Code: msgcat.CodeString("SYS_LOADED")` if you need a code. - **Observer** — `OnMessageMissing(lang, msgKey string)` and `OnTemplateIssue(lang, msgKey string, issue string)` now take string `msgKey` instead of `msgCode int`. - **YAML** — Remove `group`. Use string keys under `set:` and optional `code` per entry. See README and `docs/CONVERSION_PLAN.md`. + +## 10) New optional features (no migration required) + +The following are additive and optional. Existing YAML and code keep working as-is. + +- **Optional group** — You can add top-level `group: 0` or `group: "api"` to message files. The catalog does not interpret it; it is for organization and tooling. Omit if you do not use it. +- **CLDR plural forms** — You can add `short_forms` / `long_forms` (maps: zero, one, two, few, many, other) and optional `plural_param` per entry for languages that need more than two plural forms. If you only use `short` / `long` and `{{plural:count|singular|plural}}`, no change needed. +- **MessageDef and CLI** — You can define messages in Go with `msgcat.MessageDef` and run `msgcat extract -source en.yaml -out en.yaml .` to merge them into YAML. Optional; at runtime the catalog still loads from YAML. diff --git a/msgcat.go b/msgcat.go index 2f8eb7a..63cd979 100644 --- a/msgcat.go +++ b/msgcat.go @@ -837,9 +837,12 @@ func (dmc *DefaultMessageCatalog) LoadMessages(lang string, messages []RawMessag return fmt.Errorf("message with key %q already exists in message set for language %s", key, normalizedLang) } normalizedMessage := RawMessage{ - LongTpl: message.LongTpl, - ShortTpl: message.ShortTpl, - Code: message.Code, + LongTpl: message.LongTpl, + ShortTpl: message.ShortTpl, + Code: message.Code, + ShortForms: message.ShortForms, + LongForms: message.LongForms, + PluralParam: message.PluralParam, } langMsgSet.Set[key] = normalizedMessage dmc.runtimeMessages[normalizedLang][key] = normalizedMessage diff --git a/msgcat_cldr_test.go b/msgcat_cldr_test.go index c6f3ad0..3777d91 100644 --- a/msgcat_cldr_test.go +++ b/msgcat_cldr_test.go @@ -76,3 +76,40 @@ set: t.Errorf("short: got %q", msg.ShortText) } } + +func TestLoadMessages_preservesShortFormsLongFormsPluralParam(t *testing.T) { + dir := t.TempDir() + en := []byte(`default: + short: Err + long: Err +set: {} +`) + if err := os.WriteFile(filepath.Join(dir, "en.yaml"), en, 0644); err != nil { + t.Fatal(err) + } + catalog, err := NewMessageCatalog(Config{ResourcePath: dir}) + if err != nil { + t.Fatal(err) + } + err = catalog.LoadMessages("en", []RawMessage{{ + Key: "sys.cats", + ShortForms: map[string]string{"one": "{{name}} has {{n}} cat.", "other": "{{name}} has {{n}} cats."}, + LongForms: map[string]string{"one": "One cat.", "other": "{{n}} cats."}, + PluralParam: "n", + }}) + if err != nil { + t.Fatal(err) + } + ctx := context.WithValue(context.Background(), "language", "en") + msg1 := catalog.GetMessageWithCtx(ctx, "sys.cats", Params{"name": "Alice", "n": 1}) + if msg1.ShortText != "Alice has 1 cat." { + t.Errorf("count=1 short: got %q", msg1.ShortText) + } + if msg1.LongText != "One cat." { + t.Errorf("count=1 long: got %q", msg1.LongText) + } + msg2 := catalog.GetMessageWithCtx(ctx, "sys.cats", Params{"name": "Alice", "n": 2}) + if msg2.ShortText != "Alice has 2 cats." { + t.Errorf("count=2 short: got %q", msg2.ShortText) + } +}