# frozen-string-literal: true require "tsort" require "mobility/util" module Mobility =begin Defines convenience methods on plugin module to hook into initialize/included method calls on +Mobility::Pluggable+ instance. - #initialize_hook: called after {Mobility::Pluggable#initialize}, with attribute names. - #included_hook: called after {Mobility::Pluggable#included}. (This can be used to include any module(s) into the backend class, see {Mobility::Plugins::Backend}.) 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, ...] =end module Plugin class << self # 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 def configure(pluggable, defaults = pluggable.defaults, &block) DependencyResolver.new(pluggable, defaults).call(&block) end end 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 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 def included(pluggable) if defined?(@default) && !pluggable.defaults.has_key?(name = Plugins.lookup_name(self)) pluggable.defaults[name] = @default end super end def dependencies @dependencies ||= {} end def default(value) @default = value end # 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 def configure_default(defaults, key, *args) defaults[key] = args[0] unless args.empty? end # Does this class include all plugins this plugin depends (directly) on? # @param [Class] klass Pluggable class def dependencies_satisfied?(klass) plugin_keys = klass.included_plugins.map { |plugin| Plugins.lookup_name(plugin) } (dependencies.keys - plugin_keys).none? end # 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 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 private_constant :DependencyResolver class DependencyConflict < Mobility::Error; end class CyclicDependency < DependencyConflict; end end end