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,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