docscribe 1.5.0 → 1.5.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.
- checksums.yaml +4 -4
- data/README.md +140 -13
- data/exe/docscribe-client +105 -0
- data/lib/docscribe/cli/config_builder.rb +1 -1
- data/lib/docscribe/cli/generate.rb +11 -17
- data/lib/docscribe/cli/options.rb +17 -2
- data/lib/docscribe/cli/rbs_gen.rb +1 -1
- data/lib/docscribe/cli/run.rb +230 -9
- data/lib/docscribe/cli/server.rb +135 -0
- data/lib/docscribe/cli/sigs.rb +5 -5
- data/lib/docscribe/cli.rb +20 -31
- data/lib/docscribe/config/defaults.rb +2 -1
- data/lib/docscribe/config/filtering.rb +1 -1
- data/lib/docscribe/config/loader.rb +5 -7
- data/lib/docscribe/config/rbs.rb +2 -2
- data/lib/docscribe/config/sorbet.rb +15 -6
- data/lib/docscribe/config/template.rb +1 -0
- data/lib/docscribe/config.rb +8 -2
- data/lib/docscribe/infer/params.rb +1 -1
- data/lib/docscribe/infer/returns.rb +81 -19
- data/lib/docscribe/infer.rb +7 -3
- data/lib/docscribe/inline_rewriter/doc_builder.rb +14 -8
- data/lib/docscribe/inline_rewriter/source_helpers.rb +1 -1
- data/lib/docscribe/inline_rewriter.rb +14 -20
- data/lib/docscribe/lru_cache.rb +49 -0
- data/lib/docscribe/parsing.rb +2 -2
- data/lib/docscribe/plugin/registry.rb +1 -1
- data/lib/docscribe/plugin.rb +1 -1
- data/lib/docscribe/server.rb +608 -0
- data/lib/docscribe/types/rbs/provider.rb +120 -10
- data/lib/docscribe/types/sorbet/base_provider.rb +7 -4
- data/lib/docscribe/types/sorbet/rbi_provider.rb +3 -2
- data/lib/docscribe/types/sorbet/source_provider.rb +3 -2
- data/lib/docscribe/version.rb +1 -1
- metadata +7 -2
|
@@ -0,0 +1,608 @@
|
|
|
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_relative 'lru_cache'
|
|
11
|
+
|
|
12
|
+
module Docscribe
|
|
13
|
+
# Server/daemon mode for persistent multi-request operation.
|
|
14
|
+
#
|
|
15
|
+
# Architecture:
|
|
16
|
+
# - Daemon process loads Ruby runtime once, listens on a Unix socket
|
|
17
|
+
# - Client sends JSON-line requests, receives JSON-line responses
|
|
18
|
+
# - Auto-shutdown after idle timeout
|
|
19
|
+
# - Protocol: JSON-RPC 2.0 over Unix socket
|
|
20
|
+
module Server
|
|
21
|
+
# Unix socket path max is 104 bytes on macOS (the more restrictive).
|
|
22
|
+
# Dir.tmpdir on macOS often returns a long path under /var/folders/.../T
|
|
23
|
+
# that exceeds this limit, so we fall back to /tmp when needed.
|
|
24
|
+
SOCKET_DIR = begin
|
|
25
|
+
tmp = Dir.tmpdir || '/tmp'
|
|
26
|
+
sock_overhead = "/docscribe-#{'a' * 32}.sock".bytesize # 48
|
|
27
|
+
tmp.bytesize <= 104 - sock_overhead ? tmp : '/tmp'
|
|
28
|
+
end
|
|
29
|
+
IDLE_TIMEOUT = 300
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Start the server daemon if not running.
|
|
33
|
+
#
|
|
34
|
+
# @param [String?] config_path optional config file path
|
|
35
|
+
# @param [Boolean] daemonize redirect stdin/stdout/stderr to /dev/null
|
|
36
|
+
# @param [Integer] timeout max seconds to wait for readiness
|
|
37
|
+
# @raise [StandardError]
|
|
38
|
+
# @return [void]
|
|
39
|
+
def ensure_running!(config_path: nil, daemonize: false, timeout: 5)
|
|
40
|
+
return if running?(config_path)
|
|
41
|
+
raise 'Server mode is unavailable on this Ruby/platform (Process.fork not supported)' unless Process.respond_to?(:fork)
|
|
42
|
+
|
|
43
|
+
lock_path = "#{socket_path(config_path)}.lock"
|
|
44
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
|
|
45
|
+
lock.flock(File::LOCK_EX)
|
|
46
|
+
next if running?(config_path)
|
|
47
|
+
|
|
48
|
+
start_daemon_process(config_path: config_path, daemonize: daemonize)
|
|
49
|
+
end
|
|
50
|
+
wait_for_ready(config_path: config_path, timeout: timeout)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Start the server daemon and wait for it to become ready.
|
|
54
|
+
#
|
|
55
|
+
# @param [String?] config_path optional config path for socket/pid lookup
|
|
56
|
+
# @param [Integer] timeout max seconds to wait for readiness
|
|
57
|
+
# @param [Boolean] raise_on_timeout
|
|
58
|
+
# @raise [StandardError]
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def wait_for_ready(config_path: nil, timeout: 5, raise_on_timeout: true) # rubocop:disable SortedMethodsByCall/Waterfall
|
|
61
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
62
|
+
loop do
|
|
63
|
+
return true if running?(config_path)
|
|
64
|
+
|
|
65
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
66
|
+
raise('Docscribe: server failed to start') if raise_on_timeout
|
|
67
|
+
|
|
68
|
+
warn('Docscribe server failed to start within timeout')
|
|
69
|
+
return false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sleep 0.1
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Whether a server process is listening on the socket.
|
|
77
|
+
#
|
|
78
|
+
# On ECONNREFUSED, checks whether the PID process is still alive:
|
|
79
|
+
# if yes, the daemon is still starting up (don't clean up);
|
|
80
|
+
# if no, removes stale socket and pid files.
|
|
81
|
+
#
|
|
82
|
+
# @param [String?] config_path optional config path for socket lookup
|
|
83
|
+
# @raise [Errno::ECONNREFUSED]
|
|
84
|
+
# @raise [Errno::ENOENT]
|
|
85
|
+
# @raise [Errno::ENOTSOCK]
|
|
86
|
+
# @raise [StandardError]
|
|
87
|
+
# @return [Boolean]
|
|
88
|
+
# @return [Boolean] if Errno::ECONNREFUSED
|
|
89
|
+
# @return [Boolean] if Errno::ENOENT, Errno::ENOTSOCK
|
|
90
|
+
# @return [Boolean] if StandardError
|
|
91
|
+
def running?(config_path = nil)
|
|
92
|
+
socket = UNIXSocket.new(socket_path(config_path))
|
|
93
|
+
socket.close
|
|
94
|
+
true
|
|
95
|
+
rescue Errno::ECONNREFUSED
|
|
96
|
+
handle_stale_socket?(config_path)
|
|
97
|
+
rescue Errno::ENOENT, Errno::ENOTSOCK
|
|
98
|
+
clean_socket_files(config_path)
|
|
99
|
+
false
|
|
100
|
+
rescue StandardError
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Handle ECONNREFUSED: check if the pid process is alive.
|
|
105
|
+
# Cleans up only if the process is dead.
|
|
106
|
+
#
|
|
107
|
+
# @param [String?] config_path
|
|
108
|
+
# @return [Boolean] false (not running)
|
|
109
|
+
def handle_stale_socket?(config_path)
|
|
110
|
+
pid = read_pid(config_path)
|
|
111
|
+
return false if pid && process_alive?(pid)
|
|
112
|
+
|
|
113
|
+
clean_socket_files(config_path)
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @param [Integer] pid
|
|
118
|
+
# @raise [Errno::ESRCH]
|
|
119
|
+
# @return [Boolean]
|
|
120
|
+
# @return [Boolean] if Errno::ESRCH
|
|
121
|
+
def process_alive?(pid)
|
|
122
|
+
Process.kill(0, pid)
|
|
123
|
+
true
|
|
124
|
+
rescue Errno::ESRCH
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param [String?] config_path
|
|
129
|
+
# @raise [StandardError]
|
|
130
|
+
# @return [Integer?]
|
|
131
|
+
# @return [nil] if StandardError
|
|
132
|
+
def read_pid(config_path = nil)
|
|
133
|
+
File.read(pid_path(config_path)).to_i if File.exist?(pid_path(config_path))
|
|
134
|
+
rescue StandardError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Remove stale socket and pid files.
|
|
139
|
+
#
|
|
140
|
+
# @param [String?] config_path
|
|
141
|
+
# @return [void]
|
|
142
|
+
def clean_socket_files(config_path)
|
|
143
|
+
FileUtils.rm_f(socket_path(config_path))
|
|
144
|
+
FileUtils.rm_f(pid_path(config_path))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @param [String?] config_path
|
|
148
|
+
# @return [String]
|
|
149
|
+
def pid_path(config_path = nil)
|
|
150
|
+
"#{socket_path(config_path)}.pid"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
ENV_FILES = %w[Gemfile.lock rbs_collection.lock.yaml].freeze
|
|
154
|
+
|
|
155
|
+
# Derive a project-specific socket path from the current working directory.
|
|
156
|
+
# Uses MD5 (deterministic across processes) instead of String#hash
|
|
157
|
+
# (which varies per Ruby process due to random seeding).
|
|
158
|
+
# When a config_path is given, its path + mtime are included in the hash
|
|
159
|
+
# so different configs get different daemons.
|
|
160
|
+
# Environment files (Gemfile.lock, rbs_collection.lock.yaml) are also
|
|
161
|
+
# included so daemon is invalidated when gems or RBS types change.
|
|
162
|
+
#
|
|
163
|
+
# @param [String?] config_path optional config path to differentiate
|
|
164
|
+
# @return [String]
|
|
165
|
+
def socket_path(config_path = nil)
|
|
166
|
+
seed = +Dir.pwd
|
|
167
|
+
seed << ":#{env_hash}"
|
|
168
|
+
if config_path
|
|
169
|
+
resolved = File.expand_path(config_path)
|
|
170
|
+
mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
|
|
171
|
+
seed << ":#{resolved}:#{mtime}"
|
|
172
|
+
end
|
|
173
|
+
"#{SOCKET_DIR}/docscribe-#{Digest::MD5.hexdigest(seed)}.sock"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# @param [String] config_path
|
|
177
|
+
# @return [String]
|
|
178
|
+
def config_hash(config_path)
|
|
179
|
+
resolved = File.expand_path(config_path)
|
|
180
|
+
mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
|
|
181
|
+
Digest::MD5.hexdigest("#{resolved}:#{mtime}")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Hash of environment files that affect analysis results.
|
|
185
|
+
# When any of these change, the daemon is invalidated (new socket path).
|
|
186
|
+
#
|
|
187
|
+
# @return [String]
|
|
188
|
+
def env_hash
|
|
189
|
+
parts = ENV_FILES.map do |file|
|
|
190
|
+
path = File.join(Dir.pwd, file)
|
|
191
|
+
File.exist?(path) ? File.mtime(path).to_f.to_s : '0'
|
|
192
|
+
end
|
|
193
|
+
Digest::MD5.hexdigest(parts.join(':'))
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
public :read_pid, :pid_path, :socket_path
|
|
197
|
+
|
|
198
|
+
# @param [String?] config_path
|
|
199
|
+
# @param [Boolean] daemonize
|
|
200
|
+
# @return [void]
|
|
201
|
+
def start_daemon_process(config_path:, daemonize:)
|
|
202
|
+
warn 'Docscribe: starting server...' if daemonize
|
|
203
|
+
pid = Process.fork do # steep:ignore NoMethod
|
|
204
|
+
[$stdin, $stdout].each { _1.reopen(File::NULL) }
|
|
205
|
+
$stderr.reopen(File::NULL)
|
|
206
|
+
Daemon.new(config_path: config_path).start
|
|
207
|
+
end
|
|
208
|
+
Process.detach(pid)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# JSON-line protocol helpers.
|
|
213
|
+
module Protocol
|
|
214
|
+
module_function
|
|
215
|
+
|
|
216
|
+
# Build a JSON-RPC request hash.
|
|
217
|
+
#
|
|
218
|
+
# @note module_function: defines #build_request (visibility: private)
|
|
219
|
+
# @param [String] method method name
|
|
220
|
+
# @param [Hash<Symbol, Object>] params request parameters
|
|
221
|
+
# @return [Hash<Symbol, Object>]
|
|
222
|
+
def build_request(method, params = {})
|
|
223
|
+
{
|
|
224
|
+
jsonrpc: '2.0',
|
|
225
|
+
id: SecureRandom.hex(8),
|
|
226
|
+
method: method,
|
|
227
|
+
params: params
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Parse a single JSON-line response.
|
|
232
|
+
#
|
|
233
|
+
# @note module_function: defines #parse_response (visibility: private)
|
|
234
|
+
# @param [String] line raw JSON line
|
|
235
|
+
# @raise [JSON::ParserError]
|
|
236
|
+
# @return [Hash<String, Object>?]
|
|
237
|
+
# @return [nil] if JSON::ParserError
|
|
238
|
+
def parse_response(line)
|
|
239
|
+
JSON.parse(line)
|
|
240
|
+
rescue JSON::ParserError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Serialize a hash to a JSON line.
|
|
245
|
+
#
|
|
246
|
+
# @note module_function: defines #serialize (visibility: private)
|
|
247
|
+
# @param [Hash<Object, Object>] hash
|
|
248
|
+
# @return [String]
|
|
249
|
+
def serialize(hash)
|
|
250
|
+
"#{JSON.generate(hash)}\n"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Client for communicating with a running Docscribe daemon.
|
|
255
|
+
class Client
|
|
256
|
+
# @param [String?] socket_path custom socket path (defaults to server default)
|
|
257
|
+
# @param [String?] config_path optional config path for socket lookup
|
|
258
|
+
# @return [void]
|
|
259
|
+
def initialize(socket_path = nil, config_path: nil)
|
|
260
|
+
@socket_path = socket_path || Server.socket_path(config_path)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Send a check request to the server.
|
|
264
|
+
#
|
|
265
|
+
# @param [String] file path to file to check
|
|
266
|
+
# @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
|
|
267
|
+
# @param [Object] rest
|
|
268
|
+
# @return [Hash<String, Object>?] response hash or nil if server unreachable
|
|
269
|
+
def check(file:, strategy: :safe, **rest)
|
|
270
|
+
request('check', file: file, strategy: strategy, **rest)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Send a fix request to the server.
|
|
274
|
+
#
|
|
275
|
+
# @param [String] file path to file to fix
|
|
276
|
+
# @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
|
|
277
|
+
# @param [Object] rest
|
|
278
|
+
# @return [Hash<String, Object>?] response hash or nil if server unreachable
|
|
279
|
+
def fix(file:, strategy: :safe, **rest)
|
|
280
|
+
request('fix', file: file, strategy: strategy, **rest)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Send a shutdown request to the server.
|
|
284
|
+
#
|
|
285
|
+
# @return [Hash<String, Object>?] response hash or nil if server unreachable
|
|
286
|
+
def shutdown
|
|
287
|
+
request('shutdown')
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Ping the server and get version/pid/uptime info.
|
|
291
|
+
#
|
|
292
|
+
# @return [Hash<String, Object>?] response hash or nil if server unreachable
|
|
293
|
+
def ping
|
|
294
|
+
request('ping')
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
private
|
|
298
|
+
|
|
299
|
+
# Send a JSON-RPC request and read the response.
|
|
300
|
+
#
|
|
301
|
+
# @private
|
|
302
|
+
# @param [String] method method name
|
|
303
|
+
# @param [Object] params request parameters
|
|
304
|
+
# @return [Hash<String, Object>?]
|
|
305
|
+
def request(method, **params)
|
|
306
|
+
connect do |socket|
|
|
307
|
+
req = Protocol.build_request(method, params)
|
|
308
|
+
socket.write(Protocol.serialize(req))
|
|
309
|
+
socket.close_write
|
|
310
|
+
line = socket.gets
|
|
311
|
+
break unless line
|
|
312
|
+
|
|
313
|
+
Protocol.parse_response(line)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Connect to the Unix socket and yield the connection.
|
|
318
|
+
#
|
|
319
|
+
# @private
|
|
320
|
+
# @raise [Errno::ECONNREFUSED]
|
|
321
|
+
# @raise [Errno::ENOENT]
|
|
322
|
+
# @return [T?] yield return value or nil on connection error
|
|
323
|
+
def connect
|
|
324
|
+
socket = UNIXSocket.new(@socket_path)
|
|
325
|
+
yield socket
|
|
326
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT
|
|
327
|
+
nil
|
|
328
|
+
ensure
|
|
329
|
+
socket&.close
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Daemon process that loads the Ruby runtime once and serves requests.
|
|
334
|
+
class Daemon
|
|
335
|
+
# @param [String?] socket_path custom socket path
|
|
336
|
+
# @param [Integer] idle_timeout seconds before automatic shutdown
|
|
337
|
+
# @param [String?] config_path custom config path
|
|
338
|
+
# @return [void]
|
|
339
|
+
def initialize(socket_path: nil, idle_timeout: IDLE_TIMEOUT, config_path: nil)
|
|
340
|
+
@socket_path = socket_path || Server.socket_path(config_path)
|
|
341
|
+
@idle_timeout = idle_timeout
|
|
342
|
+
@config_path = config_path
|
|
343
|
+
@last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
344
|
+
@running = false
|
|
345
|
+
@server = nil
|
|
346
|
+
@file_cache = LRUCache.new
|
|
347
|
+
@started_at = Time.now
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Start the daemon: load dependencies, bind socket, enter listen loop.
|
|
351
|
+
#
|
|
352
|
+
# @return [void]
|
|
353
|
+
def start
|
|
354
|
+
load_dependencies
|
|
355
|
+
setup_socket
|
|
356
|
+
@running = true
|
|
357
|
+
$PROGRAM_NAME = "docscribe server (#{Dir.pwd})"
|
|
358
|
+
write_pid
|
|
359
|
+
listen_loop
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
private
|
|
363
|
+
|
|
364
|
+
# Load the full Docscribe runtime and build cached config.
|
|
365
|
+
#
|
|
366
|
+
# @private
|
|
367
|
+
# @return [void]
|
|
368
|
+
def load_dependencies
|
|
369
|
+
require 'docscribe'
|
|
370
|
+
@config = Docscribe::Config.load(@config_path)
|
|
371
|
+
@config&.load_plugins!
|
|
372
|
+
@core_rbs_provider = @config&.core_rbs_provider
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Create and bind the Unix domain socket.
|
|
376
|
+
#
|
|
377
|
+
# @private
|
|
378
|
+
# @return [void]
|
|
379
|
+
def setup_socket
|
|
380
|
+
FileUtils.rm_f(@socket_path)
|
|
381
|
+
FileUtils.mkdir_p(File.dirname(@socket_path))
|
|
382
|
+
@server = UNIXServer.new(@socket_path)
|
|
383
|
+
File.chmod(0o600, @socket_path)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# @private
|
|
387
|
+
# @return [void]
|
|
388
|
+
def write_pid
|
|
389
|
+
File.write("#{@socket_path}.pid", Process.pid)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Main accept loop with idle timeout check.
|
|
393
|
+
#
|
|
394
|
+
# @private
|
|
395
|
+
# @raise [Interrupt]
|
|
396
|
+
# @return [void]
|
|
397
|
+
def listen_loop
|
|
398
|
+
while @running
|
|
399
|
+
check_idle_timeout
|
|
400
|
+
accept_client
|
|
401
|
+
end
|
|
402
|
+
rescue Interrupt
|
|
403
|
+
@running = false
|
|
404
|
+
ensure
|
|
405
|
+
cleanup
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Check whether the idle timeout has been exceeded.
|
|
409
|
+
#
|
|
410
|
+
# @private
|
|
411
|
+
# @return [void]
|
|
412
|
+
def check_idle_timeout
|
|
413
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_request_time
|
|
414
|
+
@running = false if elapsed > @idle_timeout
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Accept a client connection if one is available.
|
|
418
|
+
#
|
|
419
|
+
# @private
|
|
420
|
+
# @return [void]
|
|
421
|
+
def accept_client
|
|
422
|
+
client = @server&.accept if @server&.wait_readable(1)
|
|
423
|
+
return unless client
|
|
424
|
+
|
|
425
|
+
handle_client(client)
|
|
426
|
+
@last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Read a request from a client connection and dispatch it.
|
|
430
|
+
#
|
|
431
|
+
# @private
|
|
432
|
+
# @param [UNIXSocket] client connected client socket
|
|
433
|
+
# @raise [StandardError]
|
|
434
|
+
# @return [void]
|
|
435
|
+
def handle_client(client)
|
|
436
|
+
request_line = client.gets or return
|
|
437
|
+
request = Protocol.parse_response(request_line)
|
|
438
|
+
request ? handle_request(client, request) : send_error(client, nil, -32_700, 'Parse error')
|
|
439
|
+
rescue StandardError => e
|
|
440
|
+
send_error(client, request&.dig('id'), -32_603, "#{e.class}: #{e.message}")
|
|
441
|
+
ensure
|
|
442
|
+
client.close
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Dispatch a parsed request to the appropriate handler.
|
|
446
|
+
#
|
|
447
|
+
# @private
|
|
448
|
+
# @param [UNIXSocket] client connected client socket
|
|
449
|
+
# @param [Hash<String, Object>] request parsed JSON-RPC request
|
|
450
|
+
# @return [void]
|
|
451
|
+
def handle_request(client, request)
|
|
452
|
+
method = request['method']
|
|
453
|
+
params = request['params'] || {}
|
|
454
|
+
|
|
455
|
+
case method
|
|
456
|
+
when 'check' then handle_check(client, request['id'], params)
|
|
457
|
+
when 'fix' then handle_fix(client, request['id'], params)
|
|
458
|
+
when 'shutdown' then handle_shutdown(client, request['id'])
|
|
459
|
+
when 'ping' then handle_ping(client, request['id'])
|
|
460
|
+
else send_error(client, request['id'], -32_601, "Unknown method: #{method}")
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# @private
|
|
465
|
+
# @param [UNIXSocket] client
|
|
466
|
+
# @param [String, Integer] id
|
|
467
|
+
# @param [Hash<String, Object>] params
|
|
468
|
+
# @return [void]
|
|
469
|
+
def handle_check(client, id, params)
|
|
470
|
+
file = params['file']
|
|
471
|
+
strategy = (params['strategy'] || 'safe').to_sym
|
|
472
|
+
return send_error(client, id, -32_602, "File not found: #{file}") unless file && File.file?(file)
|
|
473
|
+
|
|
474
|
+
apply_cli_overrides(params['cli_overrides'])
|
|
475
|
+
src, result = rewrite_file(file, strategy)
|
|
476
|
+
send_result(client, id, 'status' => result[:output] == src ? 'ok' : 'fail',
|
|
477
|
+
'changed' => result[:output] != src, 'changes' => result[:changes])
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# @private
|
|
481
|
+
# @param [UNIXSocket] client
|
|
482
|
+
# @param [String, Integer] id
|
|
483
|
+
# @param [Hash<String, Object>] params
|
|
484
|
+
# @return [void]
|
|
485
|
+
def handle_fix(client, id, params)
|
|
486
|
+
file = params['file']
|
|
487
|
+
strategy = (params['strategy'] || 'safe').to_sym
|
|
488
|
+
return send_error(client, id, -32_602, "File not found: #{file}") unless file && File.file?(file)
|
|
489
|
+
|
|
490
|
+
apply_cli_overrides(params['cli_overrides'])
|
|
491
|
+
src, result = rewrite_file(file, strategy)
|
|
492
|
+
File.write(file, result[:output]) if result[:output] != src
|
|
493
|
+
send_result(client, id, 'status' => 'ok',
|
|
494
|
+
'changed' => result[:output] != src, 'changes' => result[:changes])
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# @private
|
|
498
|
+
# @param [Hash<String, Object>?] overrides
|
|
499
|
+
# @return [void]
|
|
500
|
+
def apply_cli_overrides(overrides)
|
|
501
|
+
return reset_effective_config if overrides.nil? || overrides.empty?
|
|
502
|
+
return if @applied_overrides == overrides
|
|
503
|
+
|
|
504
|
+
config = @config or return
|
|
505
|
+
require 'docscribe/cli/config_builder'
|
|
506
|
+
opts = overrides.transform_keys(&:to_sym)
|
|
507
|
+
@effective_config = Docscribe::CLI::ConfigBuilder.build(config, opts)
|
|
508
|
+
@file_cache.clear
|
|
509
|
+
@applied_overrides = overrides
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# @private
|
|
513
|
+
# @return [void]
|
|
514
|
+
def reset_effective_config
|
|
515
|
+
return unless @effective_config
|
|
516
|
+
|
|
517
|
+
@effective_config = nil
|
|
518
|
+
@applied_overrides = nil
|
|
519
|
+
@file_cache.clear
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# @private
|
|
523
|
+
# @param [String] file
|
|
524
|
+
# @param [Symbol] strategy
|
|
525
|
+
# @raise [StandardError]
|
|
526
|
+
# @return [(String, Hash<Symbol, Object>)]
|
|
527
|
+
def rewrite_file(file, strategy)
|
|
528
|
+
config = @effective_config || @config or raise 'Docscribe: config not loaded'
|
|
529
|
+
key = [file, strategy]
|
|
530
|
+
mtime = File.mtime(file)
|
|
531
|
+
hit = @file_cache[key]
|
|
532
|
+
return [hit[:src], hit[:result]] if hit && hit[:mtime] == mtime
|
|
533
|
+
|
|
534
|
+
src = File.read(file)
|
|
535
|
+
rbs = config.respond_to?(:core_rbs_provider) ? config.core_rbs_provider : nil
|
|
536
|
+
result = Docscribe::InlineRewriter.rewrite_with_report(src, strategy: strategy, config: config, core_rbs_provider: rbs, file: file)
|
|
537
|
+
@file_cache[key] = { mtime: mtime, src: src, result: result }
|
|
538
|
+
[src, result]
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Handle a shutdown request.
|
|
542
|
+
#
|
|
543
|
+
# @private
|
|
544
|
+
# @param [UNIXSocket] client connected client socket
|
|
545
|
+
# @param [String, Integer] id request ID
|
|
546
|
+
# @return [void]
|
|
547
|
+
def handle_shutdown(client, id)
|
|
548
|
+
send_result(client, id, { 'status' => 'shutting_down' })
|
|
549
|
+
@running = false
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Handle a ping request.
|
|
553
|
+
#
|
|
554
|
+
# @private
|
|
555
|
+
# @param [UNIXSocket] client connected client socket
|
|
556
|
+
# @param [String, Integer] id request ID
|
|
557
|
+
# @return [void]
|
|
558
|
+
def handle_ping(client, id)
|
|
559
|
+
uptime = (Time.now - @started_at).to_i
|
|
560
|
+
send_result(client, id, {
|
|
561
|
+
'version' => Docscribe::VERSION,
|
|
562
|
+
'pid' => Process.pid,
|
|
563
|
+
'socket_path' => @socket_path,
|
|
564
|
+
'started_at' => @started_at.iso8601,
|
|
565
|
+
'uptime' => uptime
|
|
566
|
+
})
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Send a JSON-RPC result response.
|
|
570
|
+
#
|
|
571
|
+
# @private
|
|
572
|
+
# @param [UNIXSocket] client connected client socket
|
|
573
|
+
# @param [String, Integer] id request ID
|
|
574
|
+
# @param [Hash<String, Object>] result result data
|
|
575
|
+
# @return [void]
|
|
576
|
+
def send_result(client, id, result)
|
|
577
|
+
response = { jsonrpc: '2.0', id: id, result: result }
|
|
578
|
+
client.write(Protocol.serialize(response))
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# @private
|
|
582
|
+
# @param [UNIXSocket] client
|
|
583
|
+
# @param [String, Integer, nil] id
|
|
584
|
+
# @param [Integer] code
|
|
585
|
+
# @param [String] message
|
|
586
|
+
# @return [void]
|
|
587
|
+
def send_error(client, id, code, message)
|
|
588
|
+
response = { jsonrpc: '2.0', id: id, error: { code: code, message: message } }
|
|
589
|
+
client.write(Protocol.serialize(response))
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Cleanup socket and PID files on shutdown.
|
|
593
|
+
#
|
|
594
|
+
# @private
|
|
595
|
+
# @raise [StandardError]
|
|
596
|
+
# @return [void]
|
|
597
|
+
# @return [nil] if StandardError
|
|
598
|
+
def cleanup
|
|
599
|
+
@server&.close
|
|
600
|
+
File.unlink(@socket_path) if @socket_path && File.exist?(@socket_path)
|
|
601
|
+
pid_path = "#{@socket_path}.pid"
|
|
602
|
+
FileUtils.rm_f(pid_path)
|
|
603
|
+
rescue StandardError
|
|
604
|
+
nil
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
end
|