docscribe 1.6.0 → 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/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 -835
- data/lib/docscribe/version.rb +1 -1
- metadata +7 -3
|
@@ -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
|