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