ruby_routes 0.2.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bc2a63fd1cf72287ce09440410f609579ef5a3cdb4a33513187599dcb5b9ed7
4
- data.tar.gz: 560ab2b883aeb3e42f74ef20fd675e3a692173724a78451bd5739115803944cf
3
+ metadata.gz: 15debcef313430cfc799afcb9ba0b6f9bd8292226d023d7e854afc608d5ede64
4
+ data.tar.gz: 1d7c971980a984738c6239cc1727376b74ab61ae824ee054a92f254451574bcf
5
5
  SHA512:
6
- metadata.gz: dc41f27e4af178dd3a76dbed95f8a2df9aed4a041d3af184b76ccb39480a9b7d7facab6e2e30147926a3ca178d6e45b391c942eb9cb6fbdfd2167c794ef7d573
7
- data.tar.gz: eafd4d678f45780a7da280e8e91cfddb7f0abe21cadb248fc5f6517e3632c442d7602363b89685baacdff106588a88c1b62c0180670db681af4b7539cd66c1c2
6
+ metadata.gz: 8f272672f91127b65fab8ed4ae2eacfb95fe55310fc4bcd017b5656a0eb475e05255884f19416520ce8b36ec6ff51602561b6f9519befc1f7cf7448bca4ab3f9
7
+ data.tar.gz: c3cc386c128531ef9bb268bb904c806e1632c3d88609fd3de5077d8143f0fa56a1089ea0915de3bf2e1116c0d0d812402877480d6edc702f8923d3ee3252ef9a
@@ -0,0 +1,57 @@
1
+ require_relative 'segments/dynamic_segment'
2
+ require_relative 'segments/static_segment'
3
+ require_relative 'segments/wildcard_segment'
4
+ require_relative 'lru_strategies/hit_strategy'
5
+ require_relative 'lru_strategies/miss_strategy'
6
+
7
+ module RubyRoutes
8
+ module Constant
9
+ SEGMENTS = {
10
+ 42 => RubyRoutes::Segments::WildcardSegment, # '*'
11
+ 58 => RubyRoutes::Segments::DynamicSegment, # ':'
12
+ :default => RubyRoutes::Segments::StaticSegment
13
+ }.freeze
14
+
15
+ SEGMENT_MATCHERS = {
16
+ static: lambda do |node, segment, _idx, _segments, _params|
17
+ child = node.static_children[segment]
18
+ child ? [child, false] : nil
19
+ end,
20
+
21
+ dynamic: lambda do |node, segment, _idx, _segments, params|
22
+ return nil unless node.dynamic_child
23
+ nxt = node.dynamic_child
24
+ params[nxt.param_name.to_s] = segment if params && nxt.param_name
25
+ [nxt, false]
26
+ end,
27
+
28
+ wildcard: lambda do |node, _segment, idx, segments, params|
29
+ return nil unless node.wildcard_child
30
+ nxt = node.wildcard_child
31
+ params[nxt.param_name.to_s] = segments[idx..-1].join('/') if params && nxt.param_name
32
+ [nxt, true]
33
+ end,
34
+
35
+ # default returns nil (no match). RadixTree#find will then return [nil, {}].
36
+ default: lambda { |_node, _segment, _idx, _segments, _params| nil }
37
+ }.freeze
38
+
39
+ # singleton instances to avoid per-LRU allocations
40
+ LRU_HIT_STRATEGY = RubyRoutes::LruStrategies::HitStrategy.new.freeze
41
+ LRU_MISS_STRATEGY = RubyRoutes::LruStrategies::MissStrategy.new.freeze
42
+
43
+ # Descriptor factories for segment classification (O(1) dispatch by first byte).
44
+ DESCRIPTOR_FACTORIES = {
45
+ 42 => ->(s) { { type: :splat, name: (s[1..-1] || 'splat') } }, # '*'
46
+ 58 => ->(s) { { type: :param, name: s[1..-1] } }, # ':'
47
+ :default => ->(s) { { type: :static, value: s } }
48
+ }.freeze
49
+
50
+ def self.segment_descriptor(raw)
51
+ s = raw.to_s
52
+ key = s.empty? ? :default : s.getbyte(0)
53
+ factory = DESCRIPTOR_FACTORIES[key] || DESCRIPTOR_FACTORIES[:default]
54
+ factory.call(s)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,13 @@
1
+ module RubyRoutes
2
+ module LruStrategies
3
+ class HitStrategy
4
+ def call(lru, key)
5
+ lru.increment_hits
6
+ h = lru.instance_variable_get(:@h)
7
+ val = h.delete(key)
8
+ h[key] = val
9
+ val
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ module RubyRoutes
2
+ module LruStrategies
3
+ class MissStrategy
4
+ def call(lru, _key)
5
+ lru.increment_misses
6
+ nil
7
+ end
8
+ end
9
+ end
10
+ end
@@ -1,3 +1,5 @@
1
+ require_relative 'segment'
2
+
1
3
  module RubyRoutes
2
4
  class Node
3
5
  attr_accessor :static_children, :dynamic_child, :wildcard_child,
@@ -12,13 +14,52 @@ module RubyRoutes
12
14
  @is_endpoint = false
13
15
  end
14
16
 
17
+ # Fast traversal: minimal allocations, streamlined branching
18
+ # Returns [next_node_or_nil, should_break_bool] or [nil, false] if no match.
19
+ def traverse_for(segment, index, segments, params)
20
+ # Static match: O(1) hash lookup
21
+ child = @static_children[segment]
22
+ return [child, false] if child
23
+
24
+ # Dynamic match: single segment capture
25
+ if (dyn = @dynamic_child)
26
+ params[dyn.param_name] = segment if params
27
+ return [dyn, false]
28
+ end
29
+
30
+ # Wildcard match: consume remainder (last resort)
31
+ if (wild = @wildcard_child)
32
+ if params
33
+ # Build remainder path without intermediate array allocation
34
+ remainder = segments[index..-1]
35
+ params[wild.param_name] = remainder.size == 1 ? remainder[0] : remainder.join('/')
36
+ end
37
+ return [wild, true]
38
+ end
39
+
40
+ # No match
41
+ [nil, false]
42
+ end
43
+
44
+ # Pre-cache param names as strings to avoid repeated .to_s calls
45
+ def param_name
46
+ @param_name_str ||= @param_name&.to_s
47
+ end
48
+
49
+ def param_name=(name)
50
+ @param_name = name
51
+ @param_name_str = nil # invalidate cache
52
+ end
53
+
54
+ # Normalize method once and cache string keys
15
55
  def add_handler(method, handler)
16
- @handlers[method.to_s] = handler
56
+ method_key = method.to_s.upcase
57
+ @handlers[method_key] = handler
17
58
  @is_endpoint = true
18
59
  end
19
60
 
20
61
  def get_handler(method)
21
- @handlers[method.to_s]
62
+ @handlers[method] # assume already normalized upstream
22
63
  end
23
64
  end
24
65
  end
@@ -1,13 +1,11 @@
1
+ require_relative 'segment'
2
+
1
3
  module RubyRoutes
2
4
  class RadixTree
3
5
  class << self
4
6
  # Allow RadixTree.new(path, options...) to act as a convenience factory
5
- # returning a Route (this matches test usage where specs call
6
- # RadixTree.new('/path', to: 'controller#action')).
7
- # Calling RadixTree.new with no arguments returns an actual RadixTree instance.
8
7
  def new(*args, &block)
9
8
  if args.any?
10
- # Delegate to Route initializer when args are provided
11
9
  RubyRoutes::Route.new(*args, &block)
12
10
  else
13
11
  super()
@@ -17,64 +15,85 @@ module RubyRoutes
17
15
 
18
16
  def initialize
19
17
  @root = Node.new
20
- @_split_cache = {} # simple LRU: key -> [value, age]
21
- @split_cache_order = [] # track order for eviction
22
- @split_cache_max = 1024
18
+ @split_cache = {}
19
+ @split_cache_order = []
20
+ @split_cache_max = 2048 # larger cache for better hit rates
21
+ @empty_segments = [].freeze # reuse for root path
23
22
  end
24
23
 
25
24
  def add(path, methods, handler)
26
- segments = split_path(path)
27
25
  current = @root
26
+ segments = split_path_raw(path)
28
27
 
29
- segments.each do |segment|
30
- if segment.start_with?('*')
31
- current.wildcard_child ||= Node.new
32
- current = current.wildcard_child
33
- current.param_name = segment[1..-1] || 'splat'
34
- break
35
- elsif segment.start_with?(':')
36
- current.dynamic_child ||= Node.new
37
- current = current.dynamic_child
38
- current.param_name = segment[1..-1]
39
- else
40
- current.static_children[segment] ||= Node.new
41
- current = current.static_children[segment]
42
- end
28
+ segments.each do |raw_seg|
29
+ seg = RubyRoutes::Segment.for(raw_seg)
30
+ current = seg.ensure_child(current)
31
+ break if seg.wildcard?
43
32
  end
44
33
 
45
- methods.each { |method| current.add_handler(method, handler) }
34
+ # Normalize methods once during registration
35
+ Array(methods).each { |method| current.add_handler(method.to_s.upcase, handler) }
46
36
  end
47
37
 
48
- def find(path, method)
49
- segments = split_path(path)
50
- current = @root
51
- params = {}
52
-
53
- segments.each_with_index do |segment, index|
54
- if current.static_children.key?(segment)
55
- current = current.static_children[segment]
56
- elsif current.dynamic_child
57
- current = current.dynamic_child
58
- # keep string keys to avoid symbol allocations and extra conversions later
59
- params[current.param_name.to_s] = segment
60
- elsif current.wildcard_child
61
- current = current.wildcard_child
62
- params[current.param_name.to_s] = segments[index..-1].join('/')
63
- break
38
+ def find(path, method, params_out = nil)
39
+ # Fast path: root route
40
+ if path == '/' || path.empty?
41
+ handler = @root.get_handler(method)
42
+ if @root.is_endpoint && handler
43
+ return [handler, params_out || {}]
64
44
  else
65
45
  return [nil, {}]
66
46
  end
67
47
  end
68
48
 
49
+ segments = split_path_cached(path)
50
+ current = @root
51
+ params = params_out || {}
52
+ params.clear if params_out
53
+
54
+ # Unrolled traversal for common case (1-3 segments)
55
+ case segments.size
56
+ when 1
57
+ next_node, _ = current.traverse_for(segments[0], 0, segments, params)
58
+ current = next_node
59
+ when 2
60
+ next_node, should_break = current.traverse_for(segments[0], 0, segments, params)
61
+ return [nil, {}] unless next_node
62
+ current = next_node
63
+ unless should_break
64
+ next_node, _ = current.traverse_for(segments[1], 1, segments, params)
65
+ current = next_node
66
+ end
67
+ when 3
68
+ next_node, should_break = current.traverse_for(segments[0], 0, segments, params)
69
+ return [nil, {}] unless next_node
70
+ current = next_node
71
+ unless should_break
72
+ next_node, should_break = current.traverse_for(segments[1], 1, segments, params)
73
+ return [nil, {}] unless next_node
74
+ current = next_node
75
+ unless should_break
76
+ next_node, _ = current.traverse_for(segments[2], 2, segments, params)
77
+ current = next_node
78
+ end
79
+ end
80
+ else
81
+ # General case for longer paths
82
+ segments.each_with_index do |text, idx|
83
+ next_node, should_break = current.traverse_for(text, idx, segments, params)
84
+ return [nil, {}] unless next_node
85
+ current = next_node
86
+ break if should_break
87
+ end
88
+ end
89
+
90
+ return [nil, {}] unless current
69
91
  handler = current.get_handler(method)
70
92
  return [nil, {}] unless current.is_endpoint && handler
71
93
 
72
- # lightweight constraint checks: reject early if route constraints don't match
73
- route = handler
74
- if route.respond_to?(:constraints) && route.constraints.any?
75
- unless constraints_match?(route.constraints, params)
76
- return [nil, {}]
77
- end
94
+ # Fast constraint check
95
+ if handler.respond_to?(:constraints) && !handler.constraints.empty?
96
+ return [nil, {}] unless constraints_match_fast(handler.constraints, params)
78
97
  end
79
98
 
80
99
  [handler, params]
@@ -82,34 +101,48 @@ module RubyRoutes
82
101
 
83
102
  private
84
103
 
85
- # faster, lower-allocation trim + split
86
- def split_path(path)
87
- @split_cache ||= {}
88
- return [''] if path == '/'
104
+ # Cached path splitting with optimized common cases
105
+ def split_path_cached(path)
106
+ return @empty_segments if path == '/' || path.empty?
107
+
89
108
  if (cached = @split_cache[path])
90
109
  return cached
91
110
  end
92
111
 
93
- p = path
94
- p = p[1..-1] if p.start_with?('/')
95
- p = p[0...-1] if p.end_with?('/')
96
- segs = p.split('/')
112
+ result = split_path_raw(path)
97
113
 
98
- # simple LRU insert
99
- @split_cache[path] = segs
114
+ # Cache with simple LRU eviction
115
+ @split_cache[path] = result
100
116
  @split_cache_order << path
101
117
  if @split_cache_order.size > @split_cache_max
102
118
  oldest = @split_cache_order.shift
103
119
  @split_cache.delete(oldest)
104
120
  end
105
121
 
106
- segs
122
+ result
123
+ end
124
+
125
+ # Raw path splitting without caching (for registration)
126
+ def split_path_raw(path)
127
+ return [] if path == '/' || path.empty?
128
+
129
+ # Optimized trimming: avoid string allocations when possible
130
+ start_idx = path.start_with?('/') ? 1 : 0
131
+ end_idx = path.end_with?('/') ? -2 : -1
132
+
133
+ if start_idx == 0 && end_idx == -1
134
+ path.split('/')
135
+ else
136
+ path[start_idx..end_idx].split('/')
137
+ end
107
138
  end
108
139
 
109
- # constraints match helper (non-raising, lightweight)
110
- def constraints_match?(constraints, params)
140
+ # Optimized constraint matching with fast paths
141
+ def constraints_match_fast(constraints, params)
111
142
  constraints.each do |param, constraint|
112
- value = params[param.to_s] || params[param]
143
+ # Try both string and symbol keys (common pattern)
144
+ value = params[param.to_s]
145
+ value ||= params[param] if param.respond_to?(:to_s)
113
146
  next unless value
114
147
 
115
148
  case constraint
@@ -117,15 +150,16 @@ module RubyRoutes
117
150
  return false unless constraint.match?(value)
118
151
  when Proc
119
152
  return false unless constraint.call(value)
153
+ when :int
154
+ # Fast integer check without regex
155
+ return false unless value.is_a?(String) && value.match?(/\A\d+\z/)
156
+ when :uuid
157
+ # Fast UUID check
158
+ return false unless value.is_a?(String) && value.length == 36 &&
159
+ value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
120
160
  when Symbol
121
- case constraint
122
- when :int then return false unless value.match?(/^\d+$/)
123
- when :uuid then return false unless value.match?(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
124
- else
125
- # unknown symbol constraint — be conservative and allow
126
- end
127
- else
128
- # unknown constraint type — allow (Route will validate later if needed)
161
+ # Handle other symbolic constraints
162
+ next # unknown symbol constraint allow
129
163
  end
130
164
  end
131
165
  true
@@ -0,0 +1,43 @@
1
+ module RubyRoutes
2
+ class Route
3
+ # small LRU used for path generation cache
4
+ class SmallLru
5
+ attr_reader :hits, :misses, :evictions
6
+
7
+ # larger default to reduce eviction likelihood in benchmarks
8
+ def initialize(max_size = 1024)
9
+ @max_size = max_size
10
+ @h = {}
11
+ @hits = 0
12
+ @misses = 0
13
+ @evictions = 0
14
+
15
+ @hit_strategy = RubyRoutes::Constant::LRU_HIT_STRATEGY
16
+ @miss_strategy = RubyRoutes::Constant::LRU_MISS_STRATEGY
17
+ end
18
+
19
+ def get(key)
20
+ strategy = @h.key?(key) ? @hit_strategy : @miss_strategy
21
+ strategy.call(self, key)
22
+ end
23
+
24
+ def set(key, val)
25
+ @h.delete(key) if @h.key?(key)
26
+ @h[key] = val
27
+ if @h.size > @max_size
28
+ @h.shift
29
+ @evictions += 1
30
+ end
31
+ val
32
+ end
33
+
34
+ def increment_hits
35
+ @hits += 1
36
+ end
37
+
38
+ def increment_misses
39
+ @misses += 1
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,217 +1,223 @@
1
1
  require 'uri'
2
+ require_relative 'route/small_lru'
2
3
 
3
4
  module RubyRoutes
4
5
  class Route
5
- # small LRU used for path generation cache
6
- class SmallLru
7
- attr_reader :hits, :misses, :evictions
8
-
9
- # larger default to reduce eviction likelihood in benchmarks
10
- def initialize(max_size = 1024)
11
- @max_size = max_size
12
- @h = {}
13
- @hits = 0
14
- @misses = 0
15
- @evictions = 0
16
- end
17
-
18
- def get(key)
19
- if @h.key?(key)
20
- @hits += 1
21
- val = @h.delete(key)
22
- @h[key] = val
23
- val
24
- else
25
- @misses += 1
26
- nil
27
- end
28
- end
29
-
30
- def set(key, val)
31
- @h.delete(key) if @h.key?(key)
32
- @h[key] = val
33
- if @h.size > @max_size
34
- @h.shift
35
- @evictions += 1
36
- end
37
- val
38
- end
39
- end
40
-
41
6
  attr_reader :path, :methods, :controller, :action, :name, :constraints, :defaults
42
7
 
43
8
  def initialize(path, options = {})
44
9
  @path = normalize_path(path)
45
- @methods = Array(options[:via] || :get).map(&:to_s).map(&:upcase)
10
+ # Pre-normalize and freeze methods at creation time
11
+ raw_methods = Array(options[:via] || :get)
12
+ @methods = raw_methods.map { |m| normalize_method(m) }.freeze
13
+ @methods_set = @methods.to_set.freeze
46
14
  @controller = extract_controller(options)
47
15
  @action = options[:action] || extract_action(options[:to])
48
16
  @name = options[:as]
49
17
  @constraints = options[:constraints] || {}
50
- # pre-normalize defaults to string keys to avoid per-request transform_keys
51
- @defaults = (options[:defaults] || {}).transform_keys(&:to_s)
18
+ # Pre-normalize defaults to string keys and freeze
19
+ @defaults = (options[:defaults] || {}).transform_keys(&:to_s).freeze
52
20
 
21
+ # Pre-compile everything at initialization
22
+ precompile_route_data
53
23
  validate_route!
54
24
  end
55
25
 
56
26
  def match?(request_method, request_path)
57
- return false unless methods.include?(request_method.to_s.upcase)
58
- !!extract_path_params(request_path)
27
+ # Fast method check: use frozen Set for O(1) lookup
28
+ return false unless @methods_set.include?(request_method.to_s.upcase)
29
+ !!extract_path_params_fast(request_path)
59
30
  end
60
31
 
61
- def extract_params(request_path)
62
- path_params = extract_path_params(request_path)
63
- return {} unless path_params
32
+ def extract_params(request_path, parsed_qp = nil)
33
+ path_params = extract_path_params_fast(request_path)
34
+ return EMPTY_HASH unless path_params
64
35
 
65
- params = path_params.dup
66
- params.merge!(query_params(request_path)) # query_params returns string-keyed hash below
67
- # defaults already string keys, only merge keys that aren't present
68
- defaults.each { |k, v| params[k] = v unless params.key?(k) }
69
-
70
- validate_constraints!(params)
71
- params
36
+ # Use optimized param building
37
+ build_params_hash(path_params, request_path, parsed_qp)
72
38
  end
73
39
 
74
40
  def named?
75
- !name.nil?
41
+ !@name.nil?
76
42
  end
77
43
 
78
44
  def resource?
79
- path.match?(/\/:id$/) || path.match?(/\/:id\./)
45
+ @is_resource
80
46
  end
81
47
 
82
48
  def collection?
83
- !resource?
49
+ !@is_resource
50
+ end
51
+
52
+ def parse_query_params(path)
53
+ query_params_fast(path)
84
54
  end
85
55
 
86
- # Fast path generator: uses precompiled token list and a small LRU.
87
- # Avoids unbounded cache growth and skips URI-encoding for safe values.
56
+ # Optimized path generation with better caching and fewer allocations
88
57
  def generate_path(params = {})
89
- return '/' if path == '/'
90
-
91
- # build merged for only relevant param names
92
- merged = {}
93
- defaults.each { |k, v| merged[k] = v } # defaults already string keys
94
- params.each do |k, v|
95
- ks = k.to_s
96
- merged[ks] = v
58
+ return ROOT_PATH if @path == ROOT_PATH
59
+
60
+ # Fast path: empty params and no required params
61
+ if params.empty? && @required_params.empty?
62
+ return @static_path if @static_path
97
63
  end
98
64
 
99
- missing = compiled_required_params - merged.keys
100
- raise RubyRoutes::RouteNotFound, "Missing params: #{missing.join(', ')}" unless missing.empty?
65
+ # Build merged params efficiently
66
+ merged = build_merged_params(params)
101
67
 
102
- @gen_cache ||= SmallLru.new(256)
103
- cache_key = cache_key_for(merged)
104
- if (cached = @gen_cache.get(cache_key))
105
- return cached
68
+ # Check required params (fast Set operation)
69
+ missing_params = @required_params_set - merged.keys
70
+ unless missing_params.empty?
71
+ raise RubyRoutes::RouteNotFound, "Missing params: #{missing_params.to_a.join(', ')}"
106
72
  end
107
73
 
108
- parts = compiled_segments.map do |seg|
109
- case seg[:type]
110
- when :static
111
- seg[:value]
112
- when :param
113
- v = merged.fetch(seg[:name]).to_s
114
- safe_encode_segment(v)
115
- when :splat
116
- v = merged.fetch(seg[:name], '')
117
- arr = v.is_a?(Array) ? v : v.to_s.split('/')
118
- arr.map { |p| safe_encode_segment(p.to_s) }.join('/')
119
- end
74
+ # Cache lookup
75
+ cache_key = build_cache_key_fast(merged)
76
+ if (cached = @gen_cache.get(cache_key))
77
+ return cached
120
78
  end
121
79
 
122
- out = '/' + parts.join('/')
123
- out = '/' if out == ''
80
+ # Generate path using string buffer (avoid array allocations)
81
+ path_str = generate_path_string(merged)
82
+ @gen_cache.set(cache_key, path_str)
83
+ path_str
84
+ end
124
85
 
125
- @gen_cache.set(cache_key, out)
126
- out
86
+ # Fast query params method (cached and optimized)
87
+ def query_params(request_path)
88
+ query_params_fast(request_path)
127
89
  end
128
90
 
129
91
  private
130
92
 
131
- # compile helpers (memoize)
132
- def compiled_segments
133
- @compiled_segments ||= begin
134
- if path == '/'
135
- []
136
- else
137
- path.split('/').reject(&:empty?).map do |seg|
138
- if seg.start_with?(':')
139
- { type: :param, name: seg[1..-1] }
140
- elsif seg.start_with?('*')
141
- { type: :splat, name: (seg[1..-1] || 'splat') }
142
- else
143
- { type: :static, value: seg }
144
- end
145
- end
146
- end
147
- end
93
+ # Constants for performance
94
+ EMPTY_HASH = {}.freeze
95
+ ROOT_PATH = '/'.freeze
96
+ UNRESERVED_RE = /\A[a-zA-Z0-9\-._~]+\z/.freeze
97
+ QUERY_CACHE_SIZE = 128
98
+
99
+ # Fast method normalization
100
+ def normalize_method(method)
101
+ case method
102
+ when :get then 'GET'
103
+ when :post then 'POST'
104
+ when :put then 'PUT'
105
+ when :patch then 'PATCH'
106
+ when :delete then 'DELETE'
107
+ when :head then 'HEAD'
108
+ when :options then 'OPTIONS'
109
+ else method.to_s.upcase
110
+ end.freeze
148
111
  end
149
112
 
150
- def compiled_required_params
151
- @compiled_required_params ||= compiled_segments.select { |s| s[:type] != :static }
152
- .map { |s| s[:name] }.uniq
153
- .reject { |n| defaults.to_s.include?(n) }
113
+ # Pre-compile all route data at initialization
114
+ def precompile_route_data
115
+ @is_resource = @path.match?(/\/:id(?:$|\.)/)
116
+ @gen_cache = SmallLru.new(512) # larger cache
117
+ @query_cache = SmallLru.new(QUERY_CACHE_SIZE)
118
+
119
+ compile_segments
120
+ compile_required_params
121
+ check_static_path
154
122
  end
155
123
 
156
- # Cache key: deterministic param-order key (fast, stable)
157
- def cache_key_for(merged)
158
- # build key in route token order (parameters & splat) to avoid sorting/inspect
159
- names = compiled_param_names
160
- names.map { |n| merged[n].to_s }.join('|')
124
+ def compile_segments
125
+ @compiled_segments = if @path == ROOT_PATH
126
+ EMPTY_ARRAY
127
+ else
128
+ @path.split('/').reject(&:empty?).map do |seg|
129
+ RubyRoutes::Constant.segment_descriptor(seg)
130
+ end.freeze
131
+ end
161
132
  end
162
133
 
163
- def compiled_param_names
164
- @compiled_param_names ||= compiled_segments.map { |s| s[:name] if s[:type] != :static }.compact
134
+ def compile_required_params
135
+ param_names = @compiled_segments.filter_map { |s| s[:name] if s[:type] != :static }
136
+ @param_names = param_names.freeze
137
+ @required_params = param_names.reject { |n| @defaults.key?(n) }.freeze
138
+ @required_params_set = @required_params.to_set.freeze
165
139
  end
166
140
 
167
- # Only URI-encode a segment when it contains unsafe chars.
168
- UNRESERVED_RE = /\A[a-zA-Z0-9\-._~]+\z/
169
- def safe_encode_segment(str)
170
- # leave slash handling to splat logic (splats already split)
171
- return str if UNRESERVED_RE.match?(str)
172
- URI.encode_www_form_component(str)
141
+ def check_static_path
142
+ # Pre-generate static paths (no params)
143
+ if @required_params.empty?
144
+ @static_path = generate_static_path
145
+ end
173
146
  end
174
147
 
175
- def normalize_path(path)
176
- p = path.to_s
177
- p = "/#{p}" unless p.start_with?('/')
178
- p = p.chomp('/') unless p == '/'
179
- p
148
+ def generate_static_path
149
+ return ROOT_PATH if @compiled_segments.empty?
150
+
151
+ parts = @compiled_segments.map { |seg| seg[:value] }
152
+ "/#{parts.join('/')}"
180
153
  end
181
154
 
182
- def extract_controller(options)
183
- if options[:to]
184
- options[:to].to_s.split('#').first
185
- else
186
- options[:controller]
155
+ # Optimized param building
156
+ def build_params_hash(path_params, request_path, parsed_qp)
157
+ # Use pre-allocated hash when possible
158
+ result = get_thread_local_hash
159
+
160
+ # Path params first (highest priority)
161
+ result.update(path_params)
162
+
163
+ # Query params (if needed)
164
+ if parsed_qp
165
+ result.merge!(parsed_qp)
166
+ elsif request_path.include?('?')
167
+ qp = query_params_fast(request_path)
168
+ result.merge!(qp) unless qp.empty?
187
169
  end
170
+
171
+ # Defaults (lowest priority)
172
+ merge_defaults_fast(result) unless @defaults.empty?
173
+
174
+ # Validate constraints efficiently
175
+ validate_constraints_fast!(result) unless @constraints.empty?
176
+
177
+ result.dup
188
178
  end
189
179
 
190
- def extract_action(to)
191
- return nil unless to
192
- to.to_s.split('#').last
180
+ def get_thread_local_hash
181
+ hash = Thread.current[:ruby_routes_params] ||= {}
182
+ hash.clear
183
+ hash
184
+ end
185
+
186
+ def merge_defaults_fast(result)
187
+ @defaults.each { |k, v| result[k] = v unless result.key?(k) }
193
188
  end
194
189
 
195
- def extract_path_params(request_path)
196
- segs = compiled_segments # memoized compiled route tokens
197
- return nil if segs.empty? && request_path != '/'
190
+ # Fast path parameter extraction
191
+ def extract_path_params_fast(request_path)
192
+ return EMPTY_HASH if @compiled_segments.empty? && request_path == ROOT_PATH
193
+ return nil if @compiled_segments.empty?
194
+
195
+ # Fast path normalization
196
+ path_parts = split_path_fast(request_path)
197
+ return nil if @compiled_segments.size != path_parts.size
198
198
 
199
- req = request_path
200
- req = req[1..-1] if req.start_with?('/')
201
- req = req[0...-1] if req.end_with?('/') && req != '/'
202
- request_parts = req == '' ? [] : req.split('/')
199
+ extract_params_from_parts(path_parts)
200
+ end
203
201
 
204
- return nil if segs.size != request_parts.size
202
+ def split_path_fast(request_path)
203
+ # Optimized path splitting
204
+ path = request_path
205
+ path = path[1..-1] if path.start_with?('/')
206
+ path = path[0...-1] if path.end_with?('/') && path != ROOT_PATH
207
+ path.empty? ? [] : path.split('/')
208
+ end
205
209
 
210
+ def extract_params_from_parts(path_parts)
206
211
  params = {}
207
- segs.each_with_index do |seg, idx|
212
+
213
+ @compiled_segments.each_with_index do |seg, idx|
208
214
  case seg[:type]
209
215
  when :static
210
- return nil unless seg[:value] == request_parts[idx]
216
+ return nil unless seg[:value] == path_parts[idx]
211
217
  when :param
212
- params[seg[:name]] = request_parts[idx]
218
+ params[seg[:name]] = path_parts[idx]
213
219
  when :splat
214
- params[seg[:name]] = request_parts[idx..-1].join('/')
220
+ params[seg[:name]] = path_parts[idx..-1].join('/')
215
221
  break
216
222
  end
217
223
  end
@@ -219,15 +225,128 @@ module RubyRoutes
219
225
  params
220
226
  end
221
227
 
222
- def query_params(path)
223
- qidx = path.index('?')
224
- return {} unless qidx
225
- qs = path[(qidx + 1)..-1] || ''
226
- Rack::Utils.parse_query(qs).transform_keys(&:to_s)
228
+ # Optimized merged params building
229
+ def build_merged_params(params)
230
+ return @defaults if params.empty?
231
+
232
+ merged = get_thread_local_merged_hash
233
+ merged.update(@defaults) unless @defaults.empty?
234
+
235
+ # Convert param keys to strings efficiently
236
+ params.each { |k, v| merged[k.to_s] = v }
237
+ merged
238
+ end
239
+
240
+ def get_thread_local_merged_hash
241
+ hash = Thread.current[:ruby_routes_merged] ||= {}
242
+ hash.clear
243
+ hash
244
+ end
245
+
246
+ # Fast cache key building with minimal allocations
247
+ def build_cache_key_fast(merged)
248
+ # Use instance variable buffer to avoid repeated allocations
249
+ @cache_key_buffer ||= String.new(capacity: 128)
250
+ @cache_key_buffer.clear
251
+
252
+ return @cache_key_buffer.dup if @required_params.empty?
253
+
254
+ @required_params.each_with_index do |name, idx|
255
+ @cache_key_buffer << '|' unless idx.zero?
256
+ value = merged[name]
257
+ @cache_key_buffer << (value.is_a?(Array) ? value.join('/') : value.to_s) if value
258
+ end
259
+ @cache_key_buffer.dup
260
+ end
261
+
262
+ # Optimized path generation
263
+ def generate_path_string(merged)
264
+ return ROOT_PATH if @compiled_segments.empty?
265
+
266
+ buffer = String.new(capacity: 128)
267
+ buffer << '/'
268
+
269
+ @compiled_segments.each_with_index do |seg, idx|
270
+ buffer << '/' unless idx.zero?
271
+
272
+ case seg[:type]
273
+ when :static
274
+ buffer << seg[:value]
275
+ when :param
276
+ value = merged.fetch(seg[:name]).to_s
277
+ buffer << encode_segment_fast(value)
278
+ when :splat
279
+ value = merged.fetch(seg[:name], '')
280
+ append_splat_value(buffer, value)
281
+ end
282
+ end
283
+
284
+ buffer == '/' ? ROOT_PATH : buffer
285
+ end
286
+
287
+ def append_splat_value(buffer, value)
288
+ case value
289
+ when Array
290
+ value.each_with_index do |part, idx|
291
+ buffer << '/' unless idx.zero?
292
+ buffer << encode_segment_fast(part.to_s)
293
+ end
294
+ when String
295
+ parts = value.split('/')
296
+ parts.each_with_index do |part, idx|
297
+ buffer << '/' unless idx.zero?
298
+ buffer << encode_segment_fast(part)
299
+ end
300
+ else
301
+ buffer << encode_segment_fast(value.to_s)
302
+ end
303
+ end
304
+
305
+ # Fast segment encoding
306
+ def encode_segment_fast(str)
307
+ return str if UNRESERVED_RE.match?(str)
308
+ URI.encode_www_form_component(str)
309
+ end
310
+
311
+ # Optimized query params with caching
312
+ def query_params_fast(path)
313
+ query_start = path.index('?')
314
+ return EMPTY_HASH unless query_start
315
+
316
+ query_string = path[(query_start + 1)..-1]
317
+ return EMPTY_HASH if query_string.empty?
318
+
319
+ # Cache query param parsing
320
+ if (cached = @query_cache.get(query_string))
321
+ return cached
322
+ end
323
+
324
+ result = Rack::Utils.parse_query(query_string)
325
+ @query_cache.set(query_string, result)
326
+ result
327
+ end
328
+
329
+ def normalize_path(path)
330
+ path_str = path.to_s
331
+ path_str = "/#{path_str}" unless path_str.start_with?('/')
332
+ path_str = path_str.chomp('/') unless path_str == ROOT_PATH
333
+ path_str
334
+ end
335
+
336
+ def extract_controller(options)
337
+ to = options[:to]
338
+ return options[:controller] unless to
339
+ to.to_s.split('#', 2).first
340
+ end
341
+
342
+ def extract_action(to)
343
+ return nil unless to
344
+ to.to_s.split('#', 2).last
227
345
  end
228
346
 
229
- def validate_constraints!(params)
230
- constraints.each do |param, constraint|
347
+ # Optimized constraint validation
348
+ def validate_constraints_fast!(params)
349
+ @constraints.each do |param, constraint|
231
350
  value = params[param.to_s]
232
351
  next unless value
233
352
 
@@ -236,19 +355,23 @@ module RubyRoutes
236
355
  raise ConstraintViolation unless constraint.match?(value)
237
356
  when Proc
238
357
  raise ConstraintViolation unless constraint.call(value)
239
- when Symbol
240
- case constraint
241
- when :int then raise ConstraintViolation unless value.match?(/^\d+$/)
242
- when :uuid then raise ConstraintViolation unless value.match?(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
243
- end
358
+ when :int
359
+ raise ConstraintViolation unless value.match?(/\A\d+\z/)
360
+ when :uuid
361
+ raise ConstraintViolation unless value.length == 36 &&
362
+ value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
244
363
  end
245
364
  end
246
365
  end
247
366
 
248
367
  def validate_route!
249
- raise InvalidRoute, "Controller is required" if controller.nil?
250
- raise InvalidRoute, "Action is required" if action.nil?
251
- raise InvalidRoute, "Invalid HTTP method: #{methods}" if methods.empty?
368
+ raise InvalidRoute, "Controller is required" if @controller.nil?
369
+ raise InvalidRoute, "Action is required" if @action.nil?
370
+ raise InvalidRoute, "Invalid HTTP method: #{@methods}" if @methods.empty?
252
371
  end
372
+
373
+ # Additional constants
374
+ EMPTY_ARRAY = [].freeze
375
+ EMPTY_STRING = ''.freeze
253
376
  end
254
- end
377
+ end
@@ -5,44 +5,80 @@ module RubyRoutes
5
5
  def initialize
6
6
  @tree = RubyRoutes::RadixTree.new
7
7
  @named_routes = {}
8
- @routes = [] # keep list for specs / iteration / size
8
+ @routes = []
9
+ # Optimized recognition cache with better data structures
10
+ @recognition_cache = {}
11
+ @cache_hits = 0
12
+ @cache_misses = 0
13
+ @recognition_cache_max = 8192 # larger for better hit rates
9
14
  end
10
15
 
11
16
  def add_route(route)
12
17
  @routes << route
13
18
  @tree.add(route.path, route.methods, route)
14
19
  @named_routes[route.name] = route if route.named?
20
+ # Clear recognition cache when routes change
21
+ @recognition_cache.clear if @recognition_cache.size > 100
15
22
  route
16
23
  end
17
24
 
18
25
  def find_route(request_method, request_path)
19
- # Return the Route object (or nil) to match spec expectations.
20
- handler, _params = @tree.find(request_path, request_method.to_s.upcase)
26
+ # Optimized: avoid repeated string allocation
27
+ method_up = request_method.to_s.upcase
28
+ handler, _params = @tree.find(request_path, method_up)
21
29
  handler
22
30
  end
23
31
 
24
32
  def find_named_route(name)
25
- @named_routes[name] or raise RouteNotFound, "No route named '#{name}'"
33
+ route = @named_routes[name]
34
+ return route if route
35
+ raise RouteNotFound, "No route named '#{name}'"
26
36
  end
27
37
 
28
38
  def match(request_method, request_path)
29
- # Use RadixTree lookup and the path params it returns (avoid reparsing the path)
30
- handler, path_params = @tree.find(request_path, request_method.to_s.upcase)
39
+ # Fast path: normalize method once
40
+ method_up = method_lookup(request_method)
41
+
42
+ # Optimized cache key: avoid string interpolation when possible
43
+ cache_key = build_cache_key(method_up, request_path)
44
+
45
+ # Cache hit: return immediately
46
+ if (cached = @recognition_cache[cache_key])
47
+ @cache_hits += 1
48
+ cached_route, cached_params = cached
49
+ return {
50
+ route: cached_route,
51
+ params: cached_params,
52
+ controller: cached_route.controller,
53
+ action: cached_route.action
54
+ }
55
+ end
56
+
57
+ @cache_misses += 1
58
+
59
+ # Use thread-local params to avoid allocations
60
+ params = get_thread_local_params
61
+ handler, _ = @tree.find(request_path, method_up, params)
31
62
  return nil unless handler
32
63
 
33
64
  route = handler
34
65
 
35
- # path_params are already string-keyed in RadixTree; merge defaults + query params
36
- params = (path_params || {}).dup
37
- params = route.defaults.transform_keys(&:to_s).merge(params)
38
- params.merge!(route.send(:query_params, request_path))
66
+ # Fast path: merge defaults only if they exist
67
+ merge_defaults(route, params) if route.defaults && !route.defaults.empty?
39
68
 
40
- # Note: lightweight constraint checks are performed during RadixTree#find.
41
- # Skip full constraint re-validation here to avoid double work.
69
+ # Fast path: parse query params only if needed
70
+ if request_path.include?('?')
71
+ merge_query_params(route, request_path, params)
72
+ end
73
+
74
+ # Create return hash and cache entry
75
+ result_params = params.dup
76
+ cache_entry = [route, result_params.freeze]
77
+ insert_cache_entry(cache_key, cache_entry)
42
78
 
43
79
  {
44
80
  route: route,
45
- params: params,
81
+ params: result_params,
46
82
  controller: route.controller,
47
83
  action: route.action
48
84
  }
@@ -53,35 +89,119 @@ module RubyRoutes
53
89
  end
54
90
 
55
91
  def generate_path(name, params = {})
56
- route = find_named_route(name)
57
- generate_path_from_route(route, params)
92
+ route = @named_routes[name]
93
+ if route
94
+ route.generate_path(params)
95
+ else
96
+ raise RouteNotFound, "No route named '#{name}'"
97
+ end
58
98
  end
59
99
 
60
100
  def generate_path_from_route(route, params = {})
61
- # Delegate to Route#generate_path which uses precompiled segments + cache
62
- route.generate_path(params)
101
+ route.generate_path(params)
63
102
  end
64
103
 
65
104
  def clear!
66
105
  @routes.clear
67
106
  @named_routes.clear
107
+ @recognition_cache.clear
68
108
  @tree = RadixTree.new
109
+ @cache_hits = @cache_misses = 0
69
110
  end
70
111
 
71
112
  def size
72
113
  @routes.size
73
114
  end
115
+ alias_method :length, :size
74
116
 
75
117
  def empty?
76
118
  @routes.empty?
77
119
  end
78
120
 
79
121
  def each(&block)
122
+ return enum_for(:each) unless block_given?
80
123
  @routes.each(&block)
81
124
  end
82
125
 
83
126
  def include?(route)
84
127
  @routes.include?(route)
85
128
  end
129
+
130
+ # Performance monitoring
131
+ def cache_stats
132
+ total = @cache_hits + @cache_misses
133
+ hit_rate = total > 0 ? (@cache_hits.to_f / total * 100).round(2) : 0
134
+ {
135
+ hits: @cache_hits,
136
+ misses: @cache_misses,
137
+ hit_rate: "#{hit_rate}%",
138
+ size: @recognition_cache.size
139
+ }
140
+ end
141
+
142
+ private
143
+
144
+ # Method lookup table to avoid repeated upcasing
145
+ def method_lookup(method)
146
+ @method_cache ||= Hash.new { |h, k| h[k] = k.to_s.upcase.freeze }
147
+ @method_cache[method]
148
+ end
149
+
150
+ # Optimized cache key building - avoid string interpolation
151
+ def build_cache_key(method, path)
152
+ # Use frozen string concatenation to avoid allocations
153
+ @cache_key_buffer ||= String.new(capacity: 256)
154
+ @cache_key_buffer.clear
155
+ @cache_key_buffer << method << ':' << path
156
+ @cache_key_buffer.dup.freeze
157
+ end
158
+
159
+ # Get thread-local params hash, reusing when possible
160
+ def get_thread_local_params
161
+ # Use object pool to reduce GC pressure
162
+ @params_pool ||= []
163
+ if @params_pool.empty?
164
+ {}
165
+ else
166
+ hash = @params_pool.pop
167
+ hash.clear
168
+ hash
169
+ end
170
+ end
171
+
172
+ def return_params_to_pool(params)
173
+ @params_pool ||= []
174
+ @params_pool.push(params) if @params_pool.size < 10
175
+ end
176
+
177
+ # Fast defaults merging
178
+ def merge_defaults(route, params)
179
+ route.defaults.each do |key, value|
180
+ params[key] = value unless params.key?(key)
181
+ end
182
+ end
183
+
184
+ # Fast query params merging
185
+ def merge_query_params(route, request_path, params)
186
+ if route.respond_to?(:parse_query_params)
187
+ qp = route.parse_query_params(request_path)
188
+ params.merge!(qp) unless qp.empty?
189
+ elsif route.respond_to?(:query_params)
190
+ qp = route.query_params(request_path)
191
+ params.merge!(qp) unless qp.empty?
192
+ end
193
+ end
194
+
195
+ # Efficient cache insertion with LRU eviction
196
+ def insert_cache_entry(cache_key, cache_entry)
197
+ @recognition_cache[cache_key] = cache_entry
198
+
199
+ # Simple eviction: clear cache when it gets too large
200
+ if @recognition_cache.size > @recognition_cache_max
201
+ # Keep most recently used half
202
+ keys_to_delete = @recognition_cache.keys[0...(@recognition_cache_max / 2)]
203
+ keys_to_delete.each { |k| @recognition_cache.delete(k) }
204
+ end
205
+ end
86
206
  end
87
207
  end
@@ -0,0 +1,20 @@
1
+ require_relative 'segments/base_segment'
2
+ require_relative 'segments/dynamic_segment'
3
+ require_relative 'segments/static_segment'
4
+ require_relative 'segments/wildcard_segment'
5
+ require_relative 'constant'
6
+
7
+ module RubyRoutes
8
+ class Segment
9
+ def self.for(text)
10
+ t = text.to_s
11
+ key = t.empty? ? :default : t.getbyte(0)
12
+ segment = RubyRoutes::Constant::SEGMENTS[key] || RubyRoutes::Constant::SEGMENTS[:default]
13
+ segment.new(t)
14
+ end
15
+
16
+ def wildcard?
17
+ false
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,21 @@
1
+ module RubyRoutes
2
+ module Segments
3
+ class BaseSegment
4
+ def initialize(text = nil)
5
+ @text = text.to_s if text
6
+ end
7
+
8
+ def wildcard?
9
+ false
10
+ end
11
+
12
+ def ensure_child(current)
13
+ raise NotImplementedError, "#{self.class}#ensure_child must be implemented"
14
+ end
15
+
16
+ def match(_node, _text, _idx, _segments, _params)
17
+ raise NotImplementedError, "#{self.class}#match must be implemented"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ module RubyRoutes
2
+ module Segments
3
+ class DynamicSegment < BaseSegment
4
+ def initialize(text)
5
+ @name = text[1..-1]
6
+ end
7
+
8
+ def ensure_child(current)
9
+ current.dynamic_child ||= Node.new
10
+ current = current.dynamic_child
11
+ current.param_name = @name
12
+ current
13
+ end
14
+
15
+ def match(node, text, _idx, _segments, params)
16
+ return [nil, false] unless node.dynamic_child
17
+ nxt = node.dynamic_child
18
+ params[nxt.param_name.to_s] = text if params
19
+ [nxt, false]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ module RubyRoutes
2
+ module Segments
3
+ class StaticSegment < BaseSegment
4
+ def initialize(text)
5
+ @text = text
6
+ end
7
+
8
+ def ensure_child(current)
9
+ current.static_children[@text] ||= Node.new
10
+ current.static_children[@text]
11
+ end
12
+
13
+ def match(node, text, _idx, _segments, _params)
14
+ [node.static_children[text], false]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ module RubyRoutes
2
+ module Segments
3
+ class WildcardSegment < BaseSegment
4
+ def initialize(text)
5
+ @name = (text[1..-1] || 'splat')
6
+ end
7
+
8
+ def ensure_child(current)
9
+ current.wildcard_child ||= Node.new
10
+ current = current.wildcard_child
11
+ current.param_name = @name
12
+ current
13
+ end
14
+
15
+ def wildcard?
16
+ true
17
+ end
18
+
19
+ def match(node, _text, idx, segments, params)
20
+ return [nil, false] unless node.wildcard_child
21
+ nxt = node.wildcard_child
22
+ params[nxt.param_name.to_s] = segments[idx..-1].join('/') if params
23
+ [nxt, true]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,3 +1,3 @@
1
1
  module RubyRoutes
2
- VERSION = "0.2.0"
2
+ VERSION = "1.1.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_routes
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yosef Benny Widyokarsono
@@ -62,11 +62,20 @@ files:
62
62
  - LICENSE
63
63
  - README.md
64
64
  - lib/ruby_routes.rb
65
+ - lib/ruby_routes/constant.rb
66
+ - lib/ruby_routes/lru_strategies/hit_strategy.rb
67
+ - lib/ruby_routes/lru_strategies/miss_strategy.rb
65
68
  - lib/ruby_routes/node.rb
66
69
  - lib/ruby_routes/radix_tree.rb
67
70
  - lib/ruby_routes/route.rb
71
+ - lib/ruby_routes/route/small_lru.rb
68
72
  - lib/ruby_routes/route_set.rb
69
73
  - lib/ruby_routes/router.rb
74
+ - lib/ruby_routes/segment.rb
75
+ - lib/ruby_routes/segments/base_segment.rb
76
+ - lib/ruby_routes/segments/dynamic_segment.rb
77
+ - lib/ruby_routes/segments/static_segment.rb
78
+ - lib/ruby_routes/segments/wildcard_segment.rb
70
79
  - lib/ruby_routes/string_extensions.rb
71
80
  - lib/ruby_routes/url_helpers.rb
72
81
  - lib/ruby_routes/version.rb