diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 268f75c..bd4dda0 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -10,30 +10,21 @@ on:
jobs:
test:
- name: ruby ${{ matrix.ruby }}, sinatra ${{ matrix.sinatra }}
+ name: ruby ${{ matrix.ruby }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
ruby:
- - '2.7'
- - '3.0'
- - '3.1'
- - '3.2'
- '3.3'
- '3.4'
- sinatra:
- - ~> 4.0.0 # current stable
- include:
- - { ruby: 3.4, sinatra: head }
- - { ruby: head, sinatra: head }
- - { ruby: jruby, sinatra: ~> 4.0.0 }
- - { ruby: jruby-head, sinatra: head }
- - { ruby: truffleruby, sinatra: ~> 4.0.0 }
- - { ruby: truffleruby-head, sinatra: head }
- env:
- sinatra: ${{ matrix.sinatra }}
+ - '4.0'
+ - head
+ - jruby
+ - jruby-head
+ - truffleruby
+ - truffleruby-head
steps:
- uses: actions/checkout@v4
diff --git a/Gemfile b/Gemfile
index 9c7b2e8..febb273 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,8 +5,3 @@ path '.' do
Support::Projects.each { |name| gem(name) }
gem 'support', group: :development
end
-
-sinatra_version = ENV['sinatra'].to_s
-sinatra_version = nil if sinatra_version.empty? || (sinatra_version == 'stable')
-sinatra_version = { github: 'sinatra/sinatra' } if sinatra_version == 'head'
-gem 'sinatra', sinatra_version
diff --git a/README.md b/README.md
index a3d63a7..735fccb 100644
--- a/README.md
+++ b/README.md
@@ -160,7 +160,7 @@ Any software using Mustermann is obviously compatible with at least one of the a
## Requirements
-Ruby 2.7+ compatible Ruby implementation (MRI, JRuby, and TruffleRuby are tested).
+Ruby 3.3+ compatible Ruby implementation (MRI, JRuby, and TruffleRuby are tested).
## Release History
diff --git a/mustermann-contrib/README.md b/mustermann-contrib/README.md
index 4e0d96b..2a37679 100644
--- a/mustermann-contrib/README.md
+++ b/mustermann-contrib/README.md
@@ -258,6 +258,52 @@ Mustermann::FileUtils.cp_r(':base.:ext' => ':base.bak.:ext')
Mustermann::FileUtils.ln_s('lib/:name.rb' => 'bin/:name')
```
+
+# Mapper for Mustermann
+
+## Overview
+
+`Mustermann::Mapper` transforms strings according to a set of pattern mappings. Each mapping pairs an input pattern (used to extract parameters) with one or more output patterns (used to expand the result). All mappings that match are applied in insertion order.
+
+``` ruby
+require 'mustermann/mapper'
+
+mapper = Mustermann::Mapper.new("/:page(.:format)?" => ["/:page/view.:format", "/:page/view.html"])
+mapper['/foo'] # => "/foo/view.html"
+mapper['/foo.xml'] # => "/foo/view.xml"
+mapper['/foo/bar'] # => "/foo/bar"
+```
+
+You can also pass additional values at conversion time to supplement or override captured parameters:
+
+``` ruby
+mapper = Mustermann::Mapper.new("/:example" => "(/:prefix)?/:example.html")
+mapper['/foo', prefix: 'en'] # => "/en/foo.html"
+```
+
+## Building a Mapper
+
+Mappings can be supplied as a hash, added via `[]=`, or built with a block:
+
+``` ruby
+# Hash argument
+mapper = Mustermann::Mapper.new("/:a" => "/:a.html", "/:a/:b" => "/:b/:a")
+
+# Block (zero-argument, returns a hash)
+mapper = Mustermann::Mapper.new { { "/:a" => "/:a.html" } }
+
+# Block (one-argument, imperative)
+mapper = Mustermann::Mapper.new do |m|
+ m["/:a"] = "/:a.html"
+end
+
+# Incremental
+mapper = Mustermann::Mapper.new
+mapper["/:a"] = "/:a.html"
+```
+
+The output value may be a String, a `Mustermann::Pattern`, an `Array` of either (tried in order until one expands successfully), or a `Mustermann::Expander` directly.
+
# Flask Syntax for Mustermann
@@ -759,6 +805,43 @@ You can also pass in default options for ad hoc patterns when creating the scann
scanner = Mustermann::StringScanner.new(input, type: :shell)
```
+
+# `to_pattern` for Mustermann
+
+## Overview
+
+`mustermann/to_pattern` adds a `to_pattern` method to `String`, `Symbol`, `Regexp`, `Array`, and `Mustermann::Pattern`, and provides the `Mustermann::ToPattern` mixin so you can add the same method to your own classes.
+
+``` ruby
+require 'mustermann/to_pattern'
+
+"/foo".to_pattern # => #
+"/foo".to_pattern(type: :rails) # => #
+%r{/foo}.to_pattern # => #
+"/foo".to_pattern.to_pattern # => #
+```
+
+## `Mustermann::ToPattern` mixin
+
+Include `Mustermann::ToPattern` in any class to get a `to_pattern` method driven by its `to_s` output:
+
+``` ruby
+require 'mustermann/to_pattern'
+
+class MyRoute
+ include Mustermann::ToPattern
+
+ def to_s
+ "/users/:id"
+ end
+end
+
+MyRoute.new.to_pattern # => #
+MyRoute.new.to_pattern(type: :rails) # => #
+```
+
+If your class wraps another object (via `__getobj__`, as in `Delegator` subclasses), `to_pattern` will unwrap it before converting.
+
# URI Template Syntax for Mustermann
diff --git a/mustermann/lib/mustermann/mapper.rb b/mustermann-contrib/lib/mustermann/mapper.rb
similarity index 87%
rename from mustermann/lib/mustermann/mapper.rb
rename to mustermann-contrib/lib/mustermann/mapper.rb
index d2cc5f5..3d6aa31 100644
--- a/mustermann/lib/mustermann/mapper.rb
+++ b/mustermann-contrib/lib/mustermann/mapper.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'mustermann'
require 'mustermann/expander'
+require 'mustermann/set'
module Mustermann
# A mapper allows mapping one string to another based on pattern parsing and expanding.
@@ -42,9 +43,9 @@ class Mapper
# require 'mustermann/mapper'
# Mustermann::Mapper.new({"/:foo" => "/:foo.html"}, type: :rails)
def initialize(map = {}, additional_values: :ignore, **options, &block)
- @map = []
@options = options
@additional_values = additional_values
+ @set = Set.new(use_trie: false, use_cache: false, **options)
block.arity == 0 ? update(yield) : yield(self) if block
update(map) if map
end
@@ -54,16 +55,13 @@ def initialize(map = {}, additional_values: :ignore, **options, &block)
# @param map [Hash{String, Pattern: String, Pattern, Arry, Expander}] the mapping
def update(map)
map.to_h.each_pair do |input, output|
- input = Mustermann.new(input, **@options)
output = Expander.new(*output, additional_values: @additional_values, **@options) unless output.is_a? Expander
- @map << [input, output]
+ @set.add(input, output)
end
end
# @return [Hash{Patttern: Expander}] Hash version of the mapper.
- def to_h
- Hash[@map]
- end
+ def to_h = @set.patterns.to_h { [_1, @set[_1]] }
# Convert a string according to mappings. You can pass in additional params.
#
@@ -71,10 +69,9 @@ def to_h
# mapper = Mustermann::Mapper.new("/:example" => "(/:prefix)?/:example.html")
#
def convert(input, values = {})
- @map.inject(input) do |current, (pattern, expander)|
- params = pattern.params(current)
- params &&= Hash[values.merge(params).map { |k,v| [k.to_s, v] }]
- expander.expandable?(params) ? expander.expand(params) : current
+ @set.match_all(input).inject(input) do |current, m|
+ params = Hash[values.merge(m.params).map { |k, v| [k.to_s, v] }]
+ m.value.expandable?(params) ? m.value.expand(params) : current
end
end
diff --git a/mustermann/lib/mustermann/pattern_cache.rb b/mustermann-contrib/lib/mustermann/pattern_cache.rb
similarity index 97%
rename from mustermann/lib/mustermann/pattern_cache.rb
rename to mustermann-contrib/lib/mustermann/pattern_cache.rb
index cdab88d..861ab62 100644
--- a/mustermann/lib/mustermann/pattern_cache.rb
+++ b/mustermann-contrib/lib/mustermann/pattern_cache.rb
@@ -22,7 +22,7 @@ module Mustermann
class PatternCache
# @param [Hash] pattern_options default options used for {#create_pattern}
def initialize(**pattern_options)
- @cached = Set.new
+ @cached = ::Set.new
@mutex = Mutex.new
@pattern_options = pattern_options
end
diff --git a/mustermann-contrib/lib/mustermann/shell.rb b/mustermann-contrib/lib/mustermann/shell.rb
index 0ed4488..e79d7ac 100644
--- a/mustermann-contrib/lib/mustermann/shell.rb
+++ b/mustermann-contrib/lib/mustermann/shell.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'mustermann'
require 'mustermann/pattern'
-require 'mustermann/simple_match'
module Mustermann
# Matches strings that are identical to the pattern.
diff --git a/mustermann/lib/mustermann/to_pattern.rb b/mustermann-contrib/lib/mustermann/to_pattern.rb
similarity index 100%
rename from mustermann/lib/mustermann/to_pattern.rb
rename to mustermann-contrib/lib/mustermann/to_pattern.rb
diff --git a/mustermann-contrib/mustermann-contrib.gemspec b/mustermann-contrib/mustermann-contrib.gemspec
index a1b5b79..005b550 100644
--- a/mustermann-contrib/mustermann-contrib.gemspec
+++ b/mustermann-contrib/mustermann-contrib.gemspec
@@ -10,7 +10,7 @@ Gem::Specification.new do |s|
s.summary = %q{Collection of extensions for Mustermann}
s.description = %q{Adds many plugins to Mustermann}
s.license = 'MIT'
- s.required_ruby_version = '>= 2.7.0'
+ s.required_ruby_version = '>= 3.3.0'
s.files = `git ls-files lib`.split("\n") + ['LICENSE', 'README.md']
s.add_dependency 'mustermann', Mustermann::VERSION
diff --git a/mustermann-contrib/spec/cake_spec.rb b/mustermann-contrib/spec/cake_spec.rb
index ee185da..ec4dfe4 100644
--- a/mustermann-contrib/spec/cake_spec.rb
+++ b/mustermann-contrib/spec/cake_spec.rb
@@ -48,8 +48,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/foo?') }
it { should_not match('/foo/bar') }
@@ -81,9 +81,9 @@
end
pattern '/**' do
- it { should match('/') .capturing splat: '' }
- it { should match('/foo') .capturing splat: 'foo' }
- it { should match('/foo/bar') .capturing splat: 'foo/bar' }
+ it { should match('/') .capturing splat: [''] }
+ it { should match('/foo') .capturing splat: ['foo'] }
+ it { should match('/foo/bar') .capturing splat: ['foo/bar'] }
example { pattern.params('/foo/bar') .should be == {"splat" => ["foo/bar"]} }
it { should generate_template('/{+splat}') }
diff --git a/mustermann-contrib/spec/express_spec.rb b/mustermann-contrib/spec/express_spec.rb
index 487f089..12a3001 100644
--- a/mustermann-contrib/spec/express_spec.rb
+++ b/mustermann-contrib/spec/express_spec.rb
@@ -48,8 +48,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/foo?') }
it { should_not match('/foo/bar') }
@@ -90,8 +90,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should match('/') }
it { should_not match('/foo?') }
@@ -174,15 +174,15 @@
pattern '/(.+)' do
it { should_not match('/') }
- it { should match('/foo') .capturing splat: 'foo' }
- it { should match('/foo/bar') .capturing splat: 'foo/bar' }
+ it { should match('/foo') .capturing splat: ['foo'] }
+ it { should match('/foo/bar') .capturing splat: ['foo/bar'] }
it { should generate_template('/{+splat}') }
end
pattern '/(foo(a|b))' do
it { should_not match('/') }
- it { should match('/fooa') .capturing splat: 'fooa' }
- it { should match('/foob') .capturing splat: 'foob' }
+ it { should match('/fooa') .capturing splat: ['fooa'] }
+ it { should match('/foob') .capturing splat: ['foob'] }
it { should generate_template('/{+splat}') }
end
diff --git a/mustermann-contrib/spec/flask_spec.rb b/mustermann-contrib/spec/flask_spec.rb
index 7fea5ba..bc374ee 100644
--- a/mustermann-contrib/spec/flask_spec.rb
+++ b/mustermann-contrib/spec/flask_spec.rb
@@ -48,8 +48,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/foo?') }
it { should_not match('/foo/bar') }
@@ -75,8 +75,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/foo?') }
it { should_not match('/foo/bar') }
@@ -102,8 +102,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/f') }
it { should_not match('/foo?') }
@@ -147,7 +147,7 @@
end
pattern '/' do
- it { should match('/42').capturing foo: '42' }
+ it { should match('/42').capturing foo: 42 }
it { should_not match('/1.0') }
it { should_not match('/.5') }
@@ -168,7 +168,7 @@
end
pattern '/' do
- it { should match('/42').capturing foo: '42' }
+ it { should match('/42').capturing foo: 42 }
it { should_not match('/1.0') }
it { should_not match('/.5') }
@@ -289,9 +289,9 @@
end
pattern '///' do
- it { should match('/foo/42/42') .capturing foo: '42', bar: '42' }
- it { should match('/foo/1.0/1') .capturing foo: '1.0', bar: '1' }
- it { should match('/foo/.5/0') .capturing foo: '.5', bar: '0' }
+ it { should match('/foo/42/42') .capturing foo: 42.0, bar: 42 }
+ it { should match('/foo/1.0/1') .capturing foo: 1.0, bar: 1 }
+ it { should match('/foo/.5/0') .capturing foo: 0.5, bar: 0 }
it { should_not match('/foo/1/1.0') }
it { should_not match('/foo/1.0/1.0') }
@@ -324,7 +324,7 @@
converter = Struct.new(:convert).new(:upcase.to_proc)
pattern '/', converters: { foo: converter } do
- it { should match('/foo').capturing bar: 'foo' }
+ it { should match('/foo').capturing bar: 'FOO' }
example { pattern.params('/foo').should be == {"bar" => "FOO"} }
end
diff --git a/mustermann-contrib/spec/flask_subclass_spec.rb b/mustermann-contrib/spec/flask_subclass_spec.rb
index 6b68e53..aae78ee 100644
--- a/mustermann-contrib/spec/flask_subclass_spec.rb
+++ b/mustermann-contrib/spec/flask_subclass_spec.rb
@@ -51,8 +51,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/foo?') }
it { should_not match('/foo/bar') }
@@ -78,8 +78,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/foo?') }
it { should_not match('/foo/bar') }
@@ -105,8 +105,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/f') }
it { should_not match('/foo?') }
@@ -150,7 +150,7 @@
end
pattern '/' do
- it { should match('/42').capturing foo: '42' }
+ it { should match('/42').capturing foo: 42 }
it { should_not match('/1.0') }
it { should_not match('/.5') }
@@ -171,7 +171,7 @@
end
pattern '/' do
- it { should match('/42').capturing foo: '42' }
+ it { should match('/42').capturing foo: 42 }
it { should_not match('/1.0') }
it { should_not match('/.5') }
@@ -292,9 +292,9 @@
end
pattern '///' do
- it { should match('/foo/42/42') .capturing foo: '42', bar: '42' }
- it { should match('/foo/1.0/1') .capturing foo: '1.0', bar: '1' }
- it { should match('/foo/.5/0') .capturing foo: '.5', bar: '0' }
+ it { should match('/foo/42/42') .capturing foo: 42.0, bar: 42 }
+ it { should match('/foo/1.0/1') .capturing foo: 1.0, bar: 1 }
+ it { should match('/foo/.5/0') .capturing foo: 0.5, bar: 0 }
it { should_not match('/foo/1/1.0') }
it { should_not match('/foo/1.0/1.0') }
diff --git a/mustermann/spec/mapper_spec.rb b/mustermann-contrib/spec/mapper_spec.rb
similarity index 100%
rename from mustermann/spec/mapper_spec.rb
rename to mustermann-contrib/spec/mapper_spec.rb
diff --git a/mustermann-contrib/spec/pyramid_spec.rb b/mustermann-contrib/spec/pyramid_spec.rb
index 019a09c..fc12ce2 100644
--- a/mustermann-contrib/spec/pyramid_spec.rb
+++ b/mustermann-contrib/spec/pyramid_spec.rb
@@ -48,8 +48,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/foo?') }
it { should_not match('/foo/bar') }
@@ -72,8 +72,8 @@
end
pattern '/*foo' do
- it { should match('/foo') .capturing foo: 'foo' }
- it { should match('/foo/bar') .capturing foo: 'foo/bar' }
+ it { should match('/foo') .capturing foo: ['foo'] }
+ it { should match('/foo/bar') .capturing foo: ['foo', 'bar'] }
it { should expand .to('/') }
it { should expand(foo: nil) .to('/') }
diff --git a/mustermann-contrib/spec/simple_spec.rb b/mustermann-contrib/spec/simple_spec.rb
index 06012e2..50eee70 100644
--- a/mustermann-contrib/spec/simple_spec.rb
+++ b/mustermann-contrib/spec/simple_spec.rb
@@ -35,8 +35,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/foo?') }
it { should_not match('/foo/bar') }
@@ -76,17 +76,17 @@
end
pattern '/*' do
- it { should match('/') .capturing splat: '' }
- it { should match('/foo') .capturing splat: 'foo' }
- it { should match('/foo/bar') .capturing splat: 'foo/bar' }
+ it { should match('/') .capturing splat: [''] }
+ it { should match('/foo') .capturing splat: ['foo'] }
+ it { should match('/foo/bar') .capturing splat: ['foo/bar'] }
example { pattern.params('/foo').should be == {"splat" => ["foo"]} }
end
pattern '/:foo/*' do
- it { should match("/foo/bar/baz") .capturing foo: 'foo', splat: 'bar/baz' }
- it { should match("/foo/") .capturing foo: 'foo', splat: '' }
- it { should match('/h%20w/h%20a%20y') .capturing foo: 'h%20w', splat: 'h%20a%20y' }
+ it { should match("/foo/bar/baz") .capturing foo: 'foo', splat: ['bar/baz'] }
+ it { should match("/foo/") .capturing foo: 'foo', splat: [''] }
+ it { should match('/h%20w/h%20a%20y') .capturing foo: 'h w', splat: ['h a y'] }
it { should_not match('/foo') }
example { pattern.params('/bar/foo').should be == {"splat" => ["foo"], "foo" => "bar"} }
@@ -116,12 +116,6 @@
pattern '/*/:foo/*/*' do
it { should match('/bar/foo/bling/baz/boom') }
- it "should capture all splat parts" do
- match = pattern.match('/bar/foo/bling/baz/boom')
- match.captures.should be == ['bar', 'foo', 'bling', 'baz/boom']
- match.names.should be == ['splat', 'foo']
- end
-
it 'should map to proper params' do
pattern.params('/bar/foo/bling/baz/boom').should be == {
"foo" => "foo", "splat" => ['bar', 'bling', 'baz/boom']
@@ -139,8 +133,8 @@
it { should match('/pony%2Ejpg') .capturing file: 'pony', ext: 'jpg' }
it { should match('/pony%2ejpg') .capturing file: 'pony', ext: 'jpg' }
- it { should match('/pony%E6%AD%A3%2Ejpg') .capturing file: 'pony%E6%AD%A3', ext: 'jpg' }
- it { should match('/pony%e6%ad%a3%2ejpg') .capturing file: 'pony%e6%ad%a3', ext: 'jpg' }
+ it { should match('/pony%E6%AD%A3%2Ejpg') .capturing file: 'pony正', ext: 'jpg' }
+ it { should match('/pony%e6%ad%a3%2ejpg') .capturing file: 'pony正', ext: 'jpg' }
it { should match('/pony正%2Ejpg') .capturing file: 'pony正', ext: 'jpg' }
it { should match('/pony正%2ejpg') .capturing file: 'pony正', ext: 'jpg' }
it { should match('/pony正..jpg') .capturing file: 'pony正.', ext: 'jpg' }
@@ -153,7 +147,7 @@
it { should match('/2/test.bar') .capturing id: '2' }
it { should match('/2E/test.bar') .capturing id: '2E' }
it { should match('/2e/test.bar') .capturing id: '2e' }
- it { should match('/%2E/test.bar') .capturing id: '%2E' }
+ it { should match('/%2E/test.bar') .capturing id: '.' }
end
pattern '/10/:id' do
diff --git a/mustermann-contrib/spec/template_spec.rb b/mustermann-contrib/spec/template_spec.rb
index 5b5aed0..5c57677 100644
--- a/mustermann-contrib/spec/template_spec.rb
+++ b/mustermann-contrib/spec/template_spec.rb
@@ -86,7 +86,7 @@
pattern '/hello/{person}' do
it { should match('/hello/Frank').capturing person: 'Frank' }
it { should match('/hello/a_b~c').capturing person: 'a_b~c' }
- it { should match('/hello/a.%20').capturing person: 'a.%20' }
+ it { should match('/hello/a.%20').capturing person: 'a. ' }
it { should_not match('/hello/:') }
it { should_not match('/hello//') }
@@ -122,9 +122,9 @@
pattern '/hello/{+person}' do
it { should match('/hello/Frank') .capturing person: 'Frank' }
it { should match('/hello/a_b~c') .capturing person: 'a_b~c' }
- it { should match('/hello/a.%20') .capturing person: 'a.%20' }
- it { should match('/hello/a/%20') .capturing person: 'a/%20' }
- it { should match('/hello/:') .capturing person: ?: }
+ it { should match('/hello/a.%20') .capturing person: 'a. ' }
+ it { should match('/hello/a/%20') .capturing person: 'a/ ' }
+ it { should match('/hello/:') .capturing person: ?: }
it { should match('/hello//') .capturing person: ?/ }
it { should match('/hello/?') .capturing person: ?? }
it { should match('/hello/#') .capturing person: ?# }
@@ -155,8 +155,8 @@
pattern '/hello/{#person}' do
it { should match('/hello/#Frank') .capturing person: 'Frank' }
it { should match('/hello/#a_b~c') .capturing person: 'a_b~c' }
- it { should match('/hello/#a.%20') .capturing person: 'a.%20' }
- it { should match('/hello/#a/%20') .capturing person: 'a/%20' }
+ it { should match('/hello/#a.%20') .capturing person: 'a. ' }
+ it { should match('/hello/#a/%20') .capturing person: 'a/ ' }
it { should match('/hello/#:') .capturing person: ?: }
it { should match('/hello/#/') .capturing person: ?/ }
it { should match('/hello/#?') .capturing person: ?? }
@@ -528,9 +528,9 @@
context 'expand' do
pattern '{a*}' do
- it { should match('a') .capturing a: 'a' }
- it { should match('a,b') .capturing a: 'a,b' }
- it { should match('a,b,c') .capturing a: 'a,b,c' }
+ it { should match('a') .capturing a: ['a'] }
+ it { should match('a,b') .capturing a: ['a', 'b'] }
+ it { should match('a,b,c') .capturing a: ['a', 'b', 'c'] }
it { should_not match('a,b/c') }
it { should_not match('a,') }
@@ -539,8 +539,8 @@
end
pattern '{a*},{b}' do
- it { should match('a,b') .capturing a: 'a', b: 'b' }
- it { should match('a,b,c') .capturing a: 'a,b', b: 'c' }
+ it { should match('a,b') .capturing a: ['a'], b: 'b' }
+ it { should match('a,b,c') .capturing a: ['a', 'b'], b: 'c' }
it { should_not match('a,b/c') }
it { should_not match('a,') }
@@ -549,8 +549,8 @@
end
pattern '{a*,b}' do
- it { should match('a,b') .capturing a: 'a', b: 'b' }
- it { should match('a,b,c') .capturing a: 'a,b', b: 'c' }
+ it { should match('a,b') .capturing a: ['a'], b: 'b' }
+ it { should match('a,b,c') .capturing a: ['a', 'b'], b: 'c' }
it { should_not match('a,b/c') }
it { should_not match('a,') }
@@ -581,15 +581,15 @@
context 'expand' do
pattern '{+a*}' do
- it { should match('a') .capturing a: 'a' }
- it { should match('a,b') .capturing a: 'a,b' }
- it { should match('a,b,c') .capturing a: 'a,b,c' }
- it { should match('a,b/c') .capturing a: 'a,b/c' }
+ it { should match('a') .capturing a: ['a'] }
+ it { should match('a,b') .capturing a: ['a', 'b'] }
+ it { should match('a,b,c') .capturing a: ['a', 'b', 'c'] }
+ it { should match('a,b/c') .capturing a: ['a', 'b/c'] }
end
pattern '{+a*},{b}' do
- it { should match('a,b') .capturing a: 'a', b: 'b' }
- it { should match('a,b,c') .capturing a: 'a,b', b: 'c' }
+ it { should match('a,b') .capturing a: ['a'], b: 'b' }
+ it { should match('a,b,c') .capturing a: ['a', 'b'], b: 'c' }
it { should_not match('a,b/c') }
it { should_not match('a,') }
@@ -615,18 +615,18 @@
context 'expand' do
pattern '{#a*}' do
- it { should match('#a') .capturing a: 'a' }
- it { should match('#a,b') .capturing a: 'a,b' }
- it { should match('#a,b,c') .capturing a: 'a,b,c' }
- it { should match('#a,b/c') .capturing a: 'a,b/c' }
+ it { should match('#a') .capturing a: ['a'] }
+ it { should match('#a,b') .capturing a: ['a', 'b'] }
+ it { should match('#a,b,c') .capturing a: ['a', 'b', 'c'] }
+ it { should match('#a,b/c') .capturing a: ['a', 'b/c'] }
example { pattern.params('#a,b').should be == { 'a' => ['a', 'b'] }}
example { pattern.params('#a,b,c').should be == { 'a' => ['a', 'b', 'c'] }}
end
pattern '{#a*,b}' do
- it { should match('#a,b') .capturing a: 'a', b: 'b' }
- it { should match('#a,b,c') .capturing a: 'a,b', b: 'c' }
+ it { should match('#a,b') .capturing a: ['a'], b: 'b' }
+ it { should match('#a,b,c') .capturing a: ['a', 'b'], b: 'c' }
it { should_not match('#a,') }
example { pattern.params('#a,b').should be == { 'a' => ['a'], 'b' => 'b' }}
@@ -651,16 +651,16 @@
context 'expand' do
pattern '{.a*}' do
- it { should match('.a') .capturing a: 'a' }
- it { should match('.a.b') .capturing a: 'a.b' }
- it { should match('.a.b.c') .capturing a: 'a.b.c' }
+ it { should match('.a') .capturing a: ['a'] }
+ it { should match('.a.b') .capturing a: ['a', 'b'] }
+ it { should match('.a.b.c') .capturing a: ['a', 'b', 'c'] }
it { should_not match('.a.b,c') }
it { should_not match('.a,') }
end
pattern '{.a*,b}' do
- it { should match('.a.b') .capturing a: 'a', b: 'b' }
- it { should match('.a.b.c') .capturing a: 'a.b', b: 'c' }
+ it { should match('.a.b') .capturing a: ['a'], b: 'b' }
+ it { should match('.a.b.c') .capturing a: ['a', 'b'], b: 'c' }
it { should_not match('.a.b/c') }
it { should_not match('.a.') }
@@ -686,16 +686,16 @@
context 'expand' do
pattern '{/a*}' do
- it { should match('/a') .capturing a: 'a' }
- it { should match('/a/b') .capturing a: 'a/b' }
- it { should match('/a/b/c') .capturing a: 'a/b/c' }
+ it { should match('/a') .capturing a: ['a'] }
+ it { should match('/a/b') .capturing a: ['a', 'b'] }
+ it { should match('/a/b/c') .capturing a: ['a', 'b', 'c'] }
it { should_not match('/a/b,c') }
it { should_not match('/a,') }
end
pattern '{/a*,b}' do
- it { should match('/a/b') .capturing a: 'a', b: 'b' }
- it { should match('/a/b/c') .capturing a: 'a/b', b: 'c' }
+ it { should match('/a/b') .capturing a: ['a'], b: 'b' }
+ it { should match('/a/b/c') .capturing a: ['a', 'b'], b: 'c' }
it { should_not match('/a/b,c') }
it { should_not match('/a/') }
@@ -721,16 +721,16 @@
context 'expand' do
pattern '{;a*}' do
- it { should match(';a=1') .capturing a: 'a=1' }
- it { should match(';a=1;a=2') .capturing a: 'a=1;a=2' }
- it { should match(';a=1;a=2;a=3') .capturing a: 'a=1;a=2;a=3' }
+ it { should match(';a=1') .capturing a: ['1'] }
+ it { should match(';a=1;a=2') .capturing a: ['1', '2'] }
+ it { should match(';a=1;a=2;a=3') .capturing a: ['1', '2', '3'] }
it { should_not match(';a=1;a=2;b=3') }
it { should_not match(';a=1;a=2;a=3,') }
end
pattern '{;a*,b}' do
- it { should match(';a=1;b') .capturing a: 'a=1', b: nil }
- it { should match(';a=2;a=2;b=1') .capturing a: 'a=2;a=2', b: '1' }
+ it { should match(';a=1;b') .capturing a: ['1'], b: nil }
+ it { should match(';a=2;a=2;b=1') .capturing a: ['2', '2'], b: '1' }
it { should_not match(';a;b;c') }
it { should_not match(';a;') }
@@ -755,16 +755,16 @@
context 'expand' do
pattern '{?a*}' do
- it { should match('?a=1') .capturing a: 'a=1' }
- it { should match('?a=1&a=2') .capturing a: 'a=1&a=2' }
- it { should match('?a=1&a=2&a=3') .capturing a: 'a=1&a=2&a=3' }
+ it { should match('?a=1') .capturing a: ['1'] }
+ it { should match('?a=1&a=2') .capturing a: ['1', '2'] }
+ it { should match('?a=1&a=2&a=3') .capturing a: ['1', '2', '3'] }
it { should_not match('?a=1&a=2&b=3') }
it { should_not match('?a=1&a=2&a=3,') }
end
pattern '{?a*,b}' do
- it { should match('?a=1&b') .capturing a: 'a=1', b: nil }
- it { should match('?a=2&a=2&b=1') .capturing a: 'a=2&a=2', b: '1' }
+ it { should match('?a=1&b') .capturing a: ['1'], b: nil }
+ it { should match('?a=2&a=2&b=1') .capturing a: ['2', '2'], b: '1' }
it { should_not match('?a&b&c') }
it { should_not match('?a&') }
@@ -789,16 +789,16 @@
context 'expand' do
pattern '{&a*}' do
- it { should match('&a=1') .capturing a: 'a=1' }
- it { should match('&a=1&a=2') .capturing a: 'a=1&a=2' }
- it { should match('&a=1&a=2&a=3') .capturing a: 'a=1&a=2&a=3' }
+ it { should match('&a=1') .capturing a: ['1'] }
+ it { should match('&a=1&a=2') .capturing a: ['1', '2'] }
+ it { should match('&a=1&a=2&a=3') .capturing a: ['1', '2', '3'] }
it { should_not match('&a=1&a=2&b=3') }
it { should_not match('&a=1&a=2&a=3,') }
end
pattern '{&a*,b}' do
- it { should match('&a=1&b') .capturing a: 'a=1', b: nil }
- it { should match('&a=2&a=2&b=1') .capturing a: 'a=2&a=2', b: '1' }
+ it { should match('&a=1&b') .capturing a: ['1'], b: nil }
+ it { should match('&a=2&a=2&b=1') .capturing a: ['2', '2'], b: '1' }
it { should_not match('&a&b&c') }
it { should_not match('&a&') }
diff --git a/mustermann/spec/to_pattern_spec.rb b/mustermann-contrib/spec/to_pattern_spec.rb
similarity index 100%
rename from mustermann/spec/to_pattern_spec.rb
rename to mustermann-contrib/spec/to_pattern_spec.rb
diff --git a/mustermann/README.md b/mustermann/README.md
index b6dee55..2cf7ec1 100644
--- a/mustermann/README.md
+++ b/mustermann/README.md
@@ -41,7 +41,7 @@ pattern.params('/a/b.c') # => { "prefix" => "a", splat => ["b", "c"] }
These features are included in the library, but not loaded by default
-* **[Mapper](#-mapper):** A simple tool for mapping one string to another based on patterns.
+* **[Pattern Set](#-pattern-set):** A collection of patterns with associated values, designed for building routing tables that dispatch efficiently as the number of routes grows.
* **[Sinatra Integration](#-sinatra-integration):** Mustermann can be used as a [Sinatra](http://www.sinatrarb.com/) extension. Sinatra 2.0 and beyond will use Mustermann by default.
@@ -123,16 +123,12 @@ pattern.match('/') # => nil
pattern.match('/home') # => #
pattern =~ '/home' # => 0
pattern === '/home' # => true (this allows using it in case statements)
-pattern.names # => ['page']
-pattern.names # => {"page"=>[1]}
pattern = Mustermann.new('/home', type: :identity)
pattern.match('/') # => nil
-pattern.match('/home') # => #
+pattern.match('/home') # => #
pattern =~ '/home' # => 0
pattern === '/home' # => true (this allows using it in case statements)
-pattern.names # => []
-pattern.names # => {}
```
Moreover, patterns based on regular expressions (all but `identity` and `shell`) automatically convert to regular expressions when needed:
@@ -179,7 +175,7 @@ Peeking gives the option to match a pattern against the beginning of a string ra
* `peek` returns the matching substring.
* `peek_size` returns the number of characters matching.
-* `peek_match` will return a `MatchData` or `Mustermann::SimpleMatch` (just like `match` does for the full string)
+* `peek_match` will return a `Mustermann::Match` (just like `match` does for the full string)
* `peek_params` will return the `params` hash parsed from the substring and the number of characters.
All of the above will turn `nil` if there was no match.
@@ -359,119 +355,118 @@ pattern = Mustermann.new(":name@:domain.:tld")
email = list.detect(&pattern) # => "example@email.com"
```
-
-## Mapper
+
+## Pattern Set
-
-You can use a mapper to transform strings according to two or more mappings:
+`Mustermann::Set` is a collection of patterns where each pattern is associated with an arbitrary value — typically a handler or action. A single call to `match` returns both the captured parameters and the value for the first matching pattern, making it straightforward to build a routing table.
``` ruby
-require 'mustermann/mapper'
-
-mapper = Mustermann::Mapper.new("/:page(.:format)?" => ["/:page/view.:format", "/:page/view.html"])
-mapper['/foo'] # => "/foo/view.html"
-mapper['/foo.xml'] # => "/foo/view.xml"
-mapper['/foo/bar'] # => "/foo/bar"
-```
+require 'mustermann/set'
-
-## Sinatra Integration
+set = Mustermann::Set.new
+set.add('/users/:id', :users_show)
+set.add('/posts/:id', :posts_show)
+set.add('/posts', :posts_index)
-Mustermann is used in Sinatra by default since version 2.0, for previous versions an [extension](https://github.com/sinatra/mustermann-sinatra-extension) is available.
+m = set.match('/users/42')
+m.value # => :users_show
+m.params['id'] # => '42'
-### Configuration
+set.match('/unknown') # => nil
+```
-You can change what pattern type you want to use for your app via the `pattern` option:
+You can supply the initial mapping directly to the constructor:
``` ruby
-require 'sinatra/base'
-require 'mustermann'
+set = Mustermann::Set.new(
+ '/users/:id' => :users_show,
+ '/posts/:id' => :posts_show
+)
+```
-class MyApp < Sinatra::Base
- register Mustermann
- set :pattern, type: :shell
+Or use a block for imperative setup:
- get '/images/*.png' do
- send_file request.path_info
- end
-
- get '/index{.htm,.html,}' do
- erb :index
- end
+``` ruby
+set = Mustermann::Set.new do |s|
+ s.add('/users/:id', :users_show)
+ s.add('/posts/:id', :posts_show)
end
```
-You can use the same setting for options:
+Pattern options such as `type:` are passed as keyword arguments and apply to all patterns in the set:
``` ruby
-require 'sinatra'
-require 'mustermann'
+set = Mustermann::Set.new(type: :rails)
+set.add('/:controller(/:action(/:id))', :route)
+```
-register Mustermann
-set :pattern, capture: { ext: %w[png jpg html txt] }
+### Values
-get '/:slug(.:ext)?' do
- # slug will be 'foo' for '/foo.png'
- # slug will be 'foo.bar' for '/foo.bar'
- # slug will be 'foo.bar' for '/foo.bar.html'
- params[:slug]
-end
+Each pattern can be associated with multiple values. `match` returns the first, while `match_all` returns one match per value:
+
+``` ruby
+set = Mustermann::Set.new
+set.add('/users/:id', :admin_handler, :user_handler)
+
+set.match('/users/1').value # => :admin_handler
+set.match_all('/users/1').map(&:value) # => [:admin_handler, :user_handler]
```
-It is also possible to pass in options to a specific route:
+When no value is given, a match still succeeds but `value` is `nil`:
``` ruby
-require 'sinatra'
-require 'mustermann'
+set = Mustermann::Set.new
+set.add('/ping')
+set.match('/ping').value # => nil
+```
-register Mustermann
+### Conflict Resolution
-get '/:slug(.:ext)?', pattern: { greedy: false } do
- # slug will be 'foo' for '/foo.png'
- # slug will be 'foo' for '/foo.bar'
- # slug will be 'foo' for '/foo.bar.html'
- params[:slug]
-end
+The set follows insertion order: when two patterns both match a string, the one added first wins. Use `match_all` to retrieve every match:
+
+``` ruby
+set = Mustermann::Set.new
+set.add('/foo', :static)
+set.add('/:var', :dynamic)
+
+set.match('/foo').value # => :static
+set.match_all('/foo').map(&:value) # => [:static, :dynamic]
```
-Of course, all of the above can be combined.
-Moreover, the `capture` and the `except` option can be passed to route directly.
-And yes, this also works with `before` and `after` filters.
+### Peeking
+
+`peek_match` matches a prefix of the input rather than the full string. The unmatched remainder is available via `post_match`:
``` ruby
-require 'sinatra/base'
-require 'sinatra/respond_with'
-require 'mustermann'
+set = Mustermann::Set.new
+set.add('/users/:id', :users)
-class MyApp < Sinatra::Base
- register Mustermann, Sinatra::RespondWith
- set :pattern, capture: { id: /\d+/ } # id will only match digits
+m = set.peek_match('/users/42/posts')
+m.to_s # => '/users/42'
+m.post_match # => '/posts'
+m.value # => :users
+```
- # only capture extensions known to Rack
- before '*:ext', capture: Rack::Mime::MIME_TYPES.keys do
- content_type params[:ext] # set Content-Type
- request.path_info = params[:splat].first # drop the extension
- end
+`peek_match_all` returns every pattern that matches a prefix:
- get '/:id' do
- not_found unless page = Page.find params[:id]
- respond_with(page)
- end
-end
+``` ruby
+results = set.peek_match_all('/users/42/posts')
+results.map(&:value) # => [:users]
+results.map(&:post_match) # => ['/posts']
```
-### Why would I want this?
+### Expanding
-* It gives you fine grained control over the pattern matching
-* Allows you to use different pattern styles in your app
-* The default is more robust and powerful than the built-in patterns
-* Sinatra 2.0 will use Mustermann internally
-* Better exceptions for broken route syntax
+A set can generate strings from parameter hashes using the same interface as individual pattern expansion:
-### Why not include this in Sinatra 1.x?
+``` ruby
+set = Mustermann::Set.new
+set.add('/users/:id', :users)
+set.add('/posts/:id', :posts)
-* It would introduce breaking changes, even though these would be minor
-* Like Sinatra 2.0, Mustermann requires Ruby 2.0 or newer
+set.expand(id: '5') # => '/users/5' (first applicable pattern)
+set.expand(:posts, id: '5') # => '/posts/5' (patterns for a specific value)
+```
## Duck Typing
@@ -494,33 +489,6 @@ object = MyObject.new
Mustermann.new(object, type: :rails) # => #
```
-It might also be that you want to call `to_pattern` yourself instead of `Mustermann.new`. You can load `mustermann/to_pattern` to implement this method for strings, regular expressions and pattern objects:
-
-``` ruby
-require 'mustermann/to_pattern'
-
-"/foo".to_pattern # => #
-"/foo".to_pattern(type: :rails) # => #
-%r{/foo}.to_pattern # => #
-"/foo".to_pattern.to_pattern # => #
-```
-
-You can also use the `Mustermann::ToPattern` mixin to easily add `to_pattern` to your own objects:
-
-``` ruby
-require 'mustermann/to_pattern'
-
-class MyObject
- include Mustermann::ToPattern
-
- def to_s
- "/foo"
- end
-end
-
-MyObject.new.to_pattern # => #
-```
-
### `respond_to?`
@@ -691,6 +659,12 @@ In certain cases, Mustermann might outperform naive, equivalent regular expressi
When using a Mustermann pattern as a direct Regexp replacement (ie, via methods like `=~`, `match` or `===`), the overhead will be a single method dispatch, which some Ruby implementations might even eliminate with method inlining. This only applies to patterns using a regular expression internally (all but [identity](#-pattern-details-identity) and [shell](#-pattern-details-shell) patterns).
+### Routing
+
+`Mustermann::Set` uses a trie (prefix tree) internally to match incoming strings against a table of patterns. Rather than testing every route in sequence, the trie walks the input path one character at a time and considers only the routes sharing the current prefix. This means dispatch time grows far more slowly than a linear scan as the number of routes increases, making it well suited to applications with large routing tables.
+
+Matching and expansion on a set are thread-safe once the set has been built. Adding patterns is not thread-safe, so the recommended practice is to populate the set during application startup and treat it as read-only during request handling.
+
### Expanding
Pattern expansion significantly outperforms other, widely used Ruby tools for generating URLs from URL patterns in most use cases.
diff --git a/mustermann/bench/set.rb b/mustermann/bench/set.rb
new file mode 100644
index 0000000..2c05892
--- /dev/null
+++ b/mustermann/bench/set.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+$:.unshift File.expand_path('../lib', __dir__)
+
+require 'mustermann'
+require 'mustermann/set'
+require 'benchmark'
+
+options = { type: :sinatra }
+nesting = nil
+route_count = [10, 20, 50, 100, 200, 500, 1000, 10000]
+match_count = 1000
+
+while ARGV.any?
+ case ARGV.shift
+ when "--trie" then options[:use_trie] = ARGV.empty? || ARGV.first.start_with?("-") ? true : Integer(ARGV.shift)
+ when "--no-trie" then options[:use_trie] = false
+ when "--cache" then options[:use_cache] = true
+ when "--no-cache" then options[:use_cache] = false
+ when "-n", "--nesting" then nesting = Integer(ARGV.shift)
+ when "-r", "--routes" then route_count = ARGV.shift.split(",").map { Integer(it) }.sort
+ when "-m", "--matches" then match_count = Integer(ARGV.shift)
+ when "-p", "--type" then options[:type] = ARGV.shift.to_sym
+ else
+ warn <<~USAGE
+ Unknown option: #{ARGV.first}
+
+ Available options:
+ --trie [THRESHOLD] whether to use a trie for matching, or the threshold for using a trie
+ --no-trie do not use a trie for matching
+ --cache enable caching of matches not yet garbage collected
+ --no-cache disable caching of matches not yet garbage collected
+ -n, --nesting N nesting level of patterns, default depends on count
+ -r, --routes N1,N2.. number of routes to add
+ -m, --matches N number of matches to perform
+ USAGE
+ exit 1
+ end
+end
+
+case options[:type]
+when :sinatra, :rails, :cake, :express then prefix = ":"
+when :flask then prefix, suffix = "<", ">"
+when :pyramid, :template then prefix, suffix = "{", "}"
+else
+ warn "Unknown type: #{options[:type]}"
+ exit 1
+end
+
+line_length = 53 + route_count.last.to_s.size
+
+data = route_count.map do |count|
+ routes = []
+ examples = []
+ _nesting = nesting || count.to_s(17).size
+ base = "a" * _nesting
+
+ count.times do
+ segments = base.split("", _nesting).reverse
+ placeholder = String.new("`")
+ routes << segments.map { "/#{it}/#{prefix}#{placeholder.succ!}#{suffix}" }.join
+ examples << segments.map { "/#{it}/#{placeholder.succ!}" }.join
+ base.succ!
+ end
+
+ { count:, routes:, examples:, nesting: _nesting, rand: match_count.times.map { rand(count) } }
+end
+
+puts "", " Compilation ".center(line_length, '=')
+Benchmark.benchmark do |x|
+ data.each do |d|
+ x.report("#{d[:count]} routes") do
+ set = Mustermann::Set.new(**options)
+ d[:routes].each_with_index do |route, index|
+ set.add(route, index)
+ end
+ d[:set] = set
+ end
+ end
+end
+
+puts "", " Matching ".center(line_length, '=')
+Benchmark.bmbm do |x|
+ data.each do |d|
+ set = d[:set]
+ x.report("#{d[:count]} routes") do
+ d[:rand].each do |index|
+ example = d[:examples][index]
+ next if match = set.match(example) and match.value == index
+ route = d[:routes][index]
+ p nil, route, example, match, Mustermann.new(route, ignore_unknown_options: true, **options).match(example), set
+ raise "Expected %p but got %p for %p" % [index, match&.value, example]
+ end
+ end
+ end
+end
diff --git a/mustermann/bench/trie.rb b/mustermann/bench/trie.rb
new file mode 100644
index 0000000..7d48e86
--- /dev/null
+++ b/mustermann/bench/trie.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+$:.unshift File.expand_path('../lib', __dir__)
+
+require 'benchmark'
+require 'mustermann'
+require 'mustermann/trie'
+
+levels = ARGV[0].to_s.split(",").map { Integer(it) }
+levels = [1, 2, 3, 4] if levels.empty?
+per_level = ARGV[1].to_s.split(",").map { Integer(it) }
+per_level = [1, 5, 10] if per_level.empty?
+runs = Integer(ARGV[2] || 1000)
+line_length = 71
+
+generate = lambda do |levels, per_level|
+ routes = ['']
+ placeholder = ':`'
+
+ levels.times do
+ routes = routes.flat_map do |prefix|
+ placeholder = placeholder.succ
+ segment = '`'
+ per_level.times.map do
+ segment = segment.succ
+ "#{prefix}/#{segment}/#{placeholder}"
+ end
+ end
+ end
+
+ routes
+end
+
+scenarios = []
+
+puts "", " Compilation: Array ".center(line_length, '=')
+Benchmark.benchmark do |x|
+ levels.product(per_level).each do |(l, p)|
+ routes = generate.call(l, p)
+ examples = runs.times.map { [i = rand(routes.size), routes[i].gsub(":", "")] }
+ description = "%7i routes %4i level#{l == 1 ? ' ' : 's'}" % [routes.size, l, p]
+ x.report(description) { routes.map! { Mustermann.new(it) } }
+ scenarios << { description:, routes:, examples: }
+ end
+end
+
+puts "", " Compilation: Trie ".center(line_length, '=')
+Benchmark.benchmark do |x|
+ scenarios.each do |s|
+ x.report(s[:description]) do
+ s[:trie] = trie = Mustermann::Trie.new
+ s[:routes].each_with_index { |route, index| trie.add(route, index) }
+ end
+ end
+end
+
+puts "", " Matching: Array ".center(line_length, '=')
+Benchmark.bmbm do |x|
+ scenarios.each do |s|
+ x.report s[:description] do
+ s[:examples].each do |(expected, path)|
+ next if index = s[:routes].find_index { it.match(path) } and index == expected
+ raise "Expected %p but got %p for %p" % [expected, index, path]
+ end
+ end
+ end
+end
+
+puts "", " Matching: Trie ".center(line_length, '=')
+Benchmark.bmbm do |x|
+ scenarios.each do |s|
+ x.report s[:description] do
+ s[:examples].each do |(expected, path)|
+ next if index = s[:trie].match(path)&.value and index == expected
+ raise "Expected %p but got %p for %p" % [expected, index, path]
+ end
+ end
+ end
+end
diff --git a/mustermann/lib/mustermann.rb b/mustermann/lib/mustermann.rb
index 3a5e287..27a8420 100644
--- a/mustermann/lib/mustermann.rb
+++ b/mustermann/lib/mustermann.rb
@@ -115,10 +115,4 @@ def self.register(name, type)
def self.normalized_type(type)
type.to_s.gsub('-', '_').downcase
end
-
- # @!visibility private
- def self.extend_object(object)
- return super unless defined? ::Sinatra::Base and object.is_a? Class and object < ::Sinatra::Base
- require 'mustermann/extension'
- end
end
diff --git a/mustermann/lib/mustermann/ast/compiler.rb b/mustermann/lib/mustermann/ast/compiler.rb
index c451438..8385095 100644
--- a/mustermann/lib/mustermann/ast/compiler.rb
+++ b/mustermann/lib/mustermann/ast/compiler.rb
@@ -121,17 +121,26 @@ def register_param(parametric: false, split_params: nil, separator: nil, **optio
end
end
+ # @return [Array] all raw string representations of the character (literal + URI-encoded variants)
+ # @!visibility private
+ def self.char_representations(char, uri_decode: true, space_matches_plus: true)
+ if char == " " and space_matches_plus
+ @space_and_plus ||= char_representations(" ", space_matches_plus: false) +
+ char_representations("+", space_matches_plus: false)
+ else
+ @char_representations ||= {}
+ @char_representations[char] ||= begin
+ escaped = URI_PARSER.escape(char, /./)
+ [char, escaped.upcase, escaped.downcase].uniq
+ end
+ end
+ end
+
# @return [String] Regular expression for matching the given character in all representations
# @!visibility private
def encoded(char, uri_decode: true, space_matches_plus: true, **options)
return Regexp.escape(char) unless uri_decode
- encoded = escape(char, escape: /./)
- list = [escape(char), encoded.downcase, encoded.upcase].uniq.map { |c| Regexp.escape(c) }
- if char == " "
- list << encoded('+') if space_matches_plus
- list << " "
- end
- "(?:%s)" % list.join("|")
+ "(?:%s)" % self.class.char_representations(char, uri_decode:, space_matches_plus:).map { |c| Regexp.escape(c) }.join("|")
end
# Compiles an AST to a regular expression.
diff --git a/mustermann/lib/mustermann/ast/pattern.rb b/mustermann/lib/mustermann/ast/pattern.rb
index cfd6481..97bc816 100644
--- a/mustermann/lib/mustermann/ast/pattern.rb
+++ b/mustermann/lib/mustermann/ast/pattern.rb
@@ -83,6 +83,16 @@ def compile(**options)
raise error.class, "#{error.message}: #{@string.inspect}", error.backtrace
end
+ # Returns a regexp that matches strings excluded by the +except+ option,
+ # or +nil+ if no +except+ constraint was given. Used by the trie matcher
+ # to filter out excluded strings at leaf nodes.
+ # @return [Regexp, nil]
+ # @!visibility private
+ def except_regexp
+ return unless except_str = options[:except]
+ @except_regexp ||= Regexp.new("\\A#{compiler.new.translate(parse(except_str), no_captures: true, **options.except(:except))}\\z")
+ end
+
# Internal AST representation of pattern.
# @!visibility private
def to_ast
diff --git a/mustermann/lib/mustermann/ast/translator.rb b/mustermann/lib/mustermann/ast/translator.rb
index f8643e1..ac61847 100644
--- a/mustermann/lib/mustermann/ast/translator.rb
+++ b/mustermann/lib/mustermann/ast/translator.rb
@@ -104,9 +104,14 @@ def self.create(&block)
# @return decorator encapsulating translation
#
# @!visibility private
+ def self.factory_for(node_class)
+ @factory_for ||= {}
+ @factory_for[node_class] ||= node_class.ancestors.lazy.filter_map { dispatch_table[_1.name] }.first
+ end
+
def decorator_for(node)
- factory = node.class.ancestors.inject(nil) { |d,a| d || self.class.dispatch_table[a.name] }
- raise error_class, "#{self.class}: Cannot translate #{node.class}" unless factory
+ factory = self.class.factory_for(node.class) or
+ raise error_class, "#{self.class}: Cannot translate #{node.class}"
factory.new(node, self)
end
diff --git a/mustermann/lib/mustermann/concat.rb b/mustermann/lib/mustermann/concat.rb
index 7d38c56..c3b6091 100644
--- a/mustermann/lib/mustermann/concat.rb
+++ b/mustermann/lib/mustermann/concat.rb
@@ -75,10 +75,16 @@ def peek_size(string)
# @see Mustermann::Pattern#peek_match
def peek_match(string)
- pump(string, initial: SimpleMatch.new) do |pattern, substring|
- return unless match = pattern.peek_match(substring)
- [match, match.to_s.size]
+ substring = string
+ params = {}
+
+ patterns.each do |pattern|
+ return unless part = pattern.peek_match(substring)
+ params.merge!(part.params)
+ substring = substring[part.to_s.size..-1]
end
+
+ Match.new(self, string[0, string.size - substring.size], params, post_match: substring)
end
# @see Mustermann::Pattern#peek_params
diff --git a/mustermann/lib/mustermann/error.rb b/mustermann/lib/mustermann/error.rb
index d1fd52e..47df5f9 100644
--- a/mustermann/lib/mustermann/error.rb
+++ b/mustermann/lib/mustermann/error.rb
@@ -5,5 +5,6 @@ module Mustermann
CompileError = Class.new(Error) # Raised if anything goes wrong while compiling a {Pattern}.
ParseError = Class.new(Error) # Raised if anything goes wrong while parsing a {Pattern}.
ExpandError = Class.new(Error) # Raised if anything goes wrong while expanding a {Pattern}.
+ TrieError = Class.new(CompileError) # Raised if anything goes wrong while compiling a {Trie}.
end
end
diff --git a/mustermann/lib/mustermann/expander.rb b/mustermann/lib/mustermann/expander.rb
index e32f7c6..14a7354 100644
--- a/mustermann/lib/mustermann/expander.rb
+++ b/mustermann/lib/mustermann/expander.rb
@@ -13,15 +13,16 @@ module Mustermann
#
# expander.expand(page_id: 58, format: :html5) # => "/pages/58?format=html5"
class Expander
+ # @!visibility private
+ ADDITIONAL_VALUES = %i[raise ignore append].freeze
+
attr_reader :patterns, :additional_values, :caster
# @param [Array<#to_str, Mustermann::Pattern>] patterns list of patterns to expand, see {#add}.
# @param [Symbol] additional_values behavior when encountering additional values, see {#expand}.
# @param [Hash] options used when creating/expanding patterns, see {Mustermann.new}.
def initialize(*patterns, additional_values: :raise, **options, &block)
- unless additional_values == :raise or additional_values == :ignore or additional_values == :append
- raise ArgumentError, "Illegal value %p for additional_values" % additional_values
- end
+ raise ArgumentError, "Illegal value %p for additional_values" % additional_values unless ADDITIONAL_VALUES.include? additional_values
@patterns = []
@api_expander = AST::Expander.new
@@ -42,6 +43,10 @@ def initialize(*patterns, additional_values: :raise, **options, &block)
# @return [Mustermann::Expander] the expander
def add(*patterns)
patterns.each do |pattern|
+ if pattern.is_a? Expander
+ add(*pattern.patterns)
+ next
+ end
pattern = Mustermann.new(pattern, **@options)
if block_given?
@api_expander.add(yield(pattern))
diff --git a/mustermann/lib/mustermann/extension.rb b/mustermann/lib/mustermann/extension.rb
deleted file mode 100644
index 29ad7c9..0000000
--- a/mustermann/lib/mustermann/extension.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-# frozen_string_literal: true
-
-fail "Mustermann extension for Sinatra has been extracted into its own gem. More information at https://github.com/sinatra/mustermann-sinatra-extension"
diff --git a/mustermann/lib/mustermann/match.rb b/mustermann/lib/mustermann/match.rb
new file mode 100644
index 0000000..4aa99c3
--- /dev/null
+++ b/mustermann/lib/mustermann/match.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Mustermann
+ class Match
+ attr_reader :pattern, :string, :params, :post_match, :pre_match
+
+ def initialize(pattern, string, params = {}, post_match: '', pre_match: '')
+ @pattern = pattern
+ @string = string.freeze
+ @params = params.freeze
+ @post_match = post_match.freeze
+ @pre_match = pre_match.freeze
+ end
+
+ def [](key)
+ case key
+ when String then params[key]
+ when Symbol then params[key.to_s]
+ else raise ArgumentError, "key must be a String or Symbol, not #{key.class}"
+ end
+ end
+
+ def deconstruct_keys(keys) = keys.to_h { |key| [key, self[key]] }
+
+ def hash = pattern.hash ^ string.hash ^ params.hash
+
+ def eql?(other)
+ return false unless other.is_a? self.class
+ pattern == other.pattern && string == other.string && params == other.params
+ end
+
+ def values_at(*keys) = keys.map { |key| self[key] }
+
+ alias == eql?
+ alias to_s string
+ alias to_h params
+
+ end
+end
diff --git a/mustermann/lib/mustermann/pattern.rb b/mustermann/lib/mustermann/pattern.rb
index 3a09a25..1aa4d15 100644
--- a/mustermann/lib/mustermann/pattern.rb
+++ b/mustermann/lib/mustermann/pattern.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
require 'mustermann/error'
-require 'mustermann/simple_match'
+require 'mustermann/match'
require 'mustermann/equality_map'
require 'uri'
@@ -84,12 +84,10 @@ def to_s
end
# @param [String] string The string to match against
- # @return [MatchData, Mustermann::SimpleMatch, nil] MatchData or similar object if the pattern matches.
- # @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-match Regexp#match
- # @see http://ruby-doc.org/core-2.0/MatchData.html MatchData
- # @see Mustermann::SimpleMatch
+ # @return [Mustermann::Match, nil] the match object if the pattern matches.
+ # @see Mustermann::Match
def match(string)
- SimpleMatch.new(string) if self === string
+ Match.new(self, string) if self === string
end
# @param [String] string The string to match against
@@ -163,11 +161,11 @@ def peek(string)
# pattern.peek("/Frank/Sinatra") # => #
#
# @param [String] string The string to match against
- # @return [MatchData, Mustermann::SimpleMatch, nil] MatchData or similar object if the pattern matches.
+ # @return [Mustermann::Match, nil] MatchData or similar object if the pattern matches.
# @see #peek_params
def peek_match(string)
matched = peek(string)
- match(matched) if matched
+ Match.new(self, matched, {}, post_match: string[matched.size..-1]) if matched
end
# Tries to match the pattern against the beginning of the string (as opposed to the full string).
@@ -184,33 +182,12 @@ def peek_match(string)
# @return [Array, nil] Array with params hash and length of substing if matched, nil otherwise
def peek_params(string)
match = peek_match(string)
- [params(captures: match), match.to_s.size] if match
- end
-
- # @return [Hash{String: Array}] capture names mapped to capture index.
- # @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-named_captures Regexp#named_captures
- def named_captures
- {}
- end
-
- # @return [Array] capture names.
- # @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-names Regexp#names
- def names
- []
+ match ? [match.params, match.string.size] : nil
end
# @param [String] string the string to match against
# @return [Hash{String: String, Array}, nil] Sinatra style params if pattern matches.
- def params(string = nil, captures: nil, offset: 0)
- return unless captures ||= match(string)
- params = named_captures.map do |name, positions|
- values = positions.map { |pos| map_param(name, captures[pos + offset]) }.flatten
- values = values.first if values.size < 2 and not always_array? name
- [name, values]
- end
-
- Hash[params]
- end
+ def params(string = nil) = match(string)&.params
# @note This method is only implemented by certain subclasses.
#
diff --git a/mustermann/lib/mustermann/rails.rb b/mustermann/lib/mustermann/rails.rb
index dc921b6..d0edcb4 100644
--- a/mustermann/lib/mustermann/rails.rb
+++ b/mustermann/lib/mustermann/rails.rb
@@ -42,6 +42,6 @@ class Rails < AST::Pattern
version('4.2') { on(?\\) { |c| node(:char, expect(/./)) } }
# Rails 5.0 fixes |
- version('5.0') { on(?|) { |c| node(:or) }}
+ version('5', '6', '7', '8') { on(?|) { |c| node(:or) }}
end
end
diff --git a/mustermann/lib/mustermann/regexp_based.rb b/mustermann/lib/mustermann/regexp_based.rb
index b17113b..e7ddd54 100644
--- a/mustermann/lib/mustermann/regexp_based.rb
+++ b/mustermann/lib/mustermann/regexp_based.rb
@@ -32,17 +32,32 @@ def peek_size(string)
# @param (see Mustermann::Pattern#peek_match)
# @return (see Mustermann::Pattern#peek_match)
# @see (see Mustermann::Pattern#peek_match)
- def peek_match(string)
- @peek_regexp.match(string)
- end
+ def peek_match(string) = build_match(@peek_regexp.match(string))
+
+ def match(string) = build_match(@regexp.match(string))
+
+ # private
+
+ # def build_match(match)
+ # return unless match
+ # Match.new(self, match.string, match.named_captures, post_match: match.post_match, pre_match: match.pre_match)
+ # end
extend Forwardable
- def_delegators :regexp, :===, :=~, :match, :names, :named_captures
+ def_delegators :regexp, :===, :=~, :names
+
+ private
- def compile(**options)
- raise NotImplementedError, 'subclass responsibility'
+ def build_match(match)
+ return unless match
+ params = match.regexp.named_captures.to_h do |name, positions|
+ value = positions.size < 2 && !always_array?(name) ? map_param(name, match[name]) :
+ positions.flat_map { |pos| map_param(name, match[pos]) }
+ [name, value]
+ end
+ Match.new(self, match.to_s, params, post_match: match.post_match, pre_match: match.pre_match)
end
- private :compile
+ def compile(**options) = raise NotImplementedError, 'subclass responsibility'
end
end
diff --git a/mustermann/lib/mustermann/set.rb b/mustermann/lib/mustermann/set.rb
new file mode 100644
index 0000000..899ffa5
--- /dev/null
+++ b/mustermann/lib/mustermann/set.rb
@@ -0,0 +1,324 @@
+# frozen_string_literal: true
+require 'mustermann'
+require 'mustermann/expander'
+require 'mustermann/set/cache'
+require 'mustermann/set/linear'
+require 'mustermann/set/trie'
+
+module Mustermann
+ # A collection of patterns that can be matched against strings efficiently.
+ #
+ # Each pattern in the set may be associated with one or more arbitrary values,
+ # such as handler objects or route actions. A single {#match} call returns a
+ # {Set::Match} that provides both the captured parameters and the associated
+ # value for the matched pattern. When the set contains many patterns, an
+ # internal trie (prefix tree) is used to dispatch requests in sub-linear time.
+ #
+ # @example Building a routing table
+ # require 'mustermann/set'
+ #
+ # set = Mustermann::Set.new
+ # set.add('/users/:id', :users_show)
+ # set.add('/posts/:id', :posts_show)
+ #
+ # m = set.match('/users/42')
+ # m.value # => :users_show
+ # m.params['id'] # => '42'
+ #
+ # @example Constructor shorthand with a hash
+ # set = Mustermann::Set.new('/users/:id' => :users_show, '/posts/:id' => :posts_show)
+ #
+ # @example Block syntax
+ # set = Mustermann::Set.new do |s|
+ # s.add('/users/:id', :users_show)
+ # s.add('/posts/:id', :posts_show)
+ # end
+ #
+ # @note Adding patterns via {#add}, {#update}, or {#[]=} is not thread-safe, but matching and expanding is.
+ class Set
+ # Pattern options forwarded to {Mustermann.new} when patterns are created from strings.
+ # @return [Hash]
+ attr_reader :options
+
+ # Creates a new set, optionally pre-populated with patterns.
+ #
+ # Patterns can be supplied as a Hash (pattern → value), a plain String or
+ # Pattern, an Array of any of these, or an existing {Set}. The same forms
+ # are accepted by {#update} and {#add}.
+ #
+ # @example Empty set
+ # Mustermann::Set.new
+ #
+ # @example Pre-populated from a hash
+ # Mustermann::Set.new('/users/:id' => :users, '/posts/:id' => :posts)
+ #
+ # @example Imperative block
+ # Mustermann::Set.new do |s|
+ # s.add('/users/:id', :users)
+ # end
+ #
+ # @example Zero-argument block returning a mapping hash
+ # Mustermann::Set.new { { '/users/:id' => :users } }
+ #
+ # @param mapping [Array] initial patterns or mappings to add
+ # @param additional_values [:raise, :ignore, :append] behavior when extra keys are passed to {#expand};
+ # defaults to +:raise+
+ # @param options [Hash] pattern options forwarded to {Mustermann.new} (e.g. +type: :rails+)
+ # @raise [ArgumentError] if +additional_values+ is not a recognized behavior symbol
+ def initialize(*mapping, additional_values: :raise, use_trie: 50, use_cache: true, **options, &block)
+ raise ArgumentError, "Illegal value %p for additional_values" % additional_values unless Expander::ADDITIONAL_VALUES.include? additional_values
+ raise ArgumentError, "Illegal value %p for use_trie" % use_trie unless [true, false].include?(use_trie) or use_trie.is_a? Integer
+
+ @use_trie = use_trie
+ @use_cache = use_cache
+ @matcher = nil
+ @mapping = {}
+ @reverse_mapping = {}
+ @options = {}
+ @expanders = {}
+ @additional_values = additional_values
+
+ options.each do |key, value|
+ if key.is_a? Symbol
+ @options[key] = value
+ else
+ mapping << { key => value }
+ end
+ end
+
+ update(mapping)
+
+ block.arity == 0 ? update(yield) : yield(self) if block
+ end
+
+ # Adds a pattern to the set, optionally associated with one or more values.
+ #
+ # If the pattern is given as a String it will be compiled via {Mustermann.new}
+ # using the set's own options. The pattern must be AST-based (Sinatra, Rails,
+ # and similar types). Plain regexp patterns are not supported.
+ #
+ # Calling +add+ more than once for the same pattern appends additional values
+ # without creating duplicates.
+ #
+ # @example
+ # set.add('/users/:id', :users)
+ # set.add('/users/:id', :admin) # same pattern, second value
+ #
+ # @param pattern [String, Pattern] the pattern to add
+ # @param values [Array] zero or more values to associate with the pattern
+ # @return [self]
+ # @raise [ArgumentError] if the pattern is not AST-based, or if a reserved symbol is used as a value
+ def add(pattern, *values)
+ pattern = Mustermann.new(pattern, **options)
+ raise ArgumentError, "Non-AST patterns are not supported" unless pattern.respond_to? :to_ast
+
+ if @mapping.key? pattern
+ current = @mapping[pattern]
+ else
+ add_pattern(pattern)
+ current = @mapping[pattern] = []
+ end
+
+ values = [nil] if values.empty?
+
+ values.each do |value|
+ raise ArgumentError, "%p may not be used as a value" % value if Expander::ADDITIONAL_VALUES.include? value
+ raise ArgumentError, "the set itself may not be used as value" if value == self
+ next if current.include? value
+ current << value
+ @reverse_mapping[value] ||= []
+ @reverse_mapping[value] << pattern unless @reverse_mapping[value].include? pattern
+ @expanders[value]&.add(pattern)
+ end
+
+ self
+ end
+
+ # Adds a pattern associated with a value using hash-assignment syntax.
+ # @see #add
+ alias []= add
+
+ # Looks up a value by string or retrieves the first value for a known pattern object.
+ #
+ # When given a String, it is matched against the set and the associated value of the
+ # first matching pattern is returned. When given a {Pattern}, the first value
+ # registered for that exact pattern is returned without matching.
+ #
+ # @example String lookup
+ # set['/users/42'] # => :users_show (or nil)
+ #
+ # @example Pattern lookup
+ # pat = Mustermann.new('/users/:id')
+ # set[pat] # => :users_show (or nil)
+ #
+ # @param pattern_or_string [String, Pattern]
+ # @return [Object, nil] the associated value, or +nil+ if not found
+ # @raise [ArgumentError] for unsupported argument types
+ def [](pattern_or_string)
+ case pattern_or_string
+ when String then match(pattern_or_string)&.value
+ when Pattern then values_for_pattern(pattern_or_string)&.first
+ else raise ArgumentError, "unsupported pattern type #{pattern_or_string.class}"
+ end
+ end
+
+ # Matches the string against all patterns in the set and returns the first match.
+ #
+ # @param string [String] the string to match
+ # @return [Set::Match, nil] the first match, or +nil+ if none of the patterns match
+ def match(string) = @matcher&.match(string)
+
+ # Matches the beginning of the string against all patterns and returns the
+ # first prefix match. The unmatched remainder of the string is available via
+ # {Set::Match#post_match}.
+ #
+ # @param string [String]
+ # @return [Set::Match, nil] the first prefix match, or +nil+
+ def peek_match(string) = @matcher&.match(string, peek: true)
+
+ # Matches the string against all patterns and returns every match, one per
+ # (pattern, value) pair, in insertion order.
+ #
+ # @param string [String]
+ # @return [Array] all matches, or an empty array if none
+ def match_all(string) = @matcher&.match(string, all: true)
+
+ # Matches the beginning of the string against all patterns and returns every
+ # prefix match, one per (pattern, value) pair. The unmatched remainder is
+ # available as {Set::Match#post_match} on each result.
+ #
+ # @param string [String]
+ # @return [Array] all prefix matches, or an empty array if none
+ def peek_match_all(string) = @matcher&.match(string, all: true, peek: true)
+
+ # Returns a new set that includes all patterns from the receiver plus those
+ # from +mapping+. The receiver is not modified.
+ #
+ # @param mapping [Hash, String, Pattern, Array, Set] patterns to merge in
+ # @return [Set] a new set
+ def merge(mapping) = dup.update(mapping)
+
+ # @!visibility private
+ def initialize_copy(other)
+ @mapping = other.mapping.transform_values(&:dup)
+ @reverse_mapping = @mapping.each_with_object({}) do |(pattern, values), h|
+ values.each { |value| (h[value] ||= []) << pattern }
+ end
+ @expanders = {}
+ @matcher = nil
+ @mapping.each_key { |pattern| add_pattern(pattern) }
+ end
+
+ # Adds all patterns from +mapping+ to the set in place and returns +self+.
+ # Aliased as +merge!+.
+ #
+ # Accepts the same argument forms as {#initialize}: a Hash, a String, a
+ # {Pattern}, an Array, or another {Set}.
+ #
+ # @param mapping [Hash, String, Pattern, Array, Set]
+ # @return [self]
+ # @raise [ArgumentError] for unsupported mapping types
+ def update(mapping)
+ case mapping
+ when Set then mapping.mapping.each { |pattern, values| add(pattern, *values) }
+ when Hash then mapping.each { |k, v| add(k, v) }
+ when String, Pattern then add(mapping)
+ when Array then mapping.each { |item| update(item) }
+ else raise ArgumentError, "unsupported mapping type #{mapping.class}"
+ end
+ self
+ end
+
+ alias merge! update
+
+ # Returns all patterns that have been added to the set, in insertion order.
+ # @return [Array]
+ def patterns = @mapping.keys
+
+ # Returns an {Expander} that can generate strings from parameter hashes.
+ #
+ # When called without arguments (or with the set itself as the value) the
+ # expander covers all patterns in the set. Pass a specific value to get an
+ # expander limited to the patterns associated with that value.
+ #
+ # @param value [Object] restricts the expander to patterns associated with
+ # this value; defaults to the set itself (all patterns)
+ # @return [Mustermann::Expander]
+ def expander(value = self)
+ @expanders[value] ||= begin
+ patterns = value == self ? @mapping.keys : @reverse_mapping[value] || []
+ Mustermann::Expander.new(patterns, additional_values: @additional_values, **options)
+ end
+ end
+
+ # Generates a string from a parameter hash using the patterns in the set.
+ #
+ # When called with just a parameter hash, the first pattern that can be fully
+ # expanded with those keys is used. Pass a value as the first argument to
+ # restrict expansion to the patterns associated with that value. You may also
+ # pass an +additional_values+ behavior symbol (+:raise+, +:ignore+, or
+ # +:append+) as the first argument to override the set's default behavior for
+ # that call.
+ #
+ # @example Expand using any pattern
+ # set.expand(id: '5')
+ #
+ # @example Expand patterns for a specific value
+ # set.expand(:users, id: '5')
+ #
+ # @example Override additional_values behavior for one call
+ # set.expand(:ignore, id: '5', extra: 'ignored')
+ #
+ # @param value [Object, :raise, :ignore, :append] the value whose patterns
+ # should be used, or an additional_values behavior symbol; defaults to all
+ # patterns
+ # @param behavior [:raise, :ignore, :append, nil] how to handle extra keys;
+ # defaults to the set's +additional_values+ setting
+ # @param values [Hash, nil] the parameters to expand
+ # @return [String]
+ # @raise [Mustermann::ExpandError] if no pattern can be expanded with the given keys
+ def expand(value = self, behavior = nil, values = nil)
+ if Expander::ADDITIONAL_VALUES.include? value
+ if behavior.is_a? Hash
+ values = values ? values.merge(behavior) : behavior
+ behavior = nil
+ elsif behavior and behavior != value
+ raise ArgumentError, "behavior specified multiple times" if behavior
+ end
+ behavior = value
+ value = self
+ elsif value.is_a? Hash and behavior.nil? and values.nil?
+ values = value
+ value = self unless @reverse_mapping.key? values
+ end
+ expander(value).expand(behavior || @additional_values, values || {})
+ end
+
+ # @!visibility private
+ def values_for_pattern(pattern) = @mapping[pattern] # :nodoc:
+
+ protected
+
+ attr_reader :mapping
+
+ private
+
+ def add_pattern(pattern)
+ case @use_trie
+ when true
+ @matcher ||= Trie.new(self, @mapping.keys)
+ when Integer
+ if @mapping.size >= @use_trie
+ @matcher = Trie.new(self, @mapping.keys)
+ @use_trie = true
+ end
+ end
+
+ @matcher ||= Linear.new(self, @mapping.keys)
+ @matcher = Cache.new(@matcher) if @use_cache and not @matcher.is_a? Cache
+ @matcher.add(pattern)
+
+ @expanders[self]&.add(pattern)
+ end
+ end
+end
diff --git a/mustermann/lib/mustermann/set/cache.rb b/mustermann/lib/mustermann/set/cache.rb
new file mode 100644
index 0000000..65adffc
--- /dev/null
+++ b/mustermann/lib/mustermann/set/cache.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+require 'mustermann/equality_map'
+
+module Mustermann
+ class Set
+ class Cache
+ PLACEHOLDER = Object.new.freeze
+
+ def self.new(matcher) = defined?(ObjectSpace::WeakKeyMap) ? super : matcher
+
+ def initialize(matcher)
+ @matcher = matcher
+ @caches = {}
+ end
+
+ def add(pattern)
+ @matcher.add(pattern)
+ @caches.clear
+ end
+
+ def match(string, **options)
+ cache = @caches[options] ||= ObjectSpace::WeakKeyMap.new
+ result = cache[string] ||= @matcher.match(string, **options) || PLACEHOLDER
+ result unless result.equal? PLACEHOLDER
+ end
+ end
+
+ private_constant :Cache
+ end
+end
diff --git a/mustermann/lib/mustermann/set/linear.rb b/mustermann/lib/mustermann/set/linear.rb
new file mode 100644
index 0000000..2c32cc8
--- /dev/null
+++ b/mustermann/lib/mustermann/set/linear.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+require 'mustermann/set/match'
+
+module Mustermann
+ class Set
+ class Linear
+ def initialize(set, patterns = [])
+ @set = set
+ @patterns = patterns
+ end
+
+ def add(pattern)
+ @patterns << pattern
+ end
+
+ def match(string, all: false, peek: false)
+ result = [] if all
+ @patterns.each do |pattern|
+ next unless match = peek ? pattern.peek_match(string) : pattern.match(string)
+ return Match.new(match:, value: @set.values_for_pattern(pattern)&.first) unless all
+ values = @set.values_for_pattern(pattern) || [nil]
+ values.each { |value| result << Match.new(match:, value:) }
+ end
+ result
+ end
+ end
+
+ private_constant :Linear
+ end
+end
diff --git a/mustermann/lib/mustermann/set/match.rb b/mustermann/lib/mustermann/set/match.rb
new file mode 100644
index 0000000..9c880cb
--- /dev/null
+++ b/mustermann/lib/mustermann/set/match.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+require 'mustermann/match'
+require 'delegate'
+
+module Mustermann
+ class Set
+ class Match < DelegateClass(Mustermann::Match)
+ attr_reader :value
+
+ def initialize(*args, value: nil, match: nil, **options)
+ @value = value
+ super(match || Mustermann::Match.new(*args, **options))
+ end
+ end
+ end
+end
diff --git a/mustermann/lib/mustermann/set/trie.rb b/mustermann/lib/mustermann/set/trie.rb
new file mode 100644
index 0000000..31009fb
--- /dev/null
+++ b/mustermann/lib/mustermann/set/trie.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+require 'mustermann/ast/translator'
+require 'mustermann/set/match'
+
+module Mustermann
+ class Set
+ class Trie
+ class Translator < AST::Translator
+ translate(:node) { |trie, **o| trie[t.compile(node)] }
+ translate(:separator) { |trie, **options| trie[payload] }
+
+ translate(:root) do |trie, **options|
+ leaves = t(payload, trie, **options)
+ if leaves.is_a? Array
+ leaves.each { |leaf| leaf.patterns << t.pattern }
+ else
+ leaves.patterns << t.pattern
+ end
+ leaves
+ end
+
+ translate(:char) do |trie, **options|
+ strings = t.possible_strings(payload)
+ return trie if strings.empty?
+ primary_node = trie[strings.first]
+ strings[1..-1].each { |s| trie.wire(s, primary_node) }
+ primary_node
+ end
+
+ translate(:optional) do |trie, **options|
+ [*t(payload, trie, **options), trie]
+ end
+
+ translate(Array) do |trie, **options|
+ i = 0
+ while i < size
+ element = self[i]
+ if element.is_a? :char or element.is_a? :separator
+ trie = t(element, trie, **options)
+ i += 1
+ elsif element.is_a? :splat and self[i + 1]&.is_a? :separator
+ # Compile splat+separator together so the splat is bounded by the separator,
+ # then continue building the trie for the remaining elements.
+ trie = trie[t.compile(self[i..i + 1])]
+ i += 2
+ elsif element.is_a? :splat or !self[i + 1]&.is_a? :separator
+ return trie[t.compile(self[i..-1])]
+ else
+ trie = t(element, trie, **options)
+ return trie.flat_map { |node| t(self[i + 1..-1], node, **options) } if trie.is_a? Array
+ i += 1
+ end
+ end
+ trie
+ end
+
+ attr_reader :pattern
+
+ def initialize(pattern)
+ @pattern = pattern
+ @compiler = pattern.compiler.new
+ @options = pattern.options
+ super()
+ end
+
+ def compile(node, **options) = /\A#{@compiler.translate(node, **@options, **options)}/
+
+ def possible_strings(char)
+ return [] if char.empty?
+ @compiler.class.char_representations(char, **@options.slice(:uri_decode, :space_matches_plus))
+ end
+ end
+
+ attr_reader :patterns, :set, :static, :dynamic
+
+ def initialize(set, patterns = [])
+ @set = set
+ @patterns = []
+ @dynamic = {}
+ @static = {}
+ patterns.each { |pattern| add(pattern) }
+ end
+
+ def [](key)
+ case key
+ when String then @static[key] ||= Trie.new(@set)
+ when Regexp then @dynamic[key] ||= Trie.new(@set)
+ end
+ end
+
+ def wire(string, target)
+ return if string.empty?
+ if string.size == 1
+ @static[string] ||= target
+ else
+ (@static[string[0]] ||= Trie.new(@set)).wire(string[1..-1], target)
+ end
+ end
+
+ def match(string, all: false, peek: false, position: 0, params: {})
+ return build_matches(string, params, all:) if position >= string.size
+ result = [] if all
+
+ if node = @static[string[position]]
+ if nested_result = node.match(string, all:, peek:, position: position + 1, params:)
+ return nested_result unless all
+ result.concat(nested_result)
+ end
+ end
+
+ anchored = {}
+ @dynamic.each do |matcher, node|
+ remaining = string[position..-1]
+ regexp_match = matcher.match(remaining)
+ # Non-greedy patterns (e.g. splat .*?) can match 0 chars on non-empty input, making
+ # no progress. Retry with an end-of-string anchor so they consume the full remainder.
+ if regexp_match&.to_s&.empty? && !remaining.empty?
+ anchored_matcher = anchored[matcher] ||= Regexp.new(matcher.source + '\z')
+ regexp_match = anchored_matcher.match(remaining)
+ end
+ next unless regexp_match
+
+ regexp_match.named_captures.each do |name, value|
+ params = params.dup
+ params[name] = params[name]&.dup || []
+ params[name] << value
+ end
+
+ nested_result = node.match(string, all:, params:, peek:, position: position + regexp_match.to_s.size)
+ return nested_result unless all
+ result.concat(nested_result)
+ end
+
+ if peek
+ matches = build_matches(string[0, position], params, all:, post_match: string[position..])
+ return matches unless all
+ result.concat(matches)
+ end
+
+ result
+ end
+
+ def build_matches(string, params, all: false, **options)
+ result = [] if all
+
+ @patterns.each do |pattern|
+ next if pattern.except_regexp&.match?(string)
+
+ pattern_params = params.to_h do |key, value|
+ value = value.flat_map { |v| pattern.map_param(key, v) }
+ value = value.first if value.size < 2 and not pattern.always_array?(key)
+ [key, value]
+ end
+
+ values = @set.values_for_pattern(pattern) || [nil]
+ values.each do |value|
+ match = Set::Match.new(pattern, string, pattern_params, value:, **options)
+ return match unless all
+ result << match
+ end
+ end
+
+ result
+ end
+
+ def add(pattern)
+ Translator.new(pattern).translate(pattern.to_ast, self)
+ end
+ end
+
+ private_constant :Trie
+ end
+end
diff --git a/mustermann/lib/mustermann/simple_match.rb b/mustermann/lib/mustermann/simple_match.rb
deleted file mode 100644
index bd0d06a..0000000
--- a/mustermann/lib/mustermann/simple_match.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-module Mustermann
- # Fakes MatchData for patterns that do not support capturing.
- # @see http://ruby-doc.org/core-2.0/MatchData.html MatchData
- class SimpleMatch
- # @api private
- def initialize(string = "", names: [], captures: [])
- @string = string.dup
- @names = names
- @captures = captures
- end
-
- # @return [String] the string that was matched against
- def to_s
- @string.dup
- end
-
- # @return [Array] empty array for imitating MatchData interface
- def names
- @names.dup
- end
-
- # @return [Array] empty array for imitating MatchData interface
- def captures
- @captures.dup
- end
-
- # @return [nil] imitates MatchData interface
- def [](*args)
- args.map! do |arg|
- next arg unless arg.is_a? Symbol or arg.is_a? String
- names.index(arg.to_s)
- end
- @captures[*args]
- end
-
- # @!visibility private
- def +(other)
- SimpleMatch.new(@string + other.to_s,
- names: @names + other.names,
- captures: @captures + other.captures)
- end
-
- # @return [String] string representation
- def inspect
- "#<%p %p>" % [self.class, @string]
- end
- end
-end
diff --git a/mustermann/lib/mustermann/version.rb b/mustermann/lib/mustermann/version.rb
index 43645e8..dcc12f2 100644
--- a/mustermann/lib/mustermann/version.rb
+++ b/mustermann/lib/mustermann/version.rb
@@ -1,4 +1,4 @@
# frozen_string_literal: true
module Mustermann
- VERSION ||= '3.1.1'
+ VERSION ||= '4.0.0'
end
diff --git a/mustermann/mustermann.gemspec b/mustermann/mustermann.gemspec
index b927239..58ff8fb 100644
--- a/mustermann/mustermann.gemspec
+++ b/mustermann/mustermann.gemspec
@@ -10,6 +10,6 @@ Gem::Specification.new do |s|
s.summary = %q{Your personal string matching expert.}
s.description = %q{A library implementing patterns that behave like regular expressions.}
s.license = 'MIT'
- s.required_ruby_version = '>= 2.7.0'
+ s.required_ruby_version = '>= 3.3.0'
s.files = `git ls-files lib`.split("\n") + ['LICENSE', 'README.md']
end
diff --git a/mustermann/spec/expander_spec.rb b/mustermann/spec/expander_spec.rb
index ae418ea..9d4638b 100644
--- a/mustermann/spec/expander_spec.rb
+++ b/mustermann/spec/expander_spec.rb
@@ -46,6 +46,12 @@
expander.expand.should be == '/:foo'
end
+ it 'supports adding an expander to another expander' do
+ inner = Mustermann::Expander.new('/:foo')
+ expander = Mustermann::Expander.new << inner
+ expander.expand(foo: 'bar').should be == '/bar'
+ end
+
describe :additional_values do
context "illegal value" do
example { expect { Mustermann::Expander.new(additional_values: :foo) }.to raise_error(ArgumentError) }
diff --git a/mustermann/spec/identity_spec.rb b/mustermann/spec/identity_spec.rb
index 6873df8..d8e73f6 100644
--- a/mustermann/spec/identity_spec.rb
+++ b/mustermann/spec/identity_spec.rb
@@ -21,7 +21,9 @@
it { should_not expand(a: 10) }
example do
- pattern.match('').inspect.should be == '#'
+ match = pattern.match('')
+ match.should be_a(Mustermann::Match)
+ match.to_s.should be == ''
end
end
diff --git a/mustermann/spec/match_spec.rb b/mustermann/spec/match_spec.rb
new file mode 100644
index 0000000..efde041
--- /dev/null
+++ b/mustermann/spec/match_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+require 'support'
+require 'mustermann/match'
+require 'mustermann/sinatra'
+
+describe Mustermann::Match do
+ let(:pattern) { Mustermann::Sinatra.new('/:name') }
+ subject(:match) { pattern.match('/foo') }
+
+ its(:string) { should be == '/foo' }
+ its(:params) { should be == { 'name' => 'foo' } }
+ its(:post_match) { should be == '' }
+ its(:pre_match) { should be == '' }
+ its(:to_s) { should be == '/foo' }
+ its(:to_h) { should be == { 'name' => 'foo' } }
+
+ describe :[] do
+ example('symbol key') { match[:name].should be == 'foo' }
+ example('string key') { match['name'].should be == 'foo' }
+ example('invalid key') do
+ expect { match[1] }.to raise_error(ArgumentError, /key must be a String or Symbol/)
+ end
+ end
+
+ describe :values_at do
+ example { match.values_at(:name, 'name').should be == ['foo', 'foo'] }
+ end
+
+ describe :deconstruct_keys do
+ example { match.deconstruct_keys([:name]).should be == { name: 'foo' } }
+ end
+
+ describe :eql? do
+ example { match.eql?(pattern.match('/foo')).should be true }
+ example { match.eql?(pattern.match('/bar')).should be false }
+ example { match.eql?('not a match').should be false }
+ end
+
+ describe :hash do
+ example { match.hash.should be == pattern.match('/foo').hash }
+ example { match.hash.should_not be == pattern.match('/bar').hash }
+ end
+
+ describe :== do
+ example { (match == pattern.match('/foo')).should be true }
+ example { (match == pattern.match('/bar')).should be false }
+ end
+end
diff --git a/mustermann/spec/mustermann_spec.rb b/mustermann/spec/mustermann_spec.rb
index 92830a2..1123c3d 100644
--- a/mustermann/spec/mustermann_spec.rb
+++ b/mustermann/spec/mustermann_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'support'
require 'mustermann'
-require 'sinatra/base'
describe Mustermann do
describe :new do
@@ -66,15 +65,6 @@
example { expect { Mustermann[:expander] }.to raise_error(ArgumentError, "unsupported type :expander") }
end
- describe :extend_object do
- context 'special behavior for Sinatra only' do
- example { Object .new.extend(Mustermann).should be_a(Mustermann) }
- example { Class .new.extend(Mustermann).should be_a(Mustermann) }
-
- example { expect { Sinatra.new.extend(Mustermann) }.to raise_error(RuntimeError, "Mustermann extension for Sinatra has been extracted into its own gem. More information at https://github.com/sinatra/mustermann-sinatra-extension") }
- end
- end
-
describe :=== do
example { Mustermann.should be === Mustermann.new("") }
end
diff --git a/mustermann/spec/rails_spec.rb b/mustermann/spec/rails_spec.rb
index 7ae7b7d..cf785bd 100644
--- a/mustermann/spec/rails_spec.rb
+++ b/mustermann/spec/rails_spec.rb
@@ -48,8 +48,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/foo?') }
it { should_not match('/foo/bar') }
@@ -134,17 +134,17 @@
end
pattern '/*splat' do
- it { should match('/') .capturing splat: '' }
- it { should match('/foo') .capturing splat: 'foo' }
- it { should match('/foo/bar') .capturing splat: 'foo/bar' }
+ it { should match('/') .capturing splat: [''] }
+ it { should match('/foo') .capturing splat: ['foo'] }
+ it { should match('/foo/bar') .capturing splat: ['foo/bar'] }
it { should generate_template('/{+splat}') }
end
pattern '/:foo/*bar' do
it { should match("/foo/bar/baz") .capturing foo: 'foo', bar: 'bar/baz' }
- it { should match("/foo%2Fbar/baz") .capturing foo: 'foo%2Fbar', bar: 'baz' }
- it { should match("/foo/") .capturing foo: 'foo', bar: '' }
- it { should match('/h%20w/h%20a%20y') .capturing foo: 'h%20w', bar: 'h%20a%20y' }
+ it { should match("/foo%2Fbar/baz") .capturing foo: 'foo/bar', bar: 'baz' }
+ it { should match("/foo/") .capturing foo: 'foo', bar: '' }
+ it { should match('/h%20w/h%20a%20y') .capturing foo: 'h w', bar: 'h a y' }
it { should_not match('/foo') }
it { should expand(foo: 'foo') .to('/foo/') }
@@ -197,8 +197,8 @@
it { should match('/pony%2Ejpg') .capturing file: 'pony', ext: 'jpg' }
it { should match('/pony%2ejpg') .capturing file: 'pony', ext: 'jpg' }
- it { should match('/pony%E6%AD%A3%2Ejpg') .capturing file: 'pony%E6%AD%A3', ext: 'jpg' }
- it { should match('/pony%e6%ad%a3%2ejpg') .capturing file: 'pony%e6%ad%a3', ext: 'jpg' }
+ it { should match('/pony%E6%AD%A3%2Ejpg') .capturing file: 'pony正', ext: 'jpg' }
+ it { should match('/pony%e6%ad%a3%2ejpg') .capturing file: 'pony正', ext: 'jpg' }
it { should match('/pony正%2Ejpg') .capturing file: 'pony正', ext: 'jpg' }
it { should match('/pony正%2ejpg') .capturing file: 'pony正', ext: 'jpg' }
it { should match('/pony正..jpg') .capturing file: 'pony正.', ext: 'jpg' }
@@ -254,7 +254,7 @@
it { should match('/2/test.bar') .capturing id: '2' }
it { should match('/2E/test.bar') .capturing id: '2E' }
it { should match('/2e/test.bar') .capturing id: '2e' }
- it { should match('/%2E/test.bar') .capturing id: '%2E' }
+ it { should match('/%2E/test.bar') .capturing id: '.' }
end
pattern '/10/:id' do
@@ -369,8 +369,8 @@
pattern '/:foo', capture: 'a.b' do
it { should match('/a.b') .capturing foo: 'a.b' }
- it { should match('/a%2Eb') .capturing foo: 'a%2Eb' }
- it { should match('/a%2eb') .capturing foo: 'a%2eb' }
+ it { should match('/a%2Eb') .capturing foo: 'a.b' }
+ it { should match('/a%2eb') .capturing foo: 'a.b' }
it { should_not match('/ab') }
it { should_not match('/afb') }
@@ -644,5 +644,12 @@
it { should match('bar') }
end
end
+
+ context '8.1' do
+ pattern 'foo|bar', version: '8.1' do
+ it { should match('foo') }
+ it { should match('bar') }
+ end
+ end
end
end
diff --git a/mustermann/spec/regular_spec.rb b/mustermann/spec/regular_spec.rb
index 198a00b..c7be0cb 100644
--- a/mustermann/spec/regular_spec.rb
+++ b/mustermann/spec/regular_spec.rb
@@ -35,8 +35,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
end
diff --git a/mustermann/spec/set_spec.rb b/mustermann/spec/set_spec.rb
new file mode 100644
index 0000000..368070b
--- /dev/null
+++ b/mustermann/spec/set_spec.rb
@@ -0,0 +1,462 @@
+# frozen_string_literal: true
+require 'support'
+require 'mustermann/set'
+
+describe Mustermann::Set do
+ # Run every example against both matching strategies to ensure they agree.
+ # `use_trie: true` forces the trie; `use_trie: false` forces linear.
+ shared_examples 'a set' do |use_trie:|
+ subject(:set) { described_class.new(use_trie:, use_cache: false) }
+
+ # ── basic matching ──────────────────────────────────────────────────────
+
+ context 'basic matching' do
+ before { set.add('/:name') }
+
+ it('matches a string to a pattern') { expect(set.match('/foo')).not_to be_nil }
+ it('returns nil for no match') { expect(set.match('/foo/bar')).to be_nil }
+ it('returns a Set::Match') { expect(set.match('/foo')).to be_a(Mustermann::Set::Match) }
+ it('exposes the matched string') { expect(set.match('/foo').to_s).to eq '/foo' }
+ it('exposes params') { expect(set.match('/foo').params).to eq('name' => 'foo') }
+ it('supports symbol key access') { expect(set.match('/foo')[:name]).to eq 'foo' }
+ it('supports string key access') { expect(set.match('/foo')['name']).to eq 'foo' }
+ end
+
+ # ── values ──────────────────────────────────────────────────────────────
+
+ context 'values' do
+ it 'returns nil value when none given' do
+ set.add('/foo')
+ expect(set.match('/foo').value).to be_nil
+ end
+
+ it 'stores and returns a value' do
+ set.add('/foo', :handler)
+ expect(set.match('/foo').value).to eq :handler
+ end
+
+ it 'stores first value on single match' do
+ set.add('/foo', :a, :b)
+ expect(set.match('/foo').value).to eq :a
+ end
+
+ it 'de-duplicates values for the same pattern' do
+ set.add('/foo', :a)
+ set.add('/foo', :a)
+ expect(set.match_all('/foo').map(&:value)).to eq [:a]
+ end
+
+ it 'returns all values via match_all' do
+ set.add('/foo', :a, :b)
+ expect(set.match_all('/foo').map(&:value)).to eq [:a, :b]
+ end
+
+ it 'stores values per-pattern independently' do
+ set.add('/foo', :foo_handler)
+ set.add('/bar', :bar_handler)
+ expect(set.match('/foo').value).to eq :foo_handler
+ expect(set.match('/bar').value).to eq :bar_handler
+ end
+ end
+
+ # ── match_all ────────────────────────────────────────────────────────────
+
+ context 'match_all' do
+ it 'returns [] when nothing matches' do
+ set.add('/foo')
+ expect(set.match_all('/bar')).to eq []
+ end
+
+ it 'returns all matching patterns' do
+ set.add('/:a')
+ set.add('/:b')
+ results = set.match_all('/foo')
+ expect(results.size).to eq 2
+ end
+
+ it 'preserves insertion order' do
+ set.add('/:first', :first)
+ set.add('/:second', :second)
+ values = set.match_all('/foo').map(&:value)
+ expect(values).to eq [:first, :second]
+ end
+ end
+
+ # ── peek_match ───────────────────────────────────────────────────────────
+
+ context 'peek_match' do
+ it 'matches a prefix' do
+ set.add('/foo')
+ m = set.peek_match('/foo/extra')
+ expect(m).not_to be_nil
+ expect(m.to_s).to eq '/foo'
+ expect(m.post_match).to eq '/extra'
+ end
+
+ it 'returns nil when nothing can match' do
+ set.add('/foo')
+ expect(set.peek_match('/bar/extra')).to be_nil
+ end
+ end
+
+ # ── peek_match_all ───────────────────────────────────────────────────────
+
+ context 'peek_match_all' do
+ it 'returns all patterns that match a prefix' do
+ set.add('/:a', :first)
+ set.add('/:b', :second)
+ results = set.peek_match_all('/foo/extra')
+ expect(results.size).to eq 2
+ expect(results.map(&:to_s)).to all(eq '/foo')
+ expect(results.map(&:post_match)).to all(eq '/extra')
+ expect(results.map(&:value)).to eq [:first, :second]
+ end
+
+ it 'returns [] when nothing matches as a prefix' do
+ set.add('/foo')
+ expect(set.peek_match_all('/bar/extra')).to eq []
+ end
+ end
+
+ # ── [] accessor ──────────────────────────────────────────────────────────
+
+ context '[]' do
+ it 'looks up by string (matches against patterns)' do
+ set.add('/foo', :result)
+ expect(set['/foo']).to eq :result
+ end
+
+ it 'looks up by Pattern object' do
+ pat = Mustermann.new('/foo')
+ set.add(pat, :result)
+ expect(set[pat]).to eq :result
+ end
+
+ it 'returns nil for no match on string lookup' do
+ set.add('/foo')
+ expect(set['/bar']).to be_nil
+ end
+
+ it 'raises for unsupported type' do
+ expect { set[42] }.to raise_error(ArgumentError)
+ end
+ end
+
+ # ── update / merge ───────────────────────────────────────────────────────
+
+ context 'update' do
+ it 'merges from a Hash' do
+ set.update('/foo' => :a, '/bar' => :b)
+ expect(set['/foo']).to eq :a
+ expect(set['/bar']).to eq :b
+ end
+
+ it 'merges from an Array' do
+ set.update(['/foo', '/bar'])
+ expect(set.match('/foo')).not_to be_nil
+ expect(set.match('/bar')).not_to be_nil
+ end
+
+ it 'merges from a String' do
+ set.update('/foo')
+ expect(set.match('/foo')).not_to be_nil
+ end
+
+ it 'merges from another Set' do
+ other = described_class.new(use_trie:, use_cache: false)
+ other.add('/foo', :from_other)
+ set.update(other)
+ expect(set['/foo']).to eq :from_other
+ end
+
+ it 'combines values when merging overlapping patterns from another Set' do
+ other = described_class.new(use_trie:, use_cache: false)
+ set.add('/foo', :a)
+ other.add('/foo', :b)
+ set.update(other)
+ expect(set.match_all('/foo').map(&:value)).to eq [:a, :b]
+ end
+ end
+
+ context 'merge' do
+ it 'returns a new set without modifying the original' do
+ set.add('/foo', :a)
+ merged = set.merge('/bar' => :b)
+ expect(set.match('/bar')).to be_nil
+ expect(merged['/foo']).to eq :a
+ expect(merged['/bar']).to eq :b
+ end
+ end
+
+ # ── patterns ─────────────────────────────────────────────────────────────
+
+ context 'patterns' do
+ it 'lists all added patterns' do
+ set.add('/foo')
+ set.add('/bar')
+ expect(set.patterns.map(&:to_s)).to contain_exactly('/foo', '/bar')
+ end
+ end
+
+ # ── except option ────────────────────────────────────────────────────────
+
+ context 'except option' do
+ it 'respects the except constraint on a pattern' do
+ set.add(Mustermann.new('/:foo', except: '/bar'), :handler)
+ expect(set.match('/foo').value).to eq :handler
+ expect(set.match('/bar')).to be_nil
+ end
+
+ it 'falls through to an unrestricted pattern when except excludes a match' do
+ set.add(Mustermann.new('/:foo', except: '/bar'), :restricted)
+ set.add('/:foo', :unrestricted)
+ expect(set.match('/foo').value).to eq :restricted
+ expect(set.match('/bar').value).to eq :unrestricted
+ end
+ end
+
+ # ── conflict resolution (ordering) ───────────────────────────────────────
+
+ context 'conflict resolution' do
+ it 'returns the first-added pattern on conflict' do
+ set.add('/foo', :static)
+ set.add('/:var', :dynamic)
+ expect(set.match('/foo').value).to eq :static
+ end
+
+ it 'a dynamic pattern matches what the static one does not' do
+ set.add('/foo', :static)
+ set.add('/:var', :dynamic)
+ expect(set.match('/bar').value).to eq :dynamic
+ end
+
+ it 'two dynamic patterns: insertion order wins' do
+ set.add('/:a', :first)
+ set.add('/:b', :second)
+ expect(set.match('/foo').value).to eq :first
+ end
+
+ it 'match_all returns both when two patterns match' do
+ set.add('/foo', :static)
+ set.add('/:var', :dynamic)
+ values = set.match_all('/foo').map(&:value)
+ expect(values).to contain_exactly(:static, :dynamic)
+ end
+ end
+
+ # ── complex patterns ─────────────────────────────────────────────────────
+
+ context 'complex patterns' do
+ it 'handles nested segments' do
+ set.add('/users/:id/posts/:post_id', :posts)
+ m = set.match('/users/42/posts/7')
+ expect(m.value).to eq :posts
+ expect(m.params).to eq('id' => '42', 'post_id' => '7')
+ end
+
+ it 'handles splat captures' do
+ set.add('/files/*path', :files)
+ m = set.match('/files/a/b/c')
+ expect(m).not_to be_nil
+ # named splat (not 'splat') returns the full captured string
+ expect(m.params['path']).to eq 'a/b/c'
+ end
+
+ it 'handles unnamed splat (always an array)' do
+ set.add('/files/*', :files)
+ m = set.match('/files/a/b/c')
+ expect(m).not_to be_nil
+ expect(m.params['splat']).to eq ['a/b/c']
+ end
+
+ it 'handles optional segments (rails-style)' do
+ s = described_class.new(type: :rails, use_trie:, use_cache: false)
+ s.add('/:controller(/:action)', :route)
+ expect(s.match('/users').value).to eq :route
+ expect(s.match('/users/show').value).to eq :route
+ expect(s.match('/users').params['action']).to be_nil
+ expect(s.match('/users/show').params['action']).to eq 'show'
+ end
+
+ it 'distinguishes prefix-ambiguous patterns by length' do
+ set.add('/users', :list)
+ set.add('/users/:id', :show)
+ expect(set.match('/users').value).to eq :list
+ expect(set.match('/users/1').value).to eq :show
+ end
+
+ it 'deep nesting with multiple params' do
+ set.add('/:a/:b/:c', :deep)
+ m = set.match('/x/y/z')
+ expect(m.params).to eq('a' => 'x', 'b' => 'y', 'c' => 'z')
+ end
+
+ it 'handles URI-encoded params' do
+ set.add('/:name', :cap)
+ m = set.match('/hello%20world')
+ expect(m.params['name']).to eq 'hello world'
+ end
+
+ it 'handles overlapping static and parameterized prefixes' do
+ set.add('/users/new', :new_form)
+ set.add('/users/:id', :show)
+ expect(set.match('/users/new').value).to eq :new_form
+ expect(set.match('/users/42').value).to eq :show
+ end
+
+ it 'handles multiple splats' do
+ set.add('/*/*', :two_splats)
+ m = set.match('/foo/bar')
+ expect(m).not_to be_nil
+ expect(m.params['splat']).to eq ['foo', 'bar']
+ end
+
+ it 'handles optional prefix before required segment (sinatra-style)' do
+ set.add('(/:slug)?/bar', :route)
+ expect(set.match('/bar').value).to eq :route
+ expect(set.match('/foo/bar').value).to eq :route
+ expect(set.match('/bar').params['slug']).to be_nil
+ expect(set.match('/foo/bar').params['slug']).to eq 'foo'
+ expect(set.match('/baz')).to be_nil
+ end
+
+ it 'handles optional prefix before required segment (rails-style)' do
+ s = described_class.new(type: :rails, use_trie:, use_cache: false)
+ s.add('(/:slug)/bar', :route)
+ expect(s.match('/bar').value).to eq :route
+ expect(s.match('/foo/bar').value).to eq :route
+ expect(s.match('/bar').params['slug']).to be_nil
+ expect(s.match('/foo/bar').params['slug']).to eq 'foo'
+ expect(s.match('/baz')).to be_nil
+ end
+
+ it 'resolves conflict between exact static and optional-prefix pattern' do
+ set.add('/foo/bar', :exact)
+ set.add('(/:slug)?/bar', :optional)
+ expect(set.match('/foo/bar').value).to eq :exact
+ expect(set.match('/baz/bar').value).to eq :optional
+ expect(set.match('/bar').value).to eq :optional
+ end
+ end
+
+ # ── error handling ───────────────────────────────────────────────────────
+
+ context 'error handling' do
+ it 'rejects illegal additional_values' do
+ expect { described_class.new(additional_values: :foo, use_trie:) }
+ .to raise_error(ArgumentError)
+ end
+
+ it 'rejects illegal use_trie value' do
+ expect { described_class.new(use_trie: :maybe) }
+ .to raise_error(ArgumentError)
+ end
+
+ it 'rejects non-AST patterns' do
+ expect { set.add(Mustermann.new('/foo', type: :regular)) }
+ .to raise_error(ArgumentError, /Non-AST/)
+ end
+
+ it 'rejects reserved values' do
+ expect { set.add('/foo', :raise) }
+ .to raise_error(ArgumentError)
+ end
+
+ it 'rejects the set itself as a value' do
+ expect { set.add('/foo', set) }
+ .to raise_error(ArgumentError, /set itself/)
+ end
+
+ it 'rejects unsupported mapping types in update' do
+ expect { set.update(42) }
+ .to raise_error(ArgumentError, /unsupported mapping type/)
+ end
+ end
+
+ # ── initialization shortcuts ─────────────────────────────────────────────
+
+ context 'initialization' do
+ it 'accepts patterns in the constructor' do
+ s = described_class.new('/foo' => :a, '/bar' => :b, use_trie:, use_cache: false)
+ expect(s['/foo']).to eq :a
+ expect(s['/bar']).to eq :b
+ end
+
+ it 'accepts a zero-arity block returning a mapping' do
+ s = described_class.new(use_trie:, use_cache: false) { { '/foo' => :block } }
+ expect(s['/foo']).to eq :block
+ end
+
+ it 'accepts a one-argument block for imperative building' do
+ s = described_class.new(use_trie:, use_cache: false) { |set| set.add('/foo', :imperative) }
+ expect(s['/foo']).to eq :imperative
+ end
+ end
+
+ # ── expand ───────────────────────────────────────────────────────────────
+
+ context 'expand' do
+ it 'expands using the first matching pattern' do
+ set.add('/:name', :a)
+ expect(set.expand(name: 'foo')).to include '/foo'
+ end
+
+ it 'expands for a specific value' do
+ set.add('/users/:id', :users)
+ set.add('/posts/:id', :posts)
+ expect(set.expand(:users, id: '5')).to include '/users/5'
+ expect(set.expand(:posts, id: '5')).to include '/posts/5'
+ end
+
+ it 'expands with additional_values behavior passed as value' do
+ set.add('/:name', :handler)
+ expect(set.expand(:ignore, name: 'foo')).to include '/foo'
+ end
+
+ it 'raises when conflicting behavior is specified twice' do
+ set.add('/:name')
+ expect { set.expand(:ignore, :raise, { name: 'foo' }) }
+ .to raise_error(ArgumentError, /behavior specified multiple times/)
+ end
+ end
+ end
+
+ context 'with linear matching (use_trie: false)' do
+ include_examples 'a set', use_trie: false
+ end
+
+ context 'with trie matching (use_trie: true)' do
+ include_examples 'a set', use_trie: true
+ end
+
+ # ── strategy-specific / trie threshold ───────────────────────────────────
+
+ context 'trie threshold' do
+ it 'switches to trie when pattern count reaches threshold' do
+ set = described_class.new(use_trie: 2, use_cache: false)
+ set.add('/a', :a)
+ set.add('/b', :b) # triggers switch
+ set.add('/c', :c)
+ expect(set.match('/a').value).to eq :a
+ expect(set.match('/c').value).to eq :c
+ end
+ end
+
+ # ── caching ───────────────────────────────────────────────────────────────
+
+ context 'with cache' do
+ it 'returns consistent results on repeated lookups' do
+ set = described_class.new(use_trie: false, use_cache: true)
+ set.add('/:name', :handler)
+ 2.times { expect(set.match('/foo').value).to eq :handler }
+ end
+
+ it 'invalidates cache when a pattern is added' do
+ set = described_class.new(use_trie: false, use_cache: true)
+ set.add('/foo', :first)
+ set.match('/bar') # populate cache with nil result
+ set.add('/bar', :second)
+ expect(set.match('/bar').value).to eq :second
+ end
+ end
+end
diff --git a/mustermann/spec/simple_match_spec.rb b/mustermann/spec/simple_match_spec.rb
deleted file mode 100644
index 21955b4..0000000
--- a/mustermann/spec/simple_match_spec.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-require 'support'
-require 'mustermann/simple_match'
-
-describe Mustermann::SimpleMatch do
- subject { Mustermann::SimpleMatch.new('example') }
- its(:to_s) { should be == 'example' }
- its(:names) { should be == [] }
- its(:captures) { should be == [] }
- example { subject[1].should be == nil }
-end
diff --git a/mustermann/spec/sinatra_spec.rb b/mustermann/spec/sinatra_spec.rb
index 58139ba..18bbb30 100644
--- a/mustermann/spec/sinatra_spec.rb
+++ b/mustermann/spec/sinatra_spec.rb
@@ -42,8 +42,8 @@
it { should match('/foo') .capturing foo: 'foo' }
it { should match('/bar') .capturing foo: 'bar' }
it { should match('/foo.bar') .capturing foo: 'foo.bar' }
- it { should match('/%0Afoo') .capturing foo: '%0Afoo' }
- it { should match('/foo%2Fbar') .capturing foo: 'foo%2Fbar' }
+ it { should match('/%0Afoo') .capturing foo: "\nfoo" }
+ it { should match('/foo%2Fbar') .capturing foo: 'foo/bar' }
it { should_not match('/foo?') }
it { should_not match('/foo/bar') }
@@ -126,18 +126,18 @@
end
pattern '/*' do
- it { should match('/') .capturing splat: '' }
- it { should match('/foo') .capturing splat: 'foo' }
- it { should match('/foo/bar') .capturing splat: 'foo/bar' }
+ it { should match('/') .capturing splat: [''] }
+ it { should match('/foo') .capturing splat: ['foo'] }
+ it { should match('/foo/bar') .capturing splat: ['foo/bar'] }
it { should generate_template('/{+splat}') }
example { pattern.params('/foo').should be == {"splat" => ["foo"]} }
end
pattern '/{+splat}' do
- it { should match('/') .capturing splat: '' }
- it { should match('/foo') .capturing splat: 'foo' }
- it { should match('/foo/bar') .capturing splat: 'foo/bar' }
+ it { should match('/') .capturing splat: [''] }
+ it { should match('/foo') .capturing splat: ['foo'] }
+ it { should match('/foo/bar') .capturing splat: ['foo/bar'] }
it { should generate_template('/{+splat}') }
example { pattern.params('/foo').should be == {"splat" => ["foo"]} }
@@ -174,9 +174,9 @@
end
pattern '/:foo/*' do
- it { should match("/foo/bar/baz") .capturing foo: 'foo', splat: 'bar/baz' }
- it { should match("/foo/") .capturing foo: 'foo', splat: '' }
- it { should match('/h%20w/h%20a%20y') .capturing foo: 'h%20w', splat: 'h%20a%20y' }
+ it { should match("/foo/bar/baz") .capturing foo: 'foo', splat: ['bar/baz'] }
+ it { should match("/foo/") .capturing foo: 'foo', splat: [''] }
+ it { should match('/h%20w/h%20a%20y') .capturing foo: 'h w', splat: ['h a y'] }
it { should_not match('/foo') }
it { should generate_template('/{foo}/{+splat}') }
@@ -185,9 +185,9 @@
end
pattern '/{foo}/*' do
- it { should match("/foo/bar/baz") .capturing foo: 'foo', splat: 'bar/baz' }
- it { should match("/foo/") .capturing foo: 'foo', splat: '' }
- it { should match('/h%20w/h%20a%20y') .capturing foo: 'h%20w', splat: 'h%20a%20y' }
+ it { should match("/foo/bar/baz") .capturing foo: 'foo', splat: ['bar/baz'] }
+ it { should match("/foo/") .capturing foo: 'foo', splat: [''] }
+ it { should match('/h%20w/h%20a%20y') .capturing foo: 'h w', splat: ['h a y'] }
it { should_not match('/foo') }
it { should generate_template('/{foo}/{+splat}') }
@@ -206,6 +206,7 @@
end
pattern "/path with spaces" do
+ it { should match('/path with spaces') }
it { should match('/path%20with%20spaces') }
it { should match('/path%2Bwith%2Bspaces') }
it { should match('/path+with+spaces') }
@@ -224,12 +225,6 @@
pattern '/*/:foo/*/*' do
it { should match('/bar/foo/bling/baz/boom') }
- it "should capture all splat parts" do
- match = pattern.match('/bar/foo/bling/baz/boom')
- match.captures.should be == ['bar', 'foo', 'bling', 'baz/boom']
- match.names.should be == ['splat', 'foo']
- end
-
it 'should map to proper params' do
pattern.params('/bar/foo/bling/baz/boom').should be == {
"foo" => "foo", "splat" => ['bar', 'bling', 'baz/boom']
@@ -240,12 +235,6 @@
pattern '/{+splat}/{foo}/{+splat}/{+splat}' do
it { should match('/bar/foo/bling/baz/boom') }
- it "should capture all splat parts" do
- match = pattern.match('/bar/foo/bling/baz/boom')
- match.captures.should be == ['bar', 'foo', 'bling', 'baz/boom']
- match.names.should be == ['splat', 'foo']
- end
-
it 'should map to proper params' do
pattern.params('/bar/foo/bling/baz/boom').should be == {
"foo" => "foo", "splat" => ['bar', 'bling', 'baz/boom']
@@ -263,8 +252,8 @@
it { should match('/pony%2Ejpg') .capturing file: 'pony', ext: 'jpg' }
it { should match('/pony%2ejpg') .capturing file: 'pony', ext: 'jpg' }
- it { should match('/pony%E6%AD%A3%2Ejpg') .capturing file: 'pony%E6%AD%A3', ext: 'jpg' }
- it { should match('/pony%e6%ad%a3%2ejpg') .capturing file: 'pony%e6%ad%a3', ext: 'jpg' }
+ it { should match('/pony%E6%AD%A3%2Ejpg') .capturing file: 'pony正', ext: 'jpg' }
+ it { should match('/pony%e6%ad%a3%2ejpg') .capturing file: 'pony正', ext: 'jpg' }
it { should match('/pony正%2Ejpg') .capturing file: 'pony正', ext: 'jpg' }
it { should match('/pony正%2ejpg') .capturing file: 'pony正', ext: 'jpg' }
it { should match('/pony正..jpg') .capturing file: 'pony正.', ext: 'jpg' }
@@ -312,7 +301,7 @@
it { should match('/2/test.bar') .capturing id: '2' }
it { should match('/2E/test.bar') .capturing id: '2E' }
it { should match('/2e/test.bar') .capturing id: '2e' }
- it { should match('/%2E/test.bar') .capturing id: '%2E' }
+ it { should match('/%2E/test.bar') .capturing id: '.' }
end
pattern '/10/:id' do
@@ -524,8 +513,8 @@
pattern '/:foo', capture: 'a.b' do
it { should match('/a.b') .capturing foo: 'a.b' }
- it { should match('/a%2Eb') .capturing foo: 'a%2Eb' }
- it { should match('/a%2eb') .capturing foo: 'a%2eb' }
+ it { should match('/a%2Eb') .capturing foo: 'a.b' }
+ it { should match('/a%2eb') .capturing foo: 'a.b' }
it { should_not match('/ab') }
it { should_not match('/afb') }
@@ -652,6 +641,7 @@
end
pattern "/path with spaces", space_matches_plus: false do
+ it { should match('/path with spaces') }
it { should match('/path%20with%20spaces') }
it { should_not match('/path%2Bwith%2Bspaces') }
it { should_not match('/path+with+spaces') }
diff --git a/support/lib/support/pattern.rb b/support/lib/support/pattern.rb
index 3da8bee..2670c79 100644
--- a/support/lib/support/pattern.rb
+++ b/support/lib/support/pattern.rb
@@ -18,7 +18,6 @@ def pattern(pattern, options = nil, &block)
subject(:pattern, &instance)
its(:to_s) { should be == pattern }
its(:inspect) { should be == "#<#{described_class}:#{pattern.inspect}>" }
- its(:names) { should be_an(Array) }
its(:to_templates) { should be == [pattern] } if described_class.name == "Mustermann::Template"
example 'string should be immune to external change' do