├── spec ├── spec_helper.cr └── radix │ ├── version_spec.cr │ ├── result_spec.cr │ ├── node_spec.cr │ └── tree_spec.cr ├── src ├── radix.cr └── radix │ ├── version.cr │ ├── result.cr │ ├── node.cr │ └── tree.cr ├── shard.yml ├── .gitignore ├── Procfile.dev ├── docker-compose.yml ├── .github └── workflows │ ├── ci-nightly.yml │ └── ci.yml ├── LICENSE ├── README.md └── CHANGELOG.md /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/radix" 3 | -------------------------------------------------------------------------------- /src/radix.cr: -------------------------------------------------------------------------------- 1 | require "./radix/tree" 2 | require "./radix/version" 3 | -------------------------------------------------------------------------------- /src/radix/version.cr: -------------------------------------------------------------------------------- 1 | module Radix 2 | VERSION = "0.4.1" 3 | end 4 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: radix 2 | version: 0.4.1 3 | 4 | authors: 5 | - Luis Lavena 6 | 7 | license: MIT 8 | 9 | crystal: ">= 0.35.0" 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /lib/ 3 | /.crystal/ 4 | /.shards/ 5 | 6 | # Libraries don't need dependency lock 7 | # Dependencies will be locked in application that uses them 8 | /shard.lock 9 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | # watch for changes in src & spec and invoke `crystal spec`. Run only when changes are detected 2 | autospec: watchexec -w src -w spec -f '**/*.cr' --postpone --on-busy-update=do-nothing -- crystal spec 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | crystal: 3 | image: ghcr.io/luislavena/hydrofoil-crystal:1.2 4 | command: overmind start -f Procfile.dev 5 | working_dir: /app 6 | 7 | # Set these env variables using `export FIXUID=$(id -u) FIXGID=$(id -g)` 8 | user: ${FIXUID:-1000}:${FIXGID:-1000} 9 | 10 | volumes: 11 | - .:/app:cached 12 | -------------------------------------------------------------------------------- /spec/radix/version_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "yaml" 3 | 4 | describe "Radix::VERSION" do 5 | it "matches version defined in shard.yml" do 6 | contents = File.read(File.expand_path("../../../shard.yml", __FILE__)) 7 | meta = YAML.parse(contents) 8 | 9 | meta["version"]?.should_not be_falsey 10 | Radix::VERSION.should eq(meta["version"].as_s) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.github/workflows/ci-nightly.yml: -------------------------------------------------------------------------------- 1 | name: CI (nightly) 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 6 * * 1' # Every monday 6 AM 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - {os: ubuntu-latest, crystal: nightly} 16 | - {os: macos-latest, crystal: nightly} 17 | runs-on: ${{matrix.os}} 18 | steps: 19 | - uses: oprypin/install-crystal@v1 20 | with: 21 | crystal: ${{matrix.crystal}} 22 | - uses: actions/checkout@v2 23 | - run: crystal spec --error-on-warnings --error-trace 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - {os: ubuntu-latest, crystal: latest} 18 | - {os: macos-latest, crystal: latest} 19 | runs-on: ${{matrix.os}} 20 | steps: 21 | - uses: oprypin/install-crystal@v1 22 | with: 23 | crystal: ${{matrix.crystal}} 24 | - uses: actions/checkout@v2 25 | - run: crystal spec 26 | - run: crystal tool format --check src spec 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Luis Lavena 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/radix/result_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Radix 4 | describe Result do 5 | describe "#found?" do 6 | context "a new instance" do 7 | it "returns false when no payload is associated" do 8 | result = Result(Nil).new 9 | result.found?.should be_false 10 | end 11 | end 12 | 13 | context "with a payload" do 14 | it "returns true" do 15 | node = Node(Symbol).new("/", :root) 16 | result = Result(Symbol).new 17 | result.use node 18 | 19 | result.found?.should be_true 20 | end 21 | end 22 | end 23 | 24 | describe "#use" do 25 | it "uses the node payload" do 26 | node = Node(Symbol).new("/", :root) 27 | result = Result(Symbol).new 28 | result.payload?.should be_falsey 29 | 30 | result.use node 31 | result.payload?.should be_truthy 32 | result.payload.should eq(node.payload) 33 | end 34 | 35 | it "allow not to assign payload" do 36 | node = Node(Symbol).new("/", :root) 37 | result = Result(Symbol).new 38 | result.payload?.should be_falsey 39 | 40 | result.use node, payload: false 41 | result.payload?.should be_falsey 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /src/radix/result.cr: -------------------------------------------------------------------------------- 1 | require "./node" 2 | 3 | module Radix 4 | # Result present the output of walking our [Radix tree](https://en.wikipedia.org/wiki/Radix_tree) 5 | # `Radix::Tree` implementation. 6 | # 7 | # It provides helpers to retrieve the success (or failure) and the payload 8 | # obtained from walkin our tree using `Radix::Tree#find` 9 | # 10 | # This information can be used to perform actions in case of the *path* 11 | # that was looked on the Tree was found. 12 | # 13 | # A Result is also used recursively by `Radix::Tree#find` when collecting 14 | # extra information like *params*. 15 | class Result(T) 16 | @key : String? 17 | 18 | getter params 19 | getter! payload : T? 20 | 21 | # :nodoc: 22 | def initialize 23 | @params = {} of String => String 24 | end 25 | 26 | # Returns whatever a *payload* was found by `Tree#find` and is part of 27 | # the result. 28 | # 29 | # ``` 30 | # result = Radix::Result(Symbol).new 31 | # result.found? 32 | # # => false 33 | # 34 | # root = Radix::Node(Symbol).new("/", :root) 35 | # result.use(root) 36 | # result.found? 37 | # # => true 38 | # ``` 39 | def found? 40 | payload? ? true : false 41 | end 42 | 43 | # Adjust result information by using the details of the given `Node`. 44 | # 45 | # * Collect `Node` for future references. 46 | # * Use *payload* if present. 47 | def use(node : Node(T), payload = true) 48 | if payload && node.payload? 49 | @payload = node.payload 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Radix Tree 2 | 3 | [Radix tree](https://en.wikipedia.org/wiki/Radix_tree) implementation for 4 | Crystal language 5 | 6 | [![CI](https://github.com/luislavena/radix/workflows/CI/badge.svg)](https://github.com/luislavena/radix/actions) 7 | [![Latest Release](https://img.shields.io/github/release/luislavena/radix.svg)](https://github.com/luislavena/radix/releases) 8 | 9 | ## Installation 10 | 11 | Add this to your application's `shard.yml`: 12 | 13 | ```yaml 14 | dependencies: 15 | radix: 16 | github: luislavena/radix 17 | ``` 18 | 19 | ## Usage 20 | 21 | ### Building Trees 22 | 23 | You can associate a *payload* with each path added to the tree: 24 | 25 | ```crystal 26 | require "radix" 27 | 28 | tree = Radix::Tree(Symbol).new 29 | tree.add "/products", :products 30 | tree.add "/products/featured", :featured 31 | 32 | result = tree.find "/products/featured" 33 | 34 | if result.found? 35 | puts result.payload # => :featured 36 | end 37 | ``` 38 | 39 | The types allowed for payload are defined on Tree definition: 40 | 41 | ```crystal 42 | tree = Radix::Tree(Symbol).new 43 | 44 | # Good, since Symbol is allowed as payload 45 | tree.add "/", :root 46 | 47 | # Compilation error, Int32 is not allowed 48 | tree.add "/meaning-of-life", 42 49 | ``` 50 | 51 | Can combine multiple types if needed: 52 | 53 | ```crystal 54 | tree = Radix::Tree(Int32 | String | Symbol).new 55 | 56 | tree.add "/", :root 57 | tree.add "/meaning-of-life", 42 58 | tree.add "/hello", "world" 59 | ``` 60 | 61 | ### Lookup and placeholders 62 | 63 | You can also extract values from placeholders (as named segments or globbing): 64 | 65 | ```crystal 66 | tree.add "/products/:id", :product 67 | 68 | result = tree.find "/products/1234" 69 | 70 | if result.found? 71 | puts result.params["id"]? # => "1234" 72 | end 73 | ``` 74 | 75 | Please see `Radix::Tree#add` documentation for more usage examples. 76 | 77 | ## Caveats 78 | 79 | Pretty much all Radix implementations have their limitations and this project 80 | is no exception. 81 | 82 | When designing and adding *paths* to a Tree, please consider that two different 83 | named parameters cannot share the same level: 84 | 85 | ```crystal 86 | tree.add "/", :root 87 | tree.add "/:post", :post 88 | tree.add "/:category/:post", :category_post # => Radix::Tree::SharedKeyError 89 | ``` 90 | 91 | This is because different named parameters at the same level will result in 92 | incorrect `params` when lookup is performed, and sometimes the value for 93 | `post` or `category` parameters will not be stored as expected. 94 | 95 | To avoid this issue, usage of explicit keys that differentiate each path is 96 | recommended. 97 | 98 | For example, following a good SEO practice will be consider `/:post` as 99 | absolute permalink for the post and have a list of categories which links to 100 | the permalinks of the posts under that category: 101 | 102 | ```crystal 103 | tree.add "/", :root 104 | tree.add "/:post", :post # this is post permalink 105 | tree.add "/categories", :categories # list of categories 106 | tree.add "/categories/:category", :category # listing of posts under each category 107 | ``` 108 | 109 | ## Implementation 110 | 111 | This project has been inspired and adapted from 112 | [julienschmidt/httprouter](https://github.com/julienschmidt/httprouter) and 113 | [spriet2000/vertx-http-router](https://github.com/spriet2000/vertx-http-router) 114 | Go and Java implementations, respectively. 115 | 116 | Changes to logic and optimizations have been made to take advantage of 117 | Crystal's features. 118 | 119 | ## Contributing 120 | 121 | 1. Fork it ( https://github.com/luislavena/radix/fork ) 122 | 2. Create your feature branch (`git checkout -b my-new-feature`) 123 | 3. Commit your changes (`git commit -am 'Add some feature'`) 124 | 4. Push to the branch (`git push origin my-new-feature`) 125 | 5. Create a new Pull Request 126 | 127 | ## Contributors 128 | 129 | - [Luis Lavena](https://github.com/luislavena) - creator, maintainer 130 | -------------------------------------------------------------------------------- /spec/radix/node_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Radix 4 | describe Node do 5 | describe "#glob?" do 6 | it "returns true when key contains a glob parameter (catch all)" do 7 | node = Node(Nil).new("a") 8 | node.glob?.should be_false 9 | 10 | node = Node(Nil).new("*filepath") 11 | node.glob?.should be_true 12 | end 13 | end 14 | 15 | describe "#key=" do 16 | it "accepts change of key after initialization" do 17 | node = Node(Nil).new("abc") 18 | node.key.should eq("abc") 19 | 20 | node.key = "xyz" 21 | node.key.should eq("xyz") 22 | end 23 | 24 | it "also changes kind when modified" do 25 | node = Node(Nil).new("abc") 26 | node.normal?.should be_true 27 | 28 | node.key = ":query" 29 | node.normal?.should be_false 30 | node.named?.should be_true 31 | end 32 | end 33 | 34 | describe "#named?" do 35 | it "returns true when key contains a named parameter" do 36 | node = Node(Nil).new("a") 37 | node.named?.should be_false 38 | 39 | node = Node(Nil).new(":query") 40 | node.named?.should be_true 41 | end 42 | end 43 | 44 | describe "#normal?" do 45 | it "returns true when key does not contain named or glob parameters" do 46 | node = Node(Nil).new("a") 47 | node.normal?.should be_true 48 | 49 | node = Node(Nil).new(":query") 50 | node.normal?.should be_false 51 | 52 | node = Node(Nil).new("*filepath") 53 | node.normal?.should be_false 54 | end 55 | end 56 | 57 | describe "#payload" do 58 | it "accepts any form of payload" do 59 | node = Node.new("abc", :payload) 60 | node.payload?.should be_truthy 61 | node.payload.should eq(:payload) 62 | 63 | node = Node.new("abc", 1_000) 64 | node.payload?.should be_truthy 65 | node.payload.should eq(1_000) 66 | end 67 | 68 | # This example focuses on the internal representation of `payload` 69 | # as inferred from supplied types and default values. 70 | # 71 | # We cannot compare `typeof` against `property!` since it excludes `Nil` 72 | # from the possible types. 73 | it "makes optional to provide a payload" do 74 | node = Node(Int32).new("abc") 75 | node.payload?.should be_falsey 76 | typeof(node.@payload).should eq(Int32 | Nil) 77 | end 78 | end 79 | 80 | describe "#priority" do 81 | it "calculates it based on key length" do 82 | node = Node(Nil).new("a") 83 | node.priority.should eq(1) 84 | 85 | node = Node(Nil).new("abc") 86 | node.priority.should eq(3) 87 | end 88 | 89 | it "considers key length up until named parameter presence" do 90 | node = Node(Nil).new("/posts/:id") 91 | node.priority.should eq(7) 92 | 93 | node = Node(Nil).new("/u/:username") 94 | node.priority.should eq(3) 95 | end 96 | 97 | it "considers key length up until glob parameter presence" do 98 | node = Node(Nil).new("/search/*query") 99 | node.priority.should eq(8) 100 | 101 | node = Node(Nil).new("/*anything") 102 | node.priority.should eq(1) 103 | end 104 | 105 | it "changes when key changes" do 106 | node = Node(Nil).new("a") 107 | node.priority.should eq(1) 108 | 109 | node.key = "abc" 110 | node.priority.should eq(3) 111 | 112 | node.key = "/src/*filepath" 113 | node.priority.should eq(5) 114 | 115 | node.key = "/search/:query" 116 | node.priority.should eq(8) 117 | end 118 | end 119 | 120 | describe "#sort!" do 121 | it "orders children" do 122 | root = Node(Int32).new("/") 123 | node1 = Node(Int32).new("a", 1) 124 | node2 = Node(Int32).new("bc", 2) 125 | node3 = Node(Int32).new("def", 3) 126 | 127 | root.children.push(node1, node2, node3) 128 | root.sort! 129 | 130 | root.children[0].should eq(node3) 131 | root.children[1].should eq(node2) 132 | root.children[2].should eq(node1) 133 | end 134 | 135 | it "orders catch all and named parameters lower than normal nodes" do 136 | root = Node(Int32).new("/") 137 | node1 = Node(Int32).new("*filepath", 1) 138 | node2 = Node(Int32).new("abc", 2) 139 | node3 = Node(Int32).new(":query", 3) 140 | 141 | root.children.push(node1, node2, node3) 142 | root.sort! 143 | 144 | root.children[0].should eq(node2) 145 | root.children[1].should eq(node3) 146 | root.children[2].should eq(node1) 147 | end 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to Radix project will be documented in this file. 4 | This project aims to comply with [Semantic Versioning](http://semver.org/), 5 | so please check *Changed* and *Removed* notes before upgrading. 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.4.1] - 2021-03-23 10 | ### Fixed 11 | - Indicate minimum Crystal version (for 1.0 compatibility) [#32](https://github.com/luislavena/radix/pull/32) (@carlhoerberg) 12 | 13 | ## [0.4.0] - 2021-01-31 14 | ### Fixed 15 | - Correct lookup issue caused by partial shared key with glob [#23](https://github.com/luislavena/radix/issues/23) 16 | - Correct lookup caused by non-root key in suffix [#27](https://github.com/luislavena/radix/issues/27) 17 | 18 | ### Removed 19 | - Remove `Radix::Result#key` since exposes internal details about structure (**breaking change**) 20 | 21 | ## [0.3.9] - 2019-01-02 22 | ### Fixed 23 | - Correct catch-all issue caused when paths differ [#26](https://github.com/luislavena/radix/pull/26) (@silasb) 24 | 25 | ## [0.3.8] - 2017-03-12 26 | ### Fixed 27 | - Correct lookup issue caused by incorrect comparison of shared key [#21](https://github.com/luislavena/radix/issues/21) 28 | - Improve support for non-ascii keys in a tree. 29 | 30 | ## [0.3.7] - 2017-02-04 31 | ### Fixed 32 | - Correct prioritization of node's children using combination of kind and 33 | priority, allowing partial shared keys to coexist and resolve lookup. 34 | 35 | ## [0.3.6] - 2017-01-18 36 | ### Fixed 37 | - Correct lookup issue caused by similar priority between named paramter and 38 | shared partial key [kemalcr/kemal#293](https://github.com/kemalcr/kemal/issues/293) 39 | 40 | ## [0.3.5] - 2016-11-24 41 | ### Fixed 42 | - Correct lookup issue when dealing with catch all and shared partial key (@crisward) 43 | 44 | ## [0.3.4] - 2016-11-12 45 | ### Fixed 46 | - Ensure catch all parameter can be used as optional globbing (@jwoertink) 47 | 48 | ## [0.3.3] - 2016-11-12 [YANKED] 49 | ### Fixed 50 | - Ensure catch all parameter can be used as optional globbing (@jwoertink) 51 | 52 | ## [0.3.2] - 2016-11-05 53 | ### Fixed 54 | - Do not force adding paths with shared named parameter in an specific order (@jwoertink) 55 | - Give proper name to `Radix::VERSION` spec when running in verbose mode. 56 | - Ensure code samples in docs can be executed. 57 | 58 | ## [0.3.1] - 2016-07-29 59 | ### Added 60 | - Introduce `Radix::VERSION` so library version can be used at runtime. 61 | 62 | ## [0.3.0] - 2016-04-16 63 | ### Fixed 64 | - Improve forward compatibility with newer versions of the compiler by adding 65 | missing types to solve type inference errors. 66 | 67 | ### Changed 68 | - `Radix::Tree` now requires the usage of a type which will be used as node's 69 | payload. See [README](README.md) for details. 70 | 71 | ## [0.2.1] - 2016-03-15 72 | ### Fixed 73 | - Correct `Result#key` incorrect inferred type. 74 | 75 | ### Removed 76 | - Attempt to use two named parameters at the same level will raise 77 | `Radix::Tree::SharedKeyError` 78 | 79 | ## [0.2.0] - 2016-03-15 [YANKED] 80 | ### Removed 81 | - Attempt to use two named parameters at the same level will raise 82 | `Radix::Tree::SharedKeyError` 83 | 84 | ## [0.1.2] - 2016-03-10 85 | ### Fixed 86 | - No longer split named parameters that share same level (@alsm) 87 | 88 | ### Changed 89 | - Attempt to use two named parameters at same level will display a 90 | deprecation warning. Future versions will raise `Radix::Tree::SharedKeyError` 91 | 92 | ## [0.1.1] - 2016-02-29 93 | ### Fixed 94 | - Fix named parameter key names extraction. 95 | 96 | ## [0.1.0] - 2016-01-24 97 | ### Added 98 | - Initial release based on code extracted from Beryl. 99 | 100 | [Unreleased]: https://github.com/luislavena/radix/compare/v0.4.1...HEAD 101 | [0.4.1]: https://github.com/luislavena/radix/compare/v0.4.0...v0.4.1 102 | [0.4.0]: https://github.com/luislavena/radix/compare/v0.3.9...v0.4.0 103 | [0.3.9]: https://github.com/luislavena/radix/compare/v0.3.8...v0.3.9 104 | [0.3.8]: https://github.com/luislavena/radix/compare/v0.3.7...v0.3.8 105 | [0.3.7]: https://github.com/luislavena/radix/compare/v0.3.6...v0.3.7 106 | [0.3.6]: https://github.com/luislavena/radix/compare/v0.3.5...v0.3.6 107 | [0.3.5]: https://github.com/luislavena/radix/compare/v0.3.4...v0.3.5 108 | [0.3.4]: https://github.com/luislavena/radix/compare/v0.3.3...v0.3.4 109 | [0.3.3]: https://github.com/luislavena/radix/compare/v0.3.2...v0.3.3 110 | [0.3.2]: https://github.com/luislavena/radix/compare/v0.3.1...v0.3.2 111 | [0.3.1]: https://github.com/luislavena/radix/compare/v0.3.0...v0.3.1 112 | [0.3.0]: https://github.com/luislavena/radix/compare/v0.2.1...v0.3.0 113 | [0.2.1]: https://github.com/luislavena/radix/compare/v0.2.0...v0.2.1 114 | [0.2.0]: https://github.com/luislavena/radix/compare/v0.1.2...v0.2.0 115 | [0.1.2]: https://github.com/luislavena/radix/compare/v0.1.1...v0.1.2 116 | [0.1.1]: https://github.com/luislavena/radix/compare/v0.1.0...v0.1.1 117 | -------------------------------------------------------------------------------- /src/radix/node.cr: -------------------------------------------------------------------------------- 1 | module Radix 2 | # A Node represents one element in the structure of a [Radix tree](https://en.wikipedia.org/wiki/Radix_tree) 3 | # 4 | # Carries a *payload* and might also contain references to other nodes 5 | # down in the organization inside *children*. 6 | # 7 | # Each node also carries identification in relation to the kind of key it 8 | # contains, which helps with characteristics of the node like named 9 | # parameters or catch all kind (globbing). 10 | # 11 | # Is not expected direct usage of a node but instead manipulation via 12 | # methods within `Tree`. 13 | class Node(T) 14 | include Comparable(self) 15 | 16 | # :nodoc: 17 | enum Kind : UInt8 18 | Normal 19 | Named 20 | Glob 21 | end 22 | 23 | getter key 24 | getter? placeholder 25 | property children = [] of Node(T) 26 | property! payload : T | Nil 27 | 28 | # :nodoc: 29 | protected getter kind = Kind::Normal 30 | 31 | # Returns the priority of the Node based on it's *key* 32 | # 33 | # This value will be directly associated to the key size up until a 34 | # special elements is found. 35 | # 36 | # ``` 37 | # Radix::Node(Nil).new("a").priority 38 | # # => 1 39 | # 40 | # Radix::Node(Nil).new("abc").priority 41 | # # => 3 42 | # 43 | # Radix::Node(Nil).new("/src/*filepath").priority 44 | # # => 5 45 | # 46 | # Radix::Node(Nil).new("/search/:query").priority 47 | # # => 8 48 | # ``` 49 | getter priority : Int32 50 | 51 | # Instantiate a Node 52 | # 53 | # - *key* - A `String` that represents this node. 54 | # - *payload* - An optional payload for this node. 55 | # 56 | # When *payload* is not supplied, ensure the type of the node is provided 57 | # instead: 58 | # 59 | # ``` 60 | # # Good, node type is inferred from payload (Symbol) 61 | # node = Radix::Node.new("/", :root) 62 | # 63 | # # Good, node type is now Int32 but payload is optional 64 | # node = Radix::Node(Int32).new("/") 65 | # 66 | # # Error, node type cannot be inferred (compiler error) 67 | # node = Radix::Node.new("/") 68 | # ``` 69 | def initialize(@key : String, @payload : T? = nil, @placeholder = false) 70 | @priority = compute_priority 71 | end 72 | 73 | # Compares this node against *other*, returning `-1`, `0` or `1` depending 74 | # on whether this node differentiates from *other*. 75 | # 76 | # Comparison is done combining node's `kind` and `priority`. Nodes of 77 | # same kind are compared by priority. Nodes of different kind are 78 | # ranked. 79 | # 80 | # ### Normal nodes 81 | # 82 | # ``` 83 | # node1 = Radix::Node(Nil).new("a") # normal 84 | # node2 = Radix::Node(Nil).new("bc") # normal 85 | # node1 <=> node2 # => 1 86 | # ``` 87 | # 88 | # ### Normal vs named or glob nodes 89 | # 90 | # ``` 91 | # node1 = Radix::Node(Nil).new("a") # normal 92 | # node2 = Radix::Node(Nil).new(":query") # named 93 | # node3 = Radix::Node(Nil).new("*filepath") # glob 94 | # node1 <=> node2 # => -1 95 | # node1 <=> node3 # => -1 96 | # ``` 97 | # 98 | # ### Named vs glob nodes 99 | # 100 | # ``` 101 | # node1 = Radix::Node(Nil).new(":query") # named 102 | # node2 = Radix::Node(Nil).new("*filepath") # glob 103 | # node1 <=> node2 # => -1 104 | # ``` 105 | def <=>(other : self) 106 | result = kind <=> other.kind 107 | return result if result != 0 108 | 109 | other.priority <=> priority 110 | end 111 | 112 | # Returns `true` if the node key contains a glob parameter in it 113 | # (catch all) 114 | # 115 | # ``` 116 | # node = Radix::Node(Nil).new("*filepath") 117 | # node.glob? # => true 118 | # 119 | # node = Radix::Node(Nil).new("abc") 120 | # node.glob? # => false 121 | # ``` 122 | def glob? 123 | kind.glob? 124 | end 125 | 126 | # Changes current *key* 127 | # 128 | # ``` 129 | # node = Radix::Node(Nil).new("a") 130 | # node.key 131 | # # => "a" 132 | # 133 | # node.key = "b" 134 | # node.key 135 | # # => "b" 136 | # ``` 137 | # 138 | # This will also result in change of node's `priority` 139 | # 140 | # ``` 141 | # node = Radix::Node(Nil).new("a") 142 | # node.priority 143 | # # => 1 144 | # 145 | # node.key = "abcdef" 146 | # node.priority 147 | # # => 6 148 | # ``` 149 | def key=(@key) 150 | # reset kind on change of key 151 | @kind = Kind::Normal 152 | @priority = compute_priority 153 | end 154 | 155 | # Returns `true` if the node key contains a named parameter in it 156 | # 157 | # ``` 158 | # node = Radix::Node(Nil).new(":query") 159 | # node.named? # => true 160 | # 161 | # node = Radix::Node(Nil).new("abc") 162 | # node.named? # => false 163 | # ``` 164 | def named? 165 | kind.named? 166 | end 167 | 168 | # Returns `true` if the node key does not contain an special parameter 169 | # (named or glob) 170 | # 171 | # ``` 172 | # node = Radix::Node(Nil).new("a") 173 | # node.normal? # => true 174 | # 175 | # node = Radix::Node(Nil).new(":query") 176 | # node.normal? # => false 177 | # ``` 178 | def normal? 179 | kind.normal? 180 | end 181 | 182 | # :nodoc: 183 | private def compute_priority 184 | reader = Char::Reader.new(@key) 185 | 186 | while reader.has_next? 187 | case reader.current_char 188 | when '*' 189 | @kind = Kind::Glob 190 | break 191 | when ':' 192 | @kind = Kind::Named 193 | break 194 | else 195 | reader.next_char 196 | end 197 | end 198 | 199 | reader.pos 200 | end 201 | 202 | # :nodoc: 203 | protected def sort! 204 | @children.sort! 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /src/radix/tree.cr: -------------------------------------------------------------------------------- 1 | require "./node" 2 | require "./result" 3 | 4 | module Radix 5 | # A [Radix tree](https://en.wikipedia.org/wiki/Radix_tree) implementation. 6 | # 7 | # It allows insertion of *path* elements that will be organized inside 8 | # the tree aiming to provide fast retrieval options. 9 | # 10 | # Each inserted *path* will be represented by a `Node` or segmented and 11 | # distributed within the `Tree`. 12 | # 13 | # You can associate a *payload* at insertion which will be return back 14 | # at retrieval time. 15 | class Tree(T) 16 | # :nodoc: 17 | class DuplicateError < Exception 18 | def initialize(path) 19 | super("Duplicate trail found '#{path}'") 20 | end 21 | end 22 | 23 | # :nodoc: 24 | class SharedKeyError < Exception 25 | def initialize(new_key, existing_key) 26 | super("Tried to place key '#{new_key}' at same level as '#{existing_key}'") 27 | end 28 | end 29 | 30 | # Returns the root `Node` element of the Tree. 31 | # 32 | # On a new tree instance, this will be a placeholder. 33 | getter root : Node(T) 34 | 35 | def initialize 36 | @root = Node(T).new("", placeholder: true) 37 | end 38 | 39 | # Inserts given *path* into the Tree 40 | # 41 | # * *path* - An `String` representing the pattern to be inserted. 42 | # * *payload* - Required associated element for this path. 43 | # 44 | # If no previous elements existed in the Tree, this will replace the 45 | # defined placeholder. 46 | # 47 | # ``` 48 | # tree = Radix::Tree(Symbol).new 49 | # 50 | # # / (:root) 51 | # tree.add "/", :root 52 | # 53 | # # / (:root) 54 | # # \-abc (:abc) 55 | # tree.add "/abc", :abc 56 | # 57 | # # / (:root) 58 | # # \-abc (:abc) 59 | # # \-xyz (:xyz) 60 | # tree.add "/abcxyz", :xyz 61 | # ``` 62 | # 63 | # Nodes inside the tree will be adjusted to accommodate the different 64 | # segments of the given *path*. 65 | # 66 | # ``` 67 | # tree = Radix::Tree(Symbol).new 68 | # 69 | # # / (:root) 70 | # tree.add "/", :root 71 | # 72 | # # / (:root) 73 | # # \-products/:id (:product) 74 | # tree.add "/products/:id", :product 75 | # 76 | # # / (:root) 77 | # # \-products/ 78 | # # +-featured (:featured) 79 | # # \-:id (:product) 80 | # tree.add "/products/featured", :featured 81 | # ``` 82 | # 83 | # Catch all (globbing) and named parameters *path* will be located with 84 | # lower priority against other nodes. 85 | # 86 | # ``` 87 | # tree = Radix::Tree(Symbol).new 88 | # 89 | # # / (:root) 90 | # tree.add "/", :root 91 | # 92 | # # / (:root) 93 | # # \-*filepath (:all) 94 | # tree.add "/*filepath", :all 95 | # 96 | # # / (:root) 97 | # # +-about (:about) 98 | # # \-*filepath (:all) 99 | # tree.add "/about", :about 100 | # ``` 101 | def add(path : String, payload : T) 102 | root = @root 103 | 104 | # replace placeholder with new node 105 | if root.placeholder? 106 | @root = Node(T).new(path, payload) 107 | else 108 | add path, payload, root 109 | end 110 | end 111 | 112 | # :nodoc: 113 | private def add(path : String, payload : T, node : Node(T)) 114 | key_reader = Char::Reader.new(node.key) 115 | path_reader = Char::Reader.new(path) 116 | 117 | # move cursor position to last shared character between key and path 118 | while path_reader.has_next? && key_reader.has_next? 119 | break if path_reader.current_char != key_reader.current_char 120 | 121 | path_reader.next_char 122 | key_reader.next_char 123 | end 124 | 125 | # determine split point difference between path and key 126 | # compare if path is larger than key 127 | if path_reader.pos == 0 || 128 | (path_reader.pos < path.bytesize && path_reader.pos >= node.key.bytesize) 129 | # determine if a child of this node contains the remaining part 130 | # of the path 131 | added = false 132 | 133 | new_key = path_reader.string.byte_slice(path_reader.pos) 134 | node.children.each do |child| 135 | # if child's key starts with named parameter, compare key until 136 | # separator (if present). 137 | # Otherwise, compare just first character 138 | if child.key[0]? == ':' && new_key[0]? == ':' 139 | unless _same_key?(new_key, child.key) 140 | raise SharedKeyError.new(new_key, child.key) 141 | end 142 | else 143 | next unless child.key[0]? == new_key[0]? 144 | end 145 | 146 | # when found, add to this child 147 | added = true 148 | add new_key, payload, child 149 | break 150 | end 151 | 152 | # if no existing child shared part of the key, add a new one 153 | unless added 154 | node.children << Node(T).new(new_key, payload) 155 | end 156 | 157 | # adjust priorities 158 | node.sort! 159 | elsif path_reader.pos == path.bytesize && path_reader.pos == node.key.bytesize 160 | # determine if path matches key and potentially be a duplicate 161 | # and raise if is the case 162 | 163 | if node.payload? 164 | raise DuplicateError.new(path) 165 | else 166 | # assign payload since this is an empty node 167 | node.payload = payload 168 | end 169 | elsif path_reader.pos > 0 && path_reader.pos < node.key.bytesize 170 | # determine if current node key needs to be split to accomodate new 171 | # children nodes 172 | 173 | # build new node with partial key and adjust existing one 174 | new_key = node.key.byte_slice(path_reader.pos) 175 | swap_payload = node.payload? ? node.payload : nil 176 | 177 | new_node = Node(T).new(new_key, swap_payload) 178 | new_node.children.replace(node.children) 179 | 180 | # clear payload and children (this is no longer and endpoint) 181 | node.payload = nil 182 | node.children.clear 183 | 184 | # adjust existing node key to new partial one 185 | node.key = path_reader.string.byte_slice(0, path_reader.pos) 186 | node.children << new_node 187 | node.sort! 188 | 189 | # determine if path still continues 190 | if path_reader.pos < path.bytesize 191 | new_key = path.byte_slice(path_reader.pos) 192 | node.children << Node(T).new(new_key, payload) 193 | node.sort! 194 | 195 | # clear payload (no endpoint) 196 | node.payload = nil 197 | else 198 | # this is an endpoint, set payload 199 | node.payload = payload 200 | end 201 | end 202 | end 203 | 204 | # Returns a `Result` instance after walking the tree looking up for 205 | # *path* 206 | # 207 | # It will start walking the tree from the root node until a matching 208 | # endpoint is found (or not). 209 | # 210 | # ``` 211 | # tree = Radix::Tree(Symbol).new 212 | # tree.add "/about", :about 213 | # 214 | # result = tree.find "/products" 215 | # result.found? 216 | # # => false 217 | # 218 | # result = tree.find "/about" 219 | # result.found? 220 | # # => true 221 | # 222 | # result.payload 223 | # # => :about 224 | # ``` 225 | def find(path : String) 226 | result = Result(T).new 227 | root = @root 228 | 229 | # walk the tree from root (first time) 230 | find path, result, root, first: true 231 | 232 | result 233 | end 234 | 235 | # :nodoc: 236 | private def find(path : String, result : Result, node : Node, first = false) 237 | # special consideration when comparing the first node vs. others 238 | # in case of node key and path being the same, return the node 239 | # instead of walking character by character 240 | if first && (path.bytesize == node.key.bytesize && path == node.key) && node.payload? 241 | result.use node 242 | return 243 | end 244 | 245 | key_reader = Char::Reader.new(node.key) 246 | path_reader = Char::Reader.new(path) 247 | 248 | # walk both path and key while both have characters and they continue 249 | # to match. Consider as special cases named parameters and catch all 250 | # rules. 251 | while key_reader.has_next? && path_reader.has_next? && 252 | (key_reader.current_char == '*' || 253 | key_reader.current_char == ':' || 254 | path_reader.current_char == key_reader.current_char) 255 | case key_reader.current_char 256 | when '*' 257 | # deal with catch all (globbing) parameter 258 | # extract parameter name from key (exclude *) and value from path 259 | name = key_reader.string.byte_slice(key_reader.pos + 1) 260 | value = path_reader.string.byte_slice(path_reader.pos) 261 | 262 | # add this to result 263 | result.params[name] = value 264 | 265 | result.use node 266 | return 267 | when ':' 268 | # deal with named parameter 269 | # extract parameter name from key (from : until / or EOL) and 270 | # value from path (same rules as key) 271 | key_size = _detect_param_size(key_reader) 272 | path_size = _detect_param_size(path_reader) 273 | 274 | # obtain key and value using calculated sizes 275 | # for name: skip ':' by moving one character forward and compensate 276 | # key size. 277 | name = key_reader.string.byte_slice(key_reader.pos + 1, key_size - 1) 278 | value = path_reader.string.byte_slice(path_reader.pos, path_size) 279 | 280 | # add this information to result 281 | result.params[name] = value 282 | 283 | # advance readers positions 284 | key_reader.pos += key_size 285 | path_reader.pos += path_size 286 | else 287 | # move to the next character 288 | key_reader.next_char 289 | path_reader.next_char 290 | end 291 | end 292 | 293 | # check if we reached the end of the path & key 294 | if !path_reader.has_next? && !key_reader.has_next? 295 | # check endpoint 296 | if node.payload? 297 | result.use node 298 | return 299 | end 300 | end 301 | 302 | # determine if remaining part of key and path are still the same 303 | if (key_reader.has_next? && path_reader.has_next?) && 304 | (key_reader.current_char != path_reader.current_char || 305 | key_reader.peek_next_char != path_reader.peek_next_char) 306 | # path and key differ, skipping 307 | return 308 | end 309 | 310 | # still path to walk, check for possible trailing slash or children 311 | # nodes 312 | if path_reader.has_next? 313 | # using trailing slash? 314 | if node.key.bytesize > 0 && 315 | path_reader.pos + 1 == path.bytesize && 316 | path_reader.current_char == '/' 317 | result.use node 318 | return 319 | end 320 | 321 | # not found in current node, check inside children nodes 322 | new_path = path_reader.string.byte_slice(path_reader.pos) 323 | node.children.each do |child| 324 | # check if child key is a named parameter, catch all or shares parts 325 | # with new path 326 | if (child.glob? || child.named?) || _shared_key?(new_path, child.key) 327 | # traverse branch to determine if valid 328 | find new_path, result, child 329 | 330 | if result.found? 331 | # stop iterating over nodes 332 | return 333 | else 334 | # move to next child 335 | next 336 | end 337 | end 338 | end 339 | 340 | # path differs from key, no use searching anymore 341 | return 342 | end 343 | 344 | # key still contains characters to walk 345 | if key_reader.has_next? 346 | # determine if there is just a trailing slash? 347 | if key_reader.pos + 1 == node.key.bytesize && 348 | key_reader.current_char == '/' 349 | result.use node 350 | return 351 | end 352 | 353 | # check if remaining part is catch all 354 | if key_reader.pos < node.key.bytesize && 355 | ((key_reader.current_char == '/' && key_reader.peek_next_char == '*') || 356 | key_reader.current_char == '*') 357 | # skip to '*' only if necessary 358 | unless key_reader.current_char == '*' 359 | key_reader.next_char 360 | end 361 | 362 | # deal with catch all, but since there is nothing in the path 363 | # return parameter as empty 364 | name = key_reader.string.byte_slice(key_reader.pos + 1) 365 | 366 | result.params[name] = "" 367 | 368 | result.use node 369 | return 370 | end 371 | end 372 | end 373 | 374 | # :nodoc: 375 | private def _detect_param_size(reader) 376 | # save old position 377 | old_pos = reader.pos 378 | 379 | # move forward until '/' or EOL is detected 380 | while reader.has_next? 381 | break if reader.current_char == '/' 382 | 383 | reader.next_char 384 | end 385 | 386 | # calculate the size 387 | count = reader.pos - old_pos 388 | 389 | # restore old position 390 | reader.pos = old_pos 391 | 392 | count 393 | end 394 | 395 | # Internal: allow inline comparison of *char* against 3 defined markers: 396 | # 397 | # - Path separator (`/`) 398 | # - Named parameter (`:`) 399 | # - Catch all (`*`) 400 | @[AlwaysInline] 401 | private def _check_markers(char) 402 | (char == '/' || char == ':' || char == '*') 403 | end 404 | 405 | # Internal: Compares *path* against *key* for differences until the 406 | # following criteria is met: 407 | # 408 | # - End of *path* or *key* is reached. 409 | # - A separator (`/`) is found. 410 | # - A character between *path* or *key* differs 411 | # 412 | # ``` 413 | # _same_key?("foo", "bar") # => false (mismatch at 1st character) 414 | # _same_key?("foo/bar", "foo/baz") # => true (only `foo` is compared) 415 | # _same_key?("zipcode", "zip") # => false (`zip` is shorter) 416 | # ``` 417 | private def _same_key?(path, key) 418 | path_reader = Char::Reader.new(path) 419 | key_reader = Char::Reader.new(key) 420 | 421 | different = false 422 | 423 | while (path_reader.has_next? && path_reader.current_char != '/') && 424 | (key_reader.has_next? && key_reader.current_char != '/') 425 | if path_reader.current_char != key_reader.current_char 426 | different = true 427 | break 428 | end 429 | 430 | path_reader.next_char 431 | key_reader.next_char 432 | end 433 | 434 | (!different) && 435 | (path_reader.current_char == '/' || !path_reader.has_next?) 436 | end 437 | 438 | # Internal: Compares *path* against *key* for equality until one of the 439 | # following criterias is met: 440 | # 441 | # - End of *path* or *key* is reached. 442 | # - A separator (`/`) is found. 443 | # - A named parameter (`:`) or catch all (`*`) is found. 444 | # - A character in *path* differs from *key* 445 | # 446 | # ``` 447 | # _shared_key?("foo", "bar") # => false (mismatch at 1st character) 448 | # _shared_key?("foo/bar", "foo/baz") # => true (only `foo` is compared) 449 | # _shared_key?("zipcode", "zip") # => true (only `zip` is compared) 450 | # _shared_key?("s", "/new") # => false (1st character is a separator) 451 | # ``` 452 | private def _shared_key?(path, key) 453 | path_reader = Char::Reader.new(path) 454 | key_reader = Char::Reader.new(key) 455 | 456 | if (path_reader.current_char != key_reader.current_char) && 457 | _check_markers(key_reader.current_char) 458 | return false 459 | end 460 | 461 | different = false 462 | 463 | while (path_reader.has_next? && !_check_markers(path_reader.current_char)) && 464 | (key_reader.has_next? && !_check_markers(key_reader.current_char)) 465 | if path_reader.current_char != key_reader.current_char 466 | different = true 467 | break 468 | end 469 | 470 | path_reader.next_char 471 | key_reader.next_char 472 | end 473 | 474 | (!different) && 475 | (!key_reader.has_next? || _check_markers(key_reader.current_char)) 476 | end 477 | end 478 | end 479 | -------------------------------------------------------------------------------- /spec/radix/tree_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | # Simple Payload class 4 | record Payload 5 | 6 | module Radix 7 | describe Tree do 8 | context "a new instance" do 9 | it "contains a root placeholder node" do 10 | tree = Tree(Symbol).new 11 | tree.root.should be_a(Node(Symbol)) 12 | tree.root.payload?.should be_falsey 13 | tree.root.placeholder?.should be_true 14 | end 15 | end 16 | 17 | describe "#add" do 18 | context "on a new instance" do 19 | it "replaces placeholder with new node" do 20 | tree = Tree(Symbol).new 21 | tree.add "/abc", :abc 22 | tree.root.should be_a(Node(Symbol)) 23 | tree.root.placeholder?.should be_false 24 | tree.root.payload?.should be_truthy 25 | tree.root.payload.should eq(:abc) 26 | end 27 | end 28 | 29 | context "shared root" do 30 | it "inserts properly adjacent nodes" do 31 | tree = Tree(Symbol).new 32 | tree.add "/", :root 33 | tree.add "/a", :a 34 | tree.add "/bc", :bc 35 | 36 | # / (:root) 37 | # +-bc (:bc) 38 | # \-a (:a) 39 | tree.root.children.size.should eq(2) 40 | tree.root.children[0].key.should eq("bc") 41 | tree.root.children[0].payload.should eq(:bc) 42 | tree.root.children[1].key.should eq("a") 43 | tree.root.children[1].payload.should eq(:a) 44 | end 45 | 46 | it "inserts nodes with shared parent" do 47 | tree = Tree(Symbol).new 48 | tree.add "/", :root 49 | tree.add "/abc", :abc 50 | tree.add "/axyz", :axyz 51 | 52 | # / (:root) 53 | # +-a 54 | # +-xyz (:axyz) 55 | # \-bc (:abc) 56 | tree.root.children.size.should eq(1) 57 | tree.root.children[0].key.should eq("a") 58 | tree.root.children[0].children.size.should eq(2) 59 | tree.root.children[0].children[0].key.should eq("xyz") 60 | tree.root.children[0].children[1].key.should eq("bc") 61 | end 62 | 63 | it "inserts multiple parent nodes" do 64 | tree = Tree(Symbol).new 65 | tree.add "/", :root 66 | tree.add "/admin/users", :users 67 | tree.add "/admin/products", :products 68 | tree.add "/blog/tags", :tags 69 | tree.add "/blog/articles", :articles 70 | 71 | # / (:root) 72 | # +-admin/ 73 | # | +-products (:products) 74 | # | \-users (:users) 75 | # | 76 | # +-blog/ 77 | # +-articles (:articles) 78 | # \-tags (:tags) 79 | tree.root.children.size.should eq(2) 80 | tree.root.children[0].key.should eq("admin/") 81 | tree.root.children[0].payload?.should be_falsey 82 | tree.root.children[0].children[0].key.should eq("products") 83 | tree.root.children[0].children[1].key.should eq("users") 84 | tree.root.children[1].key.should eq("blog/") 85 | tree.root.children[1].payload?.should be_falsey 86 | tree.root.children[1].children[0].key.should eq("articles") 87 | tree.root.children[1].children[0].payload?.should be_truthy 88 | tree.root.children[1].children[1].key.should eq("tags") 89 | tree.root.children[1].children[1].payload?.should be_truthy 90 | end 91 | 92 | it "inserts multiple nodes with mixed parents" do 93 | tree = Tree(Symbol).new 94 | tree.add "/authorizations", :authorizations 95 | tree.add "/authorizations/:id", :authorization 96 | tree.add "/applications", :applications 97 | tree.add "/events", :events 98 | 99 | # / 100 | # +-events (:events) 101 | # +-a 102 | # +-uthorizations (:authorizations) 103 | # | \-/:id (:authorization) 104 | # \-pplications (:applications) 105 | tree.root.children.size.should eq(2) 106 | tree.root.children[1].key.should eq("a") 107 | tree.root.children[1].children.size.should eq(2) 108 | tree.root.children[1].children[0].payload.should eq(:authorizations) 109 | tree.root.children[1].children[1].payload.should eq(:applications) 110 | end 111 | 112 | it "supports insertion of mixed routes out of order" do 113 | tree = Tree(Symbol).new 114 | tree.add "/user/repos", :my_repos 115 | tree.add "/users/:user/repos", :user_repos 116 | tree.add "/users/:user", :user 117 | tree.add "/user", :me 118 | 119 | # /user (:me) 120 | # +-/repos (:my_repos) 121 | # \-s/:user (:user) 122 | # \-/repos (:user_repos) 123 | tree.root.key.should eq("/user") 124 | tree.root.payload?.should be_truthy 125 | tree.root.payload.should eq(:me) 126 | tree.root.children.size.should eq(2) 127 | tree.root.children[0].key.should eq("/repos") 128 | tree.root.children[1].key.should eq("s/:user") 129 | tree.root.children[1].payload.should eq(:user) 130 | tree.root.children[1].children[0].key.should eq("/repos") 131 | end 132 | end 133 | 134 | context "mixed payloads" do 135 | it "allows node with different payloads" do 136 | payload1 = Payload.new 137 | payload2 = Payload.new 138 | 139 | tree = Tree(Payload | Symbol).new 140 | tree.add "/", :root 141 | tree.add "/a", payload1 142 | tree.add "/bc", payload2 143 | 144 | # / (:root) 145 | # +-bc (payload2) 146 | # \-a (payload1) 147 | tree.root.children.size.should eq(2) 148 | tree.root.children[0].key.should eq("bc") 149 | tree.root.children[0].payload.should eq(payload2) 150 | tree.root.children[1].key.should eq("a") 151 | tree.root.children[1].payload.should eq(payload1) 152 | end 153 | end 154 | 155 | context "dealing with unicode" do 156 | it "inserts properly adjacent parent nodes" do 157 | tree = Tree(Symbol).new 158 | tree.add "/", :root 159 | tree.add "/日本語", :japanese 160 | tree.add "/素晴らしい", :amazing 161 | 162 | # / (:root) 163 | # +-素晴らしい (:amazing) 164 | # \-日本語 (:japanese) 165 | tree.root.children.size.should eq(2) 166 | tree.root.children[0].key.should eq("素晴らしい") 167 | tree.root.children[1].key.should eq("日本語") 168 | end 169 | 170 | it "inserts nodes with shared parent" do 171 | tree = Tree(Symbol).new 172 | tree.add "/", :root 173 | tree.add "/日本語", :japanese 174 | tree.add "/日本は難しい", :japanese_is_difficult 175 | 176 | # / (:root) 177 | # \-日本語 (:japanese) 178 | # \-日本は難しい (:japanese_is_difficult) 179 | tree.root.children.size.should eq(1) 180 | tree.root.children[0].key.should eq("日本") 181 | tree.root.children[0].children.size.should eq(2) 182 | tree.root.children[0].children[0].key.should eq("は難しい") 183 | tree.root.children[0].children[1].key.should eq("語") 184 | end 185 | end 186 | 187 | context "dealing with duplicates" do 188 | it "does not allow same path be defined twice" do 189 | tree = Tree(Symbol).new 190 | tree.add "/", :root 191 | tree.add "/abc", :abc 192 | 193 | expect_raises Tree::DuplicateError do 194 | tree.add "/", :other 195 | end 196 | 197 | tree.root.children.size.should eq(1) 198 | end 199 | end 200 | 201 | context "dealing with catch all and named parameters" do 202 | it "prioritizes nodes correctly" do 203 | tree = Tree(Symbol).new 204 | tree.add "/", :root 205 | tree.add "/*filepath", :all 206 | tree.add "/products", :products 207 | tree.add "/products/:id", :product 208 | tree.add "/products/:id/edit", :edit 209 | tree.add "/products/featured", :featured 210 | 211 | # / (:all) 212 | # +-products (:products) 213 | # | \-/ 214 | # | +-featured (:featured) 215 | # | \-:id (:product) 216 | # | \-/edit (:edit) 217 | # \-*filepath (:all) 218 | tree.root.children.size.should eq(2) 219 | tree.root.children[0].key.should eq("products") 220 | tree.root.children[0].children[0].key.should eq("/") 221 | 222 | nodes = tree.root.children[0].children[0].children 223 | nodes.size.should eq(2) 224 | nodes[0].key.should eq("featured") 225 | nodes[1].key.should eq(":id") 226 | nodes[1].children[0].key.should eq("/edit") 227 | 228 | tree.root.children[1].key.should eq("*filepath") 229 | end 230 | 231 | it "does not split named parameters across shared key" do 232 | tree = Tree(Symbol).new 233 | tree.add "/", :root 234 | tree.add "/:category", :category 235 | tree.add "/:category/:subcategory", :subcategory 236 | 237 | # / (:root) 238 | # +-:category (:category) 239 | # \-/:subcategory (:subcategory) 240 | tree.root.children.size.should eq(1) 241 | tree.root.children[0].key.should eq(":category") 242 | 243 | # inner children 244 | tree.root.children[0].children.size.should eq(1) 245 | tree.root.children[0].children[0].key.should eq("/:subcategory") 246 | end 247 | 248 | it "does allow same named parameter in different order of insertion" do 249 | tree = Tree(Symbol).new 250 | tree.add "/members/:id/edit", :member_edit 251 | tree.add "/members/export", :members_export 252 | tree.add "/members/:id/videos", :member_videos 253 | 254 | # /members/ 255 | # +-export (:members_export) 256 | # \-:id/ 257 | # +-videos (:members_videos) 258 | # \-edit (:members_edit) 259 | tree.root.key.should eq("/members/") 260 | tree.root.children.size.should eq(2) 261 | 262 | # first level children nodes 263 | tree.root.children[0].key.should eq("export") 264 | tree.root.children[1].key.should eq(":id/") 265 | 266 | # inner children 267 | nodes = tree.root.children[1].children 268 | nodes[0].key.should eq("videos") 269 | nodes[1].key.should eq("edit") 270 | end 271 | 272 | it "does not allow different named parameters sharing same level" do 273 | tree = Tree(Symbol).new 274 | tree.add "/", :root 275 | tree.add "/:post", :post 276 | 277 | expect_raises Tree::SharedKeyError do 278 | tree.add "/:category/:post", :category_post 279 | end 280 | end 281 | end 282 | end 283 | 284 | describe "#find" do 285 | context "a single node" do 286 | it "does not find when using different path" do 287 | tree = Tree(Symbol).new 288 | tree.add "/about", :about 289 | 290 | result = tree.find "/products" 291 | result.found?.should be_false 292 | end 293 | 294 | it "finds when key and path matches" do 295 | tree = Tree(Symbol).new 296 | tree.add "/about", :about 297 | 298 | result = tree.find "/about" 299 | result.found?.should be_true 300 | result.payload?.should be_truthy 301 | result.payload.should eq(:about) 302 | end 303 | 304 | it "finds when path contains trailing slash" do 305 | tree = Tree(Symbol).new 306 | tree.add "/about", :about 307 | 308 | result = tree.find "/about/" 309 | result.found?.should be_true 310 | end 311 | 312 | it "finds when key contains trailing slash" do 313 | tree = Tree(Symbol).new 314 | tree.add "/about/", :about 315 | 316 | result = tree.find "/about" 317 | result.found?.should be_true 318 | result.payload.should eq(:about) 319 | end 320 | end 321 | 322 | context "nodes with shared parent" do 323 | it "finds matching path" do 324 | tree = Tree(Symbol).new 325 | tree.add "/", :root 326 | tree.add "/abc", :abc 327 | tree.add "/axyz", :axyz 328 | 329 | result = tree.find("/abc") 330 | result.found?.should be_true 331 | result.payload.should eq(:abc) 332 | end 333 | 334 | it "finds matching path across separator" do 335 | tree = Tree(Symbol).new 336 | tree.add "/products", :products 337 | tree.add "/product/new", :product_new 338 | 339 | result = tree.find("/products") 340 | result.found?.should be_true 341 | result.payload.should eq(:products) 342 | end 343 | 344 | it "finds matching path across parents" do 345 | tree = Tree(Symbol).new 346 | tree.add "/", :root 347 | tree.add "/admin/users", :users 348 | tree.add "/admin/products", :products 349 | tree.add "/blog/tags", :tags 350 | tree.add "/blog/articles", :articles 351 | 352 | result = tree.find("/blog/tags/") 353 | result.found?.should be_true 354 | result.payload.should eq(:tags) 355 | end 356 | 357 | it "do not find when lookup for non-root key" do 358 | tree = Tree(Symbol).new 359 | tree.add "/prefix/", :prefix 360 | tree.add "/prefix/foo", :foo 361 | 362 | result = tree.find "/foo" 363 | result.found?.should be_false 364 | end 365 | end 366 | 367 | context "unicode nodes with shared parent" do 368 | it "finds matching path" do 369 | tree = Tree(Symbol).new 370 | tree.add "/", :root 371 | tree.add "/日本語", :japanese 372 | tree.add "/日本日本語は難しい", :japanese_is_difficult 373 | 374 | result = tree.find("/日本日本語は難しい/") 375 | result.found?.should be_true 376 | result.payload.should eq(:japanese_is_difficult) 377 | end 378 | end 379 | 380 | context "dealing with catch all" do 381 | it "finds matching path" do 382 | tree = Tree(Symbol).new 383 | tree.add "/", :root 384 | tree.add "/*filepath", :all 385 | tree.add "/about", :about 386 | 387 | result = tree.find("/src/file.png") 388 | result.found?.should be_true 389 | result.payload.should eq(:all) 390 | end 391 | 392 | it "returns catch all in parameters" do 393 | tree = Tree(Symbol).new 394 | tree.add "/", :root 395 | tree.add "/*filepath", :all 396 | tree.add "/about", :about 397 | 398 | result = tree.find("/src/file.png") 399 | result.found?.should be_true 400 | result.params.has_key?("filepath").should be_true 401 | result.params["filepath"].should eq("src/file.png") 402 | end 403 | 404 | it "returns optional catch all after slash" do 405 | tree = Tree(Symbol).new 406 | tree.add "/", :root 407 | tree.add "/search/*extra", :extra 408 | 409 | result = tree.find("/search") 410 | result.found?.should be_true 411 | result.params.has_key?("extra").should be_true 412 | result.params["extra"].empty?.should be_true 413 | end 414 | 415 | it "returns optional catch all by globbing" do 416 | tree = Tree(Symbol).new 417 | tree.add "/members*trailing", :members_catch_all 418 | 419 | result = tree.find("/members") 420 | result.found?.should be_true 421 | result.params.has_key?("trailing").should be_true 422 | result.params["trailing"].empty?.should be_true 423 | end 424 | 425 | it "does not find when catch all is not full match" do 426 | tree = Tree(Symbol).new 427 | tree.add "/", :root 428 | tree.add "/search/public/*query", :search 429 | 430 | result = tree.find("/search") 431 | result.found?.should be_false 432 | end 433 | 434 | it "does not find when path search has been exhausted" do 435 | tree = Tree(Symbol).new 436 | tree.add "/members/*trailing", :members_catch_all 437 | 438 | result = tree.find("/members2") 439 | result.found?.should be_false 440 | end 441 | 442 | it "does prefer specific path over catch all if both are present" do 443 | tree = Tree(Symbol).new 444 | tree.add "/members", :members 445 | tree.add "/members*trailing", :members_catch_all 446 | 447 | result = tree.find("/members") 448 | result.found?.should be_true 449 | result.payload.should eq(:members) 450 | end 451 | 452 | it "does prefer catch all over specific key with partially shared key" do 453 | tree = Tree(Symbol).new 454 | tree.add "/orders/*anything", :orders_catch_all 455 | tree.add "/orders/closed", :closed_orders 456 | 457 | result = tree.find("/orders/cancelled") 458 | result.found?.should be_true 459 | result.payload.should eq(:orders_catch_all) 460 | result.params.has_key?("anything").should be_true 461 | result.params["anything"].should eq("cancelled") 462 | end 463 | 464 | it "does prefer root catch all over specific partially shared key" do 465 | tree = Tree(Symbol).new 466 | tree.add "/*anything", :root_catch_all 467 | tree.add "/robots.txt", :robots 468 | tree.add "/resources", :resources 469 | 470 | result = tree.find("/reviews") 471 | result.found?.should be_true 472 | result.payload.should eq(:root_catch_all) 473 | result.params.has_key?("anything").should be_true 474 | result.params["anything"].should eq("reviews") 475 | end 476 | end 477 | 478 | context "dealing with named parameters" do 479 | it "finds matching path" do 480 | tree = Tree(Symbol).new 481 | tree.add "/", :root 482 | tree.add "/products", :products 483 | tree.add "/products/:id", :product 484 | tree.add "/products/:id/edit", :edit 485 | 486 | result = tree.find("/products/10") 487 | result.found?.should be_true 488 | result.payload.should eq(:product) 489 | end 490 | 491 | it "does not find partial matching path" do 492 | tree = Tree(Symbol).new 493 | tree.add "/", :root 494 | tree.add "/products", :products 495 | tree.add "/products/:id/edit", :edit 496 | 497 | result = tree.find("/products/10") 498 | result.found?.should be_false 499 | end 500 | 501 | it "returns named parameters in result" do 502 | tree = Tree(Symbol).new 503 | tree.add "/", :root 504 | tree.add "/products", :products 505 | tree.add "/products/:id", :product 506 | tree.add "/products/:id/edit", :edit 507 | 508 | result = tree.find("/products/10/edit") 509 | result.found?.should be_true 510 | result.params.has_key?("id").should be_true 511 | result.params["id"].should eq("10") 512 | end 513 | 514 | it "returns unicode values in parameters" do 515 | tree = Tree(Symbol).new 516 | tree.add "/", :root 517 | tree.add "/language/:name", :language 518 | tree.add "/language/:name/about", :about 519 | 520 | result = tree.find("/language/日本語") 521 | result.found?.should be_true 522 | result.params.has_key?("name").should be_true 523 | result.params["name"].should eq("日本語") 524 | end 525 | 526 | it "does prefer specific path over named parameters one if both are present" do 527 | tree = Tree(Symbol).new 528 | tree.add "/tag-edit/:tag", :edit_tag 529 | tree.add "/tag-edit2", :alternate_tag_edit 530 | 531 | result = tree.find("/tag-edit2") 532 | result.found?.should be_true 533 | result.payload.should eq(:alternate_tag_edit) 534 | end 535 | 536 | it "does prefer named parameter over specific key with partially shared key" do 537 | tree = Tree(Symbol).new 538 | tree.add "/orders/:id", :specific_order 539 | tree.add "/orders/closed", :closed_orders 540 | 541 | result = tree.find("/orders/10") 542 | result.found?.should be_true 543 | result.payload.should eq(:specific_order) 544 | result.params.has_key?("id").should be_true 545 | result.params["id"].should eq("10") 546 | end 547 | end 548 | 549 | context "dealing with multiple named parameters" do 550 | it "finds matching path" do 551 | tree = Tree(Symbol).new 552 | tree.add "/", :root 553 | tree.add "/:section/:page", :static_page 554 | 555 | result = tree.find("/about/shipping") 556 | result.found?.should be_true 557 | result.payload.should eq(:static_page) 558 | end 559 | 560 | it "returns named parameters in result" do 561 | tree = Tree(Symbol).new 562 | tree.add "/", :root 563 | tree.add "/:section/:page", :static_page 564 | 565 | result = tree.find("/about/shipping") 566 | result.found?.should be_true 567 | 568 | result.params.has_key?("section").should be_true 569 | result.params["section"].should eq("about") 570 | 571 | result.params.has_key?("page").should be_true 572 | result.params["page"].should eq("shipping") 573 | end 574 | end 575 | 576 | context "dealing with both catch all and named parameters" do 577 | it "finds matching path" do 578 | tree = Tree(Symbol).new 579 | tree.add "/", :root 580 | tree.add "/*filepath", :all 581 | tree.add "/products", :products 582 | tree.add "/products/:id", :product 583 | tree.add "/products/:id/edit", :edit 584 | tree.add "/products/featured", :featured 585 | 586 | result = tree.find("/products/1000") 587 | result.found?.should be_true 588 | result.payload.should eq(:product) 589 | 590 | result = tree.find("/admin/articles") 591 | result.found?.should be_true 592 | result.payload.should eq(:all) 593 | result.params["filepath"].should eq("admin/articles") 594 | 595 | result = tree.find("/products/featured") 596 | result.found?.should be_true 597 | result.payload.should eq(:featured) 598 | result.payload.should eq(:featured) 599 | end 600 | end 601 | 602 | context "dealing with named parameters and shared key" do 603 | it "finds matching path" do 604 | tree = Tree(Symbol).new 605 | tree.add "/one/:id", :one 606 | tree.add "/one-longer/:id", :two 607 | 608 | result = tree.find "/one-longer/10" 609 | result.found?.should be_true 610 | result.payload.should eq(:two) 611 | result.params["id"].should eq("10") 612 | end 613 | end 614 | end 615 | end 616 | end 617 | --------------------------------------------------------------------------------