i18n: server-wide locale via lang/<code>.conf catalogs (fr, es); numeric prose auto-translated at the chokepoint, en = zero-cost passthrough
This commit is contained in:
parent
5914a6d993
commit
560c860f1d
6 changed files with 686 additions and 0 deletions
294
src/i18n.rs
Normal file
294
src/i18n.rs
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
//! Server-wide message localization.
|
||||
//!
|
||||
//! The English strings in the source are the msgids (the source of truth); a per-locale
|
||||
//! catalog file `lang/<code>.conf` maps each to its translation. The catalog is parsed
|
||||
//! ONCE at startup/rehash into an in-memory map — there is no per-message disk access and
|
||||
//! no per-message parsing. `locale = en` (or a missing file) yields an empty catalog that
|
||||
//! passes English through untouched, so the default path is byte-for-byte what it was.
|
||||
//!
|
||||
//! Only prose sent to a LOCAL client is localized (numeric trailing text, notices). The
|
||||
//! hot channel-broadcast fan-out is protocol (`PRIVMSG`/`JOIN`/`MODE`) and is never
|
||||
//! touched, so the fast path is unchanged. Protocol tokens, params, nicks, channels and
|
||||
//! the S2S wire stay canonical English always.
|
||||
//!
|
||||
//! File format, one entry per line (blank lines and `#` comments ignored):
|
||||
//! ```text
|
||||
//! "No such nick/channel" = "Aucun pseudo/salon de ce nom"
|
||||
//! "Welcome to the {0} IRC Network, {1}" = "Bienvenue sur le réseau IRC {0}, {1}"
|
||||
//! ```
|
||||
//! `{0}`,`{1}`,… are positional placeholders filled after translation, so word order
|
||||
//! may differ per language. A translation whose placeholder set differs from its msgid
|
||||
//! is rejected at load time (kept as English) so a bad file can never panic or drop args.
|
||||
|
||||
use crate::map::HashMap;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// A loaded locale: English msgid -> translated string. Empty = English passthrough.
|
||||
#[derive(Default)]
|
||||
pub struct Catalog {
|
||||
map: HashMap<Box<str>, Box<str>>,
|
||||
pub locale: String,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// English passthrough (locale `en` or a missing/unreadable file).
|
||||
pub fn passthrough() -> Catalog {
|
||||
Catalog {
|
||||
map: HashMap::default(),
|
||||
locale: "en".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_passthrough(&self) -> bool {
|
||||
self.map.is_empty()
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
|
||||
/// Load `<dir>/<locale>.conf`. `en`/empty/unreadable -> passthrough. The second
|
||||
/// return value is a list of human-readable warnings (bad lines, placeholder
|
||||
/// mismatches, missing file) for the caller to log — loading never fails hard.
|
||||
pub fn load(dir: &str, locale: &str) -> (Catalog, Vec<String>) {
|
||||
let mut warnings = Vec::new();
|
||||
if locale.is_empty() || locale.eq_ignore_ascii_case("en") {
|
||||
return (Catalog::passthrough(), warnings);
|
||||
}
|
||||
let path = format!("{dir}/{locale}.conf");
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
warnings.push(format!("locale {locale}: cannot read {path}: {e} (using English)"));
|
||||
return (Catalog::passthrough(), warnings);
|
||||
}
|
||||
};
|
||||
let mut map: HashMap<Box<str>, Box<str>> = HashMap::default();
|
||||
for (i, raw) in text.lines().enumerate() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
match parse_entry(line) {
|
||||
Some((key, val)) => {
|
||||
if placeholders(&key) != placeholders(&val) {
|
||||
warnings.push(format!(
|
||||
"locale {locale}:{}: placeholder set differs from msgid, skipped: {key:?}",
|
||||
i + 1
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if !val.is_empty() {
|
||||
map.insert(key.into_boxed_str(), val.into_boxed_str());
|
||||
}
|
||||
}
|
||||
None => warnings.push(format!("locale {locale}:{}: malformed entry, skipped", i + 1)),
|
||||
}
|
||||
}
|
||||
(
|
||||
Catalog {
|
||||
map,
|
||||
locale: locale.to_string(),
|
||||
},
|
||||
warnings,
|
||||
)
|
||||
}
|
||||
|
||||
/// Translate a whole English string; returns the input unchanged if not present.
|
||||
#[inline]
|
||||
pub fn tr<'a>(&'a self, english: &'a str) -> &'a str {
|
||||
match self.map.get(english) {
|
||||
Some(t) => t,
|
||||
None => english,
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate only the *trailing prose* of a numeric body `[params ]:trailing`,
|
||||
/// leaving params (nicks/channels/numbers) intact. Passthrough locales and bodies
|
||||
/// with no catalogued trailing return the input borrowed (no allocation).
|
||||
pub fn tr_numeric<'a>(&'a self, rest: &'a str) -> Cow<'a, str> {
|
||||
if self.is_passthrough() {
|
||||
return Cow::Borrowed(rest);
|
||||
}
|
||||
let (head, trail) = if let Some(idx) = rest.find(" :") {
|
||||
(&rest[..idx + 2], &rest[idx + 2..])
|
||||
} else if rest.starts_with(':') {
|
||||
(&rest[..1], &rest[1..])
|
||||
} else {
|
||||
return Cow::Borrowed(rest);
|
||||
};
|
||||
let translated = self.tr(trail);
|
||||
if std::ptr::eq(translated.as_ptr(), trail.as_ptr()) {
|
||||
Cow::Borrowed(rest)
|
||||
} else {
|
||||
Cow::Owned(format!("{head}{translated}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill `{0}`,`{1}`,… placeholders in a (already-translated) template. `{{`/`}}` are
|
||||
/// literal braces; an out-of-range or malformed index is emitted verbatim so a bad
|
||||
/// template degrades to visible text rather than losing data.
|
||||
pub fn render(template: &str, args: &[&str]) -> String {
|
||||
if !template.contains('{') {
|
||||
return template.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(template.len() + 16);
|
||||
let b = template.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'{' if i + 1 < b.len() && b[i + 1] == b'{' => {
|
||||
out.push('{');
|
||||
i += 2;
|
||||
}
|
||||
b'}' if i + 1 < b.len() && b[i + 1] == b'}' => {
|
||||
out.push('}');
|
||||
i += 2;
|
||||
}
|
||||
b'{' => {
|
||||
if let Some(end) = template[i + 1..].find('}') {
|
||||
let inner = &template[i + 1..i + 1 + end];
|
||||
if let Ok(idx) = inner.parse::<usize>() {
|
||||
if let Some(a) = args.get(idx) {
|
||||
out.push_str(a);
|
||||
}
|
||||
i += end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push('{');
|
||||
i += 1;
|
||||
}
|
||||
_ => {
|
||||
// copy one UTF-8 char
|
||||
let ch_len = utf8_len(b[i]);
|
||||
out.push_str(&template[i..i + ch_len]);
|
||||
i += ch_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn utf8_len(first: u8) -> usize {
|
||||
match first {
|
||||
0x00..=0x7f => 1,
|
||||
0xc0..=0xdf => 2,
|
||||
0xe0..=0xef => 3,
|
||||
_ => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// The set of placeholder indices `{N}` used in `s` (ignoring `{{`/`}}`).
|
||||
fn placeholders(s: &str) -> std::collections::BTreeSet<usize> {
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
let b = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'{' {
|
||||
if i + 1 < b.len() && b[i + 1] == b'{' {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if let Some(end) = s[i + 1..].find('}') {
|
||||
if let Ok(idx) = s[i + 1..i + 1 + end].parse::<usize>() {
|
||||
set.insert(idx);
|
||||
}
|
||||
i += end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// Parse one `"msgid" = "msgstr"` line, honoring `\"`, `\\` and `\n` escapes.
|
||||
fn parse_entry(line: &str) -> Option<(String, String)> {
|
||||
let (key, rest) = read_quoted(line)?;
|
||||
let rest = rest.trim_start();
|
||||
let rest = rest.strip_prefix('=')?.trim_start();
|
||||
let (val, tail) = read_quoted(rest)?;
|
||||
if !tail.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((key, val))
|
||||
}
|
||||
|
||||
/// Read a `"..."` string at the start of `s`; return (unescaped, remainder).
|
||||
fn read_quoted(s: &str) -> Option<(String, &str)> {
|
||||
let mut chars = s.char_indices();
|
||||
if chars.next()?.1 != '"' {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::new();
|
||||
while let Some((idx, c)) = chars.next() {
|
||||
match c {
|
||||
'\\' => match chars.next()?.1 {
|
||||
'n' => out.push('\n'),
|
||||
't' => out.push('\t'),
|
||||
'"' => out.push('"'),
|
||||
'\\' => out.push('\\'),
|
||||
other => {
|
||||
out.push('\\');
|
||||
out.push(other);
|
||||
}
|
||||
},
|
||||
'"' => return Some((out, &s[idx + 1..])),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_fills_positional_and_reorders() {
|
||||
assert_eq!(render("Hi {0} and {1}", &["a", "b"]), "Hi a and b");
|
||||
// translations may reorder
|
||||
assert_eq!(render("{1} puis {0}", &["a", "b"]), "b puis a");
|
||||
assert_eq!(render("no args here", &[]), "no args here");
|
||||
assert_eq!(render("brace {{ ok }}", &[]), "brace { ok }");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_and_translate() {
|
||||
let text = "\"No such nick/channel\" = \"Aucun pseudo/salon de ce nom\"\n# c\n\"x {0}\" = \"y {0}\"\n";
|
||||
std::fs::create_dir_all("/tmp/echo-i18n-test").unwrap();
|
||||
std::fs::write("/tmp/echo-i18n-test/fr.conf", text).unwrap();
|
||||
let (cat, warns) = Catalog::load("/tmp/echo-i18n-test", "fr");
|
||||
assert!(warns.is_empty(), "{warns:?}");
|
||||
assert_eq!(cat.tr("No such nick/channel"), "Aucun pseudo/salon de ce nom");
|
||||
assert_eq!(cat.tr("unknown"), "unknown"); // fallback
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_translates_only_trailing() {
|
||||
let text = "\"You're not on that channel\" = \"Vous n'êtes pas sur ce salon\"\n";
|
||||
std::fs::create_dir_all("/tmp/echo-i18n-test2").unwrap();
|
||||
std::fs::write("/tmp/echo-i18n-test2/fr.conf", text).unwrap();
|
||||
let (cat, _) = Catalog::load("/tmp/echo-i18n-test2", "fr");
|
||||
// params (#chan) preserved, trailing prose translated
|
||||
assert_eq!(
|
||||
cat.tr_numeric("#chan :You're not on that channel"),
|
||||
"#chan :Vous n'êtes pas sur ce salon"
|
||||
);
|
||||
// data trailing not in catalog -> unchanged, no allocation
|
||||
assert!(matches!(cat.tr_numeric("bob user host * :Real Name"), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_mismatch_is_rejected() {
|
||||
let text = "\"a {0} {1}\" = \"b {0}\"\n"; // drops {1}
|
||||
std::fs::create_dir_all("/tmp/echo-i18n-test3").unwrap();
|
||||
std::fs::write("/tmp/echo-i18n-test3/fr.conf", text).unwrap();
|
||||
let (cat, warns) = Catalog::load("/tmp/echo-i18n-test3", "fr");
|
||||
assert_eq!(warns.len(), 1);
|
||||
assert_eq!(cat.tr("a {0} {1}"), "a {0} {1}"); // stayed English
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ pub mod config;
|
|||
pub mod coremods;
|
||||
pub mod extensible;
|
||||
pub mod http;
|
||||
pub mod i18n;
|
||||
pub mod ircd;
|
||||
pub mod link;
|
||||
pub mod map;
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ pub struct Server {
|
|||
pub events: VecDeque<Hook>,
|
||||
pub opers: Vec<crate::config::OperBlock>, // oper logins from config
|
||||
pub brands: Vec<crate::config::BrandBlock>, // per-SNI server/network branding
|
||||
pub catalog: crate::i18n::Catalog, // active-locale message catalog (i18n; en = passthrough)
|
||||
pub cloak_key: Option<String>, // host-cloaking key (see modules::cloak)
|
||||
pub line_ctags: String, // client-only tags of the line being handled
|
||||
// --- server-to-server (see crate::link) ---
|
||||
|
|
@ -232,6 +233,13 @@ pub struct Server {
|
|||
|
||||
impl Server {
|
||||
pub fn new(cfg: Config, event_tx: Sender<Event>, conn_counter: Arc<AtomicU64>) -> Server {
|
||||
let (catalog, i18n_warn) = crate::i18n::Catalog::load(
|
||||
cfg.raw.get("locale_dir").and_then(|v| v.last()).map(String::as_str).unwrap_or("lang"),
|
||||
cfg.raw.get("locale").and_then(|v| v.last()).map(String::as_str).unwrap_or("en"),
|
||||
);
|
||||
for w in &i18n_warn {
|
||||
eprintln!("echoircd: {w}");
|
||||
}
|
||||
Server {
|
||||
name: cfg.servername,
|
||||
network: cfg.network,
|
||||
|
|
@ -243,6 +251,7 @@ impl Server {
|
|||
events: VecDeque::new(),
|
||||
opers: cfg.opers,
|
||||
brands: cfg.brands,
|
||||
catalog,
|
||||
cloak_key: cfg.cloak_key,
|
||||
line_ctags: String::new(),
|
||||
sid: cfg.sid,
|
||||
|
|
@ -312,6 +321,19 @@ impl Server {
|
|||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Translate a whole English string to the active locale (fallback: the input).
|
||||
/// `en`/passthrough returns the borrow unchanged — zero cost.
|
||||
#[inline]
|
||||
pub fn tr<'a>(&'a self, english: &'a str) -> &'a str {
|
||||
self.catalog.tr(english)
|
||||
}
|
||||
|
||||
/// Translate an English template, then fill its `{0}`/`{1}`… placeholders. Use at
|
||||
/// call sites whose prose interpolates data (word order may differ per language).
|
||||
pub fn trf(&self, template: &str, args: &[&str]) -> String {
|
||||
crate::i18n::render(self.catalog.tr(template), args)
|
||||
}
|
||||
|
||||
/// Apply a freshly-loaded config to the running server — the shared body of
|
||||
/// REHASH and the `server.rehash` RPC. Reloads the typed fields **and**
|
||||
/// `raw_config`, so modules reading via `conf*` see the new values too.
|
||||
|
|
@ -329,6 +351,14 @@ impl Server {
|
|||
self.dnsbl_reason = fresh.dnsbl_reason;
|
||||
self.sasl_server = fresh.sasl_server;
|
||||
self.webirc = fresh.webirc;
|
||||
let (catalog, i18n_warn) = crate::i18n::Catalog::load(
|
||||
fresh.raw.get("locale_dir").and_then(|v| v.last()).map(String::as_str).unwrap_or("lang"),
|
||||
fresh.raw.get("locale").and_then(|v| v.last()).map(String::as_str).unwrap_or("en"),
|
||||
);
|
||||
for w in &i18n_warn {
|
||||
eprintln!("echoircd: {w}");
|
||||
}
|
||||
self.catalog = catalog;
|
||||
self.raw_config = fresh.raw;
|
||||
self.config_gen = self.config_gen.wrapping_add(1); // invalidate module config caches
|
||||
// Re-evaluate which linked servers are services against the fresh
|
||||
|
|
@ -1008,6 +1038,7 @@ impl Server {
|
|||
let srv = u
|
||||
.and_then(|u| u.brand_server.as_deref())
|
||||
.unwrap_or(&self.name);
|
||||
let rest = self.catalog.tr_numeric(rest);
|
||||
format!(":{srv} {code:03} {target} {rest}")
|
||||
};
|
||||
self.send(uid, line);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue