Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add GotoRelevantFile functionality #2985

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/ruby_lsp/internal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
83 changes: 83 additions & 0 deletions lib/ruby_lsp/requests/goto_relevant_file.rb
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use GlobalState#workspace_path instead.

@path = T.let(path.delete_prefix(@workspace_path), String)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vinistock I think it may be convenient to have a URI#to_workspace_relative_path that matches VS Code's Copy Relative Path value. WDYT?

end

sig { override.returns(T::Array[String]) }
def perform
find_relevant_paths
end

private

sig { returns(T::Array[String]) }
def find_relevant_paths
workspace_path = Dir.pwd
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's take in GlobalState and use its workspace_path method to get this value. I think Requests::Rename is a good reference.

relative_path = @path.delete_prefix(workspace_path)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't @path already the value without workspace path prefix?


candidate_paths = Dir.glob(File.join("**", relevant_filename_pattern(relative_path)))
return [] if candidate_paths.empty?

find_most_similar_with_jacaard(relative_path, 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}/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can store these patterns in constants too? Do we need to recompute them for each request?


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(",_")}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the above.


"{{#{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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd appreciate a comment explaining what jacaard means 😂

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
22 changes: 22 additions & 0 deletions lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -290,6 +292,7 @@ def run_initialize(message)
experimental: {
addon_detection: true,
compose_bundle: true,
goto_relevant_file: true,
},
),
serverInfo: {
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions project-words
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ ipairs
Ispec
Itest
ivar
jacaard
Jaro
Kaigi
klass
Expand Down
158 changes: 158 additions & 0 deletions test/requests/goto_relevant_file_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# typed: true
# frozen_string_literal: true

require "test_helper"

class GotoRelevantFileTest < Minitest::Test
def setup
Dir.stubs(:pwd).returns("/workspace")
Copy link
Contributor

@andyw8 andyw8 Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little wary of stubbing pwd since if some test helper, or minitest itself, uses it then it may break.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you expand on that?

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
15 changes: 15 additions & 0 deletions vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,23 @@
"command": "rubyLsp.fileOperation",
"when": "rubyLsp.activated && view == 'workbench.explorer.fileView'",
"group": "navigation"
},
{
"command": "rubyLsp.gotoRelevantFile",
"when": "rubyLsp.activated && view == 'workbench.explorer.fileView'",
"group": "navigation"
}
],
"explorer/context": [
{
"command": "rubyLsp.fileOperation",
"when": "rubyLsp.activated",
"group": "2_workspace"
},
{
"command": "rubyLsp.gotoRelevantFile",
"when": "rubyLsp.activated",
"group": "2_workspace"
}
]
},
Expand Down Expand Up @@ -159,6 +169,11 @@
"category": "Ruby LSP",
"icon": "$(ruby)"
},
{
"command": "rubyLsp.gotoRelevantFile",
"title": "Goto relevant file (test <> source code)",
"category": "Ruby LSP"
},
{
"command": "rubyLsp.collectRubyLspInfo",
"title": "Collect Ruby LSP information for issue reporting",
Expand Down
8 changes: 8 additions & 0 deletions vscode/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,14 @@ export default class Client extends LanguageClient implements ClientInterface {
return super.dispose(timeout);
}

async sendGotoRelevantFileRequest(
uri: vscode.Uri,
): Promise<{ locations: string[] } | null> {
return this.sendRequest("experimental/gotoRelevantFile", {
textDocument: { uri: uri.toString() },
});
}

private async benchmarkMiddleware<T>(
type: string | MessageSignature,
params: any,
Expand Down
1 change: 1 addition & 0 deletions vscode/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export enum Command {
StartServerInDebugMode = "rubyLsp.startServerInDebugMode",
ShowOutput = "rubyLsp.showOutput",
MigrateLaunchConfiguration = "rubyLsp.migrateLaunchConfiguration",
GotoRelevantFile = "rubyLsp.gotoRelevantFile",
}

export interface RubyInterface {
Expand Down
14 changes: 14 additions & 0 deletions vscode/src/rubyLsp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,20 @@ export class RubyLsp {
);
},
),
vscode.commands.registerCommand(Command.GotoRelevantFile, async () => {
const uri = vscode.window.activeTextEditor?.document.uri;
if (!uri) {
return;
}
const response: { locations: string[] } | null | undefined =
await this.currentActiveWorkspace()?.lspClient?.sendGotoRelevantFileRequest(
uri,
);

if (response) {
return openUris(response.locations);
}
}),
];
}

Expand Down
Loading