Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c6666c2
feat(router): add wildcard path matching support
dharshan-0 Jun 2, 2026
22d88ac
test(router): add tests for wildcard path matching
dharshan-0 Jun 2, 2026
30250f1
fix(router): fixed formatting
dharshan-0 Jun 2, 2026
d4d8c39
docs(introduction.md): document wildcard path matching
dharshan-0 Jun 3, 2026
8d0ab73
fix(router): fixed clippy errors and warnings
dharshan-0 Jun 3, 2026
7378518
fix(router): fixed formatting
dharshan-0 Jun 3, 2026
f247cc7
Merge branch 'master' into wildcard-feature
dharshan-0 Jun 3, 2026
0dc4687
test(router): add test for wildcard param
dharshan-0 Jun 3, 2026
4788c9d
fix(router): change wildcard param syntax
dharshan-0 Jun 9, 2026
22fbeed
test(router): change test for new path syntax
dharshan-0 Jun 9, 2026
62bb573
Merge branch 'master' into wildcard-feature
dharshan-0 Jun 10, 2026
669fac5
docs(introduction.md): update wildcard params to new syntax
dharshan-0 Jun 10, 2026
26cba98
Merge remote-tracking branch 'refs/remotes/origin/wildcard-feature' i…
dharshan-0 Jun 10, 2026
4559b71
docs(introduction.md): fix wildcard routing code example
dharshan-0 Jun 11, 2026
0f45536
docs(introduction.md): fix missed name fn in wildcard routing code ex…
dharshan-0 Jun 11, 2026
83a3ca0
fix(router): improve error message for invalid wildcard parameter names
dharshan-0 Jun 17, 2026
2179b38
Merge branch 'master' into wildcard-feature
ElijahAhianyo Jun 18, 2026
89fac85
Merge branch 'master' into wildcard-feature
ElijahAhianyo Jun 22, 2026
ccd7901
test(router): add test for illegal wildcard
dharshan-0 Jun 28, 2026
77ea6ca
feat(router): add precedence based routing
dharshan-0 Jun 28, 2026
66adf0c
test(router.rs): add test for precedence based routing
dharshan-0 Jun 28, 2026
e854310
docs(introduction.md): add documentation for route precedence
dharshan-0 Jun 28, 2026
8389365
Merge branch 'master' into wildcard-feature
dharshan-0 Jun 28, 2026
0e6c9ef
fix(router): made clippy happy
dharshan-0 Jun 30, 2026
44d1185
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 6, 2026
dd863b8
fix(router.rs): fixed typo in the test
dharshan-0 Jul 7, 2026
997251d
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 7, 2026
1ef194b
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 12, 2026
1ee0097
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 14, 2026
fae85a9
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 17, 2026
938647a
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 21, 2026
71e3b98
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 27, 2026
c20c4d8
Merge branch 'master' into wildcard-feature
dharshan-0 Aug 2, 2026
b95caec
Merge branch 'master' into wildcard-feature
dharshan-0 Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 82 additions & 10 deletions cot/src/router/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,32 @@ impl PathMatcher {
}
(Some('}'), State::Param { start }) => {
let param_name = &path_pattern[start..index].trim();
assert!(
Self::is_param_name_valid(param_name),
"Invalid parameter name: `{param_name}`"
);

parts.push(PathPart::Param {
name: (*param_name).to_string(),
});

if let Some(wildcard_name) = param_name.strip_prefix('*') {
assert!(
Self::is_param_name_valid(wildcard_name),
"Invalid parameter name: `{wildcard_name}`"
Comment thread
dharshan-0 marked this conversation as resolved.
Outdated
);

let next_char = char_iter.peek().map(|(_, ch)| *ch).unwrap_or_default();
assert!(
Comment thread
ElijahAhianyo marked this conversation as resolved.
next_char.is_none(),
"Wildcard must be the last part of the path: `{path_pattern}`"
);

parts.push(PathPart::Wildcard {
name: wildcard_name.to_string(),
});
} else {
assert!(
Self::is_param_name_valid(param_name),
"Invalid parameter name: `{param_name}`"
);

parts.push(PathPart::Param {
name: param_name.to_string(),
});
}
state = State::Literal { start: index + 1 };
}
(Some('/') | None, State::Param { start }) => {
Expand Down Expand Up @@ -126,6 +144,13 @@ impl PathMatcher {
}
current_path = &current_path[s.len()..];
}
PathPart::Wildcard { name } => {
if current_path.is_empty() {
return None;
}
params.push(PathParam::new(name, current_path));
current_path = "";
}
PathPart::Param { name } => {
let next_slash = current_path.find('/');
let value = if let Some(next_slash) = next_slash {
Expand All @@ -151,7 +176,7 @@ impl PathMatcher {
for part in &self.parts {
match part {
PathPart::Literal(s) => result.push_str(s),
PathPart::Param { name } => {
PathPart::Param { name } | PathPart::Wildcard { name } => {
let value = params
.get(name)
.ok_or_else(|| ReverseError::MissingParam(name.clone()))?;
Expand All @@ -171,7 +196,7 @@ impl PathMatcher {
pub(super) fn param_names(&self) -> impl Iterator<Item = &str> {
self.parts.iter().filter_map(|part| match part {
PathPart::Literal(..) => None,
PathPart::Param { name } => Some(name.as_str()),
PathPart::Param { name } | PathPart::Wildcard { name } => Some(name.as_str()),
})
}
}
Expand Down Expand Up @@ -299,6 +324,7 @@ impl<'matcher, 'path> CaptureResult<'matcher, 'path> {
enum PathPart {
Literal(String),
Param { name: String },
Wildcard { name: String },
}

impl Display for PathPart {
Expand All @@ -309,6 +335,7 @@ impl Display for PathPart {
write!(f, "{s}")
}
PathPart::Param { name } => write!(f, "{{{name}}}"),
PathPart::Wildcard { name } => write!(f, "{{*{name}}}"),
}
}
}
Expand Down Expand Up @@ -526,4 +553,49 @@ mod tests {
let params = ReverseParamMap::new();
assert_eq!(path_parser.reverse(&params).unwrap(), "/café/test");
}

#[test]
fn path_parser_wildcard_root() {
let path_parser = PathMatcher::new("/{*path}");
assert_eq!(
path_parser.capture("/foo/bar"),
Some(CaptureResult::new(
vec![PathParam::new("path", "foo/bar")],
""
))
);
}

#[test]
fn path_parser_wildcard_single_segment() {
let path_parser = PathMatcher::new("/users/rand/{*path}");
assert_eq!(
path_parser.capture("/users/rand/foo"),
Some(CaptureResult::new(vec![PathParam::new("path", "foo")], ""))
);
}

#[test]
fn path_parser_wildcard_multi_segment() {
let path_parser = PathMatcher::new("/users/rand/{*path}");
assert_eq!(
path_parser.capture("/users/rand/foo/bar"),
Some(CaptureResult::new(
vec![PathParam::new("path", "foo/bar")],
""
))
);
}

#[test]
fn path_parser_wildcard_no_match() {
let path_parser = PathMatcher::new("/prefix/{*path}");
assert_eq!(path_parser.capture("/other/foo"), None);
}

#[test]
fn path_parser_wildcard_empty_not_allowed() {
let path_parser = PathMatcher::new("/users/rand/{*path}");
assert_eq!(path_parser.capture("/users/rand/"), None);
}
}
25 changes: 25 additions & 0 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,31 @@ fn router(&self) -> Router {

Now, when you visit [`localhost:8000/hello/John/Smith/`](http://localhost:8000/hello/John), you should see `Hello, John Smith!` displayed on the page!

Cot also supports wildcard parameters to match all sub-paths within a route segment.

```rust
# struct MyApp;
async fn wildcard_path(Path(path): Path<String>) -> cot::Result<Html> {
Ok(Html::new(format!("Passed path: {path}")))
}

// inside `impl App`:
# impl App for MyApp {
fn router(&self) -> Router {
Router::with_urls([
// ...
Route::with_handler_and_name("/wildcard/{*path}", wildcard_path, "wildcard_path"),
])
}
# fn name(&self) -> &str { todo!() }
# }
```

Prefixing the parameter name with an asterisk (`*`) designates it as a wildcard.

In this case, it matches `/wildcard/foo`, `/wildcard/foo/bar` and so on, but it won't match `/wildcard/`.
Also note that, wildcard param can only be used on the end of path pattern.

## Project structure

### App
Expand Down
Loading