Skip to content

Commit

Permalink
Move DSL hover functionality from the Ruby LSP
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Aug 23, 2023
1 parent 6cdf2c3 commit a5781eb
Show file tree
Hide file tree
Showing 3 changed files with 210 additions and 1 deletion.
34 changes: 33 additions & 1 deletion lib/ruby_lsp/ruby_lsp_rails/hover.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# typed: strict
# frozen_string_literal: true

require_relative "support/rails_document_client"

module RubyLsp
module Rails
# ![Hover demo](../../hover.gif)
Expand Down Expand Up @@ -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 }
Expand All @@ -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
122 changes: 122 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/support/rails_document_client.rb
Original file line number Diff line number Diff line change
@@ -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
# "<p>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
55 changes: 55 additions & 0 deletions test/ruby_lsp_rails/hover_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

0 comments on commit a5781eb

Please sign in to comment.