Skip to content

Commit

Permalink
Add autocomplete for classes, modules and constants
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Sep 8, 2023
1 parent 7bd2869 commit d6dbb33
Show file tree
Hide file tree
Showing 11 changed files with 359 additions and 94 deletions.
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])
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

# 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

0 comments on commit d6dbb33

Please sign in to comment.