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