lib/tapioca/gemfile.rb



# typed: strict
# frozen_string_literal: true

require "tapioca/bundler_ext/auto_require_hook"

module Tapioca
  class Gemfile
    extend(T::Sig)

    Spec = T.type_alias do
      T.any(
        ::Bundler::StubSpecification,
        ::Gem::Specification,
      )
    end

    #: Bundler::Definition
    attr_reader(:definition)

    #: Array[GemSpec]
    attr_reader(:dependencies)

    #: Array[String]
    attr_reader(:missing_specs)

    #: (Array[String] excluded_gems) -> void
    def initialize(excluded_gems)
      @gemfile = File.new(Bundler.default_gemfile) #: File
      @lockfile = File.new(Bundler.default_lockfile) #: File
      @definition = Bundler::Dsl.evaluate(gemfile, lockfile, {}) #: Bundler::Definition
      @excluded_gems = excluded_gems

      dependencies, missing_specs = load_dependencies

      @dependencies = dependencies #: Array[GemSpec]
      @missing_specs = missing_specs #: Array[String]
    end

    #: (String gem_name) -> GemSpec?
    def gem(gem_name)
      dependencies.detect { |dep| dep.name == gem_name }
    end

    #: -> void
    def require_bundle
      BundlerExt::AutoRequireHook.override_require_false(exclude: @excluded_gems) do
        T.unsafe(runtime).require(*groups)
      end
    end

    private

    #: File
    attr_reader(:gemfile, :lockfile)

    #: -> [Array[GemSpec], Array[String]]
    def load_dependencies
      materialized_dependencies, missing_specs = materialize_deps
      dependencies = materialized_dependencies
        .map { |spec| GemSpec.new(spec) }
        .reject { |gem| gem.ignore?(dir) }
        .uniq(&:rbi_file_name)
        .sort_by(&:rbi_file_name)
      [dependencies, missing_specs]
    end

    #: -> [T::Enumerable[Spec], Array[String]]
    def materialize_deps
      deps = definition.locked_gems.dependencies.except(*@excluded_gems).values
      resolve = definition.resolve
      materialized_dependencies = resolve.materialize(deps)

      if Bundler::VERSION >= "2.6.0"
        missing_specs = resolve.missing_specs.map do |spec|
          "#{spec.name} (#{spec.version})"
        end
      else
        missing_spec_names = materialized_dependencies.missing_specs.map(&:name).to_set
        missing_specs = materialized_dependencies.missing_specs.map do |spec|
          "#{spec.name} (#{spec.version})"
        end
        materialized_dependencies = materialized_dependencies.to_a.reject do |spec|
          missing_spec_names.include?(spec.name)
        end
      end

      [materialized_dependencies, missing_specs]
    end

    #: -> Bundler::Runtime
    def runtime
      Bundler::Runtime.new(File.dirname(gemfile.path), definition)
    end

    #: -> Array[Symbol]
    def groups
      definition.groups
    end

    #: -> String
    def dir
      File.expand_path(gemfile.path + "/..")
    end

    class GemSpec
      extend(T::Sig)
      include GemHelper

      class << self
        extend T::Sig

        #: -> Hash[String, Gemfile::GemSpec]
        def spec_lookup_by_file_path
          @lookup ||= [*::Gem::Specification.default_stubs, *::Gem::Specification.stubs]
            .map! { |spec| new(spec.to_spec) }
            .flat_map do |spec|
              spec.files.filter_map { |file| [file.realpath.to_s, spec] if file.exist? }
            end.to_h #: Hash[String, Gemfile::GemSpec]?
        end
      end

      IGNORED_GEMS = [
        "sorbet",
        "sorbet-static",
        "sorbet-runtime",
        "sorbet-static-and-runtime",
        "debug",
        "irb",
        "fakefs",
      ].freeze #: Array[String]

      #: String
      attr_reader :full_gem_path, :version

      #: Array[Pathname]
      attr_reader :files

      #: (Spec spec) -> void
      def initialize(spec)
        @spec = spec #: Tapioca::Gemfile::Spec
        real_gem_path = to_realpath(@spec.full_gem_path)
        @full_gem_path = real_gem_path #: String
        @version = version_string #: String
        @exported_rbi_files = nil #: Array[String]?
        @files = collect_files #: Array[Pathname]
      end

      #: (BasicObject other) -> bool
      def ==(other)
        GemSpec === other && other.name == name && other.version == version
      end

      #: (String gemfile_dir) -> bool
      def ignore?(gemfile_dir)
        gem_ignored? || gem_in_app_dir?(gemfile_dir, full_gem_path)
      end

      #: -> String
      def name
        @spec.name
      end

      #: -> Array[::Gem::Dependency]
      def dependencies
        @spec.dependencies
      end

      #: -> String
      def rbi_file_name
        "#{name}@#{version}.rbi"
      end

      #: (String path) -> bool
      def contains_path?(path)
        if default_gem?
          files.any? { |file| file.to_s == to_realpath(path) }
        else
          path_in_dir?(to_realpath(path), full_gem_path) || has_parent_gemspec?(path)
        end
      end

      #: -> void
      def parse_yard_docs
        files.each do |path|
          YARD.parse(path.to_s, [], Logger::Severity::FATAL)
        rescue RangeError
          # In some circumstances, YARD will raise an error when parsing a file
          # that is actually valid Ruby. We don't want tapioca to halt in these
          # cases, so we'll rescue the error, pretend like there was no
          # documentation, and move on.
          #
          # This can be removed when https://github.com/lsegal/yard/issues/1536
          # is resolved and released.
          []
        end
      end

      #: -> Array[String]
      def exported_rbi_files
        @exported_rbi_files ||= Dir.glob("#{full_gem_path}/rbi/**/*.rbi").sort
      end

      #: -> bool
      def export_rbi_files?
        exported_rbi_files.any?
      end

      #: -> RBI::MergeTree
      def exported_rbi_tree
        rewriter = RBI::Rewriters::Merge.new(keep: RBI::Rewriters::Merge::Keep::NONE)

        exported_rbi_files.each do |file|
          rbi = RBI::Parser.parse_file(file)
          rewriter.merge(rbi)
        end

        rewriter.tree
      end

      #: (Pathname file) -> Pathname
      def relative_path_for(file)
        if default_gem?
          file.realpath.relative_path_from(RbConfig::CONFIG["rubylibdir"])
        else
          file.realpath.relative_path_from(full_gem_path)
        end
      end

      private

      #: -> Array[Pathname]
      def collect_files
        if default_gem?
          # `Bundler::RemoteSpecification` delegates missing methods to
          # `Gem::Specification`, so `files` actually always exists on spec.
          T.unsafe(@spec).files.map do |file|
            resolve_to_ruby_lib_dir(file)
          end
        else
          @spec.full_require_paths.flat_map do |path|
            Pathname.glob((Pathname.new(path) / "**/*.rb").to_s)
          end
        end
      end

      #: -> bool?
      def default_gem?
        @spec.respond_to?(:default_gem?) && @spec.default_gem?
      end

      #: -> Regexp
      def require_paths_prefix_matcher
        @require_paths_prefix_matcher ||= begin
          require_paths = T.unsafe(@spec).require_paths
          prefix_matchers = require_paths.map { |rp| Regexp.new("^#{rp}/") }
          Regexp.union(prefix_matchers)
        end #: Regexp?
      end

      #: (String file) -> Pathname
      def resolve_to_ruby_lib_dir(file)
        # We want to match require prefixes but fallback to an empty match
        # if none of the require prefixes actually match. This is so that
        # we can always replace the match with the Ruby lib directory and
        # we would have properly resolved the file under the Ruby lib dir.
        prefix_matcher = Regexp.union(require_paths_prefix_matcher, //)

        ruby_lib_dir = RbConfig::CONFIG["rubylibdir"]
        file = file.sub(prefix_matcher, "#{ruby_lib_dir}/")

        Pathname.new(file).expand_path
      end

      #: -> String
      def version_string
        version = @spec.version.to_s
        version += "-#{@spec.source.revision}" if Bundler::Source::Git === @spec.source
        version
      end

      #: (String path) -> bool
      def has_parent_gemspec?(path)
        # For some Git installed gems the location of the loaded file can
        # be different from the gem path as indicated by the spec file
        #
        # To compensate for these cases, we walk up the directory hierarchy
        # from the given file and try to match a <gem-name.gemspec> file in
        # one of those folders to see if the path really belongs in the given gem
        # or not.
        return false unless Bundler::Source::Git === @spec.source

        parent = Pathname.new(path)

        until parent.root?
          parent = parent.parent.expand_path
          return true if parent.join("#{name}.gemspec").file?
        end

        false
      end

      #: -> bool
      def gem_ignored?
        IGNORED_GEMS.include?(name)
      end
    end
  end
end