aerocontext-planning 0.4.2

Flight-route planning over aerocontext: Garmin .fpl import/export, route expansion, corridor generation, and the vertical cross-section
Documentation
//! Route-string tokenizer: hand-rolled shape tests, no regex.

use aerocontext_core::GeoPoint;

use super::{RouteError, RouteToken, UnsupportedKind};

/// Designator prefixes of published airway families (NASR `AWY_ID`
/// values: V/J Victor/Jet, T/Q low/high RNAV, TK helicopter RNAV, plus
/// the Atlantic/Pacific/Alaska letter families).
const AIRWAY_PREFIXES: [&str; 15] = [
    "TK", "V", "J", "T", "Q", "A", "B", "G", "R", "Y", "L", "M", "N", "W", "H",
];

/// Tokenize a route string. Fails on the first unsupported token — a
/// corridor must never silently omit legs.
pub fn parse(route: &str) -> Result<Vec<RouteToken>, RouteError> {
    let raw: Vec<&str> = route.split_whitespace().collect();
    if raw.is_empty() {
        return Err(RouteError::Empty);
    }
    let mut tokens = Vec::with_capacity(raw.len());
    for token in raw {
        tokens.push(classify(&token.to_ascii_uppercase())?);
    }
    structural_checks(&tokens)?;
    Ok(tokens)
}

fn classify(token: &str) -> Result<RouteToken, RouteError> {
    if token.contains('/') {
        return Err(RouteError::UnsupportedToken {
            kind: slash_kind(token),
            token: token.to_owned(),
        });
    }
    if let Some(point) = lat_lon(token) {
        return Ok(RouteToken::LatLon(point));
    }
    if is_fix_radial_distance(token) {
        return Err(RouteError::UnsupportedToken {
            kind: UnsupportedKind::FixRadialDistance,
            token: token.to_owned(),
        });
    }
    if token == "DCT" {
        return Ok(RouteToken::Dct);
    }
    if let Some((name, transition)) = token.split_once('.') {
        return Ok(RouteToken::Procedure {
            name: name.to_owned(),
            transition: Some(transition.to_owned()),
        });
    }
    if is_airway(token) {
        return Ok(RouteToken::Airway(token.to_owned()));
    }
    Ok(RouteToken::Ident(token.to_owned()))
}

fn structural_checks(tokens: &[RouteToken]) -> Result<(), RouteError> {
    if let Some(RouteToken::Airway(airway)) = tokens.first() {
        return Err(RouteError::AirwayAtRouteEdge {
            airway: airway.clone(),
        });
    }
    if let Some(RouteToken::Airway(airway)) = tokens.last() {
        return Err(RouteError::AirwayAtRouteEdge {
            airway: airway.clone(),
        });
    }
    for pair in tokens.windows(2) {
        if let [RouteToken::Airway(first), RouteToken::Airway(second)] = pair {
            return Err(RouteError::AdjacentAirways {
                first: first.clone(),
                second: second.clone(),
            });
        }
    }
    Ok(())
}

fn slash_kind(token: &str) -> UnsupportedKind {
    // FIX/NxxxxFxxx speed-altitude blocks: the part after the slash starts
    // with N or M followed by digits.
    let after = token.split_once('/').map(|(_, rest)| rest).unwrap_or("");
    let mut chars = after.chars();
    match (chars.next(), chars.next()) {
        (Some('N' | 'M' | 'K'), Some(d)) if d.is_ascii_digit() => {
            UnsupportedKind::SpeedAltitudeChange
        }
        _ => UnsupportedKind::SlashToken,
    }
}

/// `45N073W` (7 chars) or `4530N07315W` (11 chars).
fn lat_lon(token: &str) -> Option<GeoPoint> {
    // Byte-offset slicing below is char-safe only for ASCII; a multi-byte
    // char straddling an offset would panic the slice.
    if !token.is_ascii() {
        return None;
    }
    let bytes = token.as_bytes();
    match bytes.len() {
        7 => {
            let lat = parse_digits(&token[0..2])?;
            let ns = bytes[2];
            let lon = parse_digits(&token[3..6])?;
            let ew = bytes[6];
            build_lat_lon(lat, 0, ns, lon, 0, ew)
        }
        11 => {
            let lat = parse_digits(&token[0..2])?;
            let lat_min = parse_digits(&token[2..4])?;
            let ns = bytes[4];
            let lon = parse_digits(&token[5..8])?;
            let lon_min = parse_digits(&token[8..10])?;
            let ew = bytes[10];
            build_lat_lon(lat, lat_min, ns, lon, lon_min, ew)
        }
        _ => None,
    }
}

fn parse_digits(text: &str) -> Option<u32> {
    text.chars()
        .all(|c| c.is_ascii_digit())
        .then(|| text.parse().ok())
        .flatten()
}

fn build_lat_lon(
    lat_deg: u32,
    lat_min: u32,
    ns: u8,
    lon_deg: u32,
    lon_min: u32,
    ew: u8,
) -> Option<GeoPoint> {
    if lat_deg > 90 || lat_min >= 60 || lon_deg > 180 || lon_min >= 60 {
        return None;
    }
    let lat_sign = match ns {
        b'N' => 1.0,
        b'S' => -1.0,
        _ => return None,
    };
    let lon_sign = match ew {
        b'E' => 1.0,
        b'W' => -1.0,
        _ => return None,
    };
    Some(GeoPoint {
        lat: lat_sign * (f64::from(lat_deg) + f64::from(lat_min) / 60.0),
        lon: lon_sign * (f64::from(lon_deg) + f64::from(lon_min) / 60.0),
    })
}

/// `OAK090025` / `IRW12503`-style: 2–5 letters followed by 5–6 digits
/// (radial + distance) — far longer than any published fix ident.
fn is_fix_radial_distance(token: &str) -> bool {
    let letters = token.chars().take_while(char::is_ascii_uppercase).count();
    let digits = token.len() - letters;
    (2..=5).contains(&letters)
        && (5..=6).contains(&digits)
        && token[letters..].chars().all(|c| c.is_ascii_digit())
}

/// Airway designator shape: a known family prefix plus 1–3 digits (`TK`
/// checked before `T`).
fn is_airway(token: &str) -> bool {
    AIRWAY_PREFIXES.iter().any(|prefix| {
        token.len() > prefix.len()
            && token.len() <= prefix.len() + 3
            && token.starts_with(prefix)
            && token[prefix.len()..].chars().all(|c| c.is_ascii_digit())
    })
}