|
| 1 | +# typed: strict |
| 2 | +# frozen_string_literal: true |
| 3 | + |
| 4 | +module RubyLsp |
| 5 | + module Requests |
| 6 | + #  |
| 7 | + # |
| 8 | + # The [completion](https://microsoft.github.io/language-server-protocol/specification#textDocument_completion) |
| 9 | + # suggests possible completions according to what the developer is typing. Currently, completion is support for |
| 10 | + # - require paths |
| 11 | + # - classes, modules and constant names |
| 12 | + # |
| 13 | + # # Example |
| 14 | + # |
| 15 | + # ```ruby |
| 16 | + # require "ruby_lsp/requests" # --> completion: suggests `base_request`, `code_actions`, ... |
| 17 | + # |
| 18 | + # RubyLsp::Requests:: # --> completion: suggests `Completion`, `Hover`, ... |
| 19 | + # ``` |
| 20 | + class Completion < Listener |
| 21 | + extend T::Sig |
| 22 | + extend T::Generic |
| 23 | + |
| 24 | + ResponseType = type_member { { fixed: T::Array[Interface::CompletionItem] } } |
| 25 | + |
| 26 | + sig { override.returns(ResponseType) } |
| 27 | + attr_reader :_response |
| 28 | + |
| 29 | + sig do |
| 30 | + params( |
| 31 | + index: RubyIndexer::Index, |
| 32 | + nesting: T::Array[String], |
| 33 | + emitter: EventEmitter, |
| 34 | + message_queue: Thread::Queue, |
| 35 | + ).void |
| 36 | + end |
| 37 | + def initialize(index, nesting, emitter, message_queue) |
| 38 | + super(emitter, message_queue) |
| 39 | + @_response = T.let([], ResponseType) |
| 40 | + @index = index |
| 41 | + @nesting = nesting |
| 42 | + |
| 43 | + emitter.register(self, :on_tstring_content, :on_const_path_ref, :on_const, :on_top_const_ref) |
| 44 | + end |
| 45 | + |
| 46 | + sig { params(node: SyntaxTree::TStringContent).void } |
| 47 | + def on_tstring_content(node) |
| 48 | + @index.search_require_paths(node.value).map!(&:require_path).sort!.each do |path| |
| 49 | + @_response << build_completion(T.must(path), node) |
| 50 | + end |
| 51 | + end |
| 52 | + |
| 53 | + # Handle completion on regular constant references (e.g. `Bar`) |
| 54 | + sig { params(node: SyntaxTree::Const).void } |
| 55 | + def on_const(node) |
| 56 | + return if DependencyDetector::HAS_TYPECHECKER |
| 57 | + |
| 58 | + name = node.value |
| 59 | + candidates = @index.prefix_search(name, @nesting) |
| 60 | + candidates.each do |entries| |
| 61 | + @_response << build_entry_completion(name, node, entries, top_level?(T.must(entries.first).name, candidates)) |
| 62 | + end |
| 63 | + end |
| 64 | + |
| 65 | + # Handle completion on namespaced constant references (e.g. `Foo::Bar`) |
| 66 | + sig { params(node: SyntaxTree::ConstPathRef).void } |
| 67 | + def on_const_path_ref(node) |
| 68 | + return if DependencyDetector::HAS_TYPECHECKER |
| 69 | + |
| 70 | + name = full_constant_name(node) |
| 71 | + candidates = @index.prefix_search(name, @nesting) |
| 72 | + candidates.each do |entries| |
| 73 | + @_response << build_entry_completion(name, node, entries, top_level?(T.must(entries.first).name, candidates)) |
| 74 | + end |
| 75 | + end |
| 76 | + |
| 77 | + # Handle completion on top level constant references (e.g. `::Bar`) |
| 78 | + sig { params(node: SyntaxTree::TopConstRef).void } |
| 79 | + def on_top_const_ref(node) |
| 80 | + return if DependencyDetector::HAS_TYPECHECKER |
| 81 | + |
| 82 | + name = full_constant_name(node) |
| 83 | + candidates = @index.prefix_search(name, []) |
| 84 | + candidates.each { |entries| @_response << build_entry_completion(name, node, entries, true) } |
| 85 | + end |
| 86 | + |
| 87 | + private |
| 88 | + |
| 89 | + sig { params(label: String, node: SyntaxTree::TStringContent).returns(Interface::CompletionItem) } |
| 90 | + def build_completion(label, node) |
| 91 | + Interface::CompletionItem.new( |
| 92 | + label: label, |
| 93 | + text_edit: Interface::TextEdit.new( |
| 94 | + range: range_from_syntax_tree_node(node), |
| 95 | + new_text: label, |
| 96 | + ), |
| 97 | + kind: Constant::CompletionItemKind::REFERENCE, |
| 98 | + ) |
| 99 | + end |
| 100 | + |
| 101 | + sig do |
| 102 | + params( |
| 103 | + name: String, |
| 104 | + node: SyntaxTree::Node, |
| 105 | + entries: T::Array[RubyIndexer::Index::Entry], |
| 106 | + top_level: T::Boolean, |
| 107 | + ).returns(Interface::CompletionItem) |
| 108 | + end |
| 109 | + def build_entry_completion(name, node, entries, top_level) |
| 110 | + first_entry = T.must(entries.first) |
| 111 | + kind = case first_entry |
| 112 | + when RubyIndexer::Index::Entry::Class |
| 113 | + Constant::CompletionItemKind::CLASS |
| 114 | + when RubyIndexer::Index::Entry::Module |
| 115 | + Constant::CompletionItemKind::MODULE |
| 116 | + when RubyIndexer::Index::Entry::Constant |
| 117 | + Constant::CompletionItemKind::CONSTANT |
| 118 | + else |
| 119 | + Constant::CompletionItemKind::REFERENCE |
| 120 | + end |
| 121 | + |
| 122 | + insertion_text = first_entry.name.dup |
| 123 | + |
| 124 | + # If we have two entries with the same name inside the current namespace and the user selects the top level |
| 125 | + # option, we have to ensure it's prefixed with `::` or else we're completing the wrong constant. For example: |
| 126 | + # If we have the index with ["Foo::Bar", "Bar"], and we're providing suggestions for `B` inside a `Foo` module, |
| 127 | + # then selecting the `Foo::Bar` option needs to complete to `Bar` and selecting the top level `Bar` option needs |
| 128 | + # to complete to `::Bar`. |
| 129 | + insertion_text.prepend("::") if top_level |
| 130 | + |
| 131 | + # If the user is searching for a constant inside the current namespace, then we prefer completing the short name |
| 132 | + # of that constant. E.g.: |
| 133 | + # |
| 134 | + # module Foo |
| 135 | + # class Bar |
| 136 | + # end |
| 137 | + # |
| 138 | + # Foo::B # --> completion inserts `Bar` instead of `Foo::Bar` |
| 139 | + # end |
| 140 | + @nesting.each { |namespace| insertion_text.delete_prefix!("#{namespace}::") } |
| 141 | + |
| 142 | + # When using a top level constant reference (e.g.: `::Bar`), the editor includes the `::` as part of the filter. |
| 143 | + # For these top level references, we need to include the `::` as part of the filter text or else it won't match |
| 144 | + # the right entries in the index |
| 145 | + Interface::CompletionItem.new( |
| 146 | + label: first_entry.name, |
| 147 | + filter_text: top_level ? "::#{first_entry.name}" : first_entry.name, |
| 148 | + text_edit: Interface::TextEdit.new( |
| 149 | + range: range_from_syntax_tree_node(node), |
| 150 | + new_text: insertion_text, |
| 151 | + ), |
| 152 | + kind: kind, |
| 153 | + label_details: Interface::CompletionItemLabelDetails.new( |
| 154 | + description: entries.map(&:file_name).join(","), |
| 155 | + ), |
| 156 | + documentation: markdown_from_index_entries(first_entry.name, entries), |
| 157 | + ) |
| 158 | + end |
| 159 | + |
| 160 | + # Check if the `entry_name` has potential conflicts in `candidates`, so that we use a top level reference instead |
| 161 | + # of a short name |
| 162 | + sig { params(entry_name: String, candidates: T::Array[T::Array[RubyIndexer::Index::Entry]]).returns(T::Boolean) } |
| 163 | + def top_level?(entry_name, candidates) |
| 164 | + candidates.any? { |entries| T.must(entries.first).name == "#{@nesting.join("::")}::#{entry_name}" } |
| 165 | + end |
| 166 | + end |
| 167 | + end |
| 168 | +end |
0 commit comments