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