diff --git a/lib/ruby_lsp/listeners/completion.rb b/lib/ruby_lsp/listeners/completion.rb index eaf003ff3..d95d64b40 100644 --- a/lib/ruby_lsp/listeners/completion.rb +++ b/lib/ruby_lsp/listeners/completion.rb @@ -118,6 +118,9 @@ def on_constant_read_node_enter(node) top_level?(complete_name), ) end + + # Add filename-based class suggestion if appropriate + add_filename_based_class_completion(node, name, range) end # Handle completion on namespaced constant references (e.g. `Foo::Bar`) @@ -697,6 +700,45 @@ def top_level?(entry_name) false end + + # Add filename-based class completion + #: (Prism::ConstantReadNode node, String name, Interface::Range range) -> void + def add_filename_based_class_completion(node, name, range) + filename_class = filename_to_class_name + return unless filename_class&.start_with?(name) + + # Don't suggest if a constant with this exact name already exists + existing_constants = @index.constant_completion_candidates(filename_class, @node_context.nesting) + return unless existing_constants.empty? + + # Don't suggest if there are already other matching constants for the partial name (to avoid conflicts) + candidates = @index.constant_completion_candidates(name, @node_context.nesting) + return unless candidates.empty? + + @response_builder << Interface::CompletionItem.new( + label: filename_class, + filter_text: filename_class, + text_edit: Interface::TextEdit.new(range: range, new_text: filename_class), + kind: Constant::CompletionItemKind::CLASS, + label_details: Interface::CompletionItemLabelDetails.new( + description: "class suggested from filename", + ), + data: { + filename_suggestion: true, + }, + ) + end + + # Convert filename to PascalCase class name + #: -> String? + def filename_to_class_name + path = @uri.path + return unless path&.end_with?(".rb") + + basename = File.basename(path, ".rb") + # Convert snake_case to PascalCase + basename.split("_").map(&:capitalize).join + end end end end diff --git a/test/requests/completion_test.rb b/test/requests/completion_test.rb index 0b1cde627..9ceda4d2d 100644 --- a/test/requests/completion_test.rb +++ b/test/requests/completion_test.rb @@ -1734,6 +1734,123 @@ def baz end end + def test_completion_for_filename_based_class_suggestion + source = <<~RUBY + # Empty file with just a comment + Stud + RUBY + + with_server(source, stub_no_typechecker: true) do |server| + with_file_structure(server) do |tmpdir| + uri = URI("file://#{tmpdir}/student_profile.rb") + server.process_message({ + method: "textDocument/didOpen", + params: { + textDocument: { + uri: uri, + text: source, + version: 1, + languageId: "ruby", + }, + }, + }) + + server.process_message(id: 1, method: "textDocument/completion", params: { + textDocument: { uri: uri }, + position: { line: 1, character: 4 }, + }) + + result = server.pop_response.response + + student_profile_item = result.find { |item| item.label == "StudentProfile" } + + refute_nil(student_profile_item) + assert_equal("StudentProfile", student_profile_item.text_edit.new_text) + assert_equal(LanguageServer::Protocol::Constant::CompletionItemKind::CLASS, student_profile_item.kind) + assert_equal("class suggested from filename", student_profile_item.label_details.description) + end + end + end + + def test_completion_for_filename_based_class_suggestion_not_in_populated_file + source = <<~RUBY + class ExistingClass + def some_method + Stud + end + end + RUBY + + with_server(source, stub_no_typechecker: true) do |server| + with_file_structure(server) do |tmpdir| + uri = URI("file://#{tmpdir}/student_profile.rb") + server.process_message({ + method: "textDocument/didOpen", + params: { + textDocument: { + uri: uri, + text: source, + version: 1, + languageId: "ruby", + }, + }, + }) + + server.process_message(id: 1, method: "textDocument/completion", params: { + textDocument: { uri: uri }, + position: { line: 2, character: 8 }, + }) + + result = server.pop_response.response + assert_includes(result.map(&:label), "StudentProfile") + + descriptions = result.map { _1.label_details.description } + assert_equal(["class suggested from filename"], descriptions) + end + end + end + + def test_completion_for_filename_based_class_suggestion_not_when_constant_exists + source = <<~RUBY + class StudentProfile + # Existing class with same name as filename + end + + Stud + RUBY + + with_server(source, stub_no_typechecker: true) do |server| + with_file_structure(server) do |tmpdir| + uri = URI("file://#{tmpdir}/student_profile.rb") + server.process_message({ + method: "textDocument/didOpen", + params: { + textDocument: { + uri: uri, + text: source, + version: 1, + languageId: "ruby", + }, + }, + }) + + server.process_message(id: 1, method: "textDocument/completion", params: { + textDocument: { uri: uri }, + position: { line: 4, character: 4 }, + }) + + result = server.pop_response.response + student_profile_items = result.filter { |item| item.label == "StudentProfile" } + + # Should have exactly one StudentProfile (from regular completion, not filename-based) + assert_equal(1, student_profile_items.length) + + descriptions = student_profile_items.map { _1.label_details.description } + refute_includes(descriptions, "class suggested from filename") + end + end + end + private def with_file_structure(server, &block)