docscribe 1.5.2 → 1.6.1

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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+
5
+ module Docscribe
6
+ module Server
7
+ # Client for communicating with a running Docscribe daemon.
8
+ class Client
9
+ # @param [String, nil] socket_path custom socket path (defaults to server default)
10
+ # @param [String, nil] config_path optional config path for socket lookup
11
+ # @return [void]
12
+ def initialize(socket_path = nil, config_path: nil)
13
+ @socket_path = socket_path || Server.socket_path(config_path)
14
+ end
15
+
16
+ # Send a check request to the server.
17
+ #
18
+ # @param [String] file path to file to check
19
+ # @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
20
+ # @param [Hash<Symbol, Object>] rest extra JSON-RPC params (e.g. cli_overrides)
21
+ # @return [Hash<String, Object>, nil] response hash or nil if server unreachable
22
+ def check(file:, strategy: :safe, **rest)
23
+ request('check', file: file, strategy: strategy, **rest)
24
+ end
25
+
26
+ # Send a fix request to the server.
27
+ #
28
+ # @param [String] file path to file to fix
29
+ # @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
30
+ # @param [Hash<Symbol, Object>] rest extra JSON-RPC params (e.g. cli_overrides)
31
+ # @return [Hash<String, Object>, nil] response hash or nil if server unreachable
32
+ def fix(file:, strategy: :safe, **rest)
33
+ request('fix', file: file, strategy: strategy, **rest)
34
+ end
35
+
36
+ # Send a shutdown request to the server.
37
+ #
38
+ # @return [Hash<String, Object>, nil] response hash or nil if server unreachable
39
+ def shutdown
40
+ request('shutdown')
41
+ end
42
+
43
+ # Ping the server and get version/pid/uptime info.
44
+ #
45
+ # @return [Hash<String, Object>, nil] response hash or nil if server unreachable
46
+ def ping
47
+ request('ping')
48
+ end
49
+
50
+ private
51
+
52
+ # Send a JSON-RPC request and read the response.
53
+ #
54
+ # @private
55
+ # @param [String] method method name
56
+ # @param [Hash<Symbol, Object>] params request parameters
57
+ # @return [Hash<String, Object>, nil]
58
+ def request(method, **params)
59
+ connect do |socket|
60
+ req = Protocol.build_request(method, params)
61
+ socket.write(Protocol.serialize(req))
62
+ socket.close_write
63
+ line = socket.gets
64
+ break unless line
65
+
66
+ Protocol.parse_response(line)
67
+ end
68
+ end
69
+
70
+ # Connect to the Unix socket and yield the connection.
71
+ #
72
+ # @private
73
+ # @raise [Errno::ECONNREFUSED]
74
+ # @raise [Errno::ENOENT]
75
+ # @return [T, nil] yield return value or nil on connection error
76
+ def connect
77
+ socket = UNIXSocket.new(@socket_path)
78
+ yield socket
79
+ rescue Errno::ECONNREFUSED, Errno::ENOENT
80
+ nil
81
+ ensure
82
+ socket&.close
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,501 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'fileutils'
5
+ require 'time'
6
+ require 'timeout'
7
+ require_relative '../lru_cache'
8
+
9
+ module Docscribe
10
+ module Server
11
+ # Daemon process that loads the Ruby runtime once and serves requests.
12
+ class Daemon
13
+ # Standardized JSON-RPC error codes.
14
+ ERROR_CODES = {
15
+ gem_not_found: -32_000,
16
+ syntax_error: -32_001,
17
+ config_load_failure: -32_002,
18
+ timeout: -32_010,
19
+ internal: -32_099
20
+ }.freeze
21
+ # @param [String, nil] socket_path custom socket path
22
+ # @param [Integer] idle_timeout seconds before automatic shutdown
23
+ # @param [String, nil] config_path custom config path
24
+ # @return [void]
25
+ def initialize(socket_path: nil, idle_timeout: IDLE_TIMEOUT, config_path: nil)
26
+ @socket_path = socket_path || Server.socket_path(config_path)
27
+ @idle_timeout = idle_timeout
28
+ @config_path = config_path
29
+ @last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
30
+ @running = false
31
+ @server = nil
32
+ @file_cache = LRUCache.new
33
+ @started_at = Time.now
34
+ @cache_mutex = Mutex.new
35
+ @config_mutex = Mutex.new
36
+ end
37
+
38
+ # Start the daemon: load dependencies, bind socket, enter listen loop.
39
+ #
40
+ # @return [void]
41
+ def start
42
+ load_dependencies
43
+ setup_socket
44
+ @running = true
45
+ $PROGRAM_NAME = "docscribe server (#{Dir.pwd})"
46
+ write_pid
47
+ listen_loop
48
+ end
49
+
50
+ private
51
+
52
+ # Load the full Docscribe runtime and build cached config.
53
+ #
54
+ # @private
55
+ # @return [void]
56
+ def load_dependencies
57
+ require 'docscribe'
58
+ @config = Docscribe::Config.load(@config_path)
59
+ @config&.load_plugins!
60
+ @core_rbs_provider = @config&.core_rbs_provider
61
+ end
62
+
63
+ # Create and bind the Unix domain socket.
64
+ #
65
+ # @private
66
+ # @return [void]
67
+ def setup_socket
68
+ FileUtils.rm_f(@socket_path)
69
+ FileUtils.mkdir_p(File.dirname(@socket_path))
70
+ @server = UNIXServer.new(@socket_path)
71
+ File.chmod(0o600, @socket_path)
72
+ end
73
+
74
+ # @private
75
+ # @return [void]
76
+ def write_pid
77
+ File.write("#{@socket_path}.pid", Process.pid)
78
+ end
79
+
80
+ # Main accept loop with idle timeout check.
81
+ #
82
+ # @private
83
+ # @raise [Interrupt]
84
+ # @return [void]
85
+ def listen_loop
86
+ while @running
87
+ check_idle_timeout
88
+ accept_client
89
+ end
90
+ rescue Interrupt
91
+ @running = false
92
+ ensure
93
+ cleanup
94
+ end
95
+
96
+ # Check whether the idle timeout has been exceeded.
97
+ #
98
+ # @private
99
+ # @return [void]
100
+ def check_idle_timeout
101
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_request_time
102
+ @running = false if elapsed > @idle_timeout
103
+ end
104
+
105
+ # Accept a client connection if one is available.
106
+ # Spawns a thread to handle each client concurrently.
107
+ #
108
+ # @private
109
+ # @return [void]
110
+ def accept_client
111
+ client = @server&.accept if @server&.wait_readable(0.1)
112
+ return unless client
113
+
114
+ @last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
115
+ Thread.new(client) { |conn| handle_client(conn) }
116
+ end
117
+
118
+ # Read a request from a client connection and dispatch it.
119
+ #
120
+ # @private
121
+ # @param [UNIXSocket] client connected client socket
122
+ # @raise [StandardError]
123
+ # @return [void]
124
+ def handle_client(client)
125
+ request_line = client.gets or return
126
+ request = Protocol.parse_response(request_line)
127
+ request ? handle_request(client, request) : send_error(client, nil, -32_700, 'Parse error')
128
+ rescue StandardError => e
129
+ method_name = request&.dig('method')
130
+ error_params = request&.dig('params') || {}
131
+ code, message, data = classify_error(e, method_name, error_params)
132
+ send_error(client, request&.dig('id'), code, message, data)
133
+ ensure
134
+ client.close
135
+ end
136
+
137
+ # Dispatch a parsed request to the appropriate handler.
138
+ #
139
+ # @private
140
+ # @param [UNIXSocket] client connected client socket
141
+ # @param [Hash<String, Object>] request parsed JSON-RPC request
142
+ # @return [void]
143
+ def handle_request(client, request)
144
+ method = request['method']
145
+ params = request['params'] || {}
146
+
147
+ case method
148
+ when 'check' then handle_check(client, request['id'], params)
149
+ when 'fix' then handle_fix(client, request['id'], params)
150
+ when 'check_batch' then handle_check_batch(client, request['id'], params)
151
+ when 'shutdown' then handle_shutdown(client, request['id'])
152
+ when 'ping' then handle_ping(client, request['id'])
153
+ else send_error(client, request['id'], -32_601, "Unknown method: #{method}")
154
+ end
155
+ end
156
+
157
+ # @private
158
+ # @param [UNIXSocket] client
159
+ # @param [String, Integer] id
160
+ # @param [Hash<String, Object>] params
161
+ # @raise [StandardError]
162
+ # @return [void]
163
+ # @return [void] if StandardError
164
+ def handle_check(client, id, params)
165
+ file = params['file']
166
+ strategy = (params['strategy'] || 'safe').to_sym
167
+ return send_error(client, id, -32_602, "File not found: #{file}") unless file && File.file?(file)
168
+
169
+ apply_cli_overrides(params['cli_overrides'])
170
+ src, result = rewrite_file(file, strategy)
171
+ send_result(client, id, 'status' => result[:output] == src ? 'ok' : 'fail',
172
+ 'changed' => result[:output] != src, 'changes' => result[:changes])
173
+ rescue StandardError => e
174
+ handle_request_error(client, id, e, file)
175
+ end
176
+
177
+ # @private
178
+ # @param [UNIXSocket] client
179
+ # @param [String, Integer] id
180
+ # @param [Hash<String, Object>] params
181
+ # @raise [StandardError]
182
+ # @return [void]
183
+ # @return [void] if StandardError
184
+ def handle_fix(client, id, params)
185
+ file = params['file']
186
+ strategy = (params['strategy'] || 'safe').to_sym
187
+ return send_error(client, id, -32_602, "File not found: #{file}") unless file && File.file?(file)
188
+
189
+ apply_cli_overrides(params['cli_overrides'])
190
+ src, result = rewrite_file(file, strategy)
191
+ changed = result[:output] != src
192
+ File.write(file, result[:output]) if changed
193
+ send_result(client, id, 'status' => 'ok', 'changed' => changed, 'changes' => result[:changes])
194
+ rescue StandardError => e
195
+ handle_request_error(client, id, e, file)
196
+ end
197
+
198
+ # @private
199
+ # @param [UNIXSocket] client
200
+ # @param [String, Integer] id
201
+ # @param [Hash<String, Object>] params
202
+ # @return [void]
203
+ def handle_check_batch(client, id, params)
204
+ files = params['files']
205
+ return send_error(client, id, -32_602, 'Missing files parameter') unless files.is_a?(Array) && !files.empty?
206
+
207
+ strategy = (params['strategy'] || 'safe').to_sym
208
+ timeout = params['timeout']
209
+
210
+ apply_cli_overrides(params['cli_overrides'])
211
+
212
+ results = files.map do |file|
213
+ process_file_in_batch(file, strategy, timeout)
214
+ end
215
+
216
+ send_result(client, id, { 'results' => results })
217
+ end
218
+
219
+ # @private
220
+ # @param [String] file
221
+ # @param [Symbol] strategy
222
+ # @param [Integer, Float, nil] timeout
223
+ # @raise [Timeout::Error]
224
+ # @raise [StandardError]
225
+ # @return [Hash<String, Object>]
226
+ # @return [Hash] if Timeout::Error
227
+ # @return [Hash] if StandardError
228
+ def process_file_in_batch(file, strategy, timeout = nil)
229
+ return { 'file' => file, 'status' => 'error', 'error' => "File not found: #{file}" } unless File.file?(file)
230
+
231
+ if timeout
232
+ Timeout.timeout(timeout.to_f) { run_rewrite(file, strategy) }
233
+ else
234
+ run_rewrite(file, strategy)
235
+ end
236
+ rescue Timeout::Error
237
+ { 'file' => file, 'status' => 'error', 'error' => 'Timeout' }
238
+ rescue StandardError => e
239
+ { 'file' => file, 'status' => 'error', 'error' => "#{e.class}: #{e.message}" }
240
+ end
241
+
242
+ # @private
243
+ # @param [String] file
244
+ # @param [Symbol] strategy
245
+ # @return [Hash<String, Object>]
246
+ def run_rewrite(file, strategy)
247
+ src, result = rewrite_file(file, strategy)
248
+ { 'file' => file, 'status' => result[:output] == src ? 'ok' : 'fail', 'changes' => result[:changes] }
249
+ end
250
+
251
+ # @private
252
+ # @param [Hash<String, Object>, nil] overrides
253
+ # @return [void]
254
+ def apply_cli_overrides(overrides)
255
+ @config_mutex.synchronize do
256
+ return reset_effective_config_internal if overrides.nil? || overrides.empty?
257
+ return if @applied_overrides == overrides
258
+
259
+ build_effective_config(overrides)
260
+ end
261
+ end
262
+
263
+ # @private
264
+ # @param [Hash<String, Object>] overrides
265
+ # @return [void]
266
+ def build_effective_config(overrides)
267
+ config = @config or return
268
+ require 'docscribe/cli/config_builder'
269
+ opts = overrides.transform_keys(&:to_sym)
270
+ @effective_config = Docscribe::CLI::ConfigBuilder.build(config, opts)
271
+ @file_cache.clear
272
+ @applied_overrides = overrides
273
+ end
274
+
275
+ # @private
276
+ # @return [void]
277
+ def reset_effective_config
278
+ @config_mutex.synchronize { reset_effective_config_internal }
279
+ end
280
+
281
+ # @private
282
+ # @return [void]
283
+ def reset_effective_config_internal
284
+ return unless @effective_config
285
+
286
+ @effective_config = nil
287
+ @applied_overrides = nil
288
+ @file_cache.clear
289
+ end
290
+
291
+ # @private
292
+ # @param [String] file
293
+ # @param [Symbol] strategy
294
+ # @raise [StandardError]
295
+ # @return [(String, Hash<Symbol, Object>)]
296
+ def rewrite_file(file, strategy)
297
+ @cache_mutex.synchronize do
298
+ config = @effective_config || @config or raise 'Docscribe: config not loaded'
299
+ key = [file, strategy]
300
+ mtime = File.mtime(file)
301
+ hit = @file_cache[key]
302
+ return [hit[:src], hit[:result]] if hit && hit[:mtime] == mtime
303
+
304
+ rewrite_and_cache(file, strategy, config, key, mtime)
305
+ end
306
+ end
307
+
308
+ # @private
309
+ # @param [String] file
310
+ # @param [Symbol] strategy
311
+ # @param [Docscribe::Config, nil] config effective or base config
312
+ # @param [Array<(String, Symbol)>] key cache key
313
+ # @param [Time] mtime file modification time
314
+ # @return [(String, Hash<Symbol, Object>)]
315
+ def rewrite_and_cache(file, strategy, config, key, mtime)
316
+ src = File.read(file)
317
+ rbs = config.respond_to?(:core_rbs_provider) ? config.core_rbs_provider : nil
318
+ result = Docscribe::InlineRewriter.rewrite_with_report(src, strategy: strategy, config: config,
319
+ core_rbs_provider: rbs, file: file)
320
+ @file_cache[key] = { mtime: mtime, src: src, result: result }
321
+ [src, result]
322
+ end
323
+
324
+ # Handle a shutdown request.
325
+ #
326
+ # @private
327
+ # @param [UNIXSocket] client connected client socket
328
+ # @param [String, Integer] id request ID
329
+ # @return [void]
330
+ def handle_shutdown(client, id)
331
+ send_result(client, id, { 'status' => 'shutting_down' })
332
+ @running = false
333
+ end
334
+
335
+ # Handle a ping request.
336
+ #
337
+ # @private
338
+ # @param [UNIXSocket] client connected client socket
339
+ # @param [String, Integer] id request ID
340
+ # @return [void]
341
+ def handle_ping(client, id)
342
+ uptime = (Time.now - @started_at).to_i
343
+ send_result(client, id, {
344
+ 'version' => Docscribe::VERSION,
345
+ 'pid' => Process.pid,
346
+ 'socket_path' => @socket_path,
347
+ 'started_at' => @started_at.iso8601,
348
+ 'uptime' => uptime
349
+ })
350
+ end
351
+
352
+ # Send a JSON-RPC result response.
353
+ #
354
+ # @private
355
+ # @param [UNIXSocket] client connected client socket
356
+ # @param [String, Integer] id request ID
357
+ # @param [Hash<String, Object>] result result data
358
+ # @return [void]
359
+ def send_result(client, id, result)
360
+ response = { jsonrpc: '2.0', id: id, result: result }
361
+ client.write(Protocol.serialize(response))
362
+ end
363
+
364
+ # @private
365
+ # @param [Exception] exception
366
+ # @param [String, nil] _method_name
367
+ # @param [Hash<String, Object>] params
368
+ # @return [(Integer, String, Object)]
369
+ def classify_error(exception, _method_name = nil, params = {})
370
+ if exception.is_a?(LoadError) || exception.is_a?(Gem::LoadError)
371
+ classify_gem_error(exception)
372
+ elsif syntax_error?(exception)
373
+ classify_syntax_err(exception, params)
374
+ elsif timeout_error?(exception)
375
+ classify_timeout_err(exception, params)
376
+ else
377
+ classify_internal_err(exception)
378
+ end
379
+ end
380
+
381
+ # @private
382
+ # @param [Exception] exception
383
+ # @return [Boolean]
384
+ def syntax_error?(exception)
385
+ exception.is_a?(Docscribe::ParseError) ||
386
+ (defined?(Parser::SyntaxError) && exception.is_a?(Parser::SyntaxError))
387
+ end
388
+
389
+ # @private
390
+ # @param [Exception] exception
391
+ # @return [Boolean]
392
+ def timeout_error?(exception)
393
+ !!defined?(Timeout::Error) && exception.is_a?(Timeout::Error)
394
+ end
395
+
396
+ # @private
397
+ # @param [Exception] exception
398
+ # @return [(Integer, String, Hash<Symbol, String>)]
399
+ def classify_gem_error(exception)
400
+ data = { gem: nil }
401
+ data[:gem] = exception.path if exception.respond_to?(:path) && exception.path
402
+ [ERROR_CODES[:gem_not_found], "#{exception.class}: #{exception.message}", data]
403
+ end
404
+
405
+ # @private
406
+ # @param [Exception] exception
407
+ # @param [Hash<String, Object>] params
408
+ # @return [(Integer, String, Hash<Symbol, Object>)]
409
+ def classify_syntax_err(exception, params)
410
+ file = (params['file'] if params.is_a?(Hash)).to_s
411
+ line = if exception.respond_to?(:line)
412
+ exception.line
413
+ elsif exception.respond_to?(:diagnostic)
414
+ exception.diagnostic.location.line
415
+ end
416
+ data = { file: file, detail: exception.message, line: line }.compact
417
+ [ERROR_CODES[:syntax_error], "Syntax error in #{file}", data]
418
+ end
419
+
420
+ # @private
421
+ # @param [Exception] exception
422
+ # @param [Hash<String, Object>] params
423
+ # @return [(Integer, String, Hash<Symbol, Object>)]
424
+ def classify_timeout_err(exception, params)
425
+ file = (params['file'] if params.is_a?(Hash)).to_s
426
+ data = { timeout: @idle_timeout || 30, file: file }
427
+ [ERROR_CODES[:timeout], "#{exception.class}: #{exception.message}", data]
428
+ end
429
+
430
+ # @private
431
+ # @param [Exception] exception
432
+ # @return [(Integer, String, Hash<Symbol, Object>)]
433
+ def classify_internal_err(exception)
434
+ backtrace = exception.backtrace&.first(5) || []
435
+ data = { backtrace: backtrace }
436
+ [ERROR_CODES[:internal], "#{exception.class}: #{exception.message}", data]
437
+ end
438
+
439
+ # @private
440
+ # @param [UNIXSocket] client
441
+ # @param [String, Integer] id
442
+ # @param [Exception] exception
443
+ # @param [String] file
444
+ # @raise [StandardError]
445
+ # @return [void]
446
+ def handle_request_error(client, id, exception, file)
447
+ if exception.is_a?(Docscribe::ParseError) ||
448
+ (defined?(Parser::SyntaxError) && exception.is_a?(Parser::SyntaxError))
449
+ send_syntax_error(client, id, exception, file)
450
+ else
451
+ raise
452
+ end
453
+ end
454
+
455
+ # @private
456
+ # @param [UNIXSocket] client
457
+ # @param [String, Integer] id
458
+ # @param [Exception] exception
459
+ # @param [String] file
460
+ # @return [void]
461
+ def send_syntax_error(client, id, exception, file)
462
+ line = if exception.respond_to?(:line)
463
+ exception.line
464
+ elsif exception.respond_to?(:diagnostic)
465
+ exception.diagnostic.location.line
466
+ end
467
+ data = { file: file, detail: exception.message, line: line }.compact
468
+ send_error(client, id, ERROR_CODES[:syntax_error], "Syntax error in #{file}", data)
469
+ end
470
+
471
+ # @private
472
+ # @param [UNIXSocket] client
473
+ # @param [String, Integer, nil] id
474
+ # @param [Integer] code
475
+ # @param [String] message
476
+ # @param [Hash<Symbol, Object>, nil] data optional structured error data
477
+ # @return [void]
478
+ def send_error(client, id, code, message, data = nil)
479
+ error = { code: code, message: message }
480
+ error[:data] = data if data
481
+ response = { jsonrpc: '2.0', id: id, error: error }
482
+ client.write(Protocol.serialize(response))
483
+ end
484
+
485
+ # Cleanup socket and PID files on shutdown.
486
+ #
487
+ # @private
488
+ # @raise [StandardError]
489
+ # @return [void]
490
+ # @return [nil] if StandardError
491
+ def cleanup
492
+ @server&.close
493
+ File.unlink(@socket_path) if @socket_path && File.exist?(@socket_path)
494
+ pid_path = "#{@socket_path}.pid"
495
+ FileUtils.rm_f(pid_path)
496
+ rescue StandardError
497
+ nil
498
+ end
499
+ end
500
+ end
501
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ module Docscribe
7
+ module Server
8
+ # JSON-line protocol helpers.
9
+ module Protocol
10
+ module_function
11
+
12
+ # Build a JSON-RPC request hash.
13
+ #
14
+ # @note module_function: defines #build_request (visibility: private)
15
+ # @param [String] method method name
16
+ # @param [Hash<Symbol, Object>] params request parameters
17
+ # @return [Hash<Symbol, Object>]
18
+ def build_request(method, params = {})
19
+ {
20
+ jsonrpc: '2.0',
21
+ id: SecureRandom.hex(8),
22
+ method: method,
23
+ params: params
24
+ }
25
+ end
26
+
27
+ # Parse a single JSON-line response.
28
+ #
29
+ # @note module_function: defines #parse_response (visibility: private)
30
+ # @param [String] line raw JSON line
31
+ # @raise [JSON::ParserError]
32
+ # @return [Hash<String, Object>, nil]
33
+ # @return [nil] if JSON::ParserError
34
+ def parse_response(line)
35
+ JSON.parse(line)
36
+ rescue JSON::ParserError
37
+ nil
38
+ end
39
+
40
+ # Serialize a hash to a JSON line.
41
+ #
42
+ # @note module_function: defines #serialize (visibility: private)
43
+ # @param [Hash<Object, Object>] hash
44
+ # @return [String]
45
+ def serialize(hash)
46
+ "#{JSON.generate(hash)}\n"
47
+ end
48
+ end
49
+ end
50
+ end