Skip to main content

laterite_web/theme/
router.rs

1//! Page URLs as patterns, matched against a request's path and built back
2//! from a page and its parameters.
3//!
4//! A pattern is `/`-separated segments. A static segment matches itself,
5//! case-insensitively. A `:name` segment matches one segment and binds it;
6//! `:name?` is optional, `:name?value` is optional with a default, `:name|^re$`
7//! must match the expression, and `:name*` is a wildcard that takes one or
8//! more segments (`:name?*` none or more). An optional segment followed by a
9//! required one is required. One wildcard at most, with no expression and
10//! nothing optional after it.
11//!
12//! A [`RouteTable`] holds a pattern per page, ordered so the most specific
13//! matches first: more static segments first, then fewer wildcards, then fewer
14//! parameters.
15
16use std::collections::BTreeMap;
17use std::fmt;
18
19/// The value a required parameter takes when a URL is built without it.
20const DEFAULT_VALUE: &str = "default";
21
22/// Why a pattern is not one.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct PatternError(pub String);
25
26impl fmt::Display for PatternError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        f.write_str(&self.0)
29    }
30}
31
32impl std::error::Error for PatternError {}
33
34#[derive(Debug, Clone)]
35enum Segment {
36    Static(String),
37    Param {
38        name: String,
39        optional: bool,
40        default: Option<String>,
41        regex: Option<regex::Regex>,
42        wildcard: bool,
43    },
44}
45
46/// A parsed URL pattern.
47#[derive(Debug, Clone)]
48pub struct Pattern {
49    source: String,
50    segments: Vec<Segment>,
51    static_count: usize,
52    dynamic_count: usize,
53    wild_count: usize,
54}
55
56/// The parameters a match bound, by name; an optional segment the URL left
57/// out holds its default, or is absent when it has none.
58pub type Params = BTreeMap<String, String>;
59
60fn normalize(path: &str) -> String {
61    let mut p = path.to_string();
62    if !p.starts_with('/') {
63        p.insert(0, '/');
64    }
65    while p.len() > 1 && p.ends_with('/') {
66        p.pop();
67    }
68    p
69}
70
71fn segmentize(path: &str) -> Vec<String> {
72    normalize(path)
73        .split('/')
74        .filter(|s| !s.is_empty())
75        .map(str::to_string)
76        .collect()
77}
78
79impl Pattern {
80    /// Parses a pattern, refusing one the grammar does not allow.
81    pub fn parse(source: &str) -> Result<Pattern, PatternError> {
82        if !source.starts_with('/') {
83            return Err(PatternError("a URL starts with a slash".into()));
84        }
85        let mut segments = Vec::new();
86        let (mut static_count, mut dynamic_count, mut wild_count) = (0, 0, 0);
87        for raw in segmentize(source) {
88            if let Some(spec) = raw.strip_prefix(':') {
89                let (head, regex) = match spec.split_once('|') {
90                    Some((h, r)) => (h.to_string(), Some(r.to_string())),
91                    None => (spec.to_string(), None),
92                };
93                let (name_part, default) = match head.split_once('?') {
94                    Some((n, d)) => (n.to_string(), Some(d.to_string())),
95                    None => (head.clone(), None),
96                };
97                let optional = default.is_some();
98                let wildcard = name_part.ends_with('*') || default.as_deref() == Some("*");
99                let name = name_part.trim_end_matches('*').to_string();
100                let default = default
101                    .map(|d| d.trim_end_matches('*').to_string())
102                    .filter(|d| !d.is_empty());
103                if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
104                    return Err(PatternError(format!(
105                        "{raw:?} is not a parameter: a name is letters, digits and underscores"
106                    )));
107                }
108                if let Some(d) = &default {
109                    if d.contains(['*', '|', '?']) {
110                        return Err(PatternError(format!(
111                            "the default of {raw:?} cannot contain *, | or ?"
112                        )));
113                    }
114                }
115                if wildcard && regex.is_some() {
116                    return Err(PatternError(format!(
117                        "the wildcard {raw:?} cannot carry an expression"
118                    )));
119                }
120                if wildcard {
121                    wild_count += 1;
122                    if wild_count > 1 {
123                        return Err(PatternError("a URL has at most one wildcard".into()));
124                    }
125                }
126                let regex = match regex {
127                    Some(r) if !r.is_empty() => Some(regex::Regex::new(&r).map_err(|e| {
128                        PatternError(format!("the expression of {raw:?} does not parse: {e}"))
129                    })?),
130                    _ => None,
131                };
132                dynamic_count += 1;
133                segments.push(Segment::Param {
134                    name,
135                    optional,
136                    default,
137                    regex,
138                    wildcard,
139                });
140            } else {
141                if !raw
142                    .chars()
143                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
144                {
145                    return Err(PatternError(format!(
146                        "{raw:?} is not a URL segment: letters, digits, dots, hyphens and underscores"
147                    )));
148                }
149                static_count += 1;
150                segments.push(Segment::Static(raw));
151            }
152        }
153        // Nothing optional may follow the wildcard.
154        if let Some(at) = segments
155            .iter()
156            .position(|s| matches!(s, Segment::Param { wildcard: true, .. }))
157        {
158            if segments[at + 1..]
159                .iter()
160                .any(|s| matches!(s, Segment::Param { optional: true, .. }))
161            {
162                return Err(PatternError(
163                    "an optional parameter cannot follow the wildcard".into(),
164                ));
165            }
166        }
167        Ok(Pattern {
168            source: normalize(source),
169            segments,
170            static_count,
171            dynamic_count,
172            wild_count,
173        })
174    }
175
176    /// The pattern as written, normalized.
177    pub fn source(&self) -> &str {
178        &self.source
179    }
180
181    /// Whether the pattern has parameters.
182    pub fn is_static(&self) -> bool {
183        self.dynamic_count == 0
184    }
185
186    /// The parameter names, in order.
187    pub fn params(&self) -> Vec<&str> {
188        self.segments
189            .iter()
190            .filter_map(|s| match s {
191                Segment::Param { name, .. } => Some(name.as_str()),
192                Segment::Static(_) => None,
193            })
194            .collect()
195    }
196
197    /// An optional segment is required when a required one follows it.
198    fn effectively_optional(&self, index: usize) -> bool {
199        match &self.segments[index] {
200            Segment::Param { optional: true, .. } => self.segments[index + 1..]
201                .iter()
202                .all(|s| matches!(s, Segment::Param { optional: true, .. })),
203            _ => false,
204        }
205    }
206
207    /// The parameters when `path` matches, else none.
208    pub fn matches(&self, path: &str) -> Option<Params> {
209        let mut url: Vec<String> = segmentize(path);
210        let mut wild_tail: Vec<String> = Vec::new();
211        if self.wild_count == 1 && url.len() > self.segments.len() {
212            let extra = url.len() - self.segments.len();
213            let at = self
214                .segments
215                .iter()
216                .position(|s| matches!(s, Segment::Param { wildcard: true, .. }))?;
217            if url.len() > at {
218                wild_tail = url.drain(at + 1..at + 1 + extra).collect();
219            }
220        }
221        if url.len() > self.segments.len() {
222            return None;
223        }
224        let mut params = Params::new();
225        for (index, segment) in self.segments.iter().enumerate() {
226            match segment {
227                Segment::Static(s) => {
228                    let got = url.get(index)?;
229                    if !s.eq_ignore_ascii_case(got) {
230                        return None;
231                    }
232                }
233                Segment::Param {
234                    name,
235                    default,
236                    regex,
237                    wildcard,
238                    ..
239                } => {
240                    let optional = self.effectively_optional(index);
241                    match url.get(index) {
242                        None if optional => {
243                            if let Some(d) = default {
244                                params.insert(name.clone(), d.clone());
245                            }
246                        }
247                        None => return None,
248                        Some(value) => {
249                            if let Some(re) = regex {
250                                if !re.is_match(value) {
251                                    return None;
252                                }
253                            }
254                            let mut value = value.clone();
255                            if *wildcard && !wild_tail.is_empty() {
256                                value.push('/');
257                                value.push_str(&wild_tail.join("/"));
258                            }
259                            params.insert(name.clone(), value);
260                        }
261                    }
262                }
263            }
264        }
265        Some(params)
266    }
267
268    /// A URL from the pattern and `params`: a parameter left out takes its
269    /// default, a required one `default`, and the URL stops after the last
270    /// segment a value filled.
271    pub fn url(&self, params: &Params) -> String {
272        let mut out: Vec<String> = Vec::new();
273        let mut last_filled = 0;
274        for (index, segment) in self.segments.iter().enumerate() {
275            match segment {
276                Segment::Static(s) => out.push(s.clone()),
277                Segment::Param {
278                    name,
279                    optional,
280                    default,
281                    ..
282                } => {
283                    let supplied = params
284                        .get(name)
285                        .filter(|v| !v.is_empty() && Some(v.as_str()) != default.as_deref());
286                    match supplied {
287                        Some(v) => out.push(v.clone()),
288                        None if *optional => {
289                            out.push(default.clone().unwrap_or_else(|| DEFAULT_VALUE.into()));
290                            continue;
291                        }
292                        None => out.push(DEFAULT_VALUE.into()),
293                    }
294                }
295            }
296            last_filled = index;
297        }
298        out.truncate(last_filled + 1);
299        let joined: String = out.iter().map(|s| format!("/{}", s.trim())).collect();
300        normalize(&joined)
301    }
302}
303
304/// Every page's pattern, keyed by the page's name, in matching order.
305#[derive(Debug, Clone, Default)]
306pub struct RouteTable {
307    rules: Vec<(String, Pattern)>,
308}
309
310impl RouteTable {
311    /// Adds a page's pattern. The table reorders itself.
312    pub fn add(&mut self, name: impl Into<String>, pattern: Pattern) {
313        self.rules.push((name.into(), pattern));
314        self.rules.sort_by(|(_, a), (_, b)| {
315            b.static_count
316                .cmp(&a.static_count)
317                .then(a.wild_count.cmp(&b.wild_count))
318                .then(a.dynamic_count.cmp(&b.dynamic_count))
319        });
320    }
321
322    /// The first page whose pattern matches `path`, with its parameters.
323    pub fn resolve(&self, path: &str) -> Option<(&str, Params)> {
324        self.rules
325            .iter()
326            .find_map(|(name, p)| p.matches(path).map(|params| (name.as_str(), params)))
327    }
328
329    /// The URL of the page `name` with `params`, or none when no such page.
330    pub fn url(&self, name: &str, params: &Params) -> Option<String> {
331        self.rules
332            .iter()
333            .find(|(n, _)| n == name)
334            .map(|(_, p)| p.url(params))
335    }
336
337    /// The pages whose pattern has no parameter, with their URLs.
338    pub fn fixed(&self) -> Vec<(&str, String)> {
339        self.rules
340            .iter()
341            .filter(|(_, p)| p.is_static())
342            .map(|(n, p)| (n.as_str(), p.source.clone()))
343            .collect()
344    }
345
346    /// The pages, in matching order.
347    pub fn names(&self) -> Vec<&str> {
348        self.rules.iter().map(|(n, _)| n.as_str()).collect()
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn params(pairs: &[(&str, &str)]) -> Params {
357        pairs
358            .iter()
359            .map(|(k, v)| (k.to_string(), v.to_string()))
360            .collect()
361    }
362
363    #[test]
364    fn static_and_required_parameters() {
365        let p = Pattern::parse("/blog/post/:post_id").unwrap();
366        assert_eq!(
367            p.matches("/blog/post/12").unwrap(),
368            params(&[("post_id", "12")])
369        );
370        assert_eq!(
371            p.matches("/BLOG/Post/12").unwrap(),
372            params(&[("post_id", "12")])
373        );
374        assert!(p.matches("/blog/post").is_none());
375        assert!(p.matches("/blog/post/12/extra").is_none());
376        assert_eq!(p.url(&params(&[("post_id", "7")])), "/blog/post/7");
377        assert_eq!(p.url(&params(&[])), "/blog/post/default");
378    }
379
380    #[test]
381    fn optional_with_default_and_a_required_tail() {
382        let p = Pattern::parse("/blog/category/:category_id?10").unwrap();
383        assert_eq!(
384            p.matches("/blog/category").unwrap(),
385            params(&[("category_id", "10")])
386        );
387        assert_eq!(
388            p.matches("/blog/category/3").unwrap(),
389            params(&[("category_id", "3")])
390        );
391        assert_eq!(p.url(&params(&[])), "/blog/category");
392        assert_eq!(p.url(&params(&[("category_id", "10")])), "/blog/category");
393        assert_eq!(p.url(&params(&[("category_id", "4")])), "/blog/category/4");
394
395        let p = Pattern::parse("/blog/:post_id?/comments").unwrap();
396        assert!(
397            p.matches("/blog/comments").is_none(),
398            "required before a required tail"
399        );
400        assert!(p.matches("/blog/5/comments").is_some());
401
402        let p = Pattern::parse("/blog/:post_id?").unwrap();
403        assert_eq!(p.matches("/blog").unwrap(), params(&[]));
404        assert_eq!(p.matches("/blog/").unwrap(), params(&[]));
405    }
406
407    #[test]
408    fn expressions_and_wildcards() {
409        let p = Pattern::parse("/blog/:post_id|^[0-9]+$/comments").unwrap();
410        assert!(p.matches("/blog/10/comments").is_some());
411        assert!(p.matches("/blog/ten/comments").is_none());
412        let p = Pattern::parse("/blog/:post_name?|^[a-z0-9\\-]+$").unwrap();
413        assert!(p.matches("/blog/my-post").is_some());
414        assert!(p.matches("/blog/My Post").is_none());
415        assert_eq!(p.matches("/blog").unwrap(), params(&[]));
416
417        let p = Pattern::parse("/color/:color/make/:make*/edit").unwrap();
418        assert_eq!(
419            p.matches("/color/brown/make/volkswagen/beetle/retro/edit")
420                .unwrap(),
421            params(&[("color", "brown"), ("make", "volkswagen/beetle/retro")])
422        );
423        assert_eq!(
424            p.matches("/color/brown/make/vw/edit").unwrap(),
425            params(&[("color", "brown"), ("make", "vw")])
426        );
427        let p = Pattern::parse("/blog/:slug?*").unwrap();
428        assert_eq!(p.matches("/blog").unwrap(), params(&[]));
429        assert_eq!(p.matches("/blog/a/b").unwrap(), params(&[("slug", "a/b")]));
430    }
431
432    #[test]
433    fn a_bad_pattern_is_refused() {
434        assert!(Pattern::parse("blog").is_err());
435        assert!(Pattern::parse("/blog/:a*/:b*").is_err());
436        assert!(Pattern::parse("/blog/:a*|^x$").is_err());
437        assert!(Pattern::parse("/blog/:a*/:b?").is_err());
438        assert!(Pattern::parse("/blog/:id?a*b").is_err());
439        assert!(Pattern::parse("/blog/:id|(").is_err());
440        assert!(Pattern::parse("/blog/:").is_err());
441        assert!(Pattern::parse("/bl og").is_err());
442        assert!(Pattern::parse("/").is_ok());
443    }
444
445    #[test]
446    fn the_table_prefers_the_most_specific_rule() {
447        let mut t = RouteTable::default();
448        t.add("catchall", Pattern::parse("/:slug*").unwrap());
449        t.add("post", Pattern::parse("/blog/:slug").unwrap());
450        t.add("blog", Pattern::parse("/blog").unwrap());
451        t.add("home", Pattern::parse("/").unwrap());
452        t.add("archive", Pattern::parse("/blog/archive").unwrap());
453        assert_eq!(t.names(), ["archive", "blog", "post", "home", "catchall"]);
454        assert_eq!(t.resolve("/blog/archive").unwrap().0, "archive");
455        assert_eq!(t.resolve("/blog/hello").unwrap().0, "post");
456        assert_eq!(t.resolve("/blog").unwrap().0, "blog");
457        assert_eq!(t.resolve("/").unwrap().0, "home");
458        assert_eq!(t.resolve("/a/b/c").unwrap().0, "catchall");
459        assert_eq!(
460            t.url("post", &params(&[("slug", "x")])).as_deref(),
461            Some("/blog/x")
462        );
463        assert!(t.url("missing", &params(&[])).is_none());
464        assert_eq!(t.fixed().len(), 3);
465    }
466}