Compare commits

..

No commits in common. "e35d5715d1a5059fe07cfd1ff4c64515600f2197" and "ad033d4315be569a91766c52c28c89c53c7cc074" have entirely different histories.

13 changed files with 292 additions and 434 deletions

View file

@ -1,6 +1,6 @@
package router package router
// Parameter represents a URL parameter. // Parameter is 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.urbach.dev/go/router go get git.akyoto.dev/go/router
``` ```
## Usage ## Usage
@ -45,31 +45,22 @@ 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
PASS: TestTrailingSlash coverage: 76.9% of statements
PASS: TestTrailingSlashOverwrite
PASS: TestOverwrite
PASS: TestInvalidMethod
coverage: 100.0% of statements
``` ```
## Benchmarks ## Benchmarks
``` ```
BenchmarkBlog/Len1-Param0-12 211814850 5.646 ns/op 0 B/op 0 allocs/op BenchmarkLookup-12 6965749 171.2 ns/op 96 B/op 2 allocs/op
BenchmarkBlog/Len1-Param1-12 132838722 8.978 ns/op 0 B/op 0 allocs/op BenchmarkLookupNoAlloc-12 24243546 48.5 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://urbach.dev/license). Please see the [license documentation](https://akyoto.dev/license).
## Copyright ## Copyright

View file

@ -1,5 +1,7 @@
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]
@ -44,17 +46,23 @@ func (router *Router[T]) LookupNoAlloc(method string, path string, addParameter
return tree.LookupNoAlloc(path, addParameter) return tree.LookupNoAlloc(path, addParameter)
} }
// Map traverses all trees and calls the given function on every node. // Bind traverses all trees and calls the given function on every node.
func (router *Router[T]) Map(transform func(T) T) { func (router *Router[T]) Bind(transform func(T) T) {
router.get.Map(transform) router.get.Bind(transform)
router.post.Map(transform) router.post.Bind(transform)
router.delete.Map(transform) router.delete.Bind(transform)
router.put.Map(transform) router.put.Bind(transform)
router.patch.Map(transform) router.patch.Bind(transform)
router.head.Map(transform) router.head.Bind(transform)
router.connect.Map(transform) router.connect.Bind(transform)
router.trace.Map(transform) router.trace.Bind(transform)
router.options.Map(transform) router.options.Bind(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,195 +1,82 @@
package router_test package router_test
import ( import (
"strings"
"testing" "testing"
"git.urbach.dev/go/assert" "git.akyoto.dev/go/assert"
"git.urbach.dev/go/router" "git.akyoto.dev/go/router"
"git.urbach.dev/go/router/testdata"
) )
func TestHello(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/blog", "Blog")
r.Add("GET", "/blog/post", "Blog post")
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) { func TestStatic(t *testing.T) {
r := router.New[string]() router := router.New[string]()
r.Add("GET", "/hello", "Hello") router.Add("GET", "/hello", "Hello")
r.Add("GET", "/world", "World") router.Add("GET", "/world", "World")
data, params := r.Lookup("GET", "/hello") data, params := router.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 = r.Lookup("GET", "/world") data, params = router.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")
notFound := []string{ data, params = router.Lookup("GET", "/404")
"", assert.Equal(t, len(params), 0)
"?", assert.Equal(t, data, "")
"/404",
"/hell",
"/hall",
"/helloo",
}
for _, path := range notFound {
data, params = r.Lookup("GET", path)
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "")
}
} }
func TestParameter(t *testing.T) { func TestParameter(t *testing.T) {
r := router.New[string]() router := router.New[string]()
r.Add("GET", "/blog/:post", "Blog post") router.Add("GET", "/blog/:slug", "Blog post")
r.Add("GET", "/blog/:post/comments/:id", "Comment") router.Add("GET", "/blog/:slug/comments/:id", "Comment")
data, params := r.Lookup("GET", "/blog/hello-world") data, params := router.Lookup("GET", "/blog/hello-world")
assert.Equal(t, len(params), 1) assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "post") assert.Equal(t, params[0].Key, "slug")
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 = r.Lookup("GET", "/blog/hello-world/comments/123") data, params = router.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, "post") assert.Equal(t, params[0].Key, "slug")
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 TestMixed(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/", "Frontpage")
r.Add("GET", "/blog", "Blog")
r.Add("GET", "/:post", "Post")
r.Add("GET", "/sitemap.txt", "Sitemap")
data, params := r.Lookup("GET", "/")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Frontpage")
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, 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) { func TestWildcard(t *testing.T) {
r := router.New[string]() router := router.New[string]()
r.Add("GET", "/", "Frontpage") router.Add("GET", "/", "Front page")
r.Add("GET", "/users/:id", "Parameter") router.Add("GET", "/:slug", "Blog post")
r.Add("GET", "/images/static", "Static") router.Add("GET", "/users/:id", "Parameter")
r.Add("GET", "/images/*path", "Wildcard") router.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", "/") data, params := router.Lookup("GET", "/")
assert.Equal(t, len(params), 0) assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Frontpage") assert.Equal(t, data, "Front page")
data, params = r.Lookup("GET", "/blog-post") data, params = router.Lookup("GET", "/blog-post")
assert.Equal(t, len(params), 1) assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "post") assert.Equal(t, params[0].Key, "slug")
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 = r.Lookup("GET", "/users/42") data, params = router.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, _ = r.Lookup("GET", "/users/42/test.txt") data, params = router.Lookup("GET", "/images/favicon/256.png")
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",
@ -202,107 +89,26 @@ func TestMethods(t *testing.T) {
"OPTIONS", "OPTIONS",
} }
r := router.New[string]()
for _, method := range methods { for _, method := range methods {
r.Add(method, "/", method) router.Add(method, "/", method)
} }
for _, method := range methods { for _, method := range methods {
data, _ := r.Lookup(method, "/") data, _ := router.Lookup(method, "/")
assert.Equal(t, data, method) assert.Equal(t, data, method)
} }
} }
func TestGitHub(t *testing.T) { func TestGitHub(t *testing.T) {
routes := testdata.Routes("testdata/github.txt") router := router.New[string]()
r := router.New[string]() routes := loadRoutes("testdata/github.txt")
for _, route := range routes { for _, route := range routes {
r.Add(route.Method, route.Path, "octocat") router.Add(route.method, route.path, "octocat")
} }
for _, route := range routes { for _, route := range routes {
data, _ := r.Lookup(route.Method, route.Path) data, _ := router.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)
}

168
Tree.go
View file

@ -1,12 +1,38 @@
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 {
root treeNode[T] static map[string]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
@ -26,8 +52,17 @@ 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 {
node, offset, _ = node.end(path, data, i, offset) var control controlFlow
goto next node, offset, control = node.end(path, data, i, offset)
switch control {
case controlStop:
return
case controlBegin:
goto begin
case controlNext:
goto next
}
} }
default: default:
@ -51,15 +86,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 flow var control controlFlow
node, offset, control = node.end(path, data, i, offset) node, offset, control = node.end(path, data, i, offset)
switch control { switch control {
case flowStop: case controlStop:
return return
case flowBegin: case controlBegin:
goto begin goto begin
case flowNext: case controlNext:
goto next goto next
} }
} }
@ -91,34 +126,48 @@ 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 {
var ( if tree.canBeStatic[len(path)] {
i uint handler, found := tree.static[path]
parameterPath string
wildcardPath string
parameter *treeNode[T]
wildcard *treeNode[T]
node = &tree.root
)
// Skip the first loop iteration if the starting characters are equal if found {
if len(path) > 0 && len(node.prefix) > 0 && path[0] == node.prefix[0] { return handler
i = 1 }
} }
var (
i uint
offset uint
lastWildcardOffset uint
lastWildcard *treeNode[T]
empty T
node = &tree.root
)
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 i < uint(len(path)) { for {
// 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 == uint(len(node.prefix)) { if i-offset == uint(len(node.prefix)) {
if node.wildcard != nil { if node.wildcard != nil {
wildcard = node.wildcard lastWildcard = node.wildcard
wildcardPath = path[i:] lastWildcardOffset = 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 {
@ -126,8 +175,8 @@ begin:
if index != 0 { if index != 0 {
node = node.children[index] node = node.children[index]
path = path[i:] offset = i
i = 1 i++
continue continue
} }
} }
@ -136,69 +185,64 @@ begin:
// path: /|blog // path: /|blog
if node.parameter != nil { if node.parameter != nil {
node = node.parameter node = node.parameter
path = path[i:] offset = i
i = 1 i++
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[:i]) addParameter(node.prefix, path[offset:i])
index := node.indices[separator-node.startIndex] index := node.indices[separator-node.startIndex]
node = node.children[index] node = node.children[index]
path = path[i:] offset = i
i = 1 i++
goto begin goto begin
} }
i++ i++
} }
addParameter(node.prefix, path[:i])
return node.data
} }
// node: /|*any // node: /|*any
// path: /|image.png // path: /|image.png
goto notFound if node.wildcard != nil {
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] { if path[i] != node.prefix[i-offset] {
goto notFound if lastWildcard != nil {
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
} }
// Map binds all handlers to a new one provided by the callback. // Bind binds all handlers to a new one provided by the callback.
func (tree *Tree[T]) Map(transform func(T) T) { func (tree *Tree[T]) Bind(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,57 +3,36 @@ package router_test
import ( import (
"testing" "testing"
"git.urbach.dev/go/router" "git.akyoto.dev/go/router"
"git.urbach.dev/go/router/testdata"
) )
func BenchmarkBlog(b *testing.B) { func BenchmarkLookup(b *testing.B) {
routes := testdata.Routes("testdata/blog.txt") router := router.New[string]()
r := router.New[string]() routes := loadRoutes("testdata/github.txt")
for _, route := range routes { for _, route := range routes {
r.Add(route.Method, route.Path, "") router.Add(route.method, route.path, "")
} }
b.Run("Len1-Param0", func(b *testing.B) { b.ResetTimer()
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/", noop)
}
})
b.Run("Len1-Param1", 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", "/:id", noop) }
}
})
} }
func BenchmarkGitHub(b *testing.B) { func BenchmarkLookupNoAlloc(b *testing.B) {
routes := testdata.Routes("testdata/github.txt") router := router.New[string]()
r := router.New[string]() routes := loadRoutes("testdata/github.txt")
addParameter := func(string, string) {}
for _, route := range routes { for _, route := range routes {
r.Add(route.Method, route.Path, "") router.Add(route.method, route.path, "")
} }
b.Run("Len7-Param0", func(b *testing.B) { b.ResetTimer()
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/issues", noop)
}
})
b.Run("Len7-Param1", 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", "/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
View file

@ -1,11 +0,0 @@
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.urbach.dev/go/router module git.akyoto.dev/go/router
go 1.24 go 1.21
require git.urbach.dev/go/assert v0.0.0-20250225153414-7a6ed8be9b6e require git.akyoto.dev/go/assert v0.1.3

4
go.sum
View file

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

52
helper_test.go Normal file
View file

@ -0,0 +1,52 @@
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
View file

@ -1,52 +0,0 @@
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
View file

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

View file

@ -1,6 +1,8 @@
package router package router
import ( import (
"fmt"
"io"
"strings" "strings"
) )
@ -157,7 +159,6 @@ 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
} }
@ -230,7 +231,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, flow) { func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[T], int, controlFlow) {
char := path[i] char := path[i]
if char >= node.startIndex && char < node.endIndex { if char >= node.startIndex && char < node.endIndex {
@ -239,7 +240,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, flowNext return node, offset, controlNext
} }
} }
@ -247,7 +248,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, flowStop return node, offset, controlStop
} }
// node: /user/|:id // node: /user/|:id
@ -255,11 +256,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, flowBegin return node, offset, controlBegin
} }
node.append(path[i:], data) node.append(path[i:], data)
return node, offset, flowStop return node, offset, controlStop
} }
// each traverses the tree and calls the given function on every node. // each traverses the tree and calls the given function on every node.
@ -282,3 +283,47 @@ 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
}