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,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+
6
+ module Gsd
7
+ module LSP
8
+ # Client - Manages communication with LSP server
9
+ class Client
10
+ attr_reader :state, :server_info, :capabilities
11
+ attr_accessor :on_diagnostics, :on_log_message
12
+
13
+ def initialize(command, args = [])
14
+ @command = command
15
+ @args = args
16
+ @state = :stopped
17
+ @request_id = 0
18
+ @pending_requests = {}
19
+ @server_info = nil
20
+ @capabilities = {}
21
+ @buffer = ''
22
+ @mutex = Mutex.new
23
+ @on_diagnostics = nil
24
+ @on_log_message = nil
25
+ end
26
+
27
+ # Start the LSP server
28
+ def start
29
+ return false if @state != :stopped
30
+
31
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*[@command, *@args])
32
+ @state = :starting
33
+
34
+ # Start reader thread
35
+ @reader_thread = Thread.new { reader_loop }
36
+
37
+ # Initialize
38
+ initialize_server
39
+
40
+ true
41
+ rescue StandardError => e
42
+ @state = :stopped
43
+ warn "[LSP] Failed to start server: #{e.message}"
44
+ false
45
+ end
46
+
47
+ # Stop the LSP server
48
+ def stop
49
+ return false if @state == :stopped
50
+
51
+ begin
52
+ send_request(Protocol::Methods::SHUTDOWN)
53
+ send_notification(Protocol::Methods::EXIT)
54
+ rescue StandardError => e
55
+ warn "[LSP] Error during shutdown: #{e.message}"
56
+ end
57
+
58
+ @state = :stopped
59
+
60
+ # Kill threads
61
+ @reader_thread&.kill
62
+ @wait_thr&.kill
63
+
64
+ # Close streams
65
+ @stdin&.close
66
+ @stdout&.close
67
+ @stderr&.close
68
+
69
+ true
70
+ end
71
+
72
+ # Check if running
73
+ def running?
74
+ @state == :running
75
+ end
76
+
77
+ # Send request and wait for response
78
+ def request(method, params = nil, timeout: 30)
79
+ return nil unless running?
80
+
81
+ id = next_request_id
82
+ message = Protocol.build_request(method, params, id)
83
+
84
+ send_raw(message)
85
+
86
+ # Wait for response
87
+ response = wait_for_response(id, timeout)
88
+
89
+ if response && response['error']
90
+ warn "[LSP] Request #{id} failed: #{response['error']['message']}"
91
+ nil
92
+ else
93
+ response&.[]('result')
94
+ end
95
+ end
96
+
97
+ # Send notification (no response expected)
98
+ def notify(method, params = nil)
99
+ return false unless running?
100
+
101
+ message = Protocol.build_notification(method, params)
102
+ send_raw(message)
103
+ true
104
+ end
105
+
106
+ # Open a document
107
+ def open_document(uri, language_id, text, version: 1)
108
+ notify(Protocol::Methods::TEXT_DOCUMENT_DID_OPEN, {
109
+ textDocument: {
110
+ uri: uri,
111
+ languageId: language_id,
112
+ version: version,
113
+ text: text
114
+ }
115
+ })
116
+ end
117
+
118
+ # Update document
119
+ def change_document(uri, changes, version:)
120
+ notify(Protocol::Methods::TEXT_DOCUMENT_DID_CHANGE, {
121
+ textDocument: {
122
+ uri: uri,
123
+ version: version
124
+ },
125
+ contentChanges: changes
126
+ })
127
+ end
128
+
129
+ # Close document
130
+ def close_document(uri)
131
+ notify(Protocol::Methods::TEXT_DOCUMENT_DID_CLOSE, {
132
+ textDocument: { uri: uri }
133
+ })
134
+ end
135
+
136
+ # Get completions
137
+ def completion(uri, line, character)
138
+ request(Protocol::Methods::TEXT_DOCUMENT_COMPLETION, {
139
+ textDocument: { uri: uri },
140
+ position: { line: line, character: character }
141
+ })
142
+ end
143
+
144
+ # Get hover info
145
+ def hover(uri, line, character)
146
+ request(Protocol::Methods::TEXT_DOCUMENT_HOVER, {
147
+ textDocument: { uri: uri },
148
+ position: { line: line, character: character }
149
+ })
150
+ end
151
+
152
+ # Get definition
153
+ def definition(uri, line, character)
154
+ request(Protocol::Methods::TEXT_DOCUMENT_DEFINITION, {
155
+ textDocument: { uri: uri },
156
+ position: { line: line, character: character }
157
+ })
158
+ end
159
+
160
+ # Get references
161
+ def references(uri, line, character, include_declaration: false)
162
+ request(Protocol::Methods::TEXT_DOCUMENT_REFERENCES, {
163
+ textDocument: { uri: uri },
164
+ position: { line: line, character: character },
165
+ context: { includeDeclaration: include_declaration }
166
+ })
167
+ end
168
+
169
+ # Get document symbols
170
+ def document_symbol(uri)
171
+ request(Protocol::Methods::TEXT_DOCUMENT_DOCUMENT_SYMBOL, {
172
+ textDocument: { uri: uri }
173
+ })
174
+ end
175
+
176
+ # Get workspace symbols
177
+ def workspace_symbol(query)
178
+ request(Protocol::Methods::WORKSPACE_SYMBOL, {
179
+ query: query
180
+ })
181
+ end
182
+
183
+ # Format document
184
+ def formatting(uri)
185
+ request(Protocol::Methods::TEXT_DOCUMENT_FORMATTING, {
186
+ textDocument: { uri: uri },
187
+ options: { tabSize: 2, insertSpaces: true }
188
+ })
189
+ end
190
+
191
+ # Rename symbol
192
+ def rename(uri, line, character, new_name)
193
+ request(Protocol::Methods::TEXT_DOCUMENT_RENAME, {
194
+ textDocument: { uri: uri },
195
+ position: { line: line, character: character },
196
+ newName: new_name
197
+ })
198
+ end
199
+
200
+ private
201
+
202
+ def initialize_server
203
+ # Send initialize request
204
+ result = request(Protocol::Methods::INITIALIZE, {
205
+ processId: Process.pid,
206
+ rootUri: "file://#{Dir.pwd}",
207
+ capabilities: client_capabilities
208
+ }, timeout: 10)
209
+
210
+ if result
211
+ @server_info = result['serverInfo']
212
+ @capabilities = result['capabilities'] || {}
213
+ @state = :running
214
+
215
+ # Send initialized notification
216
+ notify(Protocol::Methods::INITIALIZED, {})
217
+ else
218
+ @state = :error
219
+ end
220
+ end
221
+
222
+ def client_capabilities
223
+ {
224
+ textDocument: {
225
+ synchronization: {
226
+ dynamicRegistration: false,
227
+ willSave: false,
228
+ willSaveWaitUntil: false,
229
+ didSave: false
230
+ },
231
+ completion: {
232
+ dynamicRegistration: false,
233
+ completionItem: {
234
+ snippetSupport: false,
235
+ commitCharactersSupport: false,
236
+ documentationFormat: ['plaintext', 'markdown'],
237
+ deprecatedSupport: false,
238
+ preselectSupport: false
239
+ }
240
+ },
241
+ hover: {
242
+ dynamicRegistration: false,
243
+ contentFormat: ['plaintext', 'markdown']
244
+ },
245
+ definition: {
246
+ dynamicRegistration: false,
247
+ linkSupport: false
248
+ },
249
+ documentSymbol: {
250
+ dynamicRegistration: false,
251
+ hierarchicalDocumentSymbolSupport: false,
252
+ symbolKind: { valueSet: (1..26).to_a }
253
+ },
254
+ formatting: {
255
+ dynamicRegistration: false
256
+ },
257
+ rename: {
258
+ dynamicRegistration: false,
259
+ prepareSupport: false
260
+ }
261
+ },
262
+ workspace: {
263
+ applyEdit: false,
264
+ workspaceEdit: {
265
+ documentChanges: false
266
+ },
267
+ didChangeConfiguration: {
268
+ dynamicRegistration: false
269
+ },
270
+ symbol: {
271
+ dynamicRegistration: false,
272
+ symbolKind: { valueSet: (1..26).to_a }
273
+ },
274
+ executeCommand: {
275
+ dynamicRegistration: false
276
+ }
277
+ }
278
+ }
279
+ end
280
+
281
+ def next_request_id
282
+ @mutex.synchronize do
283
+ @request_id += 1
284
+ end
285
+ end
286
+
287
+ def send_raw(message)
288
+ data = Protocol.format_message(message)
289
+ @mutex.synchronize do
290
+ @stdin.write(data)
291
+ @stdin.flush
292
+ end
293
+ rescue StandardError => e
294
+ warn "[LSP] Failed to send message: #{e.message}"
295
+ end
296
+
297
+ def reader_loop
298
+ loop do
299
+ begin
300
+ data = @stdout.readpartial(8192)
301
+ @buffer << data
302
+
303
+ # Parse all complete messages
304
+ loop do
305
+ result = Protocol.parse_message(@buffer)
306
+ break unless result
307
+
308
+ @buffer = result[:remaining] || ''
309
+ handle_message(result[:message])
310
+ end
311
+ rescue EOFError, IOError
312
+ break
313
+ rescue StandardError => e
314
+ warn "[LSP] Reader error: #{e.message}"
315
+ end
316
+ end
317
+ end
318
+
319
+ def handle_message(message)
320
+ if message['id']
321
+ # Response to a request
322
+ handle_response(message)
323
+ else
324
+ # Server notification
325
+ handle_notification(message)
326
+ end
327
+ end
328
+
329
+ def handle_response(message)
330
+ id = message['id']
331
+ @mutex.synchronize do
332
+ @pending_requests[id] = message
333
+ end
334
+ end
335
+
336
+ def handle_notification(message)
337
+ method = message['method']
338
+ params = message['params']
339
+
340
+ case method
341
+ when Protocol::Methods::TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS
342
+ handle_diagnostics(params)
343
+ when 'window/logMessage'
344
+ handle_log_message(params)
345
+ when 'window/showMessage'
346
+ handle_show_message(params)
347
+ end
348
+ end
349
+
350
+ def wait_for_response(id, timeout)
351
+ deadline = Time.now + timeout
352
+
353
+ loop do
354
+ remaining = deadline - Time.now
355
+ return nil if remaining <= 0
356
+
357
+ response = nil
358
+ @mutex.synchronize do
359
+ response = @pending_requests.delete(id)
360
+ end
361
+
362
+ return response if response
363
+
364
+ sleep(0.01)
365
+ end
366
+ end
367
+
368
+ def handle_diagnostics(params)
369
+ return unless @on_diagnostics
370
+
371
+ uri = params['uri']
372
+ diagnostics = params['diagnostics']&.map do |d|
373
+ Protocol::Diagnostic.from_h(d)
374
+ end || []
375
+
376
+ @on_diagnostics.call(uri, diagnostics)
377
+ end
378
+
379
+ def handle_log_message(params)
380
+ return unless @on_log_message
381
+
382
+ type = params['type']
383
+ message = params['message']
384
+ @on_log_message.call(type, message)
385
+ end
386
+
387
+ def handle_show_message(params)
388
+ type = params['type']
389
+ message = params['message']
390
+ warn "[LSP Server] #{message}"
391
+ end
392
+ end
393
+ end
394
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsd
4
+ module LSP
5
+ # Completion - Handles LSP autocompletion
6
+ class Completion
7
+ attr_reader :client, :cache, :cache_ttl
8
+
9
+ def initialize(client)
10
+ @client = client
11
+ @cache = {}
12
+ @cache_ttl = 5 # seconds
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ # Get completions at position
17
+ def get(uri, line, character)
18
+ return nil unless @client&.running?
19
+
20
+ cache_key = "#{uri}:#{line}:#{character}"
21
+
22
+ # Check cache
23
+ cached = check_cache(cache_key)
24
+ return cached if cached
25
+
26
+ # Request from server
27
+ result = @client.completion(uri, line, character)
28
+ return nil unless result
29
+
30
+ # Parse completions
31
+ items = parse_completion_result(result)
32
+
33
+ # Cache result
34
+ store_cache(cache_key, items)
35
+
36
+ items
37
+ end
38
+
39
+ # Get completion at cursor from text
40
+ def get_at_cursor(file_path, text, cursor_pos)
41
+ line, character = position_from_offset(text, cursor_pos)
42
+ uri = path_to_uri(file_path)
43
+
44
+ get(uri, line, character)
45
+ end
46
+
47
+ # Trigger character completion
48
+ def trigger_character?(char)
49
+ %w[. : > ].include?(char)
50
+ end
51
+
52
+ # Filter completions by prefix
53
+ def filter(items, prefix)
54
+ return items if prefix.empty?
55
+
56
+ prefix_lower = prefix.downcase
57
+ items.select do |item|
58
+ label = item[:label].downcase
59
+ label.start_with?(prefix_lower) || label.include?(prefix_lower)
60
+ end
61
+ end
62
+
63
+ # Sort completions by relevance
64
+ def sort(items, prefix)
65
+ prefix_lower = prefix.downcase
66
+
67
+ items.sort_by do |item|
68
+ label = item[:label].downcase
69
+
70
+ if label == prefix_lower
71
+ 0
72
+ elsif label.start_with?(prefix_lower)
73
+ 1
74
+ elsif label.include?(prefix_lower)
75
+ 2
76
+ else
77
+ 3
78
+ end
79
+ end
80
+ end
81
+
82
+ # Format completion for display
83
+ def format_item(item, width: 40)
84
+ label = item[:label]
85
+ kind = kind_to_symbol(item[:kind])
86
+ detail = item[:detail]
87
+
88
+ text = "#{kind} #{label}"
89
+ text += " - #{detail}" if detail && detail.length < width - text.length - 3
90
+
91
+ text[0...width]
92
+ end
93
+
94
+ # Format completion list for TUI
95
+ def format_list(items, selected_index: 0, width: 40, max_items: 10)
96
+ return [] if items.empty?
97
+
98
+ display_items = items.first(max_items)
99
+
100
+ display_items.map.with_index do |item, index|
101
+ prefix = index == selected_index ? '>' : ' '
102
+ formatted = format_item(item, width: width - 2)
103
+ "#{prefix} #{formatted}"
104
+ end
105
+ end
106
+
107
+ # Clear cache
108
+ def clear_cache
109
+ @mutex.synchronize do
110
+ @cache.clear
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def parse_completion_result(result)
117
+ # Handle different response formats
118
+ items = if result.is_a?(Array)
119
+ result
120
+ elsif result.is_a?(Hash)
121
+ result['items'] || []
122
+ else
123
+ []
124
+ end
125
+
126
+ items.map { |item| normalize_item(item) }
127
+ end
128
+
129
+ def normalize_item(item)
130
+ {
131
+ label: item['label'] || item[:label],
132
+ kind: item['kind'] || item[:kind],
133
+ detail: item['detail'] || item[:detail],
134
+ documentation: item['documentation'] || item[:documentation],
135
+ insertText: item['insertText'] || item['insertText'] || item['label'] || item[:label],
136
+ sortText: item['sortText'] || item['sortText'] || item['label'] || item[:label]
137
+ }
138
+ end
139
+
140
+ def check_cache(key)
141
+ @mutex.synchronize do
142
+ entry = @cache[key]
143
+ return nil unless entry
144
+
145
+ if Time.now - entry[:time] > @cache_ttl
146
+ @cache.delete(key)
147
+ nil
148
+ else
149
+ entry[:items]
150
+ end
151
+ end
152
+ end
153
+
154
+ def store_cache(key, items)
155
+ @mutex.synchronize do
156
+ @cache[key] = {
157
+ items: items,
158
+ time: Time.now
159
+ }
160
+
161
+ # Limit cache size
162
+ while @cache.size > 100
163
+ @cache.shift
164
+ end
165
+ end
166
+ end
167
+
168
+ def position_from_offset(text, offset)
169
+ line = 0
170
+ character = 0
171
+
172
+ text[0...offset].each_char do |char|
173
+ if char == "\n"
174
+ line += 1
175
+ character = 0
176
+ else
177
+ character += 1
178
+ end
179
+ end
180
+
181
+ [line, character]
182
+ end
183
+
184
+ def path_to_uri(path)
185
+ absolute = File.expand_path(path)
186
+ "file://#{absolute}"
187
+ end
188
+
189
+ def kind_to_symbol(kind)
190
+ symbols = {
191
+ 1 => 'Ⓣ', # Text
192
+ 2 => 'Ⓜ', # Method
193
+ 3 => 'Ⓕ', # Function
194
+ 4 => 'Ⓒ', # Constructor
195
+ 5 => 'Ⓕ', # Field
196
+ 6 => 'Ⓥ', # Variable
197
+ 7 => 'Ⓒ', # Class
198
+ 8 => 'Ⓘ', # Interface
199
+ 9 => 'Ⓜ', # Module
200
+ 10 => 'Ⓟ', # Property
201
+ 11 => 'Ⓤ', # Unit
202
+ 12 => 'Ⓥ', # Value
203
+ 13 => 'Ⓔ', # Enum
204
+ 14 => 'Ⓚ', # Keyword
205
+ 15 => 'Ⓢ', # Snippet
206
+ 16 => 'Ⓒ', # Color
207
+ 17 => 'Ⓕ', # File
208
+ 18 => 'Ⓡ' # Reference
209
+ }
210
+
211
+ symbols[kind] || '❓'
212
+ end
213
+ end
214
+
215
+ # CompletionTrigger - Detects when to trigger completion
216
+ class CompletionTrigger
217
+ attr_reader :trigger_chars, :commit_chars
218
+
219
+ def initialize
220
+ @trigger_chars = %w[. : > " ' /]
221
+ @commit_chars = %w[. ( ) , ;]
222
+ end
223
+
224
+ # Check if character should trigger completion
225
+ def should_trigger?(char, before_cursor)
226
+ return true if @trigger_chars.include?(char)
227
+
228
+ # Trigger on word characters after space
229
+ if char =~ /[a-zA-Z_]/
230
+ return true if before_cursor =~ /[\s\.\(]$/
231
+ end
232
+
233
+ false
234
+ end
235
+
236
+ # Check if character should commit completion
237
+ def should_commit?(char)
238
+ @commit_chars.include?(char)
239
+ end
240
+
241
+ # Get word at cursor position
242
+ def word_at_cursor(text, cursor_pos)
243
+ before = text[0...cursor_pos]
244
+ after = text[cursor_pos..-1] || ''
245
+
246
+ # Find word boundaries
247
+ word_start = before.rindex(/[^a-zA-Z0-9_]/) || -1
248
+ word_end = after.index(/[^a-zA-Z0-9_]/) || after.length
249
+
250
+ word = before[(word_start + 1)..-1] || ''
251
+ word += after[0...word_end]
252
+
253
+ word
254
+ end
255
+
256
+ # Get prefix for filtering
257
+ def prefix(text, cursor_pos)
258
+ before = text[0...cursor_pos]
259
+
260
+ # Get characters after last non-word char
261
+ match = before.match(/([a-zA-Z0-9_]*)$/)
262
+ match ? match[1] : ''
263
+ end
264
+ end
265
+ end
266
+ end