lib/cleanroom.rb



#
# Copyright 2014 Seth Vargo <sethvargo@gmail.com>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require_relative "cleanroom/errors"
require_relative "cleanroom/version"

module Cleanroom
  #
  # Callback for when this module is included.
  #
  # @param [Class] base
  #
  def self.included(base)
    base.send(:extend,  ClassMethods)
    base.send(:include, InstanceMethods)
  end

  #
  # Callback for when this module is included.
  #
  # @param [Class] base
  #
  def self.extended(base)
    base.send(:extend,  ClassMethods)
    base.send(:include, InstanceMethods)
  end

  #
  # Class methods
  #
  module ClassMethods
    #
    # Evaluate the file in the context of the cleanroom.
    #
    # @param [Class] instance
    #   the instance of the class to evaluate against
    # @param [String] filepath
    #   the path of the file to evaluate
    #
    def evaluate_file(instance, filepath)
      absolute_path = File.expand_path(filepath)
      file_contents = IO.read(absolute_path)
      evaluate(instance, file_contents, absolute_path, 1)
    end

    #
    # Evaluate the string or block in the context of the cleanroom.
    #
    # @param [Class] instance
    #   the instance of the class to evaluate against
    # @param [Array<String>] args
    #   the args to +instance_eval+
    # @param [Proc] block
    #   the block to +instance_eval+
    #
    def evaluate(instance, *args, &block)
      cleanroom.new(instance).instance_eval(*args, &block)
    end

    #
    # Expose the given method to the DSL.
    #
    # @param [Symbol] name
    #
    def expose(name)
      unless public_method_defined?(name)
        raise NameError, "undefined method `#{name}' for class `#{self.name}'"
      end

      exposed_methods[name] = true
    end

    #
    # The list of exposed methods.
    #
    # @return [Hash]
    #
    def exposed_methods
      @exposed_methods ||= from_superclass(:exposed_methods, {}).dup
    end

    private

    #
    # The cleanroom instance for this class. This method is intentionally
    # NOT cached!
    #
    # @return [Class]
    #
    def cleanroom
      exposed = exposed_methods.keys
      parent = name || "Anonymous"

      Class.new(Object) do
        class << self
          def class_eval
            raise Cleanroom::InaccessibleError.new(:class_eval, self)
          end

          def instance_eval
            raise Cleanroom::InaccessibleError.new(:instance_eval, self)
          end
        end

        define_method(:initialize) do |instance|
          define_singleton_method(:__instance__) do
            unless caller[0].include?(__FILE__)
              raise Cleanroom::InaccessibleError.new(:__instance__, self)
            end

            instance
          end
        end

        exposed.each do |exposed_method|
          define_method(exposed_method) do |*args, **kwargs, &block|
            __instance__.public_send(exposed_method, *args, **kwargs, &block)
          end
        end

        define_method(:class_eval) do
          raise Cleanroom::InaccessibleError.new(:class_eval, self)
        end

        define_method(:inspect) do
          "#<#{parent} (Cleanroom)>"
        end
        alias_method :to_s, :inspect
      end
    end

    #
    # Get the value from the superclass, if it responds, otherwise return
    # +default+. Since class instance variables are **not** inherited upon
    # subclassing, this is a required check to ensure subclasses inherit
    # exposed DSL methods.
    #
    # @param [Symbol] m
    #   the name of the method to find
    # @param [Object] default
    #   the default value to return if not found
    #
    def from_superclass(m, default = nil)
      return default if superclass == Cleanroom

      superclass.respond_to?(m) ? superclass.send(m) : default
    end
  end

  #
  # Instance Methods
  #
  module InstanceMethods
    #
    # Evaluate the file against the current instance.
    #
    # @param (see Cleanroom.evaluate_file)
    # @return [self]
    #
    def evaluate_file(filepath)
      self.class.evaluate_file(self, filepath)
      self
    end

    #
    # Evaluate the contents against the current instance.
    #
    # @param (see Cleanroom.evaluate_file)
    # @return [self]
    #
    def evaluate(*args, &block)
      self.class.evaluate(self, *args, &block)
      self
    end
  end
end