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.
- checksums.yaml +7 -0
- data/.rubocop_todo.yml +73 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +188 -0
- data/Rakefile +12 -0
- data/lib/mui/lsp/client.rb +290 -0
- data/lib/mui/lsp/handlers/base.rb +92 -0
- data/lib/mui/lsp/handlers/completion.rb +139 -0
- data/lib/mui/lsp/handlers/definition.rb +99 -0
- data/lib/mui/lsp/handlers/diagnostics.rb +161 -0
- data/lib/mui/lsp/handlers/hover.rb +55 -0
- data/lib/mui/lsp/handlers/references.rb +65 -0
- data/lib/mui/lsp/handlers.rb +8 -0
- data/lib/mui/lsp/highlighters/diagnostic_highlighter.rb +71 -0
- data/lib/mui/lsp/json_rpc_io.rb +120 -0
- data/lib/mui/lsp/manager.rb +366 -0
- data/lib/mui/lsp/plugin.rb +539 -0
- data/lib/mui/lsp/protocol/diagnostic.rb +88 -0
- data/lib/mui/lsp/protocol/location.rb +42 -0
- data/lib/mui/lsp/protocol/position.rb +31 -0
- data/lib/mui/lsp/protocol/range.rb +34 -0
- data/lib/mui/lsp/protocol.rb +6 -0
- data/lib/mui/lsp/request_manager.rb +72 -0
- data/lib/mui/lsp/server_config.rb +115 -0
- data/lib/mui/lsp/text_document_sync.rb +149 -0
- data/lib/mui/lsp/version.rb +7 -0
- data/lib/mui/lsp.rb +19 -0
- data/lib/mui_lsp.rb +3 -0
- data/sig/mui/lsp.rbs +6 -0
- metadata +89 -0
|
@@ -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
|