diff --git a/lib/ruby_lsp/listeners/document_link.rb b/lib/ruby_lsp/listeners/document_link.rb new file mode 100644 index 0000000000..0b37a466e2 --- /dev/null +++ b/lib/ruby_lsp/listeners/document_link.rb @@ -0,0 +1,162 @@ +# typed: strict +# frozen_string_literal: true + +require "ruby_lsp/requests/support/source_uri" + +module RubyLsp + module Listeners + class DocumentLink < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member { { fixed: T::Array[Interface::DocumentLink] } } + + GEM_TO_VERSION_MAP = T.let( + [*::Gem::Specification.default_stubs, *::Gem::Specification.stubs].map! do |s| + [s.name, s.version.to_s] + end.to_h.freeze, + T::Hash[String, String], + ) + + class << self + extend T::Sig + + sig { returns(T::Hash[String, T::Hash[String, T::Hash[String, String]]]) } + def gem_paths + @gem_paths ||= T.let( + begin + lookup = {} + + Gem::Specification.stubs.each do |stub| + spec = stub.to_spec + lookup[spec.name] = {} + lookup[spec.name][spec.version.to_s] = {} + + Dir.glob("**/*.rb", base: "#{spec.full_gem_path}/").each do |path| + lookup[spec.name][spec.version.to_s][path] = "#{spec.full_gem_path}/#{path}" + end + end + + Gem::Specification.default_stubs.each do |stub| + spec = stub.to_spec + lookup[spec.name] = {} + lookup[spec.name][spec.version.to_s] = {} + prefix_matchers = Regexp.union(spec.require_paths.map do |rp| + Regexp.new("^#{rp}/") + end) + prefix_matcher = Regexp.union(prefix_matchers, //) + + spec.files.each do |file| + path = file.sub(prefix_matcher, "") + lookup[spec.name][spec.version.to_s][path] = "#{RbConfig::CONFIG["rubylibdir"]}/#{path}" + end + end + + lookup + end, + T.nilable(T::Hash[String, T::Hash[String, T::Hash[String, String]]]), + ) + end + end + + sig { override.returns(ResponseType) } + attr_reader :_response + + sig do + params( + uri: URI::Generic, + comments: T::Array[Prism::Comment], + dispatcher: Prism::Dispatcher, + ).void + end + def initialize(uri, comments, dispatcher) + super(dispatcher) + + # Match the version based on the version in the RBI file name. Notice that the `@` symbol is sanitized to `%40` + # in the URI + path = uri.to_standardized_path + version_match = path ? /(?<=%40)[\d.]+(?=\.rbi$)/.match(path) : nil + @gem_version = T.let(version_match && version_match[0], T.nilable(String)) + @_response = T.let([], T::Array[Interface::DocumentLink]) + @lines_to_comments = T.let( + comments.to_h do |comment| + [comment.location.end_line, comment] + end, + T::Hash[Integer, Prism::Comment], + ) + + dispatcher.register( + self, + :on_def_node_enter, + :on_class_node_enter, + :on_module_node_enter, + :on_constant_write_node_enter, + :on_constant_path_write_node_enter, + ) + end + + sig { params(node: Prism::DefNode).void } + def on_def_node_enter(node) + extract_document_link(node) + end + + sig { params(node: Prism::ClassNode).void } + def on_class_node_enter(node) + extract_document_link(node) + end + + sig { params(node: Prism::ModuleNode).void } + def on_module_node_enter(node) + extract_document_link(node) + end + + sig { params(node: Prism::ConstantWriteNode).void } + def on_constant_write_node_enter(node) + extract_document_link(node) + end + + sig { params(node: Prism::ConstantPathWriteNode).void } + def on_constant_path_write_node_enter(node) + extract_document_link(node) + end + + private + + sig { params(node: Prism::Node).void } + def extract_document_link(node) + comment = @lines_to_comments[node.location.start_line - 1] + return unless comment + + match = comment.location.slice.match(%r{source://.*#\d+$}) + return unless match + + uri = T.cast(URI(T.must(match[0])), URI::Source) + gem_version = resolve_version(uri) + return if gem_version.nil? + + file_path = self.class.gem_paths.dig(uri.gem_name, gem_version, CGI.unescape(uri.path)) + return if file_path.nil? + + @_response << Interface::DocumentLink.new( + range: range_from_location(comment.location), + target: "file://#{file_path}##{uri.line_number}", + tooltip: "Jump to #{file_path}##{uri.line_number}", + ) + end + + # Try to figure out the gem version for a source:// link. The order of precedence is: + # 1. The version in the URI + # 2. The version in the RBI file name + # 3. The version from the gemspec + sig { params(uri: URI::Source).returns(T.nilable(String)) } + def resolve_version(uri) + version = uri.gem_version + return version unless version.nil? || version.empty? + + return @gem_version unless @gem_version.nil? || @gem_version.empty? + + GEM_TO_VERSION_MAP[uri.gem_name] + end + end + end +end diff --git a/lib/ruby_lsp/requests/document_link.rb b/lib/ruby_lsp/requests/document_link.rb index 52d0bed1ca..b574388ce5 100644 --- a/lib/ruby_lsp/requests/document_link.rb +++ b/lib/ruby_lsp/requests/document_link.rb @@ -1,6 +1,7 @@ # typed: strict # frozen_string_literal: true +require_relative "../listeners/document_link" require "ruby_lsp/requests/support/source_uri" module RubyLsp @@ -18,63 +19,12 @@ module Requests # def format(source, maxwidth = T.unsafe(nil)) # end # ``` - class DocumentLink < Listener + class DocumentLink < ListenerBasedRequest extend T::Sig extend T::Generic ResponseType = type_member { { fixed: T::Array[Interface::DocumentLink] } } - GEM_TO_VERSION_MAP = T.let( - [*::Gem::Specification.default_stubs, *::Gem::Specification.stubs].map! do |s| - [s.name, s.version.to_s] - end.to_h.freeze, - T::Hash[String, String], - ) - - class << self - extend T::Sig - - sig { returns(T::Hash[String, T::Hash[String, T::Hash[String, String]]]) } - def gem_paths - @gem_paths ||= T.let( - begin - lookup = {} - - Gem::Specification.stubs.each do |stub| - spec = stub.to_spec - lookup[spec.name] = {} - lookup[spec.name][spec.version.to_s] = {} - - Dir.glob("**/*.rb", base: "#{spec.full_gem_path}/").each do |path| - lookup[spec.name][spec.version.to_s][path] = "#{spec.full_gem_path}/#{path}" - end - end - - Gem::Specification.default_stubs.each do |stub| - spec = stub.to_spec - lookup[spec.name] = {} - lookup[spec.name][spec.version.to_s] = {} - prefix_matchers = Regexp.union(spec.require_paths.map do |rp| - Regexp.new("^#{rp}/") - end) - prefix_matcher = Regexp.union(prefix_matchers, //) - - spec.files.each do |file| - path = file.sub(prefix_matcher, "") - lookup[spec.name][spec.version.to_s][path] = "#{RbConfig::CONFIG["rubylibdir"]}/#{path}" - end - end - - lookup - end, - T.nilable(T::Hash[String, T::Hash[String, T::Hash[String, String]]]), - ) - end - end - - sig { override.returns(ResponseType) } - attr_reader :_response - sig do params( uri: URI::Generic, @@ -83,92 +33,13 @@ def gem_paths ).void end def initialize(uri, comments, dispatcher) - super(dispatcher) - - # Match the version based on the version in the RBI file name. Notice that the `@` symbol is sanitized to `%40` - # in the URI - path = uri.to_standardized_path - version_match = path ? /(?<=%40)[\d.]+(?=\.rbi$)/.match(path) : nil - @gem_version = T.let(version_match && version_match[0], T.nilable(String)) - @_response = T.let([], T::Array[Interface::DocumentLink]) - @lines_to_comments = T.let( - comments.to_h do |comment| - [comment.location.end_line, comment] - end, - T::Hash[Integer, Prism::Comment], - ) - - dispatcher.register( - self, - :on_def_node_enter, - :on_class_node_enter, - :on_module_node_enter, - :on_constant_write_node_enter, - :on_constant_path_write_node_enter, - ) - end - - sig { params(node: Prism::DefNode).void } - def on_def_node_enter(node) - extract_document_link(node) - end - - sig { params(node: Prism::ClassNode).void } - def on_class_node_enter(node) - extract_document_link(node) + listener = Listeners::DocumentLink.new(uri, comments, dispatcher) + super([listener]) end - sig { params(node: Prism::ModuleNode).void } - def on_module_node_enter(node) - extract_document_link(node) - end - - sig { params(node: Prism::ConstantWriteNode).void } - def on_constant_write_node_enter(node) - extract_document_link(node) - end - - sig { params(node: Prism::ConstantPathWriteNode).void } - def on_constant_path_write_node_enter(node) - extract_document_link(node) - end - - private - - sig { params(node: Prism::Node).void } - def extract_document_link(node) - comment = @lines_to_comments[node.location.start_line - 1] - return unless comment - - match = comment.location.slice.match(%r{source://.*#\d+$}) - return unless match - - uri = T.cast(URI(T.must(match[0])), URI::Source) - gem_version = resolve_version(uri) - return if gem_version.nil? - - file_path = self.class.gem_paths.dig(uri.gem_name, gem_version, CGI.unescape(uri.path)) - return if file_path.nil? - - @_response << Interface::DocumentLink.new( - range: range_from_location(comment.location), - target: "file://#{file_path}##{uri.line_number}", - tooltip: "Jump to #{file_path}##{uri.line_number}", - ) - end - - # Try to figure out the gem version for a source:// link. The order of precedence is: - # 1. The version in the URI - # 2. The version in the RBI file name - # 3. The version from the gemspec - sig { params(uri: URI::Source).returns(T.nilable(String)) } - def resolve_version(uri) - version = uri.gem_version - return version unless version.nil? || version.empty? - - return @gem_version unless @gem_version.nil? || @gem_version.empty? - - GEM_TO_VERSION_MAP[uri.gem_name] + sig { override.returns(ResponseType) } + def response + @listeners.flat_map(&:response) end end end