klenod-lsp 0.0.15

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,568 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "json"
5
+ require "language_server-protocol"
6
+ require "logger"
7
+
8
+ require_relative "documents"
9
+ require_relative "graph_index"
10
+ require_relative "languages"
11
+ require_relative "renames"
12
+ require_relative "symbols"
13
+ require_relative "text"
14
+ require_relative "version"
15
+ require_relative "workspace"
16
+
17
+ module Klenod
18
+ module LSP
19
+ # A single-threaded Language Server Protocol server over stdio.
20
+ #
21
+ # Frameworks start it with their own build context, collected in
22
+ # analysis mode so plugins skip asset work:
23
+ #
24
+ # context = config.context(mode: :development, analysis: true)
25
+ # Klenod::LSP::Server.new(context: context, entrypoints: config.entrypoints).start
26
+ #
27
+ # Open documents are transformed with the context's plugins and their
28
+ # build errors published as diagnostics. The same context also holds a
29
+ # collected module graph for cross-file features, filled in the
30
+ # background. Nothing is ever evaluated.
31
+ class Server
32
+ Protocol = LanguageServer::Protocol
33
+ Interface = Protocol::Interface
34
+ Constant = Protocol::Constant
35
+
36
+ DIAGNOSTICS_DEBOUNCE = 0.1
37
+ COLLECTION_DEBOUNCE = 0.25
38
+
39
+ HANDLERS = {
40
+ "initialize" => :handle_initialize,
41
+ "initialized" => :handle_initialized,
42
+ "shutdown" => :handle_shutdown,
43
+ "exit" => :handle_exit,
44
+ "textDocument/didOpen" => :handle_did_open,
45
+ "textDocument/didChange" => :handle_did_change,
46
+ "textDocument/didSave" => :handle_did_save,
47
+ "textDocument/didClose" => :handle_did_close,
48
+ "textDocument/definition" => :handle_definition,
49
+ "textDocument/hover" => :handle_hover,
50
+ "textDocument/completion" => :handle_completion,
51
+ "textDocument/documentLink" => :handle_document_link,
52
+ "textDocument/codeAction" => :handle_code_action,
53
+ "textDocument/references" => :handle_references,
54
+ "workspace/willRenameFiles" => :handle_will_rename_files,
55
+ "textDocument/documentSymbol" => :handle_document_symbol,
56
+ "workspace/symbol" => :handle_workspace_symbol,
57
+ "textDocument/codeLens" => :handle_code_lens,
58
+ "textDocument/prepareRename" => :handle_prepare_rename,
59
+ "textDocument/rename" => :handle_rename,
60
+ "workspace/didChangeConfiguration" => :handle_noop,
61
+ "workspace/didChangeWatchedFiles" => :handle_did_change_watched_files,
62
+ "$/cancelRequest" => :handle_noop,
63
+ "$/setTrace" => :handle_noop
64
+ }.freeze
65
+
66
+ # Reports background collection through window/workDoneProgress when
67
+ # the client supports it, and stays silent otherwise.
68
+ class WorkDoneProgress
69
+ TOKEN = "klenod-lsp.graph-index"
70
+
71
+ def initialize(server, enabled:)
72
+ @server = server
73
+ @enabled = enabled
74
+ end
75
+
76
+ def begin(total)
77
+ return unless @enabled
78
+
79
+ @server.request("window/workDoneProgress/create", Interface::WorkDoneProgressCreateParams.new(token: TOKEN))
80
+ notify(Interface::WorkDoneProgressBegin.new(kind: "begin", title: "Klenod: indexing modules", message: "0 of #{total}", percentage: 0))
81
+ end
82
+
83
+ def report(done, total)
84
+ return unless @enabled
85
+
86
+ percentage = total.zero? ? 100 : (done * 100 / total)
87
+ notify(Interface::WorkDoneProgressReport.new(kind: "report", message: "#{done} of #{total}", percentage: percentage))
88
+ end
89
+
90
+ def finish
91
+ return unless @enabled
92
+
93
+ notify(Interface::WorkDoneProgressEnd.new(kind: "end", message: "Klenod: modules indexed"))
94
+ end
95
+
96
+ private
97
+
98
+ def notify(value)
99
+ @server.notify("$/progress", Interface::ProgressParams.new(token: TOKEN, value: value))
100
+ end
101
+ end
102
+
103
+ def initialize(context:, entrypoints: [], input: $stdin, output: $stdout, logger: nil)
104
+ @reader = Protocol::Transport::Io::Reader.new(input)
105
+ @writer = Protocol::Transport::Io::Writer.new(output)
106
+ @logger = logger || Logger.new($stderr, progname: "klenod-lsp")
107
+ @workspace = Workspace.new(context: context)
108
+ @documents = Documents.new(@workspace)
109
+ @index = GraphIndex.new(workspace: @workspace, entrypoints: entrypoints, logger: @logger)
110
+ @pending_collections = {}
111
+ @closed_diagnostics = {}
112
+ @client_capabilities = {}
113
+ @position_encoding = Constant::PositionEncodingKind::UTF16
114
+ @next_request_id = 0
115
+ @shutdown_requested = false
116
+ @exit_requested = false
117
+ end
118
+
119
+ # Runs until the client sends `exit` or closes the input. Returns the
120
+ # exit status the protocol expects: 0 after `shutdown`, 1 otherwise.
121
+ #
122
+ # Everything runs inside one Async reactor: reading the client is
123
+ # fiber-aware, and background collection is a task in the same
124
+ # reactor, so no locking is needed around the graph.
125
+ def start
126
+ with_protocol_stdout do
127
+ Sync do |task|
128
+ @task = task
129
+ @reader.read do |message|
130
+ dispatch(message)
131
+ break if @exit_requested
132
+ end
133
+ ensure
134
+ @pending_collections.each_value(&:stop)
135
+ @index.stop
136
+ end
137
+ end
138
+
139
+ @shutdown_requested ? 0 : 1
140
+ end
141
+
142
+ def notify(method_name, params)
143
+ @writer.write(method: method_name, params: encode_positions(params))
144
+ end
145
+
146
+ # Server-to-client requests. Their responses arrive without a method
147
+ # and are ignored by dispatch, because nothing here depends on them.
148
+ def request(method_name, params)
149
+ @writer.write(id: "klenod-lsp-#{@next_request_id += 1}", method: method_name, params: encode_positions(params))
150
+ end
151
+
152
+ private
153
+
154
+ # Plugins may print while transforming. The protocol stream is the
155
+ # writer's IO, so anything written to $stdout meanwhile goes to stderr,
156
+ # which editors show in their language server log.
157
+ def with_protocol_stdout
158
+ previous_stdout = $stdout
159
+ $stdout = $stderr
160
+ yield
161
+ ensure
162
+ $stdout = previous_stdout
163
+ end
164
+
165
+ def dispatch(message)
166
+ method_name = message[:method]
167
+ return unless method_name
168
+
169
+ handler = HANDLERS[method_name]
170
+ if handler
171
+ result = send(handler, message)
172
+ reply(message, result) if request?(message)
173
+ elsif request?(message)
174
+ reply_error(message, Constant::ErrorCodes::METHOD_NOT_FOUND, "Unsupported method: #{method_name}")
175
+ else
176
+ @logger.debug { "Ignoring notification #{method_name}" }
177
+ end
178
+ rescue Languages::ImportNavigation::InvalidRename => error
179
+ reply_error(message, Constant::ErrorCodes::INVALID_PARAMS, error.message) if request?(message)
180
+ rescue => error
181
+ @logger.error { "#{error.class}: #{error.message}\n#{Array(error.backtrace).join("\n")}" }
182
+ reply_error(message, Constant::ErrorCodes::INTERNAL_ERROR, "#{error.class}: #{error.message}") if request?(message)
183
+ end
184
+
185
+ def request?(message)
186
+ message.key?(:id)
187
+ end
188
+
189
+ def reply(message, result)
190
+ uri = message.dig(:params, :textDocument, :uri)&.to_s
191
+ @writer.write(id: message[:id], result: encode_positions(result, uri: uri))
192
+ end
193
+
194
+ def reply_error(message, code, text)
195
+ @writer.write(id: message[:id], error: Interface::ResponseError.new(code: code, message: text))
196
+ end
197
+
198
+ # Positions are kept as Ruby character offsets internally and converted
199
+ # at the protocol boundary to the encoding selected by the client.
200
+ def handle_initialize(message)
201
+ @client_capabilities = message.dig(:params, :capabilities) || {}
202
+ encodings = Array(@client_capabilities.dig(:general, :positionEncodings))
203
+ supported = [Constant::PositionEncodingKind::UTF32, Constant::PositionEncodingKind::UTF8, Constant::PositionEncodingKind::UTF16]
204
+ @position_encoding = supported.find { |encoding| encodings.include?(encoding) } || Constant::PositionEncodingKind::UTF16
205
+ position_encoding = @position_encoding unless @position_encoding == Constant::PositionEncodingKind::UTF16
206
+
207
+ Interface::InitializeResult.new(
208
+ capabilities: Interface::ServerCapabilities.new(
209
+ position_encoding: position_encoding,
210
+ text_document_sync: Interface::TextDocumentSyncOptions.new(
211
+ open_close: true,
212
+ change: Constant::TextDocumentSyncKind::FULL,
213
+ save: true
214
+ ),
215
+ definition_provider: true,
216
+ hover_provider: true,
217
+ completion_provider: Interface::CompletionOptions.new(trigger_characters: ["%", "/", "\"", "'", ".", ":"]),
218
+ document_link_provider: Interface::DocumentLinkOptions.new,
219
+ code_action_provider: Interface::CodeActionOptions.new(code_action_kinds: [Constant::CodeActionKind::QUICK_FIX]),
220
+ references_provider: true,
221
+ document_symbol_provider: true,
222
+ workspace_symbol_provider: true,
223
+ code_lens_provider: Interface::CodeLensOptions.new,
224
+ rename_provider: Interface::RenameOptions.new(prepare_provider: true),
225
+ workspace: {
226
+ fileOperations: Interface::FileOperationOptions.new(
227
+ will_rename: Interface::FileOperationRegistrationOptions.new(
228
+ filters: [
229
+ Interface::FileOperationFilter.new(
230
+ scheme: "file",
231
+ pattern: Interface::FileOperationPattern.new(glob: File.join(@workspace.source_dir, "**"))
232
+ )
233
+ ]
234
+ )
235
+ )
236
+ }
237
+ ),
238
+ server_info: {name: "klenod", version: VERSION}
239
+ )
240
+ end
241
+
242
+ def handle_noop(_message)
243
+ nil
244
+ end
245
+
246
+ # Ask the editor to report file changes under the source directory so
247
+ # the graph and diagnostics follow files created, changed, or removed
248
+ # outside the open documents, then start indexing in the background.
249
+ # Clients without dynamic registration need a static watcher
250
+ # configuration instead.
251
+ def handle_initialized(_message)
252
+ register_file_watchers if @client_capabilities.dig(:workspace, :didChangeWatchedFiles, :dynamicRegistration)
253
+ progress_supported = @client_capabilities.dig(:window, :workDoneProgress) == true
254
+ @index.start(@task, progress: WorkDoneProgress.new(self, enabled: progress_supported)) { publish_workspace_diagnostics }
255
+ end
256
+
257
+ def register_file_watchers
258
+ request(
259
+ "client/registerCapability",
260
+ Interface::RegistrationParams.new(
261
+ registrations: [
262
+ Interface::Registration.new(
263
+ id: "klenod-lsp.watched-files",
264
+ method: "workspace/didChangeWatchedFiles",
265
+ register_options: Interface::DidChangeWatchedFilesRegistrationOptions.new(
266
+ watchers: [Interface::FileSystemWatcher.new(glob_pattern: File.join(@workspace.source_dir, "**", "*"))]
267
+ )
268
+ )
269
+ ]
270
+ )
271
+ )
272
+ end
273
+
274
+ # Changes on disk go through the build's invalidation, which keeps the
275
+ # graph's records, resolver cache, and companion ownership current.
276
+ # Every open document whose record may have changed is re-analyzed. A
277
+ # document's own file is skipped: the editor already reported that
278
+ # save through didSave.
279
+ def handle_did_change_watched_files(message)
280
+ changed_paths = []
281
+ removed_paths = []
282
+ Array(message.dig(:params, :changes)).each do |change|
283
+ path = @workspace.path_for_uri(change[:uri].to_s)
284
+ next unless path
285
+
286
+ if change[:type] == Constant::FileChangeType::DELETED
287
+ removed_paths << path
288
+ else
289
+ changed_paths << path
290
+ end
291
+ end
292
+ return if changed_paths.empty? && removed_paths.empty?
293
+
294
+ @workspace.routes.invalidate
295
+ affected = @index.invalidate(changed_paths, removed_paths)
296
+ own_paths = changed_paths + removed_paths
297
+
298
+ @documents.each do |document|
299
+ next if own_paths.include?(document.path)
300
+ next unless Languages.for(document)
301
+ next unless affected.include?(document.module_id.to_s)
302
+
303
+ @documents.invalidate(document.uri)
304
+ publish_diagnostics(document)
305
+ end
306
+ publish_workspace_diagnostics
307
+ end
308
+
309
+ # Modules the index could not collect get diagnostics even while
310
+ # closed, so a rename or deletion that breaks importers shows up in the
311
+ # editor's problem list. Open documents publish their own; a module
312
+ # that recovers has its diagnostics cleared.
313
+ def publish_workspace_diagnostics
314
+ current = {}
315
+ @index.failed.each_key do |module_id_string|
316
+ module_id = Klenod::Build::ModuleId.new(module_id_string)
317
+ next unless GraphIndex::ROOT_EXTENSIONS.include?(module_id.extname)
318
+
319
+ path = @workspace.path_for_module_id(module_id)
320
+ next unless path && File.file?(path)
321
+
322
+ uri = @workspace.uri_for_path(path)
323
+ next if @documents.fetch(uri)
324
+
325
+ document = Document.new(uri: uri, path: path, module_id: module_id, text: File.read(path), version: nil)
326
+ language = Languages.for(document)
327
+ next unless language
328
+
329
+ diagnostics = language.diagnostics(@workspace.analyze(module_id, document.text), @workspace, @index)
330
+ current[uri] = diagnostics unless diagnostics.empty?
331
+ end
332
+
333
+ (@closed_diagnostics.keys - current.keys).each do |uri|
334
+ notify("textDocument/publishDiagnostics", Interface::PublishDiagnosticsParams.new(uri: uri, diagnostics: []))
335
+ end
336
+ current.each do |uri, diagnostics|
337
+ next if @closed_diagnostics[uri] == JSON.generate(diagnostics)
338
+
339
+ notify("textDocument/publishDiagnostics", Interface::PublishDiagnosticsParams.new(uri: uri, diagnostics: diagnostics))
340
+ end
341
+ @closed_diagnostics = current.transform_values { |diagnostics| JSON.generate(diagnostics) }
342
+ end
343
+
344
+ def handle_shutdown(_message)
345
+ @shutdown_requested = true
346
+ nil
347
+ end
348
+
349
+ def handle_exit(_message)
350
+ @exit_requested = true
351
+ end
352
+
353
+ def handle_did_open(message)
354
+ text_document = message.dig(:params, :textDocument)
355
+ document = @documents.open(uri: text_document[:uri], text: text_document[:text], version: text_document[:version])
356
+ publish_diagnostics(document)
357
+ overlay(document)
358
+ collect_document(document, force: true)
359
+ end
360
+
361
+ # Editors send a change per keystroke; diagnostics wait for a short
362
+ # pause and the graph record for a slightly longer one. Requests that
363
+ # arrive meanwhile analyze the current text on demand.
364
+ def handle_did_change(message)
365
+ params = message[:params]
366
+ change = Array(params[:contentChanges]).last
367
+ return unless change
368
+
369
+ document = @documents.change(uri: params.dig(:textDocument, :uri), text: change[:text], version: params.dig(:textDocument, :version))
370
+ return unless Languages.for(document)
371
+
372
+ @pending_collections.delete(document.uri)&.stop
373
+ @pending_collections[document.uri] =
374
+ @task.async do |task|
375
+ task.sleep(DIAGNOSTICS_DEBOUNCE)
376
+ publish_diagnostics(document)
377
+ overlay(document)
378
+ task.sleep(COLLECTION_DEBOUNCE - DIAGNOSTICS_DEBOUNCE)
379
+ @pending_collections.delete(document.uri)
380
+ @index.ensure_collected(document.module_id, force: true)
381
+ end
382
+ end
383
+
384
+ # Companion files may have changed on disk, so the cached analysis is
385
+ # dropped even though the document version did not change.
386
+ def handle_did_save(message)
387
+ document = @documents.fetch(message.dig(:params, :textDocument, :uri))
388
+ return unless document
389
+
390
+ @documents.invalidate(document.uri)
391
+ publish_diagnostics(document)
392
+ collect_document(document, force: true)
393
+ end
394
+
395
+ # The record follows disk again once the editor lets go of the buffer.
396
+ def handle_did_close(message)
397
+ uri = message.dig(:params, :textDocument, :uri)
398
+ document = @documents.close(uri)
399
+ return unless document && Languages.for(document)
400
+
401
+ @pending_collections.delete(uri)&.stop
402
+ @workspace.context.graph.clear_source_override(document.module_id)
403
+ collect_document(document, force: true)
404
+ notify("textDocument/publishDiagnostics", Interface::PublishDiagnosticsParams.new(uri: uri, diagnostics: []))
405
+ publish_workspace_diagnostics
406
+ end
407
+
408
+ # The graph reads open documents from their buffers rather than disk,
409
+ # and reuses the diagnostics transform when the buffer had no errors so
410
+ # collection does not transform the same text twice.
411
+ def overlay(document)
412
+ return unless Languages.for(document)
413
+
414
+ analysis = @documents.cached_analysis(document)
415
+ transform = analysis&.transform if analysis && analysis.build_error.nil? && analysis.resolve_errors.empty?
416
+ @workspace.context.graph.override_source(document.module_id, document.text, transform: transform)
417
+ end
418
+
419
+ def collect_document(document, force: false)
420
+ return unless Languages.for(document)
421
+
422
+ @pending_collections.delete(document.uri)&.stop
423
+ @index.ensure_collected(document.module_id, force: force)
424
+ end
425
+
426
+ def handle_definition(message)
427
+ with_position(message) { |language, analysis, position| language.definition(analysis, position, @workspace, @index) }
428
+ end
429
+
430
+ def handle_hover(message)
431
+ with_position(message) { |language, analysis, position| language.hover(analysis, position, @workspace, @index) }
432
+ end
433
+
434
+ def handle_completion(message)
435
+ with_position(message) { |language, analysis, position| language.completion(analysis, position, @workspace, @index) }
436
+ end
437
+
438
+ def handle_references(message)
439
+ with_position(message) do |language, analysis, position|
440
+ include_declaration = message.dig(:params, :context, :includeDeclaration) == true
441
+ language.references(analysis, position, @workspace, @index, include_declaration: include_declaration)
442
+ end
443
+ end
444
+
445
+ # The editor asks before renaming or moving files, and applies the
446
+ # returned edits first; the watched-file events that follow bring the
447
+ # graph up to date.
448
+ def handle_will_rename_files(message)
449
+ files = Array(message.dig(:params, :files)).map { |file| [file[:oldUri].to_s, file[:newUri].to_s] }
450
+ Renames.call(files, @index, @workspace)
451
+ end
452
+
453
+ def handle_prepare_rename(message)
454
+ with_position(message) { |language, analysis, position| language.prepare_rename(analysis, position) }
455
+ end
456
+
457
+ def handle_rename(message)
458
+ with_position(message) do |language, analysis, position|
459
+ language.rename(analysis, position, message.dig(:params, :newName).to_s, @workspace)
460
+ end
461
+ end
462
+
463
+ def handle_code_lens(message)
464
+ with_document(message) { |language, analysis| language.code_lenses(analysis, @workspace, @index) }
465
+ end
466
+
467
+ def handle_document_symbol(message)
468
+ with_document(message) { |language, analysis| language.document_symbols(analysis) }
469
+ end
470
+
471
+ def handle_workspace_symbol(message)
472
+ Symbols.workspace_symbols(message.dig(:params, :query), @index, @workspace)
473
+ end
474
+
475
+ def handle_document_link(message)
476
+ with_document(message) { |language, analysis| language.document_links(analysis, @workspace) }
477
+ end
478
+
479
+ def handle_code_action(message)
480
+ with_document(message) do |language, analysis|
481
+ range = message.dig(:params, :range)
482
+ language.code_actions(analysis, range.dig(:start, :line)..range.dig(:end, :line), @workspace)
483
+ end
484
+ end
485
+
486
+ # Document requests share the same shape: nothing for documents the
487
+ # server does not handle, otherwise the language handler answers from
488
+ # the document's analysis.
489
+ def with_document(message)
490
+ document = @documents.fetch(message.dig(:params, :textDocument, :uri))
491
+ language = document && Languages.for(document)
492
+ return nil unless language
493
+
494
+ yield language, @documents.analysis_for(document)
495
+ end
496
+
497
+ def with_position(message)
498
+ with_document(message) do |language, analysis|
499
+ params = message[:params]
500
+ line = params.dig(:position, :line)
501
+ character = params.dig(:position, :character)
502
+ line_text = analysis.lines[line].to_s
503
+ position = Text::Position.new(line: line, character: Text.ruby_character(line_text, character, @position_encoding))
504
+ yield language, analysis, position
505
+ end
506
+ end
507
+
508
+ def encode_positions(value, uri: nil)
509
+ serialized = JSON.parse(JSON.generate(value), symbolize_names: true)
510
+ encode_position_nodes(serialized, uri, {})
511
+ end
512
+
513
+ def encode_position_nodes(value, uri, sources)
514
+ case value
515
+ when Array
516
+ value.map { |item| encode_position_nodes(item, uri, sources) }
517
+ when Hash
518
+ current_uri = value[:uri]&.to_s || uri
519
+ if value.key?(:line) && value.key?(:character)
520
+ line_text = source_line(current_uri, value[:line], sources)
521
+ return value.merge(character: Text.protocol_character(line_text, value[:character], @position_encoding)) if line_text
522
+ end
523
+
524
+ value.to_h do |key, child|
525
+ encoded =
526
+ if key == :changes && child.is_a?(Hash)
527
+ child.to_h { |change_uri, edits| [change_uri, encode_position_nodes(edits, change_uri.to_s, sources)] }
528
+ else
529
+ encode_position_nodes(child, current_uri, sources)
530
+ end
531
+ [key, encoded]
532
+ end
533
+ else
534
+ value
535
+ end
536
+ end
537
+
538
+ def source_line(uri, line, sources)
539
+ return nil unless uri
540
+
541
+ lines = sources.fetch(uri) do
542
+ document = @documents.fetch(uri)
543
+ source = document&.text
544
+ unless source
545
+ path = @workspace.path_for_uri(uri)
546
+ source = File.read(path) if path && File.file?(path)
547
+ end
548
+ sources[uri] = source&.lines(chomp: true)
549
+ end
550
+ lines&.[](line)
551
+ rescue SystemCallError
552
+ sources[uri] = nil
553
+ nil
554
+ end
555
+
556
+ def publish_diagnostics(document)
557
+ language = Languages.for(document)
558
+ return unless language
559
+
560
+ diagnostics = language.diagnostics(@documents.analysis_for(document), @workspace, @index)
561
+ notify(
562
+ "textDocument/publishDiagnostics",
563
+ Interface::PublishDiagnosticsParams.new(uri: document.uri, version: document.version, diagnostics: diagnostics)
564
+ )
565
+ end
566
+ end
567
+ end
568
+ end