oxiproj-engine 0.1.1

Proj-string parser, operation dispatch, and transformation pipelines for OxiProj.
Documentation
//! Proj-string tokenizer and parameter lookup.
//!
//! Ported from PROJ 9.8.0 `src/param.cpp` (token parsing + `pj_param` family).

use oxiproj_projections::ProjParamLookup;
use oxiproj_transformations::TransParamLookup;

/// An ordered list of parsed proj-string parameters.
///
/// Each entry is a `(key, optional value)` pair preserving source order, so that
/// pipeline tokens (`proj=pipeline`, `step`, `inv`) keep their relative positions.
#[derive(Debug, Clone)]
pub struct ParamList {
    /// Ordered `(key, optional value)` entries.
    pub entries: Vec<(String, Option<String>)>,
}

/// Parse a proj-string into an ordered [`ParamList`].
///
/// Splits on ASCII whitespace, strips one leading `+` per token, and splits each
/// token on the first `=`. Bare tokens become `(key, None)`. Cannot fail.
///
/// Ported from `src/param.cpp` (argument tokenization).
pub fn parse(s: &str) -> ParamList {
    let mut entries: Vec<(String, Option<String>)> = Vec::new();
    for raw in s.split_ascii_whitespace() {
        let tok = raw.strip_prefix('+').unwrap_or(raw);
        if tok.is_empty() {
            continue;
        }
        match tok.split_once('=') {
            Some((key, value)) => entries.push((key.to_string(), Some(value.to_string()))),
            None => entries.push((tok.to_string(), None)),
        }
    }
    ParamList { entries }
}

impl ParamList {
    /// Return `true` if any entry has the given key.
    pub fn exists(&self, key: &str) -> bool {
        self.entries.iter().any(|(k, _)| k == key)
    }

    /// Return the string value of the first matching key.
    ///
    /// A bare key (no `=`) yields `Some("")`; an absent key yields `None`.
    pub fn get_str(&self, key: &str) -> Option<&str> {
        for (k, v) in &self.entries {
            if k == key {
                return match v {
                    Some(val) => Some(val.as_str()),
                    None => Some(""),
                };
            }
        }
        None
    }

    /// Return the value of the first matching key parsed as `f64`.
    ///
    /// Falls back to a leading-number parse when a strict parse fails.
    pub fn get_f64(&self, key: &str) -> Option<f64> {
        for (k, v) in &self.entries {
            if k == key {
                let val = v.as_ref()?;
                if let Ok(parsed) = val.trim().parse::<f64>() {
                    return Some(parsed);
                }
                return oxiproj_core::parse_leading_f64(val).map(|(x, _)| x);
            }
        }
        None
    }

    /// Return the value of the first matching key parsed as DMS radians.
    pub fn get_dms(&self, key: &str) -> Option<f64> {
        for (k, v) in &self.entries {
            if k == key {
                let val = v.as_ref()?;
                return oxiproj_core::dmstor(val).ok();
            }
        }
        None
    }

    /// Return the value of the first matching key parsed as `i64`.
    pub fn get_int(&self, key: &str) -> Option<i64> {
        for (k, v) in &self.entries {
            if k == key {
                let val = v.as_ref()?;
                return val.trim().parse::<i64>().ok();
            }
        }
        None
    }

    /// Return the boolean interpretation of the first matching key.
    ///
    /// Absent -> `false`. A bare key -> `true`. An explicit `f`/`F`/`false` ->
    /// `false`; any other present value -> `true` (PROJ treats any present
    /// non-false value as true).
    pub fn get_bool(&self, key: &str) -> bool {
        for (k, v) in &self.entries {
            if k == key {
                return match v {
                    None => true,
                    Some(val) => !matches!(val.as_str(), "f" | "F" | "false"),
                };
            }
        }
        false
    }
}

/// A borrowing view over a [`ParamList`] implementing [`ProjParamLookup`] and [`TransParamLookup`].
pub struct ParamView<'a>(pub &'a ParamList);

impl ProjParamLookup for ParamView<'_> {
    fn get_dms(&self, key: &str) -> Option<f64> {
        self.0.get_dms(key)
    }
    fn get_f64(&self, key: &str) -> Option<f64> {
        self.0.get_f64(key)
    }
    fn get_int(&self, key: &str) -> Option<i64> {
        self.0.get_int(key)
    }
    fn get_str(&self, key: &str) -> Option<&str> {
        self.0.get_str(key)
    }
    fn get_bool(&self, key: &str) -> bool {
        self.0.get_bool(key)
    }
    fn exists(&self, key: &str) -> bool {
        self.0.exists(key)
    }
}

impl TransParamLookup for ParamView<'_> {
    fn get_dms(&self, key: &str) -> Option<f64> {
        self.0.get_dms(key)
    }
    fn get_f64(&self, key: &str) -> Option<f64> {
        self.0.get_f64(key)
    }
    fn get_int(&self, key: &str) -> Option<i64> {
        self.0.get_int(key)
    }
    fn get_str(&self, key: &str) -> Option<&str> {
        self.0.get_str(key)
    }
    fn get_bool(&self, key: &str) -> bool {
        self.0.get_bool(key)
    }
    fn exists(&self, key: &str) -> bool {
        self.0.exists(key)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use oxiproj_core::DEG_TO_RAD;

    #[test]
    fn parses_basic_merc() {
        let pl = parse("+proj=merc +lat_ts=45 +ellps=WGS84");
        assert_eq!(pl.entries.len(), 3);
        assert_eq!(pl.get_str("proj"), Some("merc"));
        let lat_ts = pl.get_dms("lat_ts").unwrap();
        assert!((lat_ts - 45.0 * DEG_TO_RAD).abs() < 1e-12);
    }

    #[test]
    fn bare_flag_is_true() {
        let pl = parse("+south");
        assert_eq!(pl.entries.len(), 1);
        assert_eq!(pl.entries[0], ("south".to_string(), None));
        assert!(pl.get_bool("south"));
        assert!(!pl.get_bool("missing"));
    }

    #[test]
    fn parses_int() {
        let pl = parse("+zone=32");
        assert_eq!(pl.get_int("zone"), Some(32));
    }

    #[test]
    fn parses_pipeline_tokens() {
        let pl = parse("+proj=pipeline +step +proj=utm +zone=32 +step +inv");
        let e = &pl.entries;
        assert!(e.contains(&("proj".to_string(), Some("pipeline".to_string()))));
        assert!(e.contains(&("step".to_string(), None)));
        assert!(e.contains(&("proj".to_string(), Some("utm".to_string()))));
        assert!(e.contains(&("zone".to_string(), Some("32".to_string()))));
        assert!(e.contains(&("inv".to_string(), None)));
        // step appears twice
        assert_eq!(
            e.iter().filter(|(k, v)| k == "step" && v.is_none()).count(),
            2
        );
    }

    #[test]
    fn lon_0_dms() {
        let pl = parse("+lon_0=9");
        let v = pl.get_dms("lon_0").unwrap();
        assert!((v - 9.0 * DEG_TO_RAD).abs() < 1e-12);
        assert!((v - 0.157079632679).abs() < 1e-9);
    }

    #[test]
    fn false_values_are_false() {
        let pl = parse("+over=false +foo=f +bar=t");
        assert!(!pl.get_bool("over"));
        assert!(!pl.get_bool("foo"));
        assert!(pl.get_bool("bar"));
    }
}