docscribe 1.6.0 → 1.6.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -193
  3. data/exe/docscribe-client +26 -7
  4. data/lib/docscribe/cli/config_builder.rb +37 -2
  5. data/lib/docscribe/cli/coverage.rb +5 -5
  6. data/lib/docscribe/cli/formatters/json.rb +74 -29
  7. data/lib/docscribe/cli/formatters/sarif.rb +20 -3
  8. data/lib/docscribe/cli/options.rb +17 -2
  9. data/lib/docscribe/cli/rbs_gen.rb +4 -4
  10. data/lib/docscribe/cli/run.rb +107 -24
  11. data/lib/docscribe/cli/update_types.rb +61 -17
  12. data/lib/docscribe/cli.rb +19 -13
  13. data/lib/docscribe/config/defaults.rb +1 -0
  14. data/lib/docscribe/config/rbs.rb +22 -1
  15. data/lib/docscribe/config/template.rb +3 -0
  16. data/lib/docscribe/config/validation.rb +19 -0
  17. data/lib/docscribe/config.rb +1 -0
  18. data/lib/docscribe/infer/behavior.rb +13 -13
  19. data/lib/docscribe/infer/params.rb +2 -2
  20. data/lib/docscribe/infer/raises.rb +5 -6
  21. data/lib/docscribe/infer/returns.rb +1612 -151
  22. data/lib/docscribe/infer.rb +7 -7
  23. data/lib/docscribe/inline_rewriter/doc_builder.rb +485 -102
  24. data/lib/docscribe/inline_rewriter.rb +263 -97
  25. data/lib/docscribe/plugin/registry.rb +1 -0
  26. data/lib/docscribe/server/base.rb +255 -0
  27. data/lib/docscribe/server/client.rb +95 -0
  28. data/lib/docscribe/server/daemon.rb +678 -0
  29. data/lib/docscribe/server/protocol.rb +50 -0
  30. data/lib/docscribe/server.rb +4 -835
  31. data/lib/docscribe/types/primitive.rb +160 -0
  32. data/lib/docscribe/types/sorbet/base_provider.rb +33 -1
  33. data/lib/docscribe/types/yard/formatter.rb +35 -6
  34. data/lib/docscribe/types/yard/parser.rb +25 -20
  35. data/lib/docscribe/types/yard/validator.rb +131 -0
  36. data/lib/docscribe/validator/generic_compatibility.rb +698 -0
  37. data/lib/docscribe/validator/type_mismatch_validator.rb +287 -0
  38. data/lib/docscribe/version.rb +1 -1
  39. metadata +12 -3
@@ -0,0 +1,678 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'fileutils'
5
+ require 'time'
6
+ require 'timeout'
7
+ require 'digest/md5'
8
+ require_relative '../lru_cache'
9
+
10
+ module Docscribe
11
+ module Server
12
+ # Daemon process that loads the Ruby runtime once and serves requests.
13
+ class Daemon
14
+ # Standardized JSON-RPC error codes.
15
+ ERROR_CODES = {
16
+ gem_not_found: -32_000,
17
+ syntax_error: -32_001,
18
+ config_load_failure: -32_002,
19
+ timeout: -32_010,
20
+ internal: -32_099
21
+ }.freeze
22
+
23
+ REQUEST_HANDLERS = {
24
+ 'check' => :handle_check,
25
+ 'fix' => :handle_fix,
26
+ 'check_batch' => :handle_check_batch,
27
+ 'update_types' => :handle_update_types
28
+ }.freeze
29
+
30
+ # @param [String?] socket_path custom socket path
31
+ # @param [Integer] idle_timeout seconds before automatic shutdown
32
+ # @param [String?] config_path custom config path
33
+ # @return [void]
34
+ def initialize(socket_path: nil, idle_timeout: IDLE_TIMEOUT, config_path: nil)
35
+ setup_socket_config(socket_path, config_path, idle_timeout)
36
+ setup_runtime_state
37
+ setup_caches
38
+ end
39
+
40
+ # @param [String?] socket_path
41
+ # @param [String?] config_path
42
+ # @param [Integer] idle_timeout
43
+ # @return [void]
44
+ def setup_socket_config(socket_path, config_path, idle_timeout)
45
+ @socket_path = socket_path || Server.socket_path(config_path)
46
+ @config_path = config_path
47
+ @idle_timeout = idle_timeout
48
+ end
49
+
50
+ # @return [void]
51
+ def setup_runtime_state
52
+ @last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
53
+ @running = false
54
+ @server = nil
55
+ @started_at = Time.now
56
+ @last_sig_hash = nil
57
+ end
58
+
59
+ # @return [void]
60
+ def setup_caches
61
+ @file_cache = LRUCache.new
62
+ @cache_mutex = Mutex.new
63
+ @config_mutex = Mutex.new
64
+ end
65
+
66
+ # Start the daemon: load dependencies, bind socket, enter listen loop.
67
+ #
68
+ # @return [void]
69
+ def start
70
+ load_dependencies
71
+ setup_socket
72
+ @running = true
73
+ $PROGRAM_NAME = "docscribe server (#{Dir.pwd})"
74
+ write_pid
75
+ listen_loop
76
+ end
77
+
78
+ private
79
+
80
+ # Load the full Docscribe runtime and build cached config.
81
+ #
82
+ # @private
83
+ # @return [void]
84
+ def load_dependencies
85
+ require 'docscribe'
86
+ @config = Docscribe::Config.load(@config_path)
87
+ @config&.load_plugins!
88
+ @core_rbs_provider = @config&.core_rbs_provider
89
+ end
90
+
91
+ # Create and bind the Unix domain socket.
92
+ #
93
+ # @private
94
+ # @return [void]
95
+ def setup_socket
96
+ FileUtils.rm_f(@socket_path)
97
+ FileUtils.mkdir_p(File.dirname(@socket_path))
98
+ @server = UNIXServer.new(@socket_path)
99
+ File.chmod(0o600, @socket_path)
100
+ end
101
+
102
+ # @private
103
+ # @return [void]
104
+ def write_pid
105
+ File.write("#{@socket_path}.pid", Process.pid)
106
+ end
107
+
108
+ # Main accept loop with idle timeout check.
109
+ #
110
+ # @private
111
+ # @raise [Interrupt]
112
+ # @return [void]
113
+ def listen_loop
114
+ while @running
115
+ check_idle_timeout
116
+ accept_client
117
+ end
118
+ rescue Interrupt
119
+ @running = false
120
+ ensure
121
+ cleanup
122
+ end
123
+
124
+ # Check whether the idle timeout has been exceeded.
125
+ #
126
+ # @private
127
+ # @return [void]
128
+ def check_idle_timeout
129
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_request_time
130
+ @running = false if elapsed > @idle_timeout
131
+ end
132
+
133
+ # Accept a client connection if one is available.
134
+ # Spawns a thread to handle each client concurrently.
135
+ #
136
+ # @private
137
+ # @return [void]
138
+ def accept_client
139
+ client = @server&.accept if @server&.wait_readable(0.1)
140
+ return unless client
141
+
142
+ @last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
143
+ Thread.new(client) { |conn| handle_client(conn) }
144
+ end
145
+
146
+ # Read a request from a client connection and dispatch it.
147
+ #
148
+ # @private
149
+ # @param [UNIXSocket] client connected client socket
150
+ # @raise [StandardError]
151
+ # @return [void]
152
+ def handle_client(client)
153
+ request_line = client.gets or return
154
+ request = Protocol.parse_response(request_line)
155
+ request ? handle_request(client, request) : send_error(client, nil, -32_700, 'Parse error')
156
+ rescue StandardError => e
157
+ method_name = request&.dig('method')
158
+ error_params = request&.dig('params') || {}
159
+ code, message, data = classify_error(e, method_name, error_params)
160
+ send_error(client, request&.dig('id'), code, message, data)
161
+ ensure
162
+ client.close
163
+ end
164
+
165
+ # Dispatch a parsed request to the appropriate handler.
166
+ #
167
+ # @private
168
+ # @param [UNIXSocket] client connected client socket
169
+ # @param [Hash<String, Object>] request parsed JSON-RPC request
170
+ # @return [void]
171
+ def handle_request(client, request)
172
+ method = request['method']
173
+ params = request['params'] || {}
174
+ dispatch_request(client, request, method, params)
175
+ end
176
+
177
+ # @private
178
+ # @param [UNIXSocket] client
179
+ # @param [Hash<String, Object>] request
180
+ # @param [String] method
181
+ # @param [Hash<String, Object>] params
182
+ # @return [void]
183
+ def dispatch_request(client, request, method, params)
184
+ handler = REQUEST_HANDLERS[method]
185
+ return send(handler, client, request['id'], params) if handler
186
+
187
+ dispatch_control_request(client, request, method)
188
+ end
189
+
190
+ # @private
191
+ # @param [UNIXSocket] client
192
+ # @param [Hash<String, Object>] request
193
+ # @param [String] method
194
+ # @return [void]
195
+ def dispatch_control_request(client, request, method)
196
+ case method
197
+ when 'shutdown' then handle_shutdown(client, request['id'])
198
+ when 'ping' then handle_ping(client, request['id'])
199
+ else send_error(client, request['id'], -32_601, "Unknown method: #{method}")
200
+ end
201
+ end
202
+
203
+ # @private
204
+ # @param [UNIXSocket] client
205
+ # @param [String, Integer] id
206
+ # @param [Hash<String, Object>] params
207
+ # @raise [StandardError]
208
+ # @return [void]
209
+ # @return [void] if StandardError
210
+ def handle_check(client, id, params)
211
+ file = params['file']
212
+ strategy = (params['strategy'] || 'safe').to_sym
213
+ return send_error(client, id, -32_602, "File not found: #{file}") unless file && File.file?(file)
214
+
215
+ apply_cli_overrides(params['cli_overrides'])
216
+ src, result = rewrite_file(file, strategy)
217
+ send_result(client, id, 'status' => result[:output] == src ? 'ok' : 'fail',
218
+ 'changed' => result[:output] != src, 'changes' => result[:changes])
219
+ rescue StandardError => e
220
+ handle_request_error(client, id, e, file)
221
+ end
222
+
223
+ # @private
224
+ # @param [UNIXSocket] client
225
+ # @param [String, Integer] id
226
+ # @param [Hash<String, Object>] params
227
+ # @raise [StandardError]
228
+ # @return [void]
229
+ # @return [void] if StandardError
230
+ def handle_fix(client, id, params)
231
+ file = params['file']
232
+ strategy = (params['strategy'] || 'safe').to_sym
233
+ return send_error(client, id, -32_602, "File not found: #{file}") unless file && File.file?(file)
234
+
235
+ apply_cli_overrides(params['cli_overrides'])
236
+ src, result = rewrite_file(file, strategy)
237
+ changed = result[:output] != src
238
+ File.write(file, result[:output]) if changed
239
+ send_result(client, id, 'status' => 'ok', 'changed' => changed, 'changes' => result[:changes])
240
+ rescue StandardError => e
241
+ handle_request_error(client, id, e, file)
242
+ end
243
+
244
+ # @private
245
+ # @param [UNIXSocket] client
246
+ # @param [String, Integer] id
247
+ # @param [Hash<String, Object>] params
248
+ # @return [void]
249
+ def handle_check_batch(client, id, params)
250
+ files = params['files']
251
+ return send_error(client, id, -32_602, 'Missing files parameter') unless files.is_a?(Array) && !files.empty?
252
+
253
+ strategy = (params['strategy'] || 'safe').to_sym
254
+ timeout = params['timeout']
255
+
256
+ apply_cli_overrides(params['cli_overrides'])
257
+
258
+ results = files.map do |file|
259
+ process_file_in_batch(file, strategy, timeout)
260
+ end
261
+
262
+ send_result(client, id, { 'results' => results })
263
+ end
264
+
265
+ # @private
266
+ # @param [String] file
267
+ # @param [Symbol] strategy
268
+ # @param [Integer, Float?] timeout
269
+ # @raise [Timeout::Error]
270
+ # @raise [StandardError]
271
+ # @return [Hash<String, Object>]
272
+ # @return [Hash] if Timeout::Error
273
+ # @return [Hash] if StandardError
274
+ def process_file_in_batch(file, strategy, timeout = nil)
275
+ return { 'file' => file, 'status' => 'error', 'error' => "File not found: #{file}" } unless File.file?(file)
276
+
277
+ if timeout
278
+ Timeout.timeout(timeout.to_f) { run_rewrite(file, strategy) }
279
+ else
280
+ run_rewrite(file, strategy)
281
+ end
282
+ rescue Timeout::Error
283
+ { 'file' => file, 'status' => 'error', 'error' => 'Timeout' }
284
+ rescue StandardError => e
285
+ { 'file' => file, 'status' => 'error', 'error' => "#{e.class}: #{e.message}" }
286
+ end
287
+
288
+ # @private
289
+ # @param [String] file
290
+ # @param [Symbol] strategy
291
+ # @return [Hash<String, Object>]
292
+ def run_rewrite(file, strategy)
293
+ src, result = rewrite_file(file, strategy)
294
+ changes = result[:changes].map { |c| c.transform_keys(&:to_s) }
295
+ { 'file' => file, 'status' => result[:output] == src ? 'ok' : 'fail', 'changes' => changes }
296
+ end
297
+
298
+ # @private
299
+ # @param [Hash<String, Object>?] overrides
300
+ # @return [void]
301
+ def apply_cli_overrides(overrides) # rubocop:disable SortedMethodsByCall/Waterfall
302
+ @config_mutex.synchronize do
303
+ return reset_effective_config_internal if overrides.nil? || overrides.empty?
304
+ return if @applied_overrides == overrides
305
+
306
+ build_effective_config(overrides)
307
+ end
308
+ end
309
+
310
+ # @private
311
+ # @param [Hash<String, Object>] overrides
312
+ # @return [void]
313
+ def build_effective_config(overrides)
314
+ config = @config or return
315
+ require 'docscribe/cli/config_builder'
316
+ require 'docscribe/cli/options'
317
+ opts = Docscribe::CLI::Options::DEFAULT.merge(overrides.transform_keys(&:to_sym))
318
+ @effective_config = Docscribe::CLI::ConfigBuilder.build(config, opts)
319
+ @file_cache.clear
320
+ @applied_overrides = overrides
321
+ end
322
+
323
+ # @private
324
+ # @return [void]
325
+ def reset_effective_config
326
+ @config_mutex.synchronize { reset_effective_config_internal }
327
+ end
328
+
329
+ # @private
330
+ # @return [void]
331
+ def reset_effective_config_internal
332
+ return unless @effective_config
333
+
334
+ @effective_config = nil
335
+ @applied_overrides = nil
336
+ @file_cache.clear
337
+ end
338
+
339
+ # @private
340
+ # @param [String] file
341
+ # @param [Symbol] strategy
342
+ # @raise [StandardError]
343
+ # @return [(String, Hash<Symbol, String, Array<Hash<Symbol, Object>>>)]
344
+ def rewrite_file(file, strategy)
345
+ @cache_mutex.synchronize do
346
+ config = @effective_config || @config or raise 'Docscribe: config not loaded'
347
+ key = [file, strategy]
348
+ mtime = File.mtime(file)
349
+ sig_hash = sig_hash_for(config)
350
+ handle_sig_change(sig_hash)
351
+ cached = cached_result(key, mtime, sig_hash)
352
+ return cached if cached
353
+
354
+ rewrite_and_cache(file, strategy, config, key, mtime)
355
+ end
356
+ end
357
+
358
+ # @private
359
+ # @param [String] sig_hash
360
+ # @return [void]
361
+ def handle_sig_change(sig_hash)
362
+ if @last_sig_hash && @last_sig_hash != sig_hash
363
+ [@config, @effective_config].compact.each { |c| clear_rbs_cache(c) }
364
+ @file_cache.clear
365
+ end
366
+ @last_sig_hash = sig_hash
367
+ end
368
+
369
+ # @private
370
+ # @param [Array<String, Symbol>] key
371
+ # @param [Time] mtime
372
+ # @param [String] sig_hash
373
+ # @return [(String, Hash<Symbol, String, Array<Hash<Symbol, Object>>>)?]
374
+ def cached_result(key, mtime, sig_hash)
375
+ hit = @file_cache[key]
376
+ return nil unless hit && hit[:mtime] == mtime && hit[:sig_hash] == sig_hash
377
+
378
+ [hit[:src], hit[:result]]
379
+ end
380
+
381
+ # Clear memoized RBS providers so next request rebuilds env with fresh sig files.
382
+ #
383
+ # @private
384
+ # @param [Docscribe::Config] config
385
+ # @raise [StandardError]
386
+ # @return [void]
387
+ # @return [nil] if StandardError
388
+ def clear_rbs_cache(config)
389
+ config.instance_variable_set(:@rbs_provider, nil) if config.instance_variable_defined?(:@rbs_provider)
390
+ config.instance_variable_set(:@core_rbs_provider, nil) if config.instance_variable_defined?(:@core_rbs_provider)
391
+ rescue StandardError
392
+ nil
393
+ end
394
+
395
+ # @private
396
+ # @param [String] file
397
+ # @param [Symbol] strategy
398
+ # @param [Docscribe::Config] config effective or base config
399
+ # @param [Array<String, Symbol>] key cache key
400
+ # @param [Time] mtime file modification time
401
+ # @return [(String, Hash<Symbol, String, Array<Hash<Symbol, Object>>>)]
402
+ def rewrite_and_cache(file, strategy, config, key, mtime)
403
+ src = File.read(file)
404
+ rbs = config.respond_to?(:core_rbs_provider) ? config.core_rbs_provider : nil
405
+ result = Docscribe::InlineRewriter.rewrite_with_report(src, strategy: strategy, config: config,
406
+ core_rbs_provider: rbs, file: file)
407
+ @file_cache[key] = { mtime: mtime, sig_hash: @last_sig_hash, src: src, result: result }
408
+ [src, result]
409
+ end
410
+
411
+ # Hash of RBS signature files for cache invalidation.
412
+ # Includes all files under sig_dirs (default: sig/**/*.rbs).
413
+ #
414
+ # @private
415
+ # @param [Docscribe::Config] config effective or base config
416
+ # @raise [StandardError]
417
+ # @return [String]
418
+ # @return [String] if StandardError
419
+ def sig_hash_for(config)
420
+ files = sig_rbs_files(sig_dirs_for(config))
421
+ parts = files.map { |p| "#{p}:#{File.mtime(p).to_f}" if File.file?(p) }.compact
422
+ parts << "count:#{files.size}"
423
+ Digest::MD5.hexdigest(parts.join('|'))
424
+ rescue StandardError
425
+ '0'
426
+ end
427
+
428
+ # @private
429
+ # @param [Array<String>] dirs
430
+ # @return [Array<String>]
431
+ def sig_rbs_files(dirs)
432
+ dirs.flat_map { |dir| Dir.glob(File.join(Dir.pwd, dir, '**', '*.rbs')) }.uniq.sort
433
+ end
434
+
435
+ # Resolve sig dirs from config, falling back to defaults.
436
+ #
437
+ # @private
438
+ # @param [Docscribe::Config] config
439
+ # @raise [StandardError]
440
+ # @return [Array<String>]
441
+ # @return [Array] if StandardError
442
+ def sig_dirs_for(config)
443
+ raw_dirs = config.raw.dig('rbs', 'sig_dirs') if config.respond_to?(:raw)
444
+ dirs = Array(raw_dirs || Docscribe::Config::DEFAULT.dig('rbs', 'sig_dirs')).map(&:to_s) # steep:ignore
445
+ dirs.empty? ? ['sig'] : dirs
446
+ rescue StandardError
447
+ ['sig']
448
+ end
449
+
450
+ # Handle update_types request (used by RubyMine plugin).
451
+ #
452
+ # @private
453
+ # @param [UNIXSocket] client connected client socket
454
+ # @param [String, Integer] id request ID
455
+ # @param [Hash<String, Object>] params request params
456
+ # @raise [StandardError]
457
+ # @return [void]
458
+ # @return [void] if StandardError
459
+ def handle_update_types(client, id, params)
460
+ target = update_types_dir(params)
461
+ apply_cli_overrides(params['cli_overrides'])
462
+ exit_code = run_update_types(target)
463
+ @file_cache.clear
464
+ send_update_types_response(client, id, target, exit_code)
465
+ rescue StandardError => e
466
+ @file_cache.clear
467
+ code, message, data = classify_error(e, 'update_types', params)
468
+ send_error(client, id, code, message, data)
469
+ end
470
+
471
+ # @private
472
+ # @param [Hash<String, Object>] params
473
+ # @return [String]
474
+ def update_types_dir(params)
475
+ params['file'] || params['dir'] || params['directory'] || '.'
476
+ end
477
+
478
+ # @private
479
+ # @param [String] target
480
+ # @return [Integer]
481
+ def run_update_types(target)
482
+ require 'docscribe/cli/update_types'
483
+ Docscribe::CLI::UpdateTypes.run([target])
484
+ end
485
+
486
+ # @private
487
+ # @param [UNIXSocket] client
488
+ # @param [String, Integer] id
489
+ # @param [String] dir
490
+ # @param [Integer] exit_code
491
+ # @return [void]
492
+ def send_update_types_response(client, id, dir, exit_code)
493
+ if exit_code.zero?
494
+ send_result(client, id, 'status' => 'ok', 'dir' => dir, 'exit_code' => exit_code)
495
+ else
496
+ send_error(client, id, ERROR_CODES[:internal], "update_types failed with exit code #{exit_code}",
497
+ { 'dir' => dir, 'exit_code' => exit_code })
498
+ end
499
+ end
500
+
501
+ # Handle a shutdown request.
502
+ #
503
+ # @private
504
+ # @param [UNIXSocket] client connected client socket
505
+ # @param [String, Integer] id request ID
506
+ # @return [void]
507
+ def handle_shutdown(client, id)
508
+ send_result(client, id, { 'status' => 'shutting_down' })
509
+ @running = false
510
+ end
511
+
512
+ # Handle a ping request.
513
+ #
514
+ # @private
515
+ # @param [UNIXSocket] client connected client socket
516
+ # @param [String, Integer] id request ID
517
+ # @return [void]
518
+ def handle_ping(client, id)
519
+ uptime = (Time.now - @started_at).to_i
520
+ send_result(client, id, {
521
+ 'version' => Docscribe::VERSION,
522
+ 'pid' => Process.pid,
523
+ 'socket_path' => @socket_path,
524
+ 'started_at' => @started_at.iso8601,
525
+ 'uptime' => uptime
526
+ })
527
+ end
528
+
529
+ # Send a JSON-RPC result response.
530
+ #
531
+ # @private
532
+ # @param [UNIXSocket] client connected client socket
533
+ # @param [String, Integer] id request ID
534
+ # @param [Hash<String, Object>] result result data
535
+ # @return [void]
536
+ def send_result(client, id, result)
537
+ response = { jsonrpc: '2.0', id: id, result: result }
538
+ client.write(Protocol.serialize(response))
539
+ end
540
+
541
+ # @private
542
+ # @param [Exception] exception
543
+ # @param [String?] _method_name
544
+ # @param [Hash<String, Object>] params
545
+ # @return [(Integer, String, Hash<Symbol, Object, nil>, nil)]
546
+ def classify_error(exception, _method_name = nil, params = {})
547
+ if exception.is_a?(LoadError) || exception.is_a?(Gem::LoadError)
548
+ classify_gem_error(exception)
549
+ elsif syntax_error?(exception)
550
+ classify_syntax_err(exception, params)
551
+ elsif timeout_error?(exception)
552
+ classify_timeout_err(exception, params)
553
+ else
554
+ classify_internal_err(exception)
555
+ end
556
+ end
557
+
558
+ # @private
559
+ # @param [Exception] exception
560
+ # @return [Boolean]
561
+ def syntax_error?(exception)
562
+ exception.is_a?(Docscribe::ParseError) ||
563
+ (defined?(Parser::SyntaxError) && exception.is_a?(Parser::SyntaxError))
564
+ end
565
+
566
+ # @private
567
+ # @param [Exception] exception
568
+ # @return [Boolean]
569
+ def timeout_error?(exception)
570
+ !!defined?(Timeout::Error) && exception.is_a?(Timeout::Error)
571
+ end
572
+
573
+ # @private
574
+ # @param [Exception] exception
575
+ # @return [(Integer, String, Hash<Symbol, String, nil>)]
576
+ def classify_gem_error(exception)
577
+ data = { gem: nil }
578
+ data[:gem] = exception.path if exception.respond_to?(:path) && exception.path
579
+ [ERROR_CODES[:gem_not_found], "#{exception.class}: #{exception.message}", data]
580
+ end
581
+
582
+ # @private
583
+ # @param [Exception] exception
584
+ # @param [Hash<String, Object>] params
585
+ # @return [(Integer, String, Hash<Symbol, Object>)]
586
+ def classify_syntax_err(exception, params)
587
+ file = (params['file'] if params.is_a?(Hash)).to_s
588
+ line = if exception.respond_to?(:line)
589
+ exception.line
590
+ elsif exception.respond_to?(:diagnostic)
591
+ exception.diagnostic.location.line
592
+ end
593
+ data = { file: file, detail: exception.message, line: line }.compact
594
+ [ERROR_CODES[:syntax_error], "Syntax error in #{file}", data]
595
+ end
596
+
597
+ # @private
598
+ # @param [Exception] exception
599
+ # @param [Hash<String, Object>] params
600
+ # @return [(Integer, String, Hash<Symbol, Object>)]
601
+ def classify_timeout_err(exception, params)
602
+ file = (params['file'] if params.is_a?(Hash)).to_s
603
+ data = { timeout: @idle_timeout || 30, file: file }
604
+ [ERROR_CODES[:timeout], "#{exception.class}: #{exception.message}", data]
605
+ end
606
+
607
+ # @private
608
+ # @param [Exception] exception
609
+ # @return [(Integer, String, Hash<Symbol, Object>)]
610
+ def classify_internal_err(exception)
611
+ backtrace = exception.backtrace&.first(5) || []
612
+ data = { backtrace: backtrace }
613
+ [ERROR_CODES[:internal], "#{exception.class}: #{exception.message}", data]
614
+ end
615
+
616
+ # @private
617
+ # @param [UNIXSocket] client
618
+ # @param [String, Integer] id
619
+ # @param [Exception] exception
620
+ # @param [String] file
621
+ # @raise [StandardError]
622
+ # @return [void]
623
+ def handle_request_error(client, id, exception, file)
624
+ if exception.is_a?(Docscribe::ParseError) ||
625
+ (defined?(Parser::SyntaxError) && exception.is_a?(Parser::SyntaxError))
626
+ send_syntax_error(client, id, exception, file)
627
+ else
628
+ raise
629
+ end
630
+ end
631
+
632
+ # @private
633
+ # @param [UNIXSocket] client
634
+ # @param [String, Integer] id
635
+ # @param [Exception] exception
636
+ # @param [String] file
637
+ # @return [void]
638
+ def send_syntax_error(client, id, exception, file)
639
+ line = if exception.respond_to?(:line)
640
+ exception.line
641
+ elsif exception.respond_to?(:diagnostic)
642
+ exception.diagnostic.location.line
643
+ end
644
+ data = { file: file, detail: exception.message, line: line }.compact
645
+ send_error(client, id, ERROR_CODES[:syntax_error], "Syntax error in #{file}", data)
646
+ end
647
+
648
+ # @private
649
+ # @param [UNIXSocket] client
650
+ # @param [String, Integer, nil] id
651
+ # @param [Integer] code
652
+ # @param [String] message
653
+ # @param [Hash<Symbol, Object>?] data optional structured error data
654
+ # @return [void]
655
+ def send_error(client, id, code, message, data = nil)
656
+ error = { code: code, message: message }
657
+ error[:data] = data if data
658
+ response = { jsonrpc: '2.0', id: id, error: error }
659
+ client.write(Protocol.serialize(response))
660
+ end
661
+
662
+ # Cleanup socket and PID files on shutdown.
663
+ #
664
+ # @private
665
+ # @raise [StandardError]
666
+ # @return [void]
667
+ # @return [nil] if StandardError
668
+ def cleanup
669
+ @server&.close
670
+ File.unlink(@socket_path) if @socket_path && File.exist?(@socket_path)
671
+ pid_path = "#{@socket_path}.pid"
672
+ FileUtils.rm_f(pid_path)
673
+ rescue StandardError
674
+ nil
675
+ end
676
+ end
677
+ end
678
+ end