From 98f3cfe9e7880e94d996d27a4540bd9902fe4690 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 23 Aug 2023 16:36:31 -0400 Subject: [PATCH] Move DSL hover functionality from the Ruby LSP --- Gemfile.lock | 8 +- lib/ruby_lsp/ruby_lsp_rails/hover.rb | 34 ++++- .../support/rails_document_client.rb | 122 ++++++++++++++++++ ruby-lsp-rails.gemspec | 2 +- test/ruby_lsp_rails/hover_test.rb | 55 ++++++++ 5 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 lib/ruby_lsp/ruby_lsp_rails/support/rails_document_client.rb diff --git a/Gemfile.lock b/Gemfile.lock index 13388cb5..4126286a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -103,7 +103,7 @@ PATH specs: ruby-lsp-rails (0.2.3) rails (>= 6.0) - ruby-lsp (>= 0.8.0, < 0.9.0) + ruby-lsp (>= 0.9.1, < 0.10.0) sorbet-runtime (>= 0.5.9897) GEM @@ -221,11 +221,11 @@ GEM rubocop (~> 1.51) rubocop-sorbet (0.7.3) rubocop (>= 0.90.0) - ruby-lsp (0.8.1) + ruby-lsp (0.9.1) language_server-protocol (~> 3.17.0) sorbet-runtime syntax_tree (>= 6.1.1, < 7) - yarp (~> 0.6.0) + yarp (>= 0.9, < 0.10) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) sorbet (0.5.10978) @@ -282,7 +282,7 @@ GEM yard-sorbet (0.8.1) sorbet-runtime (>= 0.5) yard (>= 0.9) - yarp (0.6.0) + yarp (0.9.0) zeitwerk (2.6.11) PLATFORMS diff --git a/lib/ruby_lsp/ruby_lsp_rails/hover.rb b/lib/ruby_lsp/ruby_lsp_rails/hover.rb index bab45e63..b4f7f95b 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/hover.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/hover.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require_relative "support/rails_document_client" + module RubyLsp module Rails # ![Hover demo](../../hover.gif) @@ -29,7 +31,7 @@ def initialize(client, emitter, message_queue) @response = T.let(nil, ResponseType) @client = client - emitter.register(self, :on_const) + emitter.register(self, :on_const, :on_command, :on_const_path_ref, :on_call) end sig { params(node: SyntaxTree::Const).void } @@ -46,6 +48,36 @@ def on_const(node) contents = RubyLsp::Interface::MarkupContent.new(kind: "markdown", value: content) @response = RubyLsp::Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents) end + + sig { params(node: SyntaxTree::Command).void } + def on_command(node) + message = node.message + @response = generate_rails_document_link_hover(message.value, message) + end + + sig { params(node: SyntaxTree::ConstPathRef).void } + def on_const_path_ref(node) + @response = generate_rails_document_link_hover(full_constant_name(node), node) + end + + sig { params(node: SyntaxTree::CallNode).void } + def on_call(node) + message = node.message + return if message.is_a?(Symbol) + + @response = generate_rails_document_link_hover(message.value, message) + end + + private + + sig { params(name: String, node: SyntaxTree::Node).returns(T.nilable(Interface::Hover)) } + def generate_rails_document_link_hover(name, node) + urls = Support::RailsDocumentClient.generate_rails_document_urls(name) + return if urls.empty? + + contents = RubyLsp::Interface::MarkupContent.new(kind: "markdown", value: urls.join("\n\n")) + RubyLsp::Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents) + end end end end diff --git a/lib/ruby_lsp/ruby_lsp_rails/support/rails_document_client.rb b/lib/ruby_lsp/ruby_lsp_rails/support/rails_document_client.rb new file mode 100644 index 00000000..c7a11822 --- /dev/null +++ b/lib/ruby_lsp/ruby_lsp_rails/support/rails_document_client.rb @@ -0,0 +1,122 @@ +# typed: strict +# frozen_string_literal: true + +require "net/http" + +module RubyLsp + module Rails + module Support + class RailsDocumentClient + RAILS_DOC_HOST = "https://api.rubyonrails.org" + SUPPORTED_RAILS_DOC_NAMESPACES = T.let( + Regexp.union( + /ActionDispatch/, + /ActionController/, + /AbstractController/, + /ActiveRecord/, + /ActiveModel/, + /ActiveStorage/, + /ActionText/, + /ActiveJob/, + ).freeze, + Regexp, + ) + + RAILTIES_VERSION = T.let( + [*::Gem::Specification.default_stubs, *::Gem::Specification.stubs].find do |s| + s.name == "railties" + end&.version&.to_s, + T.nilable(String), + ) + + class << self + extend T::Sig + sig do + params(name: String).returns(T::Array[String]) + end + def generate_rails_document_urls(name) + docs = search_index&.fetch(name, nil) + + return [] unless docs + + docs.map do |doc| + owner = doc[:owner] + + link_name = + # class/module name + if owner == name + name + else + "#{owner}##{name}" + end + + "[Rails Document: `#{link_name}`](#{doc[:url]})" + end + end + + sig { returns(T.nilable(T::Hash[String, T::Array[T::Hash[Symbol, String]]])) } + private def search_index + @rails_documents ||= T.let( + build_search_index, + T.nilable(T::Hash[String, T::Array[T::Hash[Symbol, String]]]), + ) + end + + sig { returns(T.nilable(T::Hash[String, T::Array[T::Hash[Symbol, String]]])) } + private def build_search_index + return unless RAILTIES_VERSION + + warn("Fetching Rails Documents...") + + response = Net::HTTP.get_response(URI("#{RAILS_DOC_HOST}/v#{RAILTIES_VERSION}/js/search_index.js")) + + if response.code == "302" + # If the version's doc is not found, e.g. Rails main, it'll be redirected + # In this case, we just fetch the latest doc + response = Net::HTTP.get_response(URI("#{RAILS_DOC_HOST}/js/search_index.js")) + end + + if response.code == "200" + process_search_index(response.body) + else + warn("Response failed: #{response.inspect}") + nil + end + rescue StandardError => e + warn("Exception occurred when fetching Rails document index: #{e.inspect}") + end + + sig { params(js: String).returns(T::Hash[String, T::Array[T::Hash[Symbol, String]]]) } + private def process_search_index(js) + raw_data = js.sub("var search_data = ", "") + info = JSON.parse(raw_data).dig("index", "info") + + # An entry looks like this: + # + # ["belongs_to", # method or module/class + # "ActiveRecord::Associations::ClassMethods", # method owner + # "classes/ActiveRecord/Associations/ClassMethods.html#method-i-belongs_to", # path to the document + # "(name, scope = nil, **options)", # method's parameters + # "

Specifies a one-to-one association with another class..."] # document preview + # + info.each_with_object({}) do |(method_or_class, method_owner, doc_path, _, doc_preview), table| + # If a method doesn't have documentation, there's no need to generate the link to it. + next if doc_preview.nil? || doc_preview.empty? + + # If the method or class/module is not from the supported namespace, reject it + next unless [method_or_class, method_owner].any? do |elem| + elem.match?(SUPPORTED_RAILS_DOC_NAMESPACES) + end + + owner = method_owner.empty? ? method_or_class : method_owner + table[method_or_class] ||= [] + # It's possible to have multiple modules defining the same method name. For example, + # both `ActiveRecord::FinderMethods` and `ActiveRecord::Associations::CollectionProxy` defines `#find` + table[method_or_class] << { owner: owner, url: "#{RAILS_DOC_HOST}/v#{RAILTIES_VERSION}/#{doc_path}" } + end + end + end + end + end + end +end diff --git a/ruby-lsp-rails.gemspec b/ruby-lsp-rails.gemspec index f77049b8..4e955cec 100644 --- a/ruby-lsp-rails.gemspec +++ b/ruby-lsp-rails.gemspec @@ -22,6 +22,6 @@ Gem::Specification.new do |spec| end spec.add_dependency("rails", ">= 6.0") - spec.add_dependency("ruby-lsp", ">= 0.8.0", "< 0.9.0") + spec.add_dependency("ruby-lsp", ">= 0.9.1", "< 0.10.0") spec.add_dependency("sorbet-runtime", ">= 0.5.9897") end diff --git a/test/ruby_lsp_rails/hover_test.rb b/test/ruby_lsp_rails/hover_test.rb index 0245de79..856b473f 100644 --- a/test/ruby_lsp_rails/hover_test.rb +++ b/test/ruby_lsp_rails/hover_test.rb @@ -10,6 +10,11 @@ class HoverTest < ActiveSupport::TestCase File.write("#{Dir.pwd}/test/dummy/tmp/app_uri.txt", "http://localhost:3000") @client = RailsClient.new @message_queue = Thread::Queue.new + + # Build the Rails documents index ahead of time + capture_io do + Support::RailsDocumentClient.send(:search_index) + end end teardown do @@ -86,6 +91,56 @@ class HoverTest < ActiveSupport::TestCase refute_match(/Schema/, T.must(listener.response).contents.value) end + + test "shows documentation for routes DSLs" do + emitter = RubyLsp::EventEmitter.new + listener = Hover.new(@client, emitter, @message_queue) + emitter.emit_for_target(Command(Ident("root"), "projects#index", nil)) + + response = T.must(listener.response).contents.value + assert_match(/\[Rails Document: `ActionDispatch::Routing::Mapper::Resources#root`\]/, response) + assert_match(%r{\(https://api\.rubyonrails\.org/.*\.html#method-i-root\)}, response) + end + + test "shows documentation for controller DSLs" do + emitter = RubyLsp::EventEmitter.new + listener = Hover.new(@client, emitter, @message_queue) + emitter.emit_for_target(Command(Ident("before_action"), "foo", nil)) + + response = T.must(listener.response).contents.value + assert_match(/\[Rails Document: `AbstractController::Callbacks::ClassMethods#before_action`\]/, response) + assert_match(%r{\(https://api\.rubyonrails\.org/.*\.html#method-i-before_action\)}, response) + end + + test "shows documentation for job DSLs" do + emitter = RubyLsp::EventEmitter.new + listener = Hover.new(@client, emitter, @message_queue) + emitter.emit_for_target(Command(Ident("queue_as"), "default", nil)) + + response = T.must(listener.response).contents.value + assert_match(/\[Rails Document: `ActiveJob::QueueName::ClassMethods#queue_as`\]/, response) + assert_match(%r{\(https://api\.rubyonrails\.org/.*\.html#method-i-queue_as\)}, response) + end + + test "shows documentation for model DSLs" do + emitter = RubyLsp::EventEmitter.new + listener = Hover.new(@client, emitter, @message_queue) + emitter.emit_for_target(CallNode(nil, ".", Ident("validate"), "foo")) + + response = T.must(listener.response).contents.value + assert_match(/\[Rails Document: `ActiveModel::EachValidator#validate`\]/, response) + assert_match(%r{\(https://api\.rubyonrails\.org/.*\.html#method-i-validate\)}, response) + end + + test "shows documentation for Rails constants" do + emitter = RubyLsp::EventEmitter.new + listener = Hover.new(@client, emitter, @message_queue) + emitter.emit_for_target(ConstPathRef(VarRef(Const("ActiveRecord")), Const("Base"))) + + response = T.must(listener.response).contents.value + assert_match(/\[Rails Document: `ActiveRecord::Base`\]/, response) + assert_match(%r{\(https://api\.rubyonrails\.org/.*Base\.html\)}, response) + end end end end