module Remarkable module DSL # This module is responsable for create optional handlers and providing macro # configration blocks. Consider the matcher below: # # class AllowValuesForMatcher < Remarkable::ActiveRecord::Base # arguments :collection => :attributes, :as => :attribute # # optional :message # optional :in, :splat => true # optional :allow_nil, :allow_blank, :default => true # end # # This allow the matcher to be called as: # # it { should allow_values_for(:email).in("jose.valim@gmail.com", "jose@another.com").message(:invalid).allow_nil } # # It also allow macros to be configured with blocks: # # should_allow_values_for :email do |m| # m.message :invalid # m.allow_nil # m.in "jose.valim@gmail.com" # m.in "jose@another.com" # end # # Which could be also writen as: # # should_allow_values_for :email do |m| # m.message = :invalid # m.allow_nil = true # m.in = [ "jose.valim@gmail.com", "jose@another.com" ] # end # # The difference between the them are: 1) optional= always require an argument # even if :default is given. 2) optional= always overwrite all previous values # even if :splat is given. # # Blocks can be also given when :block => true is set: # # should_set_session :user_id do |m| # m.to { @user.id } # end # # == I18n # # Optionals will be included in description messages if you assign them # properly on your locale file. If you have a validate_uniqueness_of # matcher with the following on your locale file: # # description: validate uniqueness of {{attributes}} # optionals: # scope: # positive: scoped to {{inspect}} # case_sensitive: # positive: case sensitive # negative: case insensitive # # When invoked like below will generate the following messages: # # validate_uniqueness_of :project_id, :scope => :company_id # #=> "validate uniqueness of project_id scoped to :company_id" # # validate_uniqueness_of :project_id, :scope => :company_id, :case_sensitive => true # #=> "validate uniqueness of project_id scoped to :company_id and case sensitive" # # validate_uniqueness_of :project_id, :scope => :company_id, :case_sensitive => false # #=> "validate uniqueness of project_id scoped to :company_id and case insensitive" # # == Interpolation options # # The default interpolation options available are "inspect" and "value". Whenever # you use :splat => true, it also adds a new interpolation option called {{sentence}}. # # Given the following matcher call: # # validate_uniqueness_of :id, :scope => [ :company_id, :project_id ] # # The following yml setting and outputs are: # # scope: # positive: scoped to {{inspect}} # # Outputs: "validate uniqueness of project_id scoped to [ :company_id, :project_id ]" # # positive: scoped to {{value}} # # Outputs: "validate uniqueness of project_id scoped to company_idproject_id" # # positive: scoped to {{value}} # # Outputs: "validate uniqueness of project_id scoped to company_id and project_id" # # == Interpolation keys # # Three keys are available to be used in I18n files and control how optionals # are appended to your description: # # * positive - When the optional is given and it evaluates to true (everything but false and nil). # * negative - When the optional is given and it evaluates to false (false or nil). # * not_given - When the optional is not given. # module Optionals OPTIONAL_KEYS = [ :positive, :negative, :not_given ] def self.included(base) #:nodoc: base.extend ClassMethods end module ClassMethods protected # Creates optional handlers for matchers dynamically. # # == Options # # * :default - The default value for this optional # * :alias - An alias for this optional # * :splat - Should be true if you expects multiple arguments # * :block - Tell this optional can also receive blocks # # == Examples # # class AllowValuesForMatcher < Remarkable::ActiveRecord::Base # arguments :collection => :attributes, :as => :attribute # # optional :message # optional :in, :splat => true # optional :allow_nil, :allow_blank, :default => true # end # def optionals(*names) options = names.extract_options! @matcher_optionals += names default = options[:default] ? "=#{options[:default].inspect}" : nil block = if options[:block] @matcher_optionals_block += names default ||= "=nil" ', &block' else nil end splat = if options[:splat] @matcher_optionals_splat += names '*' else nil end names.each do |name| class_eval <<-END, __FILE__, __LINE__ def #{name}(#{splat}value#{default}#{block}) @options ||= {} #{"@options[:#{name}] ||= []" if splat} @options[:#{name}] #{:+ if splat}= #{"block ||" if block} value self end def #{name}=(value) @options ||= {} @options[:#{name}] = value self end END end class_eval %{ alias :#{options[:alias]} :#{names.last} alias :#{options[:alias]}= :#{names.last}= } if options[:alias] # Call unique to avoid duplicate optionals. @matcher_optionals.uniq! end alias :optional :optionals # Instead of appending, prepend optionals to the beginning of optionals # array. This is important because the optionals declaration order # changes how the description message is generated. # def prepend_optionals(*names) current_optionals = @matcher_optionals.dup @matcher_optionals = [] optional(*names) @matcher_optionals += current_optionals @matcher_optionals.uniq! end alias :prepend_optional :prepend_optionals end # Overwrites description to support optionals. Check optional for # more information. # def description(options={}) #:nodoc: message = super(options) message.strip! optionals = self.class.matcher_optionals.map do |optional| if @options.key?(optional) value = @options[optional] defaults = [ (value ? :positive : :negative) ] # If optional is a symbol and it's not any to any of the reserved symbols, search for it also defaults.unshift(value) if value.is_a?(Symbol) && !OPTIONAL_KEYS.include?(value) defaults << '' options = { :default => defaults, :inspect => value.inspect, :value => value.to_s } if self.class.matcher_optionals_splat.include?(optional) value = [ value ] unless Array === value options[:sentence] = array_to_sentence(value, true) end translate_optionals_with_namespace(optional, defaults.shift, options) else translate_optionals_with_namespace(optional, :not_given, :default => '') end end.compact message << ' ' << array_to_sentence(optionals) message.strip! message end def translate_optionals_with_namespace(optional, key, options={}) #:nodoc: scope = "#{matcher_i18n_scope}.optionals.#{optional}" translation = Remarkable.t key, options.merge!(:scope => scope) return translation unless translation.empty? parent_scope = scope.split('.') parent_scope.delete_at(-3) translation = Remarkable.t key, options.merge!(:scope => parent_scope) return translation unless translation.empty? nil end end end end