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.
@@ -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