whatwg_datetime/components/
timezone_offset.rs

1use crate::parse_format;
2use crate::tokens::{TOKEN_COLON, TOKEN_MINUS, TOKEN_PLUS, TOKEN_Z};
3use crate::utils::collect_ascii_digits;
4
5/// A time-zone offset, with a signed number of hours and minutes.
6///
7/// # Examples
8/// ```
9/// use whatwg_datetime::{parse_timezone_offset, TimeZoneOffset};
10///
11/// assert_eq!(parse_timezone_offset("-07:00"), TimeZoneOffset::new_opt(-7, 0));
12/// ```
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct TimeZoneOffset {
15	pub(crate) hour: i32,
16	pub(crate) minute: i32,
17}
18
19impl TimeZoneOffset {
20	#[inline]
21	pub(crate) fn new(hour: i32, minute: i32) -> Self {
22		Self { hour, minute }
23	}
24
25	/// Creates a new `TimeZoneOffset` from a signed number of hours and minutes.
26	///
27	/// This asserts that:
28	///  - hours are in between -23 and 23, inclusive,
29	///  - minutes are in between 0 and 59, inclusive
30	///
31	/// # Examples
32	/// ```
33	/// use whatwg_datetime::TimeZoneOffset;
34	///
35	/// assert!(TimeZoneOffset::new_opt(-7, 0).is_some());
36	/// assert!(TimeZoneOffset::new_opt(23, 59).is_some());
37	/// assert!(TimeZoneOffset::new_opt(24, 0).is_none()); // Hours must be between [-23, 23]
38	/// assert!(TimeZoneOffset::new_opt(1, 60).is_none()); // Minutes must be between [0, 59]
39	/// ```
40	pub fn new_opt(hours: i32, minutes: i32) -> Option<Self> {
41		if !(-23..=23).contains(&hours) {
42			return None;
43		}
44
45		if !(0..=59).contains(&minutes) {
46			return None;
47		}
48
49		Some(Self::new(hours, minutes))
50	}
51
52	/// A minute component. This is a number from 0 to 59, inclusive.
53	///
54	/// # Examples
55	/// ```
56	/// use whatwg_datetime::TimeZoneOffset;
57	///
58	/// let tz_offset = TimeZoneOffset::new_opt(-7, 0).unwrap();
59	/// assert_eq!(tz_offset.minute(), 0);
60	/// ```
61	#[inline]
62	pub const fn minute(&self) -> i32 {
63		self.minute
64	}
65
66	/// A hour component. This is a number from -23 to 23, inclusive.
67	///
68	/// # Examples
69	/// ```
70	/// use whatwg_datetime::TimeZoneOffset;
71	///
72	/// let tz_offset = TimeZoneOffset::new_opt(-7, 0).unwrap();
73	/// assert_eq!(tz_offset.hour(), -7);
74	/// ```
75	#[inline]
76	pub const fn hour(&self) -> i32 {
77		self.hour
78	}
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82enum TimeZoneSign {
83	Positive,
84	Negative,
85}
86
87impl TryFrom<char> for TimeZoneSign {
88	type Error = ();
89	fn try_from(value: char) -> Result<Self, Self::Error> {
90		match value {
91			TOKEN_PLUS => Ok(TimeZoneSign::Positive),
92			TOKEN_MINUS => Ok(TimeZoneSign::Negative),
93			_ => Err(()),
94		}
95	}
96}
97
98/// Parse a time-zone offset, with a signed number of hours and minutes
99///
100/// This follows the rules for [parsing a time-zone offset string][whatwg-html-parse]
101/// per [WHATWG HTML Standard § 2.3.5.6 Time zones][whatwg-html-tzoffset].
102///
103/// # Examples
104/// ```
105/// use whatwg_datetime::{parse_timezone_offset, TimeZoneOffset};
106///
107/// // Parse a local datetime string with a date,
108/// // a T delimiter, anda  time with fractional seconds
109/// assert_eq!(
110///     parse_timezone_offset("-07:00"),
111///     TimeZoneOffset::new_opt(-7, 0)
112/// );
113/// ```
114///
115/// [whatwg-html-tzoffset]: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#time-zones
116/// [whatwg-html-parse]: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#parse-a-time-zone-offset-string
117#[inline]
118pub fn parse_timezone_offset(s: &str) -> Option<TimeZoneOffset> {
119	parse_format(s, parse_timezone_offset_component)
120}
121
122/// Low-level function for parsing an individual timezone offset component
123/// at a given position
124///
125/// This follows the rules for [parsing a time-zone offset component][whatwg-html-parse]
126/// per [WHATWG HTML Standard § 2.3.5.6 Time zones][whatwg-html-tzoffset].
127///
128/// > **Note**:
129/// > This function exposes a lower-level API than [`parse_timezone_offset`].
130/// > More than likely, you will want to use [`parse_timezone_offset`] instead.
131///
132/// # Examples
133/// ```
134/// use whatwg_datetime::{parse_timezone_offset_component, TimeZoneOffset};
135///
136/// let mut position = 0usize;
137/// let date = parse_timezone_offset_component("-07:00", &mut position);
138///
139/// assert_eq!(date, TimeZoneOffset::new_opt(-7, 0));
140/// ```
141///
142/// [whatwg-html-tzoffset]: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#time-zones
143/// [whatwg-html-parse]: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#parse-a-time-zone-offset-component
144pub fn parse_timezone_offset_component(s: &str, position: &mut usize) -> Option<TimeZoneOffset> {
145	let char_at = s.chars().nth(*position);
146
147	let mut minutes = 0i32;
148	let mut hours = 0i32;
149
150	match char_at {
151		Some(TOKEN_Z) => {
152			*position += 1;
153		}
154		Some(TOKEN_PLUS) | Some(TOKEN_MINUS) => {
155			let sign = TimeZoneSign::try_from(char_at.unwrap()).ok().unwrap();
156			*position += 1;
157
158			let collected = collect_ascii_digits(s, position);
159			let collected_len = collected.len();
160			if collected_len == 2 {
161				hours = collected.parse::<i32>().unwrap();
162				if *position > s.len()
163					|| s.chars().nth(*position) != Some(TOKEN_COLON)
164				{
165					return None;
166				} else {
167					*position += 1;
168				}
169
170				let parsed_mins = collect_ascii_digits(s, position);
171				if parsed_mins.len() != 2 {
172					return None;
173				}
174
175				minutes = parsed_mins.parse::<i32>().unwrap();
176			} else if collected_len == 4 {
177				let (hour_str, min_str) = collected.split_at(2);
178				hours = hour_str.parse::<i32>().unwrap();
179				minutes = min_str.parse::<i32>().unwrap();
180			} else {
181				return None;
182			}
183
184			if !(0..=23).contains(&hours) {
185				return None;
186			}
187
188			if !(0..=59).contains(&minutes) {
189				return None;
190			}
191
192			if sign == TimeZoneSign::Negative {
193				hours *= -1;
194				minutes *= -1;
195			}
196		}
197		_ => (),
198	}
199
200	Some(TimeZoneOffset::new(hours, minutes))
201}
202
203#[cfg(test)]
204mod tests {
205	#[rustfmt::skip]
206	use super::{
207		parse_timezone_offset,
208		parse_timezone_offset_component,
209		TimeZoneOffset,
210		TimeZoneSign,
211	};
212
213	#[test]
214	pub fn test_parse_timezone_sign_tryfrom_char_positive() {
215		let parsed = TimeZoneSign::try_from('+');
216		assert_eq!(parsed, Ok(TimeZoneSign::Positive));
217	}
218
219	#[test]
220	pub fn test_parse_timezone_sign_tryfrom_char_negative() {
221		let parsed = TimeZoneSign::try_from('-');
222		assert_eq!(parsed, Ok(TimeZoneSign::Negative));
223	}
224
225	#[test]
226	pub fn test_parse_timezone_sign_tryfrom_char_fails() {
227		let parsed = TimeZoneSign::try_from('a');
228		assert_eq!(parsed, Err(()));
229	}
230
231	#[test]
232	pub fn test_parse_timezone_offset() {
233		let parsed = parse_timezone_offset("+01:00");
234		assert_eq!(parsed, Some(TimeZoneOffset::new(1, 0)));
235	}
236
237	#[test]
238	pub fn test_parse_timezone_offset_z() {
239		let parsed = parse_timezone_offset("Z");
240		assert_eq!(parsed, Some(TimeZoneOffset::new(0, 0)));
241	}
242
243	#[test]
244	pub fn test_parse_timezone_offset_plus_1_hour_colon() {
245		let mut position = 0usize;
246		let parsed = parse_timezone_offset_component("+01:00", &mut position);
247
248		assert_eq!(parsed, Some(TimeZoneOffset::new(1, 0)));
249	}
250
251	#[test]
252	pub fn test_parse_timezone_offset_neg_1_hour_colon() {
253		let mut position = 0usize;
254		let parsed = parse_timezone_offset_component("-01:00", &mut position);
255
256		assert_eq!(parsed, Some(TimeZoneOffset::new(-1, 0)));
257	}
258
259	#[test]
260	pub fn test_parse_timezone_offset_plus_1_hour_no_delim() {
261		let mut position = 0usize;
262		let parsed = parse_timezone_offset_component("+0100", &mut position);
263
264		assert_eq!(parsed, Some(TimeZoneOffset::new(1, 0)));
265	}
266
267	#[test]
268	fn parse_timezone_offset_component_neg_1_hour_no_delim() {
269		let mut position = 0usize;
270		let parsed = parse_timezone_offset_component("-0100", &mut position);
271
272		assert_eq!(parsed, Some(TimeZoneOffset::new(-1, 0)));
273	}
274
275	#[test]
276	fn parse_timezone_offset_fails_not_colon() {
277		let mut position = 0usize;
278		let parsed = parse_timezone_offset_component("-01/", &mut position);
279
280		assert_eq!(parsed, None);
281	}
282
283	#[test]
284	fn parse_timezone_offset_fails_invalid_min_length() {
285		let mut position = 0usize;
286		let parsed = parse_timezone_offset_component("-010", &mut position);
287
288		assert_eq!(parsed, None);
289	}
290
291	#[test]
292	fn parse_timezone_offset_fails_colon_invalid_length_empty() {
293		let mut position = 0usize;
294		let parsed = parse_timezone_offset_component("-01:", &mut position);
295
296		assert_eq!(parsed, None);
297	}
298
299	#[test]
300	fn parse_timezone_offset_fails_colon_invalid_length() {
301		let mut position = 0usize;
302		let parsed = parse_timezone_offset_component("-01:0", &mut position);
303
304		assert_eq!(parsed, None);
305	}
306
307	#[test]
308	fn parse_timezone_offset_fails_invalid_length() {
309		let mut position = 0usize;
310		let parsed = parse_timezone_offset_component("-01000", &mut position);
311
312		assert_eq!(parsed, None);
313	}
314
315	#[test]
316	fn parse_timezone_offset_fails_invalid_hour_upper_bound() {
317		let mut position = 0usize;
318		let parsed = parse_timezone_offset_component("+24:00", &mut position);
319
320		assert_eq!(parsed, None);
321	}
322
323	#[test]
324	fn parse_timezone_offset_fails_invalid_minute_upper_bound() {
325		let mut position = 0usize;
326		let parsed = parse_timezone_offset_component("-00:67", &mut position);
327
328		assert_eq!(parsed, None);
329	}
330}