diff --git a/goto-benchmark.txt b/goto-benchmark.txt new file mode 100644 index 000000000..4b2523597 --- /dev/null +++ b/goto-benchmark.txt @@ -0,0 +1,203 @@ + +workspace: /Users/jennyshih/src/github.com/Shopify/shopify/areas/core/shopify +path: /components/shopify_payments/app/services/shopify_payments/payments_processor/processor.rb +algorithm: recursive +result: 0.635863 2.821835 3.457698 ( 3.768692) + +workspace: /Users/jennyshih/src/github.com/Shopify/shopify/areas/core/shopify +path: /components/shopify_payments/test/unit/services/shopify_payments/payments_processor/processor_test.rb +algorithm: recursive +result: 0.456094 2.042988 2.499082 ( 2.499641) + +workspace: /Users/jennyshih/src/github.com/Shopify/shopify/areas/core/shopify +path: /components/shopify_payments/app/services/shopify_payments/payments_processor/processor.rb +algorithm: jaccard +result: 0.430905 0.775094 1.205999 ( 1.606421) + +workspace: /Users/jennyshih/src/github.com/Shopify/shopify/areas/core/shopify +path: /components/shopify_payments/test/unit/services/shopify_payments/payments_processor/processor_test.rb +algorithm: jaccard +result: 0.151545 0.681743 0.833288 ( 0.855169) + +workspace: /Users/jennyshih/src/github.com/Shopify/shopify/areas/core/shopify +path: /components/shopify_payments/app/models/payments/charge.rb +algorithm: jaccard +result: 0.396473 0.744343 1.140816 ( 1.245711) + +workspace: /Users/jennyshih/src/github.com/Shopify/shopify/areas/core/shopify +path: /components/shopify_payments/test/unit/payments/charge_test.rb +algorithm: jaccard +result: 0.145461 0.657387 0.802848 ( 0.804873) + +workspace: /Users/jennyshih/src/github.com/Shopify/shopify/areas/core/shopify +path: /components/shopify_payments/app/models/payments/charge.rb +algorithm: jaccard +result: 0.404753 0.735576 1.140329 ( 1.437993) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/definition.rb +algorithm: jaccard +result: 0.055285 0.060584 0.115869 ( 0.116187) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /test/requests/definition_expectations_test.rb +algorithm: jaccard +result: 0.017593 0.070182 0.087775 ( 0.087766) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /test/requests/definition_expectations_test.rb +algorithm: jaccard +result: 0.018597 0.075378 0.093975 ( 0.094015) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /test/requests/definition_expectations_test.rb +algorithm: jaccard +result: 0.015376 0.061609 0.076985 ( 0.083124) + +workspace: /Users/jennyshih/src/github.com/Shopify/shopify/areas/core/shopify +path: /components/shopify_payments/app/models/payments/charge.rb +algorithm: jaccard +result: 0.354486 0.737402 1.091888 ( 1.095194) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /Users/jennyshih/test/requests/goto_relevant_file_test.rb +algorithm: jaccard +result: 0.013733 0.041814 0.055547 ( 0.055547) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /Users/jennyshih/test/requests/goto_relevant_file_test.rb +algorithm: jaccard +result: 0.012587 0.051676 0.064263 ( 0.064629) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /Users/jennyshih/test/requests/goto_relevant_file_test.rb +algorithm: jaccard +result: 0.012543 0.050800 0.063343 ( 0.063347) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /Users/jennyshih/test/requests/goto_relevant_file_test.rb +algorithm: jaccard +result: 0.012556 0.050881 0.063437 ( 0.063444) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /Users/jennyshih/lib/ruby_lsp/requests/goto_relevant_file.rb +algorithm: jaccard +result: 0.037421 0.052222 0.089643 ( 0.091287) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /Users/jennyshih/test/requests/goto_relevant_file_test.rb +algorithm: jaccard +result: 0.012653 0.040534 0.053187 ( 0.053193) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /test/requests/goto_relevant_file_test.rb +algorithm: jaccard +result: 0.018579 0.054580 0.073159 ( 0.078505) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/goto_relevant_file.rb +algorithm: jaccard +result: 0.050857 0.067798 0.118655 ( 0.118625) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /test/requests/goto_relevant_file_test.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/lib/ruby_lsp/requests/goto_relevant_file.rb"] +algorithm: jaccard +benchmark: 0.012562 0.041259 0.053821 ( 0.053824) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /test/requests/goto_relevant_file_test.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/lib/ruby_lsp/requests/goto_relevant_file.rb"] +algorithm: jaccard +benchmark: 0.012651 0.041844 0.054495 ( 0.054500) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/goto_relevant_file.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/test/requests/goto_relevant_file_test.rb"] +algorithm: jaccard +benchmark: 0.037979 0.041264 0.079243 ( 0.079245) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/goto_relevant_file.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/test/requests/goto_relevant_file_test.rb"] +algorithm: jaccard +benchmark: 0.038095 0.042829 0.080924 ( 0.081500) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /test/requests/goto_relevant_file_test.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/lib/ruby_lsp/requests/goto_relevant_file.rb"] +algorithm: jaccard +benchmark: 0.012594 0.040656 0.053250 ( 0.053256) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /test/requests/goto_relevant_file_test.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/lib/ruby_lsp/requests/goto_relevant_file.rb"] +algorithm: jaccard +benchmark: 0.015564 0.043131 0.058695 ( 0.058743) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/goto_relevant_file.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/test/requests/goto_relevant_file_test.rb"] +algorithm: jaccard +benchmark: 0.040471 0.057309 0.097780 ( 0.098086) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/goto_relevant_file.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/test/requests/goto_relevant_file_test.rb"] +algorithm: jaccard +benchmark: 0.040048 0.055300 0.095348 ( 0.095679) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/goto_relevant_file.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/test/requests/goto_relevant_file_test.rb"] +algorithm: jaccard +benchmark: 0.050783 0.054876 0.105659 ( 0.105619) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /test/requests/goto_relevant_file_test.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/lib/ruby_lsp/requests/goto_relevant_file.rb"] +algorithm: jaccard +benchmark: 0.512373 0.105817 0.618190 ( 0.598842) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/goto_relevant_file.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/test/requests/goto_relevant_file_test.rb"] +algorithm: jaccard +benchmark: 0.040427 0.045368 0.085795 ( 0.086113) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /test/requests/goto_relevant_file_test.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/lib/ruby_lsp/requests/goto_relevant_file.rb"] +algorithm: jaccard +benchmark: 0.013609 0.056012 0.069621 ( 0.071070) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/goto_relevant_file.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/test/requests/goto_relevant_file_test.rb"] +algorithm: jaccard +benchmark: 0.040316 0.042764 0.083080 ( 0.083219) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/goto_relevant_file.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/test/requests/goto_relevant_file_test.rb"] +algorithm: jaccard +benchmark: 0.038456 0.051550 0.090006 ( 0.090602) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /test/requests/goto_relevant_file_test.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/lib/ruby_lsp/requests/goto_relevant_file.rb"] +algorithm: jaccard +benchmark: 0.012630 0.037974 0.050604 ( 0.050971) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/goto_relevant_file.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/test/requests/goto_relevant_file_test.rb"] +algorithm: jaccard +benchmark: 0.046277 0.067478 0.113755 ( 0.174708) + +workspace: /Users/jennyshih/src/github.com/Shopify/ruby-lsp +path: /lib/ruby_lsp/requests/goto_relevant_file.rb +result: ["/Users/jennyshih/src/github.com/Shopify/ruby-lsp/test/requests/goto_relevant_file_test.rb"] +algorithm: jaccard +benchmark: 0.048515 0.079934 0.128449 ( 0.191184) + diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index c2252c41b..da302ba7a 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -79,6 +79,7 @@ require "ruby_lsp/requests/document_symbol" require "ruby_lsp/requests/folding_ranges" require "ruby_lsp/requests/formatting" +require "ruby_lsp/requests/goto_relevant_file" require "ruby_lsp/requests/hover" require "ruby_lsp/requests/inlay_hints" require "ruby_lsp/requests/on_type_formatting" diff --git a/lib/ruby_lsp/requests/goto_relevant_file.rb b/lib/ruby_lsp/requests/goto_relevant_file.rb new file mode 100644 index 000000000..6b6e172b1 --- /dev/null +++ b/lib/ruby_lsp/requests/goto_relevant_file.rb @@ -0,0 +1,83 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Requests + # Goto Relevant File is a custom [LSP + # request](https://microsoft.github.io/language-server-protocol/specification#requestMessage) + # that navigates to the relevant file for the current document. + # Currently, it supports source code file <> test file navigation. + class GotoRelevantFile < Request + extend T::Sig + + TEST_KEYWORDS = ["test", "spec", "integration_test"] + + sig { params(path: String).void } + def initialize(path) + super() + + @workspace_path = T.let(Dir.pwd, String) + @path = T.let(path.delete_prefix(@workspace_path), String) + end + + sig { override.returns(T::Array[String]) } + def perform + find_relevant_paths(@path) + end + + private + + sig { params(path: String).returns(T::Array[String]) } + def find_relevant_paths(path) + workspace_path = Dir.pwd + relpath = path.delete_prefix(workspace_path) + + candidate_paths = Dir.glob(File.join("**", relevant_filename_pattern(relpath))) + return [] if candidate_paths.empty? + + find_most_similar_with_jacaard(relpath, candidate_paths).map { File.join(workspace_path, _1) } + end + + sig { params(path: String).returns(String) } + def relevant_filename_pattern(path) + input_basename = File.basename(path, File.extname(path)) + + test_prefix_pattern = /^(#{TEST_KEYWORDS.join("_|")}_)/ + test_suffix_pattern = /(_#{TEST_KEYWORDS.join("|_")})$/ + test_pattern = /#{test_prefix_pattern}|#{test_suffix_pattern}/ + + relevant_basename_pattern = + if input_basename.match?(test_pattern) + input_basename.gsub(test_pattern, "") + else + test_prefix_glob = "#{TEST_KEYWORDS.join("_,")}_" + test_suffix_glob = "_#{TEST_KEYWORDS.join(",_")}" + + "{{#{test_prefix_glob}}#{input_basename},#{input_basename}{#{test_suffix_glob}}}" + end + + "#{relevant_basename_pattern}#{File.extname(path)}" + end + + sig { params(path: String, candidates: T::Array[String]).returns(T::Array[String]) } + def find_most_similar_with_jacaard(path, candidates) + dirs = get_dir_parts(path) + + _, results = candidates + .group_by do |other_path| + other_dirs = get_dir_parts(other_path) + # Similarity score between the two directories + (dirs & other_dirs).size.to_f / (dirs | other_dirs).size + end + .max_by(&:first) + + results || [] + end + + sig { params(path: String).returns(T::Set[String]) } + def get_dir_parts(path) + Set.new(File.dirname(path).split(File::SEPARATOR)) + end + end + end +end diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 221e0b82a..b759a6ff8 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -112,6 +112,8 @@ def process_message(message) diagnose_state(message) when "rubyLsp/discoverTests" discover_tests(message) + when "experimental/gotoRelevantFile" + experimental_goto_relevant_file(message) when "$/cancelRequest" @global_state.synchronize { @cancelled_requests << message[:params][:id] } when nil @@ -290,6 +292,7 @@ def run_initialize(message) experimental: { addon_detection: true, compose_bundle: true, + goto_relevant_file: true, }, ), serverInfo: { @@ -1123,6 +1126,25 @@ def text_document_show_syntax_tree(message) send_message(Result.new(id: message[:id], response: response)) end + sig { params(message: T::Hash[Symbol, T.untyped]).void } + def experimental_goto_relevant_file(message) + path = message.dig(:params, :textDocument, :uri).to_standardized_path + unless path.nil? || path.start_with?(@global_state.workspace_path) + send_empty_response(message[:id]) + return + end + + unless path + send_empty_response(message[:id]) + return + end + + response = { + locations: Requests::GotoRelevantFile.new(path).perform, + } + send_message(Result.new(id: message[:id], response: response)) + end + sig { params(message: T::Hash[Symbol, T.untyped]).void } def text_document_prepare_type_hierarchy(message) params = message[:params] diff --git a/test/requests/goto_relevant_file_test.rb b/test/requests/goto_relevant_file_test.rb new file mode 100644 index 000000000..204c966f0 --- /dev/null +++ b/test/requests/goto_relevant_file_test.rb @@ -0,0 +1,158 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +class GotoRelevantFileTest < Minitest::Test + def setup + Dir.stubs(:pwd).returns("/workspace") + end + + def test_when_input_is_test_file_returns_array_of_implementation_file_locations + stub_glob_pattern("**/goto_relevant_file.rb", ["lib/ruby_lsp/requests/goto_relevant_file.rb"]) + + test_file_path = "/workspace/test/requests/goto_relevant_file_test.rb" + expected = ["/workspace/lib/ruby_lsp/requests/goto_relevant_file.rb"] + + result = RubyLsp::Requests::GotoRelevantFile.new(test_file_path).perform + assert_equal(expected, result) + end + + def test_when_input_is_implementation_file_returns_array_of_test_file_locations + pattern = + "**/{{test_,spec_,integration_test_}goto_relevant_file,goto_relevant_file{_test,_spec,_integration_test}}.rb" + stub_glob_pattern(pattern, ["test/requests/goto_relevant_file_test.rb"]) + + impl_path = "/workspace/lib/ruby_lsp/requests/goto_relevant_file.rb" + expected = ["/workspace/test/requests/goto_relevant_file_test.rb"] + + result = RubyLsp::Requests::GotoRelevantFile.new(impl_path).perform + assert_equal(expected, result) + end + + def test_return_all_file_locations_that_have_the_same_highest_coefficient + pattern = "**/{{test_,spec_,integration_test_}some_feature,some_feature{_test,_spec,_integration_test}}.rb" + matches = [ + "test/unit/some_feature_test.rb", + "test/integration/some_feature_test.rb", + ] + stub_glob_pattern(pattern, matches) + + impl_path = "/workspace/lib/ruby_lsp/requests/some_feature.rb" + expected = [ + "/workspace/test/unit/some_feature_test.rb", + "/workspace/test/integration/some_feature_test.rb", + ] + + result = RubyLsp::Requests::GotoRelevantFile.new(impl_path).perform + assert_equal(expected.sort, result.sort) + end + + def test_return_empty_array_when_no_filename_matches + pattern = "**/{{test_,spec_,integration_test_}nonexistent_file,nonexistent_file{_test,_spec,_integration_test}}.rb" + stub_glob_pattern(pattern, []) + + file_path = "/workspace/lib/ruby_lsp/requests/nonexistent_file.rb" + result = RubyLsp::Requests::GotoRelevantFile.new(file_path).perform + assert_empty(result) + end + + def test_it_finds_implementation_when_file_has_test_suffix + stub_glob_pattern("**/feature.rb", ["lib/feature.rb"]) + + test_path = "/workspace/test/feature_test.rb" + expected = ["/workspace/lib/feature.rb"] + + result = RubyLsp::Requests::GotoRelevantFile.new(test_path).perform + assert_equal(expected, result) + end + + def test_it_finds_implementation_when_file_has_spec_suffix + stub_glob_pattern("**/feature.rb", ["lib/feature.rb"]) + + test_path = "/workspace/spec/feature_spec.rb" + expected = ["/workspace/lib/feature.rb"] + + result = RubyLsp::Requests::GotoRelevantFile.new(test_path).perform + assert_equal(expected, result) + end + + def test_it_finds_implementation_when_file_has_integration_test_suffix + stub_glob_pattern("**/feature.rb", ["lib/feature.rb"]) + + test_path = "/workspace/test/feature_integration_test.rb" + expected = ["/workspace/lib/feature.rb"] + + result = RubyLsp::Requests::GotoRelevantFile.new(test_path).perform + assert_equal(expected, result) + end + + def test_it_finds_implementation_when_file_has_test_prefix + stub_glob_pattern("**/feature.rb", ["lib/feature.rb"]) + + test_path = "/workspace/test/test_feature.rb" + expected = ["/workspace/lib/feature.rb"] + + result = RubyLsp::Requests::GotoRelevantFile.new(test_path).perform + assert_equal(expected, result) + end + + def test_it_finds_implementation_when_file_has_spec_prefix + stub_glob_pattern("**/feature.rb", ["lib/feature.rb"]) + + test_path = "/workspace/test/spec_feature.rb" + expected = ["/workspace/lib/feature.rb"] + + result = RubyLsp::Requests::GotoRelevantFile.new(test_path).perform + assert_equal(expected, result) + end + + def test_it_finds_implementation_when_file_has_integration_test_prefix + stub_glob_pattern("**/feature.rb", ["lib/feature.rb"]) + + test_path = "/workspace/test/integration_test_feature.rb" + expected = ["/workspace/lib/feature.rb"] + + result = RubyLsp::Requests::GotoRelevantFile.new(test_path).perform + assert_equal(expected, result) + end + + def test_it_finds_tests_for_implementation + pattern = "**/{{test_,spec_,integration_test_}feature,feature{_test,_spec,_integration_test}}.rb" + stub_glob_pattern(pattern, ["test/feature_test.rb"]) + + impl_path = "/workspace/lib/feature.rb" + expected = ["/workspace/test/feature_test.rb"] + + result = RubyLsp::Requests::GotoRelevantFile.new(impl_path).perform + assert_equal(expected, result) + end + + def test_it_finds_specs_for_implementation + pattern = "**/{{test_,spec_,integration_test_}feature,feature{_test,_spec,_integration_test}}.rb" + stub_glob_pattern(pattern, ["spec/feature_spec.rb"]) + + impl_path = "/workspace/lib/feature.rb" + expected = ["/workspace/spec/feature_spec.rb"] + + result = RubyLsp::Requests::GotoRelevantFile.new(impl_path).perform + assert_equal(expected, result) + end + + def test_it_finds_integration_tests_for_implementation + pattern = "**/{{test_,spec_,integration_test_}feature,feature{_test,_spec,_integration_test}}.rb" + stub_glob_pattern(pattern, ["test/feature_integration_test.rb"]) + + impl_path = "/workspace/lib/feature.rb" + expected = ["/workspace/test/feature_integration_test.rb"] + + result = RubyLsp::Requests::GotoRelevantFile.new(impl_path).perform + assert_equal(expected, result) + end + + private + + def stub_glob_pattern(pattern, matches) + Dir.stubs(:glob).with(pattern).returns(matches) + end +end