398 lines
14 KiB
Ruby
398 lines
14 KiB
Ruby
require 'mustermann/error'
|
|
require 'mustermann/simple_match'
|
|
require 'mustermann/equality_map'
|
|
require 'uri'
|
|
|
|
module Mustermann
|
|
# Superclass for all pattern implementations.
|
|
# @abstract
|
|
class Pattern
|
|
include Mustermann
|
|
@@uri ||= URI::Parser.new
|
|
|
|
# List of supported options.
|
|
#
|
|
# @overload supported_options
|
|
# @return [Array<Symbol>] list of supported options
|
|
# @overload supported_options(*list)
|
|
# Adds options to the list.
|
|
#
|
|
# @api private
|
|
# @param [Symbol] *list adds options to the list of supported options
|
|
# @return [Array<Symbol>] list of supported options
|
|
def self.supported_options(*list)
|
|
@supported_options ||= []
|
|
options = @supported_options.concat(list)
|
|
options += superclass.supported_options if self < Pattern
|
|
options
|
|
end
|
|
|
|
# Registers the pattern with Mustermann.
|
|
# @see Mustermann.register
|
|
# @!visibility private
|
|
def self.register(*names)
|
|
names.each { |name| Mustermann.register(name, self) }
|
|
end
|
|
|
|
# @param [Symbol] option The option to check.
|
|
# @return [Boolean] Whether or not option is supported.
|
|
def self.supported?(option, **options)
|
|
supported_options.include? option
|
|
end
|
|
|
|
# @overload new(string, **options)
|
|
# @param (see #initialize)
|
|
# @raise (see #initialize)
|
|
# @raise [ArgumentError] if some option is not supported
|
|
# @return [Mustermann::Pattern] a new instance of Mustermann::Pattern
|
|
# @see #initialize
|
|
def self.new(string, ignore_unknown_options: false, **options)
|
|
if ignore_unknown_options
|
|
options = options.select { |key, value| supported?(key, **options) if key != :ignore_unknown_options }
|
|
else
|
|
unsupported = options.keys.detect { |key| not supported?(key, **options) }
|
|
raise ArgumentError, "unsupported option %p for %p" % [unsupported, self] if unsupported
|
|
end
|
|
|
|
@map ||= EqualityMap.new
|
|
@map.fetch(string, options) { super(string, options) { options } }
|
|
end
|
|
|
|
supported_options :uri_decode, :ignore_unknown_options
|
|
attr_reader :uri_decode
|
|
|
|
# options hash passed to new (with unsupported options removed)
|
|
# @!visibility private
|
|
attr_reader :options
|
|
|
|
# @overload initialize(string, **options)
|
|
# @param [String] string the string representation of the pattern
|
|
# @param [Hash] options options for fine-tuning the pattern behavior
|
|
# @raise [Mustermann::Error] if the pattern can't be generated from the string
|
|
# @see file:README.md#Types_and_Options "Types and Options" in the README
|
|
# @see Mustermann.new
|
|
def initialize(string, uri_decode: true, **options)
|
|
@uri_decode = uri_decode
|
|
@string = string.to_s.dup
|
|
@options = yield.freeze if block_given?
|
|
end
|
|
|
|
# @return [String] the string representation of the pattern
|
|
def to_s
|
|
@string.dup
|
|
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
|
|
def match(string)
|
|
SimpleMatch.new(string) if self === string
|
|
end
|
|
|
|
# @param [String] string The string to match against
|
|
# @return [Integer, nil] nil if pattern does not match the string, zero if it does.
|
|
# @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-3D-7E Regexp#=~
|
|
def =~(string)
|
|
0 if self === string
|
|
end
|
|
|
|
# @param [String] string The string to match against
|
|
# @return [Boolean] Whether or not the pattern matches the given string
|
|
# @note Needs to be overridden by subclass.
|
|
# @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-3D-3D-3D Regexp#===
|
|
def ===(string)
|
|
raise NotImplementedError, 'subclass responsibility'
|
|
end
|
|
|
|
# Used by Ruby internally for hashing.
|
|
# @return [Fixnum] same has value for patterns that are equal
|
|
def hash
|
|
self.class.hash | @string.hash | options.hash
|
|
end
|
|
|
|
# Two patterns are considered equal if they are of the same type, have the same pattern string
|
|
# and the same options.
|
|
# @return [true, false]
|
|
def ==(other)
|
|
other.class == self.class and other.to_s == @string and other.options == options
|
|
end
|
|
|
|
# Two patterns are considered equal if they are of the same type, have the same pattern string
|
|
# and the same options.
|
|
# @return [true, false]
|
|
def eql?(other)
|
|
other.class.eql?(self.class) and other.to_s.eql?(@string) and other.options.eql?(options)
|
|
end
|
|
|
|
# Tries to match the pattern against the beginning of the string (as opposed to the full string).
|
|
# Will return the count of the matching characters if it matches.
|
|
#
|
|
# @example
|
|
# pattern = Mustermann.new('/:name')
|
|
# pattern.size("/Frank/Sinatra") # => 6
|
|
#
|
|
# @param [String] string The string to match against
|
|
# @return [Integer, nil] the number of characters that match
|
|
def peek_size(string)
|
|
# this is a very naive, unperformant implementation
|
|
string.size.downto(0).detect { |s| self === string[0, s] }
|
|
end
|
|
|
|
# Tries to match the pattern against the beginning of the string (as opposed to the full string).
|
|
# Will return the substring if it matches.
|
|
#
|
|
# @example
|
|
# pattern = Mustermann.new('/:name')
|
|
# pattern.peek("/Frank/Sinatra") # => "/Frank"
|
|
#
|
|
# @param [String] string The string to match against
|
|
# @return [String, nil] matched subsctring
|
|
def peek(string)
|
|
size = peek_size(string)
|
|
string[0, size] if size
|
|
end
|
|
|
|
# Tries to match the pattern against the beginning of the string (as opposed to the full string).
|
|
# Will return a MatchData or similar instance for the matched substring.
|
|
#
|
|
# @example
|
|
# pattern = Mustermann.new('/:name')
|
|
# pattern.peek("/Frank/Sinatra") # => #<MatchData "/Frank" name:"Frank">
|
|
#
|
|
# @param [String] string The string to match against
|
|
# @return [MatchData, Mustermann::SimpleMatch, nil] MatchData or similar object if the pattern matches.
|
|
# @see #peek_params
|
|
def peek_match(string)
|
|
matched = peek(string)
|
|
match(matched) if matched
|
|
end
|
|
|
|
# Tries to match the pattern against the beginning of the string (as opposed to the full string).
|
|
# Will return a two element Array with the params parsed from the substring as first entry and the length of
|
|
# the substring as second.
|
|
#
|
|
# @example
|
|
# pattern = Mustermann.new('/:name')
|
|
# params, _ = pattern.peek_params("/Frank/Sinatra")
|
|
#
|
|
# puts "Hello, #{params['name']}!" # Hello, Frank!
|
|
#
|
|
# @param [String] string The string to match against
|
|
# @return [Array<Hash, Integer>, 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<Integer>}] 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<String>] capture names.
|
|
# @see http://ruby-doc.org/core-2.0/Regexp.html#method-i-names Regexp#names
|
|
def names
|
|
[]
|
|
end
|
|
|
|
# @param [String] string the string to match against
|
|
# @return [Hash{String: String, Array<String>}, 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
|
|
|
|
# @note This method is only implemented by certain subclasses.
|
|
#
|
|
# @example Expanding a pattern
|
|
# pattern = Mustermann.new('/:name(.:ext)?')
|
|
# pattern.expand(name: 'hello') # => "/hello"
|
|
# pattern.expand(name: 'hello', ext: 'png') # => "/hello.png"
|
|
#
|
|
# @example Checking if a pattern supports expanding
|
|
# if pattern.respond_to? :expand
|
|
# pattern.expand(name: "foo")
|
|
# else
|
|
# warn "does not support expanding"
|
|
# end
|
|
#
|
|
# Expanding is supported by almost all patterns (notable execptions are {Mustermann::Shell},
|
|
# {Mustermann::Regular} and {Mustermann::Simple}).
|
|
#
|
|
# Union {Mustermann::Composite} patterns (with the | operator) support expanding if all
|
|
# patterns they are composed of also support it.
|
|
#
|
|
# @param (see Mustermann::Expander#expand)
|
|
# @return [String] expanded string
|
|
# @raise [NotImplementedError] raised if expand is not supported.
|
|
# @raise [Mustermann::ExpandError] raised if a value is missing or unknown
|
|
# @see Mustermann::Expander
|
|
def expand(behavior = nil, values = {})
|
|
raise NotImplementedError, "expanding not supported by #{self.class}"
|
|
end
|
|
|
|
# @note This method is only implemented by certain subclasses.
|
|
#
|
|
# Generates a list of URI template strings representing the pattern.
|
|
#
|
|
# Note that this transformation is lossy and the strings matching these
|
|
# templates might not match the pattern (and vice versa).
|
|
#
|
|
# This comes in quite handy since URI templates are not made for pattern matching.
|
|
# That way you can easily use a more precise template syntax and have it automatically
|
|
# generate hypermedia links for you.
|
|
#
|
|
# @example generating templates
|
|
# Mustermann.new("/:name").to_templates # => ["/{name}"]
|
|
# Mustermann.new("/:foo(@:bar)?/*baz").to_templates # => ["/{foo}@{bar}/{+baz}", "/{foo}/{+baz}"]
|
|
# Mustermann.new("/{name}", type: :template).to_templates # => ["/{name}"]
|
|
#
|
|
# @example generating templates from composite patterns
|
|
# pattern = Mustermann.new('/:name')
|
|
# pattern |= Mustermann.new('/{name}', type: :template)
|
|
# pattern |= Mustermann.new('/example/*nested')
|
|
# pattern.to_templates # => ["/{name}", "/example/{+nested}"]
|
|
#
|
|
# Template generation is supported by almost all patterns (notable exceptions are
|
|
# {Mustermann::Shell}, {Mustermann::Regular} and {Mustermann::Simple}).
|
|
# Union {Mustermann::Composite} patterns (with the | operator) support template generation
|
|
# if all patterns they are composed of also support it.
|
|
#
|
|
# @example Checking if a pattern supports expanding
|
|
# if pattern.respond_to? :to_templates
|
|
# pattern.to_templates
|
|
# else
|
|
# warn "does not support template generation"
|
|
# end
|
|
#
|
|
# @return [Array<String>] list of URI templates
|
|
def to_templates
|
|
raise NotImplementedError, "template generation not supported by #{self.class}"
|
|
end
|
|
|
|
# @overload |(other)
|
|
# Creates a pattern that matches any string matching either one of the patterns.
|
|
# If a string is supplied, it is treated as an identity pattern.
|
|
#
|
|
# @example
|
|
# pattern = Mustermann.new('/foo/:name') | Mustermann.new('/:first/:second')
|
|
# pattern === '/foo/bar' # => true
|
|
# pattern === '/fox/bar' # => true
|
|
# pattern === '/foo' # => false
|
|
#
|
|
# @overload &(other)
|
|
# Creates a pattern that matches any string matching both of the patterns.
|
|
# If a string is supplied, it is treated as an identity pattern.
|
|
#
|
|
# @example
|
|
# pattern = Mustermann.new('/foo/:name') & Mustermann.new('/:first/:second')
|
|
# pattern === '/foo/bar' # => true
|
|
# pattern === '/fox/bar' # => false
|
|
# pattern === '/foo' # => false
|
|
#
|
|
# @overload ^(other)
|
|
# Creates a pattern that matches any string matching exactly one of the patterns.
|
|
# If a string is supplied, it is treated as an identity pattern.
|
|
#
|
|
# @example
|
|
# pattern = Mustermann.new('/foo/:name') ^ Mustermann.new('/:first/:second')
|
|
# pattern === '/foo/bar' # => false
|
|
# pattern === '/fox/bar' # => true
|
|
# pattern === '/foo' # => false
|
|
#
|
|
# @param [Mustermann::Pattern, String] other the other pattern
|
|
# @return [Mustermann::Pattern] a composite pattern
|
|
def |(other)
|
|
Mustermann::Composite.new(self, other, operator: __callee__, type: :identity)
|
|
end
|
|
|
|
alias_method :&, :|
|
|
alias_method :^, :|
|
|
|
|
# @example
|
|
# require 'mustermann'
|
|
# prefix = Mustermann.new("/:prefix")
|
|
# about = prefix + "/about"
|
|
# about.params("/main/about") # => {"prefix" => "main"}
|
|
#
|
|
# Creates a concatenated pattern by combingin self with the other pattern supplied.
|
|
# Patterns of different types can be mixed. The availability of `to_templates` and
|
|
# `expand` depends on the patterns being concatenated.
|
|
#
|
|
# String input is treated as identity pattern.
|
|
#
|
|
# @param [Mustermann::Pattern, String] other pattern to be appended
|
|
# @return [Mustermann::Pattern] concatenated pattern
|
|
def +(other)
|
|
Concat.new(self, other, type: :identity)
|
|
end
|
|
|
|
# @example
|
|
# pattern = Mustermann.new('/:a/:b')
|
|
# strings = ["foo/bar", "/foo/bar", "/foo/bar/"]
|
|
# strings.detect(&pattern) # => "/foo/bar"
|
|
#
|
|
# @return [Proc] proc wrapping {#===}
|
|
def to_proc
|
|
@to_proc ||= method(:===).to_proc
|
|
end
|
|
|
|
# @!visibility private
|
|
# @return [Boolean]
|
|
# @see Object#respond_to?
|
|
def respond_to?(method, *args)
|
|
return super unless %i[expand to_templates].include? method
|
|
respond_to_special?(method)
|
|
end
|
|
|
|
# @!visibility private
|
|
# @return [Boolean]
|
|
# @see #respond_to?
|
|
def respond_to_special?(method)
|
|
method(method).owner != Mustermann::Pattern
|
|
end
|
|
|
|
# @!visibility private
|
|
def inspect
|
|
"#<%p:%p>" % [self.class, @string]
|
|
end
|
|
|
|
# @!visibility private
|
|
def simple_inspect
|
|
type = self.class.name[/[^:]+$/].downcase
|
|
"%s:%p" % [type, @string]
|
|
end
|
|
|
|
# @!visibility private
|
|
def map_param(key, value)
|
|
unescape(value, true)
|
|
end
|
|
|
|
# @!visibility private
|
|
def unescape(string, decode = uri_decode)
|
|
return string unless decode and string
|
|
@@uri.unescape(string)
|
|
end
|
|
|
|
# @!visibility private
|
|
ALWAYS_ARRAY = %w[splat captures]
|
|
|
|
# @!visibility private
|
|
def always_array?(key)
|
|
ALWAYS_ARRAY.include? key
|
|
end
|
|
|
|
private :unescape, :map_param, :respond_to_special?
|
|
private_constant :ALWAYS_ARRAY
|
|
end
|
|
end
|