Skip to content

Commit 721dbda

Browse files
authored
Add autocomplete for classes, modules and constants (#957)
1 parent 7835856 commit 721dbda

File tree

11 files changed

+359
-94
lines changed

11 files changed

+359
-94
lines changed

.vscode/settings.json

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,37 @@
11
{
22
// Set this value to `verbose` to see the full JSON content of LSP requests and responses
3-
"ruby lsp.trace.server": "messages",
3+
"ruby lsp.trace.server": "off",
44
"[ruby]": {
55
"editor.defaultFormatter": "Shopify.ruby-lsp",
66
},
77
"cSpell.languageSettings": [
8-
{ "languageId": "*", "locale": "en", "dictionaries": ["wordsEn"] },
9-
{ "languageId": "*", "locale": "en-US", "dictionaries": ["wordsEn"] },
10-
{ "languageId": "*", "dictionaries": ["companies", "softwareTerms", "misc"] },
11-
{ "languageId": "ruby", "dictionaries": ["ruby"]},
12-
]
8+
{
9+
"languageId": "*",
10+
"locale": "en",
11+
"dictionaries": [
12+
"wordsEn"
13+
]
14+
},
15+
{
16+
"languageId": "*",
17+
"locale": "en-US",
18+
"dictionaries": [
19+
"wordsEn"
20+
]
21+
},
22+
{
23+
"languageId": "*",
24+
"dictionaries": [
25+
"companies",
26+
"softwareTerms",
27+
"misc"
28+
]
29+
},
30+
{
31+
"languageId": "ruby",
32+
"dictionaries": [
33+
"ruby"
34+
]
35+
},
36+
]
1337
}

lib/ruby_lsp/event_emitter.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def emit_for_target(node)
4848
@listeners[:on_const_path_ref]&.each { |l| T.unsafe(l).on_const_path_ref(node) }
4949
when SyntaxTree::Const
5050
@listeners[:on_const]&.each { |l| T.unsafe(l).on_const(node) }
51+
when SyntaxTree::TopConstRef
52+
@listeners[:on_top_const_ref]&.each { |l| T.unsafe(l).on_top_const_ref(node) }
5153
end
5254
end
5355

lib/ruby_lsp/executor.rb

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -474,12 +474,18 @@ def completion(uri, position)
474474
return unless document.parsed?
475475

476476
char_position = document.create_scanner.find_char_position(position)
477-
matched, parent = document.locate(
478-
T.must(document.tree),
479-
char_position,
480-
node_types: [SyntaxTree::Command, SyntaxTree::CommandCall, SyntaxTree::CallNode],
481-
)
482477

478+
# When the user types in the first letter of a constant name, we actually receive the position of the next
479+
# immediate character. We check to see if the character is uppercase and then remove the offset to try to locate
480+
# the node, as it could not be a constant
481+
target_node_types = if ("A".."Z").cover?(document.source[char_position - 1])
482+
char_position -= 1
483+
[SyntaxTree::Const, SyntaxTree::ConstPathRef, SyntaxTree::TopConstRef]
484+
else
485+
[SyntaxTree::Command, SyntaxTree::CommandCall, SyntaxTree::CallNode]
486+
end
487+
488+
matched, parent, nesting = document.locate(T.must(document.tree), char_position, node_types: target_node_types)
483489
return unless matched && parent
484490

485491
target = case matched
@@ -500,12 +506,19 @@ def completion(uri, position)
500506
return unless (path_node.location.start_char..path_node.location.end_char).cover?(char_position)
501507

502508
path_node
509+
when SyntaxTree::Const, SyntaxTree::ConstPathRef
510+
if (parent.is_a?(SyntaxTree::ConstPathRef) || parent.is_a?(SyntaxTree::TopConstRef)) &&
511+
matched.is_a?(SyntaxTree::Const)
512+
parent
513+
else
514+
matched
515+
end
503516
end
504517

505518
return unless target
506519

507520
emitter = EventEmitter.new
508-
listener = Requests::PathCompletion.new(@index, emitter, @message_queue)
521+
listener = Requests::Completion.new(@index, nesting, emitter, @message_queue)
509522
emitter.emit_for_target(target)
510523
listener.response
511524
end
@@ -641,7 +654,10 @@ def initialize_request(options)
641654
completion_provider = if enabled_features["completion"]
642655
Interface::CompletionOptions.new(
643656
resolve_provider: false,
644-
trigger_characters: ["/"],
657+
trigger_characters: ["/", *"A".."Z"],
658+
completion_item: {
659+
labelDetailsSupport: true,
660+
},
645661
)
646662
end
647663

lib/ruby_lsp/requests.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ module RubyLsp
1717
# - [CodeActionResolve](rdoc-ref:RubyLsp::Requests::CodeActionResolve)
1818
# - [DocumentHighlight](rdoc-ref:RubyLsp::Requests::DocumentHighlight)
1919
# - [InlayHint](rdoc-ref:RubyLsp::Requests::InlayHints)
20-
# - [PathCompletion](rdoc-ref:RubyLsp::Requests::PathCompletion)
20+
# - [Completion](rdoc-ref:RubyLsp::Requests::Completion)
2121
# - [CodeLens](rdoc-ref:RubyLsp::Requests::CodeLens)
2222
# - [Definition](rdoc-ref:RubyLsp::Requests::Definition)
2323
# - [ShowSyntaxTree](rdoc-ref:RubyLsp::Requests::ShowSyntaxTree)
@@ -38,7 +38,7 @@ module Requests
3838
autoload :CodeActionResolve, "ruby_lsp/requests/code_action_resolve"
3939
autoload :DocumentHighlight, "ruby_lsp/requests/document_highlight"
4040
autoload :InlayHints, "ruby_lsp/requests/inlay_hints"
41-
autoload :PathCompletion, "ruby_lsp/requests/path_completion"
41+
autoload :Completion, "ruby_lsp/requests/completion"
4242
autoload :CodeLens, "ruby_lsp/requests/code_lens"
4343
autoload :Definition, "ruby_lsp/requests/definition"
4444
autoload :ShowSyntaxTree, "ruby_lsp/requests/show_syntax_tree"

lib/ruby_lsp/requests/completion.rb

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyLsp
5+
module Requests
6+
# ![Completion demo](../../completion.gif)
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

lib/ruby_lsp/requests/hover.rb

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -91,29 +91,10 @@ def generate_hover(name, node)
9191
entries = @index.resolve(name, @nesting)
9292
return unless entries
9393

94-
title = +"```ruby\n#{name}\n```"
95-
definitions = []
96-
content = +""
97-
entries.each do |entry|
98-
loc = entry.location
99-
100-
# We always handle locations as zero based. However, for file links in Markdown we need them to be one based,
101-
# which is why instead of the usual subtraction of 1 to line numbers, we are actually adding 1 to columns. The
102-
# format for VS Code file URIs is `file:///path/to/file.rb#Lstart_line,start_column-end_line,end_column`
103-
uri = URI::Generic.from_path(
104-
path: entry.file_path,
105-
fragment: "L#{loc.start_line},#{loc.start_column + 1}-#{loc.end_line},#{loc.end_column + 1}",
106-
)
107-
108-
definitions << "[#{entry.file_name}](#{uri})"
109-
content << "\n\n#{entry.comments.join("\n")}" unless entry.comments.empty?
110-
end
111-
112-
contents = Interface::MarkupContent.new(
113-
kind: "markdown",
114-
value: "#{title}\n\n**Definitions**: #{definitions.join(" | ")}\n\n#{content}",
94+
@_response = Interface::Hover.new(
95+
range: range_from_syntax_tree_node(node),
96+
contents: markdown_from_index_entries(name, entries),
11597
)
116-
@_response = Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents)
11798
end
11899
end
119100
end

lib/ruby_lsp/requests/path_completion.rb

Lines changed: 0 additions & 56 deletions
This file was deleted.

0 commit comments

Comments
 (0)