mustermann 4.0.0.alpha4 → 4.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c45d32932b0745c20363ab0fb0084eb861754482e3c47ed68684cf245f10100f
4
- data.tar.gz: edb3fbe54217319a9a059b2a57c74b4e8f7d84cc4721fdb9a78bbf5ed6afa113
3
+ metadata.gz: cc7122898bc258ae00b79d44d7f81fb78e95da49f2c80000c1d04c69dc68ed7f
4
+ data.tar.gz: bc039a0b69440d1fc07253be7575f752938476386f08237c8984d9f21145b5ee
5
5
  SHA512:
6
- metadata.gz: 39c217af2266a0dc8f647dafd1c5772cf10e6f921a9d8ccabdcd29357a1a3dc9991065221f12eacc473af1ca183ac8bbd14dc8eae2d83834b21c81ac55e8ffc4
7
- data.tar.gz: f9a2c84244115819b62737f222b3c181ca5c5192e83243387473b3bf07666be8e019a78e591861e55a93579c28ab9e0f63725feddc0b6a61ff01f6f736c29735
6
+ metadata.gz: 955b763efdad19885a42b27e706509f024329b9ff96707d17198407428fe75b531295cd01dc0e5f2a38aae2458fcd6c2180af98dbe71038e4d24f74a6a1fa67e
7
+ data.tar.gz: cbc49d4686b678c0f11898c4f5bd91d31d975551bbc6de1ba418de3be1ee3c6a093a4e5caa12edbd3e7bfdb1cfb9239d98b0ecc0fb5d68fa287f8e8f403242e7
data/README.md CHANGED
@@ -42,7 +42,7 @@ pattern.params('/a/b.c') # => { "prefix" => "a", splat => ["b", "c"] }
42
42
  These features are included in the library, but not loaded by default
43
43
 
44
44
  * **[Pattern Set](#-pattern-set):** A collection of patterns with associated values, designed for building routing tables that dispatch efficiently as the number of routes grows.
45
- * **Mustermann::Router:** A very basic rack router built on top of `Mustermann::Set` for demonstration purposes. Simple and fast.
45
+ * **Mustermann::Router:** A very basic rack router built on top of `Mustermann::Set` for demonstration purposes or small-footprint routing in Rack middleware. Simple and fast.
46
46
 
47
47
  <a name="-pattern-types"></a>
48
48
  ## Pattern Types
@@ -120,7 +120,7 @@ require 'mustermann'
120
120
 
121
121
  pattern = Mustermann.new('/:page')
122
122
  pattern.match('/') # => nil
123
- pattern.match('/home') # => #<MatchData "/home" page:"home">
123
+ pattern.match('/home') # => #<Mustermann::Match>
124
124
  pattern =~ '/home' # => 0
125
125
  pattern === '/home' # => true (this allows using it in case statements)
126
126
 
@@ -595,6 +595,99 @@ Mustermann.new('/:id.:ext', capture: { id: /\d+/, ext: ['png', 'jpg'] })
595
595
 
596
596
  Available POSIX character classes are: `:alnum`, `:alpha`, `:blank`, `:cntrl`, `:digit`, `:graph`, `:lower`, `:print`, `:punct`, `:space`, `:upper`, `:xdigit`, `:word` and `:ascii`.
597
597
 
598
+ #### Typed Captures
599
+
600
+ Certain Ruby classes and named symbols can be passed as a capture value. They constrain what the capture matches **and** automatically convert the captured string in `params` to the appropriate type.
601
+
602
+ ``` ruby
603
+ require 'mustermann'
604
+ require 'date'
605
+
606
+ # Integer: only matches integers, converts to Integer in params
607
+ pattern = Mustermann.new('/:id', capture: Integer)
608
+ pattern.match('/42') # matches
609
+ pattern.match('/foo') # does not match
610
+ pattern.params('/42') # => { "id" => 42 }
611
+
612
+ # Float: matches integers and decimals, converts to Float in params
613
+ pattern = Mustermann.new('/:price', capture: Float)
614
+ pattern.params('/3.14') # => { "price" => 3.14 }
615
+ pattern.params('/5') # => { "price" => 5.0 }
616
+
617
+ # Symbol: only matches word characters (\w+), converts to Symbol in params
618
+ pattern = Mustermann.new('/:format', capture: Symbol)
619
+ pattern.params('/json') # => { "format" => :json }
620
+ pattern.match('/with-hyphen') # does not match
621
+
622
+ # Date: only matches YYYY-MM-DD dates, converts to Date in params
623
+ pattern = Mustermann.new('/:date', capture: Date)
624
+ pattern.params('/2026-04-23') # => { "date" => #<Date: 2026-04-23> }
625
+ pattern.match('/04-23-2026') # does not match
626
+
627
+ # Gem::Version: matches version strings, converts to Gem::Version in params
628
+ require 'rubygems/version'
629
+ pattern = Mustermann.new('/:version', capture: Gem::Version)
630
+ pattern.params('/1.2.3') # => { "version" => #<Gem::Version "1.2.3"> }
631
+ ```
632
+
633
+ Lowercase symbol aliases are also available: `:integer`, `:float`, `:symbol`, `:date`, `:version`. They behave identically to their class counterparts:
634
+
635
+ ``` ruby
636
+ pattern = Mustermann.new('/:id', capture: :integer)
637
+ pattern.params('/42') # => { "id" => 42 }
638
+ ```
639
+
640
+ These can be mixed with other capture types in a hash:
641
+
642
+ ``` ruby
643
+ pattern = Mustermann.new('/:id(.:format)?', capture: { id: Integer, format: :slug })
644
+ pattern.params('/42') # => { "id" => 42, "format" => nil }
645
+ pattern.params('/42.json') # => { "id" => 42, "format" => "json" }
646
+ ```
647
+
648
+ Like all other capture types, these can also be used in an array:
649
+
650
+ ``` ruby
651
+ pattern = Mustermann.new('/score/:score', capture: [Integer, Float])
652
+ pattern.params('/42') # => { "score" => 42 }
653
+ pattern.params('/3.14') # => { "score" => 3.14 }
654
+ ```
655
+
656
+ #### Other Symbols
657
+
658
+ The following symbols constrain the capture with a regex but do **not** perform any type conversion — `params` still returns a string:
659
+
660
+ | Symbol | Matches |
661
+ |-----------|---------|
662
+ | `:locale` | BCP 47 language tags (`en`, `en-US`, `zh-Hans-CN`) |
663
+ | `:slug` | Lowercase URL slugs (`hello-world`, `foo-bar-baz`) |
664
+ | `:uuid` | UUIDs (`f47ac10b-58cc-4372-a567-0e02b2c3d479`, case-insensitive) |
665
+
666
+ ``` ruby
667
+ Mustermann.new('/:lang', capture: :locale).match('/zh-Hans-CN') # matches
668
+ Mustermann.new('/:slug', capture: :slug).match('/Hello') # does not match
669
+ Mustermann.new('/:id', capture: :uuid).match('/not-a-uuid') # does not match
670
+ ```
671
+
672
+ Again, these can be mixed with other capture types in a hash or array:
673
+
674
+ ```ruby
675
+ set = Mustermann::Set.new(capture: { id: [Integer, :uuid], locale: :locale })
676
+
677
+ set.add("(/:locale)?/:id", :show)
678
+ set.add("/(:locale)?", :index)
679
+
680
+ # without capture constraints, this would match the :show pattern instead
681
+ match = set.match('/en')
682
+ match.value # => :index
683
+
684
+ match = set.match('/f47ac10b-58cc-4372-a567-0e02b2c3d479')
685
+ match.value # => :show
686
+
687
+ match = set.match('/en/12')
688
+ match.value # => :show
689
+ ```
690
+
598
691
  <a name="-available-options--except"></a>
599
692
  ### `except`
600
693
 
@@ -630,10 +723,10 @@ Semi-greedy behavior is not specific to dots, it works with all characters or st
630
723
 
631
724
  ``` ruby
632
725
  pattern = Mustermann.new(':a.:b', greedy: true)
633
- pattern.match('a.b.c.d') # => #<MatchData a:"a.b.c" b:"d">
726
+ pattern.match('a.b.c.d') # => #<Mustermann::Match>
634
727
 
635
728
  pattern = Mustermann.new(':a.:b', greedy: false)
636
- pattern.match('a.b.c.d') # => #<MatchData a:"a" b:"b.c.d">
729
+ pattern.match('a.b.c.d') # => #<Mustermann::Match>
637
730
  ```
638
731
 
639
732
  <a name="-available-options--space_matches_plus"></a>
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'mustermann/ast/translator'
4
+ require 'mustermann/ast/converters'
4
5
 
5
6
  module Mustermann
6
7
  # @see Mustermann::AST::Pattern
@@ -99,46 +100,48 @@ module Mustermann
99
100
  when Hash then from_hash(capture, **options)
100
101
  when String then from_string(capture, **options)
101
102
  when nil then from_nil(**options)
102
- else capture
103
+ when Regexp then capture
104
+ when Class then from_class(capture, **options)
105
+ else raise CompileError, "invalid capture constraint %p for %p" % [capture, name]
103
106
  end
104
107
  end
105
108
 
106
109
  private
107
110
 
108
- def qualified(string, greedy: true,
109
- **options) "#{string}#{qualifier || "+#{'?' unless greedy}"}"
111
+ def qualified(string, greedy: true, **options)
112
+ "#{string}#{qualifier || "+#{'?' unless greedy}"}"
110
113
  end
111
114
 
112
- def with_lookahead(string, lookahead: nil,
113
- **options) lookahead ? "(?:(?!#{lookahead})#{string})" : string
115
+ def with_lookahead(string, lookahead: nil, **options)
116
+ lookahead ? "(?:(?!#{lookahead})#{string})" : string
114
117
  end
115
118
 
116
- def from_hash(hash,
117
- **options) pattern(capture: hash[name.to_sym],
118
- **options)
119
+ def from_hash(hash, **options)
120
+ pattern(capture: hash[name.to_sym], **options)
119
121
  end
120
122
 
121
123
  def from_array(array, **options)
122
- Regexp.union(*array.map do |e|
123
- pattern(capture: e, **options)
124
- end)
124
+ Regexp.union(*array.map { |e| pattern(capture: e, **options) })
125
125
  end
126
126
 
127
- def from_symbol(symbol,
128
- **options) qualified(with_lookahead("[[:#{symbol}:]]", **options),
129
- **options)
127
+ def from_symbol(symbol, **options)
128
+ capture, _ = CONVERTERS[symbol]
129
+ return pattern(capture:, **options) if capture
130
+ qualified(with_lookahead("[[:#{symbol}:]]", **options), **options)
130
131
  end
131
132
 
132
133
  def from_string(string, **options)
133
- Regexp.new(string.chars.map do |c|
134
- t.encoded(c, **options)
135
- end.join)
134
+ Regexp.new(string.chars.map { |c| t.encoded(c, **options) }.join)
136
135
  end
137
136
 
138
137
  def from_nil(**options)
139
- qualified(
140
- with_lookahead(default(**options), **options), **options
141
- )
138
+ qualified(with_lookahead(default(**options), **options), **options)
139
+ end
140
+
141
+ def from_class(klass, **options)
142
+ capture, _ = CONVERTERS[klass.name]
143
+ raise CompileError, "no converter for class %p" % klass unless capture
144
+ pattern(capture:, **options)
142
145
  end
143
146
 
144
147
  def default(**options) = constraint || '[^/\\?#]'
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+ require "rubygems/version"
3
+ require "date"
4
+
5
+ module Mustermann
6
+ module AST
7
+ CONVERTERS = {
8
+ "Integer" => [ /-?\d+/, :to_i ],
9
+ "Symbol" => [ /\w+/, :to_sym ],
10
+ "String" => [ nil, :to_s ],
11
+ "Float" => [ /-?\d+(?:\.\d+)?/, :to_f ],
12
+
13
+ "Date" => [
14
+ /\d{4}-\d{2}-\d{2}/,
15
+ ->(string) { Date.parse(string) }
16
+ ],
17
+
18
+ "Gem::Version" => [
19
+ Regexp.new(Gem::Version::VERSION_PATTERN),
20
+ ->(string) { Gem::Version.new(string) }
21
+ ],
22
+
23
+ locale: [ /(?:[A-Za-z]{2,3}|i)(-[A-Za-z0-9]{1,8})*/ ],
24
+ slug: [ /[a-z0-9]+(?:-[a-z0-9]+)*/ ],
25
+ uuid: [ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i ],
26
+ }
27
+
28
+ CONVERTERS.merge!({
29
+ integer: CONVERTERS["Integer"],
30
+ symbol: CONVERTERS["Symbol"],
31
+ string: CONVERTERS["String"],
32
+ float: CONVERTERS["Float"],
33
+ date: CONVERTERS["Date"],
34
+ version: CONVERTERS["Gem::Version"],
35
+ })
36
+
37
+ CONVERTERS.freeze
38
+
39
+ private_constant :CONVERTERS
40
+ end
41
+ end
@@ -26,17 +26,6 @@ module Mustermann
26
26
 
27
27
  private_constant :SIMPLE, :ENCODED, :SEGMENT_SCAN
28
28
 
29
- # Bypasses the generic build_match overhead for simple patterns: uses
30
- # MatchData#named_captures directly and avoids match.to_s / post_match /
31
- # pre_match calls (all no-ops for \A…\Z anchored regexps).
32
- def match(string)
33
- return super unless @fast_match
34
- return unless match = @regexp.match(string)
35
- params = match.named_captures
36
- params.transform_values! { |v| unescape(v) } if string.include?('%')
37
- Match.new(self, match, params:)
38
- end
39
-
40
29
  # Public override: fast path for simple patterns, falls through to super otherwise.
41
30
  # Must remain public to match AST::Pattern#to_ast visibility.
42
31
  def to_ast
@@ -46,8 +35,24 @@ module Mustermann
46
35
  ast
47
36
  end
48
37
 
38
+ def params(string = nil)
39
+ return super unless @fast_match
40
+ return unless md = @regexp.match(string)
41
+ result = md.named_captures
42
+ result.transform_values! { |v| v.include?('%') ? unescape(v) : v } if string.include?('%')
43
+ result
44
+ end
45
+
49
46
  private
50
47
 
48
+ def build_match(regexp, string)
49
+ return super unless @fast_match
50
+ return unless match = regexp.match(string)
51
+ params = match.named_captures
52
+ params.transform_values! { |v| v.include?('%') ? unescape(v) : v } if string.include?('%')
53
+ Match.new(self, match, params: params)
54
+ end
55
+
51
56
  def simple_pattern?
52
57
  options[:capture].nil? &&
53
58
  options[:except].nil? &&
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require 'mustermann/ast/converters'
2
3
  require 'mustermann/ast/translator'
3
4
 
4
5
  module Mustermann
@@ -8,14 +9,45 @@ module Mustermann
8
9
  # @see Mustermann::AST::Pattern#to_templates
9
10
  class ParamScanner < Translator
10
11
  # @!visibility private
11
- def self.scan_params(ast)
12
- new.translate(ast)
12
+ def self.scan_params(ast, options)
13
+ new.translate(ast, options)
13
14
  end
14
15
 
15
- translate(:node) { t(payload) }
16
- translate(Array) { map { |e| t(e) }.inject(:merge) }
17
- translate(Object) { {} }
18
- translate(:capture) { convert ? { name => convert } : {} }
16
+ translate(:node) { |o| t(payload, o) }
17
+ translate(:with_look_ahead) { |o| t(head, o).merge(t(payload, o)) }
18
+ translate(Array) { |o| map { |e| t(e, o) }.inject(:merge) }
19
+ translate(Object) { |o| {} }
20
+
21
+ class Capture < NodeTranslator
22
+ register :capture
23
+
24
+ def translate(options)
25
+ return { name => convert } if convert
26
+ _, converter = converter(options[:capture])
27
+ converter ? { name => converter } : {}
28
+ end
29
+
30
+ def converter(capture)
31
+ case capture
32
+ when Hash then return converter(capture[name.to_sym])
33
+ when Class then regexp, converter = CONVERTERS[capture.name]
34
+ when Symbol then regexp, converter = CONVERTERS[capture]
35
+ when Array
36
+ entries = capture.map { |item| converter(item) }.compact
37
+ regexp = Regexp.union(entries.map(&:first))
38
+
39
+ entries.map! { |r, c| [/\A#{r}\Z/, c] }
40
+
41
+ converter = ->(string) do
42
+ _, c = entries.find { |r, _| r.match?(string) }
43
+ c&.call(string) || string
44
+ end
45
+ end
46
+
47
+ return unless converter
48
+ [regexp, converter.to_proc]
49
+ end
50
+ end
19
51
  end
20
52
  end
21
53
  end
@@ -101,13 +101,13 @@ module Mustermann
101
101
  # Internal AST representation of pattern.
102
102
  # @!visibility private
103
103
  def to_ast
104
- ast = self.class.ast_cache.fetch(@string) do
104
+ ast = self.class.ast_cache.fetch([@string, options]) do
105
105
  ast = parse(@string, pattern: self)
106
106
  ast &&= transform(ast)
107
107
  ast &&= set_boundaries(ast, string: @string)
108
108
  validate(ast)
109
109
  end
110
- @param_converters ||= scan_params(ast) if ast
110
+ @param_converters ||= scan_params(ast, options) if ast
111
111
  ast
112
112
  end
113
113
 
@@ -138,12 +138,13 @@ module Mustermann
138
138
  # @see Mustermann::Pattern#map_param
139
139
  def map_param(key, value)
140
140
  return super unless param_converters.include? key
141
- param_converters[key][super]
141
+ converted = super
142
+ converted.nil? ? converted : param_converters[key][converted]
142
143
  end
143
144
 
144
145
  # @!visibility private
145
146
  def param_converters
146
- @param_converters ||= scan_params(to_ast)
147
+ @param_converters ||= scan_params(to_ast, options)
147
148
  end
148
149
 
149
150
  # @api private
@@ -22,6 +22,13 @@ module Mustermann
22
22
  node
23
23
  end
24
24
 
25
+ # eliminate redundant optional nodes - this helps avoid regexp warnings
26
+ translate(:optional) do
27
+ return t(payload) if payload.is_a? Node[:optional]
28
+ node.payload = t(payload)
29
+ node
30
+ end
31
+
25
32
  # ignore unknown objects on the tree
26
33
  translate(Object) { node }
27
34
 
@@ -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
 
@@ -159,6 +159,23 @@ module Mustermann
159
159
  end
160
160
  end
161
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
+
162
179
  # @see Object#==
163
180
  def ==(other)
164
181
  return false unless other.class == self.class
@@ -74,10 +74,13 @@ module Mustermann
74
74
  end
75
75
  end
76
76
 
77
+ # @return [Array<String>] the names of the named captures
78
+ def names = named_captures.keys
79
+
77
80
  # @overload [](key)
78
- # Access params by key.
81
+ # Access named captures by key.
79
82
  # @param key [String, Symbol] the key to access
80
- # @return the value of the param, or nil if not found
83
+ # @return the value of the named capture, or nil if not found
81
84
  #
82
85
  # @overload [](index)
83
86
  # Access captures by index.
@@ -96,8 +99,8 @@ module Mustermann
96
99
  # @return [Array] the values of the captures
97
100
  def [](key, length = nil)
98
101
  case key
99
- when String then params[key]
100
- when Symbol then params[key.to_s]
102
+ when String then named_captures[key]
103
+ when Symbol then named_captures[key.to_s]
101
104
  when Integer then length ? captures[key, length] : captures[key]
102
105
  when Range then captures[key]
103
106
  else raise ArgumentError, "key must be a String, Symbol, Integer, or Range, not #{key.class}"
@@ -129,5 +132,24 @@ module Mustermann
129
132
 
130
133
  alias == eql?
131
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
132
154
  end
133
155
  end
@@ -90,6 +90,9 @@ module Mustermann
90
90
  Match.new(self, string) if self === string
91
91
  end
92
92
 
93
+ # @return [Array<String>] list of named captures in the pattern
94
+ def names = []
95
+
93
96
  # @param [String] string The string to match against
94
97
  # @return [Integer, nil] nil if pattern does not match the string, zero if it does.
95
98
  # @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-3D-7E Regexp#=~
@@ -339,6 +342,13 @@ module Mustermann
339
342
  method(method).owner != Mustermann::Pattern
340
343
  end
341
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
+
342
352
  # @!visibility private
343
353
  def inspect
344
354
  "#<%p:%p>" % [self.class, @string]
@@ -45,5 +45,19 @@ module Mustermann
45
45
 
46
46
  # Rails 5.0 fixes |
47
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
48
62
  end
49
63
  end
@@ -11,15 +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/
22
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
23
45
  end
24
46
 
25
47
  # @param (see Mustermann::Pattern#peek_size)
@@ -33,20 +55,38 @@ module Mustermann
33
55
  # @param (see Mustermann::Pattern#peek_match)
34
56
  # @return (see Mustermann::Pattern#peek_match)
35
57
  # @see (see Mustermann::Pattern#peek_match)
36
- def peek_match(string) = build_match(@peek_regexp.match(string))
58
+ def peek_match(string) = cache_match(@peek_cache, @peek_regexp, string)
37
59
 
38
60
  # @param (see Mustermann::Pattern#match)
39
61
  # @return (see Mustermann::Pattern#match)
40
62
  # @see (see Mustermann::Pattern#match)
41
- def match(string) = build_match(@regexp.match(string))
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)
72
+ end
42
73
 
43
74
  extend Forwardable
44
75
  def_delegators :regexp, :===, :=~, :names
45
76
 
46
77
  private
47
78
 
48
- def build_match(match)
49
- return unless match
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)
50
90
  Match.new(self, match, params: build_params(match))
51
91
  end
52
92
 
@@ -185,6 +185,11 @@ module Mustermann
185
185
  # @return [self]
186
186
  # @raise [ArgumentError] if the pattern is not AST-based, or if a reserved symbol is used as a value
187
187
  def add(pattern, *values)
188
+ if pattern.is_a? Composite and pattern.operator == :|
189
+ pattern.patterns.each { |p| add(p, *values) }
190
+ return self
191
+ end
192
+
188
193
  pattern = Mustermann.new(pattern, **options)
189
194
  raise ArgumentError, "Non-AST patterns are not supported" unless pattern.respond_to? :to_ast
190
195
 
@@ -380,6 +385,38 @@ module Mustermann
380
385
  # Runs trie optimizations pro-actively and explicitly rather than at match time.
381
386
  def optimize! = @matcher&.optimize!
382
387
 
388
+ # @!visibility private
389
+ def inspect # :nodoc:
390
+ mapping = @mapping.map do |pattern, values|
391
+ if values.size > 1 or values.first.is_a? Array
392
+ "%p => %p" % [pattern.to_s, values]
393
+ elsif values.first != nil
394
+ "%p => %p" % [pattern.to_s, values.first]
395
+ else
396
+ "%p" % pattern.to_s
397
+ end
398
+ end
399
+ "#<#{self.class.name}: #{mapping.join(", ")}>"
400
+ end
401
+
402
+ # @!visibility private
403
+ def pretty_print(q) # :nodoc:
404
+ q.text "#<#{self.class.name}"
405
+ q.group(1, "", ">") do
406
+ @mapping.each_with_index do |(pattern, values), index|
407
+ q.breakable(index == 0 ? " " : ", ")
408
+ q.pp(pattern.to_s)
409
+ if values.size > 1 or values.first.is_a? Array
410
+ q.text " => "
411
+ q.pp(values)
412
+ elsif values.first != nil
413
+ q.text " => "
414
+ q.pp(values.first)
415
+ end
416
+ end
417
+ end
418
+ end
419
+
383
420
  protected
384
421
 
385
422
  attr_reader :mapping
@@ -6,24 +6,34 @@ module Mustermann
6
6
  class TryConvert < AST::Translator
7
7
  # @return [Mustermann::Sinatra, nil]
8
8
  # @!visibility private
9
- def self.convert(input, **options)
10
- new(options).translate(input)
9
+ def self.convert(type, input, **options)
10
+ new(type, **options).translate(input)
11
11
  end
12
12
 
13
+ # Reserved variable names.
14
+ # @!visibility private
15
+ attr_reader :names
16
+
13
17
  # Expected options for the resulting pattern.
14
18
  # @!visibility private
15
19
  attr_reader :options
16
20
 
21
+ # Expected pattern type for the resulting pattern.
22
+ # @!visibility private
23
+ attr_reader :type
24
+
17
25
  # @!visibility private
18
- def initialize(options)
26
+ def initialize(type, names: [], **options)
27
+ @names = names
19
28
  @options = options
29
+ @type = type
20
30
  end
21
31
 
22
32
  # @return [Mustermann::Sinatra]
23
33
  # @!visibility private
24
- def new(input, escape = false)
34
+ def new(input, escape: false, **opts)
25
35
  input = Mustermann::Sinatra.escape(input) if escape
26
- Mustermann::Sinatra.new(input, **options)
36
+ type.new(input, **opts, **options, ignore_unknown_options: true)
27
37
  end
28
38
 
29
39
  # @return [true, false] whether or not expected pattern should have uri_decode option set
@@ -32,15 +42,43 @@ module Mustermann
32
42
  options.fetch(:uri_decode, true)
33
43
  end
34
44
 
35
- translate(Object) { nil }
36
- translate(String) { t.new(self, true) }
45
+ # @return [true, false] whether or not the given options are compatible with the expected options
46
+ # @!visibility private
47
+ def compatible_options?(other_options)
48
+ other_options.all? do |key, value|
49
+ case key
50
+ when :capture then compatible_capture_option?(value)
51
+ else value == options[key]
52
+ end
53
+ end
54
+ end
37
55
 
38
- translate(Identity) { t.new(self, true) if uri_decode == t.uri_decode }
39
- translate(Sinatra) { node if options == t.options }
56
+ # @return [true, false] whether or not the given capture option is compatible with the expected capture option
57
+ # @!visibility private
58
+ def compatible_capture_option?(capture)
59
+ return true if names.empty?
60
+ case capture
61
+ when Hash then capture.all? { |n, o| !names.include?(n.to_s) and compatible_capture_option?(o) }
62
+ when Array then capture.all? { |o| compatible_capture_option?(o) }
63
+ else true
64
+ end
65
+ end
66
+
67
+ translate(Object) { nil }
68
+ translate(String) { t.new(self, escape: true) }
69
+ translate(Identity) { t.new(self, escape: true) if uri_decode == t.uri_decode }
70
+
71
+ translate(Sinatra) do
72
+ if node.class == t.type and t.options == options
73
+ node
74
+ elsif t.compatible_options? options
75
+ t.new(to_s, **options)
76
+ end
77
+ end
40
78
 
41
79
  translate AST::Pattern do
42
- next unless options == t.options
43
- t.new(SafeRenderer.translate(to_ast)) rescue nil
80
+ next unless t.compatible_options? options
81
+ t.new(SafeRenderer.translate(to_ast), **options) rescue nil
44
82
  end
45
83
  end
46
84
 
@@ -37,13 +37,13 @@ module Mustermann
37
37
  # @return [Mustermann::Sinatra, nil] the converted pattern, if possible
38
38
  # @!visibility private
39
39
  def self.try_convert(input, **options)
40
- TryConvert.convert(input, **options)
40
+ TryConvert.convert(self, input, **options)
41
41
  end
42
42
 
43
43
  # Creates a pattern that matches any string matching either one of the patterns.
44
44
  # If a string is supplied, it is treated as a fully escaped Sinatra pattern.
45
45
  #
46
- # If the other pattern is also a Sintara pattern, it might join the two to a third
46
+ # If the other pattern is also a Sinatra pattern, it might join the two to a third
47
47
  # sinatra pattern instead of generating a composite for efficiency reasons.
48
48
  #
49
49
  # This only happens if the sinatra pattern behaves exactly the same as a composite
@@ -59,12 +59,12 @@ module Mustermann
59
59
  # @return [Mustermann::Pattern] a composite pattern
60
60
  # @see Mustermann::Pattern#|
61
61
  def |(other)
62
- return super unless converted = self.class.try_convert(other, **options)
63
- return super unless converted.names.empty? or names.empty?
64
- self.class.new(safe_string + "|" + converted.safe_string, **options)
62
+ return super unless converted = try_convert(other)
63
+ return super if converted.names.any? { |name| names.include?(name) }
64
+ self.class.new(safe_string + "|" + converted.safe_string, **converted.options)
65
65
  end
66
66
 
67
- # Generates a string represenation of the pattern that can safely be used for def interpolation
67
+ # Generates a string representation of the pattern that can safely be used for def interpolation
68
68
  # without changing its semantics.
69
69
  #
70
70
  # @example
@@ -72,19 +72,41 @@ module Mustermann
72
72
  # unsafe = Mustermann.new("/:name")
73
73
  #
74
74
  # Mustermann.new("#{unsafe}bar").params("/foobar") # => { "namebar" => "foobar" }
75
- # Mustermann.new("#{unsafe.safe_string}bar").params("/foobar") # => { "name" => "bar" }
75
+ # Mustermann.new("#{unsafe.safe_string}bar").params("/foobar") # => { "name" => "foo" }
76
76
  #
77
- # @return [String] string representatin of the pattern
77
+ # @return [String] string representations of the pattern
78
78
  def safe_string
79
79
  @safe_string ||= SafeRenderer.translate(to_ast)
80
80
  end
81
81
 
82
82
  # @!visibility private
83
83
  def native_concat(other)
84
- return unless converted = self.class.try_convert(other, **options)
85
- safe_string + converted.safe_string
84
+ return unless converted = try_convert(other)
85
+ [safe_string + converted.safe_string, converted.options]
86
86
  end
87
87
 
88
- private :native_concat
88
+ # @!visibility private
89
+ def normalize_capture(pattern)
90
+ case capture = pattern.options[:capture]
91
+ when Hash then capture.slice(*pattern.names.map(&:to_sym))
92
+ when nil then {}
93
+ else pattern.names.to_h { |name| [name.to_sym, capture] }
94
+ end
95
+ end
96
+
97
+ # @!visibility private
98
+ def try_convert(other)
99
+ options = self.options
100
+
101
+ if other.is_a? Pattern and other.names.any?
102
+ capture = normalize_capture(other).merge(normalize_capture(self))
103
+ capture = nil if capture.empty?
104
+ options = options.merge(capture:)
105
+ end
106
+
107
+ self.class.try_convert(other, names:, **options)
108
+ end
109
+
110
+ private :native_concat, :normalize_capture, :try_convert
89
111
  end
90
112
  end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Mustermann
3
- VERSION ||= '4.0.0.alpha4'
3
+ VERSION ||= '4.0.0.rc1'
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mustermann
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0.alpha4
4
+ version: 4.0.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Haase
@@ -29,6 +29,7 @@ files:
29
29
  - lib/mustermann.rb
30
30
  - lib/mustermann/ast/boundaries.rb
31
31
  - lib/mustermann/ast/compiler.rb
32
+ - lib/mustermann/ast/converters.rb
32
33
  - lib/mustermann/ast/expander.rb
33
34
  - lib/mustermann/ast/fast_pattern.rb
34
35
  - lib/mustermann/ast/node.rb