pikuri-lsp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +87 -0
- data/lib/pikuri/lsp/anchor.rb +397 -0
- data/lib/pikuri/lsp/client_wrapper.rb +581 -0
- data/lib/pikuri/lsp/connection.rb +366 -0
- data/lib/pikuri/lsp/extension.rb +75 -0
- data/lib/pikuri/lsp/location.rb +100 -0
- data/lib/pikuri/lsp/lsp_tool.rb +157 -0
- data/lib/pikuri/lsp/mailbox.rb +98 -0
- data/lib/pikuri/lsp/navigator.rb +340 -0
- data/lib/pikuri/lsp/operation.rb +115 -0
- data/lib/pikuri/lsp/position.rb +72 -0
- data/lib/pikuri/lsp/position_encoding.rb +126 -0
- data/lib/pikuri/lsp/range.rb +65 -0
- data/lib/pikuri/lsp/readiness.rb +185 -0
- data/lib/pikuri/lsp/refusal.rb +23 -0
- data/lib/pikuri/lsp/registry.rb +286 -0
- data/lib/pikuri/lsp/renderer.rb +453 -0
- data/lib/pikuri/lsp/server_progress.rb +57 -0
- data/lib/pikuri/lsp/servers.rb +208 -0
- data/lib/pikuri/lsp/sources.rb +272 -0
- data/lib/pikuri/lsp/symbol_name.rb +48 -0
- data/lib/pikuri/lsp/testing.rb +417 -0
- data/lib/pikuri/lsp/uris.rb +61 -0
- data/lib/pikuri-lsp.rb +39 -0
- metadata +107 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# Turns a server's reply into the observation the model reads: one row per
|
|
6
|
+
# result, a path it can act on where there is one, a line of code where there is
|
|
7
|
+
# one, and a cap on all of it. One instance per tool call, because the notes it
|
|
8
|
+
# collects belong to that call.
|
|
9
|
+
#
|
|
10
|
+
# renderer = Renderer.new(sources: Sources.new(filesystem: filesystem))
|
|
11
|
+
# renderer.note('2 matches are outside the workspace.')
|
|
12
|
+
# renderer.finish(renderer.locations(hits, operation: operation, symbol: 'read', client: client))
|
|
13
|
+
#
|
|
14
|
+
# == Why this caps and groups when no shipped harness does
|
|
15
|
+
#
|
|
16
|
+
# Because the volumes were measured and they have no ceiling: **80,609**
|
|
17
|
+
# locations from one +findReferences+; 40 references to one Ruby method that
|
|
18
|
+
# were 4 call sites, 2 declarations and **34 spec hits**; 45 +goToDefinition+
|
|
19
|
+
# locations for a constant, one per file reopening the namespace; 14
|
|
20
|
+
# +supertypes+ rows all named +Pikuri::Tool+ with the real declaration ordered
|
|
21
|
+
# *last*. A naive cap would cut the one row that mattered, which is why
|
|
22
|
+
# {#supertypes} groups instead of trimming.
|
|
23
|
+
#
|
|
24
|
+
# == Three answers, three sentences
|
|
25
|
+
#
|
|
26
|
+
# *The server does not support this* is a {Refusal}, *the server answered
|
|
27
|
+
# nothing* is {#nothing}, *the server could not be asked* is a death report.
|
|
28
|
+
# Merging them into "No definition found. This may occur if…" is what teaches a
|
|
29
|
+
# model that a symbol has no definition when the index was merely still
|
|
30
|
+
# building. Everything else the server said is passed through as it came,
|
|
31
|
+
# glitches included: pikuri cannot pre-empt thirty servers' failure modes, and
|
|
32
|
+
# a capable model handles "that looks stale, let me ask another way" routinely.
|
|
33
|
+
#
|
|
34
|
+
# == The one edit made to a server's prose
|
|
35
|
+
#
|
|
36
|
+
# Hover answers prose rather than rows, so its paths reach none of the
|
|
37
|
+
# {Sources} passes every other result goes through — a +**Definitions**+ link
|
|
38
|
+
# into a denied root prints exactly what the walked-path denylist exists to
|
|
39
|
+
# stop. Each +file:+ URI is rewritten to the citation the other nine
|
|
40
|
+
# operations print, keeping its markdown link only where +resolve_for_read+
|
|
41
|
+
# would open the file:
|
|
42
|
+
#
|
|
43
|
+
# in: [version.rb](file:///home/m/proj/lib/version.rb#L9,3-9,20)
|
|
44
|
+
# out: [version.rb](lib/version.rb:9) — a path +read+ opens
|
|
45
|
+
# outside the workspace: version.rb — a read here is refused
|
|
46
|
+
# [a path this workspace will not read] — denied
|
|
47
|
+
#
|
|
48
|
+
# That is a denylist obligation, not a style preference: the
|
|
49
|
+
# passed-through-as-it-came rule above is about not pre-empting a server's
|
|
50
|
+
# *failure modes*, and holds for every byte that is not a +file:+ URI.
|
|
51
|
+
class Renderer
|
|
52
|
+
# Bytes one observation may reach before it is head-truncated with a
|
|
53
|
+
# marker. Half of what a grep answer is allowed, because these rows carry
|
|
54
|
+
# both a path and a line of code.
|
|
55
|
+
MAX_BYTES = 24 * 1024
|
|
56
|
+
|
|
57
|
+
# Rows from test files shown in a grouped answer. The 34 spec hits are real
|
|
58
|
+
# information — that a method is well covered — but the 4 source call sites
|
|
59
|
+
# are what was asked for, so the tests group states its size and shows a
|
|
60
|
+
# handful.
|
|
61
|
+
MAX_TEST_ROWS = 5
|
|
62
|
+
|
|
63
|
+
# Bytes of hover text kept. Hover on a constant returns the whole class
|
|
64
|
+
# comment, which in this codebase is the declared source of truth and is
|
|
65
|
+
# therefore worth having — up to a point that is not "45 documentation links
|
|
66
|
+
# ahead of the docstring".
|
|
67
|
+
MAX_HOVER_BYTES = 3 * 1024
|
|
68
|
+
|
|
69
|
+
# A +file:+ URI as it appears inside hover prose, bounded by whitespace and
|
|
70
|
+
# by the delimiters markdown puts around a URI. That such a bound exists is
|
|
71
|
+
# why the scan is +file:+-only: a +jdt:+ URI carries a whole classpath entry
|
|
72
|
+
# in its query and has no reliable end inside a sentence.
|
|
73
|
+
FILE_URI = %r{file://[^\s)\]<>"']+}
|
|
74
|
+
|
|
75
|
+
# The markdown link ruby-lsp builds its +**Definitions**+ line from.
|
|
76
|
+
LINKED_FILE_URI = /\[([^\]\n]*)\]\((#{FILE_URI})\)/
|
|
77
|
+
|
|
78
|
+
# What a citation this workspace will not read collapses to. A denied result
|
|
79
|
+
# is dropped outright in a row; hover is prose, so something has to stand
|
|
80
|
+
# where the path was.
|
|
81
|
+
DENIED_CITATION = '[a path this workspace will not read]'
|
|
82
|
+
|
|
83
|
+
# LSP's +SymbolKind+, so a row says +class+ rather than +5+.
|
|
84
|
+
SYMBOL_KINDS = {
|
|
85
|
+
1 => 'file', 2 => 'module', 3 => 'namespace', 4 => 'package', 5 => 'class',
|
|
86
|
+
6 => 'method', 7 => 'property', 8 => 'field', 9 => 'constructor', 10 => 'enum',
|
|
87
|
+
11 => 'interface', 12 => 'function', 13 => 'variable', 14 => 'constant',
|
|
88
|
+
15 => 'string', 16 => 'number', 17 => 'boolean', 18 => 'array', 19 => 'object',
|
|
89
|
+
20 => 'key', 21 => 'null', 22 => 'enum member', 23 => 'struct', 24 => 'event',
|
|
90
|
+
25 => 'operator', 26 => 'type parameter'
|
|
91
|
+
}.freeze
|
|
92
|
+
|
|
93
|
+
# Directory names that make a file a test file, plus the filename suffixes
|
|
94
|
+
# Ruby and Java conventions use — a +lib/spec_helper.rb+ is source and a
|
|
95
|
+
# +spec/foo_spec.rb+ is not.
|
|
96
|
+
TEST_DIRS = %w[spec test tests features].freeze
|
|
97
|
+
|
|
98
|
+
# @param sources [Sources] URI identity, the denylist pass and snippet text,
|
|
99
|
+
# memoized per call.
|
|
100
|
+
def initialize(sources:)
|
|
101
|
+
@sources = sources
|
|
102
|
+
@notes = []
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Record something the model should know about *how* the answer was reached
|
|
106
|
+
# — a match dropped for being outside the workspace, a server that could not
|
|
107
|
+
# be asked, an anchor found by scanning rather than by the index.
|
|
108
|
+
#
|
|
109
|
+
# @param message [String] one sentence, already phrased for the model.
|
|
110
|
+
# @return [void]
|
|
111
|
+
def note(message)
|
|
112
|
+
@notes << message
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Location-shaped answers: definition, references, implementation,
|
|
117
|
+
# typeDefinition.
|
|
118
|
+
#
|
|
119
|
+
# Deduplicated on the rendered row rather than on the +Location+: pikuri
|
|
120
|
+
# prints no column, so two results differing only in column are one row —
|
|
121
|
+
# and each copy would spend a slot of the cap {#body} hands out.
|
|
122
|
+
#
|
|
123
|
+
# @param list [Array<Location>] parsed results, in the server's order.
|
|
124
|
+
# @param operation [Operation]
|
|
125
|
+
# @param symbol [String] what was asked about, for the header.
|
|
126
|
+
# @param client [ClientWrapper] the server that answered — the only one that
|
|
127
|
+
# can read its own private URI schemes.
|
|
128
|
+
# @return [String] the body, without the notes footer.
|
|
129
|
+
def locations(list, operation:, symbol:, client:)
|
|
130
|
+
rows = list.reject { |location| @sources.denied?(location.uri) }
|
|
131
|
+
.map { |location| location_row(location, client) }
|
|
132
|
+
.uniq
|
|
133
|
+
return nothing(operation: operation, symbol: symbol) if rows.empty?
|
|
134
|
+
|
|
135
|
+
compose(operation, symbol, rows, 'result')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @param reply [Hash, nil] the +textDocument/hover+ result.
|
|
139
|
+
# @param operation [Operation]
|
|
140
|
+
# @param symbol [String]
|
|
141
|
+
# @return [String]
|
|
142
|
+
def hover(reply, operation:, symbol:)
|
|
143
|
+
text = hover_text(reply.is_a?(Hash) ? reply['contents'] : reply).strip
|
|
144
|
+
return nothing(operation: operation, symbol: symbol) if text.empty?
|
|
145
|
+
|
|
146
|
+
# Before the truncation, so {MAX_HOVER_BYTES} is spent on documentation
|
|
147
|
+
# rather than on absolute paths.
|
|
148
|
+
text = rewrite_file_uris(text)
|
|
149
|
+
|
|
150
|
+
kept, marker = Pikuri::Workspace::Search::Utils.head_truncate(
|
|
151
|
+
text, max_bytes: MAX_HOVER_BYTES, hint: 'the rest is documentation the server holds'
|
|
152
|
+
)
|
|
153
|
+
["hover #{symbol.inspect}", '', kept + marker].join("\n")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Symbol-shaped answers: +documentSymbol+ (one file's outline) and
|
|
157
|
+
# +workspaceSymbol+ (the whole index). Rows whose name matches +symbol+
|
|
158
|
+
# exactly win outright; when none does, the full list follows under a note,
|
|
159
|
+
# because a fuzzy index answer is still the fastest way to see what *is*
|
|
160
|
+
# there.
|
|
161
|
+
#
|
|
162
|
+
# @param rows [Array<Hash>] +SymbolInformation+ or +DocumentSymbol+ objects;
|
|
163
|
+
# both shapes are accepted because a server may send either.
|
|
164
|
+
# @param operation [Operation]
|
|
165
|
+
# @param symbol [String]
|
|
166
|
+
# @param client [ClientWrapper]
|
|
167
|
+
# @param uri [String, nil] the document the outline came from, for the rows
|
|
168
|
+
# that carry no URI of their own (+DocumentSymbol+ does not).
|
|
169
|
+
# @return [String]
|
|
170
|
+
def symbols(rows, operation:, symbol:, client:, uri: nil)
|
|
171
|
+
flat = flatten_symbols(rows, uri).reject { |row| @sources.denied?(row[:uri].to_s) }
|
|
172
|
+
return nothing(operation: operation, symbol: symbol) if flat.empty?
|
|
173
|
+
|
|
174
|
+
exact = flat.select { |row| SymbolName.matches?(row[:name], symbol) }
|
|
175
|
+
note_filtered(operation, symbol, flat.length, exact.length)
|
|
176
|
+
matched = exact.empty? ? flat : exact
|
|
177
|
+
compose(operation, symbol, matched.map { |row| symbol_row(row, client) }, 'symbol')
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Call-hierarchy answers, both directions.
|
|
181
|
+
#
|
|
182
|
+
# @param rows [Array<Hash>] +CallHierarchyIncomingCall+ (+from+) or
|
|
183
|
+
# +CallHierarchyOutgoingCall+ (+to+).
|
|
184
|
+
# @param operation [Operation]
|
|
185
|
+
# @param symbol [String]
|
|
186
|
+
# @param client [ClientWrapper]
|
|
187
|
+
# @return [String]
|
|
188
|
+
def calls(rows, operation:, symbol:, client:)
|
|
189
|
+
items = Array(rows).filter_map { |row| row.is_a?(Hash) ? row['from'] || row['to'] : nil }
|
|
190
|
+
.reject { |item| @sources.denied?(item['uri'].to_s) }
|
|
191
|
+
return nothing(operation: operation, symbol: symbol) if items.empty?
|
|
192
|
+
|
|
193
|
+
noun = operation.name == 'incomingCalls' ? 'caller' : 'callee'
|
|
194
|
+
compose(operation, symbol, items.map { |item| item_row(item) }, noun)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The ancestor chain, one line per level.
|
|
198
|
+
#
|
|
199
|
+
# Grouped by name rather than capped, because the noise here is a *Ruby
|
|
200
|
+
# namespace reopening*: 14 rows for one class, every one named
|
|
201
|
+
# +Pikuri::Tool+, one per file that reopens the namespace as a wrapper — and
|
|
202
|
+
# the real declaration was last in the list, so a cap would cut precisely the
|
|
203
|
+
# row a reader wants.
|
|
204
|
+
#
|
|
205
|
+
# @param levels [Array<Array<Hash>>] +TypeHierarchyItem+ lists, nearest
|
|
206
|
+
# ancestor first.
|
|
207
|
+
# @param operation [Operation]
|
|
208
|
+
# @param symbol [String]
|
|
209
|
+
# @return [String]
|
|
210
|
+
def supertypes(levels, operation:, symbol:)
|
|
211
|
+
shown = 0
|
|
212
|
+
lines = levels.filter_map do |items|
|
|
213
|
+
kept = Array(items).reject { |item| @sources.denied?(item['uri'].to_s) }
|
|
214
|
+
next if kept.empty?
|
|
215
|
+
|
|
216
|
+
shown += 1
|
|
217
|
+
" #{shown}. #{level_row(kept)}"
|
|
218
|
+
end
|
|
219
|
+
return nothing(operation: operation, symbol: symbol) if lines.empty?
|
|
220
|
+
|
|
221
|
+
[header(operation, symbol, lines.length, 'level'), '', *lines].join("\n")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The *server answered nothing* string, which is not the same claim as "this
|
|
225
|
+
# symbol has none" and must not be written as though it were.
|
|
226
|
+
#
|
|
227
|
+
# @param operation [Operation]
|
|
228
|
+
# @param symbol [String]
|
|
229
|
+
# @return [String]
|
|
230
|
+
def nothing(operation:, symbol:)
|
|
231
|
+
"The server answered no results for #{operation.name} on #{symbol.inspect}. " \
|
|
232
|
+
'That is what it said, not proof that none exist.'
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Append the notes footer and cap the whole thing.
|
|
236
|
+
#
|
|
237
|
+
# @param text [String] a body from one of the methods above.
|
|
238
|
+
# @return [String] the observation.
|
|
239
|
+
def finish(text)
|
|
240
|
+
# The notes are appended *after* the cut, not truncated with the body: they
|
|
241
|
+
# are what says a match was dropped or a server could not answer, and a
|
|
242
|
+
# long answer is exactly when that must not be the first thing to go.
|
|
243
|
+
kept, marker = Pikuri::Workspace::Search::Utils.head_truncate(
|
|
244
|
+
text, max_bytes: MAX_BYTES, hint: 'narrow the query with file: and line:'
|
|
245
|
+
)
|
|
246
|
+
[kept + marker, *footer].join("\n")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
# A row is a path (for the source/tests split) plus the line to print.
|
|
252
|
+
Row = Data.define(:path, :text)
|
|
253
|
+
private_constant :Row
|
|
254
|
+
|
|
255
|
+
# What the exact-name filter dropped, said in the terms of the operation
|
|
256
|
+
# that produced the rows: an outline holds a file's other symbols, an index
|
|
257
|
+
# search holds the server's own fuzzy near-misses.
|
|
258
|
+
def note_filtered(operation, symbol, total, exact)
|
|
259
|
+
return note("no symbol is named #{symbol.inspect} here; the full list follows.") if exact.zero?
|
|
260
|
+
return if total == exact
|
|
261
|
+
|
|
262
|
+
dropped = total - exact
|
|
263
|
+
if operation.name == 'documentSymbol'
|
|
264
|
+
note("#{dropped} other #{dropped == 1 ? 'symbol' : 'symbols'} in this file " \
|
|
265
|
+
"#{dropped == 1 ? 'is' : 'are'} not shown.")
|
|
266
|
+
else
|
|
267
|
+
note("#{dropped} further near-#{dropped == 1 ? 'match' : 'matches'} the server's fuzzy " \
|
|
268
|
+
"search returned #{dropped == 1 ? 'is' : 'are'} not shown.")
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def compose(operation, symbol, rows, noun)
|
|
273
|
+
[header(operation, symbol, rows.length, noun), '', *body(rows, operation)].join("\n")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def header(operation, symbol, count, noun)
|
|
277
|
+
"#{operation.name} #{symbol.inspect} — #{count} #{noun}#{'s' if count != 1}"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def footer
|
|
281
|
+
return [] if @notes.empty?
|
|
282
|
+
|
|
283
|
+
['', 'Notes:', *@notes.uniq.map { |note| "- #{note}" }]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# One flat list, or the source/tests split for the two operations whose
|
|
287
|
+
# volume is dominated by test files.
|
|
288
|
+
def body(rows, operation)
|
|
289
|
+
return capped(rows, operation.cap) unless operation.grouped?
|
|
290
|
+
|
|
291
|
+
source, tests = rows.partition { |row| !test?(row.path) }
|
|
292
|
+
[*group('In source', source, operation.cap), *group('In tests', tests, MAX_TEST_ROWS)]
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def capped(rows, cap)
|
|
296
|
+
shown = rows.first(cap).map(&:text)
|
|
297
|
+
return shown if rows.length == shown.length
|
|
298
|
+
|
|
299
|
+
shown + [" ... #{rows.length - shown.length} more, not shown"]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def group(title, rows, cap)
|
|
303
|
+
return [] if rows.empty?
|
|
304
|
+
|
|
305
|
+
shown = rows.first(cap)
|
|
306
|
+
count = rows.length == shown.length ? rows.length.to_s : "#{rows.length}, showing #{shown.length}"
|
|
307
|
+
["#{title} (#{count}):", *shown.map(&:text), '']
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def test?(path)
|
|
311
|
+
return false if path.nil?
|
|
312
|
+
|
|
313
|
+
segments = path.split('/')
|
|
314
|
+
segments[0..-2].any? { |segment| TEST_DIRS.include?(segment) } ||
|
|
315
|
+
segments.last.match?(/(?:_spec|_test|Test|Tests)\.[A-Za-z0-9]+\z/)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def location_row(location, client)
|
|
319
|
+
make_row(location.uri, location.anchor.start.line,
|
|
320
|
+
@sources.snippet_for(location, client: client))
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def symbol_row(symbol_row, client)
|
|
324
|
+
location = Location.new(uri: symbol_row[:uri], range: symbol_row[:range])
|
|
325
|
+
kind = SYMBOL_KINDS[symbol_row[:kind]] || 'symbol'
|
|
326
|
+
container = symbol_row[:container].to_s.empty? ? '' : " in #{symbol_row[:container]}"
|
|
327
|
+
make_row(symbol_row[:uri], symbol_row[:range].start.line,
|
|
328
|
+
"#{kind} #{symbol_row[:name]}#{container} " \
|
|
329
|
+
"#{@sources.snippet_for(location, client: client)}")
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# A +CallHierarchyItem+ and a +TypeHierarchyItem+ carry the same four fields,
|
|
333
|
+
# which is why one row shape serves the call pair and the hierarchy.
|
|
334
|
+
def item_row(item)
|
|
335
|
+
detail = item['detail'].to_s.empty? ? '' : " (#{item['detail']})"
|
|
336
|
+
kind = SYMBOL_KINDS[item['kind']] || 'symbol'
|
|
337
|
+
make_row(item['uri'].to_s, item_line(item), "#{kind} #{item['name']}#{detail}")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def make_row(uri, line, text)
|
|
341
|
+
path = @sources.in_root_file?(uri) ? @sources.label_for(uri) : nil
|
|
342
|
+
Row.new(path: path, text: " #{@sources.label_for(uri)}:#{line} #{text}")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# One ancestry level, collapsed. Rows in a level that share a name are the
|
|
346
|
+
# same ancestor seen through a different file, so the group states how many
|
|
347
|
+
# files and shows the one most likely to be the declaration.
|
|
348
|
+
def level_row(items)
|
|
349
|
+
groups = items.group_by { |item| item['name'].to_s }
|
|
350
|
+
name, rows = groups.max_by { |_name, group| group.length }
|
|
351
|
+
best = declaration_of(name, rows)
|
|
352
|
+
reopened = rows.length > 1 ? " (+#{rows.length - 1} more files declare this name)" : ''
|
|
353
|
+
others = groups.length > 1 ? " (and #{groups.length - 1} other ancestors at this level)" : ''
|
|
354
|
+
"#{name} #{@sources.label_for(best['uri'].to_s)}:#{item_line(best)}#{reopened}#{others}"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# The file whose basename matches the name's last segment is the declaration;
|
|
358
|
+
# failing that the *last* row, which is where the real one was measured to
|
|
359
|
+
# sit among a namespace's reopenings.
|
|
360
|
+
def declaration_of(name, rows)
|
|
361
|
+
wanted = SymbolName.last_segment(name)
|
|
362
|
+
candidates = ["#{snake(wanted)}.rb", "#{wanted}.java", "#{wanted}.rbs"]
|
|
363
|
+
rows.find { |item| candidates.include?(File.basename(Uris.to_path(item['uri'].to_s).to_s)) } ||
|
|
364
|
+
rows.last
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# A row whose range is missing or malformed still names a symbol worth
|
|
368
|
+
# showing, so it points at line 1 rather than failing the answer.
|
|
369
|
+
def item_line(item)
|
|
370
|
+
wire = item['selectionRange'] || item['range']
|
|
371
|
+
line = wire.is_a?(Hash) ? wire.dig('start', 'line') : nil
|
|
372
|
+
line.is_a?(Integer) ? line + 1 : 1
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def snake(name)
|
|
376
|
+
name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# +SymbolInformation+ carries a +location+; +DocumentSymbol+ carries a
|
|
380
|
+
# +range+ plus +children+ and belongs to the document that was asked for.
|
|
381
|
+
def flatten_symbols(rows, uri, container = nil)
|
|
382
|
+
Array(rows).flat_map do |row|
|
|
383
|
+
next [] unless row.is_a?(Hash)
|
|
384
|
+
|
|
385
|
+
location = row['location']
|
|
386
|
+
if location.is_a?(Hash)
|
|
387
|
+
[{ uri: location['uri'], range: parse_range(location['range']), name: row['name'],
|
|
388
|
+
kind: row['kind'], container: row['containerName'] }].reject { |built| built[:range].nil? }
|
|
389
|
+
else
|
|
390
|
+
[{ uri: uri, range: parse_range(row['selectionRange'] || row['range']), name: row['name'],
|
|
391
|
+
kind: row['kind'], container: container },
|
|
392
|
+
*flatten_symbols(row['children'], uri, row['name'])].reject { |built| built[:range].nil? }
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# A hit with no range at all is a row nothing can point at, so it is dropped
|
|
398
|
+
# rather than rendered at line 1.
|
|
399
|
+
def parse_range(wire)
|
|
400
|
+
wire.is_a?(Hash) ? Range.from_wire(wire) : nil
|
|
401
|
+
rescue KeyError, TypeError
|
|
402
|
+
nil
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# +MarkupContent+, a bare String, a +MarkedString+, or an array of any of
|
|
406
|
+
# those — servers use all four shapes for the same field.
|
|
407
|
+
def hover_text(contents)
|
|
408
|
+
case contents
|
|
409
|
+
when String then contents
|
|
410
|
+
when Array then contents.map { |part| hover_text(part) }.join("\n\n")
|
|
411
|
+
when Hash then contents['value'].to_s
|
|
412
|
+
else ''
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Links first, so the bare pass only ever sees a URI that arrived naked.
|
|
417
|
+
def rewrite_file_uris(text)
|
|
418
|
+
text.gsub(LINKED_FILE_URI) { linked_citation(Regexp.last_match(1), Regexp.last_match(2)) }
|
|
419
|
+
.gsub(FILE_URI) { bare_citation(Regexp.last_match(0)) }
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def linked_citation(text, uri)
|
|
423
|
+
label, linkable = citation(uri)
|
|
424
|
+
linkable ? "[#{text}](#{label})" : label
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# A URI the server wrote into a sentence rather than a link, so it may carry
|
|
428
|
+
# the sentence's punctuation — which is not part of the path.
|
|
429
|
+
def bare_citation(uri)
|
|
430
|
+
trailing = uri[/[.,;:]+\z/].to_s
|
|
431
|
+
citation(uri.delete_suffix(trailing)).first + trailing
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Never invent a link that was not there, and never keep one whose target
|
|
435
|
+
# +read+ would refuse — a citation the model cannot act on is the dead end
|
|
436
|
+
# {Sources} exists to avoid.
|
|
437
|
+
#
|
|
438
|
+
# @param uri [String] a +file:+ URI, fragment and all.
|
|
439
|
+
# @return [Array(String, Boolean)] what to print, and whether it may be a
|
|
440
|
+
# link target.
|
|
441
|
+
def citation(uri)
|
|
442
|
+
path_uri, _, fragment = uri.partition('#')
|
|
443
|
+
return [DENIED_CITATION, false] if @sources.denied?(path_uri)
|
|
444
|
+
|
|
445
|
+
label = @sources.label_for(path_uri)
|
|
446
|
+
return [label, false] unless @sources.actionable?(path_uri)
|
|
447
|
+
|
|
448
|
+
line = fragment[/\AL?(\d+)/, 1]
|
|
449
|
+
[line ? "#{label}:#{line}" : label, true]
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# One language server's indexing progress, shaped as a domain event a host
|
|
6
|
+
# can draw. Two states matter — a task under way, and the same task over:
|
|
7
|
+
#
|
|
8
|
+
# progress = ServerProgress.new(server_id: 'java', title: 'Initialize Workspace',
|
|
9
|
+
# message: 'Importing gradle project', percentage: 40)
|
|
10
|
+
# progress.to_s # => "java: Initialize Workspace — 40% — Importing gradle project"
|
|
11
|
+
# progress.with(message: nil, percentage: nil, done: true).to_s
|
|
12
|
+
# # => "java: Initialize Workspace — done"
|
|
13
|
+
#
|
|
14
|
+
# It is a {Pikuri::Agent::Event::Transient}, so a chrome that can rewrite a
|
|
15
|
+
# line replaces the previous update rather than logging it — a cold import
|
|
16
|
+
# emits hundreds.
|
|
17
|
+
#
|
|
18
|
+
# It exists because pikuri removed the clock: the first call blocks until
|
|
19
|
+
# the server is ready — 7s warm, 78s for a cold jdtls import — and a silent
|
|
20
|
+
# three-minute stall is indistinguishable from a hang for whoever is
|
|
21
|
+
# watching.
|
|
22
|
+
#
|
|
23
|
+
# Maps 1:1 onto LSP's +WorkDoneProgress+ +begin+ / +report+ / +end+, one
|
|
24
|
+
# variant covering the whole lifecycle.
|
|
25
|
+
#
|
|
26
|
+
# @!attribute [r] server_id
|
|
27
|
+
# @return [String] the registry entry's +id+, e.g. +"ruby"+ or +"java"+.
|
|
28
|
+
# Two servers index concurrently at boot, so a bar is keyed by this.
|
|
29
|
+
# @!attribute [r] title
|
|
30
|
+
# @return [String] the server's own name for the task, e.g.
|
|
31
|
+
# +"Initialize Workspace"+. Server-supplied text bound for a terminal,
|
|
32
|
+
# so whoever renders it defangs it via {Pikuri::Sanitizer}.
|
|
33
|
+
# @!attribute [r] message
|
|
34
|
+
# @return [String, nil] the finer detail a +report+ carries, e.g.
|
|
35
|
+
# +"3200/4096 files"+; +nil+ when the server sent none.
|
|
36
|
+
# @!attribute [r] percentage
|
|
37
|
+
# @return [Integer, nil] 0..100, or +nil+ — which is common enough that a
|
|
38
|
+
# consumer needs an indeterminate spinner and not only a bar.
|
|
39
|
+
# @!attribute [r] done
|
|
40
|
+
# @return [Boolean] the task is over and its bar should go away. Defaults
|
|
41
|
+
# to +false+.
|
|
42
|
+
ServerProgress = Data.define(:server_id, :title, :message, :percentage, :done) do
|
|
43
|
+
include Pikuri::Agent::Event::Transient
|
|
44
|
+
|
|
45
|
+
def initialize(server_id:, title:, message: nil, percentage: nil, done: false)
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [String] one line, server first — two servers index at once, so
|
|
50
|
+
# whoever reads it needs to know whose bar moved.
|
|
51
|
+
def to_s
|
|
52
|
+
detail = [percentage && "#{percentage}%", message, (done ? 'done' : nil)].compact
|
|
53
|
+
"#{server_id}: #{([title] + detail).join(' — ')}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|