From 7f348cae8c19e0e5d0be190743d54265e0576f4f Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 24 Jan 2016 19:05:28 -0300 Subject: [PATCH] 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 --- .gitignore | 8 + .travis.yml | 1 + LICENSE | 21 +++ README.md | 68 +++++++ shard.yml | 7 + spec/radix/node_spec.cr | 102 +++++++++++ spec/radix/result_spec.cr | 67 +++++++ spec/radix/tree_spec.cr | 371 ++++++++++++++++++++++++++++++++++++++ spec/spec_helper.cr | 2 + src/radix.cr | 1 + src/radix/node.cr | 133 ++++++++++++++ src/radix/result.cr | 88 +++++++++ src/radix/tree.cr | 360 ++++++++++++++++++++++++++++++++++++ 13 files changed, 1229 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 shard.yml create mode 100644 spec/radix/node_spec.cr create mode 100644 spec/radix/result_spec.cr create mode 100644 spec/radix/tree_spec.cr create mode 100644 spec/spec_helper.cr create mode 100644 src/radix.cr create mode 100644 src/radix/node.cr create mode 100644 src/radix/result.cr create mode 100644 src/radix/tree.cr diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05ddfff --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ffc7b6a --- /dev/null +++ b/.travis.yml @@ -0,0 +1 @@ +language: crystal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..15bbbc1 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6ccf6d --- /dev/null +++ b/README.md @@ -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 diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..4b31649 --- /dev/null +++ b/shard.yml @@ -0,0 +1,7 @@ +name: radix +version: 0.1.0 + +authors: + - Luis Lavena + +license: MIT diff --git a/spec/radix/node_spec.cr b/spec/radix/node_spec.cr new file mode 100644 index 0000000..2578f68 --- /dev/null +++ b/spec/radix/node_spec.cr @@ -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 diff --git a/spec/radix/result_spec.cr b/spec/radix/result_spec.cr new file mode 100644 index 0000000..a8ed8cd --- /dev/null +++ b/spec/radix/result_spec.cr @@ -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 diff --git a/spec/radix/tree_spec.cr b/spec/radix/tree_spec.cr new file mode 100644 index 0000000..5314ba5 --- /dev/null +++ b/spec/radix/tree_spec.cr @@ -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 diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..6fa0c75 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/radix" diff --git a/src/radix.cr b/src/radix.cr new file mode 100644 index 0000000..e673f5e --- /dev/null +++ b/src/radix.cr @@ -0,0 +1 @@ +require "./radix/tree" diff --git a/src/radix/node.cr b/src/radix/node.cr new file mode 100644 index 0000000..54ab2e3 --- /dev/null +++ b/src/radix/node.cr @@ -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 diff --git a/src/radix/result.cr b/src/radix/result.cr new file mode 100644 index 0000000..8064d8e --- /dev/null +++ b/src/radix/result.cr @@ -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 diff --git a/src/radix/tree.cr b/src/radix/tree.cr new file mode 100644 index 0000000..b4ed6be --- /dev/null +++ b/src/radix/tree.cr @@ -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