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.
- checksums.yaml +4 -4
- data/LICENSE +1 -2
- data/README.md +238 -261
- data/lib/mustermann/ast/compiler.rb +128 -30
- data/lib/mustermann/ast/converters.rb +41 -0
- data/lib/mustermann/ast/expander.rb +4 -5
- data/lib/mustermann/ast/fast_pattern.rb +122 -0
- data/lib/mustermann/ast/param_scanner.rb +38 -6
- data/lib/mustermann/ast/parser.rb +2 -3
- data/lib/mustermann/ast/pattern.rb +26 -4
- data/lib/mustermann/ast/transformer.rb +7 -0
- data/lib/mustermann/ast/translator.rb +11 -7
- data/lib/mustermann/composite.rb +25 -6
- data/lib/mustermann/concat.rb +16 -5
- data/lib/mustermann/error.rb +1 -0
- data/lib/mustermann/expander.rb +26 -4
- data/lib/mustermann/hybrid.rb +50 -0
- data/lib/mustermann/match.rb +155 -0
- data/lib/mustermann/pattern.rb +27 -32
- data/lib/mustermann/rails.rb +63 -0
- data/lib/mustermann/regexp_based.rb +70 -9
- data/lib/mustermann/router.rb +104 -0
- data/lib/mustermann/set/cache.rb +48 -0
- data/lib/mustermann/set/linear.rb +32 -0
- data/lib/mustermann/set/match.rb +23 -0
- data/lib/mustermann/set/strict_order.rb +29 -0
- data/lib/mustermann/set/trie.rb +270 -0
- data/lib/mustermann/set.rb +445 -0
- data/lib/mustermann/sinatra/safe_renderer.rb +1 -1
- data/lib/mustermann/sinatra/try_convert.rb +49 -11
- data/lib/mustermann/sinatra.rb +35 -11
- data/lib/mustermann/version.rb +1 -1
- data/lib/mustermann/versions.rb +47 -0
- data/lib/mustermann.rb +0 -15
- metadata +31 -45
- data/bench/capturing.rb +0 -57
- data/bench/regexp.rb +0 -21
- data/bench/simple_vs_sinatra.rb +0 -23
- data/bench/template_vs_addressable.rb +0 -26
- data/bench/uri_parser_object.rb +0 -16
- data/lib/mustermann/extension.rb +0 -3
- data/lib/mustermann/mapper.rb +0 -91
- data/lib/mustermann/pattern_cache.rb +0 -50
- data/lib/mustermann/simple_match.rb +0 -49
- data/lib/mustermann/to_pattern.rb +0 -51
- data/mustermann.gemspec +0 -18
- data/spec/ast_spec.rb +0 -15
- data/spec/composite_spec.rb +0 -163
- data/spec/concat_spec.rb +0 -127
- data/spec/equality_map_spec.rb +0 -42
- data/spec/expander_spec.rb +0 -123
- data/spec/identity_spec.rb +0 -127
- data/spec/mapper_spec.rb +0 -77
- data/spec/mustermann_spec.rb +0 -81
- data/spec/pattern_spec.rb +0 -54
- data/spec/regexp_based_spec.rb +0 -9
- data/spec/regular_spec.rb +0 -119
- data/spec/simple_match_spec.rb +0 -11
- data/spec/sinatra_spec.rb +0 -836
- 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
|
-
#
|
|
13
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
parametric: operator.parametric, separator: operator.separator, **options)
|
|
26
55
|
end
|
|
27
56
|
|
|
28
|
-
translate :with_look_ahead do
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
16
|
-
translate(
|
|
17
|
-
translate(
|
|
18
|
-
translate(
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
110
|
-
|
|
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
|
-
|
|
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
|