lib/yard/cli/diff.rb



# frozen_string_literal: true
require 'tmpdir'
require 'fileutils'
require 'open-uri'

module YARD
  module CLI
    # CLI command to return the objects that were added/removed from 2 versions
    # of a project (library, gem, working copy).
    # @since 0.6.0
    class Diff < Command
      def initialize
        super
        @list_all = false
        @use_git = false
        @compact = false
        @modified = true
        @verifier = Verifier.new
        @old_git_commit = nil
        @old_path = Dir.pwd
        log.show_backtraces = true
      end

      def description
        'Returns the object diff of two gems or .yardoc files'
      end

      def run(*args)
        registry = optparse(*args).map do |gemfile|
          if @use_git
            load_git_commit(gemfile)
            all_objects
          elsif load_gem_data(gemfile)
            log.info "Found #{gemfile}"
            all_objects
          else
            log.error "Cannot find gem #{gemfile}"
            nil
          end
        end.compact

        return if registry.size != 2

        first_object = nil
        [["Added objects", "A", added_objects(*registry)],
            ["Modified objects", "M", modified_objects(*registry)],
            ["Removed objects", "D", removed_objects(*registry)]].each do |name, short, objects|
          next if short == "M" && @modified == false
          next if objects.empty?
          last_object = nil
          all_objects_notice = false
          log.puts name + ":" unless @compact
          objects.sort_by(&:path).each do |object|
            if !@list_all && last_object && object.parent == last_object
              log.print " (...)" unless all_objects_notice
              all_objects_notice = true
              next
            elsif @compact
              log.puts if first_object
            else
              log.puts
            end
            all_objects_notice = false
            log.print "" + (@compact ? "#{short} " : "  ") +
                      object.path + " (#{object.file}:#{object.line})"
            last_object = object
            first_object = true
          end
          unless @compact
            log.puts; log.puts
          end
        end
        log.puts if @compact
      end

      private

      def all_objects
        return Registry.all if @verifier.expressions.empty?
        @verifier.run(Registry.all)
      end

      def added_objects(registry1, registry2)
        registry2.reject {|o| registry1.find {|o2| o2.path == o.path } }
      end

      def modified_objects(registry1, registry2)
        registry1.select do |obj|
          case obj
          when CodeObjects::MethodObject
            registry2.find {|o| obj == o && o.source != obj.source }
          when CodeObjects::ConstantObject
            registry2.find {|o| obj == o && o.value != obj.value }
          end
        end.compact
      end

      def removed_objects(registry1, registry2)
        registry1.reject {|o| registry2.find {|o2| o2.path == o.path } }
      end

      def load_git_commit(commit)
        Registry.clear
        commit_path = 'git_commit' + commit.gsub(/\W/, '_')
        tmpdir = File.join(Dir.tmpdir, commit_path)
        log.info "Expanding #{commit} to #{tmpdir}..."
        Dir.chdir(@old_path)
        FileUtils.mkdir_p(tmpdir)
        FileUtils.cp_r('.', tmpdir)
        Dir.chdir(tmpdir)
        log.info("git says: " + `git reset --hard #{commit}`.chomp)
        generate_yardoc(tmpdir)
      ensure
        Dir.chdir(@old_path)
        cleanup(commit_path)
      end

      def load_gem_data(gemfile)
        require_rubygems
        Registry.clear

        # First check for argument as .yardoc file
        [File.join(gemfile, '.yardoc'), gemfile].each do |yardoc|
          log.info "Searching for .yardoc db at #{yardoc}"
          next unless File.directory?(yardoc)
          Registry.load_yardoc(yardoc)
          Registry.load_all
          return true
        end

        # Next check installed RubyGems
        gemfile_without_ext = gemfile.sub(/\.gem$/, '')
        log.info "Searching for installed gem #{gemfile_without_ext}"
        YARD::GemIndex.each.find do |spec|
          next unless spec.full_name == gemfile_without_ext
          yardoc = Registry.yardoc_file_for_gem(spec.name, "= #{spec.version}")
          if yardoc
            Registry.load_yardoc(yardoc)
            Registry.load_all
          else
            log.enter_level(Logger::ERROR) do
              olddir = Dir.pwd
              Gems.run(spec.name, spec.version.to_s)
              Dir.chdir(olddir)
            end
          end
          return true
        end

        # Look for local .gem file
        gemfile += '.gem' unless gemfile =~ /\.gem$/
        log.info "Searching for local gem file #{gemfile}"
        if File.exist?(gemfile)
          File.open(gemfile, 'rb') do |io|
            expand_and_parse(gemfile, io)
          end
          return true
        end

        # Remote gemfile from rubygems.org
        url = "http://rubygems.org/downloads/#{gemfile}"
        log.info "Searching for remote gem file #{url}"
        begin
          # Note: In Ruby 2.4.x, URI.open is a private method. After
          # 2.5, URI.open behaves much like Kernel#open once you've
          # required 'open-uri'
          OpenURI.open_uri(url) {|io| expand_and_parse(gemfile, io) }
          return true
        rescue OpenURI::HTTPError
          nil # noop
        end
        false
      end

      def expand_and_parse(gemfile, io)
        dir = expand_gem(gemfile, io)
        generate_yardoc(dir)
        cleanup(gemfile)
      end

      def generate_yardoc(dir)
        Dir.chdir(dir) do
          log.enter_level(Logger::ERROR) { Yardoc.run('-n', '--no-save') }
        end
      end

      def expand_gem(gemfile, io)
        tmpdir = File.join(Dir.tmpdir, gemfile)
        FileUtils.mkdir_p(tmpdir)
        log.info "Expanding #{gemfile} to #{tmpdir}..."

        if Gem::VERSION >= '2.0.0'
          require 'rubygems/package/tar_reader'
          reader = Gem::Package::TarReader.new(io)
          reader.each do |pkg|
            next unless pkg.full_name == 'data.tar.gz'
            Zlib::GzipReader.wrap(pkg) do |gzio|
              tar = Gem::Package::TarReader.new(gzio)
              tar.each do |entry|
                file = File.join(tmpdir, entry.full_name)
                FileUtils.mkdir_p(File.dirname(file))
                File.open(file, 'wb') do |out|
                  out.write(entry.read)
                  begin
                    out.fsync
                  rescue NotImplementedError
                    nil # noop
                  end
                end
              end
            end
            break
          end
        else
          Gem::Package.open(io) do |pkg|
            pkg.each do |entry|
              pkg.extract_entry(tmpdir, entry)
            end
          end
        end

        tmpdir
      end

      def require_rubygems
        require 'rubygems'
        require 'rubygems/package'
      rescue LoadError => e
        log.error "Missing RubyGems, cannot run this command."
        raise(e)
      end

      def cleanup(gemfile)
        dir = File.join(Dir.tmpdir, gemfile)
        log.info "Cleaning up #{dir}..."
        FileUtils.rm_rf(dir)
      end

      def optparse(*args)
        opts = OptionParser.new
        opts.banner = "Usage: yard diff [options] oldgem newgem"
        opts.separator ""
        opts.separator "Example: yard diff yard-0.5.6 yard-0.5.8"
        opts.separator ""
        opts.separator "If the files don't exist locally, they will be grabbed using the `gem fetch`"
        opts.separator "command. If the gem is a .yardoc directory, it will be used. Finally, if the"
        opts.separator "gem name matches an installed gem (full name-version syntax), that gem will be used."

        opts.on('-a', '--all', 'List all objects, even if they are inside added/removed module/class') do
          @list_all = true
        end
        opts.on('--compact', 'Show compact results') { @compact = true }
        opts.on('--git', 'Compare versions from two git commit/branches') do
          @use_git = true
        end
        opts.on('--query QUERY', 'Only diff filtered objects') do |query|
          @verifier.add_expressions(query)
        end
        opts.on('--no-modified', 'Ignore modified objects') do
          @modified = false
        end
        common_options(opts)
        parse_options(opts, args)
        unless args.size == 2
          log.puts opts.banner
          exit(0)
        end

        args
      end
    end
  end
end