mui-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,539 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mui"
4
+
5
+ module Mui
6
+ module Lsp
7
+ # Main plugin class for mui-lsp
8
+ # Registers commands and keymaps for LSP integration
9
+ class Plugin < Mui::Plugin
10
+ name "lsp"
11
+
12
+ def setup
13
+ register_commands
14
+ register_keymaps
15
+ register_autocmds
16
+ setup_default_servers
17
+ end
18
+
19
+ private
20
+
21
+ def register_commands
22
+ # :LspStart - Start an LSP server
23
+ command(:LspStart) do |ctx, args|
24
+ handle_lsp_start(ctx, args)
25
+ end
26
+
27
+ # :LspStop - Stop an LSP server
28
+ command(:LspStop) do |ctx, args|
29
+ handle_lsp_stop(ctx, args)
30
+ end
31
+
32
+ # :LspStatus - Show LSP server status
33
+ command(:LspStatus) do |ctx, _args|
34
+ handle_lsp_status(ctx)
35
+ end
36
+
37
+ # :LspHover - Show hover information
38
+ command(:LspHover) do |ctx, _args|
39
+ handle_lsp_hover(ctx)
40
+ end
41
+
42
+ # :LspDefinition - Go to definition
43
+ command(:LspDefinition) do |ctx, _args|
44
+ handle_lsp_definition(ctx)
45
+ end
46
+
47
+ # :LspReferences - Show references
48
+ command(:LspReferences) do |ctx, _args|
49
+ handle_lsp_references(ctx)
50
+ end
51
+
52
+ # :LspCompletion - Show completion
53
+ command(:LspCompletion) do |ctx, _args|
54
+ handle_lsp_completion(ctx)
55
+ end
56
+
57
+ # :LspDiagnostics - Show diagnostics
58
+ command(:LspDiagnostics) do |ctx, _args|
59
+ handle_lsp_diagnostics(ctx)
60
+ end
61
+
62
+ # :LspDiagnosticShow - Show diagnostic at cursor in floating window
63
+ command(:LspDiagnosticShow) do |ctx, _args|
64
+ handle_lsp_diagnostic_show(ctx)
65
+ end
66
+
67
+ # :LspDebug - Show debug information
68
+ command(:LspDebug) do |ctx, _args|
69
+ handle_lsp_debug(ctx)
70
+ end
71
+
72
+ # :LspLog - Show LSP log in a buffer
73
+ command(:LspLog) do |ctx, _args|
74
+ handle_lsp_log(ctx)
75
+ end
76
+
77
+ # :LspOpen - Notify LSP server about current file
78
+ command(:LspOpen) do |ctx, _args|
79
+ handle_lsp_open(ctx)
80
+ end
81
+ end
82
+
83
+ def register_keymaps
84
+ # K - Show hover
85
+ keymap(:normal, "K") do |ctx|
86
+ handle_lsp_hover(ctx)
87
+ true
88
+ end
89
+
90
+ # Leader key (\) - start pending mode
91
+ keymap(:normal, "\\") do |_ctx|
92
+ @leader_pending = true
93
+ true
94
+ end
95
+
96
+ # \d - Go to definition
97
+ keymap(:normal, "d") do |ctx|
98
+ if @leader_pending
99
+ @leader_pending = false
100
+ handle_lsp_definition(ctx)
101
+ true
102
+ else
103
+ false # Let default 'd' handle it
104
+ end
105
+ end
106
+
107
+ # \r - Go to references
108
+ keymap(:normal, "r") do |ctx|
109
+ if @leader_pending
110
+ @leader_pending = false
111
+ handle_lsp_references(ctx)
112
+ true
113
+ else
114
+ false # Let default 'r' handle it
115
+ end
116
+ end
117
+
118
+ # \h - Show hover (alternative to K)
119
+ keymap(:normal, "h") do |ctx|
120
+ if @leader_pending
121
+ @leader_pending = false
122
+ handle_lsp_hover(ctx)
123
+ true
124
+ else
125
+ false # Let default 'h' handle it
126
+ end
127
+ end
128
+
129
+ # \c - Show completion
130
+ keymap(:normal, "c") do |ctx|
131
+ if @leader_pending
132
+ @leader_pending = false
133
+ handle_lsp_completion(ctx)
134
+ true
135
+ else
136
+ false # Let default 'c' handle it
137
+ end
138
+ end
139
+
140
+ # \e - Show diagnostic at cursor
141
+ keymap(:normal, "e") do |ctx|
142
+ if @leader_pending
143
+ @leader_pending = false
144
+ handle_lsp_diagnostic_show(ctx)
145
+ true
146
+ else
147
+ false # Let default 'e' handle it
148
+ end
149
+ end
150
+
151
+ # Cancel leader pending on any other key (via Escape)
152
+ keymap(:normal, "\e") do |_ctx|
153
+ if @leader_pending
154
+ @leader_pending = false
155
+ true
156
+ else
157
+ false
158
+ end
159
+ end
160
+ end
161
+
162
+ def register_autocmds
163
+ # Hook into buffer open/enter events
164
+ autocmd(:BufEnter) do |ctx|
165
+ file_path = ctx.buffer.file_path
166
+ next unless file_path && !file_path.start_with?("[")
167
+
168
+ text = ctx.buffer.lines.join("\n")
169
+ get_manager(ctx.editor).did_open(file_path: file_path, text: text)
170
+ end
171
+
172
+ # Hook into text change events
173
+ autocmd(:TextChanged) do |ctx|
174
+ file_path = ctx.buffer.file_path
175
+ next unless file_path && !file_path.start_with?("[")
176
+
177
+ text = ctx.buffer.lines.join("\n")
178
+ get_manager(ctx.editor).did_change(file_path: file_path, text: text)
179
+ end
180
+
181
+ # Hook into buffer save events
182
+ autocmd(:BufWritePost) do |ctx|
183
+ file_path = ctx.buffer.file_path
184
+ next unless file_path && !file_path.start_with?("[")
185
+
186
+ text = ctx.buffer.lines.join("\n")
187
+ get_manager(ctx.editor).did_save(file_path: file_path, text: text)
188
+ end
189
+
190
+ # Hook into buffer leave/close events
191
+ autocmd(:BufLeave) do |ctx|
192
+ file_path = ctx.buffer.file_path
193
+ next unless file_path && !file_path.start_with?("[")
194
+
195
+ get_manager(ctx.editor).did_close(file_path: file_path)
196
+ end
197
+ end
198
+
199
+ def setup_default_servers
200
+ # Load server configs from .muirc DSL (Mui.lsp { use :solargraph })
201
+ @default_server_configs = Mui.lsp_server_configs.dup
202
+ end
203
+
204
+ # Command handlers
205
+
206
+ def handle_lsp_start(ctx, args)
207
+ server_name = args.to_s.strip
208
+ mgr = get_manager(ctx.editor)
209
+ if server_name.empty?
210
+ ctx.set_message("Usage: :LspStart <server_name>")
211
+ ctx.set_message("Available: #{mgr.registered_servers.join(", ")}")
212
+ return
213
+ end
214
+
215
+ begin
216
+ mgr.start_server(server_name)
217
+ rescue StandardError => e
218
+ ctx.set_message("LSP Error: #{e.message}")
219
+ end
220
+ end
221
+
222
+ def handle_lsp_stop(ctx, args)
223
+ server_name = args.to_s.strip
224
+ mgr = get_manager(ctx.editor)
225
+ if server_name.empty?
226
+ # Stop all servers
227
+ mgr.stop_all
228
+ ctx.set_message("LSP: all servers stopped")
229
+ else
230
+ mgr.stop_server(server_name)
231
+ end
232
+ end
233
+
234
+ def handle_lsp_status(ctx)
235
+ mgr = get_manager(ctx.editor)
236
+ running = mgr.running_servers
237
+ starting = mgr.starting_servers
238
+ registered = mgr.registered_servers
239
+
240
+ parts = []
241
+ parts << "running: #{running.join(", ")}" unless running.empty?
242
+ parts << "starting: #{starting.join(", ")}" unless starting.empty?
243
+
244
+ if parts.empty?
245
+ ctx.set_message("LSP: no servers running. Registered: #{registered.join(", ")}")
246
+ else
247
+ ctx.set_message("LSP: #{parts.join(", ")} (registered: #{registered.join(", ")})")
248
+ end
249
+ end
250
+
251
+ def handle_lsp_hover(ctx)
252
+ file_path = ctx.buffer.file_path
253
+ unless file_path
254
+ ctx.set_message("LSP: no file path")
255
+ return
256
+ end
257
+
258
+ line = ctx.window.cursor_row
259
+ character = ctx.window.cursor_col
260
+ get_manager(ctx.editor).hover(file_path: file_path, line: line, character: character)
261
+ end
262
+
263
+ def handle_lsp_definition(ctx)
264
+ file_path = ctx.buffer.file_path
265
+ unless file_path
266
+ ctx.set_message("LSP: no file path")
267
+ return
268
+ end
269
+
270
+ line = ctx.window.cursor_row
271
+ character = ctx.window.cursor_col
272
+ get_manager(ctx.editor).definition(file_path: file_path, line: line, character: character)
273
+ end
274
+
275
+ def handle_lsp_references(ctx)
276
+ file_path = ctx.buffer.file_path
277
+ unless file_path
278
+ ctx.set_message("LSP: no file path")
279
+ return
280
+ end
281
+
282
+ line = ctx.window.cursor_row
283
+ character = ctx.window.cursor_col
284
+ get_manager(ctx.editor).references(file_path: file_path, line: line, character: character)
285
+ end
286
+
287
+ def handle_lsp_completion(ctx)
288
+ file_path = ctx.buffer.file_path
289
+ unless file_path
290
+ ctx.set_message("LSP: no file path")
291
+ return
292
+ end
293
+
294
+ line = ctx.window.cursor_row
295
+ character = ctx.window.cursor_col
296
+ get_manager(ctx.editor).completion(file_path: file_path, line: line, character: character)
297
+ end
298
+
299
+ def handle_lsp_diagnostics(ctx)
300
+ file_path = ctx.buffer.file_path
301
+ uri = file_path ? TextDocumentSync.path_to_uri(file_path) : nil
302
+ mgr = get_manager(ctx.editor)
303
+
304
+ diagnostics = uri ? mgr.diagnostics_handler.diagnostics_for(uri) : []
305
+
306
+ if diagnostics.empty?
307
+ ctx.set_message("LSP: no diagnostics")
308
+ return
309
+ end
310
+
311
+ ctx.set_message("LSP: #{diagnostics.length} diagnostics")
312
+ diagnostics.first(5).each do |d|
313
+ line = d.range.start.line + 1
314
+ ctx.set_message(" [#{d.severity_name}] Line #{line}: #{d.message}")
315
+ end
316
+
317
+ return unless diagnostics.length > 5
318
+
319
+ ctx.set_message(" ... and #{diagnostics.length - 5} more")
320
+ end
321
+
322
+ def handle_lsp_diagnostic_show(ctx)
323
+ file_path = ctx.buffer.file_path
324
+ uri = file_path ? TextDocumentSync.path_to_uri(file_path) : nil
325
+ mgr = get_manager(ctx.editor)
326
+
327
+ return ctx.set_message("LSP: no file") unless uri
328
+
329
+ line = ctx.window.cursor_row
330
+ diagnostics = mgr.diagnostics_handler.diagnostics_at_line(uri, line)
331
+
332
+ if diagnostics.empty?
333
+ ctx.set_message("LSP: no diagnostic at cursor")
334
+ return
335
+ end
336
+
337
+ # Format diagnostics for display
338
+ lines = diagnostics.map do |d|
339
+ "[#{d.severity_name}] #{d.message}"
340
+ end
341
+
342
+ # Use floating window if available
343
+ if ctx.editor.respond_to?(:show_floating)
344
+ ctx.editor.show_floating(lines.join("\n\n"), max_height: 10)
345
+ else
346
+ ctx.set_message(lines.first)
347
+ end
348
+ end
349
+
350
+ def handle_lsp_debug(ctx)
351
+ mgr = get_manager(ctx.editor)
352
+ info = mgr.debug_info
353
+
354
+ if info.empty?
355
+ ctx.set_message("LSP Debug: no clients")
356
+ return
357
+ end
358
+
359
+ info.each do |name, data|
360
+ status = if data[:initialized]
361
+ "ready"
362
+ elsif data[:started]
363
+ "starting"
364
+ else
365
+ "stopped"
366
+ end
367
+ ctx.set_message("LSP #{name}: #{status}")
368
+ next unless data[:last_stderr]
369
+
370
+ data[:last_stderr].split("\n").each do |line|
371
+ ctx.set_message(" #{line}")
372
+ end
373
+ end
374
+ end
375
+
376
+ def handle_lsp_log(ctx)
377
+ mgr = get_manager(ctx.editor)
378
+ info = mgr.debug_info
379
+
380
+ lines = ["=== LSP Log ===", ""]
381
+
382
+ if info.empty?
383
+ lines << "No LSP clients running."
384
+ else
385
+ info.each do |name, data|
386
+ status = if data[:initialized]
387
+ "ready"
388
+ elsif data[:started]
389
+ "starting"
390
+ else
391
+ "stopped"
392
+ end
393
+ lines << "--- #{name} (#{status}) ---"
394
+ if data[:last_stderr]
395
+ lines.concat(data[:last_stderr].split("\n"))
396
+ else
397
+ lines << "(no stderr output)"
398
+ end
399
+ lines << ""
400
+ end
401
+ end
402
+
403
+ # Add notification log
404
+ lines << "=== Notifications ==="
405
+ notifications = mgr.notification_log
406
+ if notifications.empty?
407
+ lines << "(no notifications received)"
408
+ else
409
+ notifications.each do |n|
410
+ lines << "#{n[:time].strftime("%H:%M:%S")} #{n[:method]}"
411
+ if n[:params]
412
+ params_str = n[:params].to_s
413
+ lines << " #{params_str[0, 200]}#{"..." if params_str.length > 200}"
414
+ end
415
+ end
416
+ end
417
+
418
+ ctx.editor.open_scratch_buffer("[LSP Log]", lines.join("\n"))
419
+ end
420
+
421
+ def handle_lsp_open(ctx)
422
+ file_path = ctx.buffer.file_path
423
+ unless file_path
424
+ ctx.set_message("LSP: no file path")
425
+ return
426
+ end
427
+
428
+ text = ctx.buffer.lines.join("\n")
429
+ mgr = get_manager(ctx.editor)
430
+
431
+ # Debug: check if text_sync exists
432
+ text_sync = mgr.text_sync_for(file_path)
433
+ unless text_sync
434
+ ctx.set_message("LSP: no text_sync for #{File.basename(file_path)}")
435
+ return
436
+ end
437
+
438
+ mgr.did_open(file_path: file_path, text: text)
439
+ ctx.set_message("LSP: opened #{File.basename(file_path)}")
440
+ end
441
+
442
+ public
443
+
444
+ def get_manager(editor)
445
+ @managers ||= {}.compare_by_identity
446
+ @managers[editor] ||= create_manager(editor)
447
+ end
448
+
449
+ def register_server(config)
450
+ @default_server_configs ||= []
451
+ @default_server_configs << config
452
+ end
453
+
454
+ def use_server(name)
455
+ config = case name.to_sym
456
+ when :solargraph
457
+ ServerConfig.solargraph(auto_start: true)
458
+ when :ruby_lsp
459
+ ServerConfig.ruby_lsp(auto_start: true)
460
+ when :rubocop
461
+ ServerConfig.rubocop_lsp(auto_start: true)
462
+ when :kanayago
463
+ ServerConfig.kanayago(auto_start: true)
464
+ else
465
+ raise ArgumentError, "Unknown server: #{name}. Use :solargraph, :ruby_lsp, :rubocop, or :kanayago"
466
+ end
467
+ register_server(config)
468
+ end
469
+
470
+ private
471
+
472
+ def create_manager(editor)
473
+ mgr = Manager.new(editor: editor)
474
+ # Register configured servers
475
+ @default_server_configs&.each do |config|
476
+ mgr.register_server(config)
477
+ end
478
+ mgr
479
+ end
480
+ end
481
+ end
482
+ end
483
+
484
+ # Register the plugin
485
+ Mui.plugin_manager.register(:lsp, Mui::Lsp::Plugin)
486
+
487
+ # DSL for .muirc configuration
488
+ module Mui
489
+ class << self
490
+ def lsp(&block)
491
+ @lsp_config ||= Lsp::ConfigDsl.new
492
+ @lsp_config.instance_eval(&block) if block
493
+ @lsp_config
494
+ end
495
+
496
+ def lsp_server_configs
497
+ @lsp_config&.server_configs || []
498
+ end
499
+ end
500
+
501
+ module Lsp
502
+ # DSL class for configuring LSP in .muirc
503
+ class ConfigDsl
504
+ attr_reader :server_configs
505
+
506
+ def initialize
507
+ @server_configs = []
508
+ end
509
+
510
+ def use(name, sync_on_change: nil)
511
+ config = case name.to_sym
512
+ when :solargraph
513
+ ServerConfig.solargraph(auto_start: true)
514
+ when :ruby_lsp
515
+ # ruby_lsp defaults to sync_on_change: false
516
+ ServerConfig.ruby_lsp(auto_start: true, sync_on_change: sync_on_change.nil? ? false : sync_on_change)
517
+ when :rubocop
518
+ ServerConfig.rubocop_lsp(auto_start: true)
519
+ when :kanayago
520
+ ServerConfig.kanayago(auto_start: true)
521
+ else
522
+ raise ArgumentError, "Unknown server: #{name}. Use :solargraph, :ruby_lsp, :rubocop, or :kanayago"
523
+ end
524
+ @server_configs << config
525
+ end
526
+
527
+ def server(name:, command:, language_ids:, file_patterns:, auto_start: true, sync_on_change: true)
528
+ @server_configs << ServerConfig.custom(
529
+ name: name,
530
+ command: command,
531
+ language_ids: language_ids,
532
+ file_patterns: file_patterns,
533
+ auto_start: auto_start,
534
+ sync_on_change: sync_on_change
535
+ )
536
+ end
537
+ end
538
+ end
539
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Lsp
5
+ module Protocol
6
+ # LSP DiagnosticSeverity constants
7
+ module DiagnosticSeverity
8
+ ERROR = 1
9
+ WARNING = 2
10
+ INFORMATION = 3
11
+ HINT = 4
12
+ end
13
+
14
+ # LSP Diagnostic (error/warning/info/hint message)
15
+ class Diagnostic
16
+ attr_accessor :range, :severity, :code, :source, :message, :related_information
17
+
18
+ def initialize(range:, message:, severity: nil, code: nil, source: nil, related_information: nil)
19
+ @range = range.is_a?(Range) ? range : Range.from_hash(range)
20
+ @message = message
21
+ @severity = severity
22
+ @code = code
23
+ @source = source
24
+ @related_information = related_information
25
+ end
26
+
27
+ def to_h
28
+ result = {
29
+ range: @range.to_h,
30
+ message: @message
31
+ }
32
+ result[:severity] = @severity if @severity
33
+ result[:code] = @code if @code
34
+ result[:source] = @source if @source
35
+ result[:relatedInformation] = @related_information if @related_information
36
+ result
37
+ end
38
+
39
+ def self.from_hash(hash)
40
+ new(
41
+ range: hash["range"] || hash[:range],
42
+ message: hash["message"] || hash[:message],
43
+ severity: hash["severity"] || hash[:severity],
44
+ code: hash["code"] || hash[:code],
45
+ source: hash["source"] || hash[:source],
46
+ related_information: hash["relatedInformation"] || hash[:relatedInformation]
47
+ )
48
+ end
49
+
50
+ def error?
51
+ @severity == DiagnosticSeverity::ERROR
52
+ end
53
+
54
+ def warning?
55
+ @severity == DiagnosticSeverity::WARNING
56
+ end
57
+
58
+ def information?
59
+ @severity == DiagnosticSeverity::INFORMATION
60
+ end
61
+
62
+ def hint?
63
+ @severity == DiagnosticSeverity::HINT
64
+ end
65
+
66
+ def severity_name
67
+ case @severity
68
+ when DiagnosticSeverity::ERROR then "Error"
69
+ when DiagnosticSeverity::WARNING then "Warning"
70
+ when DiagnosticSeverity::INFORMATION then "Information"
71
+ when DiagnosticSeverity::HINT then "Hint"
72
+ else "Unknown"
73
+ end
74
+ end
75
+
76
+ def ==(other)
77
+ return false unless other.is_a?(Diagnostic)
78
+
79
+ @range == other.range &&
80
+ @message == other.message &&
81
+ @severity == other.severity &&
82
+ @code == other.code &&
83
+ @source == other.source
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Mui
6
+ module Lsp
7
+ module Protocol
8
+ # LSP Location (URI and range)
9
+ class Location
10
+ attr_accessor :uri, :range
11
+
12
+ def initialize(uri:, range:)
13
+ @uri = uri
14
+ @range = range.is_a?(Range) ? range : Range.from_hash(range)
15
+ end
16
+
17
+ def to_h
18
+ { uri: @uri, range: @range.to_h }
19
+ end
20
+
21
+ def self.from_hash(hash)
22
+ new(
23
+ uri: hash["uri"] || hash[:uri],
24
+ range: hash["range"] || hash[:range]
25
+ )
26
+ end
27
+
28
+ def file_path
29
+ return nil unless @uri&.start_with?("file://")
30
+
31
+ URI.decode_www_form_component(@uri.sub("file://", ""))
32
+ end
33
+
34
+ def ==(other)
35
+ return false unless other.is_a?(Location)
36
+
37
+ @uri == other.uri && @range == other.range
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Lsp
5
+ module Protocol
6
+ # LSP Position (0-indexed line and character)
7
+ class Position
8
+ attr_accessor :line, :character
9
+
10
+ def initialize(line:, character:)
11
+ @line = line
12
+ @character = character
13
+ end
14
+
15
+ def to_h
16
+ { line: @line, character: @character }
17
+ end
18
+
19
+ def self.from_hash(hash)
20
+ new(line: hash["line"] || hash[:line], character: hash["character"] || hash[:character])
21
+ end
22
+
23
+ def ==(other)
24
+ return false unless other.is_a?(Position)
25
+
26
+ @line == other.line && @character == other.character
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end