ruby-lsp-rails 0.3.17 → 0.3.25

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: ba36df033667789339419a3ed19740d35ad9b91a3463cf2438bd390c4580aeb4
4
- data.tar.gz: 7aa58db6217944f945ca3bb9bcdefeb4f018759acf8673e222ae8fb3b5c523ee
3
+ metadata.gz: 03b28222188faf1ac819337e73c244ba0a3cb588acfb286554246151b4beaa1a
4
+ data.tar.gz: 5558fe8142c90eaa53991f99a54da04b1cf1fe2ded213ae737b55cc9ad8acd78
5
5
  SHA512:
6
- metadata.gz: a4ddf589e0065c32ee9087a4cfd0347d4e3dbdf5d1d667f1ca6a440ac1c6b2d1c18f41acfa2264f72114ecc4bfafec4c3c40748a3f6639a80c86093ca8e51cef
7
- data.tar.gz: 2dbb86bd0f701865f3d833fb13079cfd9bd27310cdca2410664c7112db73e9915c9b633de1f527220a64055d3956009a738c367c956c3c08cd5c3ae71e04d7f1
6
+ metadata.gz: fb20529701e3008b9f38ba4840446aed72aad85b9e623bc93f691a7bee1ce0418d0b2bfabef085d5446fced0f645c0f6325dd4e78925f2e3ab20cc43e1d34543
7
+ data.tar.gz: e8507ef9f18e3987c667f1c45a3419171677f318bfb4ba5572153efea73bf2ec2922a0ec62865d3aabc9e49117af8e86951975a9eadc68d7848ce6d57f75fdb2
@@ -20,6 +20,8 @@ module RubyLsp
20
20
  class Addon < ::RubyLsp::Addon
21
21
  extend T::Sig
22
22
 
23
+ RUN_MIGRATIONS_TITLE = "Run Migrations"
24
+
23
25
  sig { void }
24
26
  def initialize
25
27
  super
@@ -28,6 +30,7 @@ module RubyLsp
28
30
  # the real client is initialized, features that depend on it will not be blocked by using the NullClient
29
31
  @rails_runner_client = T.let(NullClient.new, RunnerClient)
30
32
  @global_state = T.let(nil, T.nilable(GlobalState))
33
+ @outgoing_queue = T.let(nil, T.nilable(Thread::Queue))
31
34
  @addon_mutex = T.let(Mutex.new, Mutex)
32
35
  @client_mutex = T.let(Mutex.new, Mutex)
33
36
  @client_mutex.lock
@@ -35,7 +38,8 @@ module RubyLsp
35
38
  Thread.new do
36
39
  @addon_mutex.synchronize do
37
40
  # We need to ensure the Rails client is fully loaded before we activate the server addons
38
- @client_mutex.synchronize { @rails_runner_client = RunnerClient.create_client }
41
+ @client_mutex.synchronize { @rails_runner_client = RunnerClient.create_client(T.must(@outgoing_queue)) }
42
+ offer_to_run_pending_migrations
39
43
  end
40
44
  end
41
45
  end
@@ -45,12 +49,14 @@ module RubyLsp
45
49
  @addon_mutex.synchronize { @rails_runner_client }
46
50
  end
47
51
 
48
- sig { override.params(global_state: GlobalState, message_queue: Thread::Queue).void }
49
- def activate(global_state, message_queue)
52
+ sig { override.params(global_state: GlobalState, outgoing_queue: Thread::Queue).void }
53
+ def activate(global_state, outgoing_queue)
50
54
  @global_state = global_state
51
- $stderr.puts("Activating Ruby LSP Rails add-on v#{version}")
52
- register_additional_file_watchers(global_state: global_state, message_queue: message_queue)
53
- @global_state.index.register_enhancement(IndexingEnhancement.new)
55
+ @outgoing_queue = outgoing_queue
56
+ @outgoing_queue << Notification.window_log_message("Activating Ruby LSP Rails add-on v#{VERSION}")
57
+
58
+ register_additional_file_watchers(global_state: global_state, outgoing_queue: outgoing_queue)
59
+ @global_state.index.register_enhancement(IndexingEnhancement.new(@global_state.index))
54
60
 
55
61
  # Start booting the real client in a background thread. Until this completes, the client will be a NullClient
56
62
  @client_mutex.unlock
@@ -116,18 +122,87 @@ module RubyLsp
116
122
 
117
123
  sig { params(changes: T::Array[{ uri: String, type: Integer }]).void }
118
124
  def workspace_did_change_watched_files(changes)
119
- if changes.any? do |change|
120
- change[:uri].end_with?("db/schema.rb") || change[:uri].end_with?("structure.sql")
121
- end
125
+ if changes.any? { |c| c[:uri].end_with?("db/schema.rb") || c[:uri].end_with?("structure.sql") }
122
126
  @rails_runner_client.trigger_reload
123
127
  end
128
+
129
+ if changes.any? do |c|
130
+ %r{db/migrate/.*\.rb}.match?(c[:uri]) && c[:type] != Constant::FileChangeType::CHANGED
131
+ end
132
+
133
+ offer_to_run_pending_migrations
134
+ end
135
+ end
136
+
137
+ sig { override.returns(String) }
138
+ def name
139
+ "Ruby LSP Rails"
140
+ end
141
+
142
+ sig { override.params(title: String).void }
143
+ def handle_window_show_message_response(title)
144
+ if title == RUN_MIGRATIONS_TITLE
145
+
146
+ begin_progress("run-migrations", "Running Migrations")
147
+ response = @rails_runner_client.run_migrations
148
+
149
+ if response && @outgoing_queue
150
+ if response[:status] == 0
151
+ # Both log the message and show it as part of progress because sometimes running migrations is so fast you
152
+ # can't see the progress notification
153
+ @outgoing_queue << Notification.window_log_message(response[:message])
154
+ report_progress("run-migrations", message: response[:message])
155
+ else
156
+ @outgoing_queue << Notification.window_show_message(
157
+ "Migrations failed to run\n\n#{response[:message]}",
158
+ type: Constant::MessageType::ERROR,
159
+ )
160
+ end
161
+ end
162
+
163
+ end_progress("run-migrations")
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ sig { params(id: String, title: String, percentage: T.nilable(Integer), message: T.nilable(String)).void }
170
+ def begin_progress(id, title, percentage: nil, message: nil)
171
+ return unless @global_state&.client_capabilities&.supports_progress && @outgoing_queue
172
+
173
+ @outgoing_queue << Request.new(
174
+ id: "progress-request-#{id}",
175
+ method: "window/workDoneProgress/create",
176
+ params: Interface::WorkDoneProgressCreateParams.new(token: id),
177
+ )
178
+
179
+ @outgoing_queue << Notification.progress_begin(
180
+ id,
181
+ title,
182
+ percentage: percentage,
183
+ message: "#{percentage}% completed",
184
+ )
185
+ end
186
+
187
+ sig { params(id: String, percentage: T.nilable(Integer), message: T.nilable(String)).void }
188
+ def report_progress(id, percentage: nil, message: nil)
189
+ return unless @global_state&.client_capabilities&.supports_progress && @outgoing_queue
190
+
191
+ @outgoing_queue << Notification.progress_report(id, percentage: percentage, message: message)
124
192
  end
125
193
 
126
- sig { params(global_state: GlobalState, message_queue: Thread::Queue).void }
127
- def register_additional_file_watchers(global_state:, message_queue:)
128
- return unless global_state.supports_watching_files
194
+ sig { params(id: String).void }
195
+ def end_progress(id)
196
+ return unless @global_state&.client_capabilities&.supports_progress && @outgoing_queue
129
197
 
130
- message_queue << Request.new(
198
+ @outgoing_queue << Notification.progress_end(id)
199
+ end
200
+
201
+ sig { params(global_state: GlobalState, outgoing_queue: Thread::Queue).void }
202
+ def register_additional_file_watchers(global_state:, outgoing_queue:)
203
+ return unless global_state.client_capabilities.supports_watching_files
204
+
205
+ outgoing_queue << Request.new(
131
206
  id: "ruby-lsp-rails-file-watcher",
132
207
  method: "client/registerCapability",
133
208
  params: Interface::RegistrationParams.new(
@@ -149,9 +224,26 @@ module RubyLsp
149
224
  )
150
225
  end
151
226
 
152
- sig { override.returns(String) }
153
- def name
154
- "Ruby LSP Rails"
227
+ sig { void }
228
+ def offer_to_run_pending_migrations
229
+ return unless @outgoing_queue
230
+ return unless @global_state&.client_capabilities&.window_show_message_supports_extra_properties
231
+
232
+ migration_message = @rails_runner_client.pending_migrations_message
233
+ return unless migration_message
234
+
235
+ @outgoing_queue << Request.new(
236
+ id: "rails-pending-migrations",
237
+ method: "window/showMessageRequest",
238
+ params: {
239
+ type: Constant::MessageType::INFO,
240
+ message: migration_message,
241
+ actions: [
242
+ { title: RUN_MIGRATIONS_TITLE, addon_name: name, method: "window/showMessageRequest" },
243
+ { title: "Cancel", addon_name: name, method: "window/showMessageRequest" },
244
+ ],
245
+ },
246
+ )
155
247
  end
156
248
  end
157
249
  end
@@ -141,7 +141,7 @@ module RubyLsp
141
141
 
142
142
  arg_receiver = argument.receiver
143
143
 
144
- name = arg_receiver.full_name if arg_receiver.is_a?(Prism::ConstantReadNode) ||
144
+ name = constant_name(arg_receiver) if arg_receiver.is_a?(Prism::ConstantReadNode) ||
145
145
  arg_receiver.is_a?(Prism::ConstantPathNode)
146
146
  next unless name
147
147
 
@@ -201,8 +201,8 @@ module RubyLsp
201
201
  arguments.each do |argument|
202
202
  case argument
203
203
  when Prism::ConstantReadNode, Prism::ConstantPathNode
204
- name = argument.full_name
205
- next if name.empty?
204
+ name = constant_name(argument)
205
+ next unless name
206
206
 
207
207
  append_document_symbol(
208
208
  name: "#{message} #{name}",
@@ -1,8 +1,6 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative "support/rails_document_client"
5
-
6
4
  module RubyLsp
7
5
  module Rails
8
6
  # ![Hover demo](../../hover.gif)
@@ -34,7 +32,7 @@ module RubyLsp
34
32
  @response_builder = response_builder
35
33
  @nesting = T.let(node_context.nesting, T::Array[String])
36
34
  @index = T.let(global_state.index, RubyIndexer::Index)
37
- dispatcher.register(self, :on_constant_path_node_enter, :on_constant_read_node_enter, :on_call_node_enter)
35
+ dispatcher.register(self, :on_constant_path_node_enter, :on_constant_read_node_enter)
38
36
  end
39
37
 
40
38
  sig { params(node: Prism::ConstantPathNode).void }
@@ -43,10 +41,7 @@ module RubyLsp
43
41
  return unless entries
44
42
 
45
43
  name = T.must(entries.first).name
46
-
47
44
  generate_column_content(name)
48
-
49
- generate_rails_document_link_hover(name, node.location)
50
45
  end
51
46
 
52
47
  sig { params(node: Prism::ConstantReadNode).void }
@@ -57,16 +52,6 @@ module RubyLsp
57
52
  generate_column_content(T.must(entries.first).name)
58
53
  end
59
54
 
60
- sig { params(node: Prism::CallNode).void }
61
- def on_call_node_enter(node)
62
- message_value = node.message
63
- message_loc = node.message_loc
64
-
65
- return unless message_value && message_loc
66
-
67
- generate_rails_document_link_hover(message_value, message_loc)
68
- end
69
-
70
55
  private
71
56
 
72
57
  sig { params(name: String).void }
@@ -77,25 +62,33 @@ module RubyLsp
77
62
  schema_file = model[:schema_file]
78
63
 
79
64
  @response_builder.push(
80
- "[Schema](#{URI::Generic.from_path(path: schema_file)})",
81
- category: :links,
65
+ "[Schema](#{URI::Generic.from_path(path: schema_file)})\n",
66
+ category: :documentation,
82
67
  ) if schema_file
83
68
 
84
69
  @response_builder.push(
85
- model[:columns].map do |name, type|
70
+ model[:columns].map do |name, type, default_value, nullable|
86
71
  primary_key_suffix = " (PK)" if model[:primary_keys].include?(name)
87
- "**#{name}**: #{type}#{primary_key_suffix}\n"
72
+ suffixes = []
73
+ suffixes << "default: #{format_default(default_value, type)}" if default_value
74
+ suffixes << "not null" unless nullable || primary_key_suffix
75
+ suffix_string = " - #{suffixes.join(" - ")}" if suffixes.any?
76
+ "**#{name}**: #{type}#{primary_key_suffix}#{suffix_string}\n"
88
77
  end.join("\n"),
89
78
  category: :documentation,
90
79
  )
91
80
  end
92
81
 
93
- sig { params(name: String, location: Prism::Location).void }
94
- def generate_rails_document_link_hover(name, location)
95
- urls = Support::RailsDocumentClient.generate_rails_document_urls(name)
96
- return if urls.empty?
97
-
98
- @response_builder.push(urls.join("\n\n"), category: :links)
82
+ sig { params(default_value: String, type: String).returns(String) }
83
+ def format_default(default_value, type)
84
+ case type
85
+ when "boolean"
86
+ default_value == "true" ? "true" : "false"
87
+ when "string"
88
+ default_value.inspect
89
+ else
90
+ default_value
91
+ end
99
92
  end
100
93
  end
101
94
  end
@@ -3,28 +3,30 @@
3
3
 
4
4
  module RubyLsp
5
5
  module Rails
6
- class IndexingEnhancement
6
+ class IndexingEnhancement < RubyIndexer::Enhancement
7
7
  extend T::Sig
8
- include RubyIndexer::Enhancement
9
8
 
10
9
  sig do
11
10
  override.params(
12
- index: RubyIndexer::Index,
13
11
  owner: T.nilable(RubyIndexer::Entry::Namespace),
14
12
  node: Prism::CallNode,
15
13
  file_path: String,
14
+ code_units_cache: T.any(
15
+ T.proc.params(arg0: Integer).returns(Integer),
16
+ Prism::CodeUnitsCache,
17
+ ),
16
18
  ).void
17
19
  end
18
- def on_call_node(index, owner, node, file_path)
20
+ def on_call_node_enter(owner, node, file_path, code_units_cache)
19
21
  return unless owner
20
22
 
21
23
  name = node.name
22
24
 
23
25
  case name
24
26
  when :extend
25
- handle_concern_extend(index, owner, node)
27
+ handle_concern_extend(owner, node)
26
28
  when :has_one, :has_many, :belongs_to, :has_and_belongs_to_many
27
- handle_association(index, owner, node, file_path)
29
+ handle_association(owner, node, file_path, code_units_cache)
28
30
  end
29
31
  end
30
32
 
@@ -32,13 +34,16 @@ module RubyLsp
32
34
 
33
35
  sig do
34
36
  params(
35
- index: RubyIndexer::Index,
36
37
  owner: RubyIndexer::Entry::Namespace,
37
38
  node: Prism::CallNode,
38
39
  file_path: String,
40
+ code_units_cache: T.any(
41
+ T.proc.params(arg0: Integer).returns(Integer),
42
+ Prism::CodeUnitsCache,
43
+ ),
39
44
  ).void
40
45
  end
41
- def handle_association(index, owner, node, file_path)
46
+ def handle_association(owner, node, file_path, code_units_cache)
42
47
  arguments = node.arguments&.arguments
43
48
  return unless arguments
44
49
 
@@ -53,27 +58,27 @@ module RubyLsp
53
58
 
54
59
  return unless name
55
60
 
61
+ loc = RubyIndexer::Location.from_prism_location(name_arg.location, code_units_cache)
62
+
56
63
  # Reader
57
- index.add(RubyIndexer::Entry::Method.new(
64
+ @index.add(RubyIndexer::Entry::Method.new(
58
65
  name,
59
66
  file_path,
60
- name_arg.location,
61
- name_arg.location,
67
+ loc,
68
+ loc,
62
69
  nil,
63
- index.configuration.encoding,
64
70
  [RubyIndexer::Entry::Signature.new([])],
65
71
  RubyIndexer::Entry::Visibility::PUBLIC,
66
72
  owner,
67
73
  ))
68
74
 
69
75
  # Writer
70
- index.add(RubyIndexer::Entry::Method.new(
76
+ @index.add(RubyIndexer::Entry::Method.new(
71
77
  "#{name}=",
72
78
  file_path,
73
- name_arg.location,
74
- name_arg.location,
79
+ loc,
80
+ loc,
75
81
  nil,
76
- index.configuration.encoding,
77
82
  [RubyIndexer::Entry::Signature.new([RubyIndexer::Entry::RequiredParameter.new(name: name.to_sym)])],
78
83
  RubyIndexer::Entry::Visibility::PUBLIC,
79
84
  owner,
@@ -82,12 +87,11 @@ module RubyLsp
82
87
 
83
88
  sig do
84
89
  params(
85
- index: RubyIndexer::Index,
86
90
  owner: RubyIndexer::Entry::Namespace,
87
91
  node: Prism::CallNode,
88
92
  ).void
89
93
  end
90
- def handle_concern_extend(index, owner, node)
94
+ def handle_concern_extend(owner, node)
91
95
  arguments = node.arguments&.arguments
92
96
  return unless arguments
93
97
 
@@ -97,7 +101,7 @@ module RubyLsp
97
101
  module_name = node.full_name
98
102
  next unless module_name == "ActiveSupport::Concern"
99
103
 
100
- index.register_included_hook(owner.name) do |index, base|
104
+ @index.register_included_hook(owner.name) do |index, base|
101
105
  class_methods_name = "#{owner.name}::ClassMethods"
102
106
 
103
107
  if index.indexed?(class_methods_name)
@@ -10,37 +10,51 @@ module RubyLsp
10
10
  class << self
11
11
  extend T::Sig
12
12
 
13
- sig { returns(RunnerClient) }
14
- def create_client
13
+ sig { params(outgoing_queue: Thread::Queue).returns(RunnerClient) }
14
+ def create_client(outgoing_queue)
15
15
  if File.exist?("bin/rails")
16
- new
16
+ new(outgoing_queue)
17
17
  else
18
- $stderr.puts(<<~MSG)
19
- Ruby LSP Rails failed to locate bin/rails in the current directory: #{Dir.pwd}"
20
- MSG
21
- $stderr.puts("Server dependent features will not be available")
18
+ unless outgoing_queue.closed?
19
+ outgoing_queue << RubyLsp::Notification.window_log_message(
20
+ <<~MESSAGE.chomp,
21
+ Ruby LSP Rails failed to locate bin/rails in the current directory: #{Dir.pwd}
22
+ Server dependent features will not be available
23
+ MESSAGE
24
+ type: RubyLsp::Constant::MessageType::WARNING,
25
+ )
26
+ end
27
+
22
28
  NullClient.new
23
29
  end
24
30
  rescue Errno::ENOENT, StandardError => e # rubocop:disable Lint/ShadowedException
25
- $stderr.puts("Ruby LSP Rails failed to initialize server: #{e.message}\n#{e.backtrace&.join("\n")}")
26
- $stderr.puts("Server dependent features will not be available")
31
+ unless outgoing_queue.closed?
32
+ outgoing_queue << RubyLsp::Notification.window_log_message(
33
+ <<~MESSAGE.chomp,
34
+ Ruby LSP Rails failed to initialize server: #{e.full_message}
35
+ Server dependent features will not be available
36
+ MESSAGE
37
+ type: Constant::MessageType::ERROR,
38
+ )
39
+ end
40
+
27
41
  NullClient.new
28
42
  end
29
43
  end
30
44
 
31
45
  class InitializationError < StandardError; end
32
- class IncompleteMessageError < StandardError; end
33
- class EmptyMessageError < StandardError; end
34
-
35
- MAX_RETRIES = 5
46
+ class MessageError < StandardError; end
47
+ class IncompleteMessageError < MessageError; end
48
+ class EmptyMessageError < MessageError; end
36
49
 
37
50
  extend T::Sig
38
51
 
39
52
  sig { returns(String) }
40
53
  attr_reader :rails_root
41
54
 
42
- sig { void }
43
- def initialize
55
+ sig { params(outgoing_queue: Thread::Queue).void }
56
+ def initialize(outgoing_queue)
57
+ @outgoing_queue = T.let(outgoing_queue, Thread::Queue)
44
58
  @mutex = T.let(Mutex.new, Mutex)
45
59
  # Spring needs a Process session ID. It uses this ID to "attach" itself to the parent process, so that when the
46
60
  # parent ends, the spring process ends as well. If this is not set, Spring will throw an error while trying to
@@ -55,6 +69,8 @@ module RubyLsp
55
69
  # https://github.com/Shopify/ruby-lsp-rails/issues/348
56
70
  end
57
71
 
72
+ log_message("Ruby LSP Rails booting server")
73
+
58
74
  stdin, stdout, stderr, wait_thread = Bundler.with_original_env do
59
75
  Open3.popen3("bundle", "exec", "rails", "runner", "#{__dir__}/server.rb", "start")
60
76
  end
@@ -62,6 +78,9 @@ module RubyLsp
62
78
  @stdin = T.let(stdin, IO)
63
79
  @stdout = T.let(stdout, IO)
64
80
  @stderr = T.let(stderr, IO)
81
+ @stdin.sync = true
82
+ @stdout.sync = true
83
+ @stderr.sync = true
65
84
  @wait_thread = T.let(wait_thread, Process::Waiter)
66
85
 
67
86
  # We set binmode for Windows compatibility
@@ -69,29 +88,27 @@ module RubyLsp
69
88
  @stdout.binmode
70
89
  @stderr.binmode
71
90
 
72
- $stderr.puts("Ruby LSP Rails booting server")
73
- count = 0
74
-
75
- begin
76
- count += 1
77
- initialize_response = T.must(read_response)
78
- @rails_root = T.let(initialize_response[:root], String)
79
- rescue EmptyMessageError
80
- $stderr.puts("Ruby LSP Rails is retrying initialize (#{count})")
81
- retry if count < MAX_RETRIES
82
- end
83
-
84
- $stderr.puts("Finished booting Ruby LSP Rails server")
91
+ initialize_response = T.must(read_response)
92
+ @rails_root = T.let(initialize_response[:root], String)
93
+ log_message("Finished booting Ruby LSP Rails server")
85
94
 
86
95
  unless ENV["RAILS_ENV"] == "test"
87
96
  at_exit do
88
97
  if @wait_thread.alive?
89
- $stderr.puts("Ruby LSP Rails is force killing the server")
90
98
  sleep(0.5) # give the server a bit of time if we already issued a shutdown notification
91
99
  force_kill
92
100
  end
93
101
  end
94
102
  end
103
+
104
+ @logger_thread = T.let(
105
+ Thread.new do
106
+ while (content = @stderr.gets("\n"))
107
+ log_message(content, type: RubyLsp::Constant::MessageType::LOG)
108
+ end
109
+ end,
110
+ Thread,
111
+ )
95
112
  rescue Errno::EPIPE, IncompleteMessageError
96
113
  raise InitializationError, @stderr.read
97
114
  end
@@ -99,16 +116,22 @@ module RubyLsp
99
116
  sig { params(server_addon_path: String).void }
100
117
  def register_server_addon(server_addon_path)
101
118
  send_notification("server_addon/register", server_addon_path: server_addon_path)
102
- rescue IncompleteMessageError
103
- $stderr.puts("Ruby LSP Rails failed to register server addon #{server_addon_path}")
119
+ rescue MessageError
120
+ log_message(
121
+ "Ruby LSP Rails failed to register server addon #{server_addon_path}",
122
+ type: RubyLsp::Constant::MessageType::ERROR,
123
+ )
104
124
  nil
105
125
  end
106
126
 
107
127
  sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
108
128
  def model(name)
109
129
  make_request("model", name: name)
110
- rescue IncompleteMessageError
111
- $stderr.puts("Ruby LSP Rails failed to get model information: #{@stderr.read}")
130
+ rescue MessageError
131
+ log_message(
132
+ "Ruby LSP Rails failed to get model information",
133
+ type: RubyLsp::Constant::MessageType::ERROR,
134
+ )
112
135
  nil
113
136
  end
114
137
 
@@ -124,38 +147,104 @@ module RubyLsp
124
147
  model_name: model_name,
125
148
  association_name: association_name,
126
149
  )
127
- rescue => e
128
- $stderr.puts("Ruby LSP Rails failed with #{e.message}: #{@stderr.read}")
150
+ rescue MessageError
151
+ log_message(
152
+ "Ruby LSP Rails failed to get association location",
153
+ type: RubyLsp::Constant::MessageType::ERROR,
154
+ )
155
+ nil
129
156
  end
130
157
 
131
158
  sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
132
159
  def route_location(name)
133
160
  make_request("route_location", name: name)
134
- rescue IncompleteMessageError
135
- $stderr.puts("Ruby LSP Rails failed to get route location: #{@stderr.read}")
161
+ rescue MessageError
162
+ log_message(
163
+ "Ruby LSP Rails failed to get route location",
164
+ type: RubyLsp::Constant::MessageType::ERROR,
165
+ )
136
166
  nil
137
167
  end
138
168
 
139
169
  sig { params(controller: String, action: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
140
170
  def route(controller:, action:)
141
171
  make_request("route_info", controller: controller, action: action)
142
- rescue IncompleteMessageError
143
- $stderr.puts("Ruby LSP Rails failed to get route information: #{@stderr.read}")
172
+ rescue MessageError
173
+ log_message(
174
+ "Ruby LSP Rails failed to get route information",
175
+ type: RubyLsp::Constant::MessageType::ERROR,
176
+ )
177
+ nil
178
+ end
179
+
180
+ # Delegates a notification to a server add-on
181
+ sig { params(server_addon_name: String, request_name: String, params: T.untyped).void }
182
+ def delegate_notification(server_addon_name:, request_name:, **params)
183
+ send_notification(
184
+ "server_addon/delegate",
185
+ request_name: request_name,
186
+ server_addon_name: server_addon_name,
187
+ **params,
188
+ )
189
+ end
190
+
191
+ sig { returns(T.nilable(String)) }
192
+ def pending_migrations_message
193
+ response = make_request("pending_migrations_message")
194
+ response[:pending_migrations_message] if response
195
+ rescue MessageError
196
+ log_message(
197
+ "Ruby LSP Rails failed when checking for pending migrations",
198
+ type: RubyLsp::Constant::MessageType::ERROR,
199
+ )
200
+ nil
201
+ end
202
+
203
+ sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
204
+ def run_migrations
205
+ make_request("run_migrations")
206
+ rescue MessageError
207
+ log_message(
208
+ "Ruby LSP Rails failed to run migrations",
209
+ type: RubyLsp::Constant::MessageType::ERROR,
210
+ )
211
+ nil
212
+ end
213
+
214
+ # Delegates a request to a server add-on
215
+ sig do
216
+ params(
217
+ server_addon_name: String,
218
+ request_name: String,
219
+ params: T.untyped,
220
+ ).returns(T.nilable(T::Hash[Symbol, T.untyped]))
221
+ end
222
+ def delegate_request(server_addon_name:, request_name:, **params)
223
+ make_request(
224
+ "server_addon/delegate",
225
+ server_addon_name: server_addon_name,
226
+ request_name: request_name,
227
+ **params,
228
+ )
229
+ rescue MessageError
144
230
  nil
145
231
  end
146
232
 
147
233
  sig { void }
148
234
  def trigger_reload
149
- $stderr.puts("Reloading Rails application")
235
+ log_message("Reloading Rails application")
150
236
  send_notification("reload")
151
- rescue IncompleteMessageError
152
- $stderr.puts("Ruby LSP Rails failed to trigger reload")
237
+ rescue MessageError
238
+ log_message(
239
+ "Ruby LSP Rails failed to trigger reload",
240
+ type: RubyLsp::Constant::MessageType::ERROR,
241
+ )
153
242
  nil
154
243
  end
155
244
 
156
245
  sig { void }
157
246
  def shutdown
158
- $stderr.puts("Ruby LSP Rails shutting down server")
247
+ log_message("Ruby LSP Rails shutting down server")
159
248
  send_message("shutdown")
160
249
  sleep(0.5) # give the server a bit of time to shutdown
161
250
  [@stdin, @stdout, @stderr].each(&:close)
@@ -172,24 +261,24 @@ module RubyLsp
172
261
  sig do
173
262
  params(
174
263
  request: String,
175
- params: T.nilable(T::Hash[Symbol, T.untyped]),
264
+ params: T.untyped,
176
265
  ).returns(T.nilable(T::Hash[Symbol, T.untyped]))
177
266
  end
178
- def make_request(request, params = nil)
179
- send_message(request, params)
267
+ def make_request(request, **params)
268
+ send_message(request, **params)
180
269
  read_response
181
270
  end
182
271
 
183
272
  # Notifications are like messages, but one-way, with no response sent back.
184
- sig { params(request: String, params: T.nilable(T::Hash[Symbol, T.untyped])).void }
185
- def send_notification(request, params = nil) = send_message(request, params)
273
+ sig { params(request: String, params: T.untyped).void }
274
+ def send_notification(request, **params) = send_message(request, **params)
186
275
 
187
276
  private
188
277
 
189
- sig { overridable.params(request: String, params: T.nilable(T::Hash[Symbol, T.untyped])).void }
190
- def send_message(request, params = nil)
278
+ sig { overridable.params(request: String, params: T.untyped).void }
279
+ def send_message(request, **params)
191
280
  message = { method: request }
192
- message[:params] = params if params
281
+ message[:params] = params
193
282
  json = message.to_json
194
283
 
195
284
  @mutex.synchronize do
@@ -202,11 +291,9 @@ module RubyLsp
202
291
  sig { overridable.returns(T.nilable(T::Hash[Symbol, T.untyped])) }
203
292
  def read_response
204
293
  raw_response = @mutex.synchronize do
205
- headers = @stdout.gets("\r\n\r\n")
206
- raise IncompleteMessageError unless headers
207
-
208
- content_length = headers[/Content-Length: (\d+)/i, 1].to_i
209
- raise EmptyMessageError if content_length.zero?
294
+ content_length = read_content_length
295
+ content_length = read_content_length unless content_length
296
+ raise EmptyMessageError unless content_length
210
297
 
211
298
  @stdout.read(content_length)
212
299
  end
@@ -214,7 +301,10 @@ module RubyLsp
214
301
  response = JSON.parse(T.must(raw_response), symbolize_names: true)
215
302
 
216
303
  if response[:error]
217
- $stderr.puts("Ruby LSP Rails error: " + response[:error])
304
+ log_message(
305
+ "Ruby LSP Rails error: #{response[:error]}",
306
+ type: RubyLsp::Constant::MessageType::ERROR,
307
+ )
218
308
  return
219
309
  end
220
310
 
@@ -229,6 +319,24 @@ module RubyLsp
229
319
  # Windows does not support the `TERM` signal, so we're forced to use `KILL` here
230
320
  Process.kill(T.must(Signal.list["KILL"]), @wait_thread.pid)
231
321
  end
322
+
323
+ sig { params(message: ::String, type: ::Integer).void }
324
+ def log_message(message, type: RubyLsp::Constant::MessageType::LOG)
325
+ return if @outgoing_queue.closed?
326
+
327
+ @outgoing_queue << RubyLsp::Notification.window_log_message(message, type: type)
328
+ end
329
+
330
+ sig { returns(T.nilable(Integer)) }
331
+ def read_content_length
332
+ headers = @stdout.gets("\r\n\r\n")
333
+ return unless headers
334
+
335
+ length = headers[/Content-Length: (\d+)/i, 1]
336
+ return unless length
337
+
338
+ length.to_i
339
+ end
232
340
  end
233
341
 
234
342
  class NullClient < RunnerClient
@@ -255,8 +363,13 @@ module RubyLsp
255
363
 
256
364
  private
257
365
 
258
- sig { override.params(request: String, params: T.nilable(T::Hash[Symbol, T.untyped])).void }
259
- def send_message(request, params = nil)
366
+ sig { params(message: ::String, type: ::Integer).void }
367
+ def log_message(message, type: RubyLsp::Constant::MessageType::LOG)
368
+ # no-op
369
+ end
370
+
371
+ sig { override.params(request: String, params: T.untyped).void }
372
+ def send_message(request, **params)
260
373
  # no-op
261
374
  end
262
375
 
@@ -2,13 +2,29 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "json"
5
+ require "open3"
5
6
 
6
7
  # NOTE: We should avoid printing to stderr since it causes problems. We never read the standard error pipe from the
7
8
  # client, so it will become full and eventually hang or crash. Instead, return a response with an `error` key.
8
9
 
9
10
  module RubyLsp
10
11
  module Rails
12
+ module Common
13
+ # Write a message to the client. Can be used for sending notifications to the editor
14
+ def send_message(message)
15
+ json_message = message.to_json
16
+ @stdout.write("Content-Length: #{json_message.length}\r\n\r\n#{json_message}")
17
+ end
18
+
19
+ # Log a message to the editor's output panel
20
+ def log_message(message)
21
+ $stderr.puts(message)
22
+ end
23
+ end
24
+
11
25
  class ServerAddon
26
+ include Common
27
+
12
28
  @server_addon_classes = []
13
29
  @server_addons = {}
14
30
 
@@ -38,12 +54,6 @@ module RubyLsp
38
54
  @stdout = stdout
39
55
  end
40
56
 
41
- # Write a response back. Can be used for sending notifications to the editor
42
- def write_response(response)
43
- json_response = response.to_json
44
- @stdout.write("Content-Length: #{json_response.length}\r\n\r\n#{json_response}")
45
- end
46
-
47
57
  def name
48
58
  raise NotImplementedError, "Not implemented!"
49
59
  end
@@ -54,6 +64,8 @@ module RubyLsp
54
64
  end
55
65
 
56
66
  class Server
67
+ include Common
68
+
57
69
  def initialize(stdout: $stdout, override_default_output_device: true)
58
70
  # Grab references to the original pipes so that we can change the default output device further down
59
71
  @stdin = $stdin
@@ -79,8 +91,7 @@ module RubyLsp
79
91
  routes_reloader = ::Rails.application.routes_reloader
80
92
  routes_reloader.execute_unless_loaded if routes_reloader&.respond_to?(:execute_unless_loaded)
81
93
 
82
- initialize_result = { result: { message: "ok", root: ::Rails.root.to_s } }.to_json
83
- @stdout.write("Content-Length: #{initialize_result.length}\r\n\r\n#{initialize_result}")
94
+ send_message({ result: { message: "ok", root: ::Rails.root.to_s } })
84
95
 
85
96
  while @running
86
97
  headers = @stdin.gets("\r\n\r\n")
@@ -96,34 +107,38 @@ module RubyLsp
96
107
  when "shutdown"
97
108
  @running = false
98
109
  when "model"
99
- write_response(resolve_database_info_from_model(params.fetch(:name)))
110
+ send_message(resolve_database_info_from_model(params.fetch(:name)))
100
111
  when "association_target_location"
101
- write_response(resolve_association_target(params))
112
+ send_message(resolve_association_target(params))
113
+ when "pending_migrations_message"
114
+ send_message({ result: { pending_migrations_message: pending_migrations_message } })
115
+ when "run_migrations"
116
+ send_message({ result: run_migrations })
102
117
  when "reload"
103
118
  ::Rails.application.reloader.reload!
104
119
  when "route_location"
105
- write_response(route_location(params.fetch(:name)))
120
+ send_message(route_location(params.fetch(:name)))
106
121
  when "route_info"
107
- write_response(resolve_route_info(params))
122
+ send_message(resolve_route_info(params))
108
123
  when "server_addon/register"
109
124
  require params[:server_addon_path]
110
125
  ServerAddon.finalize_registrations!(@stdout)
111
126
  when "server_addon/delegate"
112
- server_addon_name = params.delete(:server_addon_name)
113
- request_name = params.delete(:request_name)
114
- ServerAddon.delegate(server_addon_name, request_name, params)
127
+ server_addon_name = params[:server_addon_name]
128
+ request_name = params[:request_name]
129
+ ServerAddon.delegate(server_addon_name, request_name, params.except(:request_name, :server_addon_name))
115
130
  end
131
+ request_name = request
132
+ request_name = "#{params[:server_addon_name]}##{params[:request_name]}" if request == "server_addon/delegate"
133
+ # Since this is a common problem, we show a specific error message to the user, instead of the full stack trace.
134
+ rescue ActiveRecord::ConnectionNotEstablished
135
+ log_message("Request #{request_name} failed because database connection was not established.")
116
136
  rescue => e
117
- write_response({ error: e.full_message(highlight: false) })
137
+ log_message("Request #{request_name} failed:\n" + e.full_message(highlight: false))
118
138
  end
119
139
 
120
140
  private
121
141
 
122
- def write_response(response)
123
- json_response = response.to_json
124
- @stdout.write("Content-Length: #{json_response.length}\r\n\r\n#{json_response}")
125
- end
126
-
127
142
  def resolve_route_info(requirements)
128
143
  if requirements[:controller]
129
144
  requirements[:controller] = requirements.fetch(:controller).underscore.delete_suffix("_controller")
@@ -136,8 +151,10 @@ module RubyLsp
136
151
  ::Rails.application.routes.routes.find { |route| route.requirements == requirements }
137
152
  end
138
153
 
139
- if route&.source_location
140
- file, _, line = route.source_location.rpartition(":")
154
+ source_location = route&.respond_to?(:source_location) && route.source_location
155
+
156
+ if source_location
157
+ file, _, line = source_location.rpartition(":")
141
158
  body = {
142
159
  source_location: [::Rails.root.join(file).to_s, line],
143
160
  verb: route.verb,
@@ -155,6 +172,11 @@ module RubyLsp
155
172
  if ActionDispatch::Routing::Mapper.respond_to?(:route_source_locations) &&
156
173
  ActionDispatch::Routing::Mapper.route_source_locations
157
174
  def route_location(name)
175
+ # In Rails 8, Rails.application.routes.named_routes is not populated by default
176
+ if ::Rails.application.respond_to?(:reload_routes_unless_loaded)
177
+ ::Rails.application.reload_routes_unless_loaded
178
+ end
179
+
158
180
  match_data = name.match(/^(.+)(_path|_url)$/)
159
181
  return { result: nil } unless match_data
160
182
 
@@ -188,7 +210,7 @@ module RubyLsp
188
210
 
189
211
  info = {
190
212
  result: {
191
- columns: const.columns.map { |column| [column.name, column.type] },
213
+ columns: const.columns.map { |column| [column.name, column.type, column.default, column.null] },
192
214
  primary_keys: Array(const.primary_key),
193
215
  },
194
216
  }
@@ -235,6 +257,27 @@ module RubyLsp
235
257
  !const.abstract_class?
236
258
  )
237
259
  end
260
+
261
+ def pending_migrations_message
262
+ # `check_all_pending!` is only available since Rails 7.1
263
+ return unless defined?(ActiveRecord) && ActiveRecord::Migration.respond_to?(:check_all_pending!)
264
+
265
+ ActiveRecord::Migration.check_all_pending!
266
+ nil
267
+ rescue ActiveRecord::PendingMigrationError => e
268
+ e.message
269
+ end
270
+
271
+ def run_migrations
272
+ # Running migrations invokes `load` which will repeatedly load the same files. It's not designed to be invoked
273
+ # multiple times within the same process. To avoid any memory bloat, we run migrations in a separate process
274
+ stdout, status = Open3.capture2(
275
+ { "VERBOSE" => "true" },
276
+ "bundle exec rails db:migrate",
277
+ )
278
+
279
+ { message: stdout, status: status.exitstatus }
280
+ end
238
281
  end
239
282
  end
240
283
  end
@@ -3,6 +3,6 @@
3
3
 
4
4
  module RubyLsp
5
5
  module Rails
6
- VERSION = "0.3.17"
6
+ VERSION = "0.3.25"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-lsp-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.17
4
+ version: 0.3.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-10-03 00:00:00.000000000 Z
11
+ date: 2024-11-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby-lsp
@@ -16,20 +16,20 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.19.0
19
+ version: 0.21.2
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
- version: 0.20.0
22
+ version: 0.22.0
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
26
26
  requirements:
27
27
  - - ">="
28
28
  - !ruby/object:Gem::Version
29
- version: 0.19.0
29
+ version: 0.21.2
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
- version: 0.20.0
32
+ version: 0.22.0
33
33
  description: A Ruby LSP addon that adds extra editor functionality for Rails applications
34
34
  email:
35
35
  - ruby@shopify.com
@@ -53,7 +53,6 @@ files:
53
53
  - lib/ruby_lsp/ruby_lsp_rails/support/associations.rb
54
54
  - lib/ruby_lsp/ruby_lsp_rails/support/callbacks.rb
55
55
  - lib/ruby_lsp/ruby_lsp_rails/support/location_builder.rb
56
- - lib/ruby_lsp/ruby_lsp_rails/support/rails_document_client.rb
57
56
  - lib/ruby_lsp_rails/railtie.rb
58
57
  - lib/ruby_lsp_rails/version.rb
59
58
  - lib/tasks/ruby_lsp_rails_tasks.rake
@@ -81,7 +80,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
81
80
  - !ruby/object:Gem::Version
82
81
  version: '0'
83
82
  requirements: []
84
- rubygems_version: 3.5.20
83
+ rubygems_version: 3.5.23
85
84
  signing_key:
86
85
  specification_version: 4
87
86
  summary: A Ruby LSP addon for Rails
@@ -1,131 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- require "net/http"
5
-
6
- module RubyLsp
7
- module Rails
8
- module Support
9
- class RailsDocumentClient
10
- RAILS_DOC_HOST = "https://api.rubyonrails.org"
11
-
12
- SUPPORTED_RAILS_DOC_NAMESPACES = T.let(
13
- Regexp.union(
14
- /ActionDispatch/,
15
- /ActionController/,
16
- /AbstractController/,
17
- /ActiveRecord/,
18
- /ActiveModel/,
19
- /ActiveStorage/,
20
- /ActionText/,
21
- /ActiveJob/,
22
- ).freeze,
23
- Regexp,
24
- )
25
-
26
- RAILTIES_VERSION = T.let(
27
- [*::Gem::Specification.default_stubs, *::Gem::Specification.stubs].find do |s|
28
- s.name == "railties"
29
- end&.version&.to_s,
30
- T.nilable(String),
31
- )
32
-
33
- class << self
34
- extend T::Sig
35
-
36
- sig { params(name: String).returns(T::Array[String]) }
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
- $stderr.puts("Fetching search index for Rails documentation")
70
-
71
- response = Net::HTTP.get_response(
72
- URI("#{RAILS_DOC_HOST}/v#{RAILTIES_VERSION}/js/search_index.js"),
73
- { "User-Agent" => "ruby-lsp-rails/#{RubyLsp::Rails::VERSION}" },
74
- )
75
-
76
- body = case response
77
- when Net::HTTPSuccess
78
- $stderr.puts("Finished fetching search index for Rails documentation")
79
- response.body
80
- when Net::HTTPRedirection
81
- # If the version's doc is not found, e.g. Rails main, it'll be redirected
82
- # In this case, we just fetch the latest doc
83
- response = Net::HTTP.get_response(URI("#{RAILS_DOC_HOST}/js/search_index.js"))
84
- if response.is_a?(Net::HTTPSuccess)
85
- $stderr.puts("Finished fetching search index for Rails documentation")
86
- response.body
87
- end
88
- else
89
- $stderr.puts("Response failed: #{response.inspect}")
90
- nil
91
- end
92
-
93
- process_search_index(body) if body
94
- rescue StandardError => e
95
- $stderr.puts("Exception occurred when fetching Rails document index: #{e.inspect}")
96
- end
97
-
98
- sig { params(js: String).returns(T::Hash[String, T::Array[T::Hash[Symbol, String]]]) }
99
- private def process_search_index(js)
100
- raw_data = js.sub("var search_data = ", "")
101
- info = JSON.parse(raw_data).dig("index", "info")
102
-
103
- # An entry looks like this:
104
- #
105
- # ["belongs_to", # method or module/class
106
- # "ActiveRecord::Associations::ClassMethods", # method owner
107
- # "classes/ActiveRecord/Associations/ClassMethods.html#method-i-belongs_to", # path to the document
108
- # "(name, scope = nil, **options)", # method's parameters
109
- # "<p>Specifies a one-to-one association with another class..."] # document preview
110
- #
111
- info.each_with_object({}) do |(method_or_class, method_owner, doc_path, _, doc_preview), table|
112
- # If a method doesn't have documentation, there's no need to generate the link to it.
113
- next if doc_preview.nil? || doc_preview.empty?
114
-
115
- # If the method or class/module is not from the supported namespace, reject it
116
- next unless [method_or_class, method_owner].any? do |elem|
117
- elem.match?(SUPPORTED_RAILS_DOC_NAMESPACES)
118
- end
119
-
120
- owner = method_owner.empty? ? method_or_class : method_owner
121
- table[method_or_class] ||= []
122
- # It's possible to have multiple modules defining the same method name. For example,
123
- # both `ActiveRecord::FinderMethods` and `ActiveRecord::Associations::CollectionProxy` defines `#find`
124
- table[method_or_class] << { owner: owner, url: "#{RAILS_DOC_HOST}/v#{RAILTIES_VERSION}/#{doc_path}" }
125
- end
126
- end
127
- end
128
- end
129
- end
130
- end
131
- end