diff options
Diffstat (limited to 'actionpack')
44 files changed, 3967 insertions, 3 deletions
diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 497012e906..8b2c90775d 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,5 +1,10 @@ ## Rails 4.0.0 (unreleased) ## +* Integrate the Journey gem into Action Dispatch so that the global namespace + is not polluted with names that may be used as models. + + *Andrew White* + * Extract support for email address obfuscation via `:encode`, `:replace_at`, and `replace_dot` options from the `mail_to` helper into the `actionview-encoded_mail_to` gem. diff --git a/actionpack/Rakefile b/actionpack/Rakefile index 50e3bb0d48..2c4f333af5 100644 --- a/actionpack/Rakefile +++ b/actionpack/Rakefile @@ -15,7 +15,7 @@ Rake::TestTask.new(:test_action_pack) do |t| # make sure we include the tests in alphabetical order as on some systems # this will not happen automatically and the tests (as a whole) will error - t.test_files = Dir.glob('test/{abstract,controller,dispatch,template,assertions}/**/*_test.rb').sort + t.test_files = Dir.glob('test/{abstract,controller,dispatch,template,assertions,journey}/**/*_test.rb').sort t.warning = true t.verbose = true @@ -75,3 +75,9 @@ task :lines do puts "Total: Lines #{total_lines}, LOC #{total_codelines}" end + +rule '.rb' => '.y' do |t| + sh "racc -l -o #{t.name} #{t.source}" +end + +task :compile => 'lib/action_dispatch/journey/parser.rb' diff --git a/actionpack/actionpack.gemspec b/actionpack/actionpack.gemspec index 89fdd528c2..c65870cac6 100644 --- a/actionpack/actionpack.gemspec +++ b/actionpack/actionpack.gemspec @@ -23,7 +23,6 @@ Gem::Specification.new do |s| s.add_dependency 'builder', '~> 3.1.0' s.add_dependency 'rack', '~> 1.4.1' s.add_dependency 'rack-test', '~> 0.6.1' - s.add_dependency 'journey', '~> 2.0.0' s.add_dependency 'erubis', '~> 2.7.0' s.add_development_dependency 'activemodel', version diff --git a/actionpack/lib/action_dispatch.rb b/actionpack/lib/action_dispatch.rb index d002babee3..938161dc69 100644 --- a/actionpack/lib/action_dispatch.rb +++ b/actionpack/lib/action_dispatch.rb @@ -63,6 +63,7 @@ module ActionDispatch autoload :Static end + autoload :Journey autoload :MiddlewareStack, 'action_dispatch/middleware/stack' autoload :Routing diff --git a/actionpack/lib/action_dispatch/journey.rb b/actionpack/lib/action_dispatch/journey.rb new file mode 100644 index 0000000000..ad42713482 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey.rb @@ -0,0 +1,5 @@ +require 'action_dispatch/journey/router' +require 'action_dispatch/journey/gtg/builder' +require 'action_dispatch/journey/gtg/simulator' +require 'action_dispatch/journey/nfa/builder' +require 'action_dispatch/journey/nfa/simulator' diff --git a/actionpack/lib/action_dispatch/journey/backwards.rb b/actionpack/lib/action_dispatch/journey/backwards.rb new file mode 100644 index 0000000000..33f6976897 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/backwards.rb @@ -0,0 +1,5 @@ +module Rack + Mount = ActionDispatch::Journey::Router + Mount::RouteSet = ActionDispatch::Journey::Router + Mount::RegexpWithNamedGroups = ActionDispatch::Journey::Path::Pattern +end diff --git a/actionpack/lib/action_dispatch/journey/core-ext/hash.rb b/actionpack/lib/action_dispatch/journey/core-ext/hash.rb new file mode 100644 index 0000000000..fad5cdaa63 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/core-ext/hash.rb @@ -0,0 +1,11 @@ +# :stopdoc: +if RUBY_VERSION < '1.9' +class Hash + def keep_if + each do |k,v| + delete(k) unless yield(k,v) + end + end +end +end +# :startdoc: diff --git a/actionpack/lib/action_dispatch/journey/formatter.rb b/actionpack/lib/action_dispatch/journey/formatter.rb new file mode 100644 index 0000000000..873a73baf0 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/formatter.rb @@ -0,0 +1,147 @@ +module ActionDispatch + module Journey + ### + # The Formatter class is used for formatting URLs. For example, parameters + # passed to +url_for+ in rails will eventually call Formatter#generate + class Formatter + attr_reader :routes + + def initialize routes + @routes = routes + @cache = nil + end + + def generate type, name, options, recall = {}, parameterize = nil + constraints = recall.merge options + missing_keys = [] + + match_route(name, constraints) do |route| + parameterized_parts = extract_parameterized_parts route, options, recall, parameterize + next if !name && route.requirements.empty? && route.parts.empty? + + missing_keys = missing_keys(route, parameterized_parts) + next unless missing_keys.empty? + params = options.dup.delete_if do |key, _| + parameterized_parts.key?(key) || route.defaults.key?(key) + end + + return [route.format(parameterized_parts), params] + end + + raise Router::RoutingError.new "missing required keys: #{missing_keys}" + end + + def clear + @cache = nil + end + + private + def extract_parameterized_parts route, options, recall, parameterize = nil + constraints = recall.merge options + data = constraints.dup + + keys_to_keep = route.parts.reverse.drop_while { |part| + !options.key?(part) || (options[part] || recall[part]).nil? + } | route.required_parts + + (data.keys - keys_to_keep).each do |bad_key| + data.delete bad_key + end + + parameterized_parts = data.dup + + if parameterize + parameterized_parts.each do |k,v| + parameterized_parts[k] = parameterize.call(k, v) + end + end + + parameterized_parts.keep_if { |_,v| v } + parameterized_parts + end + + def named_routes + routes.named_routes + end + + def match_route name, options + if named_routes.key? name + yield named_routes[name] + else + #routes = possibles(@cache, options.to_a) + routes = non_recursive(cache, options.to_a) + + hash = routes.group_by { |_, r| + r.score options + } + + hash.keys.sort.reverse_each do |score| + next if score < 0 + + hash[score].sort_by { |i,_| i }.each do |_,route| + yield route + end + end + end + end + + def non_recursive cache, options + routes = [] + stack = [cache] + + while stack.any? + c = stack.shift + routes.concat c[:___routes] if c.key? :___routes + + options.each do |pair| + stack << c[pair] if c.key? pair + end + end + + routes + end + + # returns an array populated with missing keys if any are present + def missing_keys route, parts + missing_keys = [] + tests = route.path.requirements + route.required_parts.each { |key| + if tests.key? key + missing_keys << key unless /\A#{tests[key]}\Z/ === parts[key] + else + missing_keys << key unless parts[key] + end + } + missing_keys + end + + def possibles cache, options, depth = 0 + cache.fetch(:___routes) { [] } + options.find_all { |pair| + cache.key? pair + }.map { |pair| + possibles(cache[pair], options, depth + 1) + }.flatten(1) + end + + # returns boolean, true if no missing keys are present + def verify_required_parts! route, parts + missing_keys(route, parts).empty? + end + + def build_cache + root = { :___routes => [] } + routes.each_with_index do |route, i| + leaf = route.required_defaults.inject(root) do |h, tuple| + h[tuple] ||= {} + end + (leaf[:___routes] ||= []) << [i, route] + end + root + end + + def cache + @cache ||= build_cache + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/gtg/builder.rb b/actionpack/lib/action_dispatch/journey/gtg/builder.rb new file mode 100644 index 0000000000..10b53500fc --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/gtg/builder.rb @@ -0,0 +1,161 @@ +require 'action_dispatch/journey/gtg/transition_table' + +module ActionDispatch + module Journey + module GTG + class Builder + DUMMY = Nodes::Dummy.new # :nodoc: + + attr_reader :root, :ast, :endpoints + + def initialize root + @root = root + @ast = Nodes::Cat.new root, DUMMY + @followpos = nil + end + + def transition_table + dtrans = TransitionTable.new + marked = {} + state_id = Hash.new { |h,k| h[k] = h.length } + + start = firstpos(root) + dstates = [start] + until dstates.empty? + s = dstates.shift + next if marked[s] + marked[s] = true # mark s + + s.group_by { |state| symbol(state) }.each do |sym, ps| + u = ps.map { |l| followpos(l) }.flatten + next if u.empty? + + if u.uniq == [DUMMY] + from = state_id[s] + to = state_id[Object.new] + dtrans[from, to] = sym + + dtrans.add_accepting to + ps.each { |state| dtrans.add_memo to, state.memo } + else + dtrans[state_id[s], state_id[u]] = sym + + if u.include? DUMMY + to = state_id[u] + + accepting = ps.find_all { |l| followpos(l).include? DUMMY } + + accepting.each { |accepting_state| + dtrans.add_memo to, accepting_state.memo + } + + dtrans.add_accepting state_id[u] + end + end + + dstates << u + end + end + + dtrans + end + + def nullable? node + case node + when Nodes::Group + true + when Nodes::Star + true + when Nodes::Or + node.children.any? { |c| nullable?(c) } + when Nodes::Cat + nullable?(node.left) && nullable?(node.right) + when Nodes::Terminal + !node.left + when Nodes::Unary + nullable? node.left + else + raise ArgumentError, 'unknown nullable: %s' % node.class.name + end + end + + def firstpos node + case node + when Nodes::Star + firstpos(node.left) + when Nodes::Cat + if nullable? node.left + firstpos(node.left) | firstpos(node.right) + else + firstpos(node.left) + end + when Nodes::Or + node.children.map { |c| firstpos(c) }.flatten.uniq + when Nodes::Unary + firstpos(node.left) + when Nodes::Terminal + nullable?(node) ? [] : [node] + else + raise ArgumentError, 'unknown firstpos: %s' % node.class.name + end + end + + def lastpos node + case node + when Nodes::Star + firstpos(node.left) + when Nodes::Or + node.children.map { |c| lastpos(c) }.flatten.uniq + when Nodes::Cat + if nullable? node.right + lastpos(node.left) | lastpos(node.right) + else + lastpos(node.right) + end + when Nodes::Terminal + nullable?(node) ? [] : [node] + when Nodes::Unary + lastpos(node.left) + else + raise ArgumentError, 'unknown lastpos: %s' % node.class.name + end + end + + def followpos node + followpos_table[node] + end + + private + def followpos_table + @followpos ||= build_followpos + end + + def build_followpos + table = Hash.new { |h,k| h[k] = [] } + @ast.each do |n| + case n + when Nodes::Cat + lastpos(n.left).each do |i| + table[i] += firstpos(n.right) + end + when Nodes::Star + lastpos(n).each do |i| + table[i] += firstpos(n) + end + end + end + table + end + + def symbol edge + case edge + when Journey::Nodes::Symbol + edge.regexp + else + edge.left + end + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/gtg/simulator.rb b/actionpack/lib/action_dispatch/journey/gtg/simulator.rb new file mode 100644 index 0000000000..fda14c8680 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/gtg/simulator.rb @@ -0,0 +1,44 @@ +require 'strscan' + +module ActionDispatch + module Journey + module GTG + class MatchData + attr_reader :memos + + def initialize memos + @memos = memos + end + end + + class Simulator + attr_reader :tt + + def initialize transition_table + @tt = transition_table + end + + def simulate string + input = StringScanner.new string + state = [0] + while sym = input.scan(%r([/.?]|[^/.?]+)) + state = tt.move(state, sym) + end + + acceptance_states = state.find_all { |s| + tt.accepting? s + } + + return if acceptance_states.empty? + + memos = acceptance_states.map { |x| tt.memo x }.flatten.compact + + MatchData.new memos + end + + alias :=~ :simulate + alias :match :simulate + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb new file mode 100644 index 0000000000..b6812b6475 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb @@ -0,0 +1,155 @@ +require 'action_dispatch/journey/nfa/dot' + +module ActionDispatch + module Journey + module GTG + class TransitionTable + include Journey::NFA::Dot + + attr_reader :memos + + def initialize + @regexp_states = Hash.new { |h,k| h[k] = {} } + @string_states = Hash.new { |h,k| h[k] = {} } + @accepting = {} + @memos = Hash.new { |h,k| h[k] = [] } + end + + def add_accepting state + @accepting[state] = true + end + + def accepting_states + @accepting.keys + end + + def accepting? state + @accepting[state] + end + + def add_memo idx, memo + @memos[idx] << memo + end + + def memo idx + @memos[idx] + end + + def eclosure t + Array(t) + end + + def move t, a + move_string(t, a).concat move_regexp(t, a) + end + + def to_json + require 'json' + + simple_regexp = Hash.new { |h,k| h[k] = {} } + + @regexp_states.each do |from, hash| + hash.each do |re, to| + simple_regexp[from][re.source] = to + end + end + + JSON.dump({ + :regexp_states => simple_regexp, + :string_states => @string_states, + :accepting => @accepting + }) + end + + def to_svg + svg = IO.popen("dot -Tsvg", 'w+') { |f| + f.write to_dot + f.close_write + f.readlines + } + 3.times { svg.shift } + svg.join.sub(/width="[^"]*"/, '').sub(/height="[^"]*"/, '') + end + + def visualizer paths, title = 'FSM' + viz_dir = File.join File.dirname(__FILE__), '..', 'visualizer' + fsm_js = File.read File.join(viz_dir, 'fsm.js') + fsm_css = File.read File.join(viz_dir, 'fsm.css') + erb = File.read File.join(viz_dir, 'index.html.erb') + states = "function tt() { return #{to_json}; }" + + fun_routes = paths.shuffle.first(3).map do |ast| + ast.map { |n| + case n + when Nodes::Symbol + case n.left + when ':id' then rand(100).to_s + when ':format' then %w{ xml json }.shuffle.first + else + 'omg' + end + when Nodes::Terminal then n.symbol + else + nil + end + }.compact.join + end + + stylesheets = [fsm_css] + svg = to_svg + javascripts = [states, fsm_js] + + # Annoying hack for 1.9 warnings + fun_routes = fun_routes + stylesheets = stylesheets + svg = svg + javascripts = javascripts + + require 'erb' + template = ERB.new erb + template.result(binding) + end + + def []= from, to, sym + case sym + when String + @string_states[from][sym] = to + when Regexp + @regexp_states[from][sym] = to + else + raise ArgumentError, 'unknown symbol: %s' % sym.class + end + end + + def states + ss = @string_states.keys + @string_states.values.map(&:values).flatten + rs = @regexp_states.keys + @regexp_states.values.map(&:values).flatten + (ss + rs).uniq + end + + def transitions + @string_states.map { |from, hash| + hash.map { |s, to| [from, s, to] } + }.flatten(1) + @regexp_states.map { |from, hash| + hash.map { |s, to| [from, s, to] } + }.flatten(1) + end + + private + def move_regexp t, a + return [] if t.empty? + + t.map { |s| + @regexp_states[s].map { |re,v| re === a ? v : nil } + }.flatten.compact.uniq + end + + def move_string t, a + return [] if t.empty? + + t.map { |s| @string_states[s][a] }.compact + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/nfa/builder.rb b/actionpack/lib/action_dispatch/journey/nfa/builder.rb new file mode 100644 index 0000000000..720820accb --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/builder.rb @@ -0,0 +1,76 @@ +require 'action_dispatch/journey/nfa/transition_table' +require 'action_dispatch/journey/gtg/transition_table' + +module ActionDispatch + module Journey + module NFA + class Visitor < Visitors::Visitor + def initialize tt + @tt = tt + @i = -1 + end + + def visit_CAT node + left = visit node.left + right = visit node.right + + @tt.merge left.last, right.first + + [left.first, right.last] + end + + def visit_GROUP node + from = @i += 1 + left = visit node.left + to = @i += 1 + + @tt.accepting = to + + @tt[from, left.first] = nil + @tt[left.last, to] = nil + @tt[from, to] = nil + + [from, to] + end + + def visit_OR node + from = @i += 1 + children = node.children.map { |c| visit c } + to = @i += 1 + + children.each do |child| + @tt[from, child.first] = nil + @tt[child.last, to] = nil + end + + @tt.accepting = to + + [from, to] + end + + def terminal node + from_i = @i += 1 # new state + to_i = @i += 1 # new state + + @tt[from_i, to_i] = node + @tt.accepting = to_i + @tt.add_memo to_i, node.memo + + [from_i, to_i] + end + end + + class Builder + def initialize ast + @ast = ast + end + + def transition_table + tt = TransitionTable.new + Visitor.new(tt).accept @ast + tt + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/nfa/dot.rb b/actionpack/lib/action_dispatch/journey/nfa/dot.rb new file mode 100644 index 0000000000..6d2f851c2c --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/dot.rb @@ -0,0 +1,36 @@ +# encoding: utf-8 + +module ActionDispatch + module Journey + module NFA + module Dot + def to_dot + edges = transitions.map { |from, sym, to| + " #{from} -> #{to} [label=\"#{sym || 'ε'}\"];" + } + + #memo_nodes = memos.values.flatten.map { |n| + # label = n + # if Journey::Route === n + # label = "#{n.verb.source} #{n.path.spec}" + # end + # " #{n.object_id} [label=\"#{label}\", shape=box];" + #} + #memo_edges = memos.map { |k, memos| + # (memos || []).map { |v| " #{k} -> #{v.object_id};" } + #}.flatten.uniq + + <<-eodot +digraph nfa { + rankdir=LR; + node [shape = doublecircle]; + #{accepting_states.join ' '}; + node [shape = circle]; +#{edges.join "\n"} +} + eodot + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/nfa/simulator.rb b/actionpack/lib/action_dispatch/journey/nfa/simulator.rb new file mode 100644 index 0000000000..9948213146 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/simulator.rb @@ -0,0 +1,47 @@ +require 'strscan' + +module ActionDispatch + module Journey + module NFA + class MatchData + attr_reader :memos + + def initialize memos + @memos = memos + end + end + + class Simulator + attr_reader :tt + + def initialize transition_table + @tt = transition_table + end + + def simulate string + input = StringScanner.new string + state = tt.eclosure 0 + until input.eos? + sym = input.scan(%r([/.?]|[^/.?]+)) + + # FIXME: tt.eclosure is not needed for the GTG + state = tt.eclosure tt.move(state, sym) + end + + acceptance_states = state.find_all { |s| + tt.accepting? tt.eclosure(s).sort.last + } + + return if acceptance_states.empty? + + memos = acceptance_states.map { |x| tt.memo x }.flatten.compact + + MatchData.new memos + end + + alias :=~ :simulate + alias :match :simulate + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/nfa/transition_table.rb b/actionpack/lib/action_dispatch/journey/nfa/transition_table.rb new file mode 100644 index 0000000000..053ee4351a --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/transition_table.rb @@ -0,0 +1,166 @@ +require 'action_dispatch/journey/nfa/dot' + +module ActionDispatch + module Journey + module NFA + class TransitionTable + include Journey::NFA::Dot + + attr_accessor :accepting + attr_reader :memos + + def initialize + @table = Hash.new { |h,f| h[f] = {} } + @memos = {} + @accepting = nil + @inverted = nil + end + + def accepting? state + accepting == state + end + + def accepting_states + [accepting] + end + + def add_memo idx, memo + @memos[idx] = memo + end + + def memo idx + @memos[idx] + end + + def []= i, f, s + @table[f][i] = s + end + + def merge left, right + @memos[right] = @memos.delete left + @table[right] = @table.delete(left) + end + + def states + (@table.keys + @table.values.map(&:keys).flatten).uniq + end + + ### + # Returns a generalized transition graph with reduced states. The states + # are reduced like a DFA, but the table must be simulated like an NFA. + # + # Edges of the GTG are regular expressions + def generalized_table + gt = GTG::TransitionTable.new + marked = {} + state_id = Hash.new { |h,k| h[k] = h.length } + alphabet = self.alphabet + + stack = [eclosure(0)] + + until stack.empty? + state = stack.pop + next if marked[state] || state.empty? + + marked[state] = true + + alphabet.each do |alpha| + next_state = eclosure(following_states(state, alpha)) + next if next_state.empty? + + gt[state_id[state], state_id[next_state]] = alpha + stack << next_state + end + end + + final_groups = state_id.keys.find_all { |s| + s.sort.last == accepting + } + + final_groups.each do |states| + id = state_id[states] + + gt.add_accepting id + save = states.find { |s| + @memos.key?(s) && eclosure(s).sort.last == accepting + } + + gt.add_memo id, memo(save) + end + + gt + end + + ### + # Returns set of NFA states to which there is a transition on ast symbol + # +a+ from some state +s+ in +t+. + def following_states t, a + Array(t).map { |s| inverted[s][a] }.flatten.uniq + end + + ### + # Returns set of NFA states to which there is a transition on ast symbol + # +a+ from some state +s+ in +t+. + def move t, a + Array(t).map { |s| + inverted[s].keys.compact.find_all { |sym| + sym === a + }.map { |sym| inverted[s][sym] } + }.flatten.uniq + end + + def alphabet + inverted.values.map(&:keys).flatten.compact.uniq.sort_by { |x| x.to_s } + end + + ### + # Returns a set of NFA states reachable from some NFA state +s+ in set + # +t+ on nil-transitions alone. + def eclosure t + stack = Array(t) + seen = {} + children = [] + + until stack.empty? + s = stack.pop + next if seen[s] + + seen[s] = true + children << s + + stack.concat inverted[s][nil] + end + + children.uniq + end + + def transitions + @table.map { |to, hash| + hash.map { |from, sym| [from, sym, to] } + }.flatten(1) + end + + private + def inverted + return @inverted if @inverted + + @inverted = Hash.new { |h,from| + h[from] = Hash.new { |j,s| j[s] = [] } + } + + @table.each { |to, hash| + hash.each { |from, sym| + if sym + sym = Nodes::Symbol === sym ? sym.regexp : sym.left + end + + @inverted[from][sym] << to + } + } + + @inverted + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/nodes/node.rb b/actionpack/lib/action_dispatch/journey/nodes/node.rb new file mode 100644 index 0000000000..16d6cb8f0e --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nodes/node.rb @@ -0,0 +1,124 @@ +require 'action_dispatch/journey/visitors' + +module ActionDispatch + module Journey + module Nodes + class Node # :nodoc: + include Enumerable + + attr_accessor :left, :memo + + def initialize left + @left = left + @memo = nil + end + + def each(&block) + Visitors::Each.new(block).accept(self) + end + + def to_s + Visitors::String.new.accept(self) + end + + def to_dot + Visitors::Dot.new.accept(self) + end + + def to_sym + name.to_sym + end + + def name + left.tr '*:', '' + end + + def type + raise NotImplementedError + end + + def symbol?; false; end + def literal?; false; end + end + + class Terminal < Node + alias :symbol :left + end + + class Literal < Terminal + def literal?; true; end + def type; :LITERAL; end + end + + class Dummy < Literal + def initialize x = Object.new + super + end + + def literal?; false; end + end + + %w{ Symbol Slash Dot }.each do |t| + class_eval <<-eoruby, __FILE__, __LINE__ + 1 + class #{t} < Terminal; + def type; :#{t.upcase}; end + end + eoruby + end + + class Symbol < Terminal + attr_accessor :regexp + alias :symbol :regexp + + DEFAULT_EXP = /[^\.\/\?]+/ + def initialize left + super + @regexp = DEFAULT_EXP + end + + def default_regexp? + regexp == DEFAULT_EXP + end + + def symbol?; true; end + end + + class Unary < Node + def children; [left] end + end + + class Group < Unary + def type; :GROUP; end + end + + class Star < Unary + def type; :STAR; end + end + + class Binary < Node + attr_accessor :right + + def initialize left, right + super(left) + @right = right + end + + def children; [left, right] end + end + + class Cat < Binary + def type; :CAT; end + end + + class Or < Node + attr_reader :children + + def initialize children + @children = children + end + + def type; :OR; end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/parser.rb b/actionpack/lib/action_dispatch/journey/parser.rb new file mode 100644 index 0000000000..2489cd61eb --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/parser.rb @@ -0,0 +1,206 @@ +# +# DO NOT MODIFY!!!! +# This file is automatically generated by Racc 1.4.9 +# from Racc grammer file "". +# + +require 'racc/parser.rb' + + +require 'action_dispatch/journey/parser_extras' +module ActionDispatch + module Journey + class Parser < Racc::Parser +##### State transition tables begin ### + +racc_action_table = [ + 17, 21, 13, 15, 14, 7, nil, 16, 8, 19, + 13, 15, 14, 7, 23, 16, 8, 19, 13, 15, + 14, 7, nil, 16, 8, 13, 15, 14, 7, nil, + 16, 8, 13, 15, 14, 7, nil, 16, 8 ] + +racc_action_check = [ + 1, 17, 1, 1, 1, 1, nil, 1, 1, 1, + 20, 20, 20, 20, 20, 20, 20, 20, 7, 7, + 7, 7, nil, 7, 7, 19, 19, 19, 19, nil, + 19, 19, 0, 0, 0, 0, nil, 0, 0 ] + +racc_action_pointer = [ + 30, 0, nil, nil, nil, nil, nil, 16, nil, nil, + nil, nil, nil, nil, nil, nil, nil, 1, nil, 23, + 8, nil, nil, nil ] + +racc_action_default = [ + -18, -18, -2, -3, -4, -5, -6, -18, -9, -10, + -11, -12, -13, -14, -15, -16, -17, -18, -1, -18, + -18, 24, -8, -7 ] + +racc_goto_table = [ + 18, 1, nil, nil, nil, nil, nil, nil, 20, nil, + nil, nil, nil, nil, nil, nil, nil, nil, 22, 18 ] + +racc_goto_check = [ + 2, 1, nil, nil, nil, nil, nil, nil, 1, nil, + nil, nil, nil, nil, nil, nil, nil, nil, 2, 2 ] + +racc_goto_pointer = [ + nil, 1, -1, nil, nil, nil, nil, nil, nil, nil, + nil ] + +racc_goto_default = [ + nil, nil, 2, 3, 4, 5, 6, 9, 10, 11, + 12 ] + +racc_reduce_table = [ + 0, 0, :racc_error, + 2, 11, :_reduce_1, + 1, 11, :_reduce_2, + 1, 11, :_reduce_none, + 1, 12, :_reduce_none, + 1, 12, :_reduce_none, + 1, 12, :_reduce_none, + 3, 15, :_reduce_7, + 3, 13, :_reduce_8, + 1, 16, :_reduce_9, + 1, 14, :_reduce_none, + 1, 14, :_reduce_none, + 1, 14, :_reduce_none, + 1, 14, :_reduce_none, + 1, 19, :_reduce_14, + 1, 17, :_reduce_15, + 1, 18, :_reduce_16, + 1, 20, :_reduce_17 ] + +racc_reduce_n = 18 + +racc_shift_n = 24 + +racc_token_table = { + false => 0, + :error => 1, + :SLASH => 2, + :LITERAL => 3, + :SYMBOL => 4, + :LPAREN => 5, + :RPAREN => 6, + :DOT => 7, + :STAR => 8, + :OR => 9 } + +racc_nt_base = 10 + +racc_use_result_var = true + +Racc_arg = [ + racc_action_table, + racc_action_check, + racc_action_default, + racc_action_pointer, + racc_goto_table, + racc_goto_check, + racc_goto_default, + racc_goto_pointer, + racc_nt_base, + racc_reduce_table, + racc_token_table, + racc_shift_n, + racc_reduce_n, + racc_use_result_var ] + +Racc_token_to_s_table = [ + "$end", + "error", + "SLASH", + "LITERAL", + "SYMBOL", + "LPAREN", + "RPAREN", + "DOT", + "STAR", + "OR", + "$start", + "expressions", + "expression", + "or", + "terminal", + "group", + "star", + "symbol", + "literal", + "slash", + "dot" ] + +Racc_debug_parser = false + +##### State transition tables end ##### + +# reduce 0 omitted + +def _reduce_1(val, _values, result) + result = Cat.new(val.first, val.last) + result +end + +def _reduce_2(val, _values, result) + result = val.first + result +end + +# reduce 3 omitted + +# reduce 4 omitted + +# reduce 5 omitted + +# reduce 6 omitted + +def _reduce_7(val, _values, result) + result = Group.new(val[1]) + result +end + +def _reduce_8(val, _values, result) + result = Or.new([val.first, val.last]) + result +end + +def _reduce_9(val, _values, result) + result = Star.new(Symbol.new(val.last)) + result +end + +# reduce 10 omitted + +# reduce 11 omitted + +# reduce 12 omitted + +# reduce 13 omitted + +def _reduce_14(val, _values, result) + result = Slash.new('/') + result +end + +def _reduce_15(val, _values, result) + result = Symbol.new(val.first) + result +end + +def _reduce_16(val, _values, result) + result = Literal.new(val.first) + result +end + +def _reduce_17(val, _values, result) + result = Dot.new(val.first) + result +end + +def _reduce_none(val, _values, result) + val[0] +end + + end # class Parser + end # module Journey + end # module ActionDispatch diff --git a/actionpack/lib/action_dispatch/journey/parser.y b/actionpack/lib/action_dispatch/journey/parser.y new file mode 100644 index 0000000000..a2e1afed32 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/parser.y @@ -0,0 +1,47 @@ +class ActionDispatch::Journey::Parser + +token SLASH LITERAL SYMBOL LPAREN RPAREN DOT STAR OR + +rule + expressions + : expressions expression { result = Cat.new(val.first, val.last) } + | expression { result = val.first } + | or + ; + expression + : terminal + | group + | star + ; + group + : LPAREN expressions RPAREN { result = Group.new(val[1]) } + ; + or + : expressions OR expression { result = Or.new([val.first, val.last]) } + ; + star + : STAR { result = Star.new(Symbol.new(val.last)) } + ; + terminal + : symbol + | literal + | slash + | dot + ; + slash + : SLASH { result = Slash.new('/') } + ; + symbol + : SYMBOL { result = Symbol.new(val.first) } + ; + literal + : LITERAL { result = Literal.new(val.first) } + dot + : DOT { result = Dot.new(val.first) } + ; + +end + +---- header + +require 'action_dispatch/journey/parser_extras' diff --git a/actionpack/lib/action_dispatch/journey/parser_extras.rb b/actionpack/lib/action_dispatch/journey/parser_extras.rb new file mode 100644 index 0000000000..43f9beda12 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/parser_extras.rb @@ -0,0 +1,23 @@ +require 'action_dispatch/journey/scanner' +require 'action_dispatch/journey/nodes/node' + +module ActionDispatch + module Journey + class Parser < Racc::Parser + include Journey::Nodes + + def initialize + @scanner = Scanner.new + end + + def parse string + @scanner.scan_setup string + do_parse + end + + def next_token + @scanner.next_token + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/path/pattern.rb b/actionpack/lib/action_dispatch/journey/path/pattern.rb new file mode 100644 index 0000000000..e14168aeb2 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/path/pattern.rb @@ -0,0 +1,195 @@ +module ActionDispatch + module Journey + module Path + class Pattern + attr_reader :spec, :requirements, :anchored + + def initialize strexp + parser = Journey::Parser.new + + @anchored = true + + case strexp + when String + @spec = parser.parse strexp + @requirements = {} + @separators = "/.?" + when Router::Strexp + @spec = parser.parse strexp.path + @requirements = strexp.requirements + @separators = strexp.separators.join + @anchored = strexp.anchor + else + raise "wtf bro: #{strexp}" + end + + @names = nil + @optional_names = nil + @required_names = nil + @re = nil + @offsets = nil + end + + def ast + @spec.grep(Nodes::Symbol).each do |node| + re = @requirements[node.to_sym] + node.regexp = re if re + end + + @spec.grep(Nodes::Star).each do |node| + node = node.left + node.regexp = @requirements[node.to_sym] || /(.+)/ + end + + @spec + end + + def names + @names ||= spec.grep(Nodes::Symbol).map { |n| n.name } + end + + def required_names + @required_names ||= names - optional_names + end + + def optional_names + @optional_names ||= spec.grep(Nodes::Group).map { |group| + group.grep(Nodes::Symbol) + }.flatten.map { |n| n.name }.uniq + end + + class RegexpOffsets < Journey::Visitors::Visitor # :nodoc: + attr_reader :offsets + + def initialize matchers + @matchers = matchers + @capture_count = [0] + end + + def visit node + super + @capture_count + end + + def visit_SYMBOL node + node = node.to_sym + + if @matchers.key? node + re = /#{@matchers[node]}|/ + @capture_count.push((re.match('').length - 1) + (@capture_count.last || 0)) + else + @capture_count << (@capture_count.last || 0) + end + end + end + + class AnchoredRegexp < Journey::Visitors::Visitor # :nodoc: + def initialize separator, matchers + @separator = separator + @matchers = matchers + @separator_re = "([^#{separator}]+)" + super() + end + + def accept node + %r{\A#{visit node}\Z} + end + + def visit_CAT node + [visit(node.left), visit(node.right)].join + end + + def visit_SYMBOL node + node = node.to_sym + + return @separator_re unless @matchers.key? node + + re = @matchers[node] + "(#{re})" + end + + def visit_GROUP node + "(?:#{visit node.left})?" + end + + def visit_LITERAL node + Regexp.escape node.left + end + alias :visit_DOT :visit_LITERAL + + def visit_SLASH node + node.left + end + + def visit_STAR node + re = @matchers[node.left.to_sym] || '.+' + "(#{re})" + end + end + + class UnanchoredRegexp < AnchoredRegexp # :nodoc: + def accept node + %r{\A#{visit node}} + end + end + + class MatchData + attr_reader :names + + def initialize names, offsets, match + @names = names + @offsets = offsets + @match = match + end + + def captures + (length - 1).times.map { |i| self[i + 1] } + end + + def [] x + idx = @offsets[x - 1] + x + @match[idx] + end + + def length + @offsets.length + end + + def post_match + @match.post_match + end + + def to_s + @match.to_s + end + end + + def match other + return unless match = to_regexp.match(other) + MatchData.new names, offsets, match + end + alias :=~ :match + + def source + to_regexp.source + end + + def to_regexp + @re ||= regexp_visitor.new(@separators, @requirements).accept spec + end + + private + def regexp_visitor + @anchored ? AnchoredRegexp : UnanchoredRegexp + end + + def offsets + return @offsets if @offsets + + viz = RegexpOffsets.new @requirements + @offsets = viz.accept spec + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/route.rb b/actionpack/lib/action_dispatch/journey/route.rb new file mode 100644 index 0000000000..04d31cb580 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/route.rb @@ -0,0 +1,94 @@ +module ActionDispatch + module Journey + class Route + attr_reader :app, :path, :verb, :defaults, :ip, :name + + attr_reader :constraints + alias :conditions :constraints + + attr_accessor :precedence + + ## + # +path+ is a path constraint. + # +constraints+ is a hash of constraints to be applied to this route. + def initialize name, app, path, constraints, defaults = {} + constraints = constraints.dup + @name = name + @app = app + @path = path + @verb = constraints[:request_method] || // + @ip = constraints.delete(:ip) || // + + @constraints = constraints + @constraints.keep_if { |_,v| Regexp === v || String === v } + @defaults = defaults + @required_defaults = nil + @required_parts = nil + @parts = nil + @decorated_ast = nil + @precedence = 0 + end + + def ast + return @decorated_ast if @decorated_ast + + @decorated_ast = path.ast + @decorated_ast.grep(Nodes::Terminal).each { |n| n.memo = self } + @decorated_ast + end + + def requirements # :nodoc: + # needed for rails `rake routes` + path.requirements.merge(@defaults).delete_if { |_,v| + /.+?/ == v + } + end + + def segments + @path.names + end + + def required_keys + path.required_names.map { |x| x.to_sym } + required_defaults.keys + end + + def score constraints + required_keys = path.required_names + supplied_keys = constraints.map { |k,v| v && k.to_s }.compact + + return -1 unless (required_keys - supplied_keys).empty? + + score = (supplied_keys & path.names).length + score + (required_defaults.length * 2) + end + + def parts + @parts ||= segments.map { |n| n.to_sym } + end + alias :segment_keys :parts + + def format path_options + path_options.delete_if do |key, value| + value.to_s == defaults[key].to_s && !required_parts.include?(key) + end + + Visitors::Formatter.new(path_options).accept(path.spec) + end + + def optional_parts + path.optional_names.map { |n| n.to_sym } + end + + def required_parts + @required_parts ||= path.required_names.map { |n| n.to_sym } + end + + def required_defaults + @required_defaults ||= begin + matches = parts + @defaults.dup.delete_if { |k,_| matches.include? k } + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/router.rb b/actionpack/lib/action_dispatch/journey/router.rb new file mode 100644 index 0000000000..fcf16c1272 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/router.rb @@ -0,0 +1,168 @@ +require 'action_dispatch/journey/core-ext/hash' +require 'action_dispatch/journey/router/utils' +require 'action_dispatch/journey/router/strexp' +require 'action_dispatch/journey/routes' +require 'action_dispatch/journey/formatter' + +before = $-w +$-w = false +require 'action_dispatch/journey/parser' +$-w = before + +require 'action_dispatch/journey/route' +require 'action_dispatch/journey/path/pattern' + +module ActionDispatch + module Journey + class Router + class RoutingError < ::StandardError + end + + VERSION = '2.0.0' + + class NullReq # :nodoc: + attr_reader :env + def initialize env + @env = env + end + + def request_method + env['REQUEST_METHOD'] + end + + def path_info + env['PATH_INFO'] + end + + def ip + env['REMOTE_ADDR'] + end + + def [](k); env[k]; end + end + + attr_reader :request_class, :formatter + attr_accessor :routes + + def initialize routes, options + @options = options + @params_key = options[:parameters_key] + @request_class = options[:request_class] || NullReq + @routes = routes + end + + def call env + env['PATH_INFO'] = Utils.normalize_path env['PATH_INFO'] + + find_routes(env).each do |match, parameters, route| + script_name, path_info, set_params = env.values_at('SCRIPT_NAME', + 'PATH_INFO', + @params_key) + + unless route.path.anchored + env['SCRIPT_NAME'] = (script_name.to_s + match.to_s).chomp('/') + env['PATH_INFO'] = match.post_match + end + + env[@params_key] = (set_params || {}).merge parameters + + status, headers, body = route.app.call(env) + + if 'pass' == headers['X-Cascade'] + env['SCRIPT_NAME'] = script_name + env['PATH_INFO'] = path_info + env[@params_key] = set_params + next + end + + return [status, headers, body] + end + + return [404, {'X-Cascade' => 'pass'}, ['Not Found']] + end + + def recognize req + find_routes(req.env).each do |match, parameters, route| + unless route.path.anchored + req.env['SCRIPT_NAME'] = match.to_s + req.env['PATH_INFO'] = match.post_match.sub(/^([^\/])/, '/\1') + end + + yield(route, nil, parameters) + end + end + + def visualizer + tt = GTG::Builder.new(ast).transition_table + groups = partitioned_routes.first.map(&:ast).group_by { |a| a.to_s } + asts = groups.values.map { |v| v.first } + tt.visualizer asts + end + + private + + def partitioned_routes + routes.partitioned_routes + end + + def ast + routes.ast + end + + def simulator + routes.simulator + end + + def custom_routes + partitioned_routes.last + end + + def filter_routes path + return [] unless ast + data = simulator.match(path) + data ? data.memos : [] + end + + def find_routes env + req = request_class.new env + + routes = filter_routes(req.path_info).concat custom_routes.find_all { |r| + r.path.match(req.path_info) + } + routes.concat get_routes_as_head(routes) + + routes.sort_by!(&:precedence).select! { |r| + r.constraints.all? { |k,v| v === req.send(k) } && + r.verb === req.request_method + } + routes.reject! { |r| req.ip && !(r.ip === req.ip) } + + routes.map! { |r| + match_data = r.path.match(req.path_info) + match_names = match_data.names.map { |n| n.to_sym } + match_values = match_data.captures.map { |v| v && Utils.unescape_uri(v) } + info = Hash[match_names.zip(match_values).find_all { |_,y| y }] + + [match_data, r.defaults.merge(info), r] + } + end + + def get_routes_as_head(routes) + precedence = (routes.map(&:precedence).max || 0) + 1 + routes = routes.select { |r| + r.verb === "GET" && !(r.verb === "HEAD") + }.map! { |r| + Route.new(r.name, + r.app, + r.path, + r.conditions.merge(:request_method => "HEAD"), + r.defaults).tap do |route| + route.precedence = r.precedence + precedence + end + } + routes.flatten! + routes + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/router/strexp.rb b/actionpack/lib/action_dispatch/journey/router/strexp.rb new file mode 100644 index 0000000000..268bb4602f --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/router/strexp.rb @@ -0,0 +1,24 @@ +module ActionDispatch + module Journey + class Router + class Strexp + class << self + alias :compile :new + end + + attr_reader :path, :requirements, :separators, :anchor + + def initialize path, requirements, separators, anchor = true + @path = path + @requirements = requirements + @separators = separators + @anchor = anchor + end + + def names + @path.scan(/:\w+/).map { |s| s.tr(':', '') } + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/router/utils.rb b/actionpack/lib/action_dispatch/journey/router/utils.rb new file mode 100644 index 0000000000..a21b570013 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/router/utils.rb @@ -0,0 +1,59 @@ +require 'uri' + +module ActionDispatch + module Journey + class Router + class Utils + # Normalizes URI path. + # + # Strips off trailing slash and ensures there is a leading slash. + # + # normalize_path("/foo") # => "/foo" + # normalize_path("/foo/") # => "/foo" + # normalize_path("foo") # => "/foo" + # normalize_path("") # => "/" + def self.normalize_path(path) + path = "/#{path}" + path.squeeze!('/') + path.sub!(%r{/+\Z}, '') + path = '/' if path == '' + path + end + + # URI path and fragment escaping + # http://tools.ietf.org/html/rfc3986 + module UriEscape + # Symbol captures can generate multiple path segments, so include /. + reserved_segment = '/' + reserved_fragment = '/?' + reserved_pchar = ':@&=+$,;%' + + safe_pchar = "#{URI::REGEXP::PATTERN::UNRESERVED}#{reserved_pchar}" + safe_segment = "#{safe_pchar}#{reserved_segment}" + safe_fragment = "#{safe_pchar}#{reserved_fragment}" + if RUBY_VERSION >= '1.9' + UNSAFE_SEGMENT = Regexp.new("[^#{safe_segment}]", false).freeze + UNSAFE_FRAGMENT = Regexp.new("[^#{safe_fragment}]", false).freeze + else + UNSAFE_SEGMENT = Regexp.new("[^#{safe_segment}]", false, 'N').freeze + UNSAFE_FRAGMENT = Regexp.new("[^#{safe_fragment}]", false, 'N').freeze + end + end + + Parser = URI.const_defined?(:Parser) ? URI::Parser.new : URI + + def self.escape_path(path) + Parser.escape(path.to_s, UriEscape::UNSAFE_SEGMENT) + end + + def self.escape_fragment(fragment) + Parser.escape(fragment.to_s, UriEscape::UNSAFE_FRAGMENT) + end + + def self.unescape_uri(uri) + Parser.unescape(uri) + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/routes.rb b/actionpack/lib/action_dispatch/journey/routes.rb new file mode 100644 index 0000000000..f9c4cdbd4b --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/routes.rb @@ -0,0 +1,77 @@ +module ActionDispatch + module Journey + ### + # The Routing table. Contains all routes for a system. Routes can be + # added to the table by calling Routes#add_route + class Routes + include Enumerable + + attr_reader :routes, :named_routes + + def initialize + @routes = [] + @named_routes = {} + @ast = nil + @partitioned_routes = nil + @simulator = nil + end + + def length + @routes.length + end + alias :size :length + + def last + @routes.last + end + + def each(&block) + routes.each(&block) + end + + def clear + routes.clear + end + + def partitioned_routes + @partitioned_routes ||= routes.partition { |r| + r.path.anchored && r.ast.grep(Nodes::Symbol).all? { |n| n.default_regexp? } + } + end + + def ast + return @ast if @ast + return if partitioned_routes.first.empty? + + asts = partitioned_routes.first.map { |r| r.ast } + @ast = Nodes::Or.new(asts) + end + + def simulator + return @simulator if @simulator + + gtg = GTG::Builder.new(ast).transition_table + @simulator = GTG::Simulator.new gtg + end + + ### + # Add a route to the routing table. + def add_route app, path, conditions, defaults, name = nil + route = Route.new(name, app, path, conditions, defaults) + + route.precedence = routes.length + routes << route + named_routes[name] = route if name && !named_routes[name] + clear_cache! + route + end + + private + def clear_cache! + @ast = nil + @partitioned_routes = nil + @simulator = nil + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/scanner.rb b/actionpack/lib/action_dispatch/journey/scanner.rb new file mode 100644 index 0000000000..b45d72668b --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/scanner.rb @@ -0,0 +1,60 @@ +require 'strscan' + +module ActionDispatch + module Journey + class Scanner + def initialize + @ss = nil + end + + def scan_setup str + @ss = StringScanner.new str + end + + def eos? + @ss.eos? + end + + def pos + @ss.pos + end + + def pre_match + @ss.pre_match + end + + def next_token + return if @ss.eos? + + until token = scan || @ss.eos?; end + token + end + + private + def scan + case + # / + when text = @ss.scan(/\//) + [:SLASH, text] + when text = @ss.scan(/\*\w+/) + [:STAR, text] + when text = @ss.scan(/\(/) + [:LPAREN, text] + when text = @ss.scan(/\)/) + [:RPAREN, text] + when text = @ss.scan(/\|/) + [:OR, text] + when text = @ss.scan(/\./) + [:DOT, text] + when text = @ss.scan(/:\w+/) + [:SYMBOL, text] + when text = @ss.scan(/[\w%\-~]+/) + [:LITERAL, text] + # any char + when text = @ss.scan(/./) + [:LITERAL, text] + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/visitors.rb b/actionpack/lib/action_dispatch/journey/visitors.rb new file mode 100644 index 0000000000..b3f4796607 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/visitors.rb @@ -0,0 +1,188 @@ +# encoding: utf-8 +module ActionDispatch + module Journey + module Visitors + class Visitor # :nodoc: + DISPATCH_CACHE = Hash.new { |h,k| + h[k] = "visit_#{k}" + } + + def accept node + visit node + end + + private + def visit node + send DISPATCH_CACHE[node.type], node + end + + def binary node + visit node.left + visit node.right + end + def visit_CAT(n); binary(n); end + + def nary node + node.children.each { |c| visit c } + end + def visit_OR(n); nary(n); end + + def unary node + visit node.left + end + def visit_GROUP(n); unary(n); end + def visit_STAR(n); unary(n); end + + def terminal node; end + %w{ LITERAL SYMBOL SLASH DOT }.each do |t| + class_eval %{ def visit_#{t}(n); terminal(n); end }, __FILE__, __LINE__ + end + end + + ## + # Loop through the requirements AST + class Each < Visitor # :nodoc: + attr_reader :block + + def initialize block + @block = block + end + + def visit node + super + block.call node + end + end + + class String < Visitor + private + + def binary node + [visit(node.left), visit(node.right)].join + end + + def nary node + node.children.map { |c| visit c }.join '|' + end + + def terminal node + node.left + end + + def visit_GROUP node + "(#{visit node.left})" + end + end + + ### + # Used for formatting urls (url_for) + class Formatter < Visitor + attr_reader :options, :consumed + + def initialize options + @options = options + @consumed = {} + end + + private + def visit_GROUP node + if consumed == options + nil + else + route = visit node.left + route.include?("\0") ? nil : route + end + end + + def terminal node + node.left + end + + def binary node + [visit(node.left), visit(node.right)].join + end + + def nary node + node.children.map { |c| visit c }.join + end + + def visit_SYMBOL node + key = node.to_sym + + if value = options[key] + consumed[key] = value + Router::Utils.escape_path(value) + else + "\0" + end + end + end + + class Dot < Visitor + def initialize + @nodes = [] + @edges = [] + end + + def accept node + super + <<-eodot + digraph parse_tree { + size="8,5" + node [shape = none]; + edge [dir = none]; + #{@nodes.join "\n"} + #{@edges.join("\n")} + } + eodot + end + + private + def binary node + node.children.each do |c| + @edges << "#{node.object_id} -> #{c.object_id};" + end + super + end + + def nary node + node.children.each do |c| + @edges << "#{node.object_id} -> #{c.object_id};" + end + super + end + + def unary node + @edges << "#{node.object_id} -> #{node.left.object_id};" + super + end + + def visit_GROUP node + @nodes << "#{node.object_id} [label=\"()\"];" + super + end + + def visit_CAT node + @nodes << "#{node.object_id} [label=\"○\"];" + super + end + + def visit_STAR node + @nodes << "#{node.object_id} [label=\"*\"];" + super + end + + def visit_OR node + @nodes << "#{node.object_id} [label=\"|\"];" + super + end + + def terminal node + value = node.left + + @nodes << "#{node.object_id} [label=\"#{value}\"];" + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/visualizer/fsm.css b/actionpack/lib/action_dispatch/journey/visualizer/fsm.css new file mode 100644 index 0000000000..50caebaa18 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/visualizer/fsm.css @@ -0,0 +1,34 @@ +body { + font-family: "Helvetica Neue", Helvetica, Arial, Sans-Serif; + margin: 0; +} + +h1 { + font-size: 2.0em; font-weight: bold; text-align: center; + color: white; background-color: black; + padding: 5px 0; + margin: 0 0 20px; +} + +h2 { + text-align: center; + display: none; + font-size: 0.5em; +} + +div#chart-2 { + height: 350px; +} + +.clearfix {display: inline-block; } +.input { overflow: show;} +.instruction { color: #666; padding: 0 30px 20px; font-size: 0.9em} +.instruction p { padding: 0 0 5px; } +.instruction li { padding: 0 10px 5px; } + +.form { background: #EEE; padding: 20px 30px; border-radius: 5px; margin-left: auto; margin-right: auto; width: 500px; margin-bottom: 20px} +.form p, .form form { text-align: center } +.form form {padding: 0 10px 5px; } +.form .fun_routes { font-size: 0.9em;} +.form .fun_routes a { margin: 0 5px 0 0; } + diff --git a/actionpack/lib/action_dispatch/journey/visualizer/fsm.js b/actionpack/lib/action_dispatch/journey/visualizer/fsm.js new file mode 100644 index 0000000000..d9bcaef928 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/visualizer/fsm.js @@ -0,0 +1,134 @@ +function tokenize(input, callback) { + while(input.length > 0) { + callback(input.match(/^[\/\.\?]|[^\/\.\?]+/)[0]); + input = input.replace(/^[\/\.\?]|[^\/\.\?]+/, ''); + } +} + +var graph = d3.select("#chart-2 svg"); +var svg_edges = {}; +var svg_nodes = {}; + +graph.selectAll("g.edge").each(function() { + var node = d3.select(this); + var index = node.select("title").text().split("->"); + var left = parseInt(index[0]); + var right = parseInt(index[1]); + + if(!svg_edges[left]) { svg_edges[left] = {} } + svg_edges[left][right] = node; +}); + +graph.selectAll("g.node").each(function() { + var node = d3.select(this); + var index = parseInt(node.select("title").text()); + svg_nodes[index] = node; +}); + +function reset_graph() { + for(var key in svg_edges) { + for(var mkey in svg_edges[key]) { + var node = svg_edges[key][mkey]; + var path = node.select("path"); + var arrow = node.select("polygon"); + path.style("stroke", "black"); + arrow.style("stroke", "black").style("fill", "black"); + } + } + + for(var key in svg_nodes) { + var node = svg_nodes[key]; + node.select('ellipse').style("fill", "white"); + node.select('polygon').style("fill", "white"); + } + return false; +} + +function highlight_edge(from, to) { + var node = svg_edges[from][to]; + var path = node.select("path"); + var arrow = node.select("polygon"); + + path + .transition().duration(500) + .style("stroke", "green"); + + arrow + .transition().duration(500) + .style("stroke", "green").style("fill", "green"); +} + +function highlight_state(index, color) { + if(!color) { color = "green"; } + + svg_nodes[index].select('ellipse') + .style("fill", "white") + .transition().duration(500) + .style("fill", color); +} + +function highlight_finish(index) { + svg_nodes[index].select('polygon') + .style("fill", "while") + .transition().duration(500) + .style("fill", "blue"); +} + +function match(input) { + reset_graph(); + var table = tt(); + var states = [0]; + var regexp_states = table['regexp_states']; + var string_states = table['string_states']; + var accepting = table['accepting']; + + highlight_state(0); + + tokenize(input, function(token) { + var new_states = []; + for(var key in states) { + var state = states[key]; + + if(string_states[state] && string_states[state][token]) { + var new_state = string_states[state][token]; + highlight_edge(state, new_state); + highlight_state(new_state); + new_states.push(new_state); + } + + if(regexp_states[state]) { + for(var key in regexp_states[state]) { + var re = new RegExp("^" + key + "$"); + if(re.test(token)) { + var new_state = regexp_states[state][key]; + highlight_edge(state, new_state); + highlight_state(new_state); + new_states.push(new_state); + } + } + } + } + + if(new_states.length == 0) { + return; + } + states = new_states; + }); + + for(var key in states) { + var state = states[key]; + if(accepting[state]) { + for(var mkey in svg_edges[state]) { + if(!regexp_states[mkey] && !string_states[mkey]) { + highlight_edge(state, mkey); + highlight_finish(mkey); + } + } + } else { + highlight_state(state, "red"); + } + } + + return false; +} + diff --git a/actionpack/lib/action_dispatch/journey/visualizer/index.html.erb b/actionpack/lib/action_dispatch/journey/visualizer/index.html.erb new file mode 100644 index 0000000000..6aff10956a --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/visualizer/index.html.erb @@ -0,0 +1,52 @@ +<!DOCTYPE html> +<html> + <head> + <title><%= title %></title> + <link rel="stylesheet" href="https://raw.github.com/gist/1706081/af944401f75ea20515a02ddb3fb43d23ecb8c662/reset.css" type="text/css"> + <style> + <% stylesheets.each do |style| %> + <%= style %> + <% end %> + </style> + <script src="https://raw.github.com/gist/1706081/df464722a05c3c2bec450b7b5c8240d9c31fa52d/d3.min.js" type="text/javascript"></script> + </head> + <body> + <div id="wrapper"> + <h1>Routes FSM with NFA simulation</h1> + <div class="instruction form"> + <p> + Type a route in to the box and click "simulate". + </p> + <form onsubmit="return match(this.route.value);"> + <input type="text" size="30" name="route" value="/articles/new" /> + <button>simulate</button> + <input type="reset" value="reset" onclick="return reset_graph();"/> + </form> + <p class="fun_routes"> + Some fun routes to try: + <% fun_routes.each do |path| %> + <a href="#" onclick="document.forms[0].elements[0].value=this.text.replace(/^\s+|\s+$/g,''); return match(this.text.replace(/^\s+|\s+$/g,''));"> + <%= path %> + </a> + <% end %> + </p> + </div> + <div class='chart' id='chart-2'> + <%= svg %> + </div> + <div class="instruction"> + <p> + This is a FSM for a system that has the following routes: + </p> + <ul> + <% paths.each do |route| %> + <li><%= route %></li> + <% end %> + </ul> + </div> + </div> + <% javascripts.each do |js| %> + <script><%= js %></script> + <% end %> + </body> +</html> diff --git a/actionpack/lib/action_dispatch/routing/route_set.rb b/actionpack/lib/action_dispatch/routing/route_set.rb index eb9d4b24f1..b1959e388c 100644 --- a/actionpack/lib/action_dispatch/routing/route_set.rb +++ b/actionpack/lib/action_dispatch/routing/route_set.rb @@ -1,4 +1,4 @@ -require 'journey' +require 'action_dispatch/journey' require 'forwardable' require 'thread_safe' require 'active_support/core_ext/object/to_query' diff --git a/actionpack/test/journey/gtg/builder_test.rb b/actionpack/test/journey/gtg/builder_test.rb new file mode 100644 index 0000000000..a633c3eea6 --- /dev/null +++ b/actionpack/test/journey/gtg/builder_test.rb @@ -0,0 +1,79 @@ +require 'abstract_unit' + +module ActionDispatch + module Journey + module GTG + class TestBuilder < MiniTest::Unit::TestCase + def test_following_states_multi + table = tt ['a|a'] + assert_equal 1, table.move([0], 'a').length + end + + def test_following_states_multi_regexp + table = tt [':a|b'] + assert_equal 1, table.move([0], 'fooo').length + assert_equal 2, table.move([0], 'b').length + end + + def test_multi_path + table = tt ['/:a/d', '/b/c'] + + [ + [1, '/'], + [2, 'b'], + [2, '/'], + [1, 'c'], + ].inject([0]) { |state, (exp, sym)| + new = table.move(state, sym) + assert_equal exp, new.length + new + } + end + + def test_match_data_ambiguous + table = tt %w{ + /articles(.:format) + /articles/new(.:format) + /articles/:id/edit(.:format) + /articles/:id(.:format) + } + + sim = NFA::Simulator.new table + + match = sim.match '/articles/new' + assert_equal 2, match.memos.length + end + + ## + # Identical Routes may have different restrictions. + def test_match_same_paths + table = tt %w{ + /articles/new(.:format) + /articles/new(.:format) + } + + sim = NFA::Simulator.new table + + match = sim.match '/articles/new' + assert_equal 2, match.memos.length + end + + private + def ast strings + parser = Journey::Parser.new + asts = strings.map { |string| + memo = Object.new + ast = parser.parse string + ast.each { |n| n.memo = memo } + ast + } + Nodes::Or.new asts + end + + def tt strings + Builder.new(ast(strings)).transition_table + end + end + end + end +end diff --git a/actionpack/test/journey/gtg/transition_table_test.rb b/actionpack/test/journey/gtg/transition_table_test.rb new file mode 100644 index 0000000000..6d81b72c41 --- /dev/null +++ b/actionpack/test/journey/gtg/transition_table_test.rb @@ -0,0 +1,115 @@ +require 'abstract_unit' +require 'json' + +module ActionDispatch + module Journey + module GTG + class TestGeneralizedTable < MiniTest::Unit::TestCase + def test_to_json + table = tt %w{ + /articles(.:format) + /articles/new(.:format) + /articles/:id/edit(.:format) + /articles/:id(.:format) + } + + json = JSON.load table.to_json + assert json['regexp_states'] + assert json['string_states'] + assert json['accepting'] + end + + if system("dot -V 2>/dev/null") + def test_to_svg + table = tt %w{ + /articles(.:format) + /articles/new(.:format) + /articles/:id/edit(.:format) + /articles/:id(.:format) + } + svg = table.to_svg + assert svg + refute_match(/DOCTYPE/, svg) + end + end + + def test_simulate_gt + sim = simulator_for ['/foo', '/bar'] + assert_match sim, '/foo' + end + + def test_simulate_gt_regexp + sim = simulator_for [':foo'] + assert_match sim, 'foo' + end + + def test_simulate_gt_regexp_mix + sim = simulator_for ['/get', '/:method/foo'] + assert_match sim, '/get' + assert_match sim, '/get/foo' + end + + def test_simulate_optional + sim = simulator_for ['/foo(/bar)'] + assert_match sim, '/foo' + assert_match sim, '/foo/bar' + refute_match sim, '/foo/' + end + + def test_match_data + path_asts = asts %w{ /get /:method/foo } + paths = path_asts.dup + + builder = GTG::Builder.new Nodes::Or.new path_asts + tt = builder.transition_table + + sim = GTG::Simulator.new tt + + match = sim.match '/get' + assert_equal [paths.first], match.memos + + match = sim.match '/get/foo' + assert_equal [paths.last], match.memos + end + + def test_match_data_ambiguous + path_asts = asts %w{ + /articles(.:format) + /articles/new(.:format) + /articles/:id/edit(.:format) + /articles/:id(.:format) + } + + paths = path_asts.dup + ast = Nodes::Or.new path_asts + + builder = GTG::Builder.new ast + sim = GTG::Simulator.new builder.transition_table + + match = sim.match '/articles/new' + assert_equal [paths[1], paths[3]], match.memos + end + + private + def asts paths + parser = Journey::Parser.new + paths.map { |x| + ast = parser.parse x + ast.each { |n| n.memo = ast} + ast + } + end + + def tt paths + x = asts paths + builder = GTG::Builder.new Nodes::Or.new x + builder.transition_table + end + + def simulator_for paths + GTG::Simulator.new tt(paths) + end + end + end + end +end diff --git a/actionpack/test/journey/nfa/simulator_test.rb b/actionpack/test/journey/nfa/simulator_test.rb new file mode 100644 index 0000000000..9f89329b57 --- /dev/null +++ b/actionpack/test/journey/nfa/simulator_test.rb @@ -0,0 +1,98 @@ +require 'abstract_unit' + +module ActionDispatch + module Journey + module NFA + class TestSimulator < MiniTest::Unit::TestCase + def test_simulate_simple + sim = simulator_for ['/foo'] + assert_match sim, '/foo' + end + + def test_simulate_simple_no_match + sim = simulator_for ['/foo'] + refute_match sim, 'foo' + end + + def test_simulate_simple_no_match_too_long + sim = simulator_for ['/foo'] + refute_match sim, '/foo/bar' + end + + def test_simulate_simple_no_match_wrong_string + sim = simulator_for ['/foo'] + refute_match sim, '/bar' + end + + def test_simulate_regex + sim = simulator_for ['/:foo/bar'] + assert_match sim, '/bar/bar' + assert_match sim, '/foo/bar' + end + + def test_simulate_or + sim = simulator_for ['/foo', '/bar'] + assert_match sim, '/bar' + assert_match sim, '/foo' + refute_match sim, '/baz' + end + + def test_simulate_optional + sim = simulator_for ['/foo(/bar)'] + assert_match sim, '/foo' + assert_match sim, '/foo/bar' + refute_match sim, '/foo/' + end + + def test_matchdata_has_memos + paths = %w{ /foo /bar } + parser = Journey::Parser.new + asts = paths.map { |x| + ast = parser.parse x + ast.each { |n| n.memo = ast} + ast + } + + expected = asts.first + + builder = Builder.new Nodes::Or.new asts + + sim = Simulator.new builder.transition_table + + md = sim.match '/foo' + assert_equal [expected], md.memos + end + + def test_matchdata_memos_on_merge + parser = Journey::Parser.new + routes = [ + '/articles(.:format)', + '/articles/new(.:format)', + '/articles/:id/edit(.:format)', + '/articles/:id(.:format)', + ].map { |path| + ast = parser.parse path + ast.each { |n| n.memo = ast } + ast + } + + asts = routes.dup + + ast = Nodes::Or.new routes + + nfa = Journey::NFA::Builder.new ast + sim = Simulator.new nfa.transition_table + md = sim.match '/articles' + assert_equal [asts.first], md.memos + end + + def simulator_for paths + parser = Journey::Parser.new + asts = paths.map { |x| parser.parse x } + builder = Builder.new Nodes::Or.new asts + Simulator.new builder.transition_table + end + end + end + end +end diff --git a/actionpack/test/journey/nfa/transition_table_test.rb b/actionpack/test/journey/nfa/transition_table_test.rb new file mode 100644 index 0000000000..72cefe42bf --- /dev/null +++ b/actionpack/test/journey/nfa/transition_table_test.rb @@ -0,0 +1,72 @@ +require 'abstract_unit' + +module ActionDispatch + module Journey + module NFA + class TestTransitionTable < MiniTest::Unit::TestCase + def setup + @parser = Journey::Parser.new + end + + def test_eclosure + table = tt '/' + assert_equal [0], table.eclosure(0) + + table = tt ':a|:b' + assert_equal 3, table.eclosure(0).length + + table = tt '(:a|:b)' + assert_equal 5, table.eclosure(0).length + assert_equal 5, table.eclosure([0]).length + end + + def test_following_states_one + table = tt '/' + + assert_equal [1], table.following_states(0, '/') + assert_equal [1], table.following_states([0], '/') + end + + def test_following_states_group + table = tt 'a|b' + states = table.eclosure 0 + + assert_equal 1, table.following_states(states, 'a').length + assert_equal 1, table.following_states(states, 'b').length + end + + def test_following_states_multi + table = tt 'a|a' + states = table.eclosure 0 + + assert_equal 2, table.following_states(states, 'a').length + assert_equal 0, table.following_states(states, 'b').length + end + + def test_following_states_regexp + table = tt 'a|:a' + states = table.eclosure 0 + + assert_equal 1, table.following_states(states, 'a').length + assert_equal 1, table.following_states(states, /[^\.\/\?]+/).length + assert_equal 0, table.following_states(states, 'b').length + end + + def test_alphabet + table = tt 'a|:a' + assert_equal [/[^\.\/\?]+/, 'a'], table.alphabet + + table = tt 'a|a' + assert_equal ['a'], table.alphabet + end + + private + def tt string + ast = @parser.parse string + builder = Builder.new ast + builder.transition_table + end + end + end + end +end diff --git a/actionpack/test/journey/nodes/symbol_test.rb b/actionpack/test/journey/nodes/symbol_test.rb new file mode 100644 index 0000000000..f53840274a --- /dev/null +++ b/actionpack/test/journey/nodes/symbol_test.rb @@ -0,0 +1,17 @@ +require 'abstract_unit' + +module ActionDispatch + module Journey + module Nodes + class TestSymbol < MiniTest::Unit::TestCase + def test_default_regexp? + sym = Symbol.new nil + assert sym.default_regexp? + + sym.regexp = nil + refute sym.default_regexp? + end + end + end + end +end diff --git a/actionpack/test/journey/path/pattern_test.rb b/actionpack/test/journey/path/pattern_test.rb new file mode 100644 index 0000000000..0f2d0d44c0 --- /dev/null +++ b/actionpack/test/journey/path/pattern_test.rb @@ -0,0 +1,284 @@ +require 'abstract_unit' + +module ActionDispatch + module Journey + module Path + class TestPattern < MiniTest::Unit::TestCase + x = /.+/ + { + '/:controller(/:action)' => %r{\A/(#{x})(?:/([^/.?]+))?\Z}, + '/:controller/foo' => %r{\A/(#{x})/foo\Z}, + '/:controller/:action' => %r{\A/(#{x})/([^/.?]+)\Z}, + '/:controller' => %r{\A/(#{x})\Z}, + '/:controller(/:action(/:id))' => %r{\A/(#{x})(?:/([^/.?]+)(?:/([^/.?]+))?)?\Z}, + '/:controller/:action.xml' => %r{\A/(#{x})/([^/.?]+)\.xml\Z}, + '/:controller.:format' => %r{\A/(#{x})\.([^/.?]+)\Z}, + '/:controller(.:format)' => %r{\A/(#{x})(?:\.([^/.?]+))?\Z}, + '/:controller/*foo' => %r{\A/(#{x})/(.+)\Z}, + '/:controller/*foo/bar' => %r{\A/(#{x})/(.+)/bar\Z}, + }.each do |path, expected| + define_method(:"test_to_regexp_#{path}") do + strexp = Router::Strexp.new( + path, + { :controller => /.+/ }, + ["/", ".", "?"] + ) + path = Pattern.new strexp + assert_equal(expected, path.to_regexp) + end + end + + { + '/:controller(/:action)' => %r{\A/(#{x})(?:/([^/.?]+))?}, + '/:controller/foo' => %r{\A/(#{x})/foo}, + '/:controller/:action' => %r{\A/(#{x})/([^/.?]+)}, + '/:controller' => %r{\A/(#{x})}, + '/:controller(/:action(/:id))' => %r{\A/(#{x})(?:/([^/.?]+)(?:/([^/.?]+))?)?}, + '/:controller/:action.xml' => %r{\A/(#{x})/([^/.?]+)\.xml}, + '/:controller.:format' => %r{\A/(#{x})\.([^/.?]+)}, + '/:controller(.:format)' => %r{\A/(#{x})(?:\.([^/.?]+))?}, + '/:controller/*foo' => %r{\A/(#{x})/(.+)}, + '/:controller/*foo/bar' => %r{\A/(#{x})/(.+)/bar}, + }.each do |path, expected| + define_method(:"test_to_non_anchored_regexp_#{path}") do + strexp = Router::Strexp.new( + path, + { :controller => /.+/ }, + ["/", ".", "?"], + false + ) + path = Pattern.new strexp + assert_equal(expected, path.to_regexp) + end + end + + { + '/:controller(/:action)' => %w{ controller action }, + '/:controller/foo' => %w{ controller }, + '/:controller/:action' => %w{ controller action }, + '/:controller' => %w{ controller }, + '/:controller(/:action(/:id))' => %w{ controller action id }, + '/:controller/:action.xml' => %w{ controller action }, + '/:controller.:format' => %w{ controller format }, + '/:controller(.:format)' => %w{ controller format }, + '/:controller/*foo' => %w{ controller foo }, + '/:controller/*foo/bar' => %w{ controller foo }, + }.each do |path, expected| + define_method(:"test_names_#{path}") do + strexp = Router::Strexp.new( + path, + { :controller => /.+/ }, + ["/", ".", "?"] + ) + path = Pattern.new strexp + assert_equal(expected, path.names) + end + end + + def test_to_regexp_with_extended_group + strexp = Router::Strexp.new( + '/page/:name', + { :name => / + #ROFL + (tender|love + #MAO + )/x }, + ["/", ".", "?"] + ) + path = Pattern.new strexp + assert_match(path, '/page/tender') + assert_match(path, '/page/love') + refute_match(path, '/page/loving') + end + + def test_optional_names + [ + ['/:foo(/:bar(/:baz))', %w{ bar baz }], + ['/:foo(/:bar)', %w{ bar }], + ['/:foo(/:bar)/:lol(/:baz)', %w{ bar baz }], + ].each do |pattern, list| + path = Pattern.new pattern + assert_equal list.sort, path.optional_names.sort + end + end + + def test_to_regexp_match_non_optional + strexp = Router::Strexp.new( + '/:name', + { :name => /\d+/ }, + ["/", ".", "?"] + ) + path = Pattern.new strexp + assert_match(path, '/123') + refute_match(path, '/') + end + + def test_to_regexp_with_group + strexp = Router::Strexp.new( + '/page/:name', + { :name => /(tender|love)/ }, + ["/", ".", "?"] + ) + path = Pattern.new strexp + assert_match(path, '/page/tender') + assert_match(path, '/page/love') + refute_match(path, '/page/loving') + end + + def test_ast_sets_regular_expressions + requirements = { :name => /(tender|love)/, :value => /./ } + strexp = Router::Strexp.new( + '/page/:name/:value', + requirements, + ["/", ".", "?"] + ) + + assert_equal requirements, strexp.requirements + + path = Pattern.new strexp + nodes = path.ast.grep(Nodes::Symbol) + assert_equal 2, nodes.length + nodes.each do |node| + assert_equal requirements[node.to_sym], node.regexp + end + end + + def test_match_data_with_group + strexp = Router::Strexp.new( + '/page/:name', + { :name => /(tender|love)/ }, + ["/", ".", "?"] + ) + path = Pattern.new strexp + match = path.match '/page/tender' + assert_equal 'tender', match[1] + assert_equal 2, match.length + end + + def test_match_data_with_multi_group + strexp = Router::Strexp.new( + '/page/:name/:id', + { :name => /t(((ender|love)))()/ }, + ["/", ".", "?"] + ) + path = Pattern.new strexp + match = path.match '/page/tender/10' + assert_equal 'tender', match[1] + assert_equal '10', match[2] + assert_equal 3, match.length + assert_equal %w{ tender 10 }, match.captures + end + + def test_star_with_custom_re + z = /\d+/ + strexp = Router::Strexp.new( + '/page/*foo', + { :foo => z }, + ["/", ".", "?"] + ) + path = Pattern.new strexp + assert_equal(%r{\A/page/(#{z})\Z}, path.to_regexp) + end + + def test_insensitive_regexp_with_group + strexp = Router::Strexp.new( + '/page/:name/aaron', + { :name => /(tender|love)/i }, + ["/", ".", "?"] + ) + path = Pattern.new strexp + assert_match(path, '/page/TENDER/aaron') + assert_match(path, '/page/loVE/aaron') + refute_match(path, '/page/loVE/AAron') + end + + def test_to_regexp_with_strexp + strexp = Router::Strexp.new('/:controller', { }, ["/", ".", "?"]) + path = Pattern.new strexp + x = %r{\A/([^/.?]+)\Z} + + assert_equal(x.source, path.source) + end + + def test_to_regexp_defaults + path = Pattern.new '/:controller(/:action(/:id))' + expected = %r{\A/([^/.?]+)(?:/([^/.?]+)(?:/([^/.?]+))?)?\Z} + assert_equal expected, path.to_regexp + end + + def test_failed_match + path = Pattern.new '/:controller(/:action(/:id(.:format)))' + uri = 'content' + + refute path =~ uri + end + + def test_match_controller + path = Pattern.new '/:controller(/:action(/:id(.:format)))' + uri = '/content' + + match = path =~ uri + assert_equal %w{ controller action id format }, match.names + assert_equal 'content', match[1] + assert_nil match[2] + assert_nil match[3] + assert_nil match[4] + end + + def test_match_controller_action + path = Pattern.new '/:controller(/:action(/:id(.:format)))' + uri = '/content/list' + + match = path =~ uri + assert_equal %w{ controller action id format }, match.names + assert_equal 'content', match[1] + assert_equal 'list', match[2] + assert_nil match[3] + assert_nil match[4] + end + + def test_match_controller_action_id + path = Pattern.new '/:controller(/:action(/:id(.:format)))' + uri = '/content/list/10' + + match = path =~ uri + assert_equal %w{ controller action id format }, match.names + assert_equal 'content', match[1] + assert_equal 'list', match[2] + assert_equal '10', match[3] + assert_nil match[4] + end + + def test_match_literal + path = Path::Pattern.new "/books(/:action(.:format))" + + uri = '/books' + match = path =~ uri + assert_equal %w{ action format }, match.names + assert_nil match[1] + assert_nil match[2] + end + + def test_match_literal_with_action + path = Path::Pattern.new "/books(/:action(.:format))" + + uri = '/books/list' + match = path =~ uri + assert_equal %w{ action format }, match.names + assert_equal 'list', match[1] + assert_nil match[2] + end + + def test_match_literal_with_action_and_format + path = Path::Pattern.new "/books(/:action(.:format))" + + uri = '/books/list.rss' + match = path =~ uri + assert_equal %w{ action format }, match.names + assert_equal 'list', match[1] + assert_equal 'rss', match[2] + end + end + end + end +end diff --git a/actionpack/test/journey/route/definition/parser_test.rb b/actionpack/test/journey/route/definition/parser_test.rb new file mode 100644 index 0000000000..580235c6a1 --- /dev/null +++ b/actionpack/test/journey/route/definition/parser_test.rb @@ -0,0 +1,110 @@ +require 'abstract_unit' + +module ActionDispatch + module Journey + module Definition + class TestParser < MiniTest::Unit::TestCase + def setup + @parser = Parser.new + end + + def test_slash + assert_equal :SLASH, @parser.parse('/').type + assert_round_trip '/' + end + + def test_segment + assert_round_trip '/foo' + end + + def test_segments + assert_round_trip '/foo/bar' + end + + def test_segment_symbol + assert_round_trip '/foo/:id' + end + + def test_symbol + assert_round_trip '/:foo' + end + + def test_group + assert_round_trip '(/:foo)' + end + + def test_groups + assert_round_trip '(/:foo)(/:bar)' + end + + def test_nested_groups + assert_round_trip '(/:foo(/:bar))' + end + + def test_dot_symbol + assert_round_trip('.:format') + end + + def test_dot_literal + assert_round_trip('.xml') + end + + def test_segment_dot + assert_round_trip('/foo.:bar') + end + + def test_segment_group_dot + assert_round_trip('/foo(.:bar)') + end + + def test_segment_group + assert_round_trip('/foo(/:action)') + end + + def test_segment_groups + assert_round_trip('/foo(/:action)(/:bar)') + end + + def test_segment_nested_groups + assert_round_trip('/foo(/:action(/:bar))') + end + + def test_group_followed_by_path + assert_round_trip('/foo(/:action)/:bar') + end + + def test_star + assert_round_trip('*foo') + assert_round_trip('/*foo') + assert_round_trip('/bar/*foo') + assert_round_trip('/bar/(*foo)') + end + + def test_or + assert_round_trip('a|b') + assert_round_trip('a|b|c') + assert_round_trip('(a|b)|c') + assert_round_trip('a|(b|c)') + assert_round_trip('*a|(b|c)') + assert_round_trip('*a|:b|c') + end + + def test_arbitrary + assert_round_trip('/bar/*foo#') + end + + def test_literal_dot_paren + assert_round_trip "/sprockets.js(.:format)" + end + + def test_groups_with_dot + assert_round_trip "/(:locale)(.:format)" + end + + def assert_round_trip str + assert_equal str, @parser.parse(str).to_s + end + end + end + end +end diff --git a/actionpack/test/journey/route/definition/scanner_test.rb b/actionpack/test/journey/route/definition/scanner_test.rb new file mode 100644 index 0000000000..110baf9977 --- /dev/null +++ b/actionpack/test/journey/route/definition/scanner_test.rb @@ -0,0 +1,56 @@ +require 'abstract_unit' + +module ActionDispatch + module Journey + module Definition + class TestScanner < MiniTest::Unit::TestCase + def setup + @scanner = Scanner.new + end + + # /page/:id(/:action)(.:format) + def test_tokens + [ + ['/', [[:SLASH, '/']]], + ['*omg', [[:STAR, '*omg']]], + ['/page', [[:SLASH, '/'], [:LITERAL, 'page']]], + ['/~page', [[:SLASH, '/'], [:LITERAL, '~page']]], + ['/pa-ge', [[:SLASH, '/'], [:LITERAL, 'pa-ge']]], + ['/:page', [[:SLASH, '/'], [:SYMBOL, ':page']]], + ['/(:page)', [ + [:SLASH, '/'], + [:LPAREN, '('], + [:SYMBOL, ':page'], + [:RPAREN, ')'], + ]], + ['(/:action)', [ + [:LPAREN, '('], + [:SLASH, '/'], + [:SYMBOL, ':action'], + [:RPAREN, ')'], + ]], + ['(())', [[:LPAREN, '('], + [:LPAREN, '('], [:RPAREN, ')'], [:RPAREN, ')']]], + ['(.:format)', [ + [:LPAREN, '('], + [:DOT, '.'], + [:SYMBOL, ':format'], + [:RPAREN, ')'], + ]], + ].each do |str, expected| + @scanner.scan_setup str + assert_tokens expected, @scanner + end + end + + def assert_tokens tokens, scanner + toks = [] + while tok = scanner.next_token + toks << tok + end + assert_equal tokens, toks + end + end + end + end +end diff --git a/actionpack/test/journey/route_test.rb b/actionpack/test/journey/route_test.rb new file mode 100644 index 0000000000..b205db5fbc --- /dev/null +++ b/actionpack/test/journey/route_test.rb @@ -0,0 +1,103 @@ +require 'abstract_unit' + +module ActionDispatch + module Journey + class TestRoute < MiniTest::Unit::TestCase + def test_initialize + app = Object.new + path = Path::Pattern.new '/:controller(/:action(/:id(.:format)))' + defaults = Object.new + route = Route.new("name", app, path, {}, defaults) + + assert_equal app, route.app + assert_equal path, route.path + assert_equal defaults, route.defaults + end + + def test_route_adds_itself_as_memo + app = Object.new + path = Path::Pattern.new '/:controller(/:action(/:id(.:format)))' + defaults = Object.new + route = Route.new("name", app, path, {}, defaults) + + route.ast.grep(Nodes::Terminal).each do |node| + assert_equal route, node.memo + end + end + + def test_ip_address + path = Path::Pattern.new '/messages/:id(.:format)' + route = Route.new("name", nil, path, {:ip => '192.168.1.1'}, + { :controller => 'foo', :action => 'bar' }) + assert_equal '192.168.1.1', route.ip + end + + def test_default_ip + path = Path::Pattern.new '/messages/:id(.:format)' + route = Route.new("name", nil, path, {}, + { :controller => 'foo', :action => 'bar' }) + assert_equal(//, route.ip) + end + + def test_format_with_star + path = Path::Pattern.new '/:controller/*extra' + route = Route.new("name", nil, path, {}, + { :controller => 'foo', :action => 'bar' }) + assert_equal '/foo/himom', route.format({ + :controller => 'foo', + :extra => 'himom', + }) + end + + def test_connects_all_match + path = Path::Pattern.new '/:controller(/:action(/:id(.:format)))' + route = Route.new("name", nil, path, {:action => 'bar'}, { :controller => 'foo' }) + + assert_equal '/foo/bar/10', route.format({ + :controller => 'foo', + :action => 'bar', + :id => 10 + }) + end + + def test_extras_are_not_included_if_optional + path = Path::Pattern.new '/page/:id(/:action)' + route = Route.new("name", nil, path, { }, { :action => 'show' }) + + assert_equal '/page/10', route.format({ :id => 10 }) + end + + def test_extras_are_not_included_if_optional_with_parameter + path = Path::Pattern.new '(/sections/:section)/pages/:id' + route = Route.new("name", nil, path, { }, { :action => 'show' }) + + assert_equal '/pages/10', route.format({:id => 10}) + end + + def test_extras_are_not_included_if_optional_parameter_is_nil + path = Path::Pattern.new '(/sections/:section)/pages/:id' + route = Route.new("name", nil, path, { }, { :action => 'show' }) + + assert_equal '/pages/10', route.format({:id => 10, :section => nil}) + end + + def test_score + path = Path::Pattern.new "/page/:id(/:action)(.:format)" + specific = Route.new "name", nil, path, {}, {:controller=>"pages", :action=>"show"} + + path = Path::Pattern.new "/:controller(/:action(/:id))(.:format)" + generic = Route.new "name", nil, path, {} + + knowledge = {:id=>20, :controller=>"pages", :action=>"show"} + + routes = [specific, generic] + + refute_equal specific.score(knowledge), generic.score(knowledge) + + found = routes.sort_by { |r| r.score(knowledge) }.last + + assert_equal specific, found + end + end + end +end diff --git a/actionpack/test/journey/router/strexp_test.rb b/actionpack/test/journey/router/strexp_test.rb new file mode 100644 index 0000000000..9e0337f144 --- /dev/null +++ b/actionpack/test/journey/router/strexp_test.rb @@ -0,0 +1,32 @@ +require 'abstract_unit' + +module ActionDispatch + module Journey + class Router + class TestStrexp < MiniTest::Unit::TestCase + def test_many_names + exp = Strexp.new( + "/:controller(/:action(/:id(.:format)))", + {:controller=>/.+?/}, + ["/", ".", "?"], + true) + + assert_equal ["controller", "action", "id", "format"], exp.names + end + + def test_names + { + "/bar(.:format)" => %w{ format }, + ":format" => %w{ format }, + ":format-" => %w{ format }, + ":format0" => %w{ format0 }, + ":format1,:format2" => %w{ format1 format2 }, + }.each do |string, expected| + exp = Strexp.new(string, {}, ["/", ".", "?"]) + assert_equal expected, exp.names + end + end + end + end + end +end diff --git a/actionpack/test/journey/router/utils_test.rb b/actionpack/test/journey/router/utils_test.rb new file mode 100644 index 0000000000..97a6449c99 --- /dev/null +++ b/actionpack/test/journey/router/utils_test.rb @@ -0,0 +1,21 @@ +require 'abstract_unit' + +module ActionDispatch + module Journey + class Router + class TestUtils < MiniTest::Unit::TestCase + def test_path_escape + assert_equal "a/b%20c+d", Utils.escape_path("a/b c+d") + end + + def test_fragment_escape + assert_equal "a/b%20c+d?e", Utils.escape_fragment("a/b c+d?e") + end + + def test_uri_unescape + assert_equal "a/b c+d", Utils.unescape_uri("a%2Fb%20c+d") + end + end + end + end +end diff --git a/actionpack/test/journey/router_test.rb b/actionpack/test/journey/router_test.rb new file mode 100644 index 0000000000..1b64600ba8 --- /dev/null +++ b/actionpack/test/journey/router_test.rb @@ -0,0 +1,575 @@ +# encoding: UTF-8 +require 'abstract_unit' + +module ActionDispatch + module Journey + class TestRouter < MiniTest::Unit::TestCase + attr_reader :routes + + def setup + @routes = Routes.new + @router = Router.new(@routes, {}) + @formatter = Formatter.new(@routes) + end + + def test_request_class_reader + klass = Object.new + router = Router.new(routes, :request_class => klass) + assert_equal klass, router.request_class + end + + class FakeRequestFeeler < Struct.new(:env, :called) + def new env + self.env = env + self + end + + def hello + self.called = true + 'world' + end + + def path_info; env['PATH_INFO']; end + def request_method; env['REQUEST_METHOD']; end + def ip; env['REMOTE_ADDR']; end + end + + def test_dashes + router = Router.new(routes, {}) + + exp = Router::Strexp.new '/foo-bar-baz', {}, ['/.?'] + path = Path::Pattern.new exp + + routes.add_route nil, path, {}, {:id => nil}, {} + + env = rails_env 'PATH_INFO' => '/foo-bar-baz' + called = false + router.recognize(env) do |r, _, params| + called = true + end + assert called + end + + def test_unicode + router = Router.new(routes, {}) + + #match the escaped version of /ほげ + exp = Router::Strexp.new '/%E3%81%BB%E3%81%92', {}, ['/.?'] + path = Path::Pattern.new exp + + routes.add_route nil, path, {}, {:id => nil}, {} + + env = rails_env 'PATH_INFO' => '/%E3%81%BB%E3%81%92' + called = false + router.recognize(env) do |r, _, params| + called = true + end + assert called + end + + def test_request_class_and_requirements_success + klass = FakeRequestFeeler.new nil + router = Router.new(routes, {:request_class => klass }) + + requirements = { :hello => /world/ } + + exp = Router::Strexp.new '/foo(/:id)', {}, ['/.?'] + path = Path::Pattern.new exp + + routes.add_route nil, path, requirements, {:id => nil}, {} + + env = rails_env 'PATH_INFO' => '/foo/10' + router.recognize(env) do |r, _, params| + assert_equal({:id => '10'}, params) + end + + assert klass.called, 'hello should have been called' + assert_equal env.env, klass.env + end + + def test_request_class_and_requirements_fail + klass = FakeRequestFeeler.new nil + router = Router.new(routes, {:request_class => klass }) + + requirements = { :hello => /mom/ } + + exp = Router::Strexp.new '/foo(/:id)', {}, ['/.?'] + path = Path::Pattern.new exp + + router.routes.add_route nil, path, requirements, {:id => nil}, {} + + env = rails_env 'PATH_INFO' => '/foo/10' + router.recognize(env) do |r, _, params| + flunk 'route should not be found' + end + + assert klass.called, 'hello should have been called' + assert_equal env.env, klass.env + end + + class CustomPathRequest < Router::NullReq + def path_info + env['custom.path_info'] + end + end + + def test_request_class_overrides_path_info + router = Router.new(routes, {:request_class => CustomPathRequest }) + + exp = Router::Strexp.new '/bar', {}, ['/.?'] + path = Path::Pattern.new exp + + routes.add_route nil, path, {}, {}, {} + + env = rails_env 'PATH_INFO' => '/foo', 'custom.path_info' => '/bar' + + recognized = false + router.recognize(env) do |r, _, params| + recognized = true + end + + assert recognized, "route should have been recognized" + end + + def test_regexp_first_precedence + add_routes @router, [ + Router::Strexp.new("/whois/:domain", {:domain => /\w+\.[\w\.]+/}, ['/', '.', '?']), + Router::Strexp.new("/whois/:id(.:format)", {}, ['/', '.', '?']) + ] + + env = rails_env 'PATH_INFO' => '/whois/example.com' + + list = [] + @router.recognize(env) do |r, _, params| + list << r + end + assert_equal 2, list.length + + r = list.first + + assert_equal '/whois/:domain', r.path.spec.to_s + end + + def test_required_parts_verified_are_anchored + add_routes @router, [ + Router::Strexp.new("/foo/:id", { :id => /\d/ }, ['/', '.', '?'], false) + ] + + assert_raises(Router::RoutingError) do + @formatter.generate(:path_info, nil, { :id => '10' }, { }) + end + end + + def test_required_parts_are_verified_when_building + add_routes @router, [ + Router::Strexp.new("/foo/:id", { :id => /\d+/ }, ['/', '.', '?'], false) + ] + + path, _ = @formatter.generate(:path_info, nil, { :id => '10' }, { }) + assert_equal '/foo/10', path + + assert_raises(Router::RoutingError) do + @formatter.generate(:path_info, nil, { :id => 'aa' }, { }) + end + end + + def test_only_required_parts_are_verified + add_routes @router, [ + Router::Strexp.new("/foo(/:id)", {:id => /\d/}, ['/', '.', '?'], false) + ] + + path, _ = @formatter.generate(:path_info, nil, { :id => '10' }, { }) + assert_equal '/foo/10', path + + path, _ = @formatter.generate(:path_info, nil, { }, { }) + assert_equal '/foo', path + + path, _ = @formatter.generate(:path_info, nil, { :id => 'aa' }, { }) + assert_equal '/foo/aa', path + end + + def test_knows_what_parts_are_missing_from_named_route + route_name = "gorby_thunderhorse" + pattern = Router::Strexp.new("/foo/:id", { :id => /\d+/ }, ['/', '.', '?'], false) + path = Path::Pattern.new pattern + @router.routes.add_route nil, path, {}, {}, route_name + + error = assert_raises(Router::RoutingError) do + @formatter.generate(:path_info, route_name, { }, { }) + end + + assert_match(/required keys: \[:id\]/, error.message) + end + + def test_X_Cascade + add_routes @router, [ "/messages(.:format)" ] + resp = @router.call({ 'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/lol' }) + assert_equal ['Not Found'], resp.last + assert_equal 'pass', resp[1]['X-Cascade'] + assert_equal 404, resp.first + end + + def test_clear_trailing_slash_from_script_name_on_root_unanchored_routes + strexp = Router::Strexp.new("/", {}, ['/', '.', '?'], false) + path = Path::Pattern.new strexp + app = lambda { |env| [200, {}, ['success!']] } + @router.routes.add_route(app, path, {}, {}, {}) + + env = rack_env('SCRIPT_NAME' => '', 'PATH_INFO' => '/weblog') + resp = @router.call(env) + assert_equal ['success!'], resp.last + assert_equal '', env['SCRIPT_NAME'] + end + + def test_defaults_merge_correctly + path = Path::Pattern.new '/foo(/:id)' + @router.routes.add_route nil, path, {}, {:id => nil}, {} + + env = rails_env 'PATH_INFO' => '/foo/10' + @router.recognize(env) do |r, _, params| + assert_equal({:id => '10'}, params) + end + + env = rails_env 'PATH_INFO' => '/foo' + @router.recognize(env) do |r, _, params| + assert_equal({:id => nil}, params) + end + end + + def test_recognize_with_unbound_regexp + add_routes @router, [ + Router::Strexp.new("/foo", { }, ['/', '.', '?'], false) + ] + + env = rails_env 'PATH_INFO' => '/foo/bar' + + @router.recognize(env) { |*_| } + + assert_equal '/foo', env.env['SCRIPT_NAME'] + assert_equal '/bar', env.env['PATH_INFO'] + end + + def test_bound_regexp_keeps_path_info + add_routes @router, [ + Router::Strexp.new("/foo", { }, ['/', '.', '?'], true) + ] + + env = rails_env 'PATH_INFO' => '/foo' + + before = env.env['SCRIPT_NAME'] + + @router.recognize(env) { |*_| } + + assert_equal before, env.env['SCRIPT_NAME'] + assert_equal '/foo', env.env['PATH_INFO'] + end + + def test_path_not_found + add_routes @router, [ + "/messages(.:format)", + "/messages/new(.:format)", + "/messages/:id/edit(.:format)", + "/messages/:id(.:format)" + ] + env = rails_env 'PATH_INFO' => '/messages/unknown/path' + yielded = false + + @router.recognize(env) do |*whatever| + yielded = true + end + refute yielded + end + + def test_required_part_in_recall + add_routes @router, [ "/messages/:a/:b" ] + + path, _ = @formatter.generate(:path_info, nil, { :a => 'a' }, { :b => 'b' }) + assert_equal "/messages/a/b", path + end + + def test_splat_in_recall + add_routes @router, [ "/*path" ] + + path, _ = @formatter.generate(:path_info, nil, { }, { :path => 'b' }) + assert_equal "/b", path + end + + def test_recall_should_be_used_when_scoring + add_routes @router, [ + "/messages/:action(/:id(.:format))", + "/messages/:id(.:format)" + ] + + path, _ = @formatter.generate(:path_info, nil, { :id => 10 }, { :action => 'index' }) + assert_equal "/messages/index/10", path + end + + def test_nil_path_parts_are_ignored + path = Path::Pattern.new "/:controller(/:action(.:format))" + @router.routes.add_route nil, path, {}, {}, {} + + params = { :controller => "tasks", :format => nil } + extras = { :action => 'lol' } + + path, _ = @formatter.generate(:path_info, nil, params, extras) + assert_equal '/tasks', path + end + + def test_generate_slash + params = [ [:controller, "tasks"], + [:action, "show"] ] + str = Router::Strexp.new("/", Hash[params], ['/', '.', '?'], true) + path = Path::Pattern.new str + + @router.routes.add_route nil, path, {}, {}, {} + + path, _ = @formatter.generate(:path_info, nil, Hash[params], {}) + assert_equal '/', path + end + + def test_generate_calls_param_proc + path = Path::Pattern.new '/:controller(/:action)' + @router.routes.add_route nil, path, {}, {}, {} + + parameterized = [] + params = [ [:controller, "tasks"], + [:action, "show"] ] + + @formatter.generate( + :path_info, + nil, + Hash[params], + {}, + lambda { |k,v| parameterized << [k,v]; v }) + + assert_equal params.map(&:to_s).sort, parameterized.map(&:to_s).sort + end + + def test_generate_id + path = Path::Pattern.new '/:controller(/:action)' + @router.routes.add_route nil, path, {}, {}, {} + + path, params = @formatter.generate( + :path_info, nil, {:id=>1, :controller=>"tasks", :action=>"show"}, {}) + assert_equal '/tasks/show', path + assert_equal({:id => 1}, params) + end + + def test_generate_escapes + path = Path::Pattern.new '/:controller(/:action)' + @router.routes.add_route nil, path, {}, {}, {} + + path, _ = @formatter.generate(:path_info, + nil, { :controller => "tasks", + :action => "a/b c+d", + }, {}) + assert_equal '/tasks/a/b%20c+d', path + end + + def test_generate_extra_params + path = Path::Pattern.new '/:controller(/:action)' + @router.routes.add_route nil, path, {}, {}, {} + + path, params = @formatter.generate(:path_info, + nil, { :id => 1, + :controller => "tasks", + :action => "show", + :relative_url_root => nil + }, {}) + assert_equal '/tasks/show', path + assert_equal({:id => 1, :relative_url_root => nil}, params) + end + + def test_generate_uses_recall_if_needed + path = Path::Pattern.new '/:controller(/:action(/:id))' + @router.routes.add_route nil, path, {}, {}, {} + + path, params = @formatter.generate(:path_info, + nil, + {:controller =>"tasks", :id => 10}, + {:action =>"index"}) + assert_equal '/tasks/index/10', path + assert_equal({}, params) + end + + def test_generate_with_name + path = Path::Pattern.new '/:controller(/:action)' + @router.routes.add_route nil, path, {}, {}, {} + + path, params = @formatter.generate(:path_info, + "tasks", + {:controller=>"tasks"}, + {:controller=>"tasks", :action=>"index"}) + assert_equal '/tasks', path + assert_equal({}, params) + end + + { + '/content' => { :controller => 'content' }, + '/content/list' => { :controller => 'content', :action => 'list' }, + '/content/show/10' => { :controller => 'content', :action => 'show', :id => "10" }, + }.each do |request_path, expected| + define_method("test_recognize_#{expected.keys.map(&:to_s).join('_')}") do + path = Path::Pattern.new "/:controller(/:action(/:id))" + app = Object.new + route = @router.routes.add_route(app, path, {}, {}, {}) + + env = rails_env 'PATH_INFO' => request_path + called = false + + @router.recognize(env) do |r, _, params| + assert_equal route, r + assert_equal(expected, params) + called = true + end + + assert called + end + end + + { + :segment => ['/a%2Fb%20c+d/splat', { :segment => 'a/b c+d', :splat => 'splat' }], + :splat => ['/segment/a/b%20c+d', { :segment => 'segment', :splat => 'a/b c+d' }] + }.each do |name, (request_path, expected)| + define_method("test_recognize_#{name}") do + path = Path::Pattern.new '/:segment/*splat' + app = Object.new + route = @router.routes.add_route(app, path, {}, {}, {}) + + env = rails_env 'PATH_INFO' => request_path + called = false + + @router.recognize(env) do |r, _, params| + assert_equal route, r + assert_equal(expected, params) + called = true + end + + assert called + end + end + + def test_namespaced_controller + strexp = Router::Strexp.new( + "/:controller(/:action(/:id))", + { :controller => /.+?/ }, + ["/", ".", "?"] + ) + path = Path::Pattern.new strexp + app = Object.new + route = @router.routes.add_route(app, path, {}, {}, {}) + + env = rails_env 'PATH_INFO' => '/admin/users/show/10' + called = false + expected = { + :controller => 'admin/users', + :action => 'show', + :id => '10' + } + + @router.recognize(env) do |r, _, params| + assert_equal route, r + assert_equal(expected, params) + called = true + end + assert called + end + + def test_recognize_literal + path = Path::Pattern.new "/books(/:action(.:format))" + app = Object.new + route = @router.routes.add_route(app, path, {}, {:controller => 'books'}) + + env = rails_env 'PATH_INFO' => '/books/list.rss' + expected = { :controller => 'books', :action => 'list', :format => 'rss' } + called = false + @router.recognize(env) do |r, _, params| + assert_equal route, r + assert_equal(expected, params) + called = true + end + + assert called + end + + def test_recognize_head_request_as_get_route + path = Path::Pattern.new "/books(/:action(.:format))" + app = Object.new + conditions = { + :request_method => 'GET' + } + @router.routes.add_route(app, path, conditions, {}) + + env = rails_env 'PATH_INFO' => '/books/list.rss', + "REQUEST_METHOD" => "HEAD" + + called = false + @router.recognize(env) do |r, _, params| + called = true + end + + assert called + end + + def test_recognize_cares_about_verbs + path = Path::Pattern.new "/books(/:action(.:format))" + app = Object.new + conditions = { + :request_method => 'GET' + } + @router.routes.add_route(app, path, conditions, {}) + + conditions = conditions.dup + conditions[:request_method] = 'POST' + + post = @router.routes.add_route(app, path, conditions, {}) + + env = rails_env 'PATH_INFO' => '/books/list.rss', + "REQUEST_METHOD" => "POST" + + called = false + @router.recognize(env) do |r, _, params| + assert_equal post, r + called = true + end + + assert called + end + + private + + def add_routes router, paths + paths.each do |path| + path = Path::Pattern.new path + router.routes.add_route nil, path, {}, {}, {} + end + end + + RailsEnv = Struct.new(:env) + + def rails_env env + RailsEnv.new rack_env env + end + + def rack_env env + { + "rack.version" => [1, 1], + "rack.input" => StringIO.new, + "rack.errors" => StringIO.new, + "rack.multithread" => true, + "rack.multiprocess" => true, + "rack.run_once" => false, + "REQUEST_METHOD" => "GET", + "SERVER_NAME" => "example.org", + "SERVER_PORT" => "80", + "QUERY_STRING" => "", + "PATH_INFO" => "/content", + "rack.url_scheme" => "http", + "HTTPS" => "off", + "SCRIPT_NAME" => "", + "CONTENT_LENGTH" => "0" + }.merge env + end + end + end +end diff --git a/actionpack/test/journey/routes_test.rb b/actionpack/test/journey/routes_test.rb new file mode 100644 index 0000000000..3b17bd53b7 --- /dev/null +++ b/actionpack/test/journey/routes_test.rb @@ -0,0 +1,53 @@ +require 'abstract_unit' + +module ActionDispatch + module Journey + class TestRoutes < MiniTest::Unit::TestCase + def test_clear + routes = Routes.new + exp = Router::Strexp.new '/foo(/:id)', {}, ['/.?'] + path = Path::Pattern.new exp + requirements = { :hello => /world/ } + + routes.add_route nil, path, requirements, {:id => nil}, {} + assert_equal 1, routes.length + + routes.clear + assert_equal 0, routes.length + end + + def test_ast + routes = Routes.new + path = Path::Pattern.new '/hello' + + routes.add_route nil, path, {}, {}, {} + ast = routes.ast + routes.add_route nil, path, {}, {}, {} + refute_equal ast, routes.ast + end + + def test_simulator_changes + routes = Routes.new + path = Path::Pattern.new '/hello' + + routes.add_route nil, path, {}, {}, {} + sim = routes.simulator + routes.add_route nil, path, {}, {}, {} + refute_equal sim, routes.simulator + end + + def test_first_name_wins + #def add_route app, path, conditions, defaults, name = nil + routes = Routes.new + + one = Path::Pattern.new '/hello' + two = Path::Pattern.new '/aaron' + + routes.add_route nil, one, {}, {}, 'aaron' + routes.add_route nil, two, {}, {}, 'aaron' + + assert_equal '/hello', routes.named_routes['aaron'].path.spec.to_s + end + end + end +end |