Skip to content

Commit 17beb77

Browse files
authored
Merge pull request #131 from Shopify/vs/add_dsl_hover
Move DSL hover functionality from the Ruby LSP
2 parents 7b3e1b2 + 98f3cfe commit 17beb77

File tree

5 files changed

+215
-6
lines changed

5 files changed

+215
-6
lines changed

Gemfile.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ PATH
103103
specs:
104104
ruby-lsp-rails (0.2.3)
105105
rails (>= 6.0)
106-
ruby-lsp (>= 0.8.0, < 0.9.0)
106+
ruby-lsp (>= 0.9.1, < 0.10.0)
107107
sorbet-runtime (>= 0.5.9897)
108108

109109
GEM
@@ -221,11 +221,11 @@ GEM
221221
rubocop (~> 1.51)
222222
rubocop-sorbet (0.7.3)
223223
rubocop (>= 0.90.0)
224-
ruby-lsp (0.8.1)
224+
ruby-lsp (0.9.1)
225225
language_server-protocol (~> 3.17.0)
226226
sorbet-runtime
227227
syntax_tree (>= 6.1.1, < 7)
228-
yarp (~> 0.6.0)
228+
yarp (>= 0.9, < 0.10)
229229
ruby-progressbar (1.13.0)
230230
ruby2_keywords (0.0.5)
231231
sorbet (0.5.10987)
@@ -282,7 +282,7 @@ GEM
282282
yard-sorbet (0.8.1)
283283
sorbet-runtime (>= 0.5)
284284
yard (>= 0.9)
285-
yarp (0.6.0)
285+
yarp (0.9.0)
286286
zeitwerk (2.6.11)
287287

288288
PLATFORMS

lib/ruby_lsp/ruby_lsp_rails/hover.rb

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# typed: strict
22
# frozen_string_literal: true
33

4+
require_relative "support/rails_document_client"
5+
46
module RubyLsp
57
module Rails
68
# ![Hover demo](../../hover.gif)
@@ -29,7 +31,7 @@ def initialize(client, emitter, message_queue)
2931

3032
@response = T.let(nil, ResponseType)
3133
@client = client
32-
emitter.register(self, :on_const)
34+
emitter.register(self, :on_const, :on_command, :on_const_path_ref, :on_call)
3335
end
3436

3537
sig { params(node: SyntaxTree::Const).void }
@@ -46,6 +48,36 @@ def on_const(node)
4648
contents = RubyLsp::Interface::MarkupContent.new(kind: "markdown", value: content)
4749
@response = RubyLsp::Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents)
4850
end
51+
52+
sig { params(node: SyntaxTree::Command).void }
53+
def on_command(node)
54+
message = node.message
55+
@response = generate_rails_document_link_hover(message.value, message)
56+
end
57+
58+
sig { params(node: SyntaxTree::ConstPathRef).void }
59+
def on_const_path_ref(node)
60+
@response = generate_rails_document_link_hover(full_constant_name(node), node)
61+
end
62+
63+
sig { params(node: SyntaxTree::CallNode).void }
64+
def on_call(node)
65+
message = node.message
66+
return if message.is_a?(Symbol)
67+
68+
@response = generate_rails_document_link_hover(message.value, message)
69+
end
70+
71+
private
72+
73+
sig { params(name: String, node: SyntaxTree::Node).returns(T.nilable(Interface::Hover)) }
74+
def generate_rails_document_link_hover(name, node)
75+
urls = Support::RailsDocumentClient.generate_rails_document_urls(name)
76+
return if urls.empty?
77+
78+
contents = RubyLsp::Interface::MarkupContent.new(kind: "markdown", value: urls.join("\n\n"))
79+
RubyLsp::Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents)
80+
end
4981
end
5082
end
5183
end
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "net/http"
5+
6+
module RubyLsp
7+
module Rails
8+
module Support
9+
class RailsDocumentClient
10+
RAILS_DOC_HOST = "https://api.rubyonrails.org"
11+
SUPPORTED_RAILS_DOC_NAMESPACES = T.let(
12+
Regexp.union(
13+
/ActionDispatch/,
14+
/ActionController/,
15+
/AbstractController/,
16+
/ActiveRecord/,
17+
/ActiveModel/,
18+
/ActiveStorage/,
19+
/ActionText/,
20+
/ActiveJob/,
21+
).freeze,
22+
Regexp,
23+
)
24+
25+
RAILTIES_VERSION = T.let(
26+
[*::Gem::Specification.default_stubs, *::Gem::Specification.stubs].find do |s|
27+
s.name == "railties"
28+
end&.version&.to_s,
29+
T.nilable(String),
30+
)
31+
32+
class << self
33+
extend T::Sig
34+
sig do
35+
params(name: String).returns(T::Array[String])
36+
end
37+
def generate_rails_document_urls(name)
38+
docs = search_index&.fetch(name, nil)
39+
40+
return [] unless docs
41+
42+
docs.map do |doc|
43+
owner = doc[:owner]
44+
45+
link_name =
46+
# class/module name
47+
if owner == name
48+
name
49+
else
50+
"#{owner}##{name}"
51+
end
52+
53+
"[Rails Document: `#{link_name}`](#{doc[:url]})"
54+
end
55+
end
56+
57+
sig { returns(T.nilable(T::Hash[String, T::Array[T::Hash[Symbol, String]]])) }
58+
private def search_index
59+
@rails_documents ||= T.let(
60+
build_search_index,
61+
T.nilable(T::Hash[String, T::Array[T::Hash[Symbol, String]]]),
62+
)
63+
end
64+
65+
sig { returns(T.nilable(T::Hash[String, T::Array[T::Hash[Symbol, String]]])) }
66+
private def build_search_index
67+
return unless RAILTIES_VERSION
68+
69+
warn("Fetching Rails Documents...")
70+
71+
response = Net::HTTP.get_response(URI("#{RAILS_DOC_HOST}/v#{RAILTIES_VERSION}/js/search_index.js"))
72+
73+
if response.code == "302"
74+
# If the version's doc is not found, e.g. Rails main, it'll be redirected
75+
# In this case, we just fetch the latest doc
76+
response = Net::HTTP.get_response(URI("#{RAILS_DOC_HOST}/js/search_index.js"))
77+
end
78+
79+
if response.code == "200"
80+
process_search_index(response.body)
81+
else
82+
warn("Response failed: #{response.inspect}")
83+
nil
84+
end
85+
rescue StandardError => e
86+
warn("Exception occurred when fetching Rails document index: #{e.inspect}")
87+
end
88+
89+
sig { params(js: String).returns(T::Hash[String, T::Array[T::Hash[Symbol, String]]]) }
90+
private def process_search_index(js)
91+
raw_data = js.sub("var search_data = ", "")
92+
info = JSON.parse(raw_data).dig("index", "info")
93+
94+
# An entry looks like this:
95+
#
96+
# ["belongs_to", # method or module/class
97+
# "ActiveRecord::Associations::ClassMethods", # method owner
98+
# "classes/ActiveRecord/Associations/ClassMethods.html#method-i-belongs_to", # path to the document
99+
# "(name, scope = nil, **options)", # method's parameters
100+
# "<p>Specifies a one-to-one association with another class..."] # document preview
101+
#
102+
info.each_with_object({}) do |(method_or_class, method_owner, doc_path, _, doc_preview), table|
103+
# If a method doesn't have documentation, there's no need to generate the link to it.
104+
next if doc_preview.nil? || doc_preview.empty?
105+
106+
# If the method or class/module is not from the supported namespace, reject it
107+
next unless [method_or_class, method_owner].any? do |elem|
108+
elem.match?(SUPPORTED_RAILS_DOC_NAMESPACES)
109+
end
110+
111+
owner = method_owner.empty? ? method_or_class : method_owner
112+
table[method_or_class] ||= []
113+
# It's possible to have multiple modules defining the same method name. For example,
114+
# both `ActiveRecord::FinderMethods` and `ActiveRecord::Associations::CollectionProxy` defines `#find`
115+
table[method_or_class] << { owner: owner, url: "#{RAILS_DOC_HOST}/v#{RAILTIES_VERSION}/#{doc_path}" }
116+
end
117+
end
118+
end
119+
end
120+
end
121+
end
122+
end

ruby-lsp-rails.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ Gem::Specification.new do |spec|
2222
end
2323

2424
spec.add_dependency("rails", ">= 6.0")
25-
spec.add_dependency("ruby-lsp", ">= 0.8.0", "< 0.9.0")
25+
spec.add_dependency("ruby-lsp", ">= 0.9.1", "< 0.10.0")
2626
spec.add_dependency("sorbet-runtime", ">= 0.5.9897")
2727
end

test/ruby_lsp_rails/hover_test.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ class HoverTest < ActiveSupport::TestCase
1010
File.write("#{Dir.pwd}/test/dummy/tmp/app_uri.txt", "http://localhost:3000")
1111
@client = RailsClient.new
1212
@message_queue = Thread::Queue.new
13+
14+
# Build the Rails documents index ahead of time
15+
capture_io do
16+
Support::RailsDocumentClient.send(:search_index)
17+
end
1318
end
1419

1520
teardown do
@@ -86,6 +91,56 @@ class HoverTest < ActiveSupport::TestCase
8691

8792
refute_match(/Schema/, T.must(listener.response).contents.value)
8893
end
94+
95+
test "shows documentation for routes DSLs" do
96+
emitter = RubyLsp::EventEmitter.new
97+
listener = Hover.new(@client, emitter, @message_queue)
98+
emitter.emit_for_target(Command(Ident("root"), "projects#index", nil))
99+
100+
response = T.must(listener.response).contents.value
101+
assert_match(/\[Rails Document: `ActionDispatch::Routing::Mapper::Resources#root`\]/, response)
102+
assert_match(%r{\(https://api\.rubyonrails\.org/.*\.html#method-i-root\)}, response)
103+
end
104+
105+
test "shows documentation for controller DSLs" do
106+
emitter = RubyLsp::EventEmitter.new
107+
listener = Hover.new(@client, emitter, @message_queue)
108+
emitter.emit_for_target(Command(Ident("before_action"), "foo", nil))
109+
110+
response = T.must(listener.response).contents.value
111+
assert_match(/\[Rails Document: `AbstractController::Callbacks::ClassMethods#before_action`\]/, response)
112+
assert_match(%r{\(https://api\.rubyonrails\.org/.*\.html#method-i-before_action\)}, response)
113+
end
114+
115+
test "shows documentation for job DSLs" do
116+
emitter = RubyLsp::EventEmitter.new
117+
listener = Hover.new(@client, emitter, @message_queue)
118+
emitter.emit_for_target(Command(Ident("queue_as"), "default", nil))
119+
120+
response = T.must(listener.response).contents.value
121+
assert_match(/\[Rails Document: `ActiveJob::QueueName::ClassMethods#queue_as`\]/, response)
122+
assert_match(%r{\(https://api\.rubyonrails\.org/.*\.html#method-i-queue_as\)}, response)
123+
end
124+
125+
test "shows documentation for model DSLs" do
126+
emitter = RubyLsp::EventEmitter.new
127+
listener = Hover.new(@client, emitter, @message_queue)
128+
emitter.emit_for_target(CallNode(nil, ".", Ident("validate"), "foo"))
129+
130+
response = T.must(listener.response).contents.value
131+
assert_match(/\[Rails Document: `ActiveModel::EachValidator#validate`\]/, response)
132+
assert_match(%r{\(https://api\.rubyonrails\.org/.*\.html#method-i-validate\)}, response)
133+
end
134+
135+
test "shows documentation for Rails constants" do
136+
emitter = RubyLsp::EventEmitter.new
137+
listener = Hover.new(@client, emitter, @message_queue)
138+
emitter.emit_for_target(ConstPathRef(VarRef(Const("ActiveRecord")), Const("Base")))
139+
140+
response = T.must(listener.response).contents.value
141+
assert_match(/\[Rails Document: `ActiveRecord::Base`\]/, response)
142+
assert_match(%r{\(https://api\.rubyonrails\.org/.*Base\.html\)}, response)
143+
end
89144
end
90145
end
91146
end

0 commit comments

Comments
 (0)