1use crate::plural::{pluralize, PluralFn};
4use crate::store::{Store, ROOT_SCOPE};
5use arc_swap::ArcSwap;
6use sova_core::extend::RouteValue;
7use sova_core::{App, Request, Router};
8use std::collections::HashSet;
9use std::sync::{Arc, Mutex};
10
11#[derive(Debug, Clone)]
13pub struct I18nScope(pub Box<str>);
14
15impl RouteValue for I18nScope {}
16
17impl I18nScope {
18 pub fn as_str(&self) -> &str {
19 &self.0
20 }
21}
22
23#[derive(Debug, Clone)]
25pub struct LocaleCode(pub Box<str>);
26
27#[derive(Clone)]
29pub struct I18nState {
30 pub store: Arc<ArcSwap<Store>>,
31 pub fallback: Box<str>,
32 pub default: Box<str>,
34 pub path_prefix: bool,
36 pub plural_fn: Option<PluralFn>,
37 pub missing_handler: Option<MissingHandler>,
38 pub missing_keys: Arc<Mutex<HashSet<String>>>,
39}
40
41pub type MissingHandler = Arc<dyn Fn(&str, &str, &str) + Send + Sync>;
42
43impl I18nState {
44 pub fn translate(&self, locale: &str, scope: &str, key: &str) -> String {
45 if let Some(v) = self.lookup(locale, scope, key) {
46 return v;
47 }
48 self.record_missing(locale, scope, key);
49 if let Some(h) = &self.missing_handler {
50 h(locale, scope, key);
51 }
52 key.to_string()
53 }
54
55 pub fn has(&self, locale: &str, scope: &str, key: &str) -> bool {
56 self.lookup(locale, scope, key).is_some()
57 }
58
59 fn lookup(&self, locale: &str, scope: &str, key: &str) -> Option<String> {
60 let store = self.store.load();
61 let fallback = self.fallback.as_ref();
62 for (loc, sc) in [
63 (locale, scope),
64 (locale, ROOT_SCOPE),
65 (fallback, scope),
66 (fallback, ROOT_SCOPE),
67 ] {
68 if let Some(v) = store.lookup_flat(loc, sc, key) {
69 return Some(v);
70 }
71 }
72 None
73 }
74
75 fn record_missing(&self, locale: &str, scope: &str, key: &str) {
76 let id = format!("{locale}|{scope}|{key}");
77 let mut set = self.missing_keys.lock().unwrap();
78 if set.insert(id) {
79 tracing::warn!(locale, scope, key, "i18n missing translation");
80 }
81 }
82
83 pub fn missing_snapshot(&self) -> Vec<String> {
84 let mut v: Vec<_> = self.missing_keys.lock().unwrap().iter().cloned().collect();
85 v.sort();
86 v
87 }
88}
89
90pub trait I18nRouteExt {
92 fn i18n_scope(&mut self, scope: impl Into<String>) -> &mut Self;
93}
94
95impl I18nRouteExt for Router {
96 fn i18n_scope(&mut self, scope: impl Into<String>) -> &mut Self {
97 self.with(I18nScope(scope.into().into_boxed_str()))
98 }
99}
100
101impl I18nRouteExt for App {
102 fn i18n_scope(&mut self, scope: impl Into<String>) -> &mut Self {
103 self.with(I18nScope(scope.into().into_boxed_str()));
104 self
105 }
106}
107
108pub trait I18nExt {
110 fn locale(&self) -> &str;
111 fn i18n_scope_name(&self) -> String;
112 fn t(&self, key: &str) -> String;
113 fn t_args(&self, key: &str, args: &[(&str, &str)]) -> String;
114 fn tn(&self, key: &str, count: i64) -> String;
115 fn has_t(&self, key: &str) -> bool;
116}
117
118impl I18nExt for Request {
119 fn locale(&self) -> &str {
120 self.get::<LocaleCode>()
121 .map(|c| c.0.as_ref())
122 .unwrap_or("en")
123 }
124
125 fn i18n_scope_name(&self) -> String {
126 scope_of(self)
127 }
128
129 fn t(&self, key: &str) -> String {
130 let Some(state) = self.try_state::<I18nState>() else {
131 return key.to_string();
132 };
133 state.translate(self.locale(), &scope_of(self), key)
134 }
135
136 fn t_args(&self, key: &str, args: &[(&str, &str)]) -> String {
137 interpolate(&self.t(key), args)
138 }
139
140 fn tn(&self, key: &str, count: i64) -> String {
141 let Some(state) = self.try_state::<I18nState>() else {
142 return key.to_string();
143 };
144 let scope = scope_of(self);
145 let raw = state.translate(self.locale(), &scope, key);
146 pluralize(state.plural_fn.as_ref(), key, count, self.locale(), &raw)
147 }
148
149 fn has_t(&self, key: &str) -> bool {
150 let Some(state) = self.try_state::<I18nState>() else {
151 return false;
152 };
153 state.has(self.locale(), &scope_of(self), key)
154 }
155}
156
157pub(crate) fn scope_of(req: &Request) -> String {
158 req.route_meta::<I18nScope>()
159 .map(|s| s.0.to_string())
160 .unwrap_or_else(|| ROOT_SCOPE.to_string())
161}
162
163pub fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
165 let mut out = template.to_string();
166 for (k, v) in args {
167 out = out.replace(&format!("{{{k}}}"), v);
168 }
169 out
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn interpolate_keeps_missing_token() {
178 assert_eq!(
179 interpolate("Hello {name}, {x}", &[("name", "Ada")]),
180 "Hello Ada, {x}"
181 );
182 }
183}