lib/autotest.rb



require "find"
require "rbconfig"

require "path_expander"

##
# Autotest continuously scans the files in your project for changes
# and runs the appropriate tests.  Test failures are run until they
# have all passed. Then the full test suite is run to ensure that
# nothing else was inadvertantly broken.
#
# If you want Autotest to start over from the top, hit ^C once.  If
# you want Autotest to quit, hit ^C twice.
#
# Rails:
#
# The autotest command will automatically discover a Rails directory
# by looking for config/environment.rb. When Rails is discovered,
# autotest uses RailsAutotest to perform file mappings and other work.
# See RailsAutotest for details.
#
# Plugins:
#
# Plugins are available by creating a .autotest file either in your
# project root or in your home directory. You can then write event
# handlers in the form of:
#
#   Autotest.add_hook hook_name { |autotest| ... }
#
# The available hooks are listed in +ALL_HOOKS+.
#
# See example_dot_autotest.rb for more details.
#
# If a hook returns a true value, it signals to autotest that the hook
# was handled and should not continue executing hooks.
#
# Naming:
#
# Autotest uses a simple naming scheme to figure out how to map
# implementation files to test files following the Test::Unit naming
# scheme.
#
# * Test files must be stored in test/
# * Test files names must start with test_
# * Test class names must start with Test
# * Implementation files must be stored in lib/
# * Implementation files must match up with a test file named
#   test_.*<impl-name>.rb
#
# Strategy:
#
# 1. Find all files and associate them from impl <-> test.
# 2. Run all tests.
# 3. Scan for failures.
# 4. Detect changes in ANY (ruby?. file, rerun all failures + changed files.
# 5. Until 0 defects, goto 3.
# 6. When 0 defects, goto 2.

class Autotest

  TOPDIR = Dir.pwd + "/"

  T0 = Time.at 0

  ALL_HOOKS = [ :all_good, :died, :initialize, :post_initialize,
                :interrupt, :quit, :ran_command, :reset,
                :run_command, :updated, :waiting ]

  attr_accessor :options

  HOOKS = Hash.new { |h,k| h[k] = [] }

  WINDOZE = /mswin|mingw/ =~ RbConfig::CONFIG['host_os']
  SEP = WINDOZE ? '&' : ';'

  def self.parse_options args = ARGV
    require 'optparse'
    options = {
      :args => args.dup
    }

    OptionParser.new do |opts|
      opts.banner = <<-BANNER.gsub(/^        /, '')
        Continuous testing for your ruby app.

          Autotest automatically tests code that has changed. It assumes
          the code is in lib, and tests are in test/test_*.rb. Autotest
          uses plugins to control what happens. You configure plugins
          with require statements in the .autotest file in your
          project base directory, and a default configuration for all
          your projects in the .autotest file in your home directory.

        Usage:
            autotest [options]
      BANNER

      opts.on "-d", "--debug", "Debug mode, for reporting bugs." do
        require "pp"
        options[:debug] = true
      end

      opts.on "-f", "--focus", "Focus mode, only run named tests." do
        options[:focus] = true
      end

      opts.on "-v", "--verbose", "Be annoyingly verbose (debugs .autotest)." do
        options[:verbose] = true
      end

      opts.on "-q", "--quiet", "Be quiet." do
        options[:quiet] = true
      end

      opts.on("-r", "--rc CONF", String, "Override path to config file") do |o|
        options[:rc] = Array(o)
      end

      opts.on("-w", "--warnings", "Turn on ruby warnings") do
        $-w = true
      end

      opts.on "-h", "--help", "Show this." do
        puts opts
        exit 1
      end
    end.parse! args

    options
  end

  ##
  # Initialize and run the system.

  def self.run args = ARGV
    expander = PathExpander.new args, "**/*.rb"
    files = expander.process

    autotest = new parse_options args

    if autotest.options[:debug] then
      puts
      puts "options:"
      puts
      pp autotest.options
      puts "files:"
      puts
      pp files
      puts
    end

    autotest.extra_files = files
    autotest.run
  end

  attr_writer :known_files
  attr_accessor :extra_class_map
  attr_accessor :extra_files
  attr_accessor :failures
  attr_accessor :files_to_test # TODO: remove in favor of failures?
  attr_accessor :find_directories
  attr_accessor :find_order
  attr_accessor :interrupted
  attr_accessor :last_mtime
  attr_accessor :libs
  attr_accessor :output
  attr_accessor :prefix
  attr_accessor :sleep
  attr_accessor :tainted
  attr_accessor :test_mappings
  attr_accessor :testlib
  attr_accessor :test_prefix
  attr_accessor :wants_to_quit

  alias tainted? tainted

  ##
  # Initialize the instance and then load the user's .autotest file, if any.

  def initialize options
    # these two are set directly because they're wrapped with
    # add/remove/clear accessor methods
    @exception_list = []
    @child = nil

    self.options           = options
    self.extra_class_map   = {}
    self.extra_files       = []
    self.failures          = Hash.new { |h,k| h[k] = Hash.new { |h2,k2| h2[k2] = [] } }
    self.files_to_test     = new_hash_of_arrays
    reset_find_order
    self.libs              = %w[. lib test].join(File::PATH_SEPARATOR)
    self.output            = $stderr
    self.prefix            = nil
    self.sleep             = 1
    self.test_mappings     = []
    self.test_prefix       = "gem 'minitest'"
    self.testlib           = "minitest/autorun" # TODO: rename

    self.find_directories  = ['.']

    # file in /lib -> run test in /test
    self.add_mapping(/^lib\/.*\.rb$/) do |filename, _|
      possible = File.basename(filename).gsub '_', '_?'
      files_matching %r%^test/.*#{possible}$%
    end

    # file in /test -> run it (ruby & rails styles)
    self.add_mapping(/^test.*\/(test_.*|.*_test)\.rb$/) do |filename, _|
      filename
    end

    default_configs = [File.expand_path('~/.autotest'), './.autotest']
    configs = options[:rc] || default_configs

    configs.each do |f|
      load f if File.exist? f
    end
  end

  def debug
    find_files_to_test

    puts "Known test files:"
    puts
    pp files_to_test.keys.sort

    class_map = self.class_map

    puts
    puts "Known class map:"
    puts
    pp class_map
  end

  def class_map
    class_map = Hash[*self.find_order.grep(/^test/).map { |f| # TODO: ugly
                       [path_to_classname(f), f]
                     }.flatten]
    class_map.merge! self.extra_class_map
    class_map
  end

  ##
  # Repeatedly run failed tests, then all tests, then wait for changes
  # and carry on until killed.

  def run
    hook :initialize
    hook :post_initialize

    require "minitest/server"
    Minitest::Server.run self

    reset
    add_sigint_handler

    self.last_mtime = Time.now if options[:no_full_after_start]

    self.debug if options[:debug]

    loop do
      begin # ^c handler
        get_to_green
        if tainted? and not options[:no_full_after_failed] then
          rerun_all_tests
        else
          hook :all_good
        end
        wait_for_changes
      rescue Interrupt
        break if wants_to_quit
        reset
      end
    end
    hook :quit
    puts
  rescue Exception => err
    hook(:died, err) or (
      warn "Unhandled exception: #{err}"
      warn err.backtrace.join("\n  ")
      warn "Quitting"
    )
  ensure
    Minitest::Server.stop
  end

  ##
  # Keep running the tests after a change, until all pass.

  def get_to_green
    begin
      run_tests
      wait_for_changes unless all_good
    end until all_good
  end

  ##
  # Look for files to test then run the tests and handle the results.

  def run_tests
    new_mtime = self.find_files_to_test
    return unless new_mtime
    self.last_mtime = new_mtime

    cmd = self.make_test_cmd self.files_to_test
    return if cmd.empty?

    hook :run_command, cmd

    puts cmd unless options[:quiet]

    system cmd

    hook :ran_command
  end

  ############################################################
  # Utility Methods, not essential to reading of logic

  ##
  # Installs a sigint handler.

  def add_sigint_handler
    trap 'INT' do
      Process.kill "KILL", @child if @child

      if self.interrupted then
        self.wants_to_quit = true
      else
        unless hook :interrupt then
          puts "Interrupt a second time to quit"
          self.interrupted = true
          Kernel.sleep 1.5
        end
        raise Interrupt, nil # let the run loop catch it
      end
    end
  end

  ##
  # Installs a sigquit handler

  def add_sigquit_handler
    trap 'QUIT' do
      restart
    end
  end

  def restart
    Minitest::Server.stop

    Process.kill "KILL", @child if @child

    cmd = [$0, *options[:args]]

    index = $LOAD_PATH.index RbConfig::CONFIG["sitelibdir"]

    if index then
      extra = $LOAD_PATH[0...index]
      cmd = [Gem.ruby, "-I", extra.join(":")] + cmd
    end

    puts cmd.join(" ") if options[:verbose]

    exec(*cmd)
  end

  ##
  # If there are no files left to test (because they've all passed),
  # then all is good.

  def all_good
    failures.empty?
  end

  ##
  # Convert a path in a string, s, into a class name, changing
  # underscores to CamelCase, etc.

  def path_to_classname s
    sep = File::SEPARATOR
    f = s.sub(/^test#{sep}/, '').sub(/\.rb$/, '').split sep
    f = f.map { |path| path.split(/_|(\d+)/).map { |seg| seg.capitalize }.join }
    f = f.map { |path| path =~ /^Test/ ? path : "Test#{path}"  }

    f.join '::'
  end

  ##
  # Find the files to process, ignoring temporary files, source
  # configuration management files, etc., and return a Hash mapping
  # filename to modification time.

  def find_files
    result = {}

    targets = if options[:focus] then
                self.extra_files
              else
                self.find_directories + self.extra_files
              end

    reset_find_order

    targets.each do |target|
      order = []
      Find.find target do |f|
        Find.prune if f =~ self.exceptions
        Find.prune if f =~ /^\.\/tmp/    # temp dir, used by isolate

        next unless File.file? f
        next if f =~ /(swp|~|rej|orig)$/ # temporary/patch files
        next if f =~ /(,v)$/             # RCS files
        next if f =~ /\/\.?#/            # Emacs autosave/cvs merge files

        filename = f.sub(/^\.\//, '')

        result[filename] = File.stat(filename).mtime rescue next
        order << filename
      end

      self.find_order.push(*order.sort)
    end

    result
  end

  ##
  # Find the files which have been modified, update the recorded
  # timestamps, and use this to update the files to test. Returns
  # the latest mtime of the files modified or nil when nothing was
  # modified.

  def find_files_to_test files = find_files
    updated = files.select { |filename, mtime| self.last_mtime < mtime }

    # nothing to update or initially run
    unless updated.empty? || self.last_mtime.to_i == 0 then
      p updated if options[:verbose]

      hook :updated, updated
    end

    updated.map { |f,m| test_files_for f }.flatten.uniq.each do |filename|
      self.failures[filename] # creates key with default value
      self.files_to_test[filename] # creates key with default value
    end

    if updated.empty? then
      nil
    else
      files.values.max
    end
  end

  ##
  # Lazy accessor for the known_files hash.

  def known_files
    unless @known_files then
      @known_files = Hash[*find_order.map { |f| [f, true] }.flatten]
    end
    @known_files
  end

  ##
  # Generate the commands to test the supplied files

  def make_test_cmd files_to_test
    if options[:debug] then
      puts "Files to test:"
      puts
      pp files_to_test
      puts
    end

    cmds = []
    full, partial = reorder(failures).partition { |k,v| v.empty? }

    unless full.empty? then
      classes = full.map {|k,v| k}.flatten.uniq
      classes.unshift testlib
      classes = classes.join " "
      cmds << "#{ruby_cmd} -e \"#{test_prefix}; %w[#{classes}].each { |f| require f }\" -- --server #{$$}"
    end

    unless partial.empty? then
      files = partial.map(&:first).sort # no longer a hash because of partition
      files.select! { |path| File.file? path } # filter out (eval) and the like
      re = []

      partial.each do |path, klasses|
        klasses.each do |klass,methods|
          re << /#{klass}##{Regexp.union(methods)}/
        end
      end

      loader = "%w[#{files.join " "}].each do |f| load f; end"
      re = Regexp.union(re).to_s.gsub(/-mix/, "").gsub(/'/, ".")

      cmds << "#{ruby_cmd} -e '#{loader}' -- --server #{$$} -n '/#{re}/'"
    end

    cmds.join "#{SEP} "
  end

  def new_hash_of_arrays
    Hash.new { |h,k| h[k] = [] }
  end

  def reorder files_to_test
    max = files_to_test.size
    files_to_test.sort_by { |k,v| rand max }
  end

  ##
  # Rerun the tests from cold (reset state)

  def rerun_all_tests
    reset
    run_tests

    hook :all_good if all_good
  end

  ##
  # Clear all state information about test failures and whether
  # interrupts will kill autotest.

  def reset
    self.files_to_test.clear
    reset_find_order
    self.failures.clear

    self.interrupted   = false
    self.last_mtime    = T0
    self.tainted       = false
    self.wants_to_quit = false

    hook :reset
  end

  def reset_find_order
    self.find_order = []
    self.known_files = nil
  end

  ##
  # Determine and return the path of the ruby executable.

  def ruby
    ruby = ENV['RUBY']
    ruby ||= File.join(RbConfig::CONFIG['bindir'],
                       RbConfig::CONFIG['ruby_install_name'])

    ruby.gsub! File::SEPARATOR, File::ALT_SEPARATOR if File::ALT_SEPARATOR

    return ruby
  end

  ##
  # Returns the base of the ruby command.

  def ruby_cmd
    "#{prefix}#{ruby} -I#{libs}"
  end

  ##
  # Return the name of the file with the tests for filename by finding
  # a +test_mapping+ that matches the file and executing the mapping's
  # proc.

  def test_files_for filename
    result = []

    self.test_mappings.each do |file_re, proc|
      if filename =~ file_re then
        result = [proc.call(filename, $~)].
          flatten.sort.uniq.select { |f| known_files[f] }
        break unless result.empty?
      end
    end

    p :test_file_for => [filename, result.first] if result and options[:debug]

    output.puts "No tests matched #{filename}" if
      options[:verbose] and result.empty?

    return result
  end

  ##
  # Sleep then look for files to test, until there are some.

  def wait_for_changes
    hook :waiting
    Kernel.sleep self.sleep until find_files_to_test
  end

  ############################################################
  # File Mappings:

  ##
  # Returns all known files in the codebase matching +regexp+.

  def files_matching regexp
    self.find_order.select { |k| k =~ regexp }
  end

  ##
  # Adds a file mapping, optionally prepending the mapping to the
  # front of the list if +prepend+ is true. +regexp+ should match a
  # file path in the codebase. +proc+ is passed a matched filename and
  # Regexp.last_match. +proc+ should return an array of tests to run.
  #
  # For example, if test_helper.rb is modified, rerun all tests:
  #
  #   at.add_mapping(/test_helper.rb/) do |f, _|
  #     at.files_matching(/^test.*rb$/)
  #   end

  def add_mapping regexp, prepend = false, &proc
    if prepend then
      @test_mappings.unshift [regexp, proc]
    else
      @test_mappings.push [regexp, proc]
    end
    nil
  end

  ##
  # Removed a file mapping matching +regexp+.

  def remove_mapping regexp
    @test_mappings.delete_if do |k,v|
      k == regexp
    end
    nil
  end

  ##
  # Clears all file mappings. This is DANGEROUS as it entirely
  # disables autotest. You must add at least one file mapping that
  # does a good job of rerunning appropriate tests.

  def clear_mappings
    @test_mappings.clear
    nil
  end

  ############################################################
  # Exceptions:

  ##
  # Adds +regexp+ to the list of exceptions for find_file. This must
  # be called _before_ the exceptions are compiled.

  def add_exception regexp
    raise "exceptions already compiled" if defined? @exceptions

    @exception_list << regexp
    nil
  end

  ##
  # Removes +regexp+ to the list of exceptions for find_file. This
  # must be called _before_ the exceptions are compiled.

  def remove_exception regexp
    raise "exceptions already compiled" if defined? @exceptions
    @exception_list.delete regexp
    nil
  end

  ##
  # Clears the list of exceptions for find_file. This must be called
  # _before_ the exceptions are compiled.

  def clear_exceptions
    raise "exceptions already compiled" if defined? @exceptions
    @exception_list.clear
    nil
  end

  ##
  # Return a compiled regexp of exceptions for find_files or nil if no
  # filtering should take place. This regexp is generated from
  # +exception_list+.

  def exceptions
    unless defined? @exceptions then
      @exceptions = if @exception_list.empty? then
                      nil
                    else
                      Regexp.union(*@exception_list)
                    end
    end

    @exceptions
  end

  ############################################################
  # Hooks:

  ##
  # Call the event hook named +name+, passing in optional args
  # depending on the hook itself.
  #
  # Returns false if no hook handled the event.
  #
  # === Hook Writers!
  #
  # This executes all registered hooks <em>until one returns truthy</em>.
  # Pay attention to the return value of your block!

  def hook name, *args
    deprecated = {
      # none currently
    }

    if deprecated[name] and not HOOKS[name].empty? then
      warn "hook #{name} has been deprecated, use #{deprecated[name]}"
    end

    HOOKS[name].any? { |plugin| plugin[self, *args] }
  end

  ##
  # Add the supplied block to the available hooks, with the given
  # name.

  def self.add_hook name, &block
    HOOKS[name] << block
  end

  ############################################################
  # Server Methods:

  def minitest_start
    self.failures.clear
  end

  def minitest_result file, klass, method, fails, assertions, time
    fails.reject! { |fail| Minitest::Skip === fail }

    unless fails.empty?
      self.tainted = true
      self.failures[file][klass] << method
    end
  end
end