Extraction: initial import

Extract Radix Tree implementation from `Beryl` project into an
standalone library to facilitate usage by other developers.

- Move `Tree`, `Node` and `Result` into `Radix` namespace
- Clenaup standalone README and describe usage
This commit is contained in:
Luis Lavena 2016-01-24 19:05:28 -03:00
commit 7f348cae8c
13 changed files with 1229 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
/doc/
/libs/
/.crystal/
/.shards/
# Libraries don't need dependency lock
# Dependencies will be locked in application that uses them
/shard.lock

1
.travis.yml Normal file
View File

@ -0,0 +1 @@
language: crystal

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Luis Lavena
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

68
README.md Normal file
View File

@ -0,0 +1,68 @@
# Radix Tree
[Radix tree](https://en.wikipedia.org/wiki/Radix_tree) implementation for
Crystal language
## Installation
Add this to your application's `shard.yml`:
```yaml
dependencies:
radix:
github: luislavena/radix
```
## Usage
You can associate a *payload* with each path added to the tree:
```crystal
require "radix"
tree = Radix::Tree.new
tree.add "/products", :products
tree.add "/products/featured", :featured
result = tree.find "/products/featured"
if result.found?
puts result.payload # => :featured
end
```
You can also extract values from placeholders (as named segments or globbing):
```
tree.add "/products/:id", :product
result = tree.find "/products/1234"
if result.found?
puts result.params["id"]? # => "1234"
end
```
Please see `Radix::Tree#add` documentation for more usage examples.
## Implementation
This project has been inspired and adapted from
[julienschmidt/httprouter](https://github.com/julienschmidt/httprouter) and
[spriet2000/vertx-http-router](https://github.com/spriet2000/vertx-http-router)
Go and Java implementations, respectively.
Changes to logic and optimizations have been made to take advantage of
Crystal's features.
## Contributing
1. Fork it ( https://github.com/luislavena/crystal-beryl/fork )
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request
## Contributors
- [Luis Lavena](https://github.com/luislavena) - creator, maintainer

7
shard.yml Normal file
View File

@ -0,0 +1,7 @@
name: radix
version: 0.1.0
authors:
- Luis Lavena <luislavena@gmail.com>
license: MIT

102
spec/radix/node_spec.cr Normal file
View File

@ -0,0 +1,102 @@
require "../spec_helper"
module Radix
describe Node do
describe "#key=" do
it "accepts change of key after initialization" do
node = Node.new("abc")
node.key.should eq("abc")
node.key = "xyz"
node.key.should eq("xyz")
end
end
describe "#payload" do
it "accepts any form of payload" do
node = Node.new("abc", :payload)
node.payload?.should be_truthy
node.payload.should eq(:payload)
node = Node.new("abc", 1_000)
node.payload?.should be_truthy
node.payload.should eq(1_000)
end
it "makes optional to provide a payload" do
node = Node.new("abc")
node.payload?.should be_falsey
end
end
describe "#priority" do
it "calculates it based on key size" do
node = Node.new("a")
node.priority.should eq(1)
node = Node.new("abc")
node.priority.should eq(3)
end
it "returns zero for catch all (globbed) key" do
node = Node.new("*filepath")
node.priority.should eq(0)
node = Node.new("/src/*filepath")
node.priority.should eq(0)
end
it "returns one for keys with named parameters" do
node = Node.new(":query")
node.priority.should eq(1)
node = Node.new("/search/:query")
node.priority.should eq(1)
end
it "changes when key changes" do
node = Node.new("a")
node.priority.should eq(1)
node.key = "abc"
node.priority.should eq(3)
node.key = "*filepath"
node.priority.should eq(0)
node.key = ":query"
node.priority.should eq(1)
end
end
describe "#sort!" do
it "orders children by priority" do
root = Node.new("/")
node1 = Node.new("a")
node2 = Node.new("bc")
node3 = Node.new("def")
root.children.push(node1, node2, node3)
root.sort!
root.children[0].should eq(node3)
root.children[1].should eq(node2)
root.children[2].should eq(node1)
end
it "orders catch all and named parameters lower than others" do
root = Node.new("/")
node1 = Node.new("*filepath")
node2 = Node.new("abc")
node3 = Node.new(":query")
root.children.push(node1, node2, node3)
root.sort!
root.children[0].should eq(node2)
root.children[1].should eq(node3)
root.children[2].should eq(node1)
end
end
end
end

67
spec/radix/result_spec.cr Normal file
View File

@ -0,0 +1,67 @@
require "../spec_helper"
module Radix
describe Result do
describe "#found?" do
context "a new instance" do
it "returns false when no payload is associated" do
result = Result.new
result.found?.should be_false
end
end
context "with a payload" do
it "returns true" do
node = Node.new("/", :root)
result = Result.new
result.use node
result.found?.should be_true
end
end
end
describe "#key" do
context "a new instance" do
it "returns an empty key" do
result = Result.new
result.key.should eq("")
end
end
context "given one used node" do
it "returns the node key" do
node = Node.new("/", :root)
result = Result.new
result.use node
result.key.should eq("/")
end
end
context "using multiple nodes" do
it "combines the node keys" do
node1 = Node.new("/", :root)
node2 = Node.new("about", :about)
result = Result.new
result.use node1
result.use node2
result.key.should eq("/about")
end
end
end
describe "#use" do
it "uses the node payload" do
node = Node.new("/", :root)
result = Result.new
result.payload?.should be_falsey
result.use node
result.payload?.should be_truthy
result.payload.should eq(node.payload)
end
end
end
end

371
spec/radix/tree_spec.cr Normal file
View File

@ -0,0 +1,371 @@
require "../spec_helper"
module Radix
describe Tree do
context "a new instance" do
it "contains a root placeholder node" do
tree = Tree.new
tree.root.should be_a(Node)
tree.root.payload?.should be_falsey
tree.root.placeholder?.should be_true
end
end
describe "#add" do
context "on a new instance" do
it "replaces placeholder with new node" do
tree = Tree.new
tree.add "/abc", :abc
tree.root.should be_a(Node)
tree.root.placeholder?.should be_false
tree.root.payload?.should be_truthy
tree.root.payload.should eq(:abc)
end
end
context "shared root" do
it "inserts properly adjacent nodes" do
tree = Tree.new
tree.add "/", :root
tree.add "/a", :a
tree.add "/bc", :bc
# / (:root)
# +-bc (:bc)
# \-a (:a)
tree.root.children.size.should eq(2)
tree.root.children[0].key.should eq("bc")
tree.root.children[0].payload.should eq(:bc)
tree.root.children[1].key.should eq("a")
tree.root.children[1].payload.should eq(:a)
end
it "inserts nodes with shared parent" do
tree = Tree.new
tree.add "/", :root
tree.add "/abc", :abc
tree.add "/axyz", :axyz
# / (:root)
# +-a
# +-xyz (:axyz)
# \-bc (:abc)
tree.root.children.size.should eq(1)
tree.root.children[0].key.should eq("a")
tree.root.children[0].children.size.should eq(2)
tree.root.children[0].children[0].key.should eq("xyz")
tree.root.children[0].children[1].key.should eq("bc")
end
it "inserts multiple parent nodes" do
tree = Tree.new
tree.add "/", :root
tree.add "/admin/users", :users
tree.add "/admin/products", :products
tree.add "/blog/tags", :tags
tree.add "/blog/articles", :articles
# / (:root)
# +-admin/
# | +-products (:products)
# | \-users (:users)
# |
# +-blog/
# +-articles (:articles)
# \-tags (:tags)
tree.root.children.size.should eq(2)
tree.root.children[0].key.should eq("admin/")
tree.root.children[0].payload?.should be_falsey
tree.root.children[0].children[0].key.should eq("products")
tree.root.children[0].children[1].key.should eq("users")
tree.root.children[1].key.should eq("blog/")
tree.root.children[1].payload?.should be_falsey
tree.root.children[1].children[0].key.should eq("articles")
tree.root.children[1].children[0].payload?.should be_truthy
tree.root.children[1].children[1].key.should eq("tags")
tree.root.children[1].children[1].payload?.should be_truthy
end
it "inserts multiple nodes with mixed parents" do
tree = Tree.new
tree.add "/authorizations", :authorizations
tree.add "/authorizations/:id", :authorization
tree.add "/applications", :applications
tree.add "/events", :events
# /
# +-events (:events)
# +-a
# +-uthorizations (:authorizations)
# | \-/:id (:authorization)
# \-pplications (:applications)
tree.root.children.size.should eq(2)
tree.root.children[1].key.should eq("a")
tree.root.children[1].children.size.should eq(2)
tree.root.children[1].children[0].payload.should eq(:authorizations)
tree.root.children[1].children[1].payload.should eq(:applications)
end
it "supports insertion of mixed routes out of order" do
tree = Tree.new
tree.add "/user/repos", :my_repos
tree.add "/users/:user/repos", :user_repos
tree.add "/users/:user", :user
tree.add "/user", :me
# /user (:me)
# +-/repos (:my_repos)
# \-s/:user (:user)
# \-/repos (:user_repos)
tree.root.key.should eq("/user")
tree.root.payload?.should be_truthy
tree.root.payload.should eq(:me)
tree.root.children.size.should eq(2)
tree.root.children[0].key.should eq("/repos")
tree.root.children[1].key.should eq("s/:user")
tree.root.children[1].payload.should eq(:user)
tree.root.children[1].children[0].key.should eq("/repos")
end
end
context "dealing with duplicates" do
it "does not allow same path be defined twice" do
tree = Tree.new
tree.add "/", :root
tree.add "/abc", :abc
expect_raises Tree::DuplicateError do
tree.add "/", :other
end
tree.root.children.size.should eq(1)
end
end
context "dealing with catch all and named parameters" do
it "prioritizes nodes correctly" do
tree = Tree.new
tree.add "/", :root
tree.add "/*filepath", :all
tree.add "/products", :products
tree.add "/products/:id", :product
tree.add "/products/:id/edit", :edit
tree.add "/products/featured", :featured
# / (:all)
# +-products (:products)
# | \-/
# | +-featured (:featured)
# | \-:id (:product)
# | \-/edit (:edit)
# \-*filepath (:all)
tree.root.children.size.should eq(2)
tree.root.children[0].key.should eq("products")
tree.root.children[0].children[0].key.should eq("/")
nodes = tree.root.children[0].children[0].children
nodes.size.should eq(2)
nodes[0].key.should eq("featured")
nodes[1].key.should eq(":id")
nodes[1].children[0].key.should eq("/edit")
tree.root.children[1].key.should eq("*filepath")
end
end
end
describe "#find" do
context "a single node" do
it "does not find when using different path" do
tree = Tree.new
tree.add "/about", :about
result = tree.find "/products"
result.found?.should be_false
end
it "finds when using matching path" do
tree = Tree.new
tree.add "/about", :about
result = tree.find "/about"
result.found?.should be_true
result.key.should eq("/about")
result.payload?.should be_truthy
result.payload.should eq(:about)
end
it "finds when using path with trailing slash" do
tree = Tree.new
tree.add "/about", :about
result = tree.find "/about/"
result.found?.should be_true
result.key.should eq("/about")
end
it "finds when key has trailing slash" do
tree = Tree.new
tree.add "/about/", :about
result = tree.find "/about"
result.found?.should be_true
result.key.should eq("/about/")
result.payload.should eq(:about)
end
end
context "nodes with shared parent" do
it "finds matching path" do
tree = Tree.new
tree.add "/", :root
tree.add "/abc", :abc
tree.add "/axyz", :axyz
result = tree.find("/abc")
result.found?.should be_true
result.key.should eq("/abc")
result.payload.should eq(:abc)
end
it "finds matching path across parents" do
tree = Tree.new
tree.add "/", :root
tree.add "/admin/users", :users
tree.add "/admin/products", :products
tree.add "/blog/tags", :tags
tree.add "/blog/articles", :articles
result = tree.find("/blog/tags/")
result.found?.should be_true
result.key.should eq("/blog/tags")
result.payload.should eq(:tags)
end
end
context "dealing with catch all" do
it "finds matching path" do
tree = Tree.new
tree.add "/", :root
tree.add "/*filepath", :all
tree.add "/about", :about
result = tree.find("/src/file.png")
result.found?.should be_true
result.key.should eq("/*filepath")
result.payload.should eq(:all)
end
it "returns catch all in parameters" do
tree = Tree.new
tree.add "/", :root
tree.add "/*filepath", :all
tree.add "/about", :about
result = tree.find("/src/file.png")
result.found?.should be_true
result.params.has_key?("filepath").should be_true
result.params["filepath"].should eq("src/file.png")
end
it "returns optional catch all" do
tree = Tree.new
tree.add "/", :root
tree.add "/search/*extra", :extra
result = tree.find("/search")
result.found?.should be_true
result.key.should eq("/search/*extra")
result.params.has_key?("extra").should be_true
result.params["extra"].empty?.should be_true
end
it "does not find when catch all is not full match" do
tree = Tree.new
tree.add "/", :root
tree.add "/search/public/*query", :search
result = tree.find("/search")
result.found?.should be_false
end
end
context "dealing with named parameters" do
it "finds matching path" do
tree = Tree.new
tree.add "/", :root
tree.add "/products", :products
tree.add "/products/:id", :product
tree.add "/products/:id/edit", :edit
result = tree.find("/products/10")
result.found?.should be_true
result.key.should eq("/products/:id")
result.payload.should eq(:product)
end
it "does not find partial matching path" do
tree = Tree.new
tree.add "/", :root
tree.add "/products", :products
tree.add "/products/:id/edit", :edit
result = tree.find("/products/10")
result.found?.should be_false
end
it "returns named parameters in result" do
tree = Tree.new
tree.add "/", :root
tree.add "/products", :products
tree.add "/products/:id", :product
tree.add "/products/:id/edit", :edit
result = tree.find("/products/10/edit")
result.found?.should be_true
result.params.has_key?("id").should be_true
result.params["id"].should eq("10")
end
it "returns unicode values in parameters" do
tree = Tree.new
tree.add "/", :root
tree.add "/language/:name", :language
tree.add "/language/:name/about", :about
result = tree.find("/language/日本語")
result.found?.should be_true
result.params.has_key?("name").should be_true
result.params["name"].should eq("日本語")
end
end
context "dealing with both catch all and named parameters" do
it "finds matching path" do
tree = Tree.new
tree.add "/", :root
tree.add "/*filepath", :all
tree.add "/products", :products
tree.add "/products/:id", :product
tree.add "/products/:id/edit", :edit
tree.add "/products/featured", :featured
result = tree.find("/products/1000")
result.found?.should be_true
result.key.should eq("/products/:id")
result.payload.should eq(:product)
result = tree.find("/admin/articles")
result.found?.should be_true
result.key.should eq("/*filepath")
result.params["filepath"].should eq("admin/articles")
result = tree.find("/products/featured")
result.found?.should be_true
result.key.should eq("/products/featured")
result.payload.should eq(:featured)
end
end
end
end
end

2
spec/spec_helper.cr Normal file
View File

@ -0,0 +1,2 @@
require "spec"
require "../src/radix"

1
src/radix.cr Normal file
View File

@ -0,0 +1 @@
require "./radix/tree"

133
src/radix/node.cr Normal file
View File

@ -0,0 +1,133 @@
module Radix
# A Node represents one element in the structure of a [Radix tree](https://en.wikipedia.org/wiki/Radix_tree)
#
# Carries a *payload* and might also contain references to other nodes
# down in the organization inside *children*.
#
# Each node also carries a *priority* number, which might indicate the
# weight of this node depending on characteristics like catch all
# (globbing), named parameters or simply the size of the Node's *key*.
#
# Referenced nodes inside *children* can be sorted by *priority*.
#
# Is not expected direct usage of a node but instead manipulation via
# methods within `Tree`.
#
# ```
# node = Node.new("/", :root)
# node.children << Node.new("a", :a)
# node.children << Node.new("bc", :bc)
# node.children << Node.new("def", :def)
# node.sort!
#
# node.priority
# # => 1
#
# node.children.map &.priority
# # => [3, 2, 1]
# ```
class Node
getter :key
getter? :placeholder
property! :payload
property :children
# Returns the priority of the Node based on it's *key*
#
# This value will be directly associated to the key size or special
# elements of it.
#
# * A catch all (globbed) key will receive lowest priority (`0`)
# * A named parameter key will receive priority above catch all (`1`)
# * Any other type of key will receive priority based on its size.
#
# ```
# Node.new("a").priority
# # => 1
#
# Node.new("abc").priority
# # => 3
#
# Node.new("*filepath").priority
# # => 0
#
# Node.new(":query").priority
# # => 1
# ```
getter :priority
# Instantiate a Node
#
# - *key* - A `String` that represents this node.
# - *payload* - An Optional payload for this node.
def initialize(@key : String, @payload = nil, @placeholder = false)
@children = [] of Node
@priority = compute_priority
end
# Changes current *key*
#
# ```
# node = Node.new("a")
# node.key
# # => "a"
#
# node.key = "b"
# node.key
# # => "b"
# ```
#
# This will also result in a new priority for the node.
#
# ```
# node = Node.new("a")
# node.priority
# # => 1
#
# node.key = "abcdef"
# node.priority
# # => 6
# ```
def key=(value : String)
@key = value
@priority = compute_priority
end
# :nodoc:
private def compute_priority
reader = Char::Reader.new(@key)
while reader.has_next?
case reader.current_char
when '*'
return 0
when ':'
return 1
else
reader.next_char
end
end
reader.pos
end
# Changes the order of Node's children based on each node priority.
#
# This ensures highest priority nodes are listed before others.
#
# ```
# root = Node.new("/")
# root.children << Node.new("*filepath") # node.priority => 0
# root.children << Node.new(":query") # node.priority => 1
# root.children << Node.new("a") # node.priority => 1
# root.children << Node.new("bc") # node.priority => 2
# root.sort!
#
# root.children.map &.priority
# # => [2, 1, 1, 0]
# ```
def sort!
@children.sort_by! { |node| -node.priority }
end
end
end

88
src/radix/result.cr Normal file
View File

@ -0,0 +1,88 @@
require "./node"
module Radix
# A Result is the comulative output of walking our [Radix tree](https://en.wikipedia.org/wiki/Radix_tree)
# `Tree` implementation.
#
# It provides helpers to retrieve the information obtained from walking
# our tree using `Tree#find`
#
# This information can be used to perform actions in case of the *path*
# that was looked on the Tree was found.
#
# A Result is also used recursively by `Tree#find` when collecting extra
# information like *params*.
class Result
getter :params
getter! :payload
# :nodoc:
def initialize
@nodes = [] of Node
@params = {} of String => String
end
# Returns whatever a *payload* was found by `Tree#find` and is part of
# the result.
#
# ```
# result = Result.new
# result.found?
# # => false
#
# root = Node.new("/", :root)
# result.use(root)
# result.found?
# # => true
# ```
def found?
payload? ? true : false
end
# Returns a String built based on the nodes used in the result
#
# ```
# node1 = Node.new("/", :root)
# node2 = Node.new("about", :about)
#
# result = Result.new
# result.use node1
# result.use node2
#
# result.key
# # => "/about"
# ```
#
# When no node has been used, returns an empty String.
#
# ```
# result = Result.new
# result.key
# # => ""
# ```
def key
return @key if @key
key = String.build { |io|
@nodes.each do |node|
io << node.key
end
}
@key = key
end
# Adjust result information by using the details of the given `Node`.
#
# * Collect `Node` for future references.
# * Use *payload* if present.
def use(node : Node, payload = true)
# collect nodes
@nodes << node
if payload && node.payload?
@payload = node.payload
end
end
end
end

360
src/radix/tree.cr Normal file
View File

@ -0,0 +1,360 @@
require "./node"
require "./result"
module Radix
# A [Radix tree](https://en.wikipedia.org/wiki/Radix_tree) implementation.
#
# It allows insertion of *path* elements that will be organized inside
# the tree aiming to provide fast retrieval options.
#
# Each inserted *path* will be represented by a `Node` or segmented and
# distributed within the `Tree`.
#
# You can associate a *payload* at insertion which will be return back
# at retrieval time.
class Tree
# :nodoc:
class DuplicateError < Exception
def initialize(path)
super("Duplicate trail found '#{path}'")
end
end
# Returns the root `Node` element of the Tree.
#
# On a new tree instance, this will be a placeholder.
getter :root
def initialize
@root = Node.new("", placeholder: true)
end
# Inserts given *path* into the Tree
#
# * *path* - An `String` representing the pattern to be inserted.
# * *payload* - Required associated element for this path.
#
# If no previous elements existed in the Tree, this will replace the
# defined placeholder.
#
# ```
# tree = Tree.new
#
# # / (:root)
# tree.add "/", :root
#
# # / (:root)
# # \-abc (:abc)
# tree.add "/abc", :abc
#
# # / (:root)
# # \-abc (:abc)
# # \-xyz (:xyz)
# tree.add "/abcxyz", :xyz
# ```
#
# Nodes inside the tree will be adjusted to accomodate the different
# segments of the given *path*.
#
# ```
# tree = Tree.new
#
# # / (:root)
# tree.add "/", :root
#
# # / (:root)
# # \-products/:id (:product)
# tree.add "/products/:id", :product
#
# # / (:root)
# # \-products/
# # +-featured (:featured)
# # \-:id (:product)
# tree.add "/products/featured", :featured
# ```
#
# Catch all (globbing) and named paramters *path* will be located with
# lower priority against other nodes.
#
# ```
# tree = Tree.new
#
# # / (:root)
# tree.add "/", :root
#
# # / (:root)
# # \-*filepath (:all)
# tree.add "/*filepath", :all
#
# # / (:root)
# # +-about (:about)
# # \-*filepath (:all)
# tree.add "/about", :about
# ```
def add(path : String, payload)
root = @root
# replace placeholder with new node
if root.placeholder?
@root = Node.new(path, payload)
else
add path, payload, root
end
end
# :nodoc:
private def add(path : String, payload, node : Node)
key_reader = Char::Reader.new(node.key)
path_reader = Char::Reader.new(path)
# move cursor position to last shared character between key and path
while path_reader.has_next? && key_reader.has_next?
break if path_reader.current_char != key_reader.current_char
path_reader.next_char
key_reader.next_char
end
# determine split point difference between path and key
# compare if path is larger than key
if path_reader.pos == 0 ||
(path_reader.pos < path.size && path_reader.pos >= node.key.size)
# determine if a child of this node contains the remaining part
# of the path
added = false
new_key = path_reader.string.byte_slice(path_reader.pos)
node.children.each do |child|
# compare first character
next unless child.key[0]? == new_key[0]?
# when found, add to this child
added = true
add new_key, payload, child
break
end
# if no existing child shared part of the key, add a new one
unless added
node.children << Node.new(new_key, payload)
end
# adjust priorities
node.sort!
elsif path_reader.pos == path.size && path_reader.pos == node.key.size
# determine if path matches key and potentially be a duplicate
# and raise if is the case
if node.payload?
raise DuplicateError.new(path)
else
# assign payload since this is an empty node
node.payload = payload
end
elsif path_reader.pos > 0 && path_reader.pos < node.key.size
# determine if current node key needs to be split to accomodate new
# children nodes
# build new node with partial key and adjust existing one
new_key = node.key.byte_slice(path_reader.pos)
swap_payload = node.payload? ? node.payload : nil
new_node = Node.new(new_key, swap_payload)
new_node.children.replace(node.children)
# clear payload and children (this is no longer and endpoint)
node.payload = nil
node.children.clear
# adjust existing node key to new partial one
node.key = path_reader.string.byte_slice(0, path_reader.pos)
node.children << new_node
node.sort!
# determine if path still continues
if path_reader.pos < path.size
new_key = path.byte_slice(path_reader.pos)
node.children << Node.new(new_key, payload)
node.sort!
# clear payload (no endpoint)
node.payload = nil
else
# this is an endpoint, set payload
node.payload = payload
end
end
end
# Returns a `Result` instance after walking the tree looking up for
# *path*
#
# It will start walking the tree from the root node until a matching
# endpoint is found (or not).
#
# ```
# tree = Tree.new
# tree.add "/about", :about
#
# result = tree.find "/products"
# result.found?
# # => false
#
# result = tree.find "/about"
# result.found?
# # => true
#
# result.payload
# # => :about
# ```
def find(path : String)
result = Result.new
root = @root
# walk the tree from root (first time)
find path, result, root, first: true
result
end
# :nodoc:
private def find(path : String, result : Result, node : Node, first = false)
# special consideration when comparing the first node vs. others
# in case of node key and path being the same, return the node
# instead of walking character by character
if first && (path.size == node.key.size && path == node.key) && node.payload?
result.use node
return
end
key_reader = Char::Reader.new(node.key)
path_reader = Char::Reader.new(path)
# walk both path and key while both have characters and they continue
# to match. Consider as special cases named parameters and catch all
# rules.
while key_reader.has_next? && path_reader.has_next? &&
(key_reader.current_char == '*' ||
key_reader.current_char == ':' ||
path_reader.current_char == key_reader.current_char)
case key_reader.current_char
when '*'
# deal with catch all (globbing) parameter
# extract parameter name from key (exclude *) and value from path
name = key_reader.string.byte_slice(key_reader.pos + 1)
value = path_reader.string.byte_slice(path_reader.pos)
# add this to result
result.params[name] = value
result.use node
return
when ':'
# deal with named parameter
# extract parameter name from key (from : until / or EOL) and
# value from path (same rules as key)
key_size = _detect_param_size(key_reader)
path_size = _detect_param_size(path_reader)
# obtain key and value using calculated sizes
name = key_reader.string.byte_slice(key_reader.pos + 1, key_size)
value = path_reader.string.byte_slice(path_reader.pos, path_size)
# add this information to result
result.params[name] = value
# advance readers positions
key_reader.pos += key_size
path_reader.pos += path_size
else
# move to the next character
key_reader.next_char
path_reader.next_char
end
end
# check if we reached the end of the path & key
if !path_reader.has_next? && !key_reader.has_next?
# check endpoint
if node.payload?
result.use node
return
end
end
# still path to walk, check for possible trailing slash or children
# nodes
if path_reader.has_next?
# using trailing slash?
if node.key.size > 0 &&
path_reader.pos + 1 == path.size &&
path_reader.current_char == '/'
result.use node
return
end
# not found in current node, check inside children nodes
new_path = path_reader.string.byte_slice(path_reader.pos)
node.children.each do |child|
# check if child first character matches the new path
if child.key[0]? == new_path[0]? ||
child.key[0]? == '*' || child.key[0]? == ':'
# consider this node for key but don't use payload
result.use node, payload: false
find new_path, result, child
return
end
end
end
# key still contains characters to walk
if key_reader.has_next?
# determine if there is just a trailing slash?
if key_reader.pos + 1 == node.key.size &&
key_reader.current_char == '/'
result.use node
return
end
# check if remaining part is catch all
if key_reader.pos < node.key.size &&
key_reader.current_char == '/' &&
key_reader.peek_next_char == '*'
# skip '*'
key_reader.next_char
# deal with catch all, but since there is nothing in the path
# return parameter as empty
name = key_reader.string.byte_slice(key_reader.pos + 1)
result.params[name] = ""
result.use node
return
end
end
end
# :nodoc:
private def _detect_param_size(reader)
# save old position
old_pos = reader.pos
# move forward until '/' or EOL is detected
while reader.has_next?
break if reader.current_char == '/'
reader.next_char
end
# calculate the size
count = reader.pos - old_pos
# restore old position
reader.pos = old_pos
count
end
end
end