sadr 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +67 -1
- data/lib/sadr/client.rb +994 -175
- data/lib/sadr/future.rb +1 -1
- data/lib/sadr/protocol.rb +135 -11
- data/lib/sadr/testing/fake_client.rb +20 -0
- data/lib/sadr/testing/fake_server.rb +173 -0
- data/lib/sadr/testing/fake_transport.rb +108 -0
- data/lib/sadr/testing.rb +6 -0
- data/lib/sadr/transport.rb +67 -21
- data/lib/sadr/version.rb +1 -1
- data/lib/sadr.rb +27 -3
- data/sig/sadr.rbs +65 -9
- metadata +6 -2
data/lib/sadr/future.rb
CHANGED
|
@@ -58,7 +58,7 @@ module Sadr
|
|
|
58
58
|
Subscription.new { @lock.synchronize { @callbacks.delete(callback) } }
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
def await(timeout:
|
|
61
|
+
def await(timeout: nil)
|
|
62
62
|
valid = timeout.nil? || (timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0)
|
|
63
63
|
raise ArgumentError, "timeout must be finite and nonnegative" unless valid
|
|
64
64
|
|
data/lib/sadr/protocol.rb
CHANGED
|
@@ -9,16 +9,16 @@ module Sadr
|
|
|
9
9
|
|
|
10
10
|
absolute = File.expand_path(path).tr("\\", "/")
|
|
11
11
|
absolute = "/#{absolute}" if absolute.match?(/\A[A-Za-z]:/)
|
|
12
|
-
"file://" +
|
|
12
|
+
"file://" + percent_encode(absolute)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def path(uri)
|
|
16
|
-
parsed = URI.parse(uri)
|
|
16
|
+
parsed = URI::DEFAULT_PARSER.parse(uri)
|
|
17
17
|
valid = parsed.scheme == "file" && [nil, "", "localhost"].include?(parsed.host) &&
|
|
18
18
|
parsed.query.nil? && parsed.fragment.nil? && parsed.path&.start_with?("/")
|
|
19
19
|
raise Error, "expected local file URI" unless valid
|
|
20
20
|
|
|
21
|
-
value =
|
|
21
|
+
value = percent_decode(parsed.path)
|
|
22
22
|
raise Error, "invalid file URI path" if value.include?("\0") || !value.valid_encoding?
|
|
23
23
|
|
|
24
24
|
RUBY_PLATFORM.match?(/mswin|mingw/) ? value.sub(%r{\A/([A-Za-z]:/)}, '\\1') : value
|
|
@@ -74,17 +74,19 @@ module Sadr
|
|
|
74
74
|
inserted = fetch(edit, "data", [])
|
|
75
75
|
raise Error, "invalid semantic token delta" unless valid && inserted.is_a?(Array)
|
|
76
76
|
end
|
|
77
|
-
sorted = edits.sort_by { |edit| fetch(edit, "start") }
|
|
78
77
|
previous_end = 0
|
|
79
|
-
|
|
78
|
+
previous_start = nil
|
|
79
|
+
edits.each do |edit|
|
|
80
80
|
start = fetch(edit, "start")
|
|
81
81
|
count = fetch(edit, "deleteCount")
|
|
82
|
-
|
|
82
|
+
duplicate = previous_start && start == previous_start
|
|
83
|
+
raise Error, "invalid semantic token delta" if duplicate || start < previous_end || start + count > data.length
|
|
83
84
|
|
|
85
|
+
previous_start = start
|
|
84
86
|
previous_end = start + count
|
|
85
87
|
end
|
|
86
88
|
output = data.dup
|
|
87
|
-
|
|
89
|
+
edits.reverse_each do |edit|
|
|
88
90
|
output[fetch(edit, "start"), fetch(edit, "deleteCount")] = fetch(edit, "data", [])
|
|
89
91
|
end
|
|
90
92
|
scan_semantic(output, nil, false)
|
|
@@ -108,6 +110,29 @@ module Sadr
|
|
|
108
110
|
values
|
|
109
111
|
end
|
|
110
112
|
|
|
113
|
+
def workspace_edit(value)
|
|
114
|
+
raise Error, "invalid workspace edit" unless value.is_a?(Hash)
|
|
115
|
+
|
|
116
|
+
changes = fetch(value, "changes", nil)
|
|
117
|
+
if value.key?("changes") || value.key?(:changes)
|
|
118
|
+
raise Error, "invalid workspace edit changes" unless changes.is_a?(Hash)
|
|
119
|
+
|
|
120
|
+
changes.each do |uri, edits|
|
|
121
|
+
validate_uri(uri)
|
|
122
|
+
validate_text_edits(edits)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
document_changes = fetch(value, "documentChanges", nil)
|
|
126
|
+
if value.key?("documentChanges") || value.key?(:documentChanges)
|
|
127
|
+
raise Error, "invalid workspace document changes" unless document_changes.is_a?(Array)
|
|
128
|
+
|
|
129
|
+
document_changes.each { |change| validate_document_change(change) }
|
|
130
|
+
end
|
|
131
|
+
value
|
|
132
|
+
rescue KeyError
|
|
133
|
+
raise Error, "invalid workspace edit"
|
|
134
|
+
end
|
|
135
|
+
|
|
111
136
|
def semantic_tokens(data, legend: nil)
|
|
112
137
|
scan_semantic(data, legend, true)
|
|
113
138
|
end
|
|
@@ -122,8 +147,37 @@ module Sadr
|
|
|
122
147
|
raise Error, "invalid semantic token legend" unless valid
|
|
123
148
|
end
|
|
124
149
|
|
|
125
|
-
unless data.
|
|
126
|
-
|
|
150
|
+
unless data.empty?
|
|
151
|
+
begin
|
|
152
|
+
total = data.sum
|
|
153
|
+
minimum, maximum = data.minmax
|
|
154
|
+
rescue StandardError
|
|
155
|
+
raise Error, "invalid semantic token value"
|
|
156
|
+
end
|
|
157
|
+
valid = total.is_a?(Integer) && minimum.is_a?(Integer) && maximum.is_a?(Integer) &&
|
|
158
|
+
minimum >= 0 && maximum <= 0x7fffffff
|
|
159
|
+
raise Error, "invalid semantic token value" unless valid
|
|
160
|
+
end
|
|
161
|
+
unless collect || legend
|
|
162
|
+
row = 0
|
|
163
|
+
column = 0
|
|
164
|
+
index = 2
|
|
165
|
+
size = data.length
|
|
166
|
+
while index < size
|
|
167
|
+
length = data[index]
|
|
168
|
+
delta_row = data[index - 2]
|
|
169
|
+
delta_column = data[index - 1]
|
|
170
|
+
raise Error, "invalid semantic token value" if length.zero?
|
|
171
|
+
|
|
172
|
+
row += delta_row
|
|
173
|
+
column = delta_row.zero? ? column + delta_column : delta_column
|
|
174
|
+
raise Error, "semantic token position overflow" if column + length > 0x7fffffff
|
|
175
|
+
|
|
176
|
+
index += 5
|
|
177
|
+
end
|
|
178
|
+
raise Error, "semantic token position overflow" if row > 0x7fffffff
|
|
179
|
+
|
|
180
|
+
return nil
|
|
127
181
|
end
|
|
128
182
|
row = 0
|
|
129
183
|
column = 0
|
|
@@ -144,12 +198,14 @@ module Sadr
|
|
|
144
198
|
|
|
145
199
|
row += delta_row
|
|
146
200
|
column = delta_row.zero? ? column + delta_column : delta_column
|
|
147
|
-
raise Error, "semantic token position overflow"
|
|
201
|
+
raise Error, "semantic token position overflow" if row > 0x7fffffff || column + length > 0x7fffffff
|
|
148
202
|
|
|
149
203
|
tokens[index / 5] = Token.new(line: row, character: column, length: length, type: type, modifiers: modifiers) if collect
|
|
150
204
|
index += 5
|
|
151
205
|
end
|
|
152
206
|
tokens
|
|
207
|
+
rescue TypeError, ArgumentError
|
|
208
|
+
raise Error, "invalid semantic token value"
|
|
153
209
|
end
|
|
154
210
|
|
|
155
211
|
def uint?(value)
|
|
@@ -173,6 +229,8 @@ module Sadr
|
|
|
173
229
|
return Range_.new(start: first, end: last) if (position_tuple(first) <=> position_tuple(last)) <= 0
|
|
174
230
|
end
|
|
175
231
|
raise Error, "invalid LSP range"
|
|
232
|
+
rescue KeyError
|
|
233
|
+
raise Error, "invalid LSP range"
|
|
176
234
|
end
|
|
177
235
|
|
|
178
236
|
def position_hash(value)
|
|
@@ -185,6 +243,71 @@ module Sadr
|
|
|
185
243
|
{start: position_hash(value.start), end: position_hash(value.end)}
|
|
186
244
|
end
|
|
187
245
|
|
|
246
|
+
def validate_text_edits(edits)
|
|
247
|
+
raise Error, "invalid LSP text edits" unless edits.is_a?(Array)
|
|
248
|
+
|
|
249
|
+
edits.each do |edit|
|
|
250
|
+
valid = edit.is_a?(Hash) && fetch(edit, "newText", nil).is_a?(String) && fetch(edit, "newText").valid_encoding?
|
|
251
|
+
raise Error, "invalid LSP text edit" unless valid
|
|
252
|
+
|
|
253
|
+
range_value(fetch(edit, "range"))
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def validate_document_change(change)
|
|
258
|
+
raise Error, "invalid workspace document change" unless change.is_a?(Hash)
|
|
259
|
+
|
|
260
|
+
kind = fetch(change, "kind", nil)
|
|
261
|
+
case kind
|
|
262
|
+
when "create", "delete"
|
|
263
|
+
validate_uri(fetch(change, "uri"))
|
|
264
|
+
when "rename"
|
|
265
|
+
validate_uri(fetch(change, "oldUri"))
|
|
266
|
+
validate_uri(fetch(change, "newUri"))
|
|
267
|
+
when nil
|
|
268
|
+
document = fetch(change, "textDocument", nil)
|
|
269
|
+
raise Error, "invalid workspace text document" unless document.is_a?(Hash)
|
|
270
|
+
|
|
271
|
+
validate_uri(fetch(document, "uri"))
|
|
272
|
+
unless document.key?("version") || document.key?(:version)
|
|
273
|
+
raise Error, "invalid workspace document version"
|
|
274
|
+
end
|
|
275
|
+
version = fetch(document, "version", nil)
|
|
276
|
+
valid_version = version.nil? || (version.is_a?(Integer) && version.between?(-0x80000000, 0x7fffffff))
|
|
277
|
+
raise Error, "invalid workspace document version" unless valid_version
|
|
278
|
+
validate_text_edits(fetch(change, "edits"))
|
|
279
|
+
else
|
|
280
|
+
raise Error, "unknown workspace resource operation"
|
|
281
|
+
end
|
|
282
|
+
options = fetch(change, "options", nil)
|
|
283
|
+
raise Error, "invalid workspace resource options" if options && !options.is_a?(Hash)
|
|
284
|
+
rescue KeyError
|
|
285
|
+
raise Error, "invalid workspace document change"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def validate_uri(value)
|
|
289
|
+
valid = value.is_a?(String) && !value.empty? && value.valid_encoding? && !value.include?("\0")
|
|
290
|
+
parsed = URI::DEFAULT_PARSER.parse(value) if valid
|
|
291
|
+
raise Error, "invalid URI" unless valid && parsed&.scheme && !parsed.scheme.empty?
|
|
292
|
+
|
|
293
|
+
value
|
|
294
|
+
rescue URI::InvalidURIError
|
|
295
|
+
raise Error, "invalid URI"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def percent_encode(value)
|
|
299
|
+
value.b.each_byte.map do |byte|
|
|
300
|
+
character = byte.chr
|
|
301
|
+
character.match?(/[a-zA-Z0-9\-._~\/:]/) ? character : format("%%%02X", byte)
|
|
302
|
+
end.join
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def percent_decode(value)
|
|
306
|
+
raise Error, "invalid file URI path" if value.match?(/%(?![0-9A-Fa-f]{2})/)
|
|
307
|
+
|
|
308
|
+
value.b.gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).to_i(16).chr }.force_encoding(Encoding::UTF_8)
|
|
309
|
+
end
|
|
310
|
+
|
|
188
311
|
def valid_position?(value)
|
|
189
312
|
value.is_a?(Hash) && uint?(fetch(value, "line")) && uint?(fetch(value, "character"))
|
|
190
313
|
end
|
|
@@ -203,6 +326,7 @@ module Sadr
|
|
|
203
326
|
raise KeyError, "key not found: #{key}"
|
|
204
327
|
end
|
|
205
328
|
|
|
206
|
-
private_class_method :scan_semantic, :
|
|
329
|
+
private_class_method :scan_semantic, :validate_text_edits, :validate_document_change, :validate_uri, :percent_encode, :percent_decode,
|
|
330
|
+
:valid_position?, :position_tuple, :fetch
|
|
207
331
|
end
|
|
208
332
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sadr
|
|
4
|
+
module Testing
|
|
5
|
+
class FakeClient < Client
|
|
6
|
+
attr_reader :server
|
|
7
|
+
|
|
8
|
+
def initialize(server: FakeServer.new, **options)
|
|
9
|
+
@server = server
|
|
10
|
+
super(**{command: FakeServer.command, restart: false}.merge(options))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def build_transport(epoch)
|
|
16
|
+
@server.transport { |message, error| receive(message, error, epoch) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Sadr
|
|
6
|
+
module Testing
|
|
7
|
+
class FakeServer
|
|
8
|
+
DEFAULT_CAPABILITIES = {
|
|
9
|
+
"positionEncoding" => "utf-16",
|
|
10
|
+
"textDocumentSync" => {"openClose" => true, "change" => 2, "save" => {"includeText" => true}}
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
SCRIPT = <<~'RUBY'
|
|
14
|
+
require "json"
|
|
15
|
+
STDIN.binmode
|
|
16
|
+
STDOUT.binmode
|
|
17
|
+
STDOUT.sync = true
|
|
18
|
+
|
|
19
|
+
def send_message(message)
|
|
20
|
+
body = JSON.generate(message)
|
|
21
|
+
STDOUT.write("Content-Length: #{body.bytesize}\r\n\r\n#{body}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
range = {start: {line: 0, character: 0}, end: {line: 0, character: 1}}
|
|
25
|
+
command = {title: "Run", command: "run", arguments: []}
|
|
26
|
+
messages = []
|
|
27
|
+
semantic_refreshed = false
|
|
28
|
+
pending_semantic = nil
|
|
29
|
+
loop do
|
|
30
|
+
headers = {}
|
|
31
|
+
while (line = STDIN.gets) && line != "\r\n"
|
|
32
|
+
key, value = line.strip.split(":", 2)
|
|
33
|
+
headers[key] = value.strip
|
|
34
|
+
end
|
|
35
|
+
break unless line
|
|
36
|
+
|
|
37
|
+
message = JSON.parse(STDIN.read(headers.fetch("Content-Length").to_i))
|
|
38
|
+
messages << message
|
|
39
|
+
break if message["method"] == "exit"
|
|
40
|
+
next unless message.key?("id") && message.key?("method")
|
|
41
|
+
|
|
42
|
+
case message["method"]
|
|
43
|
+
when "initialize"
|
|
44
|
+
result = {capabilities: {positionEncoding: "utf-16", textDocumentSync: {openClose: true, change: 2, save: {includeText: true}}, semanticTokensProvider: {legend: {tokenTypes: ["variable"], tokenModifiers: []}, full: {delta: true}}}}
|
|
45
|
+
when "probe"
|
|
46
|
+
result = messages
|
|
47
|
+
when "server_request"
|
|
48
|
+
send_message(jsonrpc: "2.0", id: "server-1", method: message["params"]["method"], params: message["params"].fetch("params", {}))
|
|
49
|
+
result = true
|
|
50
|
+
when "server_notification"
|
|
51
|
+
send_message(jsonrpc: "2.0", method: message["params"]["method"], params: message["params"].fetch("params", {}))
|
|
52
|
+
result = true
|
|
53
|
+
when "stderr"
|
|
54
|
+
STDERR.write("x" * (2 << 20))
|
|
55
|
+
STDERR.flush
|
|
56
|
+
result = true
|
|
57
|
+
when "never"
|
|
58
|
+
next
|
|
59
|
+
when "crash"
|
|
60
|
+
exit!(1)
|
|
61
|
+
when "textDocument/semanticTokens/full"
|
|
62
|
+
if ENV["SADR_SEMANTIC_REVERSE"]
|
|
63
|
+
unless pending_semantic
|
|
64
|
+
pending_semantic = message
|
|
65
|
+
send_message(jsonrpc: "2.0", method: "semantic_started", params: {})
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
send_message(jsonrpc: "2.0", id: message["id"], result: {resultId: "new", data: [0, 0, 2, 0, 0]})
|
|
69
|
+
send_message(jsonrpc: "2.0", id: pending_semantic["id"], result: {resultId: "old", data: [0, 0, 1, 0, 0]})
|
|
70
|
+
pending_semantic = nil
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
if ENV["SADR_SEMANTIC_REFRESH"] && !semantic_refreshed
|
|
74
|
+
semantic_refreshed = true
|
|
75
|
+
send_message(jsonrpc: "2.0", id: "semantic-refresh", method: "workspace/semanticTokens/refresh", params: {})
|
|
76
|
+
sleep(0.02)
|
|
77
|
+
end
|
|
78
|
+
if ENV["SADR_SEMANTIC_DELAY"]
|
|
79
|
+
send_message(jsonrpc: "2.0", method: "semantic_started", params: {})
|
|
80
|
+
sleep(Float(ENV["SADR_SEMANTIC_DELAY"]))
|
|
81
|
+
end
|
|
82
|
+
result = {resultId: "first", data: [0, 0, 1, 0, 0]}
|
|
83
|
+
when "textDocument/semanticTokens/full/delta"
|
|
84
|
+
result = {resultId: "second", edits: [{start: 2, deleteCount: 1, data: [2]}]}
|
|
85
|
+
when "shutdown"
|
|
86
|
+
result = nil
|
|
87
|
+
when "textDocument/completion"
|
|
88
|
+
result = {isIncomplete: false, items: [{label: "x", detail: "optional"}], itemDefaults: {commitCharacters: ["."]}}
|
|
89
|
+
when "textDocument/hover"
|
|
90
|
+
result = {contents: {kind: "markdown", value: "hover"}, range: range, extension: true}
|
|
91
|
+
when "textDocument/signatureHelp"
|
|
92
|
+
result = {signatures: [{label: "f(x)", parameters: [{label: [2, 3]}]}], activeSignature: 0}
|
|
93
|
+
when "textDocument/documentSymbol"
|
|
94
|
+
result = [{name: "x", kind: 13, range: range, selectionRange: range, children: []}]
|
|
95
|
+
when "workspace/symbol"
|
|
96
|
+
result = [{name: "x", kind: 13, location: {uri: "file:///tmp/test.rb"}, data: {optional: true}}]
|
|
97
|
+
when "textDocument/codeAction"
|
|
98
|
+
result = [{title: "Fix", command: command}]
|
|
99
|
+
when "textDocument/codeLens"
|
|
100
|
+
result = [{range: range, command: command, data: {optional: true}}]
|
|
101
|
+
when "textDocument/inlayHint"
|
|
102
|
+
result = [{position: {line: 0, character: 0}, label: [{value: "x", tooltip: "optional"}]}]
|
|
103
|
+
when "textDocument/definition", "textDocument/typeDefinition", "textDocument/implementation", "textDocument/references", "textDocument/formatting"
|
|
104
|
+
result = []
|
|
105
|
+
when "textDocument/rename"
|
|
106
|
+
result = {changes: {}}
|
|
107
|
+
when "textDocument/diagnostic"
|
|
108
|
+
result = {kind: "full", items: []}
|
|
109
|
+
else
|
|
110
|
+
result = message["params"]
|
|
111
|
+
end
|
|
112
|
+
if ENV["SADR_INVALID_METHOD"] == message["method"]
|
|
113
|
+
result = message["method"] == "textDocument/formatting" ? [{range: {}, newText: "x"}] : "invalid"
|
|
114
|
+
end
|
|
115
|
+
if ENV["SADR_INVALID_ELEMENTS"]
|
|
116
|
+
result = case message["method"]
|
|
117
|
+
when "textDocument/completion" then [{}]
|
|
118
|
+
when "textDocument/definition" then {}
|
|
119
|
+
when "textDocument/codeAction", "textDocument/codeLens", "textDocument/inlayHint" then [1]
|
|
120
|
+
when "textDocument/diagnostic" then {}
|
|
121
|
+
else result
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
if ENV["SADR_INVALID_STRUCTURES"]
|
|
125
|
+
result = case message["method"]
|
|
126
|
+
when "textDocument/completion" then {items: [{label: "x"}]}
|
|
127
|
+
when "textDocument/hover" then {contents: {}}
|
|
128
|
+
when "textDocument/signatureHelp" then {signatures: [{}]}
|
|
129
|
+
when "textDocument/documentSymbol" then [{name: "x", kind: 13, range: range}]
|
|
130
|
+
when "workspace/symbol" then [{name: "x", kind: 13, location: {}}]
|
|
131
|
+
when "completionItem/resolve" then {}
|
|
132
|
+
when "textDocument/codeLens" then [{range: range, command: {title: "Run"}}]
|
|
133
|
+
else result
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
send_message(jsonrpc: "2.0", id: message["id"], result: result)
|
|
137
|
+
end
|
|
138
|
+
RUBY
|
|
139
|
+
|
|
140
|
+
def self.command = [RbConfig.ruby, "-e", SCRIPT]
|
|
141
|
+
|
|
142
|
+
attr_reader :capabilities
|
|
143
|
+
|
|
144
|
+
def initialize(responses: {}, capabilities: DEFAULT_CAPABILITIES)
|
|
145
|
+
raise ArgumentError, "responses must be a Hash" unless responses.is_a?(Hash)
|
|
146
|
+
raise ArgumentError, "capabilities must be a Hash" unless capabilities.is_a?(Hash)
|
|
147
|
+
|
|
148
|
+
@responses = responses.transform_keys(&:to_s)
|
|
149
|
+
@capabilities = capabilities
|
|
150
|
+
@messages = []
|
|
151
|
+
@lock = Mutex.new
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def transport(&receive) = FakeTransport.new(self, &receive)
|
|
155
|
+
|
|
156
|
+
def messages = @lock.synchronize { @messages.dup }
|
|
157
|
+
|
|
158
|
+
def dispatch(message)
|
|
159
|
+
@lock.synchronize { @messages << message }
|
|
160
|
+
return [] unless message.key?("id") && message.key?("method")
|
|
161
|
+
|
|
162
|
+
result = case message["method"]
|
|
163
|
+
when "initialize" then {"capabilities" => @capabilities}
|
|
164
|
+
when "shutdown" then nil
|
|
165
|
+
else
|
|
166
|
+
response = @responses.fetch(message["method"], message["params"])
|
|
167
|
+
response.respond_to?(:call) ? response.call(message["params"], message) : response
|
|
168
|
+
end
|
|
169
|
+
[{"jsonrpc" => "2.0", "id" => message["id"], "result" => result}]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sadr
|
|
4
|
+
module Testing
|
|
5
|
+
class FakeTransport < Transport
|
|
6
|
+
MAX_TRY_FRAME = 512
|
|
7
|
+
STOP = Object.new.freeze
|
|
8
|
+
private_constant :MAX_TRY_FRAME, :STOP
|
|
9
|
+
|
|
10
|
+
attr_reader :stderr_lines, :pid
|
|
11
|
+
|
|
12
|
+
def initialize(server, &receive)
|
|
13
|
+
raise ArgumentError, "receiver required" unless receive
|
|
14
|
+
|
|
15
|
+
@server = server
|
|
16
|
+
@receive = receive
|
|
17
|
+
@stderr_lines = []
|
|
18
|
+
@pid = Process.pid
|
|
19
|
+
@lock = Mutex.new
|
|
20
|
+
@write_lock = Mutex.new
|
|
21
|
+
@responses = Queue.new
|
|
22
|
+
@alive = true
|
|
23
|
+
@reader = Thread.new { read }
|
|
24
|
+
@reader.report_on_exception = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write(message)
|
|
28
|
+
message, = wire(message)
|
|
29
|
+
responses = @write_lock.synchronize do
|
|
30
|
+
raise Error, "language server write failed: closed transport" unless alive?
|
|
31
|
+
|
|
32
|
+
@server.dispatch(message)
|
|
33
|
+
end
|
|
34
|
+
enqueue(responses)
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def try_write(message)
|
|
39
|
+
message, bytesize = wire(message)
|
|
40
|
+
return false if bytesize > MAX_TRY_FRAME || !@write_lock.try_lock
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
return false unless alive?
|
|
44
|
+
|
|
45
|
+
responses = @server.dispatch(message)
|
|
46
|
+
ensure
|
|
47
|
+
@write_lock.unlock
|
|
48
|
+
end
|
|
49
|
+
enqueue(responses)
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def alive? = @lock.synchronize { @alive }
|
|
54
|
+
|
|
55
|
+
def close
|
|
56
|
+
reader = @lock.synchronize do
|
|
57
|
+
@responses.clear
|
|
58
|
+
if @alive
|
|
59
|
+
@alive = false
|
|
60
|
+
@responses << STOP
|
|
61
|
+
end
|
|
62
|
+
@reader
|
|
63
|
+
end
|
|
64
|
+
return if reader == Thread.current
|
|
65
|
+
|
|
66
|
+
reader.kill unless reader.join(1)
|
|
67
|
+
reader.join
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def wire(message)
|
|
74
|
+
value = frame(message)
|
|
75
|
+
body_start = value.index("\r\n\r\n") + 4
|
|
76
|
+
[JSON.parse(value.byteslice(body_start, value.bytesize - body_start)), value.bytesize]
|
|
77
|
+
rescue JSON::ParserError => error
|
|
78
|
+
raise Error, "invalid LSP JSON: #{error.message.byteslice(0, 256)}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def enqueue(responses)
|
|
82
|
+
messages = responses.map { |response| wire(response).first }
|
|
83
|
+
@lock.synchronize do
|
|
84
|
+
return unless @alive
|
|
85
|
+
|
|
86
|
+
messages.each { |message| @responses << message }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def read
|
|
91
|
+
loop do
|
|
92
|
+
message = @responses.pop
|
|
93
|
+
break if message.equal?(STOP)
|
|
94
|
+
|
|
95
|
+
@receive.call(message, nil)
|
|
96
|
+
end
|
|
97
|
+
rescue StandardError => error
|
|
98
|
+
begin
|
|
99
|
+
@receive.call(nil, error) if alive?
|
|
100
|
+
rescue StandardError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
ensure
|
|
104
|
+
@lock.synchronize { @alive = false }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/sadr/testing.rb
ADDED
data/lib/sadr/transport.rb
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
module Sadr
|
|
4
4
|
class Transport
|
|
5
5
|
MAX_MESSAGE = 32 << 20
|
|
6
|
+
MAX_TRY_FRAME = 512
|
|
7
|
+
private_constant :MAX_TRY_FRAME
|
|
6
8
|
|
|
7
9
|
attr_reader :stderr_lines, :pid
|
|
8
10
|
|
|
9
|
-
def initialize(command, cwd: nil, env: {}, &receive)
|
|
11
|
+
def initialize(command, cwd: nil, env: {}, on_spawn: nil, &receive)
|
|
10
12
|
valid = command.is_a?(Array) && !command.empty? && command.all? do |part|
|
|
11
13
|
part.is_a?(String) && !part.include?("\0")
|
|
12
14
|
end
|
|
@@ -14,14 +16,28 @@ module Sadr
|
|
|
14
16
|
raise ArgumentError, "receiver required" unless receive
|
|
15
17
|
|
|
16
18
|
options = cwd ? {chdir: cwd} : {}
|
|
17
|
-
@
|
|
18
|
-
@
|
|
19
|
-
@
|
|
20
|
-
@pid = @process.pid
|
|
19
|
+
@close_lock = Mutex.new
|
|
20
|
+
@closing = false
|
|
21
|
+
@reader = @logger = nil
|
|
21
22
|
@write_lock = Mutex.new
|
|
22
23
|
@stderr_lines = []
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
initialized = false
|
|
25
|
+
begin
|
|
26
|
+
@stdin, @stdout, @stderr, @process = Open3.popen3(env, *command, **options)
|
|
27
|
+
@pid = @process.pid
|
|
28
|
+
on_spawn&.call(self)
|
|
29
|
+
@close_lock.synchronize do
|
|
30
|
+
raise Error, "language server connection was cancelled" if @closing
|
|
31
|
+
|
|
32
|
+
@stdin.binmode
|
|
33
|
+
@stdout.binmode
|
|
34
|
+
@reader = Thread.new { read(receive) }
|
|
35
|
+
@logger = Thread.new { read_stderr }
|
|
36
|
+
end
|
|
37
|
+
initialized = true
|
|
38
|
+
ensure
|
|
39
|
+
close if @process && !initialized
|
|
40
|
+
end
|
|
25
41
|
end
|
|
26
42
|
|
|
27
43
|
def self.read_message(io)
|
|
@@ -89,39 +105,55 @@ module Sadr
|
|
|
89
105
|
end
|
|
90
106
|
|
|
91
107
|
def write(message)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
normalized = message.transform_keys(&:to_s)
|
|
95
|
-
normalized["error"] = normalized["error"].transform_keys(&:to_s) if normalized["error"].is_a?(Hash)
|
|
96
|
-
self.class.validate_message(normalized)
|
|
97
|
-
body = JSON.generate(message).b
|
|
98
|
-
raise Error, "oversized LSP message" unless body.bytesize.between?(1, MAX_MESSAGE)
|
|
108
|
+
frame = frame(message)
|
|
99
109
|
|
|
100
110
|
@write_lock.synchronize do
|
|
101
|
-
@stdin.write(
|
|
102
|
-
@stdin.write(body)
|
|
111
|
+
@stdin.write(frame)
|
|
103
112
|
@stdin.flush
|
|
104
113
|
end
|
|
105
114
|
rescue IOError, Errno::EPIPE => error
|
|
106
115
|
raise Error, "language server write failed: #{error.message}"
|
|
107
116
|
end
|
|
108
117
|
|
|
118
|
+
def try_write(message)
|
|
119
|
+
value = frame(message)
|
|
120
|
+
return false if value.bytesize > MAX_TRY_FRAME || !@write_lock.try_lock
|
|
121
|
+
|
|
122
|
+
begin
|
|
123
|
+
written = @stdin.write_nonblock(value, exception: false)
|
|
124
|
+
return false if written == :wait_writable
|
|
125
|
+
return true if written == value.bytesize
|
|
126
|
+
|
|
127
|
+
@stdin.close unless @stdin.closed?
|
|
128
|
+
false
|
|
129
|
+
rescue IOError, SystemCallError
|
|
130
|
+
false
|
|
131
|
+
ensure
|
|
132
|
+
@write_lock.unlock
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
109
136
|
def alive? = @process.alive?
|
|
110
137
|
|
|
111
138
|
def close
|
|
112
|
-
|
|
139
|
+
closing = @close_lock.synchronize do
|
|
140
|
+
next false if @closing
|
|
141
|
+
|
|
142
|
+
@closing = true
|
|
143
|
+
end
|
|
144
|
+
return unless closing
|
|
113
145
|
|
|
114
|
-
@closing = true
|
|
115
146
|
@stdin.close unless @stdin.closed?
|
|
147
|
+
pid = @pid || @process.pid
|
|
116
148
|
unless @process.join(1)
|
|
117
149
|
begin
|
|
118
|
-
Process.kill("TERM",
|
|
150
|
+
Process.kill("TERM", pid)
|
|
119
151
|
rescue Errno::ESRCH
|
|
120
152
|
nil
|
|
121
153
|
end
|
|
122
154
|
unless @process.join(1)
|
|
123
155
|
begin
|
|
124
|
-
Process.kill("KILL",
|
|
156
|
+
Process.kill("KILL", pid)
|
|
125
157
|
rescue Errno::ESRCH
|
|
126
158
|
nil
|
|
127
159
|
end
|
|
@@ -129,7 +161,7 @@ module Sadr
|
|
|
129
161
|
end
|
|
130
162
|
end
|
|
131
163
|
[@stdout, @stderr].each { |io| io.close unless io.closed? }
|
|
132
|
-
[@reader, @logger].each do |thread|
|
|
164
|
+
[@reader, @logger].compact.each do |thread|
|
|
133
165
|
next if thread == Thread.current
|
|
134
166
|
|
|
135
167
|
thread.kill unless thread.join(1)
|
|
@@ -138,6 +170,20 @@ module Sadr
|
|
|
138
170
|
|
|
139
171
|
private
|
|
140
172
|
|
|
173
|
+
def frame(message)
|
|
174
|
+
raise Error, "expected JSON-RPC object" unless message.is_a?(Hash)
|
|
175
|
+
|
|
176
|
+
normalized = message.transform_keys(&:to_s)
|
|
177
|
+
normalized["error"] = normalized["error"].transform_keys(&:to_s) if normalized["error"].is_a?(Hash)
|
|
178
|
+
self.class.validate_message(normalized)
|
|
179
|
+
body = JSON.generate(message).b
|
|
180
|
+
raise Error, "oversized LSP message" unless body.bytesize.between?(1, MAX_MESSAGE)
|
|
181
|
+
|
|
182
|
+
"Content-Length: #{body.bytesize}\r\n\r\n".b + body
|
|
183
|
+
rescue JSON::GeneratorError => error
|
|
184
|
+
raise Error, "invalid LSP JSON: #{error.message.byteslice(0, 256)}"
|
|
185
|
+
end
|
|
186
|
+
|
|
141
187
|
def read(receive)
|
|
142
188
|
loop do
|
|
143
189
|
message = self.class.read_message(@stdout)
|