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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 24417465298ff3ab66a27ece21f312a28049468e3dbf734e719c184e88fde7fb
4
+ data.tar.gz: 99955d741b2ff30d43878a5eb3dfd0e3f0702206914e6cc4279ba0ec742afdae
5
+ SHA512:
6
+ metadata.gz: 40c0b4f47e95d85dfffdeaaee3c73ab625dea253a34f1e087666de4e61d809bf276f66699496e9e6c37ea788dd9798a30767b41726d762515c5068a562c25004
7
+ data.tar.gz: f505f82dfe49014648f302df34636f21686979c6e33a853c747dfb8098dba81524a82adb21f54fe44334fe3e5d0a153d1caf567d4882248b606d7a006017e582
data/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # pikuri-lsp
2
+
3
+ Language intelligence for a [pikuri](https://codeberg.org/mvysny/pikuri)
4
+ agent: the questions grep cannot answer — *where is this defined*, *who
5
+ calls it*, *what does it inherit from* — asked of a real language server
6
+ and relayed back verbatim.
7
+
8
+ **Read-only by construction.** No formatting, no code actions, no
9
+ diagnostics push, no rename, no workspace edits. The gem is named after
10
+ what it refuses to become: the moment it applies an edit it needs the
11
+ `Confirmer` seam and stops being a closeable, auditable piece.
12
+
13
+ > **Status: young.** Everything below is in and wired — `bin/pikuri-code`
14
+ > builds its registry from the `lsp_servers:` key of
15
+ > `~/.pikuri-examples-config.yaml`, and one `c.add_extension` does the same
16
+ > for any other host. [`DESIGN.md`](DESIGN.md) carries the threat model and
17
+ > the measurements every call here rests on, `DECISIONS.md` the five forks
18
+ > (`D_lsp_*`), and [the language-intelligence chapter](../book/lsp.md) the
19
+ > teaching walk-through.
20
+
21
+ ## What is here
22
+
23
+ | class | what it does |
24
+ | --- | --- |
25
+ | `Pikuri::Lsp::Connection` | JSON-RPC over one pair of IOs: framing, id demux on a reader thread, `null` replies to server-initiated requests, no timeout |
26
+ | `Pikuri::Lsp::Position` / `Range` | pikuri's 1-based line/character coordinates, converted to and from the wire's 0-based encoded offsets in exactly one place |
27
+ | `Pikuri::Lsp::PositionEncoding` | the `utf-8` / `utf-16` / `utf-32` offset arithmetic — mandatory, because jdtls ignores the `utf-8` offer |
28
+ | `Pikuri::Lsp::Location` | both wire shapes (`Location` and `LocationLink`) behind one value type |
29
+ | `Pikuri::Lsp::Uris` | `file:` URI ↔ path, and the scheme sniff that says "this result is not a file at all" |
30
+ | `Pikuri::Lsp::ServerProgress` | the domain event a host draws a progress bar from while a cold index runs |
31
+ | `Pikuri::Lsp::ClientWrapper` | one server's lifecycle: spawn, handshake, what it advertises, restart after a death, teardown |
32
+ | `Pikuri::Lsp::Registry` | which servers exist, which files each claims, and `.from_h` to build that from a host's own config file |
33
+ | `Pikuri::Lsp::Readiness` | the indexing gate: no progress token open and nothing said for a moment, blocking with no timeout so a mid-index query cannot be answered wrongly |
34
+ | `Pikuri::Lsp::Mailbox` | the thread boundary the gate needs — newest value per key, so a three-minute wait reports current state instead of replaying a backlog |
35
+ | `Pikuri::Lsp::Servers` | one client per registry entry, started on first use, plus the routing that picks the server a file belongs to |
36
+ | `Pikuri::Lsp::LspTool` | the `lsp` tool: ten operations, `symbol` + optional `file`/`line`, and never a column — see below |
37
+ | `Pikuri::Lsp::Extension` | wires the tool onto an agent, arms the teardown, and points the progress events at the agent's event stream |
38
+ | `Pikuri::Lsp::Testing` | a scripted fake server and a hand-framed wire, so a host can test its integration with no language server installed (`require 'pikuri/lsp/testing'`) |
39
+
40
+ `bin/pikuri-lsp-check` is a dev diagnostic, not part of the gem: it runs
41
+ the measurements this design rests on against the language server you
42
+ actually have installed, and reports *ok* / *DRIFT* / *could not ask* per
43
+ probe. Run it after a server upgrade — the specs cannot catch a server
44
+ that changed its mind.
45
+
46
+ It probes two layers, because a fault in one is invisible from the other.
47
+ By default it drives the protocol client. `--drive` drives the `lsp` tool
48
+ instead: it prints the observation the model would read for every
49
+ operation, then checks what a rendered answer owes — no repeated rows,
50
+ every printed path openable. That is the layer where `Navigator`, `Anchor`,
51
+ `Sources` and `Renderer` live, and a duplicate-row bug that every protocol
52
+ probe passed through is what put it there.
53
+
54
+
55
+ ## The tool, in one paragraph
56
+
57
+ One tool with an `operation` enum of ten values —
58
+ `goToDefinition`, `findReferences`, `hover`, `documentSymbol`,
59
+ `workspaceSymbol`, `goToImplementation`, `goToTypeDefinition`,
60
+ `incomingCalls`, `outgoingCalls`, `supertypes`. The model passes a
61
+ `symbol` and, ideally, the `file` and `line` it read it on; **the column
62
+ is derived from that line's text and never asked for**, because nothing
63
+ the model has seen contains one and a column off by seven turned one
64
+ measured `findReferences` into 80,609 results. A bare `symbol` is looked
65
+ up in every server's index instead — best effort, honestly reported when
66
+ it resolves nothing. Multi-hop operations (the call pair, the ancestor
67
+ chain) run their `prepare` and their per-level walk inside the tool, so
68
+ the model never holds an opaque protocol item. Results are capped and
69
+ grouped, a result inside a dependency archive is fetched and shown as its
70
+ type name, and a remote URI is printed but never fetched.
71
+
72
+ ## Three rules it is built to
73
+
74
+ 1. **Relay, don't work around.** Every language server is broken in its
75
+ own way and the ways do not generalize, so the tool reports what the
76
+ server said and the model decides what to do about it.
77
+ 2. **Never collapse three answers into one.** *The server does not
78
+ support this*, *the server answered nothing*, and *the server is not
79
+ ready yet* stay three distinct strings. Merging them is how a shipped
80
+ client teaches a model that a symbol has no definition when in fact
81
+ the index was still building.
82
+ 3. **No fallback steering.** An error says what failed. It never
83
+ suggests grep.
84
+
85
+ ## License
86
+
87
+ MIT, as the rest of pikuri.
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Lsp
5
+ # A resolved query point: the server to ask, the document, and the exact
6
+ # position inside it. Every position-based operation needs one, and **the model
7
+ # is never asked for a column** — {.resolve} derives it.
8
+ #
9
+ # resolution = Anchor.resolve(servers: servers, filesystem: filesystem,
10
+ # symbol: 'resolve_for_read', file: 'read.rb', line: 165)
11
+ # resolution.anchors.first.params
12
+ # # => {textDocument: {uri: "file:///…/read.rb"}, position: {line: 164, character: 14}}
13
+ #
14
+ # == Why there is no +character+ parameter
15
+ #
16
+ # Not because the arithmetic is hard, but because **nothing in the model's
17
+ # input contains a column**: +read+ prints line→text and +grep+ prints
18
+ # +file:line:text+. A column could only be *invented*, and being wrong is not
19
+ # an error — one measured run had +definition+ answering 0 locations from a
20
+ # keyword column, another had +references+ seven columns off returning
21
+ # **80,609** locations instead of 2.
22
+ #
23
+ # == The two ways in, and only one is reliable
24
+ #
25
+ # * +symbol+ + +file+ + +line+ → one line read, one string search, no index and
26
+ # no server involvement. Works on every server that will ever be wired.
27
+ # * +symbol+ alone → +workspace/symbol+, which fails in opposite directions per
28
+ # server: ruby-lsp scores the query against the *fully qualified* name with a
29
+ # 0.7 Jaro-Winkler threshold, so a bare +Confirmer+ scores 0.455 and is never
30
+ # returned, while jdtls indexes no methods at all. Best effort, reported
31
+ # honestly when it resolves nothing.
32
+ #
33
+ # == The keyword trap, which is why {.locate} runs at both ends
34
+ #
35
+ # A symbol's range routinely begins at the *keyword* — +class Foo+ starts at
36
+ # +class+ — so feeding an index hit's +range.start+ back in is the failure, not
37
+ # the shortcut: +prepareTypeHierarchy+ at the keyword column returned zero
38
+ # items in 6 of 6 cases on ruby-lsp and one item every time at the symbol's own
39
+ # column. jdtls disagrees about the details in both directions and needs the
40
+ # same helper. So the column always comes from the line's *text*.
41
+ #
42
+ # An anchor that resolves outside the root is dropped with its own note: every
43
+ # position-based operation would otherwise need a document outside what the
44
+ # servers index. What is lost is following a hop *into* a dependency, which
45
+ # +hover+ and the +jdt:+ content path still answer.
46
+ class Anchor < Data.define(:client, :path, :position, :line_text)
47
+ # Anchors one call may fire. A name matching 14 declarations must not mean
48
+ # 14 fan-out queries, and a line mentioning the same identifier twice is
49
+ # queried at both columns on purpose (see {.locate}).
50
+ MAX = 5
51
+
52
+ # What {.resolve} produces: the anchors, plus what happened to the
53
+ # candidates that did not become one.
54
+ #
55
+ # @!attribute [r] anchors
56
+ # @return [Array<Anchor>] at least one — {.resolve} raises rather than
57
+ # answering none.
58
+ # @!attribute [r] notes
59
+ # @return [Array<String>] lines the tool appends under +Notes:+, e.g. a
60
+ # match that resolved outside the workspace, or a symbol found by
61
+ # scanning the file because the index had no entry for it.
62
+ Resolution = Data.define(:anchors, :notes)
63
+
64
+ # @return [String] the anchor document's +file:+ URI.
65
+ def uri
66
+ Uris.for_path(path)
67
+ end
68
+
69
+ # @return [Hash] the +{textDocument:, position:}+ every position-based
70
+ # request carries, encoded in *this* server's negotiated
71
+ # +positionEncoding+.
72
+ def params
73
+ { textDocument: { uri: uri },
74
+ position: position.to_wire(line_text: line_text, encoding: client.position_encoding) }
75
+ end
76
+
77
+ # @return [String] +"read.rb:165:15"+ — how a result block is labelled when
78
+ # one line held the symbol twice and the two columns disagreed.
79
+ def to_s
80
+ "#{File.basename(path)}:#{position}"
81
+ end
82
+
83
+ # 1-based columns where +symbol+ occurs in +line_text+.
84
+ #
85
+ # locate(' names = names.trim()', 'names') # => [5, 13]
86
+ #
87
+ # Only occurrences bounded by non-identifier characters count, so +greet+
88
+ # does not match inside +greeting+. There is deliberately no
89
+ # any-occurrence fallback: a substring hit would anchor the query on a
90
+ # *different* name and the server would answer confidently about it, which
91
+ # is the one failure mode worth more than an empty answer. Columns are
92
+ # character columns — the encoding conversion happens later, once, in
93
+ # {Position#to_wire}.
94
+ #
95
+ # @param line_text [String]
96
+ # @param symbol [String]
97
+ # @return [Array<Integer>] empty when the symbol is not on the line, which
98
+ # is a clean error rather than a silently wrong answer.
99
+ def self.locate(line_text, symbol)
100
+ found = []
101
+ offset = 0
102
+ while (index = line_text.index(symbol, offset))
103
+ found << index + 1 if bounded?(line_text, index, symbol.length)
104
+ offset = index + 1
105
+ end
106
+ found
107
+ end
108
+
109
+ # @return [Boolean] whether neither neighbour of the match is an identifier
110
+ # character.
111
+ def self.bounded?(line_text, index, length)
112
+ before = index.zero? ? nil : line_text[index - 1]
113
+ after = line_text[index + length]
114
+ [before, after].none? { |char| char&.match?(/[A-Za-z0-9_]/) }
115
+ end
116
+ private_class_method :bounded?
117
+
118
+ # Resolve the anchors for one call.
119
+ #
120
+ # @param servers [Servers] the live clients, for routing and fan-out.
121
+ # @param filesystem [Pikuri::Workspace::Filesystem] path resolution, the
122
+ # root confinement, and the seam every read routes through.
123
+ # @param symbol [String] the name the model gave.
124
+ # @param file [String, nil] narrows to one server and one document.
125
+ # @param line [Integer, nil] 1-based, as +read+ and +grep+ print it.
126
+ # @param ready [Proc] called with a client before it is asked anything —
127
+ # the readiness gate, injected so this class needs no progress emitter.
128
+ # @return [Resolution]
129
+ # @raise [Refusal] when no anchor can be produced, with the reason: no
130
+ # server claims the file, the symbol is not on the named line, or the
131
+ # index has no entry for the name.
132
+ # @raise [Pikuri::Workspace::Filesystem::Error] when +file+ is outside the
133
+ # workspace — the seam's own refusal, relayed by the tool.
134
+ def self.resolve(servers:, filesystem:, symbol:, file: nil, line: nil, ready: ->(_client) {})
135
+ return in_file(servers, filesystem, symbol, file, line, ready) if file
136
+
137
+ in_index(servers, filesystem, symbol, ready)
138
+ end
139
+
140
+ # The reliable route: one named document, and the column derived from its
141
+ # text.
142
+ def self.in_file(servers, filesystem, symbol, file, line, ready)
143
+ resolved = filesystem.resolve_for_read(file)
144
+ raise Refusal, "#{file}: no such file in the workspace" unless resolved.file?
145
+ raise Refusal, out_of_root_message(file) unless in_root?(resolved, filesystem)
146
+
147
+ client = servers.client_for(resolved)
148
+ raise Refusal, no_server_message(resolved) if client.nil?
149
+
150
+ ready.call(client)
151
+ text = resolved.read
152
+ return on_line(client, resolved.to_s, text, symbol, line) if line
153
+
154
+ in_one_file(client, filesystem, resolved, text, symbol)
155
+ end
156
+ private_class_method :in_file
157
+
158
+ # Querying every occurrence on the line — both columns of
159
+ # +names = names.trim()+ — and merging is what replaces the +character+
160
+ # parameter: identical answers collapse, genuinely different ones are
161
+ # labelled.
162
+ def self.on_line(client, path, text, symbol, line)
163
+ lines = text.lines
164
+ # +Tool::Parameters+ coerces types and validates enums; it has no range
165
+ # check, so a 0 arrives here and +lines[-1]+ would silently consult the
166
+ # *last* line of the file.
167
+ raise Refusal, "line must be 1-based, got #{line}" if line < 1
168
+ if line > lines.length
169
+ raise Refusal, "#{File.basename(path)} has #{lines.length} lines; line #{line} was asked for"
170
+ end
171
+
172
+ line_text = lines[line - 1].chomp
173
+ columns = locate(line_text, symbol)
174
+ if columns.empty?
175
+ raise Refusal, "#{symbol.inspect} is not on line #{line} of #{File.basename(path)}, " \
176
+ "which reads: #{line_text.strip.inspect}"
177
+ end
178
+
179
+ anchors = columns.first(MAX).map do |column|
180
+ new(client: client, path: path, position: Position.new(line: line, column: column),
181
+ line_text: line_text)
182
+ end
183
+ Resolution.new(anchors: anchors, notes: extra_columns_note(columns, symbol, line))
184
+ end
185
+ private_class_method :on_line
186
+
187
+ def self.extra_columns_note(columns, symbol, line)
188
+ return [] if columns.length <= MAX
189
+
190
+ ["#{symbol.inspect} occurs #{columns.length} times on line #{line}; the first #{MAX} " \
191
+ 'columns were queried.']
192
+ end
193
+ private_class_method :extra_columns_note
194
+
195
+ # +file+ without +line+. The scan is not a shortcut but the fallback the index
196
+ # route needs: a Java method is not in that index at all, and a short Ruby
197
+ # name scores below the server's own similarity threshold.
198
+ def self.in_one_file(client, filesystem, resolved, text, symbol)
199
+ hits = if client.supports?('workspaceSymbolProvider')
200
+ index_hits(client, symbol).select { |hit| Uris.to_path(hit[:uri]) == resolved.to_s }
201
+ else
202
+ []
203
+ end
204
+ return build(filesystem, client, hits.first(MAX), notes: []) unless hits.empty?
205
+
206
+ columns = first_occurrence(text, symbol)
207
+ if columns.nil?
208
+ raise Refusal, "#{symbol.inspect} does not occur in #{resolved.basename} and the " \
209
+ "server's index has no entry for it"
210
+ end
211
+
212
+ line, column = columns
213
+ line_text = text.lines[line - 1].chomp
214
+ anchor = new(client: client, path: resolved.to_s,
215
+ position: Position.new(line: line, column: column), line_text: line_text)
216
+ Resolution.new(anchors: [anchor],
217
+ notes: ["anchored on line #{line}, the first occurrence of #{symbol.inspect} " \
218
+ 'in the file; the server\'s index has no entry for that name.'])
219
+ end
220
+ private_class_method :in_one_file
221
+
222
+ # No file: fan out to every server's index and merge. A name may live in
223
+ # either language, and this is the only route that does not need one.
224
+ def self.in_index(servers, filesystem, symbol, ready)
225
+ notes = []
226
+ hits = servers.active.flat_map do |client|
227
+ unless client.supports?('workspaceSymbolProvider')
228
+ notes << "the #{client.entry.id} server cannot search by name (no workspaceSymbolProvider), " \
229
+ 'so its files were not considered.'
230
+ next []
231
+ end
232
+
233
+ ready.call(client)
234
+ index_hits(client, symbol)
235
+ end
236
+
237
+ raise Refusal, unresolved_message(symbol, notes) if hits.empty?
238
+
239
+ build(filesystem, nil, hits.first(MAX), notes: notes, total: hits.length)
240
+ end
241
+ private_class_method :in_index
242
+
243
+ # +workspace/symbol+, exact-name filtered through {SymbolName} — never on
244
+ # +name+ verbatim, which every server spells differently and which would
245
+ # reject every correct hit.
246
+ def self.index_hits(client, symbol)
247
+ rows = client.request('workspace/symbol', { query: symbol })
248
+ Array(rows).filter_map do |row|
249
+ next unless row.is_a?(Hash)
250
+ next unless SymbolName.matches?(row['name'], symbol)
251
+
252
+ location = row['location']
253
+ next unless location.is_a?(Hash) && location['uri']
254
+
255
+ { uri: location['uri'], range: location['range'], client: client, name: row['name'] }
256
+ end
257
+ end
258
+ private_class_method :index_hits
259
+
260
+ # Index hits → anchors, dropping what cannot anchor a query and saying so.
261
+ def self.build(filesystem, client, hits, notes:, total: nil)
262
+ notes = notes.dup
263
+ anchors = hits.filter_map { |hit| from_hit(filesystem, hit[:client] || client, hit, notes) }
264
+ raise Refusal, dropped_message(notes) if anchors.empty?
265
+
266
+ # Counts what was *queried*, not what the index returned: a hit that could
267
+ # not anchor anything already has its own note.
268
+ notes << "#{total} index matches; #{anchors.length} were queried." if total && total > anchors.length
269
+ Resolution.new(anchors: anchors, notes: notes)
270
+ end
271
+ private_class_method :build
272
+
273
+ def self.from_hit(filesystem, client, hit, notes)
274
+ path = Uris.to_path(hit[:uri])
275
+ if path.nil?
276
+ notes << "#{hit[:name]} is inside a dependency archive and cannot anchor a query."
277
+ return nil
278
+ end
279
+
280
+ # Lexical, not "does +resolve_for_read+ raise": an +AllowAll+ filesystem
281
+ # (the container wiring) resolves any path happily, and the boundary that
282
+ # matters here is the server's own +rootUri+ — outside it a server answers
283
+ # about a non-project file, which is a different lie.
284
+ resolved = begin
285
+ filesystem.resolve_for_read(path)
286
+ rescue Pikuri::Workspace::Filesystem::Error
287
+ nil
288
+ end
289
+ if resolved.nil? || !in_root?(resolved, filesystem)
290
+ notes << "#{hit[:name]} is outside the workspace (#{File.basename(path)}) and cannot " \
291
+ 'anchor a query.'
292
+ return nil
293
+ end
294
+
295
+ anchor_in(client, resolved, hit, notes)
296
+ end
297
+
298
+ # @param resolved [Pathname] an already-resolved absolute path.
299
+ # @param filesystem [Pikuri::Workspace::Filesystem]
300
+ # @return [Boolean] whether it sits under the root the servers index.
301
+ def self.in_root?(resolved, filesystem)
302
+ root = filesystem.project_root.to_s
303
+ path = resolved.to_s
304
+ path == root || path.start_with?("#{root}/")
305
+ end
306
+ private_class_method :in_root?
307
+ private_class_method :from_hit
308
+
309
+ def self.anchor_in(client, resolved, hit, notes)
310
+ text = resolved.read
311
+ wanted = SymbolName.last_segment(hit[:name])
312
+ line = wire_line(hit[:range])
313
+ columns = line && text.lines[line - 1] && locate(text.lines[line - 1].chomp, wanted)
314
+ if columns.nil? || columns.empty?
315
+ # Either the reply carried no range at all (newer servers may send a
316
+ # bare {uri}), or the range's line does not contain the name.
317
+ found = first_occurrence(text, wanted)
318
+ if found.nil?
319
+ notes << "#{hit[:name]} is indexed in #{resolved.basename} but the name is not in the file."
320
+ return nil
321
+ end
322
+ line, column = found
323
+ else
324
+ column = columns.first
325
+ end
326
+
327
+ new(client: client, path: resolved.to_s, position: Position.new(line: line, column: column),
328
+ line_text: text.lines[line - 1].chomp)
329
+ rescue SystemCallError, IOError => e
330
+ notes << "#{resolved.basename} could not be read (#{e.class})."
331
+ nil
332
+ end
333
+ private_class_method :anchor_in
334
+
335
+ # 1-based line a wire range starts on, or +nil+ when the reply carried no
336
+ # usable one — a newer server may answer +workspace/symbol+ with a bare
337
+ # +{uri}+, and a malformed range is the server's bug rather than a reason to
338
+ # fail the call.
339
+ #
340
+ # @param wire [Hash, nil] an LSP +Range+ object.
341
+ # @return [Integer, nil]
342
+ def self.wire_line(wire)
343
+ line = wire.is_a?(Hash) ? wire.dig('start', 'line') : nil
344
+ line.is_a?(Integer) ? line + 1 : nil
345
+ end
346
+ private_class_method :wire_line
347
+
348
+ # @return [Array(Integer, Integer), nil] 1-based line and column of the
349
+ # first occurrence of +symbol+ in +text+.
350
+ def self.first_occurrence(text, symbol)
351
+ text.lines.each_with_index do |line_text, index|
352
+ columns = locate(line_text.chomp, symbol)
353
+ return [index + 1, columns.first] unless columns.empty?
354
+ end
355
+ nil
356
+ end
357
+ private_class_method :first_occurrence
358
+
359
+ # The degrade string, naming the extension rather than the servers: which
360
+ # servers are installed is machine state the model cannot act on, and the
361
+ # file type is what the call got wrong.
362
+ #
363
+ # @param path [String, Pathname] the file nothing claimed.
364
+ # @return [String]
365
+ def self.no_server_message(path)
366
+ extension = File.extname(path.to_s)
367
+ subject = extension.empty? ? File.basename(path.to_s) : "#{extension} files"
368
+ "no language server is configured for #{subject}"
369
+ end
370
+
371
+ def self.unresolved_message(symbol, notes)
372
+ message = "#{symbol.inspect} is not in any server's index. Pass file: (and line:) to anchor " \
373
+ 'the query on a line you have read — a bare name is matched against fully ' \
374
+ 'qualified names, and some servers index no methods at all'
375
+ notes.empty? ? message : "#{message}. #{notes.join(' ')}"
376
+ end
377
+ private_class_method :unresolved_message
378
+
379
+ # The reasons live in +notes+ — one per dropped hit — so the headline stays
380
+ # true whether they were in a jar, outside the root, or indexed under a name
381
+ # the file no longer contains.
382
+ def self.dropped_message(notes)
383
+ "no index match can anchor a query. #{notes.join(' ')}".strip
384
+ end
385
+ private_class_method :dropped_message
386
+
387
+ # Out-of-root, and the reason is the *server's* boundary rather than
388
+ # pikuri's: it indexes one root, and outside it answers about a file it does
389
+ # not consider part of the project — which shipped clients report as "no
390
+ # definition found", the false premise this gem exists not to produce.
391
+ def self.out_of_root_message(file)
392
+ "#{file} is outside the workspace root the language servers index"
393
+ end
394
+ private_class_method :out_of_root_message
395
+ end
396
+ end
397
+ end