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
@@ -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,59 @@ 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
+ each_with_index.map do |element, index|
33
+ next_sibling = self[index + 1]
34
+ atomic = greedy &&
35
+ element.is_a?(:capture) &&
36
+ !element.is_a?(:splat) &&
37
+ next_sibling&.is_a?(:separator) &&
38
+ next_sibling.payload == '/'
39
+ t(element, **options, atomic: atomic)
40
+ end.join
41
+ end
42
+
14
43
  translate(:node) { |**o| t(payload, **o) }
15
44
  translate(:separator) { |**o| Regexp.escape(payload) }
16
- translate(:optional) { |**o| "(?:%s)?" % t(payload, **o) }
45
+ translate(:optional) { |**o| '(?:%s)?' % t(payload, **o) }
17
46
  translate(:char) { |**o| t.encoded(payload, **o) }
18
47
 
19
48
  translate :union do |**options|
20
- "(?:%s)" % payload.map { |e| "(?:%s)" % t(e, **options) }.join(?|)
49
+ '(?:%s)' % payload.map { |e| '(?:%s)' % t(e, **options) }.join('|')
21
50
  end
22
51
 
23
52
  translate :expression do |greedy: true, **options|
24
53
  t(payload, allow_reserved: operator.allow_reserved, greedy: greedy && !operator.allow_reserved,
25
- parametric: operator.parametric, separator: operator.separator, **options)
54
+ parametric: operator.parametric, separator: operator.separator, **options)
26
55
  end
27
56
 
28
- translate :with_look_ahead do |**options|
29
- lookahead = each_leaf.inject("") do |ahead, element|
57
+ translate :with_look_ahead do |atomic: false, **options|
58
+ greedy = options.fetch(:greedy, true)
59
+ lookahead = each_leaf.inject('') do |ahead, element|
30
60
  ahead + t(element, skip_optional: true, lookahead: ahead, greedy: false, no_captures: true, **options).to_s
31
61
  end
32
62
  lookahead << (at_end ? '$' : '/')
33
- t(head, lookahead: lookahead, **options) + t(payload, **options)
63
+ # The look-ahead already constrains what the head capture can match, so
64
+ # it is safe to make it atomic when greedy. Non-greedy captures rely on
65
+ # backtracking to extend their match and must not be committed atomically.
66
+ t(head, **options, lookahead: lookahead, atomic: greedy) + t(payload, **options)
34
67
  end
35
68
 
36
69
  # Capture compilation is complex. :(
@@ -39,9 +72,23 @@ module Mustermann
39
72
  register :capture
40
73
 
41
74
  # @!visibility private
42
- def translate(**options)
75
+ # When +atomic: true+ is passed (set by the Array translator for captures
76
+ # that are followed only by a separator or end-of-pattern), the compiled
77
+ # content is wrapped in an atomic group <tt>(?>…)</tt>. This prevents
78
+ # Oniguruma from backtracking into characters the capture has already
79
+ # consumed, giving a measurable speedup on failing matches without
80
+ # changing the result for any valid input.
81
+ def translate(atomic: false, **options)
43
82
  return pattern(**options) if options[:no_captures]
44
- "(?<#{name}>#{translate(no_captures: true, **options)})"
83
+
84
+ inner = translate(no_captures: true, **options)
85
+ # Atomic groups are only safe for pure character-class repetitions.
86
+ # Captures with an explicit array/hash/string option or a custom
87
+ # constraint produce alternations that need backtracking to resolve
88
+ # the correct alternative, so they must not be wrapped atomically.
89
+ apply_atomic = atomic && options[:capture].nil? && constraint.nil?
90
+ content = apply_atomic ? "(?>#{inner})" : inner
91
+ "(?<#{name}>#{content})"
45
92
  end
46
93
 
47
94
  # @return [String] regexp without the named capture
@@ -53,19 +100,51 @@ module Mustermann
53
100
  when Hash then from_hash(capture, **options)
54
101
  when String then from_string(capture, **options)
55
102
  when nil then from_nil(**options)
56
- 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]
57
106
  end
58
107
  end
59
108
 
60
109
  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
110
+
111
+ def qualified(string, greedy: true, **options)
112
+ "#{string}#{qualifier || "+#{'?' unless greedy}"}"
113
+ end
114
+
115
+ def with_lookahead(string, lookahead: nil, **options)
116
+ lookahead ? "(?:(?!#{lookahead})#{string})" : string
117
+ end
118
+
119
+ def from_hash(hash, **options)
120
+ pattern(capture: hash[name.to_sym], **options)
121
+ end
122
+
123
+ def from_array(array, **options)
124
+ Regexp.union(*array.map { |e| pattern(capture: e, **options) })
125
+ end
126
+
127
+ def from_symbol(symbol, **options)
128
+ capture, _ = CONVERTERS[symbol]
129
+ return pattern(capture:, **options) if capture
130
+ qualified(with_lookahead("[[:#{symbol}:]]", **options), **options)
131
+ end
132
+
133
+ def from_string(string, **options)
134
+ Regexp.new(string.chars.map { |c| t.encoded(c, **options) }.join)
135
+ end
136
+
137
+ def from_nil(**options)
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)
145
+ end
146
+
147
+ def default(**options) = constraint || '[^/\\?#]'
69
148
  end
70
149
 
71
150
  # @!visibility private
@@ -74,7 +153,7 @@ module Mustermann
74
153
  # splats are always non-greedy
75
154
  # @!visibility private
76
155
  def pattern(**options)
77
- constraint || ".*?"
156
+ constraint || '.*?'
78
157
  end
79
158
  end
80
159
 
@@ -83,11 +162,17 @@ module Mustermann
83
162
  register :variable
84
163
 
85
164
  # @!visibility private
86
- def translate(**options)
87
- return super(**options) if explode or not options[:parametric]
165
+ def translate(atomic: false, **options)
166
+ # Exploded variables expand to `pattern(?:sep pattern)*`. The engine
167
+ # must be able to backtrack through that repetition when a following
168
+ # capture (e.g. the 'b' in {/a*,b}) needs to claim the last segment.
169
+ # Strip `atomic:` so Capture#translate never wraps the repetition.
170
+ effective_atomic = atomic && !explode
171
+ return super(atomic: effective_atomic, **options) if explode or !options[:parametric]
172
+
88
173
  # Remove this line after fixing broken compatibility between 2.1 and 2.2
89
174
  options.delete(:parametric) if options.has_key?(:parametric)
90
- parametric super(parametric: false, **options)
175
+ parametric super(atomic: effective_atomic, parametric: false, **options)
91
176
  end
92
177
 
93
178
  # @!visibility private
@@ -117,21 +202,34 @@ module Mustermann
117
202
  # @!visibility private
118
203
  def register_param(parametric: false, split_params: nil, separator: nil, **options)
119
204
  return unless explode and split_params
205
+
120
206
  split_params[name] = { separator: separator, parametric: parametric }
121
207
  end
122
208
  end
123
209
 
210
+ # @return [Array<String>] all raw string representations of the character (literal + URI-encoded variants)
211
+ # @!visibility private
212
+ def self.char_representations(char, uri_decode: true, space_matches_plus: true)
213
+ if char == ' ' and space_matches_plus
214
+ @space_and_plus ||= char_representations(' ', space_matches_plus: false) +
215
+ char_representations('+', space_matches_plus: false)
216
+ else
217
+ @char_representations ||= {}
218
+ @char_representations[char] ||= begin
219
+ escaped = URI_PARSER.escape(char, /./)
220
+ [char, escaped.upcase, escaped.downcase].uniq
221
+ end
222
+ end
223
+ end
224
+
124
225
  # @return [String] Regular expression for matching the given character in all representations
125
226
  # @!visibility private
126
227
  def encoded(char, uri_decode: true, space_matches_plus: true, **options)
127
228
  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 << " "
133
- end
134
- "(?:%s)" % list.join("|")
229
+
230
+ '(?:%s)' % self.class.char_representations(char, uri_decode:, space_matches_plus:).map { |c|
231
+ Regexp.escape(c)
232
+ }.join('|')
135
233
  end
136
234
 
137
235
  # 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
@@ -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 = (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.freeze
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 ||= {}
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| {} }
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
@@ -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,16 +88,27 @@ 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 ||= Regexp.new("\\A#{compiler.new.translate(parse(except_str), no_captures: true, **options.except(:except))}\\z")
99
+ end
100
+
86
101
  # Internal AST representation of pattern.
87
102
  # @!visibility private
88
103
  def to_ast
89
- @ast_cache ||= EqualityMap.new
90
- @ast_cache.fetch(@string) do
104
+ ast = self.class.ast_cache.fetch([@string, options]) do
91
105
  ast = parse(@string, pattern: self)
92
106
  ast &&= transform(ast)
93
107
  ast &&= set_boundaries(ast, string: @string)
94
108
  validate(ast)
95
109
  end
110
+ @param_converters ||= scan_params(ast, options) if ast
111
+ ast
96
112
  end
97
113
 
98
114
  # All AST-based pattern implementations support expanding.
@@ -122,12 +138,18 @@ module Mustermann
122
138
  # @see Mustermann::Pattern#map_param
123
139
  def map_param(key, value)
124
140
  return super unless param_converters.include? key
125
- param_converters[key][super]
141
+ converted = super
142
+ converted.nil? ? converted : param_converters[key][converted]
126
143
  end
127
144
 
128
145
  # @!visibility private
129
146
  def param_converters
130
- @param_converters ||= scan_params(to_ast)
147
+ @param_converters ||= scan_params(to_ast, options)
148
+ end
149
+
150
+ # @api private
151
+ def identity_params?(params)
152
+ param_converters.empty? && super
131
153
  end
132
154
 
133
155
  private :compile, :parse, :transform, :validate, :generate_templates, :param_converters, :scan_params, :set_boundaries
@@ -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
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'mustermann/ast/node'
3
3
  require 'mustermann/error'
4
- require 'ruby2_keywords'
5
4
  require 'delegate'
6
5
 
7
6
  module Mustermann
@@ -40,9 +39,9 @@ module Mustermann
40
39
 
41
40
  # shorthand for translating a nested object
42
41
  # @!visibility private
43
- ruby2_keywords def t(*args, &block)
42
+ def t(*args, **options, &block)
44
43
  return translator unless args.any?
45
- translator.translate(*args, &block)
44
+ translator.translate(*args, **options, &block)
46
45
  end
47
46
 
48
47
  # @!visibility private
@@ -105,16 +104,21 @@ module Mustermann
105
104
  # @return decorator encapsulating translation
106
105
  #
107
106
  # @!visibility private
107
+ def self.factory_for(node_class)
108
+ @factory_for ||= {}
109
+ @factory_for[node_class] ||= node_class.ancestors.lazy.filter_map { dispatch_table[_1.name] }.first
110
+ end
111
+
108
112
  def decorator_for(node)
109
- factory = node.class.ancestors.inject(nil) { |d,a| d || self.class.dispatch_table[a.name] }
110
- raise error_class, "#{self.class}: Cannot translate #{node.class}" unless factory
113
+ factory = self.class.factory_for(node.class) or
114
+ raise error_class, "#{self.class}: Cannot translate #{node.class}"
111
115
  factory.new(node, self)
112
116
  end
113
117
 
114
118
  # Start the translation dance for a (sub)tree.
115
119
  # @!visibility private
116
- ruby2_keywords def translate(node, *args, &block)
117
- result = decorator_for(node).translate(*args, &block)
120
+ def translate(node, *args, **options, &block)
121
+ result = decorator_for(node).translate(*args, **options, &block)
118
122
  result = result.node while result.is_a? NodeTranslator
119
123
  result
120
124
  end