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
@@ -29,6 +29,11 @@ module Mustermann
29
29
  @patterns = patterns.flat_map { |p| patterns_from(p, **options) }
30
30
  end
31
31
 
32
+ # @see Mustermann::Pattern#names
33
+ def names
34
+ @names ||= patterns.flat_map { |p| p.respond_to?(:names) ? p.names : [] }.uniq
35
+ end
36
+
32
37
  # @see Mustermann::Pattern#==
33
38
  def ==(pattern)
34
39
  patterns == patterns_from(pattern)
@@ -79,19 +84,33 @@ module Mustermann
79
84
  end
80
85
 
81
86
  # @return [String] the string representation of the pattern
82
- def to_s
83
- simple_inspect
84
- end
87
+ def to_s = inspect
85
88
 
86
89
  # @!visibility private
87
90
  def inspect
88
- "#<%p:%s>" % [self.class, simple_inspect]
91
+ "(#{simple_inspect})"
89
92
  end
90
93
 
91
94
  # @!visibility private
92
95
  def simple_inspect
93
- pattern_strings = patterns.map { |p| p.simple_inspect }
94
- "(#{pattern_strings.join(" #{operator} ")})"
96
+ patterns.map { |p| p.is_a?(Composite) ? p.inspect : p.simple_inspect }.join(" #{operator} ")
97
+ end
98
+
99
+ # @!visibility private
100
+ def pretty_print(q)
101
+ q.group(1, "(", ")") do
102
+ patterns.each_with_index do |pattern, index|
103
+ unless index == 0
104
+ q.text " #{operator}"
105
+ q.breakable " "
106
+ end
107
+ if pattern.is_a?(Composite)
108
+ q.pp pattern
109
+ else
110
+ q.text pattern.simple_inspect
111
+ end
112
+ end
113
+ end
95
114
  end
96
115
 
97
116
  # @!visibility private
@@ -14,8 +14,8 @@ module Mustermann
14
14
  concat = (self + patterns.inject(:+))
15
15
  concat + other.patterns.slice(patterns.length..-1).inject(:+)
16
16
  else
17
- return super unless native = native_concat(other)
18
- self.class.new(native, **options)
17
+ native, opts = native_concat(other)
18
+ native ? self.class.new(native, **options, **opts.to_h) : super
19
19
  end
20
20
  end
21
21
 
@@ -75,10 +75,21 @@ module Mustermann
75
75
 
76
76
  # @see Mustermann::Pattern#peek_match
77
77
  def peek_match(string)
78
- pump(string, initial: SimpleMatch.new) do |pattern, substring|
79
- return unless match = pattern.peek_match(substring)
80
- [match, match.to_s.size]
78
+ post_match = string
79
+ params = {}
80
+ captures = []
81
+ named_captures = {}
82
+
83
+ patterns.each do |pattern|
84
+ return unless part = pattern.peek_match(post_match)
85
+ params.merge!(part.params)
86
+ named_captures.merge!(part.named_captures)
87
+ captures.concat(part.captures)
88
+ post_match = post_match[part.to_s.size..-1]
81
89
  end
90
+
91
+ matched = string[0, string.size - post_match.size]
92
+ Match.new(self, string, matched:, params:, captures:, named_captures:, post_match:)
82
93
  end
83
94
 
84
95
  # @see Mustermann::Pattern#peek_params
@@ -5,5 +5,6 @@ module Mustermann
5
5
  CompileError = Class.new(Error) # Raised if anything goes wrong while compiling a {Pattern}.
6
6
  ParseError = Class.new(Error) # Raised if anything goes wrong while parsing a {Pattern}.
7
7
  ExpandError = Class.new(Error) # Raised if anything goes wrong while expanding a {Pattern}.
8
+ TrieError = Class.new(CompileError) # Raised if anything goes wrong while compiling a {Trie}.
8
9
  end
9
10
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
+ require 'mustermann'
2
3
  require 'mustermann/ast/expander'
3
4
  require 'mustermann/caster'
4
- require 'mustermann'
5
5
 
6
6
  module Mustermann
7
7
  # Allows fine-grained control over pattern expansion.
@@ -13,15 +13,16 @@ module Mustermann
13
13
  #
14
14
  # expander.expand(page_id: 58, format: :html5) # => "/pages/58?format=html5"
15
15
  class Expander
16
+ # @!visibility private
17
+ ADDITIONAL_VALUES = %i[raise ignore append].freeze
18
+
16
19
  attr_reader :patterns, :additional_values, :caster
17
20
 
18
21
  # @param [Array<#to_str, Mustermann::Pattern>] patterns list of patterns to expand, see {#add}.
19
22
  # @param [Symbol] additional_values behavior when encountering additional values, see {#expand}.
20
23
  # @param [Hash] options used when creating/expanding patterns, see {Mustermann.new}.
21
24
  def initialize(*patterns, additional_values: :raise, **options, &block)
22
- unless additional_values == :raise or additional_values == :ignore or additional_values == :append
23
- raise ArgumentError, "Illegal value %p for additional_values" % additional_values
24
- end
25
+ raise ArgumentError, "Illegal value %p for additional_values" % additional_values unless ADDITIONAL_VALUES.include? additional_values
25
26
 
26
27
  @patterns = []
27
28
  @api_expander = AST::Expander.new
@@ -42,6 +43,10 @@ module Mustermann
42
43
  # @return [Mustermann::Expander] the expander
43
44
  def add(*patterns)
44
45
  patterns.each do |pattern|
46
+ if pattern.is_a? Expander
47
+ add(*pattern.patterns)
48
+ next
49
+ end
45
50
  pattern = Mustermann.new(pattern, **@options)
46
51
  if block_given?
47
52
  @api_expander.add(yield(pattern))
@@ -154,6 +159,23 @@ module Mustermann
154
159
  end
155
160
  end
156
161
 
162
+ # @!visibility private
163
+ def inspect
164
+ return "#<#{self.class.name}>" if @patterns.empty?
165
+ "#<#{self.class.name}: #{@patterns.map { |p| p.to_s.inspect }.join(", ")}>"
166
+ end
167
+
168
+ # @!visibility private
169
+ def pretty_print(q)
170
+ q.text "#<#{self.class.name}"
171
+ q.group(1, "", ">") do
172
+ @patterns.each_with_index do |pattern, index|
173
+ q.breakable(index == 0 ? " " : ", ")
174
+ q.pp pattern.to_s
175
+ end
176
+ end
177
+ end
178
+
157
179
  # @see Object#==
158
180
  def ==(other)
159
181
  return false unless other.class == self.class
@@ -0,0 +1,50 @@
1
+ require 'mustermann/sinatra'
2
+
3
+ module Mustermann
4
+ # Hybrid pattern type that bridges {Mustermann::Sinatra} and Rails pattern syntax.
5
+ #
6
+ # It supports all syntax elements of {Mustermann::Sinatra}, plus URI template-style
7
+ # placeholders, and changes the semantics of parenthesized groups to match Rails:
8
+ #
9
+ # - A group *without* a pipe operator is **implicitly optional**, even without a
10
+ # trailing `?`. So `/foo(/bar)` matches both `/foo/bar` and `/foo`.
11
+ #
12
+ # - A group *with* a pipe operator is **not** implicitly optional, to avoid the
13
+ # ambiguity of `/scope/(a|b)` also matching `/scope/`. Add a trailing `?` to make
14
+ # such a group optional explicitly: `/scope/(a|b)?`.
15
+ #
16
+ # @example Implicit optional group (no pipe)
17
+ # require 'mustermann'
18
+ # pattern = Mustermann.new('/foo(/bar)', type: :hybrid)
19
+ # pattern === '/foo' # => true
20
+ # pattern === '/foo/bar' # => true
21
+ #
22
+ # @example Non-optional group with pipe
23
+ # pattern = Mustermann.new('/scope/(a|b)', type: :hybrid)
24
+ # pattern === '/scope/a' # => true
25
+ # pattern === '/scope/' # => false
26
+ #
27
+ # @example Explicitly optional group with pipe
28
+ # pattern = Mustermann.new('/scope/(a|b)?', type: :hybrid)
29
+ # pattern === '/scope/' # => true
30
+ #
31
+ # @example Nested implicit optional groups (Rails-style resource routing)
32
+ # pattern = Mustermann.new('/:controller(/:action(/:id))', type: :hybrid)
33
+ # pattern.params('/posts') # => { "controller" => "posts" }
34
+ # pattern.params('/posts/show') # => { "controller" => "posts", "action" => "show" }
35
+ # pattern.params('/posts/show/1') # => { "controller" => "posts", "action" => "show", "id" => "1" }
36
+ #
37
+ # @see Mustermann::Sinatra
38
+ class Hybrid < Sinatra
39
+ register :hybrid
40
+
41
+ # Parses a parenthesized group. Groups without a pipe operator are wrapped in an
42
+ # optional node (implicitly optional, Rails style). Groups that do contain a pipe
43
+ # operator are left as plain groups; append `?` to make them optional explicitly.
44
+ on("(") do |c|
45
+ n = node(:group) { read unless scan(?)) }
46
+ has_or = n.payload.any? { |e| e.is_a?(:or) }
47
+ has_or && !scan("?") ? n : node(:optional, n)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mustermann
4
+ # The return value of {Mustermann::Pattern#match}, {Mustermann::Pattern#peek_match}, {Mustermann::Set#match}, and similar methods.
5
+ # Mimics large parts of the MatchData API, but also provides access to the pattern and params hash.
6
+ class Match
7
+ # @return [Mustermann::Pattern] the pattern that produced the match
8
+ attr_reader :pattern
9
+
10
+ # @return [String] the string that was matched
11
+ attr_reader :string
12
+
13
+ # @return [Hash] the params hash
14
+ attr_reader :params
15
+
16
+ # @return [Array] the captures array
17
+ attr_reader :captures
18
+
19
+ # @return [Hash] the named captures hash, usually identical to {#params}
20
+ attr_reader :named_captures
21
+
22
+ # @return [String] the post match string
23
+ attr_reader :post_match
24
+
25
+ # @return [String] the pre match string
26
+ attr_reader :pre_match
27
+
28
+ # @return [Regexp, nil] the regular expression that produced the match, if available
29
+ attr_reader :regexp
30
+
31
+ # @overload initialize(pattern, string, **options)
32
+ # @param pattern [Mustermann::Pattern] the pattern that produced the match
33
+ # @param string [String] the string that was matched
34
+ #
35
+ # @overload initialize(match, **options)
36
+ # @param match [Mustermann::Match] the match to copy pattern and string from
37
+ #
38
+ # @overload initialize(pattern, match, **options)
39
+ # @param match [Mustermann::Match, MatchData] the match to copy string from
40
+ #
41
+ # @option options [Array] :captures the captures array
42
+ # @option options [Hash] :named_captures the named captures hash
43
+ # @option options [String] :matched the matched substring (defaults to string for full matches)
44
+ # @option options [Hash] :params the params hash
45
+ # @option options [Regexp] :regexp the regular expression that produced the match
46
+ # @option options [String] :post_match the post match string
47
+ # @option options [String] :pre_match the pre match string
48
+ def initialize(pattern_or_match, string_or_match = nil, matched: nil, params: nil, post_match: nil, pre_match: nil, captures: nil, named_captures: nil, regexp: nil)
49
+ case pattern_or_match
50
+ when Mustermann::Match, MatchData then match = pattern_or_match
51
+ when Mustermann::Pattern then pattern = pattern_or_match
52
+ else raise ArgumentError, "first argument must be a Mustermann::Pattern or a MatchData, not #{pattern_or_match.class}"
53
+ end
54
+
55
+ case string_or_match
56
+ when Mustermann::Match, MatchData then match ||= string_or_match
57
+ when String then string = string_or_match
58
+ when nil # ignore
59
+ else raise ArgumentError, "second argument must be a String or a MatchData, not #{string_or_match.class}"
60
+ end
61
+
62
+ @pattern = pattern || match&.pattern
63
+ @string = string || match&.string || ''
64
+ @params = params || match&.params || {}
65
+ @post_match = post_match || match&.post_match || ''
66
+ @pre_match = pre_match || match&.pre_match || ''
67
+ @captures = captures || match&.captures || @params.values
68
+ @named_captures = named_captures || match&.named_captures || @params
69
+ @matched = matched || match&.to_s || @string
70
+
71
+ unless @regexp = regexp
72
+ @regexp = match.regexp if match.respond_to?(:regexp)
73
+ @regexp ||= pattern.respond_to?(:regexp) ? pattern.regexp : nil
74
+ end
75
+ end
76
+
77
+ # @return [Array<String>] the names of the named captures
78
+ def names = named_captures.keys
79
+
80
+ # @overload [](key)
81
+ # Access named captures by key.
82
+ # @param key [String, Symbol] the key to access
83
+ # @return the value of the named capture, or nil if not found
84
+ #
85
+ # @overload [](index)
86
+ # Access captures by index.
87
+ # @param index [Integer] the index to access
88
+ # @return the value of the capture, or nil if not found
89
+ #
90
+ # @overload [](start, length)
91
+ # Access multiple captures by index and length.
92
+ # @param start [Integer] the starting index to access
93
+ # @param length [Integer] the number of captures to access
94
+ # @return [Array] the values of the captures
95
+ #
96
+ # @overload [](range)
97
+ # Access multiple captures by range.
98
+ # @param range [Range] the range of indices to access
99
+ # @return [Array] the values of the captures
100
+ def [](key, length = nil)
101
+ case key
102
+ when String then named_captures[key]
103
+ when Symbol then named_captures[key.to_s]
104
+ when Integer then length ? captures[key, length] : captures[key]
105
+ when Range then captures[key]
106
+ else raise ArgumentError, "key must be a String, Symbol, Integer, or Range, not #{key.class}"
107
+ end
108
+ end
109
+
110
+ # Deconstructs the match into a hash of the given keys. Useful for pattern matching.
111
+ # @param keys [Array] the keys to deconstruct
112
+ # @return [Hash] a hash of the given keys and their corresponding values
113
+ # @see https://docs.ruby-lang.org/en/4.0/syntax/pattern_matching_rdoc.html
114
+ def deconstruct_keys(keys) = keys.to_h { |key| [key, self[key]] }
115
+
116
+ # @see Object#hash
117
+ def hash = pattern.hash ^ string.hash ^ params.hash
118
+
119
+ # @see Object#eql?
120
+ def eql?(other)
121
+ return false unless other.is_a? self.class
122
+ pattern == other.pattern && string == other.string && params == other.params
123
+ end
124
+
125
+ # Returns the values of the given keys as an array.
126
+ # @params keys [Array<Symbol, String>] the keys to access
127
+ # @return [Array] the values of the given keys
128
+ def values_at(*keys) = keys.map { |key| self[key] }
129
+
130
+ # @return [String] the matched substring (like MatchData#to_s)
131
+ def to_s = @matched
132
+
133
+ alias == eql?
134
+ alias to_h params
135
+
136
+ # @!visibility private
137
+ def inspect
138
+ params_str = params.map { |k, v| " #{k}:#{v.inspect}" }.join
139
+ "#<#{self.class.name}: #{@matched.inspect}#{params_str}>"
140
+ end
141
+
142
+ # @!visibility private
143
+ def pretty_print(q)
144
+ q.group(1, "#<#{self.class.name}:", ">") do
145
+ q.breakable
146
+ q.pp @matched
147
+ params.each do |key, value|
148
+ q.breakable
149
+ q.text("#{key}:")
150
+ q.pp value
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'mustermann/error'
3
- require 'mustermann/simple_match'
3
+ require 'mustermann/match'
4
4
  require 'mustermann/equality_map'
5
5
  require 'uri'
6
6
 
@@ -84,14 +84,15 @@ module Mustermann
84
84
  end
85
85
 
86
86
  # @param [String] string The string to match against
87
- # @return [MatchData, Mustermann::SimpleMatch, nil] MatchData or similar object if the pattern matches.
88
- # @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-match Regexp#match
89
- # @see http://ruby-doc.org/core-2.0/MatchData.html MatchData
90
- # @see Mustermann::SimpleMatch
87
+ # @return [Mustermann::Match, nil] the match object if the pattern matches.
88
+ # @see Mustermann::Match
91
89
  def match(string)
92
- SimpleMatch.new(string) if self === string
90
+ Match.new(self, string) if self === string
93
91
  end
94
92
 
93
+ # @return [Array<String>] list of named captures in the pattern
94
+ def names = []
95
+
95
96
  # @param [String] string The string to match against
96
97
  # @return [Integer, nil] nil if pattern does not match the string, zero if it does.
97
98
  # @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-3D-7E Regexp#=~
@@ -110,7 +111,7 @@ module Mustermann
110
111
  # Used by Ruby internally for hashing.
111
112
  # @return [Integer] same has value for patterns that are equal
112
113
  def hash
113
- self.class.hash | @string.hash | options.hash
114
+ self.class.hash ^ @string.hash ^ options.hash
114
115
  end
115
116
 
116
117
  # Two patterns are considered equal if they are of the same type, have the same pattern string
@@ -163,11 +164,11 @@ module Mustermann
163
164
  # pattern.peek("/Frank/Sinatra") # => #<MatchData "/Frank" name:"Frank">
164
165
  #
165
166
  # @param [String] string The string to match against
166
- # @return [MatchData, Mustermann::SimpleMatch, nil] MatchData or similar object if the pattern matches.
167
+ # @return [Mustermann::Match, nil] MatchData or similar object if the pattern matches.
167
168
  # @see #peek_params
168
169
  def peek_match(string)
169
170
  matched = peek(string)
170
- match(matched) if matched
171
+ Match.new(self, string, matched:, params: {}, post_match: string[matched.size..-1]) if matched
171
172
  end
172
173
 
173
174
  # Tries to match the pattern against the beginning of the string (as opposed to the full string).
@@ -184,33 +185,12 @@ module Mustermann
184
185
  # @return [Array<Hash, Integer>, nil] Array with params hash and length of substing if matched, nil otherwise
185
186
  def peek_params(string)
186
187
  match = peek_match(string)
187
- [params(captures: match), match.to_s.size] if match
188
- end
189
-
190
- # @return [Hash{String: Array<Integer>}] capture names mapped to capture index.
191
- # @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-named_captures Regexp#named_captures
192
- def named_captures
193
- {}
194
- end
195
-
196
- # @return [Array<String>] capture names.
197
- # @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-names Regexp#names
198
- def names
199
- []
188
+ match ? [match.params, match.to_s.size] : nil
200
189
  end
201
190
 
202
191
  # @param [String] string the string to match against
203
192
  # @return [Hash{String: String, Array<String>}, nil] Sinatra style params if pattern matches.
204
- def params(string = nil, captures: nil, offset: 0)
205
- return unless captures ||= match(string)
206
- params = named_captures.map do |name, positions|
207
- values = positions.map { |pos| map_param(name, captures[pos + offset]) }.flatten
208
- values = values.first if values.size < 2 and not always_array? name
209
- [name, values]
210
- end
211
-
212
- Hash[params]
213
- end
193
+ def params(string = nil) = match(string)&.params
214
194
 
215
195
  # @note This method is only implemented by certain subclasses.
216
196
  #
@@ -362,6 +342,13 @@ module Mustermann
362
342
  method(method).owner != Mustermann::Pattern
363
343
  end
364
344
 
345
+ # @!visibility private
346
+ def pretty_print(q)
347
+ q.text "#<%p:" % self.class
348
+ q.pp(@string)
349
+ q.text ">"
350
+ end
351
+
365
352
  # @!visibility private
366
353
  def inspect
367
354
  "#<%p:%p>" % [self.class, @string]
@@ -381,6 +368,7 @@ module Mustermann
381
368
  # @!visibility private
382
369
  def unescape(string, decode = uri_decode)
383
370
  return string unless decode and string
371
+ return string unless string.include?('%')
384
372
  @@uri.unescape(string)
385
373
  end
386
374
 
@@ -392,6 +380,13 @@ module Mustermann
392
380
  ALWAYS_ARRAY.include? key
393
381
  end
394
382
 
383
+ # @api private
384
+ # Returns true if params can be used as-is without calling map_param.
385
+ # Used by Set::Trie to skip building a redundant copy of the params hash.
386
+ def identity_params?(params)
387
+ !params.any? { |k, v| v.is_a?(Array) || always_array?(k) || (v.respond_to?(:include?) && v.include?('%')) }
388
+ end
389
+
395
390
  private :unescape, :map_param, :respond_to_special?
396
391
  private_constant :ALWAYS_ARRAY
397
392
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+ require 'mustermann'
3
+ require 'mustermann/ast/pattern'
4
+ require 'mustermann/ast/fast_pattern'
5
+ require 'mustermann/versions'
6
+
7
+ module Mustermann
8
+ # Rails style pattern implementation.
9
+ #
10
+ # @example
11
+ # Mustermann.new('/:foo', type: :rails) === '/bar' # => true
12
+ #
13
+ # @see Mustermann::Pattern
14
+ # @see file:README.md#rails Syntax description in the README
15
+ class Rails < AST::Pattern
16
+ include AST::FastPattern
17
+ extend Versions
18
+ register :rails
19
+
20
+ # first parser, no optional parts
21
+ version('2.3') do
22
+ on(nil) { |c| unexpected(c) }
23
+ on(?*) { |c| node(:named_splat) { scan(/\w+/) } }
24
+ on(?:) { |c| node(:capture) { scan(/\w+/) } }
25
+ end
26
+
27
+ # rack-mount
28
+ version('3.0', '3.1') do
29
+ on(?)) { |c| unexpected(c) }
30
+ on(?() { |c| node(:optional, node(:group) { read unless scan(?)) }) }
31
+ on(?\\) { |c| node(:char, expect(/./)) }
32
+ end
33
+
34
+ # stand-alone journey
35
+ version('3.2') do
36
+ on(?|) { |c| raise ParseError, "the implementation of | is broken in ActionDispatch, cannot compile compatible pattern" }
37
+ on(?\\) { |c| node(:char, c) }
38
+ end
39
+
40
+ # embedded journey, broken (ignored) escapes
41
+ version('4.0', '4.1') { on(?\\) { |c| read } }
42
+
43
+ # escapes got fixed in 4.2
44
+ version('4.2') { on(?\\) { |c| node(:char, expect(/./)) } }
45
+
46
+ # Rails 5.0 fixes |
47
+ version('5', '6', '7', '8') { on(?|) { |c| node(:or) }}
48
+
49
+ # (see Mustermann::Pattern#|)
50
+ def |(other) = combine(other, :|) { super }
51
+
52
+ # (see Mustermann::Pattern#+)
53
+ def +(other) = combine(other, :+) { super }
54
+
55
+ private
56
+
57
+ def combine(other, operator)
58
+ return yield unless hybrid = Mustermann[:hybrid].try_convert(self, **options)
59
+ native = hybrid.public_send(operator, other)
60
+ native.is_a?(Composite) ? yield : native
61
+ end
62
+ end
63
+ end
@@ -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 = /\A#{regexp}/
26
+ @regexp = /\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