module Mobility::Plugin

Defines convenience methods on plugin module to hook into initialize/included method calls on Mobility::Pluggable instance.

Also includes a configure class method to apply plugins to a pluggable ({Mobility::Pluggable} instance), with a block.

@example Defining a plugin

module MyPlugin
  extend Mobility::Plugin

  initialize_hook do |*names|
    names.each do |name|
      define_method "#{name}_foo" do
        # method body
      end
    end
  end

  included_hook do |klass, backend_class|
    backend_class.include MyBackendMethods
    klass.include MyModelMethods
  end
end

@example Configure an attributes class with plugins

class Translations < Mobility::Translations
end

Mobility::Plugin.configure(Translations) do
  cache
  fallbacks
end

Translations.included_modules
#=> [Mobility::Plugins::Fallbacks, Mobility::Plugins::Cache, ...]

Constants

DependencyResolver

Public Class Methods

configure(pluggable, defaults = pluggable.defaults, &block) click to toggle source

Configure a pluggable {Mobility::Pluggable} with a block. Yields to a clean room where plugin names define plugins on the module. Plugin dependencies are resolved before applying them.

@param [Class, Module] pluggable @param [Hash] defaults Plugin defaults hash to update @yield Block to define plugins @return [Hash] Updated plugin defaults @raise [Mobility::Plugin::CyclicDependency] if dependencies cannot be met @example

Mobility::Plugin.configure(Translations) do
  cache
  fallbacks [:en, :de]
end
# File lib/mobility/plugin.rb, line 67
def configure(pluggable, defaults = pluggable.defaults, &block)
  DependencyResolver.new(pluggable, defaults).call(&block)
end

Public Instance Methods

configure_default(defaults, key, *args) click to toggle source

Method called when defining plugins to assign a default based on arguments and keyword arguments to the plugin method. By default, we simply assign the first argument, but plugins can opt to customize this if additional arguments or keyword arguments are required. (The backend plugin uses keyword arguments to set backend options.)

@param [Hash] defaults @param [Symbol] key Plugin key on hash @param [Array] args Method arguments

# File lib/mobility/plugin.rb, line 118
def configure_default(defaults, key, *args)
  defaults[key] = args[0] unless args.empty?
end
default(value) click to toggle source
# File lib/mobility/plugin.rb, line 105
def default(value)
  @default = value
end
dependencies() click to toggle source
# File lib/mobility/plugin.rb, line 101
def dependencies
  @dependencies ||= {}
end
dependencies_satisfied?(klass) click to toggle source

Does this class include all plugins this plugin depends (directly) on? @param [Class] klass Pluggable class

# File lib/mobility/plugin.rb, line 124
def dependencies_satisfied?(klass)
  plugin_keys = klass.included_plugins.map { |plugin| Plugins.lookup_name(plugin) }
  (dependencies.keys - plugin_keys).none?
end
included(pluggable) click to toggle source
Calls superclass method
# File lib/mobility/plugin.rb, line 94
def included(pluggable)
  if defined?(@default) && !pluggable.defaults.has_key?(name = Plugins.lookup_name(self))
    pluggable.defaults[name] = @default
  end
  super
end
included_hook(&block) click to toggle source
Calls superclass method
# File lib/mobility/plugin.rb, line 82
def included_hook(&block)
  plugin = self

  define_method :included do |klass|
    super(klass).tap do |backend_class|
      if plugin.dependencies_satisfied?(self.class)
        class_exec(klass, backend_class, &block)
      end
    end
  end
end
initialize_hook(&block) click to toggle source
Calls superclass method
# File lib/mobility/plugin.rb, line 72
def initialize_hook(&block)
  plugin = self

  define_method :initialize do |*args, **options|
    super(*args, **options)

    class_exec(*args, &block) if plugin.dependencies_satisfied?(self.class)
  end
end
requires(plugin, include: true) click to toggle source

Specifies a dependency of this plugin.

By default, the dependency is included (include: true). Passing :before or :after will ensure the dependency is included before or after this plugin.

Passing false does not include the dependency, but checks that it has been included when running include and initialize hooks (so hooks will not run for this plugin if it has not been included). In other words: disable this plugin unless this dependency has been included elsewhere. (Note that this check is not applied recursively.)

@param [Symbol] plugin Name of plugin dependency @option [TrueClass, FalseClass, Symbol] include

# File lib/mobility/plugin.rb, line 143
def requires(plugin, include: true)
  unless [true, false, :before, :after].include?(include)
    raise ArgumentError, "requires 'include' keyword argument must be one of: true, false, :before or :after"
  end
  dependencies[plugin] = include
end

DependencyResolver = Struct.new(:pluggable, :defaults) do
  def call(&block)
    plugins = DSL.call(defaults, &block)
    tree = create_tree(plugins)

    pluggable.include(*tree.tsort.reverse) unless tree.empty?
  rescue TSort::Cyclic => e
    raise_cyclic_dependency!(e.message)
  end

  private

  def create_tree(plugins)
    DependencyTree.new.tap do |tree|
      visited = included_plugins
      plugins.each { |plugin| traverse(tree, plugin, visited) }
    end
  end

  def included_plugins
    pluggable.included_modules.grep(Plugin)
  end

  # Recursively traverse dependencies and add their dependencies to tree
  def traverse(tree, plugin, visited)
    return if visited.include?(plugin)

    tree.add(plugin)

    plugin.dependencies.each do |dep_name, include_order|
      next unless include_order
      dep = Plugins.load_plugin(dep_name)
      add_dependency(plugin, dep, tree, include_order)

      traverse(tree, dep, visited << plugin)
    end
  end

  def add_dependency(plugin, dep, tree, include_order)
    case include_order
    when :before
      tree[plugin] += [dep]
    when :after
      check_after_dependency!(plugin, dep)
      tree.add(dep)
      tree[dep] += [plugin]
    end
  end

  def check_after_dependency!(plugin, dep)
    if included_plugins.include?(dep)
      message = "'#{name(dep)}' plugin must come after '#{name(plugin)}' plugin"
      raise DependencyConflict, append_pluggable_name(message)
    end
  end

  def raise_cyclic_dependency!(error_message)
    components = error_message.scan(/(?<=\[).*(?=\])/).first
    names = components.split(', ').map! do |plugin|
      name(Object.const_get(plugin)).to_s
    end
    message = "Dependencies cannot be resolved between: #{names.sort.join(', ')}"
    raise CyclicDependency, append_pluggable_name(message)
  end

  def append_pluggable_name(message)
    pluggable.name ? "#{message} in #{pluggable}" : message
  end

  def name(plugin)
    Plugins.lookup_name(plugin)
  end

  class DependencyTree < Hash
    include ::TSort
    NO_DEPENDENCIES = Set.new.freeze

    def add(key)
      self[key] ||= NO_DEPENDENCIES
    end

    alias tsort_each_node each_key

    def tsort_each_child(dep, &block)
      self.fetch(dep, []).each(&block)
    end
  end

  class DSL < BasicObject
    def self.call(defaults, &block)
      new(plugins = ::Set.new, defaults).instance_eval(&block)
      plugins
    end

    def initialize(plugins, defaults)
      @plugins = plugins
      @defaults = defaults
    end

    def method_missing(m, *args)
      plugin = Plugins.load_plugin(m)
      @plugins << plugin
      plugin.configure_default(@defaults, m, *args)
    end
  end
end