diff --git a/Parameter.go b/Parameter.go index de4f0a5..b1c7cd2 100644 --- a/Parameter.go +++ b/Parameter.go @@ -1,6 +1,6 @@ package router -// Parameter represents a URL parameter. +// Parameter is a URL parameter. type Parameter struct { Key string Value string diff --git a/README.md b/README.md index 2532d80..9854c92 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ HTTP router based on radix trees. ## Installation ```shell -go get git.urbach.dev/go/router +go get git.akyoto.dev/go/router ``` ## Usage @@ -45,31 +45,22 @@ data := router.LookupNoAlloc("GET", "/users/42", func(key string, value string) ``` PASS: TestStatic PASS: TestParameter -PASS: TestMixed PASS: TestWildcard -PASS: TestMap PASS: TestMethods PASS: TestGitHub -PASS: TestTrailingSlash -PASS: TestTrailingSlashOverwrite -PASS: TestOverwrite -PASS: TestInvalidMethod -coverage: 100.0% of statements +coverage: 76.9% of statements ``` ## Benchmarks ``` -BenchmarkBlog/Len1-Param0-12 211814850 5.646 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 +BenchmarkLookup-12 6965749 171.2 ns/op 96 B/op 2 allocs/op +BenchmarkLookupNoAlloc-12 24243546 48.5 ns/op 0 B/op 0 allocs/op ``` ## License -Please see the [license documentation](https://urbach.dev/license). +Please see the [license documentation](https://akyoto.dev/license). ## Copyright diff --git a/Router.go b/Router.go index 3db0647..bbed698 100644 --- a/Router.go +++ b/Router.go @@ -1,5 +1,7 @@ package router +import "os" + // Router is a high-performance router. type Router[T any] struct { get Tree[T] @@ -44,17 +46,23 @@ func (router *Router[T]) LookupNoAlloc(method string, path string, addParameter return tree.LookupNoAlloc(path, addParameter) } -// Map traverses all trees and calls the given function on every node. -func (router *Router[T]) Map(transform func(T) T) { - router.get.Map(transform) - router.post.Map(transform) - router.delete.Map(transform) - router.put.Map(transform) - router.patch.Map(transform) - router.head.Map(transform) - router.connect.Map(transform) - router.trace.Map(transform) - router.options.Map(transform) +// Bind traverses all trees and calls the given function on every node. +func (router *Router[T]) Bind(transform func(T) T) { + router.get.Bind(transform) + router.post.Bind(transform) + router.delete.Bind(transform) + router.put.Bind(transform) + router.patch.Bind(transform) + router.head.Bind(transform) + router.connect.Bind(transform) + router.trace.Bind(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. diff --git a/Router_test.go b/Router_test.go index 94ce016..e88afe6 100644 --- a/Router_test.go +++ b/Router_test.go @@ -1,195 +1,82 @@ package router_test import ( - "strings" "testing" - "git.urbach.dev/go/assert" - "git.urbach.dev/go/router" - "git.urbach.dev/go/router/testdata" + "git.akyoto.dev/go/assert" + "git.akyoto.dev/go/router" ) -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) { - r := router.New[string]() - r.Add("GET", "/hello", "Hello") - r.Add("GET", "/world", "World") + router := router.New[string]() + router.Add("GET", "/hello", "Hello") + 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, data, "Hello") - data, params = r.Lookup("GET", "/world") + data, params = router.Lookup("GET", "/world") assert.Equal(t, len(params), 0) assert.Equal(t, data, "World") - 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, data, "") - } + data, params = router.Lookup("GET", "/404") + assert.Equal(t, len(params), 0) + assert.Equal(t, data, "") } func TestParameter(t *testing.T) { - r := router.New[string]() - r.Add("GET", "/blog/:post", "Blog post") - r.Add("GET", "/blog/:post/comments/:id", "Comment") + router := router.New[string]() + router.Add("GET", "/blog/:slug", "Blog post") + 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, params[0].Key, "post") + assert.Equal(t, params[0].Key, "slug") assert.Equal(t, params[0].Value, "hello-world") 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, params[0].Key, "post") + assert.Equal(t, params[0].Key, "slug") assert.Equal(t, params[0].Value, "hello-world") assert.Equal(t, params[1].Key, "id") assert.Equal(t, params[1].Value, "123") 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) { - 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") + router := router.New[string]() + router.Add("GET", "/", "Front page") + router.Add("GET", "/:slug", "Blog post") + router.Add("GET", "/users/:id", "Parameter") + router.Add("GET", "/images/*path", "Wildcard") - data, params := r.Lookup("GET", "/") + data, params := router.Lookup("GET", "/") 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, params[0].Key, "post") + assert.Equal(t, params[0].Key, "slug") assert.Equal(t, params[0].Value, "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, params[0].Key, "id") assert.Equal(t, params[0].Value, "42") assert.Equal(t, data, "Parameter") - 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") + data, params = router.Lookup("GET", "/images/favicon/256.png") assert.Equal(t, len(params), 1) assert.Equal(t, params[0].Key, "path") assert.Equal(t, params[0].Value, "favicon/256.png") 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) { + router := router.New[string]() methods := []string{ "GET", "POST", @@ -202,107 +89,26 @@ func TestMethods(t *testing.T) { "OPTIONS", } - r := router.New[string]() - for _, method := range methods { - r.Add(method, "/", method) + router.Add(method, "/", method) } for _, method := range methods { - data, _ := r.Lookup(method, "/") + data, _ := router.Lookup(method, "/") assert.Equal(t, data, method) } } func TestGitHub(t *testing.T) { - routes := testdata.Routes("testdata/github.txt") - r := router.New[string]() + router := router.New[string]() + routes := loadRoutes("testdata/github.txt") for _, route := range routes { - r.Add(route.Method, route.Path, "octocat") + router.Add(route.method, route.path, "octocat") } for _, route := range routes { - data, _ := r.Lookup(route.Method, route.Path) - assert.Equal(t, data, "octocat") - - data = r.LookupNoAlloc(route.Method, route.Path, func(string, string) {}) + data, _ := router.Lookup(route.method, route.path) 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) -} diff --git a/Tree.go b/Tree.go index 5a72491..d091e31 100644 --- a/Tree.go +++ b/Tree.go @@ -1,12 +1,38 @@ 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. 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. 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 i := 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. if path[i] == separator { - node, offset, _ = node.end(path, data, i, offset) - goto next + var control controlFlow + node, offset, control = node.end(path, data, i, offset) + + switch control { + case controlStop: + return + case controlBegin: + goto begin + case controlNext: + goto next + } } default: @@ -51,15 +86,15 @@ func (tree *Tree[T]) Add(path string, data T) { // node: /| // path: /|blog if i-offset == len(node.prefix) { - var control flow + var control controlFlow node, offset, control = node.end(path, data, i, offset) switch control { - case flowStop: + case controlStop: return - case flowBegin: + case controlBegin: goto begin - case flowNext: + case controlNext: 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. func (tree *Tree[T]) LookupNoAlloc(path string, addParameter func(key string, value string)) T { - var ( - i uint - parameterPath string - wildcardPath string - parameter *treeNode[T] - wildcard *treeNode[T] - node = &tree.root - ) + if tree.canBeStatic[len(path)] { + handler, found := tree.static[path] - // 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 + if found { + return handler + } } + var ( + i uint + offset uint + lastWildcardOffset uint + lastWildcard *treeNode[T] + empty T + node = &tree.root + ) + begin: // 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. // node: /| // path: /|blog - if i == uint(len(node.prefix)) { + if i-offset == uint(len(node.prefix)) { if node.wildcard != nil { - wildcard = node.wildcard - wildcardPath = path[i:] + lastWildcard = node.wildcard + lastWildcardOffset = i } - parameter = node.parameter - parameterPath = path[i:] char := path[i] if char >= node.startIndex && char < node.endIndex { @@ -126,8 +175,8 @@ begin: if index != 0 { node = node.children[index] - path = path[i:] - i = 1 + offset = i + i++ continue } } @@ -136,69 +185,64 @@ begin: // path: /|blog if node.parameter != nil { node = node.parameter - path = path[i:] - i = 1 + offset = i + 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 // path: /123|/posts if path[i] == separator { - addParameter(node.prefix, path[:i]) + addParameter(node.prefix, path[offset:i]) index := node.indices[separator-node.startIndex] node = node.children[index] - path = path[i:] - i = 1 + offset = i + i++ goto begin } i++ } - - addParameter(node.prefix, path[:i]) - return node.data } // node: /|*any // 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. // node: /b|ag // path: /b|riefcase - if path[i] != node.prefix[i] { - goto notFound + if path[i] != node.prefix[i-offset] { + if lastWildcard != nil { + addParameter(lastWildcard.prefix, path[lastWildcardOffset:]) + return lastWildcard.data + } + + return empty } 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. -func (tree *Tree[T]) Map(transform func(T) T) { +// Bind binds all handlers to a new one provided by the callback. +func (tree *Tree[T]) Bind(transform func(T) T) { tree.root.each(func(node *treeNode[T]) { node.data = transform(node.data) }) + + for key, value := range tree.static { + tree.static[key] = transform(value) + } } diff --git a/benchmarks_test.go b/benchmarks_test.go index 41f3571..5182c3b 100644 --- a/benchmarks_test.go +++ b/benchmarks_test.go @@ -3,57 +3,36 @@ package router_test import ( "testing" - "git.urbach.dev/go/router" - "git.urbach.dev/go/router/testdata" + "git.akyoto.dev/go/router" ) -func BenchmarkBlog(b *testing.B) { - routes := testdata.Routes("testdata/blog.txt") - r := router.New[string]() +func BenchmarkLookup(b *testing.B) { + router := router.New[string]() + routes := loadRoutes("testdata/github.txt") for _, route := range routes { - r.Add(route.Method, route.Path, "") + router.Add(route.method, route.path, "") } - b.Run("Len1-Param0", func(b *testing.B) { - for i := 0; i < b.N; i++ { - r.LookupNoAlloc("GET", "/", noop) - } - }) + b.ResetTimer() - b.Run("Len1-Param1", func(b *testing.B) { - for i := 0; i < b.N; i++ { - r.LookupNoAlloc("GET", "/:id", noop) - } - }) + for i := 0; i < b.N; i++ { + router.Lookup("GET", "/repos/:owner/:repo/issues") + } } -func BenchmarkGitHub(b *testing.B) { - routes := testdata.Routes("testdata/github.txt") - r := router.New[string]() +func BenchmarkLookupNoAlloc(b *testing.B) { + router := router.New[string]() + routes := loadRoutes("testdata/github.txt") + addParameter := func(string, string) {} for _, route := range routes { - r.Add(route.Method, route.Path, "") + router.Add(route.method, route.path, "") } - b.Run("Len7-Param0", func(b *testing.B) { - for i := 0; i < b.N; i++ { - r.LookupNoAlloc("GET", "/issues", noop) - } - }) + b.ResetTimer() - 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) - } - }) + for i := 0; i < b.N; i++ { + router.LookupNoAlloc("GET", "/repos/:owner/:repo/issues", addParameter) + } } - -// noop serves as an empty addParameter function. -func noop(string, string) {} diff --git a/flow.go b/flow.go deleted file mode 100644 index d84bcaa..0000000 --- a/flow.go +++ /dev/null @@ -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 -) diff --git a/go.mod b/go.mod index b147fdd..5879d15 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 049b850..9fc2547 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -git.urbach.dev/go/assert v0.0.0-20250225153414-7a6ed8be9b6e h1:lDTetvmGktDiMem+iBU3e5cGv52qUIbsqW8sV9u3gAQ= -git.urbach.dev/go/assert v0.0.0-20250225153414-7a6ed8be9b6e/go.mod h1:y9jGII9JFiF1HNIju0u87OyPCt82xKCtqnAFyEreCDo= +git.akyoto.dev/go/assert v0.1.3 h1:QwCUbmG4aZYsNk/OuRBz1zWVKmGlDUHhOnnDBfn8Qw8= +git.akyoto.dev/go/assert v0.1.3/go.mod h1:0GzMaM0eURuDwtGkJJkCsI7r2aUKr+5GmWNTFPgDocM= diff --git a/helper_test.go b/helper_test.go new file mode 100644 index 0000000..1ad5d3c --- /dev/null +++ b/helper_test.go @@ -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 +} diff --git a/testdata/Route.go b/testdata/Route.go deleted file mode 100644 index 2a2231f..0000000 --- a/testdata/Route.go +++ /dev/null @@ -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 -} diff --git a/testdata/blog.txt b/testdata/blog.txt deleted file mode 100644 index 432f70c..0000000 --- a/testdata/blog.txt +++ /dev/null @@ -1,4 +0,0 @@ -GET / -GET /:slug -GET /tags -GET /tag/:tag diff --git a/treeNode.go b/treeNode.go index c500939..18af73c 100644 --- a/treeNode.go +++ b/treeNode.go @@ -1,6 +1,8 @@ package router import ( + "fmt" + "io" "strings" ) @@ -157,7 +159,6 @@ func (node *treeNode[T]) append(path string, data T) { if node.prefix == "" { node.prefix = path node.data = data - node.addTrailingSlash(data) return } @@ -230,7 +231,7 @@ func (node *treeNode[T]) append(path string, data T) { // end is called when the node was fully parsed // and needs to decide the next control flow. // 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] 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 { node = node.children[index] 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 node.prefix == "" { node.append(path[i:], data) - return node, offset, flowStop + return node, offset, controlStop } // 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 { node = node.parameter offset = i - return node, offset, flowBegin + return node, offset, controlBegin } 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. @@ -282,3 +283,47 @@ func (node *treeNode[T]) each(callback func(*treeNode[T])) { 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 +}