Class: Pikuri::Lsp::Renderer

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/lsp/renderer.rb

Overview

Turns a server's reply into the observation the model reads: one row per result, a path it can act on where there is one, a line of code where there is one, and a cap on all of it. One instance per tool call, because the notes it collects belong to that call.

renderer = Renderer.new(sources: Sources.new(filesystem: filesystem))
renderer.note('2 matches are outside the workspace.')
renderer.finish(renderer.locations(hits, operation: operation, symbol: 'read', client: client))

Why this caps and groups when no shipped harness does

Because the volumes were measured and they have no ceiling: 80,609 locations from one findReferences; 40 references to one Ruby method that were 4 call sites, 2 declarations and 34 spec hits; 45 goToDefinition locations for a constant, one per file reopening the namespace; 14 supertypes rows all named Pikuri::Tool with the real declaration ordered last. A naive cap would cut the one row that mattered, which is why #supertypes groups instead of trimming.

Three answers, three sentences

The server does not support this is a Refusal, the server answered nothing is #nothing, the server could not be asked is a death report. Merging them into "No definition found. This may occur if…" is what teaches a model that a symbol has no definition when the index was merely still building. Everything else the server said is passed through as it came, glitches included: pikuri cannot pre-empt thirty servers' failure modes, and a capable model handles "that looks stale, let me ask another way" routinely.

The one edit made to a server's prose

Hover answers prose rather than rows, so its paths reach none of the Sources passes every other result goes through — a **Definitions** link into a denied root prints exactly what the walked-path denylist exists to stop. Each file: URI is rewritten to the citation the other nine operations print, keeping its markdown link only where resolve_for_read would open the file:

in:  [version.rb](file:///home/m/proj/lib/version.rb#L9,3-9,20)
out: [version.rb](lib/version.rb:9)            — a path +read+ opens
   outside the workspace: version.rb         — a read here is refused
   [a path this workspace will not read]     — denied

That is a denylist obligation, not a style preference: the passed-through-as-it-came rule above is about not pre-empting a server's failure modes, and holds for every byte that is not a file: URI.

Constant Summary collapse

MAX_BYTES =

Bytes one observation may reach before it is head-truncated with a marker. Half of what a grep answer is allowed, because these rows carry both a path and a line of code.

24 * 1024
MAX_TEST_ROWS =

Rows from test files shown in a grouped answer. The 34 spec hits are real information — that a method is well covered — but the 4 source call sites are what was asked for, so the tests group states its size and shows a handful.

5
MAX_HOVER_BYTES =

Bytes of hover text kept. Hover on a constant returns the whole class comment, which in this codebase is the declared source of truth and is therefore worth having — up to a point that is not "45 documentation links ahead of the docstring".

3 * 1024
FILE_URI =

A file: URI as it appears inside hover prose, bounded by whitespace and by the delimiters markdown puts around a URI. That such a bound exists is why the scan is +file:+-only: a jdt: URI carries a whole classpath entry in its query and has no reliable end inside a sentence.

%r{file://[^\s)\]<>"']+}
LINKED_FILE_URI =

The markdown link ruby-lsp builds its **Definitions** line from.

/\[([^\]\n]*)\]\((#{FILE_URI})\)/
DENIED_CITATION =

What a citation this workspace will not read collapses to. A denied result is dropped outright in a row; hover is prose, so something has to stand where the path was.

'[a path this workspace will not read]'
SYMBOL_KINDS =

LSP's SymbolKind, so a row says class rather than 5.

{
  1 => 'file', 2 => 'module', 3 => 'namespace', 4 => 'package', 5 => 'class',
  6 => 'method', 7 => 'property', 8 => 'field', 9 => 'constructor', 10 => 'enum',
  11 => 'interface', 12 => 'function', 13 => 'variable', 14 => 'constant',
  15 => 'string', 16 => 'number', 17 => 'boolean', 18 => 'array', 19 => 'object',
  20 => 'key', 21 => 'null', 22 => 'enum member', 23 => 'struct', 24 => 'event',
  25 => 'operator', 26 => 'type parameter'
}.freeze
TEST_DIRS =

Directory names that make a file a test file, plus the filename suffixes Ruby and Java conventions use — a lib/spec_helper.rb is source and a spec/foo_spec.rb is not.

%w[spec test tests features].freeze

Instance Method Summary collapse

Constructor Details

#initialize(sources:) ⇒ Renderer

Returns a new instance of Renderer.

Parameters:

  • sources (Sources)

    URI identity, the denylist pass and snippet text, memoized per call.



100
101
102
103
# File 'lib/pikuri/lsp/renderer.rb', line 100

def initialize(sources:)
  @sources = sources
  @notes = []
end

Instance Method Details

#calls(rows, operation:, symbol:, client:) ⇒ String

Call-hierarchy answers, both directions.

Parameters:

  • rows (Array<Hash>)

    CallHierarchyIncomingCall (+from+) or CallHierarchyOutgoingCall (+to+).

  • operation (Operation)
  • symbol (String)
  • client (ClientWrapper)

Returns:

  • (String)


188
189
190
191
192
193
194
195
# File 'lib/pikuri/lsp/renderer.rb', line 188

def calls(rows, operation:, symbol:, client:)
  items = Array(rows).filter_map { |row| row.is_a?(Hash) ? row['from'] || row['to'] : nil }
                     .reject { |item| @sources.denied?(item['uri'].to_s) }
  return nothing(operation: operation, symbol: symbol) if items.empty?

  noun = operation.name == 'incomingCalls' ? 'caller' : 'callee'
  compose(operation, symbol, items.map { |item| item_row(item) }, noun)
end

#finish(text) ⇒ String

Append the notes footer and cap the whole thing.

Parameters:

  • text (String)

    a body from one of the methods above.

Returns:

  • (String)

    the observation.



239
240
241
242
243
244
245
246
247
# File 'lib/pikuri/lsp/renderer.rb', line 239

def finish(text)
  # The notes are appended *after* the cut, not truncated with the body: they
  # are what says a match was dropped or a server could not answer, and a
  # long answer is exactly when that must not be the first thing to go.
  kept, marker = Pikuri::Workspace::Search::Utils.head_truncate(
    text, max_bytes: MAX_BYTES, hint: 'narrow the query with file: and line:'
  )
  [kept + marker, *footer].join("\n")
end

#hover(reply, operation:, symbol:) ⇒ String

Parameters:

  • reply (Hash, nil)

    the textDocument/hover result.

  • operation (Operation)
  • symbol (String)

Returns:

  • (String)


142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/pikuri/lsp/renderer.rb', line 142

def hover(reply, operation:, symbol:)
  text = hover_text(reply.is_a?(Hash) ? reply['contents'] : reply).strip
  return nothing(operation: operation, symbol: symbol) if text.empty?

  # Before the truncation, so {MAX_HOVER_BYTES} is spent on documentation
  # rather than on absolute paths.
  text = rewrite_file_uris(text)

  kept, marker = Pikuri::Workspace::Search::Utils.head_truncate(
    text, max_bytes: MAX_HOVER_BYTES, hint: 'the rest is documentation the server holds'
  )
  ["hover #{symbol.inspect}", '', kept + marker].join("\n")
end

#locations(list, operation:, symbol:, client:) ⇒ String

Location-shaped answers: definition, references, implementation, typeDefinition.

Deduplicated on the rendered row rather than on the Location: pikuri prints no column, so two results differing only in column are one row — and each copy would spend a slot of the cap #body hands out.

Parameters:

  • list (Array<Location>)

    parsed results, in the server's order.

  • operation (Operation)
  • symbol (String)

    what was asked about, for the header.

  • client (ClientWrapper)

    the server that answered — the only one that can read its own private URI schemes.

Returns:

  • (String)

    the body, without the notes footer.



129
130
131
132
133
134
135
136
# File 'lib/pikuri/lsp/renderer.rb', line 129

def locations(list, operation:, symbol:, client:)
  rows = list.reject { |location| @sources.denied?(location.uri) }
             .map { |location| location_row(location, client) }
             .uniq
  return nothing(operation: operation, symbol: symbol) if rows.empty?

  compose(operation, symbol, rows, 'result')
end

#note(message) ⇒ void

This method returns an undefined value.

Record something the model should know about how the answer was reached — a match dropped for being outside the workspace, a server that could not be asked, an anchor found by scanning rather than by the index.

Parameters:

  • message (String)

    one sentence, already phrased for the model.



111
112
113
114
# File 'lib/pikuri/lsp/renderer.rb', line 111

def note(message)
  @notes << message
  nil
end

#nothing(operation:, symbol:) ⇒ String

The server answered nothing string, which is not the same claim as "this symbol has none" and must not be written as though it were.

Parameters:

Returns:

  • (String)


230
231
232
233
# File 'lib/pikuri/lsp/renderer.rb', line 230

def nothing(operation:, symbol:)
  "The server answered no results for #{operation.name} on #{symbol.inspect}. " \
    'That is what it said, not proof that none exist.'
end

#supertypes(levels, operation:, symbol:) ⇒ String

The ancestor chain, one line per level.

Grouped by name rather than capped, because the noise here is a Ruby namespace reopening: 14 rows for one class, every one named Pikuri::Tool, one per file that reopens the namespace as a wrapper — and the real declaration was last in the list, so a cap would cut precisely the row a reader wants.

Parameters:

  • levels (Array<Array<Hash>>)

    TypeHierarchyItem lists, nearest ancestor first.

  • operation (Operation)
  • symbol (String)

Returns:

  • (String)


210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/pikuri/lsp/renderer.rb', line 210

def supertypes(levels, operation:, symbol:)
  shown = 0
  lines = levels.filter_map do |items|
    kept = Array(items).reject { |item| @sources.denied?(item['uri'].to_s) }
    next if kept.empty?

    shown += 1
    "  #{shown}. #{level_row(kept)}"
  end
  return nothing(operation: operation, symbol: symbol) if lines.empty?

  [header(operation, symbol, lines.length, 'level'), '', *lines].join("\n")
end

#symbols(rows, operation:, symbol:, client:, uri: nil) ⇒ String

Symbol-shaped answers: documentSymbol (one file's outline) and workspaceSymbol (the whole index). Rows whose name matches symbol exactly win outright; when none does, the full list follows under a note, because a fuzzy index answer is still the fastest way to see what is there.

Parameters:

  • rows (Array<Hash>)

    SymbolInformation or DocumentSymbol objects; both shapes are accepted because a server may send either.

  • operation (Operation)
  • symbol (String)
  • client (ClientWrapper)
  • uri (String, nil) (defaults to: nil)

    the document the outline came from, for the rows that carry no URI of their own (+DocumentSymbol+ does not).

Returns:

  • (String)


170
171
172
173
174
175
176
177
178
# File 'lib/pikuri/lsp/renderer.rb', line 170

def symbols(rows, operation:, symbol:, client:, uri: nil)
  flat = flatten_symbols(rows, uri).reject { |row| @sources.denied?(row[:uri].to_s) }
  return nothing(operation: operation, symbol: symbol) if flat.empty?

  exact = flat.select { |row| SymbolName.matches?(row[:name], symbol) }
  note_filtered(operation, symbol, flat.length, exact.length)
  matched = exact.empty? ? flat : exact
  compose(operation, symbol, matched.map { |row| symbol_row(row, client) }, 'symbol')
end