fantasy-cli 1.2.14 → 1.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/README.md +8 -1
- data/lib/gsd/agents/communication.rb +260 -0
- data/lib/gsd/agents/swarm.rb +341 -0
- data/lib/gsd/lsp/client.rb +394 -0
- data/lib/gsd/lsp/completion.rb +266 -0
- data/lib/gsd/lsp/diagnostics.rb +259 -0
- data/lib/gsd/lsp/hover.rb +244 -0
- data/lib/gsd/lsp/protocol.rb +434 -0
- data/lib/gsd/lsp/server_manager.rb +290 -0
- data/lib/gsd/lsp/symbols.rb +368 -0
- data/lib/gsd/plugins/api.rb +340 -0
- data/lib/gsd/plugins/hooks.rb +117 -95
- data/lib/gsd/plugins/hot_reload.rb +293 -0
- data/lib/gsd/plugins/registry.rb +273 -0
- data/lib/gsd/tui/agent_panel.rb +182 -0
- data/lib/gsd/tui/animations.rb +320 -0
- data/lib/gsd/tui/app.rb +442 -2
- data/lib/gsd/tui/colors.rb +15 -0
- data/lib/gsd/tui/effects.rb +263 -0
- data/lib/gsd/tui/header.rb +13 -5
- data/lib/gsd/tui/input_box.rb +10 -7
- data/lib/gsd/tui/mouse.rb +388 -0
- data/lib/gsd/tui/persistence.rb +192 -0
- data/lib/gsd/tui/session.rb +273 -0
- data/lib/gsd/tui/status_bar.rb +63 -15
- data/lib/gsd/tui/tab.rb +112 -0
- data/lib/gsd/tui/tab_manager.rb +191 -0
- data/lib/gsd/tui/transitions.rb +262 -0
- data/lib/gsd/version.rb +1 -1
- metadata +22 -1
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Gsd
|
|
6
|
+
module LSP
|
|
7
|
+
# LSP Protocol implementation
|
|
8
|
+
# Handles JSON-RPC message formatting and parsing
|
|
9
|
+
module Protocol
|
|
10
|
+
# Header constants
|
|
11
|
+
CONTENT_TYPE = 'application/vscode-jsonrpc; charset=utf-8'
|
|
12
|
+
HEADER_DELIMITER = "\r\n\r\n"
|
|
13
|
+
|
|
14
|
+
# Build a JSON-RPC request
|
|
15
|
+
def self.build_request(method, params, id = nil)
|
|
16
|
+
message = {
|
|
17
|
+
jsonrpc: '2.0',
|
|
18
|
+
method: method
|
|
19
|
+
}
|
|
20
|
+
message[:params] = params if params
|
|
21
|
+
message[:id] = id if id
|
|
22
|
+
|
|
23
|
+
message
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Build a JSON-RPC notification (no id)
|
|
27
|
+
def self.build_notification(method, params = nil)
|
|
28
|
+
message = {
|
|
29
|
+
jsonrpc: '2.0',
|
|
30
|
+
method: method
|
|
31
|
+
}
|
|
32
|
+
message[:params] = params if params
|
|
33
|
+
|
|
34
|
+
message
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Build a JSON-RPC response
|
|
38
|
+
def self.build_response(id, result = nil, error = nil)
|
|
39
|
+
message = {
|
|
40
|
+
jsonrpc: '2.0',
|
|
41
|
+
id: id
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if error
|
|
45
|
+
message[:error] = error
|
|
46
|
+
else
|
|
47
|
+
message[:result] = result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
message
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Parse raw message from server
|
|
54
|
+
def self.parse_message(data)
|
|
55
|
+
return nil if data.nil? || data.empty?
|
|
56
|
+
|
|
57
|
+
# Split headers and content
|
|
58
|
+
header_end = data.index(HEADER_DELIMITER)
|
|
59
|
+
|
|
60
|
+
# Try with just \n if \r\n not found
|
|
61
|
+
if header_end.nil?
|
|
62
|
+
header_end = data.index("\n\n")
|
|
63
|
+
header_delim = "\n\n"
|
|
64
|
+
else
|
|
65
|
+
header_delim = HEADER_DELIMITER
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
return nil unless header_end
|
|
69
|
+
|
|
70
|
+
headers = data[0...header_end]
|
|
71
|
+
content = data[header_end + header_delim.length..-1]
|
|
72
|
+
|
|
73
|
+
# Parse Content-Length
|
|
74
|
+
content_length = parse_content_length(headers)
|
|
75
|
+
return nil unless content_length
|
|
76
|
+
|
|
77
|
+
# Extract exactly content_length bytes
|
|
78
|
+
if content.length >= content_length
|
|
79
|
+
json_content = content[0...content_length]
|
|
80
|
+
remaining = content[content_length..-1]
|
|
81
|
+
|
|
82
|
+
begin
|
|
83
|
+
parsed = JSON.parse(json_content)
|
|
84
|
+
return { message: parsed, remaining: remaining }
|
|
85
|
+
rescue JSON::ParserError => e
|
|
86
|
+
warn "[LSP] JSON parse error: #{e.message}"
|
|
87
|
+
return nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Parse Content-Length header
|
|
95
|
+
def self.parse_content_length(headers)
|
|
96
|
+
headers.each_line do |line|
|
|
97
|
+
if line =~ /^Content-Length:\s*(\d+)/i
|
|
98
|
+
return $1.to_i
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Format message for transport
|
|
105
|
+
def self.format_message(message)
|
|
106
|
+
json = message.to_json
|
|
107
|
+
"Content-Length: #{json.bytesize}\r\n\r\n#{json}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Standard LSP methods
|
|
111
|
+
module Methods
|
|
112
|
+
INITIALIZE = 'initialize'
|
|
113
|
+
INITIALIZED = 'initialized'
|
|
114
|
+
SHUTDOWN = 'shutdown'
|
|
115
|
+
EXIT = 'exit'
|
|
116
|
+
|
|
117
|
+
# Text Document
|
|
118
|
+
TEXT_DOCUMENT_DID_OPEN = 'textDocument/didOpen'
|
|
119
|
+
TEXT_DOCUMENT_DID_CHANGE = 'textDocument/didChange'
|
|
120
|
+
TEXT_DOCUMENT_DID_CLOSE = 'textDocument/didClose'
|
|
121
|
+
TEXT_DOCUMENT_DID_SAVE = 'textDocument/didSave'
|
|
122
|
+
TEXT_DOCUMENT_COMPLETION = 'textDocument/completion'
|
|
123
|
+
TEXT_DOCUMENT_HOVER = 'textDocument/hover'
|
|
124
|
+
TEXT_DOCUMENT_DEFINITION = 'textDocument/definition'
|
|
125
|
+
TEXT_DOCUMENT_REFERENCES = 'textDocument/references'
|
|
126
|
+
TEXT_DOCUMENT_DOCUMENT_SYMBOL = 'textDocument/documentSymbol'
|
|
127
|
+
TEXT_DOCUMENT_FORMATTING = 'textDocument/formatting'
|
|
128
|
+
TEXT_DOCUMENT_RENAME = 'textDocument/rename'
|
|
129
|
+
|
|
130
|
+
# Workspace
|
|
131
|
+
WORKSPACE_SYMBOL = 'workspace/symbol'
|
|
132
|
+
WORKSPACE_EXECUTE_COMMAND = 'workspace/executeCommand'
|
|
133
|
+
WORKSPACE_DID_CHANGE_CONFIGURATION = 'workspace/didChangeConfiguration'
|
|
134
|
+
|
|
135
|
+
# Diagnostics
|
|
136
|
+
TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS = 'textDocument/publishDiagnostics'
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Error codes
|
|
140
|
+
module ErrorCodes
|
|
141
|
+
PARSE_ERROR = -32700
|
|
142
|
+
INVALID_REQUEST = -32600
|
|
143
|
+
METHOD_NOT_FOUND = -32601
|
|
144
|
+
INVALID_PARAMS = -32602
|
|
145
|
+
INTERNAL_ERROR = -32603
|
|
146
|
+
SERVER_ERROR_START = -32099
|
|
147
|
+
SERVER_ERROR_END = -32000
|
|
148
|
+
SERVER_NOT_INITIALIZED = -32002
|
|
149
|
+
UNKNOWN_ERROR_CODE = -32001
|
|
150
|
+
|
|
151
|
+
# LSP specific
|
|
152
|
+
REQUEST_CANCELLED = -32800
|
|
153
|
+
CONTENT_MODIFIED = -32801
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Position in a document
|
|
158
|
+
class Position
|
|
159
|
+
attr_reader :line, :character
|
|
160
|
+
|
|
161
|
+
def initialize(line, character)
|
|
162
|
+
@line = line
|
|
163
|
+
@character = character
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def to_h
|
|
167
|
+
{ line: @line, character: @character }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.from_h(hash)
|
|
171
|
+
new(hash['line'] || hash[:line], hash['character'] || hash[:character])
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Range in a document
|
|
176
|
+
class Range
|
|
177
|
+
attr_reader :start, :end
|
|
178
|
+
|
|
179
|
+
def initialize(start_pos, end_pos)
|
|
180
|
+
@start = start_pos
|
|
181
|
+
@end = end_pos
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def to_h
|
|
185
|
+
{ start: @start.to_h, end: @end.to_h }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.from_h(hash)
|
|
189
|
+
start_pos = Position.from_h(hash['start'] || hash[:start])
|
|
190
|
+
end_pos = Position.from_h(hash['end'] || hash[:end])
|
|
191
|
+
new(start_pos, end_pos)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Location (uri + range)
|
|
196
|
+
class Location
|
|
197
|
+
attr_reader :uri, :range
|
|
198
|
+
|
|
199
|
+
def initialize(uri, range)
|
|
200
|
+
@uri = uri
|
|
201
|
+
@range = range
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def to_h
|
|
205
|
+
{ uri: @uri, range: @range.to_h }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Text document identifier
|
|
210
|
+
class TextDocumentIdentifier
|
|
211
|
+
attr_reader :uri
|
|
212
|
+
|
|
213
|
+
def initialize(uri)
|
|
214
|
+
@uri = uri
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def to_h
|
|
218
|
+
{ uri: @uri }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Versioned text document identifier
|
|
223
|
+
class VersionedTextDocumentIdentifier
|
|
224
|
+
attr_reader :uri, :version
|
|
225
|
+
|
|
226
|
+
def initialize(uri, version)
|
|
227
|
+
@uri = uri
|
|
228
|
+
@version = version
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def to_h
|
|
232
|
+
{ uri: @uri, version: @version }
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Text document item
|
|
237
|
+
class TextDocumentItem
|
|
238
|
+
attr_reader :uri, :language_id, :version, :text
|
|
239
|
+
|
|
240
|
+
def initialize(uri, language_id, version, text)
|
|
241
|
+
@uri = uri
|
|
242
|
+
@language_id = language_id
|
|
243
|
+
@version = version
|
|
244
|
+
@text = text
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def to_h
|
|
248
|
+
{
|
|
249
|
+
uri: @uri,
|
|
250
|
+
languageId: @language_id,
|
|
251
|
+
version: @version,
|
|
252
|
+
text: @text
|
|
253
|
+
}
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Text edit
|
|
258
|
+
class TextEdit
|
|
259
|
+
attr_reader :range, :new_text
|
|
260
|
+
|
|
261
|
+
def initialize(range, new_text)
|
|
262
|
+
@range = range
|
|
263
|
+
@new_text = new_text
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def to_h
|
|
267
|
+
{ range: @range.to_h, newText: @new_text }
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Diagnostic
|
|
272
|
+
class Diagnostic
|
|
273
|
+
attr_reader :range, :severity, :code, :source, :message
|
|
274
|
+
|
|
275
|
+
SEVERITY = {
|
|
276
|
+
error: 1,
|
|
277
|
+
warning: 2,
|
|
278
|
+
information: 3,
|
|
279
|
+
hint: 4
|
|
280
|
+
}.freeze
|
|
281
|
+
|
|
282
|
+
def initialize(range, message, severity: :error, code: nil, source: nil)
|
|
283
|
+
@range = range
|
|
284
|
+
@message = message
|
|
285
|
+
@severity = SEVERITY[severity] || severity
|
|
286
|
+
@code = code
|
|
287
|
+
@source = source
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def to_h
|
|
291
|
+
h = {
|
|
292
|
+
range: @range.to_h,
|
|
293
|
+
message: @message,
|
|
294
|
+
severity: @severity
|
|
295
|
+
}
|
|
296
|
+
h[:code] = @code if @code
|
|
297
|
+
h[:source] = @source if @source
|
|
298
|
+
h
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def self.from_h(hash)
|
|
302
|
+
range = Range.from_h(hash['range'] || hash[:range])
|
|
303
|
+
severity = hash['severity'] || hash[:severity]
|
|
304
|
+
code = hash['code'] || hash[:code]
|
|
305
|
+
source = hash['source'] || hash[:source]
|
|
306
|
+
message = hash['message'] || hash[:message]
|
|
307
|
+
|
|
308
|
+
new(range, message, severity: severity, code: code, source: source)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Completion item
|
|
313
|
+
class CompletionItem
|
|
314
|
+
attr_reader :label, :kind, :detail, :documentation, :insert_text
|
|
315
|
+
|
|
316
|
+
KINDS = {
|
|
317
|
+
text: 1,
|
|
318
|
+
method: 2,
|
|
319
|
+
function: 3,
|
|
320
|
+
constructor: 4,
|
|
321
|
+
field: 5,
|
|
322
|
+
variable: 6,
|
|
323
|
+
class: 7,
|
|
324
|
+
interface: 8,
|
|
325
|
+
module: 9,
|
|
326
|
+
property: 10,
|
|
327
|
+
unit: 11,
|
|
328
|
+
value: 12,
|
|
329
|
+
enum: 13,
|
|
330
|
+
keyword: 14,
|
|
331
|
+
snippet: 15,
|
|
332
|
+
color: 16,
|
|
333
|
+
file: 17,
|
|
334
|
+
reference: 18
|
|
335
|
+
}.freeze
|
|
336
|
+
|
|
337
|
+
def initialize(label, kind: :text, detail: nil, documentation: nil, insert_text: nil)
|
|
338
|
+
@label = label
|
|
339
|
+
@kind = KINDS[kind] || kind
|
|
340
|
+
@detail = detail
|
|
341
|
+
@documentation = documentation
|
|
342
|
+
@insert_text = insert_text || label
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def to_h
|
|
346
|
+
h = {
|
|
347
|
+
label: @label,
|
|
348
|
+
kind: @kind
|
|
349
|
+
}
|
|
350
|
+
h[:detail] = @detail if @detail
|
|
351
|
+
h[:documentation] = @documentation if @documentation
|
|
352
|
+
h[:insertText] = @insert_text if @insert_text
|
|
353
|
+
h
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def self.from_h(hash)
|
|
357
|
+
label = hash['label'] || hash[:label]
|
|
358
|
+
kind = hash['kind'] || hash[:kind]
|
|
359
|
+
detail = hash['detail'] || hash[:detail]
|
|
360
|
+
documentation = hash['documentation'] || hash[:documentation]
|
|
361
|
+
insert_text = hash['insertText'] || hash['insertText] || hash[:insertText]
|
|
362
|
+
|
|
363
|
+
new(label, kind: kind, detail: detail, documentation: documentation, insert_text: insert_text)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Hover
|
|
368
|
+
class Hover
|
|
369
|
+
attr_reader :contents, :range
|
|
370
|
+
|
|
371
|
+
def initialize(contents, range: nil)
|
|
372
|
+
@contents = contents
|
|
373
|
+
@range = range
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def to_h
|
|
377
|
+
h = { contents: @contents }
|
|
378
|
+
h[:range] = @range.to_h if @range
|
|
379
|
+
h
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def self.from_h(hash)
|
|
383
|
+
contents = hash['contents'] || hash[:contents]
|
|
384
|
+
range = hash['range'] || hash[:range]
|
|
385
|
+
range = Range.from_h(range) if range
|
|
386
|
+
|
|
387
|
+
new(contents, range: range)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Symbol information
|
|
392
|
+
class SymbolInformation
|
|
393
|
+
attr_reader :name, :kind, :location, :container_name
|
|
394
|
+
|
|
395
|
+
SYMBOL_KINDS = {
|
|
396
|
+
file: 1,
|
|
397
|
+
module: 2,
|
|
398
|
+
namespace: 3,
|
|
399
|
+
package: 4,
|
|
400
|
+
class: 5,
|
|
401
|
+
method: 6,
|
|
402
|
+
property: 7,
|
|
403
|
+
field: 8,
|
|
404
|
+
constructor: 9,
|
|
405
|
+
enum: 10,
|
|
406
|
+
interface: 11,
|
|
407
|
+
function: 12,
|
|
408
|
+
variable: 13,
|
|
409
|
+
constant: 14,
|
|
410
|
+
string: 15,
|
|
411
|
+
number: 16,
|
|
412
|
+
boolean: 17,
|
|
413
|
+
array: 18
|
|
414
|
+
}.freeze
|
|
415
|
+
|
|
416
|
+
def initialize(name, kind, location, container_name: nil)
|
|
417
|
+
@name = name
|
|
418
|
+
@kind = SYMBOL_KINDS[kind] || kind
|
|
419
|
+
@location = location
|
|
420
|
+
@container_name = container_name
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def to_h
|
|
424
|
+
h = {
|
|
425
|
+
name: @name,
|
|
426
|
+
kind: @kind,
|
|
427
|
+
location: @location.to_h
|
|
428
|
+
}
|
|
429
|
+
h[:containerName] = @container_name if @container_name
|
|
430
|
+
h
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsd
|
|
4
|
+
module LSP
|
|
5
|
+
# ServerManager - Manages multiple LSP servers for different languages
|
|
6
|
+
class ServerManager
|
|
7
|
+
attr_reader :clients, :language_map
|
|
8
|
+
|
|
9
|
+
# Default LSP server configurations
|
|
10
|
+
DEFAULT_SERVERS = {
|
|
11
|
+
ruby: {
|
|
12
|
+
command: 'ruby-lsp',
|
|
13
|
+
args: [],
|
|
14
|
+
extensions: ['.rb', '.rake', '.gemspec'],
|
|
15
|
+
name: 'Ruby LSP'
|
|
16
|
+
},
|
|
17
|
+
python: {
|
|
18
|
+
command: 'pylsp',
|
|
19
|
+
args: [],
|
|
20
|
+
extensions: ['.py', '.pyw'],
|
|
21
|
+
name: 'Python LSP'
|
|
22
|
+
},
|
|
23
|
+
javascript: {
|
|
24
|
+
command: 'typescript-language-server',
|
|
25
|
+
args: ['--stdio'],
|
|
26
|
+
extensions: ['.js', '.jsx', '.mjs'],
|
|
27
|
+
name: 'TypeScript LSP'
|
|
28
|
+
},
|
|
29
|
+
typescript: {
|
|
30
|
+
command: 'typescript-language-server',
|
|
31
|
+
args: ['--stdio'],
|
|
32
|
+
extensions: ['.ts', '.tsx'],
|
|
33
|
+
name: 'TypeScript LSP'
|
|
34
|
+
},
|
|
35
|
+
json: {
|
|
36
|
+
command: 'vscode-json-language-server',
|
|
37
|
+
args: ['--stdio'],
|
|
38
|
+
extensions: ['.json'],
|
|
39
|
+
name: 'JSON LSP'
|
|
40
|
+
},
|
|
41
|
+
html: {
|
|
42
|
+
command: 'vscode-html-language-server',
|
|
43
|
+
args: ['--stdio'],
|
|
44
|
+
extensions: ['.html', '.htm'],
|
|
45
|
+
name: 'HTML LSP'
|
|
46
|
+
},
|
|
47
|
+
css: {
|
|
48
|
+
command: 'vscode-css-language-server',
|
|
49
|
+
args: ['--stdio'],
|
|
50
|
+
extensions: ['.css', '.scss', '.sass', '.less'],
|
|
51
|
+
name: 'CSS LSP'
|
|
52
|
+
},
|
|
53
|
+
go: {
|
|
54
|
+
command: 'gopls',
|
|
55
|
+
args: [],
|
|
56
|
+
extensions: ['.go'],
|
|
57
|
+
name: 'Go LSP'
|
|
58
|
+
},
|
|
59
|
+
rust: {
|
|
60
|
+
command: 'rust-analyzer',
|
|
61
|
+
args: [],
|
|
62
|
+
extensions: ['.rs'],
|
|
63
|
+
name: 'Rust LSP'
|
|
64
|
+
}
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
def initialize
|
|
68
|
+
@clients = {}
|
|
69
|
+
@language_map = {}
|
|
70
|
+
@mutex = Mutex.new
|
|
71
|
+
@config = DEFAULT_SERVERS.dup
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Configure a language server
|
|
75
|
+
def configure(language, command:, args: [], extensions: [])
|
|
76
|
+
@config[language] = {
|
|
77
|
+
command: command,
|
|
78
|
+
args: args,
|
|
79
|
+
extensions: extensions,
|
|
80
|
+
name: language.to_s.capitalize + ' LSP'
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get or start client for a file
|
|
85
|
+
def client_for_file(file_path)
|
|
86
|
+
language = detect_language(file_path)
|
|
87
|
+
return nil unless language
|
|
88
|
+
|
|
89
|
+
client_for_language(language)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get or start client for a language
|
|
93
|
+
def client_for_language(language)
|
|
94
|
+
language = language.to_sym
|
|
95
|
+
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
return @clients[language] if @clients[language]&.running?
|
|
98
|
+
|
|
99
|
+
config = @config[language]
|
|
100
|
+
return nil unless config
|
|
101
|
+
|
|
102
|
+
# Check if server is available
|
|
103
|
+
return nil unless server_available?(config[:command])
|
|
104
|
+
|
|
105
|
+
# Create and start client
|
|
106
|
+
client = Client.new(config[:command], config[:args])
|
|
107
|
+
client.on_diagnostics = method(:handle_diagnostics)
|
|
108
|
+
client.on_log_message = method(:handle_log_message)
|
|
109
|
+
|
|
110
|
+
if client.start
|
|
111
|
+
@clients[language] = client
|
|
112
|
+
client
|
|
113
|
+
else
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Stop all servers
|
|
120
|
+
def stop_all
|
|
121
|
+
@mutex.synchronize do
|
|
122
|
+
@clients.each do |language, client|
|
|
123
|
+
begin
|
|
124
|
+
client.stop
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
warn "[LSP] Error stopping #{language} server: #{e.message}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
@clients.clear
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Stop specific language server
|
|
135
|
+
def stop_language(language)
|
|
136
|
+
@mutex.synchronize do
|
|
137
|
+
client = @clients.delete(language.to_sym)
|
|
138
|
+
client&.stop
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Check if server is running for language
|
|
143
|
+
def running?(language)
|
|
144
|
+
client = @clients[language.to_sym]
|
|
145
|
+
client&.running? || false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# List running servers
|
|
149
|
+
def running
|
|
150
|
+
@clients.select { |_, client| client.running? }.keys
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# List configured languages
|
|
154
|
+
def configured_languages
|
|
155
|
+
@config.keys
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Get server info for language
|
|
159
|
+
def server_info(language)
|
|
160
|
+
client = @clients[language.to_sym]
|
|
161
|
+
return nil unless client
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
name: @config[language.to_sym]&.[](:name),
|
|
165
|
+
running: client.running?,
|
|
166
|
+
state: client.state,
|
|
167
|
+
server_info: client.server_info,
|
|
168
|
+
capabilities: client.capabilities
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Detect language from file extension
|
|
173
|
+
def detect_language(file_path)
|
|
174
|
+
ext = File.extname(file_path).downcase
|
|
175
|
+
|
|
176
|
+
@config.each do |language, config|
|
|
177
|
+
return language if config[:extensions]&.include?(ext)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Fallback based on content
|
|
181
|
+
detect_from_content(file_path)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Check if LSP server command is available
|
|
185
|
+
def server_available?(command)
|
|
186
|
+
system("which #{command} > /dev/null 2>&1") ||
|
|
187
|
+
system("where #{command} > /dev/null 2>&1")
|
|
188
|
+
rescue StandardError
|
|
189
|
+
false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Get all available servers
|
|
193
|
+
def available_servers
|
|
194
|
+
@config.select { |_, config| server_available?(config[:command]) }.keys
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Send notification to all running servers
|
|
198
|
+
def broadcast_notification(method, params = nil)
|
|
199
|
+
@clients.each do |language, client|
|
|
200
|
+
begin
|
|
201
|
+
client.notify(method, params) if client.running?
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
warn "[LSP] Error broadcasting to #{language}: #{e.message}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Reload configuration
|
|
209
|
+
def reload_config
|
|
210
|
+
# Stop all running servers
|
|
211
|
+
stop_all
|
|
212
|
+
|
|
213
|
+
# Reset to defaults
|
|
214
|
+
@config = DEFAULT_SERVERS.dup
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
def detect_from_content(file_path)
|
|
220
|
+
return nil unless File.exist?(file_path)
|
|
221
|
+
|
|
222
|
+
first_line = File.open(file_path, &:gets)
|
|
223
|
+
return nil unless first_line
|
|
224
|
+
|
|
225
|
+
# Shebang detection
|
|
226
|
+
if first_line.start_with?('#!')
|
|
227
|
+
case first_line
|
|
228
|
+
when /ruby/ then :ruby
|
|
229
|
+
when /python/ then :python
|
|
230
|
+
when /node/ then :javascript
|
|
231
|
+
when /bash|sh/ then :shell
|
|
232
|
+
else nil
|
|
233
|
+
end
|
|
234
|
+
else
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
rescue StandardError
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def handle_diagnostics(uri, diagnostics)
|
|
242
|
+
# Broadcast to interested parties via hooks
|
|
243
|
+
Gsd::Plugins::Hooks.instance.trigger('lsp.diagnostics', uri, diagnostics)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def handle_log_message(type, message)
|
|
247
|
+
level = case type
|
|
248
|
+
when 1 then 'ERROR'
|
|
249
|
+
when 2 then 'WARNING'
|
|
250
|
+
when 3 then 'INFO'
|
|
251
|
+
when 4 then 'LOG'
|
|
252
|
+
else 'UNKNOWN'
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
warn "[LSP] #{level}: #{message}"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# LanguageServer - Per-language server wrapper
|
|
260
|
+
class LanguageServer
|
|
261
|
+
attr_reader :language, :client, :config
|
|
262
|
+
|
|
263
|
+
def initialize(language, config)
|
|
264
|
+
@language = language
|
|
265
|
+
@config = config
|
|
266
|
+
@client = nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def start
|
|
270
|
+
return true if running?
|
|
271
|
+
|
|
272
|
+
@client = Client.new(@config[:command], @config[:args])
|
|
273
|
+
@client.start
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def stop
|
|
277
|
+
@client&.stop
|
|
278
|
+
@client = nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def running?
|
|
282
|
+
@client&.running? || false
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def capabilities
|
|
286
|
+
@client&.capabilities || {}
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|