From 20c64cbad3c67c7c98ac383adee2276bf15890de Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 29 Sep 2026 13:45:20 -0700 Subject: [PATCH 1/9] fix(router): share route syntax across routing operations --- route.go | 34 ++++---- route_path.go | 73 +++++++++++++++++ route_syntax_test.go | 115 ++++++++++++++++++++++++++ router.go | 187 ++++++++++++++++++++----------------------- 4 files changed, 296 insertions(+), 113 deletions(-) create mode 100644 route_path.go create mode 100644 route_syntax_test.go diff --git a/route.go b/route.go index 2468a8816..0c165fca2 100644 --- a/route.go +++ b/route.go @@ -81,22 +81,26 @@ func (r RouteInfo) Clone() RouteInfo { // Reverse reverses route to URL string by replacing path parameters with given params values. func (r RouteInfo) Reverse(pathValues ...any) string { uri := new(bytes.Buffer) - ln := len(pathValues) - n := 0 - for i, l := 0, len(r.Path); i < l; i++ { - hasBackslash := r.Path[i] == '\\' - if hasBackslash && i+1 < l && r.Path[i+1] == ':' { - i++ // backslash before colon escapes that colon. in that case skip backslash - } - if n < ln && (r.Path[i] == anyLabel || (!hasBackslash && r.Path[i] == paramLabel)) { - // in case of `*` wildcard or `:` (unescaped colon) param we replace everything till next slash or end of path - for ; i < l && r.Path[i] != '/'; i++ { + nextValue := 0 + for _, part := range parseRoutePath(r.Path) { + switch part.kind { + case staticKind: + uri.WriteString(part.value) + case paramKind: + if nextValue < len(pathValues) { + fmt.Fprint(uri, pathValues[nextValue]) + nextValue++ + } else { + uri.WriteByte(paramLabel) + uri.WriteString(part.value) + } + case anyKind: + if nextValue < len(pathValues) { + fmt.Fprint(uri, pathValues[nextValue]) + nextValue++ + } else { + uri.WriteString(part.value) } - fmt.Fprintf(uri, "%v", pathValues[n]) - n++ - } - if i < l { - uri.WriteByte(r.Path[i]) } } return uri.String() diff --git a/route_path.go b/route_path.go new file mode 100644 index 000000000..d4b4a4458 --- /dev/null +++ b/route_path.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import "strings" + +// routePathPart is one parsed piece of a route pattern. A backslash before a +// colon makes the colon static, including when it follows a parameter name. +type routePathPart struct { + kind kind + value string +} + +func parseRoutePath(path string) []routePathPart { + var parts []routePathPart + var literal strings.Builder + flushLiteral := func() { + if literal.Len() > 0 { + parts = append(parts, routePathPart{kind: staticKind, value: literal.String()}) + literal.Reset() + } + } + + for i := 0; i < len(path); { + switch { + case path[i] == '\\' && i+1 < len(path) && path[i+1] == ':': + literal.WriteByte(':') + i += 2 + case path[i] == ':': + flushLiteral() + start := i + 1 + i = start + for i < len(path) && path[i] != '/' { + if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' { + break + } + i++ + } + parts = append(parts, routePathPart{kind: paramKind, value: path[start:i]}) + case path[i] == '*': + flushLiteral() + start := i + for i < len(path) && path[i] != '/' { + i++ + } + parts = append(parts, routePathPart{kind: anyKind, value: path[start:i]}) + default: + literal.WriteByte(path[i]) + i++ + } + } + flushLiteral() + return parts +} + +func routeTreePath(parts []routePathPart) (string, []int) { + var path strings.Builder + var paramMarkers []int + for _, part := range parts { + switch part.kind { + case staticKind: + path.WriteString(part.value) + case paramKind: + paramMarkers = append(paramMarkers, path.Len()) + path.WriteByte(paramLabel) + case anyKind: + path.WriteByte(anyLabel) + return path.String(), paramMarkers + } + } + return path.String(), paramMarkers +} diff --git a/route_syntax_test.go b/route_syntax_test.go new file mode 100644 index 000000000..ea6b6e510 --- /dev/null +++ b/route_syntax_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func assertRouteResponse(t *testing.T, e *Echo, path string, want string) { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + if !assert.NotPanics(t, func() { e.ServeHTTP(rec, req) }) { + return + } + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, want, rec.Body.String()) +} + +func TestRouterInlineVerbRoutes(t *testing.T) { + for _, order := range [][]string{{"cancel", "get"}, {"get", "cancel"}} { + e := New() + for _, verb := range order { + verb := verb + e.GET("/r/:name\\:"+verb, func(c *Context) error { + return c.String(http.StatusOK, verb+":"+c.Param("name")) + }) + } + assertRouteResponse(t, e, "/r/foo:cancel", "cancel:foo") + assertRouteResponse(t, e, "/r/foo:get", "get:foo") + assertRouteResponse(t, e, "/r/foo:bar:cancel", "cancel:foo:bar") + } +} + +func TestRouterInlineVerbLongestSuffix(t *testing.T) { + e := New() + e.GET(`/r/:name\:foo\:bar`, func(c *Context) error { + return c.String(http.StatusOK, "long:"+c.Param("name")) + }) + e.GET(`/r/:name\:bar`, func(c *Context) error { + return c.String(http.StatusOK, "short:"+c.Param("name")) + }) + assertRouteResponse(t, e, "/r/a:foo:bar", "long:a") + assertRouteResponse(t, e, "/r/a:bar", "short:a") +} + +func TestRouterInlineVerbWithFollowingParam(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel/:action`, func(c *Context) error { + return c.String(http.StatusOK, c.Param("name")+":"+c.Param("action")) + }) + assertRouteResponse(t, e, "/r/foo:cancel/bar", "foo:bar") +} + +func TestRouterInlineVerbWithWildcard(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel/*`, func(c *Context) error { + return c.String(http.StatusOK, c.Param("name")+":"+c.Param("*")) + }) + assertRouteResponse(t, e, "/r/foo:cancel/bar", "foo:bar") + assertRouteResponse(t, e, "/r/foo:cancel/", "foo:") +} + +func TestRouterInlineVerbAndGenericParam(t *testing.T) { + e := New() + e.GET("/r/:name", func(c *Context) error { + return c.String(http.StatusOK, "generic:"+c.Param("name")) + }) + e.GET(`/r/:name\:cancel`, func(c *Context) error { + return c.String(http.StatusOK, "cancel:"+c.Param("name")) + }) + assertRouteResponse(t, e, "/r/foo:cancel", "cancel:foo") + assertRouteResponse(t, e, "/r/foo:other", "generic:foo:other") +} + +func TestRouterRemoveEscapedColonAndReadd(t *testing.T) { + e := New() + static := func(c *Context) error { return c.String(http.StatusOK, "static") } + e.GET(`/a\:b`, static) + e.GET("/a:id", func(c *Context) error { + return c.String(http.StatusOK, "param:"+c.Param("id")) + }) + assertRouteResponse(t, e, "/a:b", "static") + assert.NoError(t, e.Router().Remove(http.MethodGet, `/a\:b`)) + assertRouteResponse(t, e, "/a:b", "param::b") + e.GET(`/a\:b`, static) + assertRouteResponse(t, e, "/a:b", "static") +} + +func TestRouterRemoveInlineVerbAndReadd(t *testing.T) { + e := New() + cancel := func(c *Context) error { return c.String(http.StatusOK, "cancel:"+c.Param("name")) } + e.GET(`/r/:name\:cancel`, cancel) + e.GET(`/r/:name\:get`, func(c *Context) error { + return c.String(http.StatusOK, "get:"+c.Param("name")) + }) + assert.NoError(t, e.Router().Remove(http.MethodGet, `/r/:name\:cancel`)) + assertRouteResponse(t, e, "/r/foo:get", "get:foo") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/foo:cancel", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + e.GET(`/r/:name\:cancel`, cancel) + assertRouteResponse(t, e, "/r/foo:cancel", "cancel:foo") +} + +func TestRouteInfoReverseInlineVerb(t *testing.T) { + ri := RouteInfo{Path: `/r/:name\:cancel`} + assert.Equal(t, "/r/foo:cancel", ri.Reverse("foo")) + assert.Equal(t, "/r/:name:cancel", ri.Reverse()) +} diff --git a/router.go b/router.go index c30c8aa4f..c29f58e49 100644 --- a/router.go +++ b/router.go @@ -8,6 +8,8 @@ import ( "errors" "net/http" "net/url" + "slices" + "strings" ) // Router is interface for routing request contexts to registered routes. @@ -387,40 +389,27 @@ func (r *DefaultRouter) Remove(method string, path string) error { return errors.New("router has no routes to remove") } - if path == "" { - path = "/" - } - if path[0] != '/' { - path = "/" + path - } + path = normalizePathSlash(path) + treePath, paramMarkers := routeTreePath(parseRoutePath(path)) var nodeToRemove *node - prefixLen := 0 + search := treePath + searchOffset := 0 for { - if currentNode.originalPath == path && currentNode.isHandler { - nodeToRemove = currentNode + if !strings.HasPrefix(search, currentNode.prefix) { break } - if currentNode.kind == staticKind { - prefixLen = prefixLen + len(currentNode.prefix) - } else { - prefixLen = len(currentNode.originalPath) - } + search = search[len(currentNode.prefix):] + searchOffset += len(currentNode.prefix) - if prefixLen >= len(path) { + if search == "" { + if currentNode.originalPath == path && currentNode.isHandler { + nodeToRemove = currentNode + } break } - next := path[prefixLen] - switch next { - case paramLabel: - currentNode = currentNode.paramChild - case anyLabel: - currentNode = currentNode.anyChild - default: - currentNode = currentNode.findStaticChild(next) - } - + currentNode = currentNode.findChildWithLabel(search[0], slices.Contains(paramMarkers, searchOffset)) if currentNode == nil { break } @@ -434,18 +423,22 @@ func (r *DefaultRouter) Remove(method string, path string) error { return errors.New("could not find route to remove by given path") } - if mh := nodeToRemove.methods.find(method, false, false); mh == nil { + mh := nodeToRemove.methods.find(method, false, false) + if mh == nil { return errors.New("could not find route to remove by given path and method") } - nodeToRemove.setHandler(method, nil) - var rIndex int + rIndex := -1 for i, rr := range r.routes { - if rr.Method == method && rr.Path == path { + if rr.Method == method && rr.Path == mh.orgRouteInfo.Path { rIndex = i break } } + if rIndex < 0 { + return errors.New("could not find route to remove by given path and method") + } + nodeToRemove.setHandler(method, nil) r.routes = append(r.routes[:rIndex], r.routes[rIndex+1:]...) if !nodeToRemove.isHandler && nodeToRemove.isLeaf { @@ -535,72 +528,49 @@ func (r *DefaultRouter) Add(route Route) (RouteInfo, error) { headH = wrapHeadHandler(h) } - paramNames := make([]string, 0) - // Positions of parameter markers after names are removed. Literal colons - // remain ordinary path bytes, so no sentinel byte is reserved. - paramMarkers := make([]int, 0) - originalPath := path - wasAdded := false - var ri RouteInfo - for i, lcpIndex := 0, len(path); i < lcpIndex; i++ { - if path[i] == paramLabel { - if i > 0 && path[i-1] == '\\' { - path = path[:i-1] + path[i:] - i-- - lcpIndex-- - continue - } - j := i + 1 - - r.insert(staticKind, path[:i], method, routeMethod{RouteInfo: &RouteInfo{Method: method}}, paramMarkers) - for ; i < lcpIndex && path[i] != '/'; i++ { - } - - paramNames = append(paramNames, path[j:i]) - paramMarkers = append(paramMarkers, j-1) - path = path[:j] + path[i:] - i, lcpIndex = j, len(path) - - if i == lcpIndex { - // path node is last fragment of route path. ie. `/users/:id` - ri = route.ToRouteInfo(paramNames) - rm := routeMethod{ - RouteInfo: &RouteInfo{Method: method, Path: originalPath, Parameters: paramNames, Name: route.Name}, - handler: h, - orgRouteInfo: ri, - wrappedHeadHandler: headH, - } - r.insert(paramKind, path[:i], method, rm, paramMarkers) - wasAdded = true - break - } else { - r.insert(paramKind, path[:i], method, routeMethod{RouteInfo: &RouteInfo{Method: method}}, paramMarkers) - } - } else if path[i] == anyLabel { - r.insert(staticKind, path[:i], method, routeMethod{RouteInfo: &RouteInfo{Method: method}}, paramMarkers) + parts := parseRoutePath(path) + var paramNames []string + for _, part := range parts { + if part.kind == paramKind { + paramNames = append(paramNames, part.value) + } else if part.kind == anyKind { paramNames = append(paramNames, "*") - ri = route.ToRouteInfo(paramNames) - rm := routeMethod{ - RouteInfo: &RouteInfo{Method: method, Path: originalPath, Parameters: paramNames, Name: route.Name}, - handler: h, - orgRouteInfo: ri, - wrappedHeadHandler: headH, - } - r.insert(anyKind, path[:i+1], method, rm, paramMarkers) - wasAdded = true break } } - - if !wasAdded { - ri = route.ToRouteInfo(paramNames) - rm := routeMethod{ - RouteInfo: &RouteInfo{Method: method, Path: originalPath, Parameters: paramNames, Name: route.Name}, - handler: h, - orgRouteInfo: ri, - wrappedHeadHandler: headH, + ri := route.ToRouteInfo(paramNames) + rm := routeMethod{ + RouteInfo: &RouteInfo{Method: method, Path: path, Parameters: paramNames, Name: route.Name}, + handler: h, + orgRouteInfo: ri, + wrappedHeadHandler: headH, + } + partial := routeMethod{RouteInfo: &RouteInfo{Method: method}} + var treePath string + var paramMarkers []int +addParts: + for i, part := range parts { + switch part.kind { + case staticKind: + treePath += part.value + if i == len(parts)-1 { + r.insert(staticKind, treePath, method, rm, paramMarkers) + } + case paramKind: + r.insert(staticKind, treePath, method, partial, paramMarkers) + paramMarkers = append(paramMarkers, len(treePath)) + treePath += ":" + if i == len(parts)-1 { + r.insert(paramKind, treePath, method, rm, paramMarkers) + } else { + r.insert(paramKind, treePath, method, partial, paramMarkers) + } + case anyKind: + r.insert(staticKind, treePath, method, partial, paramMarkers) + treePath += "*" + r.insert(anyKind, treePath, method, rm, paramMarkers) + break addParts } - r.insert(staticKind, path, method, rm, paramMarkers) } r.storeRouteInfo(ri) @@ -724,14 +694,7 @@ func (r *DefaultRouter) insert(t kind, path string, method string, ri routeMetho } else if lcpLen < searchLen { searchOffset += lcpLen search = search[lcpLen:] - isParamMarker := false - for _, marker := range paramMarkers { - if marker == searchOffset { - isParamMarker = true - break - } - } - c := currentNode.findChildWithLabel(search[0], isParamMarker) + c := currentNode.findChildWithLabel(search[0], slices.Contains(paramMarkers, searchOffset)) if c != nil { // Go deeper currentNode = c @@ -836,6 +799,23 @@ func (n *node) findChildWithLabel(l byte, isParamMarker bool) *node { return nil } +// canMatchStaticSuffix checks the static path following an inline parameter +// delimiter. The route matcher uses it only when a parameter has a literal +// colon child, so ordinary parameter routes keep their slash-based fast path. +func (n *node) canMatchStaticSuffix(path string) bool { + if !strings.HasPrefix(path, n.prefix) { + return false + } + path = path[len(n.prefix):] + if path == "" { + return n.isHandler || n.methods.notFoundHandler != nil || n.anyChild != nil + } + if child := n.findStaticChild(path[0]); child != nil && child.canMatchStaticSuffix(path) { + return true + } + return n.paramChild != nil || n.anyChild != nil +} + func (n *node) setHandler(method string, r *routeMethod) { n.methods.set(method, r) n.isHandler = n.methods.isHandler() @@ -1040,6 +1020,17 @@ func (r *DefaultRouter) Route(c *Context) HandlerFunc { } else { for ; i < l && search[i] != '/'; i++ { } + // An escaped colon after a parameter is a static suffix within the + // same segment. Prefer the longest matching suffix and leave it for + // the normal static-node matcher. + if suffix := currentNode.findStaticChild(':'); suffix != nil { + for split := 0; split < i; split++ { + if search[split] == ':' && suffix.canMatchStaticSuffix(search[split:]) { + i = split + break + } + } + } } pathValues[paramIndex].Value = search[:i] From 52e6ec62e2a25a523d56598492ffa4ad27e65823 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 29 Sep 2026 14:41:21 -0700 Subject: [PATCH 2/9] fix(router): retry inline verb splits before generic paths --- route.go | 4 +- route_path.go | 42 +++++++------- route_syntax_test.go | 42 ++++++++++++++ router.go | 130 ++++++++++++++++++++++++++++--------------- 4 files changed, 151 insertions(+), 67 deletions(-) diff --git a/route.go b/route.go index 0c165fca2..30bf17116 100644 --- a/route.go +++ b/route.go @@ -82,7 +82,7 @@ func (r RouteInfo) Clone() RouteInfo { func (r RouteInfo) Reverse(pathValues ...any) string { uri := new(bytes.Buffer) nextValue := 0 - for _, part := range parseRoutePath(r.Path) { + walkRoutePath(r.Path, func(part routePathPart) { switch part.kind { case staticKind: uri.WriteString(part.value) @@ -102,7 +102,7 @@ func (r RouteInfo) Reverse(pathValues ...any) string { uri.WriteString(part.value) } } - } + }) return uri.String() } diff --git a/route_path.go b/route_path.go index d4b4a4458..571cdb365 100644 --- a/route_path.go +++ b/route_path.go @@ -14,21 +14,18 @@ type routePathPart struct { func parseRoutePath(path string) []routePathPart { var parts []routePathPart - var literal strings.Builder - flushLiteral := func() { - if literal.Len() > 0 { - parts = append(parts, routePathPart{kind: staticKind, value: literal.String()}) - literal.Reset() - } - } + walkRoutePath(path, func(part routePathPart) { parts = append(parts, part) }) + return parts +} +// walkRoutePath is the common syntax scanner. Reverse uses it directly to +// avoid allocating a parts slice for each URL it builds. +func walkRoutePath(path string, emit func(routePathPart)) { for i := 0; i < len(path); { - switch { - case path[i] == '\\' && i+1 < len(path) && path[i+1] == ':': - literal.WriteByte(':') + if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' { + emit(routePathPart{kind: staticKind, value: ":"}) i += 2 - case path[i] == ':': - flushLiteral() + } else if path[i] == ':' { start := i + 1 i = start for i < len(path) && path[i] != '/' { @@ -37,21 +34,24 @@ func parseRoutePath(path string) []routePathPart { } i++ } - parts = append(parts, routePathPart{kind: paramKind, value: path[start:i]}) - case path[i] == '*': - flushLiteral() + emit(routePathPart{kind: paramKind, value: path[start:i]}) + } else if path[i] == '*' { start := i for i < len(path) && path[i] != '/' { i++ } - parts = append(parts, routePathPart{kind: anyKind, value: path[start:i]}) - default: - literal.WriteByte(path[i]) - i++ + emit(routePathPart{kind: anyKind, value: path[start:i]}) + } else { + start := i + for i < len(path) && path[i] != ':' && path[i] != '*' { + if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' { + break + } + i++ + } + emit(routePathPart{kind: staticKind, value: path[start:i]}) } } - flushLiteral() - return parts } func routeTreePath(parts []routePathPart) (string, []int) { diff --git a/route_syntax_test.go b/route_syntax_test.go index ea6b6e510..ad06300b4 100644 --- a/route_syntax_test.go +++ b/route_syntax_test.go @@ -113,3 +113,45 @@ func TestRouteInfoReverseInlineVerb(t *testing.T) { assert.Equal(t, "/r/foo:cancel", ri.Reverse("foo")) assert.Equal(t, "/r/:name:cancel", ri.Reverse()) } + +func TestRouterInlineVerbBacktracksToGenericRoute(t *testing.T) { + e := New() + e.GET(`/r/:name\:ab:p/z`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) + e.GET(`/r/:id/info`, func(c *Context) error { return c.String(http.StatusOK, c.Param("id")) }) + assertRouteResponse(t, e, "/r/q:abc/info", "q:abc") + + e = New() + e.GET(`/r/:name\:y/:p/z`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) + e.GET(`/r/:id/info`, func(c *Context) error { return c.String(http.StatusOK, c.Param("id")) }) + assertRouteResponse(t, e, "/r/q:y/info", "q:y") +} + +func TestRouterInlineVerbMethodFallback(t *testing.T) { + e := New() + e.GET(`/r/:id`, func(c *Context) error { return c.String(http.StatusOK, c.Param("id")) }) + e.POST(`/r/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) + assertRouteResponse(t, e, "/r/foo:cancel", "foo:cancel") +} + +func TestRouterInlineVerbRequiresNonemptyParameter(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, c.Param("name")) }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/:cancel", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestRouterInlineVerbKeepsStaticSiblingPriority(t *testing.T) { + e := New() + e.GET(`/r/:name\:x:id`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) + e.GET(`/r/:name/q`, func(c *Context) error { return c.String(http.StatusOK, "static:"+c.Param("name")) }) + assertRouteResponse(t, e, "/r/a:x/q", "static:a:x") +} + +func TestRouterInlineVerbMethodNotAllowedWithoutFallback(t *testing.T) { + e := New() + e.POST(`/r/:name\:cancel`, func(c *Context) error { return c.NoContent(http.StatusOK) }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/foo:cancel", nil)) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) +} diff --git a/router.go b/router.go index c29f58e49..193ead7f7 100644 --- a/router.go +++ b/router.go @@ -169,12 +169,13 @@ type node struct { // scLabels holds the first byte (label) of each staticChildren entry in the // same order. Scanning this contiguous byte slice during routing is more // cache-friendly than dereferencing each *node to read its label. - scLabels []byte - paramsCount int - kind kind - label byte - isLeaf bool - isHandler bool + scLabels []byte + paramsCount int + kind kind + label byte + isLeaf bool + isHandler bool + hasColonChild bool } type kind uint8 @@ -460,6 +461,9 @@ func (r *DefaultRouter) Remove(method string, path string) error { } parent.staticChildren = append(parent.staticChildren[:index], parent.staticChildren[index+1:]...) parent.scLabels = append(parent.scLabels[:index], parent.scLabels[index+1:]...) + if current.label == ':' { + parent.hasColonChild = false + } case paramKind: parent.paramChild = nil case anyKind: @@ -546,29 +550,28 @@ func (r *DefaultRouter) Add(route Route) (RouteInfo, error) { wrappedHeadHandler: headH, } partial := routeMethod{RouteInfo: &RouteInfo{Method: method}} - var treePath string - var paramMarkers []int + treePath, paramMarkers := routeTreePath(parts) + pathEnd := 0 addParts: for i, part := range parts { switch part.kind { case staticKind: - treePath += part.value + pathEnd += len(part.value) if i == len(parts)-1 { - r.insert(staticKind, treePath, method, rm, paramMarkers) + r.insert(staticKind, treePath[:pathEnd], method, rm, paramMarkers) } case paramKind: - r.insert(staticKind, treePath, method, partial, paramMarkers) - paramMarkers = append(paramMarkers, len(treePath)) - treePath += ":" + r.insert(staticKind, treePath[:pathEnd], method, partial, paramMarkers) + pathEnd++ if i == len(parts)-1 { - r.insert(paramKind, treePath, method, rm, paramMarkers) + r.insert(paramKind, treePath[:pathEnd], method, rm, paramMarkers) } else { - r.insert(paramKind, treePath, method, partial, paramMarkers) + r.insert(paramKind, treePath[:pathEnd], method, partial, paramMarkers) } case anyKind: - r.insert(staticKind, treePath, method, partial, paramMarkers) - treePath += "*" - r.insert(anyKind, treePath, method, rm, paramMarkers) + r.insert(staticKind, treePath[:pathEnd], method, partial, paramMarkers) + pathEnd++ + r.insert(anyKind, treePath[:pathEnd], method, rm, paramMarkers) break addParts } } @@ -660,6 +663,7 @@ func (r *DefaultRouter) insert(t kind, path string, method string, ri routeMetho currentNode.label = currentNode.prefix[0] currentNode.prefix = currentNode.prefix[:lcpLen] currentNode.staticChildren = nil + currentNode.hasColonChild = false currentNode.scLabels = nil currentNode.methods = new(routeMethods) currentNode.originalPath = "" @@ -739,10 +743,14 @@ func newNode( anyChildren *node, ) *node { var scLabels []byte + var hasColonChild bool if len(sc) > 0 { scLabels = make([]byte, len(sc)) for i, c := range sc { scLabels[i] = c.label + if c.label == ':' { + hasColonChild = true + } } } return &node{ @@ -751,6 +759,7 @@ func newNode( prefix: pre, parent: p, staticChildren: sc, + hasColonChild: hasColonChild, scLabels: scLabels, originalPath: ppath, paramsCount: paramsCount, @@ -775,6 +784,9 @@ func (n *node) refreshLeaf() { func (n *node) addStaticChild(c *node) { n.staticChildren = append(n.staticChildren, c) n.scLabels = append(n.scLabels, c.label) + if c.label == ':' { + n.hasColonChild = true + } } func (n *node) findStaticChild(l byte) *node { @@ -799,23 +811,6 @@ func (n *node) findChildWithLabel(l byte, isParamMarker bool) *node { return nil } -// canMatchStaticSuffix checks the static path following an inline parameter -// delimiter. The route matcher uses it only when a parameter has a literal -// colon child, so ordinary parameter routes keep their slash-based fast path. -func (n *node) canMatchStaticSuffix(path string) bool { - if !strings.HasPrefix(path, n.prefix) { - return false - } - path = path[len(n.prefix):] - if path == "" { - return n.isHandler || n.methods.notFoundHandler != nil || n.anyChild != nil - } - if child := n.findStaticChild(path[0]); child != nil && child.canMatchStaticSuffix(path) { - return true - } - return n.paramChild != nil || n.anyChild != nil -} - func (n *node) setHandler(method string, r *routeMethod) { n.methods.set(method, r) n.isHandler = n.methods.isHandler() @@ -939,6 +934,12 @@ func (r *DefaultRouter) Route(c *Context) HandlerFunc { return } + var splitPlan []int + var splitOptions []int + var splitUsed bool + var fallbackNode *node + var fallbackValues PathValues + // Router tree is implemented by longest common prefix array (LCP array) https://en.wikipedia.org/wiki/LCP_array // Tree search is implemented as for loop where one loop iteration is divided into 3 separate blocks // Each of these blocks checks specific kind of node (static/param/any). Order of blocks reflex their priority in routing. @@ -946,6 +947,9 @@ func (r *DefaultRouter) Route(c *Context) HandlerFunc { // // Note: backtracking in tree is implemented by replacing/switching currentNode to previous node // and hoping to (goto statement) next block by priority to check if it is the match. +searchRoute: + splitOptions = splitOptions[:0] + splitUsed = false for { prefixLen := 0 // Prefix length lcpLen := 0 // LCP (longest common prefix) length @@ -1013,21 +1017,37 @@ func (r *DefaultRouter) Route(c *Context) HandlerFunc { currentNode = child i := 0 l := len(search) - if currentNode.isLeaf { + if currentNode.isLeaf && !splitUsed { // when param node does not have any children (path param is last piece of route path) then param node should // act similarly to any node - consider all remaining search as match i = l } else { for ; i < l && search[i] != '/'; i++ { } - // An escaped colon after a parameter is a static suffix within the - // same segment. Prefer the longest matching suffix and leave it for - // the normal static-node matcher. - if suffix := currentNode.findStaticChild(':'); suffix != nil { - for split := 0; split < i; split++ { - if search[split] == ':' && suffix.canMatchStaticSuffix(search[split:]) { - i = split - break + // Each colon is a possible split before a literal verb. If that + // route fails, retry the next split and finally the whole segment. + if currentNode.hasColonChild { + choice := 0 + if len(splitPlan) > len(splitOptions) { + choice = splitPlan[len(splitOptions)] + } + count, chosen := 0, -1 + for split := 1; split < i; split++ { + if search[split] == ':' { + if count == choice { + chosen = split + } + count++ + } + } + if count > 0 { + if len(splitPlan) == len(splitOptions) { + splitPlan = append(splitPlan, 0) + } + splitOptions = append(splitOptions, count+1) + if chosen >= 0 { + i = chosen + splitUsed = true } } } @@ -1078,6 +1098,28 @@ func (r *DefaultRouter) Route(c *Context) HandlerFunc { break } } + if matchedRouteMethod == nil && len(splitOptions) > 0 { + if fallbackNode == nil && previousBestMatchNode != nil { + fallbackNode = previousBestMatchNode + fallbackValues = append(fallbackValues, pathValues[:fallbackNode.paramsCount]...) + } + for i := len(splitOptions) - 1; i >= 0; i-- { + if splitPlan[i]+1 < splitOptions[i] { + splitPlan[i]++ + splitPlan = splitPlan[:i+1] + currentNode = r.tree + previousBestMatchNode = nil + search, searchIndex, paramIndex = path, 0, 0 + clear(pathValues) + goto searchRoute + } + } + } + if matchedRouteMethod == nil && fallbackNode != nil { + currentNode = fallbackNode + previousBestMatchNode = fallbackNode + copy(pathValues, fallbackValues) + } if currentNode == nil && previousBestMatchNode == nil { pathValues = pathValues[0:0] From 9b86d77b1b500096419ef5456b6b1906a66c6978 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 29 Sep 2026 14:48:47 -0700 Subject: [PATCH 3/9] perf(router): preserve fast path without inline verbs --- route_path.go | 9 ++ router.go | 24 +++-- router_plain.go | 273 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 9 deletions(-) create mode 100644 router_plain.go diff --git a/route_path.go b/route_path.go index 571cdb365..c0041c25c 100644 --- a/route_path.go +++ b/route_path.go @@ -12,6 +12,15 @@ type routePathPart struct { value string } +func hasInlineVerbPart(parts []routePathPart) bool { + for i := 1; i < len(parts); i++ { + if parts[i-1].kind == paramKind && parts[i].kind == staticKind && parts[i].value == ":" { + return true + } + } + return false +} + func parseRoutePath(path string) []routePathPart { var parts []routePathPart walkRoutePath(path, func(part routePathPart) { parts = append(parts, part) }) diff --git a/router.go b/router.go index 193ead7f7..a6567feab 100644 --- a/router.go +++ b/router.go @@ -72,6 +72,7 @@ type DefaultRouter struct { unescapePathParamValues bool useEscapedPathForRouting bool autoHandleHEAD bool + hasInlineVerb bool } // RouterConfig is configuration options for (default) router @@ -477,6 +478,15 @@ func (r *DefaultRouter) Remove(method string, path string) error { current = parent } } + if r.hasInlineVerb { + r.hasInlineVerb = false + for _, route := range r.routes { + if hasInlineVerbPart(parseRoutePath(route.Path)) { + r.hasInlineVerb = true + break + } + } + } return nil } @@ -533,6 +543,7 @@ func (r *DefaultRouter) Add(route Route) (RouteInfo, error) { } parts := parseRoutePath(path) + r.hasInlineVerb = r.hasInlineVerb || hasInlineVerbPart(parts) var paramNames []string for _, part := range parts { if part.kind == paramKind { @@ -863,15 +874,10 @@ var optionsMethodHandler = func(c *Context) error { return c.NoContent(http.StatusNoContent) } -// Route looks up a handler registered for method and path. It also parses URL for path parameters and loads them -// into context. -// -// For performance: -// -// - Get context from `Echo#AcquireContext()` -// - Reset it `Context#Reset()` -// - Return it `Echo#ReleaseContext()`. -func (r *DefaultRouter) Route(c *Context) HandlerFunc { +// routeInline handles requests that may need to retry a literal-colon split. +// The ordinary Route path stays separate so routers without inline verbs keep +// their existing matching cost. +func (r *DefaultRouter) routeInline(c *Context) HandlerFunc { pathValues := c.PathValues() if cap(pathValues) < r.maxPathParamsLength { pathValues = make(PathValues, 0, r.maxPathParamsLength) diff --git a/router_plain.go b/router_plain.go new file mode 100644 index 000000000..5d9ee0861 --- /dev/null +++ b/router_plain.go @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "net/http" + "net/url" + "strings" +) + +// Route looks up a handler and path parameters. It uses the established fast +// path unless the router has an inline verb and the request contains a colon. +func (r *DefaultRouter) Route(c *Context) HandlerFunc { + req := c.Request() + path := req.URL.Path + if !r.useEscapedPathForRouting && req.URL.RawPath != "" { + // Difference between URL.RawPath and URL.Path is: + // * URL.Path is where request path is stored. Value is stored in decoded form: /%47%6f%2f becomes /Go/. + // * URL.RawPath is an optional field which only gets set if the default encoding is different from Path. + path = req.URL.RawPath + } + if r.hasInlineVerb && strings.IndexByte(path, ':') >= 0 { + return r.routeInline(c) + } + pathValues := c.PathValues() + if cap(pathValues) < r.maxPathParamsLength { + pathValues = make(PathValues, 0, r.maxPathParamsLength) + } else { + pathValues = pathValues[0:cap(pathValues)] // resize slice to maximum capacity so we can index set values + } + + var ( + currentNode = r.tree // root as current node + previousBestMatchNode *node + matchedRouteMethod *routeMethod + // search stores the remaining path to check for match. By each iteration we move from start of path to end of the path + // and search value gets shorter and shorter. + search = path + searchIndex = 0 + paramIndex int // Param counter + ) + + // Backtracking is needed when a dead end (leaf node) is reached in the router tree. + // To backtrack the current node will be changed to the parent node and the next kind for the + // router logic will be returned based on fromKind or kind of the dead end node (static > param > any). + // For example if there is no static node match we should check parent next sibling by kind (param). + // Backtracking itself does not check if there is a next sibling, this is done by the router logic. + backtrackToNextNodeKind := func(fromKind kind) (nextNodeKind kind, valid bool) { + previous := currentNode + currentNode = previous.parent + valid = currentNode != nil + + // Next node type by priority + if previous.kind == anyKind { + nextNodeKind = staticKind + } else { + nextNodeKind = previous.kind + 1 + } + + if fromKind == staticKind { + // when backtracking is done from static kind block we did not change search so nothing to restore + return + } + + // restore search to value it was before we move to current node we are backtracking from. + if previous.kind == staticKind { + searchIndex -= len(previous.prefix) + } else { + paramIndex-- + // param/any node prefixes are a single marker byte, so restore searchIndex + // from the value stored for that param instead + searchIndex -= len(pathValues[paramIndex].Value) + pathValues[paramIndex].Value = "" + } + search = path[searchIndex:] + return + } + + // Router tree is implemented by longest common prefix array (LCP array) https://en.wikipedia.org/wiki/LCP_array + // Tree search is implemented as for loop where one loop iteration is divided into 3 separate blocks + // Each of these blocks checks specific kind of node (static/param/any). Order of blocks reflex their priority in routing. + // Search order/priority is: static > param > any. + // + // Note: backtracking in tree is implemented by replacing/switching currentNode to previous node + // and hoping to (goto statement) next block by priority to check if it is the match. + for { + prefixLen := 0 // Prefix length + lcpLen := 0 // LCP (longest common prefix) length + + if currentNode.kind == staticKind { + searchLen := len(search) + prefixLen = len(currentNode.prefix) + + // LCP - Longest Common Prefix (https://en.wikipedia.org/wiki/LCP_array) + lMax := min(searchLen, prefixLen) + for ; lcpLen < lMax && search[lcpLen] == currentNode.prefix[lcpLen]; lcpLen++ { + } + } + + if lcpLen != prefixLen { + // No matching prefix, let's backtrack to the first possible alternative node of the decision path + nk, ok := backtrackToNextNodeKind(staticKind) + if !ok { + break // No other possibilities on the decision path, handler will be whatever context is reset to. + } else if nk == paramKind { + goto Param + // NOTE: this case (backtracking from static node to previous any node) can not happen by current any matching logic. Any node is end of search currently + //} else if nk == anyKind { + // goto Any + } else { + // Not found (this should never be possible for static node we are looking currently) + break + } + } + + // The full prefix has matched, remove the prefix from the remaining search + search = search[lcpLen:] + searchIndex = searchIndex + lcpLen + + // Finish routing if is no request path remaining to search + if search == "" { + // in case of node that is handler we have exact method type match or something for 405 to use + if currentNode.isHandler { + // check if current node has handler registered for http method we are looking for. we store currentNode as + // best matching in case we do no find no more routes matching this path+method + if previousBestMatchNode == nil { + previousBestMatchNode = currentNode + } + if h := currentNode.methods.find(req.Method, true, r.autoHandleHEAD); h != nil { + matchedRouteMethod = h + break + } + } else if currentNode.methods.notFoundHandler != nil { + matchedRouteMethod = currentNode.methods.notFoundHandler + break + } + } + + // Static node + if search != "" { + if child := currentNode.findStaticChild(search[0]); child != nil { + currentNode = child + continue + } + } + + Param: + // Param node + if child := currentNode.paramChild; search != "" && child != nil { + currentNode = child + i := 0 + l := len(search) + if currentNode.isLeaf { + // when param node does not have any children (path param is last piece of route path) then param node should + // act similarly to any node - consider all remaining search as match + i = l + } else { + for ; i < l && search[i] != '/'; i++ { + } + } + + pathValues[paramIndex].Value = search[:i] + paramIndex++ + search = search[i:] + searchIndex = searchIndex + i + continue + } + + Any: + // Any node + if child := currentNode.anyChild; child != nil { + // If any node is found, use remaining path for paramValues + currentNode = child + pathValues[currentNode.paramsCount-1].Value = search + // update indexes/search in case we need to backtrack when no handler match is found + paramIndex++ + searchIndex += len(search) + search = "" + + if rMethod := currentNode.methods.find(req.Method, true, r.autoHandleHEAD); rMethod != nil { + matchedRouteMethod = rMethod + break + } + // we store currentNode as best matching in case we do not find more routes matching this path+method. Needed for 405 + if previousBestMatchNode == nil { + previousBestMatchNode = currentNode + } + if currentNode.methods.notFoundHandler != nil { + matchedRouteMethod = currentNode.methods.notFoundHandler + break + } + } + + // Let's backtrack to the first possible alternative node of the decision path + nk, ok := backtrackToNextNodeKind(anyKind) + if !ok { + break // No other possibilities on the decision path + } else if nk == paramKind { + goto Param + } else if nk == anyKind { + goto Any + } else { + // Not found + break + } + } + + if currentNode == nil && previousBestMatchNode == nil { + pathValues = pathValues[0:0] + + c.InitializeRoute(notFoundRouteInfo, &pathValues) + return r.notFoundHandler // nothing matched at all with given path + } + + var rHandler HandlerFunc + var rPath string + var rInfo *RouteInfo + if matchedRouteMethod != nil { + rHandler = matchedRouteMethod.handler + if req.Method == http.MethodHead && matchedRouteMethod.wrappedHeadHandler != nil { + rHandler = matchedRouteMethod.wrappedHeadHandler + // we are not touching rInfo.Method and let it be value from GET routeInfo + } + + rPath = matchedRouteMethod.Path + rInfo = matchedRouteMethod.RouteInfo + } else { + // use previous match as basis. although we have no matching handler we have path match. + // so we can send http.StatusMethodNotAllowed (405) instead of http.StatusNotFound (404) + currentNode = previousBestMatchNode + + rPath = currentNode.originalPath + rInfo = notFoundRouteInfo + if currentNode.methods.notFoundHandler != nil { + matchedRouteMethod = currentNode.methods.notFoundHandler + + rInfo = matchedRouteMethod.RouteInfo + rPath = matchedRouteMethod.Path + rHandler = matchedRouteMethod.handler + } else if currentNode.isHandler { + rInfo = methodNotAllowedRouteInfo + + c.Set(ContextKeyHeaderAllow, currentNode.methods.allowHeader) + rHandler = r.methodNotAllowedHandler + if req.Method == http.MethodOptions { + rHandler = r.optionsMethodHandler + } + } + } + + pathValues = pathValues[0:currentNode.paramsCount] + if matchedRouteMethod != nil { + for i, name := range matchedRouteMethod.Parameters { + pathValues[i].Name = name + } + } + + if r.unescapePathParamValues { + // See issue #1531, #1258 - there are cases when path parameter need to be unescaped + for i, p := range pathValues { + tmpVal, err := url.PathUnescape(p.Value) + if err == nil { // handle problems by ignoring them. + pathValues[i].Value = tmpVal + } + } + } + + c.InitializeRoute(rInfo, &pathValues) + c.SetPath(rPath) // after InitializeRoute so we would not accidentally change `notFoundRouteInfo` or `methodNotAllowedRouteInfo` Path + c.request.Pattern = rPath // help standard library based middlewares. This is a deliberate choice not to call `request.SetPathValue` for params. + return rHandler +} From ab506410c3f186a948600b677857c25b18615956 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 29 Sep 2026 15:46:58 -0700 Subject: [PATCH 4/9] fix(router): retry inline verb splits on the param node Replace the whole-search retry plan with a backtrack point on the param node: when a candidate split fails, the same param node is retried with the next literal-colon split and finally the whole path segment before routing backtracks to its parent. Static > param > any priority of ancestors, the leaf rule of later params, group middleware and catch-all routes are no longer affected by a failed split. An escaped colon after a parameter starts an inline verb only when the rest of that path segment is static (`/:name\:cancel`, optionally followed by `/...`). Split candidates are then scanned once per segment, so a request with many colons is routed in linear time. Other escaped colons after a parameter keep their older meaning as part of the parameter name. The duplicated fast-path Route and the router-level inline verb flag are removed. The param scan uses strings.IndexByte, which keeps router benchmarks within about 1% of master (geomean). --- route_path.go | 32 +++-- route_syntax_test.go | 70 ++++++++++- router.go | 126 ++++++++------------ router_plain.go | 273 ------------------------------------------- 4 files changed, 134 insertions(+), 367 deletions(-) delete mode 100644 router_plain.go diff --git a/route_path.go b/route_path.go index c0041c25c..dcf18dc4a 100644 --- a/route_path.go +++ b/route_path.go @@ -6,21 +6,13 @@ package echo import "strings" // routePathPart is one parsed piece of a route pattern. A backslash before a -// colon makes the colon static, including when it follows a parameter name. +// colon makes the colon static. After a parameter name it starts an inline verb +// (`/:name\:cancel`) when the rest of that path segment is static. type routePathPart struct { kind kind value string } -func hasInlineVerbPart(parts []routePathPart) bool { - for i := 1; i < len(parts); i++ { - if parts[i-1].kind == paramKind && parts[i].kind == staticKind && parts[i].value == ":" { - return true - } - } - return false -} - func parseRoutePath(path string) []routePathPart { var parts []routePathPart walkRoutePath(path, func(part routePathPart) { parts = append(parts, part) }) @@ -38,7 +30,7 @@ func walkRoutePath(path string, emit func(routePathPart)) { start := i + 1 i = start for i < len(path) && path[i] != '/' { - if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' { + if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' && isInlineVerb(path[i+2:]) { break } i++ @@ -63,6 +55,24 @@ func walkRoutePath(path string, emit func(routePathPart)) { } } +// isInlineVerb reports whether the route text after an escaped colon stays +// static up to the end of its path segment. Only then can the router find where +// the parameter value ends by trying the colons in the requested segment. Other +// escaped colons keep the older meaning and remain part of the parameter name. +func isInlineVerb(rest string) bool { + for i := 0; i < len(rest) && rest[i] != '/'; i++ { + switch rest[i] { + case '*': + return false + case ':': + if i == 0 || rest[i-1] != '\\' { + return false + } + } + } + return true +} + func routeTreePath(parts []routePathPart) (string, []int) { var path strings.Builder var paramMarkers []int diff --git a/route_syntax_test.go b/route_syntax_test.go index ad06300b4..41c8dc2ae 100644 --- a/route_syntax_test.go +++ b/route_syntax_test.go @@ -6,6 +6,7 @@ package echo import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -116,9 +117,10 @@ func TestRouteInfoReverseInlineVerb(t *testing.T) { func TestRouterInlineVerbBacktracksToGenericRoute(t *testing.T) { e := New() - e.GET(`/r/:name\:ab:p/z`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) + e.GET(`/r/:name\:ab/:p/z`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) e.GET(`/r/:id/info`, func(c *Context) error { return c.String(http.StatusOK, c.Param("id")) }) assertRouteResponse(t, e, "/r/q:abc/info", "q:abc") + assertRouteResponse(t, e, "/r/q:ab/info", "q:ab") e = New() e.GET(`/r/:name\:y/:p/z`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) @@ -141,11 +143,71 @@ func TestRouterInlineVerbRequiresNonemptyParameter(t *testing.T) { assert.Equal(t, http.StatusNotFound, rec.Code) } -func TestRouterInlineVerbKeepsStaticSiblingPriority(t *testing.T) { +func TestRouterInlineVerbAndStaticSibling(t *testing.T) { e := New() - e.GET(`/r/:name\:x:id`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) + e.GET(`/r/:name\:x/:id`, func(c *Context) error { return c.String(http.StatusOK, "verb:"+c.Param("name")+":"+c.Param("id")) }) e.GET(`/r/:name/q`, func(c *Context) error { return c.String(http.StatusOK, "static:"+c.Param("name")) }) - assertRouteResponse(t, e, "/r/a:x/q", "static:a:x") + assertRouteResponse(t, e, "/r/a:x/q", "verb:a:q") + assertRouteResponse(t, e, "/r/a:y/q", "static:a:y") +} + +func TestRouterInlineVerbMustEndPathSegment(t *testing.T) { + // An escaped colon that is followed by a param or wildcard in the same segment keeps its older meaning: it is part + // of the param name. Trying every colon in a request segment for such routes could not be bounded. + e := New() + ri, err := e.AddRoute(Route{Method: http.MethodGet, Path: `/r/:name\:x:id`, Handler: func(c *Context) error { return nil }}) + assert.NoError(t, err) + assert.Equal(t, []string{`name\:x:id`}, ri.Parameters) + ri, err = e.AddRoute(Route{Method: http.MethodGet, Path: `/s/:name\:x*`, Handler: func(c *Context) error { return nil }}) + assert.NoError(t, err) + assert.Equal(t, []string{`name\:x*`}, ri.Parameters) +} + +func TestRouterInlineVerbLeafParamAfterVerb(t *testing.T) { + e := New() + e.GET(`/r/:name\:x/:rest`, func(c *Context) error { return c.String(http.StatusOK, c.Param("name")+"|"+c.Param("rest")) }) + assertRouteResponse(t, e, "/r/a:x/b/c", "a|b/c") +} + +func TestRouterInlineVerbWithGroupMiddlewareAndCatchAll(t *testing.T) { + e := New() + g := e.Group("/r", func(next HandlerFunc) HandlerFunc { return next }) + g.GET("/:name", func(c *Context) error { return c.String(http.StatusOK, "generic:"+c.Param("name")) }) + g.GET(`/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, "cancel:"+c.Param("name")) }) + assertRouteResponse(t, e, "/r/foo:other", "generic:foo:other") + assertRouteResponse(t, e, "/r/foo:cancel", "cancel:foo") + + e = New() + e.GET("/r/:name", func(c *Context) error { return c.String(http.StatusOK, "generic:"+c.Param("name")) }) + e.GET(`/r/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, "cancel:"+c.Param("name")) }) + e.GET("/*", func(c *Context) error { return c.String(http.StatusOK, "any") }) + assertRouteResponse(t, e, "/r/foo:other", "generic:foo:other") + assertRouteResponse(t, e, "/r/foo:cancel", "cancel:foo") +} + +func TestRouterInlineVerbManyColons(t *testing.T) { + // Every colon in the segment is a possible split. Each is tried at most once, so a long run of colons is routed + // in linear time. + e := New() + e.GET(`/r/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, "cancel:"+c.Param("name")) }) + e.GET(`/r/:name\:c`, func(c *Context) error { return c.String(http.StatusOK, "c:"+c.Param("name")) }) + e.GET(`/r/:name\:x/:a\:y/z`, func(c *Context) error { return c.String(http.StatusOK, "nested") }) + colons := strings.Repeat(":", 1<<16) + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a"+colons+"b", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + + rec = httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a"+colons+"x/b"+colons+"y/nope", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + + // every ":c" enters the shared ":c" verb node before failing, so each split is retried + rec = httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a"+strings.Repeat(":c", 1<<15)+"b", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + + assertRouteResponse(t, e, "/r/a"+colons+"cancel", "cancel:a"+colons[1:]) } func TestRouterInlineVerbMethodNotAllowedWithoutFallback(t *testing.T) { diff --git a/router.go b/router.go index a6567feab..4c26791de 100644 --- a/router.go +++ b/router.go @@ -72,7 +72,6 @@ type DefaultRouter struct { unescapePathParamValues bool useEscapedPathForRouting bool autoHandleHEAD bool - hasInlineVerb bool } // RouterConfig is configuration options for (default) router @@ -478,15 +477,6 @@ func (r *DefaultRouter) Remove(method string, path string) error { current = parent } } - if r.hasInlineVerb { - r.hasInlineVerb = false - for _, route := range r.routes { - if hasInlineVerbPart(parseRoutePath(route.Path)) { - r.hasInlineVerb = true - break - } - } - } return nil } @@ -543,7 +533,6 @@ func (r *DefaultRouter) Add(route Route) (RouteInfo, error) { } parts := parseRoutePath(path) - r.hasInlineVerb = r.hasInlineVerb || hasInlineVerbPart(parts) var paramNames []string for _, part := range parts { if part.kind == paramKind { @@ -759,7 +748,7 @@ func newNode( scLabels = make([]byte, len(sc)) for i, c := range sc { scLabels[i] = c.label - if c.label == ':' { + if t == paramKind && c.label == ':' { hasColonChild = true } } @@ -795,11 +784,29 @@ func (n *node) refreshLeaf() { func (n *node) addStaticChild(c *node) { n.staticChildren = append(n.staticChildren, c) n.scLabels = append(n.scLabels, c.label) - if c.label == ':' { + if n.kind == paramKind && c.label == ':' { n.hasColonChild = true } } +// inlineVerbSplit returns where a param value in search ends: at the first literal colon at or after from where this +// node's inline verb child could match, otherwise at the end of the path segment. A split value is never empty. The +// scan stops at the next slash, so trying every split of a segment in turn is linear in its length. +func (n *node) inlineVerbSplit(search string, from int) int { + verbs := n.findStaticChild(':') + for i := from; i < len(search); i++ { + switch search[i] { + case '/': + return i + case ':': + if i > 0 && verbs != nil && strings.HasPrefix(search[i:], verbs.prefix) { + return i + } + } + } + return len(search) +} + func (n *node) findStaticChild(l byte) *node { for i, cl := range n.scLabels { if cl == l { @@ -874,10 +881,15 @@ var optionsMethodHandler = func(c *Context) error { return c.NoContent(http.StatusNoContent) } -// routeInline handles requests that may need to retry a literal-colon split. -// The ordinary Route path stays separate so routers without inline verbs keep -// their existing matching cost. -func (r *DefaultRouter) routeInline(c *Context) HandlerFunc { +// Route looks up a handler registered for method and path. It also parses URL for path parameters and loads them +// into context. +// +// For performance: +// +// - Get context from `Echo#AcquireContext()` +// - Reset it `Context#Reset()` +// - Return it `Echo#ReleaseContext()`. +func (r *DefaultRouter) Route(c *Context) HandlerFunc { pathValues := c.PathValues() if cap(pathValues) < r.maxPathParamsLength { pathValues = make(PathValues, 0, r.maxPathParamsLength) @@ -940,12 +952,6 @@ func (r *DefaultRouter) routeInline(c *Context) HandlerFunc { return } - var splitPlan []int - var splitOptions []int - var splitUsed bool - var fallbackNode *node - var fallbackValues PathValues - // Router tree is implemented by longest common prefix array (LCP array) https://en.wikipedia.org/wiki/LCP_array // Tree search is implemented as for loop where one loop iteration is divided into 3 separate blocks // Each of these blocks checks specific kind of node (static/param/any). Order of blocks reflex their priority in routing. @@ -953,9 +959,6 @@ func (r *DefaultRouter) routeInline(c *Context) HandlerFunc { // // Note: backtracking in tree is implemented by replacing/switching currentNode to previous node // and hoping to (goto statement) next block by priority to check if it is the match. -searchRoute: - splitOptions = splitOptions[:0] - splitUsed = false for { prefixLen := 0 // Prefix length lcpLen := 0 // LCP (longest common prefix) length @@ -1023,40 +1026,16 @@ searchRoute: currentNode = child i := 0 l := len(search) - if currentNode.isLeaf && !splitUsed { + if currentNode.isLeaf { // when param node does not have any children (path param is last piece of route path) then param node should // act similarly to any node - consider all remaining search as match i = l - } else { - for ; i < l && search[i] != '/'; i++ { - } - // Each colon is a possible split before a literal verb. If that - // route fails, retry the next split and finally the whole segment. - if currentNode.hasColonChild { - choice := 0 - if len(splitPlan) > len(splitOptions) { - choice = splitPlan[len(splitOptions)] - } - count, chosen := 0, -1 - for split := 1; split < i; split++ { - if search[split] == ':' { - if count == choice { - chosen = split - } - count++ - } - } - if count > 0 { - if len(splitPlan) == len(splitOptions) { - splitPlan = append(splitPlan, 0) - } - splitOptions = append(splitOptions, count+1) - if chosen >= 0 { - i = chosen - splitUsed = true - } - } - } + } else if currentNode.hasColonChild { + // an inline verb (`/:name\:verb`) can end the param value at a literal colon. Start with the first + // possible split, the param node is retried with the next one before backtracking (see below). + i = currentNode.inlineVerbSplit(search, 0) + } else if i = strings.IndexByte(search, '/'); i < 0 { + i = l } pathValues[paramIndex].Value = search[:i] @@ -1091,6 +1070,17 @@ searchRoute: } } + // A param value that ended at an inline verb split is a decision point of the param node itself. Retry the + // node with the next split, and finally with the whole path segment, before backtracking to its parent. + if currentNode.hasColonChild && search != "" && search[0] == ':' { + pv := &pathValues[paramIndex-1] + start := searchIndex - len(pv.Value) + searchIndex = start + currentNode.inlineVerbSplit(path[start:], len(pv.Value)+1) + pv.Value = path[start:searchIndex] + search = path[searchIndex:] + continue + } + // Let's backtrack to the first possible alternative node of the decision path nk, ok := backtrackToNextNodeKind(anyKind) if !ok { @@ -1104,28 +1094,6 @@ searchRoute: break } } - if matchedRouteMethod == nil && len(splitOptions) > 0 { - if fallbackNode == nil && previousBestMatchNode != nil { - fallbackNode = previousBestMatchNode - fallbackValues = append(fallbackValues, pathValues[:fallbackNode.paramsCount]...) - } - for i := len(splitOptions) - 1; i >= 0; i-- { - if splitPlan[i]+1 < splitOptions[i] { - splitPlan[i]++ - splitPlan = splitPlan[:i+1] - currentNode = r.tree - previousBestMatchNode = nil - search, searchIndex, paramIndex = path, 0, 0 - clear(pathValues) - goto searchRoute - } - } - } - if matchedRouteMethod == nil && fallbackNode != nil { - currentNode = fallbackNode - previousBestMatchNode = fallbackNode - copy(pathValues, fallbackValues) - } if currentNode == nil && previousBestMatchNode == nil { pathValues = pathValues[0:0] diff --git a/router_plain.go b/router_plain.go deleted file mode 100644 index 5d9ee0861..000000000 --- a/router_plain.go +++ /dev/null @@ -1,273 +0,0 @@ -// SPDX-License-Identifier: MIT -// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors - -package echo - -import ( - "net/http" - "net/url" - "strings" -) - -// Route looks up a handler and path parameters. It uses the established fast -// path unless the router has an inline verb and the request contains a colon. -func (r *DefaultRouter) Route(c *Context) HandlerFunc { - req := c.Request() - path := req.URL.Path - if !r.useEscapedPathForRouting && req.URL.RawPath != "" { - // Difference between URL.RawPath and URL.Path is: - // * URL.Path is where request path is stored. Value is stored in decoded form: /%47%6f%2f becomes /Go/. - // * URL.RawPath is an optional field which only gets set if the default encoding is different from Path. - path = req.URL.RawPath - } - if r.hasInlineVerb && strings.IndexByte(path, ':') >= 0 { - return r.routeInline(c) - } - pathValues := c.PathValues() - if cap(pathValues) < r.maxPathParamsLength { - pathValues = make(PathValues, 0, r.maxPathParamsLength) - } else { - pathValues = pathValues[0:cap(pathValues)] // resize slice to maximum capacity so we can index set values - } - - var ( - currentNode = r.tree // root as current node - previousBestMatchNode *node - matchedRouteMethod *routeMethod - // search stores the remaining path to check for match. By each iteration we move from start of path to end of the path - // and search value gets shorter and shorter. - search = path - searchIndex = 0 - paramIndex int // Param counter - ) - - // Backtracking is needed when a dead end (leaf node) is reached in the router tree. - // To backtrack the current node will be changed to the parent node and the next kind for the - // router logic will be returned based on fromKind or kind of the dead end node (static > param > any). - // For example if there is no static node match we should check parent next sibling by kind (param). - // Backtracking itself does not check if there is a next sibling, this is done by the router logic. - backtrackToNextNodeKind := func(fromKind kind) (nextNodeKind kind, valid bool) { - previous := currentNode - currentNode = previous.parent - valid = currentNode != nil - - // Next node type by priority - if previous.kind == anyKind { - nextNodeKind = staticKind - } else { - nextNodeKind = previous.kind + 1 - } - - if fromKind == staticKind { - // when backtracking is done from static kind block we did not change search so nothing to restore - return - } - - // restore search to value it was before we move to current node we are backtracking from. - if previous.kind == staticKind { - searchIndex -= len(previous.prefix) - } else { - paramIndex-- - // param/any node prefixes are a single marker byte, so restore searchIndex - // from the value stored for that param instead - searchIndex -= len(pathValues[paramIndex].Value) - pathValues[paramIndex].Value = "" - } - search = path[searchIndex:] - return - } - - // Router tree is implemented by longest common prefix array (LCP array) https://en.wikipedia.org/wiki/LCP_array - // Tree search is implemented as for loop where one loop iteration is divided into 3 separate blocks - // Each of these blocks checks specific kind of node (static/param/any). Order of blocks reflex their priority in routing. - // Search order/priority is: static > param > any. - // - // Note: backtracking in tree is implemented by replacing/switching currentNode to previous node - // and hoping to (goto statement) next block by priority to check if it is the match. - for { - prefixLen := 0 // Prefix length - lcpLen := 0 // LCP (longest common prefix) length - - if currentNode.kind == staticKind { - searchLen := len(search) - prefixLen = len(currentNode.prefix) - - // LCP - Longest Common Prefix (https://en.wikipedia.org/wiki/LCP_array) - lMax := min(searchLen, prefixLen) - for ; lcpLen < lMax && search[lcpLen] == currentNode.prefix[lcpLen]; lcpLen++ { - } - } - - if lcpLen != prefixLen { - // No matching prefix, let's backtrack to the first possible alternative node of the decision path - nk, ok := backtrackToNextNodeKind(staticKind) - if !ok { - break // No other possibilities on the decision path, handler will be whatever context is reset to. - } else if nk == paramKind { - goto Param - // NOTE: this case (backtracking from static node to previous any node) can not happen by current any matching logic. Any node is end of search currently - //} else if nk == anyKind { - // goto Any - } else { - // Not found (this should never be possible for static node we are looking currently) - break - } - } - - // The full prefix has matched, remove the prefix from the remaining search - search = search[lcpLen:] - searchIndex = searchIndex + lcpLen - - // Finish routing if is no request path remaining to search - if search == "" { - // in case of node that is handler we have exact method type match or something for 405 to use - if currentNode.isHandler { - // check if current node has handler registered for http method we are looking for. we store currentNode as - // best matching in case we do no find no more routes matching this path+method - if previousBestMatchNode == nil { - previousBestMatchNode = currentNode - } - if h := currentNode.methods.find(req.Method, true, r.autoHandleHEAD); h != nil { - matchedRouteMethod = h - break - } - } else if currentNode.methods.notFoundHandler != nil { - matchedRouteMethod = currentNode.methods.notFoundHandler - break - } - } - - // Static node - if search != "" { - if child := currentNode.findStaticChild(search[0]); child != nil { - currentNode = child - continue - } - } - - Param: - // Param node - if child := currentNode.paramChild; search != "" && child != nil { - currentNode = child - i := 0 - l := len(search) - if currentNode.isLeaf { - // when param node does not have any children (path param is last piece of route path) then param node should - // act similarly to any node - consider all remaining search as match - i = l - } else { - for ; i < l && search[i] != '/'; i++ { - } - } - - pathValues[paramIndex].Value = search[:i] - paramIndex++ - search = search[i:] - searchIndex = searchIndex + i - continue - } - - Any: - // Any node - if child := currentNode.anyChild; child != nil { - // If any node is found, use remaining path for paramValues - currentNode = child - pathValues[currentNode.paramsCount-1].Value = search - // update indexes/search in case we need to backtrack when no handler match is found - paramIndex++ - searchIndex += len(search) - search = "" - - if rMethod := currentNode.methods.find(req.Method, true, r.autoHandleHEAD); rMethod != nil { - matchedRouteMethod = rMethod - break - } - // we store currentNode as best matching in case we do not find more routes matching this path+method. Needed for 405 - if previousBestMatchNode == nil { - previousBestMatchNode = currentNode - } - if currentNode.methods.notFoundHandler != nil { - matchedRouteMethod = currentNode.methods.notFoundHandler - break - } - } - - // Let's backtrack to the first possible alternative node of the decision path - nk, ok := backtrackToNextNodeKind(anyKind) - if !ok { - break // No other possibilities on the decision path - } else if nk == paramKind { - goto Param - } else if nk == anyKind { - goto Any - } else { - // Not found - break - } - } - - if currentNode == nil && previousBestMatchNode == nil { - pathValues = pathValues[0:0] - - c.InitializeRoute(notFoundRouteInfo, &pathValues) - return r.notFoundHandler // nothing matched at all with given path - } - - var rHandler HandlerFunc - var rPath string - var rInfo *RouteInfo - if matchedRouteMethod != nil { - rHandler = matchedRouteMethod.handler - if req.Method == http.MethodHead && matchedRouteMethod.wrappedHeadHandler != nil { - rHandler = matchedRouteMethod.wrappedHeadHandler - // we are not touching rInfo.Method and let it be value from GET routeInfo - } - - rPath = matchedRouteMethod.Path - rInfo = matchedRouteMethod.RouteInfo - } else { - // use previous match as basis. although we have no matching handler we have path match. - // so we can send http.StatusMethodNotAllowed (405) instead of http.StatusNotFound (404) - currentNode = previousBestMatchNode - - rPath = currentNode.originalPath - rInfo = notFoundRouteInfo - if currentNode.methods.notFoundHandler != nil { - matchedRouteMethod = currentNode.methods.notFoundHandler - - rInfo = matchedRouteMethod.RouteInfo - rPath = matchedRouteMethod.Path - rHandler = matchedRouteMethod.handler - } else if currentNode.isHandler { - rInfo = methodNotAllowedRouteInfo - - c.Set(ContextKeyHeaderAllow, currentNode.methods.allowHeader) - rHandler = r.methodNotAllowedHandler - if req.Method == http.MethodOptions { - rHandler = r.optionsMethodHandler - } - } - } - - pathValues = pathValues[0:currentNode.paramsCount] - if matchedRouteMethod != nil { - for i, name := range matchedRouteMethod.Parameters { - pathValues[i].Name = name - } - } - - if r.unescapePathParamValues { - // See issue #1531, #1258 - there are cases when path parameter need to be unescaped - for i, p := range pathValues { - tmpVal, err := url.PathUnescape(p.Value) - if err == nil { // handle problems by ignoring them. - pathValues[i].Value = tmpVal - } - } - } - - c.InitializeRoute(rInfo, &pathValues) - c.SetPath(rPath) // after InitializeRoute so we would not accidentally change `notFoundRouteInfo` or `methodNotAllowedRouteInfo` Path - c.request.Pattern = rPath // help standard library based middlewares. This is a deliberate choice not to call `request.SetPathValue` for params. - return rHandler -} From 243be1f9a1d5cab96ee1d8736ac385853da16c3d Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 29 Sep 2026 16:20:31 -0700 Subject: [PATCH 5/9] fix(router): retry inline verb splits after a wildcard fails A wildcard ends the route search, but a param value above it that ended at an inline verb split is now retried with the next split and the whole segment, so a verb route with a wildcard cannot shadow a generic route for other methods. The retry now runs only when routing backtracks from the inline verb child into its param node, instead of on every dead end. An escaped colon after a parameter keeps its older meaning (part of the parameter name) unless the first one in the segment starts an inline verb, so a later `\:` cannot turn such a legacy route into a verb route. Reverse writes placeholders for such names without the backslash, as before. Dead hasColonChild bookkeeping for split nodes is removed. --- route.go | 6 +++-- route_path.go | 9 ++++++- route_syntax_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++++ router.go | 55 +++++++++++++++++++++++++++++------------- 4 files changed, 107 insertions(+), 20 deletions(-) diff --git a/route.go b/route.go index 30bf17116..af3fd1f26 100644 --- a/route.go +++ b/route.go @@ -9,6 +9,7 @@ import ( "fmt" "reflect" "runtime" + "strings" ) // Route contains information to adding/registering new route with the router. @@ -91,15 +92,16 @@ func (r RouteInfo) Reverse(pathValues ...any) string { fmt.Fprint(uri, pathValues[nextValue]) nextValue++ } else { + // placeholder for a missing value. An escaped colon in a param name is written without its backslash. uri.WriteByte(paramLabel) - uri.WriteString(part.value) + uri.WriteString(strings.ReplaceAll(part.value, `\:`, ":")) } case anyKind: if nextValue < len(pathValues) { fmt.Fprint(uri, pathValues[nextValue]) nextValue++ } else { - uri.WriteString(part.value) + uri.WriteString(strings.ReplaceAll(part.value, `\:`, ":")) } } }) diff --git a/route_path.go b/route_path.go index dcf18dc4a..bda8bc6c8 100644 --- a/route_path.go +++ b/route_path.go @@ -30,7 +30,14 @@ func walkRoutePath(path string, emit func(routePathPart)) { start := i + 1 i = start for i < len(path) && path[i] != '/' { - if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' && isInlineVerb(path[i+2:]) { + if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' { + if isInlineVerb(path[i+2:]) { + break + } + // not an inline verb: the rest of the segment is the param name, as before inline verbs + for i < len(path) && path[i] != '/' { + i++ + } break } i++ diff --git a/route_syntax_test.go b/route_syntax_test.go index 41c8dc2ae..25c6b7289 100644 --- a/route_syntax_test.go +++ b/route_syntax_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -161,6 +162,57 @@ func TestRouterInlineVerbMustEndPathSegment(t *testing.T) { ri, err = e.AddRoute(Route{Method: http.MethodGet, Path: `/s/:name\:x*`, Handler: func(c *Context) error { return nil }}) assert.NoError(t, err) assert.Equal(t, []string{`name\:x*`}, ri.Parameters) + // the first escaped colon decides, so a later one that is followed only by static text does not start a verb + ri, err = e.AddRoute(Route{Method: http.MethodGet, Path: `/t/:a\:x:y\:z`, Handler: func(c *Context) error { return nil }}) + assert.NoError(t, err) + assert.Equal(t, []string{`a\:x:y\:z`}, ri.Parameters) + assert.Equal(t, "/t/:a:x:y:z", ri.Reverse()) +} + +func TestRouterInlineVerbBeforeWholeSegment(t *testing.T) { + // a matching inline verb split is tried before the whole segment, also when a wildcard follows the verb + e := New() + e.GET(`/r/:name\:x/*`, func(c *Context) error { return c.String(http.StatusOK, "verb:"+c.Param("name")+"|"+c.Param("*")) }) + e.GET(`/r/:id/info`, func(c *Context) error { return c.String(http.StatusOK, "info:"+c.Param("id")) }) + assertRouteResponse(t, e, "/r/a:x/info", "verb:a|info") + assertRouteResponse(t, e, "/r/a:y/info", "info:a:y") +} + +func TestRouterInlineVerbRetriedAfterWildcard(t *testing.T) { + // a wildcard ends the search, but the split above it is still retried with the next split and the whole segment + e := New() + e.POST(`/r/:n\:v/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:n/*`, func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("n")+"|"+c.Param("*")) }) + assertRouteResponse(t, e, "/r/a:v/q", "get:a:v|q") + + e = New() + e.POST(`/r/:n\:a\:b/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:n\:b/x`, func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("n")) }) + assertRouteResponse(t, e, "/r/q:a:b/x", "get:q:a") + + e = New() + e.POST(`/r/:n\:v/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a:v/q", nil)) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) +} + +func TestRouterRemoveInlineVerbsSharingPrefix(t *testing.T) { + e := New() + h := func(c *Context) error { return c.String(http.StatusOK, c.Path()+"|"+c.Param("n")) } + e.GET(`/r/:n\:cancel`, h) + e.GET(`/r/:n\:close`, h) + e.GET(`/r/:n/x`, h) + assert.NoError(t, e.Router().Remove(http.MethodGet, `/r/:n\:cancel`)) + assertRouteResponse(t, e, "/r/a:close", `/r/:n\:close|a`) + assertRouteResponse(t, e, "/r/a:b/x", "/r/:n/x|a:b") + assert.NoError(t, e.Router().Remove(http.MethodGet, `/r/:n\:close`)) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a:close", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + e.GET(`/r/:n\:close`, h) + assertRouteResponse(t, e, "/r/a:close", `/r/:n\:close|a`) + assertRouteResponse(t, e, "/r/a:b/x", "/r/:n/x|a:b") } func TestRouterInlineVerbLeafParamAfterVerb(t *testing.T) { @@ -193,6 +245,11 @@ func TestRouterInlineVerbManyColons(t *testing.T) { e.GET(`/r/:name\:c`, func(c *Context) error { return c.String(http.StatusOK, "c:"+c.Param("name")) }) e.GET(`/r/:name\:x/:a\:y/z`, func(c *Context) error { return c.String(http.StatusOK, "nested") }) colons := strings.Repeat(":", 1<<16) + start := time.Now() + defer func() { + // linear routing takes milliseconds here; trying splits quadratically would take minutes + assert.Less(t, time.Since(start), 10*time.Second) + }() rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a"+colons+"b", nil)) diff --git a/router.go b/router.go index 4c26791de..9e2218c49 100644 --- a/router.go +++ b/router.go @@ -663,7 +663,6 @@ func (r *DefaultRouter) insert(t kind, path string, method string, ri routeMetho currentNode.label = currentNode.prefix[0] currentNode.prefix = currentNode.prefix[:lcpLen] currentNode.staticChildren = nil - currentNode.hasColonChild = false currentNode.scLabels = nil currentNode.methods = new(routeMethods) currentNode.originalPath = "" @@ -743,14 +742,10 @@ func newNode( anyChildren *node, ) *node { var scLabels []byte - var hasColonChild bool if len(sc) > 0 { scLabels = make([]byte, len(sc)) for i, c := range sc { scLabels[i] = c.label - if t == paramKind && c.label == ':' { - hasColonChild = true - } } } return &node{ @@ -759,7 +754,6 @@ func newNode( prefix: pre, parent: p, staticChildren: sc, - hasColonChild: hasColonChild, scLabels: scLabels, originalPath: ppath, paramsCount: paramsCount, @@ -784,11 +778,29 @@ func (n *node) refreshLeaf() { func (n *node) addStaticChild(c *node) { n.staticChildren = append(n.staticChildren, c) n.scLabels = append(n.scLabels, c.label) + // param nodes are never split (their prefix is a single byte), so this is where their inline verb child is set if n.kind == paramKind && c.label == ':' { n.hasColonChild = true } } +// pendingInlineVerbSplit returns the nearest param node, from n up to the root, whose value in pathValues ended at an +// inline verb split and so can still be retried. searchIndex and paramIndex are the routing state at n. +func pendingInlineVerbSplit(n *node, path string, searchIndex, paramIndex int, pathValues PathValues) *node { + for ; n != nil; n = n.parent { + if n.hasColonChild && searchIndex < len(path) && path[searchIndex] == ':' { + return n + } + if n.kind == staticKind { + searchIndex -= len(n.prefix) + } else { + paramIndex-- + searchIndex -= len(pathValues[paramIndex].Value) + } + } + return nil +} + // inlineVerbSplit returns where a param value in search ends: at the first literal colon at or after from where this // node's inline verb child could match, otherwise at the end of the path segment. A split value is never empty. The // scan stops at the next slash, so trying every split of a segment in turn is linear in its length. @@ -1070,29 +1082,38 @@ func (r *DefaultRouter) Route(c *Context) HandlerFunc { } } - // A param value that ended at an inline verb split is a decision point of the param node itself. Retry the - // node with the next split, and finally with the whole path segment, before backtracking to its parent. - if currentNode.hasColonChild && search != "" && search[0] == ':' { - pv := &pathValues[paramIndex-1] - start := searchIndex - len(pv.Value) - searchIndex = start + currentNode.inlineVerbSplit(path[start:], len(pv.Value)+1) - pv.Value = path[start:searchIndex] - search = path[searchIndex:] - continue - } - // Let's backtrack to the first possible alternative node of the decision path nk, ok := backtrackToNextNodeKind(anyKind) if !ok { break // No other possibilities on the decision path } else if nk == paramKind { + if currentNode.hasColonChild && search != "" && search[0] == ':' { + goto InlineVerbSplit + } goto Param } else if nk == anyKind { goto Any + } else if n := pendingInlineVerbSplit(currentNode, path, searchIndex, paramIndex, pathValues); n != nil { + // A wildcard ends the search, but a param value above it that ended at an inline verb split is still retried. + for currentNode != n { + backtrackToNextNodeKind(anyKind) + } + goto InlineVerbSplit } else { // Not found break } + + InlineVerbSplit: + // A param value that ended at an inline verb split is a decision point of the param node itself. When its + // inline verb child fails, retry the node with the next split, and finally with the whole path segment, + // before backtracking to its parent. + pv := &pathValues[paramIndex-1] + start := searchIndex - len(pv.Value) + searchIndex = start + currentNode.inlineVerbSplit(path[start:], len(pv.Value)+1) + pv.Value = path[start:searchIndex] + search = path[searchIndex:] + continue } if currentNode == nil && previousBestMatchNode == nil { From 0618be9beb1212034fa5b8811f0d093018454bf7 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 29 Sep 2026 16:34:36 -0700 Subject: [PATCH 6/9] fix(router): keep backtracking below an inline verb split When a wildcard fails below a param value that ended at an inline verb split, keep backtracking one node at a time instead of jumping to that param node, so the other routes below the split are tried before the next split. Without a pending split a failed wildcard still ends the search as before, and the check does not change the routing state. Adds tests for nested splits, routes below a split and a RouteNotFound wildcard below a split. --- route_syntax_test.go | 30 ++++++++++++++++++++++++++++++ router.go | 27 ++++++++++++++++----------- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/route_syntax_test.go b/route_syntax_test.go index 25c6b7289..72ac016f7 100644 --- a/route_syntax_test.go +++ b/route_syntax_test.go @@ -274,3 +274,33 @@ func TestRouterInlineVerbMethodNotAllowedWithoutFallback(t *testing.T) { e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/foo:cancel", nil)) assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) } + +func TestRouterInlineVerbWildcardBacktracksBelowSplit(t *testing.T) { + // after a wildcard below a split fails, the other routes below that split are tried before the next split + e := New() + e.POST(`/r/:n\:v/a/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:n\:v/:p/b`, func(c *Context) error { return c.String(http.StatusOK, "verb:"+c.Param("n")+"|"+c.Param("p")) }) + e.GET(`/r/:n/a/b`, func(c *Context) error { return c.String(http.StatusOK, "generic:"+c.Param("n")) }) + assertRouteResponse(t, e, "/r/q:v/a/b", "verb:q|a") + + // nested splits: the nearest pending split is retried first, then the outer one + e = New() + e.POST(`/r/:a\:x/:b\:y/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:a/:b\:y/*`, func(c *Context) error { + return c.String(http.StatusOK, c.Param("a")+"|"+c.Param("b")+"|"+c.Param("*")) + }) + assertRouteResponse(t, e, "/r/p:x/q:y/z", "p:x|q|z") + + e = New() + e.POST(`/r/:a\:x/:b\:y/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:a\:x/:b/*`, func(c *Context) error { + return c.String(http.StatusOK, c.Param("a")+"|"+c.Param("b")+"|"+c.Param("*")) + }) + assertRouteResponse(t, e, "/r/p:x/q:y/z", "p|q:y|z") + + // a RouteNotFound wildcard below a split handles the request like any other RouteNotFound route + e = New() + e.RouteNotFound(`/r/:a\:x/*`, func(c *Context) error { return c.String(http.StatusOK, "not found:"+c.Param("a")) }) + e.GET(`/r/:a/k`, func(c *Context) error { return c.String(http.StatusOK, "k") }) + assertRouteResponse(t, e, "/r/p:x/k", "not found:p") +} diff --git a/router.go b/router.go index 9e2218c49..82a23bded 100644 --- a/router.go +++ b/router.go @@ -784,12 +784,13 @@ func (n *node) addStaticChild(c *node) { } } -// pendingInlineVerbSplit returns the nearest param node, from n up to the root, whose value in pathValues ended at an -// inline verb split and so can still be retried. searchIndex and paramIndex are the routing state at n. -func pendingInlineVerbSplit(n *node, path string, searchIndex, paramIndex int, pathValues PathValues) *node { +// hasPendingInlineVerbSplit reports whether a param node from n up to the root has a value in pathValues that ended at +// an inline verb split and so can still be retried. searchIndex and paramIndex are the routing state at n. It does +// not change that state, so a request that ends here keeps its param values. +func hasPendingInlineVerbSplit(n *node, path string, searchIndex, paramIndex int, pathValues PathValues) bool { for ; n != nil; n = n.parent { if n.hasColonChild && searchIndex < len(path) && path[searchIndex] == ':' { - return n + return true } if n.kind == staticKind { searchIndex -= len(n.prefix) @@ -798,12 +799,16 @@ func pendingInlineVerbSplit(n *node, path string, searchIndex, paramIndex int, p searchIndex -= len(pathValues[paramIndex].Value) } } - return nil + return false } // inlineVerbSplit returns where a param value in search ends: at the first literal colon at or after from where this // node's inline verb child could match, otherwise at the end of the path segment. A split value is never empty. The // scan stops at the next slash, so trying every split of a segment in turn is linear in its length. +// +// A split is only chosen when the whole prefix of the inline verb child matches. Routing therefore never backtracks +// into the param node from a prefix mismatch of that child, and the split only needs to be retried when backtracking +// from within the child's subtree. func (n *node) inlineVerbSplit(search string, from int) int { verbs := n.findStaticChild(':') for i := from; i < len(search); i++ { @@ -1084,6 +1089,7 @@ func (r *DefaultRouter) Route(c *Context) HandlerFunc { // Let's backtrack to the first possible alternative node of the decision path nk, ok := backtrackToNextNodeKind(anyKind) + Backtracked: if !ok { break // No other possibilities on the decision path } else if nk == paramKind { @@ -1093,12 +1099,11 @@ func (r *DefaultRouter) Route(c *Context) HandlerFunc { goto Param } else if nk == anyKind { goto Any - } else if n := pendingInlineVerbSplit(currentNode, path, searchIndex, paramIndex, pathValues); n != nil { - // A wildcard ends the search, but a param value above it that ended at an inline verb split is still retried. - for currentNode != n { - backtrackToNextNodeKind(anyKind) - } - goto InlineVerbSplit + } else if hasPendingInlineVerbSplit(currentNode, path, searchIndex, paramIndex, pathValues) { + // A wildcard ends the search, except below a param value that ended at an inline verb split: keep + // backtracking, so the other routes below that split and then the next split are still tried. + nk, ok = backtrackToNextNodeKind(anyKind) + goto Backtracked } else { // Not found break From 3cf0a6227e49e127e715bb83af601ec71fee1b56 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 29 Sep 2026 16:36:15 -0700 Subject: [PATCH 7/9] fix(router): remove only the route registered with the given path Remove found a node by the last route that set its original path and then removed whichever route owned the method there. Routes with different param names share a node, so Remove now checks that the path is the one registered for that method. An escaped colon after a param name that contains ':' or '*' keeps its older meaning as part of the name. The escaped colon check is shared in the route syntax scanner. Adds tests for RouteNotFound on the whole segment, encoded colons, Reverse placeholders and Remove. --- route_path.go | 19 ++++++++++++------- route_syntax_test.go | 38 +++++++++++++++++++++++++++++++++++++- router.go | 5 +++-- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/route_path.go b/route_path.go index bda8bc6c8..05bccb291 100644 --- a/route_path.go +++ b/route_path.go @@ -23,15 +23,16 @@ func parseRoutePath(path string) []routePathPart { // avoid allocating a parts slice for each URL it builds. func walkRoutePath(path string, emit func(routePathPart)) { for i := 0; i < len(path); { - if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' { + if isEscapedColon(path, i) { emit(routePathPart{kind: staticKind, value: ":"}) i += 2 } else if path[i] == ':' { start := i + 1 i = start + plainName := true // an escaped colon only starts an inline verb after a name without ':' or '*' for i < len(path) && path[i] != '/' { - if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' { - if isInlineVerb(path[i+2:]) { + if isEscapedColon(path, i) { + if plainName && isInlineVerb(path[i+2:]) { break } // not an inline verb: the rest of the segment is the param name, as before inline verbs @@ -40,6 +41,9 @@ func walkRoutePath(path string, emit func(routePathPart)) { } break } + if path[i] == ':' || path[i] == '*' { + plainName = false + } i++ } emit(routePathPart{kind: paramKind, value: path[start:i]}) @@ -51,10 +55,7 @@ func walkRoutePath(path string, emit func(routePathPart)) { emit(routePathPart{kind: anyKind, value: path[start:i]}) } else { start := i - for i < len(path) && path[i] != ':' && path[i] != '*' { - if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' { - break - } + for i < len(path) && path[i] != ':' && path[i] != '*' && !isEscapedColon(path, i) { i++ } emit(routePathPart{kind: staticKind, value: path[start:i]}) @@ -62,6 +63,10 @@ func walkRoutePath(path string, emit func(routePathPart)) { } } +func isEscapedColon(path string, i int) bool { + return path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' +} + // isInlineVerb reports whether the route text after an escaped colon stays // static up to the end of its path segment. Only then can the router find where // the parameter value ends by trying the colons in the requested segment. Other diff --git a/route_syntax_test.go b/route_syntax_test.go index 72ac016f7..0264f0389 100644 --- a/route_syntax_test.go +++ b/route_syntax_test.go @@ -28,7 +28,6 @@ func TestRouterInlineVerbRoutes(t *testing.T) { for _, order := range [][]string{{"cancel", "get"}, {"get", "cancel"}} { e := New() for _, verb := range order { - verb := verb e.GET("/r/:name\\:"+verb, func(c *Context) error { return c.String(http.StatusOK, verb+":"+c.Param("name")) }) @@ -304,3 +303,40 @@ func TestRouterInlineVerbWildcardBacktracksBelowSplit(t *testing.T) { e.GET(`/r/:a/k`, func(c *Context) error { return c.String(http.StatusOK, "k") }) assertRouteResponse(t, e, "/r/p:x/k", "not found:p") } + +func TestRouterRemoveRouteSharingNode(t *testing.T) { + e := New() + e.GET("/u/:id", func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("id")) }) + e.POST("/u/:uid", func(c *Context) error { return c.String(http.StatusOK, "post:"+c.Param("uid")) }) + assert.Error(t, e.Router().Remove(http.MethodGet, "/u/:uid")) + assertRouteResponse(t, e, "/u/1", "get:1") + assert.NoError(t, e.Router().Remove(http.MethodGet, "/u/:id")) + assert.Len(t, e.Router().Routes(), 1) + + e.GET("x", func(c *Context) error { return c.NoContent(http.StatusOK) }) + assert.NoError(t, e.Router().Remove(http.MethodGet, "x")) + assert.Len(t, e.Router().Routes(), 1) +} + +func TestRouterInlineVerbMisc(t *testing.T) { + e := New() + e.POST(`/r/:n\:v/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.RouteNotFound(`/r/:n/*`, func(c *Context) error { return c.String(http.StatusOK, "not found:"+c.Param("n")) }) + // the whole segment reaches the RouteNotFound route, as a static sibling would + assertRouteResponse(t, e, "/r/a:v/q", "not found:a:v") + + // with routing on the escaped path, an encoded colon is not a verb delimiter + e = New() + e.GET(`/r/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) + e.GET(`/r/:name`, func(c *Context) error { return c.String(http.StatusOK, "generic:"+c.Param("name")) }) + assertRouteResponse(t, e, "/r/foo%3Acancel", "generic:foo%3Acancel") + + ri := RouteInfo{Path: `/r/:n\:v/*`} + assert.Equal(t, "/r/:n:v/*", ri.Reverse()) + assert.Equal(t, "/r/a:v/b/c", ri.Reverse("a", "b/c")) + + // a param name with ':' keeps an escaped colon as part of the name + ri, err := e.AddRoute(Route{Method: http.MethodGet, Path: `/s/:a:b\:v`, Handler: func(c *Context) error { return nil }}) + assert.NoError(t, err) + assert.Equal(t, []string{`a:b\:v`}, ri.Parameters) +} diff --git a/router.go b/router.go index 82a23bded..2164292a6 100644 --- a/router.go +++ b/router.go @@ -404,7 +404,7 @@ func (r *DefaultRouter) Remove(method string, path string) error { searchOffset += len(currentNode.prefix) if search == "" { - if currentNode.originalPath == path && currentNode.isHandler { + if currentNode.isHandler { nodeToRemove = currentNode } break @@ -424,8 +424,9 @@ func (r *DefaultRouter) Remove(method string, path string) error { return errors.New("could not find route to remove by given path") } + // routes with different param names can share a node, so the path must be the one registered for this method mh := nodeToRemove.methods.find(method, false, false) - if mh == nil { + if mh == nil || mh.Path != path { return errors.New("could not find route to remove by given path and method") } From 340a6528134b7d6195c82e23340af8698f107071 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 29 Sep 2026 16:47:39 -0700 Subject: [PATCH 8/9] fix(router): keep leaf params matching the rest of the path Registering an inline verb route under a param gave the param node a static child, so a sibling route ending in that param (`/files/:path`) stopped matching values across slashes. When the inline verb child is the node's only child, a param value without a split now takes the rest of the path, as a leaf param does. --- route_syntax_test.go | 24 ++++++++++++++++++++++++ router.go | 10 ++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/route_syntax_test.go b/route_syntax_test.go index 0264f0389..fd726bb19 100644 --- a/route_syntax_test.go +++ b/route_syntax_test.go @@ -340,3 +340,27 @@ func TestRouterInlineVerbMisc(t *testing.T) { assert.NoError(t, err) assert.Equal(t, []string{`a:b\:v`}, ri.Parameters) } + +func TestRouterInlineVerbKeepsLeafParam(t *testing.T) { + // a param with only an inline verb child still takes the rest of the path when no split matches + e := New() + e.GET("/files/:path", func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("path")) }) + e.POST(`/files/:name\:upload`, func(c *Context) error { return c.String(http.StatusOK, "upload:"+c.Param("name")) }) + assertRouteResponse(t, e, "/files/a/b", "get:a/b") + assertRouteResponse(t, e, "/files/a:upload/b", "get:a:upload/b") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/files/a:upload", nil)) + assert.Equal(t, "upload:a", rec.Body.String()) + + // with another child the param stops at the slash, as before + e.GET("/files/:path/meta", func(c *Context) error { return c.String(http.StatusOK, "meta:"+c.Param("path")) }) + assertRouteResponse(t, e, "/files/a/meta", "meta:a") +} + +func TestRouterInlineVerbPendingAboveParam(t *testing.T) { + // the pending split is found above a param without a split + e := New() + e.POST(`/r/:a\:v/:b/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:a/:b/q`, func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("a")+"|"+c.Param("b")) }) + assertRouteResponse(t, e, "/r/x:v/y/q", "get:x:v|y") +} diff --git a/router.go b/router.go index 2164292a6..37d43c43c 100644 --- a/router.go +++ b/router.go @@ -804,8 +804,9 @@ func hasPendingInlineVerbSplit(n *node, path string, searchIndex, paramIndex int } // inlineVerbSplit returns where a param value in search ends: at the first literal colon at or after from where this -// node's inline verb child could match, otherwise at the end of the path segment. A split value is never empty. The -// scan stops at the next slash, so trying every split of a segment in turn is linear in its length. +// node's inline verb child could match, otherwise at the end of the path segment (or of the path when that child is the +// node's only child). A split value is never empty. The scan stops at the next slash, so trying every split of a +// segment in turn is linear in its length. // // A split is only chosen when the whole prefix of the inline verb child matches. Routing therefore never backtracks // into the param node from a prefix mismatch of that child, and the split only needs to be retried when backtracking @@ -815,6 +816,11 @@ func (n *node) inlineVerbSplit(search string, from int) int { for i := from; i < len(search); i++ { switch search[i] { case '/': + if len(n.staticChildren) == 1 && n.paramChild == nil && n.anyChild == nil { + // the inline verb child is the only child: without a split the param takes the rest of the path, as a + // leaf param does + return len(search) + } return i case ':': if i > 0 && verbs != nil && strings.HasPrefix(search[i:], verbs.prefix) { From 4c2ebeea7953d86b5da2801876b2f811c0360f8a Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 29 Sep 2026 17:00:38 -0700 Subject: [PATCH 9/9] test(router): cover leaf param fallbacks next to an inline verb Drop the param and any child checks that cannot fail for a param node, and pin 405, RouteNotFound and wildcard fallbacks for a param value that spans slashes next to an inline verb route. --- route_syntax_test.go | 20 ++++++++++++++++++++ router.go | 6 +++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/route_syntax_test.go b/route_syntax_test.go index fd726bb19..34d606a37 100644 --- a/route_syntax_test.go +++ b/route_syntax_test.go @@ -364,3 +364,23 @@ func TestRouterInlineVerbPendingAboveParam(t *testing.T) { e.GET(`/r/:a/:b/q`, func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("a")+"|"+c.Param("b")) }) assertRouteResponse(t, e, "/r/x:v/y/q", "get:x:v|y") } + +func TestRouterInlineVerbKeepsLeafParamFallbacks(t *testing.T) { + e := New() + e.GET("/files/:path", func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("path")) }) + e.POST(`/files/:name\:upload`, func(c *Context) error { return c.String(http.StatusOK, "upload") }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodPut, "/files/a/b", nil)) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) + assert.Equal(t, "OPTIONS, GET", rec.Header().Get(HeaderAllow)) + + e = New() + e.RouteNotFound("/files/:path", func(c *Context) error { return c.String(http.StatusOK, "not found:"+c.Param("path")) }) + e.POST(`/files/:name\:upload`, func(c *Context) error { return c.String(http.StatusOK, "upload") }) + assertRouteResponse(t, e, "/files/a/b", "not found:a/b") + + e = New() + e.POST(`/files/:name\:upload`, func(c *Context) error { return c.String(http.StatusOK, "upload") }) + e.GET("/files/*", func(c *Context) error { return c.String(http.StatusOK, "any:"+c.Param("*")) }) + assertRouteResponse(t, e, "/files/a/b", "any:a/b") +} diff --git a/router.go b/router.go index 37d43c43c..599c561a5 100644 --- a/router.go +++ b/router.go @@ -816,9 +816,9 @@ func (n *node) inlineVerbSplit(search string, from int) int { for i := from; i < len(search); i++ { switch search[i] { case '/': - if len(n.staticChildren) == 1 && n.paramChild == nil && n.anyChild == nil { - // the inline verb child is the only child: without a split the param takes the rest of the path, as a - // leaf param does + if len(n.staticChildren) == 1 { + // the inline verb child is the only child (a param node never has a param or any child): without a + // split the param takes the rest of the path, as a leaf param does return len(search) } return i