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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/CLAUDE.md +114 -0
  4. data/README.md +1 -1
  5. data/Rakefile +15 -3
  6. data/Steepfile +13 -13
  7. data/lib/steep/annotation_parser.rb +5 -1
  8. data/lib/steep/annotations_helper.rb +12 -2
  9. data/lib/steep/ast/node/type_application.rb +22 -16
  10. data/lib/steep/ast/node/type_assertion.rb +7 -4
  11. data/lib/steep/ast/types/factory.rb +3 -2
  12. data/lib/steep/cli.rb +246 -2
  13. data/lib/steep/daemon/configuration.rb +19 -0
  14. data/lib/steep/daemon/server.rb +476 -0
  15. data/lib/steep/daemon.rb +201 -0
  16. data/lib/steep/diagnostic/ruby.rb +50 -8
  17. data/lib/steep/diagnostic/signature.rb +31 -8
  18. data/lib/steep/drivers/check.rb +301 -140
  19. data/lib/steep/drivers/print_project.rb +9 -10
  20. data/lib/steep/drivers/query.rb +102 -0
  21. data/lib/steep/drivers/start_server.rb +19 -0
  22. data/lib/steep/drivers/stop_server.rb +20 -0
  23. data/lib/steep/drivers/watch.rb +2 -2
  24. data/lib/steep/index/rbs_index.rb +38 -13
  25. data/lib/steep/index/signature_symbol_provider.rb +24 -3
  26. data/lib/steep/interface/builder.rb +48 -15
  27. data/lib/steep/interface/shape.rb +13 -5
  28. data/lib/steep/locator.rb +377 -0
  29. data/lib/steep/project/dsl.rb +26 -5
  30. data/lib/steep/project/group.rb +8 -2
  31. data/lib/steep/project/target.rb +16 -2
  32. data/lib/steep/project.rb +21 -2
  33. data/lib/steep/server/base_worker.rb +2 -2
  34. data/lib/steep/server/change_buffer.rb +2 -1
  35. data/lib/steep/server/custom_methods.rb +12 -0
  36. data/lib/steep/server/inline_source_change_detector.rb +94 -0
  37. data/lib/steep/server/interaction_worker.rb +51 -74
  38. data/lib/steep/server/lsp_formatter.rb +48 -12
  39. data/lib/steep/server/master.rb +100 -18
  40. data/lib/steep/server/target_group_files.rb +124 -151
  41. data/lib/steep/server/type_check_controller.rb +276 -123
  42. data/lib/steep/server/type_check_worker.rb +104 -3
  43. data/lib/steep/services/completion_provider/rbs.rb +74 -0
  44. data/lib/steep/services/completion_provider/ruby.rb +652 -0
  45. data/lib/steep/services/completion_provider/type_name.rb +243 -0
  46. data/lib/steep/services/completion_provider.rb +39 -662
  47. data/lib/steep/services/content_change.rb +14 -1
  48. data/lib/steep/services/file_loader.rb +4 -2
  49. data/lib/steep/services/goto_service.rb +271 -68
  50. data/lib/steep/services/hover_provider/content.rb +67 -0
  51. data/lib/steep/services/hover_provider/rbs.rb +8 -9
  52. data/lib/steep/services/hover_provider/ruby.rb +123 -64
  53. data/lib/steep/services/hover_provider/singleton_methods.rb +4 -0
  54. data/lib/steep/services/signature_service.rb +129 -54
  55. data/lib/steep/services/type_check_service.rb +72 -27
  56. data/lib/steep/signature/validator.rb +30 -18
  57. data/lib/steep/source/ignore_ranges.rb +14 -4
  58. data/lib/steep/source.rb +16 -2
  59. data/lib/steep/tagged_logging.rb +39 -0
  60. data/lib/steep/type_construction.rb +94 -21
  61. data/lib/steep/type_inference/block_params.rb +7 -7
  62. data/lib/steep/type_inference/context.rb +4 -2
  63. data/lib/steep/type_inference/logic_type_interpreter.rb +21 -3
  64. data/lib/steep/type_inference/method_call.rb +4 -0
  65. data/lib/steep/type_inference/type_env.rb +1 -1
  66. data/lib/steep/typing.rb +0 -2
  67. data/lib/steep/version.rb +1 -1
  68. data/lib/steep.rb +42 -32
  69. data/manual/ruby-diagnostics.md +67 -0
  70. data/sample/Steepfile +1 -0
  71. data/sample/lib/conference.rb +1 -0
  72. data/sample/lib/deprecated.rb +6 -0
  73. data/sample/lib/inline.rb +43 -0
  74. data/sample/sig/generics.rbs +3 -0
  75. data/steep.gemspec +4 -5
  76. metadata +26 -26
  77. 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
@@ -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