docscribe 1.5.0 → 1.5.2

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.
@@ -0,0 +1,836 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'socket'
5
+ require 'fileutils'
6
+ require 'securerandom'
7
+ require 'digest/md5'
8
+ require 'tmpdir'
9
+ require 'time'
10
+ require 'timeout'
11
+ require_relative 'lru_cache'
12
+
13
+ module Docscribe
14
+ # Server/daemon mode for persistent multi-request operation.
15
+ #
16
+ # Architecture:
17
+ # - Daemon process loads Ruby runtime once, listens on a Unix socket
18
+ # - Client sends JSON-line requests, receives JSON-line responses
19
+ # - Auto-shutdown after idle timeout
20
+ # - Protocol: JSON-RPC 2.0 over Unix socket
21
+ module Server
22
+ # Unix socket path max is 104 bytes on macOS (the more restrictive).
23
+ # Dir.tmpdir on macOS often returns a long path under /var/folders/.../T
24
+ # that exceeds this limit, so we fall back to /tmp when needed.
25
+ SOCKET_DIR = begin
26
+ tmp = Dir.tmpdir || '/tmp'
27
+ sock_overhead = "/docscribe-#{'a' * 32}.sock".bytesize # 48
28
+ tmp.bytesize <= 104 - sock_overhead ? tmp : '/tmp'
29
+ end
30
+ IDLE_TIMEOUT = 300
31
+
32
+ class << self
33
+ # Start the server daemon if not running.
34
+ #
35
+ # @param [String?] config_path optional config file path
36
+ # @param [Boolean] daemonize redirect stdin/stdout/stderr to /dev/null
37
+ # @param [Integer] timeout max seconds to wait for readiness
38
+ # @return [void]
39
+ def ensure_running!(config_path: nil, daemonize: false, timeout: 5)
40
+ return if running?(config_path)
41
+
42
+ check_platform_support!
43
+
44
+ lock_path = "#{socket_path(config_path)}.lock"
45
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
46
+ lock.flock(File::LOCK_EX)
47
+ next if running?(config_path)
48
+
49
+ start_daemon_process(config_path: config_path, daemonize: daemonize)
50
+ end
51
+ wait_for_ready(config_path: config_path, timeout: timeout)
52
+ end
53
+
54
+ # Start the server daemon and wait for it to become ready.
55
+ #
56
+ # @param [String?] config_path optional config path for socket/pid lookup
57
+ # @param [Integer] timeout max seconds to wait for readiness
58
+ # @param [Boolean] raise_on_timeout
59
+ # @raise [StandardError]
60
+ # @return [Boolean]
61
+ def wait_for_ready(config_path: nil, timeout: 5, raise_on_timeout: true) # rubocop:disable SortedMethodsByCall/Waterfall
62
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
63
+ loop do
64
+ return true if running?(config_path)
65
+
66
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
67
+ raise('Docscribe: server failed to start') if raise_on_timeout
68
+
69
+ warn('Docscribe server failed to start within timeout')
70
+ return false
71
+ end
72
+
73
+ sleep 0.1
74
+ end
75
+ end
76
+
77
+ # Whether a server process is listening on the socket.
78
+ #
79
+ # On ECONNREFUSED, checks whether the PID process is still alive:
80
+ # if yes, the daemon is still starting up (don't clean up);
81
+ # if no, removes stale socket and pid files.
82
+ #
83
+ # @param [String?] config_path optional config path for socket lookup
84
+ # @raise [Errno::ECONNREFUSED]
85
+ # @raise [Errno::ENOENT]
86
+ # @raise [Errno::ENOTSOCK]
87
+ # @raise [StandardError]
88
+ # @return [Boolean]
89
+ # @return [Boolean] if Errno::ECONNREFUSED
90
+ # @return [void, Boolean] if Errno::ENOENT, Errno::ENOTSOCK
91
+ # @return [Boolean] if StandardError
92
+ def running?(config_path = nil)
93
+ return false unless defined?(UNIXSocket)
94
+
95
+ socket = UNIXSocket.new(socket_path(config_path))
96
+ socket.close
97
+ true
98
+ rescue Errno::ECONNREFUSED
99
+ handle_stale_socket?(config_path)
100
+ rescue Errno::ENOENT, Errno::ENOTSOCK
101
+ clean_socket_files(config_path) && false
102
+ rescue StandardError
103
+ false
104
+ end
105
+
106
+ # Handle ECONNREFUSED: check if the pid process is alive.
107
+ # Cleans up only if the process is dead.
108
+ #
109
+ # @param [String?] config_path
110
+ # @return [Boolean] false (not running)
111
+ def handle_stale_socket?(config_path)
112
+ pid = read_pid(config_path)
113
+ return false if pid && process_alive?(pid)
114
+
115
+ clean_socket_files(config_path)
116
+ false
117
+ end
118
+
119
+ # @param [Integer] pid
120
+ # @raise [Errno::ESRCH]
121
+ # @return [Boolean]
122
+ # @return [Boolean] if Errno::ESRCH
123
+ def process_alive?(pid)
124
+ Process.kill(0, pid)
125
+ true
126
+ rescue Errno::ESRCH
127
+ false
128
+ end
129
+
130
+ # @param [String?] config_path
131
+ # @raise [StandardError]
132
+ # @return [Integer?]
133
+ # @return [nil] if StandardError
134
+ def read_pid(config_path = nil)
135
+ File.read(pid_path(config_path)).to_i if File.exist?(pid_path(config_path))
136
+ rescue StandardError
137
+ nil
138
+ end
139
+
140
+ # Remove stale socket and pid files.
141
+ #
142
+ # @param [String?] config_path
143
+ # @return [void]
144
+ def clean_socket_files(config_path)
145
+ FileUtils.rm_f(socket_path(config_path))
146
+ FileUtils.rm_f(pid_path(config_path))
147
+ end
148
+
149
+ # @param [String?] config_path
150
+ # @return [String]
151
+ def pid_path(config_path = nil)
152
+ "#{socket_path(config_path)}.pid"
153
+ end
154
+
155
+ ENV_FILES = %w[Gemfile.lock rbs_collection.lock.yaml].freeze
156
+
157
+ # @param [String] config_path
158
+ # @return [String]
159
+ def config_hash(config_path)
160
+ resolved = File.expand_path(config_path)
161
+ mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
162
+ Digest::MD5.hexdigest("#{resolved}:#{mtime}")
163
+ end
164
+
165
+ # Check platform compatibility before starting server.
166
+ #
167
+ # @raise [StandardError]
168
+ # @return [void]
169
+ def check_platform_support!
170
+ unless defined?(UNIXSocket)
171
+ raise 'Server mode requires Unix domain sockets, which are not available on Windows. ' \
172
+ 'Use docscribe directly without --server flag.'
173
+ end
174
+ return if Process.respond_to?(:fork)
175
+
176
+ raise 'Server mode requires Process.fork, which is not available on JRuby. ' \
177
+ 'Use docscribe directly without --server flag.'
178
+ end
179
+
180
+ # Derive a project-specific socket path from the current working directory.
181
+ # Uses MD5 (deterministic across processes) instead of String#hash
182
+ # (which varies per Ruby process due to random seeding).
183
+ # When a config_path is given, its path + mtime are included in the hash
184
+ # so different configs get different daemons.
185
+ # Environment files (Gemfile.lock, rbs_collection.lock.yaml) are also
186
+ # included so daemon is invalidated when gems or RBS types change.
187
+ #
188
+ # @param [String?] config_path optional config path to differentiate
189
+ # @return [String]
190
+ def socket_path(config_path = nil)
191
+ seed = +Dir.pwd
192
+ seed << ":#{env_hash}"
193
+ if config_path
194
+ resolved = File.expand_path(config_path)
195
+ mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
196
+ seed << ":#{resolved}:#{mtime}"
197
+ end
198
+ "#{SOCKET_DIR}/docscribe-#{Digest::MD5.hexdigest(seed)}.sock"
199
+ end
200
+
201
+ # Hash of environment files that affect analysis results.
202
+ # When any of these change, the daemon is invalidated (new socket path).
203
+ #
204
+ # @return [String]
205
+ def env_hash
206
+ parts = ENV_FILES.map do |file|
207
+ path = File.join(Dir.pwd, file)
208
+ File.exist?(path) ? File.mtime(path).to_f.to_s : '0'
209
+ end
210
+ Digest::MD5.hexdigest(parts.join(':'))
211
+ end
212
+
213
+ public :read_pid, :pid_path, :socket_path
214
+
215
+ # @param [String?] config_path
216
+ # @param [Boolean] daemonize
217
+ # @return [void]
218
+ def start_daemon_process(config_path:, daemonize:)
219
+ warn 'Docscribe: starting server...' if daemonize
220
+ pid = Process.fork do # steep:ignore NoMethod
221
+ [$stdin, $stdout].each { _1.reopen(File::NULL) }
222
+ $stderr.reopen(File::NULL)
223
+ Daemon.new(config_path: config_path).start
224
+ end
225
+ Process.detach(pid)
226
+ end
227
+ end
228
+
229
+ # JSON-line protocol helpers.
230
+ module Protocol
231
+ module_function
232
+
233
+ # Build a JSON-RPC request hash.
234
+ #
235
+ # @note module_function: defines #build_request (visibility: private)
236
+ # @param [String] method method name
237
+ # @param [Hash<Symbol, Object>] params request parameters
238
+ # @return [Hash<Symbol, Object>]
239
+ def build_request(method, params = {})
240
+ {
241
+ jsonrpc: '2.0',
242
+ id: SecureRandom.hex(8),
243
+ method: method,
244
+ params: params
245
+ }
246
+ end
247
+
248
+ # Parse a single JSON-line response.
249
+ #
250
+ # @note module_function: defines #parse_response (visibility: private)
251
+ # @param [String] line raw JSON line
252
+ # @raise [JSON::ParserError]
253
+ # @return [Hash<String, Object>?]
254
+ # @return [nil] if JSON::ParserError
255
+ def parse_response(line)
256
+ JSON.parse(line)
257
+ rescue JSON::ParserError
258
+ nil
259
+ end
260
+
261
+ # Serialize a hash to a JSON line.
262
+ #
263
+ # @note module_function: defines #serialize (visibility: private)
264
+ # @param [Hash<Object, Object>] hash
265
+ # @return [String]
266
+ def serialize(hash)
267
+ "#{JSON.generate(hash)}\n"
268
+ end
269
+ end
270
+
271
+ # Client for communicating with a running Docscribe daemon.
272
+ class Client
273
+ # @param [String?] socket_path custom socket path (defaults to server default)
274
+ # @param [String?] config_path optional config path for socket lookup
275
+ # @return [void]
276
+ def initialize(socket_path = nil, config_path: nil)
277
+ @socket_path = socket_path || Server.socket_path(config_path)
278
+ end
279
+
280
+ # Send a check request to the server.
281
+ #
282
+ # @param [String] file path to file to check
283
+ # @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
284
+ # @param [Object] rest
285
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
286
+ def check(file:, strategy: :safe, **rest)
287
+ request('check', file: file, strategy: strategy, **rest)
288
+ end
289
+
290
+ # Send a fix request to the server.
291
+ #
292
+ # @param [String] file path to file to fix
293
+ # @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
294
+ # @param [Object] rest
295
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
296
+ def fix(file:, strategy: :safe, **rest)
297
+ request('fix', file: file, strategy: strategy, **rest)
298
+ end
299
+
300
+ # Send a shutdown request to the server.
301
+ #
302
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
303
+ def shutdown
304
+ request('shutdown')
305
+ end
306
+
307
+ # Ping the server and get version/pid/uptime info.
308
+ #
309
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
310
+ def ping
311
+ request('ping')
312
+ end
313
+
314
+ private
315
+
316
+ # Send a JSON-RPC request and read the response.
317
+ #
318
+ # @private
319
+ # @param [String] method method name
320
+ # @param [Object] params request parameters
321
+ # @return [Hash<String, Object>?]
322
+ def request(method, **params)
323
+ connect do |socket|
324
+ req = Protocol.build_request(method, params)
325
+ socket.write(Protocol.serialize(req))
326
+ socket.close_write
327
+ line = socket.gets
328
+ break unless line
329
+
330
+ Protocol.parse_response(line)
331
+ end
332
+ end
333
+
334
+ # Connect to the Unix socket and yield the connection.
335
+ #
336
+ # @private
337
+ # @raise [Errno::ECONNREFUSED]
338
+ # @raise [Errno::ENOENT]
339
+ # @return [T?] yield return value or nil on connection error
340
+ def connect
341
+ socket = UNIXSocket.new(@socket_path)
342
+ yield socket
343
+ rescue Errno::ECONNREFUSED, Errno::ENOENT
344
+ nil
345
+ ensure
346
+ socket&.close
347
+ end
348
+ end
349
+
350
+ # Daemon process that loads the Ruby runtime once and serves requests.
351
+ class Daemon
352
+ # Standardized JSON-RPC error codes.
353
+ ERROR_CODES = {
354
+ gem_not_found: -32_000,
355
+ syntax_error: -32_001,
356
+ config_load_failure: -32_002,
357
+ timeout: -32_010,
358
+ internal: -32_099
359
+ }.freeze
360
+ # @param [String?] socket_path custom socket path
361
+ # @param [Integer] idle_timeout seconds before automatic shutdown
362
+ # @param [String?] config_path custom config path
363
+ # @return [void]
364
+ def initialize(socket_path: nil, idle_timeout: IDLE_TIMEOUT, config_path: nil)
365
+ @socket_path = socket_path || Server.socket_path(config_path)
366
+ @idle_timeout = idle_timeout
367
+ @config_path = config_path
368
+ @last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
369
+ @running = false
370
+ @server = nil
371
+ @file_cache = LRUCache.new
372
+ @started_at = Time.now
373
+ @cache_mutex = Mutex.new
374
+ @config_mutex = Mutex.new
375
+ end
376
+
377
+ # Start the daemon: load dependencies, bind socket, enter listen loop.
378
+ #
379
+ # @return [void]
380
+ def start
381
+ load_dependencies
382
+ setup_socket
383
+ @running = true
384
+ $PROGRAM_NAME = "docscribe server (#{Dir.pwd})"
385
+ write_pid
386
+ listen_loop
387
+ end
388
+
389
+ private
390
+
391
+ # Load the full Docscribe runtime and build cached config.
392
+ #
393
+ # @private
394
+ # @return [void]
395
+ def load_dependencies
396
+ require 'docscribe'
397
+ @config = Docscribe::Config.load(@config_path)
398
+ @config&.load_plugins!
399
+ @core_rbs_provider = @config&.core_rbs_provider
400
+ end
401
+
402
+ # Create and bind the Unix domain socket.
403
+ #
404
+ # @private
405
+ # @return [void]
406
+ def setup_socket
407
+ FileUtils.rm_f(@socket_path)
408
+ FileUtils.mkdir_p(File.dirname(@socket_path))
409
+ @server = UNIXServer.new(@socket_path)
410
+ File.chmod(0o600, @socket_path)
411
+ end
412
+
413
+ # @private
414
+ # @return [void]
415
+ def write_pid
416
+ File.write("#{@socket_path}.pid", Process.pid)
417
+ end
418
+
419
+ # Main accept loop with idle timeout check.
420
+ #
421
+ # @private
422
+ # @raise [Interrupt]
423
+ # @return [void]
424
+ def listen_loop
425
+ while @running
426
+ check_idle_timeout
427
+ accept_client
428
+ end
429
+ rescue Interrupt
430
+ @running = false
431
+ ensure
432
+ cleanup
433
+ end
434
+
435
+ # Check whether the idle timeout has been exceeded.
436
+ #
437
+ # @private
438
+ # @return [void]
439
+ def check_idle_timeout
440
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_request_time
441
+ @running = false if elapsed > @idle_timeout
442
+ end
443
+
444
+ # Accept a client connection if one is available.
445
+ # Spawns a thread to handle each client concurrently.
446
+ #
447
+ # @private
448
+ # @return [void]
449
+ def accept_client
450
+ client = @server&.accept if @server&.wait_readable(0.1)
451
+ return unless client
452
+
453
+ @last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
454
+ Thread.new(client) { |conn| handle_client(conn) }
455
+ end
456
+
457
+ # Read a request from a client connection and dispatch it.
458
+ #
459
+ # @private
460
+ # @param [UNIXSocket] client connected client socket
461
+ # @raise [StandardError]
462
+ # @return [void]
463
+ def handle_client(client)
464
+ request_line = client.gets or return
465
+ request = Protocol.parse_response(request_line)
466
+ request ? handle_request(client, request) : send_error(client, nil, -32_700, 'Parse error')
467
+ rescue StandardError => e
468
+ method_name = request&.dig('method')
469
+ error_params = request&.dig('params') || {}
470
+ code, message, data = classify_error(e, method_name, error_params)
471
+ send_error(client, request&.dig('id'), code, message, data)
472
+ ensure
473
+ client.close
474
+ end
475
+
476
+ # Dispatch a parsed request to the appropriate handler.
477
+ #
478
+ # @private
479
+ # @param [UNIXSocket] client connected client socket
480
+ # @param [Hash<String, Object>] request parsed JSON-RPC request
481
+ # @return [void]
482
+ def handle_request(client, request)
483
+ method = request['method']
484
+ params = request['params'] || {}
485
+
486
+ case method
487
+ when 'check' then handle_check(client, request['id'], params)
488
+ when 'fix' then handle_fix(client, request['id'], params)
489
+ when 'check_batch' then handle_check_batch(client, request['id'], params)
490
+ when 'shutdown' then handle_shutdown(client, request['id'])
491
+ when 'ping' then handle_ping(client, request['id'])
492
+ else send_error(client, request['id'], -32_601, "Unknown method: #{method}")
493
+ end
494
+ end
495
+
496
+ # @private
497
+ # @param [UNIXSocket] client
498
+ # @param [String, Integer] id
499
+ # @param [Hash<String, Object>] params
500
+ # @raise [StandardError]
501
+ # @return [void]
502
+ # @return [void] if StandardError
503
+ def handle_check(client, id, params)
504
+ file = params['file']
505
+ strategy = (params['strategy'] || 'safe').to_sym
506
+ return send_error(client, id, -32_602, "File not found: #{file}") unless file && File.file?(file)
507
+
508
+ apply_cli_overrides(params['cli_overrides'])
509
+ src, result = rewrite_file(file, strategy)
510
+ send_result(client, id, 'status' => result[:output] == src ? 'ok' : 'fail',
511
+ 'changed' => result[:output] != src, 'changes' => result[:changes])
512
+ rescue StandardError => e
513
+ handle_request_error(client, id, e, file)
514
+ end
515
+
516
+ # @private
517
+ # @param [UNIXSocket] client
518
+ # @param [String, Integer] id
519
+ # @param [Hash<String, Object>] params
520
+ # @raise [StandardError]
521
+ # @return [void]
522
+ # @return [void] if StandardError
523
+ def handle_fix(client, id, params)
524
+ file = params['file']
525
+ strategy = (params['strategy'] || 'safe').to_sym
526
+ return send_error(client, id, -32_602, "File not found: #{file}") unless file && File.file?(file)
527
+
528
+ apply_cli_overrides(params['cli_overrides'])
529
+ src, result = rewrite_file(file, strategy)
530
+ changed = result[:output] != src
531
+ File.write(file, result[:output]) if changed
532
+ send_result(client, id, 'status' => 'ok', 'changed' => changed, 'changes' => result[:changes])
533
+ rescue StandardError => e
534
+ handle_request_error(client, id, e, file)
535
+ end
536
+
537
+ # @private
538
+ # @param [Object] client
539
+ # @param [Object] id
540
+ # @param [Object] params
541
+ # @return [void]
542
+ def handle_check_batch(client, id, params)
543
+ files = params['files']
544
+ return send_error(client, id, -32_602, 'Missing files parameter') unless files.is_a?(Array) && !files.empty?
545
+
546
+ strategy = (params['strategy'] || 'safe').to_sym
547
+ timeout = params['timeout']
548
+
549
+ apply_cli_overrides(params['cli_overrides'])
550
+
551
+ results = files.map do |file|
552
+ process_file_in_batch(file, strategy, timeout)
553
+ end
554
+
555
+ send_result(client, id, { 'results' => results })
556
+ end
557
+
558
+ # @private
559
+ # @param [String] file
560
+ # @param [Symbol] strategy
561
+ # @param [Integer, Float?] timeout
562
+ # @raise [Timeout::Error]
563
+ # @raise [StandardError]
564
+ # @return [Hash<String, Object>]
565
+ # @return [Hash] if Timeout::Error
566
+ # @return [Hash] if StandardError
567
+ def process_file_in_batch(file, strategy, timeout = nil)
568
+ return { 'file' => file, 'status' => 'error', 'error' => "File not found: #{file}" } unless File.file?(file)
569
+
570
+ block = -> { run_rewrite(file, strategy) }
571
+ timeout ? Timeout.timeout(timeout.to_f, &block) : block.call
572
+ rescue Timeout::Error
573
+ { 'file' => file, 'status' => 'error', 'error' => 'Timeout' }
574
+ rescue StandardError => e
575
+ { 'file' => file, 'status' => 'error', 'error' => "#{e.class}: #{e.message}" }
576
+ end
577
+
578
+ # @private
579
+ # @param [String] file
580
+ # @param [Symbol] strategy
581
+ # @return [Hash<String, Object>]
582
+ def run_rewrite(file, strategy)
583
+ src, result = rewrite_file(file, strategy)
584
+ { 'file' => file, 'status' => result[:output] == src ? 'ok' : 'fail', 'changes' => result[:changes] }
585
+ end
586
+
587
+ # @private
588
+ # @param [Hash<String, Object>?] overrides
589
+ # @return [void]
590
+ def apply_cli_overrides(overrides)
591
+ @config_mutex.synchronize do
592
+ return reset_effective_config_internal if overrides.nil? || overrides.empty?
593
+ return if @applied_overrides == overrides
594
+
595
+ build_effective_config(overrides)
596
+ end
597
+ end
598
+
599
+ # @private
600
+ # @param [Hash<String, Object>] overrides
601
+ # @return [void]
602
+ def build_effective_config(overrides)
603
+ config = @config or return
604
+ require 'docscribe/cli/config_builder'
605
+ opts = overrides.transform_keys(&:to_sym)
606
+ @effective_config = Docscribe::CLI::ConfigBuilder.build(config, opts)
607
+ @file_cache.clear
608
+ @applied_overrides = overrides
609
+ end
610
+
611
+ # @private
612
+ # @return [void]
613
+ def reset_effective_config
614
+ @config_mutex.synchronize { reset_effective_config_internal }
615
+ end
616
+
617
+ # @private
618
+ # @return [void]
619
+ def reset_effective_config_internal
620
+ return unless @effective_config
621
+
622
+ @effective_config = nil
623
+ @applied_overrides = nil
624
+ @file_cache.clear
625
+ end
626
+
627
+ # @private
628
+ # @param [String] file
629
+ # @param [Symbol] strategy
630
+ # @raise [StandardError]
631
+ # @return [(String, Hash<Symbol, Object>)]
632
+ def rewrite_file(file, strategy)
633
+ @cache_mutex.synchronize do
634
+ config = @effective_config || @config or raise 'Docscribe: config not loaded'
635
+ key = [file, strategy]
636
+ mtime = File.mtime(file)
637
+ hit = @file_cache[key]
638
+ return [hit[:src], hit[:result]] if hit && hit[:mtime] == mtime
639
+
640
+ rewrite_and_cache(file, strategy, config, key, mtime)
641
+ end
642
+ end
643
+
644
+ # @private
645
+ # @param [String] file
646
+ # @param [Symbol] strategy
647
+ # @param [Object] config
648
+ # @param [Object] key
649
+ # @param [Object] mtime
650
+ # @return [(String, Hash<Symbol, Object>)]
651
+ def rewrite_and_cache(file, strategy, config, key, mtime)
652
+ src = File.read(file)
653
+ rbs = config.respond_to?(:core_rbs_provider) ? config.core_rbs_provider : nil
654
+ result = Docscribe::InlineRewriter.rewrite_with_report(src, strategy: strategy, config: config, core_rbs_provider: rbs, file: file)
655
+ @file_cache[key] = { mtime: mtime, src: src, result: result }
656
+ [src, result]
657
+ end
658
+
659
+ # Handle a shutdown request.
660
+ #
661
+ # @private
662
+ # @param [UNIXSocket] client connected client socket
663
+ # @param [String, Integer] id request ID
664
+ # @return [void]
665
+ def handle_shutdown(client, id)
666
+ send_result(client, id, { 'status' => 'shutting_down' })
667
+ @running = false
668
+ end
669
+
670
+ # Handle a ping request.
671
+ #
672
+ # @private
673
+ # @param [UNIXSocket] client connected client socket
674
+ # @param [String, Integer] id request ID
675
+ # @return [void]
676
+ def handle_ping(client, id)
677
+ uptime = (Time.now - @started_at).to_i
678
+ send_result(client, id, {
679
+ 'version' => Docscribe::VERSION,
680
+ 'pid' => Process.pid,
681
+ 'socket_path' => @socket_path,
682
+ 'started_at' => @started_at.iso8601,
683
+ 'uptime' => uptime
684
+ })
685
+ end
686
+
687
+ # Send a JSON-RPC result response.
688
+ #
689
+ # @private
690
+ # @param [UNIXSocket] client connected client socket
691
+ # @param [String, Integer] id request ID
692
+ # @param [Object] result result data
693
+ # @return [void]
694
+ def send_result(client, id, result)
695
+ response = { jsonrpc: '2.0', id: id, result: result }
696
+ client.write(Protocol.serialize(response))
697
+ end
698
+
699
+ # @private
700
+ # @param [Exception] exception
701
+ # @param [String?] _method_name
702
+ # @param [Hash<String, Object>] params
703
+ # @return [(Integer, String, Object?)]
704
+ def classify_error(exception, _method_name = nil, params = {})
705
+ if exception.is_a?(LoadError) || exception.is_a?(Gem::LoadError)
706
+ classify_gem_error(exception)
707
+ elsif syntax_error?(exception)
708
+ classify_syntax_err(exception, params)
709
+ elsif timeout_error?(exception)
710
+ classify_timeout_err(exception, params)
711
+ else
712
+ classify_internal_err(exception)
713
+ end
714
+ end
715
+
716
+ # @private
717
+ # @param [Exception] exception
718
+ # @return [Boolean]
719
+ def syntax_error?(exception)
720
+ exception.is_a?(Docscribe::ParseError) ||
721
+ (defined?(Parser::SyntaxError) && exception.is_a?(Parser::SyntaxError))
722
+ end
723
+
724
+ # @private
725
+ # @param [Exception] exception
726
+ # @return [Boolean]
727
+ def timeout_error?(exception)
728
+ !!defined?(Timeout::Error) && exception.is_a?(Timeout::Error)
729
+ end
730
+
731
+ # @private
732
+ # @param [Object] exception
733
+ # @return [(Integer, String, Object)]
734
+ def classify_gem_error(exception)
735
+ data = { gem: nil }
736
+ data[:gem] = exception.path if exception.respond_to?(:path) && exception.path
737
+ [ERROR_CODES[:gem_not_found], "#{exception.class}: #{exception.message}", data]
738
+ end
739
+
740
+ # @private
741
+ # @param [Object] exception
742
+ # @param [Hash<String, Object>] params
743
+ # @return [(Integer, String, Object)]
744
+ def classify_syntax_err(exception, params)
745
+ file = (params['file'] if params.is_a?(Hash)).to_s
746
+ line = if exception.respond_to?(:line)
747
+ exception.line
748
+ elsif exception.respond_to?(:diagnostic)
749
+ exception.diagnostic.location.line
750
+ end
751
+ data = { file: file, detail: exception.message, line: line }.compact
752
+ [ERROR_CODES[:syntax_error], "Syntax error in #{file}", data]
753
+ end
754
+
755
+ # @private
756
+ # @param [Object] exception
757
+ # @param [Hash<String, Object>] params
758
+ # @return [(Integer, String, Object)]
759
+ def classify_timeout_err(exception, params)
760
+ file = (params['file'] if params.is_a?(Hash)).to_s
761
+ data = { timeout: @idle_timeout || 30, file: file }
762
+ [ERROR_CODES[:timeout], "#{exception.class}: #{exception.message}", data]
763
+ end
764
+
765
+ # @private
766
+ # @param [Object] exception
767
+ # @return [(Integer, String, Object)]
768
+ def classify_internal_err(exception)
769
+ backtrace = exception.backtrace&.first(5) || []
770
+ data = { backtrace: backtrace }
771
+ [ERROR_CODES[:internal], "#{exception.class}: #{exception.message}", data]
772
+ end
773
+
774
+ # @private
775
+ # @param [UNIXSocket] client
776
+ # @param [String, Integer] id
777
+ # @param [Object] exception
778
+ # @param [String] file
779
+ # @raise [StandardError]
780
+ # @return [void]
781
+ def handle_request_error(client, id, exception, file)
782
+ if exception.is_a?(Docscribe::ParseError) ||
783
+ (defined?(Parser::SyntaxError) && exception.is_a?(Parser::SyntaxError))
784
+ send_syntax_error(client, id, exception, file)
785
+ else
786
+ raise
787
+ end
788
+ end
789
+
790
+ # @private
791
+ # @param [UNIXSocket] client
792
+ # @param [String, Integer] id
793
+ # @param [Object] exception
794
+ # @param [String] file
795
+ # @return [void]
796
+ def send_syntax_error(client, id, exception, file)
797
+ line = if exception.respond_to?(:line)
798
+ exception.line
799
+ elsif exception.respond_to?(:diagnostic)
800
+ exception.diagnostic.location.line
801
+ end
802
+ data = { file: file, detail: exception.message, line: line }.compact
803
+ send_error(client, id, ERROR_CODES[:syntax_error], "Syntax error in #{file}", data)
804
+ end
805
+
806
+ # @private
807
+ # @param [UNIXSocket] client
808
+ # @param [String, Integer, nil] id
809
+ # @param [Integer] code
810
+ # @param [String] message
811
+ # @param [Object?] data optional structured error data
812
+ # @return [void]
813
+ def send_error(client, id, code, message, data = nil)
814
+ error = { code: code, message: message }
815
+ error[:data] = data if data
816
+ response = { jsonrpc: '2.0', id: id, error: error }
817
+ client.write(Protocol.serialize(response))
818
+ end
819
+
820
+ # Cleanup socket and PID files on shutdown.
821
+ #
822
+ # @private
823
+ # @raise [StandardError]
824
+ # @return [void]
825
+ # @return [nil] if StandardError
826
+ def cleanup
827
+ @server&.close
828
+ File.unlink(@socket_path) if @socket_path && File.exist?(@socket_path)
829
+ pid_path = "#{@socket_path}.pid"
830
+ FileUtils.rm_f(pid_path)
831
+ rescue StandardError
832
+ nil
833
+ end
834
+ end
835
+ end
836
+ end