diff --git a/Parameter.go b/Parameter.go index b1c7cd2..de4f0a5 100644 --- a/Parameter.go +++ b/Parameter.go @@ -1,6 +1,6 @@ package router -// Parameter is a URL parameter. +// Parameter represents a URL parameter. type Parameter struct { Key string Value string diff --git a/README.md b/README.md index 9854c92..2532d80 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ HTTP router based on radix trees. ## Installation ```shell -go get git.akyoto.dev/go/router +go get git.urbach.dev/go/router ``` ## Usage @@ -45,22 +45,31 @@ 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 -coverage: 76.9% of statements +PASS: TestTrailingSlash +PASS: TestTrailingSlashOverwrite +PASS: TestOverwrite +PASS: TestInvalidMethod +coverage: 100.0% of statements ``` ## Benchmarks ``` -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 +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 ``` ## License -Please see the [license documentation](https://akyoto.dev/license). +Please see the [license documentation](https://urbach.dev/license). ## Copyright diff --git a/Router.go b/Router.go index bbed698..3db0647 100644 --- a/Router.go +++ b/Router.go @@ -1,7 +1,5 @@ package router -import "os" - // Router is a high-performance router. type Router[T any] struct { get Tree[T] @@ -46,23 +44,17 @@ func (router *Router[T]) LookupNoAlloc(method string, path string, addParameter return tree.LookupNoAlloc(path, addParameter) } -// 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) +// 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) } // selectTree returns the tree by the given HTTP method. diff --git a/Router_test.go b/Router_test.go index e88afe6..94ce016 100644 --- a/Router_test.go +++ b/Router_test.go @@ -1,82 +1,195 @@ package router_test import ( + "strings" "testing" - "git.akyoto.dev/go/assert" - "git.akyoto.dev/go/router" + "git.urbach.dev/go/assert" + "git.urbach.dev/go/router" + "git.urbach.dev/go/router/testdata" ) -func TestStatic(t *testing.T) { - router := router.New[string]() - router.Add("GET", "/hello", "Hello") - router.Add("GET", "/world", "World") +func TestHello(t *testing.T) { + r := router.New[string]() + r.Add("GET", "/blog", "Blog") + 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, data, "Hello") - data, params = router.Lookup("GET", "/world") + data, params = r.Lookup("GET", "/world") assert.Equal(t, len(params), 0) assert.Equal(t, data, "World") - data, params = router.Lookup("GET", "/404") - assert.Equal(t, len(params), 0) - assert.Equal(t, data, "") + 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, "") + } } func TestParameter(t *testing.T) { - router := router.New[string]() - router.Add("GET", "/blog/:slug", "Blog post") - router.Add("GET", "/blog/:slug/comments/:id", "Comment") + r := router.New[string]() + r.Add("GET", "/blog/:post", "Blog post") + 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, params[0].Key, "slug") + assert.Equal(t, params[0].Key, "post") assert.Equal(t, params[0].Value, "hello-world") 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, params[0].Key, "slug") + assert.Equal(t, params[0].Key, "post") 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 TestWildcard(t *testing.T) { - 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") +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 := router.Lookup("GET", "/") + data, params := r.Lookup("GET", "/") 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, 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, 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, params[0].Key, "id") assert.Equal(t, params[0].Value, "42") 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, 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", @@ -89,26 +202,107 @@ func TestMethods(t *testing.T) { "OPTIONS", } + r := router.New[string]() + for _, method := range methods { - router.Add(method, "/", method) + r.Add(method, "/", method) } for _, method := range methods { - data, _ := router.Lookup(method, "/") + data, _ := r.Lookup(method, "/") assert.Equal(t, data, method) } } func TestGitHub(t *testing.T) { - router := router.New[string]() - routes := loadRoutes("testdata/github.txt") + routes := testdata.Routes("testdata/github.txt") + r := router.New[string]() for _, route := range routes { - router.Add(route.method, route.path, "octocat") + r.Add(route.Method, route.Path, "octocat") } 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") } } + +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 d091e31..5a72491 100644 --- a/Tree.go +++ b/Tree.go @@ -1,38 +1,12 @@ 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 { - static map[string]T - root treeNode[T] - canBeStatic [2048]bool + root treeNode[T] } // 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 @@ -52,17 +26,8 @@ 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 { - 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 - } + node, offset, _ = node.end(path, data, i, offset) + goto next } default: @@ -86,15 +51,15 @@ func (tree *Tree[T]) Add(path string, data T) { // node: /| // path: /|blog if i-offset == len(node.prefix) { - var control controlFlow + var control flow node, offset, control = node.end(path, data, i, offset) switch control { - case controlStop: + case flowStop: return - case controlBegin: + case flowBegin: goto begin - case controlNext: + case flowNext: 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. 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 ( - i uint - offset uint - lastWildcardOffset uint - lastWildcard *treeNode[T] - empty T - node = &tree.root + i uint + 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 len(path) > 0 && len(node.prefix) > 0 && path[0] == node.prefix[0] { + i = 1 + } + begin: // Search tree for equal parts until we can no longer proceed - 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 - } - + for i < uint(len(path)) { // The node we just checked is entirely included in our path. // node: /| // path: /|blog - if i-offset == uint(len(node.prefix)) { + if i == uint(len(node.prefix)) { if node.wildcard != nil { - lastWildcard = node.wildcard - lastWildcardOffset = i + wildcard = node.wildcard + wildcardPath = path[i:] } + parameter = node.parameter + parameterPath = path[i:] char := path[i] if char >= node.startIndex && char < node.endIndex { @@ -175,8 +126,8 @@ begin: if index != 0 { node = node.children[index] - offset = i - i++ + path = path[i:] + i = 1 continue } } @@ -185,64 +136,69 @@ begin: // path: /|blog if node.parameter != nil { node = node.parameter - offset = i - i++ - - for { - // We reached the end. - if i == uint(len(path)) { - addParameter(node.prefix, path[offset:i]) - return node.data - } + path = path[i:] + i = 1 + for i < uint(len(path)) { // node: /:id|/posts // path: /123|/posts if path[i] == separator { - addParameter(node.prefix, path[offset:i]) + addParameter(node.prefix, path[:i]) index := node.indices[separator-node.startIndex] node = node.children[index] - offset = i - i++ + path = path[i:] + i = 1 goto begin } i++ } + + addParameter(node.prefix, path[:i]) + return node.data } // node: /|*any // path: /|image.png - if node.wildcard != nil { - addParameter(node.wildcard.prefix, path[i:]) - return node.wildcard.data - } - - return empty + goto notFound } // We got a conflict. // node: /b|ag // path: /b|riefcase - if path[i] != node.prefix[i-offset] { - if lastWildcard != nil { - addParameter(lastWildcard.prefix, path[lastWildcardOffset:]) - return lastWildcard.data - } - - return empty + if path[i] != node.prefix[i] { + goto notFound } 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. -func (tree *Tree[T]) Bind(transform func(T) T) { +// Map binds all handlers to a new one provided by the callback. +func (tree *Tree[T]) Map(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 5182c3b..41f3571 100644 --- a/benchmarks_test.go +++ b/benchmarks_test.go @@ -3,36 +3,57 @@ package router_test import ( "testing" - "git.akyoto.dev/go/router" + "git.urbach.dev/go/router" + "git.urbach.dev/go/router/testdata" ) -func BenchmarkLookup(b *testing.B) { - router := router.New[string]() - routes := loadRoutes("testdata/github.txt") +func BenchmarkBlog(b *testing.B) { + routes := testdata.Routes("testdata/blog.txt") + r := router.New[string]() 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++ { + r.LookupNoAlloc("GET", "/", noop) + } + }) - for i := 0; i < b.N; i++ { - router.Lookup("GET", "/repos/:owner/:repo/issues") - } + 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) { - router := router.New[string]() - routes := loadRoutes("testdata/github.txt") - addParameter := func(string, string) {} +func BenchmarkGitHub(b *testing.B) { + routes := testdata.Routes("testdata/github.txt") + r := router.New[string]() 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++ { + r.LookupNoAlloc("GET", "/issues", noop) + } + }) - for i := 0; i < b.N; i++ { - router.LookupNoAlloc("GET", "/repos/:owner/:repo/issues", addParameter) - } + 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) {} diff --git a/flow.go b/flow.go new file mode 100644 index 0000000..d84bcaa --- /dev/null +++ b/flow.go @@ -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 +) diff --git a/go.mod b/go.mod index 5879d15..b147fdd 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 9fc2547..049b850 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -git.akyoto.dev/go/assert v0.1.3 h1:QwCUbmG4aZYsNk/OuRBz1zWVKmGlDUHhOnnDBfn8Qw8= -git.akyoto.dev/go/assert v0.1.3/go.mod h1:0GzMaM0eURuDwtGkJJkCsI7r2aUKr+5GmWNTFPgDocM= +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= diff --git a/helper_test.go b/helper_test.go deleted file mode 100644 index 1ad5d3c..0000000 --- a/helper_test.go +++ /dev/null @@ -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 -} diff --git a/testdata/Route.go b/testdata/Route.go new file mode 100644 index 0000000..2a2231f --- /dev/null +++ b/testdata/Route.go @@ -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 +} diff --git a/testdata/blog.txt b/testdata/blog.txt new file mode 100644 index 0000000..432f70c --- /dev/null +++ b/testdata/blog.txt @@ -0,0 +1,4 @@ +GET / +GET /:slug +GET /tags +GET /tag/:tag diff --git a/treeNode.go b/treeNode.go index 18af73c..c500939 100644 --- a/treeNode.go +++ b/treeNode.go @@ -1,8 +1,6 @@ package router import ( - "fmt" - "io" "strings" ) @@ -159,6 +157,7 @@ func (node *treeNode[T]) append(path string, data T) { if node.prefix == "" { node.prefix = path node.data = data + node.addTrailingSlash(data) return } @@ -231,7 +230,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, controlFlow) { +func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[T], int, flow) { char := path[i] 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 { node = node.children[index] 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 node.prefix == "" { node.append(path[i:], data) - return node, offset, controlStop + return node, offset, flowStop } // 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 { node = node.parameter offset = i - return node, offset, controlBegin + return node, offset, flowBegin } 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. @@ -283,47 +282,3 @@ 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 -}