sadr 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fa9d3261b52e409bc33e40c02fbf3e3659d701a70115c6cd5784a106232f5669
4
+ data.tar.gz: db606ffa6c2444b3ab23a38fdcefa285a80fa6fb6956590a369b30f628d316db
5
+ SHA512:
6
+ metadata.gz: 457a517a32c63de12ab4a3461c36f34a6270e7e77ec05b7c4beb9545c0b3a31c1b01b43a41fbcbffd2fa93f08a2987541b830eda8195f9f60ae576c6b1b8c0b7
7
+ data.tar.gz: 53313130ecb14ae934774fe9c8a442a53bb6791fc75896f9ead392b4c370e27ca3ecffdc819981e4503a94919b35a41984bd06581e469f7f9dc9354030ecc041
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-16
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Sadr
2
+
3
+ Sadr is a pure Ruby Language Server Protocol client. It owns JSON-RPC framing,
4
+ server lifecycle, document synchronization, diagnostics, and semantic tokens
5
+ without depending on an editor model or text-storage gem.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "sadr"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ require "sadr"
17
+
18
+ client = Sadr::Client.new(command: ["ruby-lsp"]).start
19
+ document = Sadr::Document.new(
20
+ uri: Sadr::Protocol.uri("example.rb"),
21
+ language_id: "ruby",
22
+ version: 0,
23
+ text: "puts :hello\n"
24
+ )
25
+ client.open(document)
26
+
27
+ position = Sadr::Position.new(line: 0, character: 0)
28
+ completion = client.completion(document.uri, position).await
29
+ client.stop
30
+ ```
31
+
32
+ `Position#character` is measured in UTF-16 code units. Increment versions and
33
+ send immutable changes when a document changes:
34
+
35
+ ```ruby
36
+ client.change(document.uri, 1, [
37
+ Sadr::ContentChange.new(range: nil, text: "puts :world\n")
38
+ ])
39
+ ```
40
+
41
+ Protocol conversion accepts any text index that provides `utf16_point_at`,
42
+ `utf16_offset_at`, `offset_at_utf16`, `line_start`, and `line`. This keeps rope
43
+ and buffer ownership in the caller:
44
+
45
+ ```ruby
46
+ position = Sadr::Protocol.position(my_text_index, byte_offset)
47
+ edits = Sadr::Protocol.text_edits(my_text_index, server_edits)
48
+ ```
49
+
50
+ ## Development
51
+
52
+ ```sh
53
+ bundle install
54
+ bundle exec rake test
55
+ bundle exec rbs -I sig -r stringio validate
56
+ BUDGET=1 bundle exec rake bench
57
+ gem build --strict sadr.gemspec
58
+ ```
59
+
60
+ ## License
61
+
62
+ Sadr is available under the MIT License.
@@ -0,0 +1,17 @@
1
+ # ADR NNN: Implementation decision title
2
+
3
+ - Status: Proposed
4
+ - Date: YYYY-MM-DD
5
+
6
+ ## Context
7
+
8
+ Describe the concrete implementation question and its compatibility, data,
9
+ runtime, or component constraints.
10
+
11
+ ## Decision
12
+
13
+ Describe the durable boundary or architecture choice.
14
+
15
+ ## Consequences
16
+
17
+ Describe the important positive and negative trade-offs and when to revisit it.
@@ -0,0 +1,20 @@
1
+ # ADR 001: Keep editor documents outside Sadr
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-14
5
+
6
+ ## Context
7
+
8
+ An LSP client needs document text, versions, and UTF-16 positions. Depending on
9
+ an editor buffer or rope would couple protocol transport to one application.
10
+
11
+ ## Decision
12
+
13
+ Accept immutable URI-based documents and changes. Accept text indexes through
14
+ the five operations required for UTF-16 conversion, without a runtime gem
15
+ dependency.
16
+
17
+ ## Consequences
18
+
19
+ Sadr can be embedded in any Ruby application. Callers remain responsible for
20
+ their editor model and for applying server text edits.
@@ -0,0 +1,3 @@
1
+ # Architecture decision records
2
+
3
+ These records document Sadr's durable boundaries.
@@ -0,0 +1,529 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sadr
4
+ class Client
5
+ POSITION_METHODS = {
6
+ completion: "completion",
7
+ hover: "hover",
8
+ definition: "definition",
9
+ type_definition: "typeDefinition",
10
+ implementation: "implementation",
11
+ signature_help: "signatureHelp"
12
+ }.freeze
13
+
14
+ attr_reader :capabilities, :transport, :diagnostics, :state, :errors, :server_info, :position_encoding
15
+
16
+ def initialize(command:, root: Dir.pwd, dispatch: ->(&block) { block.call }, restart: true, env: {}, initialization_options: nil, configuration: {})
17
+ @command = command
18
+ @root = File.expand_path(root)
19
+ @dispatch = dispatch
20
+ @restart = restart
21
+ @env = env
22
+ @initialization_options = initialization_options
23
+ @configuration = configuration
24
+ @pending = {}
25
+ @handlers = {}
26
+ @documents = {}
27
+ @diagnostics = {}
28
+ @semantic = {}
29
+ @sequence = 0
30
+ @lock = Mutex.new
31
+ @state = :stopped
32
+ @restarts = 0
33
+ @epoch = 0
34
+ @errors = []
35
+ @capabilities = {}
36
+ end
37
+
38
+ def start(timeout: 10)
39
+ raise Error, "language server is already started" if %i[starting running].include?(@state)
40
+
41
+ @closing = false
42
+ @state = :starting
43
+ epoch = (@epoch += 1)
44
+ @semantic.clear
45
+ @transport = Transport.new(@command, cwd: @root, env: @env) do |message, error|
46
+ receive(message, error, epoch)
47
+ end
48
+ result = request("initialize", initialize_params).await(timeout: timeout)
49
+ raise Error, "invalid initialize result" unless result.is_a?(Hash) && result["capabilities"].is_a?(Hash)
50
+
51
+ @capabilities = result["capabilities"]
52
+ @server_info = result["serverInfo"]
53
+ validate_capabilities
54
+ notify("initialized", {})
55
+ @state = :running
56
+ self
57
+ rescue StandardError => error
58
+ @transport&.close if epoch
59
+ fail_pending(error) if epoch
60
+ @state = :failed if epoch
61
+ raise
62
+ end
63
+
64
+ def stop
65
+ @closing = true
66
+ begin
67
+ request("shutdown").await(timeout: 2) if @state == :running
68
+ notify("exit") if @transport&.alive?
69
+ rescue Error
70
+ nil
71
+ ensure
72
+ @epoch += 1
73
+ @documents.clear
74
+ @semantic.clear
75
+ @diagnostics.clear
76
+ @transport&.close
77
+ fail_pending(Error.new("language server stopped"))
78
+ @state = :stopped
79
+ end
80
+ end
81
+
82
+ def running? = @state == :running && !!@transport&.alive?
83
+
84
+ def request(method, params = {})
85
+ id = @lock.synchronize { @sequence += 1 }
86
+ future = Future.new(id, on_error: method(:report_error)) { |number| cancel(number) }
87
+ @lock.synchronize { @pending[id] = future }
88
+ raise Error, "language server is not connected" unless @transport&.alive?
89
+
90
+ @transport.write(jsonrpc: "2.0", id: id, method: method.to_s, params: params)
91
+ future
92
+ rescue StandardError => error
93
+ @lock.synchronize { @pending.delete(id) }
94
+ future.fulfill(error: error)
95
+ future
96
+ end
97
+
98
+ def notify(method, params = {})
99
+ raise Error, "language server is not connected" unless @transport&.alive?
100
+
101
+ @transport.write(jsonrpc: "2.0", method: method.to_s, params: params)
102
+ end
103
+
104
+ def on(method, &handler)
105
+ raise ArgumentError, "handler required" unless handler
106
+
107
+ @handlers[method.to_s] = handler
108
+ end
109
+
110
+ def supports?(capability) = !!@capabilities[capability.to_s]
111
+
112
+ def open(document)
113
+ validate_document(document)
114
+ stored = Document.new(uri: document.uri.dup.freeze, language_id: document.language_id.dup.freeze,
115
+ version: document.version, text: document.text.dup.freeze)
116
+ @documents[stored.uri] = stored
117
+ notify_open(stored) if open_close?
118
+ stored.uri
119
+ end
120
+
121
+ def change(uri, version, changes)
122
+ document = @documents.fetch(uri) { raise Error, "document is not open" }
123
+ unless Protocol.uint?(version) && version > document.version
124
+ raise Error, "document version must increase"
125
+ end
126
+ raise Error, "content changes must be a nonempty Array" unless changes.is_a?(Array) && !changes.empty?
127
+
128
+ text = document.text
129
+ wire_changes = changes.map do |change|
130
+ validate_change(change)
131
+ if change.range
132
+ index = DocumentIndex.new(text)
133
+ first = Protocol.offset(index, change.range.start)
134
+ last = Protocol.offset(index, change.range.end)
135
+ raise Error, "invalid content change range" if last < first
136
+
137
+ text = text.byteslice(0, first) + change.text + text.byteslice(last, text.bytesize - last)
138
+ {range: Protocol.range_hash(change.range), text: change.text}
139
+ else
140
+ text = change.text
141
+ {text: change.text}
142
+ end
143
+ end
144
+ updated = Document.new(uri: document.uri, language_id: document.language_id, version: version, text: text.freeze)
145
+ @documents[uri] = updated
146
+
147
+ mode = sync_mode
148
+ return updated unless [1, 2].include?(mode)
149
+
150
+ content_changes = mode == 2 ? wire_changes : [{text: text}]
151
+ notify("textDocument/didChange", {textDocument: {uri: uri, version: version}, contentChanges: content_changes})
152
+ updated
153
+ rescue KeyError
154
+ raise Error, "document is not open"
155
+ end
156
+
157
+ def save(uri, text: nil)
158
+ document = @documents.fetch(uri) { raise Error, "document is not open" }
159
+ sync = @capabilities["textDocumentSync"]
160
+ save = sync.is_a?(Hash) ? sync["save"] : sync.is_a?(Integer) && sync.positive?
161
+ return unless save
162
+
163
+ if text
164
+ raise Error, "saved text must be valid UTF-8" unless text.is_a?(String) && text.valid_encoding?
165
+ end
166
+ params = {textDocument: {uri: uri}}
167
+ params[:text] = text || document.text if save.is_a?(Hash) && save["includeText"]
168
+ notify("textDocument/didSave", params)
169
+ rescue KeyError
170
+ raise Error, "document is not open"
171
+ end
172
+
173
+ def close(uri)
174
+ raise Error, "document is not open" unless @documents.delete(uri)
175
+
176
+ @diagnostics.delete(uri)
177
+ @semantic.delete(uri)
178
+ notify("textDocument/didClose", {textDocument: {uri: uri}}) if open_close?
179
+ end
180
+
181
+ POSITION_METHODS.each do |ruby_name, lsp_name|
182
+ define_method(ruby_name) do |uri, position, **params|
183
+ request_at(lsp_name, uri, position, params)
184
+ end
185
+ end
186
+
187
+ def references(uri, position, include_declaration: true)
188
+ request_at("references", uri, position, context: {includeDeclaration: !!include_declaration})
189
+ end
190
+
191
+ def rename(uri, position, new_name)
192
+ raise Error, "new name must be a String" unless new_name.is_a?(String) && new_name.valid_encoding?
193
+
194
+ request_at("rename", uri, position, newName: new_name)
195
+ end
196
+
197
+ def document_symbol(uri) = request_document("documentSymbol", uri)
198
+
199
+ def formatting(uri, options)
200
+ raise Error, "formatting options must be an object" unless options.is_a?(Hash)
201
+
202
+ request_document("formatting", uri, options: options)
203
+ end
204
+
205
+ def code_action(uri, range, context)
206
+ raise Error, "code action context must be an object" unless context.is_a?(Hash)
207
+
208
+ request_document("codeAction", uri, range: Protocol.range_hash(range), context: context)
209
+ end
210
+
211
+ def code_lens(uri) = request_document("codeLens", uri)
212
+ def inlay_hint(uri, range) = request_document("inlayHint", uri, range: Protocol.range_hash(range))
213
+
214
+ def diagnostic(uri, previous_result_id: nil)
215
+ params = {}
216
+ if previous_result_id
217
+ raise Error, "previous result id must be a String" unless previous_result_id.is_a?(String)
218
+
219
+ params[:previousResultId] = previous_result_id
220
+ end
221
+ request_document("diagnostic", uri, **params)
222
+ end
223
+
224
+ def semantic_tokens(uri, version:)
225
+ document = @documents[uri]
226
+ return [] unless document && document.version == version
227
+
228
+ provider = @capabilities["semanticTokensProvider"]
229
+ return [] unless provider.is_a?(Hash) && provider["full"]
230
+
231
+ previous = @semantic[uri]
232
+ delta = previous && previous[0] && provider["full"].is_a?(Hash) && provider["full"]["delta"]
233
+ method = delta ? "textDocument/semanticTokens/full/delta" : "textDocument/semanticTokens/full"
234
+ params = {textDocument: {uri: uri}}
235
+ params[:previousResultId] = previous[0] if delta
236
+ result = request(method, params).await
237
+ current = @documents[uri]
238
+ return [] unless result && current && current.version == version
239
+
240
+ valid = result.is_a?(Hash) && (!result.key?("resultId") || result["resultId"].is_a?(String))
241
+ raise Error, "invalid semantic token result" unless valid
242
+ raise Error, "unexpected semantic token delta" if !delta && !result.key?("data")
243
+
244
+ data = result["data"] || Protocol.semantic_delta(previous[1], result["edits"])
245
+ tokens = Protocol.semantic_tokens(data, legend: provider["legend"])
246
+ @semantic[uri] = [result["resultId"], data]
247
+ tokens
248
+ end
249
+
250
+ def workspace_symbols(query)
251
+ raise Error, "query must be a String" unless query.is_a?(String)
252
+
253
+ request("workspace/symbol", {query: query})
254
+ end
255
+
256
+ def resolve_completion(item) = resolve("completionItem/resolve", item)
257
+ def resolve_code_action(action) = resolve("codeAction/resolve", action)
258
+ def resolve_code_lens(lens) = resolve("codeLens/resolve", lens)
259
+
260
+ def execute_command(command, arguments: [])
261
+ raise Error, "command must be a nonempty String" unless command.is_a?(String) && !command.empty?
262
+ raise Error, "arguments must be an Array" unless arguments.is_a?(Array)
263
+
264
+ request("workspace/executeCommand", {command: command, arguments: arguments})
265
+ end
266
+
267
+ private
268
+
269
+ def initialize_params
270
+ {
271
+ processId: Process.pid,
272
+ rootUri: Protocol.uri(@root),
273
+ clientInfo: {name: "Sadr", version: VERSION},
274
+ workspaceFolders: [{uri: Protocol.uri(@root), name: File.basename(@root)}],
275
+ initializationOptions: @initialization_options,
276
+ capabilities: {
277
+ general: {positionEncodings: ["utf-16"]},
278
+ window: {workDoneProgress: true},
279
+ textDocument: {
280
+ synchronization: {dynamicRegistration: false, didSave: true},
281
+ completion: {completionItem: {snippetSupport: true, documentationFormat: %w[markdown plaintext], resolveSupport: {properties: %w[documentation detail additionalTextEdits]}}},
282
+ hover: {contentFormat: %w[markdown plaintext]},
283
+ signatureHelp: {signatureInformation: {documentationFormat: %w[markdown plaintext], parameterInformation: {labelOffsetSupport: true}}},
284
+ documentSymbol: {hierarchicalDocumentSymbolSupport: true},
285
+ codeAction: {codeActionLiteralSupport: {codeActionKind: {valueSet: %w[quickfix refactor refactor.extract refactor.inline refactor.rewrite source source.organizeImports]}}, resolveSupport: {properties: ["edit"]}},
286
+ publishDiagnostics: {relatedInformation: true, versionSupport: true},
287
+ diagnostic: {dynamicRegistration: false, relatedDocumentSupport: false},
288
+ inlayHint: {dynamicRegistration: false},
289
+ codeLens: {dynamicRegistration: false},
290
+ semanticTokens: {requests: {full: {delta: true}}, tokenTypes: %w[namespace type class enum interface struct typeParameter parameter variable property enumMember event function method macro keyword modifier comment string number regexp operator decorator], tokenModifiers: %w[declaration definition readonly static deprecated abstract async modification documentation defaultLibrary], formats: ["relative"], overlappingTokenSupport: false, multilineTokenSupport: false}
291
+ },
292
+ workspace: {applyEdit: true, configuration: true, workspaceFolders: true, workspaceEdit: {documentChanges: true, resourceOperations: %w[create rename delete], failureHandling: "abort"}}
293
+ }
294
+ }
295
+ end
296
+
297
+ def validate_capabilities
298
+ mode = sync_mode
299
+ raise Error, "invalid text document synchronization mode" unless mode.nil? || [0, 1, 2].include?(mode)
300
+
301
+ @position_encoding = @capabilities.fetch("positionEncoding", "utf-16")
302
+ unless @position_encoding == "utf-16"
303
+ raise Error, "server selected unadvertised position encoding #{@position_encoding}"
304
+ end
305
+ semantic = @capabilities["semanticTokensProvider"]
306
+ raise Error, "missing semantic token legend" if semantic && (!semantic.is_a?(Hash) || !semantic["legend"].is_a?(Hash))
307
+
308
+ Protocol.semantic_tokens([], legend: semantic["legend"]) if semantic.is_a?(Hash)
309
+ end
310
+
311
+ def validate_document(document)
312
+ raise Error, "expected a Document" unless document.is_a?(Document)
313
+ valid_uri(document.uri)
314
+ unless document.language_id.is_a?(String) && !document.language_id.empty?
315
+ raise Error, "language id must be a nonempty String"
316
+ end
317
+ raise Error, "document version must be an unsigned integer" unless Protocol.uint?(document.version)
318
+ unless document.text.is_a?(String) && document.text.valid_encoding?
319
+ raise Error, "document text must be valid UTF-8"
320
+ end
321
+ end
322
+
323
+ def validate_change(change)
324
+ raise Error, "expected a ContentChange" unless change.is_a?(ContentChange)
325
+ raise Error, "change text must be valid UTF-8" unless change.text.is_a?(String) && change.text.valid_encoding?
326
+
327
+ Protocol.range_value(change.range) if change.range
328
+ end
329
+
330
+ def valid_uri(uri)
331
+ raise Error, "URI must be a nonempty String" unless uri.is_a?(String) && !uri.empty? && !uri.include?("\0") && uri.valid_encoding?
332
+
333
+ uri
334
+ end
335
+
336
+ def request_at(method, uri, position, params = {})
337
+ core = {textDocument: {uri: valid_uri(uri)}, position: Protocol.position_hash(position)}
338
+ request("textDocument/#{method}", params.merge(core))
339
+ end
340
+
341
+ def request_document(method, uri, **params)
342
+ request("textDocument/#{method}", params.merge(textDocument: {uri: valid_uri(uri)}))
343
+ end
344
+
345
+ def resolve(method, value)
346
+ raise Error, "resolve value must be an object" unless value.is_a?(Hash)
347
+
348
+ request(method, value)
349
+ end
350
+
351
+ def notify_open(document)
352
+ notify("textDocument/didOpen", {textDocument: {uri: document.uri, languageId: document.language_id, version: document.version, text: document.text}})
353
+ end
354
+
355
+ def sync_mode
356
+ sync = @capabilities["textDocumentSync"]
357
+ sync.is_a?(Hash) ? sync.fetch("change", 0) : sync
358
+ end
359
+
360
+ def open_close?
361
+ sync = @capabilities["textDocumentSync"]
362
+ sync.is_a?(Hash) ? sync["openClose"] : sync.is_a?(Integer) && sync.positive?
363
+ end
364
+
365
+ def cancel(id)
366
+ @lock.synchronize { @pending.delete(id) }
367
+ notify("$/cancelRequest", {id: id})
368
+ rescue Error
369
+ nil
370
+ end
371
+
372
+ def fail_pending(error)
373
+ pending = @lock.synchronize do
374
+ values = @pending.values
375
+ @pending.clear
376
+ values
377
+ end
378
+ pending.each { |future| future.fulfill(error: error) }
379
+ end
380
+
381
+ def report_error(error)
382
+ bounded = Error.new("#{error.class}: #{error.message}".scrub.byteslice(0, 2048).scrub(""))
383
+ @lock.synchronize do
384
+ @errors << bounded
385
+ @errors.shift if @errors.length > 200
386
+ end
387
+ @dispatch.call do
388
+ begin
389
+ @handlers["error"]&.call(bounded)
390
+ rescue StandardError
391
+ nil
392
+ end
393
+ end
394
+ rescue StandardError
395
+ nil
396
+ end
397
+
398
+ def receive(message, error, epoch = @epoch)
399
+ return unless epoch == @epoch
400
+
401
+ if error
402
+ receive_error(error)
403
+ elsif message.key?("id") && !message.key?("method")
404
+ future = @lock.synchronize { @pending.delete(message["id"]) }
405
+ future&.fulfill(message["result"], error: message["error"] && ServerError.new(message["error"]))
406
+ elsif message["method"]
407
+ receive_call(message, epoch)
408
+ end
409
+ rescue StandardError => failure
410
+ report_error(failure)
411
+ if message&.key?("id") && message.key?("method")
412
+ reply(message["id"], epoch, error: {code: -32603, message: "client dispatch failed"})
413
+ end
414
+ end
415
+
416
+ def receive_error(error)
417
+ running = @state == :running
418
+ @state = :failed
419
+ fail_pending(Error.new(error.message))
420
+ report_error(error)
421
+ restart_server if running && @restart && !@closing && @restarts < 3
422
+ end
423
+
424
+ def receive_call(message, epoch)
425
+ method = message["method"]
426
+ params = message.fetch("params", {})
427
+ @dispatch.call do
428
+ next unless epoch == @epoch && !@closing
429
+
430
+ begin
431
+ receive_diagnostics(params) if method == "textDocument/publishDiagnostics"
432
+ known = @handlers.key?(method)
433
+ result = @handlers[method]&.call(params)
434
+ next unless message.key?("id")
435
+
436
+ unless known
437
+ known, result = built_in_request(method, params)
438
+ end
439
+ respond_to_server(message["id"], epoch, known, result, method)
440
+ rescue StandardError => failure
441
+ report_error(failure)
442
+ reply(message["id"], epoch, error: {code: -32603, message: "client request handler failed"}) if message.key?("id")
443
+ end
444
+ end
445
+ end
446
+
447
+ def receive_diagnostics(params)
448
+ valid = params.is_a?(Hash) && params["uri"].is_a?(String) && params["diagnostics"].is_a?(Array)
449
+ raise Error, "invalid diagnostics notification" unless valid
450
+
451
+ document = @documents[params["uri"]]
452
+ version = params["version"]
453
+ raise Error, "invalid diagnostic version" if !version.nil? && !version.is_a?(Integer)
454
+ return if document && version && version < document.version
455
+
456
+ @diagnostics[params["uri"]] = Protocol.diagnostics(params["diagnostics"])
457
+ end
458
+
459
+ def built_in_request(method, params)
460
+ case method
461
+ when "workspace/configuration"
462
+ items = params.fetch("items")
463
+ raise Error, "invalid configuration request" unless items.is_a?(Array)
464
+
465
+ [true, items.map do |item|
466
+ section = item["section"]
467
+ section ? @configuration.dig(*section.split(".")) : @configuration
468
+ end]
469
+ when "workspace/workspaceFolders"
470
+ [true, [{uri: Protocol.uri(@root), name: File.basename(@root)}]]
471
+ when "window/workDoneProgress/create", "workspace/semanticTokens/refresh", "workspace/inlayHint/refresh", "workspace/codeLens/refresh", "workspace/diagnostic/refresh"
472
+ @semantic.clear if method == "workspace/semanticTokens/refresh"
473
+ [true, nil]
474
+ else
475
+ [false, nil]
476
+ end
477
+ end
478
+
479
+ def respond_to_server(id, epoch, known, result, method)
480
+ unless known
481
+ reply(id, epoch, error: {code: -32601, message: "unsupported client request #{method}"})
482
+ return
483
+ end
484
+ if result.is_a?(Future)
485
+ result.then do |value, failure|
486
+ if failure
487
+ reply(id, epoch, error: {code: -32603, message: failure.message.byteslice(0, 2048).scrub})
488
+ else
489
+ reply(id, epoch, value: value)
490
+ end
491
+ end
492
+ else
493
+ reply(id, epoch, value: result)
494
+ end
495
+ end
496
+
497
+ def reply(id, epoch, value: nil, error: nil)
498
+ return unless epoch == @epoch && !@closing
499
+
500
+ response = {jsonrpc: "2.0", id: id}
501
+ error ? response[:error] = error : response[:result] = value
502
+ @transport.write(response)
503
+ rescue StandardError => failure
504
+ report_error(failure)
505
+ end
506
+
507
+ def restart_server
508
+ return if @restart_thread&.alive?
509
+
510
+ previous = @transport
511
+ @restart_thread = Thread.new do
512
+ previous.close
513
+ until @closing || @restarts >= 3
514
+ @restarts += 1
515
+ sleep(0.2 * @restarts)
516
+ break if @closing
517
+
518
+ begin
519
+ start
520
+ @documents.values.dup.each { |document| notify_open(document) if open_close? }
521
+ break
522
+ rescue StandardError => failure
523
+ report_error(failure)
524
+ end
525
+ end
526
+ end
527
+ end
528
+ end
529
+ end