Skip to main content

cranpose_localization/
message.rs

1use std::{cell::RefCell, collections::BTreeMap, rc::Rc, sync::OnceLock};
2
3use fluent_bundle::{FluentArgs, FluentBundle, FluentError, FluentResource, FluentValue};
4
5use crate::LocalizationError;
6
7/// A static message definition emitted by `tr!` or generated catalog bindings.
8pub struct Message {
9    namespace: &'static str,
10    id: &'static str,
11    source: &'static SourceCatalog,
12}
13
14/// Source-language messages shared by generated accessors and parsed on first use.
15pub struct SourceCatalog {
16    source_locale: &'static str,
17    resource: &'static str,
18    parsed: OnceLock<Result<FluentResource, String>>,
19}
20
21impl Message {
22    /// Defines a message using a source catalog containing `id`.
23    /// Prefer the macros, which validate the source at compile time.
24    pub const fn new(
25        namespace: &'static str,
26        id: &'static str,
27        source: &'static SourceCatalog,
28    ) -> Self {
29        Self {
30            namespace,
31            id,
32            source,
33        }
34    }
35
36    /// The package namespace used for catalog lookup and app overrides.
37    pub fn namespace(&self) -> &'static str {
38        self.namespace
39    }
40
41    /// The stable catalog identifier.
42    pub fn id(&self) -> &'static str {
43        self.id
44    }
45
46    /// Formats the source-language message without any application catalog.
47    pub fn format_source(&self, arguments: &[Argument<'_>]) -> Result<String, LocalizationError> {
48        self.source(&fluent_args(arguments))
49    }
50
51    /// Returns best-effort source text after a formatting error, preserving readable
52    /// text and Fluent's unresolved-variable markers instead of showing a catalog key.
53    pub fn fallback_text(&self, arguments: &[Argument<'_>]) -> String {
54        self.source_output(&fluent_args(arguments))
55            .map_or_else(|_| "…".to_owned(), |(text, _)| text)
56    }
57
58    pub(crate) fn source(&self, args: &FluentArgs<'_>) -> Result<String, LocalizationError> {
59        let (text, errors) = self.source_output(args)?;
60        if errors.is_empty() {
61            Ok(text)
62        } else {
63            Err(self.error(format!("{errors:?}")))
64        }
65    }
66
67    fn source_output(
68        &self,
69        args: &FluentArgs<'_>,
70    ) -> Result<(String, Vec<FluentError>), LocalizationError> {
71        let bundle = self.source.bundle().map_err(|detail| self.error(detail))?;
72        let pattern = bundle
73            .get_message(self.id)
74            .and_then(|message| message.value())
75            .ok_or_else(|| self.error("source message has no value".to_owned()))?;
76        let mut errors = Vec::new();
77        let text = bundle.format_pattern(pattern, Some(args), &mut errors);
78        Ok((text.into_owned(), errors))
79    }
80
81    pub(crate) fn error(&self, detail: String) -> LocalizationError {
82        LocalizationError::Format {
83            namespace: self.namespace.to_owned(),
84            id: self.id.to_owned(),
85            detail,
86        }
87    }
88}
89
90pub(crate) fn fluent_args<'a>(arguments: &'a [Argument<'_>]) -> FluentArgs<'a> {
91    arguments
92        .iter()
93        .map(|argument| {
94            let value = match &argument.value {
95                FluentValue::String(text) => FluentValue::from(text.as_ref()),
96                value => value.clone(),
97            };
98            (argument.name, value)
99        })
100        .collect()
101}
102
103impl SourceCatalog {
104    /// Defines source-language Fluent data. Macros validate it before compilation.
105    pub const fn new(source_locale: &'static str, resource: &'static str) -> Self {
106        Self {
107            source_locale,
108            resource,
109            parsed: OnceLock::new(),
110        }
111    }
112
113    fn bundle(&'static self) -> Result<Rc<FluentBundle<&'static FluentResource>>, String> {
114        type SourceBundle = Result<Rc<FluentBundle<&'static FluentResource>>, String>;
115        thread_local! {
116            static SOURCES: RefCell<BTreeMap<usize, SourceBundle>> = const { RefCell::new(BTreeMap::new()) };
117        }
118        SOURCES.with(|sources| {
119            sources
120                .borrow_mut()
121                .entry(std::ptr::from_ref(self).addr())
122                .or_insert_with(|| self.prepare().map(Rc::new))
123                .clone()
124        })
125    }
126
127    fn prepare(&'static self) -> Result<FluentBundle<&'static FluentResource>, String> {
128        let language = self
129            .source_locale
130            .parse()
131            .map_err(|error| format!("{error}"))?;
132        let resource = self
133            .parsed
134            .get_or_init(|| {
135                FluentResource::try_new(self.resource.to_owned())
136                    .map_err(|(_, errors)| format!("{errors:?}"))
137            })
138            .as_ref()
139            .map_err(Clone::clone)?;
140        let mut bundle = crate::new_bundle(language);
141        bundle
142            .add_resource(resource)
143            .map_err(|errors| format!("{errors:?}"))?;
144        Ok(bundle)
145    }
146}
147
148/// A named value passed to a message without allocating a string for numbers.
149#[derive(Clone, Debug, PartialEq)]
150pub struct Argument<'a> {
151    pub(crate) name: &'static str,
152    pub(crate) value: FluentValue<'a>,
153}
154
155impl<'a> Argument<'a> {
156    /// Borrows strings and preserves numeric values for Fluent plural selection.
157    pub fn new(name: &'static str, value: impl Into<FluentValue<'a>>) -> Self {
158        Self {
159            name,
160            value: value.into(),
161        }
162    }
163
164    /// Transfers owned values into a cache, copying only borrowed text that must outlive the call.
165    pub fn take_owned(&mut self) -> Argument<'static> {
166        let value = match std::mem::replace(&mut self.value, FluentValue::None) {
167            FluentValue::String(text) => FluentValue::String(text.into_owned().into()),
168            FluentValue::Number(number) => FluentValue::Number(number),
169            FluentValue::Custom(custom) => FluentValue::Custom(custom),
170            FluentValue::None => FluentValue::None,
171            FluentValue::Error => FluentValue::Error,
172        };
173        Argument {
174            name: self.name,
175            value,
176        }
177    }
178
179    /// Updates an owned cache argument, reusing string storage when possible.
180    pub fn update_from(&mut self, source: &mut Argument<'_>) {
181        self.name = source.name;
182        if let FluentValue::String(target) = &mut self.value
183            && let FluentValue::String(std::borrow::Cow::Borrowed(value)) = &source.value
184        {
185            let target = target.to_mut();
186            target.clear();
187            target.push_str(value);
188            return;
189        }
190        self.value = source.take_owned().value;
191    }
192}