Skip to main content

parse_dms/
lib.rs

1//! # parse-dms — parse coordinate strings into decimal latitude/longitude
2//!
3//! Parse a geographic coordinate written in degrees/minutes/seconds (or decimal degrees),
4//! with optional hemisphere letters, into decimal degrees:
5//!
6//! ```
7//! use parse_dms::{parse_dms, Dms};
8//!
9//! let c = parse_dms("59°12'7.7\"N 002°15'39.6\"W").unwrap();
10//! assert_eq!(c, Dms::Coordinates { lat: Some(59.20213888888889), lon: Some(-2.261) });
11//!
12//! // A bare value with no hemisphere returns a single decimal number.
13//! assert_eq!(parse_dms("-51.5").unwrap(), Dms::Decimal(-51.5));
14//! ```
15//!
16//! A faithful Rust port of the [`parse-dms`](https://www.npmjs.com/package/parse-dms) npm
17//! package. Accepts a wide range of punctuation for the degree/minute/second separators
18//! (`°º:d`, `'’‘′`, `"″''`), inferring latitude/longitude from hemisphere letters or, when
19//! there are two hemisphere-less coordinates, from their order.
20
21#![forbid(unsafe_code)]
22#![doc(html_root_url = "https://docs.rs/parse-dms/0.1.0")]
23// The decimal-degree literals below are exact reference values; separators would obscure them.
24#![allow(clippy::unreadable_literal)]
25
26use regex_lite::{Captures, Regex};
27use std::fmt;
28use std::sync::OnceLock;
29
30// Compile-test the README's examples as part of `cargo test`.
31#[cfg(doctest)]
32#[doc = include_str!("../README.md")]
33struct ReadmeDoctests;
34
35/// The result of parsing a coordinate string.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum Dms {
38    /// A single coordinate with no hemisphere — just a decimal-degree value.
39    Decimal(f64),
40    /// A latitude and/or longitude in decimal degrees. A field is `None` when the input did
41    /// not supply that component.
42    Coordinates {
43        /// Decimal latitude, if present.
44        lat: Option<f64>,
45        /// Decimal longitude, if present.
46        lon: Option<f64>,
47    },
48}
49
50/// An error from [`parse_dms`].
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ParseError {
53    /// The input did not contain a recognizable coordinate.
54    CouldNotParse,
55    /// Degrees were outside the range `0..=180`.
56    DegreesOutOfRange,
57    /// Minutes were outside the range `0..=60`.
58    MinutesOutOfRange,
59    /// Seconds were outside the range `0..=60`.
60    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
102/// The hemisphere/sign letters, matched case-sensitively against the capture (mirroring the
103/// reference's uppercase lookup table).
104fn 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
120/// Compute `{ decDeg, axis }` from a regex match.
121///
122/// `suppress_trailing` mirrors the reference clearing `m1[6] = undefined` when the match
123/// began with a hemisphere letter.
124fn 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    // sign = signIndex[m[2]] || signIndex[m[1]] || signIndex[m[6]] || 1
132    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(&degrees) {
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
162/// Substring of `s` starting at UTF-16 code-unit index `start` (mirroring JS `substr`).
163fn 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
171/// Parse a coordinate string into decimal degrees.
172///
173/// Returns [`Dms::Decimal`] for a single hemisphere-less coordinate, otherwise
174/// [`Dms::Coordinates`] with the inferred latitude and/or longitude.
175///
176/// # Errors
177/// Returns [`ParseError`] if the string cannot be parsed or a component is out of range.
178///
179/// ```
180/// # use parse_dms::{parse_dms, Dms};
181/// assert_eq!(parse_dms("51 N").unwrap(), Dms::Coordinates { lat: Some(51.0), lon: None });
182/// assert!(parse_dms("foo").is_err());
183/// ```
184pub 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    // Find where the second coordinate begins, replicating `substr(m1[0].length [- 1])`.
193    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        // Two hemisphere-less coordinates → first is lat, second is lon (by order).
215        (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        // lowercase hemisphere is not recognized → a bare number
283        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        // The reference's `substr(len - 1)` re-reads a digit: "n51" → {lat:51, lon:1}.
315        assert_eq!(
316            parse_dms("n51").unwrap(),
317            Dms::Coordinates {
318                lat: Some(51.0),
319                lon: Some(1.0)
320            }
321        );
322    }
323}