Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
460 changes: 460 additions & 0 deletions README.RU.md

Large diffs are not rendered by default.

219 changes: 169 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/voluminor/language_wizard?color=green)
![GitHub repo size](https://img.shields.io/github/repo-size/voluminor/language_wizard)

> [Русская версия](README.RU.md)

# language-wizard

*A tiny, thread-safe i18n key–value store with hot language switching and a simple event model.*
Expand All @@ -13,15 +15,31 @@
`language-wizard` is a minimalistic helper for applications that need a simple dictionary-based i18n. It stores the
current ISO language code and an in-memory map of translation strings, lets you switch the active language atomically,
and exposes a small event mechanism so background workers can react to changes or closure. The internal state is guarded
by a `sync.RWMutex` for concurrent access. 
by a `sync.RWMutex` for concurrent access.

### Object Lifecycle

```mermaid
stateDiagram-v2
[*] --> Active: New(isoLang, words)\n✓ returns *LanguageWizardObj
Active --> Active: SetLanguage(lang, words)\n• swaps dictionary\n• closes changedCh\n• creates fresh changedCh\n• notifies all waiters
Active --> Closed: Close()\n• closed.Store(true)\n• close(changedCh)\n• clears words map
Closed --> Closed: Close()\nidempotent — no-op
Active --> Active: Get / CurrentLanguage / Words\nread-only, never change state
Closed --> Closed: Get / CurrentLanguage / Words\nstill work, but words is empty
note right of Closed
SetLanguage → ErrClosed
Wait → EventClose (immediate)
end note
```

## Features

* **Simple key–value dictionary** for translations.
* **Hot language switching** with atomic swap of the dictionary. 
* **Thread-safe reads/writes** guarded by a RWMutex. 
* **Defensive copy** when exposing the map to callers. 
* **Blocking wait** for language changes or closure via a tiny event model. 
* **Hot language switching** with atomic swap of the dictionary.
* **Thread-safe reads/writes** guarded by a RWMutex.
* **Defensive copy** when exposing the map to callers.
* **Blocking wait** for language changes or closure via a tiny event model.
* **Pluggable logger** for missing keys.

## Installation
Expand All @@ -30,7 +48,7 @@ by a `sync.RWMutex` for concurrent access. 
go get github.com/voluminor/language_wizard
```

Or vendor/copy the `language_wizard` package into your projects source tree.
Or vendor/copy the `language_wizard` package into your project's source tree.

## Quick Start

Expand Down Expand Up @@ -71,8 +89,8 @@ func main() {
```

`New` validates that the ISO language is not empty and the words map is non-nil and non-empty. The initial map is
defensively copied. 
`Get` returns a default when the key is empty or missing and logs undefined keys via the configured logger. 
defensively copied.
`Get` returns a default when the key is empty or missing and logs undefined keys via the configured logger.

## Concepts & API

Expand All @@ -85,7 +103,7 @@ obj, err := language_wizard.New(isoLanguage string, words map[string]string)
* Fails with `ErrNilIsoLang` if `isoLanguage` is empty.
* Fails with `ErrNilWords` if `words` is `nil` or empty.
* On success, stores the language code and a **copy** of `words`, initializes an internal change channel, and sets a
no-op logger. 
no-op logger.

### Reading

Expand All @@ -96,20 +114,37 @@ v := obj.Get(id, def) // returns def if id is empty or missing
```

* `CurrentLanguage` and `Words` take read locks; `Words` returns a defensive copy so external modifications cannot
mutate internal state. 
* `Get` logs misses in the form `"undef: <id>"` via `obj.log` and returns the provided default.&#x20;
mutate internal state.
* `Get` logs misses in the form `"undef: <id>"` via the configured logger and returns the provided default.

#### `Get` flow

```mermaid
flowchart TD
A([Get&#40;id, def&#41;]) --> B{id == &quot;&quot;?}
B -- yes --> Z1([return def])
B -- no --> C[RLock]
C --> D[val, ok = words&#91;id&#93;\nlogFn = obj.log]
D --> E[RUnlock]
E --> F{ok?}
F -- yes --> Z2([return val])
F -- no --> G[logFn&#40;&quot;undef: &quot; + id&#41;]
G --> Z3([return def])
```

> `logFn` is snapshotted under the same `RLock` as the map lookup, so concurrent `SetLog` calls cannot race with it.

### Updating

```go
err := obj.SetLanguage(isoLanguage string, words map[string]string)
```

* Validates input as in `New`; returns `ErrNilIsoLang` / `ErrNilWords` on invalid values.&#x20;
* Returns `ErrClosed` if the object was closed.&#x20;
* Returns `ErrLangAlreadySet` if `isoLanguage` equals the current one.&#x20;
* Validates input as in `New`; returns `ErrNilIsoLang` / `ErrNilWords` on invalid values.
* Returns `ErrClosed` if the object was closed.
* Returns `ErrLangAlreadySet` if `isoLanguage` equals the current one.
* On success, **atomically swaps** the language and a **copy** of the provided map, **closes** the internal change
channel to notify waiters, then creates a **fresh channel** for future waits.&#x20;
channel to notify waiters, then creates a **fresh channel** for future waits.

### Events & Waiting

Expand All @@ -125,9 +160,59 @@ ev := obj.Wait() // blocks until language changes or object is closed
ok := obj.WaitUntilClosed() // true if it was closed, false otherwise
```

* `Wait` blocks on the internal channel. When it unblocks, it inspects the `closed` flag: `EventClose` if closed,
otherwise `EventLanguageChanged`.&#x20;
* `WaitUntilClosed` is a convenience that returns `true` iff the closure event was received.&#x20;
* `Wait` snapshots the current channel under a short `RLock`, blocks on it, then checks the `closed` flag atomically to
distinguish `EventClose` from `EventLanguageChanged`.
* `WaitUntilClosed` is a convenience that returns `true` iff the closure event was received.

#### SetLanguage notification flow

```mermaid
sequenceDiagram
participant C as Caller
participant W as LanguageWizardObj
participant G1 as Goroutine A (Wait)
participant G2 as Goroutine B (Wait)
G1 ->> W: Wait()
W -->> G1: RLock → snapshot ch₀ → RUnlock
G1 ->> G1: blocking on ← ch₀
G2 ->> W: Wait()
W -->> G2: RLock → snapshot ch₀ → RUnlock
G2 ->> G2: blocking on ← ch₀
C ->> W: SetLanguage("de", words)
W ->> W: Lock
W ->> W: validate + update currentLanguage + cloneWords
W ->> W: close(ch₀) ← unblocks G1 and G2
W ->> W: ch₁ = make(chan struct{})
W ->> W: Unlock
W -->> G1: ← ch₀ fires → closed.Load()=false → EventLanguageChanged
W -->> G2: ← ch₀ fires → closed.Load()=false → EventLanguageChanged
Note over G1, G2: Both goroutines call Wait() again,\nthis time blocking on the fresh ch₁
```

#### Close notification flow

```mermaid
sequenceDiagram
participant C as Caller
participant W as LanguageWizardObj
participant G1 as Goroutine A (Wait)
participant G2 as Goroutine B (Wait)
G1 ->> W: Wait()
W -->> G1: snapshot ch₀ → blocking on ← ch₀
G2 ->> W: WaitChan()
W -->> G2: RLock → snapshot ch₀ → RUnlock
G2 ->> G2: blocking on ← ch₀ (in select)
C ->> W: Close()
W ->> W: Lock
W ->> W: closed.Store(true)
W ->> W: close(ch₀) ← unblocks G1 and G2
W ->> W: words = empty map
W ->> W: Unlock
Note right of W: ch₀ stays closed forever,\nno fresh channel is created
W -->> G1: ← ch₀ fires → closed.Load()=true → EventClose
W -->> G2: ← ch₀ fires → IsClosed()=true → handle closure
Note over G1, G2: Any future Wait() / ← WaitChan()\nreturns immediately (closed channel)
```

**Typical loop:**

Expand All @@ -149,56 +234,89 @@ return

```go
go func () {
for {
select {
case <-ctx.Done():
return
case <-obj.WaitChan():
if obj.IsClosed(){
if obj.IsClosed() {
// Cleanup and exit.
return
}

// Rebuild caches / refresh UI here.
}
}
}()
```

> Each iteration calls `obj.WaitChan()` to get a fresh snapshot of the current channel, so the loop correctly
> latches onto the new channel after every `SetLanguage`.

### Logging

```go
obj.SetLog(func (msg string) { /* ... */ })
```

* Sets a custom logger for undefined key lookups; `nil` is ignored. The logger is stored under a write lock.&#x20;
* Only `Get` calls the logger (for misses).&#x20;
* Sets a custom logger for undefined key lookups. Passing `nil` resets the logger back to the built-in no-op.
The logger is stored under a write lock.
* Only `Get` calls the logger (for misses).

### Closing

```go
obj.Close()
```

* Idempotent. Sets `closed`, **closes the change channel** (unblocking `Wait`), and clears the words map to an empty
one. Further `SetLanguage` calls will fail with `ErrClosed`.&#x20;
* Idempotent. Sets the `closed` flag, **closes the change channel** (unblocking all `Wait` calls), and clears the words
map to an empty one. Further `SetLanguage` calls will fail with `ErrClosed`.

### Errors

Exported errors:

* `ErrNilIsoLang` — ISO language is required by `New`/`SetLanguage`.&#x20;
* `ErrNilWords` — `words` must be non-nil and non-empty in `New`/`SetLanguage`.&#x20;
* `ErrLangAlreadySet` — attempted to set the same language as current.&#x20;
* `ErrClosed` — the object has been closed; updates are not allowed.&#x20;
* `ErrNilIsoLang` — ISO language is required by `New`/`SetLanguage`.
* `ErrNilWords` — `words` must be non-nil and non-empty in `New`/`SetLanguage`.
* `ErrLangAlreadySet` — attempted to set the same language as current.
* `ErrClosed` — the object has been closed; updates are not allowed.

## Thread-Safety & Concurrency Model

* The struct holds a `sync.RWMutex`; readers (`CurrentLanguage`, `Words`, `Get`) take an RLock;
writers (`SetLanguage`, `SetLog`, `Close`) take a Lock.
```mermaid
graph LR
subgraph Readers ["Read lock (RLock) — concurrent"]
CL["CurrentLanguage()"]
WO["Words()"]
GE["Get()"]
WC["WaitChan()"]
WA["Wait() — channel snapshot only"]
end

subgraph Writers ["Write lock (Lock) — exclusive"]
SL["SetLanguage()"]
SLG["SetLog()"]
CL2["Close()"]
end

subgraph Atomic ["No lock — atomic operation"]
IC["IsClosed()"]
WA2["Wait() — closed flag check"]
end

MX["sync.RWMutex"] --> Readers
MX --> Writers
AB["atomic.Bool (closed)"] --> Atomic
```

Key invariants:

* `SetLanguage` **closes** the current change channel to notify all waiters, then immediately **replaces** it with a new
channel so subsequent `Wait` calls will block until the next event.&#x20;
* `Wait` reads a snapshot of the channel under a short lock, waits on it, then distinguishes “close” vs
“language-changed” by checking the `closed` flag under an RLock.&#x20;
channel so subsequent `Wait` calls will block until the next event.
* `Wait` and `WaitChan` snapshot the channel under a minimal `RLock` — the lock is released before blocking,
so waiters never contend with writers.
* The `closed` flag is an `atomic.Bool`: reads (`IsClosed`, post-wait check in `Wait`) require no lock at all.
* `Get` snapshots both the map value and the `log` function pointer under a single `RLock`, preventing a race with
concurrent `SetLog` calls.

## Usage Patterns

Expand All @@ -210,7 +328,7 @@ return obj.Get("hi", "Hello")
}
```

This shields you from missing keys while still surfacing them via the logger.&#x20;
This shields you from missing keys while still surfacing them via the logger.

### 2) Watching for changes

Expand All @@ -227,15 +345,15 @@ return
}
```

Use this from a goroutine to keep ancillary state in sync with the active language.&#x20;
Use this from a goroutine to keep ancillary state in sync with the active language.

### 3) Hot-swap language at runtime

```go
_ = obj.SetLanguage("fr", map[string]string{"hi": "Bonjour"})
```

All current waiters are notified; subsequent waits latch onto the fresh channel.&#x20;
All current waiters are notified; subsequent waits latch onto the fresh channel.

### 4) Custom logger for undefined keys

Expand All @@ -245,25 +363,26 @@ obj.SetLog(func (s string) {
})
```

Great for collecting telemetry on missing translations.
Great for collecting telemetry on missing translations. Pass `nil` to reset back to the no-op logger.

## Testing

Run the test suite:
Run the test suite with the race detector:

```bash
go test ./...
go test -race ./...
```

Whats covered:
What's covered:

* Successful construction and basic lookups.&#x20;
* Defensive copy semantics for `Words()`.&#x20;
* `Get` defaulting and miss logging.&#x20;
* Successful construction and basic lookups.
* Defensive copy semantics for `Words()`.
* `Get` defaulting and miss logging.
* `SetLog(nil)` resets to no-op without panicking.
* Validation and error cases in `New`/`SetLanguage`.
* Language switching and current language updates.&#x20;
* Event handling: `Wait`, `WaitUntilClosed`, and close behavior.&#x20;
* `Close` clears words and blocks further updates.&#x20;
* Language switching and current language updates.
* Event handling: `Wait`, `WaitUntilClosed`, and close behavior.
* `Close` clears words and blocks further updates.

## FAQ

Expand All @@ -273,11 +392,11 @@ subsequent `SetLanguage`, you may still be observing the already-closed channel.
channel after closing it; call `Wait` in a loop and treat each return as a single event.

**Q: Can I mutate the map returned by `Words()`?**
Yes, its a copy. Mutating it wont affect the internal state. Use `SetLanguage` to replace the internal map.&#x20;
Yes, it's a copy. Mutating it won't affect the internal state. Use `SetLanguage` to replace the internal map.

**Q: What happens after `Close()`?**
`Wait` unblocks with `EventClose`, the dictionary is cleared, and `SetLanguage` returns `ErrClosed`. Reads still work
but the dictionary is empty unless you held an external copy.&#x20;
but the dictionary is empty unless you held an external copy.

## Important Behavior Notes

Expand Down Expand Up @@ -331,5 +450,5 @@ because `Wait()` never blocks again after the object is closed.
## Limitations

* Dictionary-only i18n: no ICU/plural rules, interpolation, or fallback chains—intentionally minimal.
* Blocking waits have no timeout or context cancellation; implement your own goroutine cancellation if needed.&#x20;
* Language identity equality is string-based; `SetLanguage("en", …)` to `"en"` returns `ErrLangAlreadySet`.&#x20;
* `Wait()` itself has no timeout parameter; use `WaitChan()` with a `select` and `ctx.Done()` for cancellable waits.
* Language identity equality is string-based; `SetLanguage("en", …)` to `"en"` returns `ErrLangAlreadySet`.
3 changes: 2 additions & 1 deletion get.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ func (obj *LanguageWizardObj) Get(id, def string) string {

obj.mx.RLock()
val, ok := obj.words[id]
logFn := obj.log
obj.mx.RUnlock()

if ok {
return val
}

obj.log("undef: " + id)
logFn("undef: " + id)
return def
}
Loading
Loading