ruby_routes 2.2.0 → 2.4.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +240 -163
  3. data/lib/ruby_routes/constant.rb +137 -18
  4. data/lib/ruby_routes/lru_strategies/hit_strategy.rb +31 -4
  5. data/lib/ruby_routes/lru_strategies/miss_strategy.rb +21 -0
  6. data/lib/ruby_routes/node.rb +86 -36
  7. data/lib/ruby_routes/radix_tree/finder.rb +213 -0
  8. data/lib/ruby_routes/radix_tree/inserter.rb +96 -0
  9. data/lib/ruby_routes/radix_tree.rb +65 -230
  10. data/lib/ruby_routes/route/check_helpers.rb +115 -0
  11. data/lib/ruby_routes/route/constraint_validator.rb +173 -0
  12. data/lib/ruby_routes/route/param_support.rb +200 -0
  13. data/lib/ruby_routes/route/path_builder.rb +84 -0
  14. data/lib/ruby_routes/route/path_generation.rb +87 -0
  15. data/lib/ruby_routes/route/query_helpers.rb +56 -0
  16. data/lib/ruby_routes/route/segment_compiler.rb +166 -0
  17. data/lib/ruby_routes/route/small_lru.rb +93 -18
  18. data/lib/ruby_routes/route/validation_helpers.rb +174 -0
  19. data/lib/ruby_routes/route/warning_helpers.rb +57 -0
  20. data/lib/ruby_routes/route.rb +127 -501
  21. data/lib/ruby_routes/route_set/cache_helpers.rb +76 -0
  22. data/lib/ruby_routes/route_set/collection_helpers.rb +125 -0
  23. data/lib/ruby_routes/route_set.rb +140 -132
  24. data/lib/ruby_routes/router/build_helpers.rb +99 -0
  25. data/lib/ruby_routes/router/builder.rb +97 -0
  26. data/lib/ruby_routes/router/http_helpers.rb +135 -0
  27. data/lib/ruby_routes/router/resource_helpers.rb +137 -0
  28. data/lib/ruby_routes/router/scope_helpers.rb +127 -0
  29. data/lib/ruby_routes/router.rb +196 -182
  30. data/lib/ruby_routes/segment.rb +28 -8
  31. data/lib/ruby_routes/segments/base_segment.rb +40 -4
  32. data/lib/ruby_routes/segments/dynamic_segment.rb +48 -12
  33. data/lib/ruby_routes/segments/static_segment.rb +43 -7
  34. data/lib/ruby_routes/segments/wildcard_segment.rb +58 -12
  35. data/lib/ruby_routes/string_extensions.rb +52 -15
  36. data/lib/ruby_routes/url_helpers.rb +106 -24
  37. data/lib/ruby_routes/utility/inflector_utility.rb +35 -0
  38. data/lib/ruby_routes/utility/key_builder_utility.rb +171 -77
  39. data/lib/ruby_routes/utility/method_utility.rb +137 -0
  40. data/lib/ruby_routes/utility/path_utility.rb +75 -28
  41. data/lib/ruby_routes/utility/route_utility.rb +30 -2
  42. data/lib/ruby_routes/version.rb +3 -1
  43. data/lib/ruby_routes.rb +68 -11
  44. metadata +27 -7
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'segments/base_segment'
2
4
  require_relative 'segments/dynamic_segment'
3
5
  require_relative 'segments/static_segment'
@@ -6,13 +8,47 @@ require_relative 'lru_strategies/hit_strategy'
6
8
  require_relative 'lru_strategies/miss_strategy'
7
9
 
8
10
  module RubyRoutes
11
+ # Constant
12
+ #
13
+ # Central registry for lightweight immutable structures and singleton
14
+ # strategy objects used across routing components. Centralization keeps
15
+ # hot path code free of repeated allocations and magic numbers.
16
+ #
17
+ # Responsibilities:
18
+ # - Map the first byte (ASCII) of a raw segment to its Segment subclass.
19
+ # - Provide lambda matchers for radix traversal (legacy/fallback).
20
+ # - Expose singleton LRU strategy objects (hit/miss).
21
+ # - Build compact Hash descriptors for parsed route path segments.
22
+ #
23
+ # Design Notes:
24
+ # - Numeric keys (42, 58) are ASCII codes for '*' and ':' allowing
25
+ # O(1) dispatch without extra string comparisons.
26
+ # - Descriptor factories return frozen data to enable safe reuse.
27
+ #
28
+ # @api internal
9
29
  module Constant
30
+ # Shared, canonical root path constant (single source of truth).
31
+ ROOT_PATH = '/'.freeze
32
+
33
+ # Maps a segment's first byte (ASCII) to a Segment class.
34
+ #
35
+ # Keys:
36
+ # - 42 ('*') -> Wildcard
37
+ # - 58 (':') -> Dynamic
38
+ # - :default -> Static
39
+ #
40
+ # @return [Hash{Integer, Symbol => Class}]
10
41
  SEGMENTS = {
11
42
  42 => RubyRoutes::Segments::WildcardSegment, # '*'
12
43
  58 => RubyRoutes::Segments::DynamicSegment, # ':'
13
44
  :default => RubyRoutes::Segments::StaticSegment
14
45
  }.freeze
15
46
 
47
+ # Legacy lambda-based segment matchers (kept for compatibility/fallback).
48
+ #
49
+ # Each lambda returns `[next_node, stop_traversal]` or `nil` when no match.
50
+ #
51
+ # @return [Hash{Symbol => Proc}]
16
52
  SEGMENT_MATCHERS = {
17
53
  static: lambda do |node, segment, _idx, _segments, _params|
18
54
  child = node.static_children[segment]
@@ -21,38 +57,121 @@ module RubyRoutes
21
57
 
22
58
  dynamic: lambda do |node, segment, _idx, _segments, params|
23
59
  return nil unless node.dynamic_child
24
- nxt = node.dynamic_child
25
- params[nxt.param_name.to_s] = segment if params && nxt.param_name
26
- [nxt, false]
60
+
61
+ next_node = node.dynamic_child
62
+ params[next_node.param_name.to_s] = segment if params && next_node.param_name
63
+ [next_node, false]
27
64
  end,
28
65
 
29
66
  wildcard: lambda do |node, _segment, idx, segments, params|
30
67
  return nil unless node.wildcard_child
31
- nxt = node.wildcard_child
32
- params[nxt.param_name.to_s] = segments[idx..-1].join('/') if params && nxt.param_name
33
- [nxt, true]
68
+
69
+ next_node = node.wildcard_child
70
+ params[next_node.param_name.to_s] = segments[idx..].join('/') if params && next_node.param_name
71
+ [next_node, true]
34
72
  end,
35
73
 
36
- # default returns nil (no match). RadixTree#find will then return [nil, {}].
37
- default: lambda { |_node, _segment, _idx, _segments, _params| nil }
74
+ default: ->(_node, _segment, _idx, _segments, _params) { nil }
38
75
  }.freeze
39
76
 
40
- # singleton instances to avoid per-LRU allocations
41
- LRU_HIT_STRATEGY = RubyRoutes::LruStrategies::HitStrategy.new.freeze
77
+ # Singleton instances to avoid per-cache strategy allocations.
78
+ #
79
+ # @return [RubyRoutes::LruStrategies::HitStrategy, RubyRoutes::LruStrategies::MissStrategy]
80
+ LRU_HIT_STRATEGY = RubyRoutes::LruStrategies::HitStrategy.new.freeze
42
81
  LRU_MISS_STRATEGY = RubyRoutes::LruStrategies::MissStrategy.new.freeze
43
82
 
44
- # Descriptor factories for segment classification (O(1) dispatch by first byte).
83
+ # Factories producing compact immutable descriptors for segments used
84
+ # during route compilation (faster than instantiating many objects).
85
+ #
86
+ # @return [Hash{Integer, Symbol => Proc}]
45
87
  DESCRIPTOR_FACTORIES = {
46
- 42 => ->(s) { { type: :splat, name: (s[1..-1] || 'splat').freeze } }, # '*'
47
- 58 => ->(s) { { type: :param, name: s[1..-1].freeze } }, # ':'
48
- :default => ->(s) { { type: :static, value: s.freeze } } # Intern static values
88
+ 42 => lambda { |s|
89
+ name = s[1..]
90
+ { type: :splat, name: (name.nil? || name.empty? ? 'splat' : name).freeze }
91
+ }, # '*'
92
+ 58 => ->(s) { { type: :param, name: s[1..].freeze } }, # ':'
93
+ :default => ->(s) { { type: :static, value: s.freeze } }
94
+ }.freeze
95
+
96
+ # Regex for unreserved characters (RFC 3986 subset).
97
+ #
98
+ # @return [Regexp]
99
+ UNRESERVED_RE = /\A[a-zA-Z0-9\-._~]+\z/
100
+
101
+ # Maximum size of the query parameter cache.
102
+ #
103
+ # This constant defines the maximum number of query strings that can be
104
+ # cached for fast lookup. Once the cache reaches this size, the least
105
+ # recently used entries will be evicted.
106
+ #
107
+ # @return [Integer]
108
+ QUERY_CACHE_SIZE = 128
109
+
110
+ # HTTP method constants.
111
+ HTTP_GET = 'GET'
112
+ HTTP_POST = 'POST'
113
+ HTTP_PUT = 'PUT'
114
+ HTTP_PATCH = 'PATCH'
115
+ HTTP_DELETE = 'DELETE'
116
+ HTTP_HEAD = 'HEAD'
117
+ HTTP_OPTIONS = 'OPTIONS'
118
+
119
+ # Empty constants for reuse.
120
+ EMPTY_ARRAY = [].freeze
121
+ EMPTY_PAIR = [EMPTY_ARRAY, EMPTY_ARRAY].freeze
122
+ EMPTY_STRING = ''.freeze
123
+ EMPTY_HASH = {}.freeze
124
+
125
+ # Maximum number of distinct (method, path) composite keys retained
126
+ # before the oldest are overwritten in ring order.
127
+ #
128
+ # @return [Integer]
129
+ REQUEST_KEY_CAPACITY = 4096
130
+
131
+ # Supported DSL methods for route recording.
132
+ #
133
+ # @return [Array<Symbol>]
134
+ RECORDED_METHODS = %i[
135
+ get post put patch delete match root
136
+ resources resource
137
+ namespace scope constraints defaults
138
+ mount concern concerns
139
+ ].freeze
140
+
141
+ # All supported HTTP verbs.
142
+ #
143
+ # @return [Array<Symbol>]
144
+ VERBS_ALL = %i[get post put patch delete head options].freeze
145
+
146
+ # Default result for no traversal match.
147
+ #
148
+ # @return [Array]
149
+ NO_TRAVERSAL_RESULT = [nil, false, {}].freeze
150
+
151
+ # Built-in validators for constraints.
152
+ #
153
+ # @return [Hash{Symbol => Symbol}]
154
+ BUILTIN_VALIDATORS = {
155
+ int: :validate_int_constraint,
156
+ uuid: :validate_uuid_constraint,
157
+ email: :validate_email_constraint,
158
+ slug: :validate_slug_constraint,
159
+ alpha: :validate_alpha_constraint,
160
+ alphanumeric: :validate_alphanumeric_constraint
49
161
  }.freeze
50
162
 
163
+ # Build a descriptor Hash for a raw segment string.
164
+ #
165
+ # @param raw [String, #to_s] The raw segment string.
166
+ # @return [Hash] A descriptor hash with frozen values.
167
+ #
168
+ # @example
169
+ # Constant.segment_descriptor(":id") # => { type: :param, name: "id" }
51
170
  def self.segment_descriptor(raw)
52
- s = raw.to_s
53
- key = s.empty? ? :default : s.getbyte(0)
54
- factory = DESCRIPTOR_FACTORIES[key] || DESCRIPTOR_FACTORIES[:default]
55
- factory.call(s)
171
+ segment_string = raw.to_s
172
+ dispatch_key = segment_string.empty? ? :default : segment_string.getbyte(0)
173
+ factory = DESCRIPTOR_FACTORIES[dispatch_key] || DESCRIPTOR_FACTORIES[:default]
174
+ factory.call(segment_string)
56
175
  end
57
176
  end
58
177
  end
@@ -1,12 +1,39 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RubyRoutes
2
4
  module LruStrategies
5
+ # HitStrategy
6
+ #
7
+ # Strategy object invoked when a lookup in SmallLru succeeds.
8
+ # Responsibilities:
9
+ # - Increment the hit counter.
10
+ # - Reinsert the accessed key at the logical MRU position to
11
+ # approximate LRU behavior using simple Hash order.
12
+ #
13
+ # Isolation of this logic allows the hot path in SmallLru#get
14
+ # to delegate without conditionals, and lets alternative
15
+ # eviction / promotion policies be swapped in tests or future
16
+ # tuning without rewriting cache code.
17
+ #
18
+ # @example (internal usage)
19
+ # lru = SmallLru.new
20
+ # RubyRoutes::LruStrategies::HitStrategy.new.call(lru, :k)
21
+ #
22
+ # @api internal
3
23
  class HitStrategy
24
+ # Promote a key on cache hit.
25
+ #
26
+ # @param lru [SmallLru] the owning LRU cache
27
+ # @param key [Object] the key that was found
28
+ # @return [Object] the cached value
4
29
  def call(lru, key)
5
30
  lru.increment_hits
6
- h = lru.instance_variable_get(:@h)
7
- val = h.delete(key)
8
- h[key] = val
9
- val
31
+ # Internal storage name (@hash) is intentionally accessed reflectively
32
+ # to keep strategy decoupled from public API surface.
33
+ store = lru.instance_variable_get(:@hash)
34
+ value = store.delete(key)
35
+ store[key] = value
36
+ value
10
37
  end
11
38
  end
12
39
  end
@@ -1,6 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RubyRoutes
2
4
  module LruStrategies
5
+ # MissStrategy
6
+ #
7
+ # Implements the behavior executed when a key lookup in SmallLru
8
+ # does not exist. It increments miss counters and returns nil.
9
+ #
10
+ # This object-oriented strategy form allows swapping behaviors
11
+ # without adding conditionals in the hot LRU path.
12
+ #
13
+ # @example Basic usage (internal)
14
+ # lru = SmallLru.new
15
+ # strategy = RubyRoutes::LruStrategies::MissStrategy.new
16
+ # strategy.call(lru, :unknown) # => nil (and increments lru.misses)
17
+ #
18
+ # @api internal
3
19
  class MissStrategy
20
+ # Execute miss handling.
21
+ #
22
+ # @param lru [SmallLru] the LRU cache instance
23
+ # @param _key [Object] the missed key (unused)
24
+ # @return [nil] always nil to signal absence
4
25
  def call(lru, _key)
5
26
  lru.increment_misses
6
27
  nil
@@ -1,64 +1,114 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'segment'
4
+ require_relative 'utility/path_utility'
5
+ require_relative 'utility/method_utility'
2
6
 
3
7
  module RubyRoutes
4
- # Node represents a single node in the radix tree structure.
5
- # Each node can have static children (exact matches), one dynamic child (parameter capture),
6
- # and one wildcard child (consumes remaining path segments).
8
+ # Node
9
+ #
10
+ # A single vertex in the routing radix tree.
11
+ #
12
+ # Structure:
13
+ # - static_children: Hash<String, Node> exact literal matches.
14
+ # - dynamic_child: Node (":param") matches any single segment, captures value.
15
+ # - wildcard_child: Node ("*splat") matches remaining segments (greedy).
16
+ #
17
+ # Handlers:
18
+ # - @handlers maps canonical HTTP method strings (e.g. "GET") to Route objects (or callable handlers).
19
+ # - @is_endpoint marks that at least one handler is attached (terminal path).
20
+ #
21
+ # Matching precedence (most → least specific):
22
+ # static → dynamic → wildcard
23
+ #
24
+ # Thread safety: not thread-safe (build during boot).
25
+ #
26
+ # @api internal
7
27
  class Node
8
28
  attr_accessor :param_name, :is_endpoint, :dynamic_child, :wildcard_child
9
29
  attr_reader :handlers, :static_children
10
30
 
31
+ include RubyRoutes::Utility::PathUtility
32
+ include RubyRoutes::Utility::MethodUtility
33
+
11
34
  def initialize
12
- @is_endpoint = false
13
- @handlers = {}
35
+ @is_endpoint = false
36
+ @handlers = {}
14
37
  @static_children = {}
15
- @dynamic_child = nil
16
- @wildcard_child = nil
17
- @param_name = nil
38
+ @dynamic_child = nil
39
+ @wildcard_child = nil
40
+ @param_name = nil
18
41
  end
19
42
 
43
+ # Register a handler under an HTTP method.
44
+ #
45
+ # @param method [String, Symbol]
46
+ # @param handler [Object] route or callable
47
+ # @return [Object] handler
20
48
  def add_handler(method, handler)
21
- method_str = normalize_method(method)
22
- @handlers[method_str] = handler
49
+ method_key = normalize_http_method(method)
50
+ @handlers[method_key] = handler
23
51
  @is_endpoint = true
52
+ handler
24
53
  end
25
54
 
55
+ # Fetch a handler for a method.
56
+ #
57
+ # @param method [String, Symbol]
58
+ # @return [Object, nil]
26
59
  def get_handler(method)
27
- @handlers[method]
60
+ @handlers[normalize_http_method(method)]
28
61
  end
29
62
 
30
- # Fast traversal method with minimal allocations and streamlined branching.
31
- # Matching order: static (most specific) → dynamic → wildcard (least specific)
32
- # Returns [next_node_or_nil, should_break_bool] where should_break indicates
33
- # wildcard capture that consumes remaining path segments.
63
+ # Traverses from this node using a single path segment.
64
+ # Returns [next_node_or_nil, stop_traversal(Boolean), captured_params(Hash)].
65
+ #
66
+ # Optimized + simplified (cyclomatic / perceived complexity, length).
67
+ #
68
+ # @param segment [String] the path segment to match
69
+ # @param index [Integer] the segment index in the full path
70
+ # @param segments [Array<String>] all path segments
71
+ # @param params [Hash] (unused in this method; mutation deferred)
72
+ # @return [Array] [next_node, stop, captured] or NO_TRAVERSAL_RESULT
34
73
  def traverse_for(segment, index, segments, params)
35
- # Try static child first (most specific) - O(1) hash lookup
36
- if @static_children.key?(segment)
37
- return [@static_children[segment], false]
38
- # Try dynamic child (parameter capture) - less specific than static
39
- elsif @dynamic_child
40
- # Capture parameter if params hash provided and param_name is set
41
- params[@dynamic_child.param_name] = segment if params && @dynamic_child.param_name
42
- return [@dynamic_child, false]
43
- # Try wildcard child (consumes remaining segments) - least specific
44
- elsif @wildcard_child
45
- # Capture remaining path segments for wildcard parameter
46
- if params && @wildcard_child.param_name
47
- remaining = segments[index..-1]
48
- params[@wildcard_child.param_name] = remaining.join('/')
49
- end
50
- return [@wildcard_child, true] # true signals to stop traversal
74
+ return [@static_children[segment], false, {}] if @static_children[segment]
75
+
76
+ if @dynamic_child
77
+ captured = capture_dynamic_param(@dynamic_child, segment)
78
+ return [@dynamic_child, false, captured]
79
+ end
80
+
81
+ if @wildcard_child
82
+ captured = capture_wildcard_param(@wildcard_child, segments, index)
83
+ return [@wildcard_child, true, captured]
51
84
  end
52
85
 
53
- # No match found at this node
54
- [nil, false]
86
+ RubyRoutes::Constant::NO_TRAVERSAL_RESULT
55
87
  end
56
88
 
57
89
  private
58
90
 
59
- # Fast method normalization - converts method to uppercase string
60
- def normalize_method(method)
61
- method.to_s.upcase
91
+ # Captures a dynamic parameter value and returns it for later assignment.
92
+ #
93
+ # @param dynamic_node [Node] the dynamic child node
94
+ # @param value [String] the segment value to capture
95
+ # @return [Hash] captured parameter hash or empty hash if no param
96
+ def capture_dynamic_param(dynamic_node, value)
97
+ return {} unless dynamic_node.param_name
98
+
99
+ { dynamic_node.param_name => value }
100
+ end
101
+
102
+ # Captures a wildcard parameter value and returns it for later assignment.
103
+ #
104
+ # @param wc_node [Node] the wildcard child node
105
+ # @param segments [Array<String>] the full path segments
106
+ # @param index [Integer] the current segment index
107
+ # @return [Hash] captured parameter hash or empty hash if no param
108
+ def capture_wildcard_param(wildcard_node, segments, index)
109
+ return {} unless wildcard_node.param_name
110
+
111
+ { wildcard_node.param_name => segments[index..].join('/') }
62
112
  end
63
113
  end
64
114
  end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../constant'
4
+
5
+ module RubyRoutes
6
+ class RadixTree
7
+ # Finder module for traversing the RadixTree and finding routes.
8
+ # Handles path normalization, segment traversal, and parameter extraction.
9
+ module Finder
10
+
11
+ # Evaluate constraint rules for a candidate route.
12
+ #
13
+ # @param route_handler [Object]
14
+ # @param captured_params [Hash]
15
+ # @return [Boolean]
16
+ def check_constraints(route_handler, captured_params)
17
+ return true unless route_handler.respond_to?(:validate_constraints_fast!)
18
+
19
+ begin
20
+ # Use a duplicate to avoid unintended mutation by validators.
21
+ route_handler.validate_constraints_fast!(captured_params)
22
+ true
23
+ rescue RubyRoutes::ConstraintViolation
24
+ false
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # Finds a route handler for the given path and HTTP method.
31
+ #
32
+ # @param path_input [String] the input path to match
33
+ # @param method_input [String, Symbol] the HTTP method
34
+ # @param params_out [Hash] optional output hash for captured parameters
35
+ # @return [Array] [handler, params] or [nil, params] if no match
36
+ def find(path_input, method_input, params_out = nil)
37
+ path = path_input.to_s
38
+ method = normalize_http_method(method_input)
39
+ return root_match(method, params_out) if path.empty? || path == RubyRoutes::Constant::ROOT_PATH
40
+
41
+ segments = split_path_cached(path)
42
+ return [nil, params_out || {}] if segments.empty?
43
+
44
+ params = params_out || {}
45
+ state = traversal_state
46
+ captured_params = {}
47
+
48
+ result = perform_traversal(segments, state, method, params, captured_params)
49
+ return result unless result.nil?
50
+
51
+ finalize_success(state, method, params, captured_params)
52
+ end
53
+
54
+ # Initializes the traversal state for route matching.
55
+ #
56
+ # @return [Hash] state hash with :current, :best_node, :best_params, :best_captured, :matched
57
+ def traversal_state
58
+ {
59
+ current: @root,
60
+ best_node: nil,
61
+ best_params: nil,
62
+ best_captured: nil,
63
+ matched: false # Track if any segment was successfully matched
64
+ }
65
+ end
66
+
67
+ # Performs traversal through path segments to find a matching route.
68
+ #
69
+ # @param segments [Array<String>] path segments
70
+ # @param state [Hash] traversal state
71
+ # @param method [String] normalized HTTP method
72
+ # @param params [Hash] parameters hash
73
+ # @param captured_params [Hash] hash to collect captured parameters
74
+ # @return [nil, Array] nil if traversal succeeds, Array from finalize_on_fail if traversal fails
75
+ def perform_traversal(segments, state, method, params, captured_params)
76
+ segments.each_with_index do |segment, index|
77
+ next_node, stop = traverse_for_segment(state[:current], segment, index, segments, params, captured_params)
78
+ return finalize_on_fail(state, method, params, captured_params) unless next_node
79
+
80
+ state[:current] = next_node
81
+ state[:matched] = true # Set matched to true if at least one segment matched
82
+ record_candidate(state, method, params, captured_params) if endpoint_with_method?(state[:current], method)
83
+ break if stop
84
+ end
85
+ nil # Return nil to indicate successful traversal
86
+ end
87
+
88
+ # Traverses to the next node for a given segment.
89
+ #
90
+ # @param node [Node] current node
91
+ # @param segment [String] current segment
92
+ # @param index [Integer] segment index
93
+ # @param segments [Array<String>] all segments
94
+ # @param params [Hash] parameters hash
95
+ # @param captured_params [Hash] hash to collect captured parameters
96
+ # @return [Array] [next_node, stop_traversal, segment_captured]
97
+ def traverse_for_segment(node, segment, index, segments, params, captured_params)
98
+ next_node, stop, segment_captured = node.traverse_for(segment, index, segments, params)
99
+ captured_params.merge!(segment_captured) if segment_captured
100
+ [next_node, stop]
101
+ end
102
+
103
+ # Records the current node as a candidate match.
104
+ #
105
+ # @param state [Hash] traversal state
106
+ # @param _method [String] HTTP method (unused)
107
+ # @param params [Hash] parameters hash
108
+ # @param captured_params [Hash] captured parameters from traversal
109
+ def record_candidate(state, _method, params, captured_params)
110
+ state[:best_node] = state[:current]
111
+ state[:best_params] = params.dup
112
+ state[:best_captured] = captured_params.dup
113
+ end
114
+
115
+ # Checks if the node is an endpoint with a handler for the method.
116
+ #
117
+ # @param node [Node] the node to check
118
+ # @param method [String] HTTP method
119
+ # @return [Boolean] true if endpoint and handler exists
120
+ def endpoint_with_method?(node, method)
121
+ node.is_endpoint && node.handlers[method]
122
+ end
123
+
124
+ # Finalizes the result when traversal fails mid-path.
125
+ #
126
+ # @param state [Hash] traversal state
127
+ # @param method [String] HTTP method
128
+ # @param params [Hash] parameters hash
129
+ # @param captured_params [Hash] captured parameters from traversal
130
+ # @return [Array] [handler, params] or [nil, params]
131
+ def finalize_on_fail(state, method, params, captured_params)
132
+ best_params = state[:best_params] || params
133
+ best_captured = state[:best_captured] || captured_params
134
+ finalize_match(state[:best_node], method, best_params, best_captured)
135
+ end
136
+
137
+ # Finalizes the result after successful traversal.
138
+ #
139
+ # @param state [Hash] traversal state
140
+ # @param method [String] HTTP method
141
+ # @param params [Hash] parameters hash
142
+ # @param captured_params [Hash] captured parameters from traversal
143
+ # @return [Array] [handler, params] or [nil, params]
144
+ def finalize_success(state, method, params, captured_params)
145
+ result = finalize_match(state[:current], method, params, captured_params)
146
+ return result if result[0]
147
+
148
+ # Try best candidate if current failed
149
+ if state[:best_node]
150
+ best_params = state[:best_params] || params
151
+ best_captured = state[:best_captured] || captured_params
152
+ finalize_match(state[:best_node], method, best_params, best_captured)
153
+ else
154
+ result
155
+ end
156
+ end
157
+
158
+ # Falls back to the best candidate if no exact match.
159
+ #
160
+ # @param state [Hash] traversal state
161
+ # @param method [String] HTTP method
162
+ # @param params [Hash] parameters hash
163
+ # @param captured_params [Hash] captured parameters from traversal
164
+ # @return [Array] [handler, params] or [nil, params]
165
+ def fallback_candidate(state, method, params, captured_params)
166
+ finalize_match(state[:best_node], method, state[:best_params], state[:best_captured])
167
+ end
168
+
169
+ # Common method to finalize a match attempt.
170
+ # Assumes the node is already validated as an endpoint.
171
+ #
172
+ # @param node [Node] the node to check for a handler
173
+ # @param method [String] HTTP method
174
+ # @param params [Hash] parameters hash
175
+ # @param captured_params [Hash] captured parameters from traversal
176
+ # @return [Array] [handler, params] or [nil, params]
177
+ def finalize_match(node, method, params, captured_params)
178
+ if node && endpoint_with_method?(node, method)
179
+ handler = node.handlers[method]
180
+ # Apply captured params before constraint validation
181
+ apply_captured_params(params, captured_params)
182
+ if check_constraints(handler, params)
183
+ return [handler, params]
184
+ end
185
+ end
186
+ # For non-matching paths, return nil
187
+ apply_captured_params(params, captured_params)
188
+ [nil, params]
189
+ end
190
+
191
+ # Handles matching for the root path.
192
+ #
193
+ # @param method [String] HTTP method
194
+ # @param params_out [Hash] parameters hash
195
+ # @return [Array] [handler, params] or [nil, params]
196
+ def root_match(method, params_out)
197
+ if @root.is_endpoint && (handler = @root.handlers[method])
198
+ [handler, params_out || {}]
199
+ else
200
+ [nil, params_out || {}]
201
+ end
202
+ end
203
+
204
+ # Applies captured parameters to the final params hash.
205
+ #
206
+ # @param params [Hash] the final parameters hash
207
+ # @param captured_params [Hash] captured parameters from traversal
208
+ def apply_captured_params(params, captured_params)
209
+ params.merge!(captured_params) if captured_params && !captured_params.empty?
210
+ end
211
+ end
212
+ end
213
+ end