Skip to main content

max / quasi

10.2 KB · 282 lines History Blame Raw
1 //! Matching a path against a registered pattern.
2 //!
3 //! Segment-wise, with `{name}` capturing one segment. No wildcards and no regex.
4 //! A pattern is a name for a screen or an action, and the moment it can match an
5 //! arbitrary tail it stops being one.
6 //!
7 //! # Why `{name}` and not `:name`
8 //!
9 //! `:name` is the older convention (pre-0.8 axum, actix, express, Rails) and
10 //! axum moved to `{name}` at 0.8 along with matchit. `quasi-axum` depends on
11 //! axum 0.8, so the router disagreed with the host its own adapter is written
12 //! against, and with every route in the app that first used it.
13 //!
14 //! The cost was not the inconsistency. A `{id}` written by anyone whose fingers
15 //! know the surrounding codebase parsed as a *static* segment named literally
16 //! `{id}`: registration succeeded, the route could never match, and the only
17 //! symptom was a 404 when somebody pressed the button. That shipped in the MNW
18 //! server and survived a day. So the wrong shape is now a panic at startup, the
19 //! same as the other two malformed cases, and the syntax matches the host.
20
21 use crate::request::Params;
22
23 /// One piece of a pattern between slashes.
24 #[derive(Debug, Clone, PartialEq, Eq)]
25 enum Segment {
26 /// Matches itself.
27 Static(String),
28 /// Matches anything and records it under this name.
29 Capture(String),
30 }
31
32 /// A registered path, parsed once at construction.
33 #[derive(Debug, Clone, PartialEq, Eq)]
34 pub(crate) struct Pattern {
35 /// As written, for error messages and for listing the route table.
36 source: String,
37 segments: Vec<Segment>,
38 }
39
40 impl Pattern {
41 /// Parse a pattern.
42 ///
43 /// # Panics
44 ///
45 /// If a segment is empty, a capture has no name, a brace is unbalanced, or a
46 /// segment uses the retired `:name` form. Registration happens at startup
47 /// from literals in the source, so a malformed pattern is a bug that should
48 /// stop the program rather than a condition to thread through every call
49 /// site as a `Result`.
50 ///
51 /// The last two exist because the failure they replace is silent. An
52 /// unrecognised capture shape is not a malformed pattern to a segment-wise
53 /// matcher; it is a perfectly good static segment that happens to match
54 /// nothing a caller will ever send, so the route registers, never fires, and
55 /// answers 404 to the one person who presses it.
56 pub(crate) fn parse(source: &str) -> Self {
57 assert!(
58 source.starts_with('/'),
59 "route pattern `{source}` must start with `/`"
60 );
61
62 let segments = split(source)
63 .map(|raw| {
64 assert!(
65 !raw.is_empty(),
66 "route pattern `{source}` has an empty segment"
67 );
68 assert!(
69 !raw.starts_with(':'),
70 "route pattern `{source}` uses `:name`, which was retired in \
71 favour of `{{name}}` on 2026-08-11. Write `{{{}}}`.",
72 &raw[1..]
73 );
74 match raw.strip_prefix('{') {
75 Some(rest) => {
76 let name = rest.strip_suffix('}').unwrap_or_else(|| {
77 panic!("route pattern `{source}` has an unclosed capture")
78 });
79 assert!(
80 !name.is_empty(),
81 "route pattern `{source}` has an unnamed capture"
82 );
83 // A capture is the whole segment or it is not one. `a{b}`
84 // reads as a partial match, which this matcher does not
85 // do, and silently treating it as static is the failure
86 // this whole assertion block exists to end.
87 assert!(
88 !name.contains('{') && !name.contains('}'),
89 "route pattern `{source}` has a malformed capture"
90 );
91 Segment::Capture(name.to_owned())
92 }
93 None => {
94 assert!(
95 !raw.contains('{') && !raw.contains('}'),
96 "route pattern `{source}` has a brace inside a static \
97 segment; a capture is the whole segment or none of it"
98 );
99 Segment::Static(raw.to_owned())
100 }
101 }
102 })
103 .collect();
104
105 Self {
106 source: source.to_owned(),
107 segments,
108 }
109 }
110
111 /// The pattern as it was written.
112 pub(crate) fn source(&self) -> &str {
113 &self.source
114 }
115
116 /// Match a concrete path, yielding what the captures caught.
117 ///
118 /// `None` if the path does not match. An empty `Params` is a match with no
119 /// captures, which is the common case and is not a failure.
120 pub(crate) fn match_path(&self, path: &str) -> Option<Params> {
121 let mut actual = split(path);
122 let mut captured = Params::new();
123
124 for segment in &self.segments {
125 let part = actual.next()?;
126 match segment {
127 Segment::Static(want) if want == part => {}
128 Segment::Static(_) => return None,
129 Segment::Capture(name) => {
130 // An empty capture would let `/task//edit` answer as
131 // `/task/{id}/edit` with a blank id, which is a request no
132 // renderer of ours emits and a row no store has.
133 if part.is_empty() {
134 return None;
135 }
136 captured.insert(name.clone(), part.to_owned());
137 }
138 }
139 }
140
141 actual.next().is_none().then_some(captured)
142 }
143
144 /// How specific the pattern is, most significant segment first.
145 ///
146 /// Sorted descending at registration so that `/task/new` is tried before
147 /// `/task/{id}` however they were declared. Ordering by declaration instead
148 /// would make a route table's correctness depend on the order somebody
149 /// happened to type it in, which is the kind of thing that works until a
150 /// route is moved.
151 pub(crate) fn specificity(&self) -> Vec<u8> {
152 self.segments
153 .iter()
154 .map(|s| match s {
155 Segment::Static(_) => 1,
156 Segment::Capture(_) => 0,
157 })
158 .collect()
159 }
160 }
161
162 /// The segments of a path, ignoring the leading and trailing slash.
163 ///
164 /// `/` is zero segments, `/task` is one, `/task/` is also one. A trailing slash
165 /// is not a different screen and treating it as one only ever produces a 404
166 /// somebody has to debug.
167 fn split(path: &str) -> impl Iterator<Item = &str> {
168 let trimmed = path.trim_start_matches('/').trim_end_matches('/');
169 // The root has to be zero segments rather than one empty one, or `/` and
170 // `/x` both look like a single segment to the matcher. Interior empties are
171 // kept, so `/task//edit` fails to match rather than quietly collapsing.
172 let inner = (!trimmed.is_empty()).then(|| trimmed.split('/'));
173 inner.into_iter().flatten()
174 }
175
176 #[cfg(test)]
177 mod tests {
178 use super::*;
179
180 fn captures(pattern: &str, path: &str) -> Option<Vec<(String, String)>> {
181 Pattern::parse(pattern).match_path(path).map(|p| {
182 p.iter()
183 .map(|(k, v)| (k.to_owned(), v.to_owned()))
184 .collect()
185 })
186 }
187
188 #[test]
189 fn static_path_matches_itself() {
190 assert_eq!(captures("/task", "/task"), Some(vec![]));
191 assert_eq!(captures("/task", "/tasks"), None);
192 }
193
194 #[test]
195 fn root_is_zero_segments() {
196 assert_eq!(captures("/", "/"), Some(vec![]));
197 assert_eq!(captures("/", "/task"), None);
198 }
199
200 #[test]
201 fn capture_takes_one_segment() {
202 assert_eq!(
203 captures("/task/{id}", "/task/7"),
204 Some(vec![("id".to_owned(), "7".to_owned())])
205 );
206 assert_eq!(captures("/task/{id}", "/task/7/edit"), None);
207 assert_eq!(captures("/task/{id}", "/task"), None);
208 }
209
210 #[test]
211 fn several_captures_keep_their_names() {
212 assert_eq!(
213 captures("/project/{project}/task/{id}", "/project/quasi/task/7"),
214 Some(vec![
215 ("project".to_owned(), "quasi".to_owned()),
216 ("id".to_owned(), "7".to_owned()),
217 ])
218 );
219 }
220
221 #[test]
222 fn trailing_slash_is_the_same_screen() {
223 assert_eq!(captures("/task", "/task/"), Some(vec![]));
224 assert_eq!(
225 captures("/task/{id}", "/task/7/"),
226 Some(vec![("id".to_owned(), "7".to_owned())])
227 );
228 }
229
230 #[test]
231 fn static_outranks_capture() {
232 let new = Pattern::parse("/task/new");
233 let id = Pattern::parse("/task/{id}");
234 assert!(new.specificity() > id.specificity());
235 }
236
237 #[test]
238 #[should_panic(expected = "must start with `/`")]
239 fn pattern_without_leading_slash_is_a_bug() {
240 Pattern::parse("task/{id}");
241 }
242
243 #[test]
244 #[should_panic(expected = "unnamed capture")]
245 fn unnamed_capture_is_a_bug() {
246 Pattern::parse("/task/{}");
247 }
248
249 #[test]
250 #[should_panic(expected = "was retired")]
251 fn the_old_colon_form_is_a_bug_and_says_so() {
252 // The whole point of the switch. `:id` was valid yesterday, and a
253 // pattern written from memory has to fail loudly rather than register a
254 // static segment nothing will ever match.
255 Pattern::parse("/task/:id");
256 }
257
258 #[test]
259 #[should_panic(expected = "unclosed capture")]
260 fn an_unclosed_capture_is_a_bug() {
261 Pattern::parse("/task/{id");
262 }
263
264 #[test]
265 #[should_panic(expected = "brace inside a static segment")]
266 fn a_brace_in_a_static_segment_is_a_bug() {
267 // The failure this replaces, in its purest form: `id}` is a fine static
268 // segment and matches nothing anybody sends.
269 Pattern::parse("/task/id}");
270 }
271
272 #[test]
273 fn a_capture_is_the_whole_segment() {
274 // Partial matching is not something this matcher does, so a pattern
275 // implying it must not quietly become static.
276 assert_eq!(
277 captures("/file/{name}", "/file/notes.md"),
278 Some(vec![("name".to_owned(), "notes.md".to_owned())])
279 );
280 }
281 }
282