mustermann 3.1.0 → 4.0.0.alpha

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.
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'mustermann/ast/translator'
3
4
 
4
5
  module Mustermann
@@ -9,28 +10,59 @@ module Mustermann
9
10
  class Compiler < Translator
10
11
  raises CompileError
11
12
 
12
- # Trivial compilations
13
- translate(Array) { |**o| map { |e| t(e, **o) }.join }
13
+ # Compile an array of AST nodes, detecting which captures can safely use
14
+ # atomic groups. A capture is safe to atomicize when its very next sibling
15
+ # is a *path separator* (payload '/'), because every Mustermann capture
16
+ # character class (Sinatra's [^\/\?#]+, Template's [\w\-\.~%]+, etc.)
17
+ # excludes '/', so the greedy match naturally stops before '/' and
18
+ # committing to it atomically never affects correctness.
19
+ #
20
+ # More permissive conditions (e.g. end-of-array) are intentionally avoided:
21
+ # template expressions nest captures inside inner arrays where end-of-array
22
+ # does NOT mean end-of-pattern, and non-'/' separators (e.g. '.' in
23
+ # {.a,b,c}) may appear inside the capture character class.
24
+ #
25
+ # Splats (.*?) and non-greedy captures are excluded — atomicizing them
26
+ # would commit to zero or one character and break match resolution.
27
+ # Strip `atomic:` from incoming options so a parent's value cannot bleed
28
+ # into siblings; each element's atomicity comes solely from its own context.
29
+ translate(Array) do |atomic: false, **options|
30
+ greedy = options.fetch(:greedy, true)
31
+ each_with_index.map do |element, index|
32
+ next_sibling = self[index + 1]
33
+ atomic = greedy &&
34
+ element.is_a?(:capture) &&
35
+ !element.is_a?(:splat) &&
36
+ next_sibling&.is_a?(:separator) &&
37
+ next_sibling.payload == '/'
38
+ t(element, **options, atomic: atomic)
39
+ end.join
40
+ end
41
+
14
42
  translate(:node) { |**o| t(payload, **o) }
15
43
  translate(:separator) { |**o| Regexp.escape(payload) }
16
- translate(:optional) { |**o| "(?:%s)?" % t(payload, **o) }
44
+ translate(:optional) { |**o| '(?:%s)?' % t(payload, **o) }
17
45
  translate(:char) { |**o| t.encoded(payload, **o) }
18
46
 
19
47
  translate :union do |**options|
20
- "(?:%s)" % payload.map { |e| "(?:%s)" % t(e, **options) }.join(?|)
48
+ '(?:%s)' % payload.map { |e| '(?:%s)' % t(e, **options) }.join('|')
21
49
  end
22
50
 
23
51
  translate :expression do |greedy: true, **options|
24
52
  t(payload, allow_reserved: operator.allow_reserved, greedy: greedy && !operator.allow_reserved,
25
- parametric: operator.parametric, separator: operator.separator, **options)
53
+ parametric: operator.parametric, separator: operator.separator, **options)
26
54
  end
27
55
 
28
- translate :with_look_ahead do |**options|
29
- lookahead = each_leaf.inject("") do |ahead, element|
56
+ translate :with_look_ahead do |atomic: false, **options|
57
+ greedy = options.fetch(:greedy, true)
58
+ lookahead = each_leaf.inject('') do |ahead, element|
30
59
  ahead + t(element, skip_optional: true, lookahead: ahead, greedy: false, no_captures: true, **options).to_s
31
60
  end
32
61
  lookahead << (at_end ? '$' : '/')
33
- t(head, lookahead: lookahead, **options) + t(payload, **options)
62
+ # The look-ahead already constrains what the head capture can match, so
63
+ # it is safe to make it atomic when greedy. Non-greedy captures rely on
64
+ # backtracking to extend their match and must not be committed atomically.
65
+ t(head, **options, lookahead: lookahead, atomic: greedy) + t(payload, **options)
34
66
  end
35
67
 
36
68
  # Capture compilation is complex. :(
@@ -39,9 +71,23 @@ module Mustermann
39
71
  register :capture
40
72
 
41
73
  # @!visibility private
42
- def translate(**options)
74
+ # When +atomic: true+ is passed (set by the Array translator for captures
75
+ # that are followed only by a separator or end-of-pattern), the compiled
76
+ # content is wrapped in an atomic group <tt>(?>…)</tt>. This prevents
77
+ # Oniguruma from backtracking into characters the capture has already
78
+ # consumed, giving a measurable speedup on failing matches without
79
+ # changing the result for any valid input.
80
+ def translate(atomic: false, **options)
43
81
  return pattern(**options) if options[:no_captures]
44
- "(?<#{name}>#{translate(no_captures: true, **options)})"
82
+
83
+ inner = translate(no_captures: true, **options)
84
+ # Atomic groups are only safe for pure character-class repetitions.
85
+ # Captures with an explicit array/hash/string option or a custom
86
+ # constraint produce alternations that need backtracking to resolve
87
+ # the correct alternative, so they must not be wrapped atomically.
88
+ apply_atomic = atomic && options[:capture].nil? && constraint.nil?
89
+ content = apply_atomic ? "(?>#{inner})" : inner
90
+ "(?<#{name}>#{content})"
45
91
  end
46
92
 
47
93
  # @return [String] regexp without the named capture
@@ -58,14 +104,44 @@ module Mustermann
58
104
  end
59
105
 
60
106
  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
107
+
108
+ def qualified(string, greedy: true,
109
+ **options) "#{string}#{qualifier || "+#{'?' unless greedy}"}"
110
+ end
111
+
112
+ def with_lookahead(string, lookahead: nil,
113
+ **options) lookahead ? "(?:(?!#{lookahead})#{string})" : string
114
+ end
115
+
116
+ def from_hash(hash,
117
+ **options) pattern(capture: hash[name.to_sym],
118
+ **options)
119
+ end
120
+
121
+ def from_array(array, **options)
122
+ Regexp.union(*array.map do |e|
123
+ pattern(capture: e, **options)
124
+ end)
125
+ end
126
+
127
+ def from_symbol(symbol,
128
+ **options) qualified(with_lookahead("[[:#{symbol}:]]", **options),
129
+ **options)
130
+ end
131
+
132
+ def from_string(string, **options)
133
+ Regexp.new(string.chars.map do |c|
134
+ t.encoded(c, **options)
135
+ end.join)
136
+ end
137
+
138
+ def from_nil(**options)
139
+ qualified(
140
+ with_lookahead(default(**options), **options), **options
141
+ )
142
+ end
143
+
144
+ def default(**options) = constraint || '[^/\\?#]'
69
145
  end
70
146
 
71
147
  # @!visibility private
@@ -74,7 +150,7 @@ module Mustermann
74
150
  # splats are always non-greedy
75
151
  # @!visibility private
76
152
  def pattern(**options)
77
- constraint || ".*?"
153
+ constraint || '.*?'
78
154
  end
79
155
  end
80
156
 
@@ -83,11 +159,17 @@ module Mustermann
83
159
  register :variable
84
160
 
85
161
  # @!visibility private
86
- def translate(**options)
87
- return super(**options) if explode or not options[:parametric]
162
+ def translate(atomic: false, **options)
163
+ # Exploded variables expand to `pattern(?:sep pattern)*`. The engine
164
+ # must be able to backtrack through that repetition when a following
165
+ # capture (e.g. the 'b' in {/a*,b}) needs to claim the last segment.
166
+ # Strip `atomic:` so Capture#translate never wraps the repetition.
167
+ effective_atomic = atomic && !explode
168
+ return super(atomic: effective_atomic, **options) if explode or !options[:parametric]
169
+
88
170
  # Remove this line after fixing broken compatibility between 2.1 and 2.2
89
171
  options.delete(:parametric) if options.has_key?(:parametric)
90
- parametric super(parametric: false, **options)
172
+ parametric super(atomic: effective_atomic, parametric: false, **options)
91
173
  end
92
174
 
93
175
  # @!visibility private
@@ -117,21 +199,34 @@ module Mustermann
117
199
  # @!visibility private
118
200
  def register_param(parametric: false, split_params: nil, separator: nil, **options)
119
201
  return unless explode and split_params
202
+
120
203
  split_params[name] = { separator: separator, parametric: parametric }
121
204
  end
122
205
  end
123
206
 
207
+ # @return [Array<String>] all raw string representations of the character (literal + URI-encoded variants)
208
+ # @!visibility private
209
+ def self.char_representations(char, uri_decode: true, space_matches_plus: true)
210
+ if char == ' ' and space_matches_plus
211
+ @space_and_plus ||= char_representations(' ', space_matches_plus: false) +
212
+ char_representations('+', space_matches_plus: false)
213
+ else
214
+ @char_representations ||= {}
215
+ @char_representations[char] ||= begin
216
+ escaped = URI_PARSER.escape(char, /./)
217
+ [char, escaped.upcase, escaped.downcase].uniq
218
+ end
219
+ end
220
+ end
221
+
124
222
  # @return [String] Regular expression for matching the given character in all representations
125
223
  # @!visibility private
126
224
  def encoded(char, uri_decode: true, space_matches_plus: true, **options)
127
225
  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("|")
226
+
227
+ '(?:%s)' % self.class.char_representations(char, uri_decode:, space_matches_plus:).map { |c|
228
+ Regexp.escape(c)
229
+ }.join('|')
135
230
  end
136
231
 
137
232
  # Compiles an AST to a regular expression.
@@ -83,6 +83,16 @@ module Mustermann
83
83
  raise error.class, "#{error.message}: #{@string.inspect}", error.backtrace
84
84
  end
85
85
 
86
+ # Returns a regexp that matches strings excluded by the +except+ option,
87
+ # or +nil+ if no +except+ constraint was given. Used by the trie matcher
88
+ # to filter out excluded strings at leaf nodes.
89
+ # @return [Regexp, nil]
90
+ # @!visibility private
91
+ def except_regexp
92
+ return unless except_str = options[:except]
93
+ @except_regexp ||= Regexp.new("\\A#{compiler.new.translate(parse(except_str), no_captures: true, **options.except(:except))}\\z")
94
+ end
95
+
86
96
  # Internal AST representation of pattern.
87
97
  # @!visibility private
88
98
  def to_ast
@@ -104,9 +104,14 @@ module Mustermann
104
104
  # @return decorator encapsulating translation
105
105
  #
106
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
+
107
112
  def decorator_for(node)
108
- factory = node.class.ancestors.inject(nil) { |d,a| d || self.class.dispatch_table[a.name] }
109
- 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}"
110
115
  factory.new(node, self)
111
116
  end
112
117
 
@@ -75,10 +75,16 @@ module Mustermann
75
75
 
76
76
  # @see Mustermann::Pattern#peek_match
77
77
  def peek_match(string)
78
- pump(string, initial: SimpleMatch.new) do |pattern, substring|
79
- return unless match = pattern.peek_match(substring)
80
- [match, match.to_s.size]
78
+ substring = string
79
+ params = {}
80
+
81
+ patterns.each do |pattern|
82
+ return unless part = pattern.peek_match(substring)
83
+ params.merge!(part.params)
84
+ substring = substring[part.to_s.size..-1]
81
85
  end
86
+
87
+ Match.new(self, string[0, string.size - substring.size], params, post_match: substring)
82
88
  end
83
89
 
84
90
  # @see Mustermann::Pattern#peek_params
@@ -5,5 +5,6 @@ module Mustermann
5
5
  CompileError = Class.new(Error) # Raised if anything goes wrong while compiling a {Pattern}.
6
6
  ParseError = Class.new(Error) # Raised if anything goes wrong while parsing a {Pattern}.
7
7
  ExpandError = Class.new(Error) # Raised if anything goes wrong while expanding a {Pattern}.
8
+ TrieError = Class.new(CompileError) # Raised if anything goes wrong while compiling a {Trie}.
8
9
  end
9
10
  end
@@ -13,15 +13,16 @@ module Mustermann
13
13
  #
14
14
  # expander.expand(page_id: 58, format: :html5) # => "/pages/58?format=html5"
15
15
  class Expander
16
+ # @!visibility private
17
+ ADDITIONAL_VALUES = %i[raise ignore append].freeze
18
+
16
19
  attr_reader :patterns, :additional_values, :caster
17
20
 
18
21
  # @param [Array<#to_str, Mustermann::Pattern>] patterns list of patterns to expand, see {#add}.
19
22
  # @param [Symbol] additional_values behavior when encountering additional values, see {#expand}.
20
23
  # @param [Hash] options used when creating/expanding patterns, see {Mustermann.new}.
21
24
  def initialize(*patterns, additional_values: :raise, **options, &block)
22
- unless additional_values == :raise or additional_values == :ignore or additional_values == :append
23
- raise ArgumentError, "Illegal value %p for additional_values" % additional_values
24
- end
25
+ raise ArgumentError, "Illegal value %p for additional_values" % additional_values unless ADDITIONAL_VALUES.include? additional_values
25
26
 
26
27
  @patterns = []
27
28
  @api_expander = AST::Expander.new
@@ -42,6 +43,10 @@ module Mustermann
42
43
  # @return [Mustermann::Expander] the expander
43
44
  def add(*patterns)
44
45
  patterns.each do |pattern|
46
+ if pattern.is_a? Expander
47
+ add(*pattern.patterns)
48
+ next
49
+ end
45
50
  pattern = Mustermann.new(pattern, **@options)
46
51
  if block_given?
47
52
  @api_expander.add(yield(pattern))
@@ -0,0 +1,50 @@
1
+ require 'mustermann/sinatra'
2
+
3
+ module Mustermann
4
+ # Hybrid pattern type that bridges {Mustermann::Sinatra} and Rails pattern syntax.
5
+ #
6
+ # It supports all syntax elements of {Mustermann::Sinatra}, plus URI template-style
7
+ # placeholders, and changes the semantics of parenthesized groups to match Rails:
8
+ #
9
+ # - A group *without* a pipe operator is **implicitly optional**, even without a
10
+ # trailing `?`. So `/foo(/bar)` matches both `/foo/bar` and `/foo`.
11
+ #
12
+ # - A group *with* a pipe operator is **not** implicitly optional, to avoid the
13
+ # ambiguity of `/scope/(a|b)` also matching `/scope/`. Add a trailing `?` to make
14
+ # such a group optional explicitly: `/scope/(a|b)?`.
15
+ #
16
+ # @example Implicit optional group (no pipe)
17
+ # require 'mustermann'
18
+ # pattern = Mustermann.new('/foo(/bar)', type: :hybrid)
19
+ # pattern === '/foo' # => true
20
+ # pattern === '/foo/bar' # => true
21
+ #
22
+ # @example Non-optional group with pipe
23
+ # pattern = Mustermann.new('/scope/(a|b)', type: :hybrid)
24
+ # pattern === '/scope/a' # => true
25
+ # pattern === '/scope/' # => false
26
+ #
27
+ # @example Explicitly optional group with pipe
28
+ # pattern = Mustermann.new('/scope/(a|b)?', type: :hybrid)
29
+ # pattern === '/scope/' # => true
30
+ #
31
+ # @example Nested implicit optional groups (Rails-style resource routing)
32
+ # pattern = Mustermann.new('/:controller(/:action(/:id))', type: :hybrid)
33
+ # pattern.params('/posts') # => { "controller" => "posts" }
34
+ # pattern.params('/posts/show') # => { "controller" => "posts", "action" => "show" }
35
+ # pattern.params('/posts/show/1') # => { "controller" => "posts", "action" => "show", "id" => "1" }
36
+ #
37
+ # @see Mustermann::Sinatra
38
+ class Hybrid < Sinatra
39
+ register :hybrid
40
+
41
+ # Parses a parenthesized group. Groups without a pipe operator are wrapped in an
42
+ # optional node (implicitly optional, Rails style). Groups that do contain a pipe
43
+ # operator are left as plain groups; append `?` to make them optional explicitly.
44
+ on("(") do |c|
45
+ n = node(:group) { read unless scan(?)) }
46
+ has_or = n.payload.any? { |e| e.is_a?(:or) }
47
+ has_or && !scan("?") ? n : node(:optional, n)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mustermann
4
+ class Match
5
+ attr_reader :pattern, :string, :params, :post_match, :pre_match
6
+
7
+ def initialize(pattern, string, params = {}, post_match: '', pre_match: '')
8
+ @pattern = pattern
9
+ @string = string.freeze
10
+ @params = params.freeze
11
+ @post_match = post_match.freeze
12
+ @pre_match = pre_match.freeze
13
+ end
14
+
15
+ def [](key)
16
+ case key
17
+ when String then params[key]
18
+ when Symbol then params[key.to_s]
19
+ else raise ArgumentError, "key must be a String or Symbol, not #{key.class}"
20
+ end
21
+ end
22
+
23
+ def deconstruct_keys(keys) = keys.to_h { |key| [key, self[key]] }
24
+
25
+ def hash = pattern.hash ^ string.hash ^ params.hash
26
+
27
+ def eql?(other)
28
+ return false unless other.is_a? self.class
29
+ pattern == other.pattern && string == other.string && params == other.params
30
+ end
31
+
32
+ def values_at(*keys) = keys.map { |key| self[key] }
33
+
34
+ alias == eql?
35
+ alias to_s string
36
+ alias to_h params
37
+
38
+ end
39
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'mustermann/error'
3
- require 'mustermann/simple_match'
3
+ require 'mustermann/match'
4
4
  require 'mustermann/equality_map'
5
5
  require 'uri'
6
6
 
@@ -84,12 +84,10 @@ module Mustermann
84
84
  end
85
85
 
86
86
  # @param [String] string The string to match against
87
- # @return [MatchData, Mustermann::SimpleMatch, nil] MatchData or similar object if the pattern matches.
88
- # @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-match Regexp#match
89
- # @see http://ruby-doc.org/core-2.0/MatchData.html MatchData
90
- # @see Mustermann::SimpleMatch
87
+ # @return [Mustermann::Match, nil] the match object if the pattern matches.
88
+ # @see Mustermann::Match
91
89
  def match(string)
92
- SimpleMatch.new(string) if self === string
90
+ Match.new(self, string) if self === string
93
91
  end
94
92
 
95
93
  # @param [String] string The string to match against
@@ -110,7 +108,7 @@ module Mustermann
110
108
  # Used by Ruby internally for hashing.
111
109
  # @return [Integer] same has value for patterns that are equal
112
110
  def hash
113
- self.class.hash | @string.hash | options.hash
111
+ self.class.hash ^ @string.hash ^ options.hash
114
112
  end
115
113
 
116
114
  # Two patterns are considered equal if they are of the same type, have the same pattern string
@@ -163,11 +161,11 @@ module Mustermann
163
161
  # pattern.peek("/Frank/Sinatra") # => #<MatchData "/Frank" name:"Frank">
164
162
  #
165
163
  # @param [String] string The string to match against
166
- # @return [MatchData, Mustermann::SimpleMatch, nil] MatchData or similar object if the pattern matches.
164
+ # @return [Mustermann::Match, nil] MatchData or similar object if the pattern matches.
167
165
  # @see #peek_params
168
166
  def peek_match(string)
169
167
  matched = peek(string)
170
- match(matched) if matched
168
+ Match.new(self, matched, {}, post_match: string[matched.size..-1]) if matched
171
169
  end
172
170
 
173
171
  # Tries to match the pattern against the beginning of the string (as opposed to the full string).
@@ -184,33 +182,12 @@ module Mustermann
184
182
  # @return [Array<Hash, Integer>, nil] Array with params hash and length of substing if matched, nil otherwise
185
183
  def peek_params(string)
186
184
  match = peek_match(string)
187
- [params(captures: match), match.to_s.size] if match
188
- end
189
-
190
- # @return [Hash{String: Array<Integer>}] capture names mapped to capture index.
191
- # @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-named_captures Regexp#named_captures
192
- def named_captures
193
- {}
194
- end
195
-
196
- # @return [Array<String>] capture names.
197
- # @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-names Regexp#names
198
- def names
199
- []
185
+ match ? [match.params, match.string.size] : nil
200
186
  end
201
187
 
202
188
  # @param [String] string the string to match against
203
189
  # @return [Hash{String: String, Array<String>}, nil] Sinatra style params if pattern matches.
204
- def params(string = nil, captures: nil, offset: 0)
205
- return unless captures ||= match(string)
206
- params = named_captures.map do |name, positions|
207
- values = positions.map { |pos| map_param(name, captures[pos + offset]) }.flatten
208
- values = values.first if values.size < 2 and not always_array? name
209
- [name, values]
210
- end
211
-
212
- Hash[params]
213
- end
190
+ def params(string = nil) = match(string)&.params
214
191
 
215
192
  # @note This method is only implemented by certain subclasses.
216
193
  #
@@ -42,6 +42,6 @@ module Mustermann
42
42
  version('4.2') { on(?\\) { |c| node(:char, expect(/./)) } }
43
43
 
44
44
  # Rails 5.0 fixes |
45
- version('5.0') { on(?|) { |c| node(:or) }}
45
+ version('5', '6', '7', '8') { on(?|) { |c| node(:or) }}
46
46
  end
47
47
  end
@@ -32,17 +32,32 @@ module Mustermann
32
32
  # @param (see Mustermann::Pattern#peek_match)
33
33
  # @return (see Mustermann::Pattern#peek_match)
34
34
  # @see (see Mustermann::Pattern#peek_match)
35
- def peek_match(string)
36
- @peek_regexp.match(string)
37
- end
35
+ def peek_match(string) = build_match(@peek_regexp.match(string))
36
+
37
+ def match(string) = build_match(@regexp.match(string))
38
+
39
+ # private
40
+
41
+ # def build_match(match)
42
+ # return unless match
43
+ # Match.new(self, match.string, match.named_captures, post_match: match.post_match, pre_match: match.pre_match)
44
+ # end
38
45
 
39
46
  extend Forwardable
40
- def_delegators :regexp, :===, :=~, :match, :names, :named_captures
47
+ def_delegators :regexp, :===, :=~, :names
48
+
49
+ private
41
50
 
42
- def compile(**options)
43
- raise NotImplementedError, 'subclass responsibility'
51
+ def build_match(match)
52
+ return unless match
53
+ params = match.regexp.named_captures.to_h do |name, positions|
54
+ value = positions.size < 2 && !always_array?(name) ? map_param(name, match[name]) :
55
+ positions.flat_map { |pos| map_param(name, match[pos]) }
56
+ [name, value]
57
+ end
58
+ Match.new(self, match.to_s, params, post_match: match.post_match, pre_match: match.pre_match)
44
59
  end
45
60
 
46
- private :compile
61
+ def compile(**options) = raise NotImplementedError, 'subclass responsibility'
47
62
  end
48
63
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+ require 'mustermann'
3
+ require 'mustermann/set'
4
+
5
+ module Mustermann
6
+ # An extremely simple, Rack-compatible router implementation using {Mustermann::Set} for pattern matching.
7
+ #
8
+ # @example Basic usage
9
+ # require 'mustermann/router'
10
+ #
11
+ # router = Mustermann::Router.new do
12
+ # get "/hello/:name" do |env|
13
+ # name = env["mustermann.match"][:name]
14
+ # [200, { "Content-Type" => "text/plain" }, ["Hello, #{name}!"]]
15
+ # end
16
+ # end
17
+ #
18
+ # # in config.ru
19
+ # run router
20
+ #
21
+ # @example Routing to other applications
22
+ # router = Mustermann::Router.new do
23
+ # get "/users", MyApp::Users::Index
24
+ # get "/users/:id", MyApp::Users::Show
25
+ # post "/users", MyApp::Users::Create
26
+ # fallback MyApp::NotFound
27
+ # end
28
+ #
29
+ # router.path_for(MyApp::Users::Show, id: 42) # => "/users/42"
30
+ #
31
+ # @example As middleware
32
+ # use Mustermann::Router do
33
+ # get("/up") { [200, { "Content-Type" => "text/plain" }, ["Up!"]] }
34
+ # end
35
+ #
36
+ # run MyApp
37
+ #
38
+ # @see Mustermann::Set
39
+ # @see https://rack.github.io/rack/
40
+ class Router
41
+ NOT_FOUND = [404, { "Content-Type" => "text/plain", "X-Cascade" => "pass" }, ["Not found"]].freeze
42
+ VERBS = %w[GET HEAD POST PUT PATCH DELETE OPTIONS LINK UNLINK].freeze
43
+ private_constant :VERBS, :NOT_FOUND
44
+
45
+ # Initializes a new router.
46
+ # @param key [String] The key under which the route match will be stored in the Rack environment hash (default: "mustermann.match").
47
+ # @param options [Hash] Options to be passed to the Mustermann patterns.
48
+ def initialize(fallback = nil, key: "mustermann.match", **options, &block)
49
+ @key = key
50
+ @sets = VERBS.to_h { |verb| [verb, Set.new] }
51
+ @options = options
52
+ @fallback = fallback || ->(env) { NOT_FOUND }
53
+ instance_exec(&block) if block_given?
54
+ end
55
+
56
+ # @param env [Hash] The Rack environment hash for the request.
57
+ # @return [Array] The Rack response array (status, headers, body).
58
+ def call(env)
59
+ if routes = @sets[env["REQUEST_METHOD"]] and match = routes.match(env["PATH_INFO"] || "/")
60
+ env = env.merge(@key => match)
61
+ return match.value.call(env)
62
+ end
63
+ @fallback.call(env)
64
+ end
65
+
66
+ def fallback(fallback = nil, &block) = @fallback = fallback || block || @fallback
67
+
68
+ # Adds a route for the given verb and pattern, with the given target.
69
+ #
70
+ # @note Shorthand methods, like `get`, `post`, etc. dynamically are defined for all supported verbs.
71
+ #
72
+ # @param verb [String] HTTP verb (e.g. "GET", "POST")
73
+ # @param pattern [String, Mustermann::Pattern] Pattern string or Mustermann pattern (e.g. "/users/:id")
74
+ # @param target [#call, nil] The Rack application or middleware to call when the route matches. Can be passed a block as well.
75
+ # @yield [env] Block to be used as the target if no explicit target is given.
76
+ # @yieldparam env [Hash] The Rack environment hash for the request.
77
+ # @return [void]
78
+ def route(verb, pattern, target = nil, **options, &block)
79
+ raise ArgumentError, "need to provide target, :to or a block" unless target || block
80
+ raise ArgumentError, "unknown verb: #{verb}" unless VERBS.include?(verb)
81
+ pattern = Mustermann.new(pattern, **@options, **options)
82
+ @sets[verb].add(pattern, target || block)
83
+ end
84
+
85
+ # Helps generate links
86
+ #
87
+ # @param app [#call] The Rack application or middleware for which to generate the path.
88
+ # @param (see Mustermann::Expander#expand)
89
+ # @return [String] The generated path.
90
+ def path_for(app, behavior = nil, params = {})
91
+ set = @sets.values.find { |s| s.has_value?(app) } || @sets[VERBS.first]
92
+ set.expand(app, behavior, params)
93
+ end
94
+
95
+ VERBS.each do |verb|
96
+ define_method(verb.downcase) { |*args, **opts, &block| route(verb, *args, **opts, &block) }
97
+ end
98
+ end
99
+ end