robkinyon/ruby-dsl-maker

View on GitHub
lib/dsl/maker.rb

Summary

Maintainability
D
2 days
Test Coverage
require 'dsl/maker/version'

require 'docile'

# Children of DSL::Maker are the "control class". All of the classes that inherit
# from DSL::Maker::Base are "dsl classes" - classes that are passed to Docile and
# which represent levels of the DSL. In order for :parse_dsl/:execute_dsl to
# return back the accumulated values in the order provided, we need to "pierce the
# veil" (so to speak) between the control and dsl classes. That's done using the
# parent_class class attribute in the dsl classes.

# This is the base class we provide.
class DSL::Maker
  # This is the base class for all DSL-parsing classes.
  class Base
    # 21 character method names are obscene. Make it easier to read.
    alias :___set :instance_variable_set

    # 21 character method names are obscene. Make it easier to read.
    alias :___get :instance_variable_get

    def get_binding
      binding
    end

    define_singleton_method(:add_verification) do |&block|
      # FIXME: This throws regardless. Is this because of the difference between
      # proc and block?
      #raise "Block required for add_verification" unless block_given?

      @verifications ||= []

      # This craziness converts the block provided into a proc that can be called
      # in add_entrypoint(). Taken from http://stackoverflow.com/a/2946734/1732954
      # Note: self is not preserved. This should be okay because the verification
      # should be idempotent relative to the value provided (side-effect-free).
      obj = Object.new
      obj.define_singleton_method(:_, &block)
      @verifications.push(obj.method(:_).to_proc)

      return
    end
  end

  # Create the DSL::Maker::Any type identifier, equivalent to Object.
  Any = Object

  # This is a useful module that contains all the Boolean handling we need.
  module Boolean
    {
      :yes => true, :no  => false,
      :on  => true, :off => false,
    }.each do |name, result|
      define_method(name) { result }
    end

    def self.coerce(value)
      if value
        return false if %w(no off false nil).include? value.to_s.downcase
      end
      # The bang-bang boolean-izes the value. We want this to be lossy.
      !!value
    end
  end
  Yes = On = True = true
  No = Off = False = false

  class Alias
    attr_reader :real_name
    def initialize(real_name)
      @real_name = real_name
    end
  end
  @@aliases = {}
  def self.AliasOf(name)
    @@aliases[name] ||= Alias.new(name)
  end
  def self.is_alias?(type)
    type.instance_of? Alias
  end

  class ArrayType
    attr_reader :base_type
    def initialize(base_type)
      @base_type = base_type
    end
  end
  @@arrays = {}
  ArrayOf = Class.new do
    def self.[](type)
      raise "Cannot make an array of an alias" if DSL::Maker.is_alias?(type)
      raise "Unknown type provided to ArrayOf" unless @@types.has_key?(type) || DSL::Maker.is_dsl?(type)
      @@arrays[type] ||= ArrayType.new(type)
    end
  end
  def self.is_array?(type)
    type.instance_of? ArrayType
  end

  class HashType
    def initialize(rv)
      @rv = rv
    end

    def respond_to_missing?(*args)
      true
    end

    def method_missing(methname, *args)
      @rv[methname.to_s] = args[0] unless methname.to_s =~ /__.*__/
    end
  end

  # Parse the DSL provided in the parameter.
  #
  # @param dsl [String] The DSL to be parsed by this class.
  #
  # @return    [Array] Whatever is returned by the block defined in this class.
  def self.parse_dsl(dsl=nil)
    raise 'Must call add_entrypoint before parse_dsl' unless @klass
    raise 'String required for parse_dsl' unless dsl.instance_of? String

    run_dsl { eval dsl, @klass.new.get_binding }
  end

  # Execute the DSL provided in the block.
  #
  # @param &block [Block] The DSL to be executed by this class.
  #
  # @return    [Array] Whatever is returned by the block defined in this class.
  def self.execute_dsl(&block)
    raise 'Must call add_entrypoint before execute_dsl' unless @klass
    raise 'Block required for execute_dsl' unless block_given?

    run_dsl { @klass.new.instance_eval(&block) }
  end

  # This adds a type coercion that's used when creating the DSL.
  #
  # @note These type coercions are global to all DSLs.
  #
  # @param type   [Object] the name of the helper
  # @param &block [Block]  The function to be executed when the coercion is exercised.
  #
  # Your block will receive the following signature: |attr, *args| where 'attr' is
  # the name of the attribute and *args are the arguments passed into your method
  # within the DSL. You are responsible for acting as a mutator. You have ___get()
  # and ___set() available for your use. These are aliases to
  # instance_variable_get and instance_variable_set, respectively. Please read the
  # coercions provided for you in this source file as examples.
  #
  # @return nil
  def self.add_type(type, &block)
    raise "Block required for add_type" unless block_given?
    raise "'#{type}' is already a type coercion" if @@types.has_key? type

    @@types[type] = block

    return
  end

  # Add the meat of a DSL block to some level of this class's DSL.
  #
  # In order for Docile to parse a DSL, each level must be represented by a
  # different class. This method creates anonymous classes that each represents
  # a different level in the DSL's structure.
  #
  # The creation of each DSL element is delegated to build_dsl_element.
  #
  # @param args  [Hash]   the elements of the DSL block (passed to generate_dsl)
  # @param defn_block [Proc]   what is executed once the DSL block is parsed.
  #
  # @return      [Class]  The class that implements this level's DSL definition.
  def self.generate_dsl(args={}, &defn_block)
    raise 'Block required for generate_dsl' unless block_given?

    dsl_class = Class.new(base_class) do
      include DSL::Maker::Boolean

      class << self
        attr_accessor :parent_class, :verifications
      end

      define_method(:__apply) do |*args|
        instance_exec(*args, &defn_block)
      end
    end

    args.each do |name, type|
      if dsl_class.new.respond_to? name.to_sym
        raise "Illegal attribute name '#{name}'"
      end

      build_dsl_element(dsl_class, name, type)
    end

    return dsl_class
  end

  # Add an entrypoint (top-level DSL element) to this class's DSL.
  #
  # This delegates to generate_dsl() for the majority of the work.
  #
  # @note `args` could be a Hash (to be passed to generate_dsl()) or the result
  # of a call to generate_dsl().
  #
  # @param name  [String] the name of the entrypoint
  # @param args  [Hash]   the elements of the DSL block (passed to generate_dsl)
  # @param defn_block [Proc]   what is executed once the DSL block is parsed.
  #
  # @return      [Class]  The class that implements this level's DSL definition.
  def self.add_entrypoint(name, args={}, &defn_block)
    symname = name.to_sym

    if is_entrypoint?(symname)
      raise "'#{name.to_s}' is already an entrypoint"
    end

    if is_dsl?(args)
      dsl_class = args
    else
      # Without defn_block, there's no way to give back the result of the
      # DSL parsing. So, raise an error if we don't get one.
      # TODO: Provide a default block that returns the datastructure as a HoH.

      raise "Block required for add_entrypoint" unless block_given?
      dsl_class = generate_dsl(args, &defn_block)
    end

    if @klass
      build_dsl_element(@klass, symname, dsl_class)
    else
      # FIXME: We shouldn't need the blank block here ...
      # This blank block is representative of the implicit (and missing) outermost
      # block around the DSL that we are not putting into place in :parse_dsl or
      # :execute_dsl.
      @klass = generate_dsl({
        symname => dsl_class,
      }) {}

      # This marks @klass as the root DSL class.
      @klass.parent_class = self
    end

    @entrypoints ||= {}
    return @entrypoints[symname] = dsl_class
  end

  # This returns the DSL corresponding to the entrypoint's name.
  #
  # @param name  [String] the name of the entrypoint
  #
  # @return      [Class]  The class that implements this name's DSL definition.
  def self.entrypoint(name)
    unless is_entrypoint?(name)
      raise "'#{name.to_s}' is not an entrypoint"
    end

    return @entrypoints[name.to_sym]
  end

  # This adds a helper function that's accessible within the DSL.
  #
  # Note: These helpers are global to all DSLs.
  #
  # @param name   [String] the name of the helper
  # @param &block [Block]  The function to be executed when the helper is called.
  #
  # @return nil
  def self.add_helper(name, &block)
    raise "Block required for add_helper" unless block_given?

    if has_helper? name
      raise "'#{name.to_s}' is already a helper"
    end

    base_class.class_eval do
      define_method(name.to_sym, &block)
    end

    return
  end

  # This removes a helper function that's been added with #add_helper
  #
  # @param name   [String] the name of the helper
  #
  # @return nil
  def self.remove_helper(name)
    unless has_helper? name
      raise "'#{name.to_s}' is not a helper"
    end

    base_class.class_eval do
      remove_method(name.to_sym)
    end
  end

  # This returns if the helper has been added with #add_helper
  #
  # @param name   [String] the name of the helper
  #
  # @return Boolean
  def self.has_helper?(name)
    base_class.method_defined?(name.to_sym)
  end

  # This adds a verification that's executed after the DSL is finished parsing.
  #
  # The verification will be called with the value(s) returned by the entrypoint's
  # execution. If the verification returns a true value (of any kind), then that
  # will be raised as a runtime exception.
  #
  # You can also call add_verification on the return values from generate_dsl() or
  # add_entrypoint(). In those cases, omit the :name because you have already
  # chosen the DSL layer you're adding the verification to.
  #
  # @note These verifications are specific to the DSL you add them to.
  #
  # @note Verifications are called in the order you specify them.
  #
  # @param name   [String] the name of the entrypoint to add a verification to
  # @param &block [Block]  The function to be executed when verifications execute
  #
  # @return nil
  def self.add_verification(name, &block)
    raise "Block required for add_verification" unless block_given?
    raise "'#{name.to_s}' is not an entrypoint for a verification" unless is_entrypoint?(name)

    @entrypoints[name.to_sym].add_verification(&block)
  end

  private

  # This is deliberately global to the hierarchy in order for DSL::Maker to add
  # the generic types. While this has the potential to cause userspace collisions,
  # it's highly unlikely that DSLs with divergent types will coexist in the same
  # Ruby process.
  @@types = {}

  # Add a single element of a DSL to a class representing a level in a DSL.
  #
  # Each of the types represents a coercion - a guarantee and check of the value
  # in that name. The standard type coercions are:
  #
  #   * Any  - whatever you give is returned.
  #   * String  - the string value of whatever you give is returned.
  #   * Integer - the integer value of whatever you give is returned.
  #   * Boolean - the truthiness of whatever you give is returned.
  #   * generate_dsl() - this represents a new level of the DSL.
  #   * AliasOf(<name>) - this aliases a name to another name.
  #   * ArrayOf[<type>] - this creates an array of the <type> coercion.
  #
  # @param klass [Class]  The class representing this level in the DSL.
  # @param name  [String] The name of the element we're working on.
  # @param type  [Class]  The type of this element we're working on.
  #                       This is the type coercion spoken above.
  #
  # @return   nil
  def self.build_dsl_element(klass, name, type)
    if @@types.has_key?(type)
      klass.class_eval do
        define_method(name.to_sym) do |*args|
          instance_exec('@' + name.to_s, *args, &@@types[type])
        end
      end
    elsif is_hash?(type)
      as_attr = '@' + name.to_s
      klass.class_eval do
        define_method(name.to_sym) do |*args, &dsl_block|
          if (!args.empty? || dsl_block)
            rv = {}
            Docile.dsl_eval(HashType.new(rv), &dsl_block) if dsl_block
            ___set(as_attr, rv)
          end
          ___get(as_attr)
        end
      end
    elsif is_dsl?(type)
      as_attr = '@' + name.to_s
      klass.class_eval do
        define_method(name.to_sym) do |*args, &dsl_block|
          if (!args.empty? || dsl_block)
            obj = type.new
            Docile.dsl_eval(obj, &dsl_block) if dsl_block
            rv = obj.__apply(*args)

            if v = type.instance_variable_get(:@verifications)
              v.each do |verify|
                failure = verify.call(rv)
                raise failure if failure
              end
            end

            # This is the one place where we pull out the entrypoint results and
            # put them into the control class.
            if klass.parent_class
              # Use the full instance_variable_get() in order to avoid having to
              # create accessors that could be misused outside this class.
              klass.parent_class.instance_variable_get(:@accumulator).push(rv)
            end

            ___set(as_attr, rv)
          end
          ___get(as_attr)
        end
      end
    elsif is_alias?(type)
      klass.class_eval do
        alias_method name, type.real_name
      end
    elsif is_array?(type)
      as_attr = '@' + name.to_s

      klass.class_eval do
        define_method(name.to_sym) do |*args, &dsl_block|
          rv = ___get(as_attr)
          ___set(as_attr, rv = []) unless rv

          if dsl_block
            # This code is copy-pasted from the is_dsl?() section above. Figure out
            # how to hoist this code into something reusable. But, we don't need
            # the parent_class section (do we?)
            obj = type.base_type.new
            Docile.dsl_eval(obj, &dsl_block)
            dsl_value = obj.__apply(*args)

            if v = type.base_type.instance_variable_get(:@verifications)
              v.each do |verify|
                failure = verify.call(dsl_value)
                raise failure if failure
              end
            end

            rv.push(dsl_value)
          elsif !args.empty?
            rv.concat(
              args.flatten.map do |item|
                klass.new.instance_exec('@__________', item, &@@types[type.base_type])
              end
            )
          end

          rv
        end
      end
    else
      raise "Unrecognized element type '#{type}'"
    end

    return
  end

  def self.run_dsl()
    # build_dsl_element() will use @accumulator to handle multiple entrypoints
    # if the class in question is a root DSL class. Reset it here so that we're
    # only handling the values from this run.
    @accumulator = []

    yield

    return @accumulator
  end

  def self.is_hash?(proto)
    proto == Hash
  end

  def self.is_dsl?(proto)
    proto.is_a?(Class) && proto.ancestors.include?(DSL::Maker::Base)
  end

  def self.is_entrypoint?(name)
    @entrypoints && @entrypoints.has_key?(name.to_sym)
  end

  def self.base_class
    # This is the only time we *know* that the :default helper doesn't exist yet.
    unless @base_class
      @base_class = Class.new(DSL::Maker::Base)
      add_helper(:default) do |method_name, args, position=0|
        method = method_name.to_sym
        if args.length >= (position + 1) && !self.send(method)
          self.send(method, args[position])
        end
        return
      end
    end
    @base_class
  end
end

# These are the default setups

DSL::Maker.add_type(DSL::Maker::Any) do |attr, *args|
  ___set(attr, args[0]) unless args.empty?
  ___get(attr)
end
DSL::Maker.add_type(Integer) do |attr, *args|
  ___set(attr, args[0].to_i) unless args.empty?
  ___get(attr)
end
DSL::Maker.add_type(String) do |attr, *args|
  ___set(attr, args[0].to_s) unless args.empty?
  ___get(attr)
end
DSL::Maker.add_type(DSL::Maker::Boolean) do |attr, *args|
  ___set(attr, DSL::Maker::Boolean.coerce(args[0])) unless args.empty?
  # Ensure that the default nil also returns as false.
  !!___get(attr)
end