class Autotest

def self.add_hook name, &block

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

def self.parse_options args = ARGV

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

def self.run args = ARGV

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

def add_exception regexp

def add_exception regexp
  raise "exceptions already compiled" if defined? @exceptions
  @exception_list << regexp
  nil
end

def add_mapping regexp, prepend = false, &proc

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

def add_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

def add_sigquit_handler

def add_sigquit_handler
  trap 'QUIT' do
    restart
  end
end

def all_good

def all_good
  failures.empty?
end

def class_map

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

def clear_exceptions

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

def clear_mappings

def clear_mappings
  @test_mappings.clear
  nil
end

def debug

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 exceptions

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

def files_matching regexp

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

def find_files

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

def find_files_to_test files = find_files

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

def get_to_green

def get_to_green
  begin
    run_tests
    wait_for_changes unless all_good
  end until all_good
end

def hook name, *args

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

def initialize options

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 known_files

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

def make_test_cmd files_to_test

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 minitest_result file, klass, method, fails, assertions, time

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

def minitest_start

def minitest_start
  self.failures.clear
end

def new_hash_of_arrays

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

def path_to_classname s

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

def path_to_classname s

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

def remove_exception regexp

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

def remove_mapping regexp

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

def reorder files_to_test

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

def rerun_all_tests

def rerun_all_tests
  reset
  run_tests
  hook :all_good if all_good
end

def reset

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

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

def restart

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

def ruby

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

def ruby_cmd

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

def run

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

def run_tests

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

def run_tests

def run_tests
  hook :run_command
  new_mtime = self.find_files_to_test
  return unless new_mtime
  self.last_mtime = new_mtime
  begin
    # TODO: deal with unit_diff and partial test runs later
    original_argv = ARGV.dup
    ARGV.clear
    @child = fork do
      trap "QUIT", "DEFAULT"
      trap "INT", "DEFAULT"
      files_to_test.keys.each do |file|
        load file
      end
    end
    Process.wait
  ensure
    @child = nil
    ARGV.replace original_argv
  end
  hook :ran_command
end

def test_files_for filename

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

def wait_for_changes

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