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 autocomplete for classes, modules and constants #957

Merged
merged 1 commit into from
Sep 8, 2023
Merged
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
36 changes: 30 additions & 6 deletions .vscode/settings.json
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"
]
},
]
}
2 changes: 2 additions & 0 deletions lib/ruby_lsp/event_emitter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ def emit_for_target(node)
@listeners[:on_const_path_ref]&.each { |l| T.unsafe(l).on_const_path_ref(node) }
when SyntaxTree::Const
@listeners[:on_const]&.each { |l| T.unsafe(l).on_const(node) }
when SyntaxTree::TopConstRef
@listeners[:on_top_const_ref]&.each { |l| T.unsafe(l).on_top_const_ref(node) }
end
end

Expand Down
30 changes: 23 additions & 7 deletions lib/ruby_lsp/executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -474,12 +474,18 @@ def completion(uri, position)
return unless document.parsed?

char_position = document.create_scanner.find_char_position(position)
matched, parent = document.locate(
T.must(document.tree),
char_position,
node_types: [SyntaxTree::Command, SyntaxTree::CommandCall, SyntaxTree::CallNode],
)

# When the user types in the first letter of a constant name, we actually receive the position of the next
# immediate character. We check to see if the character is uppercase and then remove the offset to try to locate
# the node, as it could not be a constant
target_node_types = if ("A".."Z").cover?(document.source[char_position - 1])
Copy link
Contributor

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. Á? 😁

Copy link
Contributor

Choose a reason for hiding this comment

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

Or just _FOO

Copy link
Contributor

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:

irb(main):001:0> _FOO = 1
=> 1
irb(main):002:0> defined? _FOO
=> "local-variable"

Copy link
Contributor

Choose a reason for hiding this comment

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

TIL!

Copy link
Member Author

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.

Copy link
Contributor

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:

irb(main):003> "Á".match?(/[[:upper:]]/)
=> true

Copy link
Contributor

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)

Copy link
Contributor

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 😁

# Define the range of Unicode characters
unicode_range = 0..0x10FFFF

# Initialize an array to store the uppercase characters
uppercase_chars = []

# Iterate over the unicode range
unicode_range.each do |i|
  char = [i].pack('U*')
  if char =~ /[[:upper:]]/
    uppercase_chars << char
  end
rescue # to avoid 'invalid byte sequence in UTF-8' for some values
  nil 
end.compact

# Output the uppercase characters
puts uppercase_chars

(1951 results)

Copy link
Member Author

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.

Copy link
Member

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:

pry> RubyVM::InstructionSequence.compile("Ã = 42", nil, nil, 0, false).to_a.dig(-1, -2, 0) == :setconstant
=> true
pry> RubyVM::InstructionSequence.compile("_FOO = 42", nil, nil, 0, false).to_a.dig(-1, -2, 0) == :setconstant
=> false
pry> RubyVM::InstructionSequence.compile("FOO = 42", nil, nil, 0, false).to_a.dig(-1, -2, 0) == :setconstant
=> true
pry> RubyVM::InstructionSequence.compile("Ҷ = 42", nil, nil, 0, false).to_a.dig(-1, -2, 0) == :setconstant
=> true

char_position -= 1
[SyntaxTree::Const, SyntaxTree::ConstPathRef, SyntaxTree::TopConstRef]
else
[SyntaxTree::Command, SyntaxTree::CommandCall, SyntaxTree::CallNode]
end

matched, parent, nesting = document.locate(T.must(document.tree), char_position, node_types: target_node_types)
return unless matched && parent

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

path_node
when SyntaxTree::Const, SyntaxTree::ConstPathRef
if (parent.is_a?(SyntaxTree::ConstPathRef) || parent.is_a?(SyntaxTree::TopConstRef)) &&
matched.is_a?(SyntaxTree::Const)
parent
else
matched
end
end

return unless target

emitter = EventEmitter.new
listener = Requests::PathCompletion.new(@index, emitter, @message_queue)
listener = Requests::Completion.new(@index, nesting, emitter, @message_queue)
emitter.emit_for_target(target)
listener.response
end
Expand Down Expand Up @@ -641,7 +654,10 @@ def initialize_request(options)
completion_provider = if enabled_features["completion"]
Interface::CompletionOptions.new(
resolve_provider: false,
trigger_characters: ["/"],
trigger_characters: ["/", *"A".."Z"],
completion_item: {
labelDetailsSupport: true,
},
)
end

Expand Down
4 changes: 2 additions & 2 deletions lib/ruby_lsp/requests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ module RubyLsp
# - [CodeActionResolve](rdoc-ref:RubyLsp::Requests::CodeActionResolve)
# - [DocumentHighlight](rdoc-ref:RubyLsp::Requests::DocumentHighlight)
# - [InlayHint](rdoc-ref:RubyLsp::Requests::InlayHints)
# - [PathCompletion](rdoc-ref:RubyLsp::Requests::PathCompletion)
# - [Completion](rdoc-ref:RubyLsp::Requests::Completion)
# - [CodeLens](rdoc-ref:RubyLsp::Requests::CodeLens)
# - [Definition](rdoc-ref:RubyLsp::Requests::Definition)
# - [ShowSyntaxTree](rdoc-ref:RubyLsp::Requests::ShowSyntaxTree)
Expand All @@ -38,7 +38,7 @@ module Requests
autoload :CodeActionResolve, "ruby_lsp/requests/code_action_resolve"
autoload :DocumentHighlight, "ruby_lsp/requests/document_highlight"
autoload :InlayHints, "ruby_lsp/requests/inlay_hints"
autoload :PathCompletion, "ruby_lsp/requests/path_completion"
autoload :Completion, "ruby_lsp/requests/completion"
autoload :CodeLens, "ruby_lsp/requests/code_lens"
autoload :Definition, "ruby_lsp/requests/definition"
autoload :ShowSyntaxTree, "ruby_lsp/requests/show_syntax_tree"
Expand Down
168 changes: 168 additions & 0 deletions lib/ruby_lsp/requests/completion.rb
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
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this dup necessary? Is name mutable?

Copy link
Member Author

Choose a reason for hiding this comment

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

We're mutating it on the lines below. If we don't dup it, we end up mutating the index entry itself.

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
25 changes: 3 additions & 22 deletions lib/ruby_lsp/requests/hover.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,29 +91,10 @@ def generate_hover(name, node)
entries = @index.resolve(name, @nesting)
return unless entries

title = +"```ruby\n#{name}\n```"
definitions = []
content = +""
entries.each do |entry|
loc = entry.location

# We always handle locations as zero based. However, for file links in Markdown we need them to be one based,
# which is why instead of the usual subtraction of 1 to line numbers, we are actually adding 1 to columns. The
# format for VS Code file URIs is `file:///path/to/file.rb#Lstart_line,start_column-end_line,end_column`
uri = URI::Generic.from_path(
path: entry.file_path,
fragment: "L#{loc.start_line},#{loc.start_column + 1}-#{loc.end_line},#{loc.end_column + 1}",
)

definitions << "[#{entry.file_name}](#{uri})"
content << "\n\n#{entry.comments.join("\n")}" unless entry.comments.empty?
end

contents = Interface::MarkupContent.new(
kind: "markdown",
value: "#{title}\n\n**Definitions**: #{definitions.join(" | ")}\n\n#{content}",
@_response = Interface::Hover.new(
range: range_from_syntax_tree_node(node),
contents: markdown_from_index_entries(name, entries),
)
@_response = Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents)
end
end
end
Expand Down
56 changes: 0 additions & 56 deletions lib/ruby_lsp/requests/path_completion.rb

This file was deleted.

Loading
Loading