lib/gem_hadar.rb



require 'rubygems'
require 'rbconfig'
if defined?(::RbConfig)
  include ::RbConfig
else
  include ::Config
end
require 'rake'
require 'net/http'
require 'uri'
require 'tins/xt'
require 'tins/secure_write'
require 'rake/clean'
require 'rake/testtask'
require 'dslkit/polite'
require 'set'
require 'pathname'
require 'erb'
require 'gem_hadar/version'
require 'term/ansicolor'
require_maybe 'yard'
require_maybe 'simplecov'
require_maybe 'rubygems/package_task'
require_maybe 'rcov/rcovtask'
require_maybe 'rspec/core/rake_task'

def GemHadar(&block)
  GemHadar.new(&block).create_all_tasks
end

class GemHadar
  include Term::ANSIColor

  if defined?(::RbConfig)
    include ::RbConfig
  else
    include ::Config
  end
  include Rake::DSL
  extend DSLKit::DSLAccessor
  include Tins::SecureWrite

  def initialize(&block)
    @dependencies = []
    @development_dependencies = []
    block and instance_eval(&block)
  end

  def has_to_be_set(name)
    fail "#{self.class}: #{name} has to be set for gem"
  end

  def assert_valid_link(name, orig_url)
    developing and return orig_url
    url = orig_url
    begin
      response = Net::HTTP.get_response(URI.parse(url))
      url = response['location']
    end while response.is_a?(Net::HTTPRedirection)
    response.is_a?(Net::HTTPOK) or
      fail "#{orig_url.inspect} for #{name} has to be a valid link"
    orig_url
  end

  dsl_accessor :developing, false

  dsl_accessor :name do
    has_to_be_set :name
  end

  dsl_accessor :name_version do
    [ name, version ] * '-'
  end

  dsl_accessor :module_type, :module

  dsl_accessor :author do
    has_to_be_set :author
  end

  dsl_accessor :email do
    has_to_be_set :email
  end

  dsl_accessor :homepage do
    has_to_be_set :homepage
  end

  dsl_accessor :summary do
    has_to_be_set :summary
  end

  dsl_accessor :description do
    has_to_be_set :description
  end

  dsl_accessor :require_paths do Set['lib'] end

  def require_path(path = nil)
    if path
      self.require_paths = Set[path]
    else
      require_paths.first
    end
  end

  dsl_accessor :readme

  dsl_accessor :title

  dsl_accessor :ignore_files do Set[] end

  dsl_accessor :test_dir

  dsl_accessor :bindir

  dsl_accessor :executables do Set[] end

  dsl_accessor :licenses do Set[] end

  dsl_accessor :test_files do
    if test_dir
      FileList[File.join(test_dir, '**/*.rb')]
    else
      FileList.new
    end
  end

  dsl_accessor :spec_dir

  dsl_accessor :spec_pattern do
    if spec_dir
      "#{spec_dir}{,/*/**}/*_spec.rb"
    else
      'spec{,/*/**}/*_spec.rb'
    end
  end

  dsl_accessor :doc_files do
    FileList[File.join('lib/**/*.rb')] + FileList[File.join('ext/**/*.c')]
  end

  dsl_accessor :yard_dir do
    'yard'
  end

  dsl_accessor :extensions do FileList['ext/**/extconf.rb'] end

  dsl_accessor :make do
    ENV['MAKE'] || %w[gmake make].find { |c| system(c, '-v') }
  end

  dsl_accessor :files do
    `git ls-files`.split("\n")
  end

  dsl_accessor :package_ignore_files do
    Set[]
  end

  dsl_accessor :path_name do name end

  dsl_accessor :path_module do path_name.camelize end

  dsl_accessor :version do
    begin
      File.read('VERSION').chomp
    rescue Errno::ENOENT
      has_to_be_set :version
    end
  end

  dsl_accessor :version_epilogue

  dsl_accessor :post_install_message

  dsl_accessor :required_ruby_version

  class RvmConfig
    extend DSLKit::DSLAccessor
    include DSLKit::BlockSelf

    def initialize(&block)
      @outer_scope = block_self(&block)
      instance_eval(&block)
    end

    dsl_accessor :use do `rvm tools strings`.split(/\n/).full?(:last) || 'ruby' end

    dsl_accessor :gemset do @outer_scope.name end
  end

  def rvm(&block)
    if block
      @rvm = RvmConfig.new(&block)
    elsif !@rvm
      @rvm = RvmConfig.new { }
    end
    @rvm
  end

  dsl_accessor :default_task_dependencies, [ :gemspec, :test ]

  def default_task
    desc 'Default task'
    task :default => default_task_dependencies
  end

  dsl_accessor :build_task_dependencies, [ :clobber, :gemspec, :package, :'version:tag' ]

  def build_task
    desc 'Build task (builds all packages for a release)'
    task :build => build_task_dependencies
  end

  def install_library(&block)
    @install_library_block = lambda do
      desc 'Install executable/library into site_ruby directories'
      task :install => :prepare_install, &block
    end
  end

  def clean(*args)
    if args.empty?
      CLEAN
    else
      CLEAN.include(*args)
    end
  end

  def clobber(*args)
    if args.empty?
      CLOBBER
    else
      CLOBBER.include(*args)
    end
  end

  def ignore(*args)
    if args.empty?
      ignore_files
    else
      args.each { |a| ignore_files << a }
    end
  end

  def package_ignore(*args)
    if args.empty?
      package_ignore_files
    else
      args.each { |a| package_ignore_files << a }
    end
  end

  def dependency(*args)
    @dependencies << args
  end

  def development_dependency(*args)
    @development_dependencies << args
  end

  def gems_install_task(&block)
    block ||= proc {  sh 'bundle install' }
    desc 'Install all gems from the Gemfile'
    namespace :gems do
      task :install => :gemspec , &block
    end
  end

  def gem_files
    (files.to_a - package_ignore_files.to_a)
  end

  def gemspec
    Gem::Specification.new do |s|
      s.name        = name
      s.version     = ::Gem::Version.new(version)
      s.author      = author
      s.email       = email
      s.homepage    = assert_valid_link(:homepage, homepage)
      s.summary     = summary
      s.description = description

      gem_files.full? { |f| s.files = Array(f) }
      test_files.full? { |t| s.test_files = Array(t) }
      extensions.full? { |e| s.extensions = Array(e) }
      bindir.full? { |b| s.bindir = b }
      executables.full? { |e| s.executables = Array(e) }
      licenses.full? { |l| s.licenses = Array(licenses) }
      post_install_message.full? { |m| s.post_install_message = m }

      required_ruby_version.full? { |v| s.required_ruby_version = v }
      s.add_development_dependency('gem_hadar', "~> #{VERSION[/\A\d+\.\d+/, 0]}")
      for d in @development_dependencies
        s.add_development_dependency(*d)
      end
      for d in @dependencies
        if s.respond_to?(:add_runtime_dependency)
          s.add_runtime_dependency(*d)
        else
          s.add_dependency(*d)
        end
      end

      require_paths.full? { |r| s.require_paths = Array(r) }

      if title
        s.rdoc_options << '--title' << title
      else
        s.rdoc_options << '--title' << "#{name.camelize} - #{summary}"
      end
      if readme
        s.rdoc_options << '--main' << readme
        s.extra_rdoc_files << readme
      end
      doc_files.full? { |df| s.extra_rdoc_files.concat Array(df) }
    end
  end

  def version_task
    desc m = "Writing version information for #{name}-#{version}"
    task :version do
      puts m
      mkdir_p dir = File.join('lib', path_name)
      secure_write(File.join(dir, 'version.rb')) do |v|
        v.puts <<EOT
#{module_type} #{path_module}
  # #{path_module} version
  VERSION         = '#{version}'
  VERSION_ARRAY   = VERSION.split('.').map(&:to_i) # :nodoc:
  VERSION_MAJOR   = VERSION_ARRAY[0] # :nodoc:
  VERSION_MINOR   = VERSION_ARRAY[1] # :nodoc:
  VERSION_BUILD   = VERSION_ARRAY[2] # :nodoc:
end
EOT
        version_epilogue.full? { |ve| v.puts ve }
      end
    end
  end

  def version_show_task
    namespace :version do
      desc "Displaying the current version"
      task :show do
        require path_name
        dir = File.join('lib', path_name)
        version_file = File.join(dir, 'version.rb')
        m = Module.new
        m.instance_eval File.read(version_file)
        version_rb   = m.const_get(
          [ path_module, 'VERSION' ] * '::'
        )
        equal        = version == version_rb ? '==' : '!='
        puts "version.rb=#{version_rb} #{equal} VERSION=#{version}"
      end
    end
  end

  def versions
    `git tag`.lines.grep(/^v?\d+\.\d+\.\d+$/).map(&:chomp).map {
      _1.sub(/\Av/, '')
    }.sort_by(&:version)
  end

  def version_diff_task
    namespace :version do
      desc "List all versions in order"
      task :list do
        system 'git fetch --tags'
        $?.success? or exit $?.exitstatus
        puts versions
      end

      desc "Displaying the diff from env var VERSION to the next version or HEAD"
      task :diff do
        arg_version = ENV.fetch('VERSION', version).dup.prepend(?v)
        my_versions = versions.map { _1.prepend(?v) } + %w[ HEAD ]
        start_version, end_version = my_versions[my_versions.index(arg_version), 2]
        puts color(172) {"Showing diff from version %s to %s:" % [ start_version, end_version ]}
        puts `git diff --color=always #{start_version}..#{end_version}`
      end
    end
  end

  def gem_hadar_update_task
    namespace :gem_hadar do
      desc 'Update gem_hadar a different version'
      task :update do
        answer = ask?("Which gem_hadar version? ", /^((?:\d+.){2}(?:\d+))$/)
        unless answer
          warn "Invalid version specification!"
          exit 1
        end
        gem_hadar_version = answer[0]
        filename = "#{name}.gemspec"
        old_data = File.read(filename)
        new_data = old_data.gsub(
          /(add_(?:development_)?dependency\(%q<gem_hadar>, \["~> )([\d.]+)("\])/
        ) { "#$1#{gem_hadar_version}#$3" }
        if old_data == new_data
          warn "#{filename.inspect} already depends on gem_hadar "\
            "#{gem_hadar_version} => Do nothing."
        else
          warn "Upgrading #{filename.inspect} to #{gem_hadar_version}."
          secure_write(filename) do |spec|
            spec.write new_data
          end
        end
      end
    end
  end

  def gemspec_task
    desc 'Create a gemspec file'
    task :gemspec => :version do
      filename = "#{name}.gemspec"
      warn "Writing to #{filename.inspect} for #{version}"
      secure_write(filename, gemspec.to_ruby)
    end
  end

  def package_task
    clean 'pkg'
    Gem::PackageTask.new(gemspec) do |pkg|
      pkg.need_tar      = true
      pkg.package_files += gem_files
    end
  end

  def install_library_task
    @install_library_block.full?(:call)
  end

  def doc_task
    clean 'doc'
    desc "Creating documentation"
    task :doc do
      sh 'yard doc'
      cmd = 'yardoc'
      if readme
        cmd << " --readme '#{readme}'"
      end
      cmd << ' - ' << doc_files * ' '
      sh cmd
    end
  end

  def test_task
    tt =  Rake::TestTask.new(:run_tests) do |t|
      t.libs << test_dir
      t.libs.concat require_paths.to_a
      t.test_files = test_files
      t.verbose    = true
    end
    desc 'Run the tests'
    task :test => [ (:compile if extensions.full?), tt.name ].compact
  end

  def spec_task
    defined? ::RSpec::Core::RakeTask or return
    st =  RSpec::Core::RakeTask.new(:run_specs) do |t|
      t.ruby_opts ||= ''
      t.ruby_opts << ' -I' << ([ spec_dir ] + require_paths.to_a).uniq * ':'
      t.pattern = spec_pattern
      t.verbose = true
    end
    task :spec => [ (:compile if extensions.full?), st.name ].compact
  end

  def rcov_task
    if defined?(::Rcov)
      rt = ::Rcov::RcovTask.new(:run_rcov) do |t|
        t.libs << test_dir
        t.libs.concat require_paths.to_a
        t.libs.uniq!
        t.test_files = test_files
        t.verbose    = true
        t.rcov_opts  = %W[-x '\\b#{test_dir}\/' -x '\\bgems\/']
      end
      desc 'Run the rcov code coverage tests'
      task :rcov => [ (:compile if extensions.full?), rt.name ].compact
      clobber 'coverage'
    else
      desc 'Run the rcov code coverage tests'
      task :rcov do
        warn "rcov doesn't work for some reason, have you tried 'gem install rcov'?"
      end
    end
  end

  def self.start_simplecov
    defined? SimpleCov or return
    filter = "#{File.basename(File.dirname(caller.first))}/"
    SimpleCov.start do
      add_filter filter
    end
  end

  def write_ignore_file
    secure_write('.gitignore') do |output|
      output.puts(ignore.sort)
    end
  end

  def write_gemfile
     default_gemfile =<<EOT
# vim: set filetype=ruby et sw=2 ts=2:

source 'https://rubygems.org'

gemspec
EOT
    current_gemfile = File.exist?('Gemfile') && File.read('Gemfile')
    case current_gemfile
    when false
      secure_write('Gemfile') do |output|
        output.write default_gemfile
      end
    when default_gemfile
      ;;
    else
      warn "INFO: Current Gemfile differs from default Gemfile."
    end
  end

  def version_bump_task
    namespace :version do
      namespace :bump do
        desc 'Bump major version'
        task :major do
          version = File.read('VERSION').chomp.version
          version.bump(:major)
          secure_write('VERSION') { |v| v.puts version }
        end

        desc 'Bump minor version'
        task :minor do
          version = File.read('VERSION').chomp.version
          version.bump(:minor)
          secure_write('VERSION') { |v| v.puts version }
        end

        desc 'Bump build version'
        task :build do
          version = File.read('VERSION').chomp.version
          version.bump(:build)
          secure_write('VERSION') { |v| v.puts version }
        end
      end
    end
  end

  def version_tag_task
    namespace :version do
      desc "Tag this commit as version #{version}"
      task :tag do
        force = ENV['FORCE'].to_i == 1
        begin
          sh "git tag -a -m 'Version #{version}' #{'-f' if force} v#{version}"
        rescue RuntimeError
          if `git diff v#{version}..HEAD`.empty?
            puts "Version #{version} is already tagged, but it's no different"
          else
            if ask?("Different version tag #{version} already exists. Overwrite with "\
                "force? (yes/NO) ", /\Ayes\z/i)
              force = true
              retry
            else
              exit 1
            end
          end
        end
      end
    end
  end

  def git_remote
    ENV.fetch('GIT_REMOTE', 'origin').split(/\s+/).first
  end

  def git_remotes
    remotes = ENV['GIT_REMOTE'].full?(:split, /\s+/)
    remotes or remotes = `git remote`.lines.map(&:chomp)
    remotes
  end

  def master_prepare_task
    namespace :master do
      desc "Prepare a remote git repository for this project"
      task :prepare do
        puts "Create a new remote git repository for #{name.inspect}"
        remote_name = ask?('Name (default: origin) ? ', /^.+$/).
          full?(:[], 0) || 'origin'
        dir         = ask?("Directory (default: /git/#{name}.git)? ", /^.+$/).
          full?(:[], 0) || "/git/#{name}.git"
        ssh_account = ask?('SSH account (format: login@host)? ', /^[^@]+@[^@]+/).
          full?(:[], 0) || exit(1)
        sh "ssh #{ssh_account} 'git init --bare #{dir}'"
        sh "git remote add -m master #{remote_name} #{ssh_account}:#{dir}"
      end
    end
  end

  def version_push_task
    namespace :version do
      git_remotes.each do |gr|
        namespace gr.to_sym do
          desc "Push version #{version} to git remote #{gr}"
          task :push do
            sh "git push #{gr} v#{version}"
          end
        end
      end

      desc "Push version #{version} to all git remotes: #{git_remotes * ' '}"
      task :push => git_remotes.map { |gr| :"version:#{gr}:push" }
    end
  end

  def master_push_task
    namespace :master do
      git_remotes.each do |gr|
        namespace gr.to_sym do
          desc "Push master to git remote #{gr}"
          task :push do
            sh "git push #{gr} master"
          end
        end
      end

      desc "Push master #{version} to all git remotes: #{git_remotes * ' '}"
      task :push => git_remotes.map { |gr| :"master:#{gr}:push" }
    end
  end

  def gem_push_task
    namespace :gem do
      path = "pkg/#{name_version}.gem"
      desc "Push gem file #{File.basename(path)} to rubygems"
      if developing
        task :push => :build do
          puts "Skipping push to rubygems while developing mode is enabled."
        end
      else
        task :push => :build do
          if File.exist?(path)
            if ask?("Do you really want to push #{path.inspect} to rubygems? "\
                "(yes/NO) ", /\Ayes\z/i)
              then
              key = ENV['GEM_HOST_API_KEY'].full? { |k| "--key #{k} " }
              sh "gem push #{key}#{path}"
            else
              exit 1
            end
          else
            warn "Cannot push gem to rubygems: #{path.inspect} doesn't exist."
            exit 1
          end
        end
      end
    end
  end

  def git_remotes_task
    task :git_remotes do
      puts git_remotes.map { |r|
        url = `git remote get-url #{r.inspect}`.chomp
        "#{r} #{url}"
      }
    end
  end

  def push_task
    master_prepare_task
    version_push_task
    master_push_task
    gem_push_task
    git_remotes_task
    task :modified do
      changed_files = `git status --porcelain`.gsub(/^\s*\S\s+/, '').lines
      unless changed_files.empty?
        warn "There are still modified files:\n#{changed_files * ''}"
        exit 1
      end
    end
    desc "Push master and version #{version} all git remotes: #{git_remotes * ' '}"
    task :push => %i[ modified build master:push version:push gem:push ]
  end

  def compile_task
    for file in extensions
      dir = File.dirname(file)
      clean File.join(dir, 'Makefile'), File.join(dir, '*.{bundle,o,so}')
    end
    desc "Compile extensions: #{extensions * ', '}"
    task :compile do
      for file in extensions
        dir, file = File.split(file)
        cd dir do
          ruby file
          sh make
        end
      end
    end
  end

  def rvm_task
    desc 'Create .rvmrc file'
    task :rvm do
      secure_write('.rvmrc') do |output|
        output.write <<EOT
rvm use #{rvm.use}
rvm gemset create #{rvm.gemset}
rvm gemset use #{rvm.gemset}
EOT
      end
    end
  end

  def yard_task
    defined? YARD or return
    namespace :yard do
      my_yard_dir = Pathname.new(yard_dir)

      desc 'Create yard documentation (including private)'
      task :private do
        sh "yardoc -o #{my_yard_dir}"
      end

      desc 'View the yard documentation'
      task :view do
        index_file = my_yard_dir + 'index.html'
        File.exist?(index_file)
        sh "open #{index_file}"
      end

      desc 'Clean the yard documentation'
      task :clean do
        rm_rf my_yard_dir
      end

      desc 'List all undocumented classes/modules/methods'
      task :'list-undoc' do
        sh "yard stats --list-undoc"
      end
    end

    desc 'Create the yard documentation and view it'
    task :yard => %i[ yard:private yard:view ]
  end

  def ask?(prompt, pattern)
    STDOUT.print prompt
    answer = STDIN.gets.chomp
    if answer =~ pattern
      $~
    end
  end

  def create_all_tasks
    default_task
    build_task
    rvm_task
    version_task
    version_show_task
    version_diff_task
    gem_hadar_update_task
    gemspec_task
    gems_install_task
    doc_task
    if test_dir
      test_task
      rcov_task
    end
    spec_task
    package_task
    yard_task
    install_library_task
    version_bump_task
    version_tag_task
    push_task
    write_ignore_file
    write_gemfile
    if extensions.full?
      compile_task
      task :prepare_install => :compile
    else
      task :prepare_install
    end
    self
  end

  class TemplateCompiler
    include Tins::BlockSelf
    include Tins::MethodMissingDelegator::DelegatorModule

    def initialize(&block)
      super block_self(&block)
      @values = {}
      instance_eval(&block)
    end

    def compile(src, dst)
      template = File.read(src)
      File.open(dst, 'w') do |output|
        erb = ERB.new(template, nil, '-')
        erb.filename = src.to_s
        output.write erb.result binding
      end
    end

    def method_missing(id, *a, &b)
      if a.empty? && id && @values.key?(id)
        @values[id]
      elsif a.size == 1
        @values[id] = a.first
      else
        super
      end
    end
  end

  class Setup
    include FileUtils

    def perform
      mkdir_p 'lib'
      unless File.exist?('VERSION')
        File.open('VERSION', 'w') do |output|
          output.puts '0.0.0'
        end
      end
      unless File.exist?('Rakefile')
        File.open('Rakefile', 'w') do |output|
          output.puts <<~EOT
            # vim: set filetype=ruby et sw=2 ts=2:

            require 'gem_hadar'

            GemHadar do
              #developing true
              #name       'TODO'
              module_type :class
              #author     'TODO'
              #email      'todo@example.com'
              #homepage   "https://github.com/TODO/NAME"
              #summary    'TODO'
              description 'TODO'
              test_dir    'spec'
              ignore      '.*.sw[pon]', 'pkg', 'Gemfile.lock', '.AppleDouble', '.bundle', '.yardoc', 'tags'
              readme      'README.md'

              #executables << 'bin/TODO'

              #dependency  'TODO', '~>1.2.3'

              #licenses << 'TODO
            end
          EOT
        end
      end
    end
  end
end

def template(pathname, &block)
  template_src = Pathname.new(pathname)
  template_dst = template_src.sub_ext('') # ignore ext, we just support erb anyway
  template_src == template_dst and raise ArgumentError,
    "pathname #{pathname.inspect} needs to have a file extension"
  file template_dst.to_s => template_src.to_s do
    GemHadar::TemplateCompiler.new(&block).compile(template_src, template_dst)
  end
  template_dst
end