lib/configurations/configurable.rb in configurations-2.0.0.pre vs lib/configurations/configurable.rb in configurations-2.0.0

- old
+ new

@@ -2,125 +2,236 @@ # Module configurable provides the API of configurations # module Configurable extend self - # Once included, Configurations installs three methods in the host module: configure, configuration_defaults and configurable + # Once included, Configurations installs three methods in the host module: + # configure, configuration_defaults and configurable # def included(base) install_configure_in(base) base.class_eval do extend ClassMethods end end - # Installs #configure in base, and makes sure that it will instantiate configuration as a subclass of the host module + # Installs #configure in base, and makes sure that it will instantiate + # configuration as a subclass of the host module # def install_configure_in(base) base.class_eval <<-EOF - class << self - # The central configure method - # @params [Proc] block the block to configure host module with - # @raise [ArgumentError] error when not given a block - # @example Configure a configuration - # MyGem.configure do |c| - # c.foo = :bar - # end - # - def configure(&block) - raise ArgumentError, 'can not configure without a block' unless block_given? - @configuration = #{base.name}::Configuration.new( - defaults: @configuration_defaults, - methods: @configuration_methods, - configurable: @configurable, - not_configured: @not_configured_callback, - &block - ) - end + # The central configure method + # @params [Proc] block the block to configure host module with + # @raise [ArgumentError] error when not given a block + # @example Configure a configuration + # MyGem.configure do |c| + # c.foo = :bar + # end + # + def self.configure(&block) + fail ArgumentError, "configure needs a block" unless block_given? + @configuration = #{base.name}.const_get(configuration_type).new( + configuration_options, + &block + ) end EOF end # Class methods that will get installed in the host module # module ClassMethods # A reader for Configuration # def configuration - @configuration ||= @configuration_defaults && configure { } + @configuration ||= @configuration_defaults && configure {} end - # Configuration defaults can be used to set the defaults of any Configuration + # Configuration defaults can be used to set the defaults of + # any Configuration # @param [Proc] block setting the default values of the configuration # def configuration_defaults(&block) @configuration_defaults = block end - # configurable can be used to set the properties which should be configurable, as well as a type which - # the given property should be asserted to - # @param [Class, Symbol, Hash] properties a type as a first argument to type assert (if any) or nested properties to allow for setting - # @param [Proc] block a block with arity 2 to evaluate when a property is set. It will be given: property name and value + # configurable can be used to set the properties which should be + # configurable, as well as a type which the given property should + # be asserted to + # @param [Class, Symbol, Hash] properties a type as a first argument to + # type assert (if any) or nested properties to allow for setting + # @param [Proc] block a block with arity 2 to evaluate when a property + # is set. It will be given: property name and value # @example Define a configurable property # configurable :foo # @example Define a type asserted, nested property for type String # configurable String, bar: :baz # @example Define a custom assertion for a property # configurable biz: %i(bi bu) do |value| - # raise ArgumentError, 'must be one of a, b, c' unless %w(a b c).include?(value) + # unless %w(a b c).include?(value) + # fail ArgumentError, 'must be one of a, b, c' + # end # end # def configurable(*properties, &block) type = properties.shift if properties.first.is_a?(Class) + @configurable ||= {} @configurable.merge! to_configurable_hash(properties, type, &block) end # returns whether a property is set to be configurable # @param [Symbol] property the property to ask status for + # @return [Boolean] whether the property is configurable # def configurable?(property) - @configurable.is_a?(Hash) && @configurable.has_key?(property) + @configurable.is_a?(Hash) && @configurable.key?(property) end - # configuration method can be used to retrieve properties from the configuration which use your gem's context + # configuration method can be used to retrieve properties + # from the configuration + # which use your gem's context # @param [Class, Symbol, Hash] method the method to define # @param [Proc] block the block to evaluate - # @example Define a configuration method 'foobararg' returning configuration properties 'foo' and 'bar' plus an argument + # @example Define a configuration method 'foobararg' # configuration_method :foobararg do |arg| # foo + bar + arg # end + # @example Define a configuration method on a nested property + # configuration_method foo: { bar: :arg } do + # baz + biz + # end # def configuration_method(method, &block) - raise ArgumentError, "can not be both a configurable property and a configuration method" if configurable?(method) + fail ArgumentError, "can't be configuration property and a method" if configurable?(method) + @configuration_methods ||= {} - @configuration_methods.merge! method => block + method_hash = if method.is_a?(Hash) + ingest_configuration_block!(method, &block) + else + { method => block } + end + + @configuration_methods.merge! method_hash end - # not_configured defines the behaviour when a property has not been configured - # This can be useful for presence validations of certain properties - # or behaviour for undefined properties deviating from the original behaviour - # @param [Proc] block the block to evaluate + # not_configured defines the behaviour when a property has not been + # configured. This can be useful for presence validations of certain + # properties or behaviour for undefined properties deviating from the + # original behaviour. + # @param [Array, Symbol, Hash] properties the properties to install + # the callback on. If omitted, the callback will be installed on + # all properties that have no specific callbacks + # @param [Proc] block the block to evaluate when a property + # has not been configured # @yield [Symbol] the property that has not been configured + # @example Define a specific not_configured callback + # not_configured :property1, property2: :property3 do |property| + # raise ArgumentError, "#{property} should be configured" + # end + # @example Define a catch-all not_configured callback + # not_configured do |property| + # raise StandardError, "You did not configure #{property}" + # end # - def not_configured(&block) - @not_configured_callback = block + def not_configured(*properties, &block) + @not_configured ||= {} + + if properties.empty? + @not_configured.default_proc = ->(h, k) { h[k] = block } + else + nested_merge_not_configured_hash(*properties, &block) + end end + # @return the class name of the configuration class to use + # + def configuration_type + if @configurable.nil? || @configurable.empty? + :ArbitraryConfiguration + else + :StrictConfiguration + end + end + private # Instantiates a configurable hash from a property and a type - # @param [Symbol, Hash, Array] properties configurable properties, either single or nested + # @param [Symbol, Hash, Array] properties configurable properties, + # either single or nested # @param [Class] type the type to assert, if any # @return a hash with configurable values pointing to their types # def to_configurable_hash(properties, type, &block) assertion_hash = {} assertion_hash.merge! block: block if block_given? assertion_hash.merge! type: type if type - assertions = ([assertion_hash] * properties.size) - Hash[properties.zip(assertions)] + zip_to_hash(assertion_hash, *properties) + end + + # Makes all values of hash point to block + # @param [Hash] hash the hash to modify + # @param [Proc] block the block to point to + # @return a hash with all previous values being keys pointing to block + # + def ingest_configuration_block!(hash, &block) + hash.each do |k, v| + value = if v.is_a?(Hash) + ingest_configuration_block!(v, &block) + else + zip_to_hash(block, *Array(v)) + end + + hash.merge! k => value + end + end + + # @return a hash of configuration options with no nil values + # + def configuration_options + { + defaults: @configuration_defaults, + methods: @configuration_methods, + configurable: @configurable, + not_configured: @not_configured + }.delete_if { |_, value| value.nil? } + end + + # merges the properties given into a not_configured hash + # @param [Symbol, Hash, Array] properties the properties to merge + # @param [Proc] block the block to point the properties to when + # not configured + # + def nested_merge_not_configured_hash(*properties, &block) + nested = properties.last.is_a?(Hash) ? properties.pop : {} + nested = ingest_configuration_block!(nested, &block) + props = zip_to_hash(block, *properties) + + @not_configured.merge! nested, &method(:configuration_deep_merge) + @not_configured.merge! props, &method(:configuration_deep_merge) + end + + # Solves merge conflicts when merging + # @param [Symbol] key the key that conflicts + # @param [Anything] oldval the value of the left side of the merge + # @param [Anything] newval the value of the right side of the merge + # @return a mergable value with conflicts solved + # + def configuration_deep_merge(_key, oldval, newval) + if oldval.is_a?(Hash) && newval.is_a?(Hash) + oldval.merge(newval, &method(:configuration_deep_merge)) + else + Array(oldval) + Array(newval) + end + end + + # Zip a value with keys to a hash so all keys point to the value + # @param [Anything] value the value to point to + # @param [Array] keys the keys to install + # + def zip_to_hash(value, *keys) + Hash[keys.zip([value] * keys.size)] end end end end