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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'mustermann/ast/translator'
4
+ require 'mustermann/ast/converters'
3
5
 
4
6
  module Mustermann
5
7
  # @see Mustermann::AST::Pattern
@@ -9,28 +11,62 @@ module Mustermann
9
11
  class Compiler < Translator
10
12
  raises CompileError
11
13
 
12
- # Trivial compilations
13
- translate(Array) { |**o| map { |e| t(e, **o) }.join }
14
+ # Compile an array of AST nodes, detecting which captures can safely use
15
+ # atomic groups. A capture is safe to atomicize when its very next sibling
16
+ # is a *path separator* (payload '/'), because every Mustermann capture
17
+ # character class (Sinatra's [^\/\?#]+, Template's [\w\-\.~%]+, etc.)
18
+ # excludes '/', so the greedy match naturally stops before '/' and
19
+ # committing to it atomically never affects correctness.
20
+ #
21
+ # More permissive conditions (e.g. end-of-array) are intentionally avoided:
22
+ # template expressions nest captures inside inner arrays where end-of-array
23
+ # does NOT mean end-of-pattern, and non-'/' separators (e.g. '.' in
24
+ # {.a,b,c}) may appear inside the capture character class.
25
+ #
26
+ # Splats (.*?) and non-greedy captures are excluded — atomicizing them
27
+ # would commit to zero or one character and break match resolution.
28
+ # Strip `atomic:` from incoming options so a parent's value cannot bleed
29
+ # into siblings; each element's atomicity comes solely from its own context.
30
+ translate(Array) do |atomic: false, **options|
31
+ greedy = options.fetch(:greedy, true)
32
+ elements = node
33
+ result = +''
34
+ elements.each_with_index do |element, index|
35
+ next_sibling = elements[index + 1]
36
+ atomic = greedy &&
37
+ element.is_a?(:capture) &&
38
+ !element.is_a?(:splat) &&
39
+ next_sibling&.is_a?(:separator) &&
40
+ next_sibling.payload == '/'
41
+ result << t(element, **options, atomic: atomic)
42
+ end
43
+ result
44
+ end
45
+
14
46
  translate(:node) { |**o| t(payload, **o) }
15
47
  translate(:separator) { |**o| Regexp.escape(payload) }
16
- translate(:optional) { |**o| "(?:%s)?" % t(payload, **o) }
48
+ translate(:optional) { |**o| '(?:%s)?' % t(payload, **o) }
17
49
  translate(:char) { |**o| t.encoded(payload, **o) }
18
50
 
19
51
  translate :union do |**options|
20
- "(?:%s)" % payload.map { |e| "(?:%s)" % t(e, **options) }.join(?|)
52
+ '(?:%s)' % payload.map { |e| '(?:%s)' % t(e, **options) }.join('|')
21
53
  end
22
54
 
23
55
  translate :expression do |greedy: true, **options|
24
56
  t(payload, allow_reserved: operator.allow_reserved, greedy: greedy && !operator.allow_reserved,
25
- parametric: operator.parametric, separator: operator.separator, **options)
57
+ parametric: operator.parametric, separator: operator.separator, **options)
26
58
  end
27
59
 
28
- translate :with_look_ahead do |**options|
29
- lookahead = each_leaf.inject("") do |ahead, element|
60
+ translate :with_look_ahead do |atomic: false, **options|
61
+ greedy = options.fetch(:greedy, true)
62
+ lookahead = each_leaf.inject(EMPTY_STRING) do |ahead, element|
30
63
  ahead + t(element, skip_optional: true, lookahead: ahead, greedy: false, no_captures: true, **options).to_s
31
64
  end
32
65
  lookahead << (at_end ? '$' : '/')
33
- t(head, lookahead: lookahead, **options) + t(payload, **options)
66
+ # The look-ahead already constrains what the head capture can match, so
67
+ # it is safe to make it atomic when greedy. Non-greedy captures rely on
68
+ # backtracking to extend their match and must not be committed atomically.
69
+ t(head, **options, lookahead: lookahead, atomic: greedy) + t(payload, **options)
34
70
  end
35
71
 
36
72
  # Capture compilation is complex. :(
@@ -39,9 +75,23 @@ module Mustermann
39
75
  register :capture
40
76
 
41
77
  # @!visibility private
42
- def translate(**options)
78
+ # When +atomic: true+ is passed (set by the Array translator for captures
79
+ # that are followed only by a separator or end-of-pattern), the compiled
80
+ # content is wrapped in an atomic group <tt>(?>…)</tt>. This prevents
81
+ # Oniguruma from backtracking into characters the capture has already
82
+ # consumed, giving a measurable speedup on failing matches without
83
+ # changing the result for any valid input.
84
+ def translate(atomic: false, **options)
43
85
  return pattern(**options) if options[:no_captures]
44
- "(?<#{name}>#{translate(no_captures: true, **options)})"
86
+
87
+ inner = translate(no_captures: true, **options)
88
+ # Atomic groups are only safe for pure character-class repetitions.
89
+ # Captures with an explicit array/hash/string option or a custom
90
+ # constraint produce alternations that need backtracking to resolve
91
+ # the correct alternative, so they must not be wrapped atomically.
92
+ apply_atomic = atomic && options[:capture].nil? && constraint.nil?
93
+ content = apply_atomic ? "(?>#{inner})" : inner
94
+ "(?<#{name}>#{content})"
45
95
  end
46
96
 
47
97
  # @return [String] regexp without the named capture
@@ -53,19 +103,51 @@ module Mustermann
53
103
  when Hash then from_hash(capture, **options)
54
104
  when String then from_string(capture, **options)
55
105
  when nil then from_nil(**options)
56
- else capture
106
+ when Regexp then capture
107
+ when Class then from_class(capture, **options)
108
+ else raise CompileError, "invalid capture constraint %p for %p" % [capture, name]
57
109
  end
58
110
  end
59
111
 
60
112
  private
61
- def qualified(string, greedy: true, **options) "#{string}#{qualifier || "+#{?? unless greedy}"}" end
62
- def with_lookahead(string, lookahead: nil, **options) lookahead ? "(?:(?!#{lookahead})#{string})" : string end
63
- def from_hash(hash, **options) pattern(capture: hash[name.to_sym], **options) end
64
- def from_array(array, **options) Regexp.union(*array.map { |e| pattern(capture: e, **options) }) end
65
- def from_symbol(symbol, **options) qualified(with_lookahead("[[:#{symbol}:]]", **options), **options) end
66
- def from_string(string, **options) Regexp.new(string.chars.map { |c| t.encoded(c, **options) }.join) end
67
- def from_nil(**options) qualified(with_lookahead(default(**options), **options), **options) end
68
- def default(**options) constraint || "[^/\\?#]" end
113
+
114
+ def qualified(string, greedy: true, **options)
115
+ "#{string}#{qualifier || "+#{'?' unless greedy}"}"
116
+ end
117
+
118
+ def with_lookahead(string, lookahead: nil, **options)
119
+ lookahead ? "(?:(?!#{lookahead})#{string})" : string
120
+ end
121
+
122
+ def from_hash(hash, **options)
123
+ pattern(capture: hash[name.to_sym], **options)
124
+ end
125
+
126
+ def from_array(array, **options)
127
+ Regexp.union(*array.map { |e| pattern(capture: e, **options) })
128
+ end
129
+
130
+ def from_symbol(symbol, **options)
131
+ capture, _ = CONVERTERS[symbol]
132
+ return pattern(capture:, **options) if capture
133
+ qualified(with_lookahead("[[:#{symbol}:]]", **options), **options)
134
+ end
135
+
136
+ def from_string(string, **options)
137
+ Regexp.new(string.chars.map { |c| t.encoded(c, **options) }.join)
138
+ end
139
+
140
+ def from_nil(**options)
141
+ qualified(with_lookahead(default(**options), **options), **options)
142
+ end
143
+
144
+ def from_class(klass, **options)
145
+ capture, _ = CONVERTERS[klass.name]
146
+ raise CompileError, "no converter for class %p" % klass unless capture
147
+ pattern(capture:, **options)
148
+ end
149
+
150
+ def default(**options) = constraint || '[^/\\?#]'
69
151
  end
70
152
 
71
153
  # @!visibility private
@@ -74,7 +156,7 @@ module Mustermann
74
156
  # splats are always non-greedy
75
157
  # @!visibility private
76
158
  def pattern(**options)
77
- constraint || ".*?"
159
+ constraint || '.*?'
78
160
  end
79
161
  end
80
162
 
@@ -83,11 +165,17 @@ module Mustermann
83
165
  register :variable
84
166
 
85
167
  # @!visibility private
86
- def translate(**options)
87
- return super(**options) if explode or not options[:parametric]
168
+ def translate(atomic: false, **options)
169
+ # Exploded variables expand to `pattern(?:sep pattern)*`. The engine
170
+ # must be able to backtrack through that repetition when a following
171
+ # capture (e.g. the 'b' in {/a*,b}) needs to claim the last segment.
172
+ # Strip `atomic:` so Capture#translate never wraps the repetition.
173
+ effective_atomic = atomic && !explode
174
+ return super(atomic: effective_atomic, **options) if explode or !options[:parametric]
175
+
88
176
  # Remove this line after fixing broken compatibility between 2.1 and 2.2
89
177
  options.delete(:parametric) if options.has_key?(:parametric)
90
- parametric super(parametric: false, **options)
178
+ parametric super(atomic: effective_atomic, parametric: false, **options)
91
179
  end
92
180
 
93
181
  # @!visibility private
@@ -117,21 +205,41 @@ module Mustermann
117
205
  # @!visibility private
118
206
  def register_param(parametric: false, split_params: nil, separator: nil, **options)
119
207
  return unless explode and split_params
208
+
120
209
  split_params[name] = { separator: separator, parametric: parametric }
121
210
  end
122
211
  end
123
212
 
213
+ # @return [Array<String>] all raw string representations of the character (literal + URI-encoded variants)
214
+ # @!visibility private
215
+ def self.char_representations(char, uri_decode: true, space_matches_plus: true)
216
+ if char == ' ' and space_matches_plus
217
+ @space_and_plus ||= char_representations(' ', space_matches_plus: false) +
218
+ char_representations('+', space_matches_plus: false)
219
+ else
220
+ @char_representations ||= {}
221
+ @char_representations[char] ||= begin
222
+ escaped = URI_PARSER.escape(char, /./)
223
+ [char, escaped.upcase, escaped.downcase].uniq
224
+ end
225
+ end
226
+ end
227
+
124
228
  # @return [String] Regular expression for matching the given character in all representations
125
229
  # @!visibility private
126
230
  def encoded(char, uri_decode: true, space_matches_plus: true, **options)
127
231
  return Regexp.escape(char) unless uri_decode
128
- encoded = escape(char, escape: /./)
129
- list = [escape(char), encoded.downcase, encoded.upcase].uniq.map { |c| Regexp.escape(c) }
130
- if char == " "
131
- list << encoded('+') if space_matches_plus
132
- list << " "
232
+
233
+ self.class.encoded_chars[[char, space_matches_plus]] ||= begin
234
+ representations = self.class.char_representations(char, uri_decode:, space_matches_plus:)
235
+ ('(?:%s)' % representations.map { |c| Regexp.escape(c) }.join('|')).freeze
133
236
  end
134
- "(?:%s)" % list.join("|")
237
+ end
238
+
239
+ # @return [Hash] regular expression source for each URI-encodable character
240
+ # @!visibility private
241
+ def self.encoded_chars
242
+ @encoded_chars ||= {}
135
243
  end
136
244
 
137
245
  # Compiles an AST to a regular expression.
@@ -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
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'mustermann/ast/translator'
3
3
  require 'mustermann/ast/compiler'
4
- require 'ruby2_keywords'
5
4
 
6
5
  module Mustermann
7
6
  module AST
@@ -12,11 +11,11 @@ module Mustermann
12
11
  class Expander < Translator
13
12
  raises ExpandError
14
13
 
15
- translate(Array, &-> (*args) do
14
+ translate Array do |*args, **options|
16
15
  inject(t.pattern) do |pattern, element|
17
- t.add_to(pattern, t(element, *args))
16
+ t.add_to(pattern, t(element, *args, **options))
18
17
  end
19
- end.ruby2_keywords)
18
+ end
20
19
 
21
20
  translate :capture do |**options|
22
21
  t.for_capture(node, **options)
@@ -123,7 +122,7 @@ module Mustermann
123
122
 
124
123
  # @see Mustermann::AST::Translator#expand
125
124
  # @!visibility private
126
- ruby2_keywords def escape(string, *args)
125
+ def escape(string, *args, **options)
127
126
  return super unless string.respond_to?(:=~)
128
127
 
129
128
  # URI::Parser is pretty slow, let's not send every string to it, even if it's unnecessary
@@ -132,14 +131,14 @@ module Mustermann
132
131
 
133
132
  # Turns a sprintf pattern into our secret internal data structure.
134
133
  # @!visibility private
135
- def pattern(string = "", *keys, **filters)
134
+ def pattern(string = EMPTY_STRING, *keys, **filters)
136
135
  [[keys, string, filters]]
137
136
  end
138
137
 
139
138
  # Creates the product of two of our secret internal data structures.
140
139
  # @!visibility private
141
140
  def add_to(list, result)
142
- list << [[], ""] if list.empty?
141
+ list << [[], EMPTY_STRING] if list.empty?
143
142
  list.inject([]) { |l, (k1, p1, f1)| l + result.map { |k2, p2, f2| [k1+k2, p1+p2, f1.merge(f2)] } }
144
143
  end
145
144
  end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mustermann
4
+ module AST
5
+ # Mixin for AST::Pattern subclasses that accelerates compilation and AST
6
+ # construction for "simple" patterns: only static path segments and
7
+ # unconstrained full-segment captures (e.g. /foo/:bar/baz/:id).
8
+ # Patterns with optional groups, constraints, or non-default options fall
9
+ # through to the full AST pipeline.
10
+ module FastPattern
11
+ # Matches patterns that consist only of slashes, static segments, and
12
+ # simple :name captures — no optional groups, no constraints.
13
+ SIMPLE = /\A(?:\/(?:[a-zA-Z0-9\-_.~]+|:[a-zA-Z_]\w*))+\z/
14
+
15
+ # Regexp fragment for each printable ASCII char, matching the same output
16
+ # as Compiler#encoded with uri_decode: true.
17
+ ENCODED = Mustermann.dedup((0..127).each_with_object({}) do |byte, h|
18
+ c = byte.chr
19
+ pct = '%%%02X' % byte
20
+ reps = [c, pct, pct.downcase].uniq
21
+ h[c] = reps.size == 1 ? Regexp.escape(reps.first) :
22
+ '(?:%s)' % reps.map { |r| Regexp.escape(r) }.join('|')
23
+ end)
24
+
25
+ SEGMENT_SCAN = %r{(/)|(:[a-zA-Z_]\w*)|([^/:]+)}
26
+
27
+ private_constant :SIMPLE, :ENCODED, :SEGMENT_SCAN
28
+
29
+ # Public override: fast path for simple patterns, falls through to super otherwise.
30
+ # Must remain public to match AST::Pattern#to_ast visibility.
31
+ def to_ast
32
+ return super unless simple_pattern?
33
+ ast = self.class.ast_cache.fetch(@string) { build_fast_ast }
34
+ @param_converters ||= EMPTY_HASH
35
+ ast
36
+ end
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
+
46
+ private
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
+
56
+ def simple_pattern?
57
+ options[:capture].nil? &&
58
+ options[:except].nil? &&
59
+ options.fetch(:greedy, true) != false &&
60
+ uri_decode &&
61
+ @string.match?(SIMPLE)
62
+ end
63
+
64
+ def compile(**options)
65
+ return super unless simple_pattern?
66
+ result = fast_compile
67
+ @fast_match = true
68
+ result
69
+ end
70
+
71
+ def fast_compile
72
+ tokens = @string.scan(SEGMENT_SCAN)
73
+ src = String.new
74
+ tokens.each_with_index do |(sep, cap, chars), i|
75
+ if sep
76
+ src << '\\/'
77
+ elsif cap
78
+ # Mirror the compiler: wrap in atomic group when the next token is a separator.
79
+ if tokens[i + 1]&.first
80
+ src << "(?<#{cap[1..]}>(?>[^/\\?#]+))"
81
+ else
82
+ src << "(?<#{cap[1..]}>[^/\\?#]+)"
83
+ end
84
+ else
85
+ chars.each_char { |c| src << ENCODED[c] }
86
+ end
87
+ end
88
+ Regexp.new(src)
89
+ end
90
+
91
+ def build_fast_ast
92
+ nodes = []
93
+ pos = 0
94
+ @string.scan(SEGMENT_SCAN) do |sep, cap, chars|
95
+ if sep
96
+ node = Node::Separator.new('/')
97
+ node.start, node.stop = pos, pos + 1
98
+ nodes << node
99
+ pos += 1
100
+ elsif cap
101
+ node = Node::Capture.new(cap[1..])
102
+ node.start, node.stop = pos, pos + cap.length
103
+ nodes << node
104
+ pos += cap.length
105
+ else
106
+ chars.each_char do |c|
107
+ node = Node::Char.new(c)
108
+ node.start, node.stop = pos, pos + 1
109
+ nodes << node
110
+ pos += 1
111
+ end
112
+ end
113
+ end
114
+ root = Node::Root.new
115
+ root.payload = nodes
116
+ root.pattern = @string
117
+ root.start, root.stop = 0, @string.length
118
+ root
119
+ end
120
+ end
121
+ end
122
+ end
@@ -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| EMPTY_HASH }
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 } : EMPTY_HASH
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
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'mustermann/ast/node'
3
3
  require 'forwardable'
4
- require 'ruby2_keywords'
5
4
  require 'strscan'
6
5
 
7
6
  module Mustermann
@@ -65,10 +64,10 @@ module Mustermann
65
64
  # @param [Symbol] type node type
66
65
  # @return [Mustermann::AST::Node]
67
66
  # @!visibility private
68
- ruby2_keywords def node(type, *args, &block)
67
+ def node(type, *args, **options, &block)
69
68
  type = Node[type] unless type.respond_to? :new
70
69
  start = pos
71
- node = block ? type.parse(*args, &block) : type.new(*args)
70
+ node = block ? type.parse(*args, **options, &block) : type.new(*args, **options)
72
71
  min_size(start, pos, node)
73
72
  end
74
73
 
@@ -23,6 +23,11 @@ module Mustermann
23
23
  instance_delegate %i[parser compiler transformer validation template_generator param_scanner boundaries] => 'self.class'
24
24
  instance_delegate parse: :parser, transform: :transformer, validate: :validation,
25
25
  generate_templates: :template_generator, scan_params: :param_scanner, set_boundaries: :boundaries
26
+
27
+ # @api private
28
+ def self.ast_cache
29
+ @ast_cache ||= EqualityMap.new
30
+ end
26
31
 
27
32
  # @api private
28
33
  # @return [#parse] parser object for pattern
@@ -83,11 +88,23 @@ module Mustermann
83
88
  raise error.class, "#{error.message}: #{@string.inspect}", error.backtrace
84
89
  end
85
90
 
91
+ # Returns a regexp that matches strings excluded by the +except+ option,
92
+ # or +nil+ if no +except+ constraint was given. Used by the trie matcher
93
+ # to filter out excluded strings at leaf nodes.
94
+ # @return [Regexp, nil]
95
+ # @!visibility private
96
+ def except_regexp
97
+ return unless except_str = options[:except]
98
+ @except_regexp ||= begin
99
+ source = compiler.new.translate(parse(except_str), no_captures: true, **options.except(:except))
100
+ Mustermann.dedup(Regexp.new("\\A#{source}\\z"))
101
+ end
102
+ end
103
+
86
104
  # Internal AST representation of pattern.
87
105
  # @!visibility private
88
106
  def to_ast
89
- @ast_cache ||= EqualityMap.new
90
- @ast_cache.fetch(@string) do
107
+ self.class.ast_cache.fetch([@string, options]) do
91
108
  ast = parse(@string, pattern: self)
92
109
  ast &&= transform(ast)
93
110
  ast &&= set_boundaries(ast, string: @string)
@@ -103,7 +120,7 @@ module Mustermann
103
120
  # @raise (see Mustermann::Pattern#expand)
104
121
  # @see Mustermann::Pattern#expand
105
122
  # @see Mustermann::Expander
106
- def expand(behavior = nil, values = {})
123
+ def expand(behavior = nil, values = EMPTY_HASH)
107
124
  @expander ||= Mustermann::Expander.new(self)
108
125
  @expander.expand(behavior, values)
109
126
  end
@@ -115,19 +132,25 @@ module Mustermann
115
132
  # @return (see Mustermann::Pattern#to_templates)
116
133
  # @see Mustermann::Pattern#to_templates
117
134
  def to_templates
118
- @to_templates ||= generate_templates(to_ast)
135
+ @to_templates ||= Mustermann.dedup(generate_templates(to_ast))
119
136
  end
120
137
 
121
138
  # @!visibility private
122
139
  # @see Mustermann::Pattern#map_param
123
140
  def map_param(key, value)
124
141
  return super unless param_converters.include? key
125
- param_converters[key][super]
142
+ converted = super
143
+ converted.nil? ? converted : param_converters[key][converted]
126
144
  end
127
145
 
128
146
  # @!visibility private
129
147
  def param_converters
130
- @param_converters ||= scan_params(to_ast)
148
+ @param_converters ||= Mustermann.dedup(scan_params(to_ast, options))
149
+ end
150
+
151
+ # @api private
152
+ def identity_params?(params)
153
+ param_converters.empty? && super
131
154
  end
132
155
 
133
156
  private :compile, :parse, :transform, :validate, :generate_templates, :param_converters, :scan_params, :set_boundaries
@@ -13,10 +13,10 @@ module Mustermann
13
13
  end
14
14
 
15
15
  # translate(:expression) is not needed, since template patterns simply call to_s
16
- translate(:root, :group) { t(payload) || [""] }
16
+ translate(:root, :group) { t(payload) || [EMPTY_STRING] }
17
17
  translate(:separator, :char) { t.escape(payload) }
18
18
  translate(:capture) { "{#{name}}" }
19
- translate(:optional) { [t(payload), ""] }
19
+ translate(:optional) { [t(payload), EMPTY_STRING] }
20
20
  translate(:named_splat, :splat) { "{+#{name}}" }
21
21
  translate(:with_look_ahead) { t([head, payload]) }
22
22
  translate(:union) { payload.flat_map { |e| t(e) } }
@@ -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