Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support document link for Rails documentation #285

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ gem "rubocop-sorbet", "~> 0.6", require: false
gem "sorbet-static-and-runtime"
gem "tapioca", "~> 0.10", require: false
gem "yard", "~> 0.9", require: false

# The Rails documentation link only activates when railties is detected.
gem "railties", "~> 7.0", require: false
50 changes: 50 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,45 @@ PATH
GEM
remote: https://rubygems.org/
specs:
actionpack (7.0.3.1)
actionview (= 7.0.3.1)
activesupport (= 7.0.3.1)
rack (~> 2.0, >= 2.2.0)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actionview (7.0.3.1)
activesupport (= 7.0.3.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activesupport (7.0.3.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
ansi (1.5.0)
ast (2.4.2)
builder (3.2.4)
coderay (1.1.3)
concurrent-ruby (1.1.10)
crass (1.0.6)
debug (1.6.2)
irb (>= 1.3.6)
reline (>= 0.3.1)
diff-lcs (1.5.0)
erubi (1.11.0)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
io-console (0.5.11)
irb (1.4.1)
reline (>= 0.3.0)
json (2.6.2)
language_server-protocol (3.17.0.1)
loofah (2.18.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
method_source (1.0.0)
minitest (5.16.3)
minitest-reporters (1.5.0)
Expand All @@ -30,13 +56,33 @@ GEM
minitest (>= 5.0)
ruby-progressbar
netrc (0.11.0)
nokogiri (1.13.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.13.8-x86_64-linux)
racc (~> 1.4)
parallel (1.22.1)
parser (3.1.2.1)
ast (~> 2.4.1)
prettier_print (0.1.0)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
racc (1.6.0)
rack (2.2.4)
rack-test (2.0.2)
rack (>= 1.3)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.3)
loofah (~> 2.3)
railties (7.0.3.1)
actionpack (= 7.0.3.1)
activesupport (= 7.0.3.1)
method_source
rake (>= 12.2)
thor (~> 1.0)
zeitwerk (~> 2.5)
rainbow (3.1.1)
rake (13.0.6)
rbi (0.0.15)
Expand Down Expand Up @@ -94,6 +140,8 @@ GEM
thor (>= 1.2.0)
yard-sorbet
thor (1.2.1)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
unicode-display_width (2.3.0)
unparser (0.6.5)
diff-lcs (~> 1.3)
Expand All @@ -104,6 +152,7 @@ GEM
yard-sorbet (0.7.0)
sorbet-runtime (>= 0.5)
yard (>= 0.9)
zeitwerk (2.6.0)

PLATFORMS
arm64-darwin-21
Expand All @@ -113,6 +162,7 @@ DEPENDENCIES
debug (~> 1.6)
minitest (~> 5.16)
minitest-reporters (~> 1.5)
railties (~> 7.0)
rake (~> 13.0)
rubocop (~> 1.33)
rubocop-minitest (~> 0.22.1)
Expand Down
19 changes: 19 additions & 0 deletions lib/ruby_lsp/requests/base_request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ def range_from_syntax_tree_node(node)
end: LanguageServer::Protocol::Interface::Position.new(line: loc.end_line - 1, character: loc.end_column),
)
end

sig { params(node: SyntaxTree::ConstPathRef).returns(String) }
def full_constant_name(node)
name = +node.constant.value
st0012 marked this conversation as resolved.
Show resolved Hide resolved
constant = T.let(node, SyntaxTree::Node)

while constant.is_a?(SyntaxTree::ConstPathRef)
constant = constant.parent

case constant
when SyntaxTree::ConstPathRef
name.prepend("#{constant.constant.value}::")
when SyntaxTree::VarRef
name.prepend("#{constant.value.value}::")
end
end
st0012 marked this conversation as resolved.
Show resolved Hide resolved

name
end
end
end
end
69 changes: 67 additions & 2 deletions lib/ruby_lsp/requests/document_link.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
# typed: strict
# frozen_string_literal: true

require "net/http"
require "ruby_lsp/requests/support/source_uri"
require "ruby_lsp/requests/support/rails_document_client"

module RubyLsp
module Requests
# ![Document link demo](../../misc/document_link.gif)
#
# The [document link](https://microsoft.github.io/language-server-protocol/specification#textDocument_documentLink)
# makes `# source://PATH_TO_FILE#line` comments in a Ruby/RBI file clickable if the file exists.
# provides 2 different features:
#
# 1. Jump from source comment
#
# ![Document link demo](../../misc/document_link.gif)
#
# It makes `# source://PATH_TO_FILE#line` comments in a Ruby/RBI file clickable if the file exists.
# When the user clicks the link, it'll open that location.
#
# # Example
Expand All @@ -18,6 +25,22 @@ module Requests
# def format(source, maxwidth = T.unsafe(nil))
# end
# ```
#
# 2. Link to Rails DSL documentation
#
# ![Document link to Rails document demo](../../misc/document_link_rails_doc.gif)
#
# When detecting Rails DSLs under certain paths, like seeing `before_save :callback` in files under `models/`,
# it makes the DSL call clickable. When clicking the link, the user will be taken to its API doc in browser.
#
# # Example
#
# ```ruby
# class Post < ApplicationRecord
# before_save :do_something # before_save will be clickable to its API document
# validates :title # validates will also be clickable
# end
# ```
class DocumentLink < BaseRequest
extend T::Sig

Expand Down Expand Up @@ -73,6 +96,7 @@ def initialize(uri, document)
# in the URI
version_match = /(?<=%40)[\d.]+(?=\.rbi$)/.match(uri)
@gem_version = T.let(version_match && version_match[0], T.nilable(String))
@file_dir = T.let(Pathname.new(uri).dirname.to_s, String)
@links = T.let([], T::Array[LanguageServer::Protocol::Interface::DocumentLink])
end

Expand All @@ -99,6 +123,47 @@ def visit_comment(node)
)
end

sig { override.params(node: SyntaxTree::Command).void }
def visit_command(node)
message = node.message
links = Support::RailsDocumentClient.generate_rails_document_link(
message.value,
range_from_syntax_tree_node(message),
@file_dir,
)

@links.concat(links)
super
end

sig { override.params(node: SyntaxTree::Call).void }
def visit_call(node)
return super if node.message == :call

message = node.message
links = Support::RailsDocumentClient.generate_rails_document_link(
message.value,
range_from_syntax_tree_node(message),
@file_dir,
)

@links.concat(links)
super
end

sig { override.params(node: SyntaxTree::ConstPathRef).void }
def visit_const_path_ref(node)
constant_name = full_constant_name(node)
links = Support::RailsDocumentClient.generate_rails_document_link(
constant_name,
range_from_syntax_tree_node(node),
@file_dir,
)

@links.concat(links)
super
end

private

# Try to figure out the gem version for a source:// link. The order of precedence is:
Expand Down
127 changes: 127 additions & 0 deletions lib/ruby_lsp/requests/support/rails_document_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# typed: strict
# frozen_string_literal: true

module RubyLsp
module Requests
module Support
class RailsDocumentClient
RAILS_DOC_HOST = "https://api.rubyonrails.org"
RAILS_DOC_PATHS_MAP = T.let(
{
"controllers" => Regexp.union(/ActionController/, /AbstractController/, /ActiveRecord/),
"models" => Regexp.union(/ActiveRecord/, /ActiveModel/, /ActiveStorage/, /ActionText/),
"config" => /ActionDispatch/,
"jobs" => Regexp.union(/ActiveJob/, /ActiveRecord/),
}.freeze, T::Hash[String, Regexp]
)
SUPPORTED_RAILS_DOC_NAMESPACES = T.let(
Regexp.union(RAILS_DOC_PATHS_MAP.values).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 { returns(T.nilable(T::Hash[String, T::Array[T::Hash[Symbol, String]]])) }
def rails_documents
@rails_documents ||= T.let(begin
table = {}

return table unless RAILTIES_VERSION

$stderr.puts "Fetching Rails Documents..."
# 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 = if Gem::Version.new(RAILTIES_VERSION).prerelease?
Net::HTTP.get_response(URI("#{RAILS_DOC_HOST}/js/search_index.js"))
else
Net::HTTP.get_response(URI("#{RAILS_DOC_HOST}/v#{RAILTIES_VERSION}/js/search_index.js"))
end

if response.code == "200"
raw_data = response.body.sub("var search_data = ", "")
data = 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
#
data.each do |ary|
doc_preview = ary[4]
# The 5th attribute is the method's document preview.
# 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?

method_or_class = ary[0]
method_owner = ary[1]

# 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

doc_path = ary[2]
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, path: doc_path }
end
else
$stderr.puts("Response failed: #{response.inspect}")
end

table
rescue StandardError => e
$stderr.puts("Exception occurred when fetching Rails document index: #{e.inspect}")
table
end, T.nilable(T::Hash[String, T::Array[T::Hash[Symbol, String]]]))
end

sig do
params(name: String, range: LanguageServer::Protocol::Interface::Range,
file_dir: String).returns(T::Array[(LanguageServer::Protocol::Interface::DocumentLink)])
end
def generate_rails_document_link(name, range, file_dir)
docs = T.must(rails_documents)[name]

return [] unless docs

docs = docs.select do |doc|
RAILS_DOC_PATHS_MAP.any? do |folder, patterns|
file_dir.match?(folder) && T.must(doc[:owner]).match?(patterns)
end
end

docs.map do |doc|
owner = doc[:owner]

tooltip_name =
# class/module name
if owner == name
name
else
"#{owner}##{name}"
end

LanguageServer::Protocol::Interface::DocumentLink.new(
range: range,
target: "#{RAILS_DOC_HOST}/v#{RAILTIES_VERSION}/#{doc[:path]}",
tooltip: "Browse the Rails documentation for: #{tooltip_name}",
)
end
end
end
end
end
end
end
Binary file added misc/document_link_rails_doc.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading