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.
- checksums.yaml +4 -4
- data/README.md +155 -88
- data/exe/docscribe-client +3 -1
- data/lib/docscribe/cli/config_dump.rb +70 -0
- data/lib/docscribe/cli/coverage.rb +313 -0
- data/lib/docscribe/cli/init.rb +81 -4
- data/lib/docscribe/cli/options.rb +16 -0
- data/lib/docscribe/cli/rbs_gen.rb +2 -1
- data/lib/docscribe/cli/run.rb +134 -0
- data/lib/docscribe/cli.rb +7 -5
- data/lib/docscribe/config.rb +10 -0
- data/lib/docscribe/infer/behavior.rb +96 -0
- data/lib/docscribe/infer/params.rb +1 -3
- data/lib/docscribe/infer.rb +35 -0
- data/lib/docscribe/inline_rewriter/doc_builder.rb +22 -9
- data/lib/docscribe/inline_rewriter.rb +142 -20
- data/lib/docscribe/server/base.rb +224 -0
- data/lib/docscribe/server/client.rb +86 -0
- data/lib/docscribe/server/daemon.rb +501 -0
- data/lib/docscribe/server/protocol.rb +50 -0
- data/lib/docscribe/server.rb +4 -834
- data/lib/docscribe/types/overload_selector.rb +79 -0
- data/lib/docscribe/types/provider_chain.rb +25 -2
- data/lib/docscribe/types/signature.rb +5 -0
- data/lib/docscribe/version.rb +1 -1
- metadata +11 -3
|
@@ -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
|