lat_long/parse.rs
1//! This module provides the [`Parsed`] enum and [`parse_str`] function.
2//!
3//! The [`parse_str`] function should proceed according to the following grammar:
4//!
5//! ```bnf
6//! <coordinate> ::= <bare_pair> | <non_bare_pair>
7//! <bare_pair> ::= <bare_dms> "," <bare_dms>
8//! <non_bare_pair> ::= ( <latitude> <separator> <longitude> )
9//! | ( <bare_dms> <separator> <longitude> )
10//! | ( <latitude> <separator> <bare_dms> )
11//!
12//! <latitude> ::= <decimal> | <signed_dms> | <labeled_dms>
13//! <longitude> ::= <decimal> | <signed_dms> | <labeled_dms>
14//! <separator> ::= WHITESPACE* "," WHITESPACE*
15//! <decimal> ::= <sign>? <digits> "." <digits>
16//! <signed_dms> ::= <sign>? <degs> WHITESPACE* <mins> WHITESPACE* <secs>
17//! <labeled_dms> ::= <degs> WHITESPACE* <mins> WHITESPACE* <secs> WHITESPACE* <direction>
18//! <bare_dms> ::= <sign> <bare_degs> ":" <bare_mins> ":" <bare_secs>
19//!
20//! <degs> ::= <digits> "°"
21//! <mins> ::= <digits> "′"
22//! <secs> ::= <digits> "." <digits> "″"
23//! <bare_degs> ::= <digit> <digit> <digit>
24//! <bare_mins> ::= <digit> <digit>
25//! <bare_secs> ::= <digit> <digit> "." <digit> <digit> <digit> <digit>+
26//!
27//! <direction> ::= "N" | "S" | "E" | "W"
28//! <sign> ::= "+" | "-"
29//! <digit> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
30//! <digits> ::= <digit>+
31//! ```
32//!
33//! # Format Notes
34//!
35//! 1. Strings **must not** have leading or trailing whitespace. [`Error::InvalidWhitespace`]
36//! 2. Whitespace **must not** appear between the *sign* and the *degrees* value. [`Error::InvalidWhitespace`]
37//! 3. *Degree*, *minute*, and *seconds* values have a maximum number of integer digits, 3, 2, and 2 respectively. [`Error::InvalidNumericFormat`]
38//! 4. The *degrees* symbol **must** be '°', (Unicode `U+00B0` `DEGREE SIGN`). [`Error::InvalidCharacter`]
39//! 5. The *minutes* symbol **must** be '′' (Unicode `U+2032` `PRIME`). [`Error::InvalidCharacter`]
40//! 6. The *seconds* symbol **must** be '″' (Unicode `U+2033` `DOUBLE PRIME`). [`Error::InvalidCharacter`]
41//! 7. Whitespace **must not** appear between the *degrees*, *minutes*, and *seconds* values and their corresponding symbols. [`Error::InvalidWhitespace`]
42//! 8. Labeled format **must** have a *direction* character 'N', 'S', 'E', or 'W' at the end of the string, this character is case sensitive. [`Error::InvalidCharacter`]
43//! 9. Bare format **must** start with the *sign* character [+|-] at the beginning of the string. [`Error::InvalidNumericFormat`]
44//! 10. The latitude and longitude values of a coordinate **may** be specified in different formats.
45//! 11. The latitude and longitude values of a coordinate **must** separated by a single comma `,` (*separator*).
46//! 12. If either latitude **and** longitude are specified in a non-bare format, the *separator* **may** have whitespace before and after: `\s*,\s*`.
47//! 12. If both latitude **and** longitude are specified in bare format, the *separator* **must not** have leading or trailing whitespace. [`Error::InvalidNumericFormat`]
48//!
49//! # Parsed Examples
50//!
51//! | Input String | Match | Result |
52//! |-------------------------------------------|-----------------|-------------------------------|
53//! | 48.858222 | Decimal | Ok(Value(Unknown)) |
54//! | +48.858222 | Decimal | Ok(Value(Unknown)) |
55//! | 48.858 | Decimal | Ok(Value(Unknown)) |
56//! | 48.9 | Decimal | Ok(Value(Unknown)) |
57//! | 48 | Decimal | Error(InvalidNumericFormat) |
58//! | 048.9 | Decimal | Ok(Value(Unknown)) |
59//! | 0048.9 | Decimal | Error(InvalidNumericFormat) |
60//! | -48.858222 | Decimal | Ok(Value(Unknown)) |
61//! | " 48.858222" | Decimal | Error(InvalidWhitespace) |
62//! | "48.858222 " | Decimal | Error(InvalidWhitespace) |
63//! | - 48.858222 | Decimal | Error(InvalidCharacter) |
64//! | 48° 51′ 29.600000″ | Signed DMS | Ok(Value(Unknown)) |
65//! | -48° 51′ 29.600000″ | Signed DMS | Ok(Value(Unknown)) |
66//! | 48° 51′ 29.600000″ | Signed DMS | Ok(Value(Unknown)) |
67//! | 48° 51' 29.600000″ N | Labeled DMS | Error(InvalidCharacter) |
68//! | 48° 51′ 29.600000″ S | Labeled DMS | Ok(Value(Latitude)) |
69//! | 48° 51′ 29.600000″ E | Labeled DMS | Ok(Value(Longitude)) |
70//! | 48° 51′ 29.600000″ W | Labeled DMS | Ok(Value(Longitude)) |
71//! | 48° 51′ 29.600000″ w | Labeled DMS | Error(InvalidCharacter) |
72//! | +048:51:29.600000 | Bare DMS | Ok(Value(Unknown)) |
73//! | -048:51:29.600000 | Bare DMS | Ok(Value(Unknown)) |
74//! | 91, 0, 0.0 | Signed DMS | Error(InvalidDegrees) |
75//! | 90, 61, 0.0 | Signed DMS | Error(InvalidMinutes) |
76//! | 90, 0, 61.0 | Signed DMS | Error(InvalidSeconds) |
77//! | 180, 1, 0.0 | Signed DMS | Error(InvalidAngle) |
78//! | 48° 51′ 29.600000″, 73° 59′ 8.400000″ | Signed+Signed | Ok(Coordinate) |
79//! | 48° 51′ 29.600000″ N, 73° 59′ 8.400000″ E | Labeled+Labeled | Ok(Coordinate) |
80//! | 48° 51′ 29.600000″ W, 73° 59′ 8.400000″ N | Labeled+Labeled | Error(InvalidLatitude) |
81//! | 48° 51′ 29.600000″ X, 73° 59′ 8.400000″ Y | Labeled+Labeled | Error(InvalidCharacter) |
82//! | 48.858222, -73.985667 | Decimal+Decimal | Ok(Coordinate) |
83//! | +048:51:29.600000, 73° 59′ 8.400000″ | Bare+Signed | Ok(Coordinate) |
84//! | 48° 51′ 29.600000″, 73.985667 | Signed+Decimal | Ok(Coordinate) |
85//! | 48.858222, 73° 59′ 8.400000″ | Decimal+Signed | Ok(Coordinate) |
86//! | 48° 51′ 29.600000″, -73.985667 | Signed+Decimal | Ok(Coordinate) |
87//! | -48.858222, 73° 59′ 8.400000″ | Decimal+Signed | Ok(Coordinate) |
88//! | +048:51:29.600000,-073:59:08.400000 | Bare+Bare | Ok(Coordinate) |
89//! | +048:51:29.600000, -073:59:08.400000 | Bare+Bare | Error(InvalidWhitespace) |
90//!
91//! # Code Examples
92//!
93//! Parse individual angles:
94//!
95//! ```rust
96//! use lat_long::parse;
97//!
98//! assert!(parse::parse_str("48.858222").is_ok());
99//! assert!(parse::parse_str("-73.985667").is_ok());
100//! assert!(parse::parse_str("48° 51′ 29.600000″ N").is_ok());
101//! assert!(parse::parse_str("73° 59′ 8.400000″ W").is_ok());
102//! assert!(parse::parse_str("+048:51:29.600000").is_ok());
103//! assert!(parse::parse_str("-073:59:08.400000").is_ok());
104//! ```
105//!
106//! Parse coordinates:
107//!
108//! ```rust
109//! use lat_long::parse;
110//!
111//! assert!(parse::parse_str("48.858222, -73.985667").is_ok());
112//! assert!(parse::parse_str("48° 51′ 29.600000″ N, 73° 59′ 8.400000″ W").is_ok());
113//! assert!(parse::parse_str("+048:51:29.600000,-073:59:08.400000").is_ok());
114//! assert!(parse::parse_str("+048:51:29.600000, 73° 59′ 8.400000″ W").is_ok());
115//! ```
116
117use crate::{Coordinate, Error, Latitude, Longitude, inner};
118use ordered_float::OrderedFloat;
119
120// ---------------------------------------------------------------------------
121// Public Types
122// ---------------------------------------------------------------------------
123
124/// The result of a successful [`parse_str`] call.
125#[derive(Clone, Copy, Debug, PartialEq)]
126pub enum Parsed {
127 Angle(Value),
128 Coordinate(Coordinate),
129}
130
131#[derive(Clone, Copy, Debug, PartialEq)]
132pub enum Value {
133 /// A value whose direction (latitude vs longitude) is not specified
134 /// by its format (decimal or signed/bare DMS).
135 Unknown(OrderedFloat<f64>),
136 /// A labeled DMS value with direction N or S — known to be a latitude.
137 Latitude(Latitude),
138 /// A labeled DMS value with direction E or W — known to be a longitude.
139 Longitude(Longitude),
140}
141
142// ---------------------------------------------------------------------------
143// Public Functions
144// ---------------------------------------------------------------------------
145
146/// Parse a string into a [`Parsed`] enum.
147///
148/// Accepts all four angle formats (decimal, signed DMS, labeled DMS, bare DMS)
149/// as individual values or as a comma-separated coordinate pair. See the
150/// module-level documentation for the full grammar and format rules.
151pub fn parse_str(s: &str) -> Result<Parsed, Error> {
152 // Rule 1: no leading or trailing whitespace.
153 if s.starts_with(|c: char| c.is_ascii_whitespace())
154 || s.ends_with(|c: char| c.is_ascii_whitespace())
155 {
156 return Err(Error::InvalidWhitespace(s.to_string()));
157 }
158
159 if s.is_empty() {
160 return Err(Error::InvalidNumericFormat(s.to_string()));
161 }
162
163 // Look for a coordinate-pair comma.
164 match find_comma(s) {
165 Some(comma_pos) => parse_pair(s, comma_pos),
166 None => parse_single(s).map(Parsed::Angle),
167 }
168}
169
170// ---------------------------------------------------------------------------
171// Private helpers: top-level dispatch
172// ---------------------------------------------------------------------------
173
174/// Find the byte index of the first ASCII comma in `s`.
175fn find_comma(s: &str) -> Option<usize> {
176 s.find(',')
177}
178
179/// Parse a single-angle string (no comma present). Returns the [`Value`].
180fn parse_single(s: &str) -> Result<Value, Error> {
181 // Try in order: labeled DMS (has °...″ + direction letter), signed DMS
182 // (has °...″ without direction), bare DMS (starts with +/-NNN:MM:SS),
183 // decimal.
184 if let Some(result) = try_labeled_dms(s) {
185 return result;
186 }
187 if let Some(result) = try_signed_dms(s) {
188 return result.map(|f| Value::Unknown(f));
189 }
190 if let Some(result) = try_bare_dms(s) {
191 return result.map(|f| Value::Unknown(f));
192 }
193 try_decimal(s).map(|f| Value::Unknown(f))
194}
195
196/// Parse a comma-separated coordinate pair. `comma_pos` is the byte index of
197/// the first comma in `s`.
198fn parse_pair(s: &str, comma_pos: usize) -> Result<Parsed, Error> {
199 let lat_src = &s[..comma_pos];
200 let after_comma = &s[comma_pos + 1..];
201
202 // Detect whitespace around the comma.
203 let has_pre_ws = lat_src.ends_with(|c: char| c.is_ascii_whitespace());
204 let has_post_ws = after_comma.starts_with(|c: char| c.is_ascii_whitespace());
205 let comma_ws = has_pre_ws || has_post_ws;
206
207 let lat_src = lat_src.trim_end();
208 let lon_src = after_comma.trim_start();
209
210 // Guard: no more commas allowed in the longitude part.
211 if lon_src.contains(',') {
212 return Err(Error::InvalidCharacter(',', s.to_string()));
213 }
214
215 // Determine which format(s) each side uses.
216 let lat_is_bare = is_bare_dms(lat_src);
217 let lon_is_bare = is_bare_dms(lon_src);
218
219 // Rule: bare+bare must have no whitespace around the comma.
220 if lat_is_bare && lon_is_bare && comma_ws {
221 return Err(Error::InvalidWhitespace(s.to_string()));
222 }
223
224 // Parse the latitude slot.
225 let lat: Latitude = parse_as_latitude(lat_src)?;
226 // Parse the longitude slot.
227 let lon: Longitude = parse_as_longitude(lon_src)?;
228
229 Ok(Parsed::Coordinate(Coordinate::new(lat, lon)))
230}
231
232// ---------------------------------------------------------------------------
233// Slot-typed parsers used for coordinate pairs
234// ---------------------------------------------------------------------------
235
236/// Parse `s` as the latitude slot of a coordinate pair.
237///
238/// Accepts: decimal, signed DMS, bare DMS (all produce `Unknown` → validated
239/// as latitude), or labeled DMS with N/S.
240/// Rejects: labeled DMS with E/W.
241fn parse_as_latitude(s: &str) -> Result<Latitude, Error> {
242 if let Some(result) = try_labeled_dms(s) {
243 return match result? {
244 Value::Latitude(lat) => Ok(lat),
245 Value::Longitude(_) => {
246 // E or W direction in the latitude slot.
247 let dir_char = s.chars().last().unwrap_or('?');
248 Err(Error::InvalidCharacter(dir_char, s.to_string()))
249 }
250 Value::Unknown(_) => unreachable!(),
251 };
252 }
253 // Signed, bare, or decimal — parse as float then validate latitude range.
254 let f = parse_as_float(s)?;
255 Latitude::try_from(f).map_err(|_| {
256 let deg = inner::to_degrees_minutes_seconds(f).0;
257 Error::InvalidLatitudeDegrees(deg)
258 })
259}
260
261/// Parse `s` as the longitude slot of a coordinate pair.
262///
263/// Accepts: decimal, signed DMS, bare DMS (all produce `Unknown` → validated
264/// as longitude), or labeled DMS with E/W.
265/// Rejects: labeled DMS with N/S.
266fn parse_as_longitude(s: &str) -> Result<Longitude, Error> {
267 if let Some(result) = try_labeled_dms(s) {
268 return match result? {
269 Value::Longitude(lon) => Ok(lon),
270 Value::Latitude(_) => {
271 // N or S direction in the longitude slot.
272 let dir_char = s.chars().last().unwrap_or('?');
273 Err(Error::InvalidCharacter(dir_char, s.to_string()))
274 }
275 Value::Unknown(_) => unreachable!(),
276 };
277 }
278 let f = parse_as_float(s)?;
279 Longitude::try_from(f).map_err(|_| {
280 let deg = inner::to_degrees_minutes_seconds(f).0;
281 Error::InvalidLongitudeDegrees(deg)
282 })
283}
284
285/// Parse any non-labeled format (decimal, signed DMS, bare DMS) into a raw
286/// float. Used when parsing a coordinate slot without a direction letter.
287fn parse_as_float(s: &str) -> Result<OrderedFloat<f64>, Error> {
288 if let Some(result) = try_signed_dms(s) {
289 return result;
290 }
291 if let Some(result) = try_bare_dms(s) {
292 return result;
293 }
294 try_decimal(s)
295}
296
297// ---------------------------------------------------------------------------
298// Format detectors / sub-parsers
299// ---------------------------------------------------------------------------
300
301/// Returns `true` if `s` looks like a bare-DMS token (`+NNN:MM:SS.sss…`).
302fn is_bare_dms(s: &str) -> bool {
303 matches!(s.as_bytes().first(), Some(b'+') | Some(b'-')) && s.contains(':')
304}
305
306/// `^(?<sign>[-+])?(?<degrees>\d{1,3})°\s*(?<minutes>\d{1,2})′\s*(?<seconds>\d{1,2}\.\d+)″$`
307///
308/// Returns `None` if the string doesn't contain the `°` symbol (not this
309/// format at all). Returns `Some(Err(_))` if it looks like this format but
310/// is malformed.
311///
312/// Returns `Some(Ok(Value::Latitude))` for N/S direction,
313/// `Some(Ok(Value::Longitude))` for E/W, `None` if no direction letter is
314/// found (caller should try signed DMS next).
315fn try_labeled_dms(s: &str) -> Option<Result<Value, Error>> {
316 // Quick reject: must contain the degrees symbol.
317 if !s.contains('°') {
318 return None;
319 }
320
321 // The labeled variant does NOT start with a sign character — the polarity
322 // is encoded in the direction letter. If we see a leading +/- it could
323 // be signed DMS; let the signed parser handle it.
324 if matches!(s.as_bytes().first(), Some(b'+') | Some(b'-')) {
325 return None;
326 }
327
328 let (deg_str, rest) = consume_up_to(s, '°')?;
329
330 // Rule 7: no whitespace between the degree value and the ° symbol.
331 if deg_str.ends_with(|c: char| c.is_ascii_whitespace()) {
332 return Some(Err(Error::InvalidWhitespace(s.to_string())));
333 }
334
335 let rest = skip_whitespace(rest);
336 let (min_str, rest) = consume_up_to(rest, '′')?;
337
338 // Rule 7: no whitespace before ′.
339 if min_str.ends_with(|c: char| c.is_ascii_whitespace()) {
340 return Some(Err(Error::InvalidWhitespace(s.to_string())));
341 }
342
343 let rest = skip_whitespace(rest);
344 let (sec_str, rest) = consume_up_to(rest, '″')?;
345
346 // Rule 7: no whitespace before ″.
347 if sec_str.ends_with(|c: char| c.is_ascii_whitespace()) {
348 return Some(Err(Error::InvalidWhitespace(s.to_string())));
349 }
350
351 // What remains after ″ must be optional whitespace + exactly one direction
352 // character (N/S/E/W) OR nothing (→ this is signed DMS, not labeled).
353 let rest = rest.trim();
354 if rest.is_empty() {
355 // No direction letter — this is actually a signed DMS value; let the
356 // signed parser handle it.
357 return None;
358 }
359
360 // Validate direction character.
361 let direction = match rest {
362 "N" | "S" | "E" | "W" => rest,
363 other => {
364 let bad = other.chars().next().unwrap_or('?');
365 return Some(Err(Error::InvalidCharacter(bad, s.to_string())));
366 }
367 };
368
369 // Parse the numeric components (positive; sign comes from direction).
370 let degrees = match parse_degrees(deg_str, 1, 3, false) {
371 Some(d) => d,
372 None => {
373 return Some(Err(Error::InvalidNumericFormat(deg_str.to_string())));
374 }
375 };
376 let minutes = match parse_minutes(min_str) {
377 Some(m) => m,
378 None => {
379 return Some(Err(Error::InvalidNumericFormat(min_str.to_string())));
380 }
381 };
382 let seconds = match parse_seconds(sec_str) {
383 Some(t) => t,
384 None => {
385 return Some(Err(Error::InvalidNumericFormat(sec_str.to_string())));
386 }
387 };
388
389 let neg = matches!(direction, "S" | "W");
390 let signed_degrees = if neg { -degrees } else { degrees };
391
392 let float = match inner::from_degrees_minutes_seconds(signed_degrees, minutes, seconds) {
393 Ok(f) => f,
394 Err(e) => return Some(Err(e)),
395 };
396
397 match direction {
398 "N" | "S" => match Latitude::try_from(float) {
399 Ok(lat) => Some(Ok(Value::Latitude(lat))),
400 Err(_) => Some(Err(Error::InvalidLatitudeDegrees(
401 inner::to_degrees_minutes_seconds(float).0,
402 ))),
403 },
404 "E" | "W" => match Longitude::try_from(float) {
405 Ok(lon) => Some(Ok(Value::Longitude(lon))),
406 Err(_) => Some(Err(Error::InvalidLongitudeDegrees(
407 inner::to_degrees_minutes_seconds(float).0,
408 ))),
409 },
410 _ => unreachable!(),
411 }
412}
413
414/// `^(?<sign>[-+])?(?<degrees>\d{1,3})°\s*(?<minutes>\d{1,2})′\s*(?<seconds>\d{1,2}\.\d+)″$`
415///
416/// Returns `None` if the string doesn't look like a signed DMS value.
417fn try_signed_dms(s: &str) -> Option<Result<OrderedFloat<f64>, Error>> {
418 if !s.contains('°') {
419 return None;
420 }
421
422 // Optional leading sign.
423 let (neg, s_inner) = consume_sign(s);
424
425 // Rule 2: if we consumed a sign, the very next character must NOT be whitespace.
426 if neg && s_inner.starts_with(|c: char| c.is_ascii_whitespace()) {
427 return Some(Err(Error::InvalidWhitespace(s.to_string())));
428 }
429
430 let (deg_str, rest) = consume_up_to(s_inner, '°')?;
431
432 // Rule 7: no whitespace before °.
433 if deg_str.ends_with(|c: char| c.is_ascii_whitespace()) {
434 return Some(Err(Error::InvalidWhitespace(s.to_string())));
435 }
436
437 let rest = skip_whitespace(rest);
438 let (min_str, rest) = consume_up_to(rest, '′')?;
439
440 if min_str.ends_with(|c: char| c.is_ascii_whitespace()) {
441 return Some(Err(Error::InvalidWhitespace(s.to_string())));
442 }
443
444 let rest = skip_whitespace(rest);
445 let (sec_str, rest) = consume_up_to(rest, '″')?;
446
447 if sec_str.ends_with(|c: char| c.is_ascii_whitespace()) {
448 return Some(Err(Error::InvalidWhitespace(s.to_string())));
449 }
450
451 // After ″ there must be nothing left for the signed variant.
452 if !rest.trim().is_empty() {
453 // There's a direction letter — this is labeled DMS, not signed.
454 return None;
455 }
456
457 let degrees = match parse_degrees(deg_str, 1, 3, neg) {
458 Some(d) => d,
459 None => return Some(Err(Error::InvalidNumericFormat(deg_str.to_string()))),
460 };
461 let minutes = match parse_minutes(min_str) {
462 Some(m) => m,
463 None => return Some(Err(Error::InvalidNumericFormat(min_str.to_string()))),
464 };
465 let seconds = match parse_seconds(sec_str) {
466 Some(t) => t,
467 None => return Some(Err(Error::InvalidNumericFormat(sec_str.to_string()))),
468 };
469
470 Some(inner::from_degrees_minutes_seconds(
471 degrees, minutes, seconds,
472 ))
473}
474
475/// `^(?<sign>[-+])(?<degrees>\d{3}):(?<minutes>\d{2}):(?<seconds>\d{2}\.\d{4,})$`
476///
477/// Returns `None` if the string doesn't start with a sign followed by digits
478/// and colons.
479fn try_bare_dms(s: &str) -> Option<Result<OrderedFloat<f64>, Error>> {
480 // Must start with mandatory sign.
481 let neg = match s.as_bytes().first()? {
482 b'+' => false,
483 b'-' => true,
484 _ => return None,
485 };
486 let s_inner = &s[1..];
487
488 // Must contain at least two colons.
489 if !s_inner.contains(':') {
490 return None;
491 }
492
493 let (deg_str, rest) = consume_up_to(s_inner, ':')?;
494 let (min_str, sec_str) = consume_up_to(rest, ':')?;
495
496 // Validate lengths: exactly 3 degree digits, exactly 2 minute digits.
497 if deg_str.len() != 3 || min_str.len() != 2 {
498 return Some(Err(Error::InvalidNumericFormat(s.to_string())));
499 }
500
501 // Seconds must be `DD.DDDD+` — at least 4 fractional digits.
502 let dot_pos = sec_str.find('.')?;
503 if dot_pos != 2 || sec_str.len() < dot_pos + 1 + 4 {
504 return Some(Err(Error::InvalidNumericFormat(s.to_string())));
505 }
506
507 let degrees = match parse_degrees(deg_str, 3, 3, neg) {
508 Some(d) => d,
509 None => return Some(Err(Error::InvalidNumericFormat(s.to_string()))),
510 };
511 let minutes = match parse_minutes(min_str) {
512 Some(m) => m,
513 None => return Some(Err(Error::InvalidNumericFormat(s.to_string()))),
514 };
515 let seconds = match parse_seconds(sec_str) {
516 Some(t) => t,
517 None => return Some(Err(Error::InvalidNumericFormat(s.to_string()))),
518 };
519
520 Some(inner::from_degrees_minutes_seconds(
521 degrees, minutes, seconds,
522 ))
523}
524
525/// `^(?<sign>[-+])?(?<int>\d{1,3})\.(?<frac>\d+)$`
526///
527/// Returns an error (not `None`) on obvious format violations so callers can
528/// produce a good diagnostic.
529fn try_decimal(s: &str) -> Result<OrderedFloat<f64>, Error> {
530 // Rule 2: check for sign followed by whitespace.
531 let (neg, rest) = consume_sign(s);
532 if neg && rest.starts_with(|c: char| c.is_ascii_whitespace()) {
533 return Err(Error::InvalidWhitespace(s.to_string()));
534 }
535
536 // Must contain exactly one dot.
537 let dot = rest
538 .find('.')
539 .ok_or_else(|| Error::InvalidNumericFormat(s.to_string()))?;
540
541 let int_part = &rest[..dot];
542 let frac_part = &rest[dot + 1..];
543
544 // Integer part: 1–3 digits.
545 if int_part.is_empty() || int_part.len() > 3 || !int_part.bytes().all(|b| b.is_ascii_digit()) {
546 return Err(Error::InvalidNumericFormat(s.to_string()));
547 }
548 // Fractional part: ≥1 digit.
549 if frac_part.is_empty() || !frac_part.bytes().all(|b| b.is_ascii_digit()) {
550 return Err(Error::InvalidNumericFormat(s.to_string()));
551 }
552
553 let int_val = parse_u32_digits(int_part.as_bytes())
554 .ok_or_else(|| Error::InvalidNumericFormat(s.to_string()))?;
555 let frac_val = parse_fraction(frac_part.as_bytes())
556 .ok_or_else(|| Error::InvalidNumericFormat(s.to_string()))?;
557
558 let magnitude = int_val as f64 + frac_val;
559 let signed = if neg { -magnitude } else { magnitude };
560 Ok(OrderedFloat(signed))
561}
562
563// ---------------------------------------------------------------------------
564// Sub-parsers (pure, no allocation)
565// ---------------------------------------------------------------------------
566
567/// Consume an optional leading `+` or `-`. Returns `(is_negative, rest_of_str)`.
568fn consume_sign(s: &str) -> (bool, &str) {
569 match s.as_bytes().first() {
570 Some(b'+') => (false, &s[1..]),
571 Some(b'-') => (true, &s[1..]),
572 _ => (false, s),
573 }
574}
575
576/// Return the slice before and after the first occurrence of Unicode `delim`.
577/// Returns `None` if the delimiter is not found.
578fn consume_up_to(s: &str, delim: char) -> Option<(&str, &str)> {
579 let pos = s.find(delim)?;
580 Some((&s[..pos], &s[pos + delim.len_utf8()..]))
581}
582
583/// Skip leading ASCII whitespace.
584fn skip_whitespace(s: &str) -> &str {
585 s.trim_start_matches(|c: char| c.is_ascii_whitespace())
586}
587
588/// Parse a degree string with `min_len..=max_len` digit count.
589/// `neg` folds the sign into the return value.
590fn parse_degrees(s: &str, min_len: usize, max_len: usize, neg: bool) -> Option<i32> {
591 if s.len() < min_len || s.len() > max_len || !s.bytes().all(|b| b.is_ascii_digit()) {
592 return None;
593 }
594 let v = parse_u32_digits(s.as_bytes())? as i32;
595 Some(if neg { -v } else { v })
596}
597
598/// Parse a minutes string (1–2 digits).
599fn parse_minutes(s: &str) -> Option<u32> {
600 if s.is_empty() || s.len() > 2 || !s.bytes().all(|b| b.is_ascii_digit()) {
601 return None;
602 }
603 parse_u32_digits(s.as_bytes())
604}
605
606/// Parse a seconds string of the form `\d{1,2}\.\d+`.
607fn parse_seconds(s: &str) -> Option<f32> {
608 let dot = s.find('.')?;
609 let int_part = &s[..dot];
610 let frac_part = &s[dot + 1..];
611 if int_part.is_empty()
612 || int_part.len() > 2
613 || frac_part.is_empty()
614 || !int_part.bytes().all(|b| b.is_ascii_digit())
615 || !frac_part.bytes().all(|b| b.is_ascii_digit())
616 {
617 return None;
618 }
619 let int_val = parse_u32_digits(int_part.as_bytes())?;
620 let frac_val = parse_fraction(frac_part.as_bytes())?;
621 Some((int_val as f64 + frac_val) as f32)
622}
623
624/// Accumulate ASCII decimal digits into a `u32`. Returns `None` on non-digit
625/// bytes or overflow.
626fn parse_u32_digits(bytes: &[u8]) -> Option<u32> {
627 let mut acc: u32 = 0;
628 for &b in bytes {
629 if !b.is_ascii_digit() {
630 return None;
631 }
632 acc = acc.checked_mul(10)?.checked_add((b - b'0') as u32)?;
633 }
634 Some(acc)
635}
636
637/// Convert the fractional-digit bytes after a `.` into a `f64` in `[0, 1)`.
638fn parse_fraction(bytes: &[u8]) -> Option<f64> {
639 let mut acc: f64 = 0.0;
640 let mut place: f64 = 0.1;
641 for &b in bytes {
642 if !b.is_ascii_digit() {
643 return None;
644 }
645 acc += (b - b'0') as f64 * place;
646 place *= 0.1;
647 }
648 Some(acc)
649}