From a66a3dfdceac3d117aeb0f0fbc5116070410af01 Mon Sep 17 00:00:00 2001 From: liutong Date: Tue, 18 Aug 2026 15:35:15 +0000 Subject: [PATCH 1/3] fix(path_match): ** wildcard now backtracks to match trailing segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ** glob was greedy — it consumed all remaining path segments and never checked what followed in the template. A route like /admin/**/settings would match /admin/anything, bypassing the /settings guard. Two fixes: - match_path_segments: try each split point for ** and recurse on the remaining template. First match wins. - DynamicRouteTrieNode: the trie can't represent ** followed by more segments (insert stores DeepWildcard and returns, discarding the tail). Skip trie insertion for these templates and fall back to the linear matcher, which now handles them correctly. Co-Authored-By: Claude Opus 4.6 --- path_match.mbt | 53 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/path_match.mbt b/path_match.mbt index 6cf1dc3..be4b539 100644 --- a/path_match.mbt +++ b/path_match.mbt @@ -63,15 +63,29 @@ fn match_path_segments( params, ) } else if template_part == "**" { - // 多级通配符 - 贪婪匹配剩余所有 - let remaining_path = [] - let mut i = path_idx - while i < path_parts.length() { - remaining_path.push(path_parts[i]) - i = i + 1 + for consumed = path_idx + consumed <= path_parts.length() + consumed = consumed + 1 { + let remaining : Array[StringView] = [] + for j = path_idx; j < consumed; j = j + 1 { + remaining.push(path_parts[j]) + } + let trial : Map[String, StringView] = Map([]) + params.each(fn(k, v) { trial.set(k, v) }) + trial.set("_", remaining.join("/")) + match + match_path_segments( + template_parts, + path_parts, + template_idx + 1, + consumed, + trial, + ) { + Some(result) => return Some(result) + None => continue + } } - params.set("_", remaining_path.join("/")) - Some(params) + None } else if template_part == path_part { // 静态段匹配 match_path_segments( @@ -351,7 +365,16 @@ fn Mocket::insert_dynamic_route( trie } } - trie.insert(path, handler, order) + let parts = path.split("/").to_array() + let mut deep_wild_mid = false + for i = 0; i < parts.length(); i = i + 1 { + if parts[i] == "**" && i + 1 < parts.length() { + deep_wild_mid = true + } + } + if !deep_wild_mid { + trie.insert(path, handler, order) + } } ///| @@ -406,6 +429,18 @@ fn Mocket::find_route( return Some(found) } } + + // 线性回退:处理 trie 无法表达的模板(如 /**/suffix) + for meth in [http_method, "*"] { + if self.dynamic_routes.get(meth) is Some(routes) { + for route in routes { + let (template, handler) = route + if match_path(template, path) is Some(params) { + return Some((handler, params)) + } + } + } + } None } From 725badfe24e470ef576b80d7c24650c068c33897 Mon Sep 17 00:00:00 2001 From: liutong Date: Tue, 18 Aug 2026 16:00:50 +0000 Subject: [PATCH 2/3] fix: ** early return ignores trailing segments, trie breaks route order Two issues fixed: 1. When the path is exhausted and the current template part is **, match_path_segments returned Some immediately without checking if more template parts follow. /admin/**/settings matched /admin. Fix: recurse past ** to validate the remaining template. 2. find_route returned the first trie hit without checking whether a lower-order route existed only in the linear list (because ** mid-pattern templates skip trie insertion). This broke registration order. Fix: within each method group, compare trie and linear results by order and take the lowest. Added regression tests for both cases. Co-Authored-By: Claude Opus 4.6 --- path_match.mbt | 72 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/path_match.mbt b/path_match.mbt index be4b539..b28f765 100644 --- a/path_match.mbt +++ b/path_match.mbt @@ -34,7 +34,17 @@ fn match_path_segments( // 路径结束但模板还有 if path_idx >= path_parts.length() { let template_part = template_parts[template_idx] - return if template_part == "**" { Some(params) } else { None } + if template_part == "**" { + params.set("_", "") + return match_path_segments( + template_parts, + path_parts, + template_idx + 1, + path_idx, + params, + ) + } + return None } // 都有内容,继续匹配 @@ -381,10 +391,10 @@ fn Mocket::insert_dynamic_route( fn DynamicRouteTrieNode::find_path( self : DynamicRouteTrieNode, path : String, -) -> (HttpHandler, Map[String, StringView])? { +) -> (HttpHandler, Map[String, StringView], Int)? { let path_parts = path.split("/").collect() match self.find(path_parts, 0, {}) { - Some(found) => Some((found.handler, found.params)) + Some(found) => Some((found.handler, found.params, found.order)) None => None } } @@ -416,30 +426,32 @@ fn Mocket::find_route( None => ignore(()) } - // 然后尝试动态路由 trie - if self.dynamic_route_tries.get(http_method) is Some(trie) { - if trie.find_path(path) is Some(found) { - return Some(found) - } - } - - // 最后检查通配符方法的动态路由 trie - if self.dynamic_route_tries.get("*") is Some(trie) { - if trie.find_path(path) is Some(found) { - return Some(found) - } - } - - // 线性回退:处理 trie 无法表达的模板(如 /**/suffix) + // 动态路由:按方法优先级(method-specific 优先于 wildcard), + // 每组内取 trie 和线性中 order 最小的匹配 for meth in [http_method, "*"] { + let mut best_order = -1 + let mut best_result : (HttpHandler, Map[String, StringView])? = None + if self.dynamic_route_tries.get(meth) is Some(trie) { + if trie.find_path(path) is Some((handler, params, order)) { + best_order = order + best_result = Some((handler, params)) + } + } if self.dynamic_routes.get(meth) is Some(routes) { - for route in routes { - let (template, handler) = route + for i = 0; i < routes.length(); i = i + 1 { + if best_order >= 0 && i >= best_order { + break + } + let (template, handler) = routes[i] if match_path(template, path) is Some(params) { - return Some((handler, params)) + best_order = i + best_result = Some((handler, params)) } } } + if best_result is Some(_) { + return best_result + } } None } @@ -535,6 +547,24 @@ test "边界情况" { @test.assert_eq(result5, None) } +///| +test "** with trailing segments" { + // ** mid-pattern must check suffix + @test.assert_eq( + match_path("/admin/**/settings", "/admin/x/settings"), + Some({ "_": "x" }), + ) + @test.assert_eq( + match_path("/admin/**/settings", "/admin/x/y/settings"), + Some({ "_": "x/y" }), + ) + // path exhausted before suffix — must NOT match + @test.assert_eq(match_path("/admin/**/settings", "/admin"), None) + @test.assert_eq(match_path("/admin/**/settings", "/admin/x"), None) + // ** at end still works when path is exhausted + @test.assert_eq(match_path("/files/**", "/files"), Some({ "_": "" })) +} + ///| test "性能对比场景" { // 静态路径应该快速返回 From d9dd33cd503cdce44ef86fec1dcdae38712d952c Mon Sep 17 00:00:00 2001 From: liutong Date: Tue, 18 Aug 2026 16:16:12 +0000 Subject: [PATCH 3/3] test: add regression test for ** mid-pattern route registration order Verifies that /admin/**/settings registered before /admin/:id/settings wins when matching /admin/x/settings (params should contain "_", not "id"). Co-Authored-By: Claude Opus 4.6 --- path_match.mbt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/path_match.mbt b/path_match.mbt index b28f765..a320dc2 100644 --- a/path_match.mbt +++ b/path_match.mbt @@ -565,6 +565,23 @@ test "** with trailing segments" { @test.assert_eq(match_path("/files/**", "/files"), Some({ "_": "" })) } +///| +test "** mid-pattern preserves registration order" { + let app = new() + let noop : HttpHandler = fn(_) noraise { text("") } + app.get("/admin/**/settings", noop) + app.get("/admin/:id/settings", noop) + let result = app.find_route("GET", "/admin/x/settings") + // first-registered ** route should win; its params use "_" not "id" + match result { + Some((_, params)) => { + @test.assert_eq(params.get("_"), Some("x")) + @test.assert_eq(params.get("id"), None) + } + None => @test.fail("expected a match") + } +} + ///| test "性能对比场景" { // 静态路径应该快速返回