ruby-lsp-rails 0.3.21 → 0.3.23
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/ruby_lsp/ruby_lsp_rails/addon.rb +97 -8
- data/lib/ruby_lsp/ruby_lsp_rails/hover.rb +18 -2
- data/lib/ruby_lsp/ruby_lsp_rails/indexing_enhancement.rb +9 -13
- data/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb +44 -19
- data/lib/ruby_lsp/ruby_lsp_rails/server.rb +31 -1
- data/lib/ruby_lsp_rails/version.rb +1 -1
- metadata +6 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 03f59a93f09d79b974b5a46176ef8d2745e361bce2247ca19bef22e72aa697f8
|
4
|
+
data.tar.gz: 7e65330c616d0db49d4d54f084284ac8fd9d181b271e68dce3f3408a66d81daf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1d92c385c3e6b7e1615213434791295bc2e6488d6e3dd853946c2c1f59345a2e0dd44a1e3019ca46293b8f48423a656f6e756941fc4da5eb371fd084735b5bce
|
7
|
+
data.tar.gz: ac372a79150173f46c8ec6d72e686785f41b98d1d986502c360e91d5fb461e8b2a7bd4a348f617365fd42c4863b7c008e140d545c06639dbcb5946dd2ed33a10
|
@@ -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
|
@@ -37,6 +39,7 @@ module RubyLsp
|
|
37
39
|
@addon_mutex.synchronize do
|
38
40
|
# We need to ensure the Rails client is fully loaded before we activate the server addons
|
39
41
|
@client_mutex.synchronize { @rails_runner_client = RunnerClient.create_client(T.must(@outgoing_queue)) }
|
42
|
+
offer_to_run_pending_migrations
|
40
43
|
end
|
41
44
|
end
|
42
45
|
end
|
@@ -53,7 +56,7 @@ module RubyLsp
|
|
53
56
|
@outgoing_queue << Notification.window_log_message("Activating Ruby LSP Rails add-on v#{VERSION}")
|
54
57
|
|
55
58
|
register_additional_file_watchers(global_state: global_state, outgoing_queue: outgoing_queue)
|
56
|
-
@global_state.index.register_enhancement(IndexingEnhancement.new)
|
59
|
+
@global_state.index.register_enhancement(IndexingEnhancement.new(@global_state.index))
|
57
60
|
|
58
61
|
# Start booting the real client in a background thread. Until this completes, the client will be a NullClient
|
59
62
|
@client_mutex.unlock
|
@@ -119,16 +122,85 @@ module RubyLsp
|
|
119
122
|
|
120
123
|
sig { params(changes: T::Array[{ uri: String, type: Integer }]).void }
|
121
124
|
def workspace_did_change_watched_files(changes)
|
122
|
-
if changes.any?
|
123
|
-
change[:uri].end_with?("db/schema.rb") || change[:uri].end_with?("structure.sql")
|
124
|
-
end
|
125
|
+
if changes.any? { |c| c[:uri].end_with?("db/schema.rb") || c[:uri].end_with?("structure.sql") }
|
125
126
|
@rails_runner_client.trigger_reload
|
126
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)
|
192
|
+
end
|
193
|
+
|
194
|
+
sig { params(id: String).void }
|
195
|
+
def end_progress(id)
|
196
|
+
return unless @global_state&.client_capabilities&.supports_progress && @outgoing_queue
|
197
|
+
|
198
|
+
@outgoing_queue << Notification.progress_end(id)
|
127
199
|
end
|
128
200
|
|
129
201
|
sig { params(global_state: GlobalState, outgoing_queue: Thread::Queue).void }
|
130
202
|
def register_additional_file_watchers(global_state:, outgoing_queue:)
|
131
|
-
return unless global_state.supports_watching_files
|
203
|
+
return unless global_state.client_capabilities.supports_watching_files
|
132
204
|
|
133
205
|
outgoing_queue << Request.new(
|
134
206
|
id: "ruby-lsp-rails-file-watcher",
|
@@ -152,9 +224,26 @@ module RubyLsp
|
|
152
224
|
)
|
153
225
|
end
|
154
226
|
|
155
|
-
sig {
|
156
|
-
def
|
157
|
-
|
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
|
+
)
|
158
247
|
end
|
159
248
|
end
|
160
249
|
end
|
@@ -67,13 +67,29 @@ module RubyLsp
|
|
67
67
|
) if schema_file
|
68
68
|
|
69
69
|
@response_builder.push(
|
70
|
-
model[:columns].map do |name, type|
|
70
|
+
model[:columns].map do |name, type, default_value, nullable|
|
71
71
|
primary_key_suffix = " (PK)" if model[:primary_keys].include?(name)
|
72
|
-
|
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"
|
73
77
|
end.join("\n"),
|
74
78
|
category: :documentation,
|
75
79
|
)
|
76
80
|
end
|
81
|
+
|
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
|
92
|
+
end
|
77
93
|
end
|
78
94
|
end
|
79
95
|
end
|
@@ -3,13 +3,11 @@
|
|
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,
|
@@ -19,16 +17,16 @@ module RubyLsp
|
|
19
17
|
),
|
20
18
|
).void
|
21
19
|
end
|
22
|
-
def
|
20
|
+
def on_call_node_enter(owner, node, file_path, code_units_cache)
|
23
21
|
return unless owner
|
24
22
|
|
25
23
|
name = node.name
|
26
24
|
|
27
25
|
case name
|
28
26
|
when :extend
|
29
|
-
handle_concern_extend(
|
27
|
+
handle_concern_extend(owner, node)
|
30
28
|
when :has_one, :has_many, :belongs_to, :has_and_belongs_to_many
|
31
|
-
handle_association(
|
29
|
+
handle_association(owner, node, file_path, code_units_cache)
|
32
30
|
end
|
33
31
|
end
|
34
32
|
|
@@ -36,7 +34,6 @@ module RubyLsp
|
|
36
34
|
|
37
35
|
sig do
|
38
36
|
params(
|
39
|
-
index: RubyIndexer::Index,
|
40
37
|
owner: RubyIndexer::Entry::Namespace,
|
41
38
|
node: Prism::CallNode,
|
42
39
|
file_path: String,
|
@@ -46,7 +43,7 @@ module RubyLsp
|
|
46
43
|
),
|
47
44
|
).void
|
48
45
|
end
|
49
|
-
def handle_association(
|
46
|
+
def handle_association(owner, node, file_path, code_units_cache)
|
50
47
|
arguments = node.arguments&.arguments
|
51
48
|
return unless arguments
|
52
49
|
|
@@ -64,7 +61,7 @@ module RubyLsp
|
|
64
61
|
loc = RubyIndexer::Location.from_prism_location(name_arg.location, code_units_cache)
|
65
62
|
|
66
63
|
# Reader
|
67
|
-
index.add(RubyIndexer::Entry::Method.new(
|
64
|
+
@index.add(RubyIndexer::Entry::Method.new(
|
68
65
|
name,
|
69
66
|
file_path,
|
70
67
|
loc,
|
@@ -76,7 +73,7 @@ module RubyLsp
|
|
76
73
|
))
|
77
74
|
|
78
75
|
# Writer
|
79
|
-
index.add(RubyIndexer::Entry::Method.new(
|
76
|
+
@index.add(RubyIndexer::Entry::Method.new(
|
80
77
|
"#{name}=",
|
81
78
|
file_path,
|
82
79
|
loc,
|
@@ -90,12 +87,11 @@ module RubyLsp
|
|
90
87
|
|
91
88
|
sig do
|
92
89
|
params(
|
93
|
-
index: RubyIndexer::Index,
|
94
90
|
owner: RubyIndexer::Entry::Namespace,
|
95
91
|
node: Prism::CallNode,
|
96
92
|
).void
|
97
93
|
end
|
98
|
-
def handle_concern_extend(
|
94
|
+
def handle_concern_extend(owner, node)
|
99
95
|
arguments = node.arguments&.arguments
|
100
96
|
return unless arguments
|
101
97
|
|
@@ -105,7 +101,7 @@ module RubyLsp
|
|
105
101
|
module_name = node.full_name
|
106
102
|
next unless module_name == "ActiveSupport::Concern"
|
107
103
|
|
108
|
-
index.register_included_hook(owner.name) do |index, base|
|
104
|
+
@index.register_included_hook(owner.name) do |index, base|
|
109
105
|
class_methods_name = "#{owner.name}::ClassMethods"
|
110
106
|
|
111
107
|
if index.indexed?(class_methods_name)
|
@@ -46,8 +46,6 @@ module RubyLsp
|
|
46
46
|
class IncompleteMessageError < StandardError; end
|
47
47
|
class EmptyMessageError < StandardError; end
|
48
48
|
|
49
|
-
MAX_RETRIES = 5
|
50
|
-
|
51
49
|
extend T::Sig
|
52
50
|
|
53
51
|
sig { returns(String) }
|
@@ -70,6 +68,8 @@ module RubyLsp
|
|
70
68
|
# https://github.com/Shopify/ruby-lsp-rails/issues/348
|
71
69
|
end
|
72
70
|
|
71
|
+
log_message("Ruby LSP Rails booting server")
|
72
|
+
|
73
73
|
stdin, stdout, stderr, wait_thread = Bundler.with_original_env do
|
74
74
|
Open3.popen3("bundle", "exec", "rails", "runner", "#{__dir__}/server.rb", "start")
|
75
75
|
end
|
@@ -77,6 +77,9 @@ module RubyLsp
|
|
77
77
|
@stdin = T.let(stdin, IO)
|
78
78
|
@stdout = T.let(stdout, IO)
|
79
79
|
@stderr = T.let(stderr, IO)
|
80
|
+
@stdin.sync = true
|
81
|
+
@stdout.sync = true
|
82
|
+
@stderr.sync = true
|
80
83
|
@wait_thread = T.let(wait_thread, Process::Waiter)
|
81
84
|
|
82
85
|
# We set binmode for Windows compatibility
|
@@ -84,18 +87,8 @@ module RubyLsp
|
|
84
87
|
@stdout.binmode
|
85
88
|
@stderr.binmode
|
86
89
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
begin
|
91
|
-
count += 1
|
92
|
-
initialize_response = T.must(read_response)
|
93
|
-
@rails_root = T.let(initialize_response[:root], String)
|
94
|
-
rescue EmptyMessageError
|
95
|
-
log_message("Ruby LSP Rails is retrying initialize (#{count})")
|
96
|
-
retry if count < MAX_RETRIES
|
97
|
-
end
|
98
|
-
|
90
|
+
initialize_response = T.must(read_response)
|
91
|
+
@rails_root = T.let(initialize_response[:root], String)
|
99
92
|
log_message("Finished booting Ruby LSP Rails server")
|
100
93
|
|
101
94
|
unless ENV["RAILS_ENV"] == "test"
|
@@ -194,6 +187,29 @@ module RubyLsp
|
|
194
187
|
)
|
195
188
|
end
|
196
189
|
|
190
|
+
sig { returns(T.nilable(String)) }
|
191
|
+
def pending_migrations_message
|
192
|
+
response = make_request("pending_migrations_message")
|
193
|
+
response[:pending_migrations_message] if response
|
194
|
+
rescue IncompleteMessageError
|
195
|
+
log_message(
|
196
|
+
"Ruby LSP Rails failed when checking for pending migrations",
|
197
|
+
type: RubyLsp::Constant::MessageType::ERROR,
|
198
|
+
)
|
199
|
+
nil
|
200
|
+
end
|
201
|
+
|
202
|
+
sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
|
203
|
+
def run_migrations
|
204
|
+
make_request("run_migrations")
|
205
|
+
rescue IncompleteMessageError
|
206
|
+
log_message(
|
207
|
+
"Ruby LSP Rails failed to run migrations",
|
208
|
+
type: RubyLsp::Constant::MessageType::ERROR,
|
209
|
+
)
|
210
|
+
nil
|
211
|
+
end
|
212
|
+
|
197
213
|
# Delegates a request to a server add-on
|
198
214
|
sig do
|
199
215
|
params(
|
@@ -274,11 +290,9 @@ module RubyLsp
|
|
274
290
|
sig { overridable.returns(T.nilable(T::Hash[Symbol, T.untyped])) }
|
275
291
|
def read_response
|
276
292
|
raw_response = @mutex.synchronize do
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
content_length = headers[/Content-Length: (\d+)/i, 1].to_i
|
281
|
-
raise EmptyMessageError if content_length.zero?
|
293
|
+
content_length = read_content_length
|
294
|
+
content_length = read_content_length unless content_length
|
295
|
+
raise EmptyMessageError unless content_length
|
282
296
|
|
283
297
|
@stdout.read(content_length)
|
284
298
|
end
|
@@ -311,6 +325,17 @@ module RubyLsp
|
|
311
325
|
|
312
326
|
@outgoing_queue << RubyLsp::Notification.window_log_message(message, type: type)
|
313
327
|
end
|
328
|
+
|
329
|
+
sig { returns(T.nilable(Integer)) }
|
330
|
+
def read_content_length
|
331
|
+
headers = @stdout.gets("\r\n\r\n")
|
332
|
+
return unless headers
|
333
|
+
|
334
|
+
length = headers[/Content-Length: (\d+)/i, 1]
|
335
|
+
return unless length
|
336
|
+
|
337
|
+
length.to_i
|
338
|
+
end
|
314
339
|
end
|
315
340
|
|
316
341
|
class NullClient < RunnerClient
|
@@ -2,6 +2,7 @@
|
|
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.
|
@@ -109,6 +110,10 @@ module RubyLsp
|
|
109
110
|
send_message(resolve_database_info_from_model(params.fetch(:name)))
|
110
111
|
when "association_target_location"
|
111
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 })
|
112
117
|
when "reload"
|
113
118
|
::Rails.application.reloader.reload!
|
114
119
|
when "route_location"
|
@@ -167,6 +172,11 @@ module RubyLsp
|
|
167
172
|
if ActionDispatch::Routing::Mapper.respond_to?(:route_source_locations) &&
|
168
173
|
ActionDispatch::Routing::Mapper.route_source_locations
|
169
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
|
+
|
170
180
|
match_data = name.match(/^(.+)(_path|_url)$/)
|
171
181
|
return { result: nil } unless match_data
|
172
182
|
|
@@ -200,7 +210,7 @@ module RubyLsp
|
|
200
210
|
|
201
211
|
info = {
|
202
212
|
result: {
|
203
|
-
columns: const.columns.map { |column| [column.name, column.type] },
|
213
|
+
columns: const.columns.map { |column| [column.name, column.type, column.default, column.null] },
|
204
214
|
primary_keys: Array(const.primary_key),
|
205
215
|
},
|
206
216
|
}
|
@@ -247,6 +257,26 @@ module RubyLsp
|
|
247
257
|
!const.abstract_class?
|
248
258
|
)
|
249
259
|
end
|
260
|
+
|
261
|
+
def pending_migrations_message
|
262
|
+
return unless defined?(ActiveRecord)
|
263
|
+
|
264
|
+
ActiveRecord::Migration.check_all_pending!
|
265
|
+
nil
|
266
|
+
rescue ActiveRecord::PendingMigrationError => e
|
267
|
+
e.message
|
268
|
+
end
|
269
|
+
|
270
|
+
def run_migrations
|
271
|
+
# Running migrations invokes `load` which will repeatedly load the same files. It's not designed to be invoked
|
272
|
+
# multiple times within the same process. To avoid any memory bloat, we run migrations in a separate process
|
273
|
+
stdout, status = Open3.capture2(
|
274
|
+
{ "VERBOSE" => "true" },
|
275
|
+
"bundle exec rails db:migrate",
|
276
|
+
)
|
277
|
+
|
278
|
+
{ message: stdout, status: status.exitstatus }
|
279
|
+
end
|
250
280
|
end
|
251
281
|
end
|
252
282
|
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.
|
4
|
+
version: 0.3.23
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Shopify
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-11-05 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
|
+
version: 0.21.2
|
20
20
|
- - "<"
|
21
21
|
- !ruby/object:Gem::Version
|
22
|
-
version: 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.
|
29
|
+
version: 0.21.2
|
30
30
|
- - "<"
|
31
31
|
- !ruby/object:Gem::Version
|
32
|
-
version: 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
|