ruby-lsp 0.8.1 → 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b43407d4d6309c7243a6c69aca8166f6ab3548bd47e939c016efe26c1d8a4a76
4
- data.tar.gz: cc5f638aab3322054cc2a09b60fe22a6e5ee8196d88492b4139907c2495c7d5d
3
+ metadata.gz: '08f4db46a392926873115c77175be859ecdfc4c5093e003f913eec7288f05fac'
4
+ data.tar.gz: 2f34acc2fc69ab4dd6ad5fc9961a86c020d550284ed18aefd3221b0525af2628
5
5
  SHA512:
6
- metadata.gz: dda98d73d8ba39cdfaffa3362177ef96887eccff10332012d43f79bdc78643d3bb79a9e43e05a5c21e4550cf9b86d8d00b5742fb51b124d2ed8c8112bc6f1d1a
7
- data.tar.gz: 25a169a3eb717f5f11b5b911c91e11e578d33734f6f616c0c92fd6ebcc89d69ddda6abb13410ef231acc398ba0472e17c778d245b89f1285d64317df98c2299c
6
+ metadata.gz: 99a9e243c0dd2f763532a5a1c52241ac86c9a7ba2ccb0814932d8e08b17add78143566ac26ad609b59ff8d96e1d2844b53995b85b473cb736c5f87e2a30c5a26
7
+ data.tar.gz: 2a1669be7a054a5d19b0b7eff113347571928cb601e1b432c6f49bc3f349a530b6d0df45c7ea5dfa46add7b69fd347713ec33f95fdca9baa896855f958814209
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.8.1
1
+ 0.9.1
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
@@ -21,6 +21,7 @@ module RubyLsp
21
21
  # - [CodeLens](rdoc-ref:RubyLsp::Requests::CodeLens)
22
22
  # - [Definition](rdoc-ref:RubyLsp::Requests::Definition)
23
23
  # - [ShowSyntaxTree](rdoc-ref:RubyLsp::Requests::ShowSyntaxTree)
24
+ # - [WorkspaceSymbol](rdoc-ref:RubyLsp::Requests::WorkspaceSymbol)
24
25
 
25
26
  module Requests
26
27
  autoload :BaseRequest, "ruby_lsp/requests/base_request"
@@ -41,6 +42,7 @@ module RubyLsp
41
42
  autoload :CodeLens, "ruby_lsp/requests/code_lens"
42
43
  autoload :Definition, "ruby_lsp/requests/definition"
43
44
  autoload :ShowSyntaxTree, "ruby_lsp/requests/show_syntax_tree"
45
+ autoload :WorkspaceSymbol, "ruby_lsp/requests/workspace_symbol"
44
46
 
45
47
  # :nodoc:
46
48
  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.1
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