use regex::Regex;
use thiserror::Error;
use std::sync::OnceLock;
static DMS_REGEX: OnceLock<Regex> = OnceLock::new();
fn get_dms_regex() -> &'static Regex {
DMS_REGEX.get_or_init(|| {
Regex::new(
r#"(?i)([NSEW])?\s?(-)?(\d+(?:\.\d+)?)[°º:d\s]?\s?(?:(\d+(?:\.\d+)?)['’‘′:]?\s?(?:(\d{1,2}(?:\.\d+)?)(?:"|″|’’|'')?)?)?\s?([NSEW])?"#
).unwrap()
})
}
#[derive(Debug, Clone, Copy)]
pub struct Coordinate {
pub lat: f64,
pub lng: f64,
}
pub fn parse(input: &str) -> Result<Coordinate, Error> {
let dms_str = input.trim();
let matched = regex_match(dms_str).ok_or(Error::CouldNotParse("no matches found"))?;
let mut parts: Vec<&str> = (0..matched.len())
.map(|i| matched.get(i).map_or("", |m| m.as_str()))
.collect();
if parts.len() < 7 {
return Err(Error::CouldNotParse("not enough matches found"));
}
let secondary_dms = if !parts[1].is_empty() {
parts[6] = "";
dms_str[parts[0].len() - 1..].trim()
} else {
dms_str[parts[0].len()..].trim()
};
let mut degree_1 = dec_deg_from_parts(parts)?;
let secondary_parts: Vec<&str> = match regex_match(secondary_dms) {
None => vec![],
Some(secondary_matched) => (0..secondary_matched.len())
.map(|i| secondary_matched.get(i).map_or("", |m| m.as_str()))
.collect(),
};
let mut degree_2 = if secondary_parts.is_empty() {
(None, None)
} else {
dec_deg_from_parts(secondary_parts)?
};
if degree_1.1.is_none() {
if degree_1.0.is_some() && degree_2.0.is_none() {
return Ok(Coordinate {
lat: degree_1.0.unwrap_or(0.0),
lng: 0.0,
});
} else if degree_1.0.is_some() && degree_2.0.is_some() {
degree_1.1 = Some(CoordinatePart::Lat);
degree_2.1 = Some(CoordinatePart::Lng);
} else {
return Err(Error::CouldNotParse(
"provided string does not have lat or lng",
));
}
}
let degree_1_is_lat = matches!(
degree_1.1.unwrap_or(CoordinatePart::Lat),
CoordinatePart::Lat
);
if degree_2.1.is_none() {
if degree_1_is_lat {
degree_2.1 = Some(CoordinatePart::Lng);
} else {
degree_2.1 = Some(CoordinatePart::Lat);
};
};
if degree_1_is_lat {
Ok(Coordinate {
lat: degree_1.0.unwrap_or_default(),
lng: degree_2.0.unwrap_or_default(),
})
} else {
Ok(Coordinate {
lat: degree_2.0.unwrap_or_default(),
lng: degree_1.0.unwrap_or_default(),
})
}
}
fn dec_deg_from_parts(parts: Vec<&str>) -> Result<(Option<f64>, Option<CoordinatePart>), Error> {
let sign = direction_to_sign(parts[2])
.or_else(|| direction_to_sign(parts[1]))
.or_else(|| direction_to_sign(parts[6]))
.unwrap_or(1.0);
let degrees = match correct_str_num(parts[3]) {
None => return Ok((None, None)),
Some(d) => d,
};
let minutes: f64 = match correct_str_num(parts[4]) {
None => return Ok((None, None)),
Some(d) => d,
};
let seconds: f64 = match correct_str_num(parts[5]) {
None => return Ok((None, None)),
Some(d) => d,
};
let lat_lng = direction_to_lat_lng(parts[1]).or_else(|| direction_to_lat_lng(parts[6]));
if !is_in_range(degrees, 0.0, 180.0) {
return Err(Error::CouldNotParse("degress is not in the range [0, 180]"));
}
if !is_in_range(minutes, 0.0, 60.0) {
return Err(Error::CouldNotParse("minutes is not in the range [0, 60]"));
}
if !is_in_range(seconds, 0.0, 60.0) {
return Err(Error::CouldNotParse("seconds is not in the range [0, 60]"));
}
let decimal_degree = sign * (degrees + minutes / 60.0 + seconds / (60.0 * 60.0));
Ok((Some(decimal_degree), lat_lng))
}
#[derive(Error, Debug)]
pub enum Error {
#[error("{0}")]
CouldNotParse(&'static str),
}
impl PartialEq for Coordinate {
fn eq(&self, other: &Self) -> bool {
self.lat == other.lat && self.lng == other.lng
}
}
impl Eq for Coordinate {}
enum CoordinatePart {
Lat,
Lng,
}
fn direction_to_sign(dir: &str) -> Option<f64> {
match dir {
"-" => Some(-1.0),
"N" => Some(1.0),
"S" => Some(-1.0),
"E" => Some(1.0),
"W" => Some(-1.0),
_ => None,
}
}
fn direction_to_lat_lng(dir: &str) -> Option<CoordinatePart> {
match dir {
"N" => Some(CoordinatePart::Lat),
"S" => Some(CoordinatePart::Lat),
"E" => Some(CoordinatePart::Lng),
"W" => Some(CoordinatePart::Lng),
_ => None,
}
}
fn correct_str_num(str: &str) -> Option<f64> {
if str.is_empty() {
return Some(0.0);
}
str.parse().ok()
}
fn regex_match(dms_string: &str) -> Option<regex::Captures<'_>> {
get_dms_regex().captures(dms_string)
}
fn is_in_range(v: f64, min: f64, max: f64) -> bool {
v >= min && v <= max
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parses_dms_pairs_with_different_separators_hemisphere_at_end() {
let test_data = [
"59°12'7.7\"N 02°15'39.6\"W",
"59º12'7.7\"N 02º15'39.6\"W",
"59 12' 7.7\" N 02 15' 39.6\" W",
"59 12'7.7''N 02 15'39.6'' W",
"59:12:7.7\"N 2:15:39.6W",
"59 12'7.7''N 02 15'39.6''W",
];
let expected = Coordinate {
lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
};
for test_str in test_data.iter() {
assert_eq!(parse(test_str).unwrap(), expected);
}
}
#[test]
fn test_parses_dms_pairs_with_hemisphere_at_beginning() {
let test_data = [
"N59°12'7.7\" W02°15'39.6\"",
"N 59°12'7.7\" W 02°15'39.6\"",
"N 59.20213888888889° W 2.261°",
"N 59.20213888888889 W 2.261",
"W02°15'39.6\" N59°12'7.7\"",
];
let expected = Coordinate {
lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
};
for test_str in test_data.iter() {
assert_eq!(parse(test_str).unwrap(), expected);
}
}
#[test]
fn test_parses_different_separators_between_pairs() {
let test_data = [
"59°12'7.7\"N 02°15'39.6\"W",
"59°12'7.7\"N , 02°15'39.6\"W",
"59°12'7.7\"N,02°15'39.6\"W",
];
let expected = Coordinate {
lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
};
for test_str in test_data.iter() {
assert_eq!(parse(test_str).unwrap(), expected);
}
}
#[test]
fn test_parses_single_coordinate_with_hemisphere() {
let test_data = ["59°12'7.7\"N", "02°15'39.6\"W"];
let expected = [
Coordinate {
lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
lng: 0.0,
},
Coordinate {
lat: 0.0,
lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
},
];
for (test_str, expected) in test_data.iter().zip(expected.iter()) {
println!("{:?} <----> {:?}", expected, &parse(test_str).unwrap());
assert_eq!(&parse(test_str).unwrap(), expected);
}
}
#[test]
fn test_infers_first_coordinate_is_lat() {
let test_data = ["59°12'7.7\" -02°15'39.6\"", "59°12'7.7\", -02°15'39.6\""];
let expected = Coordinate {
lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
};
for test_str in test_data.iter() {
assert_eq!(parse(test_str).unwrap(), expected);
}
}
#[test]
fn test_fails_for_invalid_data() {
assert!(parse("Not DMS string").is_err());
}
#[test]
fn test_decimal_degrees_parsed_correctly() {
let test_data = ["51.5, -0.126", "51.5,-0.126", "51.5 -0.126"];
let expected = Coordinate {
lat: 51.5,
lng: -0.126,
};
for test_str in test_data.iter() {
assert_eq!(parse(test_str).unwrap(), expected);
}
}
#[test]
fn test_dms_with_separators_and_spaces() {
let test_data = [
"59° 12' 7.7\" N 02° 15' 39.6\" W",
"59º 12' 7.7\" N 02º 15' 39.6\" W",
"59 12' 7.7''N 02 15' 39.6''W",
];
let expected = Coordinate {
lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
};
for test_str in test_data.iter() {
assert_eq!(parse(test_str).unwrap(), expected);
}
}
}