This commit is contained in:
Samuel Cochran 2016-07-21 16:28:54 +10:00
parent 783992bbe2
commit a8c4843b13
No known key found for this signature in database
GPG Key ID: 1A36ACA1BDECD23E
58 changed files with 3311 additions and 186 deletions

36
.gitmodules vendored Normal file
View File

@ -0,0 +1,36 @@
[submodule "vendor/gems/net-http-server"]
path = vendor/gems/net-http-server
url = git://github.com/postmodern/net-http-server.git
[submodule "vendor/gems/parslet"]
path = vendor/gems/parslet
url = git://github.com/kschiess/parslet.git
[submodule "vendor/gems/gserver"]
path = vendor/gems/gserver
url = git://github.com/ruby/gserver.git
[submodule "vendor/gems/mail"]
path = vendor/gems/mail
url = git://github.com/mikel/mail.git
[submodule "vendor/gems/sinatra"]
path = vendor/gems/sinatra
url = git://github.com/sinatra/sinatra.git
[submodule "vendor/gems/rack"]
path = vendor/gems/rack
url = git://github.com/rack/rack.git
[submodule "vendor/gems/websocket-ruby"]
path = vendor/gems/websocket-ruby
url = git://github.com/imanel/websocket-ruby.git
[submodule "vendor/gems/midi-smtp-server"]
path = vendor/gems/midi-smtp-server
url = git://github.com/4commerce-technologies-AG/midi-smtp-server.git
[submodule "vendor/gems/mime-types"]
path = vendor/gems/mime-types
url = git://github.com/mime-types/ruby-mime-types.git
[submodule "vendor/gems/mime-types-data"]
path = vendor/gems/mime-types-data
url = git://github.com/mime-types/mime-types-data.git
[submodule "vendor/gems/rack-protection"]
path = vendor/gems/rack-protection
url = git://github.com/sinatra/rack-protection.git
[submodule "vendor/gems/tilt"]
path = vendor/gems/tilt
url = git://github.com/rtomayko/tilt.git

View File

@ -2,6 +2,8 @@ source "https://rubygems.org"
gemspec
gem "sqlite3"
# mime-types 3+, required by mail, requires ruby 2.0+
gem "mime-types", "< 3" if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2")

View File

@ -1,15 +1,13 @@
#!/usr/bin/env ruby
begin
require 'mail'
rescue LoadError
require 'rubygems'
require 'mail'
end
# Make sure we can require vendored gems
$:.unshift(*Dir.glob(File.expand_path("../../vendor/gems/*/lib", __FILE__)))
require 'optparse'
require "optparse"
options = {:smtp_ip => '127.0.0.1', :smtp_port => 1025}
require "mail"
options = {:smtp_ip => "127.0.0.1", :smtp_port => 1025}
OptionParser.new do |parser|
parser.banner = <<-BANNER.gsub /^ +/, ""
@ -17,34 +15,34 @@ OptionParser.new do |parser|
sendmail-like interface to forward mail to MailCatcher.
BANNER
parser.on('--ip IP') do |ip|
parser.on("--ip IP") do |ip|
options[:smtp_ip] = ip
end
parser.on('--smtp-ip IP', 'Set the ip address of the smtp server') do |ip|
parser.on("--smtp-ip IP", "Set the ip address of the smtp server") do |ip|
options[:smtp_ip] = ip
end
parser.on('--smtp-port PORT', Integer, 'Set the port of the smtp server') do |port|
parser.on("--smtp-port PORT", Integer, "Set the port of the smtp server") do |port|
options[:smtp_port] = port
end
parser.on('-f FROM', 'Set the sending address') do |from|
parser.on("-f FROM", "Set the sending address") do |from|
options[:from] = from
end
parser.on('-oi', 'Ignored option -oi') do |ignored|
parser.on("-oi", "Ignored option -oi") do |ignored|
end
parser.on('-t', 'Ignored option -t') do |ignored|
parser.on("-t", "Ignored option -t") do |ignored|
end
parser.on('-q', 'Ignored option -q') do |ignored|
parser.on("-q", "Ignored option -q") do |ignored|
end
parser.on('-x', '--no-exit', 'Can\'t exit from the application') do
parser.on("-x", "--no-exit", "Can't exit from the application") do
options[:no_exit] = true
end
parser.on('-h', '--help', 'Display this help information') do
parser.on("-h", "--help", "Display this help information") do
puts parser
exit!
end

View File

@ -1,5 +1,8 @@
#!/usr/bin/env ruby
require 'mail_catcher'
# Make sure we can require vendored gems
$:.unshift(*Dir.glob(File.expand_path("../../vendor/gems/*/lib", __FILE__)))
require "mail_catcher"
MailCatcher.run!

View File

@ -1,32 +1,10 @@
# Apparently rubygems won't activate these on its own, so here we go. Let's
# repeat the invention of Bundler all over again.
gem "eventmachine", "1.0.9.1"
gem "mail", "~> 2.3"
gem "rack", "~> 1.5"
gem "sinatra", "~> 1.2"
gem "sqlite3", "~> 1.3"
gem "thin", "~> 1.5.0"
gem "skinny", "~> 0.2.3"
require "logger"
require "open3"
require "optparse"
require "rbconfig"
require "eventmachine"
require "thin"
module EventMachine
# Monkey patch fix for 10deb4
# See https://github.com/eventmachine/eventmachine/issues/569
def self.reactor_running?
(@reactor_running || false)
end
end
require "mail_catcher/events"
require "mail_catcher/mail"
require "mail_catcher/smtp"
require "mail_catcher/web"
require "mail_catcher/http"
require "mail_catcher/version"
module MailCatcher extend self
@ -140,6 +118,12 @@ module MailCatcher extend self
end
end
def logger
@logger ||= Logger.new(STDOUT).tap do |logger|
logger.level = Logger::INFO
end
end
def run! options=nil
# If we are passed options, fill in the blanks
options &&= options.reverse_merge @@defaults
@ -156,46 +140,56 @@ module MailCatcher extend self
puts "Starting MailCatcher"
Thin::Logging.silent = (ENV["MAILCATCHER_ENV"] != "development")
# Start up an SMTP server
@smtp_server = MailCatcher::SMTP.new(host: options[:smtp_ip], port: options[:smtp_port], logger: logger)
@smtp_server.start
# One EventMachine loop...
EventMachine.run do
# Set up an SMTP server to run within EventMachine
rescue_port options[:smtp_port] do
EventMachine.start_server options[:smtp_ip], options[:smtp_port], Smtp
puts "==> #{smtp_url}"
end
# Start up an HTTP server
@http_server = MailCatcher::HTTP.new(host: options[:http_ip], port: options[:http_port], logger: logger)
@http_server.start
# Let Thin set itself up inside our EventMachine loop
# (Skinny/WebSockets just works on the inside)
rescue_port options[:http_port] do
Thin::Server.start(options[:http_ip], options[:http_port], Web)
puts "==> #{http_url}"
end
# Set up some signal traps to gracefully quit
#Signal.trap("INT") { quit! }
#Signal.trap("TERM") { quit! }
# Open the web browser before detatching console
if options[:browse]
EventMachine.next_tick do
browse http_url
end
end
# Tell her about it
puts "==> #{smtp_url}"
puts "==> #{http_url}"
# Daemonize, if we should, but only after the servers have started.
if options[:daemon]
EventMachine.next_tick do
if quittable?
puts "*** MailCatcher runs as a daemon by default. Go to the web interface to quit."
else
puts "*** MailCatcher is now running as a daemon that cannot be quit."
end
Process.daemon
end
end
# Open a browser if we were asked to
if options[:browse]
browse http_url
end
# Daemonize, if we should, but only after the servers have started.
if options[:daemon]
if quittable?
puts "*** MailCatcher runs as a daemon by default. Go to the web interface to quit."
else
puts "*** MailCatcher is now running as a daemon that cannot be quit."
end
Process.daemon
end
# Now wait for shutdown
@smtp_server.join
@http_server.join
logger.info "Bye! 👋"
end
def quit!
EventMachine.next_tick { EventMachine.stop_event_loop }
unless quitting?
@smtp_server.stop
@http_server.stop
@quitting = true
end
end
def quitting?
!!@quitting
end
protected

View File

@ -1,7 +0,0 @@
require "eventmachine"
module MailCatcher
module Events
MessageAdded = EventMachine::Channel.new
end
end

10
lib/mail_catcher/http.rb Normal file
View File

@ -0,0 +1,10 @@
require "net/http/server"
require "rack/handler/http"
require "mail_catcher/web"
class MailCatcher::HTTP < Net::HTTP::Server::Daemon
def initialize(logger: nil, **options)
super(handler: Rack::Handler::HTTP.new(MailCatcher::Web::Application.new), log: logger, **options)
end
end

View File

@ -1,4 +1,3 @@
require "eventmachine"
require "json"
require "mail"
require "sqlite3"
@ -37,11 +36,11 @@ module MailCatcher::Mail extend self
end
end
def add_message(message)
def add_message(from:, to:, data:)
@add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, source, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))")
mail = Mail.new(message[:source])
@add_message_query.execute(message[:sender], JSON.generate(message[:recipients]), mail.subject, message[:source], mail.mime_type || "text/plain", message[:source].length)
mail = Mail.new(data)
@add_message_query.execute(from, JSON.generate(to), mail.subject, data, mail.mime_type || "text/plain", data.length)
message_id = db.last_insert_row_id
parts = mail.all_parts
parts = [mail] if parts.empty?
@ -51,11 +50,6 @@ module MailCatcher::Mail extend self
cid = part.cid if part.respond_to? :cid
add_message_part(message_id, cid, part.mime_type || "text/plain", part.attachment? ? 1 : 0, part.filename, part.charset, body, body.length)
end
EventMachine.next_tick do
message = MailCatcher::Mail.message message_id
MailCatcher::Events::MessageAdded.push message
end
end
def add_message_part(*args)

View File

@ -0,0 +1,9 @@
module MailCatcher
module Pubsub extend self
def pub(name, data)
end
def sub(*names)
end
end
end

View File

@ -1,61 +1,32 @@
require "eventmachine"
require "mail"
require "midi-smtp-server"
require "mail_catcher/mail"
class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
# We override EM's mail from processing to allow multiple mail-from commands
# per [RFC 2821](http://tools.ietf.org/html/rfc2821#section-4.1.1.2)
def process_mail_from sender
if @state.include? :mail_from
@state -= [:mail_from, :rcpt, :data]
receive_reset
end
class MailCatcher::SMTP < MidiSmtpServer::Smtpd
public :start
super
def initialize(host:, port:, logger: nil, **options)
super(port, host, 256, do_dns_reverse_lookup: false, logger: logger)
end
def current_message
@current_message ||= {}
end
def on_message_data_event(envelope:, message:, **context)
MailCatcher::Mail.add_message(from: envelope[:from], to: envelope[:to], data: message[:data])
def receive_reset
@current_message = nil
true
end
def receive_sender(sender)
current_message[:sender] = sender
true
end
def receive_recipient(recipient)
current_message[:recipients] ||= []
current_message[:recipients] << recipient
true
end
def receive_data_chunk(lines)
current_message[:source] ||= ""
lines.each do |line|
current_message[:source] << line << "\r\n"
end
true
end
def receive_message
MailCatcher::Mail.add_message current_message
puts "==> SMTP: Received message from '#{current_message[:sender]}' (#{current_message[:source].length} bytes)"
true
puts "==> SMTP: Received message from '#{envelope[:from]}' (#{message[:data].bytesize} bytes)"
rescue
puts "*** Error receiving message: #{current_message.inspect}"
puts "*** Error receiving message"
puts " MailCatcher v#{MailCatcher::VERSION}"
puts " From: #{envelope[:from].inspect}"
puts " To: #{envelope[:to].inspect}"
puts " Data: #{message[:data].inspect}"
puts " Exception: #{$!}"
puts " Backtrace:"
$!.backtrace.each do |line|
puts " #{line}"
end
puts " Please submit this as an issue at http://github.com/sj26/mailcatcher/issues"
false
ensure
@current_message = nil
raise MidiSmtpServer::Smtpd451Exception.new("Error receiving message, see MailCatcher log for details")
end
end

View File

@ -1,3 +1,3 @@
module MailCatcher
VERSION = "0.6.4"
VERSION = "2.0.0.alpha"
end

View File

@ -3,47 +3,22 @@ require "net/http"
require "uri"
require "sinatra"
require "skinny"
require "mail_catcher/events"
require "mail_catcher/mail"
class Sinatra::Request
include Skinny::Helpers
end
module MailCatcher
module Web
class Application < Sinatra::Base
set :development, ENV["MAILCATCHER_ENV"] == "development"
set :root, File.expand_path("#{__FILE__}/../../../..")
if development?
require "sprockets-helpers"
configure do
require "mail_catcher/web/assets"
Sprockets::Helpers.configure do |config|
config.environment = Assets
config.prefix = "/assets"
config.digest = false
config.public_path = public_folder
config.debug = true
end
helpers do
def javascript_tag(name)
%{<script src="/assets/#{name}.js"></script>}
end
helpers do
include Sprockets::Helpers
end
else
helpers do
def javascript_tag(name)
%{<script src="/assets/#{name}.js"></script>}
end
def stylesheet_tag(name)
%{<link rel="stylesheet" href="/assets/#{name}.css">}
end
def stylesheet_tag(name)
%{<link rel="stylesheet" href="/assets/#{name}.css">}
end
end
@ -61,18 +36,8 @@ module MailCatcher
end
get "/messages" do
if request.websocket?
request.websocket!(
:on_start => proc do |websocket|
subscription = Events::MessageAdded.subscribe { |message| websocket.send_message(JSON.generate(message)) }
websocket.on_close do |websocket|
Events::MessageAdded.unsubscribe subscription
end
end)
else
content_type :json
JSON.generate(Mail.messages)
end
content_type :json
JSON.generate(Mail.messages)
end
delete "/messages" do

View File

@ -23,6 +23,7 @@ Gem::Specification.new do |s|
"lib/**/*.rb",
"public/**/*",
"views/**/*",
"vendor/gems/*/lib/**/*.rb",
] - Dir["lib/mail_catcher/web/assets.rb"]
s.require_paths = ["lib"]
s.executables = ["mailcatcher", "catchmail"]
@ -30,14 +31,6 @@ Gem::Specification.new do |s|
s.required_ruby_version = ">= 1.9.3"
s.add_dependency "eventmachine", "1.0.9.1"
s.add_dependency "mail", "~> 2.3"
s.add_dependency "rack", "~> 1.5"
s.add_dependency "sinatra", "~> 1.2"
s.add_dependency "sqlite3", "~> 1.3"
s.add_dependency "thin", "~> 1.5.0"
s.add_dependency "skinny", "~> 0.2.3"
s.add_development_dependency "coffee-script"
s.add_development_dependency "compass"
s.add_development_dependency "minitest", "~> 5.0"

1
vendor/gems/gserver vendored Submodule

@ -0,0 +1 @@
Subproject commit f8d68f9065f486ac33f723b9a584f8b686f2bd16

1
vendor/gems/mail vendored Submodule

@ -0,0 +1 @@
Subproject commit a217776355befa3d8191c4bd3c1fad54e0e27471

1
vendor/gems/midi-smtp-server vendored Submodule

@ -0,0 +1 @@
Subproject commit d955cbb9cd4cc902dc5c7f0670e91f3ac50490b8

1
vendor/gems/mime-types vendored Submodule

@ -0,0 +1 @@
Subproject commit aa499d1ea849584c7e2e63518f10289e76c00ec6

1
vendor/gems/mime-types-data vendored Submodule

@ -0,0 +1 @@
Subproject commit 4ea7e6b9f8d49dff6cd59703dc234a0b411175e2

133
vendor/gems/mustermann/lib/mustermann.rb vendored Normal file
View File

@ -0,0 +1,133 @@
require 'mustermann/pattern'
require 'mustermann/composite'
require 'mustermann/concat'
require 'thread'
# Namespace and main entry point for the Mustermann library.
#
# Under normal circumstances the only external API entry point you should be using is {Mustermann.new}.
module Mustermann
# Type to use if no type is given.
# @api private
DEFAULT_TYPE = :sinatra
# Creates a new pattern based on input.
#
# * From {Mustermann::Pattern}: returns given pattern.
# * From String: creates a pattern from the string, depending on type option (defaults to {Mustermann::Sinatra})
# * From Regexp: creates a {Mustermann::Regular} pattern.
# * From Symbol: creates a {Mustermann::Sinatra} pattern with a single named capture named after the input.
# * From an Array or multiple inputs: creates a new pattern from each element, combines them to a {Mustermann::Composite}.
# * From anything else: Will try to call to_pattern on it or raise a TypeError.
#
# Note that if the input is a {Mustermann::Pattern}, Regexp or Symbol, the type option is ignored and if to_pattern is
# called on the object, the type will be handed on but might be ignored by the input object.
#
# If you want to enforce the pattern type, you should create them via their expected class.
#
# @example creating patterns
# require 'mustermann'
#
# Mustermann.new("/:name") # => #<Mustermann::Sinatra:"/example">
# Mustermann.new("/{name}", type: :template) # => #<Mustermann::Template:"/{name}">
# Mustermann.new(/.*/) # => #<Mustermann::Regular:".*">
# Mustermann.new(:name, capture: :word) # => #<Mustermann::Sinatra:":name">
# Mustermann.new("/", "/*.jpg", type: :shell) # => #<Mustermann::Composite:(shell:"/" | shell:"/*.jpg")>
#
# @example using custom #to_pattern
# require 'mustermann'
#
# class MyObject
# def to_pattern(**options)
# Mustermann.new("/:name", **options)
# end
# end
#
# Mustermann.new(MyObject.new, type: :rails) # => #<Mustermann::Rails:"/:name">
#
# @example enforcing type
# require 'mustermann/sinatra'
#
# Mustermann::Sinatra.new("/:name")
#
# @param [String, Pattern, Regexp, Symbol, #to_pattern, Array<String, Pattern, Regexp, Symbol, #to_pattern>]
# input The representation of the pattern
# @param [Hash] options The options hash
# @return [Mustermann::Pattern] pattern corresponding to string.
# @raise (see [])
# @raise (see Mustermann::Pattern.new)
# @raise [TypeError] if the passed object cannot be converted to a pattern
# @see file:README.md#Types_and_Options "Types and Options" in the README
def self.new(*input, type: DEFAULT_TYPE, operator: :|, **options)
type ||= DEFAULT_TYPE
input = input.first if input.size < 2
case input
when Pattern then input
when Regexp then self[:regexp].new(input, **options)
when String then self[type].new(input, **options)
when Symbol then self[:sinatra].new(input.inspect, **options)
when Array then input.map { |i| new(i, type: type, **options) }.inject(operator)
else
pattern = input.to_pattern(type: type, **options) if input.respond_to? :to_pattern
raise TypeError, "#{input.class} can't be coerced into Mustermann::Pattern" if pattern.nil?
pattern
end
end
@mutex ||= Mutex.new
@types ||= {}
# Maps a type to its factory.
#
# @example
# Mustermann[:sinatra] # => Mustermann::Sinatra
#
# @param [Symbol] name a pattern type identifier
# @raise [ArgumentError] if the type is not supported
# @return [Class, #new] pattern factory
def self.[](name)
return name if name.respond_to? :new
@types.fetch(normalized = normalized_type(name)) do
@mutex.synchronize do
error = try_require "mustermann/#{normalized}"
@types.fetch(normalized) { raise ArgumentError, "unsupported type %p#{" (#{error.message})" if error}" % name }
end
end
end
# @return [LoadError, nil]
# @!visibility private
def self.try_require(path)
require(path)
nil
rescue LoadError => error
raise(error) unless error.path == path
error
end
# @!visibility private
def self.register(name, type)
@types[normalized_type(name)] = type
end
# @!visibility private
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'
object.register Extension
end
end
# :nocov:
begin
require 'mustermann/visualizer' if defined?(Pry) or defined?(IRB)
rescue LoadError => error
raise error unless error.path == 'mustermann/visualizer'
$stderr.puts(error.message) if caller_locations[1].absolute_path =~ %r{/lib/pry/|/irb/|^\((?:irb|pry)\)$}
end
# :nocov:

View File

@ -0,0 +1,44 @@
require 'mustermann/ast/translator'
module Mustermann
module AST
# Make sure #start and #stop is set on every node and within its parents #start and #stop.
# @!visibility private
class Boundaries < Translator
# @return [Mustermann::AST::Node] the ast passed as first argument
# @!visibility private
def self.set_boundaries(ast, string: nil, start: 0, stop: string.length)
new.translate(ast, start, stop)
ast
end
translate(:node) do |start, stop|
t.set_boundaries(node, start, stop)
t(payload, node.start, node.stop)
end
translate(:with_look_ahead) do |start, stop|
t.set_boundaries(node, start, stop)
t(head, node.start, node.stop)
t(payload, node.start, node.stop)
end
translate(Array) do |start, stop|
each do |subnode|
t(subnode, start, stop)
start = subnode.stop
end
end
translate(Object) { |*| node }
# Checks that a node is within the given boundaries.
# @!visibility private
def set_boundaries(node, start, stop)
node.start = start if node.start.nil? or node.start < start
node.stop = node.start + node.min_size if node.stop.nil? or node.stop < node.start
node.stop = stop if node.stop > stop
end
end
end
end

View File

@ -0,0 +1,158 @@
require 'mustermann/ast/translator'
module Mustermann
# @see Mustermann::AST::Pattern
module AST
# Regexp compilation logic.
# @!visibility private
class Compiler < Translator
raises CompileError
# Trivial compilations
translate(Array) { |**o| map { |e| t(e, **o) }.join }
translate(:node) { |**o| t(payload, **o) }
translate(:separator) { |**o| Regexp.escape(payload) }
translate(:optional) { |**o| "(?:%s)?" % t(payload, **o) }
translate(:char) { |**o| t.encoded(payload, **o) }
translate :union do |**options|
"(?:%s)" % payload.map { |e| "(?:%s)" % t(e, **options) }.join(?|)
end
translate :expression do |greedy: true, **options|
t(payload, allow_reserved: operator.allow_reserved, greedy: greedy && !operator.allow_reserved,
parametric: operator.parametric, separator: operator.separator, **options)
end
translate :with_look_ahead do |**options|
lookahead = each_leaf.inject("") do |ahead, element|
ahead + t(element, skip_optional: true, lookahead: ahead, greedy: false, no_captures: true, **options).to_s
end
lookahead << (at_end ? '$' : '/')
t(head, lookahead: lookahead, **options) + t(payload, **options)
end
# Capture compilation is complex. :(
# @!visibility private
class Capture < NodeTranslator
register :capture
# @!visibility private
def translate(**options)
return pattern(options) if options[:no_captures]
"(?<#{name}>#{translate(no_captures: true, **options)})"
end
# @return [String] regexp without the named capture
# @!visibility private
def pattern(capture: nil, **options)
case capture
when Symbol then from_symbol(capture, **options)
when Array then from_array(capture, **options)
when Hash then from_hash(capture, **options)
when String then from_string(capture, **options)
when nil then from_nil(**options)
else capture
end
end
private
def qualified(string, greedy: true, **options) "#{string}#{qualifier || "+#{?? unless greedy}"}" end
def with_lookahead(string, lookahead: nil, **options) lookahead ? "(?:(?!#{lookahead})#{string})" : string end
def from_hash(hash, **options) pattern(capture: hash[name.to_sym], **options) end
def from_array(array, **options) Regexp.union(*array.map { |e| pattern(capture: e, **options) }) end
def from_symbol(symbol, **options) qualified(with_lookahead("[[:#{symbol}:]]", **options), **options) end
def from_string(string, **options) Regexp.new(string.chars.map { |c| t.encoded(c, **options) }.join) end
def from_nil(**options) qualified(with_lookahead(default(**options), **options), **options) end
def default(**options) constraint || "[^/\\?#]" end
end
# @!visibility private
class Splat < Capture
register :splat, :named_splat
# splats are always non-greedy
# @!visibility private
def pattern(**options)
constraint || ".*?"
end
end
# @!visibility private
class Variable < Capture
register :variable
# @!visibility private
def translate(**options)
return super(**options) if explode or not options[:parametric]
# Remove this line after fixing broken compatibility between 2.1 and 2.2
options.delete(:parametric) if options.has_key?(:parametric)
parametric super(parametric: false, **options)
end
# @!visibility private
def pattern(parametric: false, separator: nil, **options)
register_param(parametric: parametric, separator: separator, **options)
pattern = super(**options)
pattern = parametric(pattern) if parametric
pattern = "#{pattern}(?:#{Regexp.escape(separator)}#{pattern})*" if explode and separator
pattern
end
# @!visibility private
def parametric(string)
"#{Regexp.escape(name)}(?:=#{string})?"
end
# @!visibility private
def qualified(string, **options)
prefix ? "#{string}{1,#{prefix}}" : super(string, **options)
end
# @!visibility private
def default(allow_reserved: false, **options)
allow_reserved ? '[\w\-\.~%\:/\?#\[\]@\!\$\&\'\(\)\*\+,;=]' : '[\w\-\.~%]'
end
# @!visibility private
def register_param(parametric: false, split_params: nil, separator: nil, **options)
return unless explode and split_params
split_params[name] = { separator: separator, parametric: parametric }
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("|")
end
# Compiles an AST to a regular expression.
# @param [Mustermann::AST::Node] ast the tree
# @return [Regexp] corresponding regular expression.
#
# @!visibility private
def self.compile(ast, **options)
new.compile(ast, **options)
end
# Compiles an AST to a regular expression.
# @param [Mustermann::AST::Node] ast the tree
# @return [Regexp] corresponding regular expression.
#
# @!visibility private
def compile(ast, except: nil, **options)
except &&= "(?!#{translate(except, no_captures: true, **options)}\\Z)"
Regexp.new("#{except}#{translate(ast, **options)}")
end
end
private_constant :Compiler
end
end

View File

@ -0,0 +1,143 @@
require 'mustermann/ast/translator'
require 'mustermann/ast/compiler'
module Mustermann
module AST
# Looks at an AST, remembers the important bits of information to do an
# ultra fast expansion.
#
# @!visibility private
class Expander < Translator
raises ExpandError
translate Array do |*args|
inject(t.pattern) do |pattern, element|
t.add_to(pattern, t(element, *args))
end
end
translate :capture do |**options|
t.for_capture(node, **options)
end
translate :named_splat, :splat do
t.pattern + t.for_capture(node)
end
translate :expression do
t(payload, allow_reserved: operator.allow_reserved)
end
translate :root, :group do
t(payload)
end
translate :char do
t.pattern(t.escape(payload, also_escape: /[\/\?#\&\=%]/).gsub(?%, "%%"))
end
translate :separator do
t.pattern(payload.gsub(?%, "%%"))
end
translate :with_look_ahead do
t.add_to(t(head), t(payload))
end
translate :optional do
nested = t(payload)
nested += t.pattern unless nested.any? { |n| n.first.empty? }
nested
end
translate :union do
payload.map { |e| t(e) }.inject(:+)
end
# helper method for captures
# @!visibility private
def for_capture(node, **options)
name = node.name.to_sym
pattern('%s', name, name => /(?!#{pattern_for(node, **options)})./)
end
# maps sorted key list to sprintf patterns and filters
# @!visibility private
def mappings
@mappings ||= {}
end
# all the known keys
# @!visibility private
def keys
@keys ||= []
end
# add a tree for expansion
# @!visibility private
def add(ast)
translate(ast).each do |keys, pattern, filter|
self.keys.concat(keys).uniq!
mappings[keys.sort] ||= [keys, pattern, filter]
end
end
# helper method for getting a capture's pattern.
# @!visibility private
def pattern_for(node, **options)
Compiler.new.decorator_for(node).pattern(**options)
end
# @see Mustermann::Pattern#expand
# @!visibility private
def expand(values)
values = values.each_with_object({}){ |(key, value), new_hash|
new_hash[value.instance_of?(Array) ? [key] * value.length : key] = value }
keys, pattern, filters = mappings.fetch(values.keys.flatten.sort) { error_for(values) }
filters.each { |key, filter| values[key] &&= escape(values[key], also_escape: filter) }
pattern % (values[keys] || values.values_at(*keys))
end
# @see Mustermann::Pattern#expandable?
# @!visibility private
def expandable?(values)
values = values.keys if values.respond_to? :keys
values = values.sort if values.respond_to? :sort
mappings.include? values
end
# @see Mustermann::Expander#with_rest
# @!visibility private
def expandable_keys(keys)
mappings.keys.select { |k| (k - keys).empty? }.max_by(&:size) || keys
end
# helper method for raising an error for unexpandable values
# @!visibility private
def error_for(values)
expansions = mappings.keys.map(&:inspect).join(" or ")
raise error_class, "cannot expand with keys %p, possible expansions: %s" % [values.keys.sort, expansions]
end
# @see Mustermann::AST::Translator#expand
# @!visibility private
def escape(string, *args)
# URI::Parser is pretty slow, let's not send every string to it, even if it's unnecessary
string =~ /\A\w*\Z/ ? string : super
end
# Turns a sprintf pattern into our secret internal data structure.
# @!visibility private
def pattern(string = "", *keys, **filters)
[[keys, string, filters]]
end
# Creates the product of two of our secret internal data structures.
# @!visibility private
def add_to(list, result)
list << [[], ""] if list.empty?
list.inject([]) { |l, (k1, p1, f1)| l + result.map { |k2, p2, f2| [k1+k2, p1+p2, **f1, **f2] } }
end
end
end
end

View File

@ -0,0 +1,222 @@
module Mustermann
# @see Mustermann::AST::Pattern
module AST
# @!visibility private
class Node
# @!visibility private
attr_accessor :payload, :start, :stop
# @!visibility private
# @param [Symbol] name of the node
# @return [Class] factory for the node
def self.[](name)
@names ||= {}
@names[name] ||= begin
const_name = constant_name(name)
Object.const_get(const_name) if Object.const_defined?(const_name)
end
end
# Turns a class name into a node identifier.
# @!visibility private
def self.type
name[/[^:]+$/].split(/(?<=.)(?=[A-Z])/).map(&:downcase).join(?_).to_sym
end
# @!visibility private
# @param [Symbol] name of the node
# @return [String] qualified name of factory for the node
def self.constant_name(name)
return self.name if name.to_sym == :node
name = name.to_s.split(?_).map(&:capitalize).join
"#{self.name}::#{name}"
end
# Helper for creating a new instance and calling #parse on it.
# @return [Mustermann::AST::Node]
# @!visibility private
def self.parse(*args, &block)
new(*args).tap { |n| n.parse(&block) }
end
# @!visibility private
def initialize(payload = nil, **options)
options.each { |key, value| public_send("#{key}=", value) }
self.payload = payload
end
# @!visibility private
def is_a?(type)
type = Node[type] if type.is_a? Symbol
super(type)
end
# Double dispatch helper for reading from the buffer into the payload.
# @!visibility private
def parse
self.payload ||= []
while element = yield
payload << element
end
end
# Loop through all nodes that don't have child nodes.
# @!visibility private
def each_leaf(&block)
return enum_for(__method__) unless block_given?
called = false
Array(payload).each do |entry|
next unless entry.respond_to? :each_leaf
entry.each_leaf(&block)
called = true
end
yield(self) unless called
end
# @return [Integer] length of the substring
# @!visibility private
def length
stop - start if start and stop
end
# @return [Integer] minimum size for a node
# @!visibility private
def min_size
0
end
# Turns a class name into a node identifier.
# @!visibility private
def type
self.class.type
end
# @!visibility private
class Capture < Node
# @see Mustermann::AST::Compiler::Capture#default
# @!visibility private
attr_accessor :constraint
# @see Mustermann::AST::Compiler::Capture#qualified
# @!visibility private
attr_accessor :qualifier
# @see Mustermann::AST::Pattern#map_param
# @!visibility private
attr_accessor :convert
# @see Mustermann::AST::Node#parse
# @!visibility private
def parse
self.payload ||= ""
super
end
# @!visibility private
alias_method :name, :payload
end
# @!visibility private
class Char < Node
# @return [Integer] minimum size for a node
# @!visibility private
def min_size
1
end
end
# AST node for template expressions.
# @!visibility private
class Expression < Node
# @!visibility private
attr_accessor :operator
end
# @!visibility private
class Composition < Node
# @!visibility private
def initialize(payload = nil, **options)
super(Array(payload), **options)
end
end
# @!visibility private
class Group < Composition
end
# @!visibility private
class Union < Composition
end
# @!visibility private
class Optional < Node
end
# @!visibility private
class Or < Node
end
# @!visibility private
class Root < Node
# @!visibility private
attr_accessor :pattern
# Will trigger transform.
#
# @see Mustermann::AST::Node.parse
# @!visibility private
def self.parse(string, &block)
root = new
root.pattern = string
root.parse(&block)
root
end
end
# @!visibility private
class Separator < Node
# @return [Integer] minimum size for a node
# @!visibility private
def min_size
1
end
end
# @!visibility private
class Splat < Capture
# @see Mustermann::AST::Node::Capture#name
# @!visibility private
def name
"splat"
end
end
# @!visibility private
class NamedSplat < Splat
# @see Mustermann::AST::Node::Capture#name
# @!visibility private
alias_method :name, :payload
end
# AST node for template variables.
# @!visibility private
class Variable < Capture
# @!visibility private
attr_accessor :prefix, :explode
end
# @!visibility private
class WithLookAhead < Node
# @!visibility private
attr_accessor :head, :at_end
# @!visibility private
def initialize(payload, at_end, **options)
super(**options)
self.head, *self.payload = Array(payload)
self.at_end = at_end
end
end
end
end
end

View File

@ -0,0 +1,20 @@
require 'mustermann/ast/translator'
module Mustermann
module AST
# Scans an AST for param converters.
# @!visibility private
# @see Mustermann::AST::Pattern#to_templates
class ParamScanner < Translator
# @!visibility private
def self.scan_params(ast)
new.translate(ast)
end
translate(:node) { t(payload) }
translate(Array) { map { |e| t(e) }.inject(:merge) }
translate(Object) { {} }
translate(:capture) { convert ? { name => convert } : {} }
end
end
end

View File

@ -0,0 +1,233 @@
require 'mustermann/ast/node'
require 'forwardable'
require 'strscan'
module Mustermann
# @see Mustermann::AST::Pattern
module AST
# Simple, StringScanner based parser.
# @!visibility private
class Parser
# @param [String] string to be parsed
# @return [Mustermann::AST::Node] parse tree for string
# @!visibility private
def self.parse(string, **options)
new(**options).parse(string)
end
# Defines another grammar rule for first character.
#
# @see Mustermann::Rails
# @see Mustermann::Sinatra
# @see Mustermann::Template
# @!visibility private
def self.on(*chars, &block)
chars.each do |char|
define_method("read %p" % char, &block)
end
end
# Defines another grammar rule for a suffix.
#
# @see Mustermann::Sinatra
# @!visibility private
def self.suffix(pattern = /./, after: :node, &block)
@suffix ||= []
@suffix << [pattern, after, block] if block
@suffix
end
# @!visibility private
attr_reader :buffer, :string, :pattern
extend Forwardable
def_delegators :buffer, :eos?, :getch, :pos
# @!visibility private
def initialize(pattern: nil, **options)
@pattern = pattern
end
# @param [String] string to be parsed
# @return [Mustermann::AST::Node] parse tree for string
# @!visibility private
def parse(string)
@string = string
@buffer = ::StringScanner.new(string)
node(:root, string) { read unless eos? }
end
# @example
# node(:char, 'x').compile =~ 'x' # => true
#
# @param [Symbol] type node type
# @return [Mustermann::AST::Node]
# @!visibility private
def node(type, *args, &block)
type = Node[type] unless type.respond_to? :new
start = pos
node = block ? type.parse(*args, &block) : type.new(*args)
min_size(start, pos, node)
end
# Create a node for a character we don't have an explicit rule for.
#
# @param [String] char the character
# @return [Mustermann::AST::Node] the node
# @!visibility private
def default_node(char)
char == ?/ ? node(:separator, char) : node(:char, char)
end
# Reads the next element from the buffer.
# @return [Mustermann::AST::Node] next element
# @!visibility private
def read
start = pos
char = getch
method = "read %p" % char
element= respond_to?(method) ? send(method, char) : default_node(char)
min_size(start, pos, element)
read_suffix(element)
end
# sets start on node to start if it's not set to a lower value.
# sets stop on node to stop if it's not set to a higher value.
# @return [Mustermann::AST::Node] the node passed as third argument
# @!visibility private
def min_size(start, stop, node)
stop ||= start
start ||= stop
node.start = start unless node.start and node.start < start
node.stop = stop unless node.stop and node.stop > stop
node
end
# Checks for a potential suffix on the buffer.
# @param [Mustermann::AST::Node] element node without suffix
# @return [Mustermann::AST::Node] node with suffix
# @!visibility private
def read_suffix(element)
self.class.suffix.inject(element) do |ele, (regexp, after, callback)|
next ele unless ele.is_a?(after) and payload = scan(regexp)
content = instance_exec(payload, ele, &callback)
min_size(element.start, pos, content)
end
end
# Wrapper around {StringScanner#scan} that turns strings into escaped
# regular expressions and returns a MatchData if the regexp has any
# named captures.
#
# @param [Regexp, String] regexp
# @see StringScanner#scan
# @return [String, MatchData, nil]
# @!visibility private
def scan(regexp)
regexp = Regexp.new(Regexp.escape(regexp)) unless regexp.is_a? Regexp
string = buffer.scan(regexp)
regexp.names.any? ? regexp.match(string) : string
end
# Asserts a regular expression matches what's next on the buffer.
# Will return corresponding MatchData if regexp includes named captures.
#
# @param [Regexp] regexp expected to match
# @return [String, MatchData] the match
# @raise [Mustermann::ParseError] if expectation wasn't met
# @!visibility private
def expect(regexp, char: nil, **options)
scan(regexp) || unexpected(char, **options)
end
# Allows to read a string inside brackets. It does not expect the string
# to start with an opening bracket.
#
# @example
# buffer.string = "fo<o>>ba<r>"
# read_brackets(?<, ?>) # => "fo<o>"
# buffer.rest # => "ba<r>"
#
# @!visibility private
def read_brackets(open, close, char: nil, escape: ?\\, quote: false, **options)
result = ""
escape = false if escape.nil?
while current = getch
case current
when close then return result
when open then result << open << read_brackets(open, close) << close
when escape then result << escape << getch
else result << current
end
end
unexpected(char, **options)
end
# Reads an argument string of the format arg1,args2,key:value
#
# @!visibility private
def read_args(key_separator, close, separator: ?,, symbol_keys: true, **options)
list, map = [], {}
while buffer.peek(1) != close
scan(separator)
entries = read_list(close, separator, separator: key_separator, **options)
case entries.size
when 1 then list += entries
when 2 then map[symbol_keys ? entries.first.to_sym : entries.first] = entries.last
else unexpected(key_separator)
end
buffer.pos -= 1
end
expect(close)
[list, map]
end
# Reads a separated list with the ability to quote, escape and add spaces.
#
# @!visibility private
def read_list(*close, separator: ?,, escape: ?\\, quotes: [?", ?'], ignore: " ", **options)
result = []
while current = getch
element = result.empty? ? result : result.last
case current
when *close then return result
when ignore then nil # do nothing
when separator then result << ""
when escape then element << getch
when *quotes then element << read_escaped(current, escape: escape)
else element << current
end
end
unexpected(current, **options)
end
# Read a string until a terminating character, ignoring escaped versions of said character.
#
# @!visibility private
def read_escaped(close, escape: ?\\, **options)
result = ""
while current = getch
case current
when close then return result
when escape then result << getch
else result << current
end
end
unexpected(current, **options)
end
# Helper for raising an exception for an unexpected character.
# Will read character from buffer if buffer is passed in.
#
# @param [String, nil] char the unexpected character
# @raise [Mustermann::ParseError, Exception]
# @!visibility private
def unexpected(char = nil, exception: ParseError)
char ||= getch
char = "space" if char == " "
raise exception, "unexpected #{char || "end of string"} while parsing #{string.inspect}"
end
end
end
end

View File

@ -0,0 +1,136 @@
require 'mustermann/ast/parser'
require 'mustermann/ast/boundaries'
require 'mustermann/ast/compiler'
require 'mustermann/ast/transformer'
require 'mustermann/ast/validation'
require 'mustermann/ast/template_generator'
require 'mustermann/ast/param_scanner'
require 'mustermann/regexp_based'
require 'mustermann/expander'
require 'mustermann/equality_map'
module Mustermann
# @see Mustermann::AST::Pattern
module AST
# Superclass for pattern styles that parse an AST from the string pattern.
# @abstract
class Pattern < Mustermann::RegexpBased
supported_options :capture, :except, :greedy, :space_matches_plus
extend Forwardable, SingleForwardable
single_delegate on: :parser, suffix: :parser
instance_delegate %i[parser compiler transformer validation template_generator param_scanner boundaries] => 'self.class'
instance_delegate parse: :parser, transform: :transformer, validate: :validation,
generate_templates: :template_generator, scan_params: :param_scanner, set_boundaries: :boundaries
# @api private
# @return [#parse] parser object for pattern
# @!visibility private
def self.parser
return Parser if self == AST::Pattern
const_set :Parser, Class.new(superclass.parser) unless const_defined? :Parser, false
const_get :Parser
end
# @api private
# @return [#compile] compiler object for pattern
# @!visibility private
def self.compiler
Compiler
end
# @api private
# @return [#set_boundaries] translator making sure start and stop is set on all nodes
# @!visibility private
def self.boundaries
Boundaries
end
# @api private
# @return [#transform] transformer object for pattern
# @!visibility private
def self.transformer
Transformer
end
# @api private
# @return [#validate] validation object for pattern
# @!visibility private
def self.validation
Validation
end
# @api private
# @return [#generate_templates] generates URI templates for pattern
# @!visibility private
def self.template_generator
TemplateGenerator
end
# @api private
# @return [#scan_params] param scanner for pattern
# @!visibility private
def self.param_scanner
ParamScanner
end
# @!visibility private
def compile(**options)
options[:except] &&= parse options[:except]
compiler.compile(to_ast, **options)
rescue CompileError => error
error.message << ": %p" % @string
raise error
end
# Internal AST representation of pattern.
# @!visibility private
def to_ast
@ast_cache ||= EqualityMap.new
@ast_cache.fetch(@string) do
ast = parse(@string, pattern: self)
ast &&= transform(ast)
ast &&= set_boundaries(ast, string: @string)
validate(ast)
end
end
# All AST-based pattern implementations support expanding.
#
# @example (see Mustermann::Pattern#expand)
# @param (see Mustermann::Pattern#expand)
# @return (see Mustermann::Pattern#expand)
# @raise (see Mustermann::Pattern#expand)
# @see Mustermann::Pattern#expand
# @see Mustermann::Expander
def expand(behavior = nil, values = {})
@expander ||= Mustermann::Expander.new(self)
@expander.expand(behavior, values)
end
# All AST-based pattern implementations support generating templates.
#
# @example (see Mustermann::Pattern#to_templates)
# @param (see Mustermann::Pattern#to_templates)
# @return (see Mustermann::Pattern#to_templates)
# @see Mustermann::Pattern#to_templates
def to_templates
@to_templates ||= generate_templates(to_ast)
end
# @!visibility private
# @see Mustermann::Pattern#map_param
def map_param(key, value)
return super unless param_converters.include? key
param_converters[key][super]
end
# @!visibility private
def param_converters
@param_converters ||= scan_params(to_ast)
end
private :compile, :parse, :transform, :validate, :generate_templates, :param_converters, :scan_params, :set_boundaries
end
end
end

View File

@ -0,0 +1,28 @@
require 'mustermann/ast/translator'
module Mustermann
module AST
# Turns an AST into an Array of URI templates representing the AST.
# @!visibility private
# @see Mustermann::AST::Pattern#to_templates
class TemplateGenerator < Translator
# @!visibility private
def self.generate_templates(ast)
new.translate(ast).uniq
end
# translate(:expression) is not needed, since template patterns simply call to_s
translate(:root, :group) { t(payload) || [""] }
translate(:separator, :char) { t.escape(payload) }
translate(:capture) { "{#{name}}" }
translate(:optional) { [t(payload), ""] }
translate(:named_splat, :splat) { "{+#{name}}" }
translate(:with_look_ahead) { t([head, payload]) }
translate(:union) { payload.flat_map { |e| t(e) } }
translate(Array) do
map { |e| Array(t(e)) }.inject { |first, second| first.product(second).map(&:join) }
end
end
end
end

View File

@ -0,0 +1,178 @@
require 'mustermann/ast/translator'
module Mustermann
module AST
# Takes a tree, turns it into an even better tree.
# @!visibility private
class Transformer < Translator
# Transforms a tree.
# @note might mutate handed in tree instead of creating a new one
# @param [Mustermann::AST::Node] tree to be transformed
# @return [Mustermann::AST::Node] transformed tree
# @!visibility private
def self.transform(tree)
new.translate(tree)
end
# recursive descent
translate(:node) do
node.payload = t(payload)
node
end
# ignore unknown objects on the tree
translate(Object) { node }
# turn a group containing or nodes into a union
# @!visibility private
class GroupTransformer < NodeTranslator
register :group
# @!visibility private
def translate
payload.flatten! if payload.is_a?(Array)
return union if payload.any? { |e| e.is_a? :or }
self.payload = t(payload)
self
end
# @!visibility private
def union
groups = split_payload.map { |g| group(g) }
Node[:union].new(groups, start: node.start, stop: node.stop)
end
# @!visibility private
def group(elements)
return t(elements.first) if elements.size == 1
start, stop = elements.first.start, elements.last.stop if elements.any?
Node[:group].new(t(elements), start: start, stop: stop)
end
# @!visibility private
def split_payload
groups = [[]]
payload.each { |e| e.is_a?(:or) ? groups << [] : groups.last << e }
groups.map!
end
end
# inject a union node right inside the root node if it contains or nodes
# @!visibility private
class RootTransformer < GroupTransformer
register :root
# @!visibility private
def union
self.payload = [super]
self
end
end
# URI expression transformations depending on operator
# @!visibility private
class ExpressionTransform < NodeTranslator
register :expression
# @!visibility private
Operator ||= Struct.new(:separator, :allow_reserved, :prefix, :parametric)
# Operators available for expressions.
# @!visibility private
OPERATORS ||= {
nil => Operator.new(?,, false, false, false), ?+ => Operator.new(?,, true, false, false),
?# => Operator.new(?,, true, ?#, false), ?. => Operator.new(?., false, ?., false),
?/ => Operator.new(?/, false, ?/, false), ?; => Operator.new(?;, false, ?;, true),
?? => Operator.new(?&, false, ??, true), ?& => Operator.new(?&, false, ?&, true)
}
# Sets operator and inserts separators in between variables.
# @!visibility private
def translate
self.operator = OPERATORS.fetch(operator) { raise CompileError, "#{operator} operator not supported" }
separator = Node[:separator].new(operator.separator)
prefix = Node[:separator].new(operator.prefix)
self.payload = Array(payload.inject { |list, element| Array(list) << t(separator.dup) << t(element) })
payload.unshift(prefix) if operator.prefix
self
end
end
# Inserts with_look_ahead nodes wherever appropriate
# @!visibility private
class ArrayTransform < NodeTranslator
register Array
# the new array
# @!visibility private
def payload
@payload ||= []
end
# buffer for potential look ahead
# @!visibility private
def lookahead_buffer
@lookahead_buffer ||= []
end
# transform the array
# @!visibility private
def translate
each { |e| track t(e) }
payload.concat create_lookahead(lookahead_buffer, true)
end
# handle a single element from the array
# @!visibility private
def track(element)
return list_for(element) << element if lookahead_buffer.empty?
return lookahead_buffer << element if lookahead? element
lookahead = lookahead_buffer.dup
lookahead = create_lookahead(lookahead, false) if element.is_a? Node[:separator]
lookahead_buffer.clear
payload.concat(lookahead) << element
end
# turn look ahead buffer into look ahead node
# @!visibility private
def create_lookahead(elements, *args)
return elements unless elements.size > 1
[Node[:with_look_ahead].new(elements, *args, start: elements.first.start, stop: elements.last.stop)]
end
# can the given element be used in a look-ahead?
# @!visibility private
def lookahead?(element, in_lookahead = false)
case element
when Node[:char] then in_lookahead
when Node[:group] then lookahead_payload?(element.payload, in_lookahead)
when Node[:optional] then lookahead?(element.payload, true) or expect_lookahead?(element.payload)
end
end
# does the list of elements look look-ahead-ish to you?
# @!visibility private
def lookahead_payload?(payload, in_lookahead)
return unless payload[0..-2].all? { |e| lookahead?(e, in_lookahead) }
expect_lookahead?(payload.last) or lookahead?(payload.last, in_lookahead)
end
# can the current element deal with a look-ahead?
# @!visibility private
def expect_lookahead?(element)
return element.class == Node[:capture] unless element.is_a? Node[:group]
element.payload.all? { |e| expect_lookahead?(e) }
end
# helper method for deciding where to put an element for now
# @!visibility private
def list_for(element)
expect_lookahead?(element) ? lookahead_buffer : payload
end
end
end
end
end

View File

@ -0,0 +1,125 @@
require 'mustermann/ast/node'
require 'mustermann/error'
require 'delegate'
module Mustermann
module AST
# Implements translator pattern
#
# @abstract
# @!visibility private
class Translator
# Encapsulates a single node translation
# @!visibility private
class NodeTranslator < DelegateClass(Node)
# @param [Array<Symbol, Class>] types list of types to register for.
# @!visibility private
def self.register(*types)
types.each do |type|
type = Node.constant_name(type) if type.is_a? Symbol
translator.dispatch_table[type.to_s] = self
end
end
# @param node [Mustermann::AST::Node, Object]
# @param translator [Mustermann::AST::Translator]
#
# @!visibility private
def initialize(node, translator)
@translator = translator
super(node)
end
# @!visibility private
attr_reader :translator
# shorthand for translating a nested object
# @!visibility private
def t(*args, &block)
return translator unless args.any?
translator.translate(*args, &block)
end
# @!visibility private
alias_method :node, :__getobj__
end
# maps types to translations
# @!visibility private
def self.dispatch_table
@dispatch_table ||= {}
end
# some magic sauce so {NodeTranslator}s know whom to talk to for {#register}
# @!visibility private
def self.inherited(subclass)
node_translator = Class.new(NodeTranslator)
node_translator.define_singleton_method(:translator) { subclass }
subclass.const_set(:NodeTranslator, node_translator)
super
end
# DSL-ish method for specifying the exception class to use.
# @!visibility private
def self.raises(error)
define_method(:error_class) { error }
end
# DSL method for defining single method translations.
# @!visibility private
def self.translate(*types, &block)
Class.new(const_get(:NodeTranslator)) do
register(*types)
define_method(:translate, &block)
end
end
# Enables quick creation of a translator object.
#
# @example
# require 'mustermann'
# require 'mustermann/ast/translator'
#
# translator = Mustermann::AST::Translator.create do
# translate(:node) { [type, *t(payload)].flatten.compact }
# translate(Array) { map { |e| t(e) } }
# translate(Object) { }
# end
#
# ast = Mustermann.new('/:name').to_ast
# translator.translate(ast) # => [:root, :separator, :capture]
#
# @!visibility private
def self.create(&block)
Class.new(self, &block).new
end
raises Mustermann::Error
# @param [Mustermann::AST::Node, Object] node to translate
# @return decorator encapsulating translation
#
# @!visibility private
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.new(node, self)
end
# Start the translation dance for a (sub)tree.
# @!visibility private
def translate(node, *args, &block)
result = decorator_for(node).translate(*args, &block)
result = result.node while result.is_a? NodeTranslator
result
end
# @return [String] escaped character
# @!visibility private
def escape(char, parser: URI::DEFAULT_PARSER, escape: parser.regexp[:UNSAFE], also_escape: nil)
escape = Regexp.union(also_escape, escape) if also_escape
char =~ escape ? parser.escape(char, Regexp.union(*escape)) : char
end
end
end
end

View File

@ -0,0 +1,44 @@
require 'mustermann/ast/translator'
module Mustermann
module AST
# Checks the AST for certain validations, like correct capture names.
#
# Internally a poor man's visitor (abusing translator to not have to implement a visitor).
# @!visibility private
class Validation < Translator
# Runs validations.
#
# @param [Mustermann::AST::Node] ast to be validated
# @return [Mustermann::AST::Node] the validated ast
# @raise [Mustermann::AST::CompileError] if validation fails
# @!visibility private
def self.validate(ast)
new.translate(ast)
ast
end
translate(Object, :splat) {}
translate(:node) { t(payload) }
translate(Array) { each { |p| t(p)} }
translate(:capture) { t.check_name(name, forbidden: ['captures', 'splat'])}
translate(:variable, :named_splat) { t.check_name(name, forbidden: 'captures')}
# @raise [Mustermann::CompileError] if name is not acceptable
# @!visibility private
def check_name(name, forbidden: [])
raise CompileError, "capture name can't be empty" if name.nil? or name.empty?
raise CompileError, "capture name must start with underscore or lower case letter" unless name =~ /^[a-z_]/
raise CompileError, "capture name can't be #{name}" if Array(forbidden).include? name
raise CompileError, "can't use the same capture name twice" if names.include? name
names << name
end
# @return [Array<String>] list of capture names in tree
# @!visibility private
def names
@names ||= []
end
end
end
end

View File

@ -0,0 +1,108 @@
require 'delegate'
module Mustermann
# Class for defining and running simple Hash transformations.
#
# @example
# caster = Mustermann::Caster.new
# caster.register(:foo) { |value| { bar: value.upcase } }
# caster.cast(foo: "hello", baz: "world") # => { bar: "HELLO", baz: "world" }
#
# @see Mustermann::Expander#cast
#
# @!visibility private
class Caster < DelegateClass(Array)
# @param (see #register)
# @!visibility private
def initialize(*types, &block)
super([])
register(*types, &block)
end
# @param [Array<Symbol, Regexp, #cast, #===>] types identifier for cast type (some need block)
# @!visibility private
def register(*types, &block)
return if types.empty? and block.nil?
types << Any.new(&block) if types.empty?
types.each { |type| self << caster_for(type, &block) }
end
# @param [Symbol, Regexp, #cast, #===] type identifier for cast type (some need block)
# @return [#cast] specific cast operation
# @!visibility private
def caster_for(type, &block)
case type
when Symbol, Regexp then Key.new(type, &block)
else type.respond_to?(:cast) ? type : Value.new(type, &block)
end
end
# Transforms a Hash.
# @param [Hash] hash pre-transform Hash
# @return [Hash] post-transform Hash
# @!visibility private
def cast(hash)
return hash if empty?
merge = {}
hash.delete_if do |key, value|
next unless casted = lazy.map { |e| e.cast(key, value) }.detect { |e| e }
casted = { key => casted } unless casted.respond_to? :to_hash
merge.update(casted.to_hash)
end
hash.update(merge)
end
# Class for block based casts that are triggered for every key/value pair.
# @!visibility private
class Any
# @!visibility private
def initialize(&block)
@block = block
end
# @see Mustermann::Caster#cast
# @!visibility private
def cast(key, value)
case @block.arity
when 0 then @block.call
when 1 then @block.call(value)
else @block.call(key, value)
end
end
end
# Class for block based casts that are triggered for key/value pairs with a matching value.
# @!visibility private
class Value < Any
# @param [#===] type used for matching values
# @!visibility private
def initialize(type, &block)
@type = type
super(&block)
end
# @see Mustermann::Caster#cast
# @!visibility private
def cast(key, value)
super if @type === value
end
end
# Class for block based casts that are triggered for key/value pairs with a matching key.
# @!visibility private
class Key < Any
# @param [#===] type used for matching keys
# @!visibility private
def initialize(type, &block)
@type = type
super(&block)
end
# @see Mustermann::Caster#cast
# @!visibility private
def cast(key, value)
super if @type === key
end
end
end
end

View File

@ -0,0 +1,111 @@
module Mustermann
# Class for pattern objects composed of multiple patterns using binary logic.
# @see Mustermann::Pattern#&
# @see Mustermann::Pattern#|
# @see Mustermann::Pattern#^
class Composite < Pattern
attr_reader :patterns, :operator
supported_options :operator, :type
# @see Mustermann::Pattern.supported?
def self.supported?(option, type: nil, **options)
return true if super
Mustermann[type || Mustermann::DEFAULT_TYPE].supported?(option, **options)
end
# @return [Mustermann::Pattern] a new composite pattern
def self.new(*patterns, **options)
patterns = patterns.flatten
case patterns.size
when 0 then raise ArgumentError, 'cannot create empty composite pattern'
when 1 then patterns.first
else super(patterns, **options)
end
end
def initialize(patterns, operator: :|, **options)
@operator = operator.to_sym
@patterns = patterns.flat_map { |p| patterns_from(p, **options) }
end
# @see Mustermann::Pattern#==
def ==(pattern)
patterns == patterns_from(pattern)
end
# @see Mustermann::Pattern#eql?
def eql?(pattern)
patterns.eql? patterns_from(pattern)
end
# @see Mustermann::Pattern#hash
def hash
patterns.hash | operator.hash
end
# @see Mustermann::Pattern#===
def ===(string)
patterns.map { |p| p === string }.inject(operator)
end
# @see Mustermann::Pattern#params
def params(string)
with_matching(string, :params)
end
# @see Mustermann::Pattern#match
def match(string)
with_matching(string, :match)
end
# @!visibility private
def respond_to_special?(method)
return false unless operator == :|
patterns.all? { |p| p.respond_to?(method) }
end
# (see Mustermann::Pattern#expand)
def expand(behavior = nil, values = {})
raise NotImplementedError, 'expanding not supported' unless respond_to? :expand
@expander ||= Mustermann::Expander.new(*patterns)
@expander.expand(behavior, values)
end
# (see Mustermann::Pattern#to_templates)
def to_templates
raise NotImplementedError, 'template generation not supported' unless respond_to? :to_templates
patterns.flat_map(&:to_templates).uniq
end
# @return [String] the string representation of the pattern
def to_s
simple_inspect
end
# @!visibility private
def inspect
"#<%p:%s>" % [self.class, simple_inspect]
end
# @!visibility private
def simple_inspect
pattern_strings = patterns.map { |p| p.simple_inspect }
"(#{pattern_strings.join(" #{operator} ")})"
end
# @!visibility private
def with_matching(string, method)
return unless self === string
pattern = patterns.detect { |p| p === string }
pattern.public_send(method, string) if pattern
end
# @!visibility private
def patterns_from(pattern, options = nil)
return pattern.patterns if pattern.is_a? Composite and pattern.operator == self.operator
[options ? Mustermann.new(pattern, **options) : pattern]
end
private :with_matching, :patterns_from
end
end

View File

@ -0,0 +1,124 @@
module Mustermann
# Class for pattern objects that are a concatenation of other patterns.
# @see Mustermann::Pattern#+
class Concat < Composite
# Mixin for patterns to support native concatenation.
# @!visibility private
module Native
# @see Mustermann::Pattern#+
# @!visibility private
def +(other)
other &&= Mustermann.new(other, type: :identity, **options)
return super unless native = native_concat(other)
self.class.new(native, **options)
end
# @!visibility private
def native_concat(other)
"#{self}#{other}" if native_concat?(other)
end
# @!visibility private
def native_concat?(other)
other.class == self.class and other.options == options
end
private :native_concat, :native_concat?
end
# Should not be used directly.
# @!visibility private
def initialize(*)
super
AST::Validation.validate(combined_ast) if respond_to? :expand
end
# @see Mustermann::Composite#operator
# @return [Symbol] always :+
def operator
:+
end
# @see Mustermann::Pattern#===
def ===(string)
peek_size(string) == string.size
end
# @see Mustermann::Pattern#match
def match(string)
peeked = peek_match(string)
peeked if peeked.to_s == string
end
# @see Mustermann::Pattern#params
def params(string)
params, size = peek_params(string)
params if size == string.size
end
# @see Mustermann::Pattern#peek_size
def peek_size(string)
pump(string) { |p,s| p.peek_size(s) }
end
# @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]
end
end
# @see Mustermann::Pattern#peek_params
def peek_params(string)
pump(string, inject_with: :merge, with_size: true) { |p, s| p.peek_params(s) }
end
# (see Mustermann::Pattern#expand)
def expand(behavior = nil, values = {})
raise NotImplementedError, 'expanding not supported' unless respond_to? :expand
@expander ||= Mustermann::Expander.new(self) { combined_ast }
@expander.expand(behavior, values)
end
# (see Mustermann::Pattern#to_templates)
def to_templates
raise NotImplementedError, 'template generation not supported' unless respond_to? :to_templates
@to_templates ||= patterns.inject(['']) { |list, pattern| list.product(pattern.to_templates).map(&:join) }.uniq
end
# @!visibility private
def respond_to_special?(method)
method = :to_ast if method.to_sym == :expand
patterns.all? { |p| p.respond_to?(method) }
end
# used to generate results for various methods by scanning through an input string
# @!visibility private
def pump(string, inject_with: :+, initial: nil, with_size: false)
substring = string
results = Array(initial)
patterns.each do |pattern|
result, size = yield(pattern, substring)
return unless result
results << result
size ||= result
substring = substring[size..-1]
end
results = results.inject(inject_with)
with_size ? [results, string.size - substring.size] : results
end
# generates one big AST from all patterns
# will not check if patterns support AST generation
# @!visibility private
def combined_ast
payload = patterns.map { |p| AST::Node[:group].new(p.to_ast.payload) }
AST::Node[:root].new(payload)
end
private :combined_ast, :pump
end
end

View File

@ -0,0 +1,60 @@
module Mustermann
# A simple wrapper around ObjectSpace::WeakMap that allows matching keys by equality rather than identity.
# Used for caching. Note that `fetch` is not guaranteed to return the object, even if it has not been
# garbage collected yet, especially when used concurrently. Therefore, the block passed to `fetch` has to
# be idempotent.
#
# @example
# class ExpensiveComputation
# @map = Mustermann::EqualityMap.new
#
# def self.new(*args)
# @map.fetch(*args) { super }
# end
# end
#
# @see #fetch
class EqualityMap
attr_reader :map
def self.new
defined?(ObjectSpace::WeakMap) ? super : {}
end
def initialize
@keys = {}
@map = ObjectSpace::WeakMap.new
end
# @param [Array<#hash>] key for caching
# @yield block that will be called to populate entry if missing (has to be idempotent)
# @return value stored in map or result of block
def fetch(*key)
identity = @keys[key.hash]
key = identity == key ? identity : key
# it is ok that this is not thread-safe, worst case it has double cost in
# generating, object equality is not guaranteed anyways
@map[key] ||= track(key, yield)
end
# @param [#hash] key for identifying the object
# @param [Object] object to be stored
# @return [Object] same as the second parameter
def track(key, object)
ObjectSpace.define_finalizer(object, finalizer(key.hash))
@keys[key.hash] = key
object
end
# Finalizer proc needs to be generated in different scope so it doesn't keep a reference to the object.
#
# @param [Fixnum] hash for key
# @return [Proc] finalizer callback
def finalizer(hash)
proc { @keys.delete(hash) }
end
private :track, :finalizer
end
end

View File

@ -0,0 +1,6 @@
module Mustermann
Error ||= Class.new(StandardError) # Raised if anything goes wrong while generating a {Pattern}.
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}.
end

View File

@ -0,0 +1,208 @@
require 'mustermann/ast/expander'
require 'mustermann/caster'
require 'mustermann'
module Mustermann
# Allows fine-grained control over pattern expansion.
#
# @example
# expander = Mustermann::Expander.new(additional_values: :append)
# expander << "/users/:user_id"
# expander << "/pages/:page_id"
#
# expander.expand(page_id: 58, format: :html5) # => "/pages/58?format=html5"
class Expander
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
@patterns = []
@api_expander = AST::Expander.new
@additional_values = additional_values
@options = options
@caster = Caster.new
add(*patterns, &block)
end
# Add patterns to expand.
#
# @example
# expander = Mustermann::Expander.new
# expander.add("/:a.jpg", "/:b.png")
# expander.expand(a: "pony") # => "/pony.jpg"
#
# @param [Array<#to_str, Mustermann::Pattern>] patterns list of to add for expansion, Strings will be compiled to patterns.
# @return [Mustermann::Expander] the expander
def add(*patterns)
patterns.each do |pattern|
pattern = Mustermann.new(pattern, **@options)
if block_given?
@api_expander.add(yield(pattern))
else
raise NotImplementedError, "expanding not supported for #{pattern.class}" unless pattern.respond_to? :to_ast
@api_expander.add(pattern.to_ast)
end
@patterns << pattern
end
self
end
alias_method :<<, :add
# Register a block as simple hash transformation that runs before expanding the pattern.
# @return [Mustermann::Expander] the expander
#
# @overload cast
# Register a block as simple hash transformation that runs before expanding the pattern for all entries.
#
# @example casting everything that implements to_param to param
# expander.cast { |o| o.to_param if o.respond_to? :to_param }
#
# @yield every key/value pair
# @yieldparam key [Symbol] omitted if block takes less than 2
# @yieldparam value [Object] omitted if block takes no arguments
# @yieldreturn [Hash{Symbol: Object}] will replace key/value pair with returned hash
# @yieldreturn [nil, false] will keep key/value pair in hash
# @yieldreturn [Object] will replace value with returned object
#
# @overload cast(*type_matchers)
# Register a block as simple hash transformation that runs before expanding the pattern for certain entries.
#
# @example convert user to user_id
# expander = Mustermann::Expander.new('/users/:user_id')
# expand.cast(:user) { |user| { user_id: user.id } }
#
# expand.expand(user: User.current) # => "/users/42"
#
# @example convert user, page, image to user_id, page_id, image_id
# expander = Mustermann::Expander.new('/users/:user_id', '/pages/:page_id', '/:image_id.jpg')
# expand.cast(:user, :page, :image) { |key, value| { "#{key}_id".to_sym => value.id } }
#
# expand.expand(user: User.current) # => "/users/42"
#
# @example casting to multiple key/value pairs
# expander = Mustermann::Expander.new('/users/:user_id/:image_id.:format')
# expander.cast(:image) { |i| { user_id: i.owner.id, image_id: i.id, format: i.format } }
#
# expander.expander(image: User.current.avatar) # => "/users/42/avatar.jpg"
#
# @example casting all ActiveRecord objects to param
# expander.cast(ActiveRecord::Base, &:to_param)
#
# @param [Array<Symbol, Regexp, #===>] type_matchers
# To identify key/value pairs to match against.
# Regexps and Symbols match against key, everything else matches against value.
#
# @yield every key/value pair
# @yieldparam key [Symbol] omitted if block takes less than 2
# @yieldparam value [Object] omitted if block takes no arguments
# @yieldreturn [Hash{Symbol: Object}] will replace key/value pair with returned hash
# @yieldreturn [nil, false] will keep key/value pair in hash
# @yieldreturn [Object] will replace value with returned object
#
# @overload cast(*cast_objects)
#
# @param [Array<#cast>] cast_objects
# Before expanding, will call #cast on these objects for each key/value pair.
# Return value will be treated same as block return values described above.
def cast(*types, &block)
caster.register(*types, &block)
self
end
# @example Expanding a pattern
# pattern = Mustermann::Expander.new('/:name', '/:name.:ext')
# pattern.expand(name: 'hello') # => "/hello"
# pattern.expand(name: 'hello', ext: 'png') # => "/hello.png"
#
# @example Handling additional values
# pattern = Mustermann::Expander.new('/:name', '/:name.:ext')
# pattern.expand(:ignore, name: 'hello', ext: 'png', scale: '2x') # => "/hello.png"
# pattern.expand(:append, name: 'hello', ext: 'png', scale: '2x') # => "/hello.png?scale=2x"
# pattern.expand(:raise, name: 'hello', ext: 'png', scale: '2x') # raises Mustermann::ExpandError
#
# @example Setting additional values behavior for the expander object
# pattern = Mustermann::Expander.new('/:name', '/:name.:ext', additional_values: :append)
# pattern.expand(name: 'hello', ext: 'png', scale: '2x') # => "/hello.png?scale=2x"
#
# @param [Symbol] behavior
# What to do with additional key/value pairs not present in the values hash.
# Possible options: :raise, :ignore, :append.
#
# @param [Hash{Symbol: #to_s, Array<#to_s>}] values
# Values to use for expansion.
#
# @return [String] expanded string
# @raise [NotImplementedError] raised if expand is not supported.
# @raise [Mustermann::ExpandError] raised if a value is missing or unknown
def expand(behavior = nil, values = {})
behavior, values = nil, behavior if behavior.is_a? Hash
values = map_values(values)
case behavior || additional_values
when :raise then @api_expander.expand(values)
when :ignore then with_rest(values) { |uri, rest| uri }
when :append then with_rest(values) { |uri, rest| append(uri, rest) }
else raise ArgumentError, "unknown behavior %p" % behavior
end
end
# @see Object#==
def ==(other)
return false unless other.class == self.class
other.patterns == patterns and other.additional_values == additional_values
end
# @see Object#eql?
def eql?(other)
return false unless other.class == self.class
other.patterns.eql? patterns and other.additional_values.eql? additional_values
end
# @see Object#hash
def hash
patterns.hash + additional_values.hash
end
def expandable?(values)
return false unless values
expandable, _ = split_values(map_values(values))
@api_expander.expandable? expandable
end
def with_rest(values)
expandable, non_expandable = split_values(values)
yield expand(:raise, slice(values, expandable)), slice(values, non_expandable)
end
def split_values(values)
expandable = @api_expander.expandable_keys(values.keys)
non_expandable = values.keys - expandable
[expandable, non_expandable]
end
def slice(hash, keys)
Hash[keys.map { |k| [k, hash[k]] }]
end
def append(uri, values)
return uri unless values and values.any?
entries = values.map { |pair| pair.map { |e| @api_expander.escape(e, also_escape: /[\/\?#\&\=%]/) }.join(?=) }
"#{ uri }#{ uri[??]??&:?? }#{ entries.join(?&) }"
end
def map_values(values)
values = values.dup
@api_expander.keys.each { |key| values[key] ||= values.delete(key.to_s) if values.include? key.to_s }
caster.cast(values).delete_if { |k, v| v.nil? }
end
private :with_rest, :slice, :append, :caster, :map_values, :split_values
end
end

View File

@ -0,0 +1,49 @@
require 'sinatra/version'
fail "no need to load the Mustermann extension for #{::Sinatra::VERSION}" if ::Sinatra::VERSION >= '2.0.0'
require 'mustermann'
module Mustermann
# Sinatra 1.x extension switching default pattern parsing over to Mustermann.
#
# @example With classic Sinatra application
# require 'sinatra'
# require 'mustermann'
#
# register Mustermann
# get('/:id', capture: /\d+/) { ... }
#
# @example With modular Sinatra application
# require 'sinatra/base'
# require 'mustermann'
#
# class MyApp < Sinatra::Base
# register Mustermann
# get('/:id', capture: /\d+/) { ... }
# end
#
# @see file:README.md#Sinatra_Integration "Sinatra Integration" in the README
module Extension
def compile!(verb, path, block, except: nil, capture: nil, pattern: { }, **options)
if path.respond_to? :to_str
pattern[:except] = except if except
pattern[:capture] = capture if capture
if settings.respond_to? :pattern and settings.pattern?
pattern.merge! settings.pattern do |key, local, global|
next local unless local.is_a? Hash
next global.merge(local) if global.is_a? Hash
Hash.new(global).merge! local
end
end
path = Mustermann.new(path, **pattern)
condition { params.merge! path.params(captures: Array(params[:captures]), offset: -1) }
end
super(verb, path, block, options)
end
private :compile!
end
end

View File

@ -0,0 +1,76 @@
require 'mustermann'
require 'mustermann/pattern'
require 'mustermann/ast/node'
module Mustermann
# Matches strings that are identical to the pattern.
#
# @example
# Mustermann.new('/:foo', type: :identity) === '/bar' # => false
#
# @see Mustermann::Pattern
# @see file:README.md#identity Syntax description in the README
class Identity < Pattern
include Concat::Native
register :identity
# @param (see Mustermann::Pattern#===)
# @return (see Mustermann::Pattern#===)
# @see (see Mustermann::Pattern#===)
def ===(string)
unescape(string) == @string
end
# @param (see Mustermann::Pattern#peek_size)
# @return (see Mustermann::Pattern#peek_size)
# @see (see Mustermann::Pattern#peek_size)
def peek_size(string)
return unless unescape(string).start_with? @string
return @string.size if string.start_with? @string # optimization
@string.each_char.with_index.inject(0) do |count, (char, index)|
char_size = 1
escaped = @@uri.escape(char, /./)
char_size = escaped.size if string[index, escaped.size].downcase == escaped.downcase
count + char_size
end
end
# URI templates support generating templates (the logic is quite complex, though).
#
# @example (see Mustermann::Pattern#to_templates)
# @param (see Mustermann::Pattern#to_templates)
# @return (see Mustermann::Pattern#to_templates)
# @see Mustermann::Pattern#to_templates
def to_templates
[@@uri.escape(to_s)]
end
# Generates an AST so it's compatible with {Mustermann::AST::Pattern}.
# Not used internally by {Mustermann::Identity}.
# @!visibility private
def to_ast
payload = @string.each_char.with_index.map { |c, i| AST::Node[c == ?/ ? :separator : :char].new(c, start: i, stop: i+1) }
AST::Node[:root].new(payload, pattern: @string, start: 0, stop: @string.length)
end
# Identity patterns support expanding.
#
# This implementation does not use {Mustermann::Expander} internally to save memory and
# compilation time.
#
# @example (see Mustermann::Pattern#expand)
# @param (see Mustermann::Pattern#expand)
# @return (see Mustermann::Pattern#expand)
# @raise (see Mustermann::Pattern#expand)
# @see Mustermann::Pattern#expand
# @see Mustermann::Expander
def expand(behavior = nil, values = {})
return to_s if values.empty? or behavior == :ignore
raise ExpandError, "cannot expand with keys %p" % values.keys.sort if behavior == :raise
raise ArgumentError, "unknown behavior %p" % behavior if behavior != :append
params = values.map { |key, value| @@uri.escape(key.to_s) + "=" + @@uri.escape(value.to_s, /[^\w]/) }
separator = @string.include?(??) ? ?& : ??
@string + separator + params.join(?&)
end
end
end

View File

@ -0,0 +1,94 @@
require 'mustermann'
require 'mustermann/expander'
module Mustermann
# A mapper allows mapping one string to another based on pattern parsing and expanding.
#
# @example
# require 'mustermann/mapper'
# mapper = Mustermann::Mapper.new("/:foo" => "/:foo.html")
# mapper['/example'] # => "/example.html"
class Mapper
# Creates a new mapper.
#
# @overload initialize(**options)
# @param options [Hash] options The options hash
# @yield block for generating mappings as a hash
# @yieldreturn [Hash] see {#update}
#
# @example
# require 'mustermann/mapper'
# Mustermann::Mapper.new(type: :rails) {{
# "/:foo" => ["/:foo.html", "/:foo.:format"]
# }}
#
# @overload initialize(**options)
# @param options [Hash] options The options hash
# @yield block for generating mappings as a hash
# @yieldparam mapper [Mustermann::Mapper] the mapper instance
#
# @example
# require 'mustermann/mapper'
# Mustermann::Mapper.new(type: :rails) do |mapper|
# mapper["/:foo"] = ["/:foo.html", "/:foo.:format"]
# end
#
# @overload initialize(map = {}, **options)
# @param map [Hash] see {#update}
# @param [Hash] options The options hash
#
# @example map before options
# require 'mustermann/mapper'
# Mustermann::Mapper.new("/:foo" => "/:foo.html", type: :rails)
#
# @example map after options
# require 'mustermann/mapper'
# Mustermann::Mapper.new(type: :rails, "/:foo" => "/:foo.html")
def initialize(map = {}, additional_values: :ignore, **options, &block)
@map = []
@options = options
@additional_values = additional_values
block.arity == 0 ? update(yield) : yield(self) if block
update(map) if map
end
# Add multiple mappings.
#
# @param map [Hash{String, Pattern: String, Pattern, Arry<String, Pattern>, 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]
end
end
# @return [Hash{Patttern: Expander}] Hash version of the mapper.
def to_h
Hash[@map]
end
# Convert a string according to mappings. You can pass in additional params.
#
# @example mapping with and without additional parameters
# 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
end
end
# Add a single mapping.
#
# @param key [String, Pattern] format of the input string
# @param value [String, Pattern, Arry<String, Pattern>, Expander] format of the output string
def []=(key, value)
update key => value
end
alias_method :[], :convert
end
end

View File

@ -0,0 +1,397 @@
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

View File

@ -0,0 +1,49 @@
require 'set'
require 'thread'
require 'mustermann'
module Mustermann
# A simple, persistent cache for creating repositories.
#
# @example
# require 'mustermann/pattern_cache'
# cache = Mustermann::PatternCache.new
#
# # use this instead of Mustermann.new
# pattern = cache.create_pattern("/:name", type: :rails)
#
# @note
# {Mustermann::Pattern.new} (which is used by {Mustermann.new}) will reuse instances that have
# not yet been garbage collected. You only need an extra cache if you do not keep a reference to
# the patterns around.
#
# @api private
class PatternCache
# @param [Hash] pattern_options default options used for {#create_pattern}
def initialize(**pattern_options)
@cached = Set.new
@mutex = Mutex.new
@pattern_options = pattern_options
end
# @param (see Mustermann.new)
# @return (see Mustermann.new)
# @raise (see Mustermann.new)
# @see Mustermann.new
def create_pattern(string, **pattern_options)
pattern = Mustermann.new(string, **pattern_options, **@pattern_options)
@mutex.synchronize { @cached.add(pattern) } unless @cached.include? pattern
pattern
end
# Removes all pattern instances from the cache.
def clear
@mutex.synchronize { @cached.clear }
end
# @return [Integer] number of currently cached patterns
def size
@mutex.synchronize { @cached.size }
end
end
end

View File

@ -0,0 +1 @@
require 'mustermann/regular'

View File

@ -0,0 +1,47 @@
require 'mustermann/pattern'
require 'forwardable'
module Mustermann
# Superclass for patterns that internally compile to a regular expression.
# @see Mustermann::Pattern
# @abstract
class RegexpBased < Pattern
# @return [Regexp] regular expression equivalent to the pattern.
attr_reader :regexp
alias_method :to_regexp, :regexp
# @param (see Mustermann::Pattern#initialize)
# @return (see Mustermann::Pattern#initialize)
# @see (see Mustermann::Pattern#initialize)
def initialize(string, **options)
super
regexp = compile(**options)
@peek_regexp = /\A#{regexp}/
@regexp = /\A#{regexp}\Z/
end
# @param (see Mustermann::Pattern#peek_size)
# @return (see Mustermann::Pattern#peek_size)
# @see (see Mustermann::Pattern#peek_size)
def peek_size(string)
return unless match = peek_match(string)
match.to_s.size
end
# @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
extend Forwardable
def_delegators :regexp, :===, :=~, :match, :names, :named_captures
def compile(**options)
raise NotImplementedError, 'subclass responsibility'
end
private :compile
end
end

View File

@ -0,0 +1,44 @@
require 'mustermann'
require 'mustermann/regexp_based'
require 'strscan'
module Mustermann
# Regexp pattern implementation.
#
# @example
# Mustermann.new('/.*', type: :regexp) === '/bar' # => true
#
# @see Mustermann::Pattern
# @see file:README.md#simple Syntax description in the README
class Regular < RegexpBased
include Concat::Native
register :regexp, :regular
supported_options :check_anchors
# @param (see Mustermann::Pattern#initialize)
# @return (see Mustermann::Pattern#initialize)
# @see (see Mustermann::Pattern#initialize)
def initialize(string, check_anchors: true, **options)
string = $1 if string.to_s =~ /\A\(\?\-mix\:(.*)\)\Z/ && string.inspect == "/#$1/"
@check_anchors = check_anchors
super(string, **options)
end
def compile(**options)
if @check_anchors
scanner = ::StringScanner.new(@string)
check_anchors(scanner) until scanner.eos?
end
/#{@string}/
end
def check_anchors(scanner)
return scanner.scan_until(/\]/) if scanner.scan(/\[/)
return scanner.scan(/\\?./) unless illegal = scanner.scan(/\\[AzZ]|[\^\$]/)
raise CompileError, "regular expression should not contain %s: %p" % [illegal.to_s, @string]
end
private :compile, :check_anchors
end
end

View File

@ -0,0 +1,48 @@
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<String>] empty array for imitating MatchData interface
def names
@names.dup
end
# @return [Array<String>] 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

View File

@ -0,0 +1,87 @@
require 'mustermann'
require 'mustermann/identity'
require 'mustermann/ast/pattern'
require 'mustermann/sinatra/parser'
require 'mustermann/sinatra/safe_renderer'
require 'mustermann/sinatra/try_convert'
module Mustermann
# Sinatra 2.0 style pattern implementation.
#
# @example
# Mustermann.new('/:foo') === '/bar' # => true
#
# @see Mustermann::Pattern
# @see file:README.md#sinatra Syntax description in the README
class Sinatra < AST::Pattern
include Concat::Native
register :sinatra
# Takes a string and espaces any characters that have special meaning for Sinatra patterns.
#
# @example
# require 'mustermann/sinatra'
# Mustermann::Sinatra.escape("/:name") # => "/\\:name"
#
# @param [#to_s] string the input string
# @return [String] the escaped string
def self.escape(string)
string.to_s.gsub(/[\?\(\)\*:\\\|\{\}]/) { |c| "\\#{c}" }
end
# Tries to convert the given input object to a Sinatra pattern with the given options, without
# changing its parsing semantics.
# @return [Mustermann::Sinatra, nil] the converted pattern, if possible
# @!visibility private
def self.try_convert(input, **options)
TryConvert.convert(input, **options)
end
# Creates a pattern that matches any string matching either one of the patterns.
# If a string is supplied, it is treated as a fully escaped Sinatra pattern.
#
# If the other pattern is also a Sintara pattern, it might join the two to a third
# sinatra pattern instead of generating a composite for efficency reasons.
#
# This only happens if the sinatra pattern behaves exactly the same as a composite
# would in regards to matching, parsing, expanding and template generation.
#
# @example
# pattern = Mustermann.new('/foo/:name') | Mustermann.new('/:first/:second')
# pattern === '/foo/bar' # => true
# pattern === '/fox/bar' # => true
# pattern === '/foo' # => false
#
# @param [Mustermann::Pattern, String] other the other pattern
# @return [Mustermann::Pattern] a composite pattern
# @see Mustermann::Pattern#|
def |(other)
return super unless converted = self.class.try_convert(other, **options)
return super unless converted.names.empty? or names.empty?
self.class.new(safe_string + "|" + converted.safe_string, **options)
end
# Generates a string represenation of the pattern that can safely be used for def interpolation
# without changing its semantics.
#
# @example
# require 'mustermann'
# unsafe = Mustermann.new("/:name")
#
# Mustermann.new("#{unsafe}bar").params("/foobar") # => { "namebar" => "foobar" }
# Mustermann.new("#{unsafe.safe_string}bar").params("/foobar") # => { "name" => "bar" }
#
# @return [String] string representatin of the pattern
def safe_string
@safe_string ||= SafeRenderer.translate(to_ast)
end
# @!visibility private
def native_concat(other)
return unless converted = self.class.try_convert(other, **options)
safe_string + converted.safe_string
end
private :native_concat
end
end

View File

@ -0,0 +1,45 @@
module Mustermann
class Sinatra < AST::Pattern
# Sinatra syntax definition.
# @!visibility private
class Parser < AST::Parser
on(nil, ??, ?)) { |c| unexpected(c) }
on(?*) { |c| scan(/\w+/) ? node(:named_splat, buffer.matched) : node(:splat) }
on(?:) { |c| node(:capture) { scan(/\w+/) } }
on(?\\) { |c| node(:char, expect(/./)) }
on(?() { |c| node(:group) { read unless scan(?)) } }
on(?|) { |c| node(:or) }
on ?{ do |char|
current_pos = buffer.pos
type = scan(?+) ? :named_splat : :capture
name = expect(/[\w\.]+/)
if type == :capture && scan(?|)
buffer.pos = current_pos
capture = proc do
start = pos
match = expect(/(?<capture>[^\|}]+)/)
node(:capture, match[:capture], start: start)
end
grouped_captures = node(:group, [capture[]]) do
if scan(?|)
[min_size(pos - 1, pos, node(:or)), capture[]]
end
end
grouped_captures if expect(?})
else
type = :splat if type == :named_splat and name == 'splat'
expect(?})
node(type, name)
end
end
suffix ?? do |char, element|
node(:optional, element)
end
end
private_constant :Parser
end
end

View File

@ -0,0 +1,26 @@
module Mustermann
class Sinatra < AST::Pattern
# Generates a string that can safely be concatenated with other strings
# without chaning its semantics
# @see #safe_string
# @!visibility private
SafeRenderer = AST::Translator.create do
translate(:splat, :named_splat) { "{+#{name}}" }
translate(:char, :separator) { Sinatra.escape(payload) }
translate(:root) { t(payload) }
translate(:group) { "(#{t(payload)})" }
translate(:union) { "(#{t(payload, join: ?|)})" }
translate(:optional) { "#{t(payload)}?" }
translate(Array) { |join: ""| map { |e| t(e) }.join(join) }
translate(:capture) do
raise Mustermann::Error, 'cannot render variables' if node.is_a? :variable
raise Mustermann::Error, 'cannot translate constraints' if constraint or qualifier or convert
prefix = node.is_a?(:splat) ? "+" : ""
"{#{prefix}#{name}}"
end
end
private_constant :SafeRenderer
end
end

View File

@ -0,0 +1,48 @@
module Mustermann
class Sinatra < AST::Pattern
# Tries to translate objects to Sinatra patterns.
# @!visibility private
class TryConvert < AST::Translator
# @return [Mustermann::Sinatra, nil]
# @!visibility private
def self.convert(input, **options)
new(options).translate(input)
end
# Expected options for the resulting pattern.
# @!visibility private
attr_reader :options
# @!visibility private
def initialize(options)
@options = options
end
# @return [Mustermann::Sinatra]
# @!visibility private
def new(input, escape = false)
input = Mustermann::Sinatra.escape(input) if escape
Mustermann::Sinatra.new(input, **options)
end
# @return [true, false] whether or not expected pattern should have uri_decode option set
# @!visibility private
def uri_decode
options.fetch(:uri_decode, true)
end
translate(Object) { nil }
translate(String) { t.new(self, true) }
translate(Identity) { t.new(self, true) if uri_decode == t.uri_decode }
translate(Sinatra) { node if options == t.options }
translate AST::Pattern do
next unless options == t.options
t.new(SafeRenderer.translate(to_ast)) rescue nil
end
end
private_constant :TryConvert
end
end

View File

@ -0,0 +1,50 @@
require 'mustermann'
module Mustermann
# Mixin for adding {#to_pattern} ducktyping to objects.
#
# @example
# require 'mustermann/to_pattern'
#
# class Foo
# include Mustermann::ToPattern
#
# def to_s
# ":foo/:bar"
# end
# end
#
# Foo.new.to_pattern # => #<Mustermann::Sinatra:":foo/:bar">
#
# By default included into String, Symbol, Regexp, Array and {Mustermann::Pattern}.
module ToPattern
PRIMITIVES = [String, Symbol, Array, Regexp, Mustermann::Pattern]
private_constant :PRIMITIVES
# Converts the object into a {Mustermann::Pattern}.
#
# @example converting a string
# ":name.png".to_pattern # => #<Mustermann::Sinatra:":name.png">
#
# @example converting a string with options
# "/*path".to_pattern(type: :rails) # => #<Mustermann::Rails:"/*path">
#
# @example converting a regexp
# /.*/.to_pattern # => #<Mustermann::Regular:".*">
#
# @example converting a pattern
# Mustermann.new("foo").to_pattern # => #<Mustermann::Sinatra:"foo">
#
# @param [Hash] options The options hash.
# @return [Mustermann::Pattern] pattern corresponding to object.
def to_pattern(**options)
input = self if PRIMITIVES.any? { |p| self.is_a? p }
input ||= __getobj__ if respond_to?(:__getobj__)
Mustermann.new(input || to_s, **options)
end
PRIMITIVES.each do |klass|
append_features(klass)
end
end
end

View File

@ -0,0 +1,3 @@
module Mustermann
VERSION ||= '0.4.0'
end

1
vendor/gems/net-http-server vendored Submodule

@ -0,0 +1 @@
Subproject commit d2c76b6e9742ec0832c599b4115ca58893d655dd

1
vendor/gems/parslet vendored Submodule

@ -0,0 +1 @@
Subproject commit 9d59363f14b0292676187287bee25d7b5ad06aa0

1
vendor/gems/rack vendored Submodule

@ -0,0 +1 @@
Subproject commit 25a549883b85fb33970b4a1530a365c0c9e51f95

1
vendor/gems/rack-protection vendored Submodule

@ -0,0 +1 @@
Subproject commit f8d8ee2eb72f707d17b07c48bc55a8906978ea5d

1
vendor/gems/sinatra vendored Submodule

@ -0,0 +1 @@
Subproject commit 03c03d7e169954185b008253f8bf2f9068198ca3

1
vendor/gems/tilt vendored Submodule

@ -0,0 +1 @@
Subproject commit 2eb0e355745010af8c2a52338722d0961a1114fb

1
vendor/gems/websocket-ruby vendored Submodule

@ -0,0 +1 @@
Subproject commit 79a67622eeb1f0c11a31910e4bb1d3b292569898