Implement Phase 1 core translation engine (95 tests passing)

Custom i18n module with HOCON translation files, CLDR-aware pluralization
for 40+ locales, namespace::group.item key resolution, :name/:Name/:NAME
parameter interpolation, and locale fallback chains. No effect system deps.
This commit is contained in:
Jakub Zych
2026-02-22 23:30:16 +01:00
parent e4eb74bf06
commit 8684d2fb7b
26 changed files with 1844 additions and 2 deletions

View File

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