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.
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: 10)
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://" + URI::RFC2396_PARSER.escape(absolute, /[^a-zA-Z0-9\-._~\/:]/)
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 = URI::RFC2396_PARSER.unescape(parsed.path)
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
- sorted.each do |edit|
78
+ previous_start = nil
79
+ edits.each do |edit|
80
80
  start = fetch(edit, "start")
81
81
  count = fetch(edit, "deleteCount")
82
- raise Error, "invalid semantic token delta" if start < previous_end || start + count > data.length
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
- sorted.reverse_each do |edit|
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.all? { |value| value.is_a?(Integer) && value.between?(0, 0x7fffffff) }
126
- raise Error, "invalid semantic token value"
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" unless uint?(row) && uint?(column + length)
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, :valid_position?, :position_tuple, :fetch
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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sadr"
4
+ require_relative "testing/fake_transport"
5
+ require_relative "testing/fake_server"
6
+ require_relative "testing/fake_client"
@@ -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
- @stdin, @stdout, @stderr, @process = Open3.popen3(env, *command, **options)
18
- @stdin.binmode
19
- @stdout.binmode
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
- @reader = Thread.new { read(receive) }
24
- @logger = Thread.new { read_stderr }
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
- raise Error, "expected JSON-RPC object" unless message.is_a?(Hash)
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("Content-Length: #{body.bytesize}\r\n\r\n")
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
- return if @closing
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", @pid)
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", @pid)
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)