diff --git a/src/router.rs b/src/router.rs index 1de6fb4..24236f8 100644 --- a/src/router.rs +++ b/src/router.rs @@ -96,6 +96,40 @@ impl Router { } } + /// Remove a given route from the router. + /// + /// Returns the value stored under the route if it was found. + /// + /// If the route was not found or if the route is incorrect, `None` is returned. + /// + /// # Examples + /// + /// ```rust + /// # use matchit::Router; + /// let mut router = Router::new(); + /// router.insert("/home", "Welcome!"); + /// + /// assert_eq!(router.remove("/home"), Some("Welcome!")); + /// assert_eq!(router.remove("/home"), None); + /// + /// router.insert("/home/{id}/", "Hello!"); + /// assert_eq!(router.remove("/home/{id}/"), Some("Hello!")); + /// assert_eq!(router.remove("/home/{id}/"), None); + /// + /// router.insert("/home/{id}/", "Hello!"); + /// // Bad route + /// assert_eq!(router.remove("/home/{user}"), None); + /// assert_eq!(router.remove("/home/{id}/"), Some("Hello!")); + /// + /// router.insert("/home/{id}/", "Hello!"); + /// // Ill-formed route + /// assert_eq!(router.remove("/home/{id"), None); + /// assert_eq!(router.remove("/home/{id}/"), Some("Hello!")); + /// ``` + pub fn remove(&mut self, path: impl Into) -> Option { + self.root.remove(path) + } + #[cfg(feature = "__test_helpers")] pub fn check_priorities(&self) -> Result { self.root.check_priorities() diff --git a/src/tree.rs b/src/tree.rs index 3684b8d..9ec661c 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -177,6 +177,106 @@ impl Node { } } + /// Removes a route from the tree, returning the value if the route existed. + /// The provided path should be the same as the one used to insert the route (including wildcards). + pub fn remove(&mut self, full_path: impl Into) -> Option { + let mut current = self; + let unescaped = UnescapedRoute::new(full_path.into().into_bytes()); + let (full_path, param_remapping) = normalize_params(unescaped).ok()?; + let full_path = full_path.into_inner(); + let mut path: &[u8] = full_path.as_ref(); + + let drop_child = |node: &mut Node, i: usize| -> Option { + if node.children[i].param_remapping != param_remapping { + return None; + } + // if the node we are dropping doesn't have any children, we can remove it + let val = if node.children[i].children.is_empty() { + // if the parent node only has one child there are no indices + if node.children.len() == 1 && node.indices.is_empty() { + node.wild_child = false; + node.children.remove(0).value.take() + } else { + let child = node.children.remove(i); + // Indices are only used for static nodes + if child.node_type == NodeType::Static { + node.indices.remove(i); + } else { + // It was a dynamic node, we remove the wildcard child flag + node.wild_child = false; + } + child.value + } + } else { + node.children[i].value.take() + }; + + val.map(UnsafeCell::into_inner) + }; + + // Specific case if we are removing the root node + if path == current.prefix.inner() { + let val = current.value.take().map(UnsafeCell::into_inner); + // if the root node has no children, we can just reset it + if current.children.is_empty() { + *current = Self::default(); + } + return val; + } + + 'walk: loop { + // the path is longer than this node's prefix, we are expecting a child node + if path.len() > current.prefix.len() { + let (prefix, rest) = path.split_at(current.prefix.len()); + // the prefix matches + if prefix == current.prefix.inner() { + let first = rest[0]; + path = rest; + + // If there is only one child we can continue with the child node + if current.children.len() == 1 { + if current.children[0].prefix.inner() == rest { + return drop_child(current, 0); + } else { + current = &mut current.children[0]; + continue 'walk; + } + } + + // If there are many we get the index of the child matching the first byte + if let Some(i) = current.indices.iter().position(|&c| c == first) { + // continue with the child node + if current.children[i].prefix.inner() == rest { + return drop_child(current, i); + } else { + current = &mut current.children[i]; + continue 'walk; + } + } + + // If this node has a wildcard child and that it matches our standardized path + // we continue with that + if current.wild_child + && !current.children.is_empty() + && rest.len() > 2 + && rest[0] == b'{' + && rest[2] == b'}' + { + // continue with the wildcard child + if current.children.last_mut().unwrap().prefix.inner() == rest { + return drop_child(current, current.children.len() - 1); + } else { + current = current.children.last_mut().unwrap(); + continue 'walk; + } + } + } + } + + return None; + } + } + // add a child node, keeping wildcards at the end fn add_child(&mut self, child: Node) -> usize { let len = self.children.len(); diff --git a/tests/remove.rs b/tests/remove.rs new file mode 100644 index 0000000..6d6ff6b --- /dev/null +++ b/tests/remove.rs @@ -0,0 +1,248 @@ +use matchit::Router; + +struct RemoveTest { + routes: Vec<&'static str>, + ops: Vec<(Operation, &'static str, Option<&'static str>)>, + remaining: Vec<&'static str>, +} + +enum Operation { + Insert, + Remove, +} + +use Operation::*; + +impl RemoveTest { + fn run(self) { + let mut router = Router::new(); + + for route in self.routes.iter() { + assert_eq!(router.insert(*route, route.to_owned()), Ok(()), "{route}"); + } + + for (op, route, expected) in self.ops.iter() { + match op { + Insert => { + assert_eq!(router.insert(*route, route), Ok(()), "{route}") + } + Remove => { + assert_eq!(router.remove(*route), *expected, "removing {route}",) + } + } + } + + for route in self.remaining { + assert!(matches!(router.at(route), Ok(_)), "remaining {route}"); + } + } +} + +#[test] +fn normalized() { + RemoveTest { + routes: vec![ + "/x/{foo}/bar", + "/x/{bar}/baz", + "/{foo}/{baz}/bax", + "/{foo}/{bar}/baz", + "/{fod}/{baz}/{bax}/foo", + "/{fod}/baz/bax/foo", + "/{foo}/baz/bax", + "/{bar}/{bay}/bay", + "/s", + "/s/s", + "/s/s/s", + "/s/s/s/s", + "/s/s/{s}/x", + "/s/s/{y}/d", + ], + ops: vec![ + (Remove, "/x/{foo}/bar", Some("/x/{foo}/bar")), + (Remove, "/x/{bar}/baz", Some("/x/{bar}/baz")), + (Remove, "/{foo}/{baz}/bax", Some("/{foo}/{baz}/bax")), + (Remove, "/{foo}/{bar}/baz", Some("/{foo}/{bar}/baz")), + ( + Remove, + "/{fod}/{baz}/{bax}/foo", + Some("/{fod}/{baz}/{bax}/foo"), + ), + (Remove, "/{fod}/baz/bax/foo", Some("/{fod}/baz/bax/foo")), + (Remove, "/{foo}/baz/bax", Some("/{foo}/baz/bax")), + (Remove, "/{bar}/{bay}/bay", Some("/{bar}/{bay}/bay")), + (Remove, "/s", Some("/s")), + (Remove, "/s/s", Some("/s/s")), + (Remove, "/s/s/s", Some("/s/s/s")), + (Remove, "/s/s/s/s", Some("/s/s/s/s")), + (Remove, "/s/s/{s}/x", Some("/s/s/{s}/x")), + (Remove, "/s/s/{y}/d", Some("/s/s/{y}/d")), + ], + remaining: vec![], + } + .run(); +} + +#[test] +fn test() { + RemoveTest { + routes: vec!["/home", "/home/{id}"], + ops: vec![ + (Remove, "/home", Some("/home")), + (Remove, "/home", None), + (Remove, "/home/{id}", Some("/home/{id}")), + (Remove, "/home/{id}", None), + ], + remaining: vec![], + } + .run(); +} + +#[test] +fn blog() { + RemoveTest { + routes: vec![ + "/{page}", + "/posts/{year}/{month}/{post}", + "/posts/{year}/{month}/index", + "/posts/{year}/top", + "/static/{*path}", + "/favicon.ico", + ], + ops: vec![ + (Remove, "/{page}", Some("/{page}")), + ( + Remove, + "/posts/{year}/{month}/{post}", + Some("/posts/{year}/{month}/{post}"), + ), + ( + Remove, + "/posts/{year}/{month}/index", + Some("/posts/{year}/{month}/index"), + ), + (Remove, "/posts/{year}/top", Some("/posts/{year}/top")), + (Remove, "/static/{*path}", Some("/static/{*path}")), + (Remove, "/favicon.ico", Some("/favicon.ico")), + ], + remaining: vec![], + } + .run() +} + +#[test] +fn catchall() { + RemoveTest { + routes: vec!["/foo/{*catchall}", "/bar", "/bar/", "/bar/{*catchall}"], + ops: vec![ + (Remove, "/foo/{*catchall}", Some("/foo/{*catchall}")), + (Remove, "/bar/", Some("/bar/")), + (Insert, "/foo/*catchall", Some("/foo/*catchall")), + (Remove, "/bar/{*catchall}", Some("/bar/{*catchall}")), + ], + remaining: vec!["/bar", "/foo/*catchall"], + } + .run(); +} + +#[test] +fn overlapping_routes() { + RemoveTest { + routes: vec![ + "/home", + "/home/{id}", + "/users", + "/users/{id}", + "/users/{id}/posts", + "/users/{id}/posts/{post_id}", + "/articles", + "/articles/{category}", + "/articles/{category}/{id}", + ], + ops: vec![ + (Remove, "/home", Some("/home")), + (Insert, "/home", Some("/home")), + (Remove, "/home/{id}", Some("/home/{id}")), + (Insert, "/home/{id}", Some("/home/{id}")), + (Remove, "/users", Some("/users")), + (Insert, "/users", Some("/users")), + (Remove, "/users/{id}", Some("/users/{id}")), + (Insert, "/users/{id}", Some("/users/{id}")), + (Remove, "/users/{id}/posts", Some("/users/{id}/posts")), + (Insert, "/users/{id}/posts", Some("/users/{id}/posts")), + ( + Remove, + "/users/{id}/posts/{post_id}", + Some("/users/{id}/posts/{post_id}"), + ), + ( + Insert, + "/users/{id}/posts/{post_id}", + Some("/users/{id}/posts/{post_id}"), + ), + (Remove, "/articles", Some("/articles")), + (Insert, "/articles", Some("/articles")), + (Remove, "/articles/{category}", Some("/articles/{category}")), + (Insert, "/articles/{category}", Some("/articles/{category}")), + ( + Remove, + "/articles/{category}/{id}", + Some("/articles/{category}/{id}"), + ), + ( + Insert, + "/articles/{category}/{id}", + Some("/articles/{category}/{id}"), + ), + ], + remaining: vec![ + "/home", + "/home/{id}", + "/users", + "/users/{id}", + "/users/{id}/posts", + "/users/{id}/posts/{post_id}", + "/articles", + "/articles/{category}", + "/articles/{category}/{id}", + ], + } + .run(); +} + +#[test] +fn remove_root() { + RemoveTest { + routes: vec!["/"], + ops: vec![(Remove, "/", Some("/"))], + remaining: vec![], + } + .run(); +} + +#[test] +fn check_escaped_params() { + RemoveTest { + routes: vec![ + "/foo/{id}", + "/foo/{id}/bar", + "/bar/{user}/{id}", + "/bar/{user}/{id}/baz", + "/baz/{product}/{user}/{id}", + ], + ops: vec![ + (Remove, "/foo/{a}", None), + (Remove, "/foo/{a}/bar", None), + (Remove, "/bar/{a}/{b}", None), + (Remove, "/bar/{a}/{b}/baz", None), + (Remove, "/baz/{a}/{b}/{c}", None), + ], + remaining: vec![ + "/foo/{id}", + "/foo/{id}/bar", + "/bar/{user}/{id}", + "/bar/{user}/{id}/baz", + "/baz/{product}/{user}/{id}", + ], + } + .run(); +}