Skip to main content

mf2_host_std/
lib.rs

1//! `mf2-host-std` — the [`Host`] of `mf2-runtime` for native code and for the
2//! `wasm32-wasip1`: NFC
3//! through `unicode-normalization`, the shortest round-trip text of a float
4//! through `ryu`, and the UTC offset of a named time zone through `jiff`'s
5//! **bundled** IANA database — never the system's, so every server, test and
6//! `wasm32-wasip1` run answers alike. Never
7//! linked into the browser client, which uses `mf2-host-web`.
8//!
9//! # The user guide
10//!
11//! The [Rust MF2 book](https://evancarroll.github.io/rust-mf2/) is the user
12//! guide: how the crates fit together, web and native applications, the
13//! command line, and what 2.x promises.
14//! An application reaches this crate through
15//! [`mf2`](https://docs.rs/mf2), as `mf2::host_std` (feature `host-std`).
16
17#![warn(missing_docs)]
18// docs.rs (`cargo xtask docs-rs`): each feature-gated item says which features it needs.
19#![cfg_attr(docsrs, feature(doc_cfg))]
20#![no_std]
21#![forbid(unsafe_code)]
22
23extern crate alloc;
24
25use alloc::string::String;
26
27use mf2_runtime::Host;
28use unicode_normalization::UnicodeNormalization;
29
30/// The native host.
31#[derive(Clone, Copy, Default, Debug)]
32pub struct StdHost;
33
34/// The native host, for [`mf2_runtime::FormatContext::new`].
35pub static HOST: StdHost = StdHost;
36
37impl Host for StdHost {
38    fn nfc<'a>(&self, s: &'a str, buf: &'a mut String) -> &'a str {
39        buf.clear();
40        buf.extend(s.nfc());
41        buf
42    }
43
44    fn f64_to_text<'b>(&self, x: f64, buf: &'b mut [u8; 32]) -> Option<&'b str> {
45        if !x.is_finite() {
46            return None;
47        }
48        let mut b = ryu::Buffer::new();
49        let text = b.format_finite(x).as_bytes();
50        let out = buf.get_mut(..text.len())?;
51        out.copy_from_slice(text);
52        core::str::from_utf8(out).ok()
53    }
54
55    /// The offset of `zone` (an IANA name, looked up case-insensitively, or
56    /// one of jiff's special zones such as `UTC`) at `epoch_ms`, from the
57    /// bundled database; else, when `zone` is a POSIX TZ rule
58    /// (`TimeZone::rules`: a native application's system zone without an
59    /// IANA name), from that rule. `None` for anything else, or an instant
60    /// outside jiff's range (years −9999 to 9999).
61    fn zone_offset(&self, zone: &str, epoch_ms: i64) -> Option<i32> {
62        let t = jiff::Timestamp::from_millisecond(epoch_ms).ok()?;
63        let tz = match jiff::tz::TimeZoneDatabase::bundled().get(zone) {
64            Ok(tz) => tz,
65            Err(_) => jiff::tz::TimeZone::posix(zone).ok()?,
66        };
67        Some(tz.to_offset(t).seconds())
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use alloc::string::String;
74
75    use mf2_runtime::Host;
76
77    use super::HOST;
78
79    #[test]
80    fn nfc() {
81        let mut buf = String::new();
82        assert_eq!(HOST.nfc("\u{1E0A}\u{0323}", &mut buf), "\u{1E0C}\u{0307}");
83        let mut buf = String::new();
84        assert_eq!(HOST.nfc("e\u{301}", &mut buf), "\u{e9}");
85    }
86
87    #[test]
88    fn floats() {
89        let mut buf = [0u8; 32];
90        for (x, want) in [
91            (4.2, "4.2"),
92            (1.0, "1.0"),
93            (-0.5, "-0.5"),
94            (1e21, "1e21"),
95            (1.5e-7, "1.5e-7"),
96            (f64::MAX, "1.7976931348623157e308"),
97            (-f64::MIN_POSITIVE, "-2.2250738585072014e-308"),
98        ] {
99            assert_eq!(HOST.f64_to_text(x, &mut buf), Some(want));
100        }
101        assert_eq!(HOST.f64_to_text(f64::NAN, &mut buf), None);
102    }
103
104    /// Milliseconds since the epoch of `year-month-day hour:minute:second` UTC.
105    fn at(year: i32, month: u8, day: u8, hour: i64, minute: i64, second: i64) -> i64 {
106        let days =
107            mf2_runtime::Date::new(year, month, day).map_or(0, mf2_runtime::Date::days_since_epoch);
108        ((days * 24 + hour) * 60 + minute) * 60_000 + second * 1000
109    }
110
111    #[test]
112    fn zone_offsets() {
113        const H: i32 = 3600;
114        for (zone, t, want) in [
115            // EU summer time, 2006: 2006-03-26 01:00Z to 2006-10-29 01:00Z.
116            ("Europe/Paris", at(2006, 3, 26, 0, 59, 59), H),
117            ("Europe/Paris", at(2006, 3, 26, 1, 0, 0), 2 * H),
118            ("Europe/Paris", at(2006, 10, 29, 0, 59, 59), 2 * H),
119            ("Europe/Paris", at(2006, 10, 29, 1, 0, 0), H),
120            // US since 2007: 2021-03-14 07:00Z and 2021-11-07 06:00Z.
121            ("America/New_York", at(2021, 3, 14, 6, 59, 59), -5 * H),
122            ("America/New_York", at(2021, 3, 14, 7, 0, 0), -4 * H),
123            ("America/New_York", at(2021, 11, 7, 5, 59, 59), -4 * H),
124            ("America/New_York", at(2021, 11, 7, 6, 0, 0), -5 * H),
125            // Before 2007 the US rules differed: 2006-04-02 07:00Z.
126            ("America/New_York", at(2006, 3, 20, 12, 0, 0), -5 * H),
127            ("America/New_York", at(2006, 4, 2, 7, 0, 0), -4 * H),
128            // Southern hemisphere: Sydney is on summer time in January.
129            ("Australia/Sydney", at(2006, 1, 2, 0, 0, 0), 11 * H),
130            ("Australia/Sydney", at(2006, 7, 2, 0, 0, 0), 10 * H),
131            ("Asia/Kolkata", at(2006, 1, 2, 0, 0, 0), 5 * H + 1800),
132            ("Asia/Kathmandu", at(2006, 1, 2, 0, 0, 0), 5 * H + 2700),
133            ("UTC", at(2006, 1, 2, 0, 0, 0), 0),
134            ("Etc/GMT+5", at(2006, 1, 2, 0, 0, 0), -5 * H),
135            // Case-insensitive, as IANA lookups are.
136            ("europe/paris", at(2006, 1, 2, 0, 0, 0), H),
137            // Paris mean time before 1911: an offset with seconds.
138            ("Europe/Paris", at(1900, 1, 1, 0, 0, 0), 561),
139        ] {
140            assert_eq!(HOST.zone_offset(zone, t), Some(want), "{zone} at {t}");
141        }
142        assert_eq!(HOST.zone_offset("Mars/Olympus_Mons", 0), None);
143        assert_eq!(HOST.zone_offset("", 0), None);
144        assert_eq!(HOST.zone_offset("EST 5", 0), None);
145        // Outside jiff's range.
146        assert_eq!(HOST.zone_offset("Europe/Paris", i64::MAX), None);
147    }
148
149    /// A POSIX TZ rule, which is not a name the database has, follows its
150    /// own changes of offset: the zone a native application's system has
151    /// when it has no IANA name (`TZ=EST5EDT,M3.2.0,M11.1.0`).
152    #[test]
153    fn posix_rules() {
154        const H: i32 = 3600;
155        let us = "EST5EDT,M3.2.0,M11.1.0";
156        let eu = "CET-1CEST,M3.5.0,M10.5.0/3";
157        for (zone, t, want) in [
158            // The second Sunday of March 2026, 02:00 local: 07:00Z.
159            (us, at(2026, 3, 8, 6, 59, 59), -5 * H),
160            (us, at(2026, 3, 8, 7, 0, 0), -4 * H),
161            // The first Sunday of November 2026, 02:00 local daylight: 06:00Z.
162            (us, at(2026, 11, 1, 5, 59, 59), -4 * H),
163            (us, at(2026, 11, 1, 6, 0, 0), -5 * H),
164            (us, at(2026, 1, 15, 17, 0, 0), -5 * H),
165            (us, at(2026, 7, 15, 16, 0, 0), -4 * H),
166            // The last Sunday of March 2026, 02:00 local: 01:00Z.
167            (eu, at(2026, 3, 29, 0, 59, 59), H),
168            (eu, at(2026, 3, 29, 1, 0, 0), 2 * H),
169            // No daylight saving time at all.
170            ("JST-9", at(2026, 7, 15, 0, 0, 0), 9 * H),
171        ] {
172            assert_eq!(HOST.zone_offset(zone, t), Some(want), "{zone} at {t}");
173        }
174    }
175}