whatwg_datetime/components/yearless_date.rs
1use crate::tokens::TOKEN_HYPHEN;
2use crate::utils::is_valid_month;
3use crate::{collect_day_and_validate, collect_month_and_validate, parse_format};
4use whatwg_infra::collect_codepoints;
5
6/// A yearless date, consisting of a gregorian month and a day
7/// within the month, without an associated year.
8///
9/// # Examples
10///
11/// ```
12/// use whatwg_datetime::{parse_yearless_date, YearlessDate};
13///
14/// assert_eq!(parse_yearless_date("11-18"), YearlessDate::new_opt(11, 18));
15/// ```
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct YearlessDate {
18 pub(crate) month: u32,
19 pub(crate) day: u32,
20}
21
22impl YearlessDate {
23 #[inline]
24 pub(crate) fn new(month: u32, day: u32) -> Self {
25 Self { month, day }
26 }
27
28 /// Creates a new `YearlessDate` from a month and a day.
29 ///
30 /// This asserts that the month is in between 1 through 12,
31 /// inclusive, and that the day is in the valid range for
32 /// the month. Specifically:
33 /// - February must be between 1 and 29, inclusive
34 /// - April, June, September, and November must be between 1 and 30, inclusive
35 /// - All other months must be between 1 and 31, inclusive
36 ///
37 /// # Examples
38 /// ```
39 /// use whatwg_datetime::YearlessDate;
40 ///
41 /// assert!(YearlessDate::new_opt(11, 18).is_some());
42 /// assert!(YearlessDate::new_opt(2, 29).is_some());
43 /// assert!(YearlessDate::new_opt(2, 30).is_none()); // February never has 30 days
44 /// assert!(YearlessDate::new_opt(4, 31).is_none()); // April only has 30 days
45 /// assert!(YearlessDate::new_opt(13, 1).is_none()); // There are only 12 months
46 /// assert!(YearlessDate::new_opt(12, 32).is_none()); // December only has 31 days
47 /// ```
48 #[rustfmt::skip]
49 pub fn new_opt(month: u32, day: u32) -> Option<Self> {
50 if !is_valid_month(&month) {
51 return None;
52 }
53
54 match month {
55 2 => if day > 29 { return None; },
56 4 | 6 | 9 | 11 => if day > 30 { return None; },
57 _ => if day > 31 { return None; },
58 }
59
60 Some(Self::new(month, day))
61 }
62
63 /// A month component. This is a number from 1 to 12, inclusive.
64 ///
65 /// # Examples
66 /// ```
67 /// use whatwg_datetime::YearlessDate;
68 ///
69 /// let yearless_date = YearlessDate::new_opt(11, 18).unwrap();
70 /// assert_eq!(yearless_date.month(), 11);
71 /// ```
72 #[inline]
73 pub const fn month(&self) -> u32 {
74 self.month
75 }
76
77 /// A day component. This is a number from 1 to the max number
78 /// of days in the month, inclusive.
79 ///
80 /// # Examples
81 /// ```
82 /// use whatwg_datetime::YearlessDate;
83 ///
84 /// let yearless_date = YearlessDate::new_opt(11, 18).unwrap();
85 /// assert_eq!(yearless_date.day(), 18);
86 /// ```
87 #[inline]
88 pub const fn day(&self) -> u32 {
89 self.day
90 }
91}
92
93/// Parses a string consisting of a gregorian month and a day
94/// within the month, without an associated year
95///
96/// This follows the rules for [parsing a yearless date string][whatwg-html-parse]
97/// per [WHATWG HTML Standard § 2.3.5.3 Yearless dates][whatwg-html-yearless].
98///
99/// # Examples
100/// ```
101/// use whatwg_datetime::{parse_yearless_date, YearlessDate};
102///
103/// assert_eq!(parse_yearless_date("11-18"), YearlessDate::new_opt(11, 18));
104/// assert_eq!(parse_yearless_date("02-29"), YearlessDate::new_opt(2, 29));
105/// assert_eq!(parse_yearless_date("02-30"), None); // February never has 30 days
106/// assert_eq!(parse_yearless_date("04-31"), None); // April only has 30 days
107/// assert_eq!(parse_yearless_date("13-01"), None);
108/// ```
109///
110/// [whatwg-html-yearless]: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#yearless-dates
111/// [whatwg-html-parse]: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#parse-a-yearless-date-string
112#[inline]
113pub fn parse_yearless_date(s: &str) -> Option<YearlessDate> {
114 parse_format(s, parse_yearless_date_component)
115}
116
117/// Low-level function for parsing an individual yearless date component
118/// at a given position
119///
120/// This follows the rules for [parsing a yearless date component][whatwg-html-parse]
121/// per [WHATWG HTML Standard § 2.3.5.3 Yearless dates][whatwg-html-yearless].
122///
123/// > **Note**:
124/// > This function exposes a lower-level API than [`parse_yearless_date`].
125/// > More than likely, you will want to use [`parse_yearless_date`] instead.
126///
127/// # Examples
128/// ```
129/// use whatwg_datetime::{parse_yearless_date_component, YearlessDate};
130///
131/// let mut position = 0usize;
132/// let date = parse_yearless_date_component("11-18", &mut position);
133///
134/// assert_eq!(date, YearlessDate::new_opt(11, 18));
135/// ```
136///
137/// [whatwg-html-yearless]: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#yearless-dates
138/// [whatwg-html-parse]: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#parse-a-yearless-date-component
139pub fn parse_yearless_date_component(s: &str, position: &mut usize) -> Option<YearlessDate> {
140 let collected = collect_codepoints(s, position, |c| c == TOKEN_HYPHEN);
141 if !matches!(collected.len(), 0 | 2) {
142 return None;
143 }
144
145 let month = collect_month_and_validate(s, position)?;
146 if *position > s.len() || s.chars().nth(*position) != Some(TOKEN_HYPHEN) {
147 return None;
148 } else {
149 *position += 1;
150 }
151
152 let day = collect_day_and_validate(s, position, month)?;
153 Some(YearlessDate::new(month, day))
154}
155
156#[cfg(test)]
157mod tests {
158 #[rustfmt::skip]
159 use super::{
160 parse_yearless_date,
161 parse_yearless_date_component,
162 YearlessDate,
163 };
164
165 #[test]
166 fn test_parse_yearless_date() {
167 assert_eq!(
168 parse_yearless_date("11-18"),
169 Some(YearlessDate::new(11, 18))
170 );
171 }
172
173 #[test]
174 fn test_parse_yearless_date_fails_empty_string() {
175 assert_eq!(parse_yearless_date(""), None);
176 }
177
178 #[test]
179 fn test_parse_yearless_date_fails_separator() {
180 assert_eq!(parse_yearless_date("11/18"), None);
181 }
182
183 #[test]
184 fn test_parse_yearless_date_fails_month_upper_bound() {
185 assert_eq!(parse_yearless_date("13-01"), None);
186 }
187
188 #[test]
189 fn test_parse_yearless_date_fails_month_length() {
190 assert_eq!(parse_yearless_date("1-01"), None);
191 }
192
193 #[test]
194 fn test_parse_yearless_date_fails_day_lower_bound() {
195 assert_eq!(parse_yearless_date("01-00"), None);
196 }
197
198 #[test]
199 fn test_parse_yearless_date_fails_day_upper_bound() {
200 assert_eq!(parse_yearless_date("01-32"), None);
201 }
202
203 #[test]
204 fn test_parse_yearless_date_fails_day_length() {
205 assert_eq!(parse_yearless_date("01-9"), None);
206 }
207
208 #[test]
209 fn test_parse_yearless_date_component() {
210 let mut position = 0usize;
211 let parsed = parse_yearless_date_component("12-31", &mut position);
212
213 assert_eq!(parsed, Some(YearlessDate::new(12, 31)));
214 }
215
216 #[test]
217 fn test_parse_yearless_date_component_fails_empty_string() {
218 let mut position = 0usize;
219 let parsed = parse_yearless_date_component("", &mut position);
220
221 assert_eq!(parsed, None);
222 }
223
224 #[test]
225 fn test_parse_yearless_date_only_one_separator() {
226 let mut position = 0usize;
227 let parsed = parse_yearless_date_component("-", &mut position);
228
229 assert_eq!(parsed, None);
230 }
231}