From 9af3aa6323fbf66e29c5129bc5149af1cdce62a3 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 22 Apr 2026 11:15:52 -0400 Subject: [PATCH] Complete keyword hover with Rubydex --- lib/ruby_lsp/internal.rb | 1 - lib/ruby_lsp/listeners/hover.rb | 296 ++++++++--- lib/ruby_lsp/requests/completion_resolve.rb | 22 +- lib/ruby_lsp/requests/hover.rb | 7 +- lib/ruby_lsp/requests/request.rb | 36 +- lib/ruby_lsp/requests/support/common.rb | 13 + lib/ruby_lsp/static_docs.rb | 20 - ruby-lsp.gemspec | 4 +- static_docs/break.md | 103 ---- static_docs/yield.md | 81 --- test/requests/completion_resolve_test.rb | 8 +- test/requests/hover_expectations_test.rb | 518 +++++++++++++++++++- 12 files changed, 745 insertions(+), 364 deletions(-) delete mode 100644 lib/ruby_lsp/static_docs.rb delete mode 100644 static_docs/break.md delete mode 100644 static_docs/yield.md diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index 1a2cbdf953..f0b36e0d85 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -39,7 +39,6 @@ require "ruby_lsp/base_server" require "ruby_indexer/ruby_indexer" require "ruby_lsp/utils" -require "ruby_lsp/static_docs" require "ruby_lsp/scope" require "ruby_lsp/client_capabilities" require "ruby_lsp/global_state" diff --git a/lib/ruby_lsp/listeners/hover.rb b/lib/ruby_lsp/listeners/hover.rb index 8aadae7c44..fb7f8689b5 100644 --- a/lib/ruby_lsp/listeners/hover.rb +++ b/lib/ruby_lsp/listeners/hover.rb @@ -6,45 +6,13 @@ module Listeners class Hover include Requests::Support::Common - ALLOWED_TARGETS = [ - Prism::BreakNode, - Prism::CallNode, - Prism::ConstantReadNode, - Prism::ConstantWriteNode, - Prism::ConstantPathNode, - Prism::GlobalVariableAndWriteNode, - Prism::GlobalVariableOperatorWriteNode, - Prism::GlobalVariableOrWriteNode, - Prism::GlobalVariableReadNode, - Prism::GlobalVariableTargetNode, - Prism::GlobalVariableWriteNode, - Prism::InstanceVariableReadNode, - Prism::InstanceVariableAndWriteNode, - Prism::InstanceVariableOperatorWriteNode, - Prism::InstanceVariableOrWriteNode, - Prism::InstanceVariableTargetNode, - Prism::InstanceVariableWriteNode, - Prism::SymbolNode, - Prism::StringNode, - Prism::InterpolatedStringNode, - Prism::SuperNode, - Prism::ForwardingSuperNode, - Prism::YieldNode, - Prism::ClassVariableAndWriteNode, - Prism::ClassVariableOperatorWriteNode, - Prism::ClassVariableOrWriteNode, - Prism::ClassVariableReadNode, - Prism::ClassVariableTargetNode, - Prism::ClassVariableWriteNode, - ] #: Array[singleton(Prism::Node)] - ALLOWED_REMOTE_PROVIDERS = [ "https://github.com", "https://gitlab.com", ].freeze #: Array[String] - #: (ResponseBuilders::Hover response_builder, GlobalState global_state, URI::Generic uri, NodeContext node_context, Prism::Dispatcher dispatcher, SorbetLevel sorbet_level) -> void - def initialize(response_builder, global_state, uri, node_context, dispatcher, sorbet_level) # rubocop:disable Metrics/ParameterLists + #: (ResponseBuilders::Hover response_builder, GlobalState global_state, URI::Generic uri, NodeContext node_context, Prism::Dispatcher dispatcher, SorbetLevel sorbet_level, Hash[Symbol, untyped] position) -> void + def initialize(response_builder, global_state, uri, node_context, dispatcher, sorbet_level, position) # rubocop:disable Metrics/ParameterLists @response_builder = response_builder @global_state = global_state @index = global_state.index #: RubyIndexer::Index @@ -53,45 +21,80 @@ def initialize(response_builder, global_state, uri, node_context, dispatcher, so @path = uri.to_standardized_path #: String? @node_context = node_context @sorbet_level = sorbet_level + @position = position dispatcher.register( self, + :on_alias_global_variable_node_enter, + :on_alias_method_node_enter, + :on_and_node_enter, + :on_begin_node_enter, + :on_block_node_enter, :on_break_node_enter, + :on_call_node_enter, + :on_case_match_node_enter, + :on_case_node_enter, + :on_class_node_enter, + :on_singleton_class_node_enter, + :on_lambda_node_enter, + :on_class_variable_and_write_node_enter, + :on_class_variable_operator_write_node_enter, + :on_class_variable_or_write_node_enter, + :on_class_variable_read_node_enter, + :on_class_variable_target_node_enter, + :on_class_variable_write_node_enter, + :on_constant_path_node_enter, :on_constant_read_node_enter, :on_constant_write_node_enter, - :on_constant_path_node_enter, - :on_call_node_enter, + :on_def_node_enter, + :on_defined_node_enter, + :on_else_node_enter, + :on_ensure_node_enter, + :on_false_node_enter, + :on_for_node_enter, + :on_forwarding_super_node_enter, :on_global_variable_and_write_node_enter, :on_global_variable_operator_write_node_enter, :on_global_variable_or_write_node_enter, :on_global_variable_read_node_enter, :on_global_variable_target_node_enter, :on_global_variable_write_node_enter, - :on_instance_variable_read_node_enter, - :on_instance_variable_write_node_enter, + :on_if_node_enter, + :on_in_node_enter, :on_instance_variable_and_write_node_enter, :on_instance_variable_operator_write_node_enter, :on_instance_variable_or_write_node_enter, + :on_instance_variable_read_node_enter, :on_instance_variable_target_node_enter, - :on_super_node_enter, - :on_forwarding_super_node_enter, - :on_string_node_enter, + :on_instance_variable_write_node_enter, :on_interpolated_string_node_enter, + :on_module_node_enter, + :on_next_node_enter, + :on_nil_node_enter, + :on_or_node_enter, + :on_post_execution_node_enter, + :on_pre_execution_node_enter, + :on_redo_node_enter, + :on_rescue_modifier_node_enter, + :on_rescue_node_enter, + :on_retry_node_enter, + :on_return_node_enter, + :on_self_node_enter, + :on_source_encoding_node_enter, + :on_source_file_node_enter, + :on_source_line_node_enter, + :on_string_node_enter, + :on_super_node_enter, + :on_true_node_enter, + :on_undef_node_enter, + :on_unless_node_enter, + :on_until_node_enter, + :on_when_node_enter, + :on_while_node_enter, :on_yield_node_enter, - :on_class_variable_and_write_node_enter, - :on_class_variable_operator_write_node_enter, - :on_class_variable_or_write_node_enter, - :on_class_variable_read_node_enter, - :on_class_variable_target_node_enter, - :on_class_variable_write_node_enter, ) end - #: (Prism::BreakNode node) -> void - def on_break_node_enter(node) - handle_keyword_documentation(node.keyword) - end - #: (Prism::StringNode node) -> void def on_string_node_enter(node) if @path && File.basename(@path) == GEMFILE_NAME @@ -144,6 +147,12 @@ def on_call_node_enter(node) message = node.message return unless message + # `not x` is parsed as a call to `!` whose message_loc slices to "not" + if node.name == :! && message == "not" + handle_keyword_documentation("not") + return + end + handle_method_hover(message) end @@ -209,19 +218,150 @@ def on_instance_variable_target_node_enter(node) #: (Prism::SuperNode node) -> void def on_super_node_enter(node) - handle_super_node_hover + handle_super_node_hover(node.keyword_loc) end #: (Prism::ForwardingSuperNode node) -> void def on_forwarding_super_node_enter(node) - handle_super_node_hover + handle_super_node_hover(node.location) + end + + #: (Prism::AliasGlobalVariableNode) -> void + def on_alias_global_variable_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::AliasMethodNode) -> void + def on_alias_method_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::AndNode) -> void + def on_and_node_enter(node) = handle_keyword_at_location(node.operator_loc) + + #: (Prism::BeginNode) -> void + def on_begin_node_enter(node) = handle_keyword_at_location(node.begin_keyword_loc, node.end_keyword_loc) + + #: (Prism::BlockNode) -> void + def on_block_node_enter(node) = handle_keyword_at_location(node.opening_loc, node.closing_loc) + + #: (Prism::BreakNode) -> void + def on_break_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::CaseMatchNode) -> void + def on_case_match_node_enter(node) = handle_keyword_at_location(node.case_keyword_loc, node.end_keyword_loc) + + #: (Prism::CaseNode) -> void + def on_case_node_enter(node) = handle_keyword_at_location(node.case_keyword_loc, node.end_keyword_loc) + + #: (Prism::ClassNode) -> void + def on_class_node_enter(node) = handle_keyword_at_location(node.class_keyword_loc, node.end_keyword_loc) + + #: (Prism::SingletonClassNode) -> void + def on_singleton_class_node_enter(node) + handle_keyword_at_location(node.class_keyword_loc, node.end_keyword_loc) + end + + #: (Prism::LambdaNode) -> void + def on_lambda_node_enter(node) = handle_keyword_at_location(node.opening_loc, node.closing_loc) + + #: (Prism::DefNode) -> void + def on_def_node_enter(node) = handle_keyword_at_location(node.def_keyword_loc, node.end_keyword_loc) + + #: (Prism::DefinedNode) -> void + def on_defined_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::ElseNode) -> void + def on_else_node_enter(node) = handle_keyword_at_location(node.else_keyword_loc, node.end_keyword_loc) + + #: (Prism::EnsureNode) -> void + def on_ensure_node_enter(node) = handle_keyword_at_location(node.ensure_keyword_loc, node.end_keyword_loc) + + #: (Prism::FalseNode) -> void + def on_false_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::ForNode) -> void + def on_for_node_enter(node) + handle_keyword_at_location( + node.for_keyword_loc, + node.in_keyword_loc, + node.do_keyword_loc, + node.end_keyword_loc, + ) + end + + #: (Prism::IfNode) -> void + def on_if_node_enter(node) + handle_keyword_at_location(node.if_keyword_loc, node.then_keyword_loc, node.end_keyword_loc) end - #: (Prism::YieldNode node) -> void - def on_yield_node_enter(node) - handle_keyword_documentation(node.keyword) + #: (Prism::InNode) -> void + def on_in_node_enter(node) = handle_keyword_at_location(node.in_loc, node.then_loc) + + #: (Prism::ModuleNode) -> void + def on_module_node_enter(node) = handle_keyword_at_location(node.module_keyword_loc, node.end_keyword_loc) + + #: (Prism::NextNode) -> void + def on_next_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::NilNode) -> void + def on_nil_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::OrNode) -> void + def on_or_node_enter(node) = handle_keyword_at_location(node.operator_loc) + + #: (Prism::PostExecutionNode) -> void + def on_post_execution_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::PreExecutionNode) -> void + def on_pre_execution_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::RedoNode) -> void + def on_redo_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::RescueModifierNode) -> void + def on_rescue_modifier_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::RescueNode) -> void + def on_rescue_node_enter(node) = handle_keyword_at_location(node.keyword_loc, node.then_keyword_loc) + + #: (Prism::RetryNode) -> void + def on_retry_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::ReturnNode) -> void + def on_return_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::SelfNode) -> void + def on_self_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::SourceEncodingNode) -> void + def on_source_encoding_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::SourceFileNode) -> void + def on_source_file_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::SourceLineNode) -> void + def on_source_line_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::TrueNode) -> void + def on_true_node_enter(node) = handle_keyword_at_location(node.location) + + #: (Prism::UndefNode) -> void + def on_undef_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + + #: (Prism::UnlessNode) -> void + def on_unless_node_enter(node) + handle_keyword_at_location(node.keyword_loc, node.then_keyword_loc, node.end_keyword_loc) end + #: (Prism::UntilNode) -> void + def on_until_node_enter(node) = handle_keyword_at_location(node.keyword_loc, node.do_keyword_loc, node.closing_loc) + + #: (Prism::WhenNode) -> void + def on_when_node_enter(node) = handle_keyword_at_location(node.keyword_loc, node.then_keyword_loc) + + #: (Prism::WhileNode) -> void + def on_while_node_enter(node) = handle_keyword_at_location(node.keyword_loc, node.do_keyword_loc, node.closing_loc) + + #: (Prism::YieldNode) -> void + def on_yield_node_enter(node) = handle_keyword_at_location(node.keyword_loc) + #: (Prism::ClassVariableAndWriteNode node) -> void def on_class_variable_and_write_node_enter(node) handle_variable_hover(node.name.to_s) @@ -279,27 +419,37 @@ def generate_heredoc_hover(node) end end - #: (String keyword) -> void - def handle_keyword_documentation(keyword) - content = KEYWORD_DOCS[keyword] - return unless content + #: (String) -> void + def handle_keyword_documentation(name) + keyword = @graph.keyword(name) + return unless keyword - doc_uri = URI::Generic.from_path(path: File.join(STATIC_DOCS_PATH, "#{keyword}.md")) - - @response_builder.push("```ruby\n#{keyword}\n```", category: :title) - @response_builder.push("[Read more](#{doc_uri})", category: :links) - @response_builder.push(content, category: :documentation) + @response_builder.push("```ruby\n#{keyword.name}\n```", category: :title) + @response_builder.push(keyword.documentation, category: :documentation) end - #: -> void - def handle_super_node_hover - # Sorbet can handle super hover on typed true or higher - return if @sorbet_level.true_or_higher? + # Push keyword documentation when the cursor is on one of the provided locations. The keyword name is taken from + # the covering location's slice so that operator forms (`&&`, `||`, `{`, `}`, ternary `? :`) yield no hover — + # their slice is not a keyword in the Rubydex graph. + # + #: (*Prism::Location?) -> void + def handle_keyword_at_location(*locations) + loc = locations.find { |l| l && covers_position?(l, @position) } + return unless loc - surrounding_method = @node_context.surrounding_method - return unless surrounding_method + handle_keyword_documentation(loc.slice) + end + + #: (Prism::Location keyword_location) -> void + def handle_super_node_hover(keyword_location) + # Sorbet can handle the inherited-method hover on typed true or higher, but it does not surface keyword docs, so + # we still push those + unless @sorbet_level.true_or_higher? + surrounding_method = @node_context.surrounding_method + handle_method_hover(surrounding_method, inherited_only: true) if surrounding_method + end - handle_method_hover(surrounding_method, inherited_only: true) + handle_keyword_at_location(keyword_location) end #: (String message, ?inherited_only: bool) -> void diff --git a/lib/ruby_lsp/requests/completion_resolve.rb b/lib/ruby_lsp/requests/completion_resolve.rb index fe83d36d70..2e21ab0a59 100644 --- a/lib/ruby_lsp/requests/completion_resolve.rb +++ b/lib/ruby_lsp/requests/completion_resolve.rb @@ -24,6 +24,7 @@ class CompletionResolve < Request def initialize(global_state, item) super() @index = global_state.index #: RubyIndexer::Index + @graph = global_state.graph #: Rubydex::Graph @item = item end @@ -40,7 +41,7 @@ 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) + return keyword_resolve if @item.dig(:data, :keyword) entries = @index[label] || [] @@ -80,29 +81,24 @@ def perform private - #: (Hash[Symbol, untyped] item) -> Hash[Symbol, untyped] - def keyword_resolve(item) - keyword = item[:label] - content = KEYWORD_DOCS[keyword] - - if content - doc_uri = URI::Generic.from_path(path: File.join(STATIC_DOCS_PATH, "#{keyword}.md")) + #: () -> Hash[Symbol, untyped] + def keyword_resolve + keyword = @graph.keyword(@item[:label]) + if keyword @item[:documentation] = Interface::MarkupContent.new( kind: "markdown", value: <<~MARKDOWN.chomp, ```ruby - #{keyword} + #{keyword.name} ``` - [Read more](#{doc_uri}) - - #{content} + #{keyword.documentation} MARKDOWN ) end - item + @item end end end diff --git a/lib/ruby_lsp/requests/hover.rb b/lib/ruby_lsp/requests/hover.rb index d5f1e9dd2f..5095972d2a 100644 --- a/lib/ruby_lsp/requests/hover.rb +++ b/lib/ruby_lsp/requests/hover.rb @@ -26,7 +26,6 @@ def initialize(document, global_state, position, dispatcher, sorbet_level) node_context = RubyDocument.locate( document.ast, char_position, - node_types: Listeners::Hover::ALLOWED_TARGETS, code_units_cache: document.code_units_cache, ) target = node_context.node @@ -48,7 +47,7 @@ def initialize(document, global_state, position, dispatcher, sorbet_level) @target = target #: Prism::Node? uri = document.uri @response_builder = ResponseBuilders::Hover.new #: ResponseBuilders::Hover - Listeners::Hover.new(@response_builder, global_state, uri, node_context, dispatcher, sorbet_level) + Listeners::Hover.new(@response_builder, global_state, uri, node_context, dispatcher, sorbet_level, position) Addon.addons.each do |addon| addon.create_hover_listener(@response_builder, node_context, dispatcher) end @@ -77,9 +76,7 @@ def perform #: (Prism::Node? parent, Prism::Node? target) -> bool def should_refine_target?(parent, target) - (Listeners::Hover::ALLOWED_TARGETS.include?(parent.class) && - !Listeners::Hover::ALLOWED_TARGETS.include?(target.class)) || - (parent.is_a?(Prism::ConstantPathNode) && target.is_a?(Prism::ConstantReadNode)) + parent.is_a?(Prism::ConstantPathNode) && target.is_a?(Prism::ConstantReadNode) end #: (Hash[Symbol, untyped] position, Prism::Node? target) -> bool diff --git a/lib/ruby_lsp/requests/request.rb b/lib/ruby_lsp/requests/request.rb index 930b81a079..1fb9580e2e 100644 --- a/lib/ruby_lsp/requests/request.rb +++ b/lib/ruby_lsp/requests/request.rb @@ -5,6 +5,8 @@ module RubyLsp module Requests # @abstract class Request + include Support::Common + class InvalidFormatter < StandardError; end # @abstract @@ -26,24 +28,6 @@ def delegate_request_if_needed!(global_state, document, char_position) end end - # Checks if a location covers a position - #: (Prism::Location location, untyped position) -> bool - def cover?(location, position) - start_covered = - location.start_line - 1 < position[:line] || - ( - location.start_line - 1 == position[:line] && - location.start_column <= position[:character] - ) - end_covered = - location.end_line - 1 > position[:line] || - ( - location.end_line - 1 == position[:line] && - location.end_column >= position[:character] - ) - start_covered && end_covered - end - # Based on a constant node target, a constant path node parent and a position, this method will find the exact # portion of the constant path that matches the requested position, for higher precision in hover and # definition. For example: @@ -62,27 +46,13 @@ def determine_target(target, parent, position) parent = target #: as Prism::ConstantPathNode .parent #: Prism::Node? - while parent && cover?(parent.location, position) + while parent && covers_position?(parent.location, position) target = parent parent = target.is_a?(Prism::ConstantPathNode) ? target.parent : nil end target end - - # Checks if a given location covers the position requested - #: (Prism::Location? location, Hash[Symbol, untyped] position) -> bool - def covers_position?(location, position) - return false unless location - - start_line = location.start_line - 1 - end_line = location.end_line - 1 - line = position[:line] - character = position[:character] - - (start_line < line || (start_line == line && location.start_column <= character)) && - (end_line > line || (end_line == line && location.end_column >= character)) - end end end end diff --git a/lib/ruby_lsp/requests/support/common.rb b/lib/ruby_lsp/requests/support/common.rb index 8c391711cc..74edde32f5 100644 --- a/lib/ruby_lsp/requests/support/common.rb +++ b/lib/ruby_lsp/requests/support/common.rb @@ -34,6 +34,19 @@ def range_from_location(location) ) end + #: (Prism::Location? location, Hash[Symbol, untyped] position) -> bool + def covers_position?(location, position) + return false unless location + + start_line = location.start_line - 1 + end_line = location.end_line - 1 + line = position[:line] + character = position[:character] + + (start_line < line || (start_line == line && location.start_column <= character)) && + (end_line > line || (end_line == line && location.end_column >= character)) + end + #: (Prism::Node node, title: String, command_name: String, arguments: Array[untyped]?, data: Hash[untyped, untyped]?) -> Interface::CodeLens def create_code_lens(node, title:, command_name:, arguments:, data:) range = range_from_node(node) diff --git a/lib/ruby_lsp/static_docs.rb b/lib/ruby_lsp/static_docs.rb deleted file mode 100644 index edf4589d87..0000000000 --- a/lib/ruby_lsp/static_docs.rb +++ /dev/null @@ -1,20 +0,0 @@ -# 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 = File.join( - File.dirname( - File.dirname( - __dir__, #: as !nil - ), - ), - "static_docs", - ) #: String - - # A map of keyword => short documentation to be displayed on hover or completion - KEYWORD_DOCS = { - "break" => "Terminates the execution of a block or loop", - "yield" => "Invokes the passed block with the given arguments", - }.freeze #: Hash[String, String] -end diff --git a/ruby-lsp.gemspec b/ruby-lsp.gemspec index 328186e4a5..ce2a0cf583 100644 --- a/ruby-lsp.gemspec +++ b/ruby-lsp.gemspec @@ -13,9 +13,7 @@ Gem::Specification.new do |s| s.homepage = "https://github.com/Shopify/ruby-lsp" s.license = "MIT" - s.files = Dir.glob("lib/**/*.rb").grep_v(%r{^lib/ruby_indexer/test/}) + - ["README.md", "VERSION", "LICENSE.txt"] + - Dir.glob("static_docs/**/*.md") + s.files = Dir.glob("lib/**/*.rb").grep_v(%r{^lib/ruby_indexer/test/}) + ["README.md", "VERSION", "LICENSE.txt"] s.bindir = "exe" s.executables = ["ruby-lsp", "ruby-lsp-check", "ruby-lsp-launcher", "ruby-lsp-test-exec"] s.require_paths = ["lib"] diff --git a/static_docs/break.md b/static_docs/break.md deleted file mode 100644 index 16800f7fbb..0000000000 --- a/static_docs/break.md +++ /dev/null @@ -1,103 +0,0 @@ -# Break - -In Ruby, the `break` keyword is used to exit a loop or block prematurely. Unlike `next` which skips to the next iteration, `break` terminates the loop entirely and continues with the code after the loop. - -```ruby -# Basic break usage in a loop -5.times do |i| - break if i == 3 - - puts i -end -# Output: -# 0 -# 1 -# 2 -``` - -The `break` statement can be used with any of Ruby's iteration methods or loops. - -```ruby -array = [1, 2, 3, 4, 5] - -# Break in each iteration -array.each do |num| - break if num > 3 - - puts "Number: #{num}" -end -# Output: -# Number: 1 -# Number: 2 -# Number: 3 - -# Break in an infinite loop -count = 0 -loop do - count += 1 - break if count >= 3 - - puts "Count: #{count}" -end -# Output: -# Count: 1 -# Count: 2 -``` - -## Break with a Value - -When used inside a block, `break` can return a value that becomes the result of the method call. - -```ruby -# Break with a return value in map -result = [1, 2, 3, 4, 5].map do |num| - break "Too large!" if num > 3 - - num * 2 -end -puts result # Output: "Too large!" - -# Break with a value in find -number = (1..10).find do |n| - break n if n > 5 && n.even? -end -puts number # Output: 6 -``` - -## Break in Nested Loops - -When using `break` in nested loops, it only exits the innermost loop. To break from nested loops, you typically need to use a flag or return. - -```ruby -# Break in nested iteration -(1..3).each do |i| - puts "Outer: #{i}" - - (1..3).each do |j| - break if j == 2 - - puts " Inner: #{j}" - end -end -# Output: -# Outer: 1 -# Inner: 1 -# Outer: 2 -# Inner: 1 -# Outer: 3 -# Inner: 1 - -# Breaking from nested loops with a flag -found = false -(1..3).each do |i| - (1..3).each do |j| - if i * j == 4 - found = true - break - end - end - break if found -end -``` - -The `break` keyword is essential for controlling loop execution and implementing early exit conditions. It's particularly useful when you've found what you're looking for and don't need to continue iterating. \ No newline at end of file diff --git a/static_docs/yield.md b/static_docs/yield.md deleted file mode 100644 index dfa51cf875..0000000000 --- a/static_docs/yield.md +++ /dev/null @@ -1,81 +0,0 @@ -# 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 c7664f66a9..d61565d6cc 100644 --- a/test/requests/completion_resolve_test.rb +++ b/test/requests/completion_resolve_test.rb @@ -199,13 +199,9 @@ def foo result = server.pop_response.response contents = result[:documentation].value + keyword = server.global_state.graph.keyword("yield") #: as !nil assert_match("```ruby\nyield\n```", contents) - assert_match( - RubyLsp::KEYWORD_DOCS["yield"], #: as !nil - contents, - ) - expected_uri = URI::Generic.from_path(path: File.join(RubyLsp::STATIC_DOCS_PATH, "yield.md")) - assert_match("[Read more](#{expected_uri})", contents) + assert_match(keyword.documentation, contents) end end diff --git a/test/requests/hover_expectations_test.rb b/test/requests/hover_expectations_test.rb index 511bfa4d32..63f5aa1a43 100644 --- a/test/requests/hover_expectations_test.rb +++ b/test/requests/hover_expectations_test.rb @@ -893,7 +893,7 @@ def bar end end - def test_hover_is_disabled_on_super_for_typed_true + def test_hover_on_super_for_typed_true_shows_keyword_doc_only source = <<~RUBY # typed: true class Parent @@ -913,7 +913,12 @@ def foo params: { textDocument: { uri: uri }, position: { character: 4, line: 6 } }, ) - assert_nil(server.pop_response.response) + response = server.pop_response.response + refute_nil(response) + + contents = response.contents.value + refute_match("foo", contents) + assert_match("```ruby\nsuper\n```", contents) end end @@ -941,45 +946,506 @@ def name; end def test_hover_for_keywords test_cases = { - "yield" => { - source: <<~RUBY, - def foo - yield - end - RUBY - position: { line: 1, character: 2 }, - }, - "break" => { - source: <<~RUBY, - while true - break - end - RUBY - position: { line: 1, character: 2 }, - }, + "BEGIN" => { source: "BEGIN { }" }, + "END" => { source: "END { }" }, + "__ENCODING__" => { source: "__ENCODING__" }, + "__FILE__" => { source: "__FILE__" }, + "__LINE__" => { source: "__LINE__" }, + "alias" => { source: "alias foo bar" }, + "and" => { source: "true and false", position: { character: 5, line: 0 } }, + "begin" => { source: "begin\nend" }, + "break" => { source: "break" }, + "case" => { source: "case 1\nwhen 1\nend" }, + "class" => { source: "class A\nend" }, + "def" => { source: "def foo\nend" }, + "defined?" => { source: "defined?(x)" }, + "do" => { source: "proc do\nend", position: { character: 5, line: 0 } }, + "else" => { source: "if true\nelse\nend", position: { character: 0, line: 1 } }, + "ensure" => { source: "begin\nensure\nend", position: { character: 0, line: 1 } }, + "false" => { source: "false" }, + "for" => { source: "for x in [1]\nend" }, + "if" => { source: "if true\nend" }, + "in" => { source: "case x\nin 1\nend", position: { character: 0, line: 1 } }, + "module" => { source: "module A\nend" }, + "next" => { source: "next" }, + "nil" => { source: "nil" }, + "or" => { source: "true or false", position: { character: 5, line: 0 } }, + "redo" => { source: "redo" }, + "rescue" => { source: "begin\nrescue\nend", position: { character: 0, line: 1 } }, + "retry" => { source: "retry" }, + "return" => { source: "return" }, + "self" => { source: "self" }, + "true" => { source: "true" }, + "undef" => { source: "undef :foo" }, + "unless" => { source: "unless true\nend" }, + "until" => { source: "until true\nend" }, + "when" => { source: "case x\nwhen 1\nend", position: { character: 0, line: 1 } }, + "while" => { source: "while true\nend" }, + "yield" => { source: "yield" }, } test_cases.each do |keyword, config| + position = config[:position] || { character: 0, line: 0 } + with_server(config[:source]) do |server, uri| server.process_message( id: 1, method: "textDocument/hover", params: { textDocument: { uri: uri }, - position: config[:position], + position: position, }, ) - contents = server.pop_response.response.contents.value + graph = server.global_state.graph + response = server.pop_response.response + refute_nil(response, "expected hover response for keyword `#{keyword}`") + contents = response.contents.value assert_match("```ruby\n#{keyword}\n```", contents) - assert_match( - RubyLsp::KEYWORD_DOCS[keyword] || "No documentation found for #{keyword}", - contents, - ) + assert_match(graph.keyword(keyword).documentation, contents) + end + end + end + + def test_hover_does_not_show_keyword_doc_on_constant_path_of_class + source = "class Foo\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `Foo` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 7, line: 0 } }, + ) + + contents = server.pop_response.response.contents.value + refute_match("```ruby\nclass\n```", contents) + assert_match("Foo", contents) + end + end + + def test_hover_does_not_show_keyword_doc_on_constant_path_of_module + source = "module Foo\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `Foo` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 8, line: 0 } }, + ) + + contents = server.pop_response.response.contents.value + refute_match("```ruby\nmodule\n```", contents) + assert_match("Foo", contents) + end + end + + def test_hover_does_not_show_keyword_doc_on_nested_constant_path + source = "class Foo::Bar\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `Foo` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 7, line: 0 } }, + ) + contents = server.pop_response.response.contents.value + refute_match("```ruby\nclass\n```", contents) + + # cursor on `Bar` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 12, line: 0 } }, + ) + contents = server.pop_response.response.contents.value + refute_match("```ruby\nclass\n```", contents) + end + end - expected_uri = URI::Generic.from_path(path: File.join(RubyLsp::STATIC_DOCS_PATH, "#{keyword}.md")) - assert_match("[Read more](#{expected_uri})", contents) + def test_hover_does_not_show_keyword_doc_on_superclass + source = "class Bar\nend\nclass Foo < Bar\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `Bar` (the superclass) + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 13, line: 2 } }, + ) + contents = server.pop_response.response.contents.value + refute_match("```ruby\nclass\n```", contents) + assert_match("Bar", contents) + end + end + + def test_hover_does_not_show_and_keyword_doc_on_double_ampersand_operator + source = "true && false" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `&&` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 5, line: 0 } }, + ) + assert_nil(server.pop_response.response) + end + end + + def test_hover_does_not_show_or_keyword_doc_on_double_pipe_operator + source = "true || false" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `||` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 5, line: 0 } }, + ) + assert_nil(server.pop_response.response) + end + end + + def test_hover_does_not_show_do_keyword_doc_on_brace_block + source = "proc { 1 }" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `{` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 5, line: 0 } }, + ) + assert_nil(server.pop_response.response) + end + end + + def test_hover_does_not_show_keyword_doc_on_ternary_punctuation + source = "x ? 1 : 2" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `?` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 2, line: 0 } }, + ) + assert_nil(server.pop_response.response) + + # cursor on `:` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 6, line: 0 } }, + ) + assert_nil(server.pop_response.response) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_class + source = "class Foo\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `end` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + response = server.pop_response.response + refute_nil(response) + contents = response.contents.value + assert_match("```ruby\nend\n```", contents) + refute_match("```ruby\nclass\n```", contents) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_def + source = "def foo\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `end` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + response = server.pop_response.response + refute_nil(response) + contents = response.contents.value + assert_match("```ruby\nend\n```", contents) + refute_match("```ruby\ndef\n```", contents) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_if + source = "if true\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `end` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + response = server.pop_response.response + refute_nil(response) + contents = response.contents.value + assert_match("```ruby\nend\n```", contents) + refute_match("```ruby\nif\n```", contents) + end + end + + def test_hover_on_elsif_shows_elsif_keyword_doc + source = "if a\nelsif b\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `elsif` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + response = server.pop_response.response + refute_nil(response) + contents = response.contents.value + assert_match("```ruby\nelsif\n```", contents) + refute_match("```ruby\nif\n```", contents) + end + end + + def test_hover_shows_class_keyword_doc_for_singleton_class + source = "class << self\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `class` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 0 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nclass\n```", response.contents.value) + end + end + + def test_hover_shows_end_keyword_doc_for_singleton_class + source = "class << self\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `end` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) + end + end + + def test_hover_shows_do_keyword_doc_for_lambda + source = "-> do\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `do` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 3, line: 0 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\ndo\n```", response.contents.value) + end + end + + def test_hover_shows_end_keyword_doc_for_lambda + source = "-> do\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `end` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) + end + end + + def test_hover_does_not_show_keyword_doc_on_lambda_operator_or_braces + with_server("-> { }", stub_no_typechecker: true) do |server, uri| + # cursor on `->` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 0 } }, + ) + assert_nil(server.pop_response.response) + + # cursor on `{` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 3, line: 0 } }, + ) + assert_nil(server.pop_response.response) + end + end + + def test_hover_shows_not_keyword_doc + source = "not true" + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `not` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 0 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nnot\n```", response.contents.value) + end + end + + def test_hover_on_forwarding_super_shows_method_doc_and_keyword_doc + source = <<~RUBY + class Parent + # Parent greeting + def greet + end + end + + class Child < Parent + def greet + super + end end + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `super` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 8 } }, + ) + + response = server.pop_response.response + refute_nil(response) + contents = response.contents.value + + assert_match("greet", contents) + assert_match("```ruby\nsuper\n```", contents) + end + end + + def test_hover_on_super_call_shows_method_doc_and_keyword_doc + source = <<~RUBY + class Parent + def greet(name) + end + end + + class Child < Parent + def greet(name) + super(name) + end + end + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + # cursor on `super` of `super(name)` + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 7 } }, + ) + + response = server.pop_response.response + refute_nil(response) + + contents = response.contents.value + assert_match("greet", contents) + assert_match("```ruby\nsuper\n```", contents) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_module + source = "module Foo\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_while + source = "while true\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 1 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_begin_ensure + source = "begin\nensure\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 2 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) + end + end + + def test_hover_on_end_shows_end_keyword_doc_for_if_else + source = "if true\nelse\nend" + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 0, line: 2 } }, + ) + + response = server.pop_response.response + refute_nil(response) + assert_match("```ruby\nend\n```", response.contents.value) end end