| 1 |
1 |
|
//! Matching a path against a registered pattern.
|
| 2 |
2 |
|
//!
|
| 3 |
|
- |
//! Segment-wise, with `:name` capturing one segment. No wildcards and no regex.
|
|
3 |
+ |
//! Segment-wise, with `{name}` capturing one segment. No wildcards and no regex.
|
| 4 |
4 |
|
//! A pattern is a name for a screen or an action, and the moment it can match an
|
| 5 |
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.
|
| 6 |
22 |
|
|
| 7 |
23 |
|
use crate::request::Params;
|
| 8 |
24 |
|
|
| 28 |
44 |
|
///
|
| 29 |
45 |
|
/// # Panics
|
| 30 |
46 |
|
///
|
| 31 |
|
- |
/// If a segment is empty or a capture has no name. Registration happens at
|
| 32 |
|
- |
/// startup from literals in the source, so a malformed pattern is a bug
|
| 33 |
|
- |
/// that should stop the program rather than a condition to thread through
|
| 34 |
|
- |
/// every call site as a `Result`.
|
|
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.
|
| 35 |
58 |
|
pub(crate) fn parse(source: &str) -> Self {
|
| 36 |
59 |
|
assert!(
|
| 37 |
60 |
|
source.starts_with('/'),
|
| 44 |
67 |
|
!raw.is_empty(),
|
| 45 |
68 |
|
"route pattern `{source}` has an empty segment"
|
| 46 |
69 |
|
);
|
| 47 |
|
- |
match raw.strip_prefix(':') {
|
| 48 |
|
- |
Some(name) => {
|
|
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 |
+ |
});
|
| 49 |
81 |
|
assert!(
|
| 50 |
82 |
|
!name.is_empty(),
|
| 51 |
83 |
|
"route pattern `{source}` has an unnamed capture"
|
| 52 |
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 |
+ |
);
|
| 53 |
93 |
|
Segment::Capture(name.to_owned())
|
| 54 |
94 |
|
}
|
| 55 |
|
- |
None => Segment::Static(raw.to_owned()),
|
|
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 |
+ |
}
|
| 56 |
103 |
|
}
|
| 57 |
104 |
|
})
|
| 58 |
105 |
|
.collect();
|
| 83 |
130 |
|
Segment::Static(_) => return None,
|
| 84 |
131 |
|
Segment::Capture(name) => {
|
| 85 |
132 |
|
// An empty capture would let `/task//edit` answer as
|
| 86 |
|
- |
// `/task/:id/edit` with a blank id, which is a request no
|
|
133 |
+ |
// `/task/{id}/edit` with a blank id, which is a request no
|
| 87 |
134 |
|
// renderer of ours emits and a row no store has.
|
| 88 |
135 |
|
if part.is_empty() {
|
| 89 |
136 |
|
return None;
|
| 99 |
146 |
|
/// How specific the pattern is, most significant segment first.
|
| 100 |
147 |
|
///
|
| 101 |
148 |
|
/// Sorted descending at registration so that `/task/new` is tried before
|
| 102 |
|
- |
/// `/task/:id` however they were declared. Ordering by declaration instead
|
|
149 |
+ |
/// `/task/{id}` however they were declared. Ordering by declaration instead
|
| 103 |
150 |
|
/// would make a route table's correctness depend on the order somebody
|
| 104 |
151 |
|
/// happened to type it in, which is the kind of thing that works until a
|
| 105 |
152 |
|
/// route is moved.
|
| 155 |
202 |
|
#[test]
|
| 156 |
203 |
|
fn capture_takes_one_segment() {
|
| 157 |
204 |
|
assert_eq!(
|
| 158 |
|
- |
captures("/task/:id", "/task/7"),
|
|
205 |
+ |
captures("/task/{id}", "/task/7"),
|
| 159 |
206 |
|
Some(vec![("id".to_owned(), "7".to_owned())])
|
| 160 |
207 |
|
);
|
| 161 |
|
- |
assert_eq!(captures("/task/:id", "/task/7/edit"), None);
|
| 162 |
|
- |
assert_eq!(captures("/task/:id", "/task"), None);
|
|
208 |
+ |
assert_eq!(captures("/task/{id}", "/task/7/edit"), None);
|
|
209 |
+ |
assert_eq!(captures("/task/{id}", "/task"), None);
|
| 163 |
210 |
|
}
|
| 164 |
211 |
|
|
| 165 |
212 |
|
#[test]
|
| 166 |
213 |
|
fn several_captures_keep_their_names() {
|
| 167 |
214 |
|
assert_eq!(
|
| 168 |
|
- |
captures("/project/:project/task/:id", "/project/quasi/task/7"),
|
|
215 |
+ |
captures("/project/{project}/task/{id}", "/project/quasi/task/7"),
|
| 169 |
216 |
|
Some(vec![
|
| 170 |
217 |
|
("project".to_owned(), "quasi".to_owned()),
|
| 171 |
218 |
|
("id".to_owned(), "7".to_owned()),
|
| 177 |
224 |
|
fn trailing_slash_is_the_same_screen() {
|
| 178 |
225 |
|
assert_eq!(captures("/task", "/task/"), Some(vec![]));
|
| 179 |
226 |
|
assert_eq!(
|
| 180 |
|
- |
captures("/task/:id", "/task/7/"),
|
|
227 |
+ |
captures("/task/{id}", "/task/7/"),
|
| 181 |
228 |
|
Some(vec![("id".to_owned(), "7".to_owned())])
|
| 182 |
229 |
|
);
|
| 183 |
230 |
|
}
|
| 185 |
232 |
|
#[test]
|
| 186 |
233 |
|
fn static_outranks_capture() {
|
| 187 |
234 |
|
let new = Pattern::parse("/task/new");
|
| 188 |
|
- |
let id = Pattern::parse("/task/:id");
|
|
235 |
+ |
let id = Pattern::parse("/task/{id}");
|
| 189 |
236 |
|
assert!(new.specificity() > id.specificity());
|
| 190 |
237 |
|
}
|
| 191 |
238 |
|
|
| 192 |
239 |
|
#[test]
|
| 193 |
240 |
|
#[should_panic(expected = "must start with `/`")]
|
| 194 |
241 |
|
fn pattern_without_leading_slash_is_a_bug() {
|
| 195 |
|
- |
Pattern::parse("task/:id");
|
|
242 |
+ |
Pattern::parse("task/{id}");
|
| 196 |
243 |
|
}
|
| 197 |
244 |
|
|
| 198 |
245 |
|
#[test]
|
| 199 |
246 |
|
#[should_panic(expected = "unnamed capture")]
|
| 200 |
247 |
|
fn unnamed_capture_is_a_bug() {
|
| 201 |
|
- |
Pattern::parse("/task/:");
|
|
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 |
+ |
);
|
| 202 |
282 |
|
}
|
| 203 |
283 |
|
}
|