-
Notifications
You must be signed in to change notification settings - Fork 154
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 autocomplete for classes, modules and constants #957
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,13 +1,37 @@ | ||
{ | ||
// Set this value to `verbose` to see the full JSON content of LSP requests and responses | ||
"ruby lsp.trace.server": "messages", | ||
"ruby lsp.trace.server": "off", | ||
"[ruby]": { | ||
"editor.defaultFormatter": "Shopify.ruby-lsp", | ||
}, | ||
"cSpell.languageSettings": [ | ||
{ "languageId": "*", "locale": "en", "dictionaries": ["wordsEn"] }, | ||
{ "languageId": "*", "locale": "en-US", "dictionaries": ["wordsEn"] }, | ||
{ "languageId": "*", "dictionaries": ["companies", "softwareTerms", "misc"] }, | ||
{ "languageId": "ruby", "dictionaries": ["ruby"]}, | ||
] | ||
{ | ||
"languageId": "*", | ||
"locale": "en", | ||
"dictionaries": [ | ||
"wordsEn" | ||
] | ||
}, | ||
{ | ||
"languageId": "*", | ||
"locale": "en-US", | ||
"dictionaries": [ | ||
"wordsEn" | ||
] | ||
}, | ||
{ | ||
"languageId": "*", | ||
"dictionaries": [ | ||
"companies", | ||
"softwareTerms", | ||
"misc" | ||
] | ||
}, | ||
{ | ||
"languageId": "ruby", | ||
"dictionaries": [ | ||
"ruby" | ||
] | ||
}, | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
# typed: strict | ||
# frozen_string_literal: true | ||
|
||
module RubyLsp | ||
module Requests | ||
# ![Completion demo](../../completion.gif) | ||
# | ||
# The [completion](https://microsoft.github.io/language-server-protocol/specification#textDocument_completion) | ||
# suggests possible completions according to what the developer is typing. Currently, completion is support for | ||
# - require paths | ||
# - classes, modules and constant names | ||
# | ||
# # Example | ||
# | ||
# ```ruby | ||
# require "ruby_lsp/requests" # --> completion: suggests `base_request`, `code_actions`, ... | ||
# | ||
# RubyLsp::Requests:: # --> completion: suggests `Completion`, `Hover`, ... | ||
# ``` | ||
class Completion < Listener | ||
extend T::Sig | ||
extend T::Generic | ||
|
||
ResponseType = type_member { { fixed: T::Array[Interface::CompletionItem] } } | ||
|
||
sig { override.returns(ResponseType) } | ||
attr_reader :_response | ||
|
||
sig do | ||
params( | ||
index: RubyIndexer::Index, | ||
nesting: T::Array[String], | ||
emitter: EventEmitter, | ||
message_queue: Thread::Queue, | ||
).void | ||
end | ||
def initialize(index, nesting, emitter, message_queue) | ||
super(emitter, message_queue) | ||
@_response = T.let([], ResponseType) | ||
@index = index | ||
@nesting = nesting | ||
|
||
emitter.register(self, :on_tstring_content, :on_const_path_ref, :on_const, :on_top_const_ref) | ||
end | ||
|
||
sig { params(node: SyntaxTree::TStringContent).void } | ||
def on_tstring_content(node) | ||
@index.search_require_paths(node.value).map!(&:require_path).sort!.each do |path| | ||
@_response << build_completion(T.must(path), node) | ||
end | ||
end | ||
|
||
# Handle completion on regular constant references (e.g. `Bar`) | ||
sig { params(node: SyntaxTree::Const).void } | ||
def on_const(node) | ||
return if DependencyDetector::HAS_TYPECHECKER | ||
|
||
name = node.value | ||
candidates = @index.prefix_search(name, @nesting) | ||
candidates.each do |entries| | ||
@_response << build_entry_completion(name, node, entries, top_level?(T.must(entries.first).name, candidates)) | ||
end | ||
end | ||
|
||
# Handle completion on namespaced constant references (e.g. `Foo::Bar`) | ||
sig { params(node: SyntaxTree::ConstPathRef).void } | ||
def on_const_path_ref(node) | ||
return if DependencyDetector::HAS_TYPECHECKER | ||
|
||
name = full_constant_name(node) | ||
candidates = @index.prefix_search(name, @nesting) | ||
candidates.each do |entries| | ||
@_response << build_entry_completion(name, node, entries, top_level?(T.must(entries.first).name, candidates)) | ||
end | ||
end | ||
|
||
# Handle completion on top level constant references (e.g. `::Bar`) | ||
sig { params(node: SyntaxTree::TopConstRef).void } | ||
def on_top_const_ref(node) | ||
return if DependencyDetector::HAS_TYPECHECKER | ||
|
||
name = full_constant_name(node) | ||
candidates = @index.prefix_search(name, []) | ||
candidates.each { |entries| @_response << build_entry_completion(name, node, entries, true) } | ||
end | ||
|
||
private | ||
|
||
sig { params(label: String, node: SyntaxTree::TStringContent).returns(Interface::CompletionItem) } | ||
def build_completion(label, node) | ||
Interface::CompletionItem.new( | ||
label: label, | ||
text_edit: Interface::TextEdit.new( | ||
range: range_from_syntax_tree_node(node), | ||
new_text: label, | ||
), | ||
kind: Constant::CompletionItemKind::REFERENCE, | ||
) | ||
end | ||
|
||
sig do | ||
params( | ||
name: String, | ||
node: SyntaxTree::Node, | ||
entries: T::Array[RubyIndexer::Index::Entry], | ||
top_level: T::Boolean, | ||
).returns(Interface::CompletionItem) | ||
end | ||
def build_entry_completion(name, node, entries, top_level) | ||
first_entry = T.must(entries.first) | ||
kind = case first_entry | ||
when RubyIndexer::Index::Entry::Class | ||
Constant::CompletionItemKind::CLASS | ||
when RubyIndexer::Index::Entry::Module | ||
Constant::CompletionItemKind::MODULE | ||
when RubyIndexer::Index::Entry::Constant | ||
Constant::CompletionItemKind::CONSTANT | ||
else | ||
Constant::CompletionItemKind::REFERENCE | ||
end | ||
|
||
insertion_text = first_entry.name.dup | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this dup necessary? Is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're mutating it on the lines below. If we don't We were actually bit by this in the past a = first_entry.name # => Foo::Bar
a.delete_prefix!("Foo::")
first_entry.name # => Bar |
||
|
||
# If we have two entries with the same name inside the current namespace and the user selects the top level | ||
# option, we have to ensure it's prefixed with `::` or else we're completing the wrong constant. For example: | ||
# If we have the index with ["Foo::Bar", "Bar"], and we're providing suggestions for `B` inside a `Foo` module, | ||
# then selecting the `Foo::Bar` option needs to complete to `Bar` and selecting the top level `Bar` option needs | ||
# to complete to `::Bar`. | ||
insertion_text.prepend("::") if top_level | ||
|
||
# If the user is searching for a constant inside the current namespace, then we prefer completing the short name | ||
# of that constant. E.g.: | ||
# | ||
# module Foo | ||
# class Bar | ||
# end | ||
# | ||
# Foo::B # --> completion inserts `Bar` instead of `Foo::Bar` | ||
# end | ||
@nesting.each { |namespace| insertion_text.delete_prefix!("#{namespace}::") } | ||
|
||
# When using a top level constant reference (e.g.: `::Bar`), the editor includes the `::` as part of the filter. | ||
# For these top level references, we need to include the `::` as part of the filter text or else it won't match | ||
# the right entries in the index | ||
Interface::CompletionItem.new( | ||
label: first_entry.name, | ||
filter_text: top_level ? "::#{first_entry.name}" : first_entry.name, | ||
text_edit: Interface::TextEdit.new( | ||
range: range_from_syntax_tree_node(node), | ||
new_text: insertion_text, | ||
), | ||
kind: kind, | ||
label_details: Interface::CompletionItemLabelDetails.new( | ||
description: entries.map(&:file_name).join(","), | ||
), | ||
documentation: markdown_from_index_entries(first_entry.name, entries), | ||
) | ||
end | ||
|
||
# Check if the `entry_name` has potential conflicts in `candidates`, so that we use a top level reference instead | ||
# of a short name | ||
sig { params(entry_name: String, candidates: T::Array[T::Array[RubyIndexer::Index::Entry]]).returns(T::Boolean) } | ||
def top_level?(entry_name, candidates) | ||
candidates.any? { |entries| T.must(entries.first).name == "#{@nesting.join("::")}::#{entry_name}" } | ||
end | ||
end | ||
end | ||
end |
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about constants beginning with letters from other alphabets, e.g.
Á
? 😁There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or just
_FOO
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_FOO
would just be an odd-looking local variable:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TIL!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do we get all upper case characters including the ones with accents in Ruby? I never seen this done before. And we need to return the list of trigger characters to the editor.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe with
/[[:upper:]]/
, if that's consistent with what Ruby considers for constants:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(if you can find the lower and upper bounds, you can use that for the input)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With a little help from ChatGPT 😁
(1951 results)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I created #990 to explore this in more detail. For now, let's ship without unicode support.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is usually best to ask Ruby to compile a snippet with the given input and see if Ruby treats it as a constant: