1use std::collections::BTreeMap;
17use std::fmt;
18
19const DEFAULT_VALUE: &str = "default";
21
22#[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#[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
56pub 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 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 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 pub fn source(&self) -> &str {
178 &self.source
179 }
180
181 pub fn is_static(&self) -> bool {
183 self.dynamic_count == 0
184 }
185
186 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 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 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 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#[derive(Debug, Clone, Default)]
306pub struct RouteTable {
307 rules: Vec<(String, Pattern)>,
308}
309
310impl RouteTable {
311 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 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 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 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 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(¶ms(&[("post_id", "7")])), "/blog/post/7");
377 assert_eq!(p.url(¶ms(&[])), "/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(¶ms(&[])), "/blog/category");
392 assert_eq!(p.url(¶ms(&[("category_id", "10")])), "/blog/category");
393 assert_eq!(p.url(¶ms(&[("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", ¶ms(&[("slug", "x")])).as_deref(),
461 Some("/blog/x")
462 );
463 assert!(t.url("missing", ¶ms(&[])).is_none());
464 assert_eq!(t.fixed().len(), 3);
465 }
466}