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,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
|