Skip to main content

iced_markdown_editor/
i18n.rs

1//! Interface language of the built-in toolbar, link bar and status bar.
2//!
3//! The texts are [Fluent](https://projectfluent.org) messages compiled into
4//! the crate (`locales/*.ftl`). Messages missing in a translation fall back
5//! to English. The document itself is never translated.
6//!
7//! ```
8//! use iced_markdown_editor::{EditorState, Language};
9//!
10//! // E.g. from the operating system locale.
11//! let language = Language::from_tag("de-CH").unwrap_or_default();
12//! let editor = EditorState::new().language(language);
13//! assert_eq!(editor.current_language(), Language::German);
14//! ```
15
16use fluent_bundle::concurrent::FluentBundle;
17use fluent_bundle::{FluentArgs, FluentResource, FluentValue};
18use std::fmt;
19use std::sync::OnceLock;
20use unic_langid::LanguageIdentifier;
21
22/// A language of the editor interface. English by default.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
24pub enum Language {
25    #[default]
26    English,
27    German,
28    French,
29    Spanish,
30    Italian,
31}
32
33impl Language {
34    /// Every supported language.
35    pub const ALL: [Language; 5] = [
36        Language::English,
37        Language::German,
38        Language::French,
39        Language::Spanish,
40        Language::Italian,
41    ];
42
43    /// The BCP 47 language tag, e.g. `de`.
44    pub fn tag(self) -> &'static str {
45        match self {
46            Language::English => "en",
47            Language::German => "de",
48            Language::French => "fr",
49            Language::Spanish => "es",
50            Language::Italian => "it",
51        }
52    }
53
54    /// The language's own name, e.g. `Deutsch`.
55    pub fn name(self) -> &'static str {
56        match self {
57            Language::English => "English",
58            Language::German => "Deutsch",
59            Language::French => "Français",
60            Language::Spanish => "Español",
61            Language::Italian => "Italiano",
62        }
63    }
64
65    /// The language of a locale such as `fr`, `de-CH`, `es_ES.UTF-8` or
66    /// `it-IT`; the region and encoding are ignored. `None` for languages
67    /// without a translation.
68    pub fn from_tag(tag: &str) -> Option<Self> {
69        let primary = tag.split(['-', '_', '.', '@']).next()?;
70        Self::ALL
71            .into_iter()
72            .find(|language| language.tag().eq_ignore_ascii_case(primary))
73    }
74
75    fn source(self) -> &'static str {
76        match self {
77            Language::English => include_str!("../locales/en.ftl"),
78            Language::German => include_str!("../locales/de.ftl"),
79            Language::French => include_str!("../locales/fr.ftl"),
80            Language::Spanish => include_str!("../locales/es.ftl"),
81            Language::Italian => include_str!("../locales/it.ftl"),
82        }
83    }
84
85    /// The parsed messages, built on first use.
86    fn bundle(self) -> &'static FluentBundle<FluentResource> {
87        static BUNDLES: [OnceLock<FluentBundle<FluentResource>>; 5] =
88            [const { OnceLock::new() }; 5];
89        let index = Self::ALL.iter().position(|l| *l == self).unwrap_or(0);
90        BUNDLES[index].get_or_init(|| {
91            let tag: LanguageIdentifier = self.tag().parse().expect("valid language tag");
92            let mut bundle = FluentBundle::new_concurrent(vec![tag]);
93            // No Unicode isolation marks around arguments: the bundled fonts
94            // have no glyphs for them.
95            bundle.set_use_isolating(false);
96            let resource = FluentResource::try_new(self.source().to_string())
97                .unwrap_or_else(|(resource, _)| resource);
98            let _ = bundle.add_resource(resource);
99            bundle
100        })
101    }
102
103    /// The message `id` with `args`, in English if this language lacks it.
104    fn format(self, id: &str, args: Option<&FluentArgs>) -> Option<String> {
105        let bundle = self.bundle();
106        let pattern = bundle.get_message(id)?.value()?;
107        let mut errors = Vec::new();
108        Some(
109            bundle
110                .format_pattern(pattern, args, &mut errors)
111                .into_owned(),
112        )
113    }
114}
115
116impl fmt::Display for Language {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.write_str(self.name())
119    }
120}
121
122/// The text of message `id`.
123pub(crate) fn text(language: Language, id: &str) -> String {
124    text_with(language, id, &[])
125}
126
127/// The text of message `id` with named arguments.
128pub(crate) fn text_with(
129    language: Language,
130    id: &str,
131    args: &[(&'static str, FluentValue<'_>)],
132) -> String {
133    let args = (!args.is_empty()).then(|| {
134        let mut fluent = FluentArgs::with_capacity(args.len());
135        for (name, value) in args {
136            fluent.set(*name, value.clone());
137        }
138        fluent
139    });
140    language
141        .format(id, args.as_ref())
142        .or_else(|| Language::English.format(id, args.as_ref()))
143        .unwrap_or_else(|| id.to_string())
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use fluent_syntax::ast;
150
151    fn message_ids(language: Language) -> Vec<String> {
152        let resource = FluentResource::try_new(language.source().to_string())
153            .unwrap_or_else(|(_, errors)| panic!("{language:?}: {errors:?}"));
154        resource
155            .entries()
156            .filter_map(|entry| match entry {
157                ast::Entry::Message(message) => Some(message.id.name.to_string()),
158                _ => None,
159            })
160            .collect()
161    }
162
163    #[test]
164    fn translations_are_complete() {
165        let mut english = message_ids(Language::English);
166        english.sort();
167        for language in Language::ALL {
168            let mut ids = message_ids(language);
169            ids.sort();
170            assert_eq!(ids, english, "messages of {language:?}");
171        }
172    }
173
174    #[test]
175    fn messages_format_without_errors() {
176        let mut args = FluentArgs::new();
177        for name in ["current", "total", "count", "checked", "size", "max"] {
178            args.set(name, 2);
179        }
180        args.set("mime", "image/x-test");
181        args.set("error", "broken");
182        for language in Language::ALL {
183            let bundle = language.bundle();
184            for id in message_ids(language) {
185                let pattern = bundle.get_message(&id).and_then(|m| m.value());
186                let pattern = pattern.unwrap_or_else(|| panic!("{language:?}: {id}"));
187                let mut errors = Vec::new();
188                let text = bundle.format_pattern(pattern, Some(&args), &mut errors);
189                assert!(errors.is_empty(), "{language:?} {id}: {errors:?}");
190                assert!(!text.contains(['\u{2068}', '\u{2069}']));
191            }
192        }
193    }
194
195    #[test]
196    fn plurals() {
197        let words = |language, count: usize| {
198            text_with(language, "status-words", &[("count", count.into())])
199        };
200        assert_eq!(words(Language::English, 1), "1 word");
201        assert_eq!(words(Language::English, 3), "3 words");
202        assert_eq!(words(Language::German, 1), "1 Wort");
203        assert_eq!(words(Language::German, 0), "0 Wörter");
204        assert_eq!(words(Language::French, 0), "0 mot");
205        assert_eq!(words(Language::Spanish, 2), "2 palabras");
206        assert_eq!(words(Language::Italian, 2), "2 parole");
207    }
208
209    #[test]
210    fn tags() {
211        assert_eq!(Language::from_tag("de-CH"), Some(Language::German));
212        assert_eq!(Language::from_tag("fr_FR.UTF-8"), Some(Language::French));
213        assert_eq!(Language::from_tag("ES"), Some(Language::Spanish));
214        assert_eq!(Language::from_tag("ja-JP"), None);
215        for language in Language::ALL {
216            assert_eq!(Language::from_tag(language.tag()), Some(language));
217        }
218    }
219
220    #[test]
221    fn unknown_messages_fall_back_to_the_id() {
222        assert_eq!(text(Language::German, "no-such-message"), "no-such-message");
223    }
224}