Homebrew/homebrew-bundle

View on GitHub
lib/bundle/brew_dumper.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

require "json"
require "tsort"

module Bundle
  # TODO: refactor into multiple modules
  module BrewDumper
    module_function

    def reset!
      Bundle::BrewServices.reset!
      @formulae = nil
      @formulae_by_full_name = nil
      @formulae_by_name = nil
      @formula_aliases = nil
      @formula_oldnames = nil
    end

    def formulae
      return @formulae if @formulae

      formulae_by_full_name
      @formulae
    end

    def formulae_by_full_name(name = nil)
      return @formulae_by_full_name[name] if name.present? && @formulae_by_full_name&.key?(name)

      require "formula"
      require "formulary"
      Formulary.enable_factory_cache!

      @formulae_by_name ||= {}
      @formulae_by_full_name ||= {}

      if name.nil?
        formulae = Formula.installed.map(&method(:add_formula))
        sort!(formulae)
        return @formulae_by_full_name
      end

      formula = Formula[name]
      add_formula(formula)
    rescue FormulaUnavailableError => e
      opoo "'#{name}' formula is unreadable: #{e}"
      {}
    end

    def formulae_by_name(name)
      formulae_by_full_name(name) || @formulae_by_name[name]
    end

    def dump(describe: false, no_restart: false)
      requested_formula = formulae.select do |f|
        f[:installed_on_request?] || !f[:installed_as_dependency?]
      end
      requested_formula.map do |f|
        brewline = if describe && f[:desc].present?
          f[:desc].split("\n").map { |s| "# #{s}\n" }.join
        else
          ""
        end
        brewline += "brew \"#{f[:full_name]}\""

        args = f[:args].map { |arg| "\"#{arg}\"" }.sort.join(", ")
        brewline += ", args: [#{args}]" unless f[:args].empty?
        brewline += ", restart_service: true" if !no_restart && BrewServices.started?(f[:full_name])
        brewline += ", link: #{f[:link?]}" unless f[:link?].nil?
        brewline
      end.join("\n")
    end

    def formula_aliases
      return @formula_aliases if @formula_aliases

      @formula_aliases = {}
      formulae.each do |f|
        aliases = f[:aliases]
        next if aliases.blank?

        aliases.each do |a|
          @formula_aliases[a] = f[:full_name]
          if f[:full_name].include? "/" # tap formula
            tap_name = f[:full_name].rpartition("/").first
            @formula_aliases["#{tap_name}/#{a}"] = f[:full_name]
          end
        end
      end
      @formula_aliases
    end

    def formula_oldnames
      return @formula_oldnames if @formula_oldnames

      @formula_oldnames = {}
      formulae.each do |f|
        oldnames = f[:oldnames]
        next if oldnames.blank?

        oldnames.each do |oldname|
          @formula_oldnames[oldname] = f[:full_name]
          if f[:full_name].include? "/" # tap formula
            tap_name = f[:full_name].rpartition("/").first
            @formula_oldnames["#{tap_name}/#{oldname}"] = f[:full_name]
          end
        end
      end
      @formula_oldnames
    end

    def add_formula(formula)
      hash = formula_to_hash formula

      @formulae_by_name[hash[:name]] = hash
      @formulae_by_full_name[hash[:full_name]] = hash

      hash
    end
    private_class_method :add_formula

    def formula_to_hash(formula)
      keg = if formula.linked?
        link = true if formula.keg_only?
        formula.linked_keg
      else
        link = false unless formula.keg_only?
        formula.any_installed_prefix
      end

      if keg
        require "tab"

        tab = Tab.for_keg(keg)
        args = tab.used_options.map(&:name)
        version = begin
          keg.realpath.basename
        rescue
          # silently handle broken symlinks
          nil
        end.to_s
        args << "HEAD" if version.start_with?("HEAD")
        installed_as_dependency = tab.installed_as_dependency
        installed_on_request = tab.installed_on_request
        runtime_dependencies = if (runtime_deps = tab.runtime_dependencies)
          runtime_deps.filter_map { |d| d["full_name"] }

        end
        poured_from_bottle = tab.poured_from_bottle
      end

      runtime_dependencies ||= formula.runtime_dependencies.map(&:name)

      bottled = if (stable = formula.stable) && stable.bottle_defined?
        bottle_hash = formula.bottle_hash.deep_symbolize_keys
        stable.bottled?
      end

      {
        name:                     formula.name,
        desc:                     formula.desc,
        oldnames:                 formula.oldnames,
        full_name:                formula.full_name,
        aliases:                  formula.aliases,
        any_version_installed?:   formula.any_version_installed?,
        args:                     Array(args).uniq,
        version:,
        installed_as_dependency?: installed_as_dependency || false,
        installed_on_request?:    installed_on_request || false,
        dependencies:             runtime_dependencies,
        build_dependencies:       formula.deps.select(&:build?).map(&:name).uniq,
        conflicts_with:           formula.conflicts.map(&:name),
        pinned?:                  formula.pinned? || false,
        outdated?:                formula.outdated? || false,
        link?:                    link,
        poured_from_bottle?:      poured_from_bottle || false,
        bottle:                   bottle_hash || false,
        bottled:                  bottled || false,
        official_tap:             formula.tap&.official? || false,
      }
    end
    private_class_method :formula_to_hash

    class Topo < Hash
      include TSort
      alias tsort_each_node each_key
      def tsort_each_child(node, &block)
        fetch(node.downcase).sort.each(&block)
      end
    end

    def sort!(formulae)
      # Step 1: Sort by formula full name while putting tap formulae behind core formulae.
      #         So we can have a nicer output.
      formulae = formulae.sort do |a, b|
        if a[:full_name].exclude?("/") && b[:full_name].include?("/")
          -1
        elsif a[:full_name].include?("/") && b[:full_name].exclude?("/")
          1
        else
          a[:full_name] <=> b[:full_name]
        end
      end

      # Step 2: Sort by formula dependency topology.
      topo = Topo.new
      formulae.each do |f|
        topo[f[:name]] = topo[f[:full_name]] = f[:dependencies].filter_map do |dep|
          ff = formulae_by_name(dep)
          next if ff.blank?
          next unless ff[:any_version_installed?]

          ff[:full_name]
        end
      end
      @formulae = topo.tsort
                      .map { |name| @formulae_by_full_name[name] || @formulae_by_name[name] }
                      .uniq { |f| f[:full_name] }
    rescue TSort::Cyclic => e
      e.message =~ /\["([^"]*)".*"([^"]*)"\]/
      cycle_first = Regexp.last_match(1)
      cycle_last = Regexp.last_match(2)
      odie e.message if !cycle_first || !cycle_last

      odie <<~EOS
        Formulae dependency graph sorting failed (likely due to a circular dependency):
        #{cycle_first}: #{topo[cycle_first]}
        #{cycle_last}: #{topo[cycle_last]}
        Please run the following commands and try again:
          brew update
          brew uninstall --ignore-dependencies --force #{cycle_first} #{cycle_last}
          brew install #{cycle_first} #{cycle_last}
      EOS
    end
    private_class_method :sort!
  end
end