oxiproj-engine 0.1.2

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>)>,
}

/// Normalize a proj-string the way PROJ's `pj_shrink` (`src/internal.cpp`) does
/// before tokenizing: collapse runs of whitespace, and make `=` and `,` greedy
/// so they consume any surrounding whitespace.
///
/// This is what lets a proj-string written with spaces around the equals sign
/// (`+x = 0.0127`, common in the vendored `gie` corpus's helmert cases) or with
/// spaces after the commas of a value list (`+towgs84 = 1, 2, 3`) tokenize into
/// the single `key=value` / `key=v1,v2,v3` tokens the parser expects, instead of
/// dropping the value token. Without it, `x = 0.0127` splits into three tokens
/// (`x`, `=`, `0.0127`) and the parameter is silently lost.
///
/// A double-quoted substring is passed through verbatim (its spaces, `=` and `,`
/// are preserved), matching PROJ's `in_string` handling. The leading `+` sigils
/// are deliberately left in place — the per-token `strip_prefix('+')` in
/// [`parse`] removes them afterwards.
fn shrink(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut in_string = false;
    // A pending, not-yet-emitted single space collapsed from a whitespace run.
    let mut pending_ws = false;
    // The last character actually pushed to `out` (used to make `=`/`,` greedy).
    let mut last_emitted: Option<char> = None;
    for ch in s.chars() {
        if in_string {
            out.push(ch);
            if ch == '"' {
                in_string = false;
            }
            last_emitted = Some(ch);
            continue;
        }
        if ch == '"' {
            if pending_ws && last_emitted.is_some() {
                out.push(' ');
            }
            pending_ws = false;
            out.push(ch);
            last_emitted = Some(ch);
            in_string = true;
            continue;
        }
        if ch.is_whitespace() {
            pending_ws = true;
            continue;
        }
        if ch == '=' || ch == ',' {
            // Greedy: drop any whitespace immediately before the separator.
            pending_ws = false;
            out.push(ch);
            last_emitted = Some(ch);
            continue;
        }
        // Ordinary character: flush a pending space unless the last emitted
        // character was a greedy separator (which swallows the space after it).
        if pending_ws {
            pending_ws = false;
            if !matches!(last_emitted, None | Some('=') | Some(',')) {
                out.push(' ');
            }
        }
        out.push(ch);
        last_emitted = Some(ch);
    }
    out
}

/// Parse a proj-string into an ordered [`ParamList`].
///
/// First normalizes the string with `shrink` (PROJ `pj_shrink`: collapse
/// whitespace, make `=`/`,` greedy), then 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) plus `src/internal.cpp`
/// (`pj_shrink`).
pub fn parse(s: &str) -> ParamList {
    let normalized = shrink(s);
    let mut entries: Vec<(String, Option<String>)> = Vec::new();
    for raw in normalized.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 shrink_collapses_spaces_around_equals() {
        // PROJ pj_shrink: `key = value` -> `key=value`; the value token must
        // survive tokenization (regression for the dropped-helmert-params bug).
        let pl = parse("+proj=helmert x = 0.01270  dx =-0.0029  s = 0.00195");
        assert_eq!(pl.get_str("proj"), Some("helmert"));
        assert_eq!(pl.get_str("x"), Some("0.01270"));
        assert_eq!(pl.get_str("dx"), Some("-0.0029"));
        assert_eq!(pl.get_str("s"), Some("0.00195"));
    }

    #[test]
    fn shrink_makes_commas_greedy() {
        // Spaces after the commas of a value list are collapsed so the whole
        // list is one token (PROJ pj_shrink `,` greediness).
        let pl = parse("+proj=helmert towgs84 = 1, 2, 3, 4, 5, 6, 7");
        assert_eq!(pl.get_str("towgs84"), Some("1,2,3,4,5,6,7"));
    }

    #[test]
    fn shrink_preserves_scientific_notation_plus() {
        // A `+` inside a numeric literal is not a token sigil and must survive.
        let pl = parse("+proj=merc +foo=1.23e+08");
        assert_eq!(pl.get_str("foo"), Some("1.23e+08"));
    }

    #[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"));
    }
}