ruby-lsp-rails 0.3.17 → 0.3.25

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: 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