mui-lsp 0.1.1 → 0.3.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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Lsp
5
+ module Handlers
6
+ # Handler for textDocument/typeDefinition responses
7
+ class TypeDefinition < Base
8
+ protected
9
+
10
+ def handle_result(result)
11
+ locations = normalize_locations(result)
12
+ return handle_empty if locations.empty?
13
+
14
+ if locations.length == 1
15
+ jump_to_location(locations.first)
16
+ else
17
+ show_location_list(locations)
18
+ end
19
+ end
20
+
21
+ def handle_empty
22
+ @editor.message = "No type definition found"
23
+ end
24
+
25
+ private
26
+
27
+ def normalize_locations(result)
28
+ case result
29
+ when Array
30
+ result.map { |loc| parse_location(loc) }.compact
31
+ when Hash
32
+ location = parse_location(result)
33
+ location ? [location] : []
34
+ else
35
+ []
36
+ end
37
+ end
38
+
39
+ def parse_location(data)
40
+ return nil unless data
41
+
42
+ # Handle both Location and LocationLink
43
+ if data["targetUri"]
44
+ # LocationLink
45
+ Protocol::Location.new(
46
+ uri: data["targetUri"],
47
+ range: data["targetSelectionRange"] || data["targetRange"]
48
+ )
49
+ elsif data["uri"]
50
+ # Location
51
+ Protocol::Location.from_hash(data)
52
+ end
53
+ end
54
+
55
+ def jump_to_location(location)
56
+ file_path = location.file_path
57
+ unless file_path
58
+ @editor.message = "Cannot open: #{location.uri}"
59
+ return
60
+ end
61
+
62
+ line = location.range.start.line
63
+ character = location.range.start.character
64
+
65
+ # Open the file in current window
66
+ current_buffer = @editor.buffer
67
+ if current_buffer.file_path != file_path
68
+ # Need to open a different file
69
+ new_buffer = Mui::Buffer.new
70
+ new_buffer.load(file_path)
71
+ @editor.window.buffer = new_buffer
72
+ end
73
+
74
+ # Jump to position
75
+ window = @editor.window
76
+ return unless window
77
+
78
+ window.cursor_row = line
79
+ window.cursor_col = character
80
+ window.ensure_cursor_visible
81
+
82
+ @editor.message = "#{File.basename(file_path)}:#{line + 1}"
83
+ end
84
+
85
+ def show_location_list(locations)
86
+ # Store locations for picker navigation
87
+ @editor.instance_variable_set(:@lsp_picker_locations, locations)
88
+ @editor.instance_variable_set(:@lsp_picker_type, :type_definition)
89
+
90
+ # Build picker content
91
+ lines = []
92
+ locations.each_with_index do |loc, idx|
93
+ file_path = loc.file_path || loc.uri
94
+ display_path = File.basename(file_path.to_s)
95
+ line_num = loc.range.start.line + 1
96
+ lines << "#{idx + 1}. #{display_path}:#{line_num}"
97
+ end
98
+
99
+ # Open scratch buffer for picker
100
+ content = "Type Definitions (\\Enter:open, Ctrl+t:tab, \\q:close)\n\n#{lines.join("\n")}"
101
+ @editor.open_scratch_buffer("[LSP Picker]", content)
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative "handlers/base"
4
4
  require_relative "handlers/hover"
5
5
  require_relative "handlers/definition"
6
+ require_relative "handlers/type_definition"
6
7
  require_relative "handlers/references"
7
8
  require_relative "handlers/diagnostics"
8
9
  require_relative "handlers/completion"
10
+ require_relative "handlers/formatting"
@@ -86,6 +86,20 @@ module Mui
86
86
  nil
87
87
  end
88
88
 
89
+ def client_for_capability(file_path, capability)
90
+ @mutex.synchronize do
91
+ @server_configs.each do |name, config|
92
+ next unless config.handles_file?(file_path)
93
+ next unless @clients[name]&.running?
94
+
95
+ # Check if server supports the capability
96
+ capabilities = @clients[name].server_capabilities
97
+ return @clients[name] if capabilities[capability]
98
+ end
99
+ end
100
+ nil
101
+ end
102
+
89
103
  def text_sync_for(file_path)
90
104
  @mutex.synchronize do
91
105
  @server_configs.each do |name, config|
@@ -162,6 +176,23 @@ module Mui
162
176
  end
163
177
  end
164
178
 
179
+ # Sync immediately without debounce (for completion requests)
180
+ def sync_now(file_path:, text:)
181
+ uri = TextDocumentSync.path_to_uri(file_path)
182
+ text_syncs_for(file_path).each do |text_sync|
183
+ text_sync.did_change(uri: uri, text: text, debounce: false, force: true)
184
+ end
185
+ end
186
+
187
+ # Force close and re-open document to reset LSP state
188
+ def force_reopen(file_path:, text:)
189
+ uri = TextDocumentSync.path_to_uri(file_path)
190
+ text_syncs_for(file_path).each do |text_sync|
191
+ text_sync.did_close(uri: uri) if text_sync.open?(uri)
192
+ text_sync.did_open(uri: uri, text: text)
193
+ end
194
+ end
195
+
165
196
  def did_save(file_path:, text: nil)
166
197
  uri = TextDocumentSync.path_to_uri(file_path)
167
198
  # Broadcast to all matching servers
@@ -198,21 +229,106 @@ module Mui
198
229
  end
199
230
 
200
231
  def definition(file_path:, line:, character:)
201
- client = client_for(file_path)
202
- unless client
232
+ text_syncs = text_syncs_for(file_path)
233
+ if text_syncs.empty?
203
234
  @editor.message = server_unavailable_message(file_path)
204
235
  return
205
236
  end
206
237
 
207
238
  uri = TextDocumentSync.path_to_uri(file_path)
208
- handler = Handlers::Definition.new(editor: @editor, client: client)
239
+ handler = Handlers::Definition.new(editor: @editor, client: text_syncs.first.client)
240
+
241
+ # Collect results from all clients
242
+ results_mutex = Mutex.new
243
+ pending_count = text_syncs.size
244
+ all_results = []
245
+
246
+ text_syncs.each do |text_sync|
247
+ text_sync.client.definition(uri: uri, line: line, character: character) do |result, _error|
248
+ results_mutex.synchronize do
249
+ all_results << result if result
250
+ pending_count -= 1
251
+
252
+ if pending_count.zero?
253
+ merged = merge_locations(all_results)
254
+ handler.handle(merged, nil)
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
209
260
 
210
- client.definition(uri: uri, line: line, character: character) do |result, error|
211
- handler.handle(result, error)
261
+ def type_definition(file_path:, line:, character:)
262
+ # Only send to servers that support typeDefinitionProvider
263
+ text_syncs = text_syncs_for(file_path).select do |ts|
264
+ ts.client.server_capabilities["typeDefinitionProvider"]
265
+ end
266
+
267
+ if text_syncs.empty?
268
+ # Fallback message: check if any server is running but doesn't support typeDefinition
269
+ any_text_sync = text_syncs_for(file_path).first
270
+ @editor.message = if any_text_sync
271
+ "LSP: no server supports typeDefinition for this file"
272
+ else
273
+ server_unavailable_message(file_path)
274
+ end
275
+ return
276
+ end
277
+
278
+ uri = TextDocumentSync.path_to_uri(file_path)
279
+ handler = Handlers::TypeDefinition.new(editor: @editor, client: text_syncs.first.client)
280
+
281
+ # Collect results from all clients
282
+ results_mutex = Mutex.new
283
+ pending_count = text_syncs.size
284
+ all_results = []
285
+
286
+ text_syncs.each do |text_sync|
287
+ text_sync.client.type_definition(uri: uri, line: line, character: character) do |result, _error|
288
+ results_mutex.synchronize do
289
+ all_results << result if result
290
+ pending_count -= 1
291
+
292
+ if pending_count.zero?
293
+ merged = merge_locations(all_results)
294
+ handler.handle(merged, nil)
295
+ end
296
+ end
297
+ end
212
298
  end
213
299
  end
214
300
 
215
301
  def references(file_path:, line:, character:)
302
+ text_syncs = text_syncs_for(file_path)
303
+ if text_syncs.empty?
304
+ @editor.message = server_unavailable_message(file_path)
305
+ return
306
+ end
307
+
308
+ uri = TextDocumentSync.path_to_uri(file_path)
309
+ handler = Handlers::References.new(editor: @editor, client: text_syncs.first.client)
310
+
311
+ # Collect results from all clients
312
+ results_mutex = Mutex.new
313
+ pending_count = text_syncs.size
314
+ all_results = []
315
+
316
+ text_syncs.each do |text_sync|
317
+ text_sync.client.references(uri: uri, line: line, character: character) do |result, _error|
318
+ results_mutex.synchronize do
319
+ all_results << result if result
320
+ pending_count -= 1
321
+
322
+ if pending_count.zero?
323
+ merged = merge_locations(all_results)
324
+ handler.handle(merged, nil)
325
+ end
326
+ end
327
+ end
328
+ end
329
+ end
330
+
331
+ def completion(file_path:, line:, character:)
216
332
  client = client_for(file_path)
217
333
  unless client
218
334
  @editor.message = server_unavailable_message(file_path)
@@ -220,14 +336,14 @@ module Mui
220
336
  end
221
337
 
222
338
  uri = TextDocumentSync.path_to_uri(file_path)
223
- handler = Handlers::References.new(editor: @editor, client: client)
339
+ handler = Handlers::Completion.new(editor: @editor, client: client)
224
340
 
225
- client.references(uri: uri, line: line, character: character) do |result, error|
341
+ client.completion(uri: uri, line: line, character: character) do |result, error|
226
342
  handler.handle(result, error)
227
343
  end
228
344
  end
229
345
 
230
- def completion(file_path:, line:, character:)
346
+ def format(file_path:, tab_size: 2, insert_spaces: true)
231
347
  client = client_for(file_path)
232
348
  unless client
233
349
  @editor.message = server_unavailable_message(file_path)
@@ -235,13 +351,48 @@ module Mui
235
351
  end
236
352
 
237
353
  uri = TextDocumentSync.path_to_uri(file_path)
238
- handler = Handlers::Completion.new(editor: @editor, client: client)
354
+ handler = Handlers::Formatting.new(editor: @editor, client: client)
239
355
 
240
- client.completion(uri: uri, line: line, character: character) do |result, error|
356
+ client.formatting(uri: uri, tab_size: tab_size, insert_spaces: insert_spaces) do |result, error|
241
357
  handler.handle(result, error)
242
358
  end
243
359
  end
244
360
 
361
+ def jump_to_type_file(file_path:, line: nil, character: nil)
362
+ # For Ruby/RBS files, use custom toggle behavior
363
+ if file_path&.end_with?(".rb", ".rbs")
364
+ jump_to_ruby_type_file(file_path)
365
+ else
366
+ # For other languages, use LSP typeDefinition
367
+ type_definition(file_path: file_path, line: line, character: character)
368
+ end
369
+ end
370
+
371
+ private
372
+
373
+ def jump_to_ruby_type_file(file_path)
374
+ target_path = if file_path.end_with?(".rb")
375
+ find_rbs_file(file_path)
376
+ else
377
+ find_ruby_file(file_path)
378
+ end
379
+
380
+ unless target_path
381
+ ext = File.extname(file_path)
382
+ target_ext = ext == ".rb" ? ".rbs" : ".rb"
383
+ @editor.message = "No #{target_ext} file found for #{File.basename(file_path)}"
384
+ return
385
+ end
386
+
387
+ # Open the target file
388
+ new_buffer = Mui::Buffer.new
389
+ new_buffer.load(target_path)
390
+ @editor.window.buffer = new_buffer
391
+ @editor.message = "Opened #{File.basename(target_path)}"
392
+ end
393
+
394
+ public
395
+
245
396
  def running_servers
246
397
  @mutex.synchronize do
247
398
  @clients.select { |_, client| client.running? }.keys
@@ -276,6 +427,80 @@ module Mui
276
427
 
277
428
  private
278
429
 
430
+ def merge_locations(results)
431
+ # Flatten all results into a single array
432
+ merged = results.flat_map do |result|
433
+ case result
434
+ when Array then result
435
+ when Hash then [result]
436
+ else []
437
+ end
438
+ end
439
+
440
+ # Remove duplicates based on uri and range
441
+ merged.uniq do |loc|
442
+ uri = loc["uri"] || loc["targetUri"]
443
+ range = loc["range"] || loc["targetSelectionRange"]
444
+ [uri, range]
445
+ end
446
+ end
447
+
448
+ def find_rbs_file(ruby_file_path)
449
+ # Find project root
450
+ project_root = find_project_root(ruby_file_path)
451
+
452
+ # Get relative path from project root
453
+ abs_path = File.expand_path(ruby_file_path)
454
+ rel_path = abs_path.sub("#{project_root}/", "")
455
+
456
+ # Try different RBS path patterns
457
+ candidates = []
458
+
459
+ # Pattern 1: sig/relative_path.rbs (e.g., lib/mui/config.rb -> sig/lib/mui/config.rbs)
460
+ candidates << File.join(project_root, "sig", rel_path.sub(/\.rb$/, ".rbs"))
461
+
462
+ # Pattern 2: sig/without_lib.rbs (e.g., lib/mui/config.rb -> sig/mui/config.rbs)
463
+ if rel_path.start_with?("lib/")
464
+ candidates << File.join(project_root, "sig", rel_path.sub(%r{^lib/}, "").sub(/\.rb$/, ".rbs"))
465
+ end
466
+
467
+ # Pattern 3: sig/basename.rbs (e.g., lib/mui/config.rb -> sig/config.rbs)
468
+ candidates << File.join(project_root, "sig", "#{File.basename(ruby_file_path, ".rb")}.rbs")
469
+
470
+ # Return first existing file
471
+ candidates.find { |path| File.exist?(path) }
472
+ end
473
+
474
+ def find_ruby_file(rbs_file_path)
475
+ # Find project root
476
+ project_root = find_project_root(rbs_file_path)
477
+
478
+ # Get relative path from project root
479
+ abs_path = File.expand_path(rbs_file_path)
480
+ rel_path = abs_path.sub("#{project_root}/", "")
481
+
482
+ # Remove sig/ prefix if present
483
+ rel_path = rel_path.sub(%r{^sig/}, "")
484
+
485
+ # Try different Ruby path patterns
486
+ candidates = []
487
+
488
+ # Pattern 1: lib/relative_path.rb (e.g., sig/mui/config.rbs -> lib/mui/config.rb)
489
+ candidates << File.join(project_root, "lib", rel_path.sub(/\.rbs$/, ".rb"))
490
+
491
+ # Pattern 2: relative_path.rb without lib (e.g., sig/lib/mui/config.rbs -> lib/mui/config.rb)
492
+ candidates << File.join(project_root, rel_path.sub(/\.rbs$/, ".rb")) if rel_path.start_with?("lib/")
493
+
494
+ # Pattern 3: Search in lib directory for basename
495
+ basename = File.basename(rbs_file_path, ".rbs")
496
+ Dir.glob(File.join(project_root, "lib", "**", "#{basename}.rb")).each do |path|
497
+ candidates << path
498
+ end
499
+
500
+ # Return first existing file
501
+ candidates.find { |path| File.exist?(path) }
502
+ end
503
+
279
504
  def send_pending_documents(server_name)
280
505
  text_sync = @mutex.synchronize { @text_syncs[server_name] }
281
506
  config = @mutex.synchronize { @server_configs[server_name] }