Skip to content

Commit

Permalink
Merge pull request #131 from Shopify/vs/add_dsl_hover
Browse files Browse the repository at this point in the history
Move DSL hover functionality from the Ruby LSP
  • Loading branch information
vinistock authored Aug 29, 2023
2 parents 7b3e1b2 + 98f3cfe commit 17beb77
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 6 deletions.
8 changes: 4 additions & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.10987)
Expand Down Expand Up @@ -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
Expand Down
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
2 changes: 1 addition & 1 deletion ruby-lsp-rails.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 17beb77

Please sign in to comment.