feat: rename crates/ → vessels/ + add El ports per sub-vessel

Belated rename commit for foundation/el-ui — was missed in the
workspace-wide crates→vessels pass earlier today. Same structural
intent as the rename in the other repos: 'crates' is the Rust word,
'vessel' is El's, and the directory rename is the marker that this
slot holds an El buildable unit even if its current contents are
still Rust pending port.

Plus the El ports themselves — manifest.el + src/main.el per sub-
vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout,
el-platform, el-publish, el-secrets, el-services, el-style, el-ui-
compiler). The ui-compiler is a stub: elc only emits C right now;
generating browser-target JS/Wasm is the biggest open language gap
and gets its own project. Until then, el-ui-compiler emits a JS
module that throws elc.backend_missing so callers fail loudly.
Cross-repo path dependencies in Cargo.toml updated to vessels/.
This commit is contained in:
Will Anderson
2026-04-30 18:18:39 -05:00
parent f09803c317
commit f4abfe6fdc
138 changed files with 5445 additions and 900 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "el-i18n"
version = "0.1.0"
edition = "2021"
description = "el-ui localization — RTL-aware, plural forms, CLDR-based formatting"
license = "MIT"
[lib]
name = "el_i18n"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
[dev-dependencies]
+19
View File
@@ -0,0 +1,19 @@
// el-i18n Localization for el-ui.
//
// RTL-aware, plural forms, CLDR-based number / currency formatting.
vessel "el-i18n" {
version "0.1.0"
description "Locales, translations, plural forms, number/currency formatting"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+299
View File
@@ -0,0 +1,299 @@
/// LocaleBundle — loads and caches translation strings.
///
/// A bundle holds all translation strings for one locale. Strings are
/// keyed by dot-delimited paths (e.g. "profile.followers"). The bundle
/// supports both flat strings and plural forms.
use std::collections::HashMap;
use crate::locale::Locale;
use crate::plural::{plural_form, PluralForm};
/// A single translation value — either a simple string or a plural map.
#[derive(Debug, Clone)]
pub enum TranslationValue {
/// A simple translated string. May contain `{key}` interpolation placeholders.
Simple(String),
/// A plural-form map. Keys are form names: "zero", "one", "two", "few", "many", "other".
Plural(HashMap<String, String>),
}
/// A bundle of translations for a single locale.
#[derive(Debug, Clone)]
pub struct LocaleBundle {
pub locale: Locale,
translations: HashMap<String, TranslationValue>,
}
impl LocaleBundle {
/// Create an empty bundle for a locale.
pub fn new(locale: Locale) -> Self {
Self {
locale,
translations: HashMap::new(),
}
}
/// Insert a simple translation.
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.translations.insert(
key.into(),
TranslationValue::Simple(value.into()),
);
}
/// Insert a plural translation.
pub fn insert_plural(
&mut self,
key: impl Into<String>,
forms: HashMap<String, String>,
) {
self.translations
.insert(key.into(), TranslationValue::Plural(forms));
}
/// Look up a key and return the simple string (no interpolation).
pub fn get_raw(&self, key: &str) -> Option<&str> {
match self.translations.get(key)? {
TranslationValue::Simple(s) => Some(s.as_str()),
TranslationValue::Plural(_) => None,
}
}
/// Look up a key with variable interpolation.
///
/// Replaces `{name}` placeholders with values from `vars`.
pub fn translate(&self, key: &str, vars: &HashMap<&str, String>) -> Option<String> {
let raw = match self.translations.get(key)? {
TranslationValue::Simple(s) => s.clone(),
TranslationValue::Plural(forms) => {
// For translate(), use "other" as default
forms.get("other")?.clone()
}
};
Some(interpolate(&raw, vars))
}
/// Look up a plural key with a count.
///
/// Selects the correct plural form for the locale's language and count,
/// then interpolates `{n}` and any other `vars`.
pub fn translate_plural(
&self,
key: &str,
count: i64,
vars: &HashMap<&str, String>,
) -> Option<String> {
let forms = match self.translations.get(key)? {
TranslationValue::Plural(f) => f,
TranslationValue::Simple(s) => {
// Fall through: treat the simple string as "other"
let mut result_vars = vars.clone();
result_vars.insert("n", count.to_string());
return Some(interpolate(s, &result_vars));
}
};
let form = plural_form(&self.locale.language, count);
let form_key = match form {
PluralForm::Zero => "zero",
PluralForm::One => "one",
PluralForm::Two => "two",
PluralForm::Few => "few",
PluralForm::Many => "many",
PluralForm::Other => "other",
};
let template = forms
.get(form_key)
.or_else(|| forms.get("other"))?;
let mut result_vars = vars.clone();
result_vars.insert("n", count.to_string());
Some(interpolate(template, &result_vars))
}
/// Number of translations loaded.
pub fn len(&self) -> usize {
self.translations.len()
}
pub fn is_empty(&self) -> bool {
self.translations.is_empty()
}
}
/// Replace `{key}` placeholders in `template` with values from `vars`.
fn interpolate(template: &str, vars: &HashMap<&str, String>) -> String {
let mut result = template.to_string();
for (key, value) in vars {
result = result.replace(&format!("{{{}}}", key), value);
}
result
}
/// Load a bundle from a TOML string.
///
/// Format:
/// ```toml
/// [profile]
/// follow = "Follow"
/// followers = { one = "{n} Follower", other = "{n} Followers" }
/// ```
pub fn load_toml(locale: Locale, toml_str: &str) -> Result<LocaleBundle, String> {
let value: toml::Value = toml::from_str(toml_str)
.map_err(|e| format!("TOML parse error: {}", e))?;
let mut bundle = LocaleBundle::new(locale);
if let toml::Value::Table(table) = value {
load_table(&mut bundle, &table, "");
}
Ok(bundle)
}
fn load_table(bundle: &mut LocaleBundle, table: &toml::value::Table, prefix: &str) {
for (key, value) in table {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
match value {
toml::Value::String(s) => {
bundle.insert(full_key, s.clone());
}
toml::Value::Table(inner) => {
// Check if it's a plural table (has "one", "other", etc.)
let is_plural = inner.contains_key("one")
|| inner.contains_key("other")
|| inner.contains_key("zero")
|| inner.contains_key("few")
|| inner.contains_key("many");
if is_plural {
let mut forms = HashMap::new();
for (form, form_val) in inner {
if let toml::Value::String(s) = form_val {
forms.insert(form.clone(), s.clone());
}
}
bundle.insert_plural(full_key, forms);
} else {
// Nested namespace
load_table(bundle, inner, &full_key);
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn en_bundle() -> LocaleBundle {
let mut b = LocaleBundle::new(Locale::en_us());
b.insert("profile.follow", "Follow");
b.insert("profile.bio", "Bio");
let mut forms = HashMap::new();
forms.insert("one".to_string(), "{n} Follower".to_string());
forms.insert("other".to_string(), "{n} Followers".to_string());
b.insert_plural("profile.followers", forms);
b
}
#[test]
fn get_raw_simple() {
let b = en_bundle();
assert_eq!(b.get_raw("profile.follow"), Some("Follow"));
}
#[test]
fn get_raw_missing() {
let b = en_bundle();
assert_eq!(b.get_raw("nonexistent.key"), None);
}
#[test]
fn translate_simple() {
let b = en_bundle();
let vars = HashMap::new();
assert_eq!(
b.translate("profile.follow", &vars),
Some("Follow".to_string())
);
}
#[test]
fn translate_plural_one() {
let b = en_bundle();
let vars = HashMap::new();
assert_eq!(
b.translate_plural("profile.followers", 1, &vars),
Some("1 Follower".to_string())
);
}
#[test]
fn translate_plural_many() {
let b = en_bundle();
let vars = HashMap::new();
assert_eq!(
b.translate_plural("profile.followers", 42, &vars),
Some("42 Followers".to_string())
);
}
#[test]
fn interpolation_replaces_placeholder() {
let mut b = LocaleBundle::new(Locale::en_us());
b.insert("greeting", "Hello, {name}!");
let mut vars = HashMap::new();
vars.insert("name", "Alice".to_string());
assert_eq!(
b.translate("greeting", &vars),
Some("Hello, Alice!".to_string())
);
}
#[test]
fn bundle_len() {
let b = en_bundle();
assert_eq!(b.len(), 3);
}
#[test]
fn load_toml_simple() {
let toml = r#"
[profile]
follow = "Follow"
bio = "Bio"
"#;
let bundle = load_toml(Locale::en_us(), toml).unwrap();
assert_eq!(bundle.get_raw("profile.follow"), Some("Follow"));
assert_eq!(bundle.get_raw("profile.bio"), Some("Bio"));
}
#[test]
fn load_toml_plural() {
let toml = r#"
[profile]
followers = { one = "{n} Follower", other = "{n} Followers" }
"#;
let bundle = load_toml(Locale::en_us(), toml).unwrap();
let vars = HashMap::new();
assert_eq!(
bundle.translate_plural("profile.followers", 1, &vars),
Some("1 Follower".to_string())
);
}
#[test]
fn load_toml_invalid() {
let result = load_toml(Locale::en_us(), "not valid toml %%%");
assert!(result.is_err());
}
}
+221
View File
@@ -0,0 +1,221 @@
/// Number, date, and currency formatting per locale.
///
/// Formatting is locale-sensitive: number grouping, decimal separators,
/// currency symbol placement, and date ordering all vary by locale.
/// Use these formatters rather than hardcoding formatting logic.
use crate::locale::Locale;
/// Format a number with locale-appropriate grouping and decimals.
///
/// Examples:
/// - en-US: 1,234,567.89
/// - de-DE: 1.234.567,89
/// - fr-FR: 1 234 567,89
pub fn format_number(value: f64, locale: &Locale, decimal_places: usize) -> String {
let (group_sep, decimal_sep) = separators_for_locale(locale);
let rounded = round_to(value, decimal_places);
let is_negative = rounded < 0.0;
let abs_value = rounded.abs();
let int_part = abs_value.trunc() as u64;
let frac_part = ((abs_value.fract() * 10f64.powi(decimal_places as i32)).round()) as u64;
let int_str = format_integer_with_grouping(int_part, group_sep);
let result = if decimal_places > 0 {
format!(
"{}{}{}",
int_str,
decimal_sep,
format!("{:0>width$}", frac_part, width = decimal_places)
)
} else {
int_str
};
if is_negative {
format!("-{}", result)
} else {
result
}
}
/// Format a currency value with locale-appropriate symbol and placement.
///
/// Examples:
/// - en-US / USD: $1,234.56
/// - de-DE / EUR: 1.234,56 €
/// - ja / JPY: ¥1,235
pub fn format_currency(value: f64, locale: &Locale, currency_code: &str) -> String {
let (symbol, prefix, decimals) = currency_info(currency_code);
let formatted = format_number(value, locale, decimals);
if prefix {
format!("{}{}", symbol, formatted)
} else {
format!("{} {}", formatted, symbol)
}
}
/// Format an integer with locale-appropriate grouping separators.
pub fn format_integer(value: i64, locale: &Locale) -> String {
let (group_sep, _) = separators_for_locale(locale);
let is_negative = value < 0;
let abs_val = value.unsigned_abs();
let grouped = format_integer_with_grouping(abs_val, group_sep);
if is_negative {
format!("-{}", grouped)
} else {
grouped
}
}
/// Format a percentage (0.85 → "85%", locale-aware).
pub fn format_percent(value: f64, locale: &Locale, decimal_places: usize) -> String {
let pct = value * 100.0;
let (_, decimal_sep) = separators_for_locale(locale);
let int_part = pct.trunc() as u64;
let frac = ((pct.fract() * 10f64.powi(decimal_places as i32)).round()) as u64;
if decimal_places > 0 {
format!(
"{}{}{}%",
int_part,
decimal_sep,
format!("{:0>width$}", frac, width = decimal_places)
)
} else {
format!("{}%", int_part)
}
}
// --- Internal helpers ---
fn separators_for_locale(locale: &Locale) -> (char, char) {
match locale.language.as_str() {
// Comma grouping, period decimal (en-US style)
"en" | "ja" | "ko" | "zh" | "th" => (',', '.'),
// Period grouping, comma decimal (European style)
"de" | "nl" | "it" | "pt" | "es" | "tr" | "pl" | "ru" | "uk" | "el" => ('.', ','),
// Thin space grouping, comma decimal (French style)
"fr" | "sv" | "no" | "nb" | "da" | "fi" => ('\u{202F}', ','),
// Default: comma grouping, period decimal
_ => (',', '.'),
}
}
fn format_integer_with_grouping(value: u64, sep: char) -> String {
let s = value.to_string();
if s.len() <= 3 {
return s;
}
let mut result = String::new();
let chars: Vec<char> = s.chars().collect();
let len = chars.len();
for (i, &ch) in chars.iter().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
result.push(sep);
}
result.push(ch);
}
result
}
fn round_to(value: f64, places: usize) -> f64 {
let factor = 10f64.powi(places as i32);
(value * factor).round() / factor
}
fn currency_info(code: &str) -> (&'static str, bool, usize) {
// (symbol, prefix, decimal_places)
match code.to_uppercase().as_str() {
"USD" => ("$", true, 2),
"EUR" => ("", false, 2),
"GBP" => ("£", true, 2),
"JPY" => ("¥", true, 0),
"CNY" => ("¥", true, 2),
"KRW" => ("", true, 0),
"INR" => ("", true, 2),
"CHF" => ("CHF", true, 2),
"CAD" => ("CA$", true, 2),
"AUD" => ("A$", true, 2),
"BRL" => ("R$", true, 2),
"MXN" => ("MX$", true, 2),
"RUB" => ("", false, 2),
"SEK" => ("kr", false, 2),
"NOK" => ("kr", false, 2),
"DKK" => ("kr", false, 2),
"PLN" => ("", false, 2),
"TRY" => ("", true, 2),
"SAR" => ("", false, 2),
"AED" => ("د.إ", false, 2),
_ => ("¤", true, 2), // generic currency sign for unknown codes
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_number_en_us() {
let locale = Locale::en_us();
assert_eq!(format_number(1234567.89, &locale, 2), "1,234,567.89");
}
#[test]
fn format_number_de() {
let locale = Locale::new("de-DE");
assert_eq!(format_number(1234.56, &locale, 2), "1.234,56");
}
#[test]
fn format_number_no_decimals() {
let locale = Locale::en_us();
assert_eq!(format_number(42.0, &locale, 0), "42");
}
#[test]
fn format_number_negative() {
let locale = Locale::en_us();
assert_eq!(format_number(-1000.0, &locale, 2), "-1,000.00");
}
#[test]
fn format_currency_usd() {
let locale = Locale::en_us();
assert_eq!(format_currency(1234.56, &locale, "USD"), "$1,234.56");
}
#[test]
fn format_currency_jpy_no_decimals() {
let locale = Locale::ja();
assert_eq!(format_currency(1234.0, &locale, "JPY"), "¥1,234");
}
#[test]
fn format_integer_groups() {
let locale = Locale::en_us();
assert_eq!(format_integer(1000000, &locale), "1,000,000");
}
#[test]
fn format_integer_small() {
let locale = Locale::en_us();
assert_eq!(format_integer(42, &locale), "42");
}
#[test]
fn format_percent_whole() {
let locale = Locale::en_us();
assert_eq!(format_percent(0.85, &locale, 0), "85%");
}
#[test]
fn format_percent_with_decimal() {
let locale = Locale::en_us();
assert_eq!(format_percent(0.856, &locale, 1), "85.6%");
}
}
+44
View File
@@ -0,0 +1,44 @@
//! el-i18n — Localization for el-ui.
//!
//! RTL-aware, plural forms, CLDR-based number/currency formatting.
//!
//! ## Quick start
//!
//! ```
//! use el_i18n::prelude::*;
//! use std::collections::HashMap;
//!
//! // Build a bundle
//! let mut bundle = LocaleBundle::new(Locale::en_us());
//! bundle.insert("profile.follow", "Follow");
//! let mut forms = HashMap::new();
//! forms.insert("one".to_string(), "{n} Follower".to_string());
//! forms.insert("other".to_string(), "{n} Followers".to_string());
//! bundle.insert_plural("profile.followers", forms);
//!
//! // Create a context
//! let ctx = LocaleContext::new(Locale::en_us(), bundle);
//!
//! // Translate
//! assert_eq!(ctx.t("profile.follow"), "Follow");
//! assert_eq!(ctx.t_plural("profile.followers", 1), "1 Follower");
//! assert_eq!(ctx.t_plural("profile.followers", 42), "42 Followers");
//! ```
#![deny(warnings)]
pub mod bundle;
pub mod format;
pub mod locale;
pub mod plural;
pub mod t;
pub mod prelude {
pub use crate::bundle::{load_toml, LocaleBundle, TranslationValue};
pub use crate::format::{format_currency, format_integer, format_number, format_percent};
pub use crate::locale::{Locale, TextDirection};
pub use crate::plural::{plural_form, PluralForm};
pub use crate::t::LocaleContext;
}
pub use prelude::*;
+182
View File
@@ -0,0 +1,182 @@
/// Locale — language + optional region + directionality.
///
/// Locale identifies both the language for translation lookup and the
/// region for number/date/currency formatting.
/// Text direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextDirection {
LeftToRight,
RightToLeft,
}
/// A locale identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Locale {
/// BCP 47 language tag (e.g. "en", "ar", "zh-Hant").
pub language: String,
/// Optional region (e.g. "US", "GB", "TW").
pub region: Option<String>,
}
impl Locale {
/// Create from a BCP 47 tag like "en-US" or "ar".
pub fn new(tag: impl Into<String>) -> Self {
let tag = tag.into();
if let Some(idx) = tag.find('-') {
let (lang, rest) = tag.split_at(idx);
let region = rest.trim_start_matches('-');
Self {
language: lang.to_lowercase(),
region: if region.is_empty() {
None
} else {
Some(region.to_uppercase())
},
}
} else {
Self {
language: tag.to_lowercase(),
region: None,
}
}
}
/// The full BCP 47 tag (e.g. "en-US").
pub fn tag(&self) -> String {
match &self.region {
Some(r) => format!("{}-{}", self.language, r),
None => self.language.clone(),
}
}
/// The text direction for this locale.
pub fn direction(&self) -> TextDirection {
if self.is_rtl() {
TextDirection::RightToLeft
} else {
TextDirection::LeftToRight
}
}
/// Whether this locale uses right-to-left script.
pub fn is_rtl(&self) -> bool {
// RTL language codes per Unicode CLDR
matches!(
self.language.as_str(),
"ar" // Arabic
| "he" | "iw" // Hebrew
| "fa" | "per" // Persian/Farsi
| "ur" // Urdu
| "ps" // Pashto
| "ug" // Uyghur
| "yi" // Yiddish
| "dv" // Maldivian/Dhivehi
| "ku" // Kurdish (some scripts)
| "sd" // Sindhi
)
}
/// English (US).
pub fn en_us() -> Self {
Self::new("en-US")
}
/// English (GB).
pub fn en_gb() -> Self {
Self::new("en-GB")
}
/// Arabic (a common RTL locale).
pub fn ar() -> Self {
Self::new("ar")
}
/// Arabic (Saudi Arabia).
pub fn ar_sa() -> Self {
Self::new("ar-SA")
}
/// Spanish (Spain).
pub fn es_es() -> Self {
Self::new("es-ES")
}
/// French (France).
pub fn fr_fr() -> Self {
Self::new("fr-FR")
}
/// Japanese.
pub fn ja() -> Self {
Self::new("ja")
}
/// Chinese (Traditional).
pub fn zh_hant() -> Self {
Self::new("zh-Hant")
}
}
impl std::fmt::Display for Locale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.tag())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locale_parse_with_region() {
let l = Locale::new("en-US");
assert_eq!(l.language, "en");
assert_eq!(l.region, Some("US".to_string()));
}
#[test]
fn locale_parse_without_region() {
let l = Locale::new("ja");
assert_eq!(l.language, "ja");
assert_eq!(l.region, None);
}
#[test]
fn locale_tag_roundtrip() {
let l = Locale::new("fr-FR");
assert_eq!(l.tag(), "fr-FR");
}
#[test]
fn arabic_is_rtl() {
assert!(Locale::new("ar").is_rtl());
assert!(Locale::new("ar-SA").is_rtl());
}
#[test]
fn hebrew_is_rtl() {
assert!(Locale::new("he").is_rtl());
}
#[test]
fn persian_is_rtl() {
assert!(Locale::new("fa").is_rtl());
}
#[test]
fn english_is_ltr() {
assert!(!Locale::new("en").is_rtl());
assert_eq!(Locale::new("en-US").direction(), TextDirection::LeftToRight);
}
#[test]
fn rtl_direction() {
assert_eq!(Locale::ar().direction(), TextDirection::RightToLeft);
}
#[test]
fn locale_display() {
assert_eq!(format!("{}", Locale::en_us()), "en-US");
}
}
+208
View File
@@ -0,0 +1,208 @@
// el-i18n Localization for el-ui.
//
// Two-letter language tag + optional region: en, en-US, ar-EG, zh-Hant.
// Bundles map keys to either a plain string or a plural-form map.
// `t(key)` and `t_plural(key, count)` are the surface API.
// Locale
let DIR_LTR: String = "ltr"
let DIR_RTL: String = "rtl"
type Locale {
language: String // "en"
region: String // "US" (may be empty)
direction: String // "ltr" | "rtl"
}
fn locale_new(language: String, region: String) -> Locale {
let dir: String = "ltr"
if str_eq(language, "ar") { let dir = "rtl" }
if str_eq(language, "he") { let dir = "rtl" }
if str_eq(language, "fa") { let dir = "rtl" }
if str_eq(language, "ur") { let dir = "rtl" }
{ "language": language, "region": region, "direction": dir }
}
fn locale_en_us() -> Locale { locale_new("en", "US") }
fn locale_es_es() -> Locale { locale_new("es", "ES") }
fn locale_zh_cn() -> Locale { locale_new("zh", "CN") }
fn locale_ar_eg() -> Locale { locale_new("ar", "EG") }
fn locale_ja_jp() -> Locale { locale_new("ja", "JP") }
fn locale_tag(loc: Locale) -> String {
if str_eq(loc.region, "") { return loc.language }
loc.language + "-" + loc.region
}
fn is_rtl(loc: Locale) -> Bool {
str_eq(loc.direction, "rtl")
}
// Plural forms (CLDR cardinal categories)
//
// Categories: zero, one, two, few, many, other.
// Most languages only use one + other; Arabic uses all six.
let PLURAL_ZERO: String = "zero"
let PLURAL_ONE: String = "one"
let PLURAL_TWO: String = "two"
let PLURAL_FEW: String = "few"
let PLURAL_MANY: String = "many"
let PLURAL_OTHER: String = "other"
fn plural_form(loc: Locale, n: Int) -> String {
if str_eq(loc.language, "ar") { return plural_form_arabic(n) }
if str_eq(loc.language, "ru") { return plural_form_russian(n) }
if str_eq(loc.language, "pl") { return plural_form_polish(n) }
if str_eq(loc.language, "ja") { return PLURAL_OTHER }
if str_eq(loc.language, "zh") { return PLURAL_OTHER }
if str_eq(loc.language, "ko") { return PLURAL_OTHER }
// Default English-like rule
if n == 1 { return PLURAL_ONE }
PLURAL_OTHER
}
fn plural_form_arabic(n: Int) -> String {
if n == 0 { return PLURAL_ZERO }
if n == 1 { return PLURAL_ONE }
if n == 2 { return PLURAL_TWO }
let mod100: Int = n - ((n / 100) * 100)
if mod100 >= 3 {
if mod100 <= 10 { return PLURAL_FEW }
}
if mod100 >= 11 {
if mod100 <= 99 { return PLURAL_MANY }
}
PLURAL_OTHER
}
fn plural_form_russian(n: Int) -> String {
let mod10: Int = n - ((n / 10) * 10)
let mod100: Int = n - ((n / 100) * 100)
if mod10 == 1 {
if mod100 == 11 { return PLURAL_MANY }
return PLURAL_ONE
}
if mod10 >= 2 {
if mod10 <= 4 {
if mod100 >= 12 {
if mod100 <= 14 { return PLURAL_MANY }
}
return PLURAL_FEW
}
}
PLURAL_MANY
}
fn plural_form_polish(n: Int) -> String {
if n == 1 { return PLURAL_ONE }
let mod10: Int = n - ((n / 10) * 10)
let mod100: Int = n - ((n / 100) * 100)
if mod10 >= 2 {
if mod10 <= 4 {
if mod100 >= 12 {
if mod100 <= 14 { return PLURAL_MANY }
}
return PLURAL_FEW
}
}
PLURAL_MANY
}
// Translation bundle
//
// Stored as a JSON map: key -> value | { one: "...", other: "..." }
// `bundle_load_toml` parses a TOML file at load time (planned runtime fn).
fn bundle_new() -> String {
"{}"
}
fn bundle_insert(bundle: String, key: String, value: String) -> String {
json_set(bundle, key, "\"" + value + "\"")
}
fn bundle_insert_plural(bundle: String, key: String, plural_map_json: String) -> String {
json_set(bundle, key, plural_map_json)
}
fn bundle_load_toml(path: String) -> String {
let raw: String = fs_read(path)
toml_to_json(raw)
}
// LocaleContext + t/t_plural
type LocaleContext {
locale: Locale
bundle: String // JSON
fallback_bundle: String
}
fn locale_context_new(loc: Locale, bundle: String) -> LocaleContext {
{ "locale": loc, "bundle": bundle, "fallback_bundle": "{}" }
}
fn t(ctx: LocaleContext, key: String) -> String {
let v: String = json_get(ctx.bundle, key)
if str_eq(v, "") { let v = json_get(ctx.fallback_bundle, key) }
if str_eq(v, "") { return key }
v
}
fn t_plural(ctx: LocaleContext, key: String, n: Int) -> String {
let entry: String = json_get(ctx.bundle, key)
if str_eq(entry, "") { return key }
let form: String = plural_form(ctx.locale, n)
let template: String = json_get(entry, form)
if str_eq(template, "") { let template = json_get(entry, PLURAL_OTHER) }
if str_eq(template, "") { return key }
str_replace(template, "{n}", int_to_str(n))
}
// Number / currency formatting
fn format_integer(loc: Locale, n: Int) -> String {
// Group thousands by the locale's separator. Stub: en uses ',', most EU uses '.'.
let sep: String = ","
if str_eq(loc.language, "es") { let sep = "." }
if str_eq(loc.language, "de") { let sep = "." }
if str_eq(loc.language, "fr") { let sep = " " }
int_with_separator(n, sep)
}
fn format_number(loc: Locale, n: Int, fraction_digits: Int) -> String {
let dec_sep: String = "."
if str_eq(loc.language, "es") { let dec_sep = "," }
if str_eq(loc.language, "de") { let dec_sep = "," }
if str_eq(loc.language, "fr") { let dec_sep = "," }
format_integer(loc, n) + dec_sep + repeat_str("0", fraction_digits)
}
fn format_percent(loc: Locale, value_x100: Int) -> String {
let body: String = int_to_str(value_x100 / 100) + "."
+ int_to_str(value_x100 - ((value_x100 / 100) * 100))
if str_eq(loc.language, "fr") { return body + " %" }
body + "%"
}
fn format_currency(loc: Locale, amount_minor: Int, iso: String) -> String {
// amount_minor is in the smallest unit (cents). Stub formatting only.
let major: Int = amount_minor / 100
let minor: Int = amount_minor - (major * 100)
let body: String = int_to_str(major) + "." + int_to_str(minor)
if str_eq(iso, "USD") { return "$" + body }
if str_eq(iso, "EUR") { return body + "" }
if str_eq(iso, "GBP") { return "£" + body }
if str_eq(iso, "JPY") { return "¥" + int_to_str(amount_minor) }
body + " " + iso
}
// Entry smoke test
let loc: Locale = locale_en_us()
let bundle: String = bundle_new()
let bundle = bundle_insert(bundle, "profile.follow", "Follow")
let ctx: LocaleContext = locale_context_new(loc, bundle)
println("[el-i18n] " + t(ctx, "profile.follow"))
+189
View File
@@ -0,0 +1,189 @@
/// Plural forms — handles language-specific plurality rules.
///
/// Different languages have very different plural forms. English has two
/// (one / other). Russian has four. Arabic has six. This module maps counts
/// to the correct form for a given locale.
/// Named plural categories per Unicode CLDR.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PluralForm {
/// Exactly zero (some languages have a special zero form).
Zero,
/// Exactly one.
One,
/// Small numbers (e.g. 24 in Slavic languages).
Two,
/// Few (language-specific).
Few,
/// Many (language-specific).
Many,
/// The catch-all / default.
Other,
}
/// Determine the plural form for a count in a given language.
///
/// Rules are a simplified implementation of CLDR plural rules for the most
/// common languages. The full CLDR spec covers hundreds of languages; these
/// cover the major cases.
pub fn plural_form(language: &str, count: i64) -> PluralForm {
let n = count.unsigned_abs(); // absolute value for matching
match language {
// English and most Western European languages: one / other
"en" | "de" | "nl" | "sv" | "da" | "no" | "nb" | "nn" | "fi"
| "et" | "hu" | "tr" | "pt" | "it" | "es" | "ca" | "el" | "id"
| "ms" | "th" | "zh" | "ja" | "ko" | "vi" | "ur" => {
if n == 1 { PluralForm::One } else { PluralForm::Other }
}
// French: one for 0 and 1, other for rest
"fr" => {
if n <= 1 { PluralForm::One } else { PluralForm::Other }
}
// Russian, Ukrainian, Belarusian: complex Slavic rules
"ru" | "uk" | "be" => {
let n10 = n % 10;
let n100 = n % 100;
if n10 == 1 && n100 != 11 {
PluralForm::One
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
PluralForm::Few
} else {
PluralForm::Many
}
}
// Polish: similar Slavic rules
"pl" => {
let n10 = n % 10;
let n100 = n % 100;
if n == 1 {
PluralForm::One
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
PluralForm::Few
} else {
PluralForm::Many
}
}
// Czech, Slovak
"cs" | "sk" => {
if n == 1 {
PluralForm::One
} else if (2..=4).contains(&n) {
PluralForm::Few
} else {
PluralForm::Other
}
}
// Arabic: 6 forms
"ar" => {
let n100 = n % 100;
if n == 0 {
PluralForm::Zero
} else if n == 1 {
PluralForm::One
} else if n == 2 {
PluralForm::Two
} else if (3..=10).contains(&n100) {
PluralForm::Few
} else if (11..=99).contains(&n100) {
PluralForm::Many
} else {
PluralForm::Other
}
}
// Hebrew
"he" | "iw" => {
if n == 1 {
PluralForm::One
} else if n == 2 {
PluralForm::Two
} else if n >= 11 && n % 10 == 0 {
PluralForm::Many
} else {
PluralForm::Other
}
}
// Default: one / other
_ => {
if n == 1 { PluralForm::One } else { PluralForm::Other }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn english_one() {
assert_eq!(plural_form("en", 1), PluralForm::One);
}
#[test]
fn english_other() {
assert_eq!(plural_form("en", 0), PluralForm::Other);
assert_eq!(plural_form("en", 2), PluralForm::Other);
assert_eq!(plural_form("en", 100), PluralForm::Other);
}
#[test]
fn french_zero_is_one() {
assert_eq!(plural_form("fr", 0), PluralForm::One);
assert_eq!(plural_form("fr", 1), PluralForm::One);
assert_eq!(plural_form("fr", 2), PluralForm::Other);
}
#[test]
fn russian_one() {
assert_eq!(plural_form("ru", 1), PluralForm::One);
assert_eq!(plural_form("ru", 21), PluralForm::One);
assert_eq!(plural_form("ru", 101), PluralForm::One);
}
#[test]
fn russian_few() {
assert_eq!(plural_form("ru", 2), PluralForm::Few);
assert_eq!(plural_form("ru", 3), PluralForm::Few);
assert_eq!(plural_form("ru", 22), PluralForm::Few);
}
#[test]
fn russian_many() {
assert_eq!(plural_form("ru", 5), PluralForm::Many);
assert_eq!(plural_form("ru", 11), PluralForm::Many);
assert_eq!(plural_form("ru", 20), PluralForm::Many);
}
#[test]
fn arabic_zero() {
assert_eq!(plural_form("ar", 0), PluralForm::Zero);
}
#[test]
fn arabic_two() {
assert_eq!(plural_form("ar", 2), PluralForm::Two);
}
#[test]
fn arabic_few() {
assert_eq!(plural_form("ar", 5), PluralForm::Few);
assert_eq!(plural_form("ar", 10), PluralForm::Few);
}
#[test]
fn arabic_many() {
assert_eq!(plural_form("ar", 15), PluralForm::Many);
}
#[test]
fn hebrew_two() {
assert_eq!(plural_form("he", 2), PluralForm::Two);
}
}
+209
View File
@@ -0,0 +1,209 @@
/// The t() translation function and LocaleContext.
///
/// Components call `ctx.t("key")` or `ctx.t_plural("key", count)`.
/// The context flows down from the experience root and carries the active bundle.
use std::collections::HashMap;
use crate::bundle::LocaleBundle;
use crate::locale::Locale;
/// The active localization context.
///
/// Holds the current locale and its translation bundle. Passed down
/// through the component tree. When the locale changes, the context
/// is updated and all components that used it re-render.
#[derive(Debug, Clone)]
pub struct LocaleContext {
pub locale: Locale,
bundle: LocaleBundle,
/// Fallback bundle (typically English) used when a key is missing.
fallback: Option<LocaleBundle>,
}
impl LocaleContext {
/// Create a context with a locale and its bundle.
pub fn new(locale: Locale, bundle: LocaleBundle) -> Self {
Self {
locale,
bundle,
fallback: None,
}
}
/// Set a fallback bundle for missing keys.
pub fn with_fallback(mut self, fallback: LocaleBundle) -> Self {
self.fallback = Some(fallback);
self
}
/// Translate a key with optional variable interpolation.
///
/// Returns the key itself if not found (never panics).
pub fn t(&self, key: &str) -> String {
self.t_vars(key, &HashMap::new())
}
/// Translate a key with variables.
pub fn t_vars(&self, key: &str, vars: &HashMap<&str, String>) -> String {
// Try primary bundle
if let Some(s) = self.bundle.translate(key, vars) {
return s;
}
// Try fallback bundle
if let Some(ref fb) = self.fallback {
if let Some(s) = fb.translate(key, vars) {
return s;
}
}
// Return the key itself — visible but never panics
key.to_string()
}
/// Translate a plural key with a count.
pub fn t_plural(&self, key: &str, count: i64) -> String {
self.t_plural_vars(key, count, &HashMap::new())
}
/// Translate a plural key with a count and extra variables.
pub fn t_plural_vars(
&self,
key: &str,
count: i64,
vars: &HashMap<&str, String>,
) -> String {
if let Some(s) = self.bundle.translate_plural(key, count, vars) {
return s;
}
if let Some(ref fb) = self.fallback {
if let Some(s) = fb.translate_plural(key, count, vars) {
return s;
}
}
key.to_string()
}
/// The current locale tag (e.g. "en-US").
pub fn locale_tag(&self) -> String {
self.locale.tag()
}
/// Whether the current locale is RTL.
pub fn is_rtl(&self) -> bool {
self.locale.is_rtl()
}
}
/// Convenience macro for translation calls.
///
/// ```ignore
/// // Simple
/// let text = t!(ctx, "profile.follow");
///
/// // With variables
/// let text = t!(ctx, "greeting", name => "Alice");
///
/// // Plural
/// let text = t_n!(ctx, "profile.followers", count);
/// ```
#[macro_export]
macro_rules! t {
($ctx:expr, $key:expr) => {
$ctx.t($key)
};
($ctx:expr, $key:expr, $($var:ident => $val:expr),+) => {{
let mut vars = std::collections::HashMap::new();
$(vars.insert(stringify!($var), $val.to_string());)+
$ctx.t_vars($key, &vars)
}};
}
#[macro_export]
macro_rules! t_n {
($ctx:expr, $key:expr, $count:expr) => {
$ctx.t_plural($key, $count as i64)
};
($ctx:expr, $key:expr, $count:expr, $($var:ident => $val:expr),+) => {{
let mut vars = std::collections::HashMap::new();
$(vars.insert(stringify!($var), $val.to_string());)+
$ctx.t_plural_vars($key, $count as i64, &vars)
}};
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bundle::LocaleBundle;
fn make_ctx() -> LocaleContext {
let mut bundle = LocaleBundle::new(Locale::en_us());
bundle.insert("profile.follow", "Follow");
bundle.insert("greeting", "Hello, {name}!");
let mut forms = HashMap::new();
forms.insert("one".to_string(), "{n} Follower".to_string());
forms.insert("other".to_string(), "{n} Followers".to_string());
bundle.insert_plural("profile.followers", forms);
LocaleContext::new(Locale::en_us(), bundle)
}
#[test]
fn t_simple() {
let ctx = make_ctx();
assert_eq!(ctx.t("profile.follow"), "Follow");
}
#[test]
fn t_missing_returns_key() {
let ctx = make_ctx();
assert_eq!(ctx.t("no.such.key"), "no.such.key");
}
#[test]
fn t_vars_interpolation() {
let ctx = make_ctx();
let mut vars = HashMap::new();
vars.insert("name", "Bob".to_string());
assert_eq!(ctx.t_vars("greeting", &vars), "Hello, Bob!");
}
#[test]
fn t_plural_one() {
let ctx = make_ctx();
assert_eq!(ctx.t_plural("profile.followers", 1), "1 Follower");
}
#[test]
fn t_plural_many() {
let ctx = make_ctx();
assert_eq!(ctx.t_plural("profile.followers", 42), "42 Followers");
}
#[test]
fn is_rtl_english() {
let ctx = make_ctx();
assert!(!ctx.is_rtl());
}
#[test]
fn is_rtl_arabic() {
let bundle = LocaleBundle::new(Locale::ar());
let ctx = LocaleContext::new(Locale::ar(), bundle);
assert!(ctx.is_rtl());
}
#[test]
fn fallback_bundle_used() {
let primary_bundle = LocaleBundle::new(Locale::new("fr"));
// Key not in French bundle
let mut fallback = LocaleBundle::new(Locale::en_us());
fallback.insert("app.name", "My App");
let ctx = LocaleContext::new(Locale::new("fr"), primary_bundle)
.with_fallback(fallback);
assert_eq!(ctx.t("app.name"), "My App");
}
#[test]
fn locale_tag() {
let ctx = make_ctx();
assert_eq!(ctx.locale_tag(), "en-US");
}
}