Skip to content

Commit

Permalink
Add infrastructure for keyword documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Sep 30, 2024
1 parent b4280d2 commit 0c9c5e3
Show file tree
Hide file tree
Showing 9 changed files with 193 additions and 2 deletions.
1 change: 1 addition & 0 deletions lib/ruby_lsp/internal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
require "ruby_indexer/ruby_indexer"
require "core_ext/uri"
require "ruby_lsp/utils"
require "ruby_lsp/static_docs"
require "ruby_lsp/scope"
require "ruby_lsp/global_state"
require "ruby_lsp/server"
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_lsp/listeners/completion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ def add_keyword_completions(node, name)
text_edit: Interface::TextEdit.new(range: range, new_text: keyword),
kind: Constant::CompletionItemKind::KEYWORD,
data: {
skip_resolve: true,
keyword: true,
},
)
end
Expand Down
19 changes: 19 additions & 0 deletions lib/ruby_lsp/listeners/hover.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Hover
Prism::InterpolatedStringNode,
Prism::SuperNode,
Prism::ForwardingSuperNode,
Prism::YieldNode,
],
T::Array[T.class_of(Prism::Node)],
)
Expand Down Expand Up @@ -71,6 +72,7 @@ def initialize(response_builder, global_state, uri, node_context, dispatcher, so
:on_forwarding_super_node_enter,
:on_string_node_enter,
:on_interpolated_string_node_enter,
:on_yield_node_enter,
)
end

Expand Down Expand Up @@ -166,6 +168,11 @@ def on_forwarding_super_node_enter(node)
handle_super_node_hover
end

sig { params(node: Prism::YieldNode).void }
def on_yield_node_enter(node)
show_keyword_documentation(node.keyword)
end

private

sig { params(node: T.any(Prism::InterpolatedStringNode, Prism::StringNode)).void }
Expand Down Expand Up @@ -193,6 +200,18 @@ def generate_heredoc_hover(node)
end
end

sig { params(keyword: String).void }
def show_keyword_documentation(keyword)
content = KEYWORD_DOCS[keyword]
return unless content

doc_path = File.join(STATIC_DOCS_PATH, "#{keyword}.md")

@response_builder.push("```ruby\n#{keyword}\n```", category: :title)
@response_builder.push("[Read more](#{doc_path})", category: :links)
@response_builder.push(content, category: :documentation)
end

sig { void }
def handle_super_node_hover
# Sorbet can handle super hover on typed true or higher
Expand Down
29 changes: 29 additions & 0 deletions lib/ruby_lsp/requests/completion_resolve.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ def perform
# For example, forgetting to return the `insertText` included in the original item will make the editor use the
# `label` for the text edit instead
label = @item[:label].dup
return keyword_resolve(@item) if @item.dig(:data, :keyword)

entries = @index[label] || []

owner_name = @item.dig(:data, :owner_name)
Expand Down Expand Up @@ -72,6 +74,33 @@ def perform

@item
end

private

sig { params(item: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
def keyword_resolve(item)
keyword = item[:label]
content = KEYWORD_DOCS[keyword]

if content
doc_path = File.join(STATIC_DOCS_PATH, "#{keyword}.md")

@item[:documentation] = Interface::MarkupContent.new(
kind: "markdown",
value: <<~MARKDOWN.chomp,
```ruby
#{keyword}
```
[Read more](#{doc_path})
#{content}
MARKDOWN
)
end

item
end
end
end
end
15 changes: 15 additions & 0 deletions lib/ruby_lsp/static_docs.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# typed: strict
# frozen_string_literal: true

module RubyLsp
# The path to the `static_docs` directory, where we keep long form static documentation
STATIC_DOCS_PATH = T.let(File.join(File.dirname(File.dirname(T.must(__dir__))), "static_docs"), String)

# A map of keyword => short documentation to be displayed on hover or completion
KEYWORD_DOCS = T.let(
{
"yield" => "Invokes the passed block with the given arguments",
}.freeze,
T::Hash[String, String],
)
end
2 changes: 1 addition & 1 deletion ruby-lsp.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Gem::Specification.new do |s|
s.homepage = "https://github.com/Shopify/ruby-lsp"
s.license = "MIT"

s.files = Dir.glob("lib/**/*.rb") + ["README.md", "VERSION", "LICENSE.txt"]
s.files = Dir.glob("lib/**/*.rb") + ["README.md", "VERSION", "LICENSE.txt"] + Dir.glob("static_docs/**/*.md")
s.bindir = "exe"
s.executables = ["ruby-lsp", "ruby-lsp-check"]
s.require_paths = ["lib"]
Expand Down
81 changes: 81 additions & 0 deletions static_docs/yield.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Yield

In Ruby, every method implicitly accepts a block, even when not included in the parameters list.

```ruby
def foo
end

foo { 123 } # works!
```

The `yield` keyword is used to invoke the block that was passed with arguments.

```ruby
# Consider this method call. The block being passed to the method `foo` accepts an argument called `a`.
# It then takes whatever argument was passed and multiplies it by 2
foo do |a|
a * 2
end

# In the `foo` method declaration, we can use `yield` to invoke the block that was passed and provide the block
# with the value for the `a` argument
def foo
# Invoke the block passed to `foo` with the number 10 as the argument `a`
result = yield(10)
puts result # Will print 20
end
```

If `yield` is used to invoke the block, but no block was passed, that will result in a local jump error.

```ruby
# If we invoke `foo` without a block, trying to `yield` will fail
foo

# `foo': no block given (yield) (LocalJumpError)
```

We can decide to use `yield` conditionally by using Ruby's `block_given?` method, which will return `true` if a block
was passed to the method.

```ruby
def foo
# If a block is passed when invoking `foo`, call the block with argument 10 and print the result.
# Otherwise, just print that no block was passed
if block_given?
result = yield(10)
puts result
else
puts "No block passed!"
end
end

foo do |a|
a * 2
end
# => 20

foo
# => No block passed!
```

## Block parameter

In addition to implicit blocks, Ruby also allows developers to use explicit block parameters as part of the method's
signature. In this scenario, we can use the reference to the block directly instead of relying on the `yield` keyword.

```ruby
# Block parameters are prefixed with & and a name
def foo(&my_block_param)
# If a block was passed to `foo`, my_block_param will be a `Proc` object. Otherwise, it will be `nil`. We can use
# that to check for its presence
if my_block_param
# Explicit block parameters are invoked using the method `call`, which is present in all `Proc` objects
result = my_block_param.call(10)
puts result
else
puts "No block passed!"
end
end
```
25 changes: 25 additions & 0 deletions test/requests/completion_resolve_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,29 @@ def foo(a, b, c)
assert_match("Learn more about guessed types", result[:documentation].value)
end
end

def test_resolve_for_keywords
source = +<<~RUBY
def foo
yield
end
RUBY

with_server(source, stub_no_typechecker: true) do |server, _uri|
existing_item = {
label: "yield",
kind: RubyLsp::Constant::CompletionItemKind::KEYWORD,
data: { keyword: true },
}

server.process_message(id: 1, method: "completionItem/resolve", params: existing_item)

result = server.pop_response.response
contents = result[:documentation].value

assert_match("```ruby\nyield\n```", contents)
assert_match(T.must(RubyLsp::KEYWORD_DOCS["yield"]), contents)
assert_match("[Read more](#{RubyLsp::STATIC_DOCS_PATH}/yield.md)", contents)
end
end
end
21 changes: 21 additions & 0 deletions test/requests/hover_expectations_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,27 @@ def name; end
end
end

def test_hover_for_keywords
source = <<~RUBY
def foo
yield
end
RUBY

with_server(source) do |server, uri|
server.process_message(
id: 1,
method: "textDocument/hover",
params: { textDocument: { uri: uri }, position: { character: 2, line: 1 } },
)

contents = server.pop_response.response.contents.value
assert_match("```ruby\nyield\n```", contents)
assert_match(T.must(RubyLsp::KEYWORD_DOCS["yield"]), contents)
assert_match("[Read more](#{RubyLsp::STATIC_DOCS_PATH}/yield.md)", contents)
end
end

private

def create_hover_addon
Expand Down

0 comments on commit 0c9c5e3

Please sign in to comment.