Compare commits

...

10 commits

Author SHA1 Message Date
e35d5715d1
Updated dependencies
All checks were successful
/ test (push) Successful in 15s
2025-06-01 18:22:31 +02:00
ebcb5fad18
Fixed incorrect path traversal 2025-03-01 14:27:26 +01:00
7ba55e445e
Updated module path 2025-02-25 16:38:39 +01:00
f11c90da34
Improved performance 2024-03-15 09:52:34 +01:00
e348332fa9
Improved performance 2024-03-15 00:07:44 +01:00
b04aadea09
Improved performance 2024-03-14 23:26:59 +01:00
a7d64037a7
Added trailing slash for static routes 2024-03-13 13:04:03 +01:00
0323126f2c
Improved test coverage 2023-09-06 16:52:22 +02:00
763686679f
Improved test coverage 2023-09-06 11:44:04 +02:00
7df818ffff
Reduced memory usage 2023-09-02 09:19:11 +02:00
13 changed files with 436 additions and 294 deletions

View file

@ -1,6 +1,6 @@
package router package router
// Parameter is a URL parameter. // Parameter represents a URL parameter.
type Parameter struct { type Parameter struct {
Key string Key string
Value string Value string

View file

@ -11,7 +11,7 @@ HTTP router based on radix trees.
## Installation ## Installation
```shell ```shell
go get git.akyoto.dev/go/router go get git.urbach.dev/go/router
``` ```
## Usage ## Usage
@ -45,22 +45,31 @@ data := router.LookupNoAlloc("GET", "/users/42", func(key string, value string)
``` ```
PASS: TestStatic PASS: TestStatic
PASS: TestParameter PASS: TestParameter
PASS: TestMixed
PASS: TestWildcard PASS: TestWildcard
PASS: TestMap
PASS: TestMethods PASS: TestMethods
PASS: TestGitHub PASS: TestGitHub
coverage: 76.9% of statements PASS: TestTrailingSlash
PASS: TestTrailingSlashOverwrite
PASS: TestOverwrite
PASS: TestInvalidMethod
coverage: 100.0% of statements
``` ```
## Benchmarks ## Benchmarks
``` ```
BenchmarkLookup-12 6965749 171.2 ns/op 96 B/op 2 allocs/op BenchmarkBlog/Len1-Param0-12 211814850 5.646 ns/op 0 B/op 0 allocs/op
BenchmarkLookupNoAlloc-12 24243546 48.5 ns/op 0 B/op 0 allocs/op BenchmarkBlog/Len1-Param1-12 132838722 8.978 ns/op 0 B/op 0 allocs/op
BenchmarkGitHub/Len7-Param0-12 84768382 14.14 ns/op 0 B/op 0 allocs/op
BenchmarkGitHub/Len7-Param1-12 55290044 20.74 ns/op 0 B/op 0 allocs/op
BenchmarkGitHub/Len7-Param2-12 26057244 46.08 ns/op 0 B/op 0 allocs/op
``` ```
## License ## License
Please see the [license documentation](https://akyoto.dev/license). Please see the [license documentation](https://urbach.dev/license).
## Copyright ## Copyright

View file

@ -1,7 +1,5 @@
package router package router
import "os"
// Router is a high-performance router. // Router is a high-performance router.
type Router[T any] struct { type Router[T any] struct {
get Tree[T] get Tree[T]
@ -46,23 +44,17 @@ func (router *Router[T]) LookupNoAlloc(method string, path string, addParameter
return tree.LookupNoAlloc(path, addParameter) return tree.LookupNoAlloc(path, addParameter)
} }
// Bind traverses all trees and calls the given function on every node. // Map traverses all trees and calls the given function on every node.
func (router *Router[T]) Bind(transform func(T) T) { func (router *Router[T]) Map(transform func(T) T) {
router.get.Bind(transform) router.get.Map(transform)
router.post.Bind(transform) router.post.Map(transform)
router.delete.Bind(transform) router.delete.Map(transform)
router.put.Bind(transform) router.put.Map(transform)
router.patch.Bind(transform) router.patch.Map(transform)
router.head.Bind(transform) router.head.Map(transform)
router.connect.Bind(transform) router.connect.Map(transform)
router.trace.Bind(transform) router.trace.Map(transform)
router.options.Bind(transform) router.options.Map(transform)
}
// Print shows a pretty print of all the routes.
func (router *Router[T]) Print(method string) {
tree := router.selectTree(method)
tree.root.PrettyPrint(os.Stdout)
} }
// selectTree returns the tree by the given HTTP method. // selectTree returns the tree by the given HTTP method.

View file

@ -1,82 +1,195 @@
package router_test package router_test
import ( import (
"strings"
"testing" "testing"
"git.akyoto.dev/go/assert" "git.urbach.dev/go/assert"
"git.akyoto.dev/go/router" "git.urbach.dev/go/router"
"git.urbach.dev/go/router/testdata"
) )
func TestStatic(t *testing.T) { func TestHello(t *testing.T) {
router := router.New[string]() r := router.New[string]()
router.Add("GET", "/hello", "Hello") r.Add("GET", "/blog", "Blog")
router.Add("GET", "/world", "World") r.Add("GET", "/blog/post", "Blog post")
data, params := router.Lookup("GET", "/hello") data, params := r.Lookup("GET", "/blog")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Blog")
data, params = r.Lookup("GET", "/blog/post")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Blog post")
}
func TestStatic(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/hello", "Hello")
r.Add("GET", "/world", "World")
data, params := r.Lookup("GET", "/hello")
assert.Equal(t, len(params), 0) assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Hello") assert.Equal(t, data, "Hello")
data, params = router.Lookup("GET", "/world") data, params = r.Lookup("GET", "/world")
assert.Equal(t, len(params), 0) assert.Equal(t, len(params), 0)
assert.Equal(t, data, "World") assert.Equal(t, data, "World")
data, params = router.Lookup("GET", "/404") notFound := []string{
"",
"?",
"/404",
"/hell",
"/hall",
"/helloo",
}
for _, path := range notFound {
data, params = r.Lookup("GET", path)
assert.Equal(t, len(params), 0) assert.Equal(t, len(params), 0)
assert.Equal(t, data, "") assert.Equal(t, data, "")
}
} }
func TestParameter(t *testing.T) { func TestParameter(t *testing.T) {
router := router.New[string]() r := router.New[string]()
router.Add("GET", "/blog/:slug", "Blog post") r.Add("GET", "/blog/:post", "Blog post")
router.Add("GET", "/blog/:slug/comments/:id", "Comment") r.Add("GET", "/blog/:post/comments/:id", "Comment")
data, params := router.Lookup("GET", "/blog/hello-world") data, params := r.Lookup("GET", "/blog/hello-world")
assert.Equal(t, len(params), 1) assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "slug") assert.Equal(t, params[0].Key, "post")
assert.Equal(t, params[0].Value, "hello-world") assert.Equal(t, params[0].Value, "hello-world")
assert.Equal(t, data, "Blog post") assert.Equal(t, data, "Blog post")
data, params = router.Lookup("GET", "/blog/hello-world/comments/123") data, params = r.Lookup("GET", "/blog/hello-world/comments/123")
assert.Equal(t, len(params), 2) assert.Equal(t, len(params), 2)
assert.Equal(t, params[0].Key, "slug") assert.Equal(t, params[0].Key, "post")
assert.Equal(t, params[0].Value, "hello-world") assert.Equal(t, params[0].Value, "hello-world")
assert.Equal(t, params[1].Key, "id") assert.Equal(t, params[1].Key, "id")
assert.Equal(t, params[1].Value, "123") assert.Equal(t, params[1].Value, "123")
assert.Equal(t, data, "Comment") assert.Equal(t, data, "Comment")
} }
func TestWildcard(t *testing.T) { func TestMixed(t *testing.T) {
router := router.New[string]() r := router.New[string]()
router.Add("GET", "/", "Front page") r.Add("GET", "/", "Frontpage")
router.Add("GET", "/:slug", "Blog post") r.Add("GET", "/blog", "Blog")
router.Add("GET", "/users/:id", "Parameter") r.Add("GET", "/:post", "Post")
router.Add("GET", "/images/*path", "Wildcard") r.Add("GET", "/sitemap.txt", "Sitemap")
data, params := router.Lookup("GET", "/") data, params := r.Lookup("GET", "/")
assert.Equal(t, len(params), 0) assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Front page") assert.Equal(t, data, "Frontpage")
data, params = router.Lookup("GET", "/blog-post") data, params = r.Lookup("GET", "/blog")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Blog")
data, params = r.Lookup("GET", "/software")
assert.Equal(t, len(params), 1) assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "slug") assert.Equal(t, params[0].Key, "post")
assert.Equal(t, params[0].Value, "software")
assert.Equal(t, data, "Post")
data, params = r.Lookup("GET", "/sitemap.txt")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Sitemap")
}
func TestWildcard(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/", "Frontpage")
r.Add("GET", "/users/:id", "Parameter")
r.Add("GET", "/images/static", "Static")
r.Add("GET", "/images/*path", "Wildcard")
r.Add("GET", "/:post", "Blog post")
r.Add("GET", "/*any", "Wildcard")
r.Add("GET", "*root", "Root wildcard")
data, params := r.Lookup("GET", "/")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Frontpage")
data, params = r.Lookup("GET", "/blog-post")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "post")
assert.Equal(t, params[0].Value, "blog-post") assert.Equal(t, params[0].Value, "blog-post")
assert.Equal(t, data, "Blog post") assert.Equal(t, data, "Blog post")
data, params = router.Lookup("GET", "/users/42") data, params = r.Lookup("GET", "/users/42")
assert.Equal(t, len(params), 1) assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "id") assert.Equal(t, params[0].Key, "id")
assert.Equal(t, params[0].Value, "42") assert.Equal(t, params[0].Value, "42")
assert.Equal(t, data, "Parameter") assert.Equal(t, data, "Parameter")
data, params = router.Lookup("GET", "/images/favicon/256.png") data, _ = r.Lookup("GET", "/users/42/test.txt")
assert.Equal(t, data, "Wildcard")
data, params = r.Lookup("GET", "/images/static")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Static")
data, params = r.Lookup("GET", "/images/ste")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "path")
assert.Equal(t, params[0].Value, "ste")
assert.Equal(t, data, "Wildcard")
data, params = r.Lookup("GET", "/images/sta")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "path")
assert.Equal(t, params[0].Value, "sta")
assert.Equal(t, data, "Wildcard")
data, params = r.Lookup("GET", "/images/favicon/256.png")
assert.Equal(t, len(params), 1) assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "path") assert.Equal(t, params[0].Key, "path")
assert.Equal(t, params[0].Value, "favicon/256.png") assert.Equal(t, params[0].Value, "favicon/256.png")
assert.Equal(t, data, "Wildcard") assert.Equal(t, data, "Wildcard")
data, params = r.Lookup("GET", "not-a-path")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "root")
assert.Equal(t, params[0].Value, "not-a-path")
assert.Equal(t, data, "Root wildcard")
}
func TestMap(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/hello", "Hello")
r.Add("GET", "/world", "World")
r.Add("GET", "/user/:user", "User")
r.Add("GET", "/*path", "Path")
r.Add("GET", "*root", "Root")
r.Map(func(data string) string {
return strings.Repeat(data, 2)
})
data, params := r.Lookup("GET", "/hello")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "HelloHello")
data, params = r.Lookup("GET", "/world")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "WorldWorld")
data, params = r.Lookup("GET", "/user/123")
assert.Equal(t, len(params), 1)
assert.Equal(t, data, "UserUser")
data, params = r.Lookup("GET", "/test.txt")
assert.Equal(t, len(params), 1)
assert.Equal(t, data, "PathPath")
data, params = r.Lookup("GET", "test.txt")
assert.Equal(t, len(params), 1)
assert.Equal(t, data, "RootRoot")
} }
func TestMethods(t *testing.T) { func TestMethods(t *testing.T) {
router := router.New[string]()
methods := []string{ methods := []string{
"GET", "GET",
"POST", "POST",
@ -89,26 +202,107 @@ func TestMethods(t *testing.T) {
"OPTIONS", "OPTIONS",
} }
r := router.New[string]()
for _, method := range methods { for _, method := range methods {
router.Add(method, "/", method) r.Add(method, "/", method)
} }
for _, method := range methods { for _, method := range methods {
data, _ := router.Lookup(method, "/") data, _ := r.Lookup(method, "/")
assert.Equal(t, data, method) assert.Equal(t, data, method)
} }
} }
func TestGitHub(t *testing.T) { func TestGitHub(t *testing.T) {
router := router.New[string]() routes := testdata.Routes("testdata/github.txt")
routes := loadRoutes("testdata/github.txt") r := router.New[string]()
for _, route := range routes { for _, route := range routes {
router.Add(route.method, route.path, "octocat") r.Add(route.Method, route.Path, "octocat")
} }
for _, route := range routes { for _, route := range routes {
data, _ := router.Lookup(route.method, route.path) data, _ := r.Lookup(route.Method, route.Path)
assert.Equal(t, data, "octocat")
data = r.LookupNoAlloc(route.Method, route.Path, func(string, string) {})
assert.Equal(t, data, "octocat") assert.Equal(t, data, "octocat")
} }
} }
func TestTrailingSlash(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/hello", "Hello 1")
data, params := r.Lookup("GET", "/hello")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Hello 1")
data, params = r.Lookup("GET", "/hello/")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Hello 1")
}
func TestTrailingSlashOverwrite(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/hello", "route 1")
r.Add("GET", "/hello/", "route 2")
r.Add("GET", "/:param", "route 3")
r.Add("GET", "/:param/", "route 4")
r.Add("GET", "/*any", "route 5")
data, params := r.Lookup("GET", "/hello")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "route 1")
data, params = r.Lookup("GET", "/hello/")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "route 2")
data, params = r.Lookup("GET", "/param")
assert.Equal(t, len(params), 1)
assert.Equal(t, data, "route 3")
data, params = r.Lookup("GET", "/param/")
assert.Equal(t, len(params), 1)
assert.Equal(t, data, "route 4")
data, _ = r.Lookup("GET", "/wild/card/")
assert.Equal(t, data, "route 5")
}
func TestOverwrite(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/", "1")
r.Add("GET", "/", "2")
r.Add("GET", "/", "3")
r.Add("GET", "/", "4")
r.Add("GET", "/", "5")
data, params := r.Lookup("GET", "/")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "5")
}
func TestInvalidMethod(t *testing.T) {
defer func() {
if recover() == nil {
t.FailNow()
}
}()
r := router.New[string]()
r.Add("?", "/hello", "Hello")
}
func TestMemoryUsage(t *testing.T) {
escape := func(a any) {}
result := testing.Benchmark(func(b *testing.B) {
r := router.New[string]()
escape(r)
})
t.Logf("%d bytes", result.MemBytes)
}

160
Tree.go
View file

@ -1,38 +1,12 @@
package router package router
import (
"strings"
)
// controlFlow tells the main loop what it should do next.
type controlFlow int
// controlFlow values.
const (
controlStop controlFlow = 0
controlBegin controlFlow = 1
controlNext controlFlow = 2
)
// Tree represents a radix tree. // Tree represents a radix tree.
type Tree[T any] struct { type Tree[T any] struct {
static map[string]T
root treeNode[T] root treeNode[T]
canBeStatic [2048]bool
} }
// Add adds a new element to the tree. // Add adds a new element to the tree.
func (tree *Tree[T]) Add(path string, data T) { func (tree *Tree[T]) Add(path string, data T) {
if !strings.Contains(path, ":") && !strings.Contains(path, "*") {
if tree.static == nil {
tree.static = map[string]T{}
}
tree.static[path] = data
tree.canBeStatic[len(path)] = true
return
}
// Search tree for equal parts until we can no longer proceed // Search tree for equal parts until we can no longer proceed
i := 0 i := 0
offset := 0 offset := 0
@ -52,18 +26,9 @@ func (tree *Tree[T]) Add(path string, data T) {
// When we hit a separator, we'll search for a fitting child. // When we hit a separator, we'll search for a fitting child.
if path[i] == separator { if path[i] == separator {
var control controlFlow node, offset, _ = node.end(path, data, i, offset)
node, offset, control = node.end(path, data, i, offset)
switch control {
case controlStop:
return
case controlBegin:
goto begin
case controlNext:
goto next goto next
} }
}
default: default:
if i == len(path) { if i == len(path) {
@ -86,15 +51,15 @@ func (tree *Tree[T]) Add(path string, data T) {
// node: /| // node: /|
// path: /|blog // path: /|blog
if i-offset == len(node.prefix) { if i-offset == len(node.prefix) {
var control controlFlow var control flow
node, offset, control = node.end(path, data, i, offset) node, offset, control = node.end(path, data, i, offset)
switch control { switch control {
case controlStop: case flowStop:
return return
case controlBegin: case flowBegin:
goto begin goto begin
case controlNext: case flowNext:
goto next goto next
} }
} }
@ -126,48 +91,34 @@ func (tree *Tree[T]) Lookup(path string) (T, []Parameter) {
// LookupNoAlloc finds the data for the given path without using any memory allocations. // LookupNoAlloc finds the data for the given path without using any memory allocations.
func (tree *Tree[T]) LookupNoAlloc(path string, addParameter func(key string, value string)) T { func (tree *Tree[T]) LookupNoAlloc(path string, addParameter func(key string, value string)) T {
if tree.canBeStatic[len(path)] {
handler, found := tree.static[path]
if found {
return handler
}
}
var ( var (
i uint i uint
offset uint parameterPath string
lastWildcardOffset uint wildcardPath string
lastWildcard *treeNode[T] parameter *treeNode[T]
empty T wildcard *treeNode[T]
node = &tree.root node = &tree.root
) )
// Skip the first loop iteration if the starting characters are equal
if len(path) > 0 && len(node.prefix) > 0 && path[0] == node.prefix[0] {
i = 1
}
begin: begin:
// Search tree for equal parts until we can no longer proceed // Search tree for equal parts until we can no longer proceed
for { for i < uint(len(path)) {
// We reached the end.
if i == uint(len(path)) {
// node: /blog|
// path: /blog|
if i-offset == uint(len(node.prefix)) {
return node.data
}
// node: /blog|feed
// path: /blog|
return empty
}
// The node we just checked is entirely included in our path. // The node we just checked is entirely included in our path.
// node: /| // node: /|
// path: /|blog // path: /|blog
if i-offset == uint(len(node.prefix)) { if i == uint(len(node.prefix)) {
if node.wildcard != nil { if node.wildcard != nil {
lastWildcard = node.wildcard wildcard = node.wildcard
lastWildcardOffset = i wildcardPath = path[i:]
} }
parameter = node.parameter
parameterPath = path[i:]
char := path[i] char := path[i]
if char >= node.startIndex && char < node.endIndex { if char >= node.startIndex && char < node.endIndex {
@ -175,8 +126,8 @@ begin:
if index != 0 { if index != 0 {
node = node.children[index] node = node.children[index]
offset = i path = path[i:]
i++ i = 1
continue continue
} }
} }
@ -185,64 +136,69 @@ begin:
// path: /|blog // path: /|blog
if node.parameter != nil { if node.parameter != nil {
node = node.parameter node = node.parameter
offset = i path = path[i:]
i++ i = 1
for {
// We reached the end.
if i == uint(len(path)) {
addParameter(node.prefix, path[offset:i])
return node.data
}
for i < uint(len(path)) {
// node: /:id|/posts // node: /:id|/posts
// path: /123|/posts // path: /123|/posts
if path[i] == separator { if path[i] == separator {
addParameter(node.prefix, path[offset:i]) addParameter(node.prefix, path[:i])
index := node.indices[separator-node.startIndex] index := node.indices[separator-node.startIndex]
node = node.children[index] node = node.children[index]
offset = i path = path[i:]
i++ i = 1
goto begin goto begin
} }
i++ i++
} }
addParameter(node.prefix, path[:i])
return node.data
} }
// node: /|*any // node: /|*any
// path: /|image.png // path: /|image.png
if node.wildcard != nil { goto notFound
addParameter(node.wildcard.prefix, path[i:])
return node.wildcard.data
}
return empty
} }
// We got a conflict. // We got a conflict.
// node: /b|ag // node: /b|ag
// path: /b|riefcase // path: /b|riefcase
if path[i] != node.prefix[i-offset] { if path[i] != node.prefix[i] {
if lastWildcard != nil { goto notFound
addParameter(lastWildcard.prefix, path[lastWildcardOffset:])
return lastWildcard.data
}
return empty
} }
i++ i++
} }
// node: /blog|
// path: /blog|
if i == uint(len(node.prefix)) {
return node.data
}
// node: /|*any
// path: /|image.png
notFound:
if parameter != nil {
addParameter(parameter.prefix, parameterPath)
return parameter.data
}
if wildcard != nil {
addParameter(wildcard.prefix, wildcardPath)
return wildcard.data
}
var empty T
return empty
} }
// Bind binds all handlers to a new one provided by the callback. // Map binds all handlers to a new one provided by the callback.
func (tree *Tree[T]) Bind(transform func(T) T) { func (tree *Tree[T]) Map(transform func(T) T) {
tree.root.each(func(node *treeNode[T]) { tree.root.each(func(node *treeNode[T]) {
node.data = transform(node.data) node.data = transform(node.data)
}) })
for key, value := range tree.static {
tree.static[key] = transform(value)
}
} }

View file

@ -3,36 +3,57 @@ package router_test
import ( import (
"testing" "testing"
"git.akyoto.dev/go/router" "git.urbach.dev/go/router"
"git.urbach.dev/go/router/testdata"
) )
func BenchmarkLookup(b *testing.B) { func BenchmarkBlog(b *testing.B) {
router := router.New[string]() routes := testdata.Routes("testdata/blog.txt")
routes := loadRoutes("testdata/github.txt") r := router.New[string]()
for _, route := range routes { for _, route := range routes {
router.Add(route.method, route.path, "") r.Add(route.Method, route.Path, "")
} }
b.ResetTimer() b.Run("Len1-Param0", func(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
router.Lookup("GET", "/repos/:owner/:repo/issues") r.LookupNoAlloc("GET", "/", noop)
} }
})
b.Run("Len1-Param1", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/:id", noop)
}
})
} }
func BenchmarkLookupNoAlloc(b *testing.B) { func BenchmarkGitHub(b *testing.B) {
router := router.New[string]() routes := testdata.Routes("testdata/github.txt")
routes := loadRoutes("testdata/github.txt") r := router.New[string]()
addParameter := func(string, string) {}
for _, route := range routes { for _, route := range routes {
router.Add(route.method, route.path, "") r.Add(route.Method, route.Path, "")
} }
b.ResetTimer() b.Run("Len7-Param0", func(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
router.LookupNoAlloc("GET", "/repos/:owner/:repo/issues", addParameter) r.LookupNoAlloc("GET", "/issues", noop)
} }
})
b.Run("Len7-Param1", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/gists/:id", noop)
}
})
b.Run("Len7-Param2", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/repos/:owner/:repo/issues", noop)
}
})
} }
// noop serves as an empty addParameter function.
func noop(string, string) {}

11
flow.go Normal file
View file

@ -0,0 +1,11 @@
package router
// flow tells the main loop what it should do next.
type flow int
// Control flow values.
const (
flowStop flow = iota
flowBegin
flowNext
)

6
go.mod
View file

@ -1,5 +1,5 @@
module git.akyoto.dev/go/router module git.urbach.dev/go/router
go 1.21 go 1.24
require git.akyoto.dev/go/assert v0.1.3 require git.urbach.dev/go/assert v0.0.0-20250225153414-7a6ed8be9b6e

4
go.sum
View file

@ -1,2 +1,2 @@
git.akyoto.dev/go/assert v0.1.3 h1:QwCUbmG4aZYsNk/OuRBz1zWVKmGlDUHhOnnDBfn8Qw8= git.urbach.dev/go/assert v0.0.0-20250225153414-7a6ed8be9b6e h1:lDTetvmGktDiMem+iBU3e5cGv52qUIbsqW8sV9u3gAQ=
git.akyoto.dev/go/assert v0.1.3/go.mod h1:0GzMaM0eURuDwtGkJJkCsI7r2aUKr+5GmWNTFPgDocM= git.urbach.dev/go/assert v0.0.0-20250225153414-7a6ed8be9b6e/go.mod h1:y9jGII9JFiF1HNIju0u87OyPCt82xKCtqnAFyEreCDo=

View file

@ -1,52 +0,0 @@
package router_test
import (
"bufio"
"os"
"strings"
)
// route represents a single line in the router test file.
type route struct {
method string
path string
}
// loadRoutes loads all routes from a text file.
func loadRoutes(fileName string) []route {
var routes []route
for line := range linesInFile(fileName) {
line = strings.TrimSpace(line)
parts := strings.Split(line, " ")
routes = append(routes, route{
method: parts[0],
path: parts[1],
})
}
return routes
}
// linesInFile is a utility function to easily read every line in a text file.
func linesInFile(fileName string) <-chan string {
lines := make(chan string)
go func() {
defer close(lines)
file, err := os.Open(fileName)
if err != nil {
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines <- scanner.Text()
}
}()
return lines
}

52
testdata/Route.go vendored Normal file
View file

@ -0,0 +1,52 @@
package testdata
import (
"bufio"
"os"
"strings"
)
// Route represents a single line in the router test file.
type Route struct {
Method string
Path string
}
// Routes loads all routes from a text file.
func Routes(fileName string) []Route {
var routes []Route
for line := range Lines(fileName) {
line = strings.TrimSpace(line)
parts := strings.Split(line, " ")
routes = append(routes, Route{
Method: parts[0],
Path: parts[1],
})
}
return routes
}
// Lines is a utility function to easily read every line in a text file.
func Lines(fileName string) <-chan string {
lines := make(chan string)
go func() {
defer close(lines)
file, err := os.Open(fileName)
if err != nil {
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines <- scanner.Text()
}
}()
return lines
}

4
testdata/blog.txt vendored Normal file
View file

@ -0,0 +1,4 @@
GET /
GET /:slug
GET /tags
GET /tag/:tag

View file

@ -1,8 +1,6 @@
package router package router
import ( import (
"fmt"
"io"
"strings" "strings"
) )
@ -159,6 +157,7 @@ func (node *treeNode[T]) append(path string, data T) {
if node.prefix == "" { if node.prefix == "" {
node.prefix = path node.prefix = path
node.data = data node.data = data
node.addTrailingSlash(data)
return return
} }
@ -231,7 +230,7 @@ func (node *treeNode[T]) append(path string, data T) {
// end is called when the node was fully parsed // end is called when the node was fully parsed
// and needs to decide the next control flow. // and needs to decide the next control flow.
// end is only called from `tree.Add`. // end is only called from `tree.Add`.
func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[T], int, controlFlow) { func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[T], int, flow) {
char := path[i] char := path[i]
if char >= node.startIndex && char < node.endIndex { if char >= node.startIndex && char < node.endIndex {
@ -240,7 +239,7 @@ func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[
if index != 0 { if index != 0 {
node = node.children[index] node = node.children[index]
offset = i offset = i
return node, offset, controlNext return node, offset, flowNext
} }
} }
@ -248,7 +247,7 @@ func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[
// If no prefix is set, this is the starting node. // If no prefix is set, this is the starting node.
if node.prefix == "" { if node.prefix == "" {
node.append(path[i:], data) node.append(path[i:], data)
return node, offset, controlStop return node, offset, flowStop
} }
// node: /user/|:id // node: /user/|:id
@ -256,11 +255,11 @@ func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[
if node.parameter != nil && path[i] == parameter { if node.parameter != nil && path[i] == parameter {
node = node.parameter node = node.parameter
offset = i offset = i
return node, offset, controlBegin return node, offset, flowBegin
} }
node.append(path[i:], data) node.append(path[i:], data)
return node, offset, controlStop return node, offset, flowStop
} }
// each traverses the tree and calls the given function on every node. // each traverses the tree and calls the given function on every node.
@ -283,47 +282,3 @@ func (node *treeNode[T]) each(callback func(*treeNode[T])) {
node.wildcard.each(callback) node.wildcard.each(callback)
} }
} }
// PrettyPrint prints a human-readable form of the tree to the given writer.
func (node *treeNode[T]) PrettyPrint(writer io.Writer) {
node.prettyPrint(writer, -1)
}
// prettyPrint is the underlying pretty printer.
func (node *treeNode[T]) prettyPrint(writer io.Writer, level int) {
prefix := ""
if level >= 0 {
prefix = strings.Repeat(" ", level) + "|_ "
}
switch node.kind {
case ':':
prefix += ":"
case '*':
prefix += "*"
}
fmt.Fprintf(writer, "%s%s [%v]\n", prefix, node.prefix, node.data)
for _, child := range node.children {
if child == nil {
continue
}
child.prettyPrint(writer, level+1)
}
if node.parameter != nil {
node.parameter.prettyPrint(writer, level+1)
}
if node.wildcard != nil {
node.wildcard.prettyPrint(writer, level+1)
}
}
// String returns the node prefix.
func (node *treeNode[T]) String() string {
return node.prefix
}