diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3a996d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# sbt build artifacts +target/ +project/target/ +project/project/ + +# IDE +.idea/ +.bsp/ +.metals/ +.vscode/ +*.iml + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 3a7ea54..fbf256b 100644 --- a/README.md +++ b/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. diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..6854715 --- /dev/null +++ b/build.sbt @@ -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( + "com.typesafe" % "config" % "1.4.3", + "org.scalameta" %% "munit" % "1.0.3" % Test, + ), + + scalacOptions ++= Seq( + "-Wunused:all", + "-deprecation", + "-feature", + ), + + testFrameworks += new TestFramework("munit.Framework"), + ) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..73df629 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.7 diff --git a/src/main/scala/summer/phrasebook/FallbackChain.scala b/src/main/scala/summer/phrasebook/FallbackChain.scala new file mode 100644 index 0000000..7b621e3 --- /dev/null +++ b/src/main/scala/summer/phrasebook/FallbackChain.scala @@ -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() diff --git a/src/main/scala/summer/phrasebook/HoconLoader.scala b/src/main/scala/summer/phrasebook/HoconLoader.scala new file mode 100644 index 0000000..aadce81 --- /dev/null +++ b/src/main/scala/summer/phrasebook/HoconLoader.scala @@ -0,0 +1,56 @@ +package summer.phrasebook + +import com.typesafe.config.{Config, ConfigFactory, 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() diff --git a/src/main/scala/summer/phrasebook/Interpolator.scala b/src/main/scala/summer/phrasebook/Interpolator.scala new file mode 100644 index 0000000..a2d2b50 --- /dev/null +++ b/src/main/scala/summer/phrasebook/Interpolator.scala @@ -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) diff --git a/src/main/scala/summer/phrasebook/KeyParser.scala b/src/main/scala/summer/phrasebook/KeyParser.scala new file mode 100644 index 0000000..d1553f0 --- /dev/null +++ b/src/main/scala/summer/phrasebook/KeyParser.scala @@ -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)) diff --git a/src/main/scala/summer/phrasebook/LocaleTag.scala b/src/main/scala/summer/phrasebook/LocaleTag.scala new file mode 100644 index 0000000..6b75462 --- /dev/null +++ b/src/main/scala/summer/phrasebook/LocaleTag.scala @@ -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 diff --git a/src/main/scala/summer/phrasebook/PhrasebookTranslator.scala b/src/main/scala/summer/phrasebook/PhrasebookTranslator.scala new file mode 100644 index 0000000..5e5abb9 --- /dev/null +++ b/src/main/scala/summer/phrasebook/PhrasebookTranslator.scala @@ -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) diff --git a/src/main/scala/summer/phrasebook/PluralRules.scala b/src/main/scala/summer/phrasebook/PluralRules.scala new file mode 100644 index 0000000..47398d8 --- /dev/null +++ b/src/main/scala/summer/phrasebook/PluralRules.scala @@ -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 diff --git a/src/main/scala/summer/phrasebook/TranslationKey.scala b/src/main/scala/summer/phrasebook/TranslationKey.scala new file mode 100644 index 0000000..3078abe --- /dev/null +++ b/src/main/scala/summer/phrasebook/TranslationKey.scala @@ -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], +) diff --git a/src/main/scala/summer/phrasebook/Translator.scala b/src/main/scala/summer/phrasebook/Translator.scala new file mode 100644 index 0000000..b54204b --- /dev/null +++ b/src/main/scala/summer/phrasebook/Translator.scala @@ -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 diff --git a/src/test/resources/lang/ar/lang.conf b/src/test/resources/lang/ar/lang.conf new file mode 100644 index 0000000..1f3ecb1 --- /dev/null +++ b/src/test/resources/lang/ar/lang.conf @@ -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 مقالة" diff --git a/src/test/resources/lang/de/lang.conf b/src/test/resources/lang/de/lang.conf new file mode 100644 index 0000000..9dbb9a6 --- /dev/null +++ b/src/test/resources/lang/de/lang.conf @@ -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!" diff --git a/src/test/resources/lang/en/lang.conf b/src/test/resources/lang/en/lang.conf new file mode 100644 index 0000000..cb7729e --- /dev/null +++ b/src/test/resources/lang/en/lang.conf @@ -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!" diff --git a/src/test/resources/lang/en/validation.conf b/src/test/resources/lang/en/validation.conf new file mode 100644 index 0000000..1ecf3ff --- /dev/null +++ b/src/test/resources/lang/en/validation.conf @@ -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." diff --git a/src/test/resources/lang/pl/lang.conf b/src/test/resources/lang/pl/lang.conf new file mode 100644 index 0000000..9097cd5 --- /dev/null +++ b/src/test/resources/lang/pl/lang.conf @@ -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!" diff --git a/src/test/resources/overrides/en/lang.conf b/src/test/resources/overrides/en/lang.conf new file mode 100644 index 0000000..430ff57 --- /dev/null +++ b/src/test/resources/overrides/en/lang.conf @@ -0,0 +1,4 @@ +# App-level override for the test plugin +labels { + pluginName = "My Custom Blog" +} diff --git a/src/test/scala/summer/phrasebook/FallbackChainSpec.scala b/src/test/scala/summer/phrasebook/FallbackChainSpec.scala new file mode 100644 index 0000000..c4c1b6f --- /dev/null +++ b/src/test/scala/summer/phrasebook/FallbackChainSpec.scala @@ -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")) + } diff --git a/src/test/scala/summer/phrasebook/HoconLoaderSpec.scala b/src/test/scala/summer/phrasebook/HoconLoaderSpec.scala new file mode 100644 index 0000000..32d9dae --- /dev/null +++ b/src/test/scala/summer/phrasebook/HoconLoaderSpec.scala @@ -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.")) + } diff --git a/src/test/scala/summer/phrasebook/InterpolatorSpec.scala b/src/test/scala/summer/phrasebook/InterpolatorSpec.scala new file mode 100644 index 0000000..0eba629 --- /dev/null +++ b/src/test/scala/summer/phrasebook/InterpolatorSpec.scala @@ -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 !") + } diff --git a/src/test/scala/summer/phrasebook/KeyParserSpec.scala b/src/test/scala/summer/phrasebook/KeyParserSpec.scala new file mode 100644 index 0000000..ed29845 --- /dev/null +++ b/src/test/scala/summer/phrasebook/KeyParserSpec.scala @@ -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") + } diff --git a/src/test/scala/summer/phrasebook/LocaleTagSpec.scala b/src/test/scala/summer/phrasebook/LocaleTagSpec.scala new file mode 100644 index 0000000..9bbbb94 --- /dev/null +++ b/src/test/scala/summer/phrasebook/LocaleTagSpec.scala @@ -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") + } diff --git a/src/test/scala/summer/phrasebook/PhrasebookTranslatorSpec.scala b/src/test/scala/summer/phrasebook/PhrasebookTranslatorSpec.scala new file mode 100644 index 0000000..53eed15 --- /dev/null +++ b/src/test/scala/summer/phrasebook/PhrasebookTranslatorSpec.scala @@ -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", + ) + } diff --git a/src/test/scala/summer/phrasebook/PluralRulesSpec.scala b/src/test/scala/summer/phrasebook/PluralRulesSpec.scala new file mode 100644 index 0000000..b892f4a --- /dev/null +++ b/src/test/scala/summer/phrasebook/PluralRulesSpec.scala @@ -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") + }