loose_dms/
lib.rs

1//! # loose_dms
2//!
3//! This crate provides functionality to parse coordinates from strings
4//! in Degrees Minutes Seconds (DMS) or decimal degrees format, accommodating
5//! various separators and hemisphere indicators. It aims to be "loose" in the
6//! sense of allowing flexible formatting while ensuring that the parsed values
7//! are valid geographical coordinates.
8//!
9//! **Note:** This crate is a direct port of the [parse-dms](https://github.com/gmaclennan/parse-dms)
10//! javascript library by [gmaclennan](https://github.com/gmaclennan). Big thanks to the
11//! original author for their work.
12//!
13//! ## Features
14//! - Supports DMS strings with degrees, minutes, and seconds.
15//! - Accommodates hemispheres (`N`, `E`, `S`, `W`) in various positions.
16//! - Parses decimal degrees directly.
17//! - Returns structured `Coordinate` objects with `lat` and `lng` fields.
18//!
19//! ## Example
20//!
21//! ```rust
22//! use loose_dms::parse;
23//!
24//! let coord = parse("59°12'7.7\"N 02°15'39.6\"W").unwrap();
25//! assert_eq!(coord.lat, 59.20213888888889);
26//! assert_eq!(coord.lng, -2.261);
27//! ```
28
29use regex::Regex;
30use thiserror::Error;
31
32use std::sync::OnceLock;
33
34static DMS_REGEX: OnceLock<Regex> = OnceLock::new();
35
36/// For more information, look at https://github.com/gmaclennan/parse-dms
37fn get_dms_regex() -> &'static Regex {
38    DMS_REGEX.get_or_init(|| {
39        Regex::new(
40        r#"(?i)([NSEW])?\s?(-)?(\d+(?:\.\d+)?)[°º:d\s]?\s?(?:(\d+(?:\.\d+)?)['’‘′:]?\s?(?:(\d{1,2}(?:\.\d+)?)(?:"|″|’’|'')?)?)?\s?([NSEW])?"#
41  ).unwrap()
42    })
43}
44
45/// A geographical coordinate expressed as latitude and longitude in decimal degrees.
46///
47/// The coordinate uses a conventional Earth-based reference system, where:
48/// - Latitude ranges from -90.0° (south pole) to +90.0° (north pole).
49/// - Longitude ranges from -180.0° (west) to +180.0° (east).
50///
51/// # Fields
52///
53/// * `lat` - The latitude in decimal degrees, where positive values represent the northern hemisphere.
54/// * `lng` - The longitude in decimal degrees, where positive values represent the eastern hemisphere.
55#[derive(Debug, Clone, Copy)]
56pub struct Coordinate {
57    pub lat: f64,
58    pub lng: f64,
59}
60
61/// Parse coordinates from a variety of string formats.
62///
63/// Parses coordinates in DMS (Degrees Minutes Seconds) format with different separators
64/// and hemisphere indicators. Also handles decimal degree formats.
65///
66/// # Examples
67///
68/// ```
69/// use loose_dms::parse;
70///
71/// // Parse DMS coordinates with hemisphere at end
72/// let coord = parse("59°12'7.7\"N 02°15'39.6\"W").unwrap();
73/// assert_eq!(coord.lat, 59.20213888888889);
74/// assert_eq!(coord.lng, -2.261);
75///
76/// // Parse DMS coordinates with hemisphere at start
77/// let coord = parse("N59°12'7.7\" W02°15'39.6\"").unwrap();
78/// assert_eq!(coord.lat, 59.20213888888889);
79/// assert_eq!(coord.lng, -2.261);
80///
81/// // Parse decimal degrees
82/// let coord = parse("51.5, -0.126").unwrap();
83/// assert_eq!(coord.lat, 51.5);
84/// assert_eq!(coord.lng, -0.126);
85/// ```
86///
87/// # Errors
88///
89/// Returns `Error::CouldNotParse` if the string cannot be parsed as valid coordinates.
90/// This includes if:
91/// - The string format is invalid
92/// - Values are out of valid ranges (degrees: 0-180, minutes/seconds: 0-60)
93/// - Required parts are missing
94pub fn parse(input: &str) -> Result<Coordinate, Error> {
95    let dms_str = input.trim();
96    let matched = regex_match(dms_str).ok_or(Error::CouldNotParse("no matches found"))?;
97
98    let mut parts: Vec<&str> = (0..matched.len())
99        .map(|i| matched.get(i).map_or("", |m| m.as_str()))
100        .collect();
101
102    if parts.len() < 7 {
103        return Err(Error::CouldNotParse("not enough matches found"));
104    }
105
106    let secondary_dms = if !parts[1].is_empty() {
107        parts[6] = "";
108        dms_str[parts[0].len() - 1..].trim()
109    } else {
110        dms_str[parts[0].len()..].trim()
111    };
112
113    let mut degree_1 = dec_deg_from_parts(parts)?;
114
115    let secondary_parts: Vec<&str> = match regex_match(secondary_dms) {
116        None => vec![],
117        Some(secondary_matched) => (0..secondary_matched.len())
118            .map(|i| secondary_matched.get(i).map_or("", |m| m.as_str()))
119            .collect(),
120    };
121
122    let mut degree_2 = if secondary_parts.is_empty() {
123        (None, None)
124    } else {
125        dec_deg_from_parts(secondary_parts)?
126    };
127
128    if degree_1.1.is_none() {
129        if degree_1.0.is_some() && degree_2.0.is_none() {
130            return Ok(Coordinate {
131                lat: degree_1.0.unwrap_or(0.0),
132                lng: 0.0,
133            });
134            // then return 1
135        } else if degree_1.0.is_some() && degree_2.0.is_some() {
136            degree_1.1 = Some(CoordinatePart::Lat);
137            degree_2.1 = Some(CoordinatePart::Lng);
138        } else {
139            return Err(Error::CouldNotParse(
140                "provided string does not have lat or lng",
141            ));
142        }
143    }
144
145    let degree_1_is_lat = matches!(
146        degree_1.1.unwrap_or(CoordinatePart::Lat),
147        CoordinatePart::Lat
148    );
149
150    if degree_2.1.is_none() {
151        if degree_1_is_lat {
152            degree_2.1 = Some(CoordinatePart::Lng);
153        } else {
154            degree_2.1 = Some(CoordinatePart::Lat);
155        };
156    };
157
158    if degree_1_is_lat {
159        Ok(Coordinate {
160            lat: degree_1.0.unwrap_or_default(),
161            lng: degree_2.0.unwrap_or_default(),
162        })
163    } else {
164        Ok(Coordinate {
165            lat: degree_2.0.unwrap_or_default(),
166            lng: degree_1.0.unwrap_or_default(),
167        })
168    }
169}
170
171fn dec_deg_from_parts(parts: Vec<&str>) -> Result<(Option<f64>, Option<CoordinatePart>), Error> {
172    let sign = direction_to_sign(parts[2])
173        .or_else(|| direction_to_sign(parts[1]))
174        .or_else(|| direction_to_sign(parts[6]))
175        .unwrap_or(1.0);
176
177    let degrees = match correct_str_num(parts[3]) {
178        None => return Ok((None, None)),
179        Some(d) => d,
180    };
181
182    let minutes: f64 = match correct_str_num(parts[4]) {
183        None => return Ok((None, None)),
184        Some(d) => d,
185    };
186
187    let seconds: f64 = match correct_str_num(parts[5]) {
188        None => return Ok((None, None)),
189        Some(d) => d,
190    };
191
192    let lat_lng = direction_to_lat_lng(parts[1]).or_else(|| direction_to_lat_lng(parts[6]));
193
194    if !is_in_range(degrees, 0.0, 180.0) {
195        return Err(Error::CouldNotParse("degress is not in the range [0, 180]"));
196    }
197
198    if !is_in_range(minutes, 0.0, 60.0) {
199        return Err(Error::CouldNotParse("minutes is not in the range [0, 60]"));
200    }
201
202    if !is_in_range(seconds, 0.0, 60.0) {
203        return Err(Error::CouldNotParse("seconds is not in the range [0, 60]"));
204    }
205
206    let decimal_degree = sign * (degrees + minutes / 60.0 + seconds / (60.0 * 60.0));
207
208    Ok((Some(decimal_degree), lat_lng))
209}
210
211#[derive(Error, Debug)]
212pub enum Error {
213    #[error("{0}")]
214    CouldNotParse(&'static str),
215}
216
217impl PartialEq for Coordinate {
218    fn eq(&self, other: &Self) -> bool {
219        self.lat == other.lat && self.lng == other.lng
220    }
221}
222
223impl Eq for Coordinate {}
224
225enum CoordinatePart {
226    Lat,
227    Lng,
228}
229
230fn direction_to_sign(dir: &str) -> Option<f64> {
231    match dir {
232        "-" => Some(-1.0),
233        "N" => Some(1.0),
234        "S" => Some(-1.0),
235        "E" => Some(1.0),
236        "W" => Some(-1.0),
237        _ => None,
238    }
239}
240
241fn direction_to_lat_lng(dir: &str) -> Option<CoordinatePart> {
242    match dir {
243        "N" => Some(CoordinatePart::Lat),
244        "S" => Some(CoordinatePart::Lat),
245        "E" => Some(CoordinatePart::Lng),
246        "W" => Some(CoordinatePart::Lng),
247        _ => None,
248    }
249}
250
251fn correct_str_num(str: &str) -> Option<f64> {
252    if str.is_empty() {
253        return Some(0.0);
254    }
255
256    str.parse().ok()
257}
258
259fn regex_match(dms_string: &str) -> Option<regex::Captures<'_>> {
260    get_dms_regex().captures(dms_string)
261}
262
263fn is_in_range(v: f64, min: f64, max: f64) -> bool {
264    v >= min && v <= max
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_parses_dms_pairs_with_different_separators_hemisphere_at_end() {
273        let test_data = [
274            "59°12'7.7\"N 02°15'39.6\"W",
275            "59º12'7.7\"N 02º15'39.6\"W",
276            "59 12' 7.7\" N 02 15' 39.6\" W",
277            "59 12'7.7''N 02 15'39.6'' W",
278            "59:12:7.7\"N 2:15:39.6W",
279            "59 12'7.7''N 02 15'39.6''W",
280        ];
281
282        let expected = Coordinate {
283            lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
284            lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
285        };
286
287        for test_str in test_data.iter() {
288            assert_eq!(parse(test_str).unwrap(), expected);
289        }
290    }
291
292    #[test]
293    fn test_parses_dms_pairs_with_hemisphere_at_beginning() {
294        let test_data = [
295            "N59°12'7.7\" W02°15'39.6\"",
296            "N 59°12'7.7\" W 02°15'39.6\"",
297            "N 59.20213888888889° W 2.261°",
298            "N 59.20213888888889 W 2.261",
299            "W02°15'39.6\" N59°12'7.7\"",
300        ];
301
302        let expected = Coordinate {
303            lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
304            lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
305        };
306
307        for test_str in test_data.iter() {
308            assert_eq!(parse(test_str).unwrap(), expected);
309        }
310    }
311
312    #[test]
313    fn test_parses_different_separators_between_pairs() {
314        let test_data = [
315            "59°12'7.7\"N  02°15'39.6\"W",
316            "59°12'7.7\"N , 02°15'39.6\"W",
317            "59°12'7.7\"N,02°15'39.6\"W",
318        ];
319
320        let expected = Coordinate {
321            lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
322            lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
323        };
324
325        for test_str in test_data.iter() {
326            assert_eq!(parse(test_str).unwrap(), expected);
327        }
328    }
329
330    #[test]
331    fn test_parses_single_coordinate_with_hemisphere() {
332        let test_data = ["59°12'7.7\"N", "02°15'39.6\"W"];
333
334        let expected = [
335            Coordinate {
336                lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
337                lng: 0.0,
338            },
339            Coordinate {
340                lat: 0.0,
341                lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
342            },
343        ];
344
345        for (test_str, expected) in test_data.iter().zip(expected.iter()) {
346            println!("{:?} <----> {:?}", expected, &parse(test_str).unwrap());
347            assert_eq!(&parse(test_str).unwrap(), expected);
348        }
349    }
350
351    #[test]
352    fn test_infers_first_coordinate_is_lat() {
353        let test_data = ["59°12'7.7\" -02°15'39.6\"", "59°12'7.7\", -02°15'39.6\""];
354
355        let expected = Coordinate {
356            lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
357            lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
358        };
359
360        for test_str in test_data.iter() {
361            assert_eq!(parse(test_str).unwrap(), expected);
362        }
363    }
364
365    #[test]
366    fn test_fails_for_invalid_data() {
367        assert!(parse("Not DMS string").is_err());
368    }
369
370    #[test]
371    fn test_decimal_degrees_parsed_correctly() {
372        let test_data = ["51.5, -0.126", "51.5,-0.126", "51.5 -0.126"];
373
374        let expected = Coordinate {
375            lat: 51.5,
376            lng: -0.126,
377        };
378
379        for test_str in test_data.iter() {
380            assert_eq!(parse(test_str).unwrap(), expected);
381        }
382    }
383
384    #[test]
385    fn test_dms_with_separators_and_spaces() {
386        let test_data = [
387            "59° 12' 7.7\" N 02° 15' 39.6\" W",
388            "59º 12' 7.7\" N 02º 15' 39.6\" W",
389            "59 12' 7.7''N 02 15' 39.6''W",
390        ];
391
392        let expected = Coordinate {
393            lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
394            lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
395        };
396
397        for test_str in test_data.iter() {
398            assert_eq!(parse(test_str).unwrap(), expected);
399        }
400    }
401}