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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +28 -10
- data/CHANGELOG.md +59 -0
- data/README.md +67 -12
- data/lib/mui/lsp/client.rb +23 -0
- data/lib/mui/lsp/handlers/completion.rb +24 -9
- data/lib/mui/lsp/handlers/definition.rb +13 -6
- data/lib/mui/lsp/handlers/formatting.rb +99 -0
- data/lib/mui/lsp/handlers/references.rb +44 -31
- data/lib/mui/lsp/handlers/type_definition.rb +106 -0
- data/lib/mui/lsp/handlers.rb +2 -0
- data/lib/mui/lsp/manager.rb +235 -10
- data/lib/mui/lsp/plugin.rb +189 -60
- data/lib/mui/lsp/server_config.rb +22 -0
- data/lib/mui/lsp/text_document_sync.rb +5 -3
- data/lib/mui/lsp/version.rb +1 -1
- metadata +4 -2
|
@@ -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
|
data/lib/mui/lsp/handlers.rb
CHANGED
|
@@ -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"
|
data/lib/mui/lsp/manager.rb
CHANGED
|
@@ -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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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::
|
|
339
|
+
handler = Handlers::Completion.new(editor: @editor, client: client)
|
|
224
340
|
|
|
225
|
-
client.
|
|
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
|
|
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::
|
|
354
|
+
handler = Handlers::Formatting.new(editor: @editor, client: client)
|
|
239
355
|
|
|
240
|
-
client.
|
|
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] }
|