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,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# Every language server this agent may use: one {ClientWrapper} per
|
|
6
|
+
# {Registry} entry, and the routing that picks which one answers for a file.
|
|
7
|
+
# **Nothing is spawned until a call needs it** — construction is pure, and
|
|
8
|
+
# each of the two routes starts what it needs:
|
|
9
|
+
#
|
|
10
|
+
# servers = Servers.new(registry: registry, root: filesystem.project_root)
|
|
11
|
+
# servers.client_for('lib/pikuri/agent.rb') # the claiming server, spawned now
|
|
12
|
+
# servers.client_for('README.md') # => nil, and nothing spawned
|
|
13
|
+
# servers.active # every project-relevant server, spawned
|
|
14
|
+
# servers.close
|
|
15
|
+
#
|
|
16
|
+
# Arm teardown straight after construction, not after the first call: a
|
|
17
|
+
# server that started before a later one failed is registered here precisely
|
|
18
|
+
# so an already-armed +close+ still sweeps it.
|
|
19
|
+
#
|
|
20
|
+
# == Implementation details
|
|
21
|
+
#
|
|
22
|
+
# Because a start is lazy, a misconfigured +command+ is discovered by the
|
|
23
|
+
# call that needed it — a {ClientWrapper::ServerDied} naming the id and the
|
|
24
|
+
# argv, which {Navigator} relays as an +"Error: …"+ observation. That is the
|
|
25
|
+
# same path a server dying *after* a successful spawn already took.
|
|
26
|
+
#
|
|
27
|
+
# Sharing: +P_one_agent+, accepted for today — +@clients+ / +@active+ are
|
|
28
|
+
# unguarded, {#close} kills children another caller may be mid-request on,
|
|
29
|
+
# and a per-agent {Pikuri::Agent::Control::Cancellable} is threaded into
|
|
30
|
+
# every handshake. A shared server pool is buildable and simply isn't built,
|
|
31
|
+
# so N agents over one project means N sets of language servers.
|
|
32
|
+
class Servers
|
|
33
|
+
LOGGER = Pikuri.logger_for('Lsp::Servers')
|
|
34
|
+
private_constant :LOGGER
|
|
35
|
+
|
|
36
|
+
# @return [Registry] the configuration these clients run.
|
|
37
|
+
attr_reader :registry
|
|
38
|
+
|
|
39
|
+
# @param registry [Registry] which servers exist. {Registry::EMPTY} makes
|
|
40
|
+
# every method here a no-op answer, so a host with no config wires no
|
|
41
|
+
# tool and nothing else has to care.
|
|
42
|
+
# @param root [String, Pathname] the workspace root: every child's cwd, the
|
|
43
|
+
# +rootUri+ it indexes, and the tree {#active_entries} scans.
|
|
44
|
+
# @param cancellable [Pikuri::Agent::Control::Cancellable, nil] threaded
|
|
45
|
+
# into each handshake.
|
|
46
|
+
# @param spawn [Proc, nil] +entry+ → a started {ClientWrapper}. The seam a
|
|
47
|
+
# spec replaces to drive fake servers over pipes; production passes
|
|
48
|
+
# nothing.
|
|
49
|
+
def initialize(registry:, root:, cancellable: nil, spawn: nil)
|
|
50
|
+
@registry = registry
|
|
51
|
+
@root = root
|
|
52
|
+
@cancellable = cancellable
|
|
53
|
+
@spawn = spawn || lambda { |entry|
|
|
54
|
+
ClientWrapper.spawn(entry, root: @root, cancellable: @cancellable)
|
|
55
|
+
}
|
|
56
|
+
@clients = {}
|
|
57
|
+
@active = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The fan-out set: every project-relevant server, **each spawned here** if
|
|
61
|
+
# it was not running.
|
|
62
|
+
#
|
|
63
|
+
# Relevant rather than registered because a fan-out cannot choose between
|
|
64
|
+
# servers without starting them — {Navigator} gates on what each advertised
|
|
65
|
+
# in *this* workspace, and only a live handshake carries that.
|
|
66
|
+
#
|
|
67
|
+
# @return [Array<ClientWrapper>] in declaration order; empty when nothing is
|
|
68
|
+
# configured or nothing in the project matches.
|
|
69
|
+
# @raise [ClientWrapper::ServerDied] from the first server that cannot be
|
|
70
|
+
# started.
|
|
71
|
+
def active
|
|
72
|
+
active_entries.map { |entry| client(entry) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The registered entries this project has files for — the relevance question
|
|
76
|
+
# answered without spawning anything.
|
|
77
|
+
#
|
|
78
|
+
# Rescanned while the answer is *incomplete*, memoized once every
|
|
79
|
+
# registered entry is accounted for. A project gains its first +.java+
|
|
80
|
+
# file mid-session — the agent wrote it — and a scan memoized at the first
|
|
81
|
+
# call would leave jdtls out of every later fan-out, which the model reads
|
|
82
|
+
# as "no such symbol" rather than "nobody asked". A walk costs ~4 ms on
|
|
83
|
+
# this repo against a path that is about to block on a whole index, so
|
|
84
|
+
# paying it until the answer stops changing is free; once every entry is
|
|
85
|
+
# relevant nothing can invalidate it and the walk stops.
|
|
86
|
+
#
|
|
87
|
+
# The first entry claiming a basename wins, exactly as
|
|
88
|
+
# {Registry#entry_for} decides, so an entry shadowed by an earlier one is
|
|
89
|
+
# never activated by files it would not be asked about.
|
|
90
|
+
#
|
|
91
|
+
# @return [Array<Registry::StdioEntry>] in declaration order.
|
|
92
|
+
def active_entries
|
|
93
|
+
return @active if @active && @active.size == @registry.entries.size
|
|
94
|
+
|
|
95
|
+
@active = relevant_entries
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The server that answers for +path+, started if it was not running.
|
|
99
|
+
#
|
|
100
|
+
# Cheap: the path is its own evidence of relevance, so this route never
|
|
101
|
+
# walks the project the way {#active_entries} must.
|
|
102
|
+
#
|
|
103
|
+
# @param path [String, Pathname] matched on its basename by
|
|
104
|
+
# {Registry::StdioEntry#claims?}.
|
|
105
|
+
# @return [ClientWrapper, nil] +nil+ when no entry claims the file.
|
|
106
|
+
# @raise [ClientWrapper::ServerDied] when the claiming server cannot start.
|
|
107
|
+
def client_for(path)
|
|
108
|
+
entry = @registry.entry_for(path)
|
|
109
|
+
entry && client(entry)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Array<String>] ids of the servers actually running, in start
|
|
113
|
+
# order — nothing at all until a call needs one.
|
|
114
|
+
def started_ids
|
|
115
|
+
@clients.keys
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Boolean] whether there is nothing to ask — a *registry* question,
|
|
119
|
+
# not a liveness one: a configured server counts whether or not it has
|
|
120
|
+
# started, or this would answer "none" to the very first call.
|
|
121
|
+
def empty?
|
|
122
|
+
@registry.empty?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Shut every started server down. Idempotent, and never raises — it runs
|
|
126
|
+
# from an +on_close+ at exit, where one bad teardown must not take the rest
|
|
127
|
+
# of the sweep with it ({ClientWrapper#close} already swallows its own).
|
|
128
|
+
#
|
|
129
|
+
# @return [void]
|
|
130
|
+
def close
|
|
131
|
+
@clients.each_value(&:close)
|
|
132
|
+
@clients.clear
|
|
133
|
+
LOGGER.debug('closed')
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# @return [ClientWrapper]
|
|
140
|
+
def client(entry)
|
|
141
|
+
# A raising spawn stays unmemoized on purpose: installing the missing
|
|
142
|
+
# binary mid-session then recovers, and re-learning an ENOENT is free.
|
|
143
|
+
started = @clients[entry.id] ||= begin
|
|
144
|
+
LOGGER.info("#{entry.id}: starting on first use")
|
|
145
|
+
@spawn.call(entry)
|
|
146
|
+
end
|
|
147
|
+
note_relevant(entry)
|
|
148
|
+
started
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A started server is relevant by construction: the file that routed here
|
|
152
|
+
# is the evidence {#relevant_entries} goes walking for. That outranks the
|
|
153
|
+
# walk, which skips dot-directories and symlinks — without this, a server
|
|
154
|
+
# started for +.hidden/Foo.java+ would be rescanned for, never found, and
|
|
155
|
+
# left out of every fan-out.
|
|
156
|
+
#
|
|
157
|
+
# @param entry [Registry::StdioEntry]
|
|
158
|
+
# @return [void]
|
|
159
|
+
def note_relevant(entry)
|
|
160
|
+
return if @active.nil? || @active.include?(entry)
|
|
161
|
+
|
|
162
|
+
@active = @registry.entries.select { |e| e == entry || @active.include?(e) }
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# One pruned walk, stopping as soon as every entry is accounted for.
|
|
167
|
+
#
|
|
168
|
+
# Neither dot-directories nor symlinked ones are descended — +.git+ and a
|
|
169
|
+
# vendored bundle say nothing about what *this* project is written in, and
|
|
170
|
+
# not following a symlink is the cheapest cycle-free rule. So relevance is
|
|
171
|
+
# a heuristic, and it only ever narrows the fan-out: {#client_for} starts
|
|
172
|
+
# its server regardless.
|
|
173
|
+
#
|
|
174
|
+
# @return [Array<Registry::StdioEntry>]
|
|
175
|
+
def relevant_entries
|
|
176
|
+
return [] if @registry.empty?
|
|
177
|
+
|
|
178
|
+
pending = @registry.entries.dup
|
|
179
|
+
found = []
|
|
180
|
+
scan(@root.to_s, pending, found)
|
|
181
|
+
@registry.entries.select { |entry| found.include?(entry) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @return [void]
|
|
185
|
+
def scan(dir, pending, found)
|
|
186
|
+
Dir.children(dir).each do |name|
|
|
187
|
+
return if pending.empty?
|
|
188
|
+
|
|
189
|
+
path = File.join(dir, name)
|
|
190
|
+
if !File.symlink?(path) && File.directory?(path)
|
|
191
|
+
scan(path, pending, found) unless name.start_with?('.')
|
|
192
|
+
else
|
|
193
|
+
claimant = pending.find { |entry| entry.claims?(name) }
|
|
194
|
+
next if claimant.nil?
|
|
195
|
+
|
|
196
|
+
found << claimant
|
|
197
|
+
pending.delete(claimant)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
rescue SystemCallError => e
|
|
201
|
+
# A heuristic must not fail the call it serves, and an unreadable
|
|
202
|
+
# directory says nothing about relevance either way.
|
|
203
|
+
LOGGER.debug("relevance scan skipped #{dir}: #{e.message}")
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# Where the bytes behind a result come from, and what a result is *called*.
|
|
6
|
+
# One instance per tool call, so the memo and the fetch budget are per call.
|
|
7
|
+
#
|
|
8
|
+
# sources = Sources.new(filesystem: filesystem)
|
|
9
|
+
# sources.label_for(location.uri)
|
|
10
|
+
# # => "pikuri-workspace/lib/pikuri/workspace/filesystem.rb"
|
|
11
|
+
# # => "org.slf4j.LoggerFactory (slf4j-api-2.0.18.jar)"
|
|
12
|
+
# sources.snippet_for(location, client: client) # => "def resolve_for_read(path)"
|
|
13
|
+
#
|
|
14
|
+
# == Three tiers, keyed on the URI's scheme
|
|
15
|
+
#
|
|
16
|
+
# There is no standard "read this URI" request in the LSP that ships today and
|
|
17
|
+
# no way to discover a server's non-standard one, so this is policy, not a
|
|
18
|
+
# lookup:
|
|
19
|
+
#
|
|
20
|
+
# 1. **+file:+ — the only scheme pikuri reads itself.** In-root results carry
|
|
21
|
+
# the workspace-relative path, and the read goes through the +Workspace+
|
|
22
|
+
# seam. Out-of-root results (the 45 gem hits one +goToDefinition+ on a
|
|
23
|
+
# constant returned) always carry the line of code — "what does this library
|
|
24
|
+
# method do" is a top reason to want LSP at all — and carry a **path only
|
|
25
|
+
# when this workspace would read that file**, which is a per-wiring answer:
|
|
26
|
+
# +AllowAll+ (the +--yolo+ and OS-agent wirings) opens a gem checkout
|
|
27
|
+
# happily, a root-confined one refuses it, and quoting a path the +read+
|
|
28
|
+
# tool will reject is a dead-end citation. Every +file:+ URI takes a
|
|
29
|
+
# {#denied?} pass first — a result is a *walked* path, so nothing else stops
|
|
30
|
+
# an index hit under +~/.ssh+ from being quoted.
|
|
31
|
+
# 2. **A server-private scheme — standard first, one hardcoded row behind
|
|
32
|
+
# it.** LSP 3.18's +workspace/textDocumentContent+ is real discovery (the
|
|
33
|
+
# server lists the +schemes+ it will read) and wins where advertised.
|
|
34
|
+
# Nothing implements it yet, and a standard-only stance made the Java arm
|
|
35
|
+
# untestable, so {SCHEME_REQUESTS} carries exactly one row with a written
|
|
36
|
+
# deletion condition: **delete the +jdt:+ row the day jdtls advertises
|
|
37
|
+
# +textDocumentContent+**, and the standard path above it takes over with no
|
|
38
|
+
# other edit. The bar for adding a row is a server someone wired and can
|
|
39
|
+
# test.
|
|
40
|
+
# 3. **+http(s):+ and any scheme with no row — printed, never dereferenced.**
|
|
41
|
+
# The load-bearing refusal: fetching a URI the *server* chose is egress with
|
|
42
|
+
# the payload in the URL, and no network row may ever enter the table. A
|
|
43
|
+
# scheme with no row still gets the inline reason, never a bare count.
|
|
44
|
+
#
|
|
45
|
+
# One reply of attached Java source measured 22,639 bytes in 10 ms — cheap to
|
|
46
|
+
# fetch, ruinous to inline — so content is sliced to the line the result points
|
|
47
|
+
# at, memoized per URI (a 45-hit answer repeats URIs) and capped at
|
|
48
|
+
# {MAX_FETCHES} documents per call.
|
|
49
|
+
class Sources
|
|
50
|
+
# Documents one tool call may fetch over the wire. A cap rather than a
|
|
51
|
+
# budget because the failure it prevents is one call inlining a jar.
|
|
52
|
+
MAX_FETCHES = 5
|
|
53
|
+
|
|
54
|
+
# Characters of a source line a snippet keeps. A row is one line — not the
|
|
55
|
+
# result's whole +range+, which for a reopened namespace is the whole file.
|
|
56
|
+
MAX_SNIPPET_CHARS = 160
|
|
57
|
+
|
|
58
|
+
# LSP 3.18's client→server content request: +{uri}+ → +{text}+.
|
|
59
|
+
STANDARD_REQUEST = 'workspace/textDocumentContent'
|
|
60
|
+
|
|
61
|
+
# The scheme→request table. One row, and the deletion condition is in the
|
|
62
|
+
# class header. +java/classFileContents+ answers with a **bare String**
|
|
63
|
+
# where the standard request answers +{text:}+; {#fetch_from_server} accepts
|
|
64
|
+
# either, which is the whole reason both paths can share a call site.
|
|
65
|
+
SCHEME_REQUESTS = { 'jdt' => 'java/classFileContents' }.freeze
|
|
66
|
+
|
|
67
|
+
# @param filesystem [Pikuri::Workspace::Filesystem] the root confinement,
|
|
68
|
+
# the +denied?+ pass, and the seam every in-root read routes through.
|
|
69
|
+
def initialize(filesystem:)
|
|
70
|
+
@filesystem = filesystem
|
|
71
|
+
@root = filesystem.project_root.to_s
|
|
72
|
+
@memo = {}
|
|
73
|
+
@readable = {}
|
|
74
|
+
@fetches = 0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# What to call this URI in a rendered row.
|
|
78
|
+
#
|
|
79
|
+
# @param uri [String] as the server sent it.
|
|
80
|
+
# @return [String] a workspace-relative path in-root; the absolute path for a
|
|
81
|
+
# file outside the root this workspace would still read, or a bare basename
|
|
82
|
+
# for one it would refuse; +"org.slf4j.LoggerFactory
|
|
83
|
+
# (slf4j-api-2.0.18.jar)"+ for a +jdt:+ handle; else the URI itself,
|
|
84
|
+
# truncated — a real +jdt:+ URI runs to ~300 characters of encoded
|
|
85
|
+
# classpath and must never be shown raw.
|
|
86
|
+
def label_for(uri)
|
|
87
|
+
path = Uris.to_path(uri)
|
|
88
|
+
return relative(path) if path && in_root?(path)
|
|
89
|
+
return out_of_root_label(path) if path
|
|
90
|
+
|
|
91
|
+
scheme = Uris.scheme(uri)
|
|
92
|
+
return jdt_label(uri) if scheme == 'jdt'
|
|
93
|
+
|
|
94
|
+
truncate(uri, 200)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @param uri [String]
|
|
98
|
+
# @return [Boolean] whether this names a file under the workspace root —
|
|
99
|
+
# which is what decides whether a row carries a path the model can act
|
|
100
|
+
# on.
|
|
101
|
+
def in_root_file?(uri)
|
|
102
|
+
path = Uris.to_path(uri)
|
|
103
|
+
!path.nil? && in_root?(path)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether this workspace would actually open the document behind a +file:+
|
|
107
|
+
# URI — what decides whether a citation may carry a *link*, since a link to
|
|
108
|
+
# a path the +read+ tool refuses is the dead-end citation this class exists
|
|
109
|
+
# to avoid. Includes the {#denied?} pass, so it is safe to ask on its own.
|
|
110
|
+
#
|
|
111
|
+
# @param uri [String]
|
|
112
|
+
# @return [Boolean] +false+ for any non-+file:+ URI: a jar entry is not a
|
|
113
|
+
# path, so nothing here can open one.
|
|
114
|
+
def actionable?(uri)
|
|
115
|
+
path = Uris.to_path(uri)
|
|
116
|
+
!path.nil? && !@filesystem.denied?(path) && readable?(path)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Whether this URI must be dropped rather than rendered.
|
|
120
|
+
#
|
|
121
|
+
# A result is a path the *server* walked to, so it reaches no
|
|
122
|
+
# +resolve_for_read+ and this check is the only thing standing between an
|
|
123
|
+
# index hit under a credential root and the model's context. It fails
|
|
124
|
+
# closed, never raises, and is exact — the file tools run as the real user.
|
|
125
|
+
#
|
|
126
|
+
# @param uri [String]
|
|
127
|
+
# @return [Boolean] +false+ for a non-+file:+ URI, which cannot be
|
|
128
|
+
# denylist-checked at all: a jar's contents are not a path.
|
|
129
|
+
def denied?(uri)
|
|
130
|
+
path = Uris.to_path(uri)
|
|
131
|
+
!path.nil? && @filesystem.denied?(path)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# One line of code for a result, or the reason there is none.
|
|
135
|
+
#
|
|
136
|
+
# @param location [Location] what the server pointed at.
|
|
137
|
+
# @param client [ClientWrapper, nil] the server that issued the URI — the
|
|
138
|
+
# only one that can read its private schemes.
|
|
139
|
+
# @return [String] the source line, stripped and truncated, or a bracketed
|
|
140
|
+
# reason. Never +nil+: a row with no snippet still has to say why.
|
|
141
|
+
def snippet_for(location, client: nil)
|
|
142
|
+
lines, reason = lines_for(location.uri, client: client)
|
|
143
|
+
return "[#{reason}]" if lines.nil?
|
|
144
|
+
|
|
145
|
+
line = lines[location.anchor.start.line - 1]
|
|
146
|
+
return '[the server pointed past the end of this document]' if line.nil?
|
|
147
|
+
|
|
148
|
+
truncate(line.strip, MAX_SNIPPET_CHARS)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The document behind a URI, memoized for this call.
|
|
152
|
+
#
|
|
153
|
+
# @param uri [String]
|
|
154
|
+
# @param client [ClientWrapper, nil]
|
|
155
|
+
# @return [Array(Array<String>, nil), Array(nil, String)] its lines, or
|
|
156
|
+
# +nil+ and the reason.
|
|
157
|
+
def lines_for(uri, client: nil)
|
|
158
|
+
@memo[uri] ||= fetch(uri, client)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def fetch(uri, client)
|
|
164
|
+
path = Uris.to_path(uri)
|
|
165
|
+
return [nil, 'this path is on the workspace denylist'] if path && @filesystem.denied?(path)
|
|
166
|
+
return read_file(path) if path
|
|
167
|
+
|
|
168
|
+
scheme = Uris.scheme(uri)
|
|
169
|
+
if %w[http https].include?(scheme)
|
|
170
|
+
# The refusal, and it is the whole no-network claim: the URI was chosen
|
|
171
|
+
# by the server, so fetching it would be egress with the payload in the
|
|
172
|
+
# URL.
|
|
173
|
+
return [nil, 'pikuri-lsp never fetches a remote URI; this one is printed as text only']
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
fetch_from_server(uri, scheme, client)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# In-root reads route through the seam; an out-of-root result is read
|
|
180
|
+
# directly, because +resolve_for_read+ exists to refuse exactly that path
|
|
181
|
+
# and the result is a citation rather than a file the model asked for.
|
|
182
|
+
def read_file(path)
|
|
183
|
+
text = in_root?(path) ? @filesystem.resolve_for_read(path).read : File.read(path)
|
|
184
|
+
[text.lines, nil]
|
|
185
|
+
rescue Pikuri::Workspace::Filesystem::Error, SystemCallError, IOError => e
|
|
186
|
+
[nil, "could not be read (#{e.class}: #{e.message})"]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def fetch_from_server(uri, scheme, client)
|
|
190
|
+
return [nil, "only the server that issued this #{scheme}: URI can read it"] if client.nil?
|
|
191
|
+
|
|
192
|
+
request = content_request(client, scheme)
|
|
193
|
+
if request.nil?
|
|
194
|
+
return [nil, "this server's #{scheme}: URIs need LSP 3.18 content support, " \
|
|
195
|
+
'which it does not advertise']
|
|
196
|
+
end
|
|
197
|
+
if @fetches >= MAX_FETCHES
|
|
198
|
+
return [nil, "not fetched: one call reads at most #{MAX_FETCHES} documents from a server"]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
@fetches += 1
|
|
202
|
+
parse_content(client.request(request, { uri: uri }), scheme)
|
|
203
|
+
rescue Connection::ServerError, ClientWrapper::ServerDied => e
|
|
204
|
+
# Try-then-degrade: a wrongly-matched scheme, or a server that refuses
|
|
205
|
+
# the request it advertised, costs one round trip and an honest row.
|
|
206
|
+
[nil, "the server would not read this #{scheme}: URI (#{e.message})"]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# 3.18 answers +{text:}+; +java/classFileContents+ answers a bare String.
|
|
210
|
+
def parse_content(reply, scheme)
|
|
211
|
+
text = reply.is_a?(Hash) ? reply['text'] : reply
|
|
212
|
+
return [nil, "the server answered no text for this #{scheme}: URI"] unless text.is_a?(String)
|
|
213
|
+
|
|
214
|
+
[text.lines, nil]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Standard first: the server lists the schemes it will read, which is real
|
|
218
|
+
# discovery. Only if it advertises nothing does the one-row table apply —
|
|
219
|
+
# and keying on the scheme is exact rather than a guess, because only the
|
|
220
|
+
# server that issued a +jdt:+ URI can read one.
|
|
221
|
+
def content_request(client, scheme)
|
|
222
|
+
declared = client.capabilities.dig('workspace', 'textDocumentContent')
|
|
223
|
+
schemes = declared.is_a?(Hash) ? declared['schemes'] : nil
|
|
224
|
+
return STANDARD_REQUEST if declared && (schemes.nil? || schemes.include?(scheme))
|
|
225
|
+
|
|
226
|
+
SCHEME_REQUESTS[scheme]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def in_root?(path)
|
|
230
|
+
path == @root || path.start_with?("#{@root}/")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Outside the root the label is the *actionable* form: the path where this
|
|
234
|
+
# workspace would open the file, its basename where it would not. Suppressing
|
|
235
|
+
# a readable path would delete a capability the wiring granted; printing an
|
|
236
|
+
# unreadable one invites a refused read.
|
|
237
|
+
def out_of_root_label(path)
|
|
238
|
+
readable?(path) ? path : "outside the workspace: #{File.basename(path)}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def readable?(path)
|
|
242
|
+
@readable.fetch(path) do
|
|
243
|
+
@readable[path] = begin
|
|
244
|
+
@filesystem.resolve_for_read(path)
|
|
245
|
+
true
|
|
246
|
+
rescue Pikuri::Workspace::Filesystem::Error
|
|
247
|
+
false
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def relative(path)
|
|
253
|
+
path == @root ? File.basename(path) : path[(@root.length + 1)..]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# +jdt://contents/<jar>/<package>/<Type>.class?=<the whole classpath entry>+
|
|
257
|
+
# → +"<package>.<Type> (<jar>)"+, which is the only readable thing in it.
|
|
258
|
+
def jdt_label(uri)
|
|
259
|
+
segments = uri.split('?').first.to_s.split('/')
|
|
260
|
+
index = segments.index('contents')
|
|
261
|
+
return truncate(uri, 120) if index.nil? || segments.length < index + 4
|
|
262
|
+
|
|
263
|
+
jar, package, file = segments[(index + 1)..(index + 3)]
|
|
264
|
+
"#{package}.#{File.basename(file, '.*')} (#{jar})"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def truncate(text, limit)
|
|
268
|
+
text.length > limit ? "#{text[0, limit - 4]} ..." : text
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# Comparing a name the model typed against a name a server's index reports,
|
|
6
|
+
# which are not the same string even when they mean the same thing:
|
|
7
|
+
#
|
|
8
|
+
# SymbolName.last_segment('Pikuri::Workspace::Read') # => "Read"
|
|
9
|
+
# SymbolName.last_segment('Read#resolve_for_read') # => "resolve_for_read"
|
|
10
|
+
# SymbolName.last_segment('greet(String)') # => "greet"
|
|
11
|
+
# SymbolName.matches?('Pikuri::Tool', 'Tool') # => true
|
|
12
|
+
#
|
|
13
|
+
# The rule is *compare the last segment*, and it is a measured requirement
|
|
14
|
+
# rather than tidiness: ruby-lsp deliberately reports the **fully qualified**
|
|
15
|
+
# name in +workspace/symbol+ (its source says why — so that searching for
|
|
16
|
+
# +Foo::Bar+ works), servers separate a method from its owner with +#+ or +.+
|
|
17
|
+
# depending on the language, and jdtls appends a Java signature. An exact-name
|
|
18
|
+
# filter comparing +name+ verbatim therefore rejects every correct hit, which
|
|
19
|
+
# is the silent-empty failure this gem exists to avoid producing itself.
|
|
20
|
+
#
|
|
21
|
+
# It is deliberately not a fuzzy match. The server has already been fuzzy —
|
|
22
|
+
# one three-letter query returned 11 hits matched on scattered letters — and
|
|
23
|
+
# what this filter is for is picking the hits that really are the name asked
|
|
24
|
+
# for.
|
|
25
|
+
module SymbolName
|
|
26
|
+
# Separators a server may put between a container and its member.
|
|
27
|
+
SEPARATORS = /::|#|\.|\$/
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# @param name [String, nil] a name as a server reported it.
|
|
32
|
+
# @return [String] the member half, with any signature dropped. +""+ for
|
|
33
|
+
# +nil+, so a nameless row simply matches nothing.
|
|
34
|
+
def last_segment(name)
|
|
35
|
+
name.to_s.split('(').first.to_s.split(SEPARATORS).last.to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param name [String, nil] the indexed name.
|
|
39
|
+
# @param query [String] what the model asked for, which may itself be
|
|
40
|
+
# qualified (+"Pikuri::Workspace::Read"+).
|
|
41
|
+
# @return [Boolean] whether they name the same member.
|
|
42
|
+
def matches?(name, query)
|
|
43
|
+
segment = last_segment(name)
|
|
44
|
+
!segment.empty? && segment == last_segment(query)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|