mustermann 3.0.4 → 4.0.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 (60) 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 +128 -30
  5. data/lib/mustermann/ast/converters.rb +41 -0
  6. data/lib/mustermann/ast/expander.rb +4 -5
  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 +26 -4
  11. data/lib/mustermann/ast/transformer.rb +7 -0
  12. data/lib/mustermann/ast/translator.rb +11 -7
  13. data/lib/mustermann/composite.rb +25 -6
  14. data/lib/mustermann/concat.rb +16 -5
  15. data/lib/mustermann/error.rb +1 -0
  16. data/lib/mustermann/expander.rb +26 -4
  17. data/lib/mustermann/hybrid.rb +50 -0
  18. data/lib/mustermann/match.rb +155 -0
  19. data/lib/mustermann/pattern.rb +27 -32
  20. data/lib/mustermann/rails.rb +63 -0
  21. data/lib/mustermann/regexp_based.rb +70 -9
  22. data/lib/mustermann/router.rb +104 -0
  23. data/lib/mustermann/set/cache.rb +48 -0
  24. data/lib/mustermann/set/linear.rb +32 -0
  25. data/lib/mustermann/set/match.rb +23 -0
  26. data/lib/mustermann/set/strict_order.rb +29 -0
  27. data/lib/mustermann/set/trie.rb +270 -0
  28. data/lib/mustermann/set.rb +445 -0
  29. data/lib/mustermann/sinatra/safe_renderer.rb +1 -1
  30. data/lib/mustermann/sinatra/try_convert.rb +49 -11
  31. data/lib/mustermann/sinatra.rb +35 -11
  32. data/lib/mustermann/version.rb +1 -1
  33. data/lib/mustermann/versions.rb +47 -0
  34. data/lib/mustermann.rb +0 -15
  35. metadata +31 -45
  36. data/bench/capturing.rb +0 -57
  37. data/bench/regexp.rb +0 -21
  38. data/bench/simple_vs_sinatra.rb +0 -23
  39. data/bench/template_vs_addressable.rb +0 -26
  40. data/bench/uri_parser_object.rb +0 -16
  41. data/lib/mustermann/extension.rb +0 -3
  42. data/lib/mustermann/mapper.rb +0 -91
  43. data/lib/mustermann/pattern_cache.rb +0 -50
  44. data/lib/mustermann/simple_match.rb +0 -49
  45. data/lib/mustermann/to_pattern.rb +0 -51
  46. data/mustermann.gemspec +0 -18
  47. data/spec/ast_spec.rb +0 -15
  48. data/spec/composite_spec.rb +0 -163
  49. data/spec/concat_spec.rb +0 -127
  50. data/spec/equality_map_spec.rb +0 -42
  51. data/spec/expander_spec.rb +0 -123
  52. data/spec/identity_spec.rb +0 -127
  53. data/spec/mapper_spec.rb +0 -77
  54. data/spec/mustermann_spec.rb +0 -81
  55. data/spec/pattern_spec.rb +0 -54
  56. data/spec/regexp_based_spec.rb +0 -9
  57. data/spec/regular_spec.rb +0 -119
  58. data/spec/simple_match_spec.rb +0 -11
  59. data/spec/sinatra_spec.rb +0 -836
  60. data/spec/to_pattern_spec.rb +0 -70
@@ -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 = {})
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,48 @@
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
+ EMPTY_ARRAY = [].freeze
9
+
10
+ def self.new(matcher) = defined?(ObjectSpace::WeakKeyMap) ? super : matcher
11
+
12
+ def initialize(matcher)
13
+ @matcher = matcher
14
+ reset_cache
15
+ end
16
+
17
+ def add(pattern)
18
+ @matcher.add(pattern)
19
+ reset_cache
20
+ end
21
+
22
+ def match(string, all: false, peek: false)
23
+ cache = @match_cache[all][peek]
24
+ result = cache[string] ||= @matcher.match(string, all: all, peek: peek) || PLACEHOLDER
25
+ return result unless result.equal? PLACEHOLDER
26
+ all ? EMPTY_ARRAY : nil
27
+ end
28
+
29
+ def reset_cache
30
+ @match_cache = {
31
+ true => {
32
+ true => ObjectSpace::WeakKeyMap.new,
33
+ false => ObjectSpace::WeakKeyMap.new
34
+ },
35
+ false => {
36
+ true => ObjectSpace::WeakKeyMap.new,
37
+ false => ObjectSpace::WeakKeyMap.new
38
+ }
39
+ }
40
+ end
41
+
42
+ def optimize! = @matcher.optimize!
43
+ def track(...) = @matcher.track(...)
44
+ end
45
+
46
+ private_constant :Cache
47
+ end
48
+ 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,270 @@
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 { |leaf| leaf.patterns << t.pattern }
16
+ else
17
+ leaves.patterns << t.pattern
18
+ end
19
+ leaves
20
+ end
21
+
22
+ translate(:char) do |trie, **options|
23
+ return trie if payload.empty?
24
+ trie[payload]
25
+ end
26
+
27
+ translate(:optional) do |trie, **options|
28
+ [*t(payload, trie, **options), trie]
29
+ end
30
+
31
+ translate(Array) do |trie, **options|
32
+ i = 0
33
+ while i < size
34
+ element = self[i]
35
+ if element.is_a? :char or element.is_a? :separator
36
+ trie = t(element, trie, **options)
37
+ i += 1
38
+ elsif element.is_a? :splat and self[i + 1]&.is_a? :separator
39
+ # Compile splat+separator together so the splat is bounded by the separator,
40
+ # then continue building the trie for the remaining elements.
41
+ trie = trie[t.compile(self[i..i + 1])]
42
+ i += 2
43
+ elsif element.is_a? :splat or !self[i + 1]&.is_a? :separator
44
+ return trie[t.compile(self[i..-1])]
45
+ else
46
+ trie = t(element, trie, **options)
47
+ return trie.flat_map { |node| t(self[i + 1..-1], node, **options) } if trie.is_a? Array
48
+ i += 1
49
+ end
50
+ end
51
+ trie
52
+ end
53
+
54
+ attr_reader :pattern
55
+
56
+ def initialize(pattern)
57
+ @pattern = pattern
58
+ @compiler = pattern.compiler.new
59
+ @options = pattern.options
60
+ super()
61
+ end
62
+
63
+ # \G anchors to a position passed to String#match, avoiding substring allocation.
64
+ def compile(node, **options) = /\G#{@compiler.translate(node, **@options, **options)}/
65
+ end
66
+
67
+ attr_reader :patterns, :set, :static, :dynamic
68
+
69
+ def initialize(set, patterns = [])
70
+ @set = set
71
+ @patterns = []
72
+ @dynamic = {}
73
+ @static = {}
74
+ @stride = nil
75
+ @fast_static = nil
76
+ @byte_lookup = nil
77
+ @dynamic_entries = nil
78
+ patterns.each { |pattern| add(pattern) }
79
+ end
80
+
81
+ def [](key)
82
+ case key
83
+ when String then @static[key] ||= Trie.new(@set)
84
+ when Regexp then @dynamic[key] ||= Trie.new(@set)
85
+ end
86
+ end
87
+
88
+ def match(string, all: false, peek: false, position: 0, params: {})
89
+ optimize! if @stride.nil?
90
+ return build_matches(string, params, all:) if position >= string.size
91
+ result = [] if all
92
+
93
+ if @fast_static
94
+ stride = @stride
95
+ if node = @fast_static[string[position, stride]]
96
+ if nested_result = node.match(string, all:, peek:, position: position + stride, params:)
97
+ return nested_result unless all
98
+ result.concat(nested_result)
99
+ end
100
+ end
101
+ elsif @byte_lookup
102
+ if node = @byte_lookup[string.getbyte(position)]
103
+ if nested_result = node.match(string, all:, peek:, position: position + 1, params:)
104
+ return nested_result unless all
105
+ result.concat(nested_result)
106
+ end
107
+ end
108
+ end
109
+
110
+ unless @dynamic_entries.empty?
111
+ anchored = nil
112
+ base_params = all ? params : nil
113
+ @dynamic_entries.each do |matcher, node, capture_names, fast_name|
114
+ if fast_name
115
+ # Fast path: unconstrained single-segment capture — no regex, no MatchData.
116
+ end_pos = string.index('/', position) || string.size
117
+ next if end_pos == position
118
+ edge_params = all ? base_params.dup : params
119
+ edge_params[fast_name] = string.byteslice(position, end_pos - position)
120
+ nested_result = node.match(string, all:, params: edge_params, peek:, position: end_pos)
121
+ return nested_result unless all
122
+ result.concat(nested_result)
123
+ next
124
+ end
125
+
126
+ regexp_match = matcher.match(string, position)
127
+ # Non-greedy patterns (e.g. splat .*?) can match 0 chars on non-empty input, making
128
+ # no progress. Retry with an end-of-string anchor so they consume the full remainder.
129
+ if regexp_match && regexp_match.end(0) == position
130
+ anchored ||= {}
131
+ anchored_matcher = anchored[matcher] ||= Regexp.new(matcher.source + '\z')
132
+ regexp_match = anchored_matcher.match(string, position)
133
+ end
134
+ next unless regexp_match
135
+
136
+ edge_params = all ? base_params.dup : params
137
+ capture_names.each do |name|
138
+ value = regexp_match[name]
139
+ next unless value
140
+ existing = edge_params[name]
141
+ edge_params[name] = existing ? (existing.is_a?(Array) ? existing << value : [existing, value]) : value
142
+ end
143
+
144
+ nested_result = node.match(string, all:, params: edge_params, peek:, position: regexp_match.end(0))
145
+ return nested_result unless all
146
+ result.concat(nested_result)
147
+ end
148
+ end
149
+
150
+ if peek
151
+ matches = build_matches(string, params, all:, matched_length: position, post_match: string[position..], pre_match: '')
152
+ return matches unless all
153
+ result.concat(matches)
154
+ end
155
+
156
+ result
157
+ end
158
+
159
+ NIL_VALUES = [nil].freeze
160
+
161
+ def build_matches(string, params, all: false, matched_length: string.size, post_match: '', pre_match: '')
162
+ result = [] if all
163
+ matched = string[0, matched_length]
164
+
165
+ @patterns.each do |pattern|
166
+ next if pattern.except_regexp&.match?(matched)
167
+
168
+ pattern_params = build_pattern_params(pattern, params)
169
+
170
+ values = @set.values_for_pattern(pattern) || NIL_VALUES
171
+ values.each do |value|
172
+ match = Set::Match.new(pattern, string, matched:, params: pattern_params, value:, post_match:, pre_match:)
173
+ return match unless all
174
+ result << match
175
+ end
176
+ end
177
+
178
+ result
179
+ end
180
+
181
+ def build_pattern_params(pattern, params)
182
+ return params if pattern.identity_params?(params)
183
+
184
+ result = {}
185
+ params.each do |key, raw|
186
+ if raw.is_a?(Array)
187
+ val = raw.flat_map { |v| pattern.map_param(key, v) }
188
+ val = val.first if val.size < 2 && !pattern.always_array?(key)
189
+ else
190
+ val = pattern.map_param(key, raw)
191
+ val = [val] if pattern.always_array?(key)
192
+ end
193
+ result[key] = val
194
+ end
195
+ result
196
+ end
197
+
198
+ def add(pattern)
199
+ @stride = nil
200
+ @fast_static = nil
201
+ @byte_lookup = nil
202
+ @dynamic_entries = nil
203
+ Translator.new(pattern).translate(pattern.to_ast, self)
204
+ end
205
+
206
+ # Compacts the trie by replacing sequential single-char static lookups with a
207
+ # single stride-length hash lookup. The stride is the minimum number of static
208
+ # steps all paths from this node share before hitting a dynamic edge or branch.
209
+ def optimize!
210
+ depth = min_static_depth
211
+ if depth > 1
212
+ @fast_static = build_stride_hash(depth)
213
+ @byte_lookup = nil
214
+ @stride = depth
215
+ @fast_static.each_value(&:optimize!)
216
+ elsif @static.empty?
217
+ @fast_static = nil
218
+ @byte_lookup = nil
219
+ @stride = 1
220
+ # no children to recurse into
221
+ else
222
+ @fast_static = nil
223
+ @byte_lookup = Array.new(256)
224
+ @static.each { |k, v| @byte_lookup[k.getbyte(0)] = v }
225
+ @stride = 1
226
+ @static.each_value(&:optimize!)
227
+ end
228
+ @dynamic.each_value(&:optimize!)
229
+ @dynamic_entries = @dynamic.map do |matcher, node|
230
+ names = matcher.names.each(&:freeze)
231
+ # Detect unconstrained single-segment captures: can use fast string.index instead of regex.
232
+ # Two conditions: (1) the edge is a bare capture (source starts with \G(?<name>), no leading
233
+ # static chars) and (2) the capture's character class excludes '/' (PATH_INFO never has '?' or '#').
234
+ fast = if names.size == 1
235
+ name = names.first
236
+ matcher.source.start_with?("\\G(?<#{name}>") && !matcher.match?('/') ? name : nil
237
+ end
238
+ [matcher, node, names, fast]
239
+ end
240
+ end
241
+
242
+ protected
243
+
244
+ # Returns the minimum number of guaranteed static steps from this node across
245
+ # all possible paths, before encountering a dynamic edge, a terminal pattern,
246
+ # or an empty node. Branching is allowed; only the minimum depth matters.
247
+ def min_static_depth
248
+ return 0 if @dynamic.any?
249
+ return 0 if @patterns.any?
250
+ return 0 if @static.empty?
251
+ 1 + @static.values.map { |node| node.min_static_depth }.min
252
+ end
253
+
254
+ private
255
+
256
+ # Builds a hash whose keys are +stride+-character strings and whose values are
257
+ # the trie nodes reached after consuming exactly those characters.
258
+ def build_stride_hash(stride)
259
+ stride.times.reduce({ "" => self }) do |frontier, _|
260
+ frontier.each_with_object({}) do |(prefix, node), nxt|
261
+ node.static.each { |char, child| nxt[prefix + char] = child }
262
+ end
263
+ end
264
+ end
265
+
266
+ end
267
+
268
+ private_constant :Trie
269
+ end
270
+ end