Class: Pikuri::Lsp::Renderer
- Inherits:
-
Object
- Object
- Pikuri::Lsp::Renderer
- 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: ajdt: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 saysclassrather than5. { 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.rbis source and aspec/foo_spec.rbis not. %w[spec test tests features].freeze
Instance Method Summary collapse
-
#calls(rows, operation:, symbol:, client:) ⇒ String
Call-hierarchy answers, both directions.
-
#finish(text) ⇒ String
Append the notes footer and cap the whole thing.
- #hover(reply, operation:, symbol:) ⇒ String
-
#initialize(sources:) ⇒ Renderer
constructor
A new instance of Renderer.
-
#locations(list, operation:, symbol:, client:) ⇒ String
Location-shaped answers: definition, references, implementation, typeDefinition.
-
#note(message) ⇒ void
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.
-
#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.
-
#supertypes(levels, operation:, symbol:) ⇒ String
The ancestor chain, one line per level.
-
#symbols(rows, operation:, symbol:, client:, uri: nil) ⇒ String
Symbol-shaped answers:
documentSymbol(one file's outline) andworkspaceSymbol(the whole index).
Constructor Details
#initialize(sources:) ⇒ Renderer
Returns a new instance of Renderer.
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.
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.
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, *].join("\n") end |
#hover(reply, operation:, symbol:) ⇒ 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.
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.
111 112 113 114 |
# File 'lib/pikuri/lsp/renderer.rb', line 111 def note() @notes << 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.
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.
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.
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 |