Skip to main content

frust_widgets/nav/
path.rs

1//! Location parsing and path-pattern matching for the [`router`](super::router):
2//! the go_router-subset path layer.
3//!
4//! Two pieces, both dependency-free (hand-rolled, no `regex`):
5//!
6//! * [`Location`] — a *concrete* navigation target (`/users/42?tab=posts`) parsed
7//!   into normalized path segments plus a query map. This is what `go`/`push`
8//!   resolve and what a deep link delivers.
9//! * [`PathPattern`] — a compiled *route path* (`/users/:id`) whose
10//!   [`match_prefix`](PathPattern::match_prefix) consumes a run of location
11//!   segments, capturing every `:param` into a [`RouteParams`] map. The
12//!   [`router`](super::router) walks the route tree matching each route's pattern
13//!   against the remaining segments, so parent + child patterns compose (go_router
14//!   nesting semantics).
15//!
16//! No wildcards in v1 (only static segments and `:param`); percent-decoding is
17//! minimal (`%XX` hex escapes), enough for the custom-scheme deep links this
18//! router targets.
19
20use std::collections::BTreeMap;
21
22/// Path/query parameters captured by a route match, keyed by name. A `BTreeMap`
23/// so iteration (and therefore any derived query string) is deterministic.
24pub type RouteParams = BTreeMap<String, String>;
25
26/// A concrete navigation target: a normalized path plus its parsed segments and
27/// query map. Produced by [`Location::parse`]; the unit the
28/// [`router`](super::router) matches, redirects, and drives the navigator from.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct Location {
31    /// The normalized path (leading slash, no trailing slash, percent-decoded):
32    /// `/users/42`. The root path is `/`.
33    pub path: String,
34    /// The path split into decoded segments: `["users", "42"]`. Empty for `/`.
35    pub segments: Vec<String>,
36    /// The parsed, decoded query map (`?tab=posts&sort=asc` → `{tab: posts,
37    /// sort: asc}`). A key with no `=` maps to an empty string.
38    pub query: RouteParams,
39}
40
41impl Location {
42    /// Parse a raw location string (`/users/:id`-style patterns are *not* parsed
43    /// here — this is a concrete location like `/users/42?tab=posts`).
44    ///
45    /// Splits off the query at the first `?`, normalizes the path (strips a
46    /// leading/trailing slash, percent-decodes each segment — trailing-slash
47    /// tolerant), and parses `&`-separated `key=value` query pairs.
48    pub fn parse(raw: &str) -> Location {
49        let (path_part, query_part) = match raw.split_once('?') {
50            Some((p, q)) => (p, Some(q)),
51            None => (raw, None),
52        };
53
54        let segments: Vec<String> = path_part
55            .split('/')
56            .filter(|s| !s.is_empty())
57            .map(percent_decode)
58            .collect();
59
60        let path = if segments.is_empty() {
61            "/".to_string()
62        } else {
63            format!("/{}", segments.join("/"))
64        };
65
66        let mut query = RouteParams::new();
67        if let Some(q) = query_part {
68            for pair in q.split('&').filter(|s| !s.is_empty()) {
69                let (k, v) = match pair.split_once('=') {
70                    Some((k, v)) => (percent_decode(k), percent_decode(v)),
71                    None => (percent_decode(pair), String::new()),
72                };
73                query.insert(k, v);
74            }
75        }
76
77        Location {
78            path,
79            segments,
80            query,
81        }
82    }
83
84    /// The canonical string form (`path` plus a deterministically-ordered query
85    /// string). Used to compare two locations for redirect fixed-point/loop
86    /// detection, so it must be stable — hence the `BTreeMap`-ordered query.
87    pub fn location_string(&self) -> String {
88        if self.query.is_empty() {
89            self.path.clone()
90        } else {
91            let query: Vec<String> = self
92                .query
93                .iter()
94                .map(|(k, v)| {
95                    if v.is_empty() {
96                        percent_encode(k)
97                    } else {
98                        format!("{}={}", percent_encode(k), percent_encode(v))
99                    }
100                })
101                .collect();
102            format!("{}?{}", self.path, query.join("&"))
103        }
104    }
105}
106
107/// One segment of a compiled [`PathPattern`].
108#[derive(Clone, Debug, PartialEq, Eq)]
109enum Segment {
110    /// A literal segment that must match a location segment verbatim.
111    Static(String),
112    /// A `:name` capture: matches any single location segment, binding it to
113    /// `name` in the resulting [`RouteParams`].
114    Param(String),
115}
116
117/// A compiled route path pattern (`/users/:id`) — a sequence of static and
118/// `:param` [`Segment`]s. Matched against a run of [`Location`] segments by
119/// [`match_prefix`](Self::match_prefix); the [`router`](super::router) composes
120/// nested patterns by matching parent then child against successive segment runs.
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct PathPattern {
123    segments: Vec<Segment>,
124}
125
126impl PathPattern {
127    /// Compile a pattern string into segments (trailing/leading slashes ignored).
128    /// A `:name` segment is a parameter capture; everything else is static.
129    pub fn parse(pattern: &str) -> PathPattern {
130        let segments = pattern
131            .split('/')
132            .filter(|s| !s.is_empty())
133            .map(|s| match s.strip_prefix(':') {
134                Some(name) => Segment::Param(name.to_string()),
135                None => Segment::Static(s.to_string()),
136            })
137            .collect();
138        PathPattern { segments }
139    }
140
141    /// The number of segments this pattern consumes.
142    pub fn len(&self) -> usize {
143        self.segments.len()
144    }
145
146    /// Whether this pattern is empty (an index/root pattern that consumes no
147    /// segments — e.g. a `/` route or a path-less shell route).
148    pub fn is_empty(&self) -> bool {
149        self.segments.is_empty()
150    }
151
152    /// Try to match this pattern as a *prefix* of `segs`, capturing every
153    /// `:param`. Returns `(consumed, params)` — the number of leading segments
154    /// consumed (always `self.len()`) and the captured params — or `None` if a
155    /// static segment mismatches or there are too few segments to cover the
156    /// pattern.
157    ///
158    /// Prefix (not whole-slice) matching is what lets nested routes compose: a
159    /// parent consumes its segments, then the router feeds the remainder to the
160    /// child.
161    pub fn match_prefix(&self, segs: &[String]) -> Option<(usize, RouteParams)> {
162        if segs.len() < self.segments.len() {
163            return None;
164        }
165        let mut params = RouteParams::new();
166        for (pat, seg) in self.segments.iter().zip(segs.iter()) {
167            match pat {
168                Segment::Static(s) => {
169                    if s != seg {
170                        return None;
171                    }
172                }
173                Segment::Param(name) => {
174                    params.insert(name.clone(), seg.clone());
175                }
176            }
177        }
178        Some((self.segments.len(), params))
179    }
180}
181
182/// Decode minimal `%XX` percent-escapes in a single path/query token. Invalid or
183/// truncated escapes are passed through literally (lenient — a deep link with a
184/// stray `%` should not vanish).
185fn percent_decode(s: &str) -> String {
186    if !s.contains('%') {
187        return s.to_string();
188    }
189    let bytes = s.as_bytes();
190    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
191    let mut i = 0;
192    while i < bytes.len() {
193        if bytes[i] == b'%' && i + 2 < bytes.len() {
194            let hi = (bytes[i + 1] as char).to_digit(16);
195            let lo = (bytes[i + 2] as char).to_digit(16);
196            if let (Some(hi), Some(lo)) = (hi, lo) {
197                out.push((hi * 16 + lo) as u8);
198                i += 3;
199                continue;
200            }
201        }
202        out.push(bytes[i]);
203        i += 1;
204    }
205    // Decoded bytes may or may not be valid UTF-8; fall back losslessly.
206    String::from_utf8_lossy(&out).into_owned()
207}
208
209/// Percent-encode the characters that would break a `key=value&...` query string
210/// or path (minimal set: the reserved delimiters, space, and `%` itself). Used
211/// only to re-serialize a [`Location`] for stable comparison and for named-route
212/// query building.
213fn percent_encode(s: &str) -> String {
214    let mut out = String::with_capacity(s.len());
215    for b in s.bytes() {
216        match b {
217            b'%' | b'?' | b'&' | b'=' | b'#' | b'/' | b' ' => {
218                out.push('%');
219                out.push_str(&format!("{b:02X}"));
220            }
221            _ => out.push(b as char),
222        }
223    }
224    out
225}
226
227/// Percent-encode a single path segment value for named-route path building
228/// (leaves `/` alone is *not* wanted here — a param value is one segment).
229pub(super) fn encode_segment(s: &str) -> String {
230    percent_encode(s)
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn segs(parts: &[&str]) -> Vec<String> {
238        parts.iter().map(|s| s.to_string()).collect()
239    }
240
241    #[test]
242    fn parses_static_path() {
243        let loc = Location::parse("/users/list");
244        assert_eq!(loc.path, "/users/list");
245        assert_eq!(loc.segments, segs(&["users", "list"]));
246        assert!(loc.query.is_empty());
247    }
248
249    #[test]
250    fn root_path_normalizes() {
251        let loc = Location::parse("/");
252        assert_eq!(loc.path, "/");
253        assert!(loc.segments.is_empty());
254        // An empty string parses to root too.
255        assert_eq!(Location::parse("").path, "/");
256    }
257
258    #[test]
259    fn trailing_slash_is_tolerated() {
260        assert_eq!(Location::parse("/users/").segments, segs(&["users"]));
261        assert_eq!(Location::parse("/users").segments, segs(&["users"]));
262        assert_eq!(
263            Location::parse("/users/").location_string(),
264            Location::parse("/users").location_string()
265        );
266    }
267
268    #[test]
269    fn parses_query() {
270        let loc = Location::parse("/users/42?tab=posts&sort=asc");
271        assert_eq!(loc.segments, segs(&["users", "42"]));
272        assert_eq!(loc.query.get("tab").map(String::as_str), Some("posts"));
273        assert_eq!(loc.query.get("sort").map(String::as_str), Some("asc"));
274        // Deterministic (BTreeMap-ordered) re-serialization.
275        assert_eq!(loc.location_string(), "/users/42?sort=asc&tab=posts");
276    }
277
278    #[test]
279    fn query_key_without_value() {
280        let loc = Location::parse("/search?debug");
281        assert_eq!(loc.query.get("debug").map(String::as_str), Some(""));
282    }
283
284    #[test]
285    fn percent_decodes_segments_and_query() {
286        let loc = Location::parse("/notes/hello%20world?q=a%26b");
287        assert_eq!(loc.segments, segs(&["notes", "hello world"]));
288        assert_eq!(loc.query.get("q").map(String::as_str), Some("a&b"));
289    }
290
291    #[test]
292    fn matches_static_pattern() {
293        let pat = PathPattern::parse("/users");
294        let (consumed, params) = pat.match_prefix(&segs(&["users"])).expect("matches");
295        assert_eq!(consumed, 1);
296        assert!(params.is_empty());
297        assert!(pat.match_prefix(&segs(&["posts"])).is_none());
298    }
299
300    #[test]
301    fn captures_param() {
302        let pat = PathPattern::parse("/users/:id");
303        let (consumed, params) = pat.match_prefix(&segs(&["users", "42"])).expect("matches");
304        assert_eq!(consumed, 2);
305        assert_eq!(params.get("id").map(String::as_str), Some("42"));
306    }
307
308    #[test]
309    fn match_prefix_leaves_remaining_segments() {
310        // A parent pattern consumes its own segments only, leaving the rest for
311        // a child (the nesting seam).
312        let parent = PathPattern::parse("/users");
313        let (consumed, _) = parent
314            .match_prefix(&segs(&["users", "42", "posts"]))
315            .expect("prefix matches");
316        assert_eq!(consumed, 1);
317    }
318
319    #[test]
320    fn too_few_segments_fails() {
321        let pat = PathPattern::parse("/users/:id");
322        assert!(pat.match_prefix(&segs(&["users"])).is_none());
323    }
324
325    #[test]
326    fn empty_pattern_consumes_nothing() {
327        let pat = PathPattern::parse("/");
328        assert!(pat.is_empty());
329        let (consumed, params) = pat.match_prefix(&segs(&["anything"])).expect("matches");
330        assert_eq!(consumed, 0);
331        assert!(params.is_empty());
332    }
333}