es_fluent/
lib.rs

1#![doc = include_str!("../README.md")]
2
3#[cfg(feature = "derive")]
4pub use es_fluent_derive::{EsFluent, EsFluentChoice, EsFluentKv, EsFluentThis};
5
6#[doc(hidden)]
7pub use es_fluent_manager_core::{FluentManager, I18nModule, LocalizationError, Localizer};
8
9#[doc(hidden)]
10pub use fluent_bundle::FluentValue;
11
12#[doc(hidden)]
13pub use inventory as __inventory;
14
15#[doc(hidden)]
16pub use rust_embed as __rust_embed;
17
18#[doc(hidden)]
19pub use es_fluent_manager_core as __manager_core;
20
21#[doc(hidden)]
22pub use es_fluent_core as __core;
23
24#[doc(hidden)]
25pub use unic_langid;
26
27mod traits;
28pub use traits::{EsFluentChoice, FluentDisplay, ThisFtl, ToFluentString};
29
30use std::sync::{Arc, OnceLock, RwLock};
31
32#[doc(hidden)]
33static CONTEXT: OnceLock<Arc<RwLock<FluentManager>>> = OnceLock::new();
34
35#[doc(hidden)]
36static CUSTOM_LOCALIZER: OnceLock<
37    Box<
38        dyn Fn(&str, Option<&std::collections::HashMap<&str, FluentValue>>) -> Option<String>
39            + Send
40            + Sync,
41    >,
42> = OnceLock::new();
43
44/// Sets the global `FluentManager` context.
45///
46/// This function should be called once at the beginning of your application's
47/// lifecycle.
48///
49/// # Panics
50///
51/// This function will panic if the context has already been set.
52#[doc(hidden)]
53pub fn set_context(manager: FluentManager) {
54    CONTEXT
55        .set(Arc::new(RwLock::new(manager)))
56        .map_err(|_| "Context already set")
57        .expect("Failed to set context");
58}
59
60/// Sets the global `FluentManager` context with a shared `Arc<RwLock<FluentManager>>`.
61///
62/// This function is useful when you want to share the `FluentManager` between
63/// multiple threads.
64///
65/// # Panics
66///
67/// This function will panic if the context has already been set.
68#[doc(hidden)]
69pub fn set_shared_context(manager: Arc<RwLock<FluentManager>>) {
70    CONTEXT
71        .set(manager)
72        .map_err(|_| "Context already set")
73        .expect("Failed to set shared context");
74}
75
76/// Sets a custom localizer function.
77///
78/// The custom localizer will be called before the global context's `localize`
79/// method. If the custom localizer returns `Some(message)`, the message will be
80/// returned. Otherwise, the global context will be used.
81///
82/// # Panics
83///
84/// This function will panic if the custom localizer has already been set.
85#[doc(hidden)]
86pub fn set_custom_localizer<F>(localizer: F)
87where
88    F: Fn(&str, Option<&std::collections::HashMap<&str, FluentValue>>) -> Option<String>
89        + Send
90        + Sync
91        + 'static,
92{
93    CUSTOM_LOCALIZER
94        .set(Box::new(localizer))
95        .map_err(|_| "Custom localizer already set")
96        .expect("Failed to set custom localizer");
97}
98
99/// Updates the global `FluentManager` context.
100#[doc(hidden)]
101pub fn update_context<F>(f: F)
102where
103    F: FnOnce(&mut FluentManager),
104{
105    if let Some(context_arc) = CONTEXT.get() {
106        let mut context = context_arc
107            .write()
108            .expect("Failed to acquire write lock on context");
109        f(&mut context);
110    }
111}
112
113/// Localizes a message by its ID.
114///
115/// This function will first try to use the custom localizer if it has been set.
116/// If the custom localizer returns `None`, it will then try to use the global
117/// context.
118///
119/// If the message is not found, a warning will be logged and the ID will be
120/// returned as the message.
121#[doc(hidden)]
122pub fn localize<'a>(
123    id: &str,
124    args: Option<&std::collections::HashMap<&str, FluentValue<'a>>>,
125) -> String {
126    if let Some(custom_localizer) = CUSTOM_LOCALIZER.get()
127        && let Some(message) = custom_localizer(id, args)
128    {
129        return message;
130    }
131
132    if let Some(context_arc) = CONTEXT.get() {
133        let context = context_arc
134            .read()
135            .expect("Failed to acquire read lock on context");
136
137        if let Some(message) = context.localize(id, args) {
138            return message;
139        }
140    }
141
142    log::warn!("Translation for '{}' not found or context not set.", id);
143    id.to_string()
144}
145
146// FTL file generation support (requires "generate" feature)
147#[cfg(feature = "generate")]
148mod generate {
149    use std::path::PathBuf;
150
151    pub use es_fluent_generate::FluentParseMode;
152    pub use es_fluent_generate::error::FluentGenerateError;
153
154    /// Error type for FTL generation.
155    #[derive(Debug)]
156    pub enum GeneratorError {
157        /// Failed to read i18n.toml configuration.
158        Config(es_fluent_toml::I18nConfigError),
159        /// Failed to detect crate name.
160        CrateName(String),
161        /// Failed to generate FTL files.
162        Generate(FluentGenerateError),
163    }
164
165    impl std::fmt::Display for GeneratorError {
166        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167            match self {
168                Self::Config(e) => write!(f, "Configuration error: {}", e),
169                Self::CrateName(e) => write!(f, "Failed to detect crate name: {}", e),
170                Self::Generate(e) => write!(f, "Generation error: {}", e),
171            }
172        }
173    }
174
175    impl std::error::Error for GeneratorError {}
176
177    impl From<es_fluent_toml::I18nConfigError> for GeneratorError {
178        fn from(e: es_fluent_toml::I18nConfigError) -> Self {
179            Self::Config(e)
180        }
181    }
182
183    impl From<FluentGenerateError> for GeneratorError {
184        fn from(e: FluentGenerateError) -> Self {
185            Self::Generate(e)
186        }
187    }
188
189    /// Builder for generating FTL files from registered types.
190    ///
191    /// Uses the `inventory` crate to collect all types registered via
192    /// `#[derive(EsFluent)]`, `#[derive(EsFluentKv)]`, or `#[derive(EsFluentThis)]`.
193    ///
194    /// # Example
195    ///
196    /// ```ignore
197    /// use es_fluent::EsFluentGenerator;
198    ///
199    /// fn main() {
200    ///     // Uses defaults from i18n.toml and auto-detects crate name
201    ///     EsFluentGenerator::builder()
202    ///         .build()
203    ///         .generate()
204    ///         .expect("Failed to generate FTL files");
205    ///
206    ///     // Or with custom settings
207    ///     EsFluentGenerator::builder()
208    ///         .mode(es_fluent::FluentParseMode::Aggressive)
209    ///         .output_path("custom/path")
210    ///         .build()
211    ///         .generate()
212    ///         .expect("Failed to generate FTL files");
213    /// }
214    /// ```
215    #[derive(bon::Builder)]
216    pub struct EsFluentGenerator {
217        /// The parse mode (Conservative preserves existing translations, Aggressive overwrites).
218        /// Defaults to Conservative.
219        #[builder(default)]
220        mode: FluentParseMode,
221
222        /// Override the crate name (defaults to auto-detect from Cargo.toml).
223        #[builder(into)]
224        crate_name: Option<String>,
225
226        /// Override the output path (defaults to reading from i18n.toml).
227        #[builder(into)]
228        output_path: Option<PathBuf>,
229
230        /// Override the crate root directory for filtering source files.
231        /// If not provided, defaults to the current crate's src/ directory logic.
232        #[builder(into)]
233        crate_root: Option<PathBuf>,
234    }
235
236    impl EsFluentGenerator {
237        /// Generates FTL files from all registered types.
238        pub fn generate(&self) -> Result<(), GeneratorError> {
239            let crate_name = match &self.crate_name {
240                Some(name) => name.clone(),
241                None => Self::detect_crate_name()?,
242            };
243
244            let output_path = match &self.output_path {
245                Some(path) => path.clone(),
246                None => {
247                    let config = es_fluent_toml::I18nConfig::read_from_manifest_dir()?;
248                    config.assets_dir.join(&config.fallback_language)
249                },
250            };
251
252            let type_infos = if let Some(root) = &self.crate_root {
253                // Filter by explicitly provided root
254                crate::__core::registry::get_all_ftl_type_infos()
255                    .into_iter()
256                    .filter(|info| {
257                        info.file_path
258                            .as_ref()
259                            .is_some_and(|path| path.starts_with(root.to_str().unwrap_or_default()))
260                    })
261                    .collect::<Vec<_>>()
262            } else {
263                // Get the current crate's src directory to filter types
264                // file!() returns paths relative to workspace root, so we need to get the relative path
265                let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
266                    GeneratorError::CrateName("CARGO_MANIFEST_DIR not set".to_string())
267                })?;
268
269                // Get workspace root and compute relative path
270                let src_prefix = cargo_metadata::MetadataCommand::new()
271                    .exec()
272                    .ok()
273                    .and_then(|metadata| {
274                        let workspace_root = metadata.workspace_root.as_std_path();
275                        let manifest_dir_path = std::path::Path::new(&manifest_dir);
276                        manifest_dir_path
277                            .strip_prefix(workspace_root)
278                            .ok()
279                            .map(|rel| format!("{}/src/", rel.display()))
280                    })
281                    .unwrap_or_else(|| "src/".to_string());
282
283                crate::__core::registry::get_all_ftl_type_infos()
284                    .into_iter()
285                    .filter(|info| {
286                        info.file_path
287                            .as_ref()
288                            .is_some_and(|path| path.starts_with(&src_prefix))
289                    })
290                    .collect()
291            };
292
293            log::info!(
294                "Generating FTL files for {} types in crate '{}'",
295                type_infos.len(),
296                crate_name
297            );
298
299            es_fluent_generate::generate(&crate_name, output_path, type_infos, self.mode.clone())?;
300
301            Ok(())
302        }
303
304        /// Auto-detects the crate name from Cargo.toml using cargo_metadata.
305        fn detect_crate_name() -> Result<String, GeneratorError> {
306            let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
307                .map_err(|_| GeneratorError::CrateName("CARGO_MANIFEST_DIR not set".to_string()))?;
308            let manifest_path = std::path::PathBuf::from(&manifest_dir).join("Cargo.toml");
309
310            cargo_metadata::MetadataCommand::new()
311                .exec()
312                .ok()
313                .and_then(|metadata| {
314                    metadata
315                        .packages
316                        .iter()
317                        .find(|pkg| pkg.manifest_path == manifest_path)
318                        .map(|pkg| pkg.name.to_string())
319                })
320                .or_else(|| std::env::var("CARGO_PKG_NAME").ok())
321                .ok_or_else(|| {
322                    GeneratorError::CrateName("Could not determine crate name".to_string())
323                })
324        }
325    }
326}
327
328#[cfg(feature = "generate")]
329pub use generate::{EsFluentGenerator, FluentParseMode, GeneratorError};
330
331#[cfg(feature = "generate")]
332pub use es_fluent_toml;