//! Matching a path against a registered pattern. //! //! Segment-wise, with `{name}` capturing one segment. No wildcards and no regex. //! A pattern is a name for a screen or an action, and the moment it can match an //! arbitrary tail it stops being one. //! //! # Why `{name}` and not `:name` //! //! `:name` is the older convention (pre-0.8 axum, actix, express, Rails) and //! axum moved to `{name}` at 0.8 along with matchit. `quasi-axum` depends on //! axum 0.8, so the router disagreed with the host its own adapter is written //! against, and with every route in the app that first used it. //! //! The cost was not the inconsistency. A `{id}` written by anyone whose fingers //! know the surrounding codebase parsed as a *static* segment named literally //! `{id}`: registration succeeded, the route could never match, and the only //! symptom was a 404 when somebody pressed the button. That shipped in the MNW //! server and survived a day. So the wrong shape is now a panic at startup, the //! same as the other two malformed cases, and the syntax matches the host. use crate::request::Params; /// One piece of a pattern between slashes. #[derive(Debug, Clone, PartialEq, Eq)] enum Segment { /// Matches itself. Static(String), /// Matches anything and records it under this name. Capture(String), } /// A registered path, parsed once at construction. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Pattern { /// As written, for error messages and for listing the route table. source: String, segments: Vec, } impl Pattern { /// Parse a pattern. /// /// # Panics /// /// If a segment is empty, a capture has no name, a brace is unbalanced, or a /// segment uses the retired `:name` form. Registration happens at startup /// from literals in the source, so a malformed pattern is a bug that should /// stop the program rather than a condition to thread through every call /// site as a `Result`. /// /// The last two exist because the failure they replace is silent. An /// unrecognised capture shape is not a malformed pattern to a segment-wise /// matcher; it is a perfectly good static segment that happens to match /// nothing a caller will ever send, so the route registers, never fires, and /// answers 404 to the one person who presses it. pub(crate) fn parse(source: &str) -> Self { assert!( source.starts_with('/'), "route pattern `{source}` must start with `/`" ); let segments = split(source) .map(|raw| { assert!( !raw.is_empty(), "route pattern `{source}` has an empty segment" ); assert!( !raw.starts_with(':'), "route pattern `{source}` uses `:name`, which was retired in \ favour of `{{name}}` on 2026-08-11. Write `{{{}}}`.", &raw[1..] ); match raw.strip_prefix('{') { Some(rest) => { let name = rest.strip_suffix('}').unwrap_or_else(|| { panic!("route pattern `{source}` has an unclosed capture") }); assert!( !name.is_empty(), "route pattern `{source}` has an unnamed capture" ); // A capture is the whole segment or it is not one. `a{b}` // reads as a partial match, which this matcher does not // do, and silently treating it as static is the failure // this whole assertion block exists to end. assert!( !name.contains('{') && !name.contains('}'), "route pattern `{source}` has a malformed capture" ); Segment::Capture(name.to_owned()) } None => { assert!( !raw.contains('{') && !raw.contains('}'), "route pattern `{source}` has a brace inside a static \ segment; a capture is the whole segment or none of it" ); Segment::Static(raw.to_owned()) } } }) .collect(); Self { source: source.to_owned(), segments, } } /// The pattern as it was written. pub(crate) fn source(&self) -> &str { &self.source } /// Match a concrete path, yielding what the captures caught. /// /// `None` if the path does not match. An empty `Params` is a match with no /// captures, which is the common case and is not a failure. pub(crate) fn match_path(&self, path: &str) -> Option { let mut actual = split(path); let mut captured = Params::new(); for segment in &self.segments { let part = actual.next()?; match segment { Segment::Static(want) if want == part => {} Segment::Static(_) => return None, Segment::Capture(name) => { // An empty capture would let `/task//edit` answer as // `/task/{id}/edit` with a blank id, which is a request no // renderer of ours emits and a row no store has. if part.is_empty() { return None; } captured.insert(name.clone(), part.to_owned()); } } } actual.next().is_none().then_some(captured) } /// How specific the pattern is, most significant segment first. /// /// Sorted descending at registration so that `/task/new` is tried before /// `/task/{id}` however they were declared. Ordering by declaration instead /// would make a route table's correctness depend on the order somebody /// happened to type it in, which is the kind of thing that works until a /// route is moved. pub(crate) fn specificity(&self) -> Vec { self.segments .iter() .map(|s| match s { Segment::Static(_) => 1, Segment::Capture(_) => 0, }) .collect() } } /// The segments of a path, ignoring the leading and trailing slash. /// /// `/` is zero segments, `/task` is one, `/task/` is also one. A trailing slash /// is not a different screen and treating it as one only ever produces a 404 /// somebody has to debug. fn split(path: &str) -> impl Iterator { let trimmed = path.trim_start_matches('/').trim_end_matches('/'); // The root has to be zero segments rather than one empty one, or `/` and // `/x` both look like a single segment to the matcher. Interior empties are // kept, so `/task//edit` fails to match rather than quietly collapsing. let inner = (!trimmed.is_empty()).then(|| trimmed.split('/')); inner.into_iter().flatten() } #[cfg(test)] mod tests { use super::*; fn captures(pattern: &str, path: &str) -> Option> { Pattern::parse(pattern).match_path(path).map(|p| { p.iter() .map(|(k, v)| (k.to_owned(), v.to_owned())) .collect() }) } #[test] fn static_path_matches_itself() { assert_eq!(captures("/task", "/task"), Some(vec![])); assert_eq!(captures("/task", "/tasks"), None); } #[test] fn root_is_zero_segments() { assert_eq!(captures("/", "/"), Some(vec![])); assert_eq!(captures("/", "/task"), None); } #[test] fn capture_takes_one_segment() { assert_eq!( captures("/task/{id}", "/task/7"), Some(vec![("id".to_owned(), "7".to_owned())]) ); assert_eq!(captures("/task/{id}", "/task/7/edit"), None); assert_eq!(captures("/task/{id}", "/task"), None); } #[test] fn several_captures_keep_their_names() { assert_eq!( captures("/project/{project}/task/{id}", "/project/quasi/task/7"), Some(vec![ ("project".to_owned(), "quasi".to_owned()), ("id".to_owned(), "7".to_owned()), ]) ); } #[test] fn trailing_slash_is_the_same_screen() { assert_eq!(captures("/task", "/task/"), Some(vec![])); assert_eq!( captures("/task/{id}", "/task/7/"), Some(vec![("id".to_owned(), "7".to_owned())]) ); } #[test] fn static_outranks_capture() { let new = Pattern::parse("/task/new"); let id = Pattern::parse("/task/{id}"); assert!(new.specificity() > id.specificity()); } #[test] #[should_panic(expected = "must start with `/`")] fn pattern_without_leading_slash_is_a_bug() { Pattern::parse("task/{id}"); } #[test] #[should_panic(expected = "unnamed capture")] fn unnamed_capture_is_a_bug() { Pattern::parse("/task/{}"); } #[test] #[should_panic(expected = "was retired")] fn the_old_colon_form_is_a_bug_and_says_so() { // The whole point of the switch. `:id` was valid yesterday, and a // pattern written from memory has to fail loudly rather than register a // static segment nothing will ever match. Pattern::parse("/task/:id"); } #[test] #[should_panic(expected = "unclosed capture")] fn an_unclosed_capture_is_a_bug() { Pattern::parse("/task/{id"); } #[test] #[should_panic(expected = "brace inside a static segment")] fn a_brace_in_a_static_segment_is_a_bug() { // The failure this replaces, in its purest form: `id}` is a fine static // segment and matches nothing anybody sends. Pattern::parse("/task/id}"); } #[test] fn a_capture_is_the_whole_segment() { // Partial matching is not something this matcher does, so a pattern // implying it must not quietly become static. assert_eq!( captures("/file/{name}", "/file/notes.md"), Some(vec![("name".to_owned(), "notes.md".to_owned())]) ); } }