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,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsd
4
+ module LSP
5
+ # Diagnostics - Manages LSP diagnostics display
6
+ class Diagnostics
7
+ attr_reader :by_file, :max_diagnostics
8
+
9
+ def initialize(max: 100)
10
+ @by_file = {}
11
+ @max_diagnostics = max
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ # Add/update diagnostics for a file
16
+ def update(uri, diagnostics)
17
+ @mutex.synchronize do
18
+ file_path = uri_to_path(uri)
19
+ @by_file[file_path] = diagnostics.first(@max_diagnostics)
20
+ end
21
+ end
22
+
23
+ # Get diagnostics for a file
24
+ def for_file(file_path)
25
+ @mutex.synchronize do
26
+ @by_file[file_path] || []
27
+ end
28
+ end
29
+
30
+ # Get diagnostics for a specific line
31
+ def at_line(file_path, line)
32
+ diagnostics = for_file(file_path)
33
+ diagnostics.select { |d| d.range.start.line == line }
34
+ end
35
+
36
+ # Get diagnostics at position
37
+ def at_position(file_path, line, character)
38
+ diagnostics = for_file(file_path)
39
+ diagnostics.select do |d|
40
+ range = d.range
41
+ start_pos = range.start
42
+ end_pos = range.end
43
+
44
+ line >= start_pos.line && line <= end_pos.line &&
45
+ (line != start_pos.line || character >= start_pos.character) &&
46
+ (line != end_pos.line || character <= end_pos.character)
47
+ end
48
+ end
49
+
50
+ # Count total diagnostics
51
+ def count
52
+ @mutex.synchronize do
53
+ @by_file.values.sum(&:length)
54
+ end
55
+ end
56
+
57
+ # Count by severity
58
+ def count_by_severity
59
+ @mutex.synchronize do
60
+ counts = { error: 0, warning: 0, information: 0, hint: 0 }
61
+
62
+ @by_file.each_value do |diagnostics|
63
+ diagnostics.each do |d|
64
+ case d.severity
65
+ when 1 then counts[:error] += 1
66
+ when 2 then counts[:warning] += 1
67
+ when 3 then counts[:information] += 1
68
+ when 4 then counts[:hint] += 1
69
+ end
70
+ end
71
+ end
72
+
73
+ counts
74
+ end
75
+ end
76
+
77
+ # Clear diagnostics for a file
78
+ def clear_file(file_path)
79
+ @mutex.synchronize do
80
+ @by_file.delete(file_path)
81
+ end
82
+ end
83
+
84
+ # Clear all diagnostics
85
+ def clear_all
86
+ @mutex.synchronize do
87
+ @by_file.clear
88
+ end
89
+ end
90
+
91
+ # List files with diagnostics
92
+ def files
93
+ @mutex.synchronize do
94
+ @by_file.keys.dup
95
+ end
96
+ end
97
+
98
+ # Get all diagnostics as flat list
99
+ def all
100
+ @mutex.synchronize do
101
+ @by_file.flat_map do |file, diagnostics|
102
+ diagnostics.map { |d| { file: file, diagnostic: d } }
103
+ end
104
+ end
105
+ end
106
+
107
+ # Render diagnostics for display
108
+ def render_for_file(file_path, width: 80)
109
+ diagnostics = for_file(file_path)
110
+ return [] if diagnostics.empty?
111
+
112
+ diagnostics.map do |d|
113
+ severity_char = severity_to_char(d.severity)
114
+ location = "#{d.range.start.line + 1}:#{d.range.start.character + 1}"
115
+ message = d.message.lines.first.chomp
116
+
117
+ "#{severity_char} [#{location}] #{message[0...width - 10]}"
118
+ end
119
+ end
120
+
121
+ # Format diagnostic for TUI
122
+ def format_for_tui(file_path, width: 80)
123
+ lines = []
124
+ diagnostics = for_file(file_path)
125
+
126
+ return lines if diagnostics.empty?
127
+
128
+ lines << "📋 Diagnostics (#{diagnostics.length}):"
129
+
130
+ diagnostics.first(10).each do |d|
131
+ icon = severity_to_icon(d.severity)
132
+ line = d.range.start.line + 1
133
+ message = d.message.lines.first.chomp[0...width - 15]
134
+ lines << " #{icon} L#{line}: #{message}"
135
+ end
136
+
137
+ if diagnostics.length > 10
138
+ lines << " ... and #{diagnostics.length - 10} more"
139
+ end
140
+
141
+ lines
142
+ end
143
+
144
+ # Check if file has errors
145
+ def has_errors?(file_path)
146
+ diagnostics = for_file(file_path)
147
+ diagnostics.any? { |d| d.severity == 1 }
148
+ end
149
+
150
+ # Check if file has warnings
151
+ def has_warnings?(file_path)
152
+ diagnostics = for_file(file_path)
153
+ diagnostics.any? { |d| d.severity == 2 }
154
+ end
155
+
156
+ # Get error count for file
157
+ def error_count(file_path)
158
+ for_file(file_path).count { |d| d.severity == 1 }
159
+ end
160
+
161
+ # Get warning count for file
162
+ def warning_count(file_path)
163
+ for_file(file_path).count { |d| d.severity == 2 }
164
+ end
165
+
166
+ # Summary for status bar
167
+ def status_summary
168
+ counts = count_by_severity
169
+ parts = []
170
+ parts << "E:#{counts[:error]}" if counts[:error] > 0
171
+ parts << "W:#{counts[:warning]}" if counts[:warning] > 0
172
+ parts.join(' ') unless parts.empty?
173
+ end
174
+
175
+ private
176
+
177
+ def uri_to_path(uri)
178
+ return uri unless uri.start_with?('file://')
179
+
180
+ uri[7..-1]
181
+ end
182
+
183
+ def severity_to_char(severity)
184
+ case severity
185
+ when 1 then 'E'
186
+ when 2 then 'W'
187
+ when 3 then 'I'
188
+ when 4 then 'H'
189
+ else '?'
190
+ end
191
+ end
192
+
193
+ def severity_to_icon(severity)
194
+ case severity
195
+ when 1 then '🔴'
196
+ when 2 then '🟡'
197
+ when 3 then '🔵'
198
+ when 4 then '💡'
199
+ else '❓'
200
+ end
201
+ end
202
+ end
203
+
204
+ # DiagnosticsPanel - TUI panel for diagnostics
205
+ class DiagnosticsPanel
206
+ attr_reader :visible, :width, :height
207
+
208
+ def initialize(width: 60, height: 15)
209
+ @visible = false
210
+ @width = width
211
+ @height = height
212
+ @diagnostics = Diagnostics.new
213
+ @current_file = nil
214
+ end
215
+
216
+ def show
217
+ @visible = true
218
+ end
219
+
220
+ def hide
221
+ @visible = false
222
+ end
223
+
224
+ def toggle
225
+ @visible = !@visible
226
+ end
227
+
228
+ def update(uri, diagnostics)
229
+ @diagnostics.update(uri, diagnostics)
230
+ end
231
+
232
+ def set_current_file(file_path)
233
+ @current_file = file_path
234
+ end
235
+
236
+ def render
237
+ return [] unless @visible
238
+
239
+ lines = []
240
+ lines << '─' * @width
241
+ lines << ' Diagnostics'.ljust(@width)
242
+ lines << '─' * @width
243
+
244
+ if @current_file
245
+ lines.concat(@diagnostics.format_for_tui(@current_file, width: @width))
246
+ else
247
+ lines << ' No file selected'
248
+ end
249
+
250
+ # Pad to height
251
+ while lines.length < @height
252
+ lines << ''
253
+ end
254
+
255
+ lines.first(@height)
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsd
4
+ module LSP
5
+ # Hover - Handles LSP hover information
6
+ class Hover
7
+ attr_reader :client, :cache, :cache_ttl
8
+
9
+ def initialize(client)
10
+ @client = client
11
+ @cache = {}
12
+ @cache_ttl = 10 # seconds
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ # Get hover info 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.hover(uri, line, character)
28
+ return nil unless result
29
+
30
+ # Parse hover
31
+ info = parse_hover_result(result)
32
+
33
+ # Cache result
34
+ store_cache(cache_key, info)
35
+
36
+ info
37
+ end
38
+
39
+ # Get hover 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
+ # Format hover for display
48
+ def format(info, width: 60, max_height: 10)
49
+ return nil unless info
50
+
51
+ contents = info[:contents]
52
+ return nil if contents.nil? || contents.empty?
53
+
54
+ lines = extract_lines(contents)
55
+ return nil if lines.empty?
56
+
57
+ # Format and truncate
58
+ formatted = lines.map { |line| line[0...width] }
59
+ formatted.first(max_height)
60
+ end
61
+
62
+ # Format hover for TUI panel
63
+ def format_for_tui(info, width: 60, max_height: 10)
64
+ return [] unless info
65
+
66
+ lines = format(info, width: width - 2, max_height: max_height - 2)
67
+ return [] unless lines
68
+
69
+ result = []
70
+ result << '─' * width
71
+ result << ' Hover'.ljust(width)
72
+ result << '─' * width
73
+
74
+ lines.each { |line| result << " #{line}" }
75
+
76
+ # Pad to max_height
77
+ while result.length < max_height
78
+ result << ''
79
+ end
80
+
81
+ result.first(max_height)
82
+ end
83
+
84
+ # Get type information
85
+ def type_info(info)
86
+ return nil unless info
87
+
88
+ contents = info[:contents]
89
+ return nil if contents.nil?
90
+
91
+ # Extract type from hover info
92
+ if contents.is_a?(String)
93
+ extract_type_from_string(contents)
94
+ elsif contents.is_a?(Hash)
95
+ contents['value'] || contents[:value]
96
+ elsif contents.is_a?(Array)
97
+ contents.first&.[]('value') || contents.first&.[](:value)
98
+ end
99
+ end
100
+
101
+ # Clear cache
102
+ def clear_cache
103
+ @mutex.synchronize do
104
+ @cache.clear
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def parse_hover_result(result)
111
+ contents = result['contents'] || result[:contents]
112
+
113
+ {
114
+ contents: contents,
115
+ range: result['range'] || result[:range]
116
+ }
117
+ end
118
+
119
+ def extract_lines(contents)
120
+ return [] unless contents
121
+
122
+ if contents.is_a?(String)
123
+ contents.lines
124
+ elsif contents.is_a?(Hash)
125
+ value = contents['value'] || contents[:value]
126
+ value ? value.lines : []
127
+ elsif contents.is_a?(Array)
128
+ contents.flat_map do |item|
129
+ if item.is_a?(String)
130
+ item.lines
131
+ elsif item.is_a?(Hash)
132
+ value = item['value'] || item[:value]
133
+ value ? value.lines : []
134
+ else
135
+ []
136
+ end
137
+ end
138
+ else
139
+ []
140
+ end
141
+ end
142
+
143
+ def extract_type_from_string(str)
144
+ # Try to extract type from common patterns
145
+ # Ruby: "[Float] method description"
146
+ # TypeScript: "(parameter) x: string"
147
+ # Python: "function foo -> int"
148
+
149
+ if str =~ /^\[([^\]]+)\]/
150
+ return $1
151
+ elsif str =~ /:\s*(\S+)\s*$/
152
+ return $1
153
+ elsif str =~ /->\s*(\S+)/
154
+ return $1
155
+ end
156
+
157
+ nil
158
+ end
159
+
160
+ def check_cache(key)
161
+ @mutex.synchronize do
162
+ entry = @cache[key]
163
+ return nil unless entry
164
+
165
+ if Time.now - entry[:time] > @cache_ttl
166
+ @cache.delete(key)
167
+ nil
168
+ else
169
+ entry[:info]
170
+ end
171
+ end
172
+ end
173
+
174
+ def store_cache(key, info)
175
+ @mutex.synchronize do
176
+ @cache[key] = {
177
+ info: info,
178
+ time: Time.now
179
+ }
180
+
181
+ # Limit cache size
182
+ while @cache.size > 100
183
+ @cache.shift
184
+ end
185
+ end
186
+ end
187
+
188
+ def position_from_offset(text, offset)
189
+ line = 0
190
+ character = 0
191
+
192
+ text[0...offset].each_char do |char|
193
+ if char == "\n"
194
+ line += 1
195
+ character = 0
196
+ else
197
+ character += 1
198
+ end
199
+ end
200
+
201
+ [line, character]
202
+ end
203
+
204
+ def path_to_uri(path)
205
+ absolute = File.expand_path(path)
206
+ "file://#{absolute}"
207
+ end
208
+ end
209
+
210
+ # HoverPanel - TUI panel for hover info
211
+ class HoverPanel
212
+ attr_reader :visible, :width, :height, :current_info
213
+
214
+ def initialize(width: 60, height: 12)
215
+ @visible = false
216
+ @width = width
217
+ @height = height
218
+ @current_info = nil
219
+ end
220
+
221
+ def show
222
+ @visible = true
223
+ end
224
+
225
+ def hide
226
+ @visible = false
227
+ end
228
+
229
+ def toggle
230
+ @visible = !@visible
231
+ end
232
+
233
+ def update(info)
234
+ @current_info = info
235
+ end
236
+
237
+ def render
238
+ return [] unless @visible
239
+
240
+ Hover.new(nil).format_for_tui(@current_info, width: @width, max_height: @height)
241
+ end
242
+ end
243
+ end
244
+ end