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,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# Pure configuration: which language servers exist, how to launch each, and
|
|
6
|
+
# which files each one claims. No I/O and no live state — a host writes this
|
|
7
|
+
# once and {ClientWrapper} spawns from it.
|
|
8
|
+
#
|
|
9
|
+
# registry = Registry.new(entries: [
|
|
10
|
+
# Registry::StdioEntry.new(id: 'ruby', command: %w[ruby-lsp],
|
|
11
|
+
# files: %w[*.rb *.rake *.gemspec Rakefile Gemfile]),
|
|
12
|
+
# Registry::StdioEntry.new(id: 'java', command: ['jdtls', '-data', data_dir],
|
|
13
|
+
# files: %w[*.java],
|
|
14
|
+
# env: { 'JAVA_HOME' => ENV.fetch('JAVA_HOME') },
|
|
15
|
+
# init_options: { extendedClientCapabilities: {
|
|
16
|
+
# classFileContentsSupport: true } })
|
|
17
|
+
# ])
|
|
18
|
+
# registry.entry_for('lib/pikuri/agent.rb').id # => "ruby"
|
|
19
|
+
# registry.entry_for('Gemfile').id # => "ruby"
|
|
20
|
+
# registry.entry_for('README.md') # => nil
|
|
21
|
+
#
|
|
22
|
+
# A Ruby server is two facts, +command+ and +files+; the Java one exercises
|
|
23
|
+
# every remaining field.
|
|
24
|
+
class Registry
|
|
25
|
+
# Extension → LSP language identifier, for entries that don't spell out a
|
|
26
|
+
# +language_id:+. Only the +didOpen+ notification uses it, and a server
|
|
27
|
+
# given the wrong one indexes nothing, so an unmapped extension is a
|
|
28
|
+
# config error rather than a guess.
|
|
29
|
+
LANGUAGE_IDS = {
|
|
30
|
+
'.rb' => 'ruby', '.rake' => 'ruby', '.gemspec' => 'ruby',
|
|
31
|
+
'.java' => 'java', '.kt' => 'kotlin', '.kts' => 'kotlin',
|
|
32
|
+
'.scala' => 'scala', '.groovy' => 'groovy', '.clj' => 'clojure',
|
|
33
|
+
'.py' => 'python', '.js' => 'javascript', '.jsx' => 'javascriptreact',
|
|
34
|
+
'.ts' => 'typescript', '.tsx' => 'typescriptreact',
|
|
35
|
+
'.go' => 'go', '.rs' => 'rust', '.c' => 'c', '.h' => 'c',
|
|
36
|
+
'.cpp' => 'cpp', '.cc' => 'cpp', '.hpp' => 'cpp', '.cs' => 'csharp',
|
|
37
|
+
'.php' => 'php', '.swift' => 'swift', '.dart' => 'dart',
|
|
38
|
+
'.ex' => 'elixir', '.exs' => 'elixir', '.erl' => 'erlang',
|
|
39
|
+
'.hs' => 'haskell', '.lua' => 'lua', '.pl' => 'perl', '.r' => 'r',
|
|
40
|
+
'.jl' => 'julia', '.sh' => 'shellscript', '.sql' => 'sql',
|
|
41
|
+
'.html' => 'html', '.css' => 'css', '.scss' => 'scss',
|
|
42
|
+
'.json' => 'json', '.yaml' => 'yaml', '.yml' => 'yaml',
|
|
43
|
+
'.xml' => 'xml', '.md' => 'markdown', '.tex' => 'latex'
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# One language server: the argv to launch it and the files it answers for.
|
|
47
|
+
#
|
|
48
|
+
# +files+ holds *basename* globs, not extensions, because +Rakefile+ has
|
|
49
|
+
# none — and it is the dispatch key, so getting it wrong sends Ruby
|
|
50
|
+
# questions to a Java server rather than merely failing.
|
|
51
|
+
#
|
|
52
|
+
# @!attribute [r] id
|
|
53
|
+
# @return [String] this server's name in log lines, restart messages and
|
|
54
|
+
# {ServerProgress#server_id}, e.g. +"ruby"+. Never model-facing: the
|
|
55
|
+
# model names no server, it names a file.
|
|
56
|
+
# @!attribute [r] command
|
|
57
|
+
# @return [Array<String>] argv, e.g. +["jdtls", "-data", "/…/cache"]+.
|
|
58
|
+
# Spawned with cwd = the workspace root, always.
|
|
59
|
+
# @!attribute [r] files
|
|
60
|
+
# @return [Array<String>] basename globs, e.g.
|
|
61
|
+
# +["*.rb", "*.rake", "Rakefile"]+. First entry whose glob matches
|
|
62
|
+
# wins; overlaps are not detected.
|
|
63
|
+
# @!attribute [r] language_id
|
|
64
|
+
# @return [String] the +languageId+ every +didOpen+ carries, e.g.
|
|
65
|
+
# +"ruby"+. Derived at construction from the first extension-bearing
|
|
66
|
+
# glob when not given — per *entry*, not per file, which is wrong for
|
|
67
|
+
# the +eslint+-shaped server that claims eight extensions and right for
|
|
68
|
+
# everything pikuri has met.
|
|
69
|
+
# @!attribute [r] env
|
|
70
|
+
# @return [Hash{String => String}] extra environment for the child,
|
|
71
|
+
# merged over the de-bundlerized environment ({Pikuri::BundlerEnv}), e.g.
|
|
72
|
+
# +{"JAVA_HOME" => "/usr/lib/jvm/java-21"}+. Frozen, defaults to +{}+.
|
|
73
|
+
# The legitimate per-child credential channel of the "environment is
|
|
74
|
+
# not a secret store" seam.
|
|
75
|
+
# @!attribute [r] init_options
|
|
76
|
+
# @return [Hash, nil] +initializationOptions+, passed verbatim at
|
|
77
|
+
# handshake time and never parsed by pikuri — the home for outbound
|
|
78
|
+
# per-server glue, e.g. jdtls's +classFileContentsSupport+, without
|
|
79
|
+
# which every library symbol answers with silence. There is
|
|
80
|
+
# deliberately no sibling +settings:+ field for answering the
|
|
81
|
+
# server's +workspace/configuration+ requests: {Connection} replies
|
|
82
|
+
# +null+ to every server-initiated request, and both reference
|
|
83
|
+
# servers tolerate that.
|
|
84
|
+
StdioEntry = Data.define(:id, :command, :files, :language_id, :env, :init_options) do
|
|
85
|
+
# @raise [ArgumentError] on an empty +id+ / +command+ / +files+, or when
|
|
86
|
+
# +language_id:+ is omitted and no glob in +files+ carries an
|
|
87
|
+
# extension {LANGUAGE_IDS} knows.
|
|
88
|
+
def initialize(id:, command:, files:, language_id: nil, env: {}, init_options: nil)
|
|
89
|
+
raise ArgumentError, "id must be a non-empty String, got #{id.inspect}" if id.to_s.empty?
|
|
90
|
+
raise ArgumentError, "command must be a non-empty argv Array, got #{command.inspect}" if command.to_a.empty?
|
|
91
|
+
raise ArgumentError, "files must be a non-empty glob Array, got #{files.inspect}" if files.to_a.empty?
|
|
92
|
+
|
|
93
|
+
super(id: id, command: command.freeze, files: files.freeze,
|
|
94
|
+
language_id: language_id || Registry.derive_language_id(files, id),
|
|
95
|
+
env: env.freeze, init_options: init_options)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Whether this server answers for +path+ — matched on the basename, so a
|
|
99
|
+
# directory component never decides which server sees a file.
|
|
100
|
+
#
|
|
101
|
+
# @param path [String, Pathname] absolute or relative; only the basename
|
|
102
|
+
# is read.
|
|
103
|
+
# @return [Boolean]
|
|
104
|
+
def claims?(path)
|
|
105
|
+
basename = File.basename(path.to_s)
|
|
106
|
+
files.any? { |glob| File.fnmatch?(glob, basename, File::FNM_DOTMATCH) }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The fields a {StdioEntry} accepts from a config file — everything but
|
|
111
|
+
# +id+, which is the key it is written under.
|
|
112
|
+
ENTRY_FIELDS = %i[command files language_id env init_options].freeze
|
|
113
|
+
|
|
114
|
+
# Builds a registry out of a plain Hash, so a host can map its own config
|
|
115
|
+
# file onto one — which is where this belongs, because *which language
|
|
116
|
+
# servers you have installed* is machine state, like an API key. The demo
|
|
117
|
+
# scripts read the YAML they already keep keys in:
|
|
118
|
+
#
|
|
119
|
+
# # ~/.pikuri-examples-config.yaml
|
|
120
|
+
# lsp_servers:
|
|
121
|
+
# ruby:
|
|
122
|
+
# command: [ruby-lsp]
|
|
123
|
+
# files: ["*.rb", "*.rake", "*.gemspec", Rakefile, Gemfile]
|
|
124
|
+
# java:
|
|
125
|
+
# command: [jdtls, -data, /home/me/.cache/pikuri/jdtls/my-app]
|
|
126
|
+
# files: ["*.java"]
|
|
127
|
+
# env:
|
|
128
|
+
# JAVA_HOME: /usr/lib/jvm/java-21
|
|
129
|
+
# init_options:
|
|
130
|
+
# extendedClientCapabilities:
|
|
131
|
+
# classFileContentsSupport: true
|
|
132
|
+
#
|
|
133
|
+
# Registry.from_h(YAML.safe_load_file(path)['lsp_servers'])
|
|
134
|
+
#
|
|
135
|
+
# Nothing here knows what YAML is; a host reading TOML, JSON or its own
|
|
136
|
+
# settings object uses the same method. Keyed by server id rather than
|
|
137
|
+
# holding a list of +{id: …}+ Hashes, which makes the duplicate
|
|
138
|
+
# {#initialize} refuses unwritable; insertion order is dispatch order.
|
|
139
|
+
#
|
|
140
|
+
# Two things a config file cannot do, both visible above. It cannot
|
|
141
|
+
# *compute*, so jdtls's +-data+ path is spelled out rather than derived
|
|
142
|
+
# from {Pikuri::Paths.cache} — a host that wants it computed builds the
|
|
143
|
+
# {StdioEntry} in Ruby, which is equally first-class. And it cannot be
|
|
144
|
+
# type-checked by the person writing it, so every field is validated here
|
|
145
|
+
# rather than coerced: a mistyped +env+ is otherwise a jdtls that
|
|
146
|
+
# hard-fails below Java 21, and a mistyped +init_options+ is every Java
|
|
147
|
+
# library symbol answering with silence.
|
|
148
|
+
#
|
|
149
|
+
# @param config [Hash{String, Symbol => Hash}, nil] server id → that
|
|
150
|
+
# server's {StdioEntry} fields, with String or Symbol keys. +nil+ — what
|
|
151
|
+
# a host reading an absent config key gets — yields an empty registry,
|
|
152
|
+
# so no caller needs a +|| {}+.
|
|
153
|
+
# @return [Registry]
|
|
154
|
+
# @raise [ArgumentError] on an unknown field, a field of the wrong type,
|
|
155
|
+
# or anything {StdioEntry} itself refuses (an unguessable
|
|
156
|
+
# +language_id+, an empty +id+).
|
|
157
|
+
def self.from_h(config)
|
|
158
|
+
new(entries: (config || {}).map { |id, fields| entry_from_h(id.to_s, fields) })
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# One +id => fields+ pair as a {StdioEntry}.
|
|
162
|
+
#
|
|
163
|
+
# @param id [String] the key it was written under.
|
|
164
|
+
# @param fields [Hash] its config fields.
|
|
165
|
+
# @return [StdioEntry]
|
|
166
|
+
# @raise [ArgumentError] as {from_h}.
|
|
167
|
+
def self.entry_from_h(id, fields)
|
|
168
|
+
unless fields.is_a?(Hash)
|
|
169
|
+
raise ArgumentError, "lsp server #{id.inspect}: expected a Hash of fields, got #{fields.inspect}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
fields = fields.transform_keys(&:to_sym)
|
|
173
|
+
unknown = fields.keys - ENTRY_FIELDS
|
|
174
|
+
unless unknown.empty?
|
|
175
|
+
raise ArgumentError, "lsp server #{id.inspect}: unknown field(s) #{unknown.join(', ')} — " \
|
|
176
|
+
"known fields are #{ENTRY_FIELDS.join(', ')}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
StdioEntry.new(
|
|
180
|
+
id: id,
|
|
181
|
+
command: string_list(id, :command, fields[:command]),
|
|
182
|
+
files: string_list(id, :files, fields[:files]),
|
|
183
|
+
language_id: typed(id, :language_id, fields[:language_id], String),
|
|
184
|
+
env: string_hash(id, fields[:env]),
|
|
185
|
+
init_options: typed(id, :init_options, fields[:init_options], Hash)
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @param id [String] the server id, for the message.
|
|
190
|
+
# @param field [Symbol] the field name, for the message.
|
|
191
|
+
# @param value [Object] the configured value.
|
|
192
|
+
# @return [Array<String>]
|
|
193
|
+
# @raise [ArgumentError] unless +value+ is a non-empty Array of Strings.
|
|
194
|
+
def self.string_list(id, field, value)
|
|
195
|
+
return value if value.is_a?(Array) && !value.empty? && value.all?(String)
|
|
196
|
+
|
|
197
|
+
raise ArgumentError,
|
|
198
|
+
"lsp server #{id.inspect}: #{field} must be a non-empty list of strings, got #{value.inspect}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @param id [String] the server id, for the message.
|
|
202
|
+
# @param value [Object] the configured +env+, or +nil+.
|
|
203
|
+
# @return [Hash{String => String}] empty when +nil+.
|
|
204
|
+
# @raise [ArgumentError] unless every key and value is a String; a child
|
|
205
|
+
# environment holds strings, and an unquoted +PORT: 8080+ is the way in.
|
|
206
|
+
def self.string_hash(id, value)
|
|
207
|
+
return {} if value.nil?
|
|
208
|
+
|
|
209
|
+
unless value.is_a?(Hash)
|
|
210
|
+
raise ArgumentError, "lsp server #{id.inspect}: env must be a mapping, got #{value.inspect}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
value.to_h do |name, setting|
|
|
214
|
+
unless setting.is_a?(String)
|
|
215
|
+
raise ArgumentError,
|
|
216
|
+
"lsp server #{id.inspect}: env #{name} must be a String, got #{setting.inspect}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
[name.to_s, setting]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @param id [String] the server id, for the message.
|
|
224
|
+
# @param field [Symbol] the field name, for the message.
|
|
225
|
+
# @param value [Object] the configured value.
|
|
226
|
+
# @param type [Class] what it must be when given.
|
|
227
|
+
# @return [Object, nil] +value+, unchanged.
|
|
228
|
+
# @raise [ArgumentError] unless +value+ is +nil+ or a +type+.
|
|
229
|
+
def self.typed(id, field, value, type)
|
|
230
|
+
return value if value.nil? || value.is_a?(type)
|
|
231
|
+
|
|
232
|
+
raise ArgumentError, "lsp server #{id.inspect}: #{field} must be a #{type}, got #{value.inspect}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private_class_method :entry_from_h, :string_list, :string_hash, :typed
|
|
236
|
+
|
|
237
|
+
# LSP language id for a +files:+ list.
|
|
238
|
+
#
|
|
239
|
+
# @param files [Array<String>] basename globs.
|
|
240
|
+
# @param id [String] the entry's id, for the error message.
|
|
241
|
+
# @return [String]
|
|
242
|
+
# @raise [ArgumentError] when no glob carries a known extension.
|
|
243
|
+
def self.derive_language_id(files, id)
|
|
244
|
+
files.each do |glob|
|
|
245
|
+
extension = glob[/\.[A-Za-z0-9]+\z/]&.downcase
|
|
246
|
+
language = extension && LANGUAGE_IDS[extension]
|
|
247
|
+
return language if language
|
|
248
|
+
end
|
|
249
|
+
raise ArgumentError,
|
|
250
|
+
"cannot derive language_id for #{id.inspect} from files: #{files.inspect} — " \
|
|
251
|
+
'pass language_id: explicitly'
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# @return [Array<StdioEntry>] the entries, in declaration order.
|
|
255
|
+
attr_reader :entries
|
|
256
|
+
|
|
257
|
+
# @param entries [Array<StdioEntry>] zero or more servers. Order is the
|
|
258
|
+
# dispatch order.
|
|
259
|
+
# @raise [ArgumentError] if two entries share an id — one would shadow the
|
|
260
|
+
# other's log lines and progress bars.
|
|
261
|
+
def initialize(entries: [])
|
|
262
|
+
duplicate = entries.map(&:id).tally.find { |_, count| count > 1 }
|
|
263
|
+
raise ArgumentError, "duplicate server id #{duplicate.first.inspect}" if duplicate
|
|
264
|
+
|
|
265
|
+
@entries = entries.freeze
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The server that answers for +path+, or +nil+ when none claims it.
|
|
269
|
+
#
|
|
270
|
+
# @param path [String, Pathname]
|
|
271
|
+
# @return [StdioEntry, nil]
|
|
272
|
+
def entry_for(path)
|
|
273
|
+
@entries.find { |entry| entry.claims?(path) }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# @return [Boolean] whether no server is configured, in which case a host
|
|
277
|
+
# wires no +lsp+ tool at all.
|
|
278
|
+
def empty?
|
|
279
|
+
@entries.empty?
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The no-servers default, so a host that configures nothing passes nothing.
|
|
283
|
+
EMPTY = new
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|