Skip to main content

mf2_build/
features.rs

1//! The client feature set (`plans/05-tooling.md` §3.1).
2//!
3//! Features belong to the **application**, declared once on its i18n crate
4//! and applied to its server and its wasm build alike, so SSR output always
5//! matches what the client would produce. `build.rs` therefore reads them
6//! from that crate's own cargo features (`CARGO_FEATURE_FN_NUMBER`, …) and
7//! `mf2-cli` from `--features`; neither reads `mf2.toml`, which would be a
8//! second place for them to disagree.
9
10use std::collections::BTreeSet;
11
12/// Which functions the built catalogs may use, and which locale data they
13/// need.
14#[derive(Clone, Debug, Default, PartialEq, Eq)]
15pub struct Features {
16    names: BTreeSet<String>,
17}
18
19/// A built-in function and the feature it needs.
20///
21/// `plans/00-master-plan.md` §5: a function that exists only behind a feature
22/// and is used while that feature is off is a **build error** naming the file
23/// and the line.
24pub const BUILTINS: [(&str, Option<&str>); 10] = [
25    ("string", None),
26    ("number", None),
27    ("integer", None),
28    ("offset", None),
29    ("percent", Some("fn-number")),
30    ("currency", Some("fn-number")),
31    ("unit", Some("fn-number")),
32    ("datetime", Some("fn-datetime")),
33    ("date", Some("fn-datetime")),
34    ("time", Some("fn-datetime")),
35];
36
37/// The options each built-in function defines, for the `unknown-option`
38/// lint: MF2 ignores an option a function does not have, so a misspelled or
39/// borrowed name (`dateStyle` on `:datetime`) changes nothing and says
40/// nothing at run time. Options in a namespace (`u:dir`, `ns:name`) are not
41/// listed here: every function accepts `u:`'s, and another namespace's are
42/// an implementation's own.
43///
44/// These are the names the runtime and the function crates read; the
45/// conformance crate's `options_lint` test holds each list against what a
46/// formatter actually reads.
47pub const OPTIONS: [(&str, &[&str]); 10] = [
48    ("string", &[]),
49    (
50        "number",
51        &[
52            "select",
53            "signDisplay",
54            "useGrouping",
55            "minimumIntegerDigits",
56            "minimumFractionDigits",
57            "maximumFractionDigits",
58            "minimumSignificantDigits",
59            "maximumSignificantDigits",
60            "trailingZeroDisplay",
61            "roundingPriority",
62            "roundingIncrement",
63            "roundingMode",
64        ],
65    ),
66    (
67        "integer",
68        &[
69            "select",
70            "signDisplay",
71            "useGrouping",
72            "minimumIntegerDigits",
73            "maximumSignificantDigits",
74        ],
75    ),
76    ("offset", &["add", "subtract"]),
77    (
78        "percent",
79        &[
80            "signDisplay",
81            "useGrouping",
82            "minimumFractionDigits",
83            "maximumFractionDigits",
84            "minimumSignificantDigits",
85            "maximumSignificantDigits",
86            "trailingZeroDisplay",
87            "roundingPriority",
88            "roundingMode",
89        ],
90    ),
91    (
92        "currency",
93        &[
94            "currency",
95            "currencyDisplay",
96            "currencySign",
97            "fractionDigits",
98            "useGrouping",
99            "minimumIntegerDigits",
100            "minimumSignificantDigits",
101            "maximumSignificantDigits",
102            "trailingZeroDisplay",
103            "roundingPriority",
104            "roundingIncrement",
105            "roundingMode",
106        ],
107    ),
108    (
109        "unit",
110        &[
111            "unit",
112            "unitDisplay",
113            "usage",
114            "signDisplay",
115            "useGrouping",
116            "minimumIntegerDigits",
117            "minimumFractionDigits",
118            "maximumFractionDigits",
119            "minimumSignificantDigits",
120            "maximumSignificantDigits",
121            "roundingPriority",
122            "roundingIncrement",
123            "roundingMode",
124        ],
125    ),
126    (
127        "datetime",
128        &[
129            "dateFields",
130            "dateLength",
131            "timePrecision",
132            "timeZoneStyle",
133            "timeZone",
134            "hour12",
135            "calendar",
136        ],
137    ),
138    ("date", &["fields", "length", "timeZone", "calendar"]),
139    (
140        "time",
141        &[
142            "precision",
143            "timeZoneStyle",
144            "timeZone",
145            "hour12",
146            "calendar",
147        ],
148    ),
149];
150
151/// Whether the built-in function `function` defines the option `option`;
152/// `None` if `function` is not a built-in or `option` is in a namespace.
153pub fn defines_option(function: &str, option: &str) -> Option<bool> {
154    if option.contains(':') {
155        return None;
156    }
157    let (_, names) = OPTIONS.iter().find(|(name, _)| *name == function)?;
158    Some(names.contains(&option))
159}
160
161/// The features that decide what a catalog may hold: which functions a
162/// message may call, and which locale data the catalog carries for them.
163/// The wasm and the catalogs must agree on these; the others (`intl`,
164/// `datetime-intl`, the host features) change only the client code.
165pub const CATALOG_FEATURES: [&str; 3] = ["fn-number", "fn-datetime", "datetime-icu"];
166
167impl Features {
168    /// The set cargo passed this `build.rs`: every `CARGO_FEATURE_*` in the
169    /// environment, back in its `kebab-case` spelling.
170    ///
171    /// Cargo spells a feature `some-feature` as `CARGO_FEATURE_SOME_FEATURE`,
172    /// so a feature whose name already holds `_` comes back with `-`. None of
173    /// the facade's features does (`plans/00-master-plan.md` §5).
174    pub fn from_env() -> Features {
175        Features::from_vars(std::env::vars())
176    }
177
178    /// [`Features::from_env`] over a given environment, for tests.
179    pub fn from_vars(vars: impl IntoIterator<Item = (String, String)>) -> Features {
180        let names = vars
181            .into_iter()
182            .filter_map(|(key, _)| {
183                key.strip_prefix("CARGO_FEATURE_")
184                    .map(|f| f.to_ascii_lowercase().replace('_', "-"))
185            })
186            .collect();
187        Features { names }
188    }
189
190    /// The set a `--features` argument names: comma- or space-separated, with
191    /// `crate/feature` reduced to `feature` as cargo reads it.
192    pub fn parse(list: &str) -> Features {
193        let names = list
194            .split([',', ' '])
195            .map(str::trim)
196            .filter(|f| !f.is_empty())
197            .map(|f| f.rsplit_once('/').map_or(f, |(_, name)| name).to_owned())
198            .collect();
199        Features { names }
200    }
201
202    /// A set from names, for tests and `mf2 compile`.
203    pub fn from_names<S: Into<String>>(names: impl IntoIterator<Item = S>) -> Features {
204        Features {
205            names: names.into_iter().map(Into::into).collect(),
206        }
207    }
208
209    /// Only the [`CATALOG_FEATURES`] of this set: what `mf2 compile --site`
210    /// compares with the i18n crate's.
211    #[must_use]
212    pub fn for_catalogs(&self) -> Features {
213        Features::from_names(CATALOG_FEATURES.into_iter().filter(|f| self.has(f)))
214    }
215
216    /// Whether `name` is on.
217    pub fn has(&self, name: &str) -> bool {
218        self.names.contains(name)
219    }
220
221    /// The names, sorted.
222    pub fn names(&self) -> impl Iterator<Item = &str> {
223        self.names.iter().map(String::as_str)
224    }
225
226    /// Number localization: symbols, grouping, numbering systems, and
227    /// `:percent` / `:currency` / `:unit`.
228    pub fn fn_number(&self) -> bool {
229        self.has("fn-number")
230    }
231
232    /// The date and time family.
233    pub fn fn_datetime(&self) -> bool {
234        self.has("fn-datetime")
235    }
236
237    /// Dates over ICU4X, from the catalog's `icu.blob`.
238    pub fn datetime_icu(&self) -> bool {
239        self.has("datetime-icu")
240    }
241
242    /// Dates over the browser's `Intl.DateTimeFormat`.
243    pub fn datetime_intl(&self) -> bool {
244        self.has("datetime-intl")
245    }
246
247    /// Numbers, plural selection and dates through the browser's `Intl` on
248    /// the client (D4). The server keeps the Rust path, so a catalog still
249    /// carries the data unless the build writes a client variant.
250    pub fn intl(&self) -> bool {
251        self.has("intl")
252    }
253
254    /// Whether this build provides the built-in function `identifier` (an
255    /// MF2 identifier without its `:`), or `None` if it is not a built-in —
256    /// a custom function, which `[functions]` in `mf2.toml` answers for.
257    pub fn provides(&self, identifier: &str) -> Option<bool> {
258        let (_, gate) = BUILTINS.iter().find(|(name, _)| *name == identifier)?;
259        Some(match gate {
260            None => true,
261            Some(feature) => self.has(feature),
262        })
263    }
264
265    /// The feature `identifier` needs and this build does not have.
266    pub fn missing_feature(&self, identifier: &str) -> Option<&'static str> {
267        let (_, gate) = BUILTINS.iter().find(|(name, _)| *name == identifier)?;
268        gate.filter(|feature| !self.has(feature))
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::Features;
275
276    #[test]
277    fn cargo_feature_variables_become_feature_names() {
278        let features = Features::from_vars([
279            ("CARGO_FEATURE_FN_NUMBER".to_owned(), "1".to_owned()),
280            ("CARGO_FEATURE_SSR".to_owned(), "1".to_owned()),
281            ("PATH".to_owned(), "/usr/bin".to_owned()),
282        ]);
283        assert!(features.fn_number());
284        assert!(features.has("ssr"));
285        assert!(!features.fn_datetime());
286        assert_eq!(features.names().collect::<Vec<_>>(), ["fn-number", "ssr"]);
287    }
288
289    #[test]
290    fn a_features_argument_reads_like_cargos() {
291        let features = Features::parse("fn-number, mf2/fn-datetime datetime-icu");
292        assert!(features.fn_number());
293        assert!(features.fn_datetime());
294        assert!(features.datetime_icu());
295    }
296
297    #[test]
298    fn only_some_features_change_a_catalog() {
299        let features = Features::parse("default,ssr,intl,datetime-intl,fn-datetime,fn-number");
300        assert_eq!(
301            features.for_catalogs().names().collect::<Vec<_>>(),
302            ["fn-datetime", "fn-number"]
303        );
304    }
305
306    #[test]
307    fn gated_functions_need_their_feature() {
308        let none = Features::default();
309        assert_eq!(none.provides("number"), Some(true));
310        assert_eq!(none.provides("percent"), Some(false));
311        assert_eq!(none.provides("date"), Some(false));
312        assert_eq!(none.provides("app:emoji"), None);
313        assert_eq!(none.missing_feature("currency"), Some("fn-number"));
314        assert_eq!(none.missing_feature("number"), None);
315
316        let both = Features::parse("fn-number,fn-datetime");
317        assert_eq!(both.provides("percent"), Some(true));
318        assert_eq!(both.provides("time"), Some(true));
319        assert_eq!(both.missing_feature("unit"), None);
320    }
321}