ruby-lsp 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b43407d4d6309c7243a6c69aca8166f6ab3548bd47e939c016efe26c1d8a4a76
4
- data.tar.gz: cc5f638aab3322054cc2a09b60fe22a6e5ee8196d88492b4139907c2495c7d5d
3
+ metadata.gz: 3dd7fa2d26430cf1974e1d94bbfa860166dd964ffe9268d9b307b323baa86954
4
+ data.tar.gz: 6183306fd3d34396de880184fe9a09cdafccd6cb071b7927e458f658d9397221
5
5
  SHA512:
6
- metadata.gz: dda98d73d8ba39cdfaffa3362177ef96887eccff10332012d43f79bdc78643d3bb79a9e43e05a5c21e4550cf9b86d8d00b5742fb51b124d2ed8c8112bc6f1d1a
7
- data.tar.gz: 25a169a3eb717f5f11b5b911c91e11e578d33734f6f616c0c92fd6ebcc89d69ddda6abb13410ef231acc398ba0472e17c778d245b89f1285d64317df98c2299c
6
+ metadata.gz: ed5e0524b0001497cd857dad621a6b072434375ef2be8583e12ddfcd2b7d104b279ab7ea026db09e563ddd908d7d575da682bf6a30244cd6aca68da6b4ee2ad6
7
+ data.tar.gz: 8de1bdb11dfb12c34129e3fddeae89c6b3700a74cb6483424523df9584a28a8b52499fade45d7c7c047ee517ed53a5a4f54b9fcc04a386a17b092999a264563a
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.8.1
1
+ 0.9.0
data/exe/ruby-lsp CHANGED
@@ -33,7 +33,7 @@ parser = OptionParser.new do |opts|
33
33
  end
34
34
 
35
35
  begin
36
- parser.parse!
36
+ parser.parse
37
37
  rescue OptionParser::InvalidOption => e
38
38
  warn(e)
39
39
  warn("")
data/lib/core_ext/uri.rb CHANGED
@@ -6,8 +6,8 @@ module URI
6
6
  class << self
7
7
  extend T::Sig
8
8
 
9
- sig { params(path: String, scheme: String).returns(URI::Generic) }
10
- def from_path(path:, scheme: "file")
9
+ sig { params(path: String, fragment: T.nilable(String), scheme: String).returns(URI::Generic) }
10
+ def from_path(path:, fragment: nil, scheme: "file")
11
11
  # On Windows, if the path begins with the disk name, we need to add a leading slash to make it a valid URI
12
12
  escaped_path = if /^[A-Z]:/i.match?(path)
13
13
  DEFAULT_PARSER.escape("/#{path}")
@@ -15,7 +15,7 @@ module URI
15
15
  DEFAULT_PARSER.escape(path)
16
16
  end
17
17
 
18
- build(scheme: scheme, path: escaped_path)
18
+ build(scheme: scheme, path: escaped_path, fragment: fragment)
19
19
  end
20
20
  end
21
21
 
@@ -26,7 +26,7 @@ module URI
26
26
  parsed_path = path
27
27
  return unless parsed_path
28
28
 
29
- unescaped_path = CGI.unescape(parsed_path)
29
+ unescaped_path = DEFAULT_PARSER.unescape(parsed_path)
30
30
 
31
31
  # On Windows, when we're getting the file system path back from the URI, we need to remove the leading forward
32
32
  # slash
@@ -11,20 +11,40 @@ module RubyIndexer
11
11
  "included_gems" => Array,
12
12
  "excluded_patterns" => Array,
13
13
  "included_patterns" => Array,
14
+ "excluded_magic_comments" => Array,
14
15
  }.freeze,
15
- T::Hash[String, T::Class[Object]],
16
+ T::Hash[String, T.untyped],
16
17
  )
17
18
 
18
19
  sig { void }
19
20
  def initialize
20
- development_only_dependencies = Bundler.definition.dependencies.filter_map do |dependency|
21
+ excluded_gem_names = Bundler.definition.dependencies.filter_map do |dependency|
21
22
  dependency.name if dependency.groups == [:development]
22
23
  end
23
24
 
24
- @excluded_gems = T.let(development_only_dependencies, T::Array[String])
25
+ @excluded_gems = T.let(excluded_gem_names, T::Array[String])
25
26
  @included_gems = T.let([], T::Array[String])
26
- @excluded_patterns = T.let(["*_test.rb"], T::Array[String])
27
+ @excluded_patterns = T.let(["**/*_test.rb"], T::Array[String])
28
+ path = Bundler.settings["path"]
29
+ @excluded_patterns << "#{File.expand_path(path, Dir.pwd)}/**/*.rb" if path
30
+
27
31
  @included_patterns = T.let(["#{Dir.pwd}/**/*.rb"], T::Array[String])
32
+ @excluded_magic_comments = T.let(
33
+ [
34
+ "frozen_string_literal:",
35
+ "typed:",
36
+ "compiled:",
37
+ "encoding:",
38
+ "shareable_constant_value:",
39
+ "warn_indent:",
40
+ "rubocop:",
41
+ "nodoc:",
42
+ "doc:",
43
+ "coding:",
44
+ "warn_past_scope:",
45
+ ],
46
+ T::Array[String],
47
+ )
28
48
  end
29
49
 
30
50
  sig { void }
@@ -43,26 +63,80 @@ module RubyIndexer
43
63
 
44
64
  sig { returns(T::Array[String]) }
45
65
  def files_to_index
46
- files_to_index = $LOAD_PATH.flat_map { |p| Dir.glob("#{p}/**/*.rb", base: p) }
66
+ excluded_gems = @excluded_gems - @included_gems
67
+ locked_gems = Bundler.locked_gems&.specs
47
68
 
48
- @included_patterns.each do |pattern|
49
- files_to_index.concat(Dir.glob(pattern, File::FNM_PATHNAME | File::FNM_EXTGLOB))
50
- end
69
+ # NOTE: indexing the patterns (both included and excluded) needs to happen before indexing gems, otherwise we risk
70
+ # having duplicates if BUNDLE_PATH is set to a folder inside the project structure
51
71
 
52
- excluded_gem_paths = (@excluded_gems - @included_gems).filter_map do |gem_name|
53
- Gem::Specification.find_by_name(gem_name).full_gem_path
54
- rescue Gem::MissingSpecError
55
- warn("Gem #{gem_name} is excluded in .index.yml, but that gem was not found in the bundle")
72
+ # Add user specified patterns
73
+ files_to_index = @included_patterns.flat_map do |pattern|
74
+ Dir.glob(pattern, File::FNM_PATHNAME | File::FNM_EXTGLOB)
56
75
  end
57
76
 
77
+ # Remove user specified patterns
58
78
  files_to_index.reject! do |path|
59
- @excluded_patterns.any? { |pattern| File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) } ||
60
- excluded_gem_paths.any? { |gem_path| File.fnmatch?("#{gem_path}/**/*.rb", path) }
79
+ @excluded_patterns.any? do |pattern|
80
+ File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
81
+ end
82
+ end
83
+
84
+ # Add default gems to the list of files to be indexed
85
+ Dir.glob("#{RbConfig::CONFIG["rubylibdir"]}/*").each do |default_path|
86
+ # The default_path might be a Ruby file or a folder with the gem's name. For example:
87
+ # bundler/
88
+ # bundler.rb
89
+ # psych/
90
+ # psych.rb
91
+ pathname = Pathname.new(default_path)
92
+ short_name = pathname.basename.to_s.delete_suffix(".rb")
93
+
94
+ # If the gem name is excluded, then we skip it
95
+ next if excluded_gems.include?(short_name)
96
+
97
+ # If the default gem is also a part of the bundle, we skip indexing the default one and index only the one in
98
+ # the bundle, which won't be in `default_path`, but will be in `Bundler.bundle_path` instead
99
+ next if locked_gems&.any? do |locked_spec|
100
+ locked_spec.name == short_name &&
101
+ !Gem::Specification.find_by_name(short_name).full_gem_path.start_with?(RbConfig::CONFIG["rubylibprefix"])
102
+ end
103
+
104
+ if pathname.directory?
105
+ # If the default_path is a directory, we index all the Ruby files in it
106
+ files_to_index.concat(Dir.glob("#{default_path}/**/*.rb", File::FNM_PATHNAME | File::FNM_EXTGLOB))
107
+ else
108
+ # If the default_path is a Ruby file, we index it
109
+ files_to_index << default_path
110
+ end
61
111
  end
112
+
113
+ # Add the locked gems to the list of files to be indexed
114
+ locked_gems&.each do |lazy_spec|
115
+ next if excluded_gems.include?(lazy_spec.name)
116
+
117
+ spec = Gem::Specification.find_by_name(lazy_spec.name)
118
+
119
+ # When working on a gem, it will be included in the locked_gems list. Since these are the project's own files,
120
+ # we have already included and handled exclude patterns for it and should not re-include or it'll lead to
121
+ # duplicates or accidentally ignoring exclude patterns
122
+ next if spec.full_gem_path == Dir.pwd
123
+
124
+ files_to_index.concat(Dir.glob("#{spec.full_gem_path}/{#{spec.require_paths.join(",")}}/**/*.rb"))
125
+ rescue Gem::MissingSpecError
126
+ # If a gem is scoped only to some specific platform, then its dependencies may not be installed either, but they
127
+ # are still listed in locked_gems. We can't index them because they are not installed for the platform, so we
128
+ # just ignore if they're missing
129
+ end
130
+
62
131
  files_to_index.uniq!
63
132
  files_to_index
64
133
  end
65
134
 
135
+ sig { returns(Regexp) }
136
+ def magic_comment_regex
137
+ /^\s*#\s*#{@excluded_magic_comments.join("|")}/
138
+ end
139
+
66
140
  private
67
141
 
68
142
  sig { params(config: T::Hash[String, T.untyped]).void }
@@ -86,6 +160,7 @@ module RubyIndexer
86
160
  @included_gems.concat(config["included_gems"]) if config["included_gems"]
87
161
  @excluded_patterns.concat(config["excluded_patterns"]) if config["excluded_patterns"]
88
162
  @included_patterns.concat(config["included_patterns"]) if config["included_patterns"]
163
+ @excluded_magic_comments.concat(config["excluded_magic_comments"]) if config["excluded_magic_comments"]
89
164
  end
90
165
  end
91
166
  end
@@ -5,6 +5,9 @@ module RubyIndexer
5
5
  class Index
6
6
  extend T::Sig
7
7
 
8
+ # The minimum Jaro-Winkler similarity score for an entry to be considered a match for a given fuzzy search query
9
+ ENTRY_SIMILARITY_THRESHOLD = 0.7
10
+
8
11
  sig { void }
9
12
  def initialize
10
13
  # Holds all entries in the index using the following format:
@@ -50,6 +53,21 @@ module RubyIndexer
50
53
  @entries[fully_qualified_name.delete_prefix("::")]
51
54
  end
52
55
 
56
+ # Fuzzy searches index entries based on Jaro-Winkler similarity. If no query is provided, all entries are returned
57
+ sig { params(query: T.nilable(String)).returns(T::Array[Entry]) }
58
+ def fuzzy_search(query)
59
+ return @entries.flat_map { |_name, entries| entries } unless query
60
+
61
+ normalized_query = query.gsub("::", "").downcase
62
+
63
+ results = @entries.filter_map do |name, entries|
64
+ similarity = DidYouMean::JaroWinkler.distance(name.gsub("::", "").downcase, normalized_query)
65
+ [entries, -similarity] if similarity > ENTRY_SIMILARITY_THRESHOLD
66
+ end
67
+ results.sort_by!(&:last)
68
+ results.flat_map(&:first)
69
+ end
70
+
53
71
  # Try to find the entry based on the nesting from the most specific to the least specific. For example, if we have
54
72
  # the nesting as ["Foo", "Bar"] and the name as "Baz", we will try to find it in this order:
55
73
  # 1. Foo::Bar::Baz
@@ -77,6 +95,8 @@ module RubyIndexer
77
95
  content = source || File.read(path)
78
96
  visitor = IndexVisitor.new(self, YARP.parse(content), path)
79
97
  visitor.run
98
+ rescue Errno::EISDIR
99
+ # If `path` is a directory, just ignore it and continue indexing
80
100
  end
81
101
 
82
102
  class Entry
@@ -102,6 +122,11 @@ module RubyIndexer
102
122
  @comments = comments
103
123
  end
104
124
 
125
+ sig { returns(String) }
126
+ def file_name
127
+ File.basename(@file_path)
128
+ end
129
+
105
130
  class Namespace < Entry
106
131
  sig { returns(String) }
107
132
  def short_name
@@ -35,9 +35,9 @@ module RubyIndexer
35
35
  add_index_entry(node, Index::Entry::Class)
36
36
  when YARP::ModuleNode
37
37
  add_index_entry(node, Index::Entry::Module)
38
- when YARP::ConstantWriteNode, YARP::ConstantOperatorOrWriteNode
38
+ when YARP::ConstantWriteNode, YARP::ConstantOrWriteNode
39
39
  add_constant(node)
40
- when YARP::ConstantPathWriteNode, YARP::ConstantPathOperatorOrWriteNode
40
+ when YARP::ConstantPathWriteNode, YARP::ConstantPathOrWriteNode
41
41
  add_constant_with_path(node)
42
42
  end
43
43
  end
@@ -52,7 +52,7 @@ module RubyIndexer
52
52
 
53
53
  sig do
54
54
  params(
55
- node: T.any(YARP::ConstantWriteNode, YARP::ConstantOperatorOrWriteNode),
55
+ node: T.any(YARP::ConstantWriteNode, YARP::ConstantOrWriteNode),
56
56
  ).void
57
57
  end
58
58
  def add_constant(node)
@@ -62,7 +62,7 @@ module RubyIndexer
62
62
 
63
63
  sig do
64
64
  params(
65
- node: T.any(YARP::ConstantPathWriteNode, YARP::ConstantPathOperatorOrWriteNode),
65
+ node: T.any(YARP::ConstantPathWriteNode, YARP::ConstantPathOrWriteNode),
66
66
  ).void
67
67
  end
68
68
  def add_constant_with_path(node)
@@ -70,10 +70,8 @@ module RubyIndexer
70
70
  return unless node.target.parent.nil? || node.target.parent.is_a?(YARP::ConstantReadNode)
71
71
 
72
72
  name = node.target.location.slice
73
- fully_qualified_name = name.start_with?("::") ? name.delete_prefix("::") : fully_qualify_name(name)
74
-
75
73
  comments = collect_comments(node)
76
- @index << Index::Entry::Constant.new(fully_qualified_name, @file_path, node.location, comments)
74
+ @index << Index::Entry::Constant.new(fully_qualify_name(name), @file_path, node.location, comments)
77
75
  end
78
76
 
79
77
  sig { params(node: T.any(YARP::ClassNode, YARP::ModuleNode), klass: T.class_of(Index::Entry)).void }
@@ -84,11 +82,8 @@ module RubyIndexer
84
82
  return visit_child_nodes(node)
85
83
  end
86
84
 
87
- fully_qualified_name = name.start_with?("::") ? name : fully_qualify_name(name)
88
- name.delete_prefix!("::")
89
-
90
85
  comments = collect_comments(node)
91
- @index << klass.new(fully_qualified_name, @file_path, node.location, comments)
86
+ @index << klass.new(fully_qualify_name(name), @file_path, node.location, comments)
92
87
  @stack << name
93
88
  visit_child_nodes(node)
94
89
  @stack.pop
@@ -105,7 +100,12 @@ module RubyIndexer
105
100
  comment = @comments_by_line[line]
106
101
  break unless comment
107
102
 
108
- comments.unshift(comment.location.slice)
103
+ comment_content = comment.location.slice.chomp
104
+ next if comment_content.match?(RubyIndexer.configuration.magic_comment_regex)
105
+
106
+ comment_content.delete_prefix!("#")
107
+ comment_content.delete_prefix!(" ")
108
+ comments.unshift(comment_content)
109
109
  end
110
110
 
111
111
  comments
@@ -113,9 +113,11 @@ module RubyIndexer
113
113
 
114
114
  sig { params(name: String).returns(String) }
115
115
  def fully_qualify_name(name)
116
- return name if @stack.empty?
117
-
118
- "#{@stack.join("::")}::#{name}"
116
+ if @stack.empty? || name.start_with?("::")
117
+ name
118
+ else
119
+ "#{@stack.join("::")}::#{name}"
120
+ end.delete_prefix("::")
119
121
  end
120
122
  end
121
123
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "yaml"
5
+ require "did_you_mean"
5
6
 
6
7
  require "ruby_indexer/lib/ruby_indexer/visitor"
7
8
  require "ruby_indexer/lib/ruby_indexer/index"
@@ -162,10 +162,10 @@ module RubyIndexer
162
162
  RUBY
163
163
 
164
164
  foo_entry = @index["Foo"].first
165
- assert_equal("# This is a Foo comment\n# This is another Foo comment\n", foo_entry.comments.join)
165
+ assert_equal("This is a Foo comment\nThis is another Foo comment", foo_entry.comments.join("\n"))
166
166
 
167
167
  bar_entry = @index["Bar"].first
168
- assert_equal("# This Bar comment has 1 line padding\n", bar_entry.comments.join)
168
+ assert_equal("This Bar comment has 1 line padding", bar_entry.comments.join("\n"))
169
169
  end
170
170
 
171
171
  def test_comments_can_be_attached_to_a_namespaced_class
@@ -179,10 +179,10 @@ module RubyIndexer
179
179
  RUBY
180
180
 
181
181
  foo_entry = @index["Foo"].first
182
- assert_equal("# This is a Foo comment\n# This is another Foo comment\n", foo_entry.comments.join)
182
+ assert_equal("This is a Foo comment\nThis is another Foo comment", foo_entry.comments.join("\n"))
183
183
 
184
184
  bar_entry = @index["Foo::Bar"].first
185
- assert_equal("# This is a Bar comment\n", bar_entry.comments.join)
185
+ assert_equal("This is a Bar comment", bar_entry.comments.join("\n"))
186
186
  end
187
187
 
188
188
  def test_comments_can_be_attached_to_a_reopened_class
@@ -195,10 +195,26 @@ module RubyIndexer
195
195
  RUBY
196
196
 
197
197
  first_foo_entry = @index["Foo"][0]
198
- assert_equal("# This is a Foo comment\n", first_foo_entry.comments.join)
198
+ assert_equal("This is a Foo comment", first_foo_entry.comments.join("\n"))
199
199
 
200
200
  second_foo_entry = @index["Foo"][1]
201
- assert_equal("# This is another Foo comment\n", second_foo_entry.comments.join)
201
+ assert_equal("This is another Foo comment", second_foo_entry.comments.join("\n"))
202
+ end
203
+
204
+ def test_comments_removes_the_leading_pound_and_space
205
+ index(<<~RUBY)
206
+ # This is a Foo comment
207
+ class Foo; end
208
+
209
+ #This is a Bar comment
210
+ class Bar; end
211
+ RUBY
212
+
213
+ first_foo_entry = @index["Foo"][0]
214
+ assert_equal("This is a Foo comment", first_foo_entry.comments.join("\n"))
215
+
216
+ second_foo_entry = @index["Bar"][0]
217
+ assert_equal("This is a Bar comment", second_foo_entry.comments.join("\n"))
202
218
  end
203
219
  end
204
220
  end
@@ -15,6 +15,65 @@ module RubyIndexer
15
15
 
16
16
  assert(files_to_index.none? { |path| path.include?("test/fixtures") })
17
17
  assert(files_to_index.none? { |path| path.include?("minitest-reporters") })
18
+ assert(files_to_index.none? { |path| path == __FILE__ })
19
+ end
20
+
21
+ def test_files_to_index_only_includes_gem_require_paths
22
+ @config.load_config
23
+ files_to_index = @config.files_to_index
24
+
25
+ Bundler.locked_gems.specs.each do |lazy_spec|
26
+ next if lazy_spec.name == "ruby-lsp"
27
+
28
+ spec = Gem::Specification.find_by_name(lazy_spec.name)
29
+ assert(files_to_index.none? { |path| path.start_with?("#{spec.full_gem_path}/test/") })
30
+ rescue Gem::MissingSpecError
31
+ # Transitive dependencies might be missing when running tests on Windows
32
+ end
33
+ end
34
+
35
+ def test_files_to_index_does_not_include_default_gem_path_when_in_bundle
36
+ @config.load_config
37
+ files_to_index = @config.files_to_index
38
+
39
+ assert(files_to_index.none? { |path| path.start_with?("#{RbConfig::CONFIG["rubylibdir"]}/psych") })
40
+ end
41
+
42
+ def test_files_to_index_includes_default_gems
43
+ @config.load_config
44
+ files_to_index = @config.files_to_index
45
+
46
+ assert_includes(files_to_index, "#{RbConfig::CONFIG["rubylibdir"]}/pathname.rb")
47
+ assert_includes(files_to_index, "#{RbConfig::CONFIG["rubylibdir"]}/ipaddr.rb")
48
+ assert_includes(files_to_index, "#{RbConfig::CONFIG["rubylibdir"]}/abbrev.rb")
49
+ end
50
+
51
+ def test_files_to_index_includes_project_files
52
+ @config.load_config
53
+ files_to_index = @config.files_to_index
54
+
55
+ Dir.glob("#{Dir.pwd}/lib/**/*.rb").each do |path|
56
+ next if path.end_with?("_test.rb")
57
+
58
+ assert_includes(files_to_index, path)
59
+ end
60
+ end
61
+
62
+ def test_files_to_index_avoids_duplicates_if_bundle_path_is_inside_project
63
+ Bundler.settings.set_global("path", "vendor/bundle")
64
+ config = Configuration.new
65
+ config.load_config
66
+
67
+ assert_includes(config.instance_variable_get(:@excluded_patterns), "#{Dir.pwd}/vendor/bundle/**/*.rb")
68
+ ensure
69
+ Bundler.settings.set_global("path", nil)
70
+ end
71
+
72
+ def test_files_to_index_does_not_include_gems_own_installed_files
73
+ @config.load_config
74
+ files_to_index = @config.files_to_index
75
+
76
+ assert(files_to_index.none? { |path| path.start_with?(Bundler.bundle_path.join("gems", "ruby-lsp").to_s) })
18
77
  end
19
78
 
20
79
  def test_paths_are_unique
@@ -31,5 +90,25 @@ module RubyIndexer
31
90
  @config.load_config
32
91
  end
33
92
  end
93
+
94
+ def test_magic_comments_regex
95
+ regex = RubyIndexer.configuration.magic_comment_regex
96
+
97
+ [
98
+ "# frozen_string_literal:",
99
+ "# typed:",
100
+ "# compiled:",
101
+ "# encoding:",
102
+ "# shareable_constant_value:",
103
+ "# warn_indent:",
104
+ "# rubocop:",
105
+ "# nodoc:",
106
+ "# doc:",
107
+ "# coding:",
108
+ "# warn_past_scope:",
109
+ ].each do |comment|
110
+ assert_match(regex, comment)
111
+ end
112
+ end
34
113
  end
35
114
  end
@@ -84,16 +84,16 @@ module RubyIndexer
84
84
  RUBY
85
85
 
86
86
  foo_comment = @index["FOO"].first.comments.join("\n")
87
- assert_equal("# FOO comment\n", foo_comment)
87
+ assert_equal("FOO comment", foo_comment)
88
88
 
89
89
  a_foo_comment = @index["A::FOO"].first.comments.join("\n")
90
- assert_equal("# A::FOO comment\n", a_foo_comment)
90
+ assert_equal("A::FOO comment", a_foo_comment)
91
91
 
92
92
  bar_comment = @index["BAR"].first.comments.join("\n")
93
- assert_equal("# ::BAR comment\n", bar_comment)
93
+ assert_equal("::BAR comment", bar_comment)
94
94
 
95
95
  a_baz_comment = @index["A::BAZ"].first.comments.join("\n")
96
- assert_equal("# A::BAZ comment\n", a_baz_comment)
96
+ assert_equal("A::BAZ comment", a_baz_comment)
97
97
  end
98
98
 
99
99
  def test_variable_path_constants_are_ignored
@@ -90,5 +90,40 @@ module RubyIndexer
90
90
  refute_empty(entries)
91
91
  assert_equal("Foo::Baz::Something", entries.first.name)
92
92
  end
93
+
94
+ def test_fuzzy_search
95
+ @index.index_single("/fake/path/foo.rb", <<~RUBY)
96
+ class Bar; end
97
+
98
+ module Foo
99
+ class Bar
100
+ end
101
+
102
+ class Baz
103
+ class Something
104
+ end
105
+ end
106
+ end
107
+ RUBY
108
+
109
+ result = @index.fuzzy_search("Bar")
110
+ assert_equal(1, result.length)
111
+ assert_equal(@index["Bar"].first, result.first)
112
+
113
+ result = @index.fuzzy_search("foobarsomeking")
114
+ assert_equal(5, result.length)
115
+ assert_equal(["Foo::Baz::Something", "Foo::Bar", "Foo::Baz", "Foo", "Bar"], result.map(&:name))
116
+
117
+ result = @index.fuzzy_search("FooBaz")
118
+ assert_equal(4, result.length)
119
+ assert_equal(["Foo::Baz", "Foo::Bar", "Foo", "Foo::Baz::Something"], result.map(&:name))
120
+ end
121
+
122
+ def test_index_single_ignores_directories
123
+ FileUtils.mkdir("lib/this_is_a_dir.rb")
124
+ @index.index_single("lib/this_is_a_dir.rb")
125
+ ensure
126
+ FileUtils.rm_r("lib/this_is_a_dir.rb")
127
+ end
93
128
  end
94
129
  end
@@ -58,14 +58,7 @@ module RubyLsp
58
58
  warn(errored_extensions.map(&:backtraces).join("\n\n"))
59
59
  end
60
60
 
61
- if @store.experimental_features
62
- # The begin progress invocation happens during `initialize`, so that the notification is sent before we are
63
- # stuck indexing files
64
- RubyIndexer.configuration.load_config
65
- @index.index_all
66
- end_progress("indexing-progress")
67
- end
68
-
61
+ perform_initial_indexing
69
62
  check_formatter_is_available
70
63
 
71
64
  warn("Ruby LSP is ready")
@@ -180,6 +173,8 @@ module RubyLsp
180
173
  definition(uri, request.dig(:params, :position))
181
174
  when "workspace/didChangeWatchedFiles"
182
175
  did_change_watched_files(request.dig(:params, :changes))
176
+ when "workspace/symbol"
177
+ workspace_symbol(request.dig(:params, :query))
183
178
  when "rubyLsp/textDocument/showSyntaxTree"
184
179
  show_syntax_tree(uri, request.dig(:params, :range))
185
180
  end
@@ -207,6 +202,36 @@ module RubyLsp
207
202
  VOID
208
203
  end
209
204
 
205
+ sig { void }
206
+ def perform_initial_indexing
207
+ return unless @store.experimental_features
208
+
209
+ # The begin progress invocation happens during `initialize`, so that the notification is sent before we are
210
+ # stuck indexing files
211
+ RubyIndexer.configuration.load_config
212
+
213
+ begin
214
+ @index.index_all
215
+ rescue StandardError => error
216
+ @message_queue << Notification.new(
217
+ message: "window/showMessage",
218
+ params: Interface::ShowMessageParams.new(
219
+ type: Constant::MessageType::ERROR,
220
+ message: "Error while indexing: #{error.message}",
221
+ ),
222
+ )
223
+ end
224
+
225
+ # Always end the progress notification even if indexing failed or else it never goes away and the user has no way
226
+ # of dismissing it
227
+ end_progress("indexing-progress")
228
+ end
229
+
230
+ sig { params(query: T.nilable(String)).returns(T::Array[Interface::WorkspaceSymbol]) }
231
+ def workspace_symbol(query)
232
+ Requests::WorkspaceSymbol.new(query, @index).run
233
+ end
234
+
210
235
  sig { params(uri: URI::Generic, range: T.nilable(Document::RangeShape)).returns({ ast: String }) }
211
236
  def show_syntax_tree(uri, range)
212
237
  { ast: Requests::ShowSyntaxTree.new(@store.get(uri), range).run }
@@ -252,16 +277,20 @@ module RubyLsp
252
277
  document = @store.get(uri)
253
278
  return if document.syntax_error?
254
279
 
255
- target, parent = document.locate_node(position)
280
+ target, parent, nesting = document.locate_node(
281
+ position,
282
+ node_types: Requests::Hover::ALLOWED_TARGETS,
283
+ )
256
284
 
257
- if !Requests::Hover::ALLOWED_TARGETS.include?(target.class) &&
258
- Requests::Hover::ALLOWED_TARGETS.include?(parent.class)
285
+ if (Requests::Hover::ALLOWED_TARGETS.include?(parent.class) &&
286
+ !Requests::Hover::ALLOWED_TARGETS.include?(target.class)) ||
287
+ (parent.is_a?(SyntaxTree::ConstPathRef) && target.is_a?(SyntaxTree::Const))
259
288
  target = parent
260
289
  end
261
290
 
262
291
  # Instantiate all listeners
263
292
  emitter = EventEmitter.new
264
- hover = Requests::Hover.new(emitter, @message_queue)
293
+ hover = Requests::Hover.new(@index, nesting, emitter, @message_queue)
265
294
 
266
295
  # Emit events for all listeners
267
296
  emitter.emit_for_target(target)
@@ -667,6 +696,7 @@ module RubyLsp
667
696
  completion_provider: completion_provider,
668
697
  code_lens_provider: code_lens_provider,
669
698
  definition_provider: enabled_features["definition"],
699
+ workspace_symbol_provider: enabled_features["workspaceSymbol"],
670
700
  ),
671
701
  )
672
702
  end
@@ -9,12 +9,13 @@ module RubyLsp
9
9
  # request](https://microsoft.github.io/language-server-protocol/specification#textDocument_definition) jumps to the
10
10
  # definition of the symbol under the cursor.
11
11
  #
12
- # Currently, only jumping to required files is supported.
12
+ # Currently, only jumping to classes, modules and required files is supported.
13
13
  #
14
14
  # # Example
15
15
  #
16
16
  # ```ruby
17
17
  # require "some_gem/file" # <- Request go to definition on this string will take you to the file
18
+ # Product.new # <- Request go to definition on this class name will take you to its declaration.
18
19
  # ```
19
20
  class Definition < Listener
20
21
  extend T::Sig
@@ -25,8 +26,6 @@ module RubyLsp
25
26
  sig { override.returns(ResponseType) }
26
27
  attr_reader :response
27
28
 
28
- HAS_TYPECHECKER = T.let(DependencyDetector.typechecker?, T::Boolean)
29
-
30
29
  sig do
31
30
  params(
32
31
  uri: URI::Generic,
@@ -107,17 +106,26 @@ module RubyLsp
107
106
  entries = @index.resolve(value, @nesting)
108
107
  return unless entries
109
108
 
110
- workspace_path = T.must(WORKSPACE_URI.to_standardized_path)
109
+ bundle_path = begin
110
+ Bundler.bundle_path.to_s
111
+ rescue Bundler::GemfileNotFound
112
+ nil
113
+ end
111
114
 
112
115
  @response = entries.filter_map do |entry|
113
116
  location = entry.location
114
117
  # If the project has Sorbet, then we only want to handle go to definition for constants defined in gems, as an
115
118
  # additional behavior on top of jumping to RBIs. Sorbet can already handle go to definition for all constants
116
119
  # in the project, even if the files are typed false
117
- next if HAS_TYPECHECKER && entry.file_path.start_with?(workspace_path)
120
+ file_path = entry.file_path
121
+ if DependencyDetector::HAS_TYPECHECKER && bundle_path && !file_path.start_with?(bundle_path) &&
122
+ !file_path.start_with?(RbConfig::CONFIG["rubylibdir"])
123
+
124
+ next
125
+ end
118
126
 
119
127
  Interface::Location.new(
120
- uri: URI::Generic.from_path(path: entry.file_path).to_s,
128
+ uri: URI::Generic.from_path(path: file_path).to_s,
121
129
  range: Interface::Range.new(
122
130
  start: Interface::Position.new(line: location.start_line - 1, character: location.start_column),
123
131
  end: Interface::Position.new(line: location.end_line - 1, character: location.end_column),
@@ -3,19 +3,15 @@
3
3
 
4
4
  module RubyLsp
5
5
  module Requests
6
- # ![Hover demo](../../rails_document_link_hover.gif)
6
+ # ![Hover demo](../../hover.gif)
7
7
  #
8
8
  # The [hover request](https://microsoft.github.io/language-server-protocol/specification#textDocument_hover)
9
- # renders a clickable link to the code's official documentation.
10
- # It currently only supports Rails' documentation: when hovering over Rails DSLs/constants under certain paths,
11
- # like `before_save :callback` in `models/post.rb`, it generates a link to `before_save`'s API documentation.
9
+ # displays the documentation for the symbol currently under the cursor.
12
10
  #
13
11
  # # Example
14
12
  #
15
13
  # ```ruby
16
- # class Post < ApplicationRecord
17
- # before_save :do_something # when hovering on before_save, the link will be rendered
18
- # end
14
+ # String # -> Hovering over the class reference will show all declaration locations and the documentation
19
15
  # ```
20
16
  class Hover < Listener
21
17
  extend T::Sig
@@ -25,6 +21,7 @@ module RubyLsp
25
21
 
26
22
  ALLOWED_TARGETS = T.let(
27
23
  [
24
+ SyntaxTree::Const,
28
25
  SyntaxTree::Command,
29
26
  SyntaxTree::CallNode,
30
27
  SyntaxTree::ConstPathRef,
@@ -35,15 +32,24 @@ module RubyLsp
35
32
  sig { override.returns(ResponseType) }
36
33
  attr_reader :response
37
34
 
38
- sig { params(emitter: EventEmitter, message_queue: Thread::Queue).void }
39
- def initialize(emitter, message_queue)
40
- super
35
+ sig do
36
+ params(
37
+ index: RubyIndexer::Index,
38
+ nesting: T::Array[String],
39
+ emitter: EventEmitter,
40
+ message_queue: Thread::Queue,
41
+ ).void
42
+ end
43
+ def initialize(index, nesting, emitter, message_queue)
44
+ super(emitter, message_queue)
41
45
 
46
+ @nesting = nesting
47
+ @index = index
42
48
  @external_listeners.concat(
43
49
  Extension.extensions.filter_map { |ext| ext.create_hover_listener(emitter, message_queue) },
44
50
  )
45
51
  @response = T.let(nil, ResponseType)
46
- emitter.register(self, :on_command, :on_const_path_ref, :on_call)
52
+ emitter.register(self, :on_const_path_ref, :on_const)
47
53
  end
48
54
 
49
55
  # Merges responses from other hover listeners
@@ -55,40 +61,57 @@ module RubyLsp
55
61
  if @response.nil?
56
62
  @response = other.response
57
63
  else
58
- @response.contents.value << other_response.contents.value << "\n\n"
64
+ @response.contents.value << "\n\n" << other_response.contents.value
59
65
  end
60
66
 
61
67
  self
62
68
  end
63
69
 
64
- sig { params(node: SyntaxTree::Command).void }
65
- def on_command(node)
66
- message = node.message
67
- @response = generate_rails_document_link_hover(message.value, message)
68
- end
69
-
70
70
  sig { params(node: SyntaxTree::ConstPathRef).void }
71
71
  def on_const_path_ref(node)
72
- @response = generate_rails_document_link_hover(full_constant_name(node), node)
72
+ return if DependencyDetector::HAS_TYPECHECKER
73
+
74
+ name = full_constant_name(node)
75
+ generate_hover(name, node)
73
76
  end
74
77
 
75
- sig { params(node: SyntaxTree::CallNode).void }
76
- def on_call(node)
77
- message = node.message
78
- return if message.is_a?(Symbol)
78
+ sig { params(node: SyntaxTree::Const).void }
79
+ def on_const(node)
80
+ return if DependencyDetector::HAS_TYPECHECKER
79
81
 
80
- @response = generate_rails_document_link_hover(message.value, message)
82
+ generate_hover(node.value, node)
81
83
  end
82
84
 
83
85
  private
84
86
 
85
- sig { params(name: String, node: SyntaxTree::Node).returns(T.nilable(Interface::Hover)) }
86
- def generate_rails_document_link_hover(name, node)
87
- urls = Support::RailsDocumentClient.generate_rails_document_urls(name)
88
- return if urls.empty?
87
+ sig { params(name: String, node: SyntaxTree::Node).void }
88
+ def generate_hover(name, node)
89
+ entries = @index.resolve(name, @nesting)
90
+ return unless entries
91
+
92
+ title = +"```ruby\n#{name}\n```"
93
+ definitions = []
94
+ content = +""
95
+ entries.each do |entry|
96
+ loc = entry.location
97
+
98
+ # We always handle locations as zero based. However, for file links in Markdown we need them to be one based,
99
+ # which is why instead of the usual subtraction of 1 to line numbers, we are actually adding 1 to columns. The
100
+ # format for VS Code file URIs is `file:///path/to/file.rb#Lstart_line,start_column-end_line,end_column`
101
+ uri = URI::Generic.from_path(
102
+ path: entry.file_path,
103
+ fragment: "L#{loc.start_line},#{loc.start_column + 1}-#{loc.end_line},#{loc.end_column + 1}",
104
+ )
105
+
106
+ definitions << "[#{entry.file_name}](#{uri})"
107
+ content << "\n\n#{entry.comments.join("\n")}" unless entry.comments.empty?
108
+ end
89
109
 
90
- contents = Interface::MarkupContent.new(kind: "markdown", value: urls.join("\n\n"))
91
- Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents)
110
+ contents = Interface::MarkupContent.new(
111
+ kind: "markdown",
112
+ value: "#{title}\n\n**Definitions**: #{definitions.join(" | ")}\n\n#{content}",
113
+ )
114
+ @response = Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents)
92
115
  end
93
116
  end
94
117
  end
@@ -49,5 +49,7 @@ module RubyLsp
49
49
  false
50
50
  end
51
51
  end
52
+
53
+ HAS_TYPECHECKER = T.let(typechecker?, T::Boolean)
52
54
  end
53
55
  end
@@ -0,0 +1,86 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module RubyLsp
5
+ module Requests
6
+ # ![Workspace symbol demo](../../workspace_symbol.gif)
7
+ #
8
+ # The [workspace symbol](https://microsoft.github.io/language-server-protocol/specification#workspace_symbol)
9
+ # request allows fuzzy searching declarations in the entire project. On VS Code, use CTRL/CMD + T to search for
10
+ # symbols.
11
+ #
12
+ # # Example
13
+ #
14
+ # ```ruby
15
+ # # Searching for `Floo` will fuzzy match and return all declarations according to the query, including this `Foo`
16
+ # class
17
+ # class Foo
18
+ # end
19
+ # ```
20
+ #
21
+ class WorkspaceSymbol
22
+ extend T::Sig
23
+
24
+ sig { params(query: T.nilable(String), index: RubyIndexer::Index).void }
25
+ def initialize(query, index)
26
+ @query = query
27
+ @index = index
28
+ end
29
+
30
+ sig { returns(T::Array[Interface::WorkspaceSymbol]) }
31
+ def run
32
+ bundle_path = begin
33
+ Bundler.bundle_path.to_s
34
+ rescue Bundler::GemfileNotFound
35
+ nil
36
+ end
37
+
38
+ @index.fuzzy_search(@query).filter_map do |entry|
39
+ # If the project is using Sorbet, we let Sorbet handle symbols defined inside the project itself and RBIs, but
40
+ # we still return entries defined in gems to allow developers to jump directly to the source
41
+ file_path = entry.file_path
42
+ if DependencyDetector::HAS_TYPECHECKER && bundle_path && !file_path.start_with?(bundle_path) &&
43
+ !file_path.start_with?(RbConfig::CONFIG["rubylibdir"])
44
+
45
+ next
46
+ end
47
+
48
+ kind = kind_for_entry(entry)
49
+ loc = entry.location
50
+
51
+ # We use the namespace as the container name, but we also use the full name as the regular name. The reason we
52
+ # do this is to allow people to search for fully qualified names (e.g.: `Foo::Bar`). If we only included the
53
+ # short name `Bar`, then searching for `Foo::Bar` would not return any results
54
+ *container, _short_name = entry.name.split("::")
55
+
56
+ Interface::WorkspaceSymbol.new(
57
+ name: entry.name,
58
+ container_name: T.must(container).join("::"),
59
+ kind: kind,
60
+ location: Interface::Location.new(
61
+ uri: URI::Generic.from_path(path: file_path).to_s,
62
+ range: Interface::Range.new(
63
+ start: Interface::Position.new(line: loc.start_line - 1, character: loc.start_column),
64
+ end: Interface::Position.new(line: loc.end_line - 1, character: loc.end_column),
65
+ ),
66
+ ),
67
+ )
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ sig { params(entry: RubyIndexer::Index::Entry).returns(T.nilable(Integer)) }
74
+ def kind_for_entry(entry)
75
+ case entry
76
+ when RubyIndexer::Index::Entry::Class
77
+ Constant::SymbolKind::CLASS
78
+ when RubyIndexer::Index::Entry::Module
79
+ Constant::SymbolKind::NAMESPACE
80
+ when RubyIndexer::Index::Entry::Constant
81
+ Constant::SymbolKind::CONSTANT
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -41,6 +41,7 @@ module RubyLsp
41
41
  autoload :CodeLens, "ruby_lsp/requests/code_lens"
42
42
  autoload :Definition, "ruby_lsp/requests/definition"
43
43
  autoload :ShowSyntaxTree, "ruby_lsp/requests/show_syntax_tree"
44
+ autoload :WorkspaceSymbol, "ruby_lsp/requests/workspace_symbol"
44
45
 
45
46
  # :nodoc:
46
47
  module Support
@@ -6,6 +6,7 @@ require "bundler"
6
6
  require "fileutils"
7
7
  require "pathname"
8
8
  require "digest"
9
+ require "time"
9
10
 
10
11
  # This file is a script that will configure a custom bundle for the Ruby LSP. The custom bundle allows developers to use
11
12
  # the Ruby LSP without including the gem in their application's Gemfile while at the same time giving us access to the
@@ -17,6 +18,8 @@ module RubyLsp
17
18
 
18
19
  class BundleNotLocked < StandardError; end
19
20
 
21
+ FOUR_HOURS = T.let(4 * 60 * 60, Integer)
22
+
20
23
  sig { params(project_path: String, branch: T.nilable(String)).void }
21
24
  def initialize(project_path, branch: nil)
22
25
  @project_path = project_path
@@ -27,6 +30,7 @@ module RubyLsp
27
30
  @custom_gemfile = T.let(@custom_dir + "Gemfile", Pathname)
28
31
  @custom_lockfile = T.let(@custom_dir + "Gemfile.lock", Pathname)
29
32
  @lockfile_hash_path = T.let(@custom_dir + "main_lockfile_hash", Pathname)
33
+ @last_updated_path = T.let(@custom_dir + "last_updated", Pathname)
30
34
 
31
35
  # Regular bundle paths
32
36
  @gemfile = T.let(
@@ -173,15 +177,16 @@ module RubyLsp
173
177
  # custom `.ruby-lsp/Gemfile.lock` already exists and includes both gems
174
178
  command = +""
175
179
 
176
- if (@dependencies["ruby-lsp"] && @dependencies["debug"]) ||
177
- custom_bundle_dependencies["ruby-lsp"].nil? || custom_bundle_dependencies["debug"].nil?
180
+ if should_bundle_install?
178
181
  # Install gems using the custom bundle
179
- command << "bundle install "
182
+ command << "bundle check || bundle install "
180
183
  else
181
184
  # If ruby-lsp or debug are not in the Gemfile, try to update them to the latest version
182
185
  command << "bundle update "
183
186
  command << "ruby-lsp " unless @dependencies["ruby-lsp"]
184
187
  command << "debug " unless @dependencies["debug"]
188
+
189
+ @last_updated_path.write(Time.now.iso8601)
185
190
  end
186
191
 
187
192
  # Redirect stdout to stderr to prevent going into an infinite loop. The extension might confuse stdout output with
@@ -193,5 +198,12 @@ module RubyLsp
193
198
  system(env, command)
194
199
  [bundle_gemfile.to_s, expanded_path]
195
200
  end
201
+
202
+ sig { returns(T::Boolean) }
203
+ def should_bundle_install?
204
+ (!@dependencies["ruby-lsp"].nil? && !@dependencies["debug"].nil?) ||
205
+ custom_bundle_dependencies["ruby-lsp"].nil? || custom_bundle_dependencies["debug"].nil? ||
206
+ (@last_updated_path.exist? && Time.parse(@last_updated_path.read) > (Time.now - FOUR_HOURS))
207
+ end
196
208
  end
197
209
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-lsp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-08-17 00:00:00.000000000 Z
11
+ date: 2023-08-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: language_server-protocol
@@ -62,16 +62,22 @@ dependencies:
62
62
  name: yarp
63
63
  requirement: !ruby/object:Gem::Requirement
64
64
  requirements:
65
- - - "~>"
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0.9'
68
+ - - "<"
66
69
  - !ruby/object:Gem::Version
67
- version: 0.6.0
70
+ version: '0.10'
68
71
  type: :runtime
69
72
  prerelease: false
70
73
  version_requirements: !ruby/object:Gem::Requirement
71
74
  requirements:
72
- - - "~>"
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0.9'
78
+ - - "<"
73
79
  - !ruby/object:Gem::Version
74
- version: 0.6.0
80
+ version: '0.10'
75
81
  description: An opinionated language server for Ruby
76
82
  email:
77
83
  - ruby@shopify.com
@@ -130,7 +136,6 @@ files:
130
136
  - lib/ruby_lsp/requests/support/formatter_runner.rb
131
137
  - lib/ruby_lsp/requests/support/highlight_target.rb
132
138
  - lib/ruby_lsp/requests/support/prefix_tree.rb
133
- - lib/ruby_lsp/requests/support/rails_document_client.rb
134
139
  - lib/ruby_lsp/requests/support/rubocop_diagnostic.rb
135
140
  - lib/ruby_lsp/requests/support/rubocop_diagnostics_runner.rb
136
141
  - lib/ruby_lsp/requests/support/rubocop_formatting_runner.rb
@@ -140,6 +145,7 @@ files:
140
145
  - lib/ruby_lsp/requests/support/sorbet.rb
141
146
  - lib/ruby_lsp/requests/support/source_uri.rb
142
147
  - lib/ruby_lsp/requests/support/syntax_tree_formatting_runner.rb
148
+ - lib/ruby_lsp/requests/workspace_symbol.rb
143
149
  - lib/ruby_lsp/server.rb
144
150
  - lib/ruby_lsp/setup_bundler.rb
145
151
  - lib/ruby_lsp/store.rb
@@ -1,122 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- require "net/http"
5
-
6
- module RubyLsp
7
- module Requests
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