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,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# The +lsp+ tool: one tool, ten operations, and no column ever asked for.
|
|
6
|
+
#
|
|
7
|
+
# tool = LspTool.new(servers: servers, filesystem: filesystem)
|
|
8
|
+
# tool.run('operation' => 'goToDefinition', 'symbol' => 'resolve_for_read',
|
|
9
|
+
# 'file' => 'read.rb', 'line' => 165)
|
|
10
|
+
#
|
|
11
|
+
# The *surface* lives here — the schema, the description the model reads, the
|
|
12
|
+
# legs, the progress emitter. The behaviour is {Navigator}'s, the enum is
|
|
13
|
+
# {Operation}'s, and why a column is derived rather than asked for is {Anchor}'s.
|
|
14
|
+
#
|
|
15
|
+
# Ten +lsp_*+ tools would be ten descriptions in every request's prompt budget,
|
|
16
|
+
# and all three shipped harnesses reached the same one-tool conclusion. The
|
|
17
|
+
# +foo_*+ prefix convention is still satisfied: the family shares one backing
|
|
18
|
+
# resource, and +Tool::Parameters+ validates the enum strictly.
|
|
19
|
+
#
|
|
20
|
+
# == The legs, including the one that will surprise you
|
|
21
|
+
#
|
|
22
|
+
# +untrusted: :hard+, **unconditionally** — not riding +filesystem.trusted?+ the
|
|
23
|
+
# way {Pikuri::Workspace::Read} does. +read+'s conditional cannot be copied
|
|
24
|
+
# because +read+ is confined to the root, so a human's "yes, I trust this
|
|
25
|
+
# checkout" covers exactly what +read+ can reach. A server's index does not stop
|
|
26
|
+
# at the root: it reaches the whole bundle, the JDK and four hundred transitive
|
|
27
|
+
# jars, and the +jdt:+ content path reads text out of a jar nobody ever diffed.
|
|
28
|
+
# Quoting third-party prose into the context in bulk is this gem's *marquee
|
|
29
|
+
# feature*, not an edge case of it. The consequence is deliberate: on a trusted
|
|
30
|
+
# checkout +read+ declares +:none+, so wiring +lsp+ beside +fetch+ or
|
|
31
|
+
# +web_search+ can light the untrusted leg where nothing fired before.
|
|
32
|
+
#
|
|
33
|
+
# +egress_payload_review: :no_egress+, and *not* because the server is trusted:
|
|
34
|
+
# a seat whose outbound bytes do not originate in the model's choosing holds no
|
|
35
|
+
# leg to grade. +operation+, +symbol+, +file+ and +line+ reach an in-memory
|
|
36
|
+
# index, never a socket. A registered server may go online on its own — jdtls
|
|
37
|
+
# resolving a jar — but that traffic follows the project's build files and
|
|
38
|
+
# happens identically if the model never issues a single call.
|
|
39
|
+
#
|
|
40
|
+
# +private:+ is inherited from the workspace, never claimed here: the host
|
|
41
|
+
# already asked the human whether this project is private.
|
|
42
|
+
#
|
|
43
|
+
# == Sharing
|
|
44
|
+
#
|
|
45
|
+
# +P_one_agent+, and accepted as such: one wiring, one agent. Three things
|
|
46
|
+
# bind it — a per-agent {Pikuri::Agent::Control::Cancellable}, a mutable
|
|
47
|
+
# +on_progress+ hook installed after construction, and {Servers}, whose
|
|
48
|
+
# open-document bookkeeping assumes one caller (a second agent's
|
|
49
|
+
# +didClose+ can land on a document the first is still asking about).
|
|
50
|
+
#
|
|
51
|
+
# Ten agents over one project therefore means ten registries and ten sets
|
|
52
|
+
# of child processes, which is the honest cost of not having built a shared
|
|
53
|
+
# server pool. Nothing here refuses the sharing; it just goes wrong
|
|
54
|
+
# quietly, so don't.
|
|
55
|
+
class LspTool < Pikuri::Tool
|
|
56
|
+
# Description shown to the LLM (opencode-shape). Per-parameter constraints
|
|
57
|
+
# live in the parameter descriptions; the operation list is generated from
|
|
58
|
+
# {Operation::ALL} so it cannot drift from what dispatch accepts.
|
|
59
|
+
#
|
|
60
|
+
# Two things it deliberately does *not* say, both of which an earlier draft
|
|
61
|
+
# did. It does not mention that the first call waits for the server to
|
|
62
|
+
# finish indexing — the model has no notion of time, so the only content in
|
|
63
|
+
# that sentence is an implied promise that results are now complete, and a
|
|
64
|
+
# fully-started server can still be broken. And it does not announce that an
|
|
65
|
+
# unsupported operation refuses cleanly: the model learns that from the
|
|
66
|
+
# refusal, which names the missing capability. What stays is mechanics —
|
|
67
|
+
# how to anchor a call, what shapes the answer comes in — never a claim
|
|
68
|
+
# about the *quality* of what comes back, which is the model's to judge.
|
|
69
|
+
DESCRIPTION = <<~DESC
|
|
70
|
+
Ask a language server about code: where a name is defined, who calls it, what it inherits from.
|
|
71
|
+
|
|
72
|
+
Usage:
|
|
73
|
+
- Anchor a query with `symbol` plus `file` and `line` — the line number as printed when you read the file or searched it. The column is worked out from that line's text, so it is never asked for.
|
|
74
|
+
- `symbol` on its own searches every configured server's index instead. Best effort: a short name is matched against fully qualified ones and can score below a server's own threshold, and some servers index types but no methods.
|
|
75
|
+
- Servers are configured per file type. A file type nothing claims is refused, never guessed at.
|
|
76
|
+
- Results are capped and grouped, references and callers most aggressively; a truncation marker means there was more.
|
|
77
|
+
- A server's index reaches outside the project, so a result may live in a dependency rather than in your code. Such a result always carries its line of code, and carries a path when this workspace can read that file — otherwise just the file's name, or the type's name and the archive it came from.
|
|
78
|
+
|
|
79
|
+
Operations:
|
|
80
|
+
#{Operation::ALL.map { |operation| "- #{operation.name} — #{operation.summary}" }.join("\n")}
|
|
81
|
+
DESC
|
|
82
|
+
|
|
83
|
+
# @return [Proc] called with a {ServerProgress}, on the calling thread, while
|
|
84
|
+
# a call waits for a server to finish indexing. A host installs its event
|
|
85
|
+
# emitter here after construction; the default drops them, which is what a
|
|
86
|
+
# tool built outside an agent wants.
|
|
87
|
+
attr_accessor :on_progress
|
|
88
|
+
|
|
89
|
+
# @param servers [Servers] started clients. The tool never spawns.
|
|
90
|
+
# @param filesystem [Pikuri::Workspace::Filesystem] the project: path
|
|
91
|
+
# resolution, root confinement, the +denied?+ pass every walked path owes,
|
|
92
|
+
# and the +private?+ answer this tool's legs inherit.
|
|
93
|
+
# @param cancellable [Pikuri::Agent::Control::Cancellable, nil] what makes an
|
|
94
|
+
# unbounded index wait acceptable.
|
|
95
|
+
def initialize(servers:, filesystem:, cancellable: nil)
|
|
96
|
+
@servers = servers
|
|
97
|
+
@filesystem = filesystem
|
|
98
|
+
@cancellable = cancellable
|
|
99
|
+
@on_progress = ->(_progress) {}
|
|
100
|
+
tool = self
|
|
101
|
+
super(
|
|
102
|
+
name: 'lsp',
|
|
103
|
+
description: DESCRIPTION,
|
|
104
|
+
parameters: Pikuri::Tool::Parameters.build { |p|
|
|
105
|
+
p.required_enum :operation,
|
|
106
|
+
'Which question to ask; see the Operations list, ' \
|
|
107
|
+
'e.g. "goToDefinition".',
|
|
108
|
+
values: Operation.names
|
|
109
|
+
p.required_string :symbol,
|
|
110
|
+
'The name to ask about, spelled exactly as the ' \
|
|
111
|
+
'source spells it, e.g. "resolve_for_read" or ' \
|
|
112
|
+
'"Pikuri::Workspace::Read".'
|
|
113
|
+
p.optional_string :file,
|
|
114
|
+
'File the symbol is in, e.g. ' \
|
|
115
|
+
'"pikuri-workspace/lib/pikuri/workspace/read.rb". ' \
|
|
116
|
+
'Relative paths resolve against the workspace ' \
|
|
117
|
+
'root. Picks the language server and, with line, ' \
|
|
118
|
+
'anchors the query; without it every server\'s ' \
|
|
119
|
+
'index is searched. Required for documentSymbol, ' \
|
|
120
|
+
'and needed in practice for goToTypeDefinition, ' \
|
|
121
|
+
'whose usual target is a local variable that is in ' \
|
|
122
|
+
'no index.'
|
|
123
|
+
p.optional_integer :line,
|
|
124
|
+
'1-based line the symbol appears on, as printed ' \
|
|
125
|
+
'when you read the file, e.g. 165. Needs file, ' \
|
|
126
|
+
'and the symbol must occur literally on that line.'
|
|
127
|
+
},
|
|
128
|
+
execute: lambda { |operation:, symbol:, file: nil, line: nil|
|
|
129
|
+
tool.navigate(operation: operation, symbol: symbol, file: file, line: line)
|
|
130
|
+
},
|
|
131
|
+
trifecta_legs: Pikuri::Tool::TrifectaLegs.new(
|
|
132
|
+
private: filesystem.private?,
|
|
133
|
+
untrusted: :hard,
|
|
134
|
+
egress_payload_review: :no_egress
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Run one call.
|
|
140
|
+
#
|
|
141
|
+
# A fresh {Navigator} per call, rather than one captured in the +execute+
|
|
142
|
+
# lambda: {#on_progress} is installed *after* the tool is built, so a
|
|
143
|
+
# navigator built once would keep emitting into the default no-op forever.
|
|
144
|
+
#
|
|
145
|
+
# @param operation [String]
|
|
146
|
+
# @param symbol [String]
|
|
147
|
+
# @param file [String, nil]
|
|
148
|
+
# @param line [Integer, nil]
|
|
149
|
+
# @return [String] the observation.
|
|
150
|
+
def navigate(operation:, symbol:, file: nil, line: nil)
|
|
151
|
+
Navigator.new(servers: @servers, filesystem: @filesystem,
|
|
152
|
+
on_progress: @on_progress, cancellable: @cancellable)
|
|
153
|
+
.navigate(operation: operation, symbol: symbol, file: file, line: line)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# A thread boundary that keeps only the *newest* value per key. A producer
|
|
6
|
+
# thread pushes; one consumer thread drains, and the block runs on the
|
|
7
|
+
# consumer's thread:
|
|
8
|
+
#
|
|
9
|
+
# mailbox = Mailbox.new
|
|
10
|
+
# Thread.new { mailbox.push('token-1', progress) } # the reader thread
|
|
11
|
+
#
|
|
12
|
+
# mailbox.drain(tick: 0.1) do |value| # nil on an idle tick
|
|
13
|
+
# cancellable&.check!
|
|
14
|
+
# break if ready?
|
|
15
|
+
# emit.call(value) if value
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# {#drain} loops until the block +break+s — the consumer has to poll its own
|
|
19
|
+
# exit conditions anyway, so it gets one place to do it. No +stop_when:+
|
|
20
|
+
# predicate, no sentinel value.
|
|
21
|
+
#
|
|
22
|
+
# It exists for the one shape a queue gets wrong. ruby-lsp emits hundreds of
|
|
23
|
+
# +$/progress+ reports while it indexes, and a consumer blocked for three
|
|
24
|
+
# minutes would replay every one of them — a burst of stale percentages
|
|
25
|
+
# racing to catch up. Coalescing per key means the first drain yields
|
|
26
|
+
# *current* state and then tracks live, which makes +Thread::Queue+ the wrong
|
|
27
|
+
# primitive: it faithfully preserves exactly what this discards.
|
|
28
|
+
#
|
|
29
|
+
# Per key rather than one global latest because concurrent tasks are normal —
|
|
30
|
+
# jdtls holds "Importing project" and "Building workspace" open at once. The
|
|
31
|
+
# key is a parameter, so nothing here knows what the value is.
|
|
32
|
+
#
|
|
33
|
+
# Thread-safe, and that is its whole purpose. One consumer only: two threads
|
|
34
|
+
# draining would each see an arbitrary half of the values.
|
|
35
|
+
class Mailbox
|
|
36
|
+
def initialize
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
@pushed = ConditionVariable.new
|
|
39
|
+
@latest = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Park +value+ under +key+, replacing whatever was parked there, and wake
|
|
43
|
+
# the consumer.
|
|
44
|
+
#
|
|
45
|
+
# @param key [Object] what to coalesce on, e.g. a progress token.
|
|
46
|
+
# @param value [Object] the newest state for that key.
|
|
47
|
+
# @return [void]
|
|
48
|
+
def push(key, value)
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
@latest[key] = value
|
|
51
|
+
@pushed.signal
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Yield parked values on this thread until the block breaks, waking every
|
|
57
|
+
# +tick+ seconds to yield +nil+ so the consumer can check its own exit
|
|
58
|
+
# conditions. Values arrive in the order their keys were first pushed;
|
|
59
|
+
# +nil+ means the mailbox was empty, not that anything ended.
|
|
60
|
+
#
|
|
61
|
+
# @param tick [Float] seconds between idle yields. A push wakes the wait
|
|
62
|
+
# early, so this bounds the *idle* latency, not the live one.
|
|
63
|
+
# @yieldparam value [Object, nil] the newest value for one key, or +nil+ on
|
|
64
|
+
# an idle tick.
|
|
65
|
+
# @return [Object] whatever the block broke with.
|
|
66
|
+
def drain(tick:)
|
|
67
|
+
loop do
|
|
68
|
+
parked = take_all
|
|
69
|
+
if parked.empty?
|
|
70
|
+
yield nil
|
|
71
|
+
@mutex.synchronize { @pushed.wait(@mutex, tick) if @latest.empty? }
|
|
72
|
+
else
|
|
73
|
+
parked.each { |value| yield value }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Integer] how many keys are parked. For tests and diagnostics;
|
|
79
|
+
# a consumer polls by draining.
|
|
80
|
+
def size
|
|
81
|
+
@mutex.synchronize { @latest.size }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Atomically: everything parked, and an empty mailbox. Nothing is yielded
|
|
87
|
+
# under the mutex — the producer must stay free to push while the consumer
|
|
88
|
+
# works.
|
|
89
|
+
def take_all
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
values = @latest.values
|
|
92
|
+
@latest.clear
|
|
93
|
+
values
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# One +lsp+ call, end to end: route to a server, wait for its index, re-open
|
|
6
|
+
# the document, ask, render. {LspTool} holds the schema and the legs; this holds
|
|
7
|
+
# the behaviour, so a spec can drive it with no tool in sight.
|
|
8
|
+
#
|
|
9
|
+
# navigator = Navigator.new(servers: servers, filesystem: filesystem,
|
|
10
|
+
# on_progress: ->(progress) { ctx.emit_event(progress) })
|
|
11
|
+
# navigator.navigate(operation: 'goToDefinition', symbol: 'resolve_for_read',
|
|
12
|
+
# file: 'read.rb', line: 165)
|
|
13
|
+
#
|
|
14
|
+
# == The order of the five steps is the design
|
|
15
|
+
#
|
|
16
|
+
# 1. **Route on the file**, or fan out when there is none — the model names no
|
|
17
|
+
# server, and a registry keyed by language would fail in exactly the
|
|
18
|
+
# mixed-language repo that makes a registry worth having.
|
|
19
|
+
# 2. **Gate on what that server advertised**, per workspace and at dispatch.
|
|
20
|
+
# Extension routing without this is how a shipped client leaks
|
|
21
|
+
# +Method not found: textDocument/implementation+ at the model.
|
|
22
|
+
# 3. **Wait for the index** — the one place waiting beats relaying, because a
|
|
23
|
+
# mid-index server answers +[]+ with a success code and there is nothing in
|
|
24
|
+
# +[]+ to reason about. No timeout: a cold Java import measured 78 seconds,
|
|
25
|
+
# no number separates that from a hang, and progress events plus
|
|
26
|
+
# +cancellable+ make the human the timeout.
|
|
27
|
+
# 4. **Re-open the document** ({ClientWrapper#open_document}), and hand it
|
|
28
|
+
# back once every reply is in ({#release_documents}) — the pair is per
|
|
29
|
+
# *call*, because one call can ask several questions about one document.
|
|
30
|
+
# 5. **Ask and render**, running the +prepare+ hop and the per-level walk here
|
|
31
|
+
# so the model issues one call and never holds an opaque protocol item.
|
|
32
|
+
#
|
|
33
|
+
# == Errors are relayed, never worked around
|
|
34
|
+
#
|
|
35
|
+
# Every server is broken in its own way and the ways do not generalize — one
|
|
36
|
+
# advertises a type hierarchy whose downward half is a +# TODO+, another indexes
|
|
37
|
+
# no methods, a third crashes on a notification the spec allows. So there is no
|
|
38
|
+
# per-server workaround and no known-stub list here: what the server said and
|
|
39
|
+
# what it was asked go back, and the model decides. That includes a reply pikuri
|
|
40
|
+
# cannot parse ({#relaying}) — the server's bug, and dying on it would be the
|
|
41
|
+
# one failure mode this stance exists to prevent.
|
|
42
|
+
class Navigator
|
|
43
|
+
# Items a +prepare+ hop may produce before the rest are dropped with a note.
|
|
44
|
+
# +prepare+ can legitimately answer several — Java overloads, one name
|
|
45
|
+
# declared in several classes — and the model has no more idea which is meant
|
|
46
|
+
# than this does, so each is queried and the results are labelled.
|
|
47
|
+
MAX_PREPARED = 3
|
|
48
|
+
|
|
49
|
+
# @param servers [Servers] the live clients.
|
|
50
|
+
# @param filesystem [Pikuri::Workspace::Filesystem] path resolution, root
|
|
51
|
+
# confinement, the denylist pass and the seam every read routes through.
|
|
52
|
+
# @param on_progress [Proc, nil] called with a {ServerProgress} while a wait
|
|
53
|
+
# blocks — on *this* thread, which is what makes it safe to emit as an
|
|
54
|
+
# agent event.
|
|
55
|
+
# @param cancellable [Pikuri::Agent::Control::Cancellable, nil] polled by
|
|
56
|
+
# every wait and every request.
|
|
57
|
+
def initialize(servers:, filesystem:, on_progress: nil, cancellable: nil)
|
|
58
|
+
@servers = servers
|
|
59
|
+
@filesystem = filesystem
|
|
60
|
+
@on_progress = on_progress || ->(_progress) {}
|
|
61
|
+
@cancellable = cancellable
|
|
62
|
+
@ready = []
|
|
63
|
+
@texts = {}
|
|
64
|
+
@opened = []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Answer one call.
|
|
68
|
+
#
|
|
69
|
+
# @param operation [String] an {Operation} name; +Tool::Parameters+ has
|
|
70
|
+
# already refused anything outside the enum.
|
|
71
|
+
# @param symbol [String] the name to ask about.
|
|
72
|
+
# @param file [String, nil] workspace-relative or absolute; the server
|
|
73
|
+
# selector, and the document an anchor is located in.
|
|
74
|
+
# @param line [Integer, nil] 1-based, as +read+ and +grep+ print it.
|
|
75
|
+
# @return [String] the observation, including +"Error: …"+ for everything
|
|
76
|
+
# the model can react to.
|
|
77
|
+
# @raise [Pikuri::Agent::Control::Cancellable::Cancelled] on cancellation —
|
|
78
|
+
# deliberately *not* an observation.
|
|
79
|
+
def navigate(operation:, symbol:, file: nil, line: nil)
|
|
80
|
+
op = Operation[operation]
|
|
81
|
+
raise ArgumentError, "unknown lsp operation #{operation.inspect}" if op.nil?
|
|
82
|
+
|
|
83
|
+
renderer = Renderer.new(sources: Sources.new(filesystem: @filesystem))
|
|
84
|
+
@ready = []
|
|
85
|
+
@texts = {}
|
|
86
|
+
@opened = []
|
|
87
|
+
raise Refusal, 'no language server is configured' if @servers.empty?
|
|
88
|
+
|
|
89
|
+
dispatch(op, symbol, file, line, renderer)
|
|
90
|
+
rescue Refusal, Pikuri::Workspace::Filesystem::Error, ClientWrapper::ServerDied => e
|
|
91
|
+
"Error: #{e.message}"
|
|
92
|
+
rescue Connection::ServerError => e
|
|
93
|
+
"Error: the language server refused #{operation}: #{e.message}"
|
|
94
|
+
ensure
|
|
95
|
+
release_documents
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def dispatch(operation, symbol, file, line, renderer)
|
|
101
|
+
case operation.anchor
|
|
102
|
+
when :query then fan_out(operation, symbol, renderer)
|
|
103
|
+
when :document then outline(operation, symbol, file, renderer)
|
|
104
|
+
else positional(operation, symbol, file, line, renderer)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# +workspaceSymbol+ takes no position at all, so every server that can search
|
|
109
|
+
# is asked. Fanning out starts each of them and costs the *first* call their
|
|
110
|
+
# remaining index time — the accepted price of a question that names no file,
|
|
111
|
+
# and why {Servers#active} narrows the set to servers the project has files
|
|
112
|
+
# for.
|
|
113
|
+
def fan_out(operation, symbol, renderer)
|
|
114
|
+
clients = @servers.active
|
|
115
|
+
raise Refusal, no_relevant_server_message if clients.empty?
|
|
116
|
+
|
|
117
|
+
usable, refusing = clients.partition { |client| client.supports?(operation.capability) }
|
|
118
|
+
raise Refusal, unsupported_message(operation, clients) if usable.empty?
|
|
119
|
+
|
|
120
|
+
refusing.each { |client| renderer.note(cannot_answer(operation, client)) }
|
|
121
|
+
blocks = usable.map do |client|
|
|
122
|
+
ensure_ready(client)
|
|
123
|
+
rows = client.request(operation.request, { query: symbol }, cancellable: @cancellable)
|
|
124
|
+
block = relaying(operation, client) do
|
|
125
|
+
renderer.symbols(Array(rows), operation: operation, symbol: symbol, client: client)
|
|
126
|
+
end
|
|
127
|
+
usable.length > 1 ? "From the #{client.entry.id} server:\n#{block}" : block
|
|
128
|
+
end
|
|
129
|
+
renderer.finish(blocks.join("\n\n"))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# +documentSymbol+: the only file-keyed operation, and the one route from a
|
|
133
|
+
# method *name* to a position where a server's project-wide index holds
|
|
134
|
+
# types only.
|
|
135
|
+
def outline(operation, symbol, file, renderer)
|
|
136
|
+
raise Refusal, "#{operation.name} outlines one file, so it needs file:" if file.nil?
|
|
137
|
+
|
|
138
|
+
resolved = readable(file)
|
|
139
|
+
client = @servers.client_for(resolved)
|
|
140
|
+
raise Refusal, Anchor.no_server_message(resolved) if client.nil?
|
|
141
|
+
raise Refusal, unsupported_message(operation, [client]) unless client.supports?(operation.capability)
|
|
142
|
+
|
|
143
|
+
ensure_ready(client)
|
|
144
|
+
uri = open_document(client, resolved)
|
|
145
|
+
rows = client.request(operation.request, { textDocument: { uri: uri } }, cancellable: @cancellable)
|
|
146
|
+
renderer.finish(relaying(operation, client) do
|
|
147
|
+
renderer.symbols(Array(rows), operation: operation, symbol: symbol, client: client, uri: uri)
|
|
148
|
+
end)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Everything with a cursor. The capability check is per *anchor*, not per
|
|
152
|
+
# call: a bare name fans out, so two anchors can sit on two servers with
|
|
153
|
+
# different capability sets.
|
|
154
|
+
def positional(operation, symbol, file, line, renderer)
|
|
155
|
+
resolution = Anchor.resolve(servers: @servers, filesystem: @filesystem, symbol: symbol,
|
|
156
|
+
file: file, line: line, ready: method(:ensure_ready))
|
|
157
|
+
resolution.notes.each { |note| renderer.note(note) }
|
|
158
|
+
anchors = resolution.anchors.select { |anchor| anchor.client.supports?(operation.capability) }
|
|
159
|
+
if anchors.empty?
|
|
160
|
+
raise Refusal, unsupported_message(operation, resolution.anchors.map(&:client).uniq)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
answers = anchors.map { |anchor| [anchor, answer(operation, anchor, symbol, renderer)] }
|
|
164
|
+
renderer.finish(merge(answers))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# One anchor, one answer. Identical answers from several anchors collapse in
|
|
168
|
+
# {#merge}; different ones are labelled by column, which is what replaces the
|
|
169
|
+
# +character+ parameter for +names = names.trim()+.
|
|
170
|
+
def answer(operation, anchor, symbol, renderer)
|
|
171
|
+
client = anchor.client
|
|
172
|
+
ensure_ready(client)
|
|
173
|
+
open_document(client, Pathname.new(anchor.path))
|
|
174
|
+
|
|
175
|
+
relaying(operation, client) do
|
|
176
|
+
case operation.shape
|
|
177
|
+
when :hover
|
|
178
|
+
renderer.hover(request(client, operation.request, anchor.params),
|
|
179
|
+
operation: operation, symbol: symbol)
|
|
180
|
+
when :calls
|
|
181
|
+
renderer.calls(hierarchy(operation, anchor, symbol, renderer),
|
|
182
|
+
operation: operation, symbol: symbol, client: client)
|
|
183
|
+
when :supertypes
|
|
184
|
+
renderer.supertypes(ancestry(operation, anchor, symbol, renderer),
|
|
185
|
+
operation: operation, symbol: symbol)
|
|
186
|
+
else
|
|
187
|
+
result = request(client, operation.request, location_params(operation, anchor))
|
|
188
|
+
renderer.locations(Location.list_from_wire(result, encoding: client.position_encoding),
|
|
189
|
+
operation: operation, symbol: symbol, client: client)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A reply whose *shape* pikuri cannot read is the server's bug, not
|
|
195
|
+
# pikuri's, so it is relayed like everything else the server said. Without
|
|
196
|
+
# this the wire types raise +KeyError+ for a location missing its +range+ and
|
|
197
|
+
# +ArgumentError+ for a +definition+ that answered a bare String, and the
|
|
198
|
+
# turn dies on a message nobody sees — which is the one thing the relay
|
|
199
|
+
# stance is supposed to prevent.
|
|
200
|
+
def relaying(operation, client)
|
|
201
|
+
yield
|
|
202
|
+
rescue KeyError, TypeError, ArgumentError => e
|
|
203
|
+
raise Refusal, "the #{client.entry.id} server's #{operation.name} reply could not be " \
|
|
204
|
+
"read (#{e.class}: #{e.message})"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The declaration is a reference too — a caller asking "who uses this" wants
|
|
208
|
+
# it in the list, and every shipped harness includes it.
|
|
209
|
+
def location_params(operation, anchor)
|
|
210
|
+
params = anchor.params
|
|
211
|
+
params = params.merge(context: { includeDeclaration: true }) if operation.name == 'findReferences'
|
|
212
|
+
params
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The +prepare+ hop, collapsed. LSP makes both hierarchies a two-step chain
|
|
216
|
+
# and both harnesses that ship the call family hand the model the
|
|
217
|
+
# intermediate item to echo back; running it here costs one round trip the
|
|
218
|
+
# model never sees.
|
|
219
|
+
def prepared(operation, anchor, symbol, renderer)
|
|
220
|
+
items = Array(request(anchor.client, operation.prepare, anchor.params))
|
|
221
|
+
exact = items.select { |item| SymbolName.matches?(item['name'], symbol) }
|
|
222
|
+
chosen = exact.empty? ? items : exact
|
|
223
|
+
if exact.empty? && !items.empty?
|
|
224
|
+
renderer.note("nothing the server prepared here is named #{symbol.inspect}; it offered " \
|
|
225
|
+
"#{items.map { |item| item['name'] }.uniq.join(', ')}.")
|
|
226
|
+
end
|
|
227
|
+
if chosen.length > MAX_PREPARED
|
|
228
|
+
renderer.note("#{chosen.length} declarations were prepared for #{symbol.inspect}; the " \
|
|
229
|
+
"first #{MAX_PREPARED} were queried.")
|
|
230
|
+
end
|
|
231
|
+
chosen.first(MAX_PREPARED)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def hierarchy(operation, anchor, symbol, renderer)
|
|
235
|
+
prepared(operation, anchor, symbol, renderer).flat_map do |item|
|
|
236
|
+
Array(request(anchor.client, operation.request, { item: item }))
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The ancestor *chain*, walked level by level — which is the whole value of
|
|
241
|
+
# the operation, since a definition hop on the name after +extends+ answers
|
|
242
|
+
# one level and the chain costs one round trip per level.
|
|
243
|
+
#
|
|
244
|
+
# Each level is deduplicated by name before the next query, or a Ruby
|
|
245
|
+
# namespace reopened in 14 files would fan out into 14 queries for the level
|
|
246
|
+
# above it.
|
|
247
|
+
def ancestry(operation, anchor, symbol, renderer)
|
|
248
|
+
current = prepared(operation, anchor, symbol, renderer)
|
|
249
|
+
levels = []
|
|
250
|
+
operation.cap.times do
|
|
251
|
+
parents = current.flat_map { |item| Array(request(anchor.client, operation.request, { item: item })) }
|
|
252
|
+
break if parents.empty?
|
|
253
|
+
|
|
254
|
+
levels << parents
|
|
255
|
+
current = parents.group_by { |item| item['name'].to_s }.values.map(&:last).first(2)
|
|
256
|
+
end
|
|
257
|
+
if levels.length == operation.cap
|
|
258
|
+
renderer.note("the chain was walked #{operation.cap} levels deep and did not end there.")
|
|
259
|
+
end
|
|
260
|
+
levels
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def request(client, method, params)
|
|
264
|
+
client.request(method, params, cancellable: @cancellable)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Identical bodies collapse — the overwhelmingly common case, since a
|
|
268
|
+
# repeated identifier on one line usually denotes the same entity.
|
|
269
|
+
def merge(answers)
|
|
270
|
+
grouped = answers.group_by(&:last)
|
|
271
|
+
return grouped.keys.first if grouped.length == 1
|
|
272
|
+
|
|
273
|
+
grouped.map do |text, pairs|
|
|
274
|
+
"At #{pairs.map { |anchor, _| anchor.to_s }.join(', ')}:\n#{text}"
|
|
275
|
+
end.join("\n\n")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Blocks until the index is built, emitting progress on this thread. Memoized
|
|
279
|
+
# per call, so a fan-out over three anchors on one server costs one wait.
|
|
280
|
+
def ensure_ready(client)
|
|
281
|
+
return if @ready.include?(client.entry.id)
|
|
282
|
+
|
|
283
|
+
client.wait_until_ready(cancellable: @cancellable) { |progress| @on_progress.call(progress) }
|
|
284
|
+
@ready << client.entry.id
|
|
285
|
+
rescue ClientWrapper::ServerDied => e
|
|
286
|
+
raise Refusal, "the #{client.entry.id} server died while its index was building: #{e.message}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# The re-open, once per document per call, with the text on disk *now*.
|
|
290
|
+
def open_document(client, resolved)
|
|
291
|
+
uri = Uris.for_path(resolved)
|
|
292
|
+
unless @texts.key?(resolved.to_s)
|
|
293
|
+
client.open_document(resolved.to_s, text_of(resolved))
|
|
294
|
+
@opened << [client, resolved.to_s]
|
|
295
|
+
end
|
|
296
|
+
uri
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Give back every document this call opened, once every reply is in.
|
|
300
|
+
#
|
|
301
|
+
# Per *call*, not per request: +supertypes+ and the call-hierarchy operations
|
|
302
|
+
# ask several questions about one document, and a close after the first would
|
|
303
|
+
# drop it under the rest.
|
|
304
|
+
def release_documents
|
|
305
|
+
opened = @opened
|
|
306
|
+
@opened = []
|
|
307
|
+
opened.each { |client, path| client.close_document(path) }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Through the seam, always: the text a +didOpen+ carries is a read of a
|
|
311
|
+
# workspace file like any other.
|
|
312
|
+
def text_of(resolved)
|
|
313
|
+
@texts[resolved.to_s] ||= @filesystem.resolve_for_read(resolved).read
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def readable(file)
|
|
317
|
+
resolved = @filesystem.resolve_for_read(file)
|
|
318
|
+
raise Refusal, "#{file}: no such file in the workspace" unless resolved.file?
|
|
319
|
+
|
|
320
|
+
resolved
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def unsupported_message(operation, clients)
|
|
324
|
+
ids = clients.map { |client| client.entry.id }
|
|
325
|
+
"the #{ids.join(' and ')} server does not support #{operation.name} in this workspace — " \
|
|
326
|
+
"it advertises no #{operation.capability}"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def cannot_answer(operation, client)
|
|
330
|
+
"the #{client.entry.id} server does not support #{operation.name}, so its files were not searched."
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def no_relevant_server_message
|
|
334
|
+
ids = @servers.registry.entries.map(&:id)
|
|
335
|
+
"no configured language server has files in this project (configured: #{ids.join(', ')}) — " \
|
|
336
|
+
'name a file to ask a specific one'
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|