lib/solargraph/doc_map.rb



# frozen_string_literal: true


module Solargraph
  # A collection of pins generated from required gems.

  #

  class DocMap
    # @return [Array<String>]

    attr_reader :requires

    # @return [Array<Gem::Specification>]

    attr_reader :preferences

    # @return [Array<Pin::Base>]

    attr_reader :pins

    # @return [Array<Gem::Specification>]

    attr_reader :uncached_gemspecs

    # @param requires [Array<String>]

    # @param preferences [Array<Gem::Specification>]

    # @param rbs_path [String, Pathname, nil]

    def initialize(requires, preferences, rbs_path = nil)
      @requires = requires.compact
      @preferences = preferences.compact
      @rbs_path = rbs_path
      generate
    end

    # @return [Array<Gem::Specification>]

    def gemspecs
      @gemspecs ||= required_gem_map.values.compact
    end

    # @return [Array<String>]

    def unresolved_requires
      @unresolved_requires ||= required_gem_map.select { |_, gemspec| gemspec.nil? }.keys
    end

    # @return [Hash{Gem::Specification => Array[Pin::Base]}]

    def self.gems_in_memory
      @gems_in_memory ||= {}
    end

    # @return [Set<Gem::Specification>]

    def dependencies
      @dependencies ||= (gemspecs.flat_map { |spec| fetch_dependencies(spec) } - gemspecs).to_set
    end

    private

    # @return [void]

    def generate
      @pins = []
      @uncached_gemspecs = []
      required_gem_map.each do |path, gemspec|
        if gemspec
          try_cache gemspec
        else
          try_stdlib_map path
        end
      end
      dependencies.each { |dep| try_cache dep }
      @uncached_gemspecs.uniq!
    end

    # @return [Hash{String => Gem::Specification, nil}]

    def required_gem_map
      @required_gem_map ||= requires.to_h { |path| [path, resolve_path_to_gemspec(path)] }
    end

    # @return [Hash{String => Gem::Specification}]

    def preference_map
      @preference_map ||= preferences.to_h { |gemspec| [gemspec.name, gemspec] }
    end

    # @param gemspec [Gem::Specification]

    # @return [void]

    def try_cache gemspec
      return if try_gem_in_memory(gemspec)
      cache_file = File.join('gems', "#{gemspec.name}-#{gemspec.version}.ser")
      if Cache.exist?(cache_file)
        cached = Cache.load(cache_file)
        gempins = update_from_collection(gemspec, cached)
        self.class.gems_in_memory[gemspec] = gempins
        @pins.concat gempins
      else
        Solargraph.logger.debug "No pin cache for #{gemspec.name} #{gemspec.version}"
        @uncached_gemspecs.push gemspec
      end
    end

    # @param path [String] require path that might be in the RBS stdlib collection

    # @return [void]

    def try_stdlib_map path
      map = RbsMap::StdlibMap.load(path)
      if map.resolved?
        Solargraph.logger.debug "Loading stdlib pins for #{path}"
        @pins.concat map.pins
      else
        # @todo Temporarily ignoring unresolved `require 'set'`

        Solargraph.logger.debug "Require path #{path} could not be resolved" unless path == 'set'
      end
    end

    # @param gemspec [Gem::Specification]

    # @return [Boolean]

    def try_gem_in_memory gemspec
      gempins = DocMap.gems_in_memory[gemspec]
      return false unless gempins
      Solargraph.logger.debug "Found #{gemspec.name} #{gemspec.version} in memory"
      @pins.concat gempins
      true
    end

    def update_from_collection gemspec, gempins
      return gempins unless @rbs_path && File.directory?(@rbs_path)
      return gempins if RbsMap.new(gemspec.name, gemspec.version).resolved?

      rbs_map = RbsMap.new(gemspec.name, gemspec.version, directories: [@rbs_path])
      return gempins unless rbs_map.resolved?

      Solargraph.logger.info "Updating #{gemspec.name} #{gemspec.version} from collection"
      GemPins.combine(gempins, rbs_map)
    end

    # @param path [String]

    # @return [Gem::Specification, nil]

    def resolve_path_to_gemspec path
      return nil if path.empty?

      gemspec = Gem::Specification.find_by_path(path)
      if gemspec.nil?
        gem_name_guess = path.split('/').first
        begin
          # this can happen when the gem is included via a local path in

          # a Gemfile; Gem doesn't try to index the paths in that case.

          #

          # See if we can make a good guess:

          potential_gemspec = Gem::Specification.find_by_name(gem_name_guess)
          file = "lib/#{path}.rb"
          gemspec = potential_gemspec if potential_gemspec.files.any? { |gemspec_file| file == gemspec_file }
        rescue Gem::MissingSpecError
          Solargraph.logger.debug "Require path #{path} could not be resolved to a gem via find_by_path or guess of #{gem_name_guess}"
          nil
        end
      end
      gemspec_or_preference gemspec
    end

    # @param gemspec [Gem::Specification, nil]

    # @return [Gem::Specification, nil]

    def gemspec_or_preference gemspec
      return gemspec unless gemspec && preference_map.key?(gemspec.name)
      return gemspec if gemspec.version == preference_map[gemspec.name].version

      change_gemspec_version gemspec, preference_map[by_path.name].version
    end

    # @param gemspec [Gem::Specification]

    # @param version [Gem::Version]

    # @return [Gem::Specification]

    def change_gemspec_version gemspec, version
      Gem::Specification.find_by_name(gemspec.name, "= #{version}")
    rescue Gem::MissingSpecError
      Solargraph.logger.info "Gem #{gemspec.name} version #{version} not found. Using #{gemspec.version} instead"
      gemspec
    end

    # @param gemspec [Gem::Specification]

    # @return [Array<Gem::Specification>]

    def fetch_dependencies gemspec
      only_runtime_dependencies(gemspec).each_with_object(Set.new) do |spec, deps|
        Solargraph.logger.info "Adding #{spec.name} dependency for #{gemspec.name}"
        dep = Gem::Specification.find_by_name(spec.name, spec.requirement)
        deps.merge fetch_dependencies(dep) if deps.add?(dep)
      rescue Gem::MissingSpecError
        Solargraph.logger.warn "Gem dependency #{spec.name} #{spec.requirements} for #{gemspec.name} not found."
      end.to_a
    end

    # @param gemspec [Gem::Specification]

    # @return [Array<Gem::Dependency>]

    def only_runtime_dependencies gemspec
      gemspec.dependencies - gemspec.development_dependencies
    end
  end
end