Compare commits

..

2 Commits

Author SHA1 Message Date
Jakub Zych
6e923eea45 Switch HOCON dependency from typesafe-config to Jig
Jig (ma.chinespirit::jig) is the SummerCMS standard for HOCON.
Uses sconfig under the hood (no Java deps, Scala Native
compatible). Adapts HoconLoader to sconfig's parameterless method style.
2026-02-23 00:39:19 +01:00
Jakub Zych
8684d2fb7b Implement Phase 1 core translation engine (95 tests passing)
Custom i18n module with HOCON translation files, CLDR-aware pluralization
for 40+ locales, namespace::group.item key resolution, :name/:Name/:NAME
parameter interpolation, and locale fallback chains. No effect system deps.
2026-02-22 23:30:16 +01:00
26 changed files with 1845 additions and 2 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# sbt build artifacts
target/
project/target/
project/project/
# IDE
.idea/
.bsp/
.metals/
.vscode/
*.iml
# OS
.DS_Store
Thumbs.db

261
README.md
View File

@@ -1,3 +1,260 @@
# summercms-phrasebook
# summer-phrasebook
## SummerCMS i18n support
Internationalization module for [SummerCMS](https://git.golem15.com/golem15/summercms). Provides translation loading, CLDR-aware pluralization, and parameter interpolation — the Scala 3 equivalent of Laravel's `Illuminate\Translation` + WinterCMS `Storm\Translation`.
## Quick start
```scala
import summer.phrasebook.*
import java.nio.file.Path
val translator = PhrasebookTranslator(
initialLocale = LocaleTag("en"),
initialFallback = Some(LocaleTag("en")),
)
// Register a plugin's translation directory
translator.addNamespace("golem15.blog", Path.of("plugins/golem15/blog/lang"))
// Simple lookup
translator.get("golem15.blog::lang.labels.pluginName")
// => "Blog"
// With parameter replacement
translator.get("golem15.blog::lang.welcome", Map("name" -> "Alice"))
// => "Welcome, Alice!"
// Pluralization (auto-selects correct CLDR form)
translator.choice("golem15.blog::lang.posts_count", 5)
// => "5 posts"
// Switch locale
translator.setLocale(LocaleTag("pl"))
translator.choice("golem15.blog::lang.posts_count", 5)
// => "5 wpisów"
```
## Translation file format
Translation files use [HOCON](https://github.com/lightbend/config/blob/main/HOCON.md) (`.conf`), organized per-plugin, per-locale, per-group:
```
plugins/golem15/blog/lang/
en/
lang.conf
validation.conf
pl/
lang.conf
```
Example `lang/en/lang.conf`:
```hocon
labels {
pluginName = "Blog"
posts = "Posts"
}
post {
create_title = "Create Post"
edit_title = "Edit Post"
}
# Pluralization: pipe-separated forms (English: singular|plural)
posts_count = ":count post|:count posts"
# Explicit range syntax
items_range = "{0} No items|{1} One item|[2,*] Many items"
# Parameter interpolation
welcome = "Welcome, :name!"
```
Nested HOCON keys are flattened to dot-separated paths: `labels.pluginName`, `post.create_title`, etc.
## Key format
Keys follow the WinterCMS/Laravel convention:
```
namespace::group.item
```
| Part | Description | Example |
|------|-------------|---------|
| `namespace` | Plugin identifier (optional) | `golem15.blog` |
| `group` | Filename without `.conf` | `lang` |
| `item` | Dot-path into the flattened HOCON | `labels.pluginName` |
Examples:
```
golem15.blog::lang.labels.pluginName -> plugins/golem15/blog/lang/{locale}/lang.conf -> labels.pluginName
golem15.blog::validation.required -> plugins/golem15/blog/lang/{locale}/validation.conf -> required
system::validation.required -> modules/system/lang/{locale}/validation.conf -> required
```
Keys without a namespace that start with `validation.` auto-resolve to `system::validation.*` for WinterCMS compatibility.
## Parameter interpolation
Use `:name` placeholders with automatic case variants:
| Placeholder | Replacement | Result for `"alice"` |
|-------------|-------------|---------------------|
| `:name` | as-is | `alice` |
| `:Name` | ucfirst | `Alice` |
| `:NAME` | uppercase | `ALICE` |
```scala
translator.get("golem15.blog::lang.greeting", Map("name" -> "alice", "count" -> "3"))
// Given: "Hello :Name, you have :count new messages"
// => "Hello Alice, you have 3 new messages"
```
## Pluralization
Two mechanisms, evaluated in order:
### 1. Explicit conditions
Exact values with `{N}` and ranges with `[from,to]` (`*` = unbounded):
```hocon
items = "{0} No items|{1} One item|[2,5] A few items|[6,*] Many items"
```
### 2. CLDR plural rules
When no explicit condition matches, the engine selects a form by CLDR index based on the locale. Forms are pipe-separated, ordered by CLDR category:
| Locale group | Forms | Categories |
|-------------|-------|------------|
| Chinese, Japanese, Korean, Turkish, ... | 1 | other |
| English, German, Spanish, Italian, ... | 2 | one \| other |
| French, Hindi, ... | 2 | one (0 and 1) \| other |
| Polish, Russian, Ukrainian, Czech, ... | 3 | one \| few \| many |
| Slovenian, Maltese, Welsh | 4 | varies |
| Arabic | 6 | zero \| one \| two \| few \| many \| other |
Covers 40+ locale groups. Ported from Laravel's `MessageSelector` (Zend Framework, BSD license).
```hocon
# English: singular|plural
posts_count = ":count post|:count posts"
# Polish: one|few|many
posts_count = ":count wpis|:count wpisy|:count wpisów"
# Arabic: zero|one|two|few|many|other
posts_count = "لا مقالات|مقالة واحدة|مقالتان|:count مقالات|:count مقالة|:count مقالة"
```
```scala
translator.choice("golem15.blog::lang.posts_count", 1, locale = Some(LocaleTag("pl")))
// => "1 wpis"
translator.choice("golem15.blog::lang.posts_count", 22, locale = Some(LocaleTag("pl")))
// => "22 wpisy"
```
## Locale fallback
When a key is not found in the requested locale, the engine walks a fallback chain:
```
requested (de-at) -> parent (de) -> configured fallback (en) -> core locale (en)
```
First match wins. If no translation is found anywhere, the raw key string is returned.
## Namespace aliases
Shorthand aliases for long plugin namespaces:
```scala
translator.addAlias("blog", "golem15.blog")
translator.get("blog::lang.labels.pluginName")
// => "Blog"
```
## Runtime overrides
Override any translation at runtime (locale-specific):
```scala
translator.set("golem15.blog::lang.labels.pluginName", "My Custom Blog")
translator.get("golem15.blog::lang.labels.pluginName")
// => "My Custom Blog"
```
## API reference
```scala
trait Translator:
def get(key: String, replace: Map[String, String] = Map.empty,
locale: Option[LocaleTag] = None): String
def choice(key: String, number: Int, replace: Map[String, String] = Map.empty,
locale: Option[LocaleTag] = None): String
def has(key: String, locale: Option[LocaleTag] = None): Boolean
def allForLocale(locale: LocaleTag,
namespace: Option[String] = None): Map[String, String]
def setLocale(locale: LocaleTag): Unit
def getLocale: LocaleTag
def getFallback: Option[LocaleTag]
def setFallback(locale: LocaleTag): Unit
def addNamespace(namespace: String, path: java.nio.file.Path): Unit
def addAlias(alias: String, namespace: String): Unit
def set(key: String, value: String, locale: Option[LocaleTag] = None): Unit
def clearCache(): Unit
```
## Module structure
```
src/main/scala/summer/phrasebook/
LocaleTag.scala Opaque type for locale identifiers
TranslationKey.scala ParsedKey case class (namespace/group/item)
KeyParser.scala namespace::group.item parser with cache
PluralRules.scala CLDR plural index rules (40+ locales)
Interpolator.scala :name / :Name / :NAME replacement
HoconLoader.scala HOCON -> flat Map[String, String]
FallbackChain.scala Locale fallback chain builder
Translator.scala Public API trait
PhrasebookTranslator.scala Full implementation
```
## Dependencies
| Dependency | Version | Purpose |
|-----------|---------|---------|
| `com.typesafe:config` | 1.4.3 | HOCON parsing |
| `org.scalameta:munit` | 1.0.3 | Testing (test scope only) |
No effect systems. No Cats, no ZIO. Pure direct-style Scala 3 on JDK 21.
## Building
```bash
sbt compile # compile
sbt test # run all 95 tests
```
Requires JDK 21+ and sbt 1.10+.
## Roadmap
- **Phase 2** — Integration: Pebble template extension (`|_` filter, `trans()`/`transChoice()` functions), Tapir endpoint for JSON translation export to Vue admin SPA
- **Phase 3** — Tooling: `summer phrasebook:scan` (extract translatable strings), `summer phrasebook:export/import` (flat files for translators), dev-mode hot-reload
- **Phase 4** — Database-backed translation overrides, compile-time key validation macros
## License
Part of SummerCMS. Plural rules derived from the Zend Framework (BSD license) via Laravel.

20
build.sbt Normal file
View File

@@ -0,0 +1,20 @@
lazy val root = (project in file("."))
.settings(
name := "summer-phrasebook",
organization := "com.golem15.summer",
version := "0.1.0-SNAPSHOT",
scalaVersion := "3.3.4",
libraryDependencies ++= Seq(
"ma.chinespirit" %% "jig" % "0.1.0",
"org.scalameta" %% "munit" % "1.0.3" % Test,
),
scalacOptions ++= Seq(
"-Wunused:all",
"-deprecation",
"-feature",
),
testFrameworks += new TestFramework("munit.Framework"),
)

1
project/build.properties Normal file
View File

@@ -0,0 +1 @@
sbt.version=1.10.7

View File

@@ -0,0 +1,50 @@
package summer.phrasebook
/**
* Builds the locale fallback chain for translation resolution.
*
* Order: requested locale → parent locale → configured fallback → core locale ("en").
*
* Example: `de-at` with fallback `de` and core `en`:
* `Vector("de-at", "de", "en")`
*
* Duplicates are removed while preserving order.
*/
object FallbackChain:
val CoreLocale: LocaleTag = LocaleTag("en")
/**
* Build the fallback chain for a given requested locale.
*
* @param requested the locale the user asked for
* @param fallback optional configured fallback locale (e.g. site default)
* @param core the ultimate fallback, defaults to "en"
*/
def build(
requested: LocaleTag,
fallback: Option[LocaleTag] = None,
core: LocaleTag = CoreLocale,
): Vector[LocaleTag] =
val chain = Vector.newBuilder[LocaleTag]
val seen = collection.mutable.Set.empty[String]
def add(tag: LocaleTag): Unit =
if seen.add(tag.value) then chain += tag
// 1. Requested locale
add(requested)
// 2. Walk up parent chain (e.g. de-at -> de)
var current = requested
while current.parent.isDefined do
current = current.parent.get
add(current)
// 3. Configured fallback
fallback.foreach(add)
// 4. Core locale
add(core)
chain.result()

View File

@@ -0,0 +1,57 @@
package summer.phrasebook
import machinespir.it.jig.{Config, ConfigFactory}
import org.ekrich.config.ConfigValueType
import java.nio.file.{Files, Path}
import scala.jdk.CollectionConverters.*
/**
* Loads HOCON `.conf` files and flattens them into `Map[String, String]`.
*
* Nested HOCON keys become dot-separated paths:
* ```hocon
* labels {
* pluginName = "Blog"
* post {
* title = "Post Title"
* }
* }
* ```
* becomes:
* ```
* "labels.pluginName" -> "Blog"
* "labels.post.title" -> "Post Title"
* ```
*/
object HoconLoader:
/** Load and flatten a single HOCON file. Returns empty map if file doesn't exist. */
def load(path: Path): Map[String, String] =
if !Files.exists(path) then Map.empty
else
val config = ConfigFactory.parseFile(path.toFile).resolve()
flatten(config)
/** Load from a classpath resource name. */
def loadResource(resourceName: String): Map[String, String] =
val config = ConfigFactory.load(resourceName).resolve()
flatten(config)
/** Flatten a Typesafe Config into a string map. */
def flatten(config: Config): Map[String, String] =
val builder = Map.newBuilder[String, String]
for entry <- config.entrySet.asScala do
val key = entry.getKey.nn
val value = entry.getValue.nn
value.valueType match
case ConfigValueType.STRING | ConfigValueType.NUMBER | ConfigValueType.BOOLEAN =>
builder += key -> config.getString(key).nn
case ConfigValueType.NULL =>
// skip nulls
case ConfigValueType.LIST =>
// Store list values as comma-separated for simple cases
builder += key -> config.getStringList(key).nn.asScala.mkString(",")
case ConfigValueType.OBJECT =>
// Objects are already flattened by entrySet() traversal
()
builder.result()

View File

@@ -0,0 +1,41 @@
package summer.phrasebook
/**
* Performs parameter interpolation on translation strings.
*
* Ported from Laravel's `Translator::makeReplacements()`.
*
* For each replacement pair (key -> value), three substitutions are applied:
* - `:key` -> value (as-is)
* - `:Key` -> ucfirst(value)
* - `:KEY` -> uppercase(value)
*
* Longer keys are replaced first to avoid partial-match issues
* (mirroring PHP's `strtr()` behavior).
*/
object Interpolator:
def replace(line: String, replacements: Map[String, String]): String =
if replacements.isEmpty then return line
// Build all substitution pairs, sorted by key length descending
// so longer placeholders are replaced first.
val pairs = replacements.toVector
.flatMap { (key, value) =>
val k = key.stripPrefix(":")
Vector(
s":${ucfirst(k)}" -> ucfirst(value),
s":${k.toUpperCase}" -> value.toUpperCase.nn,
s":$k" -> value,
)
}
.sortBy(-_._1.length)
var result = line
for (placeholder, value) <- pairs do
result = result.replace(placeholder, value)
result
private def ucfirst(s: String): String =
if s.isEmpty then s
else s.charAt(0).toUpper.toString + s.substring(1)

View File

@@ -0,0 +1,52 @@
package summer.phrasebook
import java.util.concurrent.ConcurrentHashMap
/**
* Parses translation key strings into [[ParsedKey]] components.
*
* Ported from Laravel's `Illuminate\Support\NamespacedItemResolver`.
*
* Format: `[namespace::]group[.item[.subitem...]]`
*
* The result is cached in a ConcurrentHashMap for repeated lookups.
*/
object KeyParser:
private val cache = new ConcurrentHashMap[String, ParsedKey]()
def parse(key: String): ParsedKey =
val cached = cache.get(key)
if cached != null then cached
else
val parsed = doParse(key)
cache.put(key, parsed)
parsed
def clearCache(): Unit = cache.clear()
private def doParse(key: String): ParsedKey =
val nsIdx = key.indexOf("::")
if nsIdx < 0 then
parseBasic(key)
else
parseNamespaced(key, nsIdx)
private def parseBasic(key: String): ParsedKey =
val dotIdx = key.indexOf('.')
if dotIdx < 0 then
ParsedKey(namespace = None, group = key, item = None)
else
val group = key.substring(0, dotIdx)
val item = key.substring(dotIdx + 1)
ParsedKey(namespace = None, group = group, item = Some(item))
private def parseNamespaced(key: String, nsIdx: Int): ParsedKey =
val namespace = key.substring(0, nsIdx).toLowerCase.nn
val rest = key.substring(nsIdx + 2)
val dotIdx = rest.indexOf('.')
if dotIdx < 0 then
ParsedKey(namespace = Some(namespace), group = rest, item = None)
else
val group = rest.substring(0, dotIdx)
val item = rest.substring(dotIdx + 1)
ParsedKey(namespace = Some(namespace), group = group, item = Some(item))

View File

@@ -0,0 +1,35 @@
package summer.phrasebook
/** Opaque type wrapping a locale identifier string (e.g. "en", "pl", "de-at"). */
opaque type LocaleTag = String
object LocaleTag:
def apply(raw: String): LocaleTag =
raw.trim.toLowerCase.nn
extension (tag: LocaleTag)
def value: String = tag
/** Parent locale: "de-at" -> Some("de"), "de" -> None */
def parent: Option[LocaleTag] =
val idx = tag.lastIndexOf('-')
if idx > 0 then Some(LocaleTag(tag.substring(0, idx))) else None
/** Language portion only: "de-at" -> "de", "zh-tw" -> "zh" */
def language: String =
val idx = tag.indexOf('-')
if idx > 0 then tag.substring(0, idx) else tag
/**
* Normalize for plural rule lookup: "zh-tw" -> "zh_TW".
* MessageSelector in Laravel uses underscore + uppercase region.
*/
def toPluralLocale: String =
val idx = tag.indexOf('-')
if idx > 0 then
val lang = tag.substring(0, idx)
val region = tag.substring(idx + 1).toUpperCase.nn
s"${lang}_${region}"
else tag
given CanEqual[LocaleTag, LocaleTag] = CanEqual.derived

View File

@@ -0,0 +1,201 @@
package summer.phrasebook
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
/**
* Full implementation of the [[Translator]] trait.
*
* Ported from Laravel's `Illuminate\Translation\Translator` +
* WinterCMS `Winter\Storm\Translation\Translator`.
*
* Thread-safe: uses ConcurrentHashMap for loaded translations and overrides.
*/
final class PhrasebookTranslator(
initialLocale: LocaleTag,
initialFallback: Option[LocaleTag] = None,
) extends Translator:
// --- Mutable state ---
@volatile private var currentLocale: LocaleTag = initialLocale
@volatile private var fallbackLocale: Option[LocaleTag] = initialFallback
/** namespace -> filesystem path */
private val namespaces = new ConcurrentHashMap[String, Path]()
/** alias -> real namespace */
private val aliases = new ConcurrentHashMap[String, String]()
/**
* Loaded translations: (namespace, group, locale) -> flat Map.
* Using a composite string key "ns|group|locale" for simplicity.
*/
private val loaded = new ConcurrentHashMap[String, Map[String, String]]()
/** Runtime overrides: (locale, fullKey) -> value */
private val overrides = new ConcurrentHashMap[String, String]()
// --- Public API ---
override def get(
key: String,
replace: Map[String, String],
locale: Option[LocaleTag],
): String =
val effectiveLocale = locale.getOrElse(currentLocale)
val parsed = KeyParser.parse(key)
val ns = resolveNamespace(parsed.namespace)
// Check runtime overrides first
val overrideKey = s"${effectiveLocale.value}|$key"
val overridden = overrides.get(overrideKey)
if overridden != null then
return Interpolator.replace(overridden, replace)
// WinterCMS compat: validation.* without namespace -> system::validation.*
val (finalNs, finalParsed) =
if ns.isEmpty && parsed.group == "validation" then
(Some("system"), parsed.copy(namespace = Some("system")))
else
(ns, parsed)
// Walk fallback chain
val chain = FallbackChain.build(effectiveLocale, fallbackLocale)
val result = chain.iterator.flatMap { loc =>
getLine(finalNs, finalParsed.group, loc, finalParsed.item)
}.nextOption()
result match
case Some(line) => Interpolator.replace(line, replace)
case None => key // Return raw key if not found (Laravel behavior)
override def choice(
key: String,
number: Int,
replace: Map[String, String],
locale: Option[LocaleTag],
): String =
val effectiveLocale = locale.getOrElse(currentLocale)
val line = get(key, Map.empty, Some(effectiveLocale))
// If get() returned the raw key (not found), return it with replacements
if line == key then
return Interpolator.replace(key, replace + ("count" -> number.toString))
val chosen = PluralRules.choose(line, number, effectiveLocale)
Interpolator.replace(chosen, replace + ("count" -> number.toString))
override def has(key: String, locale: Option[LocaleTag]): Boolean =
val effectiveLocale = locale.getOrElse(currentLocale)
val result = get(key, Map.empty, Some(effectiveLocale))
result != key
override def allForLocale(
locale: LocaleTag,
namespace: Option[String],
): Map[String, String] =
import scala.jdk.CollectionConverters.*
val prefix = namespace match
case Some(ns) => s"${resolveNamespace(Some(ns)).getOrElse(ns)}|"
case None => ""
val result = Map.newBuilder[String, String]
for entry <- loaded.entrySet().asScala do
val compositeKey = entry.getKey.nn
if compositeKey.endsWith(s"|${locale.value}") &&
(prefix.isEmpty || compositeKey.startsWith(prefix))
then
val translations = entry.getValue.nn
result ++= translations
result.result()
override def setLocale(locale: LocaleTag): Unit =
require(!locale.value.contains("/") && !locale.value.contains("\\"),
s"Invalid locale: ${locale.value}")
currentLocale = locale
override def getLocale: LocaleTag = currentLocale
override def getFallback: Option[LocaleTag] = fallbackLocale
override def setFallback(locale: LocaleTag): Unit =
fallbackLocale = Some(locale)
override def addNamespace(namespace: String, path: Path): Unit =
namespaces.put(namespace.toLowerCase.nn, path)
override def addAlias(alias: String, namespace: String): Unit =
aliases.put(alias.toLowerCase.nn, namespace.toLowerCase.nn)
override def set(key: String, value: String, locale: Option[LocaleTag]): Unit =
val effectiveLocale = locale.getOrElse(currentLocale)
overrides.put(s"${effectiveLocale.value}|$key", value)
override def clearCache(): Unit =
loaded.clear()
overrides.clear()
KeyParser.clearCache()
// --- Internal ---
/**
* Resolve a namespace through aliases.
* Returns None for no-namespace keys.
*/
private def resolveNamespace(ns: Option[String]): Option[String] =
ns.map { n =>
val lower = n.toLowerCase.nn
val aliased = aliases.get(lower)
if aliased != null then aliased else lower
}
/**
* Get a translation line for a specific (namespace, group, locale, item) combo.
*/
private def getLine(
namespace: Option[String],
group: String,
locale: LocaleTag,
item: Option[String],
): Option[String] =
val translations = loadGroup(namespace, group, locale)
item match
case Some(itemKey) => translations.get(itemKey)
case None => None // Requesting a whole group returns None for get()
/**
* Load translations for a (namespace, group, locale) triple.
* Lazy-loads from disk on first access.
*/
private def loadGroup(
namespace: Option[String],
group: String,
locale: LocaleTag,
): Map[String, String] =
val cacheKey = s"${namespace.getOrElse("*")}|$group|${locale.value}"
val cached = loaded.get(cacheKey)
if cached != null then cached
else
val translations = doLoad(namespace, group, locale)
loaded.put(cacheKey, translations)
translations
/**
* Actually load translations from disk.
*/
private def doLoad(
namespace: Option[String],
group: String,
locale: LocaleTag,
): Map[String, String] =
namespace match
case None =>
// No namespace - would need a default path. Return empty.
Map.empty
case Some(ns) =>
val basePath = namespaces.get(ns)
if basePath == null then Map.empty
else
val confFile = basePath.resolve(locale.value).resolve(s"$group.conf")
HoconLoader.load(confFile)

View File

@@ -0,0 +1,182 @@
package summer.phrasebook
/**
* CLDR-based plural rule engine ported from Laravel's `MessageSelector`.
*
* Two-pass approach:
* 1. Try explicit conditions: `{0} none|{1} one|[2,*] many`
* 2. Fall back to CLDR plural index for the locale.
*
* The `getPluralIndex` method covers ~40 locale groups from the
* Zend Framework / Laravel plural rules (BSD licensed).
*/
object PluralRules:
/**
* Choose the correct plural form from a pipe-delimited string.
*
* @param line pipe-delimited translation e.g. "item|items" or "{0} none|{1} one|[2,*] many"
* @param number the count to pluralize for
* @param locale the locale tag (used for CLDR rules)
* @return the selected form string
*/
def choose(line: String, number: Int, locale: LocaleTag): String =
val segments = line.split("\\|", -1).map(_.trim.nn).toVector
// Pass 1: try explicit conditions
extractExplicit(segments, number) match
case Some(value) => value
case None =>
// Pass 2: strip conditions, use CLDR index
val stripped = segments.map(stripCondition)
val index = getPluralIndex(locale.toPluralLocale, number)
if index < stripped.size then stripped(index)
else stripped.lastOption.getOrElse(line)
/**
* Try to match a segment with explicit condition syntax.
* `{N}` = exact value, `[from,to]` = range (inclusive), `*` = wildcard.
*/
private def extractExplicit(segments: Vector[String], number: Int): Option[String] =
segments.iterator.flatMap(extractFromString(_, number)).nextOption()
private val conditionPattern = """^[\{\[]([^\[\]\{\}]*)[\}\]](.*)""".r
private def extractFromString(part: String, number: Int): Option[String] =
part match
case conditionPattern(condition, value) =>
if condition.contains(',') then
val parts = condition.split(',')
if parts.length == 2 then
val from = parts(0).trim
val to = parts(1).trim
val fromOk = from == "*" || number >= from.toInt
val toOk = to == "*" || number <= to.toInt
if fromOk && toOk then Some(value.trim.nn) else None
else None
else
// Exact match
if condition.trim == number.toString then Some(value.trim.nn) else None
case _ => None
/** Strip the leading `{...}` or `[...]` condition prefix from a segment. */
private val stripPattern = """^[\{\[]([^\[\]\{\}]*)[\}\]](.*)""".r
private def stripCondition(segment: String): String =
segment match
case stripPattern(_, value) => value.trim.nn
case _ => segment
/**
* Returns the 0-based plural form index for a given locale and number.
*
* Ported from Laravel `MessageSelector::getPluralIndex()` which itself
* derives from the Zend Framework (BSD license).
*/
def getPluralIndex(locale: String, number: Int): Int =
// Use language portion for matching (e.g. "pt_BR" -> "pt")
val lang = locale.indexOf('_') match
case -1 => locale.toLowerCase.nn
case idx => locale.substring(0, idx).toLowerCase.nn
val n = math.abs(number)
lang match
// No plural: always 0
case "az" | "bo" | "dz" | "id" | "ja" | "jv" | "ka" | "km" |
"kn" | "ko" | "ms" | "th" | "tr" | "vi" | "zh" =>
0
// 2 forms: singular | plural (n == 1 ? 0 : 1)
case "af" | "bn" | "bg" | "ca" | "da" | "de" | "el" | "en" |
"eo" | "es" | "et" | "eu" | "fa" | "fi" | "fo" | "fur" |
"fy" | "gl" | "gu" | "ha" | "he" | "hu" | "is" | "it" |
"ku" | "lb" | "ml" | "mn" | "mr" | "nah" | "nb" | "ne" |
"nl" | "nn" | "no" | "om" | "or" | "pa" | "pap" | "ps" |
"pt" | "so" | "sq" | "sv" | "sw" | "ta" | "te" | "tk" |
"ur" | "zu" =>
if n == 1 then 0 else 1
// French-style: 0 and 1 are singular
case "am" | "bh" | "fil" | "fr" | "gun" | "hi" | "hy" |
"ln" | "mg" | "nso" | "ti" | "wa" | "xbr" =>
if n == 0 || n == 1 then 0 else 1
// Slavic (Russian/Ukrainian/Bosnian/Croatian/Serbian/Belarusian): 3 forms
case "be" | "bs" | "hr" | "ru" | "sr" | "uk" =>
if n % 10 == 1 && n % 100 != 11 then 0
else if n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) then 1
else 2
// Czech / Slovak: 3 forms
case "cs" | "sk" =>
if n == 1 then 0
else if n >= 2 && n <= 4 then 1
else 2
// Irish
case "ga" =>
if n == 1 then 0
else if n == 2 then 1
else 2
// Lithuanian: 3 forms
case "lt" =>
if n % 10 == 1 && n % 100 != 11 then 0
else if n % 10 >= 2 && (n % 100 < 10 || n % 100 >= 20) then 1
else 2
// Slovenian: 4 forms
case "sl" =>
if n % 100 == 1 then 0
else if n % 100 == 2 then 1
else if n % 100 == 3 || n % 100 == 4 then 2
else 3
// Macedonian: last digit
case "mk" =>
if n % 10 == 1 then 0 else 1
// Maltese: 4 forms
case "mt" =>
if n == 1 then 0
else if n == 0 || (n % 100 > 1 && n % 100 < 11) then 1
else if n % 100 > 10 && n % 100 < 20 then 2
else 3
// Latvian: 3 forms
case "lv" =>
if n == 0 then 0
else if n % 10 == 1 && n % 100 != 11 then 1
else 2
// Polish: 3 forms
case "pl" =>
if n == 1 then 0
else if n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) then 1
else 2
// Welsh: 4 forms
case "cy" =>
if n == 1 then 0
else if n == 2 then 1
else if n == 8 || n == 11 then 2
else 3
// Romanian: 3 forms
case "ro" =>
if n == 1 then 0
else if n == 0 || (n % 100 > 0 && n % 100 < 20) then 1
else 2
// Arabic: 6 forms
case "ar" =>
if n == 0 then 0
else if n == 1 then 1
else if n == 2 then 2
else if n % 100 >= 3 && n % 100 <= 10 then 3
else if n % 100 >= 11 && n % 100 <= 99 then 4
else 5
// Default: no plural
case _ => 0

View File

@@ -0,0 +1,15 @@
package summer.phrasebook
/**
* Parsed translation key components.
*
* Examples:
* "validation.required" -> ParsedKey(None, "validation", Some("required"))
* "winter.pages::lang.editor.title" -> ParsedKey(Some("winter.pages"), "lang", Some("editor.title"))
* "lang" -> ParsedKey(None, "lang", None)
*/
final case class ParsedKey(
namespace: Option[String],
group: String,
item: Option[String],
)

View File

@@ -0,0 +1,66 @@
package summer.phrasebook
import java.nio.file.Path
/**
* Public API for the translation engine.
*
* This trait mirrors Laravel's `Illuminate\Contracts\Translation\Translator`
* extended with WinterCMS additions (namespace aliases, runtime set, etc.).
*/
trait Translator:
/** Get the translation for the given key. Returns the key itself if not found. */
def get(
key: String,
replace: Map[String, String] = Map.empty,
locale: Option[LocaleTag] = None,
): String
/**
* Get a translation with plural form selection.
*
* @param key translation key (may contain pipe-delimited plural forms)
* @param number the count for plural selection
* @param replace parameter replacements (`:count` is auto-added)
* @param locale optional locale override
*/
def choice(
key: String,
number: Int,
replace: Map[String, String] = Map.empty,
locale: Option[LocaleTag] = None,
): String
/** Check whether a translation exists for the given key. */
def has(key: String, locale: Option[LocaleTag] = None): Boolean
/** Get all translations for a locale, optionally filtered by namespace. */
def allForLocale(
locale: LocaleTag,
namespace: Option[String] = None,
): Map[String, String]
/** Set the current default locale. */
def setLocale(locale: LocaleTag): Unit
/** Get the current default locale. */
def getLocale: LocaleTag
/** Get the configured fallback locale. */
def getFallback: Option[LocaleTag]
/** Set the fallback locale. */
def setFallback(locale: LocaleTag): Unit
/** Register a namespace with its filesystem path for translation loading. */
def addNamespace(namespace: String, path: Path): Unit
/** Register a namespace alias (e.g. "user" -> "golem15.user"). */
def addAlias(alias: String, namespace: String): Unit
/** Set a runtime translation override. */
def set(key: String, value: String, locale: Option[LocaleTag] = None): Unit
/** Clear all cached/loaded translations. */
def clearCache(): Unit

View File

@@ -0,0 +1,3 @@
# Arabic translations - 6 plural forms
# zero|one|two|few(3-10)|many(11-99)|other(100+)
posts_count = "لا مقالات|مقالة واحدة|مقالتان|:count مقالات|:count مقالة|:count مقالة"

View File

@@ -0,0 +1,13 @@
# German translations for test plugin
labels {
pluginName = "Blog"
posts = "Beiträge"
}
post {
create_title = "Beitrag erstellen"
}
posts_count = ":count Beitrag|:count Beiträge"
welcome = "Willkommen, :name!"

View File

@@ -0,0 +1,23 @@
# English translations for test plugin
labels {
pluginName = "Blog"
posts = "Posts"
categories = "Categories"
}
post {
create_title = "Create Post"
edit_title = "Edit Post"
delete_confirm = "Are you sure you want to delete this post?"
}
# Pluralization: English has 2 forms (singular|plural)
posts_count = ":count post|:count posts"
# Explicit range syntax
items_range = "{0} No items|{1} One item|[2,5] A few items|[6,*] Many items"
# Parameter interpolation
welcome = "Welcome, :name!"
greeting = "Hello :Name, you have :count new messages"
shout = "ATTENTION :NAME!"

View File

@@ -0,0 +1,4 @@
# English validation messages
required = "The :attribute field is required."
min_string = "The :attribute must be at least :min characters."
email = "The :attribute must be a valid email address."

View File

@@ -0,0 +1,18 @@
# Polish translations for test plugin
labels {
pluginName = "Blog"
posts = "Wpisy"
categories = "Kategorie"
}
post {
create_title = "Utwórz wpis"
edit_title = "Edytuj wpis"
delete_confirm = "Czy na pewno chcesz usunąć ten wpis?"
}
# Polish: 3 CLDR forms (one|few|many)
# n==1: wpis; n%10 in 2..4 && n%100 not in 12..14: wpisy; else: wpisów
posts_count = ":count wpis|:count wpisy|:count wpisów"
welcome = "Witaj, :name!"

View File

@@ -0,0 +1,4 @@
# App-level override for the test plugin
labels {
pluginName = "My Custom Blog"
}

View File

@@ -0,0 +1,64 @@
package summer.phrasebook
class FallbackChainSpec extends munit.FunSuite:
test("simple locale with no fallback") {
val chain = FallbackChain.build(LocaleTag("en"))
assertEquals(chain.map(_.value), Vector("en"))
}
test("locale with parent") {
val chain = FallbackChain.build(LocaleTag("de-at"))
assertEquals(chain.map(_.value), Vector("de-at", "de", "en"))
}
test("locale with configured fallback") {
val chain = FallbackChain.build(
LocaleTag("fr"),
fallback = Some(LocaleTag("en")),
)
assertEquals(chain.map(_.value), Vector("fr", "en"))
}
test("locale with parent and different fallback") {
val chain = FallbackChain.build(
LocaleTag("de-at"),
fallback = Some(LocaleTag("fr")),
)
assertEquals(chain.map(_.value), Vector("de-at", "de", "fr", "en"))
}
test("no duplicates when fallback matches parent") {
val chain = FallbackChain.build(
LocaleTag("de-at"),
fallback = Some(LocaleTag("de")),
)
assertEquals(chain.map(_.value), Vector("de-at", "de", "en"))
}
test("no duplicates when requested is core") {
val chain = FallbackChain.build(LocaleTag("en"))
assertEquals(chain.map(_.value), Vector("en"))
}
test("no duplicates when fallback is core") {
val chain = FallbackChain.build(
LocaleTag("pl"),
fallback = Some(LocaleTag("en")),
)
assertEquals(chain.map(_.value), Vector("pl", "en"))
}
test("multi-level parent chain") {
// Hypothetical deeply nested locale
val chain = FallbackChain.build(LocaleTag("zh-hans-cn"))
assertEquals(chain.map(_.value), Vector("zh-hans-cn", "zh-hans", "zh", "en"))
}
test("custom core locale") {
val chain = FallbackChain.build(
LocaleTag("fr"),
core = LocaleTag("de"),
)
assertEquals(chain.map(_.value), Vector("fr", "de"))
}

View File

@@ -0,0 +1,49 @@
package summer.phrasebook
import java.nio.file.Path
class HoconLoaderSpec extends munit.FunSuite:
val testResources: Path =
Path.of(getClass.getClassLoader.getResource("lang/en/lang.conf").toURI).getParent.getParent
test("load English lang.conf") {
val translations = HoconLoader.load(testResources.resolve("en/lang.conf"))
assertEquals(translations.get("labels.pluginName"), Some("Blog"))
assertEquals(translations.get("labels.posts"), Some("Posts"))
assertEquals(translations.get("post.create_title"), Some("Create Post"))
}
test("load Polish lang.conf") {
val translations = HoconLoader.load(testResources.resolve("pl/lang.conf"))
assertEquals(translations.get("labels.pluginName"), Some("Blog"))
assertEquals(translations.get("labels.posts"), Some("Wpisy"))
assertEquals(translations.get("post.create_title"), Some("Utwórz wpis"))
}
test("nested keys are dot-separated") {
val translations = HoconLoader.load(testResources.resolve("en/lang.conf"))
assertEquals(translations.get("post.edit_title"), Some("Edit Post"))
assertEquals(translations.get("post.delete_confirm"), Some("Are you sure you want to delete this post?"))
}
test("translation with placeholders is loaded as-is") {
val translations = HoconLoader.load(testResources.resolve("en/lang.conf"))
assertEquals(translations.get("welcome"), Some("Welcome, :name!"))
}
test("plural string with pipes is loaded as-is") {
val translations = HoconLoader.load(testResources.resolve("en/lang.conf"))
assertEquals(translations.get("posts_count"), Some(":count post|:count posts"))
}
test("nonexistent file returns empty map") {
val translations = HoconLoader.load(testResources.resolve("xx/nonexistent.conf"))
assert(translations.isEmpty)
}
test("validation.conf loads correctly") {
val translations = HoconLoader.load(testResources.resolve("en/validation.conf"))
assertEquals(translations.get("required"), Some("The :attribute field is required."))
assertEquals(translations.get("email"), Some("The :attribute must be a valid email address."))
}

View File

@@ -0,0 +1,63 @@
package summer.phrasebook
class InterpolatorSpec extends munit.FunSuite:
test("simple replacement") {
val result = Interpolator.replace("Hello, :name!", Map("name" -> "World"))
assertEquals(result, "Hello, World!")
}
test("ucfirst replacement") {
val result = Interpolator.replace("Hello, :Name!", Map("name" -> "world"))
assertEquals(result, "Hello, World!")
}
test("uppercase replacement") {
val result = Interpolator.replace("ATTENTION :NAME!", Map("name" -> "world"))
assertEquals(result, "ATTENTION WORLD!")
}
test("all three case variants in one string") {
val result = Interpolator.replace(
":name :Name :NAME",
Map("name" -> "hello"),
)
assertEquals(result, "hello Hello HELLO")
}
test("multiple different replacements") {
val result = Interpolator.replace(
"The :attribute must be at least :min characters.",
Map("attribute" -> "password", "min" -> "8"),
)
assertEquals(result, "The password must be at least 8 characters.")
}
test("empty replacements returns original") {
val result = Interpolator.replace("No replacements here", Map.empty)
assertEquals(result, "No replacements here")
}
test("key with colon prefix is stripped") {
// Users can pass ":name" or "name" - both should work
val result = Interpolator.replace("Hello :name", Map(":name" -> "World"))
assertEquals(result, "Hello World")
}
test("no placeholder in string") {
val result = Interpolator.replace("No placeholders", Map("name" -> "World"))
assertEquals(result, "No placeholders")
}
test("longer keys replaced first to avoid partial match") {
val result = Interpolator.replace(
":count_total and :count items",
Map("count" -> "5", "count_total" -> "10"),
)
assertEquals(result, "10 and 5 items")
}
test("empty value replacement") {
val result = Interpolator.replace("Hello :name!", Map("name" -> ""))
assertEquals(result, "Hello !")
}

View File

@@ -0,0 +1,67 @@
package summer.phrasebook
class KeyParserSpec extends munit.FunSuite:
override def beforeEach(context: BeforeEach): Unit =
KeyParser.clearCache()
test("parse simple group.item key") {
val parsed = KeyParser.parse("validation.required")
assertEquals(parsed.namespace, None)
assertEquals(parsed.group, "validation")
assertEquals(parsed.item, Some("required"))
}
test("parse group-only key (no item)") {
val parsed = KeyParser.parse("lang")
assertEquals(parsed.namespace, None)
assertEquals(parsed.group, "lang")
assertEquals(parsed.item, None)
}
test("parse namespaced key") {
val parsed = KeyParser.parse("winter.pages::lang.editor.title")
assertEquals(parsed.namespace, Some("winter.pages"))
assertEquals(parsed.group, "lang")
assertEquals(parsed.item, Some("editor.title"))
}
test("parse namespaced key with simple item") {
val parsed = KeyParser.parse("system::validation.required")
assertEquals(parsed.namespace, Some("system"))
assertEquals(parsed.group, "validation")
assertEquals(parsed.item, Some("required"))
}
test("parse namespaced group-only key") {
val parsed = KeyParser.parse("system::validation")
assertEquals(parsed.namespace, Some("system"))
assertEquals(parsed.group, "validation")
assertEquals(parsed.item, None)
}
test("namespace is lowercased") {
val parsed = KeyParser.parse("Winter.Pages::lang.title")
assertEquals(parsed.namespace, Some("winter.pages"))
}
test("deeply nested item preserves dots") {
val parsed = KeyParser.parse("system::validation.custom.email.required")
assertEquals(parsed.namespace, Some("system"))
assertEquals(parsed.group, "validation")
assertEquals(parsed.item, Some("custom.email.required"))
}
test("results are cached") {
val first = KeyParser.parse("test.key")
val second = KeyParser.parse("test.key")
assert(first eq second, "Cached results should be the same reference")
}
test("clearCache removes cached entries") {
val first = KeyParser.parse("cached.key")
KeyParser.clearCache()
val second = KeyParser.parse("cached.key")
assertEquals(first, second)
assert(!(first eq second), "After clear, should be a new instance")
}

View File

@@ -0,0 +1,34 @@
package summer.phrasebook
class LocaleTagSpec extends munit.FunSuite:
test("apply normalizes to lowercase") {
assertEquals(LocaleTag("EN").value, "en")
assertEquals(LocaleTag("De-AT").value, "de-at")
}
test("apply trims whitespace") {
assertEquals(LocaleTag(" en ").value, "en")
}
test("parent returns None for simple locale") {
assertEquals(LocaleTag("en").parent, None)
}
test("parent returns parent locale") {
assertEquals(LocaleTag("de-at").parent.map(_.value), Some("de"))
}
test("language returns language portion") {
assertEquals(LocaleTag("de-at").language, "de")
assertEquals(LocaleTag("en").language, "en")
}
test("toPluralLocale converts dash to underscore+uppercase region") {
assertEquals(LocaleTag("zh-tw").toPluralLocale, "zh_TW")
assertEquals(LocaleTag("pt-br").toPluralLocale, "pt_BR")
}
test("toPluralLocale leaves simple locale unchanged") {
assertEquals(LocaleTag("en").toPluralLocale, "en")
}

View File

@@ -0,0 +1,296 @@
package summer.phrasebook
import java.nio.file.Path
class PhrasebookTranslatorSpec extends munit.FunSuite:
val langDir: Path =
Path.of(getClass.getClassLoader.getResource("lang/en/lang.conf").toURI).getParent.getParent
val overridesDir: Path =
Path.of(getClass.getClassLoader.getResource("overrides/en/lang.conf").toURI).getParent.getParent
def makeTranslator(): PhrasebookTranslator =
val t = new PhrasebookTranslator(
initialLocale = LocaleTag("en"),
initialFallback = Some(LocaleTag("en")),
)
t.addNamespace("test.plugin", langDir)
t
override def beforeEach(context: BeforeEach): Unit =
KeyParser.clearCache()
// --- Basic get ---
test("get simple translation") {
val t = makeTranslator()
assertEquals(
t.get("test.plugin::lang.labels.pluginName"),
"Blog",
)
}
test("get nested translation") {
val t = makeTranslator()
assertEquals(
t.get("test.plugin::lang.post.create_title"),
"Create Post",
)
}
test("get returns raw key when not found") {
val t = makeTranslator()
assertEquals(
t.get("test.plugin::lang.nonexistent.key"),
"test.plugin::lang.nonexistent.key",
)
}
test("get from different group (validation)") {
val t = makeTranslator()
assertEquals(
t.get("test.plugin::validation.required"),
"The :attribute field is required.",
)
}
// --- Parameter replacement ---
test("get with replacements") {
val t = makeTranslator()
assertEquals(
t.get("test.plugin::lang.welcome", Map("name" -> "Alice")),
"Welcome, Alice!",
)
}
test("get with multiple replacements and case variants") {
val t = makeTranslator()
assertEquals(
t.get("test.plugin::lang.greeting", Map("name" -> "alice", "count" -> "3")),
"Hello Alice, you have 3 new messages",
)
}
test("get with uppercase replacement") {
val t = makeTranslator()
assertEquals(
t.get("test.plugin::lang.shout", Map("name" -> "alice")),
"ATTENTION ALICE!",
)
}
test("validation message with attribute replacement") {
val t = makeTranslator()
assertEquals(
t.get("test.plugin::validation.required", Map("attribute" -> "email")),
"The email field is required.",
)
}
// --- Locale switching ---
test("get translation in Polish") {
val t = makeTranslator()
assertEquals(
t.get("test.plugin::lang.labels.posts", locale = Some(LocaleTag("pl"))),
"Wpisy",
)
}
test("get translation in German") {
val t = makeTranslator()
assertEquals(
t.get("test.plugin::lang.labels.posts", locale = Some(LocaleTag("de"))),
"Beiträge",
)
}
test("setLocale changes default locale") {
val t = makeTranslator()
t.setLocale(LocaleTag("pl"))
assertEquals(
t.get("test.plugin::lang.post.create_title"),
"Utwórz wpis",
)
}
// --- Fallback ---
test("falls back to English when key missing in Polish") {
val t = makeTranslator()
// Polish has no "shout" key, should fall back to English
assertEquals(
t.get("test.plugin::lang.shout", Map("name" -> "alice"), locale = Some(LocaleTag("pl"))),
"ATTENTION ALICE!",
)
}
test("falls back to English when key missing in German") {
val t = makeTranslator()
assertEquals(
t.get("test.plugin::lang.post.edit_title", locale = Some(LocaleTag("de"))),
"Edit Post",
)
}
// --- Pluralization via choice ---
test("choice English singular") {
val t = makeTranslator()
assertEquals(
t.choice("test.plugin::lang.posts_count", 1),
"1 post",
)
}
test("choice English plural") {
val t = makeTranslator()
assertEquals(
t.choice("test.plugin::lang.posts_count", 5),
"5 posts",
)
}
test("choice Polish forms") {
val t = makeTranslator()
// 1 wpis (one)
assertEquals(
t.choice("test.plugin::lang.posts_count", 1, locale = Some(LocaleTag("pl"))),
"1 wpis",
)
// 2 wpisy (few)
assertEquals(
t.choice("test.plugin::lang.posts_count", 2, locale = Some(LocaleTag("pl"))),
"2 wpisy",
)
// 5 wpisów (many)
assertEquals(
t.choice("test.plugin::lang.posts_count", 5, locale = Some(LocaleTag("pl"))),
"5 wpisów",
)
// 22 wpisy (few: n%10=2, n%100=22)
assertEquals(
t.choice("test.plugin::lang.posts_count", 22, locale = Some(LocaleTag("pl"))),
"22 wpisy",
)
}
test("choice with explicit range syntax") {
val t = makeTranslator()
assertEquals(
t.choice("test.plugin::lang.items_range", 0),
"No items",
)
assertEquals(
t.choice("test.plugin::lang.items_range", 1),
"One item",
)
assertEquals(
t.choice("test.plugin::lang.items_range", 3),
"A few items",
)
assertEquals(
t.choice("test.plugin::lang.items_range", 10),
"Many items",
)
}
// --- has ---
test("has returns true for existing key") {
val t = makeTranslator()
assert(t.has("test.plugin::lang.labels.pluginName"))
}
test("has returns false for missing key") {
val t = makeTranslator()
assert(!t.has("test.plugin::lang.nonexistent"))
}
// --- Namespace alias ---
test("namespace alias resolves correctly") {
val t = makeTranslator()
t.addAlias("blog", "test.plugin")
assertEquals(
t.get("blog::lang.labels.pluginName"),
"Blog",
)
}
// --- Runtime set ---
test("set overrides a translation at runtime") {
val t = makeTranslator()
t.set("test.plugin::lang.labels.pluginName", "My Blog")
assertEquals(
t.get("test.plugin::lang.labels.pluginName"),
"My Blog",
)
}
test("set override is locale-specific") {
val t = makeTranslator()
t.set("test.plugin::lang.labels.pluginName", "My Blog", Some(LocaleTag("en")))
// Polish should still return original
assertEquals(
t.get("test.plugin::lang.labels.pluginName", locale = Some(LocaleTag("pl"))),
"Blog",
)
}
// --- clearCache ---
test("clearCache resets loaded translations") {
val t = makeTranslator()
// Load something
t.get("test.plugin::lang.labels.pluginName")
// Clear
t.clearCache()
// Should still work (reloads)
assertEquals(
t.get("test.plugin::lang.labels.pluginName"),
"Blog",
)
}
// --- setLocale validation ---
test("setLocale rejects path traversal") {
val t = makeTranslator()
intercept[IllegalArgumentException] {
t.setLocale(LocaleTag("../etc/passwd"))
}
}
// --- getLocale / getFallback ---
test("getLocale returns current locale") {
val t = makeTranslator()
assertEquals(t.getLocale.value, "en")
t.setLocale(LocaleTag("pl"))
assertEquals(t.getLocale.value, "pl")
}
test("getFallback returns configured fallback") {
val t = makeTranslator()
assertEquals(t.getFallback.map(_.value), Some("en"))
}
test("setFallback changes fallback locale") {
val t = makeTranslator()
t.setFallback(LocaleTag("de"))
assertEquals(t.getFallback.map(_.value), Some("de"))
}
// --- Unknown namespace ---
test("unknown namespace returns raw key") {
val t = makeTranslator()
assertEquals(
t.get("unknown.ns::lang.key"),
"unknown.ns::lang.key",
)
}

View File

@@ -0,0 +1,213 @@
package summer.phrasebook
class PluralRulesSpec extends munit.FunSuite:
// --- getPluralIndex tests ---
test("English: 2 forms (singular/plural)") {
assertEquals(PluralRules.getPluralIndex("en", 0), 1)
assertEquals(PluralRules.getPluralIndex("en", 1), 0)
assertEquals(PluralRules.getPluralIndex("en", 2), 1)
assertEquals(PluralRules.getPluralIndex("en", 100), 1)
}
test("French: 0 and 1 are singular") {
assertEquals(PluralRules.getPluralIndex("fr", 0), 0)
assertEquals(PluralRules.getPluralIndex("fr", 1), 0)
assertEquals(PluralRules.getPluralIndex("fr", 2), 1)
assertEquals(PluralRules.getPluralIndex("fr", 5), 1)
}
test("Polish: 3 forms (one/few/many)") {
assertEquals(PluralRules.getPluralIndex("pl", 1), 0) // one
assertEquals(PluralRules.getPluralIndex("pl", 2), 1) // few
assertEquals(PluralRules.getPluralIndex("pl", 3), 1) // few
assertEquals(PluralRules.getPluralIndex("pl", 4), 1) // few
assertEquals(PluralRules.getPluralIndex("pl", 5), 2) // many
assertEquals(PluralRules.getPluralIndex("pl", 10), 2) // many
assertEquals(PluralRules.getPluralIndex("pl", 11), 2) // many
assertEquals(PluralRules.getPluralIndex("pl", 12), 2) // many (12-14 are exceptions)
assertEquals(PluralRules.getPluralIndex("pl", 13), 2) // many
assertEquals(PluralRules.getPluralIndex("pl", 14), 2) // many
assertEquals(PluralRules.getPluralIndex("pl", 22), 1) // few
assertEquals(PluralRules.getPluralIndex("pl", 23), 1) // few
assertEquals(PluralRules.getPluralIndex("pl", 24), 1) // few
assertEquals(PluralRules.getPluralIndex("pl", 25), 2) // many
assertEquals(PluralRules.getPluralIndex("pl", 100), 2) // many
assertEquals(PluralRules.getPluralIndex("pl", 112), 2) // many (n%100=12, exception)
}
test("Russian: 3 forms (Slavic rule)") {
assertEquals(PluralRules.getPluralIndex("ru", 1), 0) // one
assertEquals(PluralRules.getPluralIndex("ru", 2), 1) // few
assertEquals(PluralRules.getPluralIndex("ru", 5), 2) // many
assertEquals(PluralRules.getPluralIndex("ru", 11), 2) // many (exception: 11-19)
assertEquals(PluralRules.getPluralIndex("ru", 21), 0) // one
assertEquals(PluralRules.getPluralIndex("ru", 22), 1) // few
assertEquals(PluralRules.getPluralIndex("ru", 25), 2) // many
assertEquals(PluralRules.getPluralIndex("ru", 111), 2) // many (n%100=11)
assertEquals(PluralRules.getPluralIndex("ru", 121), 0) // one (n%10=1, n%100=21)
}
test("Czech: 3 forms") {
assertEquals(PluralRules.getPluralIndex("cs", 1), 0)
assertEquals(PluralRules.getPluralIndex("cs", 2), 1)
assertEquals(PluralRules.getPluralIndex("cs", 4), 1)
assertEquals(PluralRules.getPluralIndex("cs", 5), 2)
assertEquals(PluralRules.getPluralIndex("cs", 100), 2)
}
test("Japanese: no plural (always 0)") {
assertEquals(PluralRules.getPluralIndex("ja", 0), 0)
assertEquals(PluralRules.getPluralIndex("ja", 1), 0)
assertEquals(PluralRules.getPluralIndex("ja", 100), 0)
}
test("Chinese: no plural (always 0)") {
assertEquals(PluralRules.getPluralIndex("zh", 0), 0)
assertEquals(PluralRules.getPluralIndex("zh", 1), 0)
assertEquals(PluralRules.getPluralIndex("zh", 42), 0)
}
test("Arabic: 6 forms") {
assertEquals(PluralRules.getPluralIndex("ar", 0), 0) // zero
assertEquals(PluralRules.getPluralIndex("ar", 1), 1) // one
assertEquals(PluralRules.getPluralIndex("ar", 2), 2) // two
assertEquals(PluralRules.getPluralIndex("ar", 3), 3) // few (3-10)
assertEquals(PluralRules.getPluralIndex("ar", 10), 3) // few
assertEquals(PluralRules.getPluralIndex("ar", 11), 4) // many (11-99)
assertEquals(PluralRules.getPluralIndex("ar", 99), 4) // many
assertEquals(PluralRules.getPluralIndex("ar", 100), 5) // other (100+)
assertEquals(PluralRules.getPluralIndex("ar", 1000), 5) // other
}
test("Slovenian: 4 forms") {
assertEquals(PluralRules.getPluralIndex("sl", 1), 0) // n%100==1
assertEquals(PluralRules.getPluralIndex("sl", 2), 1) // n%100==2
assertEquals(PluralRules.getPluralIndex("sl", 3), 2) // n%100==3 or 4
assertEquals(PluralRules.getPluralIndex("sl", 4), 2)
assertEquals(PluralRules.getPluralIndex("sl", 5), 3) // other
assertEquals(PluralRules.getPluralIndex("sl", 101), 0) // n%100==1
assertEquals(PluralRules.getPluralIndex("sl", 102), 1) // n%100==2
}
test("Welsh: 4 forms") {
assertEquals(PluralRules.getPluralIndex("cy", 1), 0)
assertEquals(PluralRules.getPluralIndex("cy", 2), 1)
assertEquals(PluralRules.getPluralIndex("cy", 8), 2)
assertEquals(PluralRules.getPluralIndex("cy", 11), 2)
assertEquals(PluralRules.getPluralIndex("cy", 5), 3)
}
test("Maltese: 4 forms") {
assertEquals(PluralRules.getPluralIndex("mt", 1), 0) // one
assertEquals(PluralRules.getPluralIndex("mt", 0), 1) // few (0, or n%100 2-10)
assertEquals(PluralRules.getPluralIndex("mt", 2), 1) // few
assertEquals(PluralRules.getPluralIndex("mt", 10), 1) // few
assertEquals(PluralRules.getPluralIndex("mt", 11), 2) // many (n%100 11-19)
assertEquals(PluralRules.getPluralIndex("mt", 19), 2) // many
assertEquals(PluralRules.getPluralIndex("mt", 20), 3) // other
}
test("Lithuanian: 3 forms") {
assertEquals(PluralRules.getPluralIndex("lt", 1), 0) // one (n%10==1, not 11)
assertEquals(PluralRules.getPluralIndex("lt", 2), 1) // few (n%10>=2, n%100<10 or >=20)
assertEquals(PluralRules.getPluralIndex("lt", 10), 2) // other
assertEquals(PluralRules.getPluralIndex("lt", 11), 2) // exception
assertEquals(PluralRules.getPluralIndex("lt", 21), 0) // one
assertEquals(PluralRules.getPluralIndex("lt", 12), 2) // exception (n%100 in 10-19)
}
test("Latvian: 3 forms") {
assertEquals(PluralRules.getPluralIndex("lv", 0), 0) // zero
assertEquals(PluralRules.getPluralIndex("lv", 1), 1) // one (n%10==1, not 11)
assertEquals(PluralRules.getPluralIndex("lv", 2), 2) // other
assertEquals(PluralRules.getPluralIndex("lv", 11), 2) // other (exception)
assertEquals(PluralRules.getPluralIndex("lv", 21), 1) // one
}
test("Romanian: 3 forms") {
assertEquals(PluralRules.getPluralIndex("ro", 1), 0) // one
assertEquals(PluralRules.getPluralIndex("ro", 0), 1) // few (0 or n%100 1-19)
assertEquals(PluralRules.getPluralIndex("ro", 19), 1) // few
assertEquals(PluralRules.getPluralIndex("ro", 20), 2) // other
assertEquals(PluralRules.getPluralIndex("ro", 100), 2) // other
}
test("Macedonian: last digit rule") {
assertEquals(PluralRules.getPluralIndex("mk", 1), 0) // n%10==1
assertEquals(PluralRules.getPluralIndex("mk", 2), 1)
assertEquals(PluralRules.getPluralIndex("mk", 11), 0) // n%10==1
assertEquals(PluralRules.getPluralIndex("mk", 21), 0) // n%10==1
}
test("Irish: 3 forms") {
assertEquals(PluralRules.getPluralIndex("ga", 1), 0)
assertEquals(PluralRules.getPluralIndex("ga", 2), 1)
assertEquals(PluralRules.getPluralIndex("ga", 3), 2)
}
test("locale with region uses language portion") {
// pt_BR should use "pt" rules (2 forms)
assertEquals(PluralRules.getPluralIndex("pt_BR", 1), 0)
assertEquals(PluralRules.getPluralIndex("pt_BR", 2), 1)
}
test("unknown locale returns 0") {
assertEquals(PluralRules.getPluralIndex("xx", 5), 0)
}
test("negative numbers use absolute value") {
assertEquals(PluralRules.getPluralIndex("en", -1), 0)
assertEquals(PluralRules.getPluralIndex("en", -5), 1)
}
// --- choose() tests ---
test("choose with simple English plural") {
val en = LocaleTag("en")
assertEquals(PluralRules.choose("item|items", 1, en), "item")
assertEquals(PluralRules.choose("item|items", 0, en), "items")
assertEquals(PluralRules.choose("item|items", 5, en), "items")
}
test("choose with explicit conditions") {
val en = LocaleTag("en")
assertEquals(PluralRules.choose("{0} No items|{1} One item|[2,*] Many items", 0, en), "No items")
assertEquals(PluralRules.choose("{0} No items|{1} One item|[2,*] Many items", 1, en), "One item")
assertEquals(PluralRules.choose("{0} No items|{1} One item|[2,*] Many items", 5, en), "Many items")
assertEquals(PluralRules.choose("{0} No items|{1} One item|[2,*] Many items", 100, en), "Many items")
}
test("choose with explicit range") {
val en = LocaleTag("en")
assertEquals(PluralRules.choose("[1,3] Few|[4,*] Many", 2, en), "Few")
assertEquals(PluralRules.choose("[1,3] Few|[4,*] Many", 4, en), "Many")
}
test("choose with Polish 3 forms") {
val pl = LocaleTag("pl")
val line = "wpis|wpisy|wpisów"
assertEquals(PluralRules.choose(line, 1, pl), "wpis")
assertEquals(PluralRules.choose(line, 2, pl), "wpisy")
assertEquals(PluralRules.choose(line, 5, pl), "wpisów")
assertEquals(PluralRules.choose(line, 22, pl), "wpisy")
assertEquals(PluralRules.choose(line, 12, pl), "wpisów")
}
test("choose with Arabic 6 forms") {
val ar = LocaleTag("ar")
val line = "zero|one|two|few|many|other"
assertEquals(PluralRules.choose(line, 0, ar), "zero")
assertEquals(PluralRules.choose(line, 1, ar), "one")
assertEquals(PluralRules.choose(line, 2, ar), "two")
assertEquals(PluralRules.choose(line, 5, ar), "few")
assertEquals(PluralRules.choose(line, 50, ar), "many")
assertEquals(PluralRules.choose(line, 100, ar), "other")
}
test("choose falls back to last segment if index out of bounds") {
val en = LocaleTag("en")
// Only 1 segment but English wants index 1 for n!=1
assertEquals(PluralRules.choose("only one form", 5, en), "only one form")
}