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,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mui
6
+ module Lsp
7
+ # JSON-RPC 2.0 over stdio communication handler
8
+ # Handles LSP message framing with Content-Length headers
9
+ class JsonRpcIO
10
+ CONTENT_LENGTH_HEADER = "Content-Length:"
11
+ HEADER_DELIMITER = "\r\n\r\n"
12
+
13
+ attr_reader :input, :output
14
+
15
+ def initialize(input:, output:)
16
+ @input = input
17
+ @output = output
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def read_message
22
+ content_length = read_headers
23
+ return nil if content_length.nil?
24
+
25
+ body = @input.read(content_length)
26
+ return nil if body.nil? || body.empty?
27
+
28
+ JSON.parse(body)
29
+ rescue JSON::ParserError => e
30
+ raise Error, "Failed to parse JSON: #{e.message}"
31
+ end
32
+
33
+ def write_message(message)
34
+ json = JSON.generate(message)
35
+ content = "#{CONTENT_LENGTH_HEADER} #{json.bytesize}\r\n\r\n#{json}"
36
+
37
+ @mutex.synchronize do
38
+ @output.write(content)
39
+ @output.flush
40
+ end
41
+ true
42
+ rescue IOError, Errno::EPIPE
43
+ # Pipe is broken, server probably exited
44
+ false
45
+ end
46
+
47
+ def self.build_request(id:, method:, params: nil)
48
+ message = {
49
+ jsonrpc: "2.0",
50
+ id: id,
51
+ method: method
52
+ }
53
+ message[:params] = params if params
54
+ message
55
+ end
56
+
57
+ def self.build_notification(method:, params: nil)
58
+ message = {
59
+ jsonrpc: "2.0",
60
+ method: method
61
+ }
62
+ message[:params] = params if params
63
+ message
64
+ end
65
+
66
+ def self.build_response(id:, result:)
67
+ {
68
+ jsonrpc: "2.0",
69
+ id: id,
70
+ result: result
71
+ }
72
+ end
73
+
74
+ def self.build_error_response(id:, code:, message:, data: nil)
75
+ error = {
76
+ code: code,
77
+ message: message
78
+ }
79
+ error[:data] = data if data
80
+
81
+ {
82
+ jsonrpc: "2.0",
83
+ id: id,
84
+ error: error
85
+ }
86
+ end
87
+
88
+ def self.request?(message)
89
+ message.key?("id") && message.key?("method")
90
+ end
91
+
92
+ def self.notification?(message)
93
+ !message.key?("id") && message.key?("method")
94
+ end
95
+
96
+ def self.response?(message)
97
+ message.key?("id") && !message.key?("method") && (message.key?("result") || message.key?("error"))
98
+ end
99
+
100
+ private
101
+
102
+ def read_headers
103
+ headers = {}
104
+ loop do
105
+ line = @input.gets("\r\n")
106
+ return nil if line.nil?
107
+
108
+ line = line.chomp("\r\n")
109
+ break if line.empty?
110
+
111
+ if line.start_with?(CONTENT_LENGTH_HEADER)
112
+ headers[:content_length] = line.sub(CONTENT_LENGTH_HEADER, "").strip.to_i
113
+ end
114
+ end
115
+
116
+ headers[:content_length]
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Lsp
5
+ # Manages multiple LSP clients and coordinates between editor and servers
6
+ class Manager
7
+ attr_reader :editor, :diagnostics_handler
8
+
9
+ def initialize(editor:)
10
+ @editor = editor
11
+ @server_configs = {}
12
+ @clients = {}
13
+ @text_syncs = {}
14
+ @diagnostics_handler = Handlers::Diagnostics.new(editor: editor)
15
+ @mutex = Mutex.new
16
+ @pending_documents = {} # file_path => text, documents waiting for server startup
17
+ end
18
+
19
+ def register_server(config)
20
+ @mutex.synchronize do
21
+ @server_configs[config.name] = config
22
+ end
23
+ end
24
+
25
+ def start_server(name, root_path = nil)
26
+ config = @mutex.synchronize { @server_configs[name] }
27
+ unless config
28
+ @editor.message = "LSP Error: Unknown server: #{name}"
29
+ return false
30
+ end
31
+
32
+ root_path ||= Dir.pwd
33
+
34
+ client = Client.new(
35
+ command: config.command,
36
+ root_path: root_path,
37
+ on_notification: method(:handle_notification)
38
+ )
39
+
40
+ text_sync = TextDocumentSync.new(
41
+ client: client,
42
+ server_config: config
43
+ )
44
+
45
+ client.start
46
+
47
+ @mutex.synchronize do
48
+ @clients[name] = client
49
+ @text_syncs[name] = text_sync
50
+ end
51
+
52
+ # Check if initialized after a short delay
53
+ @editor.message = if client.initialized
54
+ "LSP: #{name} ready"
55
+ else
56
+ "LSP: #{name} starting..."
57
+ end
58
+ true
59
+ rescue StandardError => e
60
+ @editor.message = "LSP Error: #{e.message}"
61
+ false
62
+ end
63
+
64
+ def stop_server(name)
65
+ client = @mutex.synchronize { @clients.delete(name) }
66
+ text_sync = @mutex.synchronize { @text_syncs.delete(name) }
67
+
68
+ return unless client
69
+
70
+ text_sync&.close_all
71
+ client.stop
72
+ @editor.message = "LSP server stopped: #{name}"
73
+ end
74
+
75
+ def stop_all
76
+ names = @mutex.synchronize { @clients.keys.dup }
77
+ names.each { |name| stop_server(name) }
78
+ end
79
+
80
+ def client_for(file_path)
81
+ @mutex.synchronize do
82
+ @server_configs.each do |name, config|
83
+ return @clients[name] if config.handles_file?(file_path) && @clients[name]&.running?
84
+ end
85
+ end
86
+ nil
87
+ end
88
+
89
+ def text_sync_for(file_path)
90
+ @mutex.synchronize do
91
+ @server_configs.each do |name, config|
92
+ # Only return text_sync if the client is running
93
+ next unless config.handles_file?(file_path)
94
+ next unless @clients[name]&.running?
95
+ return @text_syncs[name] if @text_syncs[name]
96
+ end
97
+ end
98
+ nil
99
+ end
100
+
101
+ def text_syncs_for(file_path)
102
+ result = []
103
+ @mutex.synchronize do
104
+ @server_configs.each do |name, config|
105
+ next unless config.handles_file?(file_path)
106
+ next unless @clients[name]&.running?
107
+
108
+ result << @text_syncs[name] if @text_syncs[name]
109
+ end
110
+ end
111
+ result
112
+ end
113
+
114
+ def auto_start_for(file_path)
115
+ servers_to_start = []
116
+
117
+ @mutex.synchronize do
118
+ @server_configs.each do |name, config|
119
+ next unless config.auto_start && config.handles_file?(file_path)
120
+ next if @clients[name]&.running?
121
+
122
+ servers_to_start << [name, find_project_root(file_path)]
123
+ end
124
+ end
125
+
126
+ return false if servers_to_start.empty?
127
+
128
+ # Start servers in background thread to avoid blocking editor startup
129
+ Thread.new do
130
+ servers_to_start.each do |name, root_path|
131
+ start_server(name, root_path)
132
+ # Send pending documents after server starts
133
+ send_pending_documents(name)
134
+ rescue StandardError => e
135
+ @editor.message = "LSP auto-start error (#{name}): #{e.message}"
136
+ end
137
+ end
138
+
139
+ true
140
+ end
141
+
142
+ def did_open(file_path:, text:)
143
+ # Store document for servers that are starting up
144
+ @mutex.synchronize do
145
+ @pending_documents[file_path] = text
146
+ end
147
+
148
+ auto_start_for(file_path)
149
+
150
+ uri = TextDocumentSync.path_to_uri(file_path)
151
+ # Broadcast to all matching servers that are already running
152
+ text_syncs_for(file_path).each do |text_sync|
153
+ text_sync.did_open(uri: uri, text: text)
154
+ end
155
+ end
156
+
157
+ def did_change(file_path:, text:)
158
+ uri = TextDocumentSync.path_to_uri(file_path)
159
+ # Broadcast to all matching servers (each text_sync checks sync_on_change)
160
+ text_syncs_for(file_path).each do |text_sync|
161
+ text_sync.did_change(uri: uri, text: text)
162
+ end
163
+ end
164
+
165
+ def did_save(file_path:, text: nil)
166
+ uri = TextDocumentSync.path_to_uri(file_path)
167
+ # Broadcast to all matching servers
168
+ text_syncs_for(file_path).each do |text_sync|
169
+ text_sync.did_save(uri: uri, text: text)
170
+ end
171
+ end
172
+
173
+ def did_close(file_path:)
174
+ @mutex.synchronize do
175
+ @pending_documents.delete(file_path)
176
+ end
177
+
178
+ uri = TextDocumentSync.path_to_uri(file_path)
179
+ # Broadcast to all matching servers
180
+ text_syncs_for(file_path).each do |text_sync|
181
+ text_sync.did_close(uri: uri)
182
+ end
183
+ end
184
+
185
+ def hover(file_path:, line:, character:)
186
+ client = client_for(file_path)
187
+ unless client
188
+ @editor.message = server_unavailable_message(file_path)
189
+ return
190
+ end
191
+
192
+ uri = TextDocumentSync.path_to_uri(file_path)
193
+ handler = Handlers::Hover.new(editor: @editor, client: client)
194
+
195
+ client.hover(uri: uri, line: line, character: character) do |result, error|
196
+ handler.handle(result, error)
197
+ end
198
+ end
199
+
200
+ def definition(file_path:, line:, character:)
201
+ client = client_for(file_path)
202
+ unless client
203
+ @editor.message = server_unavailable_message(file_path)
204
+ return
205
+ end
206
+
207
+ uri = TextDocumentSync.path_to_uri(file_path)
208
+ handler = Handlers::Definition.new(editor: @editor, client: client)
209
+
210
+ client.definition(uri: uri, line: line, character: character) do |result, error|
211
+ handler.handle(result, error)
212
+ end
213
+ end
214
+
215
+ def references(file_path:, line:, character:)
216
+ client = client_for(file_path)
217
+ unless client
218
+ @editor.message = server_unavailable_message(file_path)
219
+ return
220
+ end
221
+
222
+ uri = TextDocumentSync.path_to_uri(file_path)
223
+ handler = Handlers::References.new(editor: @editor, client: client)
224
+
225
+ client.references(uri: uri, line: line, character: character) do |result, error|
226
+ handler.handle(result, error)
227
+ end
228
+ end
229
+
230
+ def completion(file_path:, line:, character:)
231
+ client = client_for(file_path)
232
+ unless client
233
+ @editor.message = server_unavailable_message(file_path)
234
+ return
235
+ end
236
+
237
+ uri = TextDocumentSync.path_to_uri(file_path)
238
+ handler = Handlers::Completion.new(editor: @editor, client: client)
239
+
240
+ client.completion(uri: uri, line: line, character: character) do |result, error|
241
+ handler.handle(result, error)
242
+ end
243
+ end
244
+
245
+ def running_servers
246
+ @mutex.synchronize do
247
+ @clients.select { |_, client| client.running? }.keys
248
+ end
249
+ end
250
+
251
+ def starting_servers
252
+ @mutex.synchronize do
253
+ @clients.select { |_, client| client.started? && !client.initialized }.keys
254
+ end
255
+ end
256
+
257
+ def registered_servers
258
+ @mutex.synchronize { @server_configs.keys }
259
+ end
260
+
261
+ def debug_info
262
+ @mutex.synchronize do
263
+ @clients.transform_values do |client|
264
+ {
265
+ started: client.started?,
266
+ initialized: client.initialized,
267
+ last_stderr: client.last_stderr
268
+ }
269
+ end
270
+ end
271
+ end
272
+
273
+ def notification_log
274
+ @notification_log ||= []
275
+ end
276
+
277
+ private
278
+
279
+ def send_pending_documents(server_name)
280
+ text_sync = @mutex.synchronize { @text_syncs[server_name] }
281
+ config = @mutex.synchronize { @server_configs[server_name] }
282
+ return unless text_sync && config
283
+
284
+ pending = @mutex.synchronize { @pending_documents.dup }
285
+ pending.each do |file_path, text|
286
+ next unless config.handles_file?(file_path)
287
+
288
+ uri = TextDocumentSync.path_to_uri(file_path)
289
+ text_sync.did_open(uri: uri, text: text)
290
+ end
291
+ end
292
+
293
+ def server_unavailable_message(file_path)
294
+ # Check if any server is starting (but not yet initialized)
295
+ starting = starting_servers_for(file_path)
296
+ return "LSP: #{starting.join(", ")} still initializing..." unless starting.empty?
297
+
298
+ # Check if file type is supported
299
+ supported = @mutex.synchronize do
300
+ @server_configs.select { |_, config| config.handles_file?(file_path) }.keys
301
+ end
302
+
303
+ if supported.empty?
304
+ "LSP: no server configured for #{File.basename(file_path)}"
305
+ else
306
+ "LSP: #{supported.join(", ")} not running. Use :LspStart"
307
+ end
308
+ end
309
+
310
+ def starting_servers_for(file_path)
311
+ @mutex.synchronize do
312
+ @server_configs.select do |name, config|
313
+ config.handles_file?(file_path) && @clients[name]&.started? && !@clients[name]&.initialized
314
+ end.keys
315
+ end
316
+ end
317
+
318
+ def handle_notification(method, params)
319
+ # Log all notifications for debugging
320
+ @notification_log ||= []
321
+ @notification_log << { method: method, params: params, time: Time.now }
322
+ @notification_log.shift if @notification_log.size > 50
323
+
324
+ case method
325
+ when "textDocument/publishDiagnostics"
326
+ @diagnostics_handler.handle(params)
327
+ when "window/showMessage"
328
+ handle_show_message(params)
329
+ when "window/logMessage"
330
+ # Ignore log messages for now
331
+ end
332
+ end
333
+
334
+ def handle_show_message(params)
335
+ message = params["message"]
336
+ type = params["type"]
337
+
338
+ prefix = case type
339
+ when 1 then "[Error] "
340
+ when 2 then "[Warning] "
341
+ when 3 then "[Info] "
342
+ else ""
343
+ end
344
+
345
+ @editor.message = "#{prefix}#{message}"
346
+ end
347
+
348
+ def find_project_root(file_path)
349
+ dir = File.dirname(File.expand_path(file_path))
350
+
351
+ # Look for common project markers
352
+ markers = [".git", "Gemfile", "package.json", "Cargo.toml", "go.mod", ".project"]
353
+
354
+ while dir != "/"
355
+ markers.each do |marker|
356
+ return dir if File.exist?(File.join(dir, marker))
357
+ end
358
+ dir = File.dirname(dir)
359
+ end
360
+
361
+ # Default to the file's directory
362
+ File.dirname(File.expand_path(file_path))
363
+ end
364
+ end
365
+ end
366
+ end