mustermann 3.0.4 → 4.0.1

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -2
  3. data/README.md +238 -261
  4. data/lib/mustermann/ast/compiler.rb +137 -29
  5. data/lib/mustermann/ast/converters.rb +41 -0
  6. data/lib/mustermann/ast/expander.rb +6 -7
  7. data/lib/mustermann/ast/fast_pattern.rb +122 -0
  8. data/lib/mustermann/ast/param_scanner.rb +38 -6
  9. data/lib/mustermann/ast/parser.rb +2 -3
  10. data/lib/mustermann/ast/pattern.rb +29 -6
  11. data/lib/mustermann/ast/template_generator.rb +2 -2
  12. data/lib/mustermann/ast/transformer.rb +7 -0
  13. data/lib/mustermann/ast/translator.rb +14 -5
  14. data/lib/mustermann/ast/validation.rb +1 -1
  15. data/lib/mustermann/composite.rb +26 -7
  16. data/lib/mustermann/concat.rb +17 -6
  17. data/lib/mustermann/error.rb +1 -0
  18. data/lib/mustermann/expander.rb +28 -6
  19. data/lib/mustermann/hybrid.rb +50 -0
  20. data/lib/mustermann/identity.rb +1 -1
  21. data/lib/mustermann/match.rb +155 -0
  22. data/lib/mustermann/pattern.rb +30 -35
  23. data/lib/mustermann/rails.rb +63 -0
  24. data/lib/mustermann/regexp_based.rb +70 -9
  25. data/lib/mustermann/regular.rb +1 -1
  26. data/lib/mustermann/router.rb +104 -0
  27. data/lib/mustermann/set/cache.rb +47 -0
  28. data/lib/mustermann/set/linear.rb +32 -0
  29. data/lib/mustermann/set/match.rb +23 -0
  30. data/lib/mustermann/set/strict_order.rb +29 -0
  31. data/lib/mustermann/set/trie.rb +282 -0
  32. data/lib/mustermann/set.rb +447 -0
  33. data/lib/mustermann/sinatra/safe_renderer.rb +1 -1
  34. data/lib/mustermann/sinatra/try_convert.rb +49 -11
  35. data/lib/mustermann/sinatra.rb +35 -11
  36. data/lib/mustermann/version.rb +1 -1
  37. data/lib/mustermann/versions.rb +47 -0
  38. data/lib/mustermann.rb +38 -12
  39. metadata +31 -45
  40. data/bench/capturing.rb +0 -57
  41. data/bench/regexp.rb +0 -21
  42. data/bench/simple_vs_sinatra.rb +0 -23
  43. data/bench/template_vs_addressable.rb +0 -26
  44. data/bench/uri_parser_object.rb +0 -16
  45. data/lib/mustermann/extension.rb +0 -3
  46. data/lib/mustermann/mapper.rb +0 -91
  47. data/lib/mustermann/pattern_cache.rb +0 -50
  48. data/lib/mustermann/simple_match.rb +0 -49
  49. data/lib/mustermann/to_pattern.rb +0 -51
  50. data/mustermann.gemspec +0 -18
  51. data/spec/ast_spec.rb +0 -15
  52. data/spec/composite_spec.rb +0 -163
  53. data/spec/concat_spec.rb +0 -127
  54. data/spec/equality_map_spec.rb +0 -42
  55. data/spec/expander_spec.rb +0 -123
  56. data/spec/identity_spec.rb +0 -127
  57. data/spec/mapper_spec.rb +0 -77
  58. data/spec/mustermann_spec.rb +0 -81
  59. data/spec/pattern_spec.rb +0 -54
  60. data/spec/regexp_based_spec.rb +0 -9
  61. data/spec/regular_spec.rb +0 -119
  62. data/spec/simple_match_spec.rb +0 -11
  63. data/spec/sinatra_spec.rb +0 -836
  64. data/spec/to_pattern_spec.rb +0 -70
@@ -11,14 +11,37 @@ module Mustermann
11
11
  attr_reader :regexp
12
12
  alias_method :to_regexp, :regexp
13
13
 
14
+ # @api private
15
+ supported_options :cache
16
+
14
17
  # @param (see Mustermann::Pattern#initialize)
15
18
  # @return (see Mustermann::Pattern#initialize)
16
19
  # @see (see Mustermann::Pattern#initialize)
17
20
  def initialize(string, **options)
21
+ cache = options.delete(:cache) { true }
22
+
18
23
  super
19
- regexp = compile(**options)
20
- @peek_regexp = /\A#{regexp}/
21
- @regexp = /\A#{regexp}\Z/
24
+ regexp = compile(**options)
25
+ @peek_regexp = Mustermann.dedup(/\A#{regexp}/)
26
+ @regexp = Mustermann.dedup(/\A#{regexp}\Z/)
27
+ @simple_captures = @regexp.named_captures.none? { |name, positions| positions.size > 1 || always_array?(name) }
28
+
29
+ cache_class = ObjectSpace::WeakKeyMap if defined?(ObjectSpace::WeakKeyMap)
30
+
31
+ case cache
32
+ when true
33
+ @match_cache = cache_class&.new || false
34
+ @peek_cache = cache_class&.new || false
35
+ when false
36
+ @match_cache = false
37
+ @peek_cache = false
38
+ when Hash
39
+ @match_cache = cache[:match] || cache_class&.new || false
40
+ @peek_cache = cache[:peek] || cache_class&.new || false
41
+ else
42
+ @match_cache = cache.new
43
+ @peek_cache = cache.new
44
+ end
22
45
  end
23
46
 
24
47
  # @param (see Mustermann::Pattern#peek_size)
@@ -32,17 +55,55 @@ module Mustermann
32
55
  # @param (see Mustermann::Pattern#peek_match)
33
56
  # @return (see Mustermann::Pattern#peek_match)
34
57
  # @see (see Mustermann::Pattern#peek_match)
35
- def peek_match(string)
36
- @peek_regexp.match(string)
58
+ def peek_match(string) = cache_match(@peek_cache, @peek_regexp, string)
59
+
60
+ # @param (see Mustermann::Pattern#match)
61
+ # @return (see Mustermann::Pattern#match)
62
+ # @see (see Mustermann::Pattern#match)
63
+ def match(string) = cache_match(@match_cache, @regexp, string)
64
+
65
+ # Extracts params directly from the regexp without allocating a Match object or
66
+ # populating the match cache — significant GC savings when called in hot loops.
67
+ # @param (see Mustermann::Pattern#params)
68
+ # @return (see Mustermann::Pattern#params)
69
+ def params(string = nil)
70
+ return unless md = @regexp.match(string)
71
+ build_params(md)
37
72
  end
38
73
 
39
74
  extend Forwardable
40
- def_delegators :regexp, :===, :=~, :match, :names, :named_captures
75
+ def_delegators :regexp, :===, :=~, :names
76
+
77
+ private
78
+
79
+ def cache_match(cache, regexp, string)
80
+ if cache
81
+ return cache[string] if cache.key?(string)
82
+ cache[string] = build_match(regexp, string)
83
+ else
84
+ build_match(regexp, string)
85
+ end
86
+ end
87
+
88
+ def build_match(regexp, string)
89
+ return unless match = regexp.match(string)
90
+ Match.new(self, match, params: build_params(match))
91
+ end
41
92
 
42
- def compile(**options)
43
- raise NotImplementedError, 'subclass responsibility'
93
+ def build_params(match)
94
+ if @simple_captures
95
+ params = match.named_captures
96
+ return params if params.empty? || identity_params?(params)
97
+ params.each_with_object({}) { |(k, v), h| h[k] = map_param(k, v) }
98
+ else
99
+ match.regexp.named_captures.to_h do |name, positions|
100
+ value = positions.size < 2 && !always_array?(name) ? map_param(name, match[name]) :
101
+ positions.flat_map { |pos| map_param(name, match[pos]) }
102
+ [name, value]
103
+ end
104
+ end
44
105
  end
45
106
 
46
- private :compile
107
+ def compile(**options) = raise NotImplementedError, 'subclass responsibility'
47
108
  end
48
109
  end
@@ -21,7 +21,7 @@ module Mustermann
21
21
  # @see (see Mustermann::Pattern#initialize)
22
22
  def initialize(string, check_anchors: true, **options)
23
23
  string = $1 if string.to_s =~ /\A\(\?\-mix\:(.*)\)\Z/ && string.inspect == "/#$1/"
24
- string = string.source.gsub!(/(?<!\\)(?:\s|#.*$)/, '') if extended_regexp?(string)
24
+ string = string.source.gsub!(/(?<!\\)(?:\s|#.*$)/, EMPTY_STRING) if extended_regexp?(string)
25
25
  @check_anchors = check_anchors
26
26
  super(string, **options)
27
27
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+ require 'mustermann'
3
+ require 'mustermann/set'
4
+
5
+ module Mustermann
6
+ # An extremely simple, Rack-compatible router implementation using {Mustermann::Set} for pattern matching.
7
+ #
8
+ # @example Basic usage
9
+ # require 'mustermann/router'
10
+ #
11
+ # router = Mustermann::Router.new do
12
+ # get "/hello/:name" do |env|
13
+ # name = env["mustermann.match"][:name]
14
+ # [200, { "content-type" => "text/plain" }, ["Hello, #{name}!"]]
15
+ # end
16
+ # end
17
+ #
18
+ # # in config.ru
19
+ # use Rack::Head
20
+ # run router
21
+ #
22
+ # @example Routing to other applications
23
+ # router = Mustermann::Router.new do
24
+ # get "/users", MyApp::Users::Index
25
+ # get "/users/:id", MyApp::Users::Show
26
+ # post "/users", MyApp::Users::Create
27
+ # fallback MyApp::NotFound
28
+ # end
29
+ #
30
+ # router.path_for(MyApp::Users::Show, id: 42) # => "/users/42"
31
+ #
32
+ # @example As middleware
33
+ # use Mustermann::Router do
34
+ # get("/up") { [200, { "content-type" => "text/plain" }, ["Up!"]] }
35
+ # end
36
+ #
37
+ # run MyApp
38
+ #
39
+ # @see Mustermann::Set
40
+ # @see https://rack.github.io/rack/
41
+ class Router
42
+ NOT_FOUND = [404, { "content-type" => "text/plain", "x-cascade" => "pass" }, ["Not found"]].freeze
43
+ VERBS = %w[GET POST PUT PATCH DELETE OPTIONS LINK UNLINK].freeze
44
+ private_constant :VERBS, :NOT_FOUND
45
+
46
+ # Initializes a new router.
47
+ # @param key [String] The key under which the route match will be stored in the Rack environment hash (default: "mustermann.match").
48
+ # @param options [Hash] Options to be passed to the Mustermann patterns.
49
+ def initialize(fallback = nil, key: "mustermann.match", **options, &block)
50
+ @key = key
51
+ @sets = VERBS.to_h { |verb| [verb, Set.new(**options)] }
52
+ @fallback = fallback || ->(env) { NOT_FOUND.dup }
53
+
54
+ if block_given?
55
+ instance_exec(&block)
56
+ @sets.each_value(&:optimize!)
57
+ end
58
+ end
59
+
60
+ # @param env [Hash] The Rack environment hash for the request.
61
+ # @return [Array] The Rack response array (status, headers, body).
62
+ def call(env)
63
+ request_method = env["REQUEST_METHOD"] || "GET"
64
+ request_method = "GET" if request_method == "HEAD"
65
+ if routes = @sets[request_method] and match = routes.match(env["PATH_INFO"] || "/")
66
+ env[@key] = match
67
+ return match.value.call(env)
68
+ end
69
+ @fallback.call(env)
70
+ end
71
+
72
+ def fallback(fallback = nil, &block) = @fallback = fallback || block || @fallback
73
+
74
+ # Adds a route for the given verb and pattern, with the given target.
75
+ #
76
+ # @note Shorthand methods, like `get`, `post`, etc. dynamically are defined for all supported verbs.
77
+ #
78
+ # @param verb [String] HTTP verb (e.g. "GET", "POST")
79
+ # @param pattern [String, Mustermann::Pattern] Pattern string or Mustermann pattern (e.g. "/users/:id")
80
+ # @param target [#call, nil] The Rack application or middleware to call when the route matches. Can be passed a block as well.
81
+ # @yield [env] Block to be used as the target if no explicit target is given.
82
+ # @yieldparam env [Hash] The Rack environment hash for the request.
83
+ # @return [void]
84
+ def route(verb, pattern, target = nil, **options, &block)
85
+ raise ArgumentError, "need to provide target, :to or a block" unless target || block
86
+ raise ArgumentError, "unknown verb: #{verb}" unless VERBS.include?(verb)
87
+ @sets[verb].add(pattern, target || block)
88
+ end
89
+
90
+ # Helps generate links
91
+ #
92
+ # @param app [#call] The Rack application or middleware for which to generate the path.
93
+ # @param (see Mustermann::Expander#expand)
94
+ # @return [String] The generated path.
95
+ def path_for(app, behavior = nil, params = EMPTY_HASH)
96
+ set = @sets.values.find { |s| s.has_value?(app) } || @sets[VERBS.first]
97
+ set.expand(app, behavior, params)
98
+ end
99
+
100
+ VERBS.each do |verb|
101
+ define_method(verb.downcase) { |*args, **opts, &block| route(verb, *args, **opts, &block) }
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+ require 'mustermann/equality_map'
3
+
4
+ module Mustermann
5
+ class Set
6
+ class Cache
7
+ PLACEHOLDER = Object.new.freeze
8
+
9
+ def self.new(matcher) = defined?(ObjectSpace::WeakKeyMap) ? super : matcher
10
+
11
+ def initialize(matcher)
12
+ @matcher = matcher
13
+ reset_cache
14
+ end
15
+
16
+ def add(pattern)
17
+ @matcher.add(pattern)
18
+ reset_cache
19
+ end
20
+
21
+ def match(string, all: false, peek: false)
22
+ cache = @match_cache[all][peek]
23
+ result = cache[string] ||= @matcher.match(string, all: all, peek: peek) || PLACEHOLDER
24
+ return result unless result.equal? PLACEHOLDER
25
+ all ? EMPTY_ARRAY : nil
26
+ end
27
+
28
+ def reset_cache
29
+ @match_cache = {
30
+ true => {
31
+ true => ObjectSpace::WeakKeyMap.new,
32
+ false => ObjectSpace::WeakKeyMap.new
33
+ },
34
+ false => {
35
+ true => ObjectSpace::WeakKeyMap.new,
36
+ false => ObjectSpace::WeakKeyMap.new
37
+ }
38
+ }
39
+ end
40
+
41
+ def optimize! = @matcher.optimize!
42
+ def track(...) = @matcher.track(...)
43
+ end
44
+
45
+ private_constant :Cache
46
+ end
47
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+ require 'mustermann/set/match'
3
+
4
+ module Mustermann
5
+ class Set
6
+ class Linear
7
+ def initialize(set, patterns = [])
8
+ @set = set
9
+ @patterns = patterns
10
+ end
11
+
12
+ def add(pattern)
13
+ @patterns << pattern
14
+ end
15
+
16
+ def match(string, all: false, peek: false)
17
+ result = [] if all
18
+ @patterns.each do |pattern|
19
+ next unless match = peek ? pattern.peek_match(string) : pattern.match(string)
20
+ return Match.new(match, value: @set.values_for_pattern(pattern)&.first) unless all
21
+ values = @set.values_for_pattern(pattern) || [nil]
22
+ values.each { |value| result << Match.new(match, value:) }
23
+ end
24
+ result
25
+ end
26
+
27
+ def optimize! = nil
28
+ end
29
+
30
+ private_constant :Linear
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ require 'mustermann/match'
3
+
4
+ module Mustermann
5
+ class Set
6
+
7
+ # Subclass of {Mustermann::Match} that also includes the value associated with the pattern that produced the match.
8
+ class Match < Mustermann::Match
9
+ # @return the value associated with the pattern that produced the match, if any
10
+ attr_reader :value
11
+
12
+ # (see Mustermann::Match#initialize)
13
+ # @option options [Object] :value the value associated with the pattern that produced the match, if any
14
+ def initialize(*, value: nil, **)
15
+ @value = value
16
+ super(*, **)
17
+ end
18
+
19
+ # @see Mustermann::Match#eql?
20
+ def eql?(other) = super && value == other.value
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mustermann
4
+ class Set
5
+ class StrictOrder
6
+ def initialize(matcher)
7
+ @matcher = matcher
8
+ @order = {}
9
+ @count = 0
10
+ end
11
+
12
+ def add(...) = @matcher.add(...)
13
+ def optimize! = @matcher.optimize!
14
+
15
+ def match(string, all: false, peek: false)
16
+ possible = @matcher.match(string, all: true, peek: peek)
17
+ possible.sort_by! { |m| @order.dig(m.pattern, m.value) }
18
+ all ? possible : possible.first
19
+ end
20
+
21
+ def track(pattern, value)
22
+ @order[pattern] ||= {}
23
+ @order[pattern][value] = @count += 1
24
+ end
25
+ end
26
+
27
+ private_constant :StrictOrder
28
+ end
29
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+ require 'mustermann/ast/translator'
3
+ require 'mustermann/set/match'
4
+
5
+ module Mustermann
6
+ class Set
7
+ class Trie
8
+ class Translator < AST::Translator
9
+ translate(:node) { |trie, **o| trie[t.compile(node)] }
10
+ translate(:separator) { |trie, **options| trie[payload] }
11
+
12
+ translate(:root) do |trie, **options|
13
+ leaves = t(payload, trie, **options)
14
+ if leaves.is_a? Array
15
+ leaves.each do |leaf|
16
+ leaf.patterns ||= []
17
+ leaf.patterns << t.pattern
18
+ end
19
+ else
20
+ leaves.patterns ||= []
21
+ leaves.patterns << t.pattern
22
+ end
23
+ leaves
24
+ end
25
+
26
+ translate(:char) do |trie, **options|
27
+ return trie if payload.empty?
28
+ trie[payload]
29
+ end
30
+
31
+ translate(:optional) do |trie, **options|
32
+ [*t(payload, trie, **options), trie]
33
+ end
34
+
35
+ translate(Array) do |trie, **options|
36
+ i = 0
37
+ while i < size
38
+ element = self[i]
39
+ if element.is_a? :char or element.is_a? :separator
40
+ trie = t(element, trie, **options)
41
+ i += 1
42
+ elsif element.is_a? :splat and self[i + 1]&.is_a? :separator
43
+ # Compile splat+separator together so the splat is bounded by the separator,
44
+ # then continue building the trie for the remaining elements.
45
+ trie = trie[t.compile(self[i..i + 1])]
46
+ i += 2
47
+ elsif element.is_a? :splat or !self[i + 1]&.is_a? :separator
48
+ return trie[t.compile(self[i..-1])]
49
+ else
50
+ trie = t(element, trie, **options)
51
+ return trie.flat_map { |node| t(self[i + 1..-1], node, **options) } if trie.is_a? Array
52
+ i += 1
53
+ end
54
+ end
55
+ trie
56
+ end
57
+
58
+ attr_reader :pattern
59
+
60
+ def initialize(pattern)
61
+ @pattern = pattern
62
+ @compiler = pattern.compiler.new
63
+ @options = pattern.options
64
+ super()
65
+ end
66
+
67
+ # \G anchors to a position passed to String#match, avoiding substring allocation.
68
+ def compile(node, **options) = /\G#{@compiler.translate(node, **@options, **options)}/
69
+ end
70
+
71
+ attr_accessor :patterns, :set, :static, :dynamic
72
+
73
+ def initialize(set, patterns = nil)
74
+ @set = set
75
+ @patterns = nil
76
+ @dynamic = nil
77
+ @static = nil
78
+ @stride = nil
79
+ @fast_static = nil
80
+ @byte_lookup = nil
81
+ @dynamic_entries = nil
82
+ patterns&.each { |pattern| add(pattern) }
83
+ end
84
+
85
+ def [](key)
86
+ case key = Mustermann.dedup(key)
87
+ when String
88
+ @static ||= {}
89
+ @static[key] ||= Trie.new(@set)
90
+ when Regexp
91
+ @dynamic ||= {}
92
+ @dynamic[key] ||= Trie.new(@set)
93
+ end
94
+ end
95
+
96
+ def match(string, all: false, peek: false, position: 0, params: {})
97
+ optimize! if @stride.nil?
98
+ return build_matches(string, params, all:) if position >= string.size
99
+ result = [] if all
100
+
101
+ if @fast_static
102
+ stride = @stride
103
+ if node = @fast_static[string[position, stride]]
104
+ if nested_result = node.match(string, all:, peek:, position: position + stride, params:)
105
+ return nested_result unless all
106
+ result.concat(nested_result)
107
+ end
108
+ end
109
+ elsif @byte_lookup
110
+ if node = @byte_lookup[string.getbyte(position)]
111
+ if nested_result = node.match(string, all:, peek:, position: position + 1, params:)
112
+ return nested_result unless all
113
+ result.concat(nested_result)
114
+ end
115
+ end
116
+ end
117
+
118
+ if @dynamic_entries&.any?
119
+ base_params = all ? params : nil
120
+ @dynamic_entries.each do |matcher, node, capture_names, fast_name|
121
+ if fast_name
122
+ # Fast path: unconstrained single-segment capture — no regex, no MatchData.
123
+ end_pos = string.index('/', position) || string.size
124
+ next if end_pos == position
125
+ edge_params = all ? base_params.dup : params
126
+ edge_params[fast_name] = string.byteslice(position, end_pos - position)
127
+ nested_result = node.match(string, all:, params: edge_params, peek:, position: end_pos)
128
+ return nested_result unless all
129
+ result.concat(nested_result)
130
+ next
131
+ end
132
+
133
+ regexp_match = matcher.match(string, position)
134
+ # Non-greedy patterns (e.g. splat .*?) can match 0 chars on non-empty input, making
135
+ # no progress. Retry with an end-of-string anchor so they consume the full remainder.
136
+ if regexp_match && regexp_match.end(0) == position
137
+ @@anchored ||= {}
138
+ anchored_matcher = @@anchored[matcher] ||= Mustermann.dedup(Regexp.new(matcher.source + '\z'))
139
+ regexp_match = anchored_matcher.match(string, position)
140
+ end
141
+ next unless regexp_match
142
+
143
+ edge_params = all ? base_params.dup : params
144
+ capture_names.each do |name|
145
+ value = regexp_match[name]
146
+ next unless value
147
+ existing = edge_params[name]
148
+ edge_params[name] = existing ? (existing.is_a?(Array) ? existing << value : [existing, value]) : value
149
+ end
150
+
151
+ nested_result = node.match(string, all:, params: edge_params, peek:, position: regexp_match.end(0))
152
+ return nested_result unless all
153
+ result.concat(nested_result)
154
+ end
155
+ end
156
+
157
+ if peek
158
+ matches = build_matches(string, params, all:, matched_length: position, post_match: string[position..], pre_match: EMPTY_STRING)
159
+ return matches unless all
160
+ result.concat(matches)
161
+ end
162
+
163
+ result
164
+ end
165
+
166
+ NIL_VALUES = [nil].freeze
167
+
168
+ def build_matches(string, params, all: false, matched_length: string.size, post_match: EMPTY_STRING, pre_match: EMPTY_STRING)
169
+ result = [] if all
170
+ matched = string[0, matched_length]
171
+
172
+ @patterns&.each do |pattern|
173
+ next if pattern.except_regexp&.match?(matched)
174
+
175
+ pattern_params = build_pattern_params(pattern, params)
176
+
177
+ values = @set.values_for_pattern(pattern) || NIL_VALUES
178
+ values.each do |value|
179
+ match = Set::Match.new(pattern, string, matched:, params: pattern_params, value:, post_match:, pre_match:)
180
+ return match unless all
181
+ result << match
182
+ end
183
+ end
184
+
185
+ result
186
+ end
187
+
188
+ def build_pattern_params(pattern, params)
189
+ return params if pattern.identity_params?(params)
190
+
191
+ result = {}
192
+ params.each do |key, raw|
193
+ if raw.is_a?(Array)
194
+ val = raw.flat_map { |v| pattern.map_param(key, v) }
195
+ val = val.first if val.size < 2 && !pattern.always_array?(key)
196
+ else
197
+ val = pattern.map_param(key, raw)
198
+ val = [val] if pattern.always_array?(key)
199
+ end
200
+ result[key] = val
201
+ end
202
+ result
203
+ end
204
+
205
+ def add(pattern)
206
+ @stride = nil
207
+ @fast_static = nil
208
+ @byte_lookup = nil
209
+ @dynamic_entries = nil
210
+ Translator.new(pattern).translate(pattern.to_ast, self)
211
+ end
212
+
213
+ # Compacts the trie by replacing sequential single-char static lookups with a
214
+ # single stride-length hash lookup. The stride is the minimum number of static
215
+ # steps all paths from this node share before hitting a dynamic edge or branch.
216
+ def optimize!
217
+ depth = min_static_depth
218
+ if depth > 1
219
+ @fast_static = build_stride_hash(depth)
220
+ @byte_lookup = nil
221
+ @stride = depth
222
+ @fast_static.each_value(&:optimize!)
223
+ elsif !@static&.any?
224
+ @fast_static = nil
225
+ @byte_lookup = nil
226
+ @stride = 1
227
+ # no children to recurse into
228
+ else
229
+ @fast_static = nil
230
+ @byte_lookup = Array.new(256)
231
+ @stride = 1
232
+ @static&.each { |k, v| @byte_lookup[k.getbyte(0)] = v }
233
+ @static&.each_value(&:optimize!)
234
+ end
235
+
236
+ @dynamic&.each_value(&:optimize!)
237
+ @dynamic_entries = @dynamic&.map do |matcher, node|
238
+ names = matcher.names.each(&:freeze)
239
+ # Detect unconstrained single-segment captures: can use fast string.index instead of regex.
240
+ # Two conditions: (1) the edge is a bare capture (source starts with \G(?<name>), no leading
241
+ # static chars) and (2) the capture's character class excludes '/' (PATH_INFO never has '?' or '#').
242
+ fast = if names.size == 1
243
+ name = names.first
244
+ matcher.source.start_with?("\\G(?<#{name}>") && !matcher.match?('/') ? name : nil
245
+ end
246
+ [matcher, node, names, fast]
247
+ end
248
+
249
+ @fast_static = Mustermann.dedup(@fast_static)
250
+ @dynamic_entries = Mustermann.dedup(@dynamic_entries)
251
+ @byte_lookup = Mustermann.dedup(@byte_lookup)
252
+ end
253
+
254
+ protected
255
+
256
+ # Returns the minimum number of guaranteed static steps from this node across
257
+ # all possible paths, before encountering a dynamic edge, a terminal pattern,
258
+ # or an empty node. Branching is allowed; only the minimum depth matters.
259
+ def min_static_depth
260
+ return 0 if @dynamic&.any?
261
+ return 0 if @patterns&.any?
262
+ return 0 if @static&.empty?
263
+ 1 + @static.values.map { |node| node.min_static_depth }.min
264
+ end
265
+
266
+ private
267
+
268
+ # Builds a hash whose keys are +stride+-character strings and whose values are
269
+ # the trie nodes reached after consuming exactly those characters.
270
+ def build_stride_hash(stride)
271
+ stride.times.reduce({ EMPTY_STRING => self }) do |frontier, _|
272
+ frontier.each_with_object({}) do |(prefix, node), nxt|
273
+ node.static.each { |char, child| nxt[prefix + char] = child }
274
+ end
275
+ end
276
+ end
277
+
278
+ end
279
+
280
+ private_constant :Trie
281
+ end
282
+ end