├── .gemtest ├── test ├── helper.rb ├── nodes │ └── test_symbol.rb ├── router │ ├── test_utils.rb │ └── test_strexp.rb ├── test_routes.rb ├── route │ └── definition │ │ ├── test_scanner.rb │ │ └── test_parser.rb ├── gtg │ ├── test_builder.rb │ └── test_transition_table.rb ├── nfa │ ├── test_transition_table.rb │ └── test_simulator.rb ├── test_route.rb ├── path │ └── test_pattern.rb └── test_router.rb ├── lib ├── journey │ ├── backwards.rb │ ├── parser_extras.rb │ ├── router │ │ ├── strexp.rb │ │ └── utils.rb │ ├── nfa │ │ ├── dot.rb │ │ ├── simulator.rb │ │ ├── builder.rb │ │ └── transition_table.rb │ ├── gtg │ │ ├── simulator.rb │ │ ├── transition_table.rb │ │ └── builder.rb │ ├── visualizer │ │ ├── fsm.css │ │ ├── index.html.erb │ │ └── fsm.js │ ├── parser.y │ ├── scanner.rb │ ├── routes.rb │ ├── nodes │ │ └── node.rb │ ├── route.rb │ ├── formatter.rb │ ├── visitors.rb │ ├── router.rb │ ├── parser.rb │ └── path │ │ └── pattern.rb └── journey.rb ├── .travis.yml ├── .autotest ├── Gemfile ├── CHANGELOG.rdoc ├── Rakefile ├── Manifest.txt ├── README.rdoc └── journey.gemspec /.gemtest: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'minitest/autorun' 3 | require 'journey' 4 | require 'stringio' 5 | -------------------------------------------------------------------------------- /lib/journey/backwards.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | Mount = Journey::Router 3 | Mount::RouteSet = Journey::Router 4 | Mount::RegexpWithNamedGroups = Journey::Path::Pattern 5 | end 6 | -------------------------------------------------------------------------------- /lib/journey.rb: -------------------------------------------------------------------------------- 1 | require 'journey/router' 2 | require 'journey/gtg/builder' 3 | require 'journey/gtg/simulator' 4 | require 'journey/nfa/builder' 5 | require 'journey/nfa/simulator' 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - rbx-19mode 5 | - jruby-19mode 6 | matrix: 7 | allow_failures: 8 | - rvm: rbx-19mode 9 | - rvm: jruby-19mode 10 | -------------------------------------------------------------------------------- /.autotest: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | require 'autotest/restart' 4 | 5 | Autotest.add_hook :initialize do |at| 6 | at.testlib = 'minitest/autorun' 7 | at.find_directories = ARGV unless ARGV.empty? 8 | end 9 | -------------------------------------------------------------------------------- /test/nodes/test_symbol.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Journey 4 | module Nodes 5 | class TestSymbol < MiniTest::Unit::TestCase 6 | def test_default_regexp? 7 | sym = Symbol.new nil 8 | assert sym.default_regexp? 9 | 10 | sym.regexp = nil 11 | refute sym.default_regexp? 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/journey/parser_extras.rb: -------------------------------------------------------------------------------- 1 | require 'journey/scanner' 2 | require 'journey/nodes/node' 3 | 4 | module Journey 5 | class Parser < Racc::Parser 6 | include Journey::Nodes 7 | 8 | def initialize 9 | @scanner = Scanner.new 10 | end 11 | 12 | def parse string 13 | @scanner.scan_setup string 14 | do_parse 15 | end 16 | 17 | def next_token 18 | @scanner.next_token 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | # DO NOT EDIT THIS FILE. Instead, edit Rakefile, and run `rake bundler:gemfile`. 4 | 5 | source :gemcutter 6 | 7 | 8 | gem "minitest", "~>2.3", :group => [:development, :test] 9 | gem "racc", ">=1.4.6", :group => [:development, :test] 10 | gem "rdoc", "~>3.11", :group => [:development, :test] 11 | gem "json", ">=0", :group => [:development, :test] 12 | gem "hoe", "~>2.12", :group => [:development, :test] 13 | 14 | # vim: syntax=ruby 15 | -------------------------------------------------------------------------------- /test/router/test_utils.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Journey 4 | class Router 5 | class TestUtils < MiniTest::Unit::TestCase 6 | def test_path_escape 7 | assert_equal "a/b%20c+d", Utils.escape_path("a/b c+d") 8 | end 9 | 10 | def test_fragment_escape 11 | assert_equal "a/b%20c+d?e", Utils.escape_fragment("a/b c+d?e") 12 | end 13 | 14 | def test_uri_unescape 15 | assert_equal "a/b c+d", Utils.unescape_uri("a%2Fb%20c+d") 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/journey/router/strexp.rb: -------------------------------------------------------------------------------- 1 | module Journey 2 | class Router 3 | class Strexp 4 | class << self 5 | alias :compile :new 6 | end 7 | 8 | attr_reader :path, :requirements, :separators, :anchor 9 | 10 | def initialize path, requirements, separators, anchor = true 11 | @path = path 12 | @requirements = requirements 13 | @separators = separators 14 | @anchor = anchor 15 | end 16 | 17 | def names 18 | @path.scan(/:\w+/).map { |s| s.tr(':', '') } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /CHANGELOG.rdoc: -------------------------------------------------------------------------------- 1 | Thu Jun 14 14:03:22 2012 Aaron Patterson 2 | 3 | * lib/journey/formatter.rb: when generating routes, skip route 4 | literals (routes that do not have replacement values like 5 | "/:controller") when matching unnamed routes. 6 | 7 | https://github.com/rails/rails/issues/6459 8 | 9 | * test/test_router.rb: corresponding test 10 | 11 | Wed Feb 15 11:49:41 2012 Aaron Patterson 12 | 13 | * lib/journey/formatter.rb: reject non-truthy parameters parts. 14 | Fixes rails ticket #4587 15 | 16 | Mon Jan 23 17:07:53 2012 Aaron Patterson 17 | 18 | * Added symbol? and literal? predicate methods to the ast nodes for 19 | easier AST traversal. 20 | * Made the dummy node a real class. 21 | -------------------------------------------------------------------------------- /lib/journey/nfa/dot.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Journey 4 | module NFA 5 | module Dot 6 | def to_dot 7 | edges = transitions.map { |from, sym, to| 8 | " #{from} -> #{to} [label=\"#{sym || 'ε'}\"];" 9 | } 10 | 11 | #memo_nodes = memos.values.flatten.map { |n| 12 | # label = n 13 | # if Journey::Route === n 14 | # label = "#{n.verb.source} #{n.path.spec}" 15 | # end 16 | # " #{n.object_id} [label=\"#{label}\", shape=box];" 17 | #} 18 | #memo_edges = memos.map { |k, memos| 19 | # (memos || []).map { |v| " #{k} -> #{v.object_id};" } 20 | #}.flatten.uniq 21 | 22 | <<-eodot 23 | digraph nfa { 24 | rankdir=LR; 25 | node [shape = doublecircle]; 26 | #{accepting_states.join ' '}; 27 | node [shape = circle]; 28 | #{edges.join "\n"} 29 | } 30 | eodot 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/router/test_strexp.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Journey 4 | class Router 5 | class TestStrexp < MiniTest::Unit::TestCase 6 | def test_many_names 7 | exp = Strexp.new( 8 | "/:controller(/:action(/:id(.:format)))", 9 | {:controller=>/.+?/}, 10 | ["/", ".", "?"], 11 | true) 12 | 13 | assert_equal ["controller", "action", "id", "format"], exp.names 14 | end 15 | 16 | def test_names 17 | { 18 | "/bar(.:format)" => %w{ format }, 19 | ":format" => %w{ format }, 20 | ":format-" => %w{ format }, 21 | ":format0" => %w{ format0 }, 22 | ":format1,:format2" => %w{ format1 format2 }, 23 | }.each do |string, expected| 24 | exp = Strexp.new(string, {}, ["/", ".", "?"]) 25 | assert_equal expected, exp.names 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | require 'rubygems' 4 | require 'hoe' 5 | 6 | Hoe.plugins.delete :rubyforge 7 | Hoe.plugin :minitest 8 | Hoe.plugin :gemspec # `gem install hoe-gemspec` 9 | Hoe.plugin :git # `gem install hoe-git` 10 | Hoe.plugin :bundler # `gem install hoe-bundler` 11 | 12 | Hoe.spec 'journey' do 13 | developer('Aaron Patterson', 'aaron@tenderlovemaking.com') 14 | self.readme_file = 'README.rdoc' 15 | self.history_file = 'CHANGELOG.rdoc' 16 | self.extra_rdoc_files = FileList['*.rdoc'] 17 | self.extra_dev_deps += [ 18 | ["racc", ">= 1.4.6"], 19 | ["rdoc", "~> 3.11"], 20 | ["json"], 21 | ] 22 | self.spec_extras = { 23 | :required_ruby_version => '>= 1.9.3' 24 | } 25 | end 26 | 27 | rule '.rb' => '.y' do |t| 28 | sh "racc -l -o #{t.name} #{t.source}" 29 | end 30 | 31 | task :compile => "lib/journey/parser.rb" 32 | 33 | Rake::Task[:test].prerequisites.unshift "lib/journey/parser.rb" 34 | 35 | # vim: syntax=ruby 36 | -------------------------------------------------------------------------------- /lib/journey/gtg/simulator.rb: -------------------------------------------------------------------------------- 1 | require 'strscan' 2 | 3 | module Journey 4 | module GTG 5 | class MatchData 6 | attr_reader :memos 7 | 8 | def initialize memos 9 | @memos = memos 10 | end 11 | end 12 | 13 | class Simulator 14 | attr_reader :tt 15 | 16 | def initialize transition_table 17 | @tt = transition_table 18 | end 19 | 20 | def simulate string 21 | input = StringScanner.new string 22 | state = [0] 23 | while sym = input.scan(%r([/.?]|[^/.?]+)) 24 | state = tt.move(state, sym) 25 | end 26 | 27 | acceptance_states = state.find_all { |s| 28 | tt.accepting? s 29 | } 30 | 31 | return if acceptance_states.empty? 32 | 33 | memos = acceptance_states.map { |x| tt.memo x }.flatten.compact 34 | 35 | MatchData.new memos 36 | end 37 | 38 | alias :=~ :simulate 39 | alias :match :simulate 40 | end 41 | end 42 | end 43 | 44 | -------------------------------------------------------------------------------- /lib/journey/visualizer/fsm.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Helvetica Neue", Helvetica, Arial, Sans-Serif; 3 | margin: 0; 4 | } 5 | 6 | h1 { 7 | font-size: 2.0em; font-weight: bold; text-align: center; 8 | color: white; background-color: black; 9 | padding: 5px 0; 10 | margin: 0 0 20px; 11 | } 12 | 13 | h2 { 14 | text-align: center; 15 | display: none; 16 | font-size: 0.5em; 17 | } 18 | 19 | div#chart-2 { 20 | height: 350px; 21 | } 22 | 23 | .clearfix {display: inline-block; } 24 | .input { overflow: show;} 25 | .instruction { color: #666; padding: 0 30px 20px; font-size: 0.9em} 26 | .instruction p { padding: 0 0 5px; } 27 | .instruction li { padding: 0 10px 5px; } 28 | 29 | .form { background: #EEE; padding: 20px 30px; border-radius: 5px; margin-left: auto; margin-right: auto; width: 500px; margin-bottom: 20px} 30 | .form p, .form form { text-align: center } 31 | .form form {padding: 0 10px 5px; } 32 | .form .fun_routes { font-size: 0.9em;} 33 | .form .fun_routes a { margin: 0 5px 0 0; } 34 | 35 | -------------------------------------------------------------------------------- /lib/journey/nfa/simulator.rb: -------------------------------------------------------------------------------- 1 | require 'strscan' 2 | 3 | module Journey 4 | module NFA 5 | class MatchData 6 | attr_reader :memos 7 | 8 | def initialize memos 9 | @memos = memos 10 | end 11 | end 12 | 13 | class Simulator 14 | attr_reader :tt 15 | 16 | def initialize transition_table 17 | @tt = transition_table 18 | end 19 | 20 | def simulate string 21 | input = StringScanner.new string 22 | state = tt.eclosure 0 23 | until input.eos? 24 | sym = input.scan(%r([/.?]|[^/.?]+)) 25 | 26 | # FIXME: tt.eclosure is not needed for the GTG 27 | state = tt.eclosure tt.move(state, sym) 28 | end 29 | 30 | acceptance_states = state.find_all { |s| 31 | tt.accepting? tt.eclosure(s).sort.last 32 | } 33 | 34 | return if acceptance_states.empty? 35 | 36 | memos = acceptance_states.map { |x| tt.memo x }.flatten.compact 37 | 38 | MatchData.new memos 39 | end 40 | 41 | alias :=~ :simulate 42 | alias :match :simulate 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/journey/parser.y: -------------------------------------------------------------------------------- 1 | class Journey::Parser 2 | 3 | token SLASH LITERAL SYMBOL LPAREN RPAREN DOT STAR OR 4 | 5 | rule 6 | expressions 7 | : expressions expression { result = Cat.new(val.first, val.last) } 8 | | expression { result = val.first } 9 | | or 10 | ; 11 | expression 12 | : terminal 13 | | group 14 | | star 15 | ; 16 | group 17 | : LPAREN expressions RPAREN { result = Group.new(val[1]) } 18 | ; 19 | or 20 | : expressions OR expression { result = Or.new([val.first, val.last]) } 21 | ; 22 | star 23 | : STAR { result = Star.new(Symbol.new(val.last)) } 24 | ; 25 | terminal 26 | : symbol 27 | | literal 28 | | slash 29 | | dot 30 | ; 31 | slash 32 | : SLASH { result = Slash.new('/') } 33 | ; 34 | symbol 35 | : SYMBOL { result = Symbol.new(val.first) } 36 | ; 37 | literal 38 | : LITERAL { result = Literal.new(val.first) } 39 | dot 40 | : DOT { result = Dot.new(val.first) } 41 | ; 42 | 43 | end 44 | 45 | ---- header 46 | 47 | require 'journey/parser_extras' 48 | -------------------------------------------------------------------------------- /lib/journey/scanner.rb: -------------------------------------------------------------------------------- 1 | require 'strscan' 2 | 3 | module Journey 4 | class Scanner 5 | def initialize 6 | @ss = nil 7 | end 8 | 9 | def scan_setup str 10 | @ss = StringScanner.new str 11 | end 12 | 13 | def eos? 14 | @ss.eos? 15 | end 16 | 17 | def pos 18 | @ss.pos 19 | end 20 | 21 | def pre_match 22 | @ss.pre_match 23 | end 24 | 25 | def next_token 26 | return if @ss.eos? 27 | 28 | until token = scan || @ss.eos?; end 29 | token 30 | end 31 | 32 | private 33 | def scan 34 | case 35 | # / 36 | when text = @ss.scan(/\//) 37 | [:SLASH, text] 38 | when text = @ss.scan(/\*\w+/) 39 | [:STAR, text] 40 | when text = @ss.scan(/\(/) 41 | [:LPAREN, text] 42 | when text = @ss.scan(/\)/) 43 | [:RPAREN, text] 44 | when text = @ss.scan(/\|/) 45 | [:OR, text] 46 | when text = @ss.scan(/\./) 47 | [:DOT, text] 48 | when text = @ss.scan(/:\w+/) 49 | [:SYMBOL, text] 50 | when text = @ss.scan(/[\w%\-~]+/) 51 | [:LITERAL, text] 52 | # any char 53 | when text = @ss.scan(/./) 54 | [:LITERAL, text] 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /Manifest.txt: -------------------------------------------------------------------------------- 1 | .autotest 2 | .gemtest 3 | .travis.yml 4 | CHANGELOG.rdoc 5 | Gemfile 6 | Manifest.txt 7 | README.rdoc 8 | Rakefile 9 | journey.gemspec 10 | lib/journey.rb 11 | lib/journey/backwards.rb 12 | lib/journey/formatter.rb 13 | lib/journey/gtg/builder.rb 14 | lib/journey/gtg/simulator.rb 15 | lib/journey/gtg/transition_table.rb 16 | lib/journey/nfa/builder.rb 17 | lib/journey/nfa/dot.rb 18 | lib/journey/nfa/simulator.rb 19 | lib/journey/nfa/transition_table.rb 20 | lib/journey/nodes/node.rb 21 | lib/journey/parser.rb 22 | lib/journey/parser.y 23 | lib/journey/parser_extras.rb 24 | lib/journey/path/pattern.rb 25 | lib/journey/route.rb 26 | lib/journey/router.rb 27 | lib/journey/router/strexp.rb 28 | lib/journey/router/utils.rb 29 | lib/journey/routes.rb 30 | lib/journey/scanner.rb 31 | lib/journey/visitors.rb 32 | lib/journey/visualizer/fsm.css 33 | lib/journey/visualizer/fsm.js 34 | lib/journey/visualizer/index.html.erb 35 | test/gtg/test_builder.rb 36 | test/gtg/test_transition_table.rb 37 | test/helper.rb 38 | test/nfa/test_simulator.rb 39 | test/nfa/test_transition_table.rb 40 | test/nodes/test_symbol.rb 41 | test/path/test_pattern.rb 42 | test/route/definition/test_parser.rb 43 | test/route/definition/test_scanner.rb 44 | test/router/test_strexp.rb 45 | test/router/test_utils.rb 46 | test/test_route.rb 47 | test/test_router.rb 48 | test/test_routes.rb 49 | -------------------------------------------------------------------------------- /test/test_routes.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Journey 4 | class TestRoutes < MiniTest::Unit::TestCase 5 | def test_clear 6 | routes = Routes.new 7 | exp = Router::Strexp.new '/foo(/:id)', {}, ['/.?'] 8 | path = Path::Pattern.new exp 9 | requirements = { :hello => /world/ } 10 | 11 | routes.add_route nil, path, requirements, {:id => nil}, {} 12 | assert_equal 1, routes.length 13 | 14 | routes.clear 15 | assert_equal 0, routes.length 16 | end 17 | 18 | def test_ast 19 | routes = Routes.new 20 | path = Path::Pattern.new '/hello' 21 | 22 | routes.add_route nil, path, {}, {}, {} 23 | ast = routes.ast 24 | routes.add_route nil, path, {}, {}, {} 25 | refute_equal ast, routes.ast 26 | end 27 | 28 | def test_simulator_changes 29 | routes = Routes.new 30 | path = Path::Pattern.new '/hello' 31 | 32 | routes.add_route nil, path, {}, {}, {} 33 | sim = routes.simulator 34 | routes.add_route nil, path, {}, {}, {} 35 | refute_equal sim, routes.simulator 36 | end 37 | 38 | def test_first_name_wins 39 | #def add_route app, path, conditions, defaults, name = nil 40 | routes = Routes.new 41 | 42 | one = Path::Pattern.new '/hello' 43 | two = Path::Pattern.new '/aaron' 44 | 45 | routes.add_route nil, one, {}, {}, 'aaron' 46 | routes.add_route nil, two, {}, {}, 'aaron' 47 | 48 | assert_equal '/hello', routes.named_routes['aaron'].path.spec.to_s 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = Journey {}[http://travis-ci.org/rails/journey] 2 | 3 | * http://github.com/rails/journey 4 | 5 | == DESCRIPTION: 6 | 7 | Journey is a router. It routes requests. 8 | 9 | Note: This gem was merged on Rails 4.0 and will only receive security fixes. 10 | 11 | == FEATURES/PROBLEMS: 12 | 13 | * Designed for rails 14 | 15 | == SYNOPSIS: 16 | 17 | Too complex right now. :( 18 | 19 | == REQUIREMENTS: 20 | 21 | * None 22 | 23 | == INSTALL: 24 | 25 | * gem install journey 26 | 27 | == LICENSE: 28 | 29 | (The MIT License) 30 | 31 | Copyright (c) 2011 Aaron Patterson 32 | 33 | Permission is hereby granted, free of charge, to any person obtaining 34 | a copy of this software and associated documentation files (the 35 | 'Software'), to deal in the Software without restriction, including 36 | without limitation the rights to use, copy, modify, merge, publish, 37 | distribute, sublicense, and/or sell copies of the Software, and to 38 | permit persons to whom the Software is furnished to do so, subject to 39 | the following conditions: 40 | 41 | The above copyright notice and this permission notice shall be 42 | included in all copies or substantial portions of the Software. 43 | 44 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 45 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 46 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 47 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 48 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 49 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 50 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 51 | -------------------------------------------------------------------------------- /lib/journey/nfa/builder.rb: -------------------------------------------------------------------------------- 1 | require 'journey/nfa/transition_table' 2 | require 'journey/gtg/transition_table' 3 | 4 | module Journey 5 | module NFA 6 | class Visitor < Visitors::Visitor 7 | def initialize tt 8 | @tt = tt 9 | @i = -1 10 | end 11 | 12 | def visit_CAT node 13 | left = visit node.left 14 | right = visit node.right 15 | 16 | @tt.merge left.last, right.first 17 | 18 | [left.first, right.last] 19 | end 20 | 21 | def visit_GROUP node 22 | from = @i += 1 23 | left = visit node.left 24 | to = @i += 1 25 | 26 | @tt.accepting = to 27 | 28 | @tt[from, left.first] = nil 29 | @tt[left.last, to] = nil 30 | @tt[from, to] = nil 31 | 32 | [from, to] 33 | end 34 | 35 | def visit_OR node 36 | from = @i += 1 37 | children = node.children.map { |c| visit c } 38 | to = @i += 1 39 | 40 | children.each do |child| 41 | @tt[from, child.first] = nil 42 | @tt[child.last, to] = nil 43 | end 44 | 45 | @tt.accepting = to 46 | 47 | [from, to] 48 | end 49 | 50 | def terminal node 51 | from_i = @i += 1 # new state 52 | to_i = @i += 1 # new state 53 | 54 | @tt[from_i, to_i] = node 55 | @tt.accepting = to_i 56 | @tt.add_memo to_i, node.memo 57 | 58 | [from_i, to_i] 59 | end 60 | end 61 | 62 | class Builder 63 | def initialize ast 64 | @ast = ast 65 | end 66 | 67 | def transition_table 68 | tt = TransitionTable.new 69 | Visitor.new(tt).accept @ast 70 | tt 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/route/definition/test_scanner.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Journey 4 | module Definition 5 | class TestScanner < MiniTest::Unit::TestCase 6 | def setup 7 | @scanner = Scanner.new 8 | end 9 | 10 | # /page/:id(/:action)(.:format) 11 | def test_tokens 12 | [ 13 | ['/', [[:SLASH, '/']]], 14 | ['*omg', [[:STAR, '*omg']]], 15 | ['/page', [[:SLASH, '/'], [:LITERAL, 'page']]], 16 | ['/~page', [[:SLASH, '/'], [:LITERAL, '~page']]], 17 | ['/pa-ge', [[:SLASH, '/'], [:LITERAL, 'pa-ge']]], 18 | ['/:page', [[:SLASH, '/'], [:SYMBOL, ':page']]], 19 | ['/(:page)', [ 20 | [:SLASH, '/'], 21 | [:LPAREN, '('], 22 | [:SYMBOL, ':page'], 23 | [:RPAREN, ')'], 24 | ]], 25 | ['(/:action)', [ 26 | [:LPAREN, '('], 27 | [:SLASH, '/'], 28 | [:SYMBOL, ':action'], 29 | [:RPAREN, ')'], 30 | ]], 31 | ['(())', [[:LPAREN, '('], 32 | [:LPAREN, '('], [:RPAREN, ')'], [:RPAREN, ')']]], 33 | ['(.:format)', [ 34 | [:LPAREN, '('], 35 | [:DOT, '.'], 36 | [:SYMBOL, ':format'], 37 | [:RPAREN, ')'], 38 | ]], 39 | ].each do |str, expected| 40 | @scanner.scan_setup str 41 | assert_tokens expected, @scanner 42 | end 43 | end 44 | 45 | def assert_tokens tokens, scanner 46 | toks = [] 47 | while tok = scanner.next_token 48 | toks << tok 49 | end 50 | assert_equal tokens, toks 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/journey/visualizer/index.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= title %> 5 | 6 | 11 | 12 | 13 | 14 |
15 |

Routes FSM with NFA simulation

16 |
17 |

18 | Type a route in to the box and click "simulate". 19 |

20 |
21 | 22 | 23 | 24 |
25 |

26 | Some fun routes to try: 27 | <% fun_routes.each do |path| %> 28 | 29 | <%= path %> 30 | 31 | <% end %> 32 |

33 |
34 |
35 | <%= svg %> 36 |
37 |
38 |

39 | This is a FSM for a system that has the following routes: 40 |

41 |
    42 | <% paths.each do |route| %> 43 |
  • <%= route %>
  • 44 | <% end %> 45 |
46 |
47 |
48 | <% javascripts.each do |js| %> 49 | 50 | <% end %> 51 | 52 | 53 | -------------------------------------------------------------------------------- /lib/journey/routes.rb: -------------------------------------------------------------------------------- 1 | module Journey 2 | ### 3 | # The Routing table. Contains all routes for a system. Routes can be 4 | # added to the table by calling Routes#add_route 5 | class Routes 6 | include Enumerable 7 | 8 | attr_reader :routes, :named_routes 9 | 10 | def initialize 11 | @routes = [] 12 | @named_routes = {} 13 | @ast = nil 14 | @partitioned_routes = nil 15 | @simulator = nil 16 | end 17 | 18 | def length 19 | @routes.length 20 | end 21 | alias :size :length 22 | 23 | def last 24 | @routes.last 25 | end 26 | 27 | def each(&block) 28 | routes.each(&block) 29 | end 30 | 31 | def clear 32 | routes.clear 33 | end 34 | 35 | def partitioned_routes 36 | @partitioned_routes ||= routes.partition { |r| 37 | r.path.anchored && r.ast.grep(Nodes::Symbol).all? { |n| n.default_regexp? } 38 | } 39 | end 40 | 41 | def ast 42 | return @ast if @ast 43 | return if partitioned_routes.first.empty? 44 | 45 | asts = partitioned_routes.first.map { |r| r.ast } 46 | @ast = Nodes::Or.new(asts) 47 | end 48 | 49 | def simulator 50 | return @simulator if @simulator 51 | 52 | gtg = GTG::Builder.new(ast).transition_table 53 | @simulator = GTG::Simulator.new gtg 54 | end 55 | 56 | ### 57 | # Add a route to the routing table. 58 | def add_route app, path, conditions, defaults, name = nil 59 | route = Route.new(name, app, path, conditions, defaults) 60 | 61 | route.precedence = routes.length 62 | routes << route 63 | named_routes[name] = route if name && !named_routes[name] 64 | clear_cache! 65 | route 66 | end 67 | 68 | private 69 | def clear_cache! 70 | @ast = nil 71 | @partitioned_routes = nil 72 | @simulator = nil 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/journey/router/utils.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | 3 | module Journey 4 | class Router 5 | class Utils 6 | # Normalizes URI path. 7 | # 8 | # Strips off trailing slash and ensures there is a leading slash. 9 | # 10 | # normalize_path("/foo") # => "/foo" 11 | # normalize_path("/foo/") # => "/foo" 12 | # normalize_path("foo") # => "/foo" 13 | # normalize_path("") # => "/" 14 | def self.normalize_path(path) 15 | path = "/#{path}" 16 | path.squeeze!('/') 17 | path.sub!(%r{/+\Z}, '') 18 | path = '/' if path == '' 19 | path 20 | end 21 | 22 | # URI path and fragment escaping 23 | # http://tools.ietf.org/html/rfc3986 24 | module UriEscape 25 | # Symbol captures can generate multiple path segments, so include /. 26 | reserved_segment = '/' 27 | reserved_fragment = '/?' 28 | reserved_pchar = ':@&=+$,;%' 29 | 30 | safe_pchar = "#{URI::REGEXP::PATTERN::UNRESERVED}#{reserved_pchar}" 31 | safe_segment = "#{safe_pchar}#{reserved_segment}" 32 | safe_fragment = "#{safe_pchar}#{reserved_fragment}" 33 | if RUBY_VERSION >= '1.9' 34 | UNSAFE_SEGMENT = Regexp.new("[^#{safe_segment}]", false).freeze 35 | UNSAFE_FRAGMENT = Regexp.new("[^#{safe_fragment}]", false).freeze 36 | else 37 | UNSAFE_SEGMENT = Regexp.new("[^#{safe_segment}]", false, 'N').freeze 38 | UNSAFE_FRAGMENT = Regexp.new("[^#{safe_fragment}]", false, 'N').freeze 39 | end 40 | end 41 | 42 | Parser = URI.const_defined?(:Parser) ? URI::Parser.new : URI 43 | 44 | def self.escape_path(path) 45 | Parser.escape(path.to_s, UriEscape::UNSAFE_SEGMENT) 46 | end 47 | 48 | def self.escape_fragment(fragment) 49 | Parser.escape(fragment.to_s, UriEscape::UNSAFE_FRAGMENT) 50 | end 51 | 52 | def self.unescape_uri(uri) 53 | Parser.unescape(uri) 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/gtg/test_builder.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Journey 4 | module GTG 5 | class TestBuilder < MiniTest::Unit::TestCase 6 | def test_following_states_multi 7 | table = tt ['a|a'] 8 | assert_equal 1, table.move([0], 'a').length 9 | end 10 | 11 | def test_following_states_multi_regexp 12 | table = tt [':a|b'] 13 | assert_equal 1, table.move([0], 'fooo').length 14 | assert_equal 2, table.move([0], 'b').length 15 | end 16 | 17 | def test_multi_path 18 | table = tt ['/:a/d', '/b/c'] 19 | 20 | [ 21 | [1, '/'], 22 | [2, 'b'], 23 | [2, '/'], 24 | [1, 'c'], 25 | ].inject([0]) { |state, (exp, sym)| 26 | new = table.move(state, sym) 27 | assert_equal exp, new.length 28 | new 29 | } 30 | end 31 | 32 | def test_match_data_ambiguous 33 | table = tt %w{ 34 | /articles(.:format) 35 | /articles/new(.:format) 36 | /articles/:id/edit(.:format) 37 | /articles/:id(.:format) 38 | } 39 | 40 | sim = NFA::Simulator.new table 41 | 42 | match = sim.match '/articles/new' 43 | assert_equal 2, match.memos.length 44 | end 45 | 46 | ## 47 | # Identical Routes may have different restrictions. 48 | def test_match_same_paths 49 | table = tt %w{ 50 | /articles/new(.:format) 51 | /articles/new(.:format) 52 | } 53 | 54 | sim = NFA::Simulator.new table 55 | 56 | match = sim.match '/articles/new' 57 | assert_equal 2, match.memos.length 58 | end 59 | 60 | private 61 | def ast strings 62 | parser = Journey::Parser.new 63 | asts = strings.map { |string| 64 | memo = Object.new 65 | ast = parser.parse string 66 | ast.each { |n| n.memo = memo } 67 | ast 68 | } 69 | Nodes::Or.new asts 70 | end 71 | 72 | def tt strings 73 | Builder.new(ast(strings)).transition_table 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/nfa/test_transition_table.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Journey 4 | module NFA 5 | class TestTransitionTable < MiniTest::Unit::TestCase 6 | def setup 7 | @parser = Journey::Parser.new 8 | end 9 | 10 | def test_eclosure 11 | table = tt '/' 12 | assert_equal [0], table.eclosure(0) 13 | 14 | table = tt ':a|:b' 15 | assert_equal 3, table.eclosure(0).length 16 | 17 | table = tt '(:a|:b)' 18 | assert_equal 5, table.eclosure(0).length 19 | assert_equal 5, table.eclosure([0]).length 20 | end 21 | 22 | def test_following_states_one 23 | table = tt '/' 24 | 25 | assert_equal [1], table.following_states(0, '/') 26 | assert_equal [1], table.following_states([0], '/') 27 | end 28 | 29 | def test_following_states_group 30 | table = tt 'a|b' 31 | states = table.eclosure 0 32 | 33 | assert_equal 1, table.following_states(states, 'a').length 34 | assert_equal 1, table.following_states(states, 'b').length 35 | end 36 | 37 | def test_following_states_multi 38 | table = tt 'a|a' 39 | states = table.eclosure 0 40 | 41 | assert_equal 2, table.following_states(states, 'a').length 42 | assert_equal 0, table.following_states(states, 'b').length 43 | end 44 | 45 | def test_following_states_regexp 46 | table = tt 'a|:a' 47 | states = table.eclosure 0 48 | 49 | assert_equal 1, table.following_states(states, 'a').length 50 | assert_equal 1, table.following_states(states, /[^\.\/\?]+/).length 51 | assert_equal 0, table.following_states(states, 'b').length 52 | end 53 | 54 | def test_alphabet 55 | table = tt 'a|:a' 56 | assert_equal [/[^\.\/\?]+/, 'a'], table.alphabet 57 | 58 | table = tt 'a|a' 59 | assert_equal ['a'], table.alphabet 60 | end 61 | 62 | private 63 | def tt string 64 | ast = @parser.parse string 65 | builder = Builder.new ast 66 | builder.transition_table 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/journey/nodes/node.rb: -------------------------------------------------------------------------------- 1 | require 'journey/visitors' 2 | 3 | module Journey 4 | module Nodes 5 | class Node # :nodoc: 6 | include Enumerable 7 | 8 | attr_accessor :left, :memo 9 | 10 | def initialize left 11 | @left = left 12 | @memo = nil 13 | end 14 | 15 | def each(&block) 16 | Visitors::Each.new(block).accept(self) 17 | end 18 | 19 | def to_s 20 | Visitors::String.new.accept(self) 21 | end 22 | 23 | def to_dot 24 | Visitors::Dot.new.accept(self) 25 | end 26 | 27 | def to_sym 28 | name.to_sym 29 | end 30 | 31 | def name 32 | left.tr '*:', '' 33 | end 34 | 35 | def type 36 | raise NotImplementedError 37 | end 38 | 39 | def symbol?; false; end 40 | def literal?; false; end 41 | end 42 | 43 | class Terminal < Node 44 | alias :symbol :left 45 | end 46 | 47 | class Literal < Terminal 48 | def literal?; true; end 49 | def type; :LITERAL; end 50 | end 51 | 52 | class Dummy < Literal 53 | def initialize x = Object.new 54 | super 55 | end 56 | 57 | def literal?; false; end 58 | end 59 | 60 | %w{ Symbol Slash Dot }.each do |t| 61 | class_eval <<-eoruby, __FILE__, __LINE__ + 1 62 | class #{t} < Terminal; 63 | def type; :#{t.upcase}; end 64 | end 65 | eoruby 66 | end 67 | 68 | class Symbol < Terminal 69 | attr_accessor :regexp 70 | alias :symbol :regexp 71 | 72 | DEFAULT_EXP = /[^\.\/\?]+/ 73 | def initialize left 74 | super 75 | @regexp = DEFAULT_EXP 76 | end 77 | 78 | def default_regexp? 79 | regexp == DEFAULT_EXP 80 | end 81 | 82 | def symbol?; true; end 83 | end 84 | 85 | class Unary < Node 86 | def children; [left] end 87 | end 88 | 89 | class Group < Unary 90 | def type; :GROUP; end 91 | end 92 | 93 | class Star < Unary 94 | def type; :STAR; end 95 | end 96 | 97 | class Binary < Node 98 | attr_accessor :right 99 | 100 | def initialize left, right 101 | super(left) 102 | @right = right 103 | end 104 | 105 | def children; [left, right] end 106 | end 107 | 108 | class Cat < Binary 109 | def type; :CAT; end 110 | end 111 | 112 | class Or < Node 113 | attr_reader :children 114 | 115 | def initialize children 116 | @children = children 117 | end 118 | 119 | def type; :OR; end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/journey/route.rb: -------------------------------------------------------------------------------- 1 | module Journey 2 | class Route 3 | attr_reader :app, :path, :verb, :defaults, :ip, :name 4 | 5 | attr_reader :constraints 6 | alias :conditions :constraints 7 | 8 | attr_accessor :precedence 9 | 10 | ## 11 | # +path+ is a path constraint. 12 | # +constraints+ is a hash of constraints to be applied to this route. 13 | def initialize name, app, path, constraints, defaults = {} 14 | constraints = constraints.dup 15 | @name = name 16 | @app = app 17 | @path = path 18 | @verb = constraints[:request_method] || // 19 | @ip = constraints.delete(:ip) || // 20 | 21 | @constraints = constraints 22 | @constraints.keep_if { |_,v| Regexp === v || String === v } 23 | @defaults = defaults 24 | @required_defaults = nil 25 | @required_parts = nil 26 | @parts = nil 27 | @decorated_ast = nil 28 | @precedence = 0 29 | end 30 | 31 | def ast 32 | return @decorated_ast if @decorated_ast 33 | 34 | @decorated_ast = path.ast 35 | @decorated_ast.grep(Nodes::Terminal).each { |n| n.memo = self } 36 | @decorated_ast 37 | end 38 | 39 | def requirements # :nodoc: 40 | # needed for rails `rake routes` 41 | path.requirements.merge(@defaults).delete_if { |_,v| 42 | /.+?/ == v 43 | } 44 | end 45 | 46 | def segments 47 | @path.names 48 | end 49 | 50 | def required_keys 51 | path.required_names.map { |x| x.to_sym } + required_defaults.keys 52 | end 53 | 54 | def score constraints 55 | required_keys = path.required_names 56 | supplied_keys = constraints.map { |k,v| v && k.to_s }.compact 57 | 58 | return -1 unless (required_keys - supplied_keys).empty? 59 | 60 | score = (supplied_keys & path.names).length 61 | score + (required_defaults.length * 2) 62 | end 63 | 64 | def parts 65 | @parts ||= segments.map { |n| n.to_sym } 66 | end 67 | alias :segment_keys :parts 68 | 69 | def format path_options 70 | path_options.delete_if do |key, value| 71 | value.to_s == defaults[key].to_s && !required_parts.include?(key) 72 | end 73 | 74 | Visitors::Formatter.new(path_options).accept(path.spec) 75 | end 76 | 77 | def optional_parts 78 | path.optional_names.map { |n| n.to_sym } 79 | end 80 | 81 | def required_parts 82 | @required_parts ||= path.required_names.map { |n| n.to_sym } 83 | end 84 | 85 | def required_defaults 86 | @required_defaults ||= begin 87 | matches = parts 88 | @defaults.dup.delete_if { |k,_| matches.include? k } 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/nfa/test_simulator.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Journey 4 | module NFA 5 | class TestSimulator < MiniTest::Unit::TestCase 6 | def test_simulate_simple 7 | sim = simulator_for ['/foo'] 8 | assert_match sim, '/foo' 9 | end 10 | 11 | def test_simulate_simple_no_match 12 | sim = simulator_for ['/foo'] 13 | refute_match sim, 'foo' 14 | end 15 | 16 | def test_simulate_simple_no_match_too_long 17 | sim = simulator_for ['/foo'] 18 | refute_match sim, '/foo/bar' 19 | end 20 | 21 | def test_simulate_simple_no_match_wrong_string 22 | sim = simulator_for ['/foo'] 23 | refute_match sim, '/bar' 24 | end 25 | 26 | def test_simulate_regex 27 | sim = simulator_for ['/:foo/bar'] 28 | assert_match sim, '/bar/bar' 29 | assert_match sim, '/foo/bar' 30 | end 31 | 32 | def test_simulate_or 33 | sim = simulator_for ['/foo', '/bar'] 34 | assert_match sim, '/bar' 35 | assert_match sim, '/foo' 36 | refute_match sim, '/baz' 37 | end 38 | 39 | def test_simulate_optional 40 | sim = simulator_for ['/foo(/bar)'] 41 | assert_match sim, '/foo' 42 | assert_match sim, '/foo/bar' 43 | refute_match sim, '/foo/' 44 | end 45 | 46 | def test_matchdata_has_memos 47 | paths = %w{ /foo /bar } 48 | parser = Journey::Parser.new 49 | asts = paths.map { |x| 50 | ast = parser.parse x 51 | ast.each { |n| n.memo = ast} 52 | ast 53 | } 54 | 55 | expected = asts.first 56 | 57 | builder = Builder.new Nodes::Or.new asts 58 | 59 | sim = Simulator.new builder.transition_table 60 | 61 | md = sim.match '/foo' 62 | assert_equal [expected], md.memos 63 | end 64 | 65 | def test_matchdata_memos_on_merge 66 | parser = Journey::Parser.new 67 | routes = [ 68 | '/articles(.:format)', 69 | '/articles/new(.:format)', 70 | '/articles/:id/edit(.:format)', 71 | '/articles/:id(.:format)', 72 | ].map { |path| 73 | ast = parser.parse path 74 | ast.each { |n| n.memo = ast } 75 | ast 76 | } 77 | 78 | asts = routes.dup 79 | 80 | ast = Nodes::Or.new routes 81 | 82 | nfa = Journey::NFA::Builder.new ast 83 | sim = Simulator.new nfa.transition_table 84 | md = sim.match '/articles' 85 | assert_equal [asts.first], md.memos 86 | end 87 | 88 | def simulator_for paths 89 | parser = Journey::Parser.new 90 | asts = paths.map { |x| parser.parse x } 91 | builder = Builder.new Nodes::Or.new asts 92 | Simulator.new builder.transition_table 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/route/definition/test_parser.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Journey 4 | module Definition 5 | class TestParser < MiniTest::Unit::TestCase 6 | def setup 7 | @parser = Parser.new 8 | end 9 | 10 | def test_slash 11 | assert_equal :SLASH, @parser.parse('/').type 12 | assert_round_trip '/' 13 | end 14 | 15 | def test_segment 16 | assert_round_trip '/foo' 17 | end 18 | 19 | def test_segments 20 | assert_round_trip '/foo/bar' 21 | end 22 | 23 | def test_segment_symbol 24 | assert_round_trip '/foo/:id' 25 | end 26 | 27 | def test_symbol 28 | assert_round_trip '/:foo' 29 | end 30 | 31 | def test_group 32 | assert_round_trip '(/:foo)' 33 | end 34 | 35 | def test_groups 36 | assert_round_trip '(/:foo)(/:bar)' 37 | end 38 | 39 | def test_nested_groups 40 | assert_round_trip '(/:foo(/:bar))' 41 | end 42 | 43 | def test_dot_symbol 44 | assert_round_trip('.:format') 45 | end 46 | 47 | def test_dot_literal 48 | assert_round_trip('.xml') 49 | end 50 | 51 | def test_segment_dot 52 | assert_round_trip('/foo.:bar') 53 | end 54 | 55 | def test_segment_group_dot 56 | assert_round_trip('/foo(.:bar)') 57 | end 58 | 59 | def test_segment_group 60 | assert_round_trip('/foo(/:action)') 61 | end 62 | 63 | def test_segment_groups 64 | assert_round_trip('/foo(/:action)(/:bar)') 65 | end 66 | 67 | def test_segment_nested_groups 68 | assert_round_trip('/foo(/:action(/:bar))') 69 | end 70 | 71 | def test_group_followed_by_path 72 | assert_round_trip('/foo(/:action)/:bar') 73 | end 74 | 75 | def test_star 76 | assert_round_trip('*foo') 77 | assert_round_trip('/*foo') 78 | assert_round_trip('/bar/*foo') 79 | assert_round_trip('/bar/(*foo)') 80 | end 81 | 82 | def test_or 83 | assert_round_trip('a|b') 84 | assert_round_trip('a|b|c') 85 | assert_round_trip('(a|b)|c') 86 | assert_round_trip('a|(b|c)') 87 | assert_round_trip('*a|(b|c)') 88 | assert_round_trip('*a|:b|c') 89 | end 90 | 91 | def test_arbitrary 92 | assert_round_trip('/bar/*foo#') 93 | end 94 | 95 | def test_literal_dot_paren 96 | assert_round_trip "/sprockets.js(.:format)" 97 | end 98 | 99 | def test_groups_with_dot 100 | assert_round_trip "/(:locale)(.:format)" 101 | end 102 | 103 | def assert_round_trip str 104 | assert_equal str, @parser.parse(str).to_s 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/gtg/test_transition_table.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | require 'json' 3 | 4 | module Journey 5 | module GTG 6 | class TestGeneralizedTable < MiniTest::Unit::TestCase 7 | def test_to_json 8 | table = tt %w{ 9 | /articles(.:format) 10 | /articles/new(.:format) 11 | /articles/:id/edit(.:format) 12 | /articles/:id(.:format) 13 | } 14 | 15 | json = JSON.load table.to_json 16 | assert json['regexp_states'] 17 | assert json['string_states'] 18 | assert json['accepting'] 19 | end 20 | 21 | if system("dot -V 2>/dev/null") 22 | def test_to_svg 23 | table = tt %w{ 24 | /articles(.:format) 25 | /articles/new(.:format) 26 | /articles/:id/edit(.:format) 27 | /articles/:id(.:format) 28 | } 29 | svg = table.to_svg 30 | assert svg 31 | refute_match(/DOCTYPE/, svg) 32 | end 33 | end 34 | 35 | def test_simulate_gt 36 | sim = simulator_for ['/foo', '/bar'] 37 | assert_match sim, '/foo' 38 | end 39 | 40 | def test_simulate_gt_regexp 41 | sim = simulator_for [':foo'] 42 | assert_match sim, 'foo' 43 | end 44 | 45 | def test_simulate_gt_regexp_mix 46 | sim = simulator_for ['/get', '/:method/foo'] 47 | assert_match sim, '/get' 48 | assert_match sim, '/get/foo' 49 | end 50 | 51 | def test_simulate_optional 52 | sim = simulator_for ['/foo(/bar)'] 53 | assert_match sim, '/foo' 54 | assert_match sim, '/foo/bar' 55 | refute_match sim, '/foo/' 56 | end 57 | 58 | def test_match_data 59 | path_asts = asts %w{ /get /:method/foo } 60 | paths = path_asts.dup 61 | 62 | builder = GTG::Builder.new Nodes::Or.new path_asts 63 | tt = builder.transition_table 64 | 65 | sim = GTG::Simulator.new tt 66 | 67 | match = sim.match '/get' 68 | assert_equal [paths.first], match.memos 69 | 70 | match = sim.match '/get/foo' 71 | assert_equal [paths.last], match.memos 72 | end 73 | 74 | def test_match_data_ambiguous 75 | path_asts = asts %w{ 76 | /articles(.:format) 77 | /articles/new(.:format) 78 | /articles/:id/edit(.:format) 79 | /articles/:id(.:format) 80 | } 81 | 82 | paths = path_asts.dup 83 | ast = Nodes::Or.new path_asts 84 | 85 | builder = GTG::Builder.new ast 86 | sim = GTG::Simulator.new builder.transition_table 87 | 88 | match = sim.match '/articles/new' 89 | assert_equal [paths[1], paths[3]], match.memos 90 | end 91 | 92 | private 93 | def asts paths 94 | parser = Journey::Parser.new 95 | paths.map { |x| 96 | ast = parser.parse x 97 | ast.each { |n| n.memo = ast} 98 | ast 99 | } 100 | end 101 | 102 | def tt paths 103 | x = asts paths 104 | builder = GTG::Builder.new Nodes::Or.new x 105 | builder.transition_table 106 | end 107 | 108 | def simulator_for paths 109 | GTG::Simulator.new tt(paths) 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /journey.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "journey" 5 | s.version = "2.0.0.20120723141804" 6 | 7 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 8 | s.authors = ["Aaron Patterson"] 9 | s.date = "2012-07-23" 10 | s.description = "Journey is a router. It routes requests." 11 | s.email = ["aaron@tenderlovemaking.com"] 12 | s.extra_rdoc_files = ["Manifest.txt", "CHANGELOG.rdoc", "README.rdoc"] 13 | s.files = [".autotest", ".gemtest", ".travis.yml", "CHANGELOG.rdoc", "Gemfile", "Manifest.txt", "README.rdoc", "Rakefile", "journey.gemspec", "lib/journey.rb", "lib/journey/backwards.rb", "lib/journey/formatter.rb", "lib/journey/gtg/builder.rb", "lib/journey/gtg/simulator.rb", "lib/journey/gtg/transition_table.rb", "lib/journey/nfa/builder.rb", "lib/journey/nfa/dot.rb", "lib/journey/nfa/simulator.rb", "lib/journey/nfa/transition_table.rb", "lib/journey/nodes/node.rb", "lib/journey/parser.rb", "lib/journey/parser.y", "lib/journey/parser_extras.rb", "lib/journey/path/pattern.rb", "lib/journey/route.rb", "lib/journey/router.rb", "lib/journey/router/strexp.rb", "lib/journey/router/utils.rb", "lib/journey/routes.rb", "lib/journey/scanner.rb", "lib/journey/visitors.rb", "lib/journey/visualizer/fsm.css", "lib/journey/visualizer/fsm.js", "lib/journey/visualizer/index.html.erb", "test/gtg/test_builder.rb", "test/gtg/test_transition_table.rb", "test/helper.rb", "test/nfa/test_simulator.rb", "test/nfa/test_transition_table.rb", "test/nodes/test_symbol.rb", "test/path/test_pattern.rb", "test/route/definition/test_parser.rb", "test/route/definition/test_scanner.rb", "test/router/test_strexp.rb", "test/router/test_utils.rb", "test/test_route.rb", "test/test_router.rb", "test/test_routes.rb"] 14 | s.homepage = "http://github.com/rails/journey" 15 | s.rdoc_options = ["--main", "README.rdoc"] 16 | s.require_paths = ["lib"] 17 | s.rubyforge_project = "journey" 18 | s.rubygems_version = "1.8.23" 19 | s.license = "MIT" 20 | s.summary = "Journey is a router" 21 | s.test_files = ["test/gtg/test_builder.rb", "test/gtg/test_transition_table.rb", "test/nfa/test_simulator.rb", "test/nfa/test_transition_table.rb", "test/nodes/test_symbol.rb", "test/path/test_pattern.rb", "test/route/definition/test_parser.rb", "test/route/definition/test_scanner.rb", "test/router/test_strexp.rb", "test/router/test_utils.rb", "test/test_route.rb", "test/test_router.rb", "test/test_routes.rb"] 22 | 23 | if s.respond_to? :specification_version then 24 | s.specification_version = 3 25 | 26 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 27 | s.add_development_dependency(%q, ["~> 3.2"]) 28 | s.add_development_dependency(%q, ["~> 3.10"]) 29 | s.add_development_dependency(%q, [">= 1.4.6"]) 30 | s.add_development_dependency(%q, [">= 0"]) 31 | s.add_development_dependency(%q, ["~> 3.0"]) 32 | else 33 | s.add_dependency(%q, ["~> 3.2"]) 34 | s.add_dependency(%q, ["~> 3.10"]) 35 | s.add_dependency(%q, [">= 1.4.6"]) 36 | s.add_dependency(%q, [">= 0"]) 37 | s.add_dependency(%q, ["~> 3.0"]) 38 | end 39 | else 40 | s.add_dependency(%q, ["~> 3.2"]) 41 | s.add_dependency(%q, ["~> 3.10"]) 42 | s.add_dependency(%q, [">= 1.4.6"]) 43 | s.add_dependency(%q, [">= 0"]) 44 | s.add_dependency(%q, ["~> 3.0"]) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/test_route.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Journey 4 | class TestRoute < MiniTest::Unit::TestCase 5 | def test_initialize 6 | app = Object.new 7 | path = Path::Pattern.new '/:controller(/:action(/:id(.:format)))' 8 | defaults = Object.new 9 | route = Route.new("name", app, path, {}, defaults) 10 | 11 | assert_equal app, route.app 12 | assert_equal path, route.path 13 | assert_equal defaults, route.defaults 14 | end 15 | 16 | def test_route_adds_itself_as_memo 17 | app = Object.new 18 | path = Path::Pattern.new '/:controller(/:action(/:id(.:format)))' 19 | defaults = Object.new 20 | route = Route.new("name", app, path, {}, defaults) 21 | 22 | route.ast.grep(Nodes::Terminal).each do |node| 23 | assert_equal route, node.memo 24 | end 25 | end 26 | 27 | def test_ip_address 28 | path = Path::Pattern.new '/messages/:id(.:format)' 29 | route = Route.new("name", nil, path, {:ip => '192.168.1.1'}, 30 | { :controller => 'foo', :action => 'bar' }) 31 | assert_equal '192.168.1.1', route.ip 32 | end 33 | 34 | def test_default_ip 35 | path = Path::Pattern.new '/messages/:id(.:format)' 36 | route = Route.new("name", nil, path, {}, 37 | { :controller => 'foo', :action => 'bar' }) 38 | assert_equal(//, route.ip) 39 | end 40 | 41 | def test_format_with_star 42 | path = Path::Pattern.new '/:controller/*extra' 43 | route = Route.new("name", nil, path, {}, 44 | { :controller => 'foo', :action => 'bar' }) 45 | assert_equal '/foo/himom', route.format({ 46 | :controller => 'foo', 47 | :extra => 'himom', 48 | }) 49 | end 50 | 51 | def test_connects_all_match 52 | path = Path::Pattern.new '/:controller(/:action(/:id(.:format)))' 53 | route = Route.new("name", nil, path, {:action => 'bar'}, { :controller => 'foo' }) 54 | 55 | assert_equal '/foo/bar/10', route.format({ 56 | :controller => 'foo', 57 | :action => 'bar', 58 | :id => 10 59 | }) 60 | end 61 | 62 | def test_extras_are_not_included_if_optional 63 | path = Path::Pattern.new '/page/:id(/:action)' 64 | route = Route.new("name", nil, path, { }, { :action => 'show' }) 65 | 66 | assert_equal '/page/10', route.format({ :id => 10 }) 67 | end 68 | 69 | def test_extras_are_not_included_if_optional_with_parameter 70 | path = Path::Pattern.new '(/sections/:section)/pages/:id' 71 | route = Route.new("name", nil, path, { }, { :action => 'show' }) 72 | 73 | assert_equal '/pages/10', route.format({:id => 10}) 74 | end 75 | 76 | def test_extras_are_not_included_if_optional_parameter_is_nil 77 | path = Path::Pattern.new '(/sections/:section)/pages/:id' 78 | route = Route.new("name", nil, path, { }, { :action => 'show' }) 79 | 80 | assert_equal '/pages/10', route.format({:id => 10, :section => nil}) 81 | end 82 | 83 | def test_score 84 | path = Path::Pattern.new "/page/:id(/:action)(.:format)" 85 | specific = Route.new "name", nil, path, {}, {:controller=>"pages", :action=>"show"} 86 | 87 | path = Path::Pattern.new "/:controller(/:action(/:id))(.:format)" 88 | generic = Route.new "name", nil, path, {} 89 | 90 | knowledge = {:id=>20, :controller=>"pages", :action=>"show"} 91 | 92 | routes = [specific, generic] 93 | 94 | refute_equal specific.score(knowledge), generic.score(knowledge) 95 | 96 | found = routes.sort_by { |r| r.score(knowledge) }.last 97 | 98 | assert_equal specific, found 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/journey/visualizer/fsm.js: -------------------------------------------------------------------------------- 1 | function tokenize(input, callback) { 2 | while(input.length > 0) { 3 | callback(input.match(/^[\/\.\?]|[^\/\.\?]+/)[0]); 4 | input = input.replace(/^[\/\.\?]|[^\/\.\?]+/, ''); 5 | } 6 | } 7 | 8 | var graph = d3.select("#chart-2 svg"); 9 | var svg_edges = {}; 10 | var svg_nodes = {}; 11 | 12 | graph.selectAll("g.edge").each(function() { 13 | var node = d3.select(this); 14 | var index = node.select("title").text().split("->"); 15 | var left = parseInt(index[0]); 16 | var right = parseInt(index[1]); 17 | 18 | if(!svg_edges[left]) { svg_edges[left] = {} } 19 | svg_edges[left][right] = node; 20 | }); 21 | 22 | graph.selectAll("g.node").each(function() { 23 | var node = d3.select(this); 24 | var index = parseInt(node.select("title").text()); 25 | svg_nodes[index] = node; 26 | }); 27 | 28 | function reset_graph() { 29 | for(var key in svg_edges) { 30 | for(var mkey in svg_edges[key]) { 31 | var node = svg_edges[key][mkey]; 32 | var path = node.select("path"); 33 | var arrow = node.select("polygon"); 34 | path.style("stroke", "black"); 35 | arrow.style("stroke", "black").style("fill", "black"); 36 | } 37 | } 38 | 39 | for(var key in svg_nodes) { 40 | var node = svg_nodes[key]; 41 | node.select('ellipse').style("fill", "white"); 42 | node.select('polygon').style("fill", "white"); 43 | } 44 | return false; 45 | } 46 | 47 | function highlight_edge(from, to) { 48 | var node = svg_edges[from][to]; 49 | var path = node.select("path"); 50 | var arrow = node.select("polygon"); 51 | 52 | path 53 | .transition().duration(500) 54 | .style("stroke", "green"); 55 | 56 | arrow 57 | .transition().duration(500) 58 | .style("stroke", "green").style("fill", "green"); 59 | } 60 | 61 | function highlight_state(index, color) { 62 | if(!color) { color = "green"; } 63 | 64 | svg_nodes[index].select('ellipse') 65 | .style("fill", "white") 66 | .transition().duration(500) 67 | .style("fill", color); 68 | } 69 | 70 | function highlight_finish(index) { 71 | svg_nodes[index].select('polygon') 72 | .style("fill", "while") 73 | .transition().duration(500) 74 | .style("fill", "blue"); 75 | } 76 | 77 | function match(input) { 78 | reset_graph(); 79 | var table = tt(); 80 | var states = [0]; 81 | var regexp_states = table['regexp_states']; 82 | var string_states = table['string_states']; 83 | var accepting = table['accepting']; 84 | 85 | highlight_state(0); 86 | 87 | tokenize(input, function(token) { 88 | var new_states = []; 89 | for(var key in states) { 90 | var state = states[key]; 91 | 92 | if(string_states[state] && string_states[state][token]) { 93 | var new_state = string_states[state][token]; 94 | highlight_edge(state, new_state); 95 | highlight_state(new_state); 96 | new_states.push(new_state); 97 | } 98 | 99 | if(regexp_states[state]) { 100 | for(var key in regexp_states[state]) { 101 | var re = new RegExp("^" + key + "$"); 102 | if(re.test(token)) { 103 | var new_state = regexp_states[state][key]; 104 | highlight_edge(state, new_state); 105 | highlight_state(new_state); 106 | new_states.push(new_state); 107 | } 108 | } 109 | } 110 | } 111 | 112 | if(new_states.length == 0) { 113 | return; 114 | } 115 | states = new_states; 116 | }); 117 | 118 | for(var key in states) { 119 | var state = states[key]; 120 | if(accepting[state]) { 121 | for(var mkey in svg_edges[state]) { 122 | if(!regexp_states[mkey] && !string_states[mkey]) { 123 | highlight_edge(state, mkey); 124 | highlight_finish(mkey); 125 | } 126 | } 127 | } else { 128 | highlight_state(state, "red"); 129 | } 130 | } 131 | 132 | return false; 133 | } 134 | 135 | -------------------------------------------------------------------------------- /lib/journey/formatter.rb: -------------------------------------------------------------------------------- 1 | module Journey 2 | ### 3 | # The Formatter class is used for formatting URLs. For example, parameters 4 | # passed to +url_for+ in rails will eventually call Formatter#generate 5 | class Formatter 6 | attr_reader :routes 7 | 8 | def initialize routes 9 | @routes = routes 10 | @cache = nil 11 | end 12 | 13 | def generate type, name, options, recall = {}, parameterize = nil 14 | constraints = recall.merge options 15 | missing_keys = [] 16 | 17 | match_route(name, constraints) do |route| 18 | parameterized_parts = extract_parameterized_parts route, options, recall, parameterize 19 | next if !name && route.requirements.empty? && route.parts.empty? 20 | 21 | missing_keys = missing_keys(route, parameterized_parts) 22 | next unless missing_keys.empty? 23 | params = options.dup.delete_if do |key, _| 24 | parameterized_parts.key?(key) || route.defaults.key?(key) 25 | end 26 | 27 | return [route.format(parameterized_parts), params] 28 | end 29 | 30 | raise Router::RoutingError.new "missing required keys: #{missing_keys}" 31 | end 32 | 33 | def clear 34 | @cache = nil 35 | end 36 | 37 | private 38 | def extract_parameterized_parts route, options, recall, parameterize = nil 39 | constraints = recall.merge options 40 | data = constraints.dup 41 | 42 | keys_to_keep = route.parts.reverse.drop_while { |part| 43 | !options.key?(part) || (options[part] || recall[part]).nil? 44 | } | route.required_parts 45 | 46 | (data.keys - keys_to_keep).each do |bad_key| 47 | data.delete bad_key 48 | end 49 | 50 | parameterized_parts = data.dup 51 | 52 | if parameterize 53 | parameterized_parts.each do |k,v| 54 | parameterized_parts[k] = parameterize.call(k, v) 55 | end 56 | end 57 | 58 | parameterized_parts.keep_if { |_,v| v } 59 | parameterized_parts 60 | end 61 | 62 | def named_routes 63 | routes.named_routes 64 | end 65 | 66 | def match_route name, options 67 | if named_routes.key? name 68 | yield named_routes[name] 69 | else 70 | #routes = possibles(@cache, options.to_a) 71 | routes = non_recursive(cache, options.to_a) 72 | 73 | hash = routes.group_by { |_, r| 74 | r.score options 75 | } 76 | 77 | hash.keys.sort.reverse_each do |score| 78 | next if score < 0 79 | 80 | hash[score].sort_by { |i,_| i }.each do |_,route| 81 | yield route 82 | end 83 | end 84 | end 85 | end 86 | 87 | def non_recursive cache, options 88 | routes = [] 89 | stack = [cache] 90 | 91 | while stack.any? 92 | c = stack.shift 93 | routes.concat c[:___routes] if c.key? :___routes 94 | 95 | options.each do |pair| 96 | stack << c[pair] if c.key? pair 97 | end 98 | end 99 | 100 | routes 101 | end 102 | 103 | # returns an array populated with missing keys if any are present 104 | def missing_keys route, parts 105 | missing_keys = [] 106 | tests = route.path.requirements 107 | route.required_parts.each { |key| 108 | if tests.key? key 109 | missing_keys << key unless /\A#{tests[key]}\Z/ === parts[key] 110 | else 111 | missing_keys << key unless parts[key] 112 | end 113 | } 114 | missing_keys 115 | end 116 | 117 | def possibles cache, options, depth = 0 118 | cache.fetch(:___routes) { [] } + options.find_all { |pair| 119 | cache.key? pair 120 | }.map { |pair| 121 | possibles(cache[pair], options, depth + 1) 122 | }.flatten(1) 123 | end 124 | 125 | # returns boolean, true if no missing keys are present 126 | def verify_required_parts! route, parts 127 | missing_keys(route, parts).empty? 128 | end 129 | 130 | def build_cache 131 | root = { :___routes => [] } 132 | routes.each_with_index do |route, i| 133 | leaf = route.required_defaults.inject(root) do |h, tuple| 134 | h[tuple] ||= {} 135 | end 136 | (leaf[:___routes] ||= []) << [i, route] 137 | end 138 | root 139 | end 140 | 141 | def cache 142 | @cache ||= build_cache 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/journey/gtg/transition_table.rb: -------------------------------------------------------------------------------- 1 | require 'journey/nfa/dot' 2 | 3 | module Journey 4 | module GTG 5 | class TransitionTable 6 | include Journey::NFA::Dot 7 | 8 | attr_reader :memos 9 | 10 | def initialize 11 | @regexp_states = Hash.new { |h,k| h[k] = {} } 12 | @string_states = Hash.new { |h,k| h[k] = {} } 13 | @accepting = {} 14 | @memos = Hash.new { |h,k| h[k] = [] } 15 | end 16 | 17 | def add_accepting state 18 | @accepting[state] = true 19 | end 20 | 21 | def accepting_states 22 | @accepting.keys 23 | end 24 | 25 | def accepting? state 26 | @accepting[state] 27 | end 28 | 29 | def add_memo idx, memo 30 | @memos[idx] << memo 31 | end 32 | 33 | def memo idx 34 | @memos[idx] 35 | end 36 | 37 | def eclosure t 38 | Array(t) 39 | end 40 | 41 | def move t, a 42 | move_string(t, a).concat move_regexp(t, a) 43 | end 44 | 45 | def to_json 46 | require 'json' 47 | 48 | simple_regexp = Hash.new { |h,k| h[k] = {} } 49 | 50 | @regexp_states.each do |from, hash| 51 | hash.each do |re, to| 52 | simple_regexp[from][re.source] = to 53 | end 54 | end 55 | 56 | JSON.dump({ 57 | :regexp_states => simple_regexp, 58 | :string_states => @string_states, 59 | :accepting => @accepting 60 | }) 61 | end 62 | 63 | def to_svg 64 | svg = IO.popen("dot -Tsvg", 'w+') { |f| 65 | f.write to_dot 66 | f.close_write 67 | f.readlines 68 | } 69 | 3.times { svg.shift } 70 | svg.join.sub(/width="[^"]*"/, '').sub(/height="[^"]*"/, '') 71 | end 72 | 73 | def visualizer paths, title = 'FSM' 74 | viz_dir = File.join File.dirname(__FILE__), '..', 'visualizer' 75 | fsm_js = File.read File.join(viz_dir, 'fsm.js') 76 | fsm_css = File.read File.join(viz_dir, 'fsm.css') 77 | erb = File.read File.join(viz_dir, 'index.html.erb') 78 | states = "function tt() { return #{to_json}; }" 79 | 80 | fun_routes = paths.shuffle.first(3).map do |ast| 81 | ast.map { |n| 82 | case n 83 | when Nodes::Symbol 84 | case n.left 85 | when ':id' then rand(100).to_s 86 | when ':format' then %w{ xml json }.shuffle.first 87 | else 88 | 'omg' 89 | end 90 | when Nodes::Terminal then n.symbol 91 | else 92 | nil 93 | end 94 | }.compact.join 95 | end 96 | 97 | stylesheets = [fsm_css] 98 | svg = to_svg 99 | javascripts = [states, fsm_js] 100 | 101 | # Annoying hack for 1.9 warnings 102 | fun_routes = fun_routes 103 | stylesheets = stylesheets 104 | svg = svg 105 | javascripts = javascripts 106 | 107 | require 'erb' 108 | template = ERB.new erb 109 | template.result(binding) 110 | end 111 | 112 | def []= from, to, sym 113 | case sym 114 | when String 115 | @string_states[from][sym] = to 116 | when Regexp 117 | @regexp_states[from][sym] = to 118 | else 119 | raise ArgumentError, 'unknown symbol: %s' % sym.class 120 | end 121 | end 122 | 123 | def states 124 | ss = @string_states.keys + @string_states.values.map(&:values).flatten 125 | rs = @regexp_states.keys + @regexp_states.values.map(&:values).flatten 126 | (ss + rs).uniq 127 | end 128 | 129 | def transitions 130 | @string_states.map { |from, hash| 131 | hash.map { |s, to| [from, s, to] } 132 | }.flatten(1) + @regexp_states.map { |from, hash| 133 | hash.map { |s, to| [from, s, to] } 134 | }.flatten(1) 135 | end 136 | 137 | private 138 | def move_regexp t, a 139 | return [] if t.empty? 140 | 141 | t.map { |s| 142 | @regexp_states[s].map { |re,v| re === a ? v : nil } 143 | }.flatten.compact.uniq 144 | end 145 | 146 | def move_string t, a 147 | return [] if t.empty? 148 | 149 | t.map { |s| @string_states[s][a] }.compact 150 | end 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/journey/visitors.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module Journey 3 | module Visitors 4 | class Visitor # :nodoc: 5 | DISPATCH_CACHE = Hash.new { |h,k| 6 | h[k] = "visit_#{k}" 7 | } 8 | 9 | def accept node 10 | visit node 11 | end 12 | 13 | private 14 | def visit node 15 | send DISPATCH_CACHE[node.type], node 16 | end 17 | 18 | def binary node 19 | visit node.left 20 | visit node.right 21 | end 22 | def visit_CAT(n); binary(n); end 23 | 24 | def nary node 25 | node.children.each { |c| visit c } 26 | end 27 | def visit_OR(n); nary(n); end 28 | 29 | def unary node 30 | visit node.left 31 | end 32 | def visit_GROUP(n); unary(n); end 33 | def visit_STAR(n); unary(n); end 34 | 35 | def terminal node; end 36 | %w{ LITERAL SYMBOL SLASH DOT }.each do |t| 37 | class_eval %{ def visit_#{t}(n); terminal(n); end }, __FILE__, __LINE__ 38 | end 39 | end 40 | 41 | ## 42 | # Loop through the requirements AST 43 | class Each < Visitor # :nodoc: 44 | attr_reader :block 45 | 46 | def initialize block 47 | @block = block 48 | end 49 | 50 | def visit node 51 | super 52 | block.call node 53 | end 54 | end 55 | 56 | class String < Visitor 57 | private 58 | 59 | def binary node 60 | [visit(node.left), visit(node.right)].join 61 | end 62 | 63 | def nary node 64 | node.children.map { |c| visit c }.join '|' 65 | end 66 | 67 | def terminal node 68 | node.left 69 | end 70 | 71 | def visit_GROUP node 72 | "(#{visit node.left})" 73 | end 74 | end 75 | 76 | ### 77 | # Used for formatting urls (url_for) 78 | class Formatter < Visitor 79 | attr_reader :options, :consumed 80 | 81 | def initialize options 82 | @options = options 83 | @consumed = {} 84 | end 85 | 86 | private 87 | def visit_GROUP node 88 | if consumed == options 89 | nil 90 | else 91 | route = visit node.left 92 | route.include?("\0") ? nil : route 93 | end 94 | end 95 | 96 | def terminal node 97 | node.left 98 | end 99 | 100 | def binary node 101 | [visit(node.left), visit(node.right)].join 102 | end 103 | 104 | def nary node 105 | node.children.map { |c| visit c }.join 106 | end 107 | 108 | def visit_SYMBOL node 109 | key = node.to_sym 110 | 111 | if value = options[key] 112 | consumed[key] = value 113 | Router::Utils.escape_path(value) 114 | else 115 | "\0" 116 | end 117 | end 118 | end 119 | 120 | class Dot < Visitor 121 | def initialize 122 | @nodes = [] 123 | @edges = [] 124 | end 125 | 126 | def accept node 127 | super 128 | <<-eodot 129 | digraph parse_tree { 130 | size="8,5" 131 | node [shape = none]; 132 | edge [dir = none]; 133 | #{@nodes.join "\n"} 134 | #{@edges.join("\n")} 135 | } 136 | eodot 137 | end 138 | 139 | private 140 | def binary node 141 | node.children.each do |c| 142 | @edges << "#{node.object_id} -> #{c.object_id};" 143 | end 144 | super 145 | end 146 | 147 | def nary node 148 | node.children.each do |c| 149 | @edges << "#{node.object_id} -> #{c.object_id};" 150 | end 151 | super 152 | end 153 | 154 | def unary node 155 | @edges << "#{node.object_id} -> #{node.left.object_id};" 156 | super 157 | end 158 | 159 | def visit_GROUP node 160 | @nodes << "#{node.object_id} [label=\"()\"];" 161 | super 162 | end 163 | 164 | def visit_CAT node 165 | @nodes << "#{node.object_id} [label=\"○\"];" 166 | super 167 | end 168 | 169 | def visit_STAR node 170 | @nodes << "#{node.object_id} [label=\"*\"];" 171 | super 172 | end 173 | 174 | def visit_OR node 175 | @nodes << "#{node.object_id} [label=\"|\"];" 176 | super 177 | end 178 | 179 | def terminal node 180 | value = node.left 181 | 182 | @nodes << "#{node.object_id} [label=\"#{value}\"];" 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/journey/nfa/transition_table.rb: -------------------------------------------------------------------------------- 1 | require 'journey/nfa/dot' 2 | 3 | module Journey 4 | module NFA 5 | class TransitionTable 6 | include Journey::NFA::Dot 7 | 8 | attr_accessor :accepting 9 | attr_reader :memos 10 | 11 | def initialize 12 | @table = Hash.new { |h,f| h[f] = {} } 13 | @memos = {} 14 | @accepting = nil 15 | @inverted = nil 16 | end 17 | 18 | def accepting? state 19 | accepting == state 20 | end 21 | 22 | def accepting_states 23 | [accepting] 24 | end 25 | 26 | def add_memo idx, memo 27 | @memos[idx] = memo 28 | end 29 | 30 | def memo idx 31 | @memos[idx] 32 | end 33 | 34 | def []= i, f, s 35 | @table[f][i] = s 36 | end 37 | 38 | def merge left, right 39 | @memos[right] = @memos.delete left 40 | @table[right] = @table.delete(left) 41 | end 42 | 43 | def states 44 | (@table.keys + @table.values.map(&:keys).flatten).uniq 45 | end 46 | 47 | ### 48 | # Returns a generalized transition graph with reduced states. The states 49 | # are reduced like a DFA, but the table must be simulated like an NFA. 50 | # 51 | # Edges of the GTG are regular expressions 52 | def generalized_table 53 | gt = GTG::TransitionTable.new 54 | marked = {} 55 | state_id = Hash.new { |h,k| h[k] = h.length } 56 | alphabet = self.alphabet 57 | 58 | stack = [eclosure(0)] 59 | 60 | until stack.empty? 61 | state = stack.pop 62 | next if marked[state] || state.empty? 63 | 64 | marked[state] = true 65 | 66 | alphabet.each do |alpha| 67 | next_state = eclosure(following_states(state, alpha)) 68 | next if next_state.empty? 69 | 70 | gt[state_id[state], state_id[next_state]] = alpha 71 | stack << next_state 72 | end 73 | end 74 | 75 | final_groups = state_id.keys.find_all { |s| 76 | s.sort.last == accepting 77 | } 78 | 79 | final_groups.each do |states| 80 | id = state_id[states] 81 | 82 | gt.add_accepting id 83 | save = states.find { |s| 84 | @memos.key?(s) && eclosure(s).sort.last == accepting 85 | } 86 | 87 | gt.add_memo id, memo(save) 88 | end 89 | 90 | gt 91 | end 92 | 93 | ### 94 | # Returns set of NFA states to which there is a transition on ast symbol 95 | # +a+ from some state +s+ in +t+. 96 | def following_states t, a 97 | Array(t).map { |s| inverted[s][a] }.flatten.uniq 98 | end 99 | 100 | ### 101 | # Returns set of NFA states to which there is a transition on ast symbol 102 | # +a+ from some state +s+ in +t+. 103 | def move t, a 104 | Array(t).map { |s| 105 | inverted[s].keys.compact.find_all { |sym| 106 | sym === a 107 | }.map { |sym| inverted[s][sym] } 108 | }.flatten.uniq 109 | end 110 | 111 | def alphabet 112 | inverted.values.map(&:keys).flatten.compact.uniq.sort_by { |x| x.to_s } 113 | end 114 | 115 | ### 116 | # Returns a set of NFA states reachable from some NFA state +s+ in set 117 | # +t+ on nil-transitions alone. 118 | def eclosure t 119 | stack = Array(t) 120 | seen = {} 121 | children = [] 122 | 123 | until stack.empty? 124 | s = stack.pop 125 | next if seen[s] 126 | 127 | seen[s] = true 128 | children << s 129 | 130 | stack.concat inverted[s][nil] 131 | end 132 | 133 | children.uniq 134 | end 135 | 136 | def transitions 137 | @table.map { |to, hash| 138 | hash.map { |from, sym| [from, sym, to] } 139 | }.flatten(1) 140 | end 141 | 142 | private 143 | def inverted 144 | return @inverted if @inverted 145 | 146 | @inverted = Hash.new { |h,from| 147 | h[from] = Hash.new { |j,s| j[s] = [] } 148 | } 149 | 150 | @table.each { |to, hash| 151 | hash.each { |from, sym| 152 | if sym 153 | sym = Nodes::Symbol === sym ? sym.regexp : sym.left 154 | end 155 | 156 | @inverted[from][sym] << to 157 | } 158 | } 159 | 160 | @inverted 161 | end 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/journey/gtg/builder.rb: -------------------------------------------------------------------------------- 1 | require 'journey/gtg/transition_table' 2 | 3 | module Journey 4 | module GTG 5 | class Builder 6 | DUMMY = Nodes::Dummy.new # :nodoc: 7 | 8 | attr_reader :root, :ast, :endpoints 9 | 10 | def initialize root 11 | @root = root 12 | @ast = Nodes::Cat.new root, DUMMY 13 | @followpos = nil 14 | end 15 | 16 | def transition_table 17 | dtrans = TransitionTable.new 18 | marked = {} 19 | state_id = Hash.new { |h,k| h[k] = h.length } 20 | 21 | start = firstpos(root) 22 | dstates = [start] 23 | until dstates.empty? 24 | s = dstates.shift 25 | next if marked[s] 26 | marked[s] = true # mark s 27 | 28 | s.group_by { |state| symbol(state) }.each do |sym, ps| 29 | u = ps.map { |l| followpos(l) }.flatten 30 | next if u.empty? 31 | 32 | if u.uniq == [DUMMY] 33 | from = state_id[s] 34 | to = state_id[Object.new] 35 | dtrans[from, to] = sym 36 | 37 | dtrans.add_accepting to 38 | ps.each { |state| dtrans.add_memo to, state.memo } 39 | else 40 | dtrans[state_id[s], state_id[u]] = sym 41 | 42 | if u.include? DUMMY 43 | to = state_id[u] 44 | 45 | accepting = ps.find_all { |l| followpos(l).include? DUMMY } 46 | 47 | accepting.each { |accepting_state| 48 | dtrans.add_memo to, accepting_state.memo 49 | } 50 | 51 | dtrans.add_accepting state_id[u] 52 | end 53 | end 54 | 55 | dstates << u 56 | end 57 | end 58 | 59 | dtrans 60 | end 61 | 62 | def nullable? node 63 | case node 64 | when Nodes::Group 65 | true 66 | when Nodes::Star 67 | true 68 | when Nodes::Or 69 | node.children.any? { |c| nullable?(c) } 70 | when Nodes::Cat 71 | nullable?(node.left) && nullable?(node.right) 72 | when Nodes::Terminal 73 | !node.left 74 | when Nodes::Unary 75 | nullable? node.left 76 | else 77 | raise ArgumentError, 'unknown nullable: %s' % node.class.name 78 | end 79 | end 80 | 81 | def firstpos node 82 | case node 83 | when Nodes::Star 84 | firstpos(node.left) 85 | when Nodes::Cat 86 | if nullable? node.left 87 | firstpos(node.left) | firstpos(node.right) 88 | else 89 | firstpos(node.left) 90 | end 91 | when Nodes::Or 92 | node.children.map { |c| firstpos(c) }.flatten.uniq 93 | when Nodes::Unary 94 | firstpos(node.left) 95 | when Nodes::Terminal 96 | nullable?(node) ? [] : [node] 97 | else 98 | raise ArgumentError, 'unknown firstpos: %s' % node.class.name 99 | end 100 | end 101 | 102 | def lastpos node 103 | case node 104 | when Nodes::Star 105 | firstpos(node.left) 106 | when Nodes::Or 107 | node.children.map { |c| lastpos(c) }.flatten.uniq 108 | when Nodes::Cat 109 | if nullable? node.right 110 | lastpos(node.left) | lastpos(node.right) 111 | else 112 | lastpos(node.right) 113 | end 114 | when Nodes::Terminal 115 | nullable?(node) ? [] : [node] 116 | when Nodes::Unary 117 | lastpos(node.left) 118 | else 119 | raise ArgumentError, 'unknown lastpos: %s' % node.class.name 120 | end 121 | end 122 | 123 | def followpos node 124 | followpos_table[node] 125 | end 126 | 127 | private 128 | def followpos_table 129 | @followpos ||= build_followpos 130 | end 131 | 132 | def build_followpos 133 | table = Hash.new { |h,k| h[k] = [] } 134 | @ast.each do |n| 135 | case n 136 | when Nodes::Cat 137 | lastpos(n.left).each do |i| 138 | table[i] += firstpos(n.right) 139 | end 140 | when Nodes::Star 141 | lastpos(n).each do |i| 142 | table[i] += firstpos(n) 143 | end 144 | end 145 | end 146 | table 147 | end 148 | 149 | def symbol edge 150 | case edge 151 | when Journey::Nodes::Symbol 152 | edge.regexp 153 | else 154 | edge.left 155 | end 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/journey/router.rb: -------------------------------------------------------------------------------- 1 | require 'journey/router/utils' 2 | require 'journey/router/strexp' 3 | require 'journey/routes' 4 | require 'journey/formatter' 5 | 6 | before = $-w 7 | $-w = false 8 | require 'journey/parser' 9 | $-w = before 10 | 11 | require 'journey/route' 12 | require 'journey/path/pattern' 13 | 14 | module Journey 15 | class Router 16 | class RoutingError < ::StandardError 17 | end 18 | 19 | VERSION = '2.0.0' 20 | 21 | class NullReq # :nodoc: 22 | attr_reader :env 23 | def initialize env 24 | @env = env 25 | end 26 | 27 | def request_method 28 | env['REQUEST_METHOD'] 29 | end 30 | 31 | def path_info 32 | env['PATH_INFO'] 33 | end 34 | 35 | def ip 36 | env['REMOTE_ADDR'] 37 | end 38 | 39 | def [](k); env[k]; end 40 | end 41 | 42 | attr_reader :request_class, :formatter 43 | attr_accessor :routes 44 | 45 | def initialize routes, options 46 | @options = options 47 | @params_key = options[:parameters_key] 48 | @request_class = options[:request_class] || NullReq 49 | @routes = routes 50 | end 51 | 52 | def call env 53 | env['PATH_INFO'] = Utils.normalize_path env['PATH_INFO'] 54 | 55 | find_routes(env).each do |match, parameters, route| 56 | script_name, path_info, set_params = env.values_at('SCRIPT_NAME', 57 | 'PATH_INFO', 58 | @params_key) 59 | 60 | unless route.path.anchored 61 | env['SCRIPT_NAME'] = (script_name.to_s + match.to_s).chomp('/') 62 | env['PATH_INFO'] = match.post_match 63 | end 64 | 65 | env[@params_key] = (set_params || {}).merge parameters 66 | 67 | status, headers, body = route.app.call(env) 68 | 69 | if 'pass' == headers['X-Cascade'] 70 | env['SCRIPT_NAME'] = script_name 71 | env['PATH_INFO'] = path_info 72 | env[@params_key] = set_params 73 | next 74 | end 75 | 76 | return [status, headers, body] 77 | end 78 | 79 | return [404, {'X-Cascade' => 'pass'}, ['Not Found']] 80 | end 81 | 82 | def recognize req 83 | find_routes(req.env).each do |match, parameters, route| 84 | unless route.path.anchored 85 | req.env['SCRIPT_NAME'] = match.to_s 86 | req.env['PATH_INFO'] = match.post_match.sub(/^([^\/])/, '/\1') 87 | end 88 | 89 | yield(route, nil, parameters) 90 | end 91 | end 92 | 93 | def visualizer 94 | tt = GTG::Builder.new(ast).transition_table 95 | groups = partitioned_routes.first.map(&:ast).group_by { |a| a.to_s } 96 | asts = groups.values.map { |v| v.first } 97 | tt.visualizer asts 98 | end 99 | 100 | private 101 | 102 | def partitioned_routes 103 | routes.partitioned_routes 104 | end 105 | 106 | def ast 107 | routes.ast 108 | end 109 | 110 | def simulator 111 | routes.simulator 112 | end 113 | 114 | def custom_routes 115 | partitioned_routes.last 116 | end 117 | 118 | def filter_routes path 119 | return [] unless ast 120 | data = simulator.match(path) 121 | data ? data.memos : [] 122 | end 123 | 124 | def find_routes env 125 | req = request_class.new env 126 | 127 | routes = filter_routes(req.path_info).concat custom_routes.find_all { |r| 128 | r.path.match(req.path_info) 129 | } 130 | routes.concat get_routes_as_head(routes) 131 | 132 | routes.sort_by!(&:precedence).select! { |r| 133 | r.constraints.all? { |k,v| v === req.send(k) } && 134 | r.verb === req.request_method 135 | } 136 | routes.reject! { |r| req.ip && !(r.ip === req.ip) } 137 | 138 | routes.map! { |r| 139 | match_data = r.path.match(req.path_info) 140 | match_names = match_data.names.map { |n| n.to_sym } 141 | match_values = match_data.captures.map { |v| v && Utils.unescape_uri(v) } 142 | info = Hash[match_names.zip(match_values).find_all { |_,y| y }] 143 | 144 | [match_data, r.defaults.merge(info), r] 145 | } 146 | end 147 | 148 | def get_routes_as_head(routes) 149 | precedence = (routes.map(&:precedence).max || 0) + 1 150 | routes = routes.select { |r| 151 | r.verb === "GET" && !(r.verb === "HEAD") 152 | }.map! { |r| 153 | Route.new(r.name, 154 | r.app, 155 | r.path, 156 | r.conditions.merge(:request_method => "HEAD"), 157 | r.defaults).tap do |route| 158 | route.precedence = r.precedence + precedence 159 | end 160 | } 161 | routes.flatten! 162 | routes 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /lib/journey/parser.rb: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT MODIFY!!!! 3 | # This file is automatically generated by Racc 1.4.8 4 | # from Racc grammer file "". 5 | # 6 | 7 | require 'racc/parser.rb' 8 | 9 | 10 | require 'journey/parser_extras' 11 | module Journey 12 | class Parser < Racc::Parser 13 | ##### State transition tables begin ### 14 | 15 | racc_action_table = [ 16 | 17, 21, 13, 15, 14, 7, nil, 16, 8, 19, 17 | 13, 15, 14, 7, 23, 16, 8, 19, 13, 15, 18 | 14, 7, nil, 16, 8, 13, 15, 14, 7, nil, 19 | 16, 8, 13, 15, 14, 7, nil, 16, 8 ] 20 | 21 | racc_action_check = [ 22 | 1, 17, 1, 1, 1, 1, nil, 1, 1, 1, 23 | 20, 20, 20, 20, 20, 20, 20, 20, 7, 7, 24 | 7, 7, nil, 7, 7, 19, 19, 19, 19, nil, 25 | 19, 19, 0, 0, 0, 0, nil, 0, 0 ] 26 | 27 | racc_action_pointer = [ 28 | 30, 0, nil, nil, nil, nil, nil, 16, nil, nil, 29 | nil, nil, nil, nil, nil, nil, nil, 1, nil, 23, 30 | 8, nil, nil, nil ] 31 | 32 | racc_action_default = [ 33 | -18, -18, -2, -3, -4, -5, -6, -18, -9, -10, 34 | -11, -12, -13, -14, -15, -16, -17, -18, -1, -18, 35 | -18, 24, -8, -7 ] 36 | 37 | racc_goto_table = [ 38 | 18, 1, nil, nil, nil, nil, nil, nil, 20, nil, 39 | nil, nil, nil, nil, nil, nil, nil, nil, 22, 18 ] 40 | 41 | racc_goto_check = [ 42 | 2, 1, nil, nil, nil, nil, nil, nil, 1, nil, 43 | nil, nil, nil, nil, nil, nil, nil, nil, 2, 2 ] 44 | 45 | racc_goto_pointer = [ 46 | nil, 1, -1, nil, nil, nil, nil, nil, nil, nil, 47 | nil ] 48 | 49 | racc_goto_default = [ 50 | nil, nil, 2, 3, 4, 5, 6, 9, 10, 11, 51 | 12 ] 52 | 53 | racc_reduce_table = [ 54 | 0, 0, :racc_error, 55 | 2, 11, :_reduce_1, 56 | 1, 11, :_reduce_2, 57 | 1, 11, :_reduce_none, 58 | 1, 12, :_reduce_none, 59 | 1, 12, :_reduce_none, 60 | 1, 12, :_reduce_none, 61 | 3, 15, :_reduce_7, 62 | 3, 13, :_reduce_8, 63 | 1, 16, :_reduce_9, 64 | 1, 14, :_reduce_none, 65 | 1, 14, :_reduce_none, 66 | 1, 14, :_reduce_none, 67 | 1, 14, :_reduce_none, 68 | 1, 19, :_reduce_14, 69 | 1, 17, :_reduce_15, 70 | 1, 18, :_reduce_16, 71 | 1, 20, :_reduce_17 ] 72 | 73 | racc_reduce_n = 18 74 | 75 | racc_shift_n = 24 76 | 77 | racc_token_table = { 78 | false => 0, 79 | :error => 1, 80 | :SLASH => 2, 81 | :LITERAL => 3, 82 | :SYMBOL => 4, 83 | :LPAREN => 5, 84 | :RPAREN => 6, 85 | :DOT => 7, 86 | :STAR => 8, 87 | :OR => 9 } 88 | 89 | racc_nt_base = 10 90 | 91 | racc_use_result_var = true 92 | 93 | Racc_arg = [ 94 | racc_action_table, 95 | racc_action_check, 96 | racc_action_default, 97 | racc_action_pointer, 98 | racc_goto_table, 99 | racc_goto_check, 100 | racc_goto_default, 101 | racc_goto_pointer, 102 | racc_nt_base, 103 | racc_reduce_table, 104 | racc_token_table, 105 | racc_shift_n, 106 | racc_reduce_n, 107 | racc_use_result_var ] 108 | 109 | Racc_token_to_s_table = [ 110 | "$end", 111 | "error", 112 | "SLASH", 113 | "LITERAL", 114 | "SYMBOL", 115 | "LPAREN", 116 | "RPAREN", 117 | "DOT", 118 | "STAR", 119 | "OR", 120 | "$start", 121 | "expressions", 122 | "expression", 123 | "or", 124 | "terminal", 125 | "group", 126 | "star", 127 | "symbol", 128 | "literal", 129 | "slash", 130 | "dot" ] 131 | 132 | Racc_debug_parser = false 133 | 134 | ##### State transition tables end ##### 135 | 136 | # reduce 0 omitted 137 | 138 | def _reduce_1(val, _values, result) 139 | result = Cat.new(val.first, val.last) 140 | result 141 | end 142 | 143 | def _reduce_2(val, _values, result) 144 | result = val.first 145 | result 146 | end 147 | 148 | # reduce 3 omitted 149 | 150 | # reduce 4 omitted 151 | 152 | # reduce 5 omitted 153 | 154 | # reduce 6 omitted 155 | 156 | def _reduce_7(val, _values, result) 157 | result = Group.new(val[1]) 158 | result 159 | end 160 | 161 | def _reduce_8(val, _values, result) 162 | result = Or.new([val.first, val.last]) 163 | result 164 | end 165 | 166 | def _reduce_9(val, _values, result) 167 | result = Star.new(Symbol.new(val.last)) 168 | result 169 | end 170 | 171 | # reduce 10 omitted 172 | 173 | # reduce 11 omitted 174 | 175 | # reduce 12 omitted 176 | 177 | # reduce 13 omitted 178 | 179 | def _reduce_14(val, _values, result) 180 | result = Slash.new('/') 181 | result 182 | end 183 | 184 | def _reduce_15(val, _values, result) 185 | result = Symbol.new(val.first) 186 | result 187 | end 188 | 189 | def _reduce_16(val, _values, result) 190 | result = Literal.new(val.first) 191 | result 192 | end 193 | 194 | def _reduce_17(val, _values, result) 195 | result = Dot.new(val.first) 196 | result 197 | end 198 | 199 | def _reduce_none(val, _values, result) 200 | val[0] 201 | end 202 | 203 | end # class Parser 204 | end # module Journey 205 | -------------------------------------------------------------------------------- /lib/journey/path/pattern.rb: -------------------------------------------------------------------------------- 1 | module Journey 2 | module Path 3 | class Pattern 4 | attr_reader :spec, :requirements, :anchored 5 | 6 | def initialize strexp 7 | parser = Journey::Parser.new 8 | 9 | @anchored = true 10 | 11 | case strexp 12 | when String 13 | @spec = parser.parse strexp 14 | @requirements = {} 15 | @separators = "/.?" 16 | when Router::Strexp 17 | @spec = parser.parse strexp.path 18 | @requirements = strexp.requirements 19 | @separators = strexp.separators.join 20 | @anchored = strexp.anchor 21 | else 22 | raise "wtf bro: #{strexp}" 23 | end 24 | 25 | @names = nil 26 | @optional_names = nil 27 | @required_names = nil 28 | @re = nil 29 | @offsets = nil 30 | end 31 | 32 | def ast 33 | @spec.grep(Nodes::Symbol).each do |node| 34 | re = @requirements[node.to_sym] 35 | node.regexp = re if re 36 | end 37 | 38 | @spec.grep(Nodes::Star).each do |node| 39 | node = node.left 40 | node.regexp = @requirements[node.to_sym] || /(.+)/ 41 | end 42 | 43 | @spec 44 | end 45 | 46 | def names 47 | @names ||= spec.grep(Nodes::Symbol).map { |n| n.name } 48 | end 49 | 50 | def required_names 51 | @required_names ||= names - optional_names 52 | end 53 | 54 | def optional_names 55 | @optional_names ||= spec.grep(Nodes::Group).map { |group| 56 | group.grep(Nodes::Symbol) 57 | }.flatten.map { |n| n.name }.uniq 58 | end 59 | 60 | class RegexpOffsets < Journey::Visitors::Visitor # :nodoc: 61 | attr_reader :offsets 62 | 63 | def initialize matchers 64 | @matchers = matchers 65 | @capture_count = [0] 66 | end 67 | 68 | def visit node 69 | super 70 | @capture_count 71 | end 72 | 73 | def visit_SYMBOL node 74 | node = node.to_sym 75 | 76 | if @matchers.key? node 77 | re = /#{@matchers[node]}|/ 78 | @capture_count.push((re.match('').length - 1) + (@capture_count.last || 0)) 79 | else 80 | @capture_count << (@capture_count.last || 0) 81 | end 82 | end 83 | end 84 | 85 | class AnchoredRegexp < Journey::Visitors::Visitor # :nodoc: 86 | def initialize separator, matchers 87 | @separator = separator 88 | @matchers = matchers 89 | @separator_re = "([^#{separator}]+)" 90 | super() 91 | end 92 | 93 | def accept node 94 | %r{\A#{visit node}\Z} 95 | end 96 | 97 | def visit_CAT node 98 | [visit(node.left), visit(node.right)].join 99 | end 100 | 101 | def visit_SYMBOL node 102 | node = node.to_sym 103 | 104 | return @separator_re unless @matchers.key? node 105 | 106 | re = @matchers[node] 107 | "(#{re})" 108 | end 109 | 110 | def visit_GROUP node 111 | "(?:#{visit node.left})?" 112 | end 113 | 114 | def visit_LITERAL node 115 | Regexp.escape node.left 116 | end 117 | alias :visit_DOT :visit_LITERAL 118 | 119 | def visit_SLASH node 120 | node.left 121 | end 122 | 123 | def visit_STAR node 124 | re = @matchers[node.left.to_sym] || '.+' 125 | "(#{re})" 126 | end 127 | end 128 | 129 | class UnanchoredRegexp < AnchoredRegexp # :nodoc: 130 | def accept node 131 | %r{\A#{visit node}} 132 | end 133 | end 134 | 135 | class MatchData 136 | attr_reader :names 137 | 138 | def initialize names, offsets, match 139 | @names = names 140 | @offsets = offsets 141 | @match = match 142 | end 143 | 144 | def captures 145 | (length - 1).times.map { |i| self[i + 1] } 146 | end 147 | 148 | def [] x 149 | idx = @offsets[x - 1] + x 150 | @match[idx] 151 | end 152 | 153 | def length 154 | @offsets.length 155 | end 156 | 157 | def post_match 158 | @match.post_match 159 | end 160 | 161 | def to_s 162 | @match.to_s 163 | end 164 | end 165 | 166 | def match other 167 | return unless match = to_regexp.match(other) 168 | MatchData.new names, offsets, match 169 | end 170 | alias :=~ :match 171 | 172 | def source 173 | to_regexp.source 174 | end 175 | 176 | def to_regexp 177 | @re ||= regexp_visitor.new(@separators, @requirements).accept spec 178 | end 179 | 180 | private 181 | def regexp_visitor 182 | @anchored ? AnchoredRegexp : UnanchoredRegexp 183 | end 184 | 185 | def offsets 186 | return @offsets if @offsets 187 | 188 | viz = RegexpOffsets.new @requirements 189 | @offsets = viz.accept spec 190 | end 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /test/path/test_pattern.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Journey 4 | module Path 5 | class TestPattern < MiniTest::Unit::TestCase 6 | x = /.+/ 7 | { 8 | '/:controller(/:action)' => %r{\A/(#{x})(?:/([^/.?]+))?\Z}, 9 | '/:controller/foo' => %r{\A/(#{x})/foo\Z}, 10 | '/:controller/:action' => %r{\A/(#{x})/([^/.?]+)\Z}, 11 | '/:controller' => %r{\A/(#{x})\Z}, 12 | '/:controller(/:action(/:id))' => %r{\A/(#{x})(?:/([^/.?]+)(?:/([^/.?]+))?)?\Z}, 13 | '/:controller/:action.xml' => %r{\A/(#{x})/([^/.?]+)\.xml\Z}, 14 | '/:controller.:format' => %r{\A/(#{x})\.([^/.?]+)\Z}, 15 | '/:controller(.:format)' => %r{\A/(#{x})(?:\.([^/.?]+))?\Z}, 16 | '/:controller/*foo' => %r{\A/(#{x})/(.+)\Z}, 17 | '/:controller/*foo/bar' => %r{\A/(#{x})/(.+)/bar\Z}, 18 | }.each do |path, expected| 19 | define_method(:"test_to_regexp_#{path}") do 20 | strexp = Router::Strexp.new( 21 | path, 22 | { :controller => /.+/ }, 23 | ["/", ".", "?"] 24 | ) 25 | path = Pattern.new strexp 26 | assert_equal(expected, path.to_regexp) 27 | end 28 | end 29 | 30 | { 31 | '/:controller(/:action)' => %r{\A/(#{x})(?:/([^/.?]+))?}, 32 | '/:controller/foo' => %r{\A/(#{x})/foo}, 33 | '/:controller/:action' => %r{\A/(#{x})/([^/.?]+)}, 34 | '/:controller' => %r{\A/(#{x})}, 35 | '/:controller(/:action(/:id))' => %r{\A/(#{x})(?:/([^/.?]+)(?:/([^/.?]+))?)?}, 36 | '/:controller/:action.xml' => %r{\A/(#{x})/([^/.?]+)\.xml}, 37 | '/:controller.:format' => %r{\A/(#{x})\.([^/.?]+)}, 38 | '/:controller(.:format)' => %r{\A/(#{x})(?:\.([^/.?]+))?}, 39 | '/:controller/*foo' => %r{\A/(#{x})/(.+)}, 40 | '/:controller/*foo/bar' => %r{\A/(#{x})/(.+)/bar}, 41 | }.each do |path, expected| 42 | define_method(:"test_to_non_anchored_regexp_#{path}") do 43 | strexp = Router::Strexp.new( 44 | path, 45 | { :controller => /.+/ }, 46 | ["/", ".", "?"], 47 | false 48 | ) 49 | path = Pattern.new strexp 50 | assert_equal(expected, path.to_regexp) 51 | end 52 | end 53 | 54 | { 55 | '/:controller(/:action)' => %w{ controller action }, 56 | '/:controller/foo' => %w{ controller }, 57 | '/:controller/:action' => %w{ controller action }, 58 | '/:controller' => %w{ controller }, 59 | '/:controller(/:action(/:id))' => %w{ controller action id }, 60 | '/:controller/:action.xml' => %w{ controller action }, 61 | '/:controller.:format' => %w{ controller format }, 62 | '/:controller(.:format)' => %w{ controller format }, 63 | '/:controller/*foo' => %w{ controller foo }, 64 | '/:controller/*foo/bar' => %w{ controller foo }, 65 | }.each do |path, expected| 66 | define_method(:"test_names_#{path}") do 67 | strexp = Router::Strexp.new( 68 | path, 69 | { :controller => /.+/ }, 70 | ["/", ".", "?"] 71 | ) 72 | path = Pattern.new strexp 73 | assert_equal(expected, path.names) 74 | end 75 | end 76 | 77 | def test_to_regexp_with_extended_group 78 | strexp = Router::Strexp.new( 79 | '/page/:name', 80 | { :name => / 81 | #ROFL 82 | (tender|love 83 | #MAO 84 | )/x }, 85 | ["/", ".", "?"] 86 | ) 87 | path = Pattern.new strexp 88 | assert_match(path, '/page/tender') 89 | assert_match(path, '/page/love') 90 | refute_match(path, '/page/loving') 91 | end 92 | 93 | def test_optional_names 94 | [ 95 | ['/:foo(/:bar(/:baz))', %w{ bar baz }], 96 | ['/:foo(/:bar)', %w{ bar }], 97 | ['/:foo(/:bar)/:lol(/:baz)', %w{ bar baz }], 98 | ].each do |pattern, list| 99 | path = Pattern.new pattern 100 | assert_equal list.sort, path.optional_names.sort 101 | end 102 | end 103 | 104 | def test_to_regexp_match_non_optional 105 | strexp = Router::Strexp.new( 106 | '/:name', 107 | { :name => /\d+/ }, 108 | ["/", ".", "?"] 109 | ) 110 | path = Pattern.new strexp 111 | assert_match(path, '/123') 112 | refute_match(path, '/') 113 | end 114 | 115 | def test_to_regexp_with_group 116 | strexp = Router::Strexp.new( 117 | '/page/:name', 118 | { :name => /(tender|love)/ }, 119 | ["/", ".", "?"] 120 | ) 121 | path = Pattern.new strexp 122 | assert_match(path, '/page/tender') 123 | assert_match(path, '/page/love') 124 | refute_match(path, '/page/loving') 125 | end 126 | 127 | def test_ast_sets_regular_expressions 128 | requirements = { :name => /(tender|love)/, :value => /./ } 129 | strexp = Router::Strexp.new( 130 | '/page/:name/:value', 131 | requirements, 132 | ["/", ".", "?"] 133 | ) 134 | 135 | assert_equal requirements, strexp.requirements 136 | 137 | path = Pattern.new strexp 138 | nodes = path.ast.grep(Nodes::Symbol) 139 | assert_equal 2, nodes.length 140 | nodes.each do |node| 141 | assert_equal requirements[node.to_sym], node.regexp 142 | end 143 | end 144 | 145 | def test_match_data_with_group 146 | strexp = Router::Strexp.new( 147 | '/page/:name', 148 | { :name => /(tender|love)/ }, 149 | ["/", ".", "?"] 150 | ) 151 | path = Pattern.new strexp 152 | match = path.match '/page/tender' 153 | assert_equal 'tender', match[1] 154 | assert_equal 2, match.length 155 | end 156 | 157 | def test_match_data_with_multi_group 158 | strexp = Router::Strexp.new( 159 | '/page/:name/:id', 160 | { :name => /t(((ender|love)))()/ }, 161 | ["/", ".", "?"] 162 | ) 163 | path = Pattern.new strexp 164 | match = path.match '/page/tender/10' 165 | assert_equal 'tender', match[1] 166 | assert_equal '10', match[2] 167 | assert_equal 3, match.length 168 | assert_equal %w{ tender 10 }, match.captures 169 | end 170 | 171 | def test_star_with_custom_re 172 | z = /\d+/ 173 | strexp = Router::Strexp.new( 174 | '/page/*foo', 175 | { :foo => z }, 176 | ["/", ".", "?"] 177 | ) 178 | path = Pattern.new strexp 179 | assert_equal(%r{\A/page/(#{z})\Z}, path.to_regexp) 180 | end 181 | 182 | def test_insensitive_regexp_with_group 183 | strexp = Router::Strexp.new( 184 | '/page/:name/aaron', 185 | { :name => /(tender|love)/i }, 186 | ["/", ".", "?"] 187 | ) 188 | path = Pattern.new strexp 189 | assert_match(path, '/page/TENDER/aaron') 190 | assert_match(path, '/page/loVE/aaron') 191 | refute_match(path, '/page/loVE/AAron') 192 | end 193 | 194 | def test_to_regexp_with_strexp 195 | strexp = Router::Strexp.new('/:controller', { }, ["/", ".", "?"]) 196 | path = Pattern.new strexp 197 | x = %r{\A/([^/.?]+)\Z} 198 | 199 | assert_equal(x.source, path.source) 200 | end 201 | 202 | def test_to_regexp_defaults 203 | path = Pattern.new '/:controller(/:action(/:id))' 204 | expected = %r{\A/([^/.?]+)(?:/([^/.?]+)(?:/([^/.?]+))?)?\Z} 205 | assert_equal expected, path.to_regexp 206 | end 207 | 208 | def test_failed_match 209 | path = Pattern.new '/:controller(/:action(/:id(.:format)))' 210 | uri = 'content' 211 | 212 | refute path =~ uri 213 | end 214 | 215 | def test_match_controller 216 | path = Pattern.new '/:controller(/:action(/:id(.:format)))' 217 | uri = '/content' 218 | 219 | match = path =~ uri 220 | assert_equal %w{ controller action id format }, match.names 221 | assert_equal 'content', match[1] 222 | assert_nil match[2] 223 | assert_nil match[3] 224 | assert_nil match[4] 225 | end 226 | 227 | def test_match_controller_action 228 | path = Pattern.new '/:controller(/:action(/:id(.:format)))' 229 | uri = '/content/list' 230 | 231 | match = path =~ uri 232 | assert_equal %w{ controller action id format }, match.names 233 | assert_equal 'content', match[1] 234 | assert_equal 'list', match[2] 235 | assert_nil match[3] 236 | assert_nil match[4] 237 | end 238 | 239 | def test_match_controller_action_id 240 | path = Pattern.new '/:controller(/:action(/:id(.:format)))' 241 | uri = '/content/list/10' 242 | 243 | match = path =~ uri 244 | assert_equal %w{ controller action id format }, match.names 245 | assert_equal 'content', match[1] 246 | assert_equal 'list', match[2] 247 | assert_equal '10', match[3] 248 | assert_nil match[4] 249 | end 250 | 251 | def test_match_literal 252 | path = Path::Pattern.new "/books(/:action(.:format))" 253 | 254 | uri = '/books' 255 | match = path =~ uri 256 | assert_equal %w{ action format }, match.names 257 | assert_nil match[1] 258 | assert_nil match[2] 259 | end 260 | 261 | def test_match_literal_with_action 262 | path = Path::Pattern.new "/books(/:action(.:format))" 263 | 264 | uri = '/books/list' 265 | match = path =~ uri 266 | assert_equal %w{ action format }, match.names 267 | assert_equal 'list', match[1] 268 | assert_nil match[2] 269 | end 270 | 271 | def test_match_literal_with_action_and_format 272 | path = Path::Pattern.new "/books(/:action(.:format))" 273 | 274 | uri = '/books/list.rss' 275 | match = path =~ uri 276 | assert_equal %w{ action format }, match.names 277 | assert_equal 'list', match[1] 278 | assert_equal 'rss', match[2] 279 | end 280 | end 281 | end 282 | end 283 | -------------------------------------------------------------------------------- /test/test_router.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require 'helper' 3 | 4 | module Journey 5 | class TestRouter < MiniTest::Unit::TestCase 6 | attr_reader :routes 7 | 8 | def setup 9 | @routes = Routes.new 10 | @router = Router.new(@routes, {}) 11 | @formatter = Formatter.new(@routes) 12 | end 13 | 14 | def test_request_class_reader 15 | klass = Object.new 16 | router = Router.new(routes, :request_class => klass) 17 | assert_equal klass, router.request_class 18 | end 19 | 20 | class FakeRequestFeeler < Struct.new(:env, :called) 21 | def new env 22 | self.env = env 23 | self 24 | end 25 | 26 | def hello 27 | self.called = true 28 | 'world' 29 | end 30 | 31 | def path_info; env['PATH_INFO']; end 32 | def request_method; env['REQUEST_METHOD']; end 33 | def ip; env['REMOTE_ADDR']; end 34 | end 35 | 36 | def test_dashes 37 | router = Router.new(routes, {}) 38 | 39 | exp = Router::Strexp.new '/foo-bar-baz', {}, ['/.?'] 40 | path = Path::Pattern.new exp 41 | 42 | routes.add_route nil, path, {}, {:id => nil}, {} 43 | 44 | env = rails_env 'PATH_INFO' => '/foo-bar-baz' 45 | called = false 46 | router.recognize(env) do |r, _, params| 47 | called = true 48 | end 49 | assert called 50 | end 51 | 52 | def test_unicode 53 | router = Router.new(routes, {}) 54 | 55 | #match the escaped version of /ほげ 56 | exp = Router::Strexp.new '/%E3%81%BB%E3%81%92', {}, ['/.?'] 57 | path = Path::Pattern.new exp 58 | 59 | routes.add_route nil, path, {}, {:id => nil}, {} 60 | 61 | env = rails_env 'PATH_INFO' => '/%E3%81%BB%E3%81%92' 62 | called = false 63 | router.recognize(env) do |r, _, params| 64 | called = true 65 | end 66 | assert called 67 | end 68 | 69 | def test_request_class_and_requirements_success 70 | klass = FakeRequestFeeler.new nil 71 | router = Router.new(routes, {:request_class => klass }) 72 | 73 | requirements = { :hello => /world/ } 74 | 75 | exp = Router::Strexp.new '/foo(/:id)', {}, ['/.?'] 76 | path = Path::Pattern.new exp 77 | 78 | routes.add_route nil, path, requirements, {:id => nil}, {} 79 | 80 | env = rails_env 'PATH_INFO' => '/foo/10' 81 | router.recognize(env) do |r, _, params| 82 | assert_equal({:id => '10'}, params) 83 | end 84 | 85 | assert klass.called, 'hello should have been called' 86 | assert_equal env.env, klass.env 87 | end 88 | 89 | def test_request_class_and_requirements_fail 90 | klass = FakeRequestFeeler.new nil 91 | router = Router.new(routes, {:request_class => klass }) 92 | 93 | requirements = { :hello => /mom/ } 94 | 95 | exp = Router::Strexp.new '/foo(/:id)', {}, ['/.?'] 96 | path = Path::Pattern.new exp 97 | 98 | router.routes.add_route nil, path, requirements, {:id => nil}, {} 99 | 100 | env = rails_env 'PATH_INFO' => '/foo/10' 101 | router.recognize(env) do |r, _, params| 102 | flunk 'route should not be found' 103 | end 104 | 105 | assert klass.called, 'hello should have been called' 106 | assert_equal env.env, klass.env 107 | end 108 | 109 | class CustomPathRequest < Router::NullReq 110 | def path_info 111 | env['custom.path_info'] 112 | end 113 | end 114 | 115 | def test_request_class_overrides_path_info 116 | router = Router.new(routes, {:request_class => CustomPathRequest }) 117 | 118 | exp = Router::Strexp.new '/bar', {}, ['/.?'] 119 | path = Path::Pattern.new exp 120 | 121 | routes.add_route nil, path, {}, {}, {} 122 | 123 | env = rails_env 'PATH_INFO' => '/foo', 'custom.path_info' => '/bar' 124 | 125 | recognized = false 126 | router.recognize(env) do |r, _, params| 127 | recognized = true 128 | end 129 | 130 | assert recognized, "route should have been recognized" 131 | end 132 | 133 | def test_regexp_first_precedence 134 | add_routes @router, [ 135 | Router::Strexp.new("/whois/:domain", {:domain => /\w+\.[\w\.]+/}, ['/', '.', '?']), 136 | Router::Strexp.new("/whois/:id(.:format)", {}, ['/', '.', '?']) 137 | ] 138 | 139 | env = rails_env 'PATH_INFO' => '/whois/example.com' 140 | 141 | list = [] 142 | @router.recognize(env) do |r, _, params| 143 | list << r 144 | end 145 | assert_equal 2, list.length 146 | 147 | r = list.first 148 | 149 | assert_equal '/whois/:domain', r.path.spec.to_s 150 | end 151 | 152 | def test_required_parts_verified_are_anchored 153 | add_routes @router, [ 154 | Router::Strexp.new("/foo/:id", { :id => /\d/ }, ['/', '.', '?'], false) 155 | ] 156 | 157 | assert_raises(Router::RoutingError) do 158 | @formatter.generate(:path_info, nil, { :id => '10' }, { }) 159 | end 160 | end 161 | 162 | def test_required_parts_are_verified_when_building 163 | add_routes @router, [ 164 | Router::Strexp.new("/foo/:id", { :id => /\d+/ }, ['/', '.', '?'], false) 165 | ] 166 | 167 | path, _ = @formatter.generate(:path_info, nil, { :id => '10' }, { }) 168 | assert_equal '/foo/10', path 169 | 170 | assert_raises(Router::RoutingError) do 171 | @formatter.generate(:path_info, nil, { :id => 'aa' }, { }) 172 | end 173 | end 174 | 175 | def test_only_required_parts_are_verified 176 | add_routes @router, [ 177 | Router::Strexp.new("/foo(/:id)", {:id => /\d/}, ['/', '.', '?'], false) 178 | ] 179 | 180 | path, _ = @formatter.generate(:path_info, nil, { :id => '10' }, { }) 181 | assert_equal '/foo/10', path 182 | 183 | path, _ = @formatter.generate(:path_info, nil, { }, { }) 184 | assert_equal '/foo', path 185 | 186 | path, _ = @formatter.generate(:path_info, nil, { :id => 'aa' }, { }) 187 | assert_equal '/foo/aa', path 188 | end 189 | 190 | def test_knows_what_parts_are_missing_from_named_route 191 | route_name = "gorby_thunderhorse" 192 | pattern = Router::Strexp.new("/foo/:id", { :id => /\d+/ }, ['/', '.', '?'], false) 193 | path = Path::Pattern.new pattern 194 | @router.routes.add_route nil, path, {}, {}, route_name 195 | 196 | error = assert_raises(Router::RoutingError) do 197 | @formatter.generate(:path_info, route_name, { }, { }) 198 | end 199 | 200 | assert_match(/required keys: \[:id\]/, error.message) 201 | end 202 | 203 | def test_X_Cascade 204 | add_routes @router, [ "/messages(.:format)" ] 205 | resp = @router.call({ 'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/lol' }) 206 | assert_equal ['Not Found'], resp.last 207 | assert_equal 'pass', resp[1]['X-Cascade'] 208 | assert_equal 404, resp.first 209 | end 210 | 211 | def test_clear_trailing_slash_from_script_name_on_root_unanchored_routes 212 | strexp = Router::Strexp.new("/", {}, ['/', '.', '?'], false) 213 | path = Path::Pattern.new strexp 214 | app = lambda { |env| [200, {}, ['success!']] } 215 | @router.routes.add_route(app, path, {}, {}, {}) 216 | 217 | env = rack_env('SCRIPT_NAME' => '', 'PATH_INFO' => '/weblog') 218 | resp = @router.call(env) 219 | assert_equal ['success!'], resp.last 220 | assert_equal '', env['SCRIPT_NAME'] 221 | end 222 | 223 | def test_defaults_merge_correctly 224 | path = Path::Pattern.new '/foo(/:id)' 225 | @router.routes.add_route nil, path, {}, {:id => nil}, {} 226 | 227 | env = rails_env 'PATH_INFO' => '/foo/10' 228 | @router.recognize(env) do |r, _, params| 229 | assert_equal({:id => '10'}, params) 230 | end 231 | 232 | env = rails_env 'PATH_INFO' => '/foo' 233 | @router.recognize(env) do |r, _, params| 234 | assert_equal({:id => nil}, params) 235 | end 236 | end 237 | 238 | def test_recognize_with_unbound_regexp 239 | add_routes @router, [ 240 | Router::Strexp.new("/foo", { }, ['/', '.', '?'], false) 241 | ] 242 | 243 | env = rails_env 'PATH_INFO' => '/foo/bar' 244 | 245 | @router.recognize(env) { |*_| } 246 | 247 | assert_equal '/foo', env.env['SCRIPT_NAME'] 248 | assert_equal '/bar', env.env['PATH_INFO'] 249 | end 250 | 251 | def test_bound_regexp_keeps_path_info 252 | add_routes @router, [ 253 | Router::Strexp.new("/foo", { }, ['/', '.', '?'], true) 254 | ] 255 | 256 | env = rails_env 'PATH_INFO' => '/foo' 257 | 258 | before = env.env['SCRIPT_NAME'] 259 | 260 | @router.recognize(env) { |*_| } 261 | 262 | assert_equal before, env.env['SCRIPT_NAME'] 263 | assert_equal '/foo', env.env['PATH_INFO'] 264 | end 265 | 266 | def test_path_not_found 267 | add_routes @router, [ 268 | "/messages(.:format)", 269 | "/messages/new(.:format)", 270 | "/messages/:id/edit(.:format)", 271 | "/messages/:id(.:format)" 272 | ] 273 | env = rails_env 'PATH_INFO' => '/messages/unknown/path' 274 | yielded = false 275 | 276 | @router.recognize(env) do |*whatever| 277 | yielded = true 278 | end 279 | refute yielded 280 | end 281 | 282 | def test_required_part_in_recall 283 | add_routes @router, [ "/messages/:a/:b" ] 284 | 285 | path, _ = @formatter.generate(:path_info, nil, { :a => 'a' }, { :b => 'b' }) 286 | assert_equal "/messages/a/b", path 287 | end 288 | 289 | def test_splat_in_recall 290 | add_routes @router, [ "/*path" ] 291 | 292 | path, _ = @formatter.generate(:path_info, nil, { }, { :path => 'b' }) 293 | assert_equal "/b", path 294 | end 295 | 296 | def test_recall_should_be_used_when_scoring 297 | add_routes @router, [ 298 | "/messages/:action(/:id(.:format))", 299 | "/messages/:id(.:format)" 300 | ] 301 | 302 | path, _ = @formatter.generate(:path_info, nil, { :id => 10 }, { :action => 'index' }) 303 | assert_equal "/messages/index/10", path 304 | end 305 | 306 | def test_nil_path_parts_are_ignored 307 | path = Path::Pattern.new "/:controller(/:action(.:format))" 308 | @router.routes.add_route nil, path, {}, {}, {} 309 | 310 | params = { :controller => "tasks", :format => nil } 311 | extras = { :action => 'lol' } 312 | 313 | path, _ = @formatter.generate(:path_info, nil, params, extras) 314 | assert_equal '/tasks', path 315 | end 316 | 317 | def test_generate_slash 318 | params = [ [:controller, "tasks"], 319 | [:action, "show"] ] 320 | str = Router::Strexp.new("/", Hash[params], ['/', '.', '?'], true) 321 | path = Path::Pattern.new str 322 | 323 | @router.routes.add_route nil, path, {}, {}, {} 324 | 325 | path, _ = @formatter.generate(:path_info, nil, Hash[params], {}) 326 | assert_equal '/', path 327 | end 328 | 329 | def test_generate_calls_param_proc 330 | path = Path::Pattern.new '/:controller(/:action)' 331 | @router.routes.add_route nil, path, {}, {}, {} 332 | 333 | parameterized = [] 334 | params = [ [:controller, "tasks"], 335 | [:action, "show"] ] 336 | 337 | @formatter.generate( 338 | :path_info, 339 | nil, 340 | Hash[params], 341 | {}, 342 | lambda { |k,v| parameterized << [k,v]; v }) 343 | 344 | assert_equal params.map(&:to_s).sort, parameterized.map(&:to_s).sort 345 | end 346 | 347 | def test_generate_id 348 | path = Path::Pattern.new '/:controller(/:action)' 349 | @router.routes.add_route nil, path, {}, {}, {} 350 | 351 | path, params = @formatter.generate( 352 | :path_info, nil, {:id=>1, :controller=>"tasks", :action=>"show"}, {}) 353 | assert_equal '/tasks/show', path 354 | assert_equal({:id => 1}, params) 355 | end 356 | 357 | def test_generate_escapes 358 | path = Path::Pattern.new '/:controller(/:action)' 359 | @router.routes.add_route nil, path, {}, {}, {} 360 | 361 | path, _ = @formatter.generate(:path_info, 362 | nil, { :controller => "tasks", 363 | :action => "a/b c+d", 364 | }, {}) 365 | assert_equal '/tasks/a/b%20c+d', path 366 | end 367 | 368 | def test_generate_extra_params 369 | path = Path::Pattern.new '/:controller(/:action)' 370 | @router.routes.add_route nil, path, {}, {}, {} 371 | 372 | path, params = @formatter.generate(:path_info, 373 | nil, { :id => 1, 374 | :controller => "tasks", 375 | :action => "show", 376 | :relative_url_root => nil 377 | }, {}) 378 | assert_equal '/tasks/show', path 379 | assert_equal({:id => 1, :relative_url_root => nil}, params) 380 | end 381 | 382 | def test_generate_uses_recall_if_needed 383 | path = Path::Pattern.new '/:controller(/:action(/:id))' 384 | @router.routes.add_route nil, path, {}, {}, {} 385 | 386 | path, params = @formatter.generate(:path_info, 387 | nil, 388 | {:controller =>"tasks", :id => 10}, 389 | {:action =>"index"}) 390 | assert_equal '/tasks/index/10', path 391 | assert_equal({}, params) 392 | end 393 | 394 | def test_generate_with_name 395 | path = Path::Pattern.new '/:controller(/:action)' 396 | @router.routes.add_route nil, path, {}, {}, {} 397 | 398 | path, params = @formatter.generate(:path_info, 399 | "tasks", 400 | {:controller=>"tasks"}, 401 | {:controller=>"tasks", :action=>"index"}) 402 | assert_equal '/tasks', path 403 | assert_equal({}, params) 404 | end 405 | 406 | { 407 | '/content' => { :controller => 'content' }, 408 | '/content/list' => { :controller => 'content', :action => 'list' }, 409 | '/content/show/10' => { :controller => 'content', :action => 'show', :id => "10" }, 410 | }.each do |request_path, expected| 411 | define_method("test_recognize_#{expected.keys.map(&:to_s).join('_')}") do 412 | path = Path::Pattern.new "/:controller(/:action(/:id))" 413 | app = Object.new 414 | route = @router.routes.add_route(app, path, {}, {}, {}) 415 | 416 | env = rails_env 'PATH_INFO' => request_path 417 | called = false 418 | 419 | @router.recognize(env) do |r, _, params| 420 | assert_equal route, r 421 | assert_equal(expected, params) 422 | called = true 423 | end 424 | 425 | assert called 426 | end 427 | end 428 | 429 | { 430 | :segment => ['/a%2Fb%20c+d/splat', { :segment => 'a/b c+d', :splat => 'splat' }], 431 | :splat => ['/segment/a/b%20c+d', { :segment => 'segment', :splat => 'a/b c+d' }] 432 | }.each do |name, (request_path, expected)| 433 | define_method("test_recognize_#{name}") do 434 | path = Path::Pattern.new '/:segment/*splat' 435 | app = Object.new 436 | route = @router.routes.add_route(app, path, {}, {}, {}) 437 | 438 | env = rails_env 'PATH_INFO' => request_path 439 | called = false 440 | 441 | @router.recognize(env) do |r, _, params| 442 | assert_equal route, r 443 | assert_equal(expected, params) 444 | called = true 445 | end 446 | 447 | assert called 448 | end 449 | end 450 | 451 | def test_namespaced_controller 452 | strexp = Router::Strexp.new( 453 | "/:controller(/:action(/:id))", 454 | { :controller => /.+?/ }, 455 | ["/", ".", "?"] 456 | ) 457 | path = Path::Pattern.new strexp 458 | app = Object.new 459 | route = @router.routes.add_route(app, path, {}, {}, {}) 460 | 461 | env = rails_env 'PATH_INFO' => '/admin/users/show/10' 462 | called = false 463 | expected = { 464 | :controller => 'admin/users', 465 | :action => 'show', 466 | :id => '10' 467 | } 468 | 469 | @router.recognize(env) do |r, _, params| 470 | assert_equal route, r 471 | assert_equal(expected, params) 472 | called = true 473 | end 474 | assert called 475 | end 476 | 477 | def test_recognize_literal 478 | path = Path::Pattern.new "/books(/:action(.:format))" 479 | app = Object.new 480 | route = @router.routes.add_route(app, path, {}, {:controller => 'books'}) 481 | 482 | env = rails_env 'PATH_INFO' => '/books/list.rss' 483 | expected = { :controller => 'books', :action => 'list', :format => 'rss' } 484 | called = false 485 | @router.recognize(env) do |r, _, params| 486 | assert_equal route, r 487 | assert_equal(expected, params) 488 | called = true 489 | end 490 | 491 | assert called 492 | end 493 | 494 | def test_recognize_head_request_as_get_route 495 | path = Path::Pattern.new "/books(/:action(.:format))" 496 | app = Object.new 497 | conditions = { 498 | :request_method => 'GET' 499 | } 500 | @router.routes.add_route(app, path, conditions, {}) 501 | 502 | env = rails_env 'PATH_INFO' => '/books/list.rss', 503 | "REQUEST_METHOD" => "HEAD" 504 | 505 | called = false 506 | @router.recognize(env) do |r, _, params| 507 | called = true 508 | end 509 | 510 | assert called 511 | end 512 | 513 | def test_recognize_cares_about_verbs 514 | path = Path::Pattern.new "/books(/:action(.:format))" 515 | app = Object.new 516 | conditions = { 517 | :request_method => 'GET' 518 | } 519 | @router.routes.add_route(app, path, conditions, {}) 520 | 521 | conditions = conditions.dup 522 | conditions[:request_method] = 'POST' 523 | 524 | post = @router.routes.add_route(app, path, conditions, {}) 525 | 526 | env = rails_env 'PATH_INFO' => '/books/list.rss', 527 | "REQUEST_METHOD" => "POST" 528 | 529 | called = false 530 | @router.recognize(env) do |r, _, params| 531 | assert_equal post, r 532 | called = true 533 | end 534 | 535 | assert called 536 | end 537 | 538 | private 539 | 540 | def add_routes router, paths 541 | paths.each do |path| 542 | path = Path::Pattern.new path 543 | router.routes.add_route nil, path, {}, {}, {} 544 | end 545 | end 546 | 547 | RailsEnv = Struct.new(:env) 548 | 549 | def rails_env env 550 | RailsEnv.new rack_env env 551 | end 552 | 553 | def rack_env env 554 | { 555 | "rack.version" => [1, 1], 556 | "rack.input" => StringIO.new, 557 | "rack.errors" => StringIO.new, 558 | "rack.multithread" => true, 559 | "rack.multiprocess" => true, 560 | "rack.run_once" => false, 561 | "REQUEST_METHOD" => "GET", 562 | "SERVER_NAME" => "example.org", 563 | "SERVER_PORT" => "80", 564 | "QUERY_STRING" => "", 565 | "PATH_INFO" => "/content", 566 | "rack.url_scheme" => "http", 567 | "HTTPS" => "off", 568 | "SCRIPT_NAME" => "", 569 | "CONTENT_LENGTH" => "0" 570 | }.merge env 571 | end 572 | end 573 | end 574 | --------------------------------------------------------------------------------