Compare commits
2 Commits
e4eb74bf06
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e923eea45 | ||
|
|
8684d2fb7b |
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
261
README.md
@@ -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
20
build.sbt
Normal 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
1
project/build.properties
Normal file
@@ -0,0 +1 @@
|
||||
sbt.version=1.10.7
|
||||
50
src/main/scala/summer/phrasebook/FallbackChain.scala
Normal file
50
src/main/scala/summer/phrasebook/FallbackChain.scala
Normal 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()
|
||||
57
src/main/scala/summer/phrasebook/HoconLoader.scala
Normal file
57
src/main/scala/summer/phrasebook/HoconLoader.scala
Normal 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()
|
||||
41
src/main/scala/summer/phrasebook/Interpolator.scala
Normal file
41
src/main/scala/summer/phrasebook/Interpolator.scala
Normal 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)
|
||||
52
src/main/scala/summer/phrasebook/KeyParser.scala
Normal file
52
src/main/scala/summer/phrasebook/KeyParser.scala
Normal 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))
|
||||
35
src/main/scala/summer/phrasebook/LocaleTag.scala
Normal file
35
src/main/scala/summer/phrasebook/LocaleTag.scala
Normal 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
|
||||
201
src/main/scala/summer/phrasebook/PhrasebookTranslator.scala
Normal file
201
src/main/scala/summer/phrasebook/PhrasebookTranslator.scala
Normal 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)
|
||||
182
src/main/scala/summer/phrasebook/PluralRules.scala
Normal file
182
src/main/scala/summer/phrasebook/PluralRules.scala
Normal 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
|
||||
15
src/main/scala/summer/phrasebook/TranslationKey.scala
Normal file
15
src/main/scala/summer/phrasebook/TranslationKey.scala
Normal 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],
|
||||
)
|
||||
66
src/main/scala/summer/phrasebook/Translator.scala
Normal file
66
src/main/scala/summer/phrasebook/Translator.scala
Normal 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
|
||||
3
src/test/resources/lang/ar/lang.conf
Normal file
3
src/test/resources/lang/ar/lang.conf
Normal 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 مقالة"
|
||||
13
src/test/resources/lang/de/lang.conf
Normal file
13
src/test/resources/lang/de/lang.conf
Normal 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!"
|
||||
23
src/test/resources/lang/en/lang.conf
Normal file
23
src/test/resources/lang/en/lang.conf
Normal 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!"
|
||||
4
src/test/resources/lang/en/validation.conf
Normal file
4
src/test/resources/lang/en/validation.conf
Normal 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."
|
||||
18
src/test/resources/lang/pl/lang.conf
Normal file
18
src/test/resources/lang/pl/lang.conf
Normal 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!"
|
||||
4
src/test/resources/overrides/en/lang.conf
Normal file
4
src/test/resources/overrides/en/lang.conf
Normal file
@@ -0,0 +1,4 @@
|
||||
# App-level override for the test plugin
|
||||
labels {
|
||||
pluginName = "My Custom Blog"
|
||||
}
|
||||
64
src/test/scala/summer/phrasebook/FallbackChainSpec.scala
Normal file
64
src/test/scala/summer/phrasebook/FallbackChainSpec.scala
Normal 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"))
|
||||
}
|
||||
49
src/test/scala/summer/phrasebook/HoconLoaderSpec.scala
Normal file
49
src/test/scala/summer/phrasebook/HoconLoaderSpec.scala
Normal 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."))
|
||||
}
|
||||
63
src/test/scala/summer/phrasebook/InterpolatorSpec.scala
Normal file
63
src/test/scala/summer/phrasebook/InterpolatorSpec.scala
Normal 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 !")
|
||||
}
|
||||
67
src/test/scala/summer/phrasebook/KeyParserSpec.scala
Normal file
67
src/test/scala/summer/phrasebook/KeyParserSpec.scala
Normal 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")
|
||||
}
|
||||
34
src/test/scala/summer/phrasebook/LocaleTagSpec.scala
Normal file
34
src/test/scala/summer/phrasebook/LocaleTagSpec.scala
Normal 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")
|
||||
}
|
||||
296
src/test/scala/summer/phrasebook/PhrasebookTranslatorSpec.scala
Normal file
296
src/test/scala/summer/phrasebook/PhrasebookTranslatorSpec.scala
Normal 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",
|
||||
)
|
||||
}
|
||||
213
src/test/scala/summer/phrasebook/PluralRulesSpec.scala
Normal file
213
src/test/scala/summer/phrasebook/PluralRulesSpec.scala
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user