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,417 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
module Lsp
|
|
7
|
+
# Test support: drive the **real** {Connection} / {ClientWrapper} against a
|
|
8
|
+
# scripted language server over in-process pipes — no binary, no spawn, no
|
|
9
|
+
# network, and one backtrace instead of two.
|
|
10
|
+
#
|
|
11
|
+
# Not loaded by default: +require 'pikuri/lsp/testing'+ from a spec. It lives
|
|
12
|
+
# off the production load path (the gem's Zeitwerk loader ignores it).
|
|
13
|
+
#
|
|
14
|
+
# == Two ways to be a server, and when to pick which
|
|
15
|
+
#
|
|
16
|
+
# {Wire} is the *manual* half: it hands you the client's frames one at a time
|
|
17
|
+
# and you answer them. Use it to assert on what the client wrote, and to play
|
|
18
|
+
# the sequences no real server would perform on demand — a reply that never
|
|
19
|
+
# arrives, a truncated frame, a notification mid-wait.
|
|
20
|
+
#
|
|
21
|
+
# channel, wire = Testing::Wire.channel
|
|
22
|
+
# connection = Pikuri::Lsp::Connection.new(**channel, server_id: 'test')
|
|
23
|
+
# answer = Thread.new { connection.request('textDocument/hover') }
|
|
24
|
+
# wire.reply(wire.take, 'contents' => 'hi')
|
|
25
|
+
# answer.value # => {"contents"=>"hi"}
|
|
26
|
+
#
|
|
27
|
+
# {FakeServer} is the *scripted* half: it answers on its own thread, so the
|
|
28
|
+
# code under test just makes calls. Use it whenever the client makes more than
|
|
29
|
+
# one, and to replay recorded bytes.
|
|
30
|
+
#
|
|
31
|
+
# server = Testing::FakeServer.new(capabilities: { 'definitionProvider' => true })
|
|
32
|
+
# server.on('textDocument/definition') { |_params| [] }
|
|
33
|
+
# client = Pikuri::Lsp::ClientWrapper.new(entry: entry, root: root, **server.channel)
|
|
34
|
+
# server.serve
|
|
35
|
+
# client.start # the handshake is answered for you
|
|
36
|
+
# client.request('textDocument/definition') # => []
|
|
37
|
+
# client.close
|
|
38
|
+
# server.stop
|
|
39
|
+
#
|
|
40
|
+
# == Why the framing here is hand-rolled
|
|
41
|
+
#
|
|
42
|
+
# Neither class touches {Connection}'s framing or the {Location} / {Position}
|
|
43
|
+
# value types to *build* what it sends. A fake that replies through the code
|
|
44
|
+
# under test agrees with that code's bugs, so a serializer defect would show
|
|
45
|
+
# up nowhere. Going the other way is deliberate and useful: parsing the
|
|
46
|
+
# client's own output with {Position.from_wire} is how an asymmetry — a field
|
|
47
|
+
# pikuri can write but not read — surfaces at once.
|
|
48
|
+
#
|
|
49
|
+
# For the *inbound* direction the same argument means canned Hashes are not
|
|
50
|
+
# enough, so {FakeServer#on_json} and {FakeServer#send_frame} take recorded
|
|
51
|
+
# bytes and put them on the wire untouched.
|
|
52
|
+
module Testing
|
|
53
|
+
# The server side of one LSP channel: hand-rolled framing over a pair of
|
|
54
|
+
# pipes, blocking reads, and no behaviour of its own.
|
|
55
|
+
#
|
|
56
|
+
# Drive it from the *example's* thread with the client under test on a
|
|
57
|
+
# worker (or use {FakeServer}). A spec that writes more than the pipe
|
|
58
|
+
# buffer with nobody reading will deadlock.
|
|
59
|
+
class Wire
|
|
60
|
+
# @return [Array(Hash{Symbol => IO}, Wire)] the client's
|
|
61
|
+
# +{stdin:, stdout:}+ kwargs, and the server side of the same pair.
|
|
62
|
+
def self.channel
|
|
63
|
+
to_server = IO.pipe
|
|
64
|
+
to_client = IO.pipe
|
|
65
|
+
[{ stdin: to_server[1], stdout: to_client[0] },
|
|
66
|
+
new(read: to_server[0], write: to_client[1])]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param read [IO] where the client's frames arrive.
|
|
70
|
+
# @param write [IO] where this side's frames go.
|
|
71
|
+
def initialize(read:, write:)
|
|
72
|
+
@read = read
|
|
73
|
+
@write = write
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The next message the client wrote, blocking until there is one.
|
|
77
|
+
#
|
|
78
|
+
# @return [Hash{String => Object}]
|
|
79
|
+
# @raise [EOFError] when the client closed its end.
|
|
80
|
+
def take
|
|
81
|
+
headers = {}
|
|
82
|
+
while (line = @read.readline("\r\n").chomp("\r\n")) != ''
|
|
83
|
+
key, value = line.split(':', 2)
|
|
84
|
+
headers[key.strip.downcase] = value.strip
|
|
85
|
+
end
|
|
86
|
+
JSON.parse(@read.read(Integer(headers.fetch('content-length'))))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Frame and send +body+ exactly as given — the door recorded bytes come
|
|
90
|
+
# through.
|
|
91
|
+
#
|
|
92
|
+
# @param body [String] a complete JSON-RPC message.
|
|
93
|
+
# @return [void]
|
|
94
|
+
def send_raw(body)
|
|
95
|
+
@write.write("Content-Length: #{body.bytesize}\r\n\r\n#{body}")
|
|
96
|
+
@write.flush
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @param message [Hash] serialized with +JSON.generate+.
|
|
100
|
+
# @return [void]
|
|
101
|
+
def send_message(message)
|
|
102
|
+
send_raw(JSON.generate(message))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @param request [Hash] the message being answered; only its +id+ is read.
|
|
106
|
+
# @param result [Hash, Array, String, Numeric, true, false, nil] the
|
|
107
|
+
# +result+ member — +nil+ and +[]+ are real answers.
|
|
108
|
+
# @return [void]
|
|
109
|
+
def reply(request, result)
|
|
110
|
+
send_message('jsonrpc' => '2.0', 'id' => request['id'], 'result' => result)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param request [Hash] the message being refused.
|
|
114
|
+
# @param code [Integer] JSON-RPC code, e.g. +-32601+ for
|
|
115
|
+
# +Method not found+.
|
|
116
|
+
# @param message [String]
|
|
117
|
+
# @return [void]
|
|
118
|
+
def reply_error(request, code:, message:)
|
|
119
|
+
send_message('jsonrpc' => '2.0', 'id' => request['id'],
|
|
120
|
+
'error' => { 'code' => code, 'message' => message })
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param method [String] e.g. +"$/progress"+.
|
|
124
|
+
# @param params [Hash]
|
|
125
|
+
# @return [void]
|
|
126
|
+
def notify(method, params = {})
|
|
127
|
+
send_message('jsonrpc' => '2.0', 'method' => method, 'params' => params)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Ask the *client* something, which a real server does for
|
|
131
|
+
# +window/workDoneProgress/create+ and +workspace/configuration+.
|
|
132
|
+
#
|
|
133
|
+
# @param method [String]
|
|
134
|
+
# @param params [Hash]
|
|
135
|
+
# @param id [String, Integer] ids are the sender's to choose, and a String
|
|
136
|
+
# keeps a server-initiated request distinguishable in a spec.
|
|
137
|
+
# @return [void]
|
|
138
|
+
def request(method, params = {}, id: 'server-1')
|
|
139
|
+
send_message('jsonrpc' => '2.0', 'id' => id, 'method' => method, 'params' => params)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Play the server half of the handshake: answer +initialize+, absorb
|
|
143
|
+
# +initialized+.
|
|
144
|
+
#
|
|
145
|
+
# @param capabilities [Hash{String => Object}] what to advertise.
|
|
146
|
+
# @param server_info [Hash{String => String}, nil]
|
|
147
|
+
# @return [Hash] the client's +initialize+ request, so an example can
|
|
148
|
+
# assert on what it declared.
|
|
149
|
+
# @raise [RuntimeError] if the client said anything else first.
|
|
150
|
+
def handshake(capabilities: {}, server_info: nil)
|
|
151
|
+
request = take
|
|
152
|
+
raise "expected initialize, got #{request['method'].inspect}" unless request['method'] == 'initialize'
|
|
153
|
+
|
|
154
|
+
result = { 'capabilities' => capabilities }
|
|
155
|
+
result['serverInfo'] = server_info if server_info
|
|
156
|
+
reply(request, result)
|
|
157
|
+
absorbed = take
|
|
158
|
+
raise "expected initialized, got #{absorbed['method'].inspect}" unless absorbed['method'] == 'initialized'
|
|
159
|
+
|
|
160
|
+
request
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Close both ends — from the client's side, indistinguishable from the
|
|
164
|
+
# server dying.
|
|
165
|
+
#
|
|
166
|
+
# @return [void]
|
|
167
|
+
def close
|
|
168
|
+
[@write, @read].each { |io| io.close unless io.closed? }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# A language server that answers on its own thread: the handshake for free,
|
|
173
|
+
# one block per method, and everything the client said kept for assertions.
|
|
174
|
+
#
|
|
175
|
+
# server = FakeServer.new(capabilities: { 'definitionProvider' => true })
|
|
176
|
+
# server.on('textDocument/definition') { |params| [{ 'uri' => params['uri'], 'range' => nil }] }
|
|
177
|
+
# server.on_json('typeHierarchy/supertypes') { File.read('spec/fixtures/ruby_lsp_supertypes.json') }
|
|
178
|
+
# server.serve
|
|
179
|
+
# client.start # … and whatever else the example drives
|
|
180
|
+
# server.await('initialized') # the last frame of the handshake
|
|
181
|
+
# server.received.map { |message| message['method'] }
|
|
182
|
+
# # => ["initialize", "initialized"]
|
|
183
|
+
# server.stop
|
|
184
|
+
#
|
|
185
|
+
# An unrouted request is answered +-32601 Method not found+, which is what
|
|
186
|
+
# ruby-lsp does for an operation it never advertised — so a call the example
|
|
187
|
+
# did not expect fails loudly instead of hanging.
|
|
188
|
+
#
|
|
189
|
+
# Thread-safe for the calls above: the serving thread reads and answers
|
|
190
|
+
# while the example scripts routes and reads {#received}.
|
|
191
|
+
class FakeServer
|
|
192
|
+
# JSON-RPC's code for a method the server does not implement.
|
|
193
|
+
METHOD_NOT_FOUND = -32_601
|
|
194
|
+
|
|
195
|
+
# @return [Wire] the raw channel, for a sequence no route can express.
|
|
196
|
+
attr_reader :wire
|
|
197
|
+
|
|
198
|
+
# @return [Hash{Symbol => IO}] the +{stdin:, stdout:}+ kwargs for
|
|
199
|
+
# {Connection#initialize} or {ClientWrapper#initialize}.
|
|
200
|
+
attr_reader :channel
|
|
201
|
+
|
|
202
|
+
# @param capabilities [Hash{String => Object}] advertised at +initialize+;
|
|
203
|
+
# the client's {ClientWrapper#supports?} reads exactly this.
|
|
204
|
+
# @param server_info [Hash{String => String}, nil] +name+ / +version+.
|
|
205
|
+
# @param initialize_result [String, nil] a recorded +initialize+ *result*
|
|
206
|
+
# member, as JSON text, put on the wire byte-for-byte. Overrides
|
|
207
|
+
# +capabilities+ and +server_info+ when given.
|
|
208
|
+
def initialize(capabilities: {}, server_info: nil, initialize_result: nil)
|
|
209
|
+
@capabilities = capabilities
|
|
210
|
+
@server_info = server_info
|
|
211
|
+
@initialize_result = initialize_result
|
|
212
|
+
@routes = {}
|
|
213
|
+
@received = []
|
|
214
|
+
@mutex = Mutex.new
|
|
215
|
+
@channel, @wire = Wire.channel
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Answer +method+ with whatever the block returns, serialized as the
|
|
219
|
+
# +result+ member.
|
|
220
|
+
#
|
|
221
|
+
# @param method [String] e.g. +"textDocument/definition"+.
|
|
222
|
+
# @yieldparam params [Hash, nil] the request's +params+.
|
|
223
|
+
# @yieldreturn [Hash, Array, String, Numeric, true, false, nil] the
|
|
224
|
+
# +result+ member — +nil+ is an answer, not a miss.
|
|
225
|
+
# @return [void]
|
|
226
|
+
def on(method, &block)
|
|
227
|
+
@mutex.synchronize { @routes[method] = { json: false, block: block } }
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Answer +method+ with recorded bytes: the block returns the +result+
|
|
232
|
+
# member as JSON *text*, spliced into the response with only the id
|
|
233
|
+
# filled in, so key order and spacing survive exactly as captured.
|
|
234
|
+
#
|
|
235
|
+
# @param method [String]
|
|
236
|
+
# @yieldparam params [Hash, nil]
|
|
237
|
+
# @yieldreturn [String] JSON text.
|
|
238
|
+
# @return [void]
|
|
239
|
+
def on_json(method, &block)
|
|
240
|
+
@mutex.synchronize { @routes[method] = { json: true, block: block } }
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Refuse +method+ the way a server refuses an operation it does not have.
|
|
245
|
+
#
|
|
246
|
+
# @param method [String]
|
|
247
|
+
# @param code [Integer]
|
|
248
|
+
# @param message [String]
|
|
249
|
+
# @return [void]
|
|
250
|
+
def refuse(method, code: METHOD_NOT_FOUND, message: nil)
|
|
251
|
+
on(method) { raise Refusal.new(code, message || "Method not found: #{method}") }
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Start the serving thread; a second call is a no-op. It has to be
|
|
256
|
+
# running before the client's handshake, which is the first thing that
|
|
257
|
+
# needs an answer.
|
|
258
|
+
#
|
|
259
|
+
# @return [self]
|
|
260
|
+
def serve
|
|
261
|
+
@thread ||= Thread.new { serve_loop }
|
|
262
|
+
self
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Push a notification at the client, unprompted.
|
|
266
|
+
#
|
|
267
|
+
# @param method [String]
|
|
268
|
+
# @param params [Hash]
|
|
269
|
+
# @return [void]
|
|
270
|
+
def notify(method, params = {})
|
|
271
|
+
@wire.notify(method, params)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Put a complete recorded frame on the wire untouched — how a captured
|
|
275
|
+
# +$/progress+ or +window/logMessage+ notification is replayed.
|
|
276
|
+
#
|
|
277
|
+
# @param body [String] a complete JSON-RPC message as JSON text.
|
|
278
|
+
# @return [void]
|
|
279
|
+
def send_frame(body)
|
|
280
|
+
@wire.send_raw(body)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Ask the client something, to exercise its reply to a server-initiated
|
|
284
|
+
# request.
|
|
285
|
+
#
|
|
286
|
+
# @param method [String]
|
|
287
|
+
# @param params [Hash]
|
|
288
|
+
# @param id [String, Integer]
|
|
289
|
+
# @return [void]
|
|
290
|
+
def request(method, params = {}, id: 'server-1')
|
|
291
|
+
@wire.request(method, params, id: id)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Die: close the channel with no warning and no +exit+, the way a crashed
|
|
295
|
+
# child does.
|
|
296
|
+
#
|
|
297
|
+
# @return [void]
|
|
298
|
+
def crash!
|
|
299
|
+
@wire.close
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# @return [Array<Hash>] every message the client sent, in order, parsed.
|
|
303
|
+
# A snapshot, and it can lag by one message: a client call returns when
|
|
304
|
+
# its *bytes* are written, and the serving thread records them a moment
|
|
305
|
+
# later. Assert through {#await} rather than racing it.
|
|
306
|
+
def received
|
|
307
|
+
@mutex.synchronize { @received.dup }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Block until the client has sent a matching message, and return it.
|
|
311
|
+
#
|
|
312
|
+
# server.await('textDocument/didOpen')
|
|
313
|
+
# server.await { |message| message['id'] == 'srv-1' && message.key?('result') }
|
|
314
|
+
#
|
|
315
|
+
# @param method [String, nil] match on the +method+ member; omit and pass
|
|
316
|
+
# a block to match on anything else (a *reply* to a server-initiated
|
|
317
|
+
# request carries no method at all).
|
|
318
|
+
# @param timeout [Numeric] seconds before giving up.
|
|
319
|
+
# @yieldparam message [Hash]
|
|
320
|
+
# @yieldreturn [Boolean]
|
|
321
|
+
# @return [Hash] the first matching message.
|
|
322
|
+
# @raise [RuntimeError] on timeout, listing what did arrive — a spec
|
|
323
|
+
# waiting for the wrong thing must fail, not hang.
|
|
324
|
+
def await(method = nil, timeout: 2)
|
|
325
|
+
matcher = block_given? ? ->(message) { yield(message) } : ->(message) { message['method'] == method }
|
|
326
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
327
|
+
loop do
|
|
328
|
+
found = @mutex.synchronize { @received.find(&matcher) }
|
|
329
|
+
return found if found
|
|
330
|
+
|
|
331
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
332
|
+
raise "waited #{timeout}s for #{method || 'a match'}; received " \
|
|
333
|
+
"#{received.map { |message| message['method'] }.inspect}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
sleep 0.01
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Stop serving and close the channel. Idempotent.
|
|
341
|
+
#
|
|
342
|
+
# @return [void]
|
|
343
|
+
def stop
|
|
344
|
+
@wire.close
|
|
345
|
+
@thread&.join(2)
|
|
346
|
+
@thread&.kill
|
|
347
|
+
@thread = nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Raised by a route to answer with a JSON-RPC error instead of a result.
|
|
351
|
+
# {#refuse} is the ordinary way to reach it.
|
|
352
|
+
class Refusal < StandardError
|
|
353
|
+
# @return [Integer] JSON-RPC error code.
|
|
354
|
+
attr_reader :code
|
|
355
|
+
|
|
356
|
+
# @param code [Integer]
|
|
357
|
+
# @param message [String]
|
|
358
|
+
def initialize(code, message)
|
|
359
|
+
@code = code
|
|
360
|
+
super(message)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
private
|
|
365
|
+
|
|
366
|
+
def serve_loop
|
|
367
|
+
loop do
|
|
368
|
+
message = @wire.take
|
|
369
|
+
@mutex.synchronize { @received << message }
|
|
370
|
+
dispatch(message)
|
|
371
|
+
end
|
|
372
|
+
rescue EOFError, IOError, Errno::EPIPE, Errno::EBADF
|
|
373
|
+
nil # the client hung up, which is how a spec ends
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def dispatch(message)
|
|
377
|
+
method = message['method']
|
|
378
|
+
return if message['id'].nil? # a notification: nothing to answer
|
|
379
|
+
|
|
380
|
+
case method
|
|
381
|
+
when 'initialize' then answer_initialize(message)
|
|
382
|
+
when 'shutdown' then @wire.reply(message, nil)
|
|
383
|
+
else answer_route(message, method)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def answer_initialize(message)
|
|
388
|
+
if @initialize_result
|
|
389
|
+
@wire.send_raw(%({"jsonrpc":"2.0","id":#{message['id'].to_json},"result":#{@initialize_result}}))
|
|
390
|
+
return
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
result = { 'capabilities' => @capabilities }
|
|
394
|
+
result['serverInfo'] = @server_info if @server_info
|
|
395
|
+
@wire.reply(message, result)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def answer_route(message, method)
|
|
399
|
+
route = @mutex.synchronize { @routes[method] }
|
|
400
|
+
return @wire.reply_error(message, code: METHOD_NOT_FOUND, message: "Method not found: #{method}") unless route
|
|
401
|
+
|
|
402
|
+
begin
|
|
403
|
+
value = route[:block].call(message['params'])
|
|
404
|
+
rescue Refusal => e
|
|
405
|
+
return @wire.reply_error(message, code: e.code, message: e.message)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
if route[:json]
|
|
409
|
+
@wire.send_raw(%({"jsonrpc":"2.0","id":#{message['id'].to_json},"result":#{value}}))
|
|
410
|
+
else
|
|
411
|
+
@wire.reply(message, value)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
module Lsp
|
|
7
|
+
# +file:+ URI ↔ local path, plus the scheme sniff that decides whether a
|
|
8
|
+
# result is a path at all:
|
|
9
|
+
#
|
|
10
|
+
# Uris.for_path('/home/m/my repo/a.rb') # => "file:///home/m/my%20repo/a.rb"
|
|
11
|
+
# Uris.to_path('file:///home/m/a%23b.rb') # => "/home/m/a#b.rb"
|
|
12
|
+
# Uris.scheme('jdt://contents/rt.jar/java.lang/String.class?=…') # => "jdt"
|
|
13
|
+
# Uris.to_path('jdt://contents/…') # => nil
|
|
14
|
+
#
|
|
15
|
+
# {.to_path} answering +nil+ is the load-bearing half: a server's answer is
|
|
16
|
+
# routinely *not* a file (jdtls hands back +jdt:+ URIs for anything inside a
|
|
17
|
+
# jar), and a client that assumes otherwise ends up denylist-checking a path
|
|
18
|
+
# that never existed.
|
|
19
|
+
module Uris
|
|
20
|
+
# Characters left unescaped in a path segment: RFC 3986 unreserved, plus
|
|
21
|
+
# +/+ and +:+ which are legal in a path. Everything else — space, +#+,
|
|
22
|
+
# +?+, non-ASCII — is percent-encoded, because +#+ and +?+ would
|
|
23
|
+
# otherwise silently truncate the URI into a fragment or a query.
|
|
24
|
+
PATH_SAFE = %r{[^A-Za-z0-9\-_.!~*'()/:]}
|
|
25
|
+
private_constant :PATH_SAFE
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @param path [String, Pathname] an absolute local path.
|
|
30
|
+
# @return [String] the +file:+ URI a +didOpen+ carries.
|
|
31
|
+
# @raise [ArgumentError] if +path+ is relative — a server resolves URIs
|
|
32
|
+
# against nothing, so a relative one is a silently wrong document.
|
|
33
|
+
def for_path(path)
|
|
34
|
+
str = path.to_s
|
|
35
|
+
raise ArgumentError, "path must be absolute, got #{str.inspect}" unless str.start_with?('/')
|
|
36
|
+
|
|
37
|
+
"file://#{URI::DEFAULT_PARSER.escape(str, PATH_SAFE)}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Local path behind a +file:+ URI.
|
|
41
|
+
#
|
|
42
|
+
# @param uri [String] any URI a server answered with.
|
|
43
|
+
# @return [String, nil] the percent-decoded path, or +nil+ when +uri+ is
|
|
44
|
+
# not a +file:+ URI or names a remote host (+file://host/p+ is a UNC
|
|
45
|
+
# path; pikuri is Linux-first and has no way to read one).
|
|
46
|
+
def to_path(uri)
|
|
47
|
+
match = %r{\Afile://([^/]*)(/.*)\z}.match(uri)
|
|
48
|
+
return nil unless match && ['', 'localhost'].include?(match[1].downcase)
|
|
49
|
+
|
|
50
|
+
URI::DEFAULT_PARSER.unescape(match[2]).force_encoding(Encoding::UTF_8)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param uri [String]
|
|
54
|
+
# @return [String, nil] the lowercased scheme, or +nil+ if +uri+ carries
|
|
55
|
+
# none.
|
|
56
|
+
def scheme(uri)
|
|
57
|
+
uri[/\A([A-Za-z][A-Za-z0-9+.\-]*):/, 1]&.downcase
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/pikuri-lsp.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pikuri-core'
|
|
4
|
+
require 'pikuri-workspace'
|
|
5
|
+
|
|
6
|
+
# Entry file for the pikuri-lsp gem: mounts a per-gem Zeitwerk loader at this
|
|
7
|
+
# +lib/+ (contributing to the shared +Pikuri::+ namespace), so
|
|
8
|
+
# +lib/pikuri/lsp/connection.rb+ autoloads as +Pikuri::Lsp::Connection+, etc.
|
|
9
|
+
module Pikuri
|
|
10
|
+
# Language Server Protocol client: the questions grep cannot answer — where
|
|
11
|
+
# is this defined, who calls it, what does it inherit from — asked of a real
|
|
12
|
+
# language server and relayed back verbatim. Read-only by construction; the
|
|
13
|
+
# moment it applies an edit it needs the +Confirmer+ seam and stops being a
|
|
14
|
+
# gem you can audit in one sitting.
|
|
15
|
+
#
|
|
16
|
+
# Three layers, and start reading at the first:
|
|
17
|
+
#
|
|
18
|
+
# * {Lsp::Connection} is the *protocol* — JSON-RPC over one IO pair, and
|
|
19
|
+
# nothing of LSP's vocabulary.
|
|
20
|
+
# * {Lsp::Position} / {Lsp::Range} / {Lsp::Location} / {Lsp::ServerProgress}
|
|
21
|
+
# are the *boundary* — the wire shapes messy enough that the rest of the gem
|
|
22
|
+
# should never see them. Coordinates cross it exactly once: 1-based line and
|
|
23
|
+
# character column inside pikuri, 0-based and {Lsp::PositionEncoding}-encoded
|
|
24
|
+
# outside.
|
|
25
|
+
# * {Lsp::LspTool} over {Lsp::Navigator} is the *policy* — which server answers
|
|
26
|
+
# a file, whether it advertised the operation, whether its index is built yet,
|
|
27
|
+
# and how much of what it said is worth rendering.
|
|
28
|
+
module Lsp
|
|
29
|
+
LOADER = Zeitwerk::Loader.new
|
|
30
|
+
LOADER.tag = 'pikuri-lsp'
|
|
31
|
+
LOADER.push_dir(File.expand_path('.', __dir__))
|
|
32
|
+
LOADER.ignore(__FILE__)
|
|
33
|
+
# Opt-in test scaffolding (+require 'pikuri/lsp/testing'+), off the
|
|
34
|
+
# production load path — as pikuri-core does with +pikuri/testing.rb+.
|
|
35
|
+
LOADER.ignore(File.expand_path('pikuri/lsp/testing.rb', __dir__))
|
|
36
|
+
LOADER.setup
|
|
37
|
+
LOADER.eager_load
|
|
38
|
+
end
|
|
39
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pikuri-lsp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Martin Vysny
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: pikuri-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: pikuri-workspace
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - '='
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.1.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - '='
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.1.0
|
|
40
|
+
description: |
|
|
41
|
+
pikuri-lsp lets a pikuri agent ask a real language server the
|
|
42
|
+
questions grep cannot answer: where is this defined, who calls it,
|
|
43
|
+
what does it inherit from. It speaks LSP over a long-lived stdio
|
|
44
|
+
child (+Pikuri::Lsp::Connection+ owns the JSON-RPC framing, the
|
|
45
|
+
id demux and the reply to server-initiated requests) and models
|
|
46
|
+
coordinates once, at the wire boundary, in
|
|
47
|
+
+Pikuri::Lsp::Position+ / +Range+ / +Location+.
|
|
48
|
+
|
|
49
|
+
Read-only by construction: no formatting, no code actions, no
|
|
50
|
+
rename, no workspace edits. It reads what the server says and
|
|
51
|
+
reports it.
|
|
52
|
+
email:
|
|
53
|
+
- martin@vysny.me
|
|
54
|
+
executables: []
|
|
55
|
+
extensions: []
|
|
56
|
+
extra_rdoc_files: []
|
|
57
|
+
files:
|
|
58
|
+
- README.md
|
|
59
|
+
- lib/pikuri-lsp.rb
|
|
60
|
+
- lib/pikuri/lsp/anchor.rb
|
|
61
|
+
- lib/pikuri/lsp/client_wrapper.rb
|
|
62
|
+
- lib/pikuri/lsp/connection.rb
|
|
63
|
+
- lib/pikuri/lsp/extension.rb
|
|
64
|
+
- lib/pikuri/lsp/location.rb
|
|
65
|
+
- lib/pikuri/lsp/lsp_tool.rb
|
|
66
|
+
- lib/pikuri/lsp/mailbox.rb
|
|
67
|
+
- lib/pikuri/lsp/navigator.rb
|
|
68
|
+
- lib/pikuri/lsp/operation.rb
|
|
69
|
+
- lib/pikuri/lsp/position.rb
|
|
70
|
+
- lib/pikuri/lsp/position_encoding.rb
|
|
71
|
+
- lib/pikuri/lsp/range.rb
|
|
72
|
+
- lib/pikuri/lsp/readiness.rb
|
|
73
|
+
- lib/pikuri/lsp/refusal.rb
|
|
74
|
+
- lib/pikuri/lsp/registry.rb
|
|
75
|
+
- lib/pikuri/lsp/renderer.rb
|
|
76
|
+
- lib/pikuri/lsp/server_progress.rb
|
|
77
|
+
- lib/pikuri/lsp/servers.rb
|
|
78
|
+
- lib/pikuri/lsp/sources.rb
|
|
79
|
+
- lib/pikuri/lsp/symbol_name.rb
|
|
80
|
+
- lib/pikuri/lsp/testing.rb
|
|
81
|
+
- lib/pikuri/lsp/uris.rb
|
|
82
|
+
homepage: https://codeberg.org/mvysny/pikuri
|
|
83
|
+
licenses:
|
|
84
|
+
- MIT
|
|
85
|
+
metadata:
|
|
86
|
+
source_code_uri: https://codeberg.org/mvysny/pikuri/src/branch/master
|
|
87
|
+
changelog_uri: https://codeberg.org/mvysny/pikuri/src/branch/master/CHANGELOG.md
|
|
88
|
+
bug_tracker_uri: https://codeberg.org/mvysny/pikuri/issues
|
|
89
|
+
rubygems_mfa_required: 'true'
|
|
90
|
+
rdoc_options: []
|
|
91
|
+
require_paths:
|
|
92
|
+
- lib
|
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '3.3'
|
|
98
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
requirements: []
|
|
104
|
+
rubygems_version: 3.6.7
|
|
105
|
+
specification_version: 4
|
|
106
|
+
summary: Language Server Protocol client for pikuri — read-only code navigation.
|
|
107
|
+
test_files: []
|