pikuri-lsp 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 +7 -0
- data/README.md +87 -0
- data/lib/pikuri/lsp/anchor.rb +397 -0
- data/lib/pikuri/lsp/client_wrapper.rb +581 -0
- data/lib/pikuri/lsp/connection.rb +366 -0
- data/lib/pikuri/lsp/extension.rb +75 -0
- data/lib/pikuri/lsp/location.rb +100 -0
- data/lib/pikuri/lsp/lsp_tool.rb +157 -0
- data/lib/pikuri/lsp/mailbox.rb +98 -0
- data/lib/pikuri/lsp/navigator.rb +340 -0
- data/lib/pikuri/lsp/operation.rb +115 -0
- data/lib/pikuri/lsp/position.rb +72 -0
- data/lib/pikuri/lsp/position_encoding.rb +126 -0
- data/lib/pikuri/lsp/range.rb +65 -0
- data/lib/pikuri/lsp/readiness.rb +185 -0
- data/lib/pikuri/lsp/refusal.rb +23 -0
- data/lib/pikuri/lsp/registry.rb +286 -0
- data/lib/pikuri/lsp/renderer.rb +453 -0
- data/lib/pikuri/lsp/server_progress.rb +57 -0
- data/lib/pikuri/lsp/servers.rb +208 -0
- data/lib/pikuri/lsp/sources.rb +272 -0
- data/lib/pikuri/lsp/symbol_name.rb +48 -0
- data/lib/pikuri/lsp/testing.rb +417 -0
- data/lib/pikuri/lsp/uris.rb +61 -0
- data/lib/pikuri-lsp.rb +39 -0
- metadata +107 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
module Lsp
|
|
7
|
+
# LSP's JSON-RPC channel over one pair of IOs: framing, a reader thread
|
|
8
|
+
# that demuxes replies by id, and the answer every server-initiated request
|
|
9
|
+
# needs. Given a subprocess's stdin/stdout it turns an asynchronous
|
|
10
|
+
# bidirectional stream into a blocking {#request} plus a fire-and-forget
|
|
11
|
+
# {#notify}:
|
|
12
|
+
#
|
|
13
|
+
# conn = Connection.new(stdin: child_in, stdout: child_out, server_id: 'ruby')
|
|
14
|
+
# conn.on_notification do |method, params|
|
|
15
|
+
# puts params['message'] if method == 'window/logMessage'
|
|
16
|
+
# end
|
|
17
|
+
# caps = conn.request('initialize', { processId: Process.pid, rootUri: root_uri })
|
|
18
|
+
# conn.notify('initialized', {})
|
|
19
|
+
# conn.request('textDocument/definition', params, cancellable: cancellable)
|
|
20
|
+
# conn.close
|
|
21
|
+
#
|
|
22
|
+
# It knows no LSP vocabulary — no handshake, no capabilities, no document
|
|
23
|
+
# state — and it spawns nothing: the IO pair arrives from outside, which is
|
|
24
|
+
# what lets a fake server drive it over +IO.pipe+ in-process.
|
|
25
|
+
#
|
|
26
|
+
# == There is no timeout
|
|
27
|
+
#
|
|
28
|
+
# {#request} blocks until the server answers, however long that takes: a
|
|
29
|
+
# cold jdtls import is minutes, and no defensible number separates "slow
|
|
30
|
+
# index" from "hung". Two things replace the clock here — the child's death
|
|
31
|
+
# (EOF on stdout ends the wait with {Closed}) and the +cancellable:+ poll,
|
|
32
|
+
# which makes the human the timeout via Ctrl+C. The third is the server's
|
|
33
|
+
# own progress notifications, which a handler turns into {ServerProgress} so
|
|
34
|
+
# that a long wait at least looks like one.
|
|
35
|
+
#
|
|
36
|
+
# == Implementation details
|
|
37
|
+
#
|
|
38
|
+
# Server→client requests (+window/workDoneProgress/create+,
|
|
39
|
+
# +workspace/configuration+, +client/registerCapability+) are answered
|
|
40
|
+
# +result: null+ from the reader thread, because a server may *block* on the
|
|
41
|
+
# reply and would then never answer the request pikuri is waiting for. No
|
|
42
|
+
# hook to supply a real result: both measured servers proceed fine, and an
|
|
43
|
+
# invented answer is worse than none.
|
|
44
|
+
#
|
|
45
|
+
# A JSON-RPC +error+ member is a {ServerError}, distinct from any other
|
|
46
|
+
# failure on purpose — "the server refused this method" (ruby-lsp answers
|
|
47
|
+
# +Method not found+ for an operation it never advertised) must not read
|
|
48
|
+
# like "the server answered nothing".
|
|
49
|
+
#
|
|
50
|
+
# == Thread-safety
|
|
51
|
+
#
|
|
52
|
+
# Thread-safe, which is the point: one reader thread owns +stdout+ while any
|
|
53
|
+
# number of callers write and wait. Notification handlers are the exception
|
|
54
|
+
# to watch — they run on the *reader* thread, so a handler must not touch
|
|
55
|
+
# anything thread-confined. Park the value there and let the waiting thread
|
|
56
|
+
# pick it up.
|
|
57
|
+
class Connection
|
|
58
|
+
LOGGER = Pikuri.logger_for('Lsp::Connection')
|
|
59
|
+
private_constant :LOGGER
|
|
60
|
+
|
|
61
|
+
# How often a blocked {#request} wakes to re-check +cancellable+.
|
|
62
|
+
POLL_INTERVAL = 0.1
|
|
63
|
+
|
|
64
|
+
# How long {#close} waits for the reader thread to notice its IOs went
|
|
65
|
+
# away before giving up on it.
|
|
66
|
+
CLOSE_JOIN_TIMEOUT = 2
|
|
67
|
+
|
|
68
|
+
# Raised when the channel is gone: the child died, the stream hit EOF
|
|
69
|
+
# mid-message, a write failed, or {#close} was called. Terminal for this
|
|
70
|
+
# object — the reason lives in the message and in {#dead_reason}, and
|
|
71
|
+
# getting a working channel back means building another one.
|
|
72
|
+
class Closed < StandardError; end
|
|
73
|
+
|
|
74
|
+
# A JSON-RPC +error+ response. Not a bug in pikuri — the server
|
|
75
|
+
# understood the frame and refused the call.
|
|
76
|
+
class ServerError < StandardError
|
|
77
|
+
# @return [Integer] JSON-RPC error code, e.g. +-32601+ for
|
|
78
|
+
# +Method not found+.
|
|
79
|
+
attr_reader :code
|
|
80
|
+
|
|
81
|
+
# @return [Hash, Array, String, Numeric, nil] the +data+ member, whose
|
|
82
|
+
# shape each server invents for itself.
|
|
83
|
+
attr_reader :data
|
|
84
|
+
|
|
85
|
+
# @param method [String] the request method that was refused.
|
|
86
|
+
# @param error [Hash{String => Object}] the +error+ member.
|
|
87
|
+
def initialize(method, error)
|
|
88
|
+
@code = error['code']
|
|
89
|
+
@data = error['data']
|
|
90
|
+
super("#{method} failed: #{error['message']} (code #{@code})")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Starts the reader thread immediately, so a server that talks first is
|
|
95
|
+
# heard.
|
|
96
|
+
#
|
|
97
|
+
# @param stdin [IO] the server's standard input — pikuri writes here.
|
|
98
|
+
# @param stdout [IO] the server's standard output — the reader thread owns
|
|
99
|
+
# it exclusively from here on.
|
|
100
|
+
# @param server_id [String] the registry entry's id, for log lines only.
|
|
101
|
+
def initialize(stdin:, stdout:, server_id: 'lsp')
|
|
102
|
+
@stdin = stdin
|
|
103
|
+
@stdout = stdout
|
|
104
|
+
@server_id = server_id
|
|
105
|
+
@write_mutex = Mutex.new
|
|
106
|
+
@state = Mutex.new
|
|
107
|
+
@answered = ConditionVariable.new
|
|
108
|
+
@pending = {}
|
|
109
|
+
@handlers = []
|
|
110
|
+
@next_id = 0
|
|
111
|
+
@dead = nil
|
|
112
|
+
@closed = false
|
|
113
|
+
@reader = Thread.new { read_loop }
|
|
114
|
+
@reader.name = "lsp-reader #{server_id}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Send a request and block until the server answers it.
|
|
118
|
+
#
|
|
119
|
+
# @param method [String] LSP method name, e.g. +"textDocument/definition"+.
|
|
120
|
+
# @param params [Hash] the +params+ member, serialized as-is.
|
|
121
|
+
# @param cancellable [Pikuri::Agent::Control::Cancellable, nil] polled
|
|
122
|
+
# every {POLL_INTERVAL} while waiting; its +Cancelled+ propagates.
|
|
123
|
+
# @return [Hash, Array, String, Integer, true, false, nil] the +result+
|
|
124
|
+
# member verbatim. +nil+ is a real answer ("nothing found"), not an
|
|
125
|
+
# error — and +java/classFileContents+ answers a bare String where the
|
|
126
|
+
# spec suggests an object, so callers accept what they get.
|
|
127
|
+
# @raise [ServerError] on a JSON-RPC +error+ response.
|
|
128
|
+
# @raise [Closed] if the channel dies before the answer arrives.
|
|
129
|
+
# @raise [Pikuri::Agent::Control::Cancellable::Cancelled] on cancellation.
|
|
130
|
+
def request(method, params = {}, cancellable: nil)
|
|
131
|
+
id = register_request
|
|
132
|
+
begin
|
|
133
|
+
write(jsonrpc: '2.0', id: id, method: method, params: params)
|
|
134
|
+
rescue StandardError
|
|
135
|
+
@state.synchronize { @pending.delete(id) }
|
|
136
|
+
raise
|
|
137
|
+
end
|
|
138
|
+
await(id, method, cancellable)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Send a request and deliberately *not* wait for its answer, for the one
|
|
142
|
+
# case where the answer changes nothing: teardown, where +shutdown+ must
|
|
143
|
+
# reach the server ahead of +exit+. A reply that does arrive is dropped as
|
|
144
|
+
# an unknown id.
|
|
145
|
+
#
|
|
146
|
+
# @param method [String] e.g. +"shutdown"+.
|
|
147
|
+
# @param params [Hash]
|
|
148
|
+
# @return [Integer] the id it went out with.
|
|
149
|
+
# @raise [Closed] if the channel is already gone or the write fails.
|
|
150
|
+
def send_request(method, params = {})
|
|
151
|
+
id = next_id!
|
|
152
|
+
write(jsonrpc: '2.0', id: id, method: method, params: params)
|
|
153
|
+
id
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Send a notification — no id, no reply, returns as soon as the bytes are
|
|
157
|
+
# written.
|
|
158
|
+
#
|
|
159
|
+
# @param method [String] e.g. +"textDocument/didOpen"+.
|
|
160
|
+
# @param params [Hash]
|
|
161
|
+
# @return [void]
|
|
162
|
+
# @raise [Closed] if the channel is already gone or the write fails.
|
|
163
|
+
def notify(method, params = {})
|
|
164
|
+
write(jsonrpc: '2.0', method: method, params: params)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Register a handler for every server notification (+$/progress+,
|
|
168
|
+
# +window/logMessage+, +language/status+, …), called in registration order
|
|
169
|
+
# on the reader thread. One that raises is logged and skipped rather than
|
|
170
|
+
# taking the reader — and with it the channel — down.
|
|
171
|
+
#
|
|
172
|
+
# @yieldparam method [String]
|
|
173
|
+
# @yieldparam params [Hash, nil]
|
|
174
|
+
# @return [void]
|
|
175
|
+
def on_notification(&block)
|
|
176
|
+
@state.synchronize { @handlers << block }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @return [Boolean] whether the channel can still carry a request.
|
|
180
|
+
def alive?
|
|
181
|
+
@state.synchronize { @dead.nil? }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @return [String, nil] why the channel died — +"server closed stdout
|
|
185
|
+
# (EOF)"+, a truncated frame, a parse failure, +"closed by pikuri"+ — or
|
|
186
|
+
# +nil+ while it is alive. What the *child* said on its way out is the
|
|
187
|
+
# stderr tail, which belongs to whoever spawned it.
|
|
188
|
+
def dead_reason
|
|
189
|
+
@state.synchronize { @dead }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Close both IOs and stop the reader thread; every waiting and subsequent
|
|
193
|
+
# {#request} raises {Closed}. Idempotent, and sends no +shutdown+ —
|
|
194
|
+
# protocol lifecycle belongs to the layer that spawned the child.
|
|
195
|
+
#
|
|
196
|
+
# @return [void]
|
|
197
|
+
def close
|
|
198
|
+
already_closed = @state.synchronize do
|
|
199
|
+
was_closed = @closed
|
|
200
|
+
@closed = true
|
|
201
|
+
was_closed
|
|
202
|
+
end
|
|
203
|
+
return if already_closed
|
|
204
|
+
|
|
205
|
+
die!('closed by pikuri')
|
|
206
|
+
close_io(@stdin)
|
|
207
|
+
close_io(@stdout)
|
|
208
|
+
return if @reader.join(CLOSE_JOIN_TIMEOUT)
|
|
209
|
+
|
|
210
|
+
LOGGER.warn("#{@server_id}: reader thread did not stop within #{CLOSE_JOIN_TIMEOUT}s; killing it")
|
|
211
|
+
@reader.kill
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def next_id!
|
|
217
|
+
@state.synchronize do
|
|
218
|
+
raise Closed, "#{@server_id}: #{@dead}" if @dead
|
|
219
|
+
|
|
220
|
+
@next_id += 1
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def register_request
|
|
225
|
+
id = next_id!
|
|
226
|
+
@state.synchronize { @pending[id] = nil }
|
|
227
|
+
id
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def await(id, method, cancellable)
|
|
231
|
+
@state.synchronize do
|
|
232
|
+
loop do
|
|
233
|
+
response = @pending[id]
|
|
234
|
+
if response
|
|
235
|
+
raise ServerError.new(method, response['error']) if response.key?('error')
|
|
236
|
+
|
|
237
|
+
return response['result']
|
|
238
|
+
end
|
|
239
|
+
raise Closed, "#{@server_id}: #{@dead} (waiting for #{method})" if @dead
|
|
240
|
+
|
|
241
|
+
@answered.wait(@state, POLL_INTERVAL)
|
|
242
|
+
cancellable&.check!
|
|
243
|
+
end
|
|
244
|
+
ensure
|
|
245
|
+
@pending.delete(id)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def write(message)
|
|
250
|
+
body = JSON.generate(message)
|
|
251
|
+
@write_mutex.synchronize do
|
|
252
|
+
raise Closed, "#{@server_id}: #{dead_reason}" unless alive?
|
|
253
|
+
|
|
254
|
+
@stdin.write("Content-Length: #{body.bytesize}\r\n\r\n#{body}")
|
|
255
|
+
@stdin.flush
|
|
256
|
+
end
|
|
257
|
+
rescue IOError, SystemCallError => e
|
|
258
|
+
reason = "write failed: #{e.class}: #{e.message}"
|
|
259
|
+
die!(reason)
|
|
260
|
+
raise Closed, "#{@server_id}: #{reason}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def read_loop
|
|
264
|
+
while (message = read_message)
|
|
265
|
+
dispatch(message)
|
|
266
|
+
end
|
|
267
|
+
die!('server closed stdout (EOF)')
|
|
268
|
+
rescue Closed => e
|
|
269
|
+
report_reader_stopped(e.message)
|
|
270
|
+
die!(e.message)
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
report_reader_stopped("#{e.class}: #{e.message}")
|
|
273
|
+
die!("#{e.class}: #{e.message}")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# A reader that stops *after* {#close} pulled its IO out from under it is
|
|
277
|
+
# the teardown working, so it says so at debug; anything else is the
|
|
278
|
+
# channel dying on its own and belongs in the log at error.
|
|
279
|
+
def report_reader_stopped(reason)
|
|
280
|
+
line = "#{@server_id}: reader stopped — #{reason}"
|
|
281
|
+
@state.synchronize { @closed } ? LOGGER.debug(line) : LOGGER.error(line)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# @return [Hash, nil] the parsed message, or +nil+ at a clean EOF.
|
|
285
|
+
def read_message
|
|
286
|
+
headers = {}
|
|
287
|
+
loop do
|
|
288
|
+
begin
|
|
289
|
+
line = @stdout.readline("\r\n")
|
|
290
|
+
rescue EOFError
|
|
291
|
+
return nil if headers.empty?
|
|
292
|
+
|
|
293
|
+
raise Closed, 'EOF inside a header block'
|
|
294
|
+
end
|
|
295
|
+
line = line.chomp("\r\n")
|
|
296
|
+
break if line.empty?
|
|
297
|
+
|
|
298
|
+
key, value = line.split(':', 2)
|
|
299
|
+
headers[key.strip.downcase] = value.to_s.strip
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
length = Integer(headers.fetch('content-length'))
|
|
303
|
+
body = @stdout.read(length)
|
|
304
|
+
if body.nil? || body.bytesize < length
|
|
305
|
+
raise Closed, "truncated frame: wanted #{length} bytes, got #{body&.bytesize.inspect}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
JSON.parse(body.force_encoding(Encoding::UTF_8))
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def dispatch(message)
|
|
312
|
+
id = message['id']
|
|
313
|
+
if id && (message.key?('result') || message.key?('error'))
|
|
314
|
+
complete(id, message)
|
|
315
|
+
elsif id
|
|
316
|
+
# A server may block on this reply, so it must not wait for the agent
|
|
317
|
+
# thread — which is busy waiting for the request this would unblock.
|
|
318
|
+
respond_null(id, message['method'])
|
|
319
|
+
elsif message['method']
|
|
320
|
+
notify_handlers(message['method'], message['params'])
|
|
321
|
+
else
|
|
322
|
+
LOGGER.warn("#{@server_id}: unrecognized message #{message.inspect}")
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def complete(id, message)
|
|
327
|
+
@state.synchronize do
|
|
328
|
+
unless @pending.key?(id)
|
|
329
|
+
LOGGER.debug("#{@server_id}: response to unknown id #{id.inspect}")
|
|
330
|
+
next
|
|
331
|
+
end
|
|
332
|
+
@pending[id] = message
|
|
333
|
+
@answered.broadcast
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def respond_null(id, method)
|
|
338
|
+
LOGGER.debug("#{@server_id}: answering server request #{method.inspect} with null")
|
|
339
|
+
write(jsonrpc: '2.0', id: id, result: nil)
|
|
340
|
+
rescue Closed
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def notify_handlers(method, params)
|
|
345
|
+
@state.synchronize { @handlers.dup }.each do |handler|
|
|
346
|
+
handler.call(method, params)
|
|
347
|
+
rescue StandardError => e
|
|
348
|
+
LOGGER.error("#{@server_id}: notification handler for #{method.inspect} raised #{e.class}: #{e.message}")
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def die!(reason)
|
|
353
|
+
@state.synchronize do
|
|
354
|
+
@dead ||= reason
|
|
355
|
+
@answered.broadcast
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def close_io(io)
|
|
360
|
+
io.close unless io.closed?
|
|
361
|
+
rescue IOError, SystemCallError => e
|
|
362
|
+
LOGGER.debug("#{@server_id}: closing #{io.inspect} raised #{e.class}: #{e.message}")
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# Wires the +lsp+ tool onto an agent: builds the {Servers} runtime from a
|
|
6
|
+
# {Registry}, arms its teardown, and hands the tool an event emitter so a host
|
|
7
|
+
# can draw a bar while a cold index runs.
|
|
8
|
+
#
|
|
9
|
+
# registry = Pikuri::Lsp::Registry.from_h(config['lsp_servers'])
|
|
10
|
+
# Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
|
|
11
|
+
# c.add_extension Pikuri::Lsp::Extension.new(registry: registry, filesystem: filesystem)
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# An empty {Registry} makes the whole extension a no-op — no tool, no
|
|
15
|
+
# teardown — so a host that ships the gem without config costs the model
|
|
16
|
+
# nothing.
|
|
17
|
+
#
|
|
18
|
+
# == Implementation details
|
|
19
|
+
#
|
|
20
|
+
# There is deliberately no +trifecta_contribution+: the legs belong to the
|
|
21
|
+
# tool, which holds the filesystem that answers them, and a second declaration
|
|
22
|
+
# here would only put the extension's name on the report row.
|
|
23
|
+
class Extension
|
|
24
|
+
include Pikuri::Agent::Extension
|
|
25
|
+
|
|
26
|
+
# @param registry [Registry] configured language servers. Defaults to
|
|
27
|
+
# {Registry::EMPTY}, which makes this extension a no-op.
|
|
28
|
+
# @param filesystem [Pikuri::Workspace::Filesystem] the project: the
|
|
29
|
+
# servers' root, path resolution, the denylist pass every walked result
|
|
30
|
+
# owes, and the +private?+ answer the tool's legs inherit.
|
|
31
|
+
def initialize(filesystem:, registry: Registry::EMPTY)
|
|
32
|
+
@registry = registry
|
|
33
|
+
@filesystem = filesystem
|
|
34
|
+
@servers = nil
|
|
35
|
+
@tool = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Servers, nil] the runtime built in +configure+, or +nil+ when the
|
|
39
|
+
# registry was empty.
|
|
40
|
+
attr_reader :servers
|
|
41
|
+
|
|
42
|
+
# Build the {Servers} runtime, arm its teardown, register the tool — in that
|
|
43
|
+
# order, so +close+ is armed before any call can spawn a child.
|
|
44
|
+
#
|
|
45
|
+
# @param c [Pikuri::Agent::Configurator]
|
|
46
|
+
# @return [void]
|
|
47
|
+
def configure(c)
|
|
48
|
+
return if @registry.empty?
|
|
49
|
+
|
|
50
|
+
@servers = Servers.new(registry: @registry, root: @filesystem.project_root,
|
|
51
|
+
cancellable: c.cancellable)
|
|
52
|
+
c.on_close { @servers.close }
|
|
53
|
+
@tool = LspTool.new(servers: @servers, filesystem: @filesystem,
|
|
54
|
+
cancellable: c.cancellable)
|
|
55
|
+
c.add_tool @tool
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Point the tool's progress callback at the agent's event stream.
|
|
60
|
+
#
|
|
61
|
+
# The tool emits on the thread that is blocked waiting for an index — the
|
|
62
|
+
# agent's own — which is what makes {Pikuri::Agent::ExtensionContext#emit_event}
|
|
63
|
+
# legal from there.
|
|
64
|
+
#
|
|
65
|
+
# @param ctx [Pikuri::Agent::ExtensionContext]
|
|
66
|
+
# @return [void]
|
|
67
|
+
def bind(ctx)
|
|
68
|
+
return if @tool.nil?
|
|
69
|
+
|
|
70
|
+
@tool.on_progress = ->(progress) { ctx.emit_event(progress) }
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# A place a server pointed at, parsed from *either* wire shape a
|
|
6
|
+
# navigation request can answer with. Servers pick between +Location+
|
|
7
|
+
# (+uri+/+range+) and +LocationLink+
|
|
8
|
+
# (+targetUri+/+targetRange+/+targetSelectionRange+) based on the
|
|
9
|
+
# +linkSupport+ the client declared, and ruby-lsp picks the second — so
|
|
10
|
+
# both go in one door and nothing downstream learns there were two:
|
|
11
|
+
#
|
|
12
|
+
# locations = Location.list_from_wire(connection.request('textDocument/definition', params))
|
|
13
|
+
# locations.map { |loc| "#{loc.path}:#{loc.anchor.start.line}" }
|
|
14
|
+
# # => ["/home/m/pikuri/pikuri-workspace/lib/pikuri/workspace/filesystem.rb:218"]
|
|
15
|
+
#
|
|
16
|
+
# A result is a *walked* path — the model never supplied it — so it reaches
|
|
17
|
+
# no +resolve_for_read+, and every consumer owes it a
|
|
18
|
+
# +Filesystem#denied?+ pass plus root confinement (+D_walked_path_denylist+).
|
|
19
|
+
# {#path} returning +nil+ is the other half of that: a +jdt:+ URI inside a
|
|
20
|
+
# jar can be neither denylist-checked nor read, and has to be rendered from
|
|
21
|
+
# content the server hands over.
|
|
22
|
+
#
|
|
23
|
+
# @!attribute [r] uri
|
|
24
|
+
# @return [String] the URI verbatim, e.g.
|
|
25
|
+
# +"file:///home/m/pikuri/read.rb"+ — or a ~300-character
|
|
26
|
+
# +"jdt://contents/…"+ whose query string is a whole classpath entry,
|
|
27
|
+
# which is why a Java target is never rendered raw.
|
|
28
|
+
# @!attribute [r] range
|
|
29
|
+
# @return [Range] the whole target — a definition's entire body, or every
|
|
30
|
+
# line of a reopened namespace.
|
|
31
|
+
# @!attribute [r] selection_range
|
|
32
|
+
# @return [Range, nil] just the identifier inside {#range} when the server
|
|
33
|
+
# sent a +LocationLink+, +nil+ for a plain +Location+. The one thing the
|
|
34
|
+
# link shape buys, and what a one-line snippet points at.
|
|
35
|
+
Location = Data.define(:uri, :range, :selection_range) do
|
|
36
|
+
# Parse one +Location+ or +LocationLink+.
|
|
37
|
+
#
|
|
38
|
+
# @param hash [Hash{String => Object}] either wire shape.
|
|
39
|
+
# @param text [String, nil] the target document's text, when to hand —
|
|
40
|
+
# what makes the columns exact, see {Range.from_wire}.
|
|
41
|
+
# @param encoding [String] the server's negotiated +positionEncoding+.
|
|
42
|
+
# @return [Location]
|
|
43
|
+
# @raise [KeyError] if the hash is neither shape.
|
|
44
|
+
def self.from_wire(hash, text: nil, encoding: PositionEncoding::DEFAULT)
|
|
45
|
+
uri = hash['uri'] || hash.fetch('targetUri')
|
|
46
|
+
range = hash['range'] || hash.fetch('targetRange')
|
|
47
|
+
selection = hash['targetSelectionRange']
|
|
48
|
+
new(uri: uri,
|
|
49
|
+
range: Range.from_wire(range, text: text, encoding: encoding),
|
|
50
|
+
selection_range: selection && Range.from_wire(selection, text: text, encoding: encoding))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Parse whatever a navigation request answered with: LSP allows a single
|
|
54
|
+
# object, an array, or +null+ for the same method, and servers use all
|
|
55
|
+
# three.
|
|
56
|
+
#
|
|
57
|
+
# @param result [Hash, Array<Hash>, nil] the +result+ member.
|
|
58
|
+
# @param text [String, nil] as in {.from_wire}; only useful for a
|
|
59
|
+
# single-document answer.
|
|
60
|
+
# @param encoding [String] the server's negotiated +positionEncoding+.
|
|
61
|
+
# @return [Array<Location>] empty for +null+ or +[]+ — which means *the
|
|
62
|
+
# server answered nothing*, and must never be rendered as "no
|
|
63
|
+
# definition exists".
|
|
64
|
+
def self.list_from_wire(result, text: nil, encoding: PositionEncoding::DEFAULT)
|
|
65
|
+
case result
|
|
66
|
+
when nil then []
|
|
67
|
+
when Hash then [from_wire(result, text: text, encoding: encoding)]
|
|
68
|
+
when Array then result.map { |item| from_wire(item, text: text, encoding: encoding) }
|
|
69
|
+
else raise ArgumentError, "not a location result: #{result.inspect}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initialize(uri:, range:, selection_range: nil)
|
|
74
|
+
super
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [String, nil] local path, or +nil+ for a non-+file:+ URI. See
|
|
78
|
+
# {Uris.to_path}.
|
|
79
|
+
def path
|
|
80
|
+
Uris.to_path(uri)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [String, nil] the URI's scheme, e.g. +"file"+ or +"jdt"+.
|
|
84
|
+
def scheme
|
|
85
|
+
Uris.scheme(uri)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Boolean] whether this names a readable local file.
|
|
89
|
+
def file?
|
|
90
|
+
!path.nil?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Range] the tightest range the server gave: the identifier when
|
|
94
|
+
# it sent a link, the whole target otherwise.
|
|
95
|
+
def anchor
|
|
96
|
+
selection_range || range
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|