lib/ruby_indexer/test/configuration_test.rb



# typed: true
# frozen_string_literal: true

require "test_helper"

module RubyIndexer
  class ConfigurationTest < Minitest::Test
    def setup
      @config = Configuration.new
      @workspace_path = File.expand_path(File.join("..", "..", ".."), __dir__)
      @config.workspace_path = @workspace_path
    end

    def test_load_configuration_executes_configure_block
      @config.apply_config({ "excluded_patterns" => ["**/fixtures/**/*"] })
      uris = @config.indexable_uris

      bundle_path = Bundler.bundle_path.join("gems")

      assert(uris.none? { |uri| uri.full_path.include?("test/fixtures") })
      assert(uris.none? { |uri| uri.full_path.include?(bundle_path.join("minitest-reporters").to_s) })
      assert(uris.none? { |uri| uri.full_path.include?(bundle_path.join("ansi").to_s) })
      assert(uris.any? { |uri| uri.full_path.include?(bundle_path.join("prism").to_s) })
      assert(uris.none? { |uri| uri.full_path == __FILE__ })
    end

    def test_indexable_uris_have_expanded_full_paths
      @config.apply_config({ "included_patterns" => ["**/*.rb"] })
      uris = @config.indexable_uris

      # All paths should be expanded
      assert(uris.all? { |uri| File.absolute_path?(uri.full_path) })
    end

    def test_indexable_uris_only_includes_gem_require_paths
      uris = @config.indexable_uris

      Bundler.locked_gems.specs.each do |lazy_spec|
        next if lazy_spec.name == "ruby-lsp"

        spec = Gem::Specification.find_by_name(lazy_spec.name)

        test_uris = uris.select do |uri|
          File.fnmatch?(File.join(spec.full_gem_path, "test/**/*"), uri.full_path, File::Constants::FNM_PATHNAME)
        end
        assert_empty(test_uris)
      rescue Gem::MissingSpecError
        # Transitive dependencies might be missing when running tests on Windows
      end
    end

    def test_indexable_uris_does_not_include_default_gem_path_when_in_bundle
      uris = @config.indexable_uris
      assert(uris.none? { |uri| uri.full_path.start_with?("#{RbConfig::CONFIG["rubylibdir"]}/psych") })
    end

    def test_indexable_uris_includes_default_gems
      paths = @config.indexable_uris.map(&:full_path)

      assert_includes(paths, "#{RbConfig::CONFIG["rubylibdir"]}/pathname.rb")
      assert_includes(paths, "#{RbConfig::CONFIG["rubylibdir"]}/ipaddr.rb")
    end

    def test_indexable_uris_includes_project_files
      paths = @config.indexable_uris.map(&:full_path)

      Dir.glob("#{Dir.pwd}/lib/**/*.rb").each do |path|
        next if path.end_with?("_test.rb")

        assert_includes(paths, path)
      end
    end

    def test_indexable_uris_avoids_duplicates_if_bundle_path_is_inside_project
      Bundler.settings.temporary(path: "vendor/bundle") do
        config = Configuration.new

        assert_includes(config.instance_variable_get(:@excluded_patterns), "vendor/bundle/**/*.rb")
      end
    end

    def test_indexable_uris_does_not_include_gems_own_installed_files
      uris = @config.indexable_uris
      uris_inside_bundled_lsp = uris.select do |uri|
        uri.full_path.start_with?(Bundler.bundle_path.join("gems", "ruby-lsp").to_s)
      end

      assert_empty(
        uris_inside_bundled_lsp,
        "Indexable URIs should not include files from the gem currently being worked on. " \
          "Included: #{uris_inside_bundled_lsp.map(&:full_path)}",
      )
    end

    def test_indexable_uris_does_not_include_non_ruby_files_inside_rubylibdir
      path = Pathname.new(RbConfig::CONFIG["rubylibdir"]).join("extra_file.txt").to_s
      FileUtils.touch(path)

      begin
        uris = @config.indexable_uris
        assert(uris.none? { |uri| uri.full_path == path })
      ensure
        FileUtils.rm(path)
      end
    end

    def test_paths_are_unique
      uris = @config.indexable_uris
      assert_equal(uris.uniq.length, uris.length)
    end

    def test_configuration_raises_for_unknown_keys
      assert_raises(ArgumentError) do
        @config.apply_config({ "unknown_config" => 123 })
      end
    end

    def test_magic_comments_regex
      regex = @config.magic_comment_regex

      [
        "# frozen_string_literal:",
        "# typed:",
        "# compiled:",
        "# encoding:",
        "# shareable_constant_value:",
        "# warn_indent:",
        "# rubocop:",
        "# nodoc:",
        "# doc:",
        "# coding:",
        "# warn_past_scope:",
      ].each do |comment|
        assert_match(regex, comment)
      end
    end

    def test_indexable_uris_respect_given_workspace_path
      Dir.mktmpdir do |dir|
        FileUtils.mkdir(File.join(dir, "ignore"))
        FileUtils.touch(File.join(dir, "ignore", "file0.rb"))
        FileUtils.touch(File.join(dir, "file1.rb"))
        FileUtils.touch(File.join(dir, "file2.rb"))

        @config.apply_config({ "excluded_patterns" => ["ignore/**/*.rb"] })
        @config.workspace_path = dir

        uris = @config.indexable_uris
        assert(uris.none? { |uri| uri.full_path.start_with?(File.join(dir, "ignore")) })

        # The regular default gem path is ~/.rubies/3.4.1/lib/ruby/3.4.0
        # The alternative default gem path is ~/.rubies/3.4.1/lib/ruby/gems/3.4.0
        # Here part_1 contains ~/.rubies/3.4.1/lib/ruby/ and part_2 contains 3.4.0, so that we can turn it into the
        # alternative path
        part_1, part_2 = Pathname.new(RbConfig::CONFIG["rubylibdir"]).split
        other_default_gem_dir = part_1.join("gems").join(part_2).to_s

        # After switching the workspace path, all indexable URIs will be found in one of these places:
        # - The new workspace path
        # - The Ruby LSP's own code (because Bundler is requiring the dependency from source)
        # - Bundled gems
        # - Default gems
        # - Other default gem directory
        assert(
          uris.all? do |u|
            u.full_path.start_with?(dir) ||
            u.full_path.start_with?(File.join(Dir.pwd, "lib")) ||
            u.full_path.start_with?(Bundler.bundle_path.to_s) ||
            u.full_path.start_with?(RbConfig::CONFIG["rubylibdir"]) ||
            u.full_path.start_with?(other_default_gem_dir)
          end,
        )
      end
    end

    def test_includes_top_level_files
      Dir.mktmpdir do |dir|
        FileUtils.touch(File.join(dir, "find_me.rb"))
        @config.workspace_path = dir

        uris = @config.indexable_uris
        assert(uris.find { |u| File.basename(u.full_path) == "find_me.rb" })
      end
    end

    def test_transitive_dependencies_for_non_dev_gems_are_not_excluded
      Dir.mktmpdir do |dir|
        Dir.chdir(dir) do
          # Both IRB and debug depend on reline. Since IRB is in the default group, reline should not be excluded
          File.write(File.join(dir, "Gemfile"), <<~RUBY)
            source "https://rubygems.org"
            gem "irb"
            gem "ruby-lsp", path: "#{Bundler.root}"

            group :development do
              gem "debug"
            end
          RUBY

          Bundler.with_unbundled_env do
            capture_subprocess_io do
              system("bundle install")
            end

            stdout, _stderr = capture_subprocess_io do
              script = [
                "require \"ruby_lsp/internal\"",
                "print RubyIndexer::Configuration.new.instance_variable_get(:@excluded_gems).join(\",\")",
              ].join(";")
              system("bundle exec ruby -e '#{script}'")
            end

            excluded_gems = stdout.split(",")
            assert_includes(excluded_gems, "debug")
            refute_includes(excluded_gems, "reline")
            refute_includes(excluded_gems, "irb")
          end
        end
      end
    end

    def test_does_not_fail_if_there_are_missing_specs_due_to_platform_constraints
      Dir.mktmpdir do |dir|
        Dir.chdir(dir) do
          File.write(File.join(dir, "Gemfile"), <<~RUBY)
            source "https://rubygems.org"
            gem "ruby-lsp", path: "#{Bundler.root}"

            platforms :windows do
              gem "tzinfo"
              gem "tzinfo-data"
            end
          RUBY

          Bundler.with_unbundled_env do
            capture_subprocess_io { system("bundle install") }

            _stdout, stderr = capture_subprocess_io do
              script = [
                "require \"ruby_lsp/internal\"",
                "RubyIndexer::Configuration.new.indexable_uris",
              ].join(";")

              system("bundle exec ruby -e '#{script}'")
            end

            assert_empty(stderr)
          end
        end
      end
    end

    def test_indexables_include_non_test_files_in_test_directories
      # In order to linearize test parent classes and accurately detect the framework being used, then intermediate
      # parent classes _must_ also be indexed. Otherwise, we have no way of linearizing the rest of the ancestors to
      # determine what the test class ultimately inherits from.
      #
      # Therefore, we need to ensure that test files are excluded, but non test files inside test directories have to be
      # indexed
      FileUtils.touch("test/test_case.rb")

      uris = @config.indexable_uris
      project_paths = uris.filter_map do |uri|
        path = uri.full_path
        next if path.start_with?(Bundler.bundle_path.to_s) || path.start_with?(RbConfig::CONFIG["rubylibdir"])

        Pathname.new(path).relative_path_from(Dir.pwd).to_s
      end

      begin
        assert_includes(project_paths, "test/requests/support/expectations_test_runner.rb")
        assert_includes(project_paths, "test/test_helper.rb")
        assert_includes(project_paths, "test/test_case.rb")
      ensure
        FileUtils.rm("test/test_case.rb")
      end
    end
  end
end