steep 1.10.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -1
- data/CLAUDE.md +114 -0
- data/README.md +1 -1
- data/Rakefile +15 -3
- data/Steepfile +13 -13
- data/lib/steep/annotation_parser.rb +5 -1
- data/lib/steep/annotations_helper.rb +12 -2
- data/lib/steep/ast/node/type_application.rb +22 -16
- data/lib/steep/ast/node/type_assertion.rb +7 -4
- data/lib/steep/ast/types/factory.rb +3 -2
- data/lib/steep/cli.rb +246 -2
- data/lib/steep/daemon/configuration.rb +19 -0
- data/lib/steep/daemon/server.rb +476 -0
- data/lib/steep/daemon.rb +201 -0
- data/lib/steep/diagnostic/ruby.rb +50 -8
- data/lib/steep/diagnostic/signature.rb +31 -8
- data/lib/steep/drivers/check.rb +301 -140
- data/lib/steep/drivers/print_project.rb +9 -10
- data/lib/steep/drivers/query.rb +102 -0
- data/lib/steep/drivers/start_server.rb +19 -0
- data/lib/steep/drivers/stop_server.rb +20 -0
- data/lib/steep/drivers/watch.rb +2 -2
- data/lib/steep/index/rbs_index.rb +38 -13
- data/lib/steep/index/signature_symbol_provider.rb +24 -3
- data/lib/steep/interface/builder.rb +48 -15
- data/lib/steep/interface/shape.rb +13 -5
- data/lib/steep/locator.rb +377 -0
- data/lib/steep/project/dsl.rb +26 -5
- data/lib/steep/project/group.rb +8 -2
- data/lib/steep/project/target.rb +16 -2
- data/lib/steep/project.rb +21 -2
- data/lib/steep/server/base_worker.rb +2 -2
- data/lib/steep/server/change_buffer.rb +2 -1
- data/lib/steep/server/custom_methods.rb +12 -0
- data/lib/steep/server/inline_source_change_detector.rb +94 -0
- data/lib/steep/server/interaction_worker.rb +51 -74
- data/lib/steep/server/lsp_formatter.rb +48 -12
- data/lib/steep/server/master.rb +100 -18
- data/lib/steep/server/target_group_files.rb +124 -151
- data/lib/steep/server/type_check_controller.rb +276 -123
- data/lib/steep/server/type_check_worker.rb +104 -3
- data/lib/steep/services/completion_provider/rbs.rb +74 -0
- data/lib/steep/services/completion_provider/ruby.rb +652 -0
- data/lib/steep/services/completion_provider/type_name.rb +243 -0
- data/lib/steep/services/completion_provider.rb +39 -662
- data/lib/steep/services/content_change.rb +14 -1
- data/lib/steep/services/file_loader.rb +4 -2
- data/lib/steep/services/goto_service.rb +271 -68
- data/lib/steep/services/hover_provider/content.rb +67 -0
- data/lib/steep/services/hover_provider/rbs.rb +8 -9
- data/lib/steep/services/hover_provider/ruby.rb +123 -64
- data/lib/steep/services/hover_provider/singleton_methods.rb +4 -0
- data/lib/steep/services/signature_service.rb +129 -54
- data/lib/steep/services/type_check_service.rb +72 -27
- data/lib/steep/signature/validator.rb +30 -18
- data/lib/steep/source/ignore_ranges.rb +14 -4
- data/lib/steep/source.rb +16 -2
- data/lib/steep/tagged_logging.rb +39 -0
- data/lib/steep/type_construction.rb +94 -21
- data/lib/steep/type_inference/block_params.rb +7 -7
- data/lib/steep/type_inference/context.rb +4 -2
- data/lib/steep/type_inference/logic_type_interpreter.rb +21 -3
- data/lib/steep/type_inference/method_call.rb +4 -0
- data/lib/steep/type_inference/type_env.rb +1 -1
- data/lib/steep/typing.rb +0 -2
- data/lib/steep/version.rb +1 -1
- data/lib/steep.rb +42 -32
- data/manual/ruby-diagnostics.md +67 -0
- data/sample/Steepfile +1 -0
- data/sample/lib/conference.rb +1 -0
- data/sample/lib/deprecated.rb +6 -0
- data/sample/lib/inline.rb +43 -0
- data/sample/sig/generics.rbs +3 -0
- data/steep.gemspec +4 -5
- metadata +26 -26
- data/lib/steep/services/type_name_completion.rb +0 -236
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Steep
|
|
4
|
+
module Daemon
|
|
5
|
+
class Server
|
|
6
|
+
attr_reader :config, :project, :stderr
|
|
7
|
+
attr_reader :file_tracker, :shutdown_flag
|
|
8
|
+
attr_reader :warmup_status, :warmup_mutex
|
|
9
|
+
|
|
10
|
+
# LSP file change types (from Language Server Protocol specification)
|
|
11
|
+
FILE_CHANGE_TYPE = {
|
|
12
|
+
created: 1,
|
|
13
|
+
changed: 2,
|
|
14
|
+
deleted: 3
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
class FileTracker
|
|
18
|
+
def initialize
|
|
19
|
+
@mtimes = {}
|
|
20
|
+
@pending_changes = {}
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def register(paths)
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
paths.each do |path|
|
|
27
|
+
key = path.to_s
|
|
28
|
+
@mtimes[key] ||= safe_mtime(key)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def record_changes(changes)
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
changes.each do |path, type|
|
|
36
|
+
@pending_changes[path] = type
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def flush_pending_changes
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
changes = @pending_changes.to_a
|
|
44
|
+
@pending_changes.clear
|
|
45
|
+
|
|
46
|
+
changes.each do |path, type|
|
|
47
|
+
@mtimes[path] = type == :deleted ? nil : safe_mtime(path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
changes
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def track_and_detect(paths)
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
changed = [] #: Array[[String, Symbol]]
|
|
57
|
+
paths.each do |path|
|
|
58
|
+
key = path.to_s
|
|
59
|
+
current = safe_mtime(key)
|
|
60
|
+
old = @mtimes[key]
|
|
61
|
+
|
|
62
|
+
if old.nil?
|
|
63
|
+
@mtimes[key] = current
|
|
64
|
+
changed << [key, :created] if current
|
|
65
|
+
elsif current != old
|
|
66
|
+
@mtimes[key] = current
|
|
67
|
+
type = current ? :changed : :deleted
|
|
68
|
+
changed << [key, type]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
changed
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def safe_mtime(path)
|
|
78
|
+
File.mtime(path)
|
|
79
|
+
rescue Errno::ENOENT
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def initialize(config:, project:, stderr:)
|
|
85
|
+
@config = config
|
|
86
|
+
@project = project
|
|
87
|
+
@stderr = stderr
|
|
88
|
+
@shutdown_flag = false
|
|
89
|
+
@file_tracker = FileTracker.new
|
|
90
|
+
@warmup_status = :not_started # :warming_up, :ready, :failed
|
|
91
|
+
@warmup_mutex = Mutex.new
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run
|
|
95
|
+
Steep.logger.info { "Steep server starting for #{Dir.pwd}" }
|
|
96
|
+
Steep.logger.info { "PID: #{Process.pid}" }
|
|
97
|
+
|
|
98
|
+
client_read, server_write = IO.pipe
|
|
99
|
+
server_read, client_write = IO.pipe
|
|
100
|
+
|
|
101
|
+
client_reader = LanguageServer::Protocol::Transport::Io::Reader.new(client_read)
|
|
102
|
+
client_writer = LanguageServer::Protocol::Transport::Io::Writer.new(client_write)
|
|
103
|
+
server_reader = LanguageServer::Protocol::Transport::Io::Reader.new(server_read)
|
|
104
|
+
server_writer = LanguageServer::Protocol::Transport::Io::Writer.new(server_write)
|
|
105
|
+
|
|
106
|
+
job_count = (ENV["STEEP_SERVER_JOB_COUNT"] || [Etc.nprocessors - 1, 1].max).to_i
|
|
107
|
+
|
|
108
|
+
Steep.logger.info { "Starting #{job_count} typecheck worker(s)..." }
|
|
109
|
+
|
|
110
|
+
workers = ::Steep::Server::WorkerProcess.start_typecheck_workers(
|
|
111
|
+
steepfile: @project.steepfile_path,
|
|
112
|
+
args: [],
|
|
113
|
+
delay_shutdown: true,
|
|
114
|
+
steep_command: nil,
|
|
115
|
+
count: job_count
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
interaction_worker = ::Steep::Server::WorkerProcess.start_worker(
|
|
119
|
+
:interaction,
|
|
120
|
+
name: "interaction",
|
|
121
|
+
steepfile: @project.steepfile_path,
|
|
122
|
+
steep_command: nil
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
master = ::Steep::Server::Master.new(
|
|
126
|
+
project: @project,
|
|
127
|
+
reader: server_reader,
|
|
128
|
+
writer: server_writer,
|
|
129
|
+
interaction_worker: interaction_worker,
|
|
130
|
+
typecheck_workers: workers,
|
|
131
|
+
refork: true
|
|
132
|
+
)
|
|
133
|
+
master.typecheck_automatically = false
|
|
134
|
+
|
|
135
|
+
master_thread = Thread.start do
|
|
136
|
+
Thread.current.abort_on_exception = true
|
|
137
|
+
master.start
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
Steep.logger.info { "Initializing (loading RBS environment)..." }
|
|
141
|
+
init_id = SecureRandom.alphanumeric(10)
|
|
142
|
+
client_writer.write(method: :initialize, id: init_id, params: {})
|
|
143
|
+
wait_for_response(client_reader, init_id)
|
|
144
|
+
|
|
145
|
+
all_paths = collect_all_project_paths
|
|
146
|
+
@file_tracker.register(all_paths)
|
|
147
|
+
|
|
148
|
+
Steep.logger.info { "Server ready. Tracking #{all_paths.size} files." }
|
|
149
|
+
Steep.logger.info { "Socket: #{@config.socket_path}" }
|
|
150
|
+
|
|
151
|
+
# SAFE: Verify socket path is actually a socket before deleting (prevents symlink attacks)
|
|
152
|
+
if File.exist?(@config.socket_path)
|
|
153
|
+
unless File.socket?(@config.socket_path)
|
|
154
|
+
raise "#{@config.socket_path} exists but is not a socket (possible symlink attack)"
|
|
155
|
+
end
|
|
156
|
+
File.delete(@config.socket_path)
|
|
157
|
+
end
|
|
158
|
+
@unix_server = UNIXServer.new(@config.socket_path)
|
|
159
|
+
# SAFE: Restrict socket access to owner only (prevents unauthorized connections)
|
|
160
|
+
File.chmod(0600, @config.socket_path)
|
|
161
|
+
|
|
162
|
+
warmup_thread = Thread.new do
|
|
163
|
+
Thread.current.abort_on_exception = false
|
|
164
|
+
set_warmup_status(:warming_up)
|
|
165
|
+
stderr.puts "Warming up type checker (loading gem signatures and RBS files)..."
|
|
166
|
+
warm_typecheck_on_startup(client_writer, client_reader)
|
|
167
|
+
stderr.puts "Warm-up complete. Ready for fast type checking."
|
|
168
|
+
set_warmup_status(:ready)
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
Steep.logger.error { "Warm-up error: #{e.class}: #{e.message}" }
|
|
171
|
+
Steep.logger.debug { e.backtrace&.first(10)&.join("\n") }
|
|
172
|
+
set_warmup_status(:failed)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
watcher_thread = start_background_watcher(client_writer, client_reader)
|
|
176
|
+
|
|
177
|
+
server = @unix_server or raise
|
|
178
|
+
|
|
179
|
+
Signal.trap("TERM") { @shutdown_flag = true; server.close rescue nil }
|
|
180
|
+
Signal.trap("INT") { @shutdown_flag = true; server.close rescue nil }
|
|
181
|
+
|
|
182
|
+
until @shutdown_flag
|
|
183
|
+
begin
|
|
184
|
+
ready = IO.select([server], nil, nil, 1) # steep:ignore UnresolvedOverloading
|
|
185
|
+
next unless ready
|
|
186
|
+
|
|
187
|
+
client_socket = server.accept
|
|
188
|
+
|
|
189
|
+
unless warmup_ready?
|
|
190
|
+
sleep 0.1 until warmup_ready? || @shutdown_flag
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
next if @shutdown_flag
|
|
194
|
+
|
|
195
|
+
handle_client(client_socket, client_writer, client_reader)
|
|
196
|
+
rescue IOError, Errno::EBADF
|
|
197
|
+
break if @shutdown_flag
|
|
198
|
+
raise
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
Steep.logger.error { "Error handling client: #{e.class}: #{e.message}" }
|
|
201
|
+
Steep.logger.debug { e.backtrace&.first(10)&.join("\n") }
|
|
202
|
+
ensure
|
|
203
|
+
client_socket&.close rescue nil
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
Steep.logger.info { "Shutting down..." }
|
|
208
|
+
warmup_thread&.kill
|
|
209
|
+
watcher_thread&.kill
|
|
210
|
+
shutdown_master(client_writer, client_reader)
|
|
211
|
+
master_thread.join(10)
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
Steep.logger.fatal { "Fatal error: #{e.class}: #{e.message}" }
|
|
214
|
+
Steep.logger.error { e.backtrace&.join("\n") }
|
|
215
|
+
ensure
|
|
216
|
+
Daemon.cleanup
|
|
217
|
+
Steep.logger.info { "Server stopped." }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
def warmup_ready?
|
|
223
|
+
@warmup_mutex.synchronize { @warmup_status == :ready }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def set_warmup_status(status)
|
|
227
|
+
@warmup_mutex.synchronize { @warmup_status = status }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def handle_client(client_socket, master_writer, master_reader)
|
|
231
|
+
client_reader = LanguageServer::Protocol::Transport::Io::Reader.new(client_socket)
|
|
232
|
+
client_writer = LanguageServer::Protocol::Transport::Io::Writer.new(client_socket)
|
|
233
|
+
|
|
234
|
+
request = read_one_message(client_reader) or return
|
|
235
|
+
|
|
236
|
+
if request[:method] == ::Steep::Server::CustomMethods::TypeCheck::METHOD || request[:method].nil?
|
|
237
|
+
handle_typecheck_request(request, client_writer, master_writer, master_reader)
|
|
238
|
+
else
|
|
239
|
+
handle_lsp_request(request, client_writer, master_writer, master_reader)
|
|
240
|
+
end
|
|
241
|
+
rescue Errno::EPIPE, IOError
|
|
242
|
+
Steep.logger.warn { "Client disconnected during request" }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def handle_typecheck_request(request, client_writer, master_writer, master_reader)
|
|
246
|
+
client_request_id = request[:id]
|
|
247
|
+
params = request[:params]
|
|
248
|
+
|
|
249
|
+
code_count = params[:code_paths]&.size || 0
|
|
250
|
+
sig_count = params[:signature_paths]&.size || 0
|
|
251
|
+
Steep.logger.info { "Check request: #{code_count} code, #{sig_count} signature files" }
|
|
252
|
+
|
|
253
|
+
sync_changed_files(master_writer, params)
|
|
254
|
+
|
|
255
|
+
request_guid = SecureRandom.uuid
|
|
256
|
+
master_writer.write(
|
|
257
|
+
::Steep::Server::CustomMethods::TypeCheck.request(request_guid, params)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
master_reader.read do |message|
|
|
261
|
+
if message[:id] == request_guid
|
|
262
|
+
client_writer.write({ id: client_request_id, result: message[:result] })
|
|
263
|
+
return
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
client_writer.write(message)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def handle_lsp_request(request, client_writer, master_writer, master_reader)
|
|
271
|
+
client_request_id = request[:id]
|
|
272
|
+
method = request[:method]
|
|
273
|
+
|
|
274
|
+
Steep.logger.info { "LSP request: #{method}" }
|
|
275
|
+
|
|
276
|
+
request_guid = SecureRandom.uuid
|
|
277
|
+
master_writer.write({
|
|
278
|
+
id: request_guid,
|
|
279
|
+
method: method,
|
|
280
|
+
params: request[:params]
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
master_reader.read do |message|
|
|
284
|
+
if message[:id] == request_guid
|
|
285
|
+
client_writer.write({ id: client_request_id, result: message[:result] })
|
|
286
|
+
return
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def sync_changed_files(master_writer, params)
|
|
292
|
+
request_paths = [] #: Array[String]
|
|
293
|
+
(params[:code_paths] || []).each { |_, path| request_paths << path }
|
|
294
|
+
(params[:signature_paths] || []).each { |_, path| request_paths << path }
|
|
295
|
+
|
|
296
|
+
changes_map = {} #: Hash[String, Symbol]
|
|
297
|
+
@file_tracker.flush_pending_changes.each { |path, type| changes_map[path] = type }
|
|
298
|
+
@file_tracker.track_and_detect(request_paths).each { |path, type| changes_map[path] = type }
|
|
299
|
+
|
|
300
|
+
return if changes_map.empty?
|
|
301
|
+
|
|
302
|
+
Steep.logger.info { "Syncing #{changes_map.size} changed file(s) to workers" }
|
|
303
|
+
|
|
304
|
+
lsp_changes = changes_map.map do |path, type|
|
|
305
|
+
{ uri: "file://#{path}", type: FILE_CHANGE_TYPE[type] }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
master_writer.write(
|
|
309
|
+
method: "workspace/didChangeWatchedFiles",
|
|
310
|
+
params: { changes: lsp_changes }
|
|
311
|
+
)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def collect_all_project_paths
|
|
315
|
+
paths = Set.new
|
|
316
|
+
loader = ::Steep::Services::FileLoader.new(base_dir: @project.base_dir)
|
|
317
|
+
|
|
318
|
+
@project.targets.each do |target|
|
|
319
|
+
loader.each_path_in_target(target) do |path|
|
|
320
|
+
abs = @project.absolute_path(path)
|
|
321
|
+
paths << abs.to_s if abs.file?
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
sig_dir = @project.base_dir + "sig"
|
|
326
|
+
if sig_dir.directory?
|
|
327
|
+
sig_dir.glob("**/*.rbs").each { |p| paths << p.to_s if p.file? }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
paths.to_a
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def start_background_watcher(master_writer, master_reader)
|
|
334
|
+
require "listen"
|
|
335
|
+
|
|
336
|
+
watch_dirs = [@project.base_dir.to_s]
|
|
337
|
+
|
|
338
|
+
listener = Listen.to(*watch_dirs, only: /\.(rb|rbs)$/, wait_for_delay: 1) do |modified, added, removed|
|
|
339
|
+
all_paths = modified + added + removed
|
|
340
|
+
next if all_paths.empty?
|
|
341
|
+
|
|
342
|
+
changes = build_lsp_changes(all_paths, added, removed)
|
|
343
|
+
next if changes.empty?
|
|
344
|
+
|
|
345
|
+
has_signature_change = all_paths.any? { |p| p.end_with?(".rbs") }
|
|
346
|
+
|
|
347
|
+
Steep.logger.info { "Watcher: #{changes.size} file(s) changed" +
|
|
348
|
+
(has_signature_change ? " (includes signatures, pre-warming...)" : "") }
|
|
349
|
+
|
|
350
|
+
master_writer.write(
|
|
351
|
+
method: "workspace/didChangeWatchedFiles",
|
|
352
|
+
params: { changes: changes }
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if has_signature_change
|
|
356
|
+
warm_typecheck(master_writer, master_reader)
|
|
357
|
+
end
|
|
358
|
+
rescue StandardError => e
|
|
359
|
+
Steep.logger.error { "Watcher error: #{e.class}: #{e.message}" }
|
|
360
|
+
Steep.logger.debug { e.backtrace&.first(5)&.join("\n") }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
Thread.new do
|
|
364
|
+
listener.start
|
|
365
|
+
sleep
|
|
366
|
+
rescue StandardError => e
|
|
367
|
+
Steep.logger.error { "Watcher thread error: #{e.class}: #{e.message}" }
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def categorize_path_changes(all_paths, added, removed)
|
|
372
|
+
added_set = added.to_set
|
|
373
|
+
removed_set = removed.to_set
|
|
374
|
+
|
|
375
|
+
all_paths.map do |path|
|
|
376
|
+
if added_set.include?(path)
|
|
377
|
+
:created
|
|
378
|
+
elsif removed_set.include?(path)
|
|
379
|
+
:deleted
|
|
380
|
+
else
|
|
381
|
+
:changed
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def build_lsp_changes(all_paths, added, removed)
|
|
387
|
+
types = categorize_path_changes(all_paths, added, removed)
|
|
388
|
+
|
|
389
|
+
all_paths.zip(types).map do |path, type|
|
|
390
|
+
{ uri: "file://#{path}", type: FILE_CHANGE_TYPE[type || :changed] }
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def collect_warmup_files
|
|
395
|
+
params = { library_paths: [], inline_paths: [], signature_paths: [], code_paths: [] } #: ::Steep::Server::CustomMethods::TypeCheck::params
|
|
396
|
+
loader = ::Steep::Services::FileLoader.new(base_dir: @project.base_dir)
|
|
397
|
+
|
|
398
|
+
@project.targets.each do |target|
|
|
399
|
+
loader.each_path_in_target(target) do |path|
|
|
400
|
+
abs = @project.absolute_path(path)
|
|
401
|
+
if abs.file? && abs.to_s.end_with?(".rb")
|
|
402
|
+
params[:code_paths] << [target.name.to_s, abs.to_s]
|
|
403
|
+
break
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
params
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def warm_typecheck(master_writer, master_reader)
|
|
412
|
+
params = collect_warmup_files
|
|
413
|
+
return if params[:code_paths].empty?
|
|
414
|
+
|
|
415
|
+
guid = SecureRandom.uuid
|
|
416
|
+
Steep.logger.info { "Watcher: warm-up typecheck started (#{params[:code_paths].size} targets)" }
|
|
417
|
+
master_writer.write(
|
|
418
|
+
::Steep::Server::CustomMethods::TypeCheck.request(guid, params)
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
master_reader.read do |message|
|
|
422
|
+
if message[:id] == guid
|
|
423
|
+
Steep.logger.info { "Watcher: warm-up typecheck completed" }
|
|
424
|
+
break
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def warm_typecheck_on_startup(master_writer, master_reader)
|
|
430
|
+
params = collect_warmup_files
|
|
431
|
+
|
|
432
|
+
if params[:code_paths].empty?
|
|
433
|
+
Steep.logger.warn { "No Ruby files found for warm-up, skipping" }
|
|
434
|
+
return
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
guid = SecureRandom.uuid
|
|
438
|
+
Steep.logger.info { "Checking #{params[:code_paths].size} file(s) to trigger RBS loading..." }
|
|
439
|
+
|
|
440
|
+
start_time = Time.now
|
|
441
|
+
master_writer.write(
|
|
442
|
+
::Steep::Server::CustomMethods::TypeCheck.request(guid, params)
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
master_reader.read do |message|
|
|
446
|
+
if message[:id] == guid
|
|
447
|
+
elapsed = Time.now - start_time
|
|
448
|
+
Steep.logger.info { "RBS environment loaded in #{elapsed.round(2)}s" }
|
|
449
|
+
break
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def wait_for_response(reader, id)
|
|
455
|
+
reader.read do |message|
|
|
456
|
+
return message if message[:id] == id
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def read_one_message(reader)
|
|
461
|
+
reader.read do |message|
|
|
462
|
+
return message
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def shutdown_master(writer, reader)
|
|
467
|
+
id = SecureRandom.alphanumeric(10)
|
|
468
|
+
writer.write(method: :shutdown, id: id)
|
|
469
|
+
wait_for_response(reader, id)
|
|
470
|
+
writer.write(method: :exit)
|
|
471
|
+
rescue StandardError => e
|
|
472
|
+
Steep.logger.error { "Shutdown error: #{e.message}" }
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
data/lib/steep/daemon.rb
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
require "steep/daemon/configuration"
|
|
6
|
+
require "steep/daemon/server"
|
|
7
|
+
|
|
8
|
+
module Steep
|
|
9
|
+
module Daemon
|
|
10
|
+
SOCKET_DIR = File.join(Dir.tmpdir, "steep-server")
|
|
11
|
+
|
|
12
|
+
LARGE_LOG_FILE_THRESHOLD = 10 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def config
|
|
16
|
+
@config ||= Configuration.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def project_id
|
|
20
|
+
config.project_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def socket_path
|
|
24
|
+
config.socket_path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def pid_path
|
|
28
|
+
config.pid_path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def log_path
|
|
32
|
+
config.log_path
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def starting?
|
|
36
|
+
return false unless File.exist?(pid_path)
|
|
37
|
+
return false if File.exist?(socket_path)
|
|
38
|
+
|
|
39
|
+
pid = File.read(pid_path).to_i
|
|
40
|
+
Process.kill(0, pid)
|
|
41
|
+
true
|
|
42
|
+
rescue Errno::ESRCH, Errno::ENOENT
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def running?
|
|
47
|
+
return false unless File.exist?(pid_path) && File.exist?(socket_path)
|
|
48
|
+
|
|
49
|
+
pid = File.read(pid_path).to_i
|
|
50
|
+
Process.kill(0, pid)
|
|
51
|
+
socket = UNIXSocket.new(socket_path)
|
|
52
|
+
socket.close
|
|
53
|
+
true
|
|
54
|
+
rescue Errno::ESRCH, Errno::ENOENT, Errno::ECONNREFUSED, Errno::ENOTSOCK
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cleanup
|
|
59
|
+
[socket_path, pid_path].each do |path|
|
|
60
|
+
File.delete(path)
|
|
61
|
+
rescue Errno::ENOENT
|
|
62
|
+
# File already deleted
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def start(stderr:)
|
|
67
|
+
if running?
|
|
68
|
+
stderr.puts "Steep server already running (PID: #{File.read(pid_path).strip})"
|
|
69
|
+
return true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
cleanup
|
|
73
|
+
|
|
74
|
+
unless Steep.can_fork?
|
|
75
|
+
stderr.puts "The daemon server is not supported on this platform (fork() is not available)"
|
|
76
|
+
return false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
child_pid = fork do
|
|
80
|
+
Process.setsid
|
|
81
|
+
daemon_pid = fork do
|
|
82
|
+
File.write(pid_path, Process.pid.to_s)
|
|
83
|
+
log_file = File.open(log_path, "a")
|
|
84
|
+
log_file.sync = true
|
|
85
|
+
$stdout.reopen(log_file)
|
|
86
|
+
$stderr.reopen(log_file)
|
|
87
|
+
$stdin.reopen("/dev/null")
|
|
88
|
+
run_server(stderr:)
|
|
89
|
+
end
|
|
90
|
+
exit!(0) if daemon_pid
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Process.waitpid(child_pid) if child_pid
|
|
94
|
+
|
|
95
|
+
40.times do
|
|
96
|
+
sleep 0.5
|
|
97
|
+
next unless running?
|
|
98
|
+
|
|
99
|
+
stderr.puts "Steep server started (PID: #{File.read(pid_path).strip})"
|
|
100
|
+
return true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
stderr.puts "Failed to start steep server. Check log: #{log_path}"
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def stop(stderr:)
|
|
108
|
+
unless File.exist?(pid_path)
|
|
109
|
+
stderr.puts "Steep server is not running"
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
pid = File.read(pid_path).to_i
|
|
114
|
+
Process.kill("TERM", pid)
|
|
115
|
+
process_alive = true
|
|
116
|
+
20.times do
|
|
117
|
+
sleep 0.5
|
|
118
|
+
Process.kill(0, pid)
|
|
119
|
+
rescue Errno::ESRCH
|
|
120
|
+
process_alive = false
|
|
121
|
+
break
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if process_alive
|
|
125
|
+
Process.kill("KILL", pid)
|
|
126
|
+
stderr.puts "Steep server did not stop gracefully, forcefully killed (PID: #{pid})"
|
|
127
|
+
else
|
|
128
|
+
stderr.puts "Steep server stopped (PID: #{pid})"
|
|
129
|
+
end
|
|
130
|
+
cleanup
|
|
131
|
+
rescue Errno::ESRCH
|
|
132
|
+
cleanup
|
|
133
|
+
stderr.puts "Steep server was not running (cleaned up stale files)"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def status(stderr:)
|
|
137
|
+
if running?
|
|
138
|
+
pid = File.read(pid_path).to_i
|
|
139
|
+
stderr.puts "Steep server running (PID: #{pid})"
|
|
140
|
+
stderr.puts " Socket: #{socket_path}"
|
|
141
|
+
stderr.puts " Log: #{log_path}"
|
|
142
|
+
|
|
143
|
+
if File.exist?(log_path)
|
|
144
|
+
log_content = if File.size(log_path) > LARGE_LOG_FILE_THRESHOLD
|
|
145
|
+
# SAFE: log_path is controlled internally, no user input
|
|
146
|
+
`tail -n 20 #{log_path.shellescape}`
|
|
147
|
+
else
|
|
148
|
+
File.readlines(log_path).last(20).join
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
if log_content.include?("Warm-up complete")
|
|
152
|
+
stderr.puts " Status: Ready"
|
|
153
|
+
elsif log_content.include?("Warming up type checker")
|
|
154
|
+
stderr.puts " Status: Warming up (loading RBS environment)"
|
|
155
|
+
else
|
|
156
|
+
stderr.puts " Status: Starting"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
else
|
|
160
|
+
stderr.puts "Steep server is not running"
|
|
161
|
+
|
|
162
|
+
if File.exist?(pid_path) || File.exist?(socket_path)
|
|
163
|
+
stderr.puts " (Found stale files - run 'steep server stop' to clean up)"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def run_server(stderr:)
|
|
171
|
+
project = load_project
|
|
172
|
+
server = Server.new(config: config, project: project, stderr:)
|
|
173
|
+
server.run
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def load_project
|
|
177
|
+
steep_file = Pathname("Steepfile")
|
|
178
|
+
steep_file_path = steep_file.realpath
|
|
179
|
+
|
|
180
|
+
project = ::Steep::Project.new(steepfile_path: steep_file_path)
|
|
181
|
+
::Steep::Project::DSL.parse(project, steep_file.read, filename: steep_file.to_s)
|
|
182
|
+
|
|
183
|
+
project.targets.each do |target|
|
|
184
|
+
case target.options.load_collection_lock
|
|
185
|
+
when nil, RBS::Collection::Config::Lockfile
|
|
186
|
+
# OK
|
|
187
|
+
when RBS::Collection::Config::CollectionNotAvailable
|
|
188
|
+
config_path = target.options.collection_config_path || raise
|
|
189
|
+
lockfile_path = RBS::Collection::Config.to_lockfile_path(config_path)
|
|
190
|
+
RBS::Collection::Installer.new(
|
|
191
|
+
lockfile_path: lockfile_path, stdout: $stderr
|
|
192
|
+
).install_from_lockfile
|
|
193
|
+
target.options.load_collection_lock(force: true)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
project
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|