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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Lsp
5
+ # The +lsp+ tool: one tool, ten operations, and no column ever asked for.
6
+ #
7
+ # tool = LspTool.new(servers: servers, filesystem: filesystem)
8
+ # tool.run('operation' => 'goToDefinition', 'symbol' => 'resolve_for_read',
9
+ # 'file' => 'read.rb', 'line' => 165)
10
+ #
11
+ # The *surface* lives here — the schema, the description the model reads, the
12
+ # legs, the progress emitter. The behaviour is {Navigator}'s, the enum is
13
+ # {Operation}'s, and why a column is derived rather than asked for is {Anchor}'s.
14
+ #
15
+ # Ten +lsp_*+ tools would be ten descriptions in every request's prompt budget,
16
+ # and all three shipped harnesses reached the same one-tool conclusion. The
17
+ # +foo_*+ prefix convention is still satisfied: the family shares one backing
18
+ # resource, and +Tool::Parameters+ validates the enum strictly.
19
+ #
20
+ # == The legs, including the one that will surprise you
21
+ #
22
+ # +untrusted: :hard+, **unconditionally** — not riding +filesystem.trusted?+ the
23
+ # way {Pikuri::Workspace::Read} does. +read+'s conditional cannot be copied
24
+ # because +read+ is confined to the root, so a human's "yes, I trust this
25
+ # checkout" covers exactly what +read+ can reach. A server's index does not stop
26
+ # at the root: it reaches the whole bundle, the JDK and four hundred transitive
27
+ # jars, and the +jdt:+ content path reads text out of a jar nobody ever diffed.
28
+ # Quoting third-party prose into the context in bulk is this gem's *marquee
29
+ # feature*, not an edge case of it. The consequence is deliberate: on a trusted
30
+ # checkout +read+ declares +:none+, so wiring +lsp+ beside +fetch+ or
31
+ # +web_search+ can light the untrusted leg where nothing fired before.
32
+ #
33
+ # +egress_payload_review: :no_egress+, and *not* because the server is trusted:
34
+ # a seat whose outbound bytes do not originate in the model's choosing holds no
35
+ # leg to grade. +operation+, +symbol+, +file+ and +line+ reach an in-memory
36
+ # index, never a socket. A registered server may go online on its own — jdtls
37
+ # resolving a jar — but that traffic follows the project's build files and
38
+ # happens identically if the model never issues a single call.
39
+ #
40
+ # +private:+ is inherited from the workspace, never claimed here: the host
41
+ # already asked the human whether this project is private.
42
+ #
43
+ # == Sharing
44
+ #
45
+ # +P_one_agent+, and accepted as such: one wiring, one agent. Three things
46
+ # bind it — a per-agent {Pikuri::Agent::Control::Cancellable}, a mutable
47
+ # +on_progress+ hook installed after construction, and {Servers}, whose
48
+ # open-document bookkeeping assumes one caller (a second agent's
49
+ # +didClose+ can land on a document the first is still asking about).
50
+ #
51
+ # Ten agents over one project therefore means ten registries and ten sets
52
+ # of child processes, which is the honest cost of not having built a shared
53
+ # server pool. Nothing here refuses the sharing; it just goes wrong
54
+ # quietly, so don't.
55
+ class LspTool < Pikuri::Tool
56
+ # Description shown to the LLM (opencode-shape). Per-parameter constraints
57
+ # live in the parameter descriptions; the operation list is generated from
58
+ # {Operation::ALL} so it cannot drift from what dispatch accepts.
59
+ #
60
+ # Two things it deliberately does *not* say, both of which an earlier draft
61
+ # did. It does not mention that the first call waits for the server to
62
+ # finish indexing — the model has no notion of time, so the only content in
63
+ # that sentence is an implied promise that results are now complete, and a
64
+ # fully-started server can still be broken. And it does not announce that an
65
+ # unsupported operation refuses cleanly: the model learns that from the
66
+ # refusal, which names the missing capability. What stays is mechanics —
67
+ # how to anchor a call, what shapes the answer comes in — never a claim
68
+ # about the *quality* of what comes back, which is the model's to judge.
69
+ DESCRIPTION = <<~DESC
70
+ Ask a language server about code: where a name is defined, who calls it, what it inherits from.
71
+
72
+ Usage:
73
+ - Anchor a query with `symbol` plus `file` and `line` — the line number as printed when you read the file or searched it. The column is worked out from that line's text, so it is never asked for.
74
+ - `symbol` on its own searches every configured server's index instead. Best effort: a short name is matched against fully qualified ones and can score below a server's own threshold, and some servers index types but no methods.
75
+ - Servers are configured per file type. A file type nothing claims is refused, never guessed at.
76
+ - Results are capped and grouped, references and callers most aggressively; a truncation marker means there was more.
77
+ - A server's index reaches outside the project, so a result may live in a dependency rather than in your code. Such a result always carries its line of code, and carries a path when this workspace can read that file — otherwise just the file's name, or the type's name and the archive it came from.
78
+
79
+ Operations:
80
+ #{Operation::ALL.map { |operation| "- #{operation.name} — #{operation.summary}" }.join("\n")}
81
+ DESC
82
+
83
+ # @return [Proc] called with a {ServerProgress}, on the calling thread, while
84
+ # a call waits for a server to finish indexing. A host installs its event
85
+ # emitter here after construction; the default drops them, which is what a
86
+ # tool built outside an agent wants.
87
+ attr_accessor :on_progress
88
+
89
+ # @param servers [Servers] started clients. The tool never spawns.
90
+ # @param filesystem [Pikuri::Workspace::Filesystem] the project: path
91
+ # resolution, root confinement, the +denied?+ pass every walked path owes,
92
+ # and the +private?+ answer this tool's legs inherit.
93
+ # @param cancellable [Pikuri::Agent::Control::Cancellable, nil] what makes an
94
+ # unbounded index wait acceptable.
95
+ def initialize(servers:, filesystem:, cancellable: nil)
96
+ @servers = servers
97
+ @filesystem = filesystem
98
+ @cancellable = cancellable
99
+ @on_progress = ->(_progress) {}
100
+ tool = self
101
+ super(
102
+ name: 'lsp',
103
+ description: DESCRIPTION,
104
+ parameters: Pikuri::Tool::Parameters.build { |p|
105
+ p.required_enum :operation,
106
+ 'Which question to ask; see the Operations list, ' \
107
+ 'e.g. "goToDefinition".',
108
+ values: Operation.names
109
+ p.required_string :symbol,
110
+ 'The name to ask about, spelled exactly as the ' \
111
+ 'source spells it, e.g. "resolve_for_read" or ' \
112
+ '"Pikuri::Workspace::Read".'
113
+ p.optional_string :file,
114
+ 'File the symbol is in, e.g. ' \
115
+ '"pikuri-workspace/lib/pikuri/workspace/read.rb". ' \
116
+ 'Relative paths resolve against the workspace ' \
117
+ 'root. Picks the language server and, with line, ' \
118
+ 'anchors the query; without it every server\'s ' \
119
+ 'index is searched. Required for documentSymbol, ' \
120
+ 'and needed in practice for goToTypeDefinition, ' \
121
+ 'whose usual target is a local variable that is in ' \
122
+ 'no index.'
123
+ p.optional_integer :line,
124
+ '1-based line the symbol appears on, as printed ' \
125
+ 'when you read the file, e.g. 165. Needs file, ' \
126
+ 'and the symbol must occur literally on that line.'
127
+ },
128
+ execute: lambda { |operation:, symbol:, file: nil, line: nil|
129
+ tool.navigate(operation: operation, symbol: symbol, file: file, line: line)
130
+ },
131
+ trifecta_legs: Pikuri::Tool::TrifectaLegs.new(
132
+ private: filesystem.private?,
133
+ untrusted: :hard,
134
+ egress_payload_review: :no_egress
135
+ )
136
+ )
137
+ end
138
+
139
+ # Run one call.
140
+ #
141
+ # A fresh {Navigator} per call, rather than one captured in the +execute+
142
+ # lambda: {#on_progress} is installed *after* the tool is built, so a
143
+ # navigator built once would keep emitting into the default no-op forever.
144
+ #
145
+ # @param operation [String]
146
+ # @param symbol [String]
147
+ # @param file [String, nil]
148
+ # @param line [Integer, nil]
149
+ # @return [String] the observation.
150
+ def navigate(operation:, symbol:, file: nil, line: nil)
151
+ Navigator.new(servers: @servers, filesystem: @filesystem,
152
+ on_progress: @on_progress, cancellable: @cancellable)
153
+ .navigate(operation: operation, symbol: symbol, file: file, line: line)
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Lsp
5
+ # A thread boundary that keeps only the *newest* value per key. A producer
6
+ # thread pushes; one consumer thread drains, and the block runs on the
7
+ # consumer's thread:
8
+ #
9
+ # mailbox = Mailbox.new
10
+ # Thread.new { mailbox.push('token-1', progress) } # the reader thread
11
+ #
12
+ # mailbox.drain(tick: 0.1) do |value| # nil on an idle tick
13
+ # cancellable&.check!
14
+ # break if ready?
15
+ # emit.call(value) if value
16
+ # end
17
+ #
18
+ # {#drain} loops until the block +break+s — the consumer has to poll its own
19
+ # exit conditions anyway, so it gets one place to do it. No +stop_when:+
20
+ # predicate, no sentinel value.
21
+ #
22
+ # It exists for the one shape a queue gets wrong. ruby-lsp emits hundreds of
23
+ # +$/progress+ reports while it indexes, and a consumer blocked for three
24
+ # minutes would replay every one of them — a burst of stale percentages
25
+ # racing to catch up. Coalescing per key means the first drain yields
26
+ # *current* state and then tracks live, which makes +Thread::Queue+ the wrong
27
+ # primitive: it faithfully preserves exactly what this discards.
28
+ #
29
+ # Per key rather than one global latest because concurrent tasks are normal —
30
+ # jdtls holds "Importing project" and "Building workspace" open at once. The
31
+ # key is a parameter, so nothing here knows what the value is.
32
+ #
33
+ # Thread-safe, and that is its whole purpose. One consumer only: two threads
34
+ # draining would each see an arbitrary half of the values.
35
+ class Mailbox
36
+ def initialize
37
+ @mutex = Mutex.new
38
+ @pushed = ConditionVariable.new
39
+ @latest = {}
40
+ end
41
+
42
+ # Park +value+ under +key+, replacing whatever was parked there, and wake
43
+ # the consumer.
44
+ #
45
+ # @param key [Object] what to coalesce on, e.g. a progress token.
46
+ # @param value [Object] the newest state for that key.
47
+ # @return [void]
48
+ def push(key, value)
49
+ @mutex.synchronize do
50
+ @latest[key] = value
51
+ @pushed.signal
52
+ end
53
+ nil
54
+ end
55
+
56
+ # Yield parked values on this thread until the block breaks, waking every
57
+ # +tick+ seconds to yield +nil+ so the consumer can check its own exit
58
+ # conditions. Values arrive in the order their keys were first pushed;
59
+ # +nil+ means the mailbox was empty, not that anything ended.
60
+ #
61
+ # @param tick [Float] seconds between idle yields. A push wakes the wait
62
+ # early, so this bounds the *idle* latency, not the live one.
63
+ # @yieldparam value [Object, nil] the newest value for one key, or +nil+ on
64
+ # an idle tick.
65
+ # @return [Object] whatever the block broke with.
66
+ def drain(tick:)
67
+ loop do
68
+ parked = take_all
69
+ if parked.empty?
70
+ yield nil
71
+ @mutex.synchronize { @pushed.wait(@mutex, tick) if @latest.empty? }
72
+ else
73
+ parked.each { |value| yield value }
74
+ end
75
+ end
76
+ end
77
+
78
+ # @return [Integer] how many keys are parked. For tests and diagnostics;
79
+ # a consumer polls by draining.
80
+ def size
81
+ @mutex.synchronize { @latest.size }
82
+ end
83
+
84
+ private
85
+
86
+ # Atomically: everything parked, and an empty mailbox. Nothing is yielded
87
+ # under the mutex — the producer must stay free to push while the consumer
88
+ # works.
89
+ def take_all
90
+ @mutex.synchronize do
91
+ values = @latest.values
92
+ @latest.clear
93
+ values
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Lsp
5
+ # One +lsp+ call, end to end: route to a server, wait for its index, re-open
6
+ # the document, ask, render. {LspTool} holds the schema and the legs; this holds
7
+ # the behaviour, so a spec can drive it with no tool in sight.
8
+ #
9
+ # navigator = Navigator.new(servers: servers, filesystem: filesystem,
10
+ # on_progress: ->(progress) { ctx.emit_event(progress) })
11
+ # navigator.navigate(operation: 'goToDefinition', symbol: 'resolve_for_read',
12
+ # file: 'read.rb', line: 165)
13
+ #
14
+ # == The order of the five steps is the design
15
+ #
16
+ # 1. **Route on the file**, or fan out when there is none — the model names no
17
+ # server, and a registry keyed by language would fail in exactly the
18
+ # mixed-language repo that makes a registry worth having.
19
+ # 2. **Gate on what that server advertised**, per workspace and at dispatch.
20
+ # Extension routing without this is how a shipped client leaks
21
+ # +Method not found: textDocument/implementation+ at the model.
22
+ # 3. **Wait for the index** — the one place waiting beats relaying, because a
23
+ # mid-index server answers +[]+ with a success code and there is nothing in
24
+ # +[]+ to reason about. No timeout: a cold Java import measured 78 seconds,
25
+ # no number separates that from a hang, and progress events plus
26
+ # +cancellable+ make the human the timeout.
27
+ # 4. **Re-open the document** ({ClientWrapper#open_document}), and hand it
28
+ # back once every reply is in ({#release_documents}) — the pair is per
29
+ # *call*, because one call can ask several questions about one document.
30
+ # 5. **Ask and render**, running the +prepare+ hop and the per-level walk here
31
+ # so the model issues one call and never holds an opaque protocol item.
32
+ #
33
+ # == Errors are relayed, never worked around
34
+ #
35
+ # Every server is broken in its own way and the ways do not generalize — one
36
+ # advertises a type hierarchy whose downward half is a +# TODO+, another indexes
37
+ # no methods, a third crashes on a notification the spec allows. So there is no
38
+ # per-server workaround and no known-stub list here: what the server said and
39
+ # what it was asked go back, and the model decides. That includes a reply pikuri
40
+ # cannot parse ({#relaying}) — the server's bug, and dying on it would be the
41
+ # one failure mode this stance exists to prevent.
42
+ class Navigator
43
+ # Items a +prepare+ hop may produce before the rest are dropped with a note.
44
+ # +prepare+ can legitimately answer several — Java overloads, one name
45
+ # declared in several classes — and the model has no more idea which is meant
46
+ # than this does, so each is queried and the results are labelled.
47
+ MAX_PREPARED = 3
48
+
49
+ # @param servers [Servers] the live clients.
50
+ # @param filesystem [Pikuri::Workspace::Filesystem] path resolution, root
51
+ # confinement, the denylist pass and the seam every read routes through.
52
+ # @param on_progress [Proc, nil] called with a {ServerProgress} while a wait
53
+ # blocks — on *this* thread, which is what makes it safe to emit as an
54
+ # agent event.
55
+ # @param cancellable [Pikuri::Agent::Control::Cancellable, nil] polled by
56
+ # every wait and every request.
57
+ def initialize(servers:, filesystem:, on_progress: nil, cancellable: nil)
58
+ @servers = servers
59
+ @filesystem = filesystem
60
+ @on_progress = on_progress || ->(_progress) {}
61
+ @cancellable = cancellable
62
+ @ready = []
63
+ @texts = {}
64
+ @opened = []
65
+ end
66
+
67
+ # Answer one call.
68
+ #
69
+ # @param operation [String] an {Operation} name; +Tool::Parameters+ has
70
+ # already refused anything outside the enum.
71
+ # @param symbol [String] the name to ask about.
72
+ # @param file [String, nil] workspace-relative or absolute; the server
73
+ # selector, and the document an anchor is located in.
74
+ # @param line [Integer, nil] 1-based, as +read+ and +grep+ print it.
75
+ # @return [String] the observation, including +"Error: …"+ for everything
76
+ # the model can react to.
77
+ # @raise [Pikuri::Agent::Control::Cancellable::Cancelled] on cancellation —
78
+ # deliberately *not* an observation.
79
+ def navigate(operation:, symbol:, file: nil, line: nil)
80
+ op = Operation[operation]
81
+ raise ArgumentError, "unknown lsp operation #{operation.inspect}" if op.nil?
82
+
83
+ renderer = Renderer.new(sources: Sources.new(filesystem: @filesystem))
84
+ @ready = []
85
+ @texts = {}
86
+ @opened = []
87
+ raise Refusal, 'no language server is configured' if @servers.empty?
88
+
89
+ dispatch(op, symbol, file, line, renderer)
90
+ rescue Refusal, Pikuri::Workspace::Filesystem::Error, ClientWrapper::ServerDied => e
91
+ "Error: #{e.message}"
92
+ rescue Connection::ServerError => e
93
+ "Error: the language server refused #{operation}: #{e.message}"
94
+ ensure
95
+ release_documents
96
+ end
97
+
98
+ private
99
+
100
+ def dispatch(operation, symbol, file, line, renderer)
101
+ case operation.anchor
102
+ when :query then fan_out(operation, symbol, renderer)
103
+ when :document then outline(operation, symbol, file, renderer)
104
+ else positional(operation, symbol, file, line, renderer)
105
+ end
106
+ end
107
+
108
+ # +workspaceSymbol+ takes no position at all, so every server that can search
109
+ # is asked. Fanning out starts each of them and costs the *first* call their
110
+ # remaining index time — the accepted price of a question that names no file,
111
+ # and why {Servers#active} narrows the set to servers the project has files
112
+ # for.
113
+ def fan_out(operation, symbol, renderer)
114
+ clients = @servers.active
115
+ raise Refusal, no_relevant_server_message if clients.empty?
116
+
117
+ usable, refusing = clients.partition { |client| client.supports?(operation.capability) }
118
+ raise Refusal, unsupported_message(operation, clients) if usable.empty?
119
+
120
+ refusing.each { |client| renderer.note(cannot_answer(operation, client)) }
121
+ blocks = usable.map do |client|
122
+ ensure_ready(client)
123
+ rows = client.request(operation.request, { query: symbol }, cancellable: @cancellable)
124
+ block = relaying(operation, client) do
125
+ renderer.symbols(Array(rows), operation: operation, symbol: symbol, client: client)
126
+ end
127
+ usable.length > 1 ? "From the #{client.entry.id} server:\n#{block}" : block
128
+ end
129
+ renderer.finish(blocks.join("\n\n"))
130
+ end
131
+
132
+ # +documentSymbol+: the only file-keyed operation, and the one route from a
133
+ # method *name* to a position where a server's project-wide index holds
134
+ # types only.
135
+ def outline(operation, symbol, file, renderer)
136
+ raise Refusal, "#{operation.name} outlines one file, so it needs file:" if file.nil?
137
+
138
+ resolved = readable(file)
139
+ client = @servers.client_for(resolved)
140
+ raise Refusal, Anchor.no_server_message(resolved) if client.nil?
141
+ raise Refusal, unsupported_message(operation, [client]) unless client.supports?(operation.capability)
142
+
143
+ ensure_ready(client)
144
+ uri = open_document(client, resolved)
145
+ rows = client.request(operation.request, { textDocument: { uri: uri } }, cancellable: @cancellable)
146
+ renderer.finish(relaying(operation, client) do
147
+ renderer.symbols(Array(rows), operation: operation, symbol: symbol, client: client, uri: uri)
148
+ end)
149
+ end
150
+
151
+ # Everything with a cursor. The capability check is per *anchor*, not per
152
+ # call: a bare name fans out, so two anchors can sit on two servers with
153
+ # different capability sets.
154
+ def positional(operation, symbol, file, line, renderer)
155
+ resolution = Anchor.resolve(servers: @servers, filesystem: @filesystem, symbol: symbol,
156
+ file: file, line: line, ready: method(:ensure_ready))
157
+ resolution.notes.each { |note| renderer.note(note) }
158
+ anchors = resolution.anchors.select { |anchor| anchor.client.supports?(operation.capability) }
159
+ if anchors.empty?
160
+ raise Refusal, unsupported_message(operation, resolution.anchors.map(&:client).uniq)
161
+ end
162
+
163
+ answers = anchors.map { |anchor| [anchor, answer(operation, anchor, symbol, renderer)] }
164
+ renderer.finish(merge(answers))
165
+ end
166
+
167
+ # One anchor, one answer. Identical answers from several anchors collapse in
168
+ # {#merge}; different ones are labelled by column, which is what replaces the
169
+ # +character+ parameter for +names = names.trim()+.
170
+ def answer(operation, anchor, symbol, renderer)
171
+ client = anchor.client
172
+ ensure_ready(client)
173
+ open_document(client, Pathname.new(anchor.path))
174
+
175
+ relaying(operation, client) do
176
+ case operation.shape
177
+ when :hover
178
+ renderer.hover(request(client, operation.request, anchor.params),
179
+ operation: operation, symbol: symbol)
180
+ when :calls
181
+ renderer.calls(hierarchy(operation, anchor, symbol, renderer),
182
+ operation: operation, symbol: symbol, client: client)
183
+ when :supertypes
184
+ renderer.supertypes(ancestry(operation, anchor, symbol, renderer),
185
+ operation: operation, symbol: symbol)
186
+ else
187
+ result = request(client, operation.request, location_params(operation, anchor))
188
+ renderer.locations(Location.list_from_wire(result, encoding: client.position_encoding),
189
+ operation: operation, symbol: symbol, client: client)
190
+ end
191
+ end
192
+ end
193
+
194
+ # A reply whose *shape* pikuri cannot read is the server's bug, not
195
+ # pikuri's, so it is relayed like everything else the server said. Without
196
+ # this the wire types raise +KeyError+ for a location missing its +range+ and
197
+ # +ArgumentError+ for a +definition+ that answered a bare String, and the
198
+ # turn dies on a message nobody sees — which is the one thing the relay
199
+ # stance is supposed to prevent.
200
+ def relaying(operation, client)
201
+ yield
202
+ rescue KeyError, TypeError, ArgumentError => e
203
+ raise Refusal, "the #{client.entry.id} server's #{operation.name} reply could not be " \
204
+ "read (#{e.class}: #{e.message})"
205
+ end
206
+
207
+ # The declaration is a reference too — a caller asking "who uses this" wants
208
+ # it in the list, and every shipped harness includes it.
209
+ def location_params(operation, anchor)
210
+ params = anchor.params
211
+ params = params.merge(context: { includeDeclaration: true }) if operation.name == 'findReferences'
212
+ params
213
+ end
214
+
215
+ # The +prepare+ hop, collapsed. LSP makes both hierarchies a two-step chain
216
+ # and both harnesses that ship the call family hand the model the
217
+ # intermediate item to echo back; running it here costs one round trip the
218
+ # model never sees.
219
+ def prepared(operation, anchor, symbol, renderer)
220
+ items = Array(request(anchor.client, operation.prepare, anchor.params))
221
+ exact = items.select { |item| SymbolName.matches?(item['name'], symbol) }
222
+ chosen = exact.empty? ? items : exact
223
+ if exact.empty? && !items.empty?
224
+ renderer.note("nothing the server prepared here is named #{symbol.inspect}; it offered " \
225
+ "#{items.map { |item| item['name'] }.uniq.join(', ')}.")
226
+ end
227
+ if chosen.length > MAX_PREPARED
228
+ renderer.note("#{chosen.length} declarations were prepared for #{symbol.inspect}; the " \
229
+ "first #{MAX_PREPARED} were queried.")
230
+ end
231
+ chosen.first(MAX_PREPARED)
232
+ end
233
+
234
+ def hierarchy(operation, anchor, symbol, renderer)
235
+ prepared(operation, anchor, symbol, renderer).flat_map do |item|
236
+ Array(request(anchor.client, operation.request, { item: item }))
237
+ end
238
+ end
239
+
240
+ # The ancestor *chain*, walked level by level — which is the whole value of
241
+ # the operation, since a definition hop on the name after +extends+ answers
242
+ # one level and the chain costs one round trip per level.
243
+ #
244
+ # Each level is deduplicated by name before the next query, or a Ruby
245
+ # namespace reopened in 14 files would fan out into 14 queries for the level
246
+ # above it.
247
+ def ancestry(operation, anchor, symbol, renderer)
248
+ current = prepared(operation, anchor, symbol, renderer)
249
+ levels = []
250
+ operation.cap.times do
251
+ parents = current.flat_map { |item| Array(request(anchor.client, operation.request, { item: item })) }
252
+ break if parents.empty?
253
+
254
+ levels << parents
255
+ current = parents.group_by { |item| item['name'].to_s }.values.map(&:last).first(2)
256
+ end
257
+ if levels.length == operation.cap
258
+ renderer.note("the chain was walked #{operation.cap} levels deep and did not end there.")
259
+ end
260
+ levels
261
+ end
262
+
263
+ def request(client, method, params)
264
+ client.request(method, params, cancellable: @cancellable)
265
+ end
266
+
267
+ # Identical bodies collapse — the overwhelmingly common case, since a
268
+ # repeated identifier on one line usually denotes the same entity.
269
+ def merge(answers)
270
+ grouped = answers.group_by(&:last)
271
+ return grouped.keys.first if grouped.length == 1
272
+
273
+ grouped.map do |text, pairs|
274
+ "At #{pairs.map { |anchor, _| anchor.to_s }.join(', ')}:\n#{text}"
275
+ end.join("\n\n")
276
+ end
277
+
278
+ # Blocks until the index is built, emitting progress on this thread. Memoized
279
+ # per call, so a fan-out over three anchors on one server costs one wait.
280
+ def ensure_ready(client)
281
+ return if @ready.include?(client.entry.id)
282
+
283
+ client.wait_until_ready(cancellable: @cancellable) { |progress| @on_progress.call(progress) }
284
+ @ready << client.entry.id
285
+ rescue ClientWrapper::ServerDied => e
286
+ raise Refusal, "the #{client.entry.id} server died while its index was building: #{e.message}"
287
+ end
288
+
289
+ # The re-open, once per document per call, with the text on disk *now*.
290
+ def open_document(client, resolved)
291
+ uri = Uris.for_path(resolved)
292
+ unless @texts.key?(resolved.to_s)
293
+ client.open_document(resolved.to_s, text_of(resolved))
294
+ @opened << [client, resolved.to_s]
295
+ end
296
+ uri
297
+ end
298
+
299
+ # Give back every document this call opened, once every reply is in.
300
+ #
301
+ # Per *call*, not per request: +supertypes+ and the call-hierarchy operations
302
+ # ask several questions about one document, and a close after the first would
303
+ # drop it under the rest.
304
+ def release_documents
305
+ opened = @opened
306
+ @opened = []
307
+ opened.each { |client, path| client.close_document(path) }
308
+ end
309
+
310
+ # Through the seam, always: the text a +didOpen+ carries is a read of a
311
+ # workspace file like any other.
312
+ def text_of(resolved)
313
+ @texts[resolved.to_s] ||= @filesystem.resolve_for_read(resolved).read
314
+ end
315
+
316
+ def readable(file)
317
+ resolved = @filesystem.resolve_for_read(file)
318
+ raise Refusal, "#{file}: no such file in the workspace" unless resolved.file?
319
+
320
+ resolved
321
+ end
322
+
323
+ def unsupported_message(operation, clients)
324
+ ids = clients.map { |client| client.entry.id }
325
+ "the #{ids.join(' and ')} server does not support #{operation.name} in this workspace — " \
326
+ "it advertises no #{operation.capability}"
327
+ end
328
+
329
+ def cannot_answer(operation, client)
330
+ "the #{client.entry.id} server does not support #{operation.name}, so its files were not searched."
331
+ end
332
+
333
+ def no_relevant_server_message
334
+ ids = @servers.registry.entries.map(&:id)
335
+ "no configured language server has files in this project (configured: #{ids.join(', ')}) — " \
336
+ 'name a file to ask a specific one'
337
+ end
338
+ end
339
+ end
340
+ end