Skip to main content

bern_protocol/
format.rs

1//! The message format of translated texts: `tr!` and enum labels.
2//!
3//! A text is plain words with placeholders, a subset of ICU MessageFormat:
4//!
5//! - `{name}`: the argument as text: text as it is, a number as Bern
6//!   writes numbers, a labelled enum by its label.
7//! - `{name, plural, one {# episode} other {# episodes}}`: the branch
8//!   for the number's plural category in the text's language, or an exact
9//!   `=N`; `#` in a branch is the number.
10//! - `{name, date, EEE, MMM d}`: a `CalendarDate` written with the
11//!   pattern: `y` year, `M` month, `MM` two-digit month, `MMM` and `MMMM`
12//!   the short and full month name, `d` and `dd` the day, `EEE` and `EEEE`
13//!   the short and full weekday; other characters as they are.
14//! - `{name, duration, m:ss}`: a number of seconds as `h`, `m` and `s`
15//!   fields, each doubled for two digits; the largest field written takes
16//!   what the larger ones would hold.
17//!
18//! `{{` and `}}` are literal braces; in a plural branch `}` always ends the
19//! branch, so a branch cannot hold a literal `}`. The names of weekdays and
20//! months come from [`date_names`], in every language Bern's runtimes
21//! speak.
22
23use std::fmt::Write as _;
24
25/// One part of a parsed text.
26#[derive(Clone, Debug, PartialEq)]
27pub enum Piece {
28    Text(String),
29    /// `{name}`.
30    Value(String),
31    /// `#` in a plural branch: the number.
32    Number,
33    Plural {
34        name: String,
35        /// Category (`one`, `other`, …) or exact value (`=0`), and text.
36        branches: Vec<(String, Vec<Piece>)>,
37    },
38    Date {
39        name: String,
40        pattern: String,
41    },
42    Duration {
43        name: String,
44        pattern: String,
45    },
46}
47
48/// How a text uses one of its arguments.
49#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
50pub enum Use {
51    /// As text.
52    Value,
53    /// Its plural category: a number.
54    Plural,
55    /// As a date: a `CalendarDate`.
56    Date,
57    /// As seconds: a number.
58    Duration,
59}
60
61impl Use {
62    pub fn name(self) -> &'static str {
63        match self {
64            Use::Value => "text",
65            Use::Plural => "plural",
66            Use::Date => "date",
67            Use::Duration => "duration",
68        }
69    }
70}
71
72const CATEGORIES: [&str; 6] = ["zero", "one", "two", "few", "many", "other"];
73
74/// The plural categories a language's numbers fall into: Chinese, Japanese
75/// and Korean use one form for every number; the languages Bern speaks
76/// otherwise tell one apart from the rest.
77pub fn plural_categories(language: &str) -> &'static [&'static str] {
78    let base = language.split(['-', '_']).next().unwrap_or(language);
79    match base {
80        "zh" | "ja" | "ko" | "vi" | "th" | "id" | "ms" => &["other"],
81        _ => &["one", "other"],
82    }
83}
84
85/// The plural category of `number` in `language`.
86pub fn plural_category(language: &str, number: f64) -> &'static str {
87    let categories = plural_categories(language);
88    if categories.contains(&"one") && number == 1.0 {
89        "one"
90    } else {
91        "other"
92    }
93}
94
95/// Parse a text into its pieces.
96pub fn parse(text: &str) -> Result<Vec<Piece>, String> {
97    let chars: Vec<char> = text.chars().collect();
98    let mut at = 0;
99    let pieces = message(&chars, &mut at, false, text)?;
100    if at < chars.len() {
101        return Err(format!("unmatched `}}` in \"{text}\""));
102    }
103    Ok(pieces)
104}
105
106fn push_text(pieces: &mut Vec<Piece>, c: char) {
107    if let Some(Piece::Text(text)) = pieces.last_mut() {
108        text.push(c);
109    } else {
110        pieces.push(Piece::Text(c.to_string()));
111    }
112}
113
114/// Pieces up to the end, or up to the `}` closing a plural branch.
115fn message(chars: &[char], at: &mut usize, branch: bool, text: &str) -> Result<Vec<Piece>, String> {
116    let mut pieces = Vec::new();
117    while *at < chars.len() {
118        let c = chars[*at];
119        let next = chars.get(*at + 1).copied();
120        match c {
121            '{' if next == Some('{') => {
122                push_text(&mut pieces, '{');
123                *at += 2;
124            }
125            // In a plural branch a `}` always ends the branch: `}}` there is
126            // the branch and then the placeholder closing.
127            '}' if branch => return Ok(pieces),
128            '}' if next == Some('}') => {
129                push_text(&mut pieces, '}');
130                *at += 2;
131            }
132            '}' => return Err(format!("unmatched `}}` in \"{text}\"")),
133            '#' if branch => {
134                pieces.push(Piece::Number);
135                *at += 1;
136            }
137            '{' => {
138                *at += 1;
139                pieces.push(placeholder(chars, at, text)?);
140            }
141            other => {
142                push_text(&mut pieces, other);
143                *at += 1;
144            }
145        }
146    }
147    if branch {
148        return Err(format!("unclosed plural branch in \"{text}\""));
149    }
150    Ok(pieces)
151}
152
153fn skip_space(chars: &[char], at: &mut usize) {
154    while *at < chars.len() && chars[*at].is_whitespace() {
155        *at += 1;
156    }
157}
158
159/// Characters up to one of `stops`, trimmed.
160fn word(chars: &[char], at: &mut usize, stops: &[char]) -> String {
161    let start = *at;
162    while *at < chars.len() && !stops.contains(&chars[*at]) {
163        *at += 1;
164    }
165    chars[start..*at]
166        .iter()
167        .collect::<String>()
168        .trim()
169        .to_string()
170}
171
172fn valid_name(name: &str) -> bool {
173    !name.is_empty()
174        && name
175            .chars()
176            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
177        && !name.starts_with(|c: char| c.is_ascii_digit())
178}
179
180/// A placeholder after its `{`, through its `}`.
181fn placeholder(chars: &[char], at: &mut usize, text: &str) -> Result<Piece, String> {
182    let name = word(chars, at, &[',', '}', '{']);
183    if !valid_name(&name) {
184        return Err(format!("invalid placeholder `{{{name}}}` in \"{text}\""));
185    }
186    match chars.get(*at) {
187        Some('}') => {
188            *at += 1;
189            Ok(Piece::Value(name))
190        }
191        Some(',') => {
192            *at += 1;
193            let kind = word(chars, at, &[',', '}', '{']);
194            if chars.get(*at) != Some(&',') {
195                return Err(format!(
196                    "`{{{name}, {kind}}}` needs its form after a comma in \"{text}\""
197                ));
198            }
199            *at += 1;
200            match kind.as_str() {
201                "plural" => {
202                    let mut branches = Vec::new();
203                    loop {
204                        skip_space(chars, at);
205                        match chars.get(*at) {
206                            Some('}') => {
207                                *at += 1;
208                                break;
209                            }
210                            None => {
211                                return Err(format!(
212                                    "unclosed `{{{name}, plural, …` in \"{text}\""
213                                ));
214                            }
215                            _ => {}
216                        }
217                        let selector = word(chars, at, &['{', '}']);
218                        let exact = selector
219                            .strip_prefix('=')
220                            .is_some_and(|value| value.parse::<i64>().is_ok());
221                        if !exact && !CATEGORIES.contains(&selector.as_str()) {
222                            return Err(format!(
223                                "`{selector}` is not a plural category (zero, one, two, few, many, other, or =N) in \"{text}\""
224                            ));
225                        }
226                        if chars.get(*at) != Some(&'{') {
227                            return Err(format!(
228                                "the `{selector}` branch needs its text in braces in \"{text}\""
229                            ));
230                        }
231                        *at += 1;
232                        let body = message(chars, at, true, text)?;
233                        *at += 1;
234                        if branches.iter().any(|(existing, _)| *existing == selector) {
235                            return Err(format!(
236                                "the `{selector}` branch is written twice in \"{text}\""
237                            ));
238                        }
239                        branches.push((selector, body));
240                    }
241                    if !branches.iter().any(|(selector, _)| selector == "other") {
242                        return Err(format!(
243                            "`{{{name}, plural, …}}` needs an `other` branch in \"{text}\""
244                        ));
245                    }
246                    Ok(Piece::Plural { name, branches })
247                }
248                "date" | "duration" => {
249                    let pattern = word(chars, at, &['}', '{']);
250                    if chars.get(*at) != Some(&'}') {
251                        return Err(format!("unclosed `{{{name}, {kind}, …` in \"{text}\""));
252                    }
253                    *at += 1;
254                    if pattern.is_empty() {
255                        return Err(format!(
256                            "`{{{name}, {kind}, …}}` needs a pattern in \"{text}\""
257                        ));
258                    }
259                    if kind == "date" {
260                        check_date(&pattern)
261                            .map_err(|problem| format!("{problem} in \"{text}\""))?;
262                        Ok(Piece::Date { name, pattern })
263                    } else {
264                        check_duration(&pattern)
265                            .map_err(|problem| format!("{problem} in \"{text}\""))?;
266                        Ok(Piece::Duration { name, pattern })
267                    }
268                }
269                other => Err(format!(
270                    "`{other}` is not a form of placeholder (plural, date or duration) in \"{text}\""
271                )),
272            }
273        }
274        _ => Err(format!("unclosed `{{` in \"{text}\"")),
275    }
276}
277
278/// Runs of one pattern letter, and the characters between them.
279fn runs(pattern: &str) -> Vec<(char, usize)> {
280    let mut out: Vec<(char, usize)> = Vec::new();
281    for c in pattern.chars() {
282        match out.last_mut() {
283            Some((last, count)) if *last == c && c.is_ascii_alphabetic() => *count += 1,
284            _ => out.push((c, 1)),
285        }
286    }
287    out
288}
289
290fn check_date(pattern: &str) -> Result<(), String> {
291    for (c, count) in runs(pattern) {
292        let fine = match c {
293            'y' => count == 1 || count == 2 || count == 4,
294            'M' => count <= 4,
295            'd' => count <= 2,
296            'E' => count == 3 || count == 4,
297            c if c.is_ascii_alphabetic() => false,
298            _ => true,
299        };
300        if !fine {
301            return Err(format!(
302                "`{}` is not a date field (y, M, MM, MMM, MMMM, d, dd, EEE, EEEE)",
303                c.to_string().repeat(count)
304            ));
305        }
306    }
307    Ok(())
308}
309
310fn check_duration(pattern: &str) -> Result<(), String> {
311    for (c, count) in runs(pattern) {
312        let fine = match c {
313            'h' | 'm' | 's' => count <= 2,
314            c if c.is_ascii_alphabetic() => false,
315            _ => true,
316        };
317        if !fine {
318            return Err(format!(
319                "`{}` is not a duration field (h, hh, m, mm, s, ss)",
320                c.to_string().repeat(count)
321            ));
322        }
323    }
324    Ok(())
325}
326
327/// The arguments a text uses, in order of first use, with how each is
328/// used. An argument used two ways is listed with each.
329pub fn uses(pieces: &[Piece]) -> Vec<(String, Use)> {
330    fn visit(pieces: &[Piece], out: &mut Vec<(String, Use)>) {
331        for piece in pieces {
332            let (name, how) = match piece {
333                Piece::Value(name) => (name, Use::Value),
334                Piece::Plural { name, branches } => {
335                    let entry = (name.clone(), Use::Plural);
336                    if !out.contains(&entry) {
337                        out.push(entry);
338                    }
339                    for (_, body) in branches {
340                        visit(body, out);
341                    }
342                    continue;
343                }
344                Piece::Date { name, .. } => (name, Use::Date),
345                Piece::Duration { name, .. } => (name, Use::Duration),
346                Piece::Text(_) | Piece::Number => continue,
347            };
348            let entry = (name.clone(), how);
349            if !out.contains(&entry) {
350                out.push(entry);
351            }
352        }
353    }
354    let mut out = Vec::new();
355    visit(pieces, &mut out);
356    out
357}
358
359/// Plural branches a text in `language` lacks: each category the
360/// language uses needs one, so every number has its form.
361pub fn missing_plurals(pieces: &[Piece], language: &str) -> Vec<String> {
362    let mut missing = Vec::new();
363    for piece in pieces {
364        if let Piece::Plural { name, branches } = piece {
365            for category in plural_categories(language) {
366                if !branches.iter().any(|(selector, _)| selector == category) {
367                    missing.push(format!("`{category}` of `{name}`"));
368                }
369            }
370            for (_, body) in branches {
371                missing.extend(missing_plurals(body, language));
372            }
373        }
374    }
375    missing
376}
377
378/// A value a text shows.
379#[derive(Clone, Debug, PartialEq)]
380pub enum Arg {
381    Text(String),
382    /// A number, and the way Bern writes it.
383    Number {
384        value: f64,
385        text: String,
386    },
387    /// A calendar date: year, month 1–12, day 1–31.
388    Date {
389        year: i64,
390        month: i64,
391        day: i64,
392    },
393}
394
395/// The day of the week of a date, 0 for Monday.
396pub fn weekday(year: i64, month: i64, day: i64) -> usize {
397    // Sakamoto's method, from Sunday 0.
398    const OFFSETS: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
399    let month = month.clamp(1, 12);
400    let year = if month < 3 { year - 1 } else { year };
401    let sunday_first = (year + year.div_euclid(4) - year.div_euclid(100)
402        + year.div_euclid(400)
403        + OFFSETS[(month - 1) as usize]
404        + day)
405        .rem_euclid(7);
406    ((sunday_first + 6) % 7) as usize
407}
408
409/// Write a date with a checked pattern; `name(key)` is a framework text.
410pub fn format_date(
411    pattern: &str,
412    (year, month, day): (i64, i64, i64),
413    name: &dyn Fn(&str) -> String,
414) -> String {
415    let mut out = String::new();
416    for (c, count) in runs(pattern) {
417        match (c, count) {
418            ('y', 2) => {
419                let _ = write!(out, "{:02}", year.rem_euclid(100));
420            }
421            ('y', _) => {
422                let _ = write!(out, "{year}");
423            }
424            ('M', 1) => {
425                let _ = write!(out, "{month}");
426            }
427            ('M', 2) => {
428                let _ = write!(out, "{month:02}");
429            }
430            ('M', 3) => out.push_str(&name(&format!("bern.date.month.{month}"))),
431            ('M', _) => out.push_str(&name(&format!("bern.date.month_long.{month}"))),
432            ('d', 1) => {
433                let _ = write!(out, "{day}");
434            }
435            ('d', _) => {
436                let _ = write!(out, "{day:02}");
437            }
438            ('E', 3) => out.push_str(&name(&format!(
439                "bern.date.weekday.{}",
440                weekday(year, month, day) + 1
441            ))),
442            ('E', _) => out.push_str(&name(&format!(
443                "bern.date.weekday_long.{}",
444                weekday(year, month, day) + 1
445            ))),
446            (c, count) => {
447                for _ in 0..count {
448                    out.push(c);
449                }
450            }
451        }
452    }
453    out
454}
455
456/// Write a number of seconds with a checked pattern.
457pub fn format_duration(pattern: &str, seconds: f64) -> String {
458    let total = if seconds.is_finite() {
459        seconds.max(0.0).floor() as i64
460    } else {
461        0
462    };
463    let has_hours = pattern.contains('h');
464    let has_minutes = pattern.contains('m');
465    let hours = total / 3600;
466    let minutes = if has_hours {
467        total % 3600 / 60
468    } else {
469        total / 60
470    };
471    let seconds = if has_minutes || has_hours {
472        total % 60
473    } else {
474        total
475    };
476    let mut out = String::new();
477    for (c, count) in runs(pattern) {
478        let value = match c {
479            'h' => Some(hours),
480            'm' => Some(minutes),
481            's' => Some(seconds),
482            _ => None,
483        };
484        match value {
485            Some(value) if count == 2 => {
486                let _ = write!(out, "{value:02}");
487            }
488            Some(value) => {
489                let _ = write!(out, "{value}");
490            }
491            None => {
492                for _ in 0..count {
493                    out.push(c);
494                }
495            }
496        }
497    }
498    out
499}
500
501/// Write a text in `language` with its arguments. `name(key)` gives the
502/// framework texts for month and weekday names.
503pub fn format(
504    pieces: &[Piece],
505    language: &str,
506    arg: &dyn Fn(&str) -> Option<Arg>,
507    name: &dyn Fn(&str) -> String,
508) -> String {
509    fn number(arg: Option<Arg>) -> (f64, String) {
510        match arg {
511            Some(Arg::Number { value, text }) => (value, text),
512            Some(Arg::Text(text)) => (text.parse().unwrap_or(0.0), text),
513            _ => (0.0, String::new()),
514        }
515    }
516    fn write(
517        pieces: &[Piece],
518        language: &str,
519        arg: &dyn Fn(&str) -> Option<Arg>,
520        name: &dyn Fn(&str) -> String,
521        hash: Option<&str>,
522        out: &mut String,
523    ) {
524        for piece in pieces {
525            match piece {
526                Piece::Text(text) => out.push_str(text),
527                Piece::Number => out.push_str(hash.unwrap_or_default()),
528                Piece::Value(key) => match arg(key) {
529                    Some(Arg::Text(text)) => out.push_str(&text),
530                    Some(Arg::Number { text, .. }) => out.push_str(&text),
531                    Some(Arg::Date { year, month, day }) => {
532                        let _ = write!(out, "{year:04}-{month:02}-{day:02}");
533                    }
534                    None => {}
535                },
536                Piece::Plural {
537                    name: key,
538                    branches,
539                } => {
540                    let (value, text) = number(arg(key));
541                    let exact = format!("={value}");
542                    let category = plural_category(language, value);
543                    let chosen = branches
544                        .iter()
545                        .find(|(selector, _)| *selector == exact)
546                        .or_else(|| branches.iter().find(|(selector, _)| selector == category))
547                        .or_else(|| branches.iter().find(|(selector, _)| selector == "other"));
548                    if let Some((_, body)) = chosen {
549                        write(body, language, arg, name, Some(&text), out);
550                    }
551                }
552                Piece::Date { name: key, pattern } => {
553                    if let Some(Arg::Date { year, month, day }) = arg(key) {
554                        out.push_str(&format_date(pattern, (year, month, day), name));
555                    }
556                }
557                Piece::Duration { name: key, pattern } => {
558                    let (value, _) = number(arg(key));
559                    out.push_str(&format_duration(pattern, value));
560                }
561            }
562        }
563    }
564    let mut out = String::new();
565    write(pieces, language, arg, name, None, &mut out);
566    out
567}
568
569/// The names of weekdays (from Monday) and months, short and full, in the
570/// languages Bern's runtimes speak, as framework texts:
571/// `bern.date.weekday.1` … `.7`, `bern.date.weekday_long.N`,
572/// `bern.date.month.1` … `.12`, `bern.date.month_long.N`.
573pub fn date_names() -> Vec<(String, Vec<(&'static str, String)>)> {
574    const WEEKDAYS: [(&str, [&str; 7], [&str; 7]); 4] = [
575        (
576            "en",
577            ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
578            [
579                "Monday",
580                "Tuesday",
581                "Wednesday",
582                "Thursday",
583                "Friday",
584                "Saturday",
585                "Sunday",
586            ],
587        ),
588        (
589            "zh-Hans",
590            ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
591            [
592                "星期一",
593                "星期二",
594                "星期三",
595                "星期四",
596                "星期五",
597                "星期六",
598                "星期日",
599            ],
600        ),
601        (
602            "zh-Hant",
603            ["週一", "週二", "週三", "週四", "週五", "週六", "週日"],
604            [
605                "星期一",
606                "星期二",
607                "星期三",
608                "星期四",
609                "星期五",
610                "星期六",
611                "星期日",
612            ],
613        ),
614        (
615            "ja",
616            ["月", "火", "水", "木", "金", "土", "日"],
617            [
618                "月曜日",
619                "火曜日",
620                "水曜日",
621                "木曜日",
622                "金曜日",
623                "土曜日",
624                "日曜日",
625            ],
626        ),
627    ];
628    const MONTHS_EN: [&str; 12] = [
629        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
630    ];
631    const MONTHS_EN_LONG: [&str; 12] = [
632        "January",
633        "February",
634        "March",
635        "April",
636        "May",
637        "June",
638        "July",
639        "August",
640        "September",
641        "October",
642        "November",
643        "December",
644    ];
645    const MONTHS_ZH_LONG: [&str; 12] = [
646        "一月",
647        "二月",
648        "三月",
649        "四月",
650        "五月",
651        "六月",
652        "七月",
653        "八月",
654        "九月",
655        "十月",
656        "十一月",
657        "十二月",
658    ];
659    let mut names = Vec::new();
660    for day in 0..7 {
661        names.push((
662            format!("bern.date.weekday.{}", day + 1),
663            WEEKDAYS
664                .iter()
665                .map(|(language, short, _)| (*language, short[day].to_string()))
666                .collect(),
667        ));
668        names.push((
669            format!("bern.date.weekday_long.{}", day + 1),
670            WEEKDAYS
671                .iter()
672                .map(|(language, _, long)| (*language, long[day].to_string()))
673                .collect(),
674        ));
675    }
676    for month in 0..12 {
677        let numbered = format!("{}月", month + 1);
678        names.push((
679            format!("bern.date.month.{}", month + 1),
680            vec![
681                ("en", MONTHS_EN[month].to_string()),
682                ("zh-Hans", numbered.clone()),
683                ("zh-Hant", numbered.clone()),
684                ("ja", numbered.clone()),
685            ],
686        ));
687        names.push((
688            format!("bern.date.month_long.{}", month + 1),
689            vec![
690                ("en", MONTHS_EN_LONG[month].to_string()),
691                ("zh-Hans", MONTHS_ZH_LONG[month].to_string()),
692                ("zh-Hant", MONTHS_ZH_LONG[month].to_string()),
693                ("ja", numbered),
694            ],
695        ));
696    }
697    names
698}
699
700/// The name of the framework type of calendar dates.
701pub const CALENDAR_DATE: &str = "CalendarDate";
702
703/// The record of a calendar date: year, month 1–12, day 1–31.
704pub fn calendar_date_def() -> crate::RecordDef {
705    let field = |name: &str| crate::FieldDef {
706        name: name.to_string(),
707        ty: crate::Type::Int,
708        doc: String::new(),
709        content: false,
710    };
711    crate::RecordDef {
712        name: CALENDAR_DATE.to_string(),
713        fields: vec![field("year"), field("month"), field("day")],
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720
721    fn names(key: &str) -> String {
722        date_names()
723            .into_iter()
724            .find(|(name, _)| name == key)
725            .and_then(|(_, translations)| {
726                translations
727                    .into_iter()
728                    .find(|(language, _)| *language == "en")
729                    .map(|(_, text)| text)
730            })
731            .unwrap_or_default()
732    }
733
734    fn show(text: &str, language: &str, args: &[(&str, Arg)]) -> String {
735        let pieces = parse(text).unwrap();
736        format(
737            &pieces,
738            language,
739            &|name| {
740                args.iter()
741                    .find(|(candidate, _)| *candidate == name)
742                    .map(|(_, arg)| arg.clone())
743            },
744            &names,
745        )
746    }
747
748    fn n(value: f64) -> Arg {
749        Arg::Number {
750            value,
751            text: if value.fract() == 0.0 {
752                format!("{}", value as i64)
753            } else {
754                value.to_string()
755            },
756        }
757    }
758
759    #[test]
760    fn plain_placeholders_and_braces() {
761        assert_eq!(
762            show("已播放 {count} 集 {{x}}", "zh-Hans", &[("count", n(3.0))]),
763            "已播放 3 集 {x}"
764        );
765        assert_eq!(
766            uses(&parse("{a} {b} {a}").unwrap()),
767            vec![("a".into(), Use::Value), ("b".into(), Use::Value)]
768        );
769        assert!(parse("{Count}").is_err());
770        assert!(parse("{count").is_err());
771        assert!(parse("a } b").is_err());
772    }
773
774    #[test]
775    fn plurals_follow_the_language() {
776        let text = "{n, plural, =0 {no episodes} one {# episode} other {# episodes}}";
777        assert_eq!(show(text, "en", &[("n", n(1.0))]), "1 episode");
778        assert_eq!(show(text, "en", &[("n", n(12.0))]), "12 episodes");
779        assert_eq!(show(text, "en", &[("n", n(0.0))]), "no episodes");
780        assert_eq!(
781            show("共 {n, plural, other {# 话}}", "zh-Hans", &[("n", n(1.0))]),
782            "共 1 话"
783        );
784        let pieces = parse("{n, plural, other {# episodes}}").unwrap();
785        assert_eq!(missing_plurals(&pieces, "en"), vec!["`one` of `n`"]);
786        assert!(missing_plurals(&pieces, "zh-Hans").is_empty());
787        assert!(parse("{n, plural, one {#}}").is_err(), "other is required");
788        assert!(parse("{n, plural, single {#} other {#}}").is_err());
789    }
790
791    #[test]
792    fn dates_and_durations() {
793        // 2026-10-12 is a Monday.
794        assert_eq!(weekday(2026, 10, 12), 0);
795        assert_eq!(weekday(2000, 1, 1), 5);
796        let today = Arg::Date {
797            year: 2026,
798            month: 10,
799            day: 12,
800        };
801        assert_eq!(
802            show("{d, date, EEE, MMM d}", "en", &[("d", today.clone())]),
803            "Mon, Oct 12"
804        );
805        assert_eq!(
806            show("{d, date, EEEE yyyy-MM-dd}", "en", &[("d", today)]),
807            "Monday 2026-10-12"
808        );
809        assert_eq!(format_duration("m:ss", 555.0), "9:15");
810        assert_eq!(format_duration("h:mm:ss", 3725.0), "1:02:05");
811        assert_eq!(format_duration("hh:mm", 18.0 * 3600.0 + 1800.0), "18:30");
812        assert!(parse("{d, date, YYYY}").is_err());
813        assert!(parse("{t, duration, mmm}").is_err());
814        assert!(parse("{t, size, m}").is_err());
815    }
816}