ruby-lsp 0.18.4 → 0.19.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.
@@ -40,6 +40,8 @@ module RubyLsp
40
40
  # For example, forgetting to return the `insertText` included in the original item will make the editor use the
41
41
  # `label` for the text edit instead
42
42
  label = @item[:label].dup
43
+ return keyword_resolve(@item) if @item.dig(:data, :keyword)
44
+
43
45
  entries = @index[label] || []
44
46
 
45
47
  owner_name = @item.dig(:data, :owner_name)
@@ -72,6 +74,33 @@ module RubyLsp
72
74
 
73
75
  @item
74
76
  end
77
+
78
+ private
79
+
80
+ sig { params(item: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
81
+ def keyword_resolve(item)
82
+ keyword = item[:label]
83
+ content = KEYWORD_DOCS[keyword]
84
+
85
+ if content
86
+ doc_path = File.join(STATIC_DOCS_PATH, "#{keyword}.md")
87
+
88
+ @item[:documentation] = Interface::MarkupContent.new(
89
+ kind: "markdown",
90
+ value: <<~MARKDOWN.chomp,
91
+ ```ruby
92
+ #{keyword}
93
+ ```
94
+
95
+ [Read more](#{doc_path})
96
+
97
+ #{content}
98
+ MARKDOWN
99
+ )
100
+ end
101
+
102
+ item
103
+ end
75
104
  end
76
105
  end
77
106
  end
@@ -0,0 +1,189 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module RubyLsp
5
+ module Requests
6
+ # The
7
+ # [rename](https://microsoft.github.io/language-server-protocol/specification#textDocument_rename)
8
+ # request renames all instances of a symbol in a document.
9
+ class Rename < Request
10
+ extend T::Sig
11
+ include Support::Common
12
+
13
+ class InvalidNameError < StandardError; end
14
+
15
+ sig do
16
+ params(
17
+ global_state: GlobalState,
18
+ store: Store,
19
+ document: T.any(RubyDocument, ERBDocument),
20
+ params: T::Hash[Symbol, T.untyped],
21
+ ).void
22
+ end
23
+ def initialize(global_state, store, document, params)
24
+ super()
25
+ @global_state = global_state
26
+ @store = store
27
+ @document = document
28
+ @position = T.let(params[:position], T::Hash[Symbol, Integer])
29
+ @new_name = T.let(params[:newName], String)
30
+ end
31
+
32
+ sig { override.returns(T.nilable(Interface::WorkspaceEdit)) }
33
+ def perform
34
+ char_position = @document.create_scanner.find_char_position(@position)
35
+
36
+ node_context = RubyDocument.locate(
37
+ @document.parse_result.value,
38
+ char_position,
39
+ node_types: [Prism::ConstantReadNode, Prism::ConstantPathNode, Prism::ConstantPathTargetNode],
40
+ )
41
+ target = node_context.node
42
+ parent = node_context.parent
43
+ return if !target || target.is_a?(Prism::ProgramNode)
44
+
45
+ if target.is_a?(Prism::ConstantReadNode) && parent.is_a?(Prism::ConstantPathNode)
46
+ target = determine_target(
47
+ target,
48
+ parent,
49
+ @position,
50
+ )
51
+ end
52
+
53
+ target = T.cast(
54
+ target,
55
+ T.any(Prism::ConstantReadNode, Prism::ConstantPathNode, Prism::ConstantPathTargetNode),
56
+ )
57
+
58
+ name = constant_name(target)
59
+ return unless name
60
+
61
+ entries = @global_state.index.resolve(name, node_context.nesting)
62
+ return unless entries
63
+
64
+ if (conflict_entries = @global_state.index.resolve(@new_name, node_context.nesting))
65
+ raise InvalidNameError, "The new name is already in use by #{T.must(conflict_entries.first).name}"
66
+ end
67
+
68
+ fully_qualified_name = T.must(entries.first).name
69
+ changes = collect_text_edits(fully_qualified_name, name)
70
+
71
+ # If the client doesn't support resource operations, such as renaming files, then we can only return the basic
72
+ # text changes
73
+ unless @global_state.supported_resource_operations.include?("rename")
74
+ return Interface::WorkspaceEdit.new(changes: changes)
75
+ end
76
+
77
+ # Text edits must be applied before any resource operations, such as renaming files. Otherwise, the file is
78
+ # renamed and then the URI associated to the text edit no longer exists, causing it to be dropped
79
+ document_changes = changes.map do |uri, edits|
80
+ Interface::TextDocumentEdit.new(
81
+ text_document: Interface::VersionedTextDocumentIdentifier.new(uri: uri, version: nil),
82
+ edits: edits,
83
+ )
84
+ end
85
+
86
+ collect_file_renames(fully_qualified_name, document_changes)
87
+ Interface::WorkspaceEdit.new(document_changes: document_changes)
88
+ end
89
+
90
+ private
91
+
92
+ sig do
93
+ params(
94
+ fully_qualified_name: String,
95
+ document_changes: T::Array[T.any(Interface::RenameFile, Interface::TextDocumentEdit)],
96
+ ).void
97
+ end
98
+ def collect_file_renames(fully_qualified_name, document_changes)
99
+ # Check if the declarations of the symbol being renamed match the file name. In case they do, we automatically
100
+ # rename the files for the user.
101
+ #
102
+ # We also look for an associated test file and rename it too
103
+ short_name = T.must(fully_qualified_name.split("::").last)
104
+
105
+ T.must(@global_state.index[fully_qualified_name]).each do |entry|
106
+ # Do not rename files that are not part of the workspace
107
+ next unless entry.file_path.start_with?(@global_state.workspace_path)
108
+
109
+ case entry
110
+ when RubyIndexer::Entry::Class, RubyIndexer::Entry::Module, RubyIndexer::Entry::Constant,
111
+ RubyIndexer::Entry::ConstantAlias, RubyIndexer::Entry::UnresolvedConstantAlias
112
+
113
+ file_name = file_from_constant_name(short_name)
114
+
115
+ if "#{file_name}.rb" == entry.file_name
116
+ new_file_name = file_from_constant_name(T.must(@new_name.split("::").last))
117
+
118
+ old_uri = URI::Generic.from_path(path: entry.file_path).to_s
119
+ new_uri = URI::Generic.from_path(path: File.join(
120
+ File.dirname(entry.file_path),
121
+ "#{new_file_name}.rb",
122
+ )).to_s
123
+
124
+ document_changes << Interface::RenameFile.new(kind: "rename", old_uri: old_uri, new_uri: new_uri)
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ sig { params(fully_qualified_name: String, name: String).returns(T::Hash[String, T::Array[Interface::TextEdit]]) }
131
+ def collect_text_edits(fully_qualified_name, name)
132
+ changes = {}
133
+
134
+ Dir.glob(File.join(@global_state.workspace_path, "**/*.rb")).each do |path|
135
+ uri = URI::Generic.from_path(path: path)
136
+ # If the document is being managed by the client, then we should use whatever is present in the store instead
137
+ # of reading from disk
138
+ next if @store.key?(uri)
139
+
140
+ parse_result = Prism.parse_file(path)
141
+ edits = collect_changes(fully_qualified_name, parse_result, name, uri)
142
+ changes[uri.to_s] = edits unless edits.empty?
143
+ end
144
+
145
+ @store.each do |uri, document|
146
+ edits = collect_changes(fully_qualified_name, document.parse_result, name, document.uri)
147
+ changes[uri] = edits unless edits.empty?
148
+ end
149
+
150
+ changes
151
+ end
152
+
153
+ sig do
154
+ params(
155
+ fully_qualified_name: String,
156
+ parse_result: Prism::ParseResult,
157
+ name: String,
158
+ uri: URI::Generic,
159
+ ).returns(T::Array[Interface::TextEdit])
160
+ end
161
+ def collect_changes(fully_qualified_name, parse_result, name, uri)
162
+ dispatcher = Prism::Dispatcher.new
163
+ finder = RubyIndexer::ReferenceFinder.new(fully_qualified_name, @global_state.index, dispatcher)
164
+ dispatcher.visit(parse_result.value)
165
+
166
+ finder.references.uniq(&:location).map do |reference|
167
+ adjust_reference_for_edit(name, reference)
168
+ end
169
+ end
170
+
171
+ sig { params(name: String, reference: RubyIndexer::ReferenceFinder::Reference).returns(Interface::TextEdit) }
172
+ def adjust_reference_for_edit(name, reference)
173
+ # The reference may include a namespace in front. We need to check if the rename new name includes namespaces
174
+ # and then adjust both the text and the location to produce the correct edit
175
+ location = reference.location
176
+ new_text = reference.name.sub(name, @new_name)
177
+
178
+ Interface::TextEdit.new(range: range_from_location(location), new_text: new_text)
179
+ end
180
+
181
+ sig { params(constant_name: String).returns(String) }
182
+ def file_from_constant_name(constant_name)
183
+ constant_name
184
+ .gsub(/([a-z])([A-Z])|([A-Z])([A-Z][a-z])/, '\1\3_\2\4')
185
+ .downcase
186
+ end
187
+ end
188
+ end
189
+ end
@@ -19,6 +19,13 @@ module URI
19
19
  T::Array[Symbol],
20
20
  )
21
21
 
22
+ # `uri` for Ruby 3.4 switched the default parser from RFC2396 to RFC3986. The new parser emits a deprecation
23
+ # warning on a few methods and delegates them to RFC2396, namely `extract`/`make_regexp`/`escape`/`unescape`.
24
+ # On earlier versions of the uri gem, the RFC2396_PARSER constant doesn't exist, so it needs some special
25
+ # handling to select a parser that doesn't emit deprecations. While it was backported to Ruby 3.1, users may
26
+ # have the uri gem in their own bundle and thus not use a compatible version.
27
+ PARSER = T.let(const_defined?(:RFC2396_PARSER) ? RFC2396_PARSER : DEFAULT_PARSER, RFC2396_Parser)
28
+
22
29
  T.unsafe(self).alias_method(:gem_name, :host)
23
30
  T.unsafe(self).alias_method(:line_number, :fragment)
24
31
 
@@ -41,7 +48,7 @@ module URI
41
48
  {
42
49
  scheme: "source",
43
50
  host: gem_name,
44
- path: DEFAULT_PARSER.escape("/#{gem_version}/#{path}"),
51
+ path: PARSER.escape("/#{gem_version}/#{path}"),
45
52
  fragment: line_number,
46
53
  }
47
54
  )
@@ -67,6 +67,8 @@ module RubyLsp
67
67
  text_document_definition(message)
68
68
  when "textDocument/prepareTypeHierarchy"
69
69
  text_document_prepare_type_hierarchy(message)
70
+ when "textDocument/rename"
71
+ text_document_rename(message)
70
72
  when "typeHierarchy/supertypes"
71
73
  type_hierarchy_supertypes(message)
72
74
  when "typeHierarchy/subtypes"
@@ -123,9 +125,9 @@ module RubyLsp
123
125
  send_log_message("Error processing #{message[:method]}: #{e.full_message}", type: Constant::MessageType::ERROR)
124
126
  end
125
127
 
126
- sig { void }
127
- def load_addons
128
- errors = Addon.load_addons(@global_state, @outgoing_queue)
128
+ sig { params(include_project_addons: T::Boolean).void }
129
+ def load_addons(include_project_addons: true)
130
+ errors = Addon.load_addons(@global_state, @outgoing_queue, include_project_addons: include_project_addons)
129
131
 
130
132
  if errors.any?
131
133
  send_log_message(
@@ -227,6 +229,7 @@ module RubyLsp
227
229
  workspace_symbol_provider: enabled_features["workspaceSymbol"] && !@global_state.has_type_checker,
228
230
  signature_help_provider: signature_help_provider,
229
231
  type_hierarchy_provider: type_hierarchy_provider,
232
+ rename_provider: !@global_state.has_type_checker,
230
233
  experimental: {
231
234
  addon_detection: true,
232
235
  },
@@ -320,14 +323,18 @@ module RubyLsp
320
323
  language_id: language_id,
321
324
  )
322
325
 
323
- if document.past_expensive_limit?
326
+ if document.past_expensive_limit? && text_document[:uri].scheme == "file"
327
+ log_message = <<~MESSAGE
328
+ The file #{text_document[:uri].path} is too long. For performance reasons, semantic highlighting and
329
+ diagnostics will be disabled.
330
+ MESSAGE
331
+
324
332
  send_message(
325
333
  Notification.new(
326
- method: "window/showMessage",
327
- params: Interface::ShowMessageParams.new(
334
+ method: "window/logMessage",
335
+ params: Interface::LogMessageParams.new(
328
336
  type: Constant::MessageType::WARNING,
329
- message: "This file is too long. For performance reasons, semantic highlighting and " \
330
- "diagnostics will be disabled",
337
+ message: log_message,
331
338
  ),
332
339
  ),
333
340
  )
@@ -609,6 +616,26 @@ module RubyLsp
609
616
  )
610
617
  end
611
618
 
619
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
620
+ def text_document_rename(message)
621
+ params = message[:params]
622
+ document = @store.get(params.dig(:textDocument, :uri))
623
+
624
+ unless document.is_a?(RubyDocument)
625
+ send_empty_response(message[:id])
626
+ return
627
+ end
628
+
629
+ send_message(
630
+ Result.new(
631
+ id: message[:id],
632
+ response: Requests::Rename.new(@global_state, @store, document, params).perform,
633
+ ),
634
+ )
635
+ rescue Requests::Rename::InvalidNameError => e
636
+ send_message(Error.new(id: message[:id], code: Constant::ErrorCodes::REQUEST_FAILED, message: e.message))
637
+ end
638
+
612
639
  sig { params(document: Document[T.untyped]).returns(RubyDocument::SorbetLevel) }
613
640
  def sorbet_level(document)
614
641
  return RubyDocument::SorbetLevel::Ignore unless @global_state.has_type_checker
@@ -0,0 +1,15 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module RubyLsp
5
+ # The path to the `static_docs` directory, where we keep long-form static documentation
6
+ STATIC_DOCS_PATH = T.let(File.join(File.dirname(File.dirname(T.must(__dir__))), "static_docs"), String)
7
+
8
+ # A map of keyword => short documentation to be displayed on hover or completion
9
+ KEYWORD_DOCS = T.let(
10
+ {
11
+ "yield" => "Invokes the passed block with the given arguments",
12
+ }.freeze,
13
+ T::Hash[String, String],
14
+ )
15
+ end
@@ -99,6 +99,18 @@ module RubyLsp
99
99
  @state.delete(uri.to_s)
100
100
  end
101
101
 
102
+ sig { params(uri: URI::Generic).returns(T::Boolean) }
103
+ def key?(uri)
104
+ @state.key?(uri.to_s)
105
+ end
106
+
107
+ sig { params(block: T.proc.params(uri: String, document: Document[T.untyped]).void).void }
108
+ def each(&block)
109
+ @state.each do |uri, document|
110
+ block.call(uri, document)
111
+ end
112
+ end
113
+
102
114
  sig do
103
115
  type_parameters(:T)
104
116
  .params(
@@ -42,7 +42,7 @@ module RubyLsp
42
42
  RubyIndexer::IndexablePath.new(nil, T.must(uri.to_standardized_path)),
43
43
  source,
44
44
  )
45
- server.load_addons if load_addons
45
+ server.load_addons(include_project_addons: false) if load_addons
46
46
  block.call(server, uri)
47
47
  ensure
48
48
  if load_addons
@@ -89,7 +89,12 @@ module RubyLsp
89
89
 
90
90
  Type.new("#{parts.join("::")}::#{last}::<Class:#{last}>")
91
91
  else
92
- raw_receiver = node.receiver&.slice
92
+
93
+ raw_receiver = if receiver.is_a?(Prism::CallNode)
94
+ receiver.message
95
+ else
96
+ receiver&.slice
97
+ end
93
98
 
94
99
  if raw_receiver
95
100
  guessed_name = raw_receiver
@@ -0,0 +1,81 @@
1
+ # Yield
2
+
3
+ In Ruby, every method implicitly accepts a block, even when not included in the parameters list.
4
+
5
+ ```ruby
6
+ def foo
7
+ end
8
+
9
+ foo { 123 } # works!
10
+ ```
11
+
12
+ The `yield` keyword is used to invoke the block that was passed with arguments.
13
+
14
+ ```ruby
15
+ # Consider this method call. The block being passed to the method `foo` accepts an argument called `a`.
16
+ # It then takes whatever argument was passed and multiplies it by 2
17
+ foo do |a|
18
+ a * 2
19
+ end
20
+
21
+ # In the `foo` method declaration, we can use `yield` to invoke the block that was passed and provide the block
22
+ # with the value for the `a` argument
23
+ def foo
24
+ # Invoke the block passed to `foo` with the number 10 as the argument `a`
25
+ result = yield(10)
26
+ puts result # Will print 20
27
+ end
28
+ ```
29
+
30
+ If `yield` is used to invoke the block, but no block was passed, that will result in a local jump error.
31
+
32
+ ```ruby
33
+ # If we invoke `foo` without a block, trying to `yield` will fail
34
+ foo
35
+
36
+ # `foo': no block given (yield) (LocalJumpError)
37
+ ```
38
+
39
+ We can decide to use `yield` conditionally by using Ruby's `block_given?` method, which will return `true` if a block
40
+ was passed to the method.
41
+
42
+ ```ruby
43
+ def foo
44
+ # If a block is passed when invoking `foo`, call the block with argument 10 and print the result.
45
+ # Otherwise, just print that no block was passed
46
+ if block_given?
47
+ result = yield(10)
48
+ puts result
49
+ else
50
+ puts "No block passed!"
51
+ end
52
+ end
53
+
54
+ foo do |a|
55
+ a * 2
56
+ end
57
+ # => 20
58
+
59
+ foo
60
+ # => No block passed!
61
+ ```
62
+
63
+ ## Block parameter
64
+
65
+ In addition to implicit blocks, Ruby also allows developers to use explicit block parameters as part of the method's
66
+ signature. In this scenario, we can use the reference to the block directly instead of relying on the `yield` keyword.
67
+
68
+ ```ruby
69
+ # Block parameters are prefixed with & and a name
70
+ def foo(&my_block_param)
71
+ # If a block was passed to `foo`, `my_block_param` will be a `Proc` object. Otherwise, it will be `nil`. We can use
72
+ # that to check for its presence
73
+ if my_block_param
74
+ # Explicit block parameters are invoked using the method `call`, which is present in all `Proc` objects
75
+ result = my_block_param.call(10)
76
+ puts result
77
+ else
78
+ puts "No block passed!"
79
+ end
80
+ end
81
+ ```
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.18.4
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-09-30 00:00:00.000000000 Z
11
+ date: 2024-10-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: language_server-protocol
@@ -28,16 +28,22 @@ dependencies:
28
28
  name: prism
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '1.1'
34
+ - - "<"
32
35
  - !ruby/object:Gem::Version
33
- version: '1.0'
36
+ version: '2.0'
34
37
  type: :runtime
35
38
  prerelease: false
36
39
  version_requirements: !ruby/object:Gem::Requirement
37
40
  requirements:
38
- - - "~>"
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '1.1'
44
+ - - "<"
39
45
  - !ruby/object:Gem::Version
40
- version: '1.0'
46
+ version: '2.0'
41
47
  - !ruby/object:Gem::Dependency
42
48
  name: rbs
43
49
  requirement: !ruby/object:Gem::Requirement
@@ -99,6 +105,7 @@ files:
99
105
  - lib/ruby_indexer/lib/ruby_indexer/location.rb
100
106
  - lib/ruby_indexer/lib/ruby_indexer/prefix_tree.rb
101
107
  - lib/ruby_indexer/lib/ruby_indexer/rbs_indexer.rb
108
+ - lib/ruby_indexer/lib/ruby_indexer/reference_finder.rb
102
109
  - lib/ruby_indexer/ruby_indexer.rb
103
110
  - lib/ruby_indexer/test/classes_and_modules_test.rb
104
111
  - lib/ruby_indexer/test/configuration_test.rb
@@ -109,6 +116,7 @@ files:
109
116
  - lib/ruby_indexer/test/method_test.rb
110
117
  - lib/ruby_indexer/test/prefix_tree_test.rb
111
118
  - lib/ruby_indexer/test/rbs_indexer_test.rb
119
+ - lib/ruby_indexer/test/reference_finder_test.rb
112
120
  - lib/ruby_indexer/test/test_case.rb
113
121
  - lib/ruby_lsp/addon.rb
114
122
  - lib/ruby_lsp/base_server.rb
@@ -146,6 +154,7 @@ files:
146
154
  - lib/ruby_lsp/requests/inlay_hints.rb
147
155
  - lib/ruby_lsp/requests/on_type_formatting.rb
148
156
  - lib/ruby_lsp/requests/prepare_type_hierarchy.rb
157
+ - lib/ruby_lsp/requests/rename.rb
149
158
  - lib/ruby_lsp/requests/request.rb
150
159
  - lib/ruby_lsp/requests/selection_ranges.rb
151
160
  - lib/ruby_lsp/requests/semantic_highlighting.rb
@@ -173,10 +182,12 @@ files:
173
182
  - lib/ruby_lsp/scope.rb
174
183
  - lib/ruby_lsp/server.rb
175
184
  - lib/ruby_lsp/setup_bundler.rb
185
+ - lib/ruby_lsp/static_docs.rb
176
186
  - lib/ruby_lsp/store.rb
177
187
  - lib/ruby_lsp/test_helper.rb
178
188
  - lib/ruby_lsp/type_inferrer.rb
179
189
  - lib/ruby_lsp/utils.rb
190
+ - static_docs/yield.md
180
191
  homepage: https://github.com/Shopify/ruby-lsp
181
192
  licenses:
182
193
  - MIT
@@ -198,7 +209,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
198
209
  - !ruby/object:Gem::Version
199
210
  version: '0'
200
211
  requirements: []
201
- rubygems_version: 3.5.18
212
+ rubygems_version: 3.5.20
202
213
  signing_key:
203
214
  specification_version: 4
204
215
  summary: An opinionated language server for Ruby