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.
@@ -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: []