1use std::fmt::Write as _;
24
25#[derive(Clone, Debug, PartialEq)]
27pub enum Piece {
28 Text(String),
29 Value(String),
31 Number,
33 Plural {
34 name: String,
35 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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
50pub enum Use {
51 Value,
53 Plural,
55 Date,
57 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
74pub 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
85pub 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
95pub 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
114fn 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 '}' 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
159fn 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
180fn 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
278fn 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
327pub 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
359pub 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#[derive(Clone, Debug, PartialEq)]
380pub enum Arg {
381 Text(String),
382 Number {
384 value: f64,
385 text: String,
386 },
387 Date {
389 year: i64,
390 month: i64,
391 day: i64,
392 },
393}
394
395pub fn weekday(year: i64, month: i64, day: i64) -> usize {
397 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
409pub 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
456pub 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
501pub 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
569pub 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
700pub const CALENDAR_DATE: &str = "CalendarDate";
702
703pub 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 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}