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