Skip to main content

mf2_build/
config.rs

1//! `mf2.toml` (`plans/05-tooling.md` §3.1): the one place a corpus is
2//! configured, read by `build.rs` through [`crate::Build`] and by `mf2-cli`,
3//! so the two always agree.
4//!
5//! ```toml
6//! source_locale = "en"
7//!
8//! [fallback]                 # chains, flattened at build time (D5)
9//! "es-MX" = ["es", "en"]
10//!
11//! [catalog]
12//! strip = ["cold", "ids"]    # production client catalogs
13//! missing = "fallback"       # fallback | id | empty
14//!
15//! [locale_data]
16//! currencies = "used"        # "used" | "all" | ["USD", "EUR"]
17//! units = "used"
18//!
19//! [lints]
20//! neutral-numbers = "warn"
21//!
22//! [functions]                # custom functions for the generated registry
23//! "app:emoji" = "my_app_i18n::functions::emoji"
24//! ```
25//!
26//! The client feature set is *not* here: it is the i18n crate's own cargo
27//! features, which [`Features`](crate::Features) reads.
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::path::{Path, PathBuf};
31
32use mf2_locale_data::number::Selection;
33use mf2_resource::LineIndex;
34use serde::Deserialize;
35
36use crate::error::{Error, Result};
37use crate::lint::{Level, Lint};
38
39/// The name of the file, beside the i18n crate's `Cargo.toml`.
40pub const FILE_NAME: &str = "mf2.toml";
41
42/// A corpus's configuration.
43#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
44#[serde(deny_unknown_fields, default)]
45#[non_exhaustive]
46pub struct Config {
47    /// The locale the manifest and every lint compare against.
48    pub source_locale: String,
49    /// Per locale, the locales to take a missing message from, in order.
50    /// Chains are flattened at build time (D5); the source locale is the
51    /// implicit last resort.
52    pub fallback: BTreeMap<String, Vec<String>>,
53    /// What the per-locale catalogs carry.
54    pub catalog: CatalogConfig,
55    /// How much CLDR data a catalog carries.
56    pub locale_data: LocaleDataConfig,
57    /// Lint levels, by lint name.
58    pub lints: BTreeMap<Lint, Level>,
59    /// Custom functions for the generated registry: MF2 identifier → the
60    /// Rust path of a `&'static dyn Function`.
61    pub functions: BTreeMap<String, String>,
62}
63
64impl Default for Config {
65    fn default() -> Self {
66        Config {
67            source_locale: "en".to_owned(),
68            fallback: BTreeMap::new(),
69            catalog: CatalogConfig::default(),
70            locale_data: LocaleDataConfig::default(),
71            lints: BTreeMap::new(),
72            functions: BTreeMap::new(),
73        }
74    }
75}
76
77/// `[catalog]`.
78#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
79#[serde(deny_unknown_fields, default)]
80#[non_exhaustive]
81pub struct CatalogConfig {
82    /// Sections to leave out of the catalogs (`plans/02-catalog-format.md`
83    /// §2.3).
84    pub strip: BTreeSet<Strip>,
85    /// What a locale that lacks a message gets.
86    pub missing: Missing,
87}
88
89impl Default for CatalogConfig {
90    fn default() -> Self {
91        CatalogConfig {
92            strip: [Strip::Cold, Strip::Ids].into_iter().collect(),
93            missing: Missing::Fallback,
94        }
95    }
96}
97
98/// A catalog section a production build leaves out.
99#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Deserialize)]
100#[serde(rename_all = "kebab-case")]
101#[non_exhaustive]
102pub enum Strip {
103    /// Attributes, comments and the other cold data.
104    Cold,
105    /// The id table — the client formats by `MsgId`.
106    Ids,
107}
108
109/// What a locale that lacks a message gets.
110#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Deserialize)]
111#[serde(rename_all = "kebab-case")]
112#[non_exhaustive]
113pub enum Missing {
114    /// The fallback chain's text, flagged as a fallback (D5, F7).
115    #[default]
116    Fallback,
117    /// The message's id, so a gap is visible in the page.
118    Id,
119    /// Nothing at all.
120    Empty,
121}
122
123/// `[locale_data]`.
124#[derive(Clone, Debug, PartialEq, Eq, Default, Deserialize)]
125#[serde(deny_unknown_fields, default)]
126#[non_exhaustive]
127pub struct LocaleDataConfig {
128    /// Which currencies a catalog carries.
129    pub currencies: DataSet,
130    /// Which units a catalog carries.
131    pub units: DataSet,
132}
133
134/// How much of one CLDR table a catalog carries.
135#[derive(Clone, Debug, PartialEq, Eq, Default)]
136#[non_exhaustive]
137pub enum DataSet {
138    /// Only what the corpus names in a literal option (`"used"`, the
139    /// default). A non-literal option value makes it [`DataSet::All`]
140    /// anyway, with a `dynamic-currency` / `dynamic-unit` warning.
141    #[default]
142    Used,
143    /// Every one CLDR has (`"all"`).
144    All,
145    /// What the corpus names in a literal option, plus these. When an
146    /// option is a variable, these are the codes it can hold: the catalog
147    /// carries the list, not every code, and there is no warning.
148    Listed(BTreeSet<String>),
149}
150
151impl DataSet {
152    /// This set together with the codes the corpus was found to use.
153    ///
154    /// An explicit list wins over a variable's every-code: the developer's
155    /// list says which codes the variable can hold.
156    pub fn with_used(&self, used: &Selection) -> Selection {
157        match (self, used) {
158            (DataSet::All, _) => Selection::All,
159            (DataSet::Used, used) => used.clone(),
160            (DataSet::Listed(listed), Selection::All) => Selection::Listed(listed.clone()),
161            (DataSet::Listed(listed), Selection::Listed(used)) => {
162                Selection::Listed(listed.iter().chain(used).cloned().collect())
163            }
164        }
165    }
166}
167
168impl<'de> Deserialize<'de> for DataSet {
169    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
170        #[derive(Deserialize)]
171        #[serde(untagged)]
172        enum Raw {
173            Word(String),
174            List(BTreeSet<String>),
175        }
176        match Raw::deserialize(d)? {
177            Raw::Word(w) if w == "used" => Ok(DataSet::Used),
178            Raw::Word(w) if w == "all" => Ok(DataSet::All),
179            Raw::Word(w) => Err(serde::de::Error::custom(format!(
180                "expected \"used\", \"all\" or a list of codes, not {w:?}"
181            ))),
182            Raw::List(list) => Ok(DataSet::Listed(list)),
183        }
184    }
185}
186
187impl Config {
188    /// Reads `<dir>/mf2.toml`, or the defaults if there is none.
189    pub fn load(dir: &Path) -> Result<Config> {
190        let path = dir.join(FILE_NAME);
191        match std::fs::read_to_string(&path) {
192            Ok(text) => Config::parse(&text, &path),
193            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
194            Err(source) => Err(Error::io(path, source)),
195        }
196    }
197
198    /// Reads a configuration from TOML, naming `path` in any error.
199    pub fn parse(text: &str, path: &Path) -> Result<Config> {
200        let config: Config = toml::from_str(text).map_err(|e| config_error(text, path, &e))?;
201        config.validate(path)?;
202        Ok(config)
203    }
204
205    /// Writes the configuration back as TOML (`mf2 init`).
206    pub fn to_toml(&self) -> String {
207        toml::to_string_pretty(self).unwrap_or_default()
208    }
209
210    /// What `lint` does in this configuration.
211    pub fn level(&self, lint: Lint) -> Level {
212        self.lints
213            .get(&lint)
214            .copied()
215            .unwrap_or_else(|| lint.default_level())
216    }
217
218    /// The fallback chain of `locale`: the locales to look in after it, in
219    /// order, ending at the source locale. A locale with no chain of its own
220    /// falls back to its parent tags (`es-MX` → `es`) and then to the source.
221    pub fn chain(&self, locale: &str) -> Vec<String> {
222        let mut chain: Vec<String> = match self.fallback.get(locale) {
223            Some(listed) => listed.clone(),
224            None => truncations(locale),
225        };
226        if locale != self.source_locale && !chain.contains(&self.source_locale) {
227            chain.push(self.source_locale.clone());
228        }
229        chain.retain(|l| l != locale);
230        let mut seen = BTreeSet::new();
231        chain.retain(|l| seen.insert(l.clone()));
232        chain
233    }
234
235    fn validate(&self, path: &Path) -> Result<()> {
236        let bad = |message: String| Error::Config {
237            path: path.to_path_buf(),
238            message,
239        };
240        if self.source_locale.is_empty() {
241            return Err(bad("source_locale must be a BCP 47 tag".to_owned()));
242        }
243        for (&lint, &level) in &self.lints {
244            if level < lint.floor() {
245                return Err(bad(format!(
246                    "[lints] {lint} = {level:?}: this lint cannot be set below \
247                     \"{}\" — the rest of the build relies on it",
248                    lint.floor()
249                )));
250            }
251        }
252        for (locale, chain) in &self.fallback {
253            if chain.iter().any(|l| l == locale) {
254                return Err(bad(format!(
255                    "[fallback] {locale:?}: a locale may not fall back to itself"
256                )));
257            }
258        }
259        for (identifier, path_to_fn) in &self.functions {
260            if identifier.is_empty() || path_to_fn.is_empty() {
261                return Err(bad(format!(
262                    "[functions] {identifier:?}: needs a Rust path to a \
263                     `&'static dyn Function`"
264                )));
265            }
266        }
267        Ok(())
268    }
269}
270
271/// `es-MX` → `["es"]`: the tag's parents, longest first.
272fn truncations(locale: &str) -> Vec<String> {
273    let mut out = Vec::new();
274    let mut rest = locale;
275    while let Some(cut) = rest.rfind('-') {
276        rest = &rest[..cut];
277        if !rest.is_empty() {
278            out.push(rest.to_owned());
279        }
280    }
281    out
282}
283
284/// A TOML error with the line and column it happened at.
285///
286/// `toml`'s `Display` renders a snippet of the file; `message` is the
287/// sentence alone, which is what a report wants beside its own position.
288fn config_error(text: &str, path: &Path, e: &toml::de::Error) -> Error {
289    let message = match e.span() {
290        Some(span) => {
291            let index = LineIndex::new(text);
292            let at = index.position(text, u32::try_from(span.start).unwrap_or(u32::MAX));
293            format!("{}:{}: {}", at.line, at.column, e.message())
294        }
295        None => e.message().to_owned(),
296    };
297    Error::Config {
298        path: path.to_path_buf(),
299        message,
300    }
301}
302
303/// `Serialize` for `mf2 init`, which writes a file that reads back as itself.
304mod serialize {
305    use super::{CatalogConfig, Config, DataSet, LocaleDataConfig, Missing, Strip};
306    use serde::ser::{Serialize, SerializeMap, SerializeSeq, Serializer};
307
308    impl Serialize for Config {
309        fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
310            let mut m = s.serialize_map(None)?;
311            m.serialize_entry("source_locale", &self.source_locale)?;
312            if !self.fallback.is_empty() {
313                m.serialize_entry("fallback", &self.fallback)?;
314            }
315            m.serialize_entry("catalog", &self.catalog)?;
316            m.serialize_entry("locale_data", &self.locale_data)?;
317            if !self.lints.is_empty() {
318                let named: std::collections::BTreeMap<&str, String> = self
319                    .lints
320                    .iter()
321                    .map(|(l, v)| (l.name(), v.to_string()))
322                    .collect();
323                m.serialize_entry("lints", &named)?;
324            }
325            if !self.functions.is_empty() {
326                m.serialize_entry("functions", &self.functions)?;
327            }
328            m.end()
329        }
330    }
331
332    impl Serialize for CatalogConfig {
333        fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
334            let mut m = s.serialize_map(Some(2))?;
335            m.serialize_entry("strip", &self.strip)?;
336            m.serialize_entry("missing", &self.missing)?;
337            m.end()
338        }
339    }
340
341    impl Serialize for LocaleDataConfig {
342        fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
343            let mut m = s.serialize_map(Some(2))?;
344            m.serialize_entry("currencies", &self.currencies)?;
345            m.serialize_entry("units", &self.units)?;
346            m.end()
347        }
348    }
349
350    impl Serialize for Strip {
351        fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
352            s.serialize_str(match self {
353                Strip::Cold => "cold",
354                Strip::Ids => "ids",
355            })
356        }
357    }
358
359    impl Serialize for Missing {
360        fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
361            s.serialize_str(match self {
362                Missing::Fallback => "fallback",
363                Missing::Id => "id",
364                Missing::Empty => "empty",
365            })
366        }
367    }
368
369    impl Serialize for DataSet {
370        fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
371            match self {
372                DataSet::Used => s.serialize_str("used"),
373                DataSet::All => s.serialize_str("all"),
374                DataSet::Listed(list) => {
375                    let mut seq = s.serialize_seq(Some(list.len()))?;
376                    for code in list {
377                        seq.serialize_element(code)?;
378                    }
379                    seq.end()
380                }
381            }
382        }
383    }
384}
385
386/// Where `mf2.toml` and `locales/` live.
387#[derive(Clone, Debug, PartialEq, Eq)]
388pub struct Layout {
389    /// The i18n crate's directory.
390    pub root: PathBuf,
391    /// `<root>/locales`.
392    pub locales: PathBuf,
393}
394
395impl Layout {
396    /// The layout of the i18n crate rooted at `root`.
397    pub fn new(root: impl Into<PathBuf>) -> Layout {
398        let root = root.into();
399        let locales = root.join("locales");
400        Layout { root, locales }
401    }
402
403    /// The locale tags `locales/` holds, sorted: one directory per tag
404    /// (`locales/en/…`), or one flat JSON file per tag (`locales/en.json`).
405    pub fn locales(&self) -> Result<Vec<String>> {
406        let mut tags = BTreeSet::new();
407        let dir = std::fs::read_dir(&self.locales)
408            .map_err(|source| Error::io(self.locales.clone(), source))?;
409        for entry in dir {
410            let entry = entry.map_err(|source| Error::io(self.locales.clone(), source))?;
411            let path = entry.path();
412            let kind = entry
413                .file_type()
414                .map_err(|source| Error::io(path.clone(), source))?;
415            let name = entry.file_name();
416            let name = name.to_string_lossy();
417            let tag = if kind.is_dir() {
418                name.as_ref()
419            } else if let Some(tag) = name.strip_suffix(".json") {
420                tag
421            } else {
422                continue;
423            };
424            if !is_tag(tag) {
425                return Err(Error::Layout(format!(
426                    "{}: {tag:?} is not a locale tag — `locales/` holds one \
427                     directory or one .json file per tag, and nothing else",
428                    self.locales.display()
429                )));
430            }
431            tags.insert(tag.to_owned());
432        }
433        if tags.is_empty() {
434            return Err(Error::Layout(format!(
435                "{}: no locales — expected a directory or a .json file per tag",
436                self.locales.display()
437            )));
438        }
439        Ok(tags.into_iter().collect())
440    }
441}
442
443/// Whether `name` can be a locale tag: BCP 47's shape, loosely — subtags of
444/// ASCII letters and digits joined by `-`.
445///
446/// Loose because the tag is only ever matched against CLDR data, which
447/// decides what it means; strict because it becomes a file name and is
448/// interpolated into generated Rust, and because an editor's scratch
449/// directory under `locales/` should say so rather than become a locale.
450fn is_tag(name: &str) -> bool {
451    !name.is_empty()
452        && name.len() <= 64
453        && name
454            .split('-')
455            .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric()))
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    fn set(codes: &[&str]) -> BTreeSet<String> {
463        codes.iter().map(|c| (*c).to_owned()).collect()
464    }
465
466    #[test]
467    fn an_explicit_list_wins_over_a_variables_every_code() {
468        let listed = DataSet::Listed(set(&["EUR", "USD"]));
469        assert_eq!(
470            listed.with_used(&Selection::All),
471            Selection::Listed(set(&["EUR", "USD"]))
472        );
473        assert_eq!(
474            listed.with_used(&Selection::Listed(set(&["JPY"]))),
475            Selection::Listed(set(&["EUR", "JPY", "USD"]))
476        );
477        assert_eq!(DataSet::Used.with_used(&Selection::All), Selection::All);
478        assert_eq!(
479            DataSet::All.with_used(&Selection::Listed(set(&["JPY"]))),
480            Selection::All
481        );
482    }
483}