1#![forbid(unsafe_code)]
22#![doc(html_root_url = "https://docs.rs/parse-dms/0.1.0")]
23#![allow(clippy::unreadable_literal)]
25
26use regex_lite::{Captures, Regex};
27use std::fmt;
28use std::sync::OnceLock;
29
30#[cfg(doctest)]
32#[doc = include_str!("../README.md")]
33struct ReadmeDoctests;
34
35#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum Dms {
38 Decimal(f64),
40 Coordinates {
43 lat: Option<f64>,
45 lon: Option<f64>,
47 },
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ParseError {
53 CouldNotParse,
55 DegreesOutOfRange,
57 MinutesOutOfRange,
59 SecondsOutOfRange,
61}
62
63impl fmt::Display for ParseError {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 let msg = match self {
66 ParseError::CouldNotParse => "could not parse string",
67 ParseError::DegreesOutOfRange => "degrees out of range",
68 ParseError::MinutesOutOfRange => "minutes out of range",
69 ParseError::SecondsOutOfRange => "seconds out of range",
70 };
71 f.write_str(msg)
72 }
73}
74
75impl std::error::Error for ParseError {}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum Axis {
79 Lat,
80 Lon,
81}
82
83impl Axis {
84 fn opposite(self) -> Axis {
85 match self {
86 Axis::Lat => Axis::Lon,
87 Axis::Lon => Axis::Lat,
88 }
89 }
90}
91
92fn dms_regex() -> &'static Regex {
93 static RE: OnceLock<Regex> = OnceLock::new();
94 RE.get_or_init(|| {
95 Regex::new(
96 r#"(?i)([NSEW])?\s?(-)?(\d+(?:\.\d+)?)[°º:d\s]?\s?(?:(\d+(?:\.\d+)?)['’‘′:]?\s?(?:(\d{1,2}(?:\.\d+)?)(?:"|″|’’|'')?)?)?\s?([NSEW])?"#,
97 )
98 .expect("static regex is valid")
99 })
100}
101
102fn hemisphere_sign(letter: &str) -> Option<f64> {
105 match letter {
106 "N" | "E" => Some(1.0),
107 "S" | "W" => Some(-1.0),
108 _ => None,
109 }
110}
111
112fn hemisphere_axis(letter: &str) -> Option<Axis> {
113 match letter {
114 "N" | "S" => Some(Axis::Lat),
115 "E" | "W" => Some(Axis::Lon),
116 _ => None,
117 }
118}
119
120fn dec_deg_from_match(
125 m: &Captures<'_>,
126 suppress_trailing: bool,
127) -> Result<(f64, Option<Axis>), ParseError> {
128 let g = |i: usize| m.get(i).map(|x| x.as_str());
129 let g6 = if suppress_trailing { None } else { g(6) };
130
131 let sign = if g(2) == Some("-") {
133 -1.0
134 } else {
135 g(1).and_then(hemisphere_sign)
136 .or_else(|| g6.and_then(hemisphere_sign))
137 .unwrap_or(1.0)
138 };
139
140 let degrees: f64 = g(3).and_then(|s| s.parse().ok()).unwrap_or(0.0);
141 let minutes: f64 = g(4).map_or(0.0, |s| s.parse().unwrap_or(0.0));
142 let seconds: f64 = g(5).map_or(0.0, |s| s.parse().unwrap_or(0.0));
143
144 let axis = g(1)
145 .and_then(hemisphere_axis)
146 .or_else(|| g6.and_then(hemisphere_axis));
147
148 if !(0.0..=180.0).contains(°rees) {
149 return Err(ParseError::DegreesOutOfRange);
150 }
151 if !(0.0..=60.0).contains(&minutes) {
152 return Err(ParseError::MinutesOutOfRange);
153 }
154 if !(0.0..=60.0).contains(&seconds) {
155 return Err(ParseError::SecondsOutOfRange);
156 }
157
158 let dec = sign * (degrees + minutes / 60.0 + seconds / 3600.0);
159 Ok((dec, axis))
160}
161
162fn utf16_substr(s: &str, start: usize) -> String {
164 let units: Vec<u16> = s.encode_utf16().collect();
165 if start >= units.len() {
166 return String::new();
167 }
168 String::from_utf16_lossy(&units[start..])
169}
170
171pub fn parse_dms(input: &str) -> Result<Dms, ParseError> {
185 let s = input.trim();
186 let re = dms_regex();
187
188 let m1 = re.captures(s).ok_or(ParseError::CouldNotParse)?;
189 let leading_hemisphere = m1.get(1).is_some();
190 let (dec1, axis1_match) = dec_deg_from_match(&m1, leading_hemisphere)?;
191
192 let matched = m1.get(0).map_or("", |x| x.as_str());
194 let utf16_len = matched.encode_utf16().count();
195 let start = if leading_hemisphere {
196 utf16_len.saturating_sub(1)
197 } else {
198 utf16_len
199 };
200 let rest = utf16_substr(s, start);
201 let s2 = rest.trim();
202
203 let dec2 = match re.captures(s2) {
204 Some(c2) => Some(dec_deg_from_match(&c2, false)?),
205 None => None,
206 };
207
208 let axis2_match = dec2.and_then(|(_, a)| a);
209 let (axis1, axis2) = if let Some(a1) = axis1_match {
210 (a1, axis2_match.unwrap_or_else(|| a1.opposite()))
211 } else if dec2.is_none() {
212 return Ok(Dms::Decimal(dec1));
213 } else {
214 (Axis::Lat, Axis::Lon)
216 };
217
218 let mut lat = None;
219 let mut lon = None;
220 assign(&mut lat, &mut lon, axis1, dec1);
221 if let Some((d2, _)) = dec2 {
222 assign(&mut lat, &mut lon, axis2, d2);
223 }
224 Ok(Dms::Coordinates { lat, lon })
225}
226
227fn assign(lat: &mut Option<f64>, lon: &mut Option<f64>, axis: Axis, value: f64) {
228 match axis {
229 Axis::Lat => *lat = Some(value),
230 Axis::Lon => *lon = Some(value),
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn dms_pair() {
240 assert_eq!(
241 parse_dms("59°12'7.7\"N 002°15'39.6\"W").unwrap(),
242 Dms::Coordinates {
243 lat: Some(59.20213888888889),
244 lon: Some(-2.261)
245 }
246 );
247 }
248
249 #[test]
250 fn decimal_pair_inferred_order() {
251 assert_eq!(
252 parse_dms("59.20213 -2.260987").unwrap(),
253 Dms::Coordinates {
254 lat: Some(59.20213),
255 lon: Some(-2.260987)
256 }
257 );
258 }
259
260 #[test]
261 fn single_with_hemisphere() {
262 assert_eq!(
263 parse_dms("51 N").unwrap(),
264 Dms::Coordinates {
265 lat: Some(51.0),
266 lon: None
267 }
268 );
269 assert_eq!(
270 parse_dms("51 E").unwrap(),
271 Dms::Coordinates {
272 lat: None,
273 lon: Some(51.0)
274 }
275 );
276 }
277
278 #[test]
279 fn single_decimal() {
280 assert_eq!(parse_dms("51").unwrap(), Dms::Decimal(51.0));
281 assert_eq!(parse_dms("-51.5").unwrap(), Dms::Decimal(-51.5));
282 assert_eq!(parse_dms("51n").unwrap(), Dms::Decimal(51.0));
284 }
285
286 #[test]
287 fn colon_and_d_separators() {
288 assert_eq!(
289 parse_dms("51:30:00 N 000:30:00 E").unwrap(),
290 Dms::Coordinates {
291 lat: Some(51.5),
292 lon: Some(0.5)
293 }
294 );
295 assert_eq!(
296 parse_dms("51d30N").unwrap(),
297 Dms::Coordinates {
298 lat: Some(51.5),
299 lon: None
300 }
301 );
302 }
303
304 #[test]
305 fn out_of_range_and_unparseable() {
306 assert_eq!(parse_dms("181"), Err(ParseError::DegreesOutOfRange));
307 assert_eq!(parse_dms("12°61'N"), Err(ParseError::MinutesOutOfRange));
308 assert_eq!(parse_dms("foo"), Err(ParseError::CouldNotParse));
309 assert_eq!(parse_dms(""), Err(ParseError::CouldNotParse));
310 }
311
312 #[test]
313 fn leading_hemisphere_overlap() {
314 assert_eq!(
316 parse_dms("n51").unwrap(),
317 Dms::Coordinates {
318 lat: Some(51.0),
319 lon: Some(1.0)
320 }
321 );
322 }
323}