Skip to main content

max / quasi

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