Skip to main content

oxiproj_engine/
params.rs

1//! Proj-string tokenizer and parameter lookup.
2//!
3//! Ported from PROJ 9.8.0 `src/param.cpp` (token parsing + `pj_param` family).
4
5use oxiproj_projections::ProjParamLookup;
6use oxiproj_transformations::TransParamLookup;
7
8/// An ordered list of parsed proj-string parameters.
9///
10/// Each entry is a `(key, optional value)` pair preserving source order, so that
11/// pipeline tokens (`proj=pipeline`, `step`, `inv`) keep their relative positions.
12#[derive(Debug, Clone)]
13pub struct ParamList {
14    /// Ordered `(key, optional value)` entries.
15    pub entries: Vec<(String, Option<String>)>,
16}
17
18/// Normalize a proj-string the way PROJ's `pj_shrink` (`src/internal.cpp`) does
19/// before tokenizing: collapse runs of whitespace, and make `=` and `,` greedy
20/// so they consume any surrounding whitespace.
21///
22/// This is what lets a proj-string written with spaces around the equals sign
23/// (`+x = 0.0127`, common in the vendored `gie` corpus's helmert cases) or with
24/// spaces after the commas of a value list (`+towgs84 = 1, 2, 3`) tokenize into
25/// the single `key=value` / `key=v1,v2,v3` tokens the parser expects, instead of
26/// dropping the value token. Without it, `x = 0.0127` splits into three tokens
27/// (`x`, `=`, `0.0127`) and the parameter is silently lost.
28///
29/// A double-quoted substring is passed through verbatim (its spaces, `=` and `,`
30/// are preserved), matching PROJ's `in_string` handling. The leading `+` sigils
31/// are deliberately left in place — the per-token `strip_prefix('+')` in
32/// [`parse`] removes them afterwards.
33fn shrink(s: &str) -> String {
34    let mut out = String::with_capacity(s.len());
35    let mut in_string = false;
36    // A pending, not-yet-emitted single space collapsed from a whitespace run.
37    let mut pending_ws = false;
38    // The last character actually pushed to `out` (used to make `=`/`,` greedy).
39    let mut last_emitted: Option<char> = None;
40    for ch in s.chars() {
41        if in_string {
42            out.push(ch);
43            if ch == '"' {
44                in_string = false;
45            }
46            last_emitted = Some(ch);
47            continue;
48        }
49        if ch == '"' {
50            if pending_ws && last_emitted.is_some() {
51                out.push(' ');
52            }
53            pending_ws = false;
54            out.push(ch);
55            last_emitted = Some(ch);
56            in_string = true;
57            continue;
58        }
59        if ch.is_whitespace() {
60            pending_ws = true;
61            continue;
62        }
63        if ch == '=' || ch == ',' {
64            // Greedy: drop any whitespace immediately before the separator.
65            pending_ws = false;
66            out.push(ch);
67            last_emitted = Some(ch);
68            continue;
69        }
70        // Ordinary character: flush a pending space unless the last emitted
71        // character was a greedy separator (which swallows the space after it).
72        if pending_ws {
73            pending_ws = false;
74            if !matches!(last_emitted, None | Some('=') | Some(',')) {
75                out.push(' ');
76            }
77        }
78        out.push(ch);
79        last_emitted = Some(ch);
80    }
81    out
82}
83
84/// Parse a proj-string into an ordered [`ParamList`].
85///
86/// First normalizes the string with `shrink` (PROJ `pj_shrink`: collapse
87/// whitespace, make `=`/`,` greedy), then splits on ASCII whitespace, strips one
88/// leading `+` per token, and splits each token on the first `=`. Bare tokens
89/// become `(key, None)`. Cannot fail.
90///
91/// Ported from `src/param.cpp` (argument tokenization) plus `src/internal.cpp`
92/// (`pj_shrink`).
93pub fn parse(s: &str) -> ParamList {
94    let normalized = shrink(s);
95    let mut entries: Vec<(String, Option<String>)> = Vec::new();
96    for raw in normalized.split_ascii_whitespace() {
97        let tok = raw.strip_prefix('+').unwrap_or(raw);
98        if tok.is_empty() {
99            continue;
100        }
101        match tok.split_once('=') {
102            Some((key, value)) => entries.push((key.to_string(), Some(value.to_string()))),
103            None => entries.push((tok.to_string(), None)),
104        }
105    }
106    ParamList { entries }
107}
108
109impl ParamList {
110    /// Return `true` if any entry has the given key.
111    pub fn exists(&self, key: &str) -> bool {
112        self.entries.iter().any(|(k, _)| k == key)
113    }
114
115    /// Return the string value of the first matching key.
116    ///
117    /// A bare key (no `=`) yields `Some("")`; an absent key yields `None`.
118    pub fn get_str(&self, key: &str) -> Option<&str> {
119        for (k, v) in &self.entries {
120            if k == key {
121                return match v {
122                    Some(val) => Some(val.as_str()),
123                    None => Some(""),
124                };
125            }
126        }
127        None
128    }
129
130    /// Return the value of the first matching key parsed as `f64`.
131    ///
132    /// Falls back to a leading-number parse when a strict parse fails.
133    pub fn get_f64(&self, key: &str) -> Option<f64> {
134        for (k, v) in &self.entries {
135            if k == key {
136                let val = v.as_ref()?;
137                if let Ok(parsed) = val.trim().parse::<f64>() {
138                    return Some(parsed);
139                }
140                return oxiproj_core::parse_leading_f64(val).map(|(x, _)| x);
141            }
142        }
143        None
144    }
145
146    /// Return the value of the first matching key parsed as DMS radians.
147    pub fn get_dms(&self, key: &str) -> Option<f64> {
148        for (k, v) in &self.entries {
149            if k == key {
150                let val = v.as_ref()?;
151                return oxiproj_core::dmstor(val).ok();
152            }
153        }
154        None
155    }
156
157    /// Return the value of the first matching key parsed as `i64`.
158    pub fn get_int(&self, key: &str) -> Option<i64> {
159        for (k, v) in &self.entries {
160            if k == key {
161                let val = v.as_ref()?;
162                return val.trim().parse::<i64>().ok();
163            }
164        }
165        None
166    }
167
168    /// Return the boolean interpretation of the first matching key.
169    ///
170    /// Absent -> `false`. A bare key -> `true`. An explicit `f`/`F`/`false` ->
171    /// `false`; any other present value -> `true` (PROJ treats any present
172    /// non-false value as true).
173    pub fn get_bool(&self, key: &str) -> bool {
174        for (k, v) in &self.entries {
175            if k == key {
176                return match v {
177                    None => true,
178                    Some(val) => !matches!(val.as_str(), "f" | "F" | "false"),
179                };
180            }
181        }
182        false
183    }
184}
185
186/// A borrowing view over a [`ParamList`] implementing [`ProjParamLookup`] and [`TransParamLookup`].
187pub struct ParamView<'a>(pub &'a ParamList);
188
189impl ProjParamLookup for ParamView<'_> {
190    fn get_dms(&self, key: &str) -> Option<f64> {
191        self.0.get_dms(key)
192    }
193    fn get_f64(&self, key: &str) -> Option<f64> {
194        self.0.get_f64(key)
195    }
196    fn get_int(&self, key: &str) -> Option<i64> {
197        self.0.get_int(key)
198    }
199    fn get_str(&self, key: &str) -> Option<&str> {
200        self.0.get_str(key)
201    }
202    fn get_bool(&self, key: &str) -> bool {
203        self.0.get_bool(key)
204    }
205    fn exists(&self, key: &str) -> bool {
206        self.0.exists(key)
207    }
208}
209
210impl TransParamLookup for ParamView<'_> {
211    fn get_dms(&self, key: &str) -> Option<f64> {
212        self.0.get_dms(key)
213    }
214    fn get_f64(&self, key: &str) -> Option<f64> {
215        self.0.get_f64(key)
216    }
217    fn get_int(&self, key: &str) -> Option<i64> {
218        self.0.get_int(key)
219    }
220    fn get_str(&self, key: &str) -> Option<&str> {
221        self.0.get_str(key)
222    }
223    fn get_bool(&self, key: &str) -> bool {
224        self.0.get_bool(key)
225    }
226    fn exists(&self, key: &str) -> bool {
227        self.0.exists(key)
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use oxiproj_core::DEG_TO_RAD;
235
236    #[test]
237    fn parses_basic_merc() {
238        let pl = parse("+proj=merc +lat_ts=45 +ellps=WGS84");
239        assert_eq!(pl.entries.len(), 3);
240        assert_eq!(pl.get_str("proj"), Some("merc"));
241        let lat_ts = pl.get_dms("lat_ts").unwrap();
242        assert!((lat_ts - 45.0 * DEG_TO_RAD).abs() < 1e-12);
243    }
244
245    #[test]
246    fn bare_flag_is_true() {
247        let pl = parse("+south");
248        assert_eq!(pl.entries.len(), 1);
249        assert_eq!(pl.entries[0], ("south".to_string(), None));
250        assert!(pl.get_bool("south"));
251        assert!(!pl.get_bool("missing"));
252    }
253
254    #[test]
255    fn parses_int() {
256        let pl = parse("+zone=32");
257        assert_eq!(pl.get_int("zone"), Some(32));
258    }
259
260    #[test]
261    fn parses_pipeline_tokens() {
262        let pl = parse("+proj=pipeline +step +proj=utm +zone=32 +step +inv");
263        let e = &pl.entries;
264        assert!(e.contains(&("proj".to_string(), Some("pipeline".to_string()))));
265        assert!(e.contains(&("step".to_string(), None)));
266        assert!(e.contains(&("proj".to_string(), Some("utm".to_string()))));
267        assert!(e.contains(&("zone".to_string(), Some("32".to_string()))));
268        assert!(e.contains(&("inv".to_string(), None)));
269        // step appears twice
270        assert_eq!(
271            e.iter().filter(|(k, v)| k == "step" && v.is_none()).count(),
272            2
273        );
274    }
275
276    #[test]
277    fn lon_0_dms() {
278        let pl = parse("+lon_0=9");
279        let v = pl.get_dms("lon_0").unwrap();
280        assert!((v - 9.0 * DEG_TO_RAD).abs() < 1e-12);
281        assert!((v - 0.157079632679).abs() < 1e-9);
282    }
283
284    #[test]
285    fn shrink_collapses_spaces_around_equals() {
286        // PROJ pj_shrink: `key = value` -> `key=value`; the value token must
287        // survive tokenization (regression for the dropped-helmert-params bug).
288        let pl = parse("+proj=helmert x = 0.01270  dx =-0.0029  s = 0.00195");
289        assert_eq!(pl.get_str("proj"), Some("helmert"));
290        assert_eq!(pl.get_str("x"), Some("0.01270"));
291        assert_eq!(pl.get_str("dx"), Some("-0.0029"));
292        assert_eq!(pl.get_str("s"), Some("0.00195"));
293    }
294
295    #[test]
296    fn shrink_makes_commas_greedy() {
297        // Spaces after the commas of a value list are collapsed so the whole
298        // list is one token (PROJ pj_shrink `,` greediness).
299        let pl = parse("+proj=helmert towgs84 = 1, 2, 3, 4, 5, 6, 7");
300        assert_eq!(pl.get_str("towgs84"), Some("1,2,3,4,5,6,7"));
301    }
302
303    #[test]
304    fn shrink_preserves_scientific_notation_plus() {
305        // A `+` inside a numeric literal is not a token sigil and must survive.
306        let pl = parse("+proj=merc +foo=1.23e+08");
307        assert_eq!(pl.get_str("foo"), Some("1.23e+08"));
308    }
309
310    #[test]
311    fn false_values_are_false() {
312        let pl = parse("+over=false +foo=f +bar=t");
313        assert!(!pl.get_bool("over"));
314        assert!(!pl.get_bool("foo"));
315        assert!(pl.get_bool("bar"));
316    }
317}