Compare commits
No commits in common. "e35d5715d1a5059fe07cfd1ff4c64515600f2197" and "ad033d4315be569a91766c52c28c89c53c7cc074" have entirely different histories.
e35d5715d1
...
ad033d4315
13 changed files with 292 additions and 434 deletions
|
@ -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
|
||||||
|
|
19
README.md
19
README.md
|
@ -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
|
||||||
|
|
||||||
|
|
30
Router.go
30
Router.go
|
@ -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.
|
||||||
|
|
264
Router_test.go
264
Router_test.go
|
@ -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
168
Tree.go
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
11
flow.go
|
@ -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
6
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
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -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
52
helper_test.go
Normal 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
52
testdata/Route.go
vendored
|
@ -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
4
testdata/blog.txt
vendored
|
@ -1,4 +0,0 @@
|
||||||
GET /
|
|
||||||
GET /:slug
|
|
||||||
GET /tags
|
|
||||||
GET /tag/:tag
|
|
57
treeNode.go
57
treeNode.go
|
@ -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
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue