From b60651e85cf1f36a07289eb8fcd7933777ddb536 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Thu, 19 Sep 2024 17:00:46 -0400 Subject: [PATCH] Add infrastructure for keyword documentation --- lib/ruby_lsp/internal.rb | 1 + lib/ruby_lsp/listeners/completion.rb | 2 +- lib/ruby_lsp/listeners/hover.rb | 19 +++++ lib/ruby_lsp/requests/completion_resolve.rb | 29 ++++++++ lib/ruby_lsp/static_docs.rb | 15 ++++ ruby-lsp.gemspec | 2 +- static_docs/yield.md | 81 +++++++++++++++++++++ test/requests/completion_resolve_test.rb | 25 +++++++ test/requests/hover_expectations_test.rb | 21 ++++++ 9 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 lib/ruby_lsp/static_docs.rb create mode 100644 static_docs/yield.md diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index 6cb064161..138e7e6ac 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -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/parameter_scope" require "ruby_lsp/global_state" require "ruby_lsp/server" diff --git a/lib/ruby_lsp/listeners/completion.rb b/lib/ruby_lsp/listeners/completion.rb index 48d580c5f..5c379267f 100644 --- a/lib/ruby_lsp/listeners/completion.rb +++ b/lib/ruby_lsp/listeners/completion.rb @@ -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 diff --git a/lib/ruby_lsp/listeners/hover.rb b/lib/ruby_lsp/listeners/hover.rb index 3b3f8c2c7..a1ccf6d2e 100644 --- a/lib/ruby_lsp/listeners/hover.rb +++ b/lib/ruby_lsp/listeners/hover.rb @@ -23,6 +23,7 @@ class Hover Prism::StringNode, Prism::SuperNode, Prism::ForwardingSuperNode, + Prism::YieldNode, ], T::Array[T.class_of(Prism::Node)], ) @@ -68,6 +69,7 @@ def initialize(response_builder, global_state, uri, node_context, dispatcher, so :on_instance_variable_target_node_enter, :on_super_node_enter, :on_forwarding_super_node_enter, + :on_yield_node_enter, ) end @@ -153,8 +155,25 @@ 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(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 diff --git a/lib/ruby_lsp/requests/completion_resolve.rb b/lib/ruby_lsp/requests/completion_resolve.rb index 69f94903a..f8248bd7e 100644 --- a/lib/ruby_lsp/requests/completion_resolve.rb +++ b/lib/ruby_lsp/requests/completion_resolve.rb @@ -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) @@ -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 diff --git a/lib/ruby_lsp/static_docs.rb b/lib/ruby_lsp/static_docs.rb new file mode 100644 index 000000000..0b7686c57 --- /dev/null +++ b/lib/ruby_lsp/static_docs.rb @@ -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 diff --git a/ruby-lsp.gemspec b/ruby-lsp.gemspec index 22fea0a76..77abe7b7a 100644 --- a/ruby-lsp.gemspec +++ b/ruby-lsp.gemspec @@ -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"] diff --git a/static_docs/yield.md b/static_docs/yield.md new file mode 100644 index 000000000..22b02febc --- /dev/null +++ b/static_docs/yield.md @@ -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 +``` diff --git a/test/requests/completion_resolve_test.rb b/test/requests/completion_resolve_test.rb index f61fe4a71..343596708 100644 --- a/test/requests/completion_resolve_test.rb +++ b/test/requests/completion_resolve_test.rb @@ -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 diff --git a/test/requests/hover_expectations_test.rb b/test/requests/hover_expectations_test.rb index db37969d6..846f3b0e9 100644 --- a/test/requests/hover_expectations_test.rb +++ b/test/requests/hover_expectations_test.rb @@ -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